Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffcdf5ee10 | ||
|
|
899c504d1f | ||
|
|
d82aa9d4b4 | ||
|
|
ae08382b81 | ||
|
|
b9f8008c2c | ||
|
|
d25cee3d8c | ||
|
|
48714cd98b | ||
|
|
1a2bf580cd | ||
|
|
2ca1ab2250 | ||
|
|
2571c243c9 | ||
|
|
89f0d6a546 | ||
|
|
8bc9460989 | ||
|
|
fcd4b3ad7f | ||
|
|
ab92bf84bb | ||
|
|
bb55afb562 | ||
|
|
e088bc056e |
14
AGENTS.md
14
AGENTS.md
@@ -47,11 +47,21 @@ Sub-directories have their own `AGENTS.md` with deeper context (e.g. `ios/AGENTS
|
||||
- `release.yaml` — runs on `v*` tags (build Docker images, upload source maps, create Gitea release)
|
||||
- Secrets: `DOCKER_USER`, `DOCKER_TOKEN`, `GITEA_TOKEN`, `GLITCHTIP_AUTH_TOKEN`
|
||||
|
||||
### Git credentials
|
||||
|
||||
Credentials are embedded in the remote URL — no `HOME=/root` or credential helper needed for push:
|
||||
|
||||
```
|
||||
https://kamil:95782641Apple%24@gitea.kalekber.cc/kamil/libnovel.git
|
||||
```
|
||||
|
||||
All git commands still use `HOME=/root` prefix for consistency (picks up `/root/.gitconfig` for user name/email), but push auth works without it.
|
||||
|
||||
### Releasing a new version
|
||||
|
||||
```bash
|
||||
git tag v2.5.X -m "Short title\n\nOptional longer body"
|
||||
git push origin v2.5.X
|
||||
HOME=/root git tag v2.6.X -m "Short title"
|
||||
HOME=/root git push origin v3-cleanup --tags
|
||||
```
|
||||
|
||||
CI will build all Docker images, upload source maps to GlitchTip, and create a Gitea release automatically.
|
||||
|
||||
@@ -189,6 +189,7 @@ func run() error {
|
||||
ChapterImageStore: store,
|
||||
Producer: producer,
|
||||
TaskReader: store,
|
||||
ImportFileStore: store,
|
||||
SearchIndex: searchIndex,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
|
||||
@@ -192,18 +192,22 @@ func run() error {
|
||||
|
||||
deps := runner.Dependencies{
|
||||
Consumer: consumer,
|
||||
BookWriter: store,
|
||||
BookReader: store,
|
||||
AudioStore: store,
|
||||
CoverStore: store,
|
||||
TranslationStore: store,
|
||||
SearchIndex: searchIndex,
|
||||
Novel: novel,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
CFAI: cfaiClient,
|
||||
LibreTranslate: ltClient,
|
||||
Log: log,
|
||||
BookWriter: store,
|
||||
BookReader: store,
|
||||
AudioStore: store,
|
||||
CoverStore: store,
|
||||
TranslationStore: store,
|
||||
BookImport: storage.NewBookImporter(store),
|
||||
ImportChapterStore: store,
|
||||
ChapterIngester: store,
|
||||
SearchIndex: searchIndex,
|
||||
Novel: novel,
|
||||
Kokoro: kokoroClient,
|
||||
PocketTTS: pocketTTSClient,
|
||||
CFAI: cfaiClient,
|
||||
LibreTranslate: ltClient,
|
||||
Notifier: store,
|
||||
Log: log,
|
||||
}
|
||||
r := runner.New(rCfg, deps)
|
||||
|
||||
|
||||
@@ -3,7 +3,23 @@ module github.com/libnovel/backend
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/getsentry/sentry-go v0.43.0
|
||||
github.com/hibiken/asynq v0.26.0
|
||||
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d
|
||||
github.com/meilisearch/meilisearch-go v0.36.1
|
||||
github.com/minio/minio-go/v7 v7.0.98
|
||||
github.com/pdfcpu/pdfcpu v0.11.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/redis/go-redis/v9 v9.18.0
|
||||
github.com/yuin/goldmark v1.8.2
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0
|
||||
go.opentelemetry.io/otel v1.42.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0
|
||||
go.opentelemetry.io/otel/log v0.18.0
|
||||
go.opentelemetry.io/otel/sdk v1.42.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0
|
||||
golang.org/x/net v0.51.0
|
||||
)
|
||||
|
||||
@@ -12,55 +28,45 @@ require (
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/getsentry/sentry-go v0.43.0 // indirect
|
||||
github.com/go-ini/ini v1.67.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/hibiken/asynq v0.26.0 // indirect
|
||||
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d // indirect
|
||||
github.com/hhrutter/lzw v1.0.0 // indirect
|
||||
github.com/hhrutter/pkcs7 v0.2.0 // indirect
|
||||
github.com/hhrutter/tiff v1.0.2 // indirect
|
||||
github.com/klauspost/compress v1.18.2 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
|
||||
github.com/klauspost/crc32 v1.3.0 // indirect
|
||||
github.com/meilisearch/meilisearch-go v0.36.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/minio/crc64nvme v1.1.1 // indirect
|
||||
github.com/minio/md5-simd v1.1.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/redis/go-redis/v9 v9.18.0 // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/rs/xid v1.6.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.1 // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
|
||||
go.opentelemetry.io/otel v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk v1.42.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.42.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/image v0.32.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
@@ -68,5 +74,5 @@ require (
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
|
||||
google.golang.org/grpc v1.79.2 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
)
|
||||
|
||||
@@ -2,10 +2,16 @@ github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7X
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
@@ -14,8 +20,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/getsentry/sentry-go v0.43.0 h1:XbXLpFicpo8HmBDaInk7dum18G9KSLcjZiyUKS+hLW4=
|
||||
github.com/getsentry/sentry-go v0.43.0/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
|
||||
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
|
||||
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -25,10 +35,20 @@ github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0=
|
||||
github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo=
|
||||
github.com/hhrutter/pkcs7 v0.2.0 h1:i4HN2XMbGQpZRnKBLsUwO3dSckzgX142TNqY/KfXg+I=
|
||||
github.com/hhrutter/pkcs7 v0.2.0/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE=
|
||||
github.com/hhrutter/tiff v1.0.2 h1:7H3FQQpKu/i5WaSChoD1nnJbGx4MxU5TlNqqpxw55z8=
|
||||
github.com/hhrutter/tiff v1.0.2/go.mod h1:pcOeuK5loFUE7Y/WnzGw20YxUdnqjY1P0Jlcieb/cCw=
|
||||
github.com/hibiken/asynq v0.26.0 h1:1Zxr92MlDnb1Zt/QR5g2vSCqUS03i95lUfqx5X7/wrw=
|
||||
github.com/hibiken/asynq v0.26.0/go.mod h1:Qk4e57bTnWDoyJ67VkchuV6VzSM9IQW2nPvAGuDyw58=
|
||||
github.com/hibiken/asynq/x v0.0.0-20260203063626-d704b68a426d h1:Ld5m8EIK5QVOq/owOexKIbETij3skACg4eU1pArHsrw=
|
||||
@@ -40,6 +60,14 @@ github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4O
|
||||
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM=
|
||||
github.com/klauspost/crc32 v1.3.0/go.mod h1:D7kQaZhnkX/Y0tstFGf8VUzv2UofNGqCjnC3zdHB0Hw=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/meilisearch/meilisearch-go v0.36.1 h1:mJTCJE5g7tRvaqKco6DfqOuJEjX+rRltDEnkEC02Y0M=
|
||||
github.com/meilisearch/meilisearch-go v0.36.1/go.mod h1:hWcR0MuWLSzHfbz9GGzIr3s9rnXLm1jqkmHkJPbUSvM=
|
||||
github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI=
|
||||
@@ -50,42 +78,44 @@ github.com/minio/minio-go/v7 v7.0.98 h1:MeAVKjLVz+XJ28zFcuYyImNSAh8Mq725uNW4beRi
|
||||
github.com/minio/minio-go/v7 v7.0.98/go.mod h1:cY0Y+W7yozf0mdIclrttzo1Iiu7mEf9y7nk2uXqMOvM=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/pdfcpu/pdfcpu v0.11.1 h1:htHBSkGH5jMKWC6e0sihBFbcKZ8vG1M67c8/dJxhjas=
|
||||
github.com/pdfcpu/pdfcpu v0.11.1/go.mod h1:pP3aGga7pRvwFWAm9WwFvo+V68DfANi9kxSQYioNYcw=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
|
||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
|
||||
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
|
||||
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
|
||||
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY=
|
||||
github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
|
||||
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=
|
||||
@@ -108,18 +138,26 @@ go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXY
|
||||
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
|
||||
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
|
||||
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
|
||||
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
|
||||
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
@@ -128,6 +166,8 @@ golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
|
||||
@@ -136,8 +176,10 @@ google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
|
||||
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -40,6 +40,10 @@ func (c *Consumer) FinishTranslationTask(ctx context.Context, id string, result
|
||||
return c.pb.FinishTranslationTask(ctx, id, result)
|
||||
}
|
||||
|
||||
func (c *Consumer) FinishImportTask(ctx context.Context, id string, result domain.ImportResult) error {
|
||||
return c.pb.FinishImportTask(ctx, id, result)
|
||||
}
|
||||
|
||||
func (c *Consumer) FailTask(ctx context.Context, id, errMsg string) error {
|
||||
return c.pb.FailTask(ctx, id, errMsg)
|
||||
}
|
||||
@@ -60,6 +64,12 @@ func (c *Consumer) ClaimNextTranslationTask(ctx context.Context, workerID string
|
||||
return c.pb.ClaimNextTranslationTask(ctx, workerID)
|
||||
}
|
||||
|
||||
// ClaimNextImportTask delegates to PocketBase because import tasks
|
||||
// are stored in PocketBase (not Redis/Asynq) and must still be polled directly.
|
||||
func (c *Consumer) ClaimNextImportTask(ctx context.Context, workerID string) (domain.ImportTask, bool, error) {
|
||||
return c.pb.ClaimNextImportTask(ctx, workerID)
|
||||
}
|
||||
|
||||
func (c *Consumer) HeartbeatTask(ctx context.Context, id string) error {
|
||||
return c.pb.HeartbeatTask(ctx, id)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/taskqueue"
|
||||
)
|
||||
|
||||
@@ -87,6 +88,30 @@ func (p *Producer) CreateTranslationTask(ctx context.Context, slug string, chapt
|
||||
return p.pb.CreateTranslationTask(ctx, slug, chapter, lang)
|
||||
}
|
||||
|
||||
// CreateImportTask creates a PocketBase record then enqueues an Asynq job for PDF/EPUB import.
|
||||
func (p *Producer) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) {
|
||||
id, err := p.pb.CreateImportTask(ctx, task)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
payload := ImportPayload{
|
||||
PBTaskID: id,
|
||||
Slug: task.Slug,
|
||||
Title: task.Title,
|
||||
FileType: task.FileType,
|
||||
ObjectKey: task.ObjectKey,
|
||||
ChaptersKey: task.ChaptersKey,
|
||||
}
|
||||
if err := p.enqueue(ctx, TypeImportBook, payload); err != nil {
|
||||
// Non-fatal: PB record exists; runner will pick it up on next poll.
|
||||
p.log.Warn("asynq enqueue import failed (task still in PB, runner will poll)",
|
||||
"task_id", id, "err", err)
|
||||
return id, nil
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// CancelTask delegates to PocketBase; Asynq jobs may already be running and
|
||||
// cannot be reliably cancelled, so we only update the audit record.
|
||||
func (p *Producer) CancelTask(ctx context.Context, id string) error {
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
TypeAudioGenerate = "audio:generate"
|
||||
TypeScrapeBook = "scrape:book"
|
||||
TypeScrapeCatalogue = "scrape:catalogue"
|
||||
TypeImportBook = "import:book"
|
||||
)
|
||||
|
||||
// AudioPayload is the Asynq job payload for audio generation tasks.
|
||||
@@ -44,3 +45,13 @@ type ScrapePayload struct {
|
||||
FromChapter int `json:"from_chapter"` // 0 unless Kind=="book_range"
|
||||
ToChapter int `json:"to_chapter"` // 0 unless Kind=="book_range"
|
||||
}
|
||||
|
||||
// ImportPayload is the Asynq job payload for PDF/EPUB import tasks.
|
||||
type ImportPayload struct {
|
||||
PBTaskID string `json:"pb_task_id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
|
||||
ChaptersKey string `json:"chapters_key"` // MinIO path to pre-parsed chapters JSON
|
||||
}
|
||||
|
||||
234
backend/internal/backend/handlers_import.go
Normal file
234
backend/internal/backend/handlers_import.go
Normal file
@@ -0,0 +1,234 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
type importRequest struct {
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Genres []string `json:"genres"`
|
||||
Summary string `json:"summary"`
|
||||
BookStatus string `json:"book_status"` // "ongoing" | "completed" | "hiatus"
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||
ObjectKey string `json:"object_key"` // MinIO path to uploaded file
|
||||
}
|
||||
|
||||
type importResponse struct {
|
||||
TaskID string `json:"task_id"`
|
||||
Slug string `json:"slug"`
|
||||
Preview *importPreview `json:"preview,omitempty"`
|
||||
}
|
||||
|
||||
type importPreview struct {
|
||||
Chapters int `json:"chapters"`
|
||||
FirstLines []string `json:"first_lines"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminImport(w http.ResponseWriter, r *http.Request) {
|
||||
if s.deps.Producer == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "task queue not configured")
|
||||
return
|
||||
}
|
||||
|
||||
ct := r.Header.Get("Content-Type")
|
||||
var req importRequest
|
||||
var objectKey string
|
||||
var chaptersKey string
|
||||
var chapterCount int
|
||||
|
||||
if strings.HasPrefix(ct, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse multipart: "+err.Error())
|
||||
return
|
||||
}
|
||||
req.Title = r.FormValue("title")
|
||||
req.Author = r.FormValue("author")
|
||||
req.CoverURL = r.FormValue("cover_url")
|
||||
req.Summary = r.FormValue("summary")
|
||||
req.BookStatus = r.FormValue("book_status")
|
||||
if g := r.FormValue("genres"); g != "" {
|
||||
for _, s := range strings.Split(g, ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
req.Genres = append(req.Genres, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
req.FileName = r.FormValue("file_name")
|
||||
req.FileType = r.FormValue("file_type")
|
||||
analyzeOnly := r.FormValue("analyze") == "true"
|
||||
|
||||
file, header, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse file: "+err.Error())
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if req.FileName == "" {
|
||||
req.FileName = header.Filename
|
||||
}
|
||||
if req.FileType == "" {
|
||||
req.FileType = strings.TrimPrefix(filepath.Ext(header.Filename), ".")
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "read file: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Analyze only - just count chapters
|
||||
if analyzeOnly {
|
||||
preview := analyzeImportFile(data, req.FileType)
|
||||
writeJSON(w, 0, importResponse{
|
||||
Preview: preview,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Parse PDF/EPUB on the backend (with timeout) and store chapters as JSON.
|
||||
// The runner only needs to ingest pre-parsed chapters — no PDF parsing on runner.
|
||||
parseCtx, parseCancel := context.WithTimeout(r.Context(), 3*time.Minute)
|
||||
defer parseCancel()
|
||||
chapters, parseErr := storage.ParseImportFile(parseCtx, data, req.FileType)
|
||||
if parseErr != nil || len(chapters) == 0 {
|
||||
jsonError(w, http.StatusUnprocessableEntity, "could not parse file: "+func() string {
|
||||
if parseErr != nil { return parseErr.Error() }
|
||||
return "no chapters found"
|
||||
}())
|
||||
return
|
||||
}
|
||||
|
||||
// Store raw file in MinIO (for reference/re-import).
|
||||
objectKey = fmt.Sprintf("imports/%d_%s", time.Now().Unix(), header.Filename)
|
||||
if s.deps.ImportFileStore == nil {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
return
|
||||
}
|
||||
if err := s.deps.ImportFileStore.PutImportFile(r.Context(), objectKey, data); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "upload file: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Store pre-parsed chapters JSON in MinIO so runner can ingest without re-parsing.
|
||||
chaptersJSON, _ := json.Marshal(chapters)
|
||||
chaptersKey = fmt.Sprintf("imports/%d_%s_chapters.json", time.Now().Unix(), strings.TrimSuffix(header.Filename, filepath.Ext(header.Filename)))
|
||||
if err := s.deps.ImportFileStore.PutImportChapters(r.Context(), chaptersKey, chaptersJSON); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "store chapters: "+err.Error())
|
||||
return
|
||||
}
|
||||
chapterCount = len(chapters)
|
||||
} else {
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "parse body: "+err.Error())
|
||||
return
|
||||
}
|
||||
objectKey = req.ObjectKey
|
||||
}
|
||||
|
||||
if req.Title == "" {
|
||||
jsonError(w, http.StatusBadRequest, "title is required")
|
||||
return
|
||||
}
|
||||
if req.FileType != "pdf" && req.FileType != "epub" {
|
||||
jsonError(w, http.StatusBadRequest, "file_type must be 'pdf' or 'epub'")
|
||||
return
|
||||
}
|
||||
|
||||
slug := strings.ToLower(strings.ReplaceAll(req.Title, " ", "-"))
|
||||
slug = strings.Map(func(r rune) rune {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
return r
|
||||
}
|
||||
return -1
|
||||
}, slug)
|
||||
|
||||
taskID, err := s.deps.Producer.CreateImportTask(r.Context(), domain.ImportTask{
|
||||
Slug: slug,
|
||||
Title: req.Title,
|
||||
Author: req.Author,
|
||||
CoverURL: req.CoverURL,
|
||||
Genres: req.Genres,
|
||||
Summary: req.Summary,
|
||||
BookStatus: req.BookStatus,
|
||||
FileType: req.FileType,
|
||||
ObjectKey: objectKey,
|
||||
ChaptersKey: chaptersKey,
|
||||
ChaptersTotal: chapterCount,
|
||||
InitiatorUserID: "",
|
||||
})
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "create import task: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, importResponse{
|
||||
TaskID: taskID,
|
||||
Slug: slug,
|
||||
Preview: &importPreview{Chapters: chapterCount},
|
||||
})
|
||||
}
|
||||
|
||||
// analyzeImportFile parses the file to count chapters and extract preview lines.
|
||||
func analyzeImportFile(data []byte, fileType string) *importPreview {
|
||||
count, firstLines, err := storage.AnalyzeFile(data, fileType)
|
||||
if err != nil || count == 0 {
|
||||
// Fall back to rough size estimate so the UI still shows something
|
||||
count = estimateChapters(data, fileType)
|
||||
}
|
||||
return &importPreview{
|
||||
Chapters: count,
|
||||
FirstLines: firstLines,
|
||||
}
|
||||
}
|
||||
|
||||
func estimateChapters(data []byte, fileType string) int {
|
||||
// Rough estimate: ~100KB per chapter for PDF, ~50KB for EPUB
|
||||
size := len(data)
|
||||
if fileType == "pdf" {
|
||||
return size / 100000
|
||||
}
|
||||
return size / 50000
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminImportStatus(w http.ResponseWriter, r *http.Request) {
|
||||
taskID := r.PathValue("id")
|
||||
if taskID == "" {
|
||||
jsonError(w, http.StatusBadRequest, "task id required")
|
||||
return
|
||||
}
|
||||
|
||||
task, ok, err := s.deps.TaskReader.GetImportTask(r.Context(), taskID)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "get task: "+err.Error())
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
jsonError(w, http.StatusNotFound, "task not found")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, task)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminImportList(w http.ResponseWriter, r *http.Request) {
|
||||
tasks, err := s.deps.TaskReader.ListImportTasks(r.Context())
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "list tasks: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]any{"tasks": tasks})
|
||||
}
|
||||
126
backend/internal/backend/handlers_notifications.go
Normal file
126
backend/internal/backend/handlers_notifications.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package backend
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/libnovel/backend/internal/storage"
|
||||
)
|
||||
|
||||
// handleDismissNotification handles DELETE /api/notifications/{id}.
|
||||
func (s *Server) handleDismissNotification(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
jsonError(w, http.StatusBadRequest, "notification id required")
|
||||
return
|
||||
}
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
return
|
||||
}
|
||||
if err := store.DeleteNotification(r.Context(), id); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "dismiss notification: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]any{"success": true})
|
||||
}
|
||||
|
||||
// handleClearAllNotifications handles DELETE /api/notifications?user_id=...
|
||||
func (s *Server) handleClearAllNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
jsonError(w, http.StatusBadRequest, "user_id required")
|
||||
return
|
||||
}
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
return
|
||||
}
|
||||
if err := store.ClearAllNotifications(r.Context(), userID); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "clear notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]any{"success": true})
|
||||
}
|
||||
|
||||
// handleMarkAllNotificationsRead handles PATCH /api/notifications?user_id=...
|
||||
func (s *Server) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
jsonError(w, http.StatusBadRequest, "user_id required")
|
||||
return
|
||||
}
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
return
|
||||
}
|
||||
if err := store.MarkAllNotificationsRead(r.Context(), userID); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "mark all read: "+err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 0, map[string]any{"success": true})
|
||||
}
|
||||
|
||||
type notification struct {
|
||||
ID string `json:"id"`
|
||||
UserID string `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Link string `json:"link"`
|
||||
Read bool `json:"read"`
|
||||
}
|
||||
|
||||
func (s *Server) handleListNotifications(w http.ResponseWriter, r *http.Request) {
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
jsonError(w, http.StatusBadRequest, "user_id required")
|
||||
return
|
||||
}
|
||||
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
return
|
||||
}
|
||||
|
||||
items, err := store.ListNotifications(r.Context(), userID, 50)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "list notifications: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Parse each item as notification
|
||||
notifications := make([]notification, 0, len(items))
|
||||
for _, item := range items {
|
||||
b, _ := json.Marshal(item)
|
||||
var n notification
|
||||
json.Unmarshal(b, &n)
|
||||
notifications = append(notifications, n)
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{"notifications": notifications})
|
||||
}
|
||||
|
||||
func (s *Server) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
if id == "" {
|
||||
jsonError(w, http.StatusBadRequest, "notification id required")
|
||||
return
|
||||
}
|
||||
|
||||
store, ok := s.deps.Producer.(*storage.Store)
|
||||
if !ok {
|
||||
jsonError(w, http.StatusInternalServerError, "storage not available")
|
||||
return
|
||||
}
|
||||
|
||||
if err := store.MarkNotificationRead(r.Context(), id); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, "mark read: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, 0, map[string]any{"success": true})
|
||||
}
|
||||
@@ -85,6 +85,9 @@ type Dependencies struct {
|
||||
// BookWriter writes book metadata and chapter refs to PocketBase.
|
||||
// Used by admin text-gen apply endpoints.
|
||||
BookWriter bookstore.BookWriter
|
||||
// ImportFileStore uploads raw PDF/EPUB files to MinIO for the runner to process.
|
||||
// Always wired to the concrete *storage.Store (not the Asynq wrapper).
|
||||
ImportFileStore bookstore.ImportFileStore
|
||||
// AIJobStore tracks long-running AI generation jobs in PocketBase.
|
||||
// If nil, job persistence is disabled (jobs still run but are not recorded).
|
||||
AIJobStore bookstore.AIJobStore
|
||||
@@ -244,6 +247,18 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
|
||||
// Admin data repair endpoints
|
||||
mux.HandleFunc("POST /api/admin/dedup-chapters/{slug}", s.handleDedupChapters)
|
||||
|
||||
// Import (PDF/EPUB)
|
||||
mux.HandleFunc("POST /api/admin/import", s.handleAdminImport)
|
||||
mux.HandleFunc("GET /api/admin/import", s.handleAdminImportList)
|
||||
mux.HandleFunc("GET /api/admin/import/{id}", s.handleAdminImportStatus)
|
||||
|
||||
// Notifications
|
||||
mux.HandleFunc("GET /api/notifications", s.handleListNotifications)
|
||||
mux.HandleFunc("PATCH /api/notifications", s.handleMarkAllNotificationsRead)
|
||||
mux.HandleFunc("PATCH /api/notifications/{id}", s.handleMarkNotificationRead)
|
||||
mux.HandleFunc("DELETE /api/notifications", s.handleClearAllNotifications)
|
||||
mux.HandleFunc("DELETE /api/notifications/{id}", s.handleDismissNotification)
|
||||
|
||||
// Voices list
|
||||
mux.HandleFunc("GET /api/voices", s.handleVoices)
|
||||
|
||||
|
||||
@@ -200,3 +200,29 @@ type TranslationStore interface {
|
||||
// GetTranslation retrieves translated markdown from MinIO.
|
||||
GetTranslation(ctx context.Context, key string) (string, error)
|
||||
}
|
||||
|
||||
// Chapter represents a single chapter extracted from PDF/EPUB.
|
||||
type Chapter struct {
|
||||
Number int // 1-based chapter number
|
||||
Title string // chapter title (may be empty)
|
||||
Content string // plain text content
|
||||
}
|
||||
|
||||
// BookImporter handles PDF/EPUB file parsing and chapter extraction.
|
||||
// Used by the runner to import books from uploaded files.
|
||||
type BookImporter interface {
|
||||
// Import extracts chapters from a PDF or EPUB file stored in MinIO.
|
||||
// Returns the extracted chapters or an error.
|
||||
Import(ctx context.Context, objectKey, fileType string) ([]Chapter, error)
|
||||
}
|
||||
|
||||
// ImportFileStore uploads raw import files to object storage.
|
||||
// Kept separate from BookImporter so the HTTP handler can upload the file
|
||||
// without a concrete type assertion, regardless of which Producer is wired.
|
||||
type ImportFileStore interface {
|
||||
PutImportFile(ctx context.Context, objectKey string, data []byte) error
|
||||
// PutImportChapters stores the pre-parsed chapters JSON under the given key.
|
||||
PutImportChapters(ctx context.Context, key string, data []byte) error
|
||||
// GetImportChapters retrieves the pre-parsed chapters JSON.
|
||||
GetImportChapters(ctx context.Context, key string) ([]byte, error)
|
||||
}
|
||||
|
||||
@@ -170,6 +170,37 @@ type TranslationResult struct {
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
// ImportTask represents a PDF/EPUB import job stored in PocketBase.
|
||||
type ImportTask struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"` // derived from filename
|
||||
Title string `json:"title"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"` // "pdf" or "epub"
|
||||
ObjectKey string `json:"object_key,omitempty"` // MinIO path to uploaded file
|
||||
ChaptersKey string `json:"chapters_key,omitempty"` // MinIO path to pre-parsed chapters JSON
|
||||
Author string `json:"author,omitempty"`
|
||||
CoverURL string `json:"cover_url,omitempty"`
|
||||
Genres []string `json:"genres,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
BookStatus string `json:"book_status,omitempty"` // "ongoing" | "completed" | "hiatus"
|
||||
WorkerID string `json:"worker_id,omitempty"`
|
||||
InitiatorUserID string `json:"initiator_user_id,omitempty"` // PocketBase user ID who submitted the import
|
||||
Status TaskStatus `json:"status"`
|
||||
ChaptersDone int `json:"chapters_done"`
|
||||
ChaptersTotal int `json:"chapters_total"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
Started time.Time `json:"started"`
|
||||
Finished time.Time `json:"finished,omitempty"`
|
||||
}
|
||||
|
||||
// ImportResult is the outcome reported by the runner after finishing an ImportTask.
|
||||
type ImportResult struct {
|
||||
Slug string `json:"slug,omitempty"`
|
||||
ChaptersImported int `json:"chapters_imported"`
|
||||
ErrorMessage string `json:"error_message,omitempty"`
|
||||
}
|
||||
|
||||
// AIJob represents an AI generation task tracked in PocketBase (ai_jobs collection).
|
||||
type AIJob struct {
|
||||
ID string `json:"id"`
|
||||
|
||||
@@ -54,6 +54,7 @@ func (r *Runner) runAsynq(ctx context.Context) error {
|
||||
mux.HandleFunc(asynqqueue.TypeAudioGenerate, r.handleAudioTask)
|
||||
mux.HandleFunc(asynqqueue.TypeScrapeBook, r.handleScrapeTask)
|
||||
mux.HandleFunc(asynqqueue.TypeScrapeCatalogue, r.handleScrapeTask)
|
||||
mux.HandleFunc(asynqqueue.TypeImportBook, r.handleImportTask)
|
||||
|
||||
// Register Asynq queue metrics with the default Prometheus registry so
|
||||
// the /metrics endpoint (metrics.go) can expose them.
|
||||
@@ -191,6 +192,25 @@ func (r *Runner) handleAudioTask(ctx context.Context, t *asynq.Task) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleImportTask is the Asynq handler for TypeImportBook (PDF/EPUB import).
|
||||
func (r *Runner) handleImportTask(ctx context.Context, t *asynq.Task) error {
|
||||
var p asynqqueue.ImportPayload
|
||||
if err := json.Unmarshal(t.Payload(), &p); err != nil {
|
||||
return fmt.Errorf("unmarshal import payload: %w", err)
|
||||
}
|
||||
task := domain.ImportTask{
|
||||
ID: p.PBTaskID,
|
||||
Slug: p.Slug,
|
||||
Title: p.Title,
|
||||
FileType: p.FileType,
|
||||
ChaptersKey: p.ChaptersKey,
|
||||
}
|
||||
r.tasksRunning.Add(1)
|
||||
defer r.tasksRunning.Add(-1)
|
||||
r.runImportTask(ctx, task, p.ObjectKey)
|
||||
return nil
|
||||
}
|
||||
|
||||
// pollTranslationTasks claims all available translation tasks from PocketBase
|
||||
// and dispatches them to goroutines. Translation tasks don't go through Redis/Asynq
|
||||
// because they're stored in PocketBase, so we need this separate poll loop.
|
||||
|
||||
@@ -15,6 +15,7 @@ package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
@@ -39,6 +40,21 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
// Notifier creates notifications for users.
|
||||
type Notifier interface {
|
||||
CreateNotification(ctx context.Context, userID, title, message, link string) error
|
||||
}
|
||||
|
||||
// ChapterIngester persists imported chapters for a book.
|
||||
type ChapterIngester interface {
|
||||
IngestChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error
|
||||
}
|
||||
|
||||
// ImportChapterStore retrieves pre-parsed chapter JSON blobs from object storage.
|
||||
type ImportChapterStore interface {
|
||||
GetImportChapters(ctx context.Context, key string) ([]byte, error)
|
||||
}
|
||||
|
||||
// Config tunes the runner behaviour.
|
||||
type Config struct {
|
||||
// WorkerID uniquely identifies this runner instance in PocketBase records.
|
||||
@@ -103,6 +119,17 @@ type Dependencies struct {
|
||||
TranslationStore bookstore.TranslationStore
|
||||
// CoverStore stores book cover images in MinIO.
|
||||
CoverStore bookstore.CoverStore
|
||||
// BookImport handles PDF/EPUB file parsing and chapter extraction.
|
||||
// Kept for backward compatibility when ChaptersKey is not set.
|
||||
BookImport bookstore.BookImporter
|
||||
// ImportChapterStore retrieves pre-parsed chapter JSON blobs from MinIO.
|
||||
// When set and the task has a ChaptersKey, the runner reads from here
|
||||
// instead of calling BookImport.Import() (the new preferred path).
|
||||
ImportChapterStore ImportChapterStore
|
||||
// ChapterIngester persists extracted chapters into MinIO/PocketBase.
|
||||
ChapterIngester ChapterIngester
|
||||
// Notifier creates notifications for users.
|
||||
Notifier Notifier
|
||||
// SearchIndex indexes books in Meilisearch after scraping.
|
||||
// If nil a no-op is used.
|
||||
SearchIndex meili.Client
|
||||
@@ -225,6 +252,7 @@ func (r *Runner) runPoll(ctx context.Context) error {
|
||||
scrapeSem := make(chan struct{}, r.cfg.MaxConcurrentScrape)
|
||||
audioSem := make(chan struct{}, r.cfg.MaxConcurrentAudio)
|
||||
translationSem := make(chan struct{}, r.cfg.MaxConcurrentTranslation)
|
||||
importSem := make(chan struct{}, 1) // Limit concurrent imports
|
||||
var wg sync.WaitGroup
|
||||
|
||||
tick := time.NewTicker(r.cfg.PollInterval)
|
||||
@@ -244,7 +272,7 @@ func (r *Runner) runPoll(ctx context.Context) error {
|
||||
|
||||
// Run one poll immediately on startup, then on each tick.
|
||||
for {
|
||||
r.poll(ctx, scrapeSem, audioSem, translationSem, &wg)
|
||||
r.poll(ctx, scrapeSem, audioSem, translationSem, importSem, &wg)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -269,7 +297,7 @@ func (r *Runner) runPoll(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// poll claims all available pending tasks and dispatches them to goroutines.
|
||||
func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem, translationSem chan struct{}, wg *sync.WaitGroup) {
|
||||
func (r *Runner) poll(ctx context.Context, scrapeSem, audioSem, translationSem, importSem chan struct{}, wg *sync.WaitGroup) {
|
||||
// ── Heartbeat file ────────────────────────────────────────────────────
|
||||
// Touch /tmp/runner.alive so the Docker health check can confirm the
|
||||
// runner is actively polling. Failure is non-fatal — just log it.
|
||||
@@ -385,6 +413,39 @@ translationLoop:
|
||||
r.runTranslationTask(ctx, t)
|
||||
}(task)
|
||||
}
|
||||
|
||||
// ── Import tasks ─────────────────────────────────────────────────────
|
||||
importLoop:
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case importSem <- struct{}{}:
|
||||
// Slot acquired — proceed to claim a task.
|
||||
default:
|
||||
// All slots busy; leave remaining pending tasks for next tick.
|
||||
break importLoop
|
||||
}
|
||||
task, ok, err := r.deps.Consumer.ClaimNextImportTask(ctx, r.cfg.WorkerID)
|
||||
if err != nil {
|
||||
<-importSem
|
||||
r.deps.Log.Error("runner: ClaimNextImportTask failed", "err", err)
|
||||
break
|
||||
}
|
||||
if !ok {
|
||||
<-importSem
|
||||
break
|
||||
}
|
||||
r.tasksRunning.Add(1)
|
||||
wg.Add(1)
|
||||
go func(t domain.ImportTask) {
|
||||
defer wg.Done()
|
||||
defer func() { <-importSem }()
|
||||
defer r.tasksRunning.Add(-1)
|
||||
r.runImportTask(ctx, t, t.ObjectKey)
|
||||
}(task)
|
||||
}
|
||||
}
|
||||
|
||||
// newOrchestrator builds an orchestrator with the Meilisearch post-hook wired in.
|
||||
@@ -451,9 +512,21 @@ func (r *Runner) runScrapeTask(ctx context.Context, task domain.ScrapeTask) {
|
||||
if result.ErrorMessage != "" {
|
||||
r.tasksFailed.Add(1)
|
||||
span.SetStatus(codes.Error, result.ErrorMessage)
|
||||
if r.deps.Notifier != nil {
|
||||
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||
"Scrape Failed",
|
||||
fmt.Sprintf("Scrape task (%s) failed: %s", task.Kind, result.ErrorMessage),
|
||||
"/admin/tasks")
|
||||
}
|
||||
} else {
|
||||
r.tasksCompleted.Add(1)
|
||||
span.SetStatus(codes.Ok, "")
|
||||
if r.deps.Notifier != nil {
|
||||
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||
"Scrape Complete",
|
||||
fmt.Sprintf("Scraped %d chapters, skipped %d (%s)", result.ChaptersScraped, result.ChaptersSkipped, task.Kind),
|
||||
"/admin/tasks")
|
||||
}
|
||||
}
|
||||
|
||||
log.Info("runner: scrape task finished",
|
||||
@@ -533,6 +606,12 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
||||
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishAudioTask failed", "err", err)
|
||||
}
|
||||
if r.deps.Notifier != nil {
|
||||
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||
"Audio Failed",
|
||||
fmt.Sprintf("Ch.%d of %s (%s): %s", task.Chapter, task.Slug, task.Voice, msg),
|
||||
fmt.Sprintf("/books/%s", task.Slug))
|
||||
}
|
||||
}
|
||||
|
||||
raw, err := r.deps.BookReader.ReadChapter(ctx, task.Slug, task.Chapter)
|
||||
@@ -597,5 +676,148 @@ func (r *Runner) runAudioTask(ctx context.Context, task domain.AudioTask) {
|
||||
if err := r.deps.Consumer.FinishAudioTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishAudioTask failed", "err", err)
|
||||
}
|
||||
if r.deps.Notifier != nil {
|
||||
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||
"Audio Ready",
|
||||
fmt.Sprintf("Ch.%d of %s (%s) is ready", task.Chapter, task.Slug, task.Voice),
|
||||
fmt.Sprintf("/books/%s", task.Slug))
|
||||
}
|
||||
log.Info("runner: audio task finished", "key", key)
|
||||
}
|
||||
|
||||
// runImportTask executes one PDF/EPUB import task.
|
||||
// Preferred path: when task.ChaptersKey is set, it reads pre-parsed chapters
|
||||
// JSON from MinIO (written by the backend at upload time) and ingests them.
|
||||
// Fallback path: when ChaptersKey is empty, calls BookImport.Import() to
|
||||
// parse the raw file on the runner (legacy behaviour, not used for new tasks).
|
||||
func (r *Runner) runImportTask(ctx context.Context, task domain.ImportTask, objectKey string) {
|
||||
ctx, span := otel.Tracer("runner").Start(ctx, "runner.import_task")
|
||||
defer span.End()
|
||||
span.SetAttributes(
|
||||
attribute.String("task.id", task.ID),
|
||||
attribute.String("book.slug", task.Slug),
|
||||
attribute.String("file.type", task.FileType),
|
||||
attribute.String("chapters_key", task.ChaptersKey),
|
||||
)
|
||||
|
||||
log := r.deps.Log.With("task_id", task.ID, "slug", task.Slug, "file_type", task.FileType)
|
||||
log.Info("runner: import task starting", "chapters_key", task.ChaptersKey)
|
||||
|
||||
hbCtx, hbCancel := context.WithCancel(ctx)
|
||||
defer hbCancel()
|
||||
go func() {
|
||||
tick := time.NewTicker(r.cfg.HeartbeatInterval)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-hbCtx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
if err := r.deps.Consumer.HeartbeatTask(ctx, task.ID); err != nil {
|
||||
log.Warn("runner: heartbeat failed", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
fail := func(msg string) {
|
||||
log.Error("runner: import task failed", "reason", msg)
|
||||
r.tasksFailed.Add(1)
|
||||
span.SetStatus(codes.Error, msg)
|
||||
result := domain.ImportResult{ErrorMessage: msg}
|
||||
if err := r.deps.Consumer.FinishImportTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishImportTask failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
var chapters []bookstore.Chapter
|
||||
|
||||
if task.ChaptersKey != "" && r.deps.ImportChapterStore != nil {
|
||||
// New path: read pre-parsed chapters JSON uploaded by the backend.
|
||||
raw, err := r.deps.ImportChapterStore.GetImportChapters(ctx, task.ChaptersKey)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("get chapters JSON: %v", err))
|
||||
return
|
||||
}
|
||||
if err := json.Unmarshal(raw, &chapters); err != nil {
|
||||
fail(fmt.Sprintf("unmarshal chapters JSON: %v", err))
|
||||
return
|
||||
}
|
||||
log.Info("runner: loaded pre-parsed chapters", "count", len(chapters))
|
||||
} else {
|
||||
// Legacy path: parse the raw file on the runner.
|
||||
if r.deps.BookImport == nil {
|
||||
fail("book import not configured (BookImport dependency missing)")
|
||||
return
|
||||
}
|
||||
var err error
|
||||
chapters, err = r.deps.BookImport.Import(ctx, objectKey, task.FileType)
|
||||
if err != nil {
|
||||
fail(fmt.Sprintf("import file: %v", err))
|
||||
return
|
||||
}
|
||||
log.Info("runner: parsed chapters from file (legacy path)", "count", len(chapters))
|
||||
}
|
||||
|
||||
if len(chapters) == 0 {
|
||||
fail("no chapters extracted from file")
|
||||
return
|
||||
}
|
||||
|
||||
// Persist chapters via ChapterIngester.
|
||||
if r.deps.ChapterIngester == nil {
|
||||
fail("chapter ingester not configured")
|
||||
return
|
||||
}
|
||||
if err := r.deps.ChapterIngester.IngestChapters(ctx, task.Slug, chapters); err != nil {
|
||||
fail(fmt.Sprintf("store chapters: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Write book metadata so the book appears in PocketBase catalogue.
|
||||
if r.deps.BookWriter != nil {
|
||||
meta := domain.BookMeta{
|
||||
Slug: task.Slug,
|
||||
Title: task.Title,
|
||||
Author: task.Author,
|
||||
Cover: task.CoverURL,
|
||||
Status: task.BookStatus,
|
||||
Genres: task.Genres,
|
||||
Summary: task.Summary,
|
||||
TotalChapters: len(chapters),
|
||||
}
|
||||
if meta.Status == "" {
|
||||
meta.Status = "completed"
|
||||
}
|
||||
if err := r.deps.BookWriter.WriteMetadata(ctx, meta); err != nil {
|
||||
log.Warn("runner: import task WriteMetadata failed (non-fatal)", "err", err)
|
||||
} else {
|
||||
// Index in Meilisearch so the book is searchable.
|
||||
if err := r.deps.SearchIndex.UpsertBook(ctx, meta); err != nil {
|
||||
log.Warn("runner: import task meilisearch upsert failed (non-fatal)", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
r.tasksCompleted.Add(1)
|
||||
span.SetStatus(codes.Ok, "")
|
||||
result := domain.ImportResult{
|
||||
Slug: task.Slug,
|
||||
ChaptersImported: len(chapters),
|
||||
}
|
||||
if err := r.deps.Consumer.FinishImportTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishImportTask failed", "err", err)
|
||||
}
|
||||
|
||||
// Notify the user who initiated the import.
|
||||
if r.deps.Notifier != nil {
|
||||
msg := fmt.Sprintf("Import completed: %d chapters from %s", len(chapters), task.Title)
|
||||
targetUser := task.InitiatorUserID
|
||||
if targetUser == "" {
|
||||
targetUser = "admin"
|
||||
}
|
||||
_ = r.deps.Notifier.CreateNotification(ctx, targetUser, "Import Complete", msg, "/admin/import")
|
||||
}
|
||||
|
||||
log.Info("runner: import task finished", "chapters", len(chapters))
|
||||
}
|
||||
|
||||
@@ -54,6 +54,10 @@ func (s *stubConsumer) ClaimNextTranslationTask(_ context.Context, _ string) (do
|
||||
return domain.TranslationTask{}, false, nil
|
||||
}
|
||||
|
||||
func (s *stubConsumer) ClaimNextImportTask(_ context.Context, _ string) (domain.ImportTask, bool, error) {
|
||||
return domain.ImportTask{}, false, nil
|
||||
}
|
||||
|
||||
func (s *stubConsumer) FinishScrapeTask(_ context.Context, id string, _ domain.ScrapeResult) error {
|
||||
s.finished = append(s.finished, id)
|
||||
return nil
|
||||
@@ -69,6 +73,11 @@ func (s *stubConsumer) FinishTranslationTask(_ context.Context, id string, _ dom
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubConsumer) FinishImportTask(_ context.Context, id string, _ domain.ImportResult) error {
|
||||
s.finished = append(s.finished, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubConsumer) FailTask(_ context.Context, id, _ string) error {
|
||||
s.failCalled = append(s.failCalled, id)
|
||||
return nil
|
||||
|
||||
@@ -53,6 +53,12 @@ func (r *Runner) runTranslationTask(ctx context.Context, task domain.Translation
|
||||
if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishTranslationTask failed", "err", err)
|
||||
}
|
||||
if r.deps.Notifier != nil {
|
||||
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||
"Translation Failed",
|
||||
fmt.Sprintf("Ch.%d of %s (%s): %s", task.Chapter, task.Slug, task.Lang, msg),
|
||||
fmt.Sprintf("/books/%s", task.Slug))
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: LibreTranslate must be configured.
|
||||
@@ -93,5 +99,11 @@ func (r *Runner) runTranslationTask(ctx context.Context, task domain.Translation
|
||||
if err := r.deps.Consumer.FinishTranslationTask(ctx, task.ID, result); err != nil {
|
||||
log.Error("runner: FinishTranslationTask failed", "err", err)
|
||||
}
|
||||
if r.deps.Notifier != nil {
|
||||
_ = r.deps.Notifier.CreateNotification(ctx, "admin",
|
||||
"Translation Ready",
|
||||
fmt.Sprintf("Ch.%d of %s translated to %s", task.Chapter, task.Slug, task.Lang),
|
||||
fmt.Sprintf("/books/%s", task.Slug))
|
||||
}
|
||||
log.Info("runner: translation task finished", "key", key)
|
||||
}
|
||||
|
||||
892
backend/internal/storage/import.go
Normal file
892
backend/internal/storage/import.go
Normal file
@@ -0,0 +1,892 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/libnovel/backend/internal/bookstore"
|
||||
"github.com/libnovel/backend/internal/domain"
|
||||
minio "github.com/minio/minio-go/v7"
|
||||
"github.com/pdfcpu/pdfcpu/pkg/api"
|
||||
pdfcpu "github.com/pdfcpu/pdfcpu/pkg/pdfcpu"
|
||||
"github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// chapterHeadingRE matches common chapter heading patterns:
|
||||
// "Chapter 1", "Chapter 1:", "Chapter 1 -", "CHAPTER ONE", "1.", "Part 1", etc.
|
||||
var chapterHeadingRE = regexp.MustCompile(
|
||||
`(?i)^(?:chapter|ch\.?|part|episode|book)\s+(\d+|[ivxlcdm]+)\b|^\d{1,4}[\.\)]\s+\S`)
|
||||
|
||||
type importer struct {
|
||||
mc *minioClient
|
||||
}
|
||||
|
||||
// NewBookImporter creates a BookImporter that reads files from MinIO.
|
||||
func NewBookImporter(s *Store) bookstore.BookImporter {
|
||||
return &importer{mc: s.mc}
|
||||
}
|
||||
|
||||
func (i *importer) Import(ctx context.Context, objectKey, fileType string) ([]bookstore.Chapter, error) {
|
||||
if fileType != "pdf" && fileType != "epub" {
|
||||
return nil, fmt.Errorf("unsupported file type: %s", fileType)
|
||||
}
|
||||
|
||||
obj, err := i.mc.client.GetObject(ctx, "imports", objectKey, minio.GetObjectOptions{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get object from minio: %w", err)
|
||||
}
|
||||
defer obj.Close()
|
||||
|
||||
data, err := io.ReadAll(obj)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read object: %w", err)
|
||||
}
|
||||
|
||||
if fileType == "pdf" {
|
||||
return parsePDF(data)
|
||||
}
|
||||
return parseEPUB(data)
|
||||
}
|
||||
|
||||
// AnalyzeFile parses the given PDF or EPUB data and returns the detected
|
||||
// chapter count and up to 3 preview lines (first non-empty line of each of
|
||||
// the first 3 chapters). It is used by the analyze-only endpoint so users
|
||||
// can preview chapter count before committing the import.
|
||||
// Note: uses parsePDF which is backed by pdfcpu ExtractContent — fast, no hang risk.
|
||||
func AnalyzeFile(data []byte, fileType string) (chapterCount int, firstLines []string, err error) {
|
||||
var chapters []bookstore.Chapter
|
||||
switch fileType {
|
||||
case "pdf":
|
||||
chapters, err = parsePDF(data)
|
||||
case "epub":
|
||||
chapters, err = parseEPUB(data)
|
||||
default:
|
||||
return 0, nil, fmt.Errorf("unsupported file type: %s", fileType)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
chapterCount = len(chapters)
|
||||
for i, ch := range chapters {
|
||||
if i >= 3 {
|
||||
break
|
||||
}
|
||||
line := strings.TrimSpace(ch.Content)
|
||||
if nl := strings.Index(line, "\n"); nl > 0 {
|
||||
line = line[:nl]
|
||||
}
|
||||
if len(line) > 120 {
|
||||
line = line[:120] + "…"
|
||||
}
|
||||
firstLines = append(firstLines, line)
|
||||
}
|
||||
return chapterCount, firstLines, nil
|
||||
}
|
||||
|
||||
|
||||
|
||||
// decryptPDF strips encryption from a PDF using an empty user password.
|
||||
// Returns the decrypted bytes, or an error if decryption is not possible.
|
||||
// This handles the common case of "owner-only" encrypted PDFs (copy/print
|
||||
// restrictions) which use an empty user password and open normally in readers.
|
||||
func decryptPDF(data []byte) ([]byte, error) {
|
||||
conf := model.NewDefaultConfiguration()
|
||||
conf.UserPW = ""
|
||||
conf.OwnerPW = ""
|
||||
|
||||
var out bytes.Buffer
|
||||
err := api.Decrypt(bytes.NewReader(data), &out, conf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// ParseImportFile parses a PDF or EPUB and returns chapters.
|
||||
// Unlike AnalyzeFile it respects ctx cancellation so callers can apply a timeout.
|
||||
// For PDFs it first attempts to strip encryption with an empty password.
|
||||
func ParseImportFile(ctx context.Context, data []byte, fileType string) ([]bookstore.Chapter, error) {
|
||||
type result struct {
|
||||
chapters []bookstore.Chapter
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
go func() {
|
||||
var chapters []bookstore.Chapter
|
||||
var err error
|
||||
switch fileType {
|
||||
case "pdf":
|
||||
chapters, err = parsePDF(data)
|
||||
case "epub":
|
||||
chapters, err = parseEPUB(data)
|
||||
default:
|
||||
err = fmt.Errorf("unsupported file type: %s", fileType)
|
||||
}
|
||||
ch <- result{chapters, err}
|
||||
}()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, fmt.Errorf("parse timed out: %w", ctx.Err())
|
||||
case r := <-ch:
|
||||
return r.chapters, r.err
|
||||
}
|
||||
}
|
||||
|
||||
// pdfSkipBookmarks lists bookmark titles that are front/back matter, not story chapters.
|
||||
// These are skipped when building the chapter list.
|
||||
var pdfSkipBookmarks = map[string]bool{
|
||||
"cover": true, "insert": true, "title page": true, "copyright": true,
|
||||
"appendix": true, "color insert": true, "color illustrations": true,
|
||||
}
|
||||
|
||||
// parsePDF extracts chapters from PDF bytes.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Decrypt owner-protected PDFs (empty user password).
|
||||
// 2. Read the PDF outline (bookmarks) — these give chapter titles and page ranges.
|
||||
// 3. Extract raw content streams for every page using pdfcpu ExtractContent.
|
||||
// 4. For each story bookmark, concatenate the extracted text of its pages.
|
||||
//
|
||||
// Falls back to paragraph-splitting when no bookmarks are found.
|
||||
// This is fast (~100ms for a 250-page PDF) because it avoids font-glyph
|
||||
// resolution which causes older PDF libraries to hang on publisher PDFs.
|
||||
func parsePDF(data []byte) ([]bookstore.Chapter, error) {
|
||||
// Decrypt owner-protected PDFs (empty user password).
|
||||
decrypted, err := decryptPDF(data)
|
||||
if err == nil {
|
||||
data = decrypted
|
||||
}
|
||||
|
||||
conf := model.NewDefaultConfiguration()
|
||||
conf.UserPW = ""
|
||||
conf.OwnerPW = ""
|
||||
|
||||
// Extract all page content streams to a temp directory.
|
||||
tmpDir, err := os.MkdirTemp("", "pdf-extract-*")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create temp dir: %w", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
if err := api.ExtractContent(bytes.NewReader(data), tmpDir, "out", nil, conf); err != nil {
|
||||
return nil, fmt.Errorf("extract PDF content: %w", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(tmpDir)
|
||||
if err != nil || len(entries) == 0 {
|
||||
return nil, fmt.Errorf("PDF has no content pages")
|
||||
}
|
||||
|
||||
// Sort entries by filename so index == page number - 1.
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
|
||||
|
||||
// Build page-index → extracted text map.
|
||||
pageTexts := make(map[int]string, len(entries))
|
||||
for idx, e := range entries {
|
||||
raw, readErr := os.ReadFile(tmpDir + "/" + e.Name())
|
||||
if readErr != nil {
|
||||
continue
|
||||
}
|
||||
pageTexts[idx+1] = extractTextFromContentStream(raw)
|
||||
}
|
||||
|
||||
// Try to use bookmarks (outline) for chapter structure.
|
||||
bookmarks, bmErr := api.Bookmarks(bytes.NewReader(data), conf)
|
||||
if bmErr == nil && len(bookmarks) > 0 {
|
||||
chapters := chaptersFromBookmarks(bookmarks, pageTexts)
|
||||
if len(chapters) > 0 {
|
||||
return chapters, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: concatenate all page texts and split by heading patterns.
|
||||
var sb strings.Builder
|
||||
for p := 1; p <= len(entries); p++ {
|
||||
sb.WriteString(pageTexts[p])
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
chapters := extractChaptersFromText(sb.String())
|
||||
if len(chapters) == 0 {
|
||||
return nil, fmt.Errorf("could not extract any chapters from PDF")
|
||||
}
|
||||
return chapters, nil
|
||||
}
|
||||
|
||||
// chaptersFromBookmarks builds a chapter list from PDF bookmarks + per-page text.
|
||||
// It flattens the bookmark tree, skips front/back matter entries, and assigns
|
||||
// page ranges so each chapter spans from its own start page to the next
|
||||
// bookmark's start page minus one.
|
||||
func chaptersFromBookmarks(bookmarks []pdfcpu.Bookmark, pageTexts map[int]string) []bookstore.Chapter {
|
||||
// Flatten bookmark tree.
|
||||
var flat []pdfcpu.Bookmark
|
||||
var flatten func([]pdfcpu.Bookmark)
|
||||
flatten = func(bms []pdfcpu.Bookmark) {
|
||||
for _, bm := range bms {
|
||||
flat = append(flat, bm)
|
||||
flatten(bm.Kids)
|
||||
}
|
||||
}
|
||||
flatten(bookmarks)
|
||||
|
||||
// Sort by page number.
|
||||
sort.Slice(flat, func(i, j int) bool { return flat[i].PageFrom < flat[j].PageFrom })
|
||||
|
||||
// Assign PageThru for entries where it's 0 (last bookmark or missing).
|
||||
maxPage := 0
|
||||
for p := range pageTexts {
|
||||
if p > maxPage {
|
||||
maxPage = p
|
||||
}
|
||||
}
|
||||
for i := range flat {
|
||||
if flat[i].PageThru == 0 {
|
||||
if i+1 < len(flat) {
|
||||
flat[i].PageThru = flat[i+1].PageFrom - 1
|
||||
} else {
|
||||
flat[i].PageThru = maxPage
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var chapters []bookstore.Chapter
|
||||
chNum := 0
|
||||
for _, bm := range flat {
|
||||
if pdfSkipBookmarks[strings.ToLower(strings.TrimSpace(bm.Title))] {
|
||||
continue
|
||||
}
|
||||
// Gather text for all pages in this bookmark's range.
|
||||
var sb strings.Builder
|
||||
for p := bm.PageFrom; p <= bm.PageThru; p++ {
|
||||
if t, ok := pageTexts[p]; ok {
|
||||
sb.WriteString(t)
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
text := strings.TrimSpace(sb.String())
|
||||
if len(text) < 50 {
|
||||
continue // skip nearly-empty sections
|
||||
}
|
||||
chNum++
|
||||
chapters = append(chapters, bookstore.Chapter{
|
||||
Number: chNum,
|
||||
Title: bm.Title,
|
||||
Content: text,
|
||||
})
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
// extractTextFromContentStream parses a raw PDF content stream and extracts
|
||||
// readable text from Tj and TJ operators.
|
||||
//
|
||||
// TJ arrays may contain a mix of literal strings (parenthesised) and hex glyph
|
||||
// arrays. Only the literal strings are decoded — hex arrays require per-font
|
||||
// ToUnicode CMaps and are skipped. Kerning adjustment numbers inside TJ arrays
|
||||
// are also ignored (they're just spacing hints).
|
||||
//
|
||||
// Line breaks are inserted on ET / Td / TD / T* operators.
|
||||
func extractTextFromContentStream(stream []byte) string {
|
||||
s := string(stream)
|
||||
var sb strings.Builder
|
||||
i := 0
|
||||
n := len(s)
|
||||
for i < n {
|
||||
// TJ array: [ ... ]TJ — collect all literal strings, skip hex & numbers.
|
||||
if s[i] == '[' {
|
||||
j := i + 1
|
||||
for j < n && s[j] != ']' {
|
||||
if s[j] == '(' {
|
||||
// Literal string inside TJ array.
|
||||
k := j + 1
|
||||
depth := 1
|
||||
for k < n && depth > 0 {
|
||||
if s[k] == '\\' {
|
||||
k += 2
|
||||
continue
|
||||
}
|
||||
if s[k] == '(' {
|
||||
depth++
|
||||
} else if s[k] == ')' {
|
||||
depth--
|
||||
}
|
||||
k++
|
||||
}
|
||||
lit := pdfUnescapeString(s[j+1 : k-1])
|
||||
if hasPrintableASCII(lit) {
|
||||
sb.WriteString(lit)
|
||||
}
|
||||
j = k
|
||||
continue
|
||||
}
|
||||
j++
|
||||
}
|
||||
// Check if this is a TJ operator (skip whitespace after ']').
|
||||
end := j + 1
|
||||
for end < n && (s[end] == ' ' || s[end] == '\t' || s[end] == '\r' || s[end] == '\n') {
|
||||
end++
|
||||
}
|
||||
if end+2 <= n && s[end:end+2] == "TJ" && (end+2 == n || !isAlphaNum(s[end+2])) {
|
||||
i = end + 2
|
||||
continue
|
||||
}
|
||||
i = j + 1
|
||||
continue
|
||||
}
|
||||
// Single string: (string) Tj
|
||||
if s[i] == '(' {
|
||||
j := i + 1
|
||||
depth := 1
|
||||
for j < n && depth > 0 {
|
||||
if s[j] == '\\' {
|
||||
j += 2
|
||||
continue
|
||||
}
|
||||
if s[j] == '(' {
|
||||
depth++
|
||||
} else if s[j] == ')' {
|
||||
depth--
|
||||
}
|
||||
j++
|
||||
}
|
||||
lit := pdfUnescapeString(s[i+1 : j-1])
|
||||
if hasPrintableASCII(lit) {
|
||||
// Check for Tj operator.
|
||||
end := j
|
||||
for end < n && (s[end] == ' ' || s[end] == '\t') {
|
||||
end++
|
||||
}
|
||||
if end+2 <= n && s[end:end+2] == "Tj" && (end+2 == n || !isAlphaNum(s[end+2])) {
|
||||
sb.WriteString(lit)
|
||||
i = end + 2
|
||||
continue
|
||||
}
|
||||
}
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
// Detect end of text object (ET) — add a newline.
|
||||
if i+2 <= n && s[i:i+2] == "ET" && (i+2 == n || !isAlphaNum(s[i+2])) {
|
||||
sb.WriteByte('\n')
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
// Detect Td / TD / T* — newline within text block.
|
||||
if i+2 <= n && (s[i:i+2] == "Td" || s[i:i+2] == "TD" || s[i:i+2] == "T*") &&
|
||||
(i+2 == n || !isAlphaNum(s[i+2])) {
|
||||
sb.WriteByte('\n')
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func isAlphaNum(b byte) bool {
|
||||
return (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9') || b == '_'
|
||||
}
|
||||
|
||||
func hasPrintableASCII(s string) bool {
|
||||
for _, c := range s {
|
||||
if c >= 0x20 && c < 0x7F {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pdfUnescapeString handles PDF string escape sequences.
|
||||
func pdfUnescapeString(s string) string {
|
||||
if !strings.ContainsRune(s, '\\') {
|
||||
return s
|
||||
}
|
||||
var sb strings.Builder
|
||||
i := 0
|
||||
for i < len(s) {
|
||||
if s[i] == '\\' && i+1 < len(s) {
|
||||
switch s[i+1] {
|
||||
case 'n':
|
||||
sb.WriteByte('\n')
|
||||
case 'r':
|
||||
sb.WriteByte('\r')
|
||||
case 't':
|
||||
sb.WriteByte('\t')
|
||||
case '(', ')', '\\':
|
||||
sb.WriteByte(s[i+1])
|
||||
default:
|
||||
// Octal escape \ddd
|
||||
if s[i+1] >= '0' && s[i+1] <= '7' {
|
||||
end := i + 2
|
||||
for end < i+5 && end < len(s) && s[end] >= '0' && s[end] <= '7' {
|
||||
end++
|
||||
}
|
||||
val, _ := strconv.ParseInt(s[i+1:end], 8, 16)
|
||||
sb.WriteByte(byte(val))
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
sb.WriteByte(s[i+1])
|
||||
}
|
||||
i += 2
|
||||
} else {
|
||||
sb.WriteByte(s[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ── EPUB parsing ──────────────────────────────────────────────────────────────
|
||||
|
||||
func parseEPUB(data []byte) ([]bookstore.Chapter, error) {
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open EPUB zip: %w", err)
|
||||
}
|
||||
|
||||
// 1. Read META-INF/container.xml → find rootfile (content.opf path).
|
||||
opfPath, err := epubRootfilePath(zr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("epub container: %w", err)
|
||||
}
|
||||
|
||||
// 2. Parse content.opf → spine order of chapter files.
|
||||
spineFiles, titleMap, err := epubSpine(zr, opfPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("epub spine: %w", err)
|
||||
}
|
||||
|
||||
if len(spineFiles) == 0 {
|
||||
return nil, fmt.Errorf("EPUB spine is empty")
|
||||
}
|
||||
|
||||
// Base directory of the OPF file for resolving relative hrefs.
|
||||
opfDir := ""
|
||||
if idx := strings.LastIndex(opfPath, "/"); idx >= 0 {
|
||||
opfDir = opfPath[:idx+1]
|
||||
}
|
||||
|
||||
var chapters []bookstore.Chapter
|
||||
for i, href := range spineFiles {
|
||||
fullPath := opfDir + href
|
||||
content, err := epubFileContent(zr, fullPath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
text := htmlToText(content)
|
||||
if strings.TrimSpace(text) == "" {
|
||||
continue
|
||||
}
|
||||
title := titleMap[href]
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("Chapter %d", i+1)
|
||||
}
|
||||
chapters = append(chapters, bookstore.Chapter{
|
||||
Number: i + 1,
|
||||
Title: title,
|
||||
Content: text,
|
||||
})
|
||||
}
|
||||
|
||||
if len(chapters) == 0 {
|
||||
return nil, fmt.Errorf("no readable chapters found in EPUB")
|
||||
}
|
||||
return chapters, nil
|
||||
}
|
||||
|
||||
// epubRootfilePath parses META-INF/container.xml and returns the full-path
|
||||
// of the OPF package document.
|
||||
func epubRootfilePath(zr *zip.Reader) (string, error) {
|
||||
f := zipFile(zr, "META-INF/container.xml")
|
||||
if f == nil {
|
||||
return "", fmt.Errorf("META-INF/container.xml not found")
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
doc, err := html.Parse(rc)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var path string
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && strings.EqualFold(n.Data, "rootfile") {
|
||||
for _, a := range n.Attr {
|
||||
if strings.EqualFold(a.Key, "full-path") {
|
||||
path = a.Val
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
|
||||
if path == "" {
|
||||
return "", fmt.Errorf("rootfile full-path not found in container.xml")
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// epubSpine parses the OPF document and returns the spine item hrefs in order,
|
||||
// plus a map from href → nav title (if available from NCX/NAV).
|
||||
func epubSpine(zr *zip.Reader, opfPath string) ([]string, map[string]string, error) {
|
||||
f := zipFile(zr, opfPath)
|
||||
if f == nil {
|
||||
return nil, nil, fmt.Errorf("OPF file %q not found in EPUB", opfPath)
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
opfData, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Build id→href map from <manifest>.
|
||||
idToHref := make(map[string]string)
|
||||
// Also keep a href→navTitle map (populated from NCX later).
|
||||
hrefTitle := make(map[string]string)
|
||||
|
||||
// Parse OPF XML with html.Parse (handles malformed XML too).
|
||||
doc, _ := html.Parse(bytes.NewReader(opfData))
|
||||
|
||||
var manifestItems []struct{ id, href, mediaType string }
|
||||
var spineIdrefs []string
|
||||
var ncxID string
|
||||
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode {
|
||||
tag := strings.ToLower(n.Data)
|
||||
switch tag {
|
||||
case "item":
|
||||
var id, href, mt string
|
||||
for _, a := range n.Attr {
|
||||
switch strings.ToLower(a.Key) {
|
||||
case "id":
|
||||
id = a.Val
|
||||
case "href":
|
||||
href = a.Val
|
||||
case "media-type":
|
||||
mt = a.Val
|
||||
}
|
||||
}
|
||||
if id != "" && href != "" {
|
||||
manifestItems = append(manifestItems, struct{ id, href, mediaType string }{id, href, mt})
|
||||
idToHref[id] = href
|
||||
}
|
||||
case "itemref":
|
||||
for _, a := range n.Attr {
|
||||
if strings.ToLower(a.Key) == "idref" {
|
||||
spineIdrefs = append(spineIdrefs, a.Val)
|
||||
}
|
||||
}
|
||||
case "spine":
|
||||
for _, a := range n.Attr {
|
||||
if strings.ToLower(a.Key) == "toc" {
|
||||
ncxID = a.Val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
|
||||
// Build ordered spine href list.
|
||||
var spineHrefs []string
|
||||
for _, idref := range spineIdrefs {
|
||||
if href, ok := idToHref[idref]; ok {
|
||||
spineHrefs = append(spineHrefs, href)
|
||||
}
|
||||
}
|
||||
|
||||
// If no explicit spine, fall back to all XHTML items in manifest order.
|
||||
if len(spineHrefs) == 0 {
|
||||
sort.Slice(manifestItems, func(i, j int) bool {
|
||||
return manifestItems[i].href < manifestItems[j].href
|
||||
})
|
||||
for _, it := range manifestItems {
|
||||
mt := strings.ToLower(it.mediaType)
|
||||
if strings.Contains(mt, "html") || strings.HasSuffix(strings.ToLower(it.href), ".html") || strings.HasSuffix(strings.ToLower(it.href), ".xhtml") {
|
||||
spineHrefs = append(spineHrefs, it.href)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to get chapter titles from NCX (toc.ncx).
|
||||
opfDir := ""
|
||||
if idx := strings.LastIndex(opfPath, "/"); idx >= 0 {
|
||||
opfDir = opfPath[:idx+1]
|
||||
}
|
||||
if ncxHref, ok := idToHref[ncxID]; ok {
|
||||
ncxPath := opfDir + ncxHref
|
||||
if ncxFile := zipFile(zr, ncxPath); ncxFile != nil {
|
||||
if ncxRC, err := ncxFile.Open(); err == nil {
|
||||
defer ncxRC.Close()
|
||||
parseNCXTitles(ncxRC, hrefTitle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return spineHrefs, hrefTitle, nil
|
||||
}
|
||||
|
||||
// parseNCXTitles extracts navPoint label→src mappings from a toc.ncx.
|
||||
func parseNCXTitles(r io.Reader, out map[string]string) {
|
||||
doc, err := html.Parse(r)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Collect navPoints: each has a <navLabel><text>…</text></navLabel> and
|
||||
// a <content src="…"/> child.
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && strings.EqualFold(n.Data, "navpoint") {
|
||||
var label, src string
|
||||
var inner func(*html.Node)
|
||||
inner = func(c *html.Node) {
|
||||
if c.Type == html.ElementNode {
|
||||
if strings.EqualFold(c.Data, "text") && label == "" {
|
||||
if c.FirstChild != nil && c.FirstChild.Type == html.TextNode {
|
||||
label = strings.TrimSpace(c.FirstChild.Data)
|
||||
}
|
||||
}
|
||||
if strings.EqualFold(c.Data, "content") {
|
||||
for _, a := range c.Attr {
|
||||
if strings.EqualFold(a.Key, "src") {
|
||||
// Strip fragment identifier (#...).
|
||||
src = strings.SplitN(a.Val, "#", 2)[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for child := c.FirstChild; child != nil; child = child.NextSibling {
|
||||
inner(child)
|
||||
}
|
||||
}
|
||||
inner(n)
|
||||
if label != "" && src != "" {
|
||||
out[src] = label
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
}
|
||||
|
||||
// epubFileContent returns the raw bytes of a file inside the EPUB zip.
|
||||
func epubFileContent(zr *zip.Reader, path string) ([]byte, error) {
|
||||
f := zipFile(zr, path)
|
||||
if f == nil {
|
||||
return nil, fmt.Errorf("file %q not in EPUB", path)
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// zipFile finds a file by name (case-insensitive) in a zip.Reader.
|
||||
func zipFile(zr *zip.Reader, name string) *zip.File {
|
||||
nameLower := strings.ToLower(name)
|
||||
for _, f := range zr.File {
|
||||
if strings.ToLower(f.Name) == nameLower {
|
||||
return f
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// htmlToText converts HTML/XHTML content to plain text suitable for storage.
|
||||
func htmlToText(data []byte) string {
|
||||
doc, err := html.Parse(bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return string(data)
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.TextNode {
|
||||
text := strings.TrimSpace(n.Data)
|
||||
if text != "" {
|
||||
sb.WriteString(text)
|
||||
sb.WriteByte(' ')
|
||||
}
|
||||
}
|
||||
if n.Type == html.ElementNode {
|
||||
switch strings.ToLower(n.Data) {
|
||||
case "p", "div", "br", "h1", "h2", "h3", "h4", "h5", "h6", "li", "tr":
|
||||
// Block-level: ensure newline before content.
|
||||
if sb.Len() > 0 {
|
||||
s := sb.String()
|
||||
if s[len(s)-1] != '\n' {
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
case "script", "style", "head":
|
||||
// Skip entirely.
|
||||
return
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
if n.Type == html.ElementNode {
|
||||
switch strings.ToLower(n.Data) {
|
||||
case "p", "div", "h1", "h2", "h3", "h4", "h5", "h6", "li", "tr":
|
||||
sb.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
|
||||
// Collapse multiple blank lines.
|
||||
lines := strings.Split(sb.String(), "\n")
|
||||
var out []string
|
||||
blanks := 0
|
||||
for _, l := range lines {
|
||||
l = strings.TrimSpace(l)
|
||||
if l == "" {
|
||||
blanks++
|
||||
if blanks <= 1 {
|
||||
out = append(out, "")
|
||||
}
|
||||
} else {
|
||||
blanks = 0
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(out, "\n"))
|
||||
}
|
||||
|
||||
// ── Chapter segmentation (shared by PDF and plain-text paths) ─────────────────
|
||||
|
||||
// extractChaptersFromText splits a block of plain text into chapters by
|
||||
// detecting heading lines that match chapterHeadingRE.
|
||||
// Falls back to paragraph-splitting when no headings are found.
|
||||
func extractChaptersFromText(text string) []bookstore.Chapter {
|
||||
lines := strings.Split(text, "\n")
|
||||
|
||||
type segment struct {
|
||||
title string
|
||||
number int
|
||||
lines []string
|
||||
}
|
||||
|
||||
var segments []segment
|
||||
var cur *segment
|
||||
chNum := 0
|
||||
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if chapterHeadingRE.MatchString(line) {
|
||||
if cur != nil {
|
||||
segments = append(segments, *cur)
|
||||
}
|
||||
chNum++
|
||||
// Try to parse the explicit chapter number from the heading.
|
||||
if m := regexp.MustCompile(`\d+`).FindString(line); m != "" {
|
||||
if n, err := strconv.Atoi(m); err == nil && n > 0 && n < 100000 {
|
||||
chNum = n
|
||||
}
|
||||
}
|
||||
cur = &segment{title: line, number: chNum}
|
||||
} else if cur != nil && line != "" {
|
||||
cur.lines = append(cur.lines, line)
|
||||
}
|
||||
}
|
||||
if cur != nil {
|
||||
segments = append(segments, *cur)
|
||||
}
|
||||
|
||||
// Require segments to have meaningful content (>= 100 chars).
|
||||
var chapters []bookstore.Chapter
|
||||
for _, seg := range segments {
|
||||
content := strings.Join(seg.lines, "\n")
|
||||
if len(strings.TrimSpace(content)) < 50 {
|
||||
continue
|
||||
}
|
||||
chapters = append(chapters, bookstore.Chapter{
|
||||
Number: seg.number,
|
||||
Title: seg.title,
|
||||
Content: content,
|
||||
})
|
||||
}
|
||||
|
||||
// Fallback: no headings found — split by double newlines (paragraph blocks).
|
||||
if len(chapters) == 0 {
|
||||
paragraphs := strings.Split(text, "\n\n")
|
||||
n := 0
|
||||
for _, para := range paragraphs {
|
||||
para = strings.TrimSpace(para)
|
||||
if len(para) > 100 {
|
||||
n++
|
||||
chapters = append(chapters, bookstore.Chapter{
|
||||
Number: n,
|
||||
Title: fmt.Sprintf("Chapter %d", n),
|
||||
Content: para,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return chapters
|
||||
}
|
||||
|
||||
// ── Chapter ingestion ─────────────────────────────────────────────────────────
|
||||
|
||||
// IngestChapters stores extracted chapters for a book.
|
||||
// Each chapter is written as a markdown file in the chapters MinIO bucket
|
||||
// and its index record is upserted in PocketBase via WriteChapter.
|
||||
func (s *Store) IngestChapters(ctx context.Context, slug string, chapters []bookstore.Chapter) error {
|
||||
for _, ch := range chapters {
|
||||
var mdContent string
|
||||
if ch.Title != "" && ch.Title != fmt.Sprintf("Chapter %d", ch.Number) {
|
||||
mdContent = fmt.Sprintf("# %s\n\n%s", ch.Title, ch.Content)
|
||||
} else {
|
||||
mdContent = fmt.Sprintf("# Chapter %d\n\n%s", ch.Number, ch.Content)
|
||||
}
|
||||
domainCh := domain.Chapter{
|
||||
Ref: domain.ChapterRef{Number: ch.Number, Title: ch.Title},
|
||||
Text: mdContent,
|
||||
}
|
||||
if err := s.WriteChapter(ctx, slug, domainCh); err != nil {
|
||||
return fmt.Errorf("ingest chapter %d: %w", ch.Number, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetImportObjectKey returns the MinIO object key for an uploaded import file.
|
||||
func GetImportObjectKey(filename string) string {
|
||||
return fmt.Sprintf("imports/%s", filename)
|
||||
}
|
||||
@@ -647,6 +647,118 @@ func (s *Store) CreateTranslationTask(ctx context.Context, slug string, chapter
|
||||
return rec.ID, nil
|
||||
}
|
||||
|
||||
func (s *Store) CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error) {
|
||||
payload := map[string]any{
|
||||
"slug": task.Slug,
|
||||
"title": task.Title,
|
||||
"file_name": task.Slug + "." + task.FileType,
|
||||
"file_type": task.FileType,
|
||||
"object_key": task.ObjectKey,
|
||||
"chapters_key": task.ChaptersKey,
|
||||
"author": task.Author,
|
||||
"cover_url": task.CoverURL,
|
||||
"summary": task.Summary,
|
||||
"book_status": task.BookStatus,
|
||||
"status": string(domain.TaskStatusPending),
|
||||
"chapters_done": 0,
|
||||
"chapters_total": task.ChaptersTotal,
|
||||
"started": time.Now().UTC().Format(time.RFC3339),
|
||||
"initiator_user_id": task.InitiatorUserID,
|
||||
}
|
||||
if len(task.Genres) > 0 {
|
||||
payload["genres"] = strings.Join(task.Genres, ",")
|
||||
}
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if err := s.pb.post(ctx, "/api/collections/import_tasks/records", payload, &rec); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return rec.ID, nil
|
||||
}
|
||||
|
||||
// CreateNotification creates a notification record in PocketBase.
|
||||
func (s *Store) CreateNotification(ctx context.Context, userID, title, message, link string) error {
|
||||
payload := map[string]any{
|
||||
"user_id": userID,
|
||||
"title": title,
|
||||
"message": message,
|
||||
"link": link,
|
||||
"read": false,
|
||||
"created": time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
return s.pb.post(ctx, "/api/collections/notifications/records", payload, nil)
|
||||
}
|
||||
|
||||
// ListNotifications returns notifications for a user.
|
||||
func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]map[string]any, error) {
|
||||
filter := fmt.Sprintf("user_id='%s'", userID)
|
||||
items, err := s.pb.listAll(ctx, "notifications", filter, "-created")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Parse each json.RawMessage into a map
|
||||
results := make([]map[string]any, 0, len(items))
|
||||
for _, raw := range items {
|
||||
var m map[string]any
|
||||
if json.Unmarshal(raw, &m) == nil {
|
||||
results = append(results, m)
|
||||
}
|
||||
}
|
||||
if limit > 0 && len(results) > limit {
|
||||
results = results[:limit]
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// MarkNotificationRead marks a notification as read.
|
||||
func (s *Store) MarkNotificationRead(ctx context.Context, id string) error {
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", id),
|
||||
map[string]any{"read": true})
|
||||
}
|
||||
|
||||
// DeleteNotification deletes a single notification by ID.
|
||||
func (s *Store) DeleteNotification(ctx context.Context, id string) error {
|
||||
return s.pb.delete(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", id))
|
||||
}
|
||||
|
||||
// ClearAllNotifications deletes all notifications for a user.
|
||||
func (s *Store) ClearAllNotifications(ctx context.Context, userID string) error {
|
||||
filter := fmt.Sprintf("user_id='%s'", userID)
|
||||
items, err := s.pb.listAll(ctx, "notifications", filter, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("ClearAllNotifications list: %w", err)
|
||||
}
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||
_ = s.pb.delete(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", rec.ID))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkAllNotificationsRead marks all notifications for a user as read.
|
||||
func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error {
|
||||
filter := fmt.Sprintf("user_id='%s'&&read=false", userID)
|
||||
items, err := s.pb.listAll(ctx, "notifications", filter, "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("MarkAllNotificationsRead list: %w", err)
|
||||
}
|
||||
for _, raw := range items {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if json.Unmarshal(raw, &rec) == nil && rec.ID != "" {
|
||||
_ = s.pb.patch(ctx, fmt.Sprintf("/api/collections/notifications/records/%s", rec.ID),
|
||||
map[string]any{"read": true})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) CancelTask(ctx context.Context, id string) error {
|
||||
// Try scraping_tasks first, then audio_jobs, then translation_jobs.
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/scraping_tasks/records/%s", id),
|
||||
@@ -721,6 +833,18 @@ func (s *Store) ClaimNextTranslationTask(ctx context.Context, workerID string) (
|
||||
return task, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) ClaimNextImportTask(ctx context.Context, workerID string) (domain.ImportTask, bool, error) {
|
||||
raw, err := s.pb.claimRecord(ctx, "import_tasks", workerID, nil)
|
||||
if err != nil {
|
||||
return domain.ImportTask{}, false, err
|
||||
}
|
||||
if raw == nil {
|
||||
return domain.ImportTask{}, false, nil
|
||||
}
|
||||
task, err := parseImportTask(raw)
|
||||
return task, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error {
|
||||
status := string(domain.TaskStatusDone)
|
||||
if result.ErrorMessage != "" {
|
||||
@@ -761,6 +885,20 @@ func (s *Store) FinishTranslationTask(ctx context.Context, id string, result dom
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) FinishImportTask(ctx context.Context, id string, result domain.ImportResult) error {
|
||||
status := string(domain.TaskStatusDone)
|
||||
if result.ErrorMessage != "" {
|
||||
status = string(domain.TaskStatusFailed)
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/import_tasks/records/%s", id), map[string]any{
|
||||
"status": status,
|
||||
"chapters_done": result.ChaptersImported,
|
||||
"chapters_total": result.ChaptersImported,
|
||||
"error_message": result.ErrorMessage,
|
||||
"finished": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
|
||||
payload := map[string]any{
|
||||
"status": string(domain.TaskStatusFailed),
|
||||
@@ -777,7 +915,7 @@ func (s *Store) FailTask(ctx context.Context, id, errMsg string) error {
|
||||
}
|
||||
|
||||
// HeartbeatTask updates the heartbeat_at field on a running task.
|
||||
// Tries scraping_tasks first, then audio_jobs, then translation_jobs.
|
||||
// Tries scraping_tasks, audio_jobs, translation_jobs, then import_tasks.
|
||||
func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
|
||||
payload := map[string]any{
|
||||
"heartbeat_at": time.Now().UTC().Format(time.RFC3339),
|
||||
@@ -788,7 +926,10 @@ func (s *Store) HeartbeatTask(ctx context.Context, id string) error {
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/audio_jobs/records/%s", id), payload); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload)
|
||||
if err := s.pb.patch(ctx, fmt.Sprintf("/api/collections/translation_jobs/records/%s", id), payload); err == nil {
|
||||
return nil
|
||||
}
|
||||
return s.pb.patch(ctx, fmt.Sprintf("/api/collections/import_tasks/records/%s", id), payload)
|
||||
}
|
||||
|
||||
// ReapStaleTasks finds all running tasks whose heartbeat_at is either missing
|
||||
@@ -806,7 +947,7 @@ func (s *Store) ReapStaleTasks(ctx context.Context, staleAfter time.Duration) (i
|
||||
}
|
||||
|
||||
total := 0
|
||||
for _, collection := range []string{"scraping_tasks", "audio_jobs", "translation_jobs"} {
|
||||
for _, collection := range []string{"scraping_tasks", "audio_jobs", "translation_jobs", "import_tasks"} {
|
||||
items, err := s.pb.listAll(ctx, collection, filter, "")
|
||||
if err != nil {
|
||||
return total, fmt.Errorf("ReapStaleTasks list %s: %w", collection, err)
|
||||
@@ -899,8 +1040,7 @@ func (s *Store) ListTranslationTasks(ctx context.Context) ([]domain.TranslationT
|
||||
}
|
||||
|
||||
func (s *Store) GetTranslationTask(ctx context.Context, cacheKey string) (domain.TranslationTask, bool, error) {
|
||||
filter := fmt.Sprintf(`cache_key='%s'`, cacheKey)
|
||||
items, err := s.pb.listAll(ctx, "translation_jobs", filter, "-started")
|
||||
items, err := s.pb.listAll(ctx, "translation_jobs", fmt.Sprintf("cache_key=%q", cacheKey), "-started")
|
||||
if err != nil || len(items) == 0 {
|
||||
return domain.TranslationTask{}, false, err
|
||||
}
|
||||
@@ -908,6 +1048,33 @@ func (s *Store) GetTranslationTask(ctx context.Context, cacheKey string) (domain
|
||||
return t, err == nil, err
|
||||
}
|
||||
|
||||
func (s *Store) ListImportTasks(ctx context.Context) ([]domain.ImportTask, error) {
|
||||
items, err := s.pb.listAll(ctx, "import_tasks", "", "-started")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tasks := make([]domain.ImportTask, 0, len(items))
|
||||
for _, raw := range items {
|
||||
t, err := parseImportTask(raw)
|
||||
if err == nil {
|
||||
tasks = append(tasks, t)
|
||||
}
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetImportTask(ctx context.Context, id string) (domain.ImportTask, bool, error) {
|
||||
var raw json.RawMessage
|
||||
if err := s.pb.get(ctx, fmt.Sprintf("/api/collections/import_tasks/records/%s", id), &raw); err != nil {
|
||||
if err == ErrNotFound {
|
||||
return domain.ImportTask{}, false, nil
|
||||
}
|
||||
return domain.ImportTask{}, false, err
|
||||
}
|
||||
t, err := parseImportTask(raw)
|
||||
return t, err == nil, err
|
||||
}
|
||||
|
||||
// ── Parsers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func parseScrapeTask(raw json.RawMessage) (domain.ScrapeTask, error) {
|
||||
@@ -1014,6 +1181,66 @@ func parseTranslationTask(raw json.RawMessage) (domain.TranslationTask, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func parseImportTask(raw json.RawMessage) (domain.ImportTask, error) {
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
ChaptersKey string `json:"chapters_key"`
|
||||
Author string `json:"author"`
|
||||
CoverURL string `json:"cover_url"`
|
||||
Genres string `json:"genres"` // stored as comma-separated
|
||||
Summary string `json:"summary"`
|
||||
BookStatus string `json:"book_status"`
|
||||
WorkerID string `json:"worker_id"`
|
||||
InitiatorUserID string `json:"initiator_user_id"`
|
||||
Status string `json:"status"`
|
||||
ChaptersDone int `json:"chapters_done"`
|
||||
ChaptersTotal int `json:"chapters_total"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
Started string `json:"started"`
|
||||
Finished string `json:"finished"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &rec); err != nil {
|
||||
return domain.ImportTask{}, err
|
||||
}
|
||||
started, _ := time.Parse(time.RFC3339, rec.Started)
|
||||
finished, _ := time.Parse(time.RFC3339, rec.Finished)
|
||||
var genres []string
|
||||
if rec.Genres != "" {
|
||||
for _, g := range strings.Split(rec.Genres, ",") {
|
||||
if g = strings.TrimSpace(g); g != "" {
|
||||
genres = append(genres, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
return domain.ImportTask{
|
||||
ID: rec.ID,
|
||||
Slug: rec.Slug,
|
||||
Title: rec.Title,
|
||||
FileName: rec.FileName,
|
||||
FileType: rec.FileType,
|
||||
ObjectKey: rec.ObjectKey,
|
||||
ChaptersKey: rec.ChaptersKey,
|
||||
Author: rec.Author,
|
||||
CoverURL: rec.CoverURL,
|
||||
Genres: genres,
|
||||
Summary: rec.Summary,
|
||||
BookStatus: rec.BookStatus,
|
||||
WorkerID: rec.WorkerID,
|
||||
InitiatorUserID: rec.InitiatorUserID,
|
||||
Status: domain.TaskStatus(rec.Status),
|
||||
ChaptersDone: rec.ChaptersDone,
|
||||
ChaptersTotal: rec.ChaptersTotal,
|
||||
ErrorMessage: rec.ErrorMessage,
|
||||
Started: started,
|
||||
Finished: finished,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ── CoverStore ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func (s *Store) PutCover(ctx context.Context, slug string, data []byte, contentType string) error {
|
||||
@@ -1040,6 +1267,25 @@ func (s *Store) GetCover(ctx context.Context, slug string) ([]byte, string, bool
|
||||
return data, ct, true, nil
|
||||
}
|
||||
|
||||
// PutImportFile stores an uploaded import file (PDF/EPUB) in MinIO.
|
||||
func (s *Store) PutImportFile(ctx context.Context, key string, data []byte) error {
|
||||
return s.mc.putObject(ctx, "imports", key, "application/octet-stream", data)
|
||||
}
|
||||
|
||||
// PutImportChapters stores a pre-parsed chapters JSON blob in MinIO.
|
||||
func (s *Store) PutImportChapters(ctx context.Context, key string, data []byte) error {
|
||||
return s.mc.putObject(ctx, "imports", key, "application/json", data)
|
||||
}
|
||||
|
||||
// GetImportChapters retrieves the pre-parsed chapters JSON from MinIO.
|
||||
func (s *Store) GetImportChapters(ctx context.Context, key string) ([]byte, error) {
|
||||
data, err := s.mc.getObject(ctx, "imports", key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get chapters object: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (s *Store) CoverExists(ctx context.Context, slug string) bool {
|
||||
return s.mc.coverExists(ctx, CoverObjectKey(slug))
|
||||
}
|
||||
|
||||
@@ -33,6 +33,11 @@ type Producer interface {
|
||||
// returns the assigned PocketBase record ID.
|
||||
CreateTranslationTask(ctx context.Context, slug string, chapter int, lang string) (string, error)
|
||||
|
||||
// CreateImportTask inserts a new import task with status=pending and
|
||||
// returns the assigned PocketBase record ID.
|
||||
// The task struct must have at minimum Slug, Title, FileType, and ObjectKey set.
|
||||
CreateImportTask(ctx context.Context, task domain.ImportTask) (string, error)
|
||||
|
||||
// CancelTask transitions a pending task to status=cancelled.
|
||||
// Returns ErrNotFound if the task does not exist.
|
||||
CancelTask(ctx context.Context, id string) error
|
||||
@@ -59,6 +64,11 @@ type Consumer interface {
|
||||
// Returns (zero, false, nil) when the queue is empty.
|
||||
ClaimNextTranslationTask(ctx context.Context, workerID string) (domain.TranslationTask, bool, error)
|
||||
|
||||
// ClaimNextImportTask atomically finds the oldest pending import task,
|
||||
// sets its status=running and worker_id=workerID, and returns it.
|
||||
// Returns (zero, false, nil) when the queue is empty.
|
||||
ClaimNextImportTask(ctx context.Context, workerID string) (domain.ImportTask, bool, error)
|
||||
|
||||
// FinishScrapeTask marks a running scrape task as done and records the result.
|
||||
FinishScrapeTask(ctx context.Context, id string, result domain.ScrapeResult) error
|
||||
|
||||
@@ -68,6 +78,9 @@ type Consumer interface {
|
||||
// FinishTranslationTask marks a running translation task as done and records the result.
|
||||
FinishTranslationTask(ctx context.Context, id string, result domain.TranslationResult) error
|
||||
|
||||
// FinishImportTask marks a running import task as done and records the result.
|
||||
FinishImportTask(ctx context.Context, id string, result domain.ImportResult) error
|
||||
|
||||
// FailTask marks a task (scrape, audio, or translation) as failed with an error message.
|
||||
FailTask(ctx context.Context, id, errMsg string) error
|
||||
|
||||
@@ -104,4 +117,11 @@ type Reader interface {
|
||||
// GetTranslationTask returns the most recent translation task for cacheKey.
|
||||
// Returns (zero, false, nil) if not found.
|
||||
GetTranslationTask(ctx context.Context, cacheKey string) (domain.TranslationTask, bool, error)
|
||||
|
||||
// ListImportTasks returns all import tasks sorted by started descending.
|
||||
ListImportTasks(ctx context.Context) ([]domain.ImportTask, error)
|
||||
|
||||
// GetImportTask returns a single import task by ID.
|
||||
// Returns (zero, false, nil) if not found.
|
||||
GetImportTask(ctx context.Context, id string) (domain.ImportTask, bool, error)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ func (s *stubStore) CreateAudioTask(_ context.Context, _ string, _ int, _ string
|
||||
func (s *stubStore) CreateTranslationTask(_ context.Context, _ string, _ int, _ string) (string, error) {
|
||||
return "translation-1", nil
|
||||
}
|
||||
func (s *stubStore) CreateImportTask(_ context.Context, _ domain.ImportTask) (string, error) {
|
||||
return "import-1", nil
|
||||
}
|
||||
func (s *stubStore) CancelTask(_ context.Context, _ string) error { return nil }
|
||||
func (s *stubStore) CancelAudioTasksBySlug(_ context.Context, _ string) (int, error) { return 0, nil }
|
||||
|
||||
@@ -38,6 +41,9 @@ func (s *stubStore) ClaimNextAudioTask(_ context.Context, _ string) (domain.Audi
|
||||
func (s *stubStore) ClaimNextTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
|
||||
return domain.TranslationTask{ID: "translation-1", Status: domain.TaskStatusRunning}, true, nil
|
||||
}
|
||||
func (s *stubStore) ClaimNextImportTask(_ context.Context, _ string) (domain.ImportTask, bool, error) {
|
||||
return domain.ImportTask{ID: "import-1", Status: domain.TaskStatusRunning}, true, nil
|
||||
}
|
||||
func (s *stubStore) FinishScrapeTask(_ context.Context, _ string, _ domain.ScrapeResult) error {
|
||||
return nil
|
||||
}
|
||||
@@ -47,6 +53,9 @@ func (s *stubStore) FinishAudioTask(_ context.Context, _ string, _ domain.AudioR
|
||||
func (s *stubStore) FinishTranslationTask(_ context.Context, _ string, _ domain.TranslationResult) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubStore) FinishImportTask(_ context.Context, _ string, _ domain.ImportResult) error {
|
||||
return nil
|
||||
}
|
||||
func (s *stubStore) FailTask(_ context.Context, _, _ string) error { return nil }
|
||||
|
||||
func (s *stubStore) HeartbeatTask(_ context.Context, _ string) error { return nil }
|
||||
@@ -69,6 +78,10 @@ func (s *stubStore) ListTranslationTasks(_ context.Context) ([]domain.Translatio
|
||||
func (s *stubStore) GetTranslationTask(_ context.Context, _ string) (domain.TranslationTask, bool, error) {
|
||||
return domain.TranslationTask{}, false, nil
|
||||
}
|
||||
func (s *stubStore) ListImportTasks(_ context.Context) ([]domain.ImportTask, error) { return nil, nil }
|
||||
func (s *stubStore) GetImportTask(_ context.Context, _ string) (domain.ImportTask, bool, error) {
|
||||
return domain.ImportTask{}, false, nil
|
||||
}
|
||||
|
||||
// Verify the stub satisfies all three interfaces at compile time.
|
||||
var _ taskqueue.Producer = (*stubStore)(nil)
|
||||
|
||||
@@ -58,6 +58,8 @@ services:
|
||||
mc mb --ignore-existing local/audio;
|
||||
mc mb --ignore-existing local/avatars;
|
||||
mc mb --ignore-existing local/catalogue;
|
||||
mc mb --ignore-existing local/translations;
|
||||
mc mb --ignore-existing local/imports;
|
||||
echo 'buckets ready';
|
||||
"
|
||||
environment:
|
||||
@@ -307,6 +309,8 @@ services:
|
||||
# OpenTelemetry tracing
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: "${OTEL_EXPORTER_OTLP_ENDPOINT}"
|
||||
OTEL_SERVICE_NAME: "ui"
|
||||
# Allow large PDF/EPUB uploads (adapter-node default is 512KB)
|
||||
BODY_SIZE_LIMIT: "52428800"
|
||||
# OAuth2 providers
|
||||
GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}"
|
||||
GOOGLE_CLIENT_SECRET: "${GOOGLE_CLIENT_SECRET}"
|
||||
|
||||
@@ -299,6 +299,40 @@ create "translation_jobs" '{
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "import_tasks" '{
|
||||
"name":"import_tasks","type":"base","fields":[
|
||||
{"name":"slug", "type":"text", "required":true},
|
||||
{"name":"title", "type":"text", "required":true},
|
||||
{"name":"file_name", "type":"text"},
|
||||
{"name":"file_type", "type":"text"},
|
||||
{"name":"object_key", "type":"text"},
|
||||
{"name":"chapters_key", "type":"text"},
|
||||
{"name":"author", "type":"text"},
|
||||
{"name":"cover_url", "type":"text"},
|
||||
{"name":"genres", "type":"text"},
|
||||
{"name":"summary", "type":"text"},
|
||||
{"name":"book_status", "type":"text"},
|
||||
{"name":"worker_id", "type":"text"},
|
||||
{"name":"initiator_user_id", "type":"text"},
|
||||
{"name":"status", "type":"text", "required":true},
|
||||
{"name":"chapters_done", "type":"number"},
|
||||
{"name":"chapters_total", "type":"number"},
|
||||
{"name":"error_message", "type":"text"},
|
||||
{"name":"started", "type":"date"},
|
||||
{"name":"finished", "type":"date"},
|
||||
{"name":"heartbeat_at", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "notifications" '{
|
||||
"name":"notifications","type":"base","fields":[
|
||||
{"name":"user_id", "type":"text","required":true},
|
||||
{"name":"title", "type":"text","required":true},
|
||||
{"name":"message", "type":"text"},
|
||||
{"name":"link", "type":"text"},
|
||||
{"name":"read", "type":"bool"},
|
||||
{"name":"created", "type":"date"}
|
||||
]}'
|
||||
|
||||
create "ai_jobs" '{
|
||||
"name":"ai_jobs","type":"base","fields":[
|
||||
{"name":"kind", "type":"text", "required":true},
|
||||
|
||||
@@ -402,11 +402,13 @@
|
||||
"admin_nav_scrape": "Scrape",
|
||||
"admin_nav_audio": "Audio",
|
||||
"admin_nav_translation": "Translation",
|
||||
"admin_nav_import": "Import",
|
||||
"admin_nav_changelog": "Changelog",
|
||||
"admin_nav_image_gen": "Image Gen",
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "AI Jobs",
|
||||
"admin_nav_notifications": "Notifications",
|
||||
"admin_nav_feedback": "Feedback",
|
||||
"admin_nav_errors": "Errors",
|
||||
"admin_nav_analytics": "Analytics",
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"nav_library": "Bibliothèque",
|
||||
"nav_catalogue": "Catalogue",
|
||||
"nav_feed": "Fil",
|
||||
@@ -11,7 +10,6 @@
|
||||
"nav_sign_out": "Déconnexion",
|
||||
"nav_toggle_menu": "Menu",
|
||||
"nav_admin_panel": "Panneau admin",
|
||||
|
||||
"footer_library": "Bibliothèque",
|
||||
"footer_catalogue": "Catalogue",
|
||||
"footer_feedback": "Retour",
|
||||
@@ -20,7 +18,6 @@
|
||||
"footer_dmca": "DMCA",
|
||||
"footer_copyright": "© {year} libnovel",
|
||||
"footer_dev": "dev",
|
||||
|
||||
"home_title": "libnovel",
|
||||
"home_stat_books": "Livres",
|
||||
"home_stat_chapters": "Chapitres",
|
||||
@@ -34,7 +31,6 @@
|
||||
"home_discover_novels": "Découvrir des romans",
|
||||
"home_via_reader": "via {username}",
|
||||
"home_chapter_badge": "ch.{n}",
|
||||
|
||||
"player_generating": "Génération… {percent}%",
|
||||
"player_loading": "Chargement…",
|
||||
"player_chapters": "Chapitres",
|
||||
@@ -58,7 +54,6 @@
|
||||
"player_auto_next_aria": "Suivant auto {state}",
|
||||
"player_go_to_chapter": "Aller au chapitre",
|
||||
"player_close": "Fermer le lecteur",
|
||||
|
||||
"login_page_title": "Connexion — libnovel",
|
||||
"login_heading": "Se connecter à libnovel",
|
||||
"login_subheading": "Choisissez un fournisseur pour continuer",
|
||||
@@ -68,7 +63,6 @@
|
||||
"login_error_oauth_state": "Connexion annulée ou expirée. Veuillez réessayer.",
|
||||
"login_error_oauth_failed": "Impossible de se connecter au fournisseur. Veuillez réessayer.",
|
||||
"login_error_oauth_no_email": "Votre compte n'a pas d'adresse e-mail vérifiée. Ajoutez-en une et réessayez.",
|
||||
|
||||
"books_page_title": "Bibliothèque — libnovel",
|
||||
"books_heading": "Votre bibliothèque",
|
||||
"books_empty_title": "Aucun livre pour l'instant",
|
||||
@@ -78,7 +72,6 @@
|
||||
"books_last_read": "Dernier lu : Ch.{n}",
|
||||
"books_reading_progress": "Ch.{current} / {total}",
|
||||
"books_remove": "Supprimer",
|
||||
|
||||
"catalogue_page_title": "Catalogue — libnovel",
|
||||
"catalogue_heading": "Catalogue",
|
||||
"catalogue_search_placeholder": "Rechercher des romans…",
|
||||
@@ -99,7 +92,6 @@
|
||||
"catalogue_loading": "Chargement…",
|
||||
"catalogue_load_more": "Charger plus",
|
||||
"catalogue_results_count": "{n} résultats",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_signin_to_save": "Connectez-vous pour sauvegarder",
|
||||
"book_detail_add_to_library": "Ajouter à la bibliothèque",
|
||||
@@ -116,13 +108,11 @@
|
||||
"book_detail_rescrape": "Réextraire",
|
||||
"book_detail_scraping": "Extraction en cours…",
|
||||
"book_detail_in_library": "Dans la bibliothèque",
|
||||
|
||||
"chapters_page_title": "Chapitres — {title}",
|
||||
"chapters_heading": "Chapitres",
|
||||
"chapters_back_to_book": "Retour au livre",
|
||||
"chapters_reading_now": "En cours de lecture",
|
||||
"chapters_empty": "Aucun chapitre extrait pour l'instant.",
|
||||
|
||||
"reader_page_title": "{title} — Ch.{n} — libnovel",
|
||||
"reader_play_narration": "Lire la narration",
|
||||
"reader_generating_audio": "Génération audio…",
|
||||
@@ -144,7 +134,6 @@
|
||||
"reader_auto_next": "Suivant auto",
|
||||
"reader_speed": "Vitesse",
|
||||
"reader_preview_notice": "Aperçu — ce chapitre n'a pas été entièrement extrait.",
|
||||
|
||||
"profile_page_title": "Profil — libnovel",
|
||||
"profile_heading": "Profil",
|
||||
"profile_avatar_label": "Avatar",
|
||||
@@ -179,7 +168,6 @@
|
||||
"profile_sessions_heading": "Sessions actives",
|
||||
"profile_sign_out_all": "Se déconnecter de tous les autres appareils",
|
||||
"profile_joined": "Inscrit le {date}",
|
||||
|
||||
"user_page_title": "{username} — libnovel",
|
||||
"user_library_heading": "Bibliothèque de {username}",
|
||||
"user_follow": "Suivre",
|
||||
@@ -187,13 +175,11 @@
|
||||
"user_followers": "{n} abonnés",
|
||||
"user_following": "{n} abonnements",
|
||||
"user_library_empty": "Aucun livre dans la bibliothèque.",
|
||||
|
||||
"error_not_found_title": "Page introuvable",
|
||||
"error_not_found_body": "La page que vous cherchez n'existe pas.",
|
||||
"error_generic_title": "Une erreur s'est produite",
|
||||
"error_go_home": "Accueil",
|
||||
"error_status": "Erreur {status}",
|
||||
|
||||
"admin_scrape_page_title": "Extraction — Admin",
|
||||
"admin_scrape_heading": "Extraction",
|
||||
"admin_scrape_catalogue": "Extraire le catalogue",
|
||||
@@ -211,14 +197,11 @@
|
||||
"admin_scrape_status_cancelled": "Annulé",
|
||||
"admin_tasks_heading": "Tâches récentes",
|
||||
"admin_tasks_empty": "Aucune tâche pour l'instant.",
|
||||
|
||||
"admin_audio_page_title": "Audio — Admin",
|
||||
"admin_audio_heading": "Tâches audio",
|
||||
"admin_audio_empty": "Aucune tâche audio.",
|
||||
|
||||
"admin_changelog_page_title": "Changelog — Admin",
|
||||
"admin_changelog_heading": "Changelog",
|
||||
|
||||
"comments_heading": "Commentaires",
|
||||
"comments_empty": "Aucun commentaire pour l'instant. Soyez le premier !",
|
||||
"comments_placeholder": "Écrire un commentaire…",
|
||||
@@ -232,12 +215,10 @@
|
||||
"comments_hide_replies": "Masquer les réponses",
|
||||
"comments_edited": "modifié",
|
||||
"comments_deleted": "[supprimé]",
|
||||
|
||||
"disclaimer_page_title": "Avertissement — libnovel",
|
||||
"privacy_page_title": "Politique de confidentialité — libnovel",
|
||||
"dmca_page_title": "DMCA — libnovel",
|
||||
"terms_page_title": "Conditions d'utilisation — libnovel",
|
||||
|
||||
"common_loading": "Chargement…",
|
||||
"common_error": "Erreur",
|
||||
"common_save": "Enregistrer",
|
||||
@@ -251,15 +232,12 @@
|
||||
"common_no": "Non",
|
||||
"common_on": "activé",
|
||||
"common_off": "désactivé",
|
||||
|
||||
"locale_switcher_label": "Langue",
|
||||
|
||||
"books_empty_library": "Votre bibliothèque est vide.",
|
||||
"books_empty_discover": "Les livres que vous commencez à lire ou enregistrez depuis",
|
||||
"books_empty_discover_link": "Découvrir",
|
||||
"books_empty_discover_suffix": "apparaîtront ici.",
|
||||
"books_count": "{n} livre{s}",
|
||||
|
||||
"catalogue_sort_updated": "Mis à jour",
|
||||
"catalogue_search_button": "Rechercher",
|
||||
"catalogue_refresh": "Actualiser",
|
||||
@@ -292,7 +270,6 @@
|
||||
"catalogue_scrape_forbidden_badge": "Interdit",
|
||||
"catalogue_scrape_novel_button": "Extraire",
|
||||
"catalogue_scraping_novel": "Extraction…",
|
||||
|
||||
"book_detail_not_in_library": "pas dans la bibliothèque",
|
||||
"book_detail_continue_ch": "Continuer ch.{n}",
|
||||
"book_detail_start_ch1": "Commencer au ch.1",
|
||||
@@ -328,18 +305,15 @@
|
||||
"book_detail_rescrape_book": "Réextraire le livre",
|
||||
"book_detail_less": "Moins",
|
||||
"book_detail_more": "Plus",
|
||||
|
||||
"chapters_search_placeholder": "Rechercher des chapitres…",
|
||||
"chapters_jump_to": "Aller au Ch.{n}",
|
||||
"chapters_no_match": "Aucun chapitre ne correspond à « {q} »",
|
||||
"chapters_none_available": "Aucun chapitre disponible pour l'instant.",
|
||||
"chapters_reading_indicator": "en cours",
|
||||
"chapters_result_count": "{n} résultats",
|
||||
|
||||
"reader_fetching_chapter": "Récupération du chapitre…",
|
||||
"reader_words": "{n} mots",
|
||||
"reader_preview_audio_notice": "Aperçu — audio non disponible pour les livres hors bibliothèque.",
|
||||
|
||||
"profile_click_to_change": "Cliquez sur l'avatar pour changer la photo",
|
||||
"profile_tts_voice": "Voix TTS",
|
||||
"profile_auto_advance": "Avancer automatiquement au chapitre suivant",
|
||||
@@ -357,7 +331,6 @@
|
||||
"profile_updating": "Mise à jour…",
|
||||
"profile_password_changed_ok": "Mot de passe modifié avec succès.",
|
||||
"profile_playback_speed": "Vitesse de lecture — {speed}x",
|
||||
|
||||
"profile_subscription_heading": "Abonnement",
|
||||
"profile_plan_pro": "Pro",
|
||||
"profile_plan_free": "Gratuit",
|
||||
@@ -369,7 +342,7 @@
|
||||
"profile_upgrade_monthly": "Mensuel — 6 $ / mois",
|
||||
"profile_upgrade_annual": "Annuel — 48 $ / an",
|
||||
"profile_free_limits": "Plan gratuit : 3 chapitres audio par jour, lecture en anglais uniquement.",
|
||||
"subscribe_page_title": "Passer Pro \u2014 libnovel",
|
||||
"subscribe_page_title": "Passer Pro — libnovel",
|
||||
"subscribe_heading": "Lisez plus. Écoutez plus.",
|
||||
"subscribe_subheading": "Passez Pro et débloquez l'expérience libnovel complète.",
|
||||
"subscribe_monthly_label": "Mensuel",
|
||||
@@ -389,14 +362,12 @@
|
||||
"subscribe_benefit_downloads": "Télécharger des chapitres pour une écoute hors ligne",
|
||||
"subscribe_login_prompt": "Connectez-vous pour vous abonner",
|
||||
"subscribe_login_cta": "Se connecter",
|
||||
|
||||
"user_currently_reading": "En cours de lecture",
|
||||
"user_library_count": "Bibliothèque ({n})",
|
||||
"user_joined": "Inscrit le {date}",
|
||||
"user_followers_label": "abonnés",
|
||||
"user_following_label": "abonnements",
|
||||
"user_no_books": "Aucun livre dans la bibliothèque pour l'instant.",
|
||||
|
||||
"admin_pages_label": "Pages",
|
||||
"admin_tools_label": "Outils",
|
||||
"admin_nav_scrape": "Scrape",
|
||||
@@ -407,12 +378,12 @@
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Tâches IA",
|
||||
"admin_nav_notifications": "Notifications",
|
||||
"admin_nav_errors": "Erreurs",
|
||||
"admin_nav_analytics": "Analytique",
|
||||
"admin_nav_logs": "Journaux",
|
||||
"admin_nav_uptime": "Disponibilité",
|
||||
"admin_nav_push": "Notifications",
|
||||
|
||||
"admin_scrape_status_idle": "Inactif",
|
||||
"admin_scrape_full_catalogue": "Catalogue complet",
|
||||
"admin_scrape_single_book": "Livre unique",
|
||||
@@ -423,25 +394,21 @@
|
||||
"admin_scrape_start": "Démarrer l'extraction",
|
||||
"admin_scrape_queuing": "En file d'attente…",
|
||||
"admin_scrape_running": "En cours…",
|
||||
|
||||
"admin_audio_filter_jobs": "Filtrer par slug, voix ou statut…",
|
||||
"admin_audio_filter_cache": "Filtrer par slug, chapitre ou voix…",
|
||||
"admin_audio_no_matching_jobs": "Aucun job correspondant.",
|
||||
"admin_audio_no_jobs": "Aucun job audio pour l'instant.",
|
||||
"admin_audio_cache_empty": "Cache audio vide.",
|
||||
"admin_audio_no_cache_results": "Aucun résultat.",
|
||||
|
||||
"admin_changelog_gitea": "Releases Gitea",
|
||||
"admin_changelog_no_releases": "Aucune release trouvée.",
|
||||
"admin_changelog_load_error": "Impossible de charger les releases : {error}",
|
||||
|
||||
"comments_top": "Les meilleures",
|
||||
"comments_new": "Nouvelles",
|
||||
"comments_posting": "Publication…",
|
||||
"comments_login_link": "Connectez-vous",
|
||||
"comments_login_suffix": "pour laisser un commentaire.",
|
||||
"comments_anonymous": "Anonyme",
|
||||
|
||||
"reader_audio_narration": "Narration Audio",
|
||||
"reader_playing": "Lecture en cours — contrôles ci-dessous",
|
||||
"reader_paused": "En pause — contrôles ci-dessous",
|
||||
@@ -454,7 +421,6 @@
|
||||
"reader_voice_applies_next": "La nouvelle voix s'appliquera au prochain « Lire la narration ».",
|
||||
"reader_choose_voice": "Choisir une voix",
|
||||
"reader_generating_narration": "Génération de la narration…",
|
||||
|
||||
"profile_font_family": "Police",
|
||||
"profile_font_system": "Système",
|
||||
"profile_font_serif": "Serif",
|
||||
@@ -464,7 +430,6 @@
|
||||
"profile_text_size_md": "Normal",
|
||||
"profile_text_size_lg": "Grand",
|
||||
"profile_text_size_xl": "Très grand",
|
||||
|
||||
"feed_page_title": "Fil — LibNovel",
|
||||
"feed_heading": "Fil d'abonnements",
|
||||
"feed_subheading": "Livres lus par vos abonnements",
|
||||
@@ -477,19 +442,17 @@
|
||||
"feed_find_users_cta": "Trouver des lecteurs",
|
||||
"admin_nav_gitea": "Gitea",
|
||||
"admin_nav_grafana": "Grafana",
|
||||
|
||||
"admin_translation_page_title": "Translation \u2014 Admin",
|
||||
"admin_translation_page_title": "Translation — Admin",
|
||||
"admin_translation_heading": "Machine Translation",
|
||||
"admin_translation_tab_enqueue": "Enqueue",
|
||||
"admin_translation_tab_jobs": "Jobs",
|
||||
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
|
||||
"admin_translation_filter_placeholder": "Filter by slug, lang, or status…",
|
||||
"admin_translation_no_matching": "No matching jobs.",
|
||||
"admin_translation_no_jobs": "No translation jobs yet.",
|
||||
|
||||
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
|
||||
"admin_ai_jobs_page_title": "AI Jobs — Admin",
|
||||
"admin_ai_jobs_heading": "AI Jobs",
|
||||
"admin_ai_jobs_subheading": "Background AI generation tasks",
|
||||
|
||||
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
|
||||
"admin_text_gen_heading": "Text Generation"
|
||||
"admin_text_gen_page_title": "Text Gen — Admin",
|
||||
"admin_text_gen_heading": "Text Generation",
|
||||
"admin_nav_import": "Import"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"nav_library": "Perpustakaan",
|
||||
"nav_catalogue": "Katalog",
|
||||
"nav_feed": "Umpan",
|
||||
@@ -11,7 +10,6 @@
|
||||
"nav_sign_out": "Keluar",
|
||||
"nav_toggle_menu": "Menu",
|
||||
"nav_admin_panel": "Panel admin",
|
||||
|
||||
"footer_library": "Perpustakaan",
|
||||
"footer_catalogue": "Katalog",
|
||||
"footer_feedback": "Masukan",
|
||||
@@ -20,7 +18,6 @@
|
||||
"footer_dmca": "DMCA",
|
||||
"footer_copyright": "© {year} libnovel",
|
||||
"footer_dev": "dev",
|
||||
|
||||
"home_title": "libnovel",
|
||||
"home_stat_books": "Buku",
|
||||
"home_stat_chapters": "Bab",
|
||||
@@ -34,7 +31,6 @@
|
||||
"home_discover_novels": "Temukan Novel",
|
||||
"home_via_reader": "via {username}",
|
||||
"home_chapter_badge": "bab.{n}",
|
||||
|
||||
"player_generating": "Membuat… {percent}%",
|
||||
"player_loading": "Memuat…",
|
||||
"player_chapters": "Bab",
|
||||
@@ -58,7 +54,6 @@
|
||||
"player_auto_next_aria": "Auto-lanjut {state}",
|
||||
"player_go_to_chapter": "Pergi ke bab",
|
||||
"player_close": "Tutup pemutar",
|
||||
|
||||
"login_page_title": "Masuk — libnovel",
|
||||
"login_heading": "Masuk ke libnovel",
|
||||
"login_subheading": "Pilih penyedia untuk melanjutkan",
|
||||
@@ -68,7 +63,6 @@
|
||||
"login_error_oauth_state": "Masuk dibatalkan atau kedaluwarsa. Coba lagi.",
|
||||
"login_error_oauth_failed": "Tidak dapat terhubung ke penyedia. Coba lagi.",
|
||||
"login_error_oauth_no_email": "Akunmu tidak memiliki alamat email terverifikasi. Tambahkan dan coba lagi.",
|
||||
|
||||
"books_page_title": "Perpustakaan — libnovel",
|
||||
"books_heading": "Perpustakaanmu",
|
||||
"books_empty_title": "Belum ada buku",
|
||||
@@ -78,7 +72,6 @@
|
||||
"books_last_read": "Terakhir: Bab.{n}",
|
||||
"books_reading_progress": "Bab.{current} / {total}",
|
||||
"books_remove": "Hapus",
|
||||
|
||||
"catalogue_page_title": "Katalog — libnovel",
|
||||
"catalogue_heading": "Katalog",
|
||||
"catalogue_search_placeholder": "Cari novel…",
|
||||
@@ -99,7 +92,6 @@
|
||||
"catalogue_loading": "Memuat…",
|
||||
"catalogue_load_more": "Muat lebih banyak",
|
||||
"catalogue_results_count": "{n} hasil",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_signin_to_save": "Masuk untuk menyimpan",
|
||||
"book_detail_add_to_library": "Tambah ke Perpustakaan",
|
||||
@@ -116,13 +108,11 @@
|
||||
"book_detail_rescrape": "Perbarui",
|
||||
"book_detail_scraping": "Memperbarui…",
|
||||
"book_detail_in_library": "Ada di Perpustakaan",
|
||||
|
||||
"chapters_page_title": "Bab — {title}",
|
||||
"chapters_heading": "Bab",
|
||||
"chapters_back_to_book": "Kembali ke buku",
|
||||
"chapters_reading_now": "Sedang dibaca",
|
||||
"chapters_empty": "Belum ada bab yang diambil.",
|
||||
|
||||
"reader_page_title": "{title} — Bab.{n} — libnovel",
|
||||
"reader_play_narration": "Putar narasi",
|
||||
"reader_generating_audio": "Membuat audio…",
|
||||
@@ -144,7 +134,6 @@
|
||||
"reader_auto_next": "Auto-lanjut",
|
||||
"reader_speed": "Kecepatan",
|
||||
"reader_preview_notice": "Pratinjau — bab ini belum sepenuhnya diambil.",
|
||||
|
||||
"profile_page_title": "Profil — libnovel",
|
||||
"profile_heading": "Profil",
|
||||
"profile_avatar_label": "Avatar",
|
||||
@@ -179,7 +168,6 @@
|
||||
"profile_sessions_heading": "Sesi aktif",
|
||||
"profile_sign_out_all": "Keluar dari semua perangkat lain",
|
||||
"profile_joined": "Bergabung {date}",
|
||||
|
||||
"user_page_title": "{username} — libnovel",
|
||||
"user_library_heading": "Perpustakaan {username}",
|
||||
"user_follow": "Ikuti",
|
||||
@@ -187,13 +175,11 @@
|
||||
"user_followers": "{n} pengikut",
|
||||
"user_following": "{n} mengikuti",
|
||||
"user_library_empty": "Tidak ada buku di perpustakaan.",
|
||||
|
||||
"error_not_found_title": "Halaman tidak ditemukan",
|
||||
"error_not_found_body": "Halaman yang kamu cari tidak ada.",
|
||||
"error_generic_title": "Terjadi kesalahan",
|
||||
"error_go_home": "Ke beranda",
|
||||
"error_status": "Error {status}",
|
||||
|
||||
"admin_scrape_page_title": "Scrape — Admin",
|
||||
"admin_scrape_heading": "Scrape",
|
||||
"admin_scrape_catalogue": "Scrape Katalog",
|
||||
@@ -211,14 +197,11 @@
|
||||
"admin_scrape_status_cancelled": "Dibatalkan",
|
||||
"admin_tasks_heading": "Tugas terbaru",
|
||||
"admin_tasks_empty": "Belum ada tugas.",
|
||||
|
||||
"admin_audio_page_title": "Audio — Admin",
|
||||
"admin_audio_heading": "Tugas Audio",
|
||||
"admin_audio_empty": "Tidak ada tugas audio.",
|
||||
|
||||
"admin_changelog_page_title": "Changelog — Admin",
|
||||
"admin_changelog_heading": "Changelog",
|
||||
|
||||
"comments_heading": "Komentar",
|
||||
"comments_empty": "Belum ada komentar. Jadilah yang pertama!",
|
||||
"comments_placeholder": "Tulis komentar…",
|
||||
@@ -232,12 +215,10 @@
|
||||
"comments_hide_replies": "Sembunyikan balasan",
|
||||
"comments_edited": "diedit",
|
||||
"comments_deleted": "[dihapus]",
|
||||
|
||||
"disclaimer_page_title": "Penyangkalan — libnovel",
|
||||
"privacy_page_title": "Kebijakan Privasi — libnovel",
|
||||
"dmca_page_title": "DMCA — libnovel",
|
||||
"terms_page_title": "Syarat Layanan — libnovel",
|
||||
|
||||
"common_loading": "Memuat…",
|
||||
"common_error": "Error",
|
||||
"common_save": "Simpan",
|
||||
@@ -251,15 +232,12 @@
|
||||
"common_no": "Tidak",
|
||||
"common_on": "aktif",
|
||||
"common_off": "nonaktif",
|
||||
|
||||
"locale_switcher_label": "Bahasa",
|
||||
|
||||
"books_empty_library": "Perpustakaanmu kosong.",
|
||||
"books_empty_discover": "Buku yang mulai kamu baca atau simpan dari",
|
||||
"books_empty_discover_link": "Temukan",
|
||||
"books_empty_discover_suffix": "akan muncul di sini.",
|
||||
"books_count": "{n} buku",
|
||||
|
||||
"catalogue_sort_updated": "Diperbarui",
|
||||
"catalogue_search_button": "Cari",
|
||||
"catalogue_refresh": "Segarkan",
|
||||
@@ -292,7 +270,6 @@
|
||||
"catalogue_scrape_forbidden_badge": "Terlarang",
|
||||
"catalogue_scrape_novel_button": "Scrape",
|
||||
"catalogue_scraping_novel": "Scraping…",
|
||||
|
||||
"book_detail_not_in_library": "tidak di perpustakaan",
|
||||
"book_detail_continue_ch": "Lanjutkan bab.{n}",
|
||||
"book_detail_start_ch1": "Mulai dari bab.1",
|
||||
@@ -328,18 +305,15 @@
|
||||
"book_detail_rescrape_book": "Scrape ulang buku",
|
||||
"book_detail_less": "Lebih sedikit",
|
||||
"book_detail_more": "Selengkapnya",
|
||||
|
||||
"chapters_search_placeholder": "Cari bab…",
|
||||
"chapters_jump_to": "Loncat ke Bab.{n}",
|
||||
"chapters_no_match": "Tidak ada bab yang cocok dengan \"{q}\"",
|
||||
"chapters_none_available": "Belum ada bab tersedia.",
|
||||
"chapters_reading_indicator": "sedang dibaca",
|
||||
"chapters_result_count": "{n} hasil",
|
||||
|
||||
"reader_fetching_chapter": "Mengambil bab…",
|
||||
"reader_words": "{n} kata",
|
||||
"reader_preview_audio_notice": "Pratinjau — audio tidak tersedia untuk buku di luar perpustakaan.",
|
||||
|
||||
"profile_click_to_change": "Klik avatar untuk mengganti foto",
|
||||
"profile_tts_voice": "Suara TTS",
|
||||
"profile_auto_advance": "Otomatis lanjut ke bab berikutnya",
|
||||
@@ -357,7 +331,6 @@
|
||||
"profile_updating": "Memperbarui…",
|
||||
"profile_password_changed_ok": "Kata sandi berhasil diubah.",
|
||||
"profile_playback_speed": "Kecepatan pemutaran — {speed}x",
|
||||
|
||||
"profile_subscription_heading": "Langganan",
|
||||
"profile_plan_pro": "Pro",
|
||||
"profile_plan_free": "Gratis",
|
||||
@@ -369,7 +342,7 @@
|
||||
"profile_upgrade_monthly": "Bulanan — $6 / bln",
|
||||
"profile_upgrade_annual": "Tahunan — $48 / thn",
|
||||
"profile_free_limits": "Paket gratis: 3 bab audio per hari, hanya bahasa Inggris.",
|
||||
"subscribe_page_title": "Jadi Pro \u2014 libnovel",
|
||||
"subscribe_page_title": "Jadi Pro — libnovel",
|
||||
"subscribe_heading": "Baca lebih. Dengarkan lebih.",
|
||||
"subscribe_subheading": "Tingkatkan ke Pro dan buka pengalaman libnovel sepenuhnya.",
|
||||
"subscribe_monthly_label": "Bulanan",
|
||||
@@ -389,14 +362,12 @@
|
||||
"subscribe_benefit_downloads": "Unduh bab untuk didengarkan secara offline",
|
||||
"subscribe_login_prompt": "Masuk untuk berlangganan",
|
||||
"subscribe_login_cta": "Masuk",
|
||||
|
||||
"user_currently_reading": "Sedang Dibaca",
|
||||
"user_library_count": "Perpustakaan ({n})",
|
||||
"user_joined": "Bergabung {date}",
|
||||
"user_followers_label": "pengikut",
|
||||
"user_following_label": "mengikuti",
|
||||
"user_no_books": "Belum ada buku di perpustakaan.",
|
||||
|
||||
"admin_pages_label": "Halaman",
|
||||
"admin_tools_label": "Alat",
|
||||
"admin_nav_scrape": "Scrape",
|
||||
@@ -407,12 +378,12 @@
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Tugas AI",
|
||||
"admin_nav_notifications": "Notifikasi",
|
||||
"admin_nav_errors": "Kesalahan",
|
||||
"admin_nav_analytics": "Analitik",
|
||||
"admin_nav_logs": "Log",
|
||||
"admin_nav_uptime": "Uptime",
|
||||
"admin_nav_push": "Notifikasi",
|
||||
|
||||
"admin_scrape_status_idle": "Menunggu",
|
||||
"admin_scrape_full_catalogue": "Katalog penuh",
|
||||
"admin_scrape_single_book": "Satu buku",
|
||||
@@ -423,25 +394,21 @@
|
||||
"admin_scrape_start": "Mulai scrape",
|
||||
"admin_scrape_queuing": "Mengantri…",
|
||||
"admin_scrape_running": "Berjalan…",
|
||||
|
||||
"admin_audio_filter_jobs": "Filter berdasarkan slug, suara, atau status…",
|
||||
"admin_audio_filter_cache": "Filter berdasarkan slug, bab, atau suara…",
|
||||
"admin_audio_no_matching_jobs": "Tidak ada pekerjaan yang cocok.",
|
||||
"admin_audio_no_jobs": "Belum ada pekerjaan audio.",
|
||||
"admin_audio_cache_empty": "Cache audio kosong.",
|
||||
"admin_audio_no_cache_results": "Tidak ada hasil.",
|
||||
|
||||
"admin_changelog_gitea": "Rilis Gitea",
|
||||
"admin_changelog_no_releases": "Tidak ada rilis.",
|
||||
"admin_changelog_load_error": "Gagal memuat rilis: {error}",
|
||||
|
||||
"comments_top": "Teratas",
|
||||
"comments_new": "Terbaru",
|
||||
"comments_posting": "Mengirim…",
|
||||
"comments_login_link": "Masuk",
|
||||
"comments_login_suffix": "untuk meninggalkan komentar.",
|
||||
"comments_anonymous": "Anonim",
|
||||
|
||||
"reader_audio_narration": "Narasi Audio",
|
||||
"reader_playing": "Memutar — kontrol di bawah",
|
||||
"reader_paused": "Dijeda — kontrol di bawah",
|
||||
@@ -454,7 +421,6 @@
|
||||
"reader_voice_applies_next": "Suara baru berlaku pada \"Putar narasi\" berikutnya.",
|
||||
"reader_choose_voice": "Pilih Suara",
|
||||
"reader_generating_narration": "Membuat narasi…",
|
||||
|
||||
"profile_font_family": "Jenis Font",
|
||||
"profile_font_system": "Sistem",
|
||||
"profile_font_serif": "Serif",
|
||||
@@ -464,7 +430,6 @@
|
||||
"profile_text_size_md": "Normal",
|
||||
"profile_text_size_lg": "Besar",
|
||||
"profile_text_size_xl": "Sangat Besar",
|
||||
|
||||
"feed_page_title": "Umpan — LibNovel",
|
||||
"feed_heading": "Umpan Ikutan",
|
||||
"feed_subheading": "Buku yang sedang dibaca oleh pengguna yang Anda ikuti",
|
||||
@@ -477,19 +442,17 @@
|
||||
"feed_find_users_cta": "Temukan pembaca",
|
||||
"admin_nav_gitea": "Gitea",
|
||||
"admin_nav_grafana": "Grafana",
|
||||
|
||||
"admin_translation_page_title": "Translation \u2014 Admin",
|
||||
"admin_translation_page_title": "Translation — Admin",
|
||||
"admin_translation_heading": "Machine Translation",
|
||||
"admin_translation_tab_enqueue": "Enqueue",
|
||||
"admin_translation_tab_jobs": "Jobs",
|
||||
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
|
||||
"admin_translation_filter_placeholder": "Filter by slug, lang, or status…",
|
||||
"admin_translation_no_matching": "No matching jobs.",
|
||||
"admin_translation_no_jobs": "No translation jobs yet.",
|
||||
|
||||
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
|
||||
"admin_ai_jobs_page_title": "AI Jobs — Admin",
|
||||
"admin_ai_jobs_heading": "AI Jobs",
|
||||
"admin_ai_jobs_subheading": "Background AI generation tasks",
|
||||
|
||||
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
|
||||
"admin_text_gen_heading": "Text Generation"
|
||||
"admin_text_gen_page_title": "Text Gen — Admin",
|
||||
"admin_text_gen_heading": "Text Generation",
|
||||
"admin_nav_import": "Import"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"nav_library": "Biblioteca",
|
||||
"nav_catalogue": "Catálogo",
|
||||
"nav_feed": "Feed",
|
||||
@@ -11,7 +10,6 @@
|
||||
"nav_sign_out": "Sair",
|
||||
"nav_toggle_menu": "Menu",
|
||||
"nav_admin_panel": "Painel admin",
|
||||
|
||||
"footer_library": "Biblioteca",
|
||||
"footer_catalogue": "Catálogo",
|
||||
"footer_feedback": "Feedback",
|
||||
@@ -20,7 +18,6 @@
|
||||
"footer_dmca": "DMCA",
|
||||
"footer_copyright": "© {year} libnovel",
|
||||
"footer_dev": "dev",
|
||||
|
||||
"home_title": "libnovel",
|
||||
"home_stat_books": "Livros",
|
||||
"home_stat_chapters": "Capítulos",
|
||||
@@ -34,7 +31,6 @@
|
||||
"home_discover_novels": "Descobrir Romances",
|
||||
"home_via_reader": "via {username}",
|
||||
"home_chapter_badge": "cap.{n}",
|
||||
|
||||
"player_generating": "Gerando… {percent}%",
|
||||
"player_loading": "Carregando…",
|
||||
"player_chapters": "Capítulos",
|
||||
@@ -58,7 +54,6 @@
|
||||
"player_auto_next_aria": "Próximo automático {state}",
|
||||
"player_go_to_chapter": "Ir para capítulo",
|
||||
"player_close": "Fechar player",
|
||||
|
||||
"login_page_title": "Entrar — libnovel",
|
||||
"login_heading": "Entrar no libnovel",
|
||||
"login_subheading": "Escolha um provedor para continuar",
|
||||
@@ -68,7 +63,6 @@
|
||||
"login_error_oauth_state": "Login cancelado ou expirado. Tente novamente.",
|
||||
"login_error_oauth_failed": "Não foi possível conectar ao provedor. Tente novamente.",
|
||||
"login_error_oauth_no_email": "Sua conta não tem endereço de email verificado. Adicione um e tente novamente.",
|
||||
|
||||
"books_page_title": "Biblioteca — libnovel",
|
||||
"books_heading": "Sua Biblioteca",
|
||||
"books_empty_title": "Nenhum livro ainda",
|
||||
@@ -78,7 +72,6 @@
|
||||
"books_last_read": "Último: Cap.{n}",
|
||||
"books_reading_progress": "Cap.{current} / {total}",
|
||||
"books_remove": "Remover",
|
||||
|
||||
"catalogue_page_title": "Catálogo — libnovel",
|
||||
"catalogue_heading": "Catálogo",
|
||||
"catalogue_search_placeholder": "Pesquisar romances…",
|
||||
@@ -99,7 +92,6 @@
|
||||
"catalogue_loading": "Carregando…",
|
||||
"catalogue_load_more": "Carregar mais",
|
||||
"catalogue_results_count": "{n} resultados",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_signin_to_save": "Entre para salvar",
|
||||
"book_detail_add_to_library": "Adicionar à Biblioteca",
|
||||
@@ -116,13 +108,11 @@
|
||||
"book_detail_rescrape": "Atualizar",
|
||||
"book_detail_scraping": "Atualizando…",
|
||||
"book_detail_in_library": "Na Biblioteca",
|
||||
|
||||
"chapters_page_title": "Capítulos — {title}",
|
||||
"chapters_heading": "Capítulos",
|
||||
"chapters_back_to_book": "Voltar ao livro",
|
||||
"chapters_reading_now": "Lendo",
|
||||
"chapters_empty": "Nenhum capítulo extraído ainda.",
|
||||
|
||||
"reader_page_title": "{title} — Cap.{n} — libnovel",
|
||||
"reader_play_narration": "Reproduzir narração",
|
||||
"reader_generating_audio": "Gerando áudio…",
|
||||
@@ -144,7 +134,6 @@
|
||||
"reader_auto_next": "Próximo automático",
|
||||
"reader_speed": "Velocidade",
|
||||
"reader_preview_notice": "Prévia — este capítulo não foi totalmente extraído.",
|
||||
|
||||
"profile_page_title": "Perfil — libnovel",
|
||||
"profile_heading": "Perfil",
|
||||
"profile_avatar_label": "Avatar",
|
||||
@@ -179,7 +168,6 @@
|
||||
"profile_sessions_heading": "Sessões ativas",
|
||||
"profile_sign_out_all": "Sair de todos os outros dispositivos",
|
||||
"profile_joined": "Entrou em {date}",
|
||||
|
||||
"user_page_title": "{username} — libnovel",
|
||||
"user_library_heading": "Biblioteca de {username}",
|
||||
"user_follow": "Seguir",
|
||||
@@ -187,13 +175,11 @@
|
||||
"user_followers": "{n} seguidores",
|
||||
"user_following": "{n} seguindo",
|
||||
"user_library_empty": "Nenhum livro na biblioteca.",
|
||||
|
||||
"error_not_found_title": "Página não encontrada",
|
||||
"error_not_found_body": "A página que você procura não existe.",
|
||||
"error_generic_title": "Algo deu errado",
|
||||
"error_go_home": "Ir para início",
|
||||
"error_status": "Erro {status}",
|
||||
|
||||
"admin_scrape_page_title": "Extração — Admin",
|
||||
"admin_scrape_heading": "Extração",
|
||||
"admin_scrape_catalogue": "Extrair Catálogo",
|
||||
@@ -211,14 +197,11 @@
|
||||
"admin_scrape_status_cancelled": "Cancelado",
|
||||
"admin_tasks_heading": "Tarefas recentes",
|
||||
"admin_tasks_empty": "Nenhuma tarefa ainda.",
|
||||
|
||||
"admin_audio_page_title": "Áudio — Admin",
|
||||
"admin_audio_heading": "Tarefas de Áudio",
|
||||
"admin_audio_empty": "Nenhuma tarefa de áudio.",
|
||||
|
||||
"admin_changelog_page_title": "Changelog — Admin",
|
||||
"admin_changelog_heading": "Changelog",
|
||||
|
||||
"comments_heading": "Comentários",
|
||||
"comments_empty": "Nenhum comentário ainda. Seja o primeiro!",
|
||||
"comments_placeholder": "Escreva um comentário…",
|
||||
@@ -232,12 +215,10 @@
|
||||
"comments_hide_replies": "Ocultar respostas",
|
||||
"comments_edited": "editado",
|
||||
"comments_deleted": "[excluído]",
|
||||
|
||||
"disclaimer_page_title": "Aviso Legal — libnovel",
|
||||
"privacy_page_title": "Política de Privacidade — libnovel",
|
||||
"dmca_page_title": "DMCA — libnovel",
|
||||
"terms_page_title": "Termos de Serviço — libnovel",
|
||||
|
||||
"common_loading": "Carregando…",
|
||||
"common_error": "Erro",
|
||||
"common_save": "Salvar",
|
||||
@@ -251,15 +232,12 @@
|
||||
"common_no": "Não",
|
||||
"common_on": "ativado",
|
||||
"common_off": "desativado",
|
||||
|
||||
"locale_switcher_label": "Idioma",
|
||||
|
||||
"books_empty_library": "Sua biblioteca está vazia.",
|
||||
"books_empty_discover": "Livros que você começar a ler ou salvar de",
|
||||
"books_empty_discover_link": "Descobrir",
|
||||
"books_empty_discover_suffix": "aparecerão aqui.",
|
||||
"books_count": "{n} livro{s}",
|
||||
|
||||
"catalogue_sort_updated": "Atualizado",
|
||||
"catalogue_search_button": "Pesquisar",
|
||||
"catalogue_refresh": "Atualizar",
|
||||
@@ -292,7 +270,6 @@
|
||||
"catalogue_scrape_forbidden_badge": "Proibido",
|
||||
"catalogue_scrape_novel_button": "Extrair",
|
||||
"catalogue_scraping_novel": "Extraindo…",
|
||||
|
||||
"book_detail_not_in_library": "não está na biblioteca",
|
||||
"book_detail_continue_ch": "Continuar cap.{n}",
|
||||
"book_detail_start_ch1": "Começar pelo cap.1",
|
||||
@@ -328,18 +305,15 @@
|
||||
"book_detail_rescrape_book": "Reextrair livro",
|
||||
"book_detail_less": "Menos",
|
||||
"book_detail_more": "Mais",
|
||||
|
||||
"chapters_search_placeholder": "Pesquisar capítulos…",
|
||||
"chapters_jump_to": "Ir para Cap.{n}",
|
||||
"chapters_no_match": "Nenhum capítulo encontrado para \"{q}\"",
|
||||
"chapters_none_available": "Nenhum capítulo disponível ainda.",
|
||||
"chapters_reading_indicator": "lendo",
|
||||
"chapters_result_count": "{n} resultados",
|
||||
|
||||
"reader_fetching_chapter": "Buscando capítulo…",
|
||||
"reader_words": "{n} palavras",
|
||||
"reader_preview_audio_notice": "Prévia — áudio não disponível para livros fora da biblioteca.",
|
||||
|
||||
"profile_click_to_change": "Clique no avatar para mudar a foto",
|
||||
"profile_tts_voice": "Voz TTS",
|
||||
"profile_auto_advance": "Avançar automaticamente para o próximo capítulo",
|
||||
@@ -357,7 +331,6 @@
|
||||
"profile_updating": "Atualizando…",
|
||||
"profile_password_changed_ok": "Senha alterada com sucesso.",
|
||||
"profile_playback_speed": "Velocidade de reprodução — {speed}x",
|
||||
|
||||
"profile_subscription_heading": "Assinatura",
|
||||
"profile_plan_pro": "Pro",
|
||||
"profile_plan_free": "Gratuito",
|
||||
@@ -369,7 +342,7 @@
|
||||
"profile_upgrade_monthly": "Mensal — $6 / mês",
|
||||
"profile_upgrade_annual": "Anual — $48 / ano",
|
||||
"profile_free_limits": "Plano gratuito: 3 capítulos de áudio por dia, somente inglês.",
|
||||
"subscribe_page_title": "Seja Pro \u2014 libnovel",
|
||||
"subscribe_page_title": "Seja Pro — libnovel",
|
||||
"subscribe_heading": "Leia mais. Ouça mais.",
|
||||
"subscribe_subheading": "Torne-se Pro e desbloqueie a experiência completa do libnovel.",
|
||||
"subscribe_monthly_label": "Mensal",
|
||||
@@ -389,14 +362,12 @@
|
||||
"subscribe_benefit_downloads": "Baixe capítulos para ouvir offline",
|
||||
"subscribe_login_prompt": "Entre para assinar",
|
||||
"subscribe_login_cta": "Entrar",
|
||||
|
||||
"user_currently_reading": "Lendo Agora",
|
||||
"user_library_count": "Biblioteca ({n})",
|
||||
"user_joined": "Entrou em {date}",
|
||||
"user_followers_label": "seguidores",
|
||||
"user_following_label": "seguindo",
|
||||
"user_no_books": "Nenhum livro na biblioteca ainda.",
|
||||
|
||||
"admin_pages_label": "Páginas",
|
||||
"admin_tools_label": "Ferramentas",
|
||||
"admin_nav_scrape": "Scrape",
|
||||
@@ -407,12 +378,12 @@
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Tarefas de IA",
|
||||
"admin_nav_notifications": "Notificações",
|
||||
"admin_nav_errors": "Erros",
|
||||
"admin_nav_analytics": "Análise",
|
||||
"admin_nav_logs": "Logs",
|
||||
"admin_nav_uptime": "Uptime",
|
||||
"admin_nav_push": "Notificações",
|
||||
|
||||
"admin_scrape_status_idle": "Ocioso",
|
||||
"admin_scrape_full_catalogue": "Catálogo completo",
|
||||
"admin_scrape_single_book": "Livro único",
|
||||
@@ -423,25 +394,21 @@
|
||||
"admin_scrape_start": "Iniciar extração",
|
||||
"admin_scrape_queuing": "Na fila…",
|
||||
"admin_scrape_running": "Executando…",
|
||||
|
||||
"admin_audio_filter_jobs": "Filtrar por slug, voz ou status…",
|
||||
"admin_audio_filter_cache": "Filtrar por slug, capítulo ou voz…",
|
||||
"admin_audio_no_matching_jobs": "Nenhum job correspondente.",
|
||||
"admin_audio_no_jobs": "Nenhum job de áudio ainda.",
|
||||
"admin_audio_cache_empty": "Cache de áudio vazio.",
|
||||
"admin_audio_no_cache_results": "Sem resultados.",
|
||||
|
||||
"admin_changelog_gitea": "Releases do Gitea",
|
||||
"admin_changelog_no_releases": "Nenhum release encontrado.",
|
||||
"admin_changelog_load_error": "Não foi possível carregar os releases: {error}",
|
||||
|
||||
"comments_top": "Mais votados",
|
||||
"comments_new": "Novos",
|
||||
"comments_posting": "Publicando…",
|
||||
"comments_login_link": "Entre",
|
||||
"comments_login_suffix": "para deixar um comentário.",
|
||||
"comments_anonymous": "Anônimo",
|
||||
|
||||
"reader_audio_narration": "Narração em Áudio",
|
||||
"reader_playing": "Reproduzindo — controles abaixo",
|
||||
"reader_paused": "Pausado — controles abaixo",
|
||||
@@ -454,7 +421,6 @@
|
||||
"reader_voice_applies_next": "A nova voz será aplicada no próximo \"Reproduzir narração\".",
|
||||
"reader_choose_voice": "Escolher Voz",
|
||||
"reader_generating_narration": "Gerando narração…",
|
||||
|
||||
"profile_font_family": "Fonte",
|
||||
"profile_font_system": "Sistema",
|
||||
"profile_font_serif": "Serif",
|
||||
@@ -464,7 +430,6 @@
|
||||
"profile_text_size_md": "Normal",
|
||||
"profile_text_size_lg": "Grande",
|
||||
"profile_text_size_xl": "Muito grande",
|
||||
|
||||
"feed_page_title": "Feed — LibNovel",
|
||||
"feed_heading": "Feed de seguidos",
|
||||
"feed_subheading": "Livros que seus seguidos estão lendo",
|
||||
@@ -477,19 +442,17 @@
|
||||
"feed_find_users_cta": "Encontrar leitores",
|
||||
"admin_nav_gitea": "Gitea",
|
||||
"admin_nav_grafana": "Grafana",
|
||||
|
||||
"admin_translation_page_title": "Translation \u2014 Admin",
|
||||
"admin_translation_page_title": "Translation — Admin",
|
||||
"admin_translation_heading": "Machine Translation",
|
||||
"admin_translation_tab_enqueue": "Enqueue",
|
||||
"admin_translation_tab_jobs": "Jobs",
|
||||
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
|
||||
"admin_translation_filter_placeholder": "Filter by slug, lang, or status…",
|
||||
"admin_translation_no_matching": "No matching jobs.",
|
||||
"admin_translation_no_jobs": "No translation jobs yet.",
|
||||
|
||||
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
|
||||
"admin_ai_jobs_page_title": "AI Jobs — Admin",
|
||||
"admin_ai_jobs_heading": "AI Jobs",
|
||||
"admin_ai_jobs_subheading": "Background AI generation tasks",
|
||||
|
||||
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
|
||||
"admin_text_gen_heading": "Text Generation"
|
||||
"admin_text_gen_page_title": "Text Gen — Admin",
|
||||
"admin_text_gen_heading": "Text Generation",
|
||||
"admin_nav_import": "Import"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
|
||||
"nav_library": "Библиотека",
|
||||
"nav_catalogue": "Каталог",
|
||||
"nav_feed": "Лента",
|
||||
@@ -11,7 +10,6 @@
|
||||
"nav_sign_out": "Выйти",
|
||||
"nav_toggle_menu": "Меню",
|
||||
"nav_admin_panel": "Панель администратора",
|
||||
|
||||
"footer_library": "Библиотека",
|
||||
"footer_catalogue": "Каталог",
|
||||
"footer_feedback": "Обратная связь",
|
||||
@@ -20,7 +18,6 @@
|
||||
"footer_dmca": "DMCA",
|
||||
"footer_copyright": "© {year} libnovel",
|
||||
"footer_dev": "dev",
|
||||
|
||||
"home_title": "libnovel",
|
||||
"home_stat_books": "Книги",
|
||||
"home_stat_chapters": "Главы",
|
||||
@@ -34,7 +31,6 @@
|
||||
"home_discover_novels": "Открыть новеллы",
|
||||
"home_via_reader": "от {username}",
|
||||
"home_chapter_badge": "гл.{n}",
|
||||
|
||||
"player_generating": "Генерация… {percent}%",
|
||||
"player_loading": "Загрузка…",
|
||||
"player_chapters": "Главы",
|
||||
@@ -58,7 +54,6 @@
|
||||
"player_auto_next_aria": "Автопереход {state}",
|
||||
"player_go_to_chapter": "Перейти к главе",
|
||||
"player_close": "Закрыть плеер",
|
||||
|
||||
"login_page_title": "Вход — libnovel",
|
||||
"login_heading": "Войти в libnovel",
|
||||
"login_subheading": "Выберите провайдера для входа",
|
||||
@@ -68,7 +63,6 @@
|
||||
"login_error_oauth_state": "Вход отменён или истёк срок действия. Попробуйте снова.",
|
||||
"login_error_oauth_failed": "Не удалось подключиться к провайдеру. Попробуйте снова.",
|
||||
"login_error_oauth_no_email": "У вашего аккаунта нет подтверждённого email. Добавьте его и повторите попытку.",
|
||||
|
||||
"books_page_title": "Библиотека — libnovel",
|
||||
"books_heading": "Ваша библиотека",
|
||||
"books_empty_title": "Книг пока нет",
|
||||
@@ -78,7 +72,6 @@
|
||||
"books_last_read": "Последнее: гл.{n}",
|
||||
"books_reading_progress": "Гл.{current} / {total}",
|
||||
"books_remove": "Удалить",
|
||||
|
||||
"catalogue_page_title": "Каталог — libnovel",
|
||||
"catalogue_heading": "Каталог",
|
||||
"catalogue_search_placeholder": "Поиск новелл…",
|
||||
@@ -99,7 +92,6 @@
|
||||
"catalogue_loading": "Загрузка…",
|
||||
"catalogue_load_more": "Загрузить ещё",
|
||||
"catalogue_results_count": "{n} результатов",
|
||||
|
||||
"book_detail_page_title": "{title} — libnovel",
|
||||
"book_detail_signin_to_save": "Войдите, чтобы сохранить",
|
||||
"book_detail_add_to_library": "В библиотеку",
|
||||
@@ -116,13 +108,11 @@
|
||||
"book_detail_rescrape": "Обновить",
|
||||
"book_detail_scraping": "Обновление…",
|
||||
"book_detail_in_library": "В библиотеке",
|
||||
|
||||
"chapters_page_title": "Главы — {title}",
|
||||
"chapters_heading": "Главы",
|
||||
"chapters_back_to_book": "К книге",
|
||||
"chapters_reading_now": "Читается",
|
||||
"chapters_empty": "Главы ещё не загружены.",
|
||||
|
||||
"reader_page_title": "{title} — Гл.{n} — libnovel",
|
||||
"reader_play_narration": "Воспроизвести озвучку",
|
||||
"reader_generating_audio": "Генерация аудио…",
|
||||
@@ -144,7 +134,6 @@
|
||||
"reader_auto_next": "Автопереход",
|
||||
"reader_speed": "Скорость",
|
||||
"reader_preview_notice": "Предпросмотр — эта глава не полностью загружена.",
|
||||
|
||||
"profile_page_title": "Профиль — libnovel",
|
||||
"profile_heading": "Профиль",
|
||||
"profile_avatar_label": "Аватар",
|
||||
@@ -179,7 +168,6 @@
|
||||
"profile_sessions_heading": "Активные сессии",
|
||||
"profile_sign_out_all": "Выйти на всех других устройствах",
|
||||
"profile_joined": "Зарегистрирован {date}",
|
||||
|
||||
"user_page_title": "{username} — libnovel",
|
||||
"user_library_heading": "Библиотека {username}",
|
||||
"user_follow": "Подписаться",
|
||||
@@ -187,13 +175,11 @@
|
||||
"user_followers": "{n} подписчиков",
|
||||
"user_following": "{n} подписок",
|
||||
"user_library_empty": "В библиотеке нет книг.",
|
||||
|
||||
"error_not_found_title": "Страница не найдена",
|
||||
"error_not_found_body": "Запрошенная страница не существует.",
|
||||
"error_generic_title": "Что-то пошло не так",
|
||||
"error_go_home": "На главную",
|
||||
"error_status": "Ошибка {status}",
|
||||
|
||||
"admin_scrape_page_title": "Парсинг — Админ",
|
||||
"admin_scrape_heading": "Парсинг",
|
||||
"admin_scrape_catalogue": "Парсинг каталога",
|
||||
@@ -211,14 +197,11 @@
|
||||
"admin_scrape_status_cancelled": "Отменено",
|
||||
"admin_tasks_heading": "Последние задачи",
|
||||
"admin_tasks_empty": "Задач пока нет.",
|
||||
|
||||
"admin_audio_page_title": "Аудио — Админ",
|
||||
"admin_audio_heading": "Аудио задачи",
|
||||
"admin_audio_empty": "Аудио задач нет.",
|
||||
|
||||
"admin_changelog_page_title": "Changelog — Админ",
|
||||
"admin_changelog_heading": "Changelog",
|
||||
|
||||
"comments_heading": "Комментарии",
|
||||
"comments_empty": "Комментариев пока нет. Будьте первым!",
|
||||
"comments_placeholder": "Написать комментарий…",
|
||||
@@ -232,12 +215,10 @@
|
||||
"comments_hide_replies": "Скрыть ответы",
|
||||
"comments_edited": "изменено",
|
||||
"comments_deleted": "[удалено]",
|
||||
|
||||
"disclaimer_page_title": "Отказ от ответственности — libnovel",
|
||||
"privacy_page_title": "Политика конфиденциальности — libnovel",
|
||||
"dmca_page_title": "DMCA — libnovel",
|
||||
"terms_page_title": "Условия использования — libnovel",
|
||||
|
||||
"common_loading": "Загрузка…",
|
||||
"common_error": "Ошибка",
|
||||
"common_save": "Сохранить",
|
||||
@@ -251,15 +232,12 @@
|
||||
"common_no": "Нет",
|
||||
"common_on": "вкл.",
|
||||
"common_off": "выкл.",
|
||||
|
||||
"locale_switcher_label": "Язык",
|
||||
|
||||
"books_empty_library": "Ваша библиотека пуста.",
|
||||
"books_empty_discover": "Книги, которые вы начнёте читать или сохраните из",
|
||||
"books_empty_discover_link": "Каталога",
|
||||
"books_empty_discover_suffix": "появятся здесь.",
|
||||
"books_count": "{n} книг{s}",
|
||||
|
||||
"catalogue_sort_updated": "По дате обновления",
|
||||
"catalogue_search_button": "Поиск",
|
||||
"catalogue_refresh": "Обновить",
|
||||
@@ -292,7 +270,6 @@
|
||||
"catalogue_scrape_forbidden_badge": "Запрещено",
|
||||
"catalogue_scrape_novel_button": "Парсить",
|
||||
"catalogue_scraping_novel": "Парсинг…",
|
||||
|
||||
"book_detail_not_in_library": "не в библиотеке",
|
||||
"book_detail_continue_ch": "Продолжить гл.{n}",
|
||||
"book_detail_start_ch1": "Начать с гл.1",
|
||||
@@ -328,18 +305,15 @@
|
||||
"book_detail_rescrape_book": "Перепарсить книгу",
|
||||
"book_detail_less": "Скрыть",
|
||||
"book_detail_more": "Ещё",
|
||||
|
||||
"chapters_search_placeholder": "Поиск глав…",
|
||||
"chapters_jump_to": "Перейти к гл.{n}",
|
||||
"chapters_no_match": "Главы по запросу «{q}» не найдены",
|
||||
"chapters_none_available": "Глав пока нет.",
|
||||
"chapters_reading_indicator": "читается",
|
||||
"chapters_result_count": "{n} результатов",
|
||||
|
||||
"reader_fetching_chapter": "Загрузка главы…",
|
||||
"reader_words": "{n} слов",
|
||||
"reader_preview_audio_notice": "Предпросмотр — аудио недоступно для книг вне библиотеки.",
|
||||
|
||||
"profile_click_to_change": "Нажмите на аватар для смены фото",
|
||||
"profile_tts_voice": "Голос TTS",
|
||||
"profile_auto_advance": "Автопереход к следующей главе",
|
||||
@@ -357,7 +331,6 @@
|
||||
"profile_updating": "Обновление…",
|
||||
"profile_password_changed_ok": "Пароль успешно изменён.",
|
||||
"profile_playback_speed": "Скорость воспроизведения — {speed}x",
|
||||
|
||||
"profile_subscription_heading": "Подписка",
|
||||
"profile_plan_pro": "Pro",
|
||||
"profile_plan_free": "Бесплатно",
|
||||
@@ -369,7 +342,7 @@
|
||||
"profile_upgrade_monthly": "Ежемесячно — $6 / мес",
|
||||
"profile_upgrade_annual": "Ежегодно — $48 / год",
|
||||
"profile_free_limits": "Бесплатный план: 3 аудиоглавы в день, только английский.",
|
||||
"subscribe_page_title": "Перейти на Pro \u2014 libnovel",
|
||||
"subscribe_page_title": "Перейти на Pro — libnovel",
|
||||
"subscribe_heading": "Читайте больше. Слушайте больше.",
|
||||
"subscribe_subheading": "Перейдите на Pro и откройте полный опыт libnovel.",
|
||||
"subscribe_monthly_label": "Ежемесячно",
|
||||
@@ -389,14 +362,12 @@
|
||||
"subscribe_benefit_downloads": "Скачивайте главы для прослушивания офлайн",
|
||||
"subscribe_login_prompt": "Войдите, чтобы оформить подписку",
|
||||
"subscribe_login_cta": "Войти",
|
||||
|
||||
"user_currently_reading": "Сейчас читает",
|
||||
"user_library_count": "Библиотека ({n})",
|
||||
"user_joined": "Зарегистрирован {date}",
|
||||
"user_followers_label": "подписчиков",
|
||||
"user_following_label": "подписок",
|
||||
"user_no_books": "Книг в библиотеке пока нет.",
|
||||
|
||||
"admin_pages_label": "Страницы",
|
||||
"admin_tools_label": "Инструменты",
|
||||
"admin_nav_scrape": "Скрейпинг",
|
||||
@@ -407,12 +378,12 @@
|
||||
"admin_nav_text_gen": "Text Gen",
|
||||
"admin_nav_catalogue_tools": "Catalogue Tools",
|
||||
"admin_nav_ai_jobs": "Задачи ИИ",
|
||||
"admin_nav_notifications": "Уведомления",
|
||||
"admin_nav_errors": "Ошибки",
|
||||
"admin_nav_analytics": "Аналитика",
|
||||
"admin_nav_logs": "Логи",
|
||||
"admin_nav_uptime": "Мониторинг",
|
||||
"admin_nav_push": "Уведомления",
|
||||
|
||||
"admin_scrape_status_idle": "Ожидание",
|
||||
"admin_scrape_full_catalogue": "Полный каталог",
|
||||
"admin_scrape_single_book": "Одна книга",
|
||||
@@ -423,25 +394,21 @@
|
||||
"admin_scrape_start": "Начать парсинг",
|
||||
"admin_scrape_queuing": "В очереди…",
|
||||
"admin_scrape_running": "Выполняется…",
|
||||
|
||||
"admin_audio_filter_jobs": "Фильтр по slug, голосу или статусу…",
|
||||
"admin_audio_filter_cache": "Фильтр по slug, главе или голосу…",
|
||||
"admin_audio_no_matching_jobs": "Заданий не найдено.",
|
||||
"admin_audio_no_jobs": "Аудиозаданий пока нет.",
|
||||
"admin_audio_cache_empty": "Аудиокэш пуст.",
|
||||
"admin_audio_no_cache_results": "Результатов нет.",
|
||||
|
||||
"admin_changelog_gitea": "Релизы Gitea",
|
||||
"admin_changelog_no_releases": "Релизов не найдено.",
|
||||
"admin_changelog_load_error": "Не удалось загрузить релизы: {error}",
|
||||
|
||||
"comments_top": "Лучшие",
|
||||
"comments_new": "Новые",
|
||||
"comments_posting": "Отправка…",
|
||||
"comments_login_link": "Войдите",
|
||||
"comments_login_suffix": "чтобы оставить комментарий.",
|
||||
"comments_anonymous": "Аноним",
|
||||
|
||||
"reader_audio_narration": "Аудионарратив",
|
||||
"reader_playing": "Воспроизводится — управление ниже",
|
||||
"reader_paused": "Пауза — управление ниже",
|
||||
@@ -454,7 +421,6 @@
|
||||
"reader_voice_applies_next": "Новый голос применится при следующем нажатии «Воспроизвести».",
|
||||
"reader_choose_voice": "Выбрать голос",
|
||||
"reader_generating_narration": "Генерация озвучки…",
|
||||
|
||||
"profile_font_family": "Шрифт",
|
||||
"profile_font_system": "Системный",
|
||||
"profile_font_serif": "Serif",
|
||||
@@ -464,7 +430,6 @@
|
||||
"profile_text_size_md": "Нормальный",
|
||||
"profile_text_size_lg": "Большой",
|
||||
"profile_text_size_xl": "Очень большой",
|
||||
|
||||
"feed_page_title": "Лента — LibNovel",
|
||||
"feed_heading": "Лента подписок",
|
||||
"feed_subheading": "Книги, которые читают ваши подписки",
|
||||
@@ -477,19 +442,17 @@
|
||||
"feed_find_users_cta": "Найти читателей",
|
||||
"admin_nav_gitea": "Gitea",
|
||||
"admin_nav_grafana": "Grafana",
|
||||
|
||||
"admin_translation_page_title": "Translation \u2014 Admin",
|
||||
"admin_translation_page_title": "Translation — Admin",
|
||||
"admin_translation_heading": "Machine Translation",
|
||||
"admin_translation_tab_enqueue": "Enqueue",
|
||||
"admin_translation_tab_jobs": "Jobs",
|
||||
"admin_translation_filter_placeholder": "Filter by slug, lang, or status\u2026",
|
||||
"admin_translation_filter_placeholder": "Filter by slug, lang, or status…",
|
||||
"admin_translation_no_matching": "No matching jobs.",
|
||||
"admin_translation_no_jobs": "No translation jobs yet.",
|
||||
|
||||
"admin_ai_jobs_page_title": "AI Jobs \u2014 Admin",
|
||||
"admin_ai_jobs_page_title": "AI Jobs — Admin",
|
||||
"admin_ai_jobs_heading": "AI Jobs",
|
||||
"admin_ai_jobs_subheading": "Background AI generation tasks",
|
||||
|
||||
"admin_text_gen_page_title": "Text Gen \u2014 Admin",
|
||||
"admin_text_gen_heading": "Text Generation"
|
||||
"admin_text_gen_page_title": "Text Gen — Admin",
|
||||
"admin_text_gen_heading": "Text Generation",
|
||||
"admin_nav_import": "Import"
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> Auto-generated i18n message functions. Import `messages.js` to use translated strings.
|
||||
|
||||
Compiled from: `/Users/kalekber/code/libnovel-v2/ui/project.inlang`
|
||||
Compiled from: `/opt/libnovel-v3/ui/project.inlang`
|
||||
|
||||
|
||||
## What is this folder?
|
||||
|
||||
@@ -373,11 +373,13 @@ export * from './admin_tools_label.js'
|
||||
export * from './admin_nav_scrape.js'
|
||||
export * from './admin_nav_audio.js'
|
||||
export * from './admin_nav_translation.js'
|
||||
export * from './admin_nav_import.js'
|
||||
export * from './admin_nav_changelog.js'
|
||||
export * from './admin_nav_image_gen.js'
|
||||
export * from './admin_nav_text_gen.js'
|
||||
export * from './admin_nav_catalogue_tools.js'
|
||||
export * from './admin_nav_ai_jobs.js'
|
||||
export * from './admin_nav_notifications.js'
|
||||
export * from './admin_nav_feedback.js'
|
||||
export * from './admin_nav_errors.js'
|
||||
export * from './admin_nav_analytics.js'
|
||||
@@ -405,18 +407,6 @@ export * from './admin_audio_no_cache_results.js'
|
||||
export * from './admin_changelog_gitea.js'
|
||||
export * from './admin_changelog_no_releases.js'
|
||||
export * from './admin_changelog_load_error.js'
|
||||
export * from './admin_translation_page_title.js'
|
||||
export * from './admin_translation_heading.js'
|
||||
export * from './admin_translation_tab_enqueue.js'
|
||||
export * from './admin_translation_tab_jobs.js'
|
||||
export * from './admin_translation_filter_placeholder.js'
|
||||
export * from './admin_translation_no_matching.js'
|
||||
export * from './admin_translation_no_jobs.js'
|
||||
export * from './admin_ai_jobs_page_title.js'
|
||||
export * from './admin_ai_jobs_heading.js'
|
||||
export * from './admin_ai_jobs_subheading.js'
|
||||
export * from './admin_text_gen_page_title.js'
|
||||
export * from './admin_text_gen_heading.js'
|
||||
export * from './comments_top.js'
|
||||
export * from './comments_new.js'
|
||||
export * from './comments_posting.js'
|
||||
@@ -453,4 +443,16 @@ export * from './feed_not_logged_in.js'
|
||||
export * from './feed_reader_label.js'
|
||||
export * from './feed_chapters_label.js'
|
||||
export * from './feed_browse_cta.js'
|
||||
export * from './feed_find_users_cta.js'
|
||||
export * from './feed_find_users_cta.js'
|
||||
export * from './admin_translation_page_title.js'
|
||||
export * from './admin_translation_heading.js'
|
||||
export * from './admin_translation_tab_enqueue.js'
|
||||
export * from './admin_translation_tab_jobs.js'
|
||||
export * from './admin_translation_filter_placeholder.js'
|
||||
export * from './admin_translation_no_matching.js'
|
||||
export * from './admin_translation_no_jobs.js'
|
||||
export * from './admin_ai_jobs_page_title.js'
|
||||
export * from './admin_ai_jobs_heading.js'
|
||||
export * from './admin_ai_jobs_subheading.js'
|
||||
export * from './admin_text_gen_page_title.js'
|
||||
export * from './admin_text_gen_heading.js'
|
||||
@@ -26,6 +26,10 @@ const fr_admin_ai_jobs_heading = /** @type {(inputs: Admin_Ai_Jobs_HeadingInputs
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "AI Jobs" |
|
||||
*
|
||||
* @param {Admin_Ai_Jobs_HeadingInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
@@ -37,4 +41,4 @@ export const admin_ai_jobs_heading = /** @type {((inputs?: Admin_Ai_Jobs_Heading
|
||||
if (locale === "id") return id_admin_ai_jobs_heading(inputs)
|
||||
if (locale === "pt") return pt_admin_ai_jobs_heading(inputs)
|
||||
return fr_admin_ai_jobs_heading(inputs)
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,10 @@ const fr_admin_ai_jobs_page_title = /** @type {(inputs: Admin_Ai_Jobs_Page_Title
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "AI Jobs — Admin" |
|
||||
*
|
||||
* @param {Admin_Ai_Jobs_Page_TitleInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
@@ -37,4 +41,4 @@ export const admin_ai_jobs_page_title = /** @type {((inputs?: Admin_Ai_Jobs_Page
|
||||
if (locale === "id") return id_admin_ai_jobs_page_title(inputs)
|
||||
if (locale === "pt") return pt_admin_ai_jobs_page_title(inputs)
|
||||
return fr_admin_ai_jobs_page_title(inputs)
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,10 @@ const fr_admin_ai_jobs_subheading = /** @type {(inputs: Admin_Ai_Jobs_Subheading
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Background AI generation tasks" |
|
||||
*
|
||||
* @param {Admin_Ai_Jobs_SubheadingInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
@@ -37,4 +41,4 @@ export const admin_ai_jobs_subheading = /** @type {((inputs?: Admin_Ai_Jobs_Subh
|
||||
if (locale === "id") return id_admin_ai_jobs_subheading(inputs)
|
||||
if (locale === "pt") return pt_admin_ai_jobs_subheading(inputs)
|
||||
return fr_admin_ai_jobs_subheading(inputs)
|
||||
});
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_import.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_import.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_ImportInputs */
|
||||
|
||||
const en_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Import`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Import`)
|
||||
};
|
||||
|
||||
const id_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Import`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Import`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_import = /** @type {(inputs: Admin_Nav_ImportInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Import`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Import" |
|
||||
*
|
||||
* @param {Admin_Nav_ImportInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_import = /** @type {((inputs?: Admin_Nav_ImportInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_ImportInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_import(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_import(inputs)
|
||||
if (locale === "id") return id_admin_nav_import(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_import(inputs)
|
||||
return fr_admin_nav_import(inputs)
|
||||
});
|
||||
44
ui/src/lib/paraglide/messages/admin_nav_notifications.js
Normal file
44
ui/src/lib/paraglide/messages/admin_nav_notifications.js
Normal file
@@ -0,0 +1,44 @@
|
||||
/* eslint-disable */
|
||||
import { getLocale, experimentalStaticLocale } from '../runtime.js';
|
||||
|
||||
/** @typedef {import('../runtime.js').LocalizedString} LocalizedString */
|
||||
|
||||
/** @typedef {{}} Admin_Nav_NotificationsInputs */
|
||||
|
||||
const en_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notifications`)
|
||||
};
|
||||
|
||||
const ru_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Уведомления`)
|
||||
};
|
||||
|
||||
const id_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notifikasi`)
|
||||
};
|
||||
|
||||
const pt_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notificações`)
|
||||
};
|
||||
|
||||
const fr_admin_nav_notifications = /** @type {(inputs: Admin_Nav_NotificationsInputs) => LocalizedString} */ () => {
|
||||
return /** @type {LocalizedString} */ (`Notifications`)
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Notifications" |
|
||||
*
|
||||
* @param {Admin_Nav_NotificationsInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
*/
|
||||
export const admin_nav_notifications = /** @type {((inputs?: Admin_Nav_NotificationsInputs, options?: { locale?: "en" | "ru" | "id" | "pt" | "fr" }) => LocalizedString) & import('../runtime.js').MessageMetadata<Admin_Nav_NotificationsInputs, { locale?: "en" | "ru" | "id" | "pt" | "fr" }, {}>} */ ((inputs = {}, options = {}) => {
|
||||
const locale = experimentalStaticLocale ?? options.locale ?? getLocale()
|
||||
if (locale === "en") return en_admin_nav_notifications(inputs)
|
||||
if (locale === "ru") return ru_admin_nav_notifications(inputs)
|
||||
if (locale === "id") return id_admin_nav_notifications(inputs)
|
||||
if (locale === "pt") return pt_admin_nav_notifications(inputs)
|
||||
return fr_admin_nav_notifications(inputs)
|
||||
});
|
||||
@@ -26,6 +26,10 @@ const fr_admin_text_gen_heading = /** @type {(inputs: Admin_Text_Gen_HeadingInpu
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Text Generation" |
|
||||
*
|
||||
* @param {Admin_Text_Gen_HeadingInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
@@ -37,4 +41,4 @@ export const admin_text_gen_heading = /** @type {((inputs?: Admin_Text_Gen_Headi
|
||||
if (locale === "id") return id_admin_text_gen_heading(inputs)
|
||||
if (locale === "pt") return pt_admin_text_gen_heading(inputs)
|
||||
return fr_admin_text_gen_heading(inputs)
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,10 @@ const fr_admin_text_gen_page_title = /** @type {(inputs: Admin_Text_Gen_Page_Tit
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Text Gen — Admin" |
|
||||
*
|
||||
* @param {Admin_Text_Gen_Page_TitleInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
@@ -37,4 +41,4 @@ export const admin_text_gen_page_title = /** @type {((inputs?: Admin_Text_Gen_Pa
|
||||
if (locale === "id") return id_admin_text_gen_page_title(inputs)
|
||||
if (locale === "pt") return pt_admin_text_gen_page_title(inputs)
|
||||
return fr_admin_text_gen_page_title(inputs)
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,10 @@ const fr_admin_translation_filter_placeholder = /** @type {(inputs: Admin_Transl
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Filter by slug, lang, or status…" |
|
||||
*
|
||||
* @param {Admin_Translation_Filter_PlaceholderInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
@@ -37,4 +41,4 @@ export const admin_translation_filter_placeholder = /** @type {((inputs?: Admin_
|
||||
if (locale === "id") return id_admin_translation_filter_placeholder(inputs)
|
||||
if (locale === "pt") return pt_admin_translation_filter_placeholder(inputs)
|
||||
return fr_admin_translation_filter_placeholder(inputs)
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,10 @@ const fr_admin_translation_heading = /** @type {(inputs: Admin_Translation_Headi
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Machine Translation" |
|
||||
*
|
||||
* @param {Admin_Translation_HeadingInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
@@ -37,4 +41,4 @@ export const admin_translation_heading = /** @type {((inputs?: Admin_Translation
|
||||
if (locale === "id") return id_admin_translation_heading(inputs)
|
||||
if (locale === "pt") return pt_admin_translation_heading(inputs)
|
||||
return fr_admin_translation_heading(inputs)
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,10 @@ const fr_admin_translation_no_jobs = /** @type {(inputs: Admin_Translation_No_Jo
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "No translation jobs yet." |
|
||||
*
|
||||
* @param {Admin_Translation_No_JobsInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
@@ -37,4 +41,4 @@ export const admin_translation_no_jobs = /** @type {((inputs?: Admin_Translation
|
||||
if (locale === "id") return id_admin_translation_no_jobs(inputs)
|
||||
if (locale === "pt") return pt_admin_translation_no_jobs(inputs)
|
||||
return fr_admin_translation_no_jobs(inputs)
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,10 @@ const fr_admin_translation_no_matching = /** @type {(inputs: Admin_Translation_N
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "No matching jobs." |
|
||||
*
|
||||
* @param {Admin_Translation_No_MatchingInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
@@ -37,4 +41,4 @@ export const admin_translation_no_matching = /** @type {((inputs?: Admin_Transla
|
||||
if (locale === "id") return id_admin_translation_no_matching(inputs)
|
||||
if (locale === "pt") return pt_admin_translation_no_matching(inputs)
|
||||
return fr_admin_translation_no_matching(inputs)
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,10 @@ const fr_admin_translation_page_title = /** @type {(inputs: Admin_Translation_Pa
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Translation — Admin" |
|
||||
*
|
||||
* @param {Admin_Translation_Page_TitleInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
@@ -37,4 +41,4 @@ export const admin_translation_page_title = /** @type {((inputs?: Admin_Translat
|
||||
if (locale === "id") return id_admin_translation_page_title(inputs)
|
||||
if (locale === "pt") return pt_admin_translation_page_title(inputs)
|
||||
return fr_admin_translation_page_title(inputs)
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,10 @@ const fr_admin_translation_tab_enqueue = /** @type {(inputs: Admin_Translation_T
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Enqueue" |
|
||||
*
|
||||
* @param {Admin_Translation_Tab_EnqueueInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
@@ -37,4 +41,4 @@ export const admin_translation_tab_enqueue = /** @type {((inputs?: Admin_Transla
|
||||
if (locale === "id") return id_admin_translation_tab_enqueue(inputs)
|
||||
if (locale === "pt") return pt_admin_translation_tab_enqueue(inputs)
|
||||
return fr_admin_translation_tab_enqueue(inputs)
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,10 @@ const fr_admin_translation_tab_jobs = /** @type {(inputs: Admin_Translation_Tab_
|
||||
};
|
||||
|
||||
/**
|
||||
* | output |
|
||||
* | --- |
|
||||
* | "Jobs" |
|
||||
*
|
||||
* @param {Admin_Translation_Tab_JobsInputs} inputs
|
||||
* @param {{ locale?: "en" | "ru" | "id" | "pt" | "fr" }} options
|
||||
* @returns {LocalizedString}
|
||||
@@ -37,4 +41,4 @@ export const admin_translation_tab_jobs = /** @type {((inputs?: Admin_Translatio
|
||||
if (locale === "id") return id_admin_translation_tab_jobs(inputs)
|
||||
if (locale === "pt") return pt_admin_translation_tab_jobs(inputs)
|
||||
return fr_admin_translation_tab_jobs(inputs)
|
||||
});
|
||||
});
|
||||
@@ -41,4 +41,4 @@ export const profile_theme_cyber = /** @type {((inputs?: Profile_Theme_CyberInpu
|
||||
if (locale === "id") return id_profile_theme_cyber(inputs)
|
||||
if (locale === "pt") return pt_profile_theme_cyber(inputs)
|
||||
return fr_profile_theme_cyber(inputs)
|
||||
});
|
||||
});
|
||||
@@ -41,4 +41,4 @@ export const profile_theme_forest = /** @type {((inputs?: Profile_Theme_ForestIn
|
||||
if (locale === "id") return id_profile_theme_forest(inputs)
|
||||
if (locale === "pt") return pt_profile_theme_forest(inputs)
|
||||
return fr_profile_theme_forest(inputs)
|
||||
});
|
||||
});
|
||||
@@ -41,4 +41,4 @@ export const profile_theme_mono = /** @type {((inputs?: Profile_Theme_MonoInputs
|
||||
if (locale === "id") return id_profile_theme_mono(inputs)
|
||||
if (locale === "pt") return pt_profile_theme_mono(inputs)
|
||||
return fr_profile_theme_mono(inputs)
|
||||
});
|
||||
});
|
||||
@@ -1383,6 +1383,20 @@ export async function revokeUserSession(recordId: string, userId: string): Promi
|
||||
return del.ok || del.status === 204;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session by its auth session ID (the value stored in the cookie).
|
||||
* Used on logout so the row doesn't linger as a phantom active session.
|
||||
*/
|
||||
export async function deleteSessionByAuthId(authSessionId: string): Promise<void> {
|
||||
const row = await listOne<UserSession>('user_sessions', `session_id="${authSessionId}"`);
|
||||
if (!row) return;
|
||||
const token = await getToken();
|
||||
await fetch(`${PB_URL}/api/collections/user_sessions/records/${row.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke all sessions for a user (used on password change etc).
|
||||
*/
|
||||
@@ -2287,10 +2301,17 @@ export async function getUserStats(
|
||||
|
||||
// ─── AI Jobs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const AI_JOBS_CACHE_KEY = 'admin:ai_jobs';
|
||||
const AI_JOBS_CACHE_TTL = 30; // 30 seconds — same as other admin job lists
|
||||
|
||||
/**
|
||||
* List all AI jobs from PocketBase, sorted by started descending.
|
||||
* No caching — admin views always want fresh data.
|
||||
* Short-lived cache (30s) to avoid hammering PocketBase on every navigation.
|
||||
*/
|
||||
export async function listAIJobs(): Promise<AIJob[]> {
|
||||
return listAll<AIJob>('ai_jobs', '', '-started');
|
||||
const cached = await cache.get<AIJob[]>(AI_JOBS_CACHE_KEY);
|
||||
if (cached) return cached;
|
||||
const jobs = await listAll<AIJob>('ai_jobs', '', '-started');
|
||||
await cache.set(AI_JOBS_CACHE_KEY, jobs, AI_JOBS_CACHE_TTL);
|
||||
return jobs;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,52 @@
|
||||
// Universal search
|
||||
let searchOpen = $state(false);
|
||||
|
||||
// Notifications
|
||||
let notificationsOpen = $state(false);
|
||||
let notifications = $state<{id: string; title: string; message: string; link: string; read: boolean}[]>([]);
|
||||
let notifFilter = $state<'all' | 'unread'>('all');
|
||||
async function loadNotifications() {
|
||||
if (!data.user) return;
|
||||
try {
|
||||
const res = await fetch('/api/notifications?user_id=' + data.user.id);
|
||||
if (res.ok) {
|
||||
const d = await res.json();
|
||||
notifications = d.notifications || [];
|
||||
}
|
||||
} catch (e) { console.error('load notifications:', e); }
|
||||
}
|
||||
async function markRead(id: string) {
|
||||
try {
|
||||
await fetch('/api/notifications/' + id, { method: 'PATCH' });
|
||||
notifications = notifications.map(n => n.id === id ? {...n, read: true} : n);
|
||||
} catch (e) { console.error('mark read:', e); }
|
||||
}
|
||||
async function markAllRead() {
|
||||
if (!data.user) return;
|
||||
try {
|
||||
await fetch('/api/notifications?user_id=' + data.user.id, { method: 'PATCH' });
|
||||
notifications = notifications.map(n => ({ ...n, read: true }));
|
||||
} catch (e) { console.error('mark all read:', e); }
|
||||
}
|
||||
async function dismissNotification(id: string) {
|
||||
try {
|
||||
await fetch('/api/notifications/' + id, { method: 'DELETE' });
|
||||
notifications = notifications.filter(n => n.id !== id);
|
||||
} catch (e) { console.error('dismiss notification:', e); }
|
||||
}
|
||||
async function clearAllNotifications() {
|
||||
if (!data.user) return;
|
||||
try {
|
||||
await fetch('/api/notifications?user_id=' + data.user.id, { method: 'DELETE' });
|
||||
notifications = [];
|
||||
} catch (e) { console.error('clear notifications:', e); }
|
||||
}
|
||||
$effect(() => { if (data.user) loadNotifications(); });
|
||||
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||
const filteredNotifications = $derived(
|
||||
notifFilter === 'unread' ? notifications.filter(n => !n.read) : notifications
|
||||
);
|
||||
|
||||
// Close search on navigation
|
||||
$effect(() => {
|
||||
void page.url.pathname;
|
||||
@@ -529,7 +575,7 @@
|
||||
{#if !/\/books\/[^/]+\/chapters\//.test(page.url.pathname)}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; }}
|
||||
onclick={() => { searchOpen = true; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; menuOpen = false; notificationsOpen = false; }}
|
||||
title="Search (/ or ⌘K)"
|
||||
aria-label="Search books"
|
||||
class="flex items-center justify-center w-8 h-8 rounded transition-colors text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)"
|
||||
@@ -539,6 +585,106 @@
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- Notifications bell -->
|
||||
{#if data.user?.role === 'admin'}
|
||||
<div class="relative">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => { notificationsOpen = !notificationsOpen; searchOpen = false; userMenuOpen = false; langMenuOpen = false; themeMenuOpen = false; }}
|
||||
title="Notifications"
|
||||
class="flex items-center justify-center w-8 h-8 rounded transition-colors {notificationsOpen ? 'bg-(--color-surface-2)' : 'hover:bg-(--color-surface-2)'} relative"
|
||||
>
|
||||
<svg class="w-4 h-4 text-(--color-muted)" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"/>
|
||||
</svg>
|
||||
{#if unreadCount > 0}
|
||||
<span class="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
{/if}
|
||||
</button>
|
||||
{#if notificationsOpen}
|
||||
<div class="absolute right-0 top-full mt-1 w-80 bg-(--color-surface-2) border border-(--color-border) rounded-lg shadow-xl z-50 flex flex-col max-h-[28rem]">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-3 pt-3 pb-2 shrink-0">
|
||||
<span class="text-sm font-semibold">Notifications</span>
|
||||
<div class="flex items-center gap-1">
|
||||
{#if unreadCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={markAllRead}
|
||||
class="text-xs text-(--color-muted) hover:text-(--color-text) transition-colors px-1.5 py-0.5 rounded hover:bg-(--color-surface-3)"
|
||||
>Mark all read</button>
|
||||
{/if}
|
||||
{#if notifications.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearAllNotifications}
|
||||
class="text-xs text-(--color-muted) hover:text-red-400 transition-colors px-1.5 py-0.5 rounded hover:bg-(--color-surface-3)"
|
||||
>Clear all</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Filter tabs -->
|
||||
<div class="flex gap-0 px-3 pb-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => notifFilter = 'all'}
|
||||
class="text-xs px-2.5 py-1 rounded-l border border-(--color-border) transition-colors {notifFilter === 'all' ? 'bg-(--color-brand) text-black border-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>All ({notifications.length})</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => notifFilter = 'unread'}
|
||||
class="text-xs px-2.5 py-1 rounded-r border border-l-0 border-(--color-border) transition-colors {notifFilter === 'unread' ? 'bg-(--color-brand) text-black border-(--color-brand)' : 'text-(--color-muted) hover:text-(--color-text)'}"
|
||||
>Unread ({unreadCount})</button>
|
||||
</div>
|
||||
<!-- List -->
|
||||
<div class="overflow-y-auto flex-1 min-h-0">
|
||||
{#if filteredNotifications.length === 0}
|
||||
<div class="p-4 text-center text-(--color-muted) text-sm">
|
||||
{notifFilter === 'unread' ? 'No unread notifications' : 'No notifications'}
|
||||
</div>
|
||||
{:else}
|
||||
{#each filteredNotifications as n (n.id)}
|
||||
<div class="flex items-start gap-1 border-b border-(--color-border)/40 hover:bg-(--color-surface-3) group {n.read ? 'opacity-60' : ''}">
|
||||
<a
|
||||
href={n.link || '/admin'}
|
||||
onclick={() => { markRead(n.id); notificationsOpen = false; }}
|
||||
class="flex-1 p-3 min-w-0"
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if !n.read}
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-(--color-brand) shrink-0"></span>
|
||||
{/if}
|
||||
<span class="text-sm font-medium truncate">{n.title}</span>
|
||||
</div>
|
||||
<div class="text-xs text-(--color-muted) mt-0.5 line-clamp-2">{n.message}</div>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => dismissNotification(n.id)}
|
||||
class="shrink-0 p-2.5 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Footer -->
|
||||
<div class="px-3 py-2 border-t border-(--color-border)/40 shrink-0">
|
||||
<a
|
||||
href="/admin/notifications"
|
||||
onclick={() => notificationsOpen = false}
|
||||
class="block text-center text-xs text-(--color-muted) hover:text-(--color-brand) transition-colors"
|
||||
>View all notifications</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Theme dropdown (desktop) -->
|
||||
<div class="hidden sm:block relative">
|
||||
<button
|
||||
|
||||
@@ -18,6 +18,11 @@
|
||||
label: () => m.admin_nav_translation(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/import',
|
||||
label: () => m.admin_nav_import(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/image-gen',
|
||||
label: () => m.admin_nav_image_gen(),
|
||||
@@ -33,6 +38,11 @@
|
||||
label: () => m.admin_nav_ai_jobs(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/notifications',
|
||||
label: () => m.admin_nav_notifications(),
|
||||
icon: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" />`
|
||||
},
|
||||
{
|
||||
href: '/admin/catalogue-tools',
|
||||
label: () => m.admin_nav_catalogue_tools(),
|
||||
|
||||
@@ -6,7 +6,8 @@ export type { AIJob };
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
const jobs = await listAIJobs().catch((e): AIJob[] => {
|
||||
// Stream jobs so navigation is instant; list populates a moment later.
|
||||
const jobs = listAIJobs().catch((e): AIJob[] => {
|
||||
log.warn('admin/ai-jobs', 'failed to load ai jobs', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import type { AIJob } from '$lib/server/pocketbase';
|
||||
@@ -8,11 +7,11 @@
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let jobs = $state<AIJob[]>(untrack(() => data.jobs));
|
||||
let jobs = $state<AIJob[]>([]);
|
||||
|
||||
// Keep in sync on server reloads
|
||||
// Resolve streamed promise on load and on server reloads (invalidateAll)
|
||||
$effect(() => {
|
||||
jobs = data.jobs;
|
||||
data.jobs.then((resolved) => { jobs = resolved; });
|
||||
});
|
||||
|
||||
// ── Live-poll while any job is in-flight ─────────────────────────────────────
|
||||
@@ -156,22 +155,24 @@
|
||||
};
|
||||
review = r;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
let payload: { pattern?: string; slug?: string; results?: ProposedTitle[] } = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
|
||||
r.pattern = payload.pattern ?? '';
|
||||
r.titles = (payload.results ?? []).map((t: ProposedTitle) => ({ ...t }));
|
||||
r.loading = false;
|
||||
} catch (e) {
|
||||
r.loading = false;
|
||||
r.error = String(e);
|
||||
}
|
||||
} else if (job.kind === 'image-gen') {
|
||||
review = {
|
||||
...r,
|
||||
pattern: payload.pattern ?? '',
|
||||
titles: (payload.results ?? []).map((t: ProposedTitle) => ({ ...t })),
|
||||
loading: false
|
||||
};
|
||||
} catch (e) {
|
||||
review = { ...r, loading: false, error: String(e) };
|
||||
}
|
||||
} else if (job.kind === 'image-gen') {
|
||||
const r: ImageGenReview = {
|
||||
kind: 'image-gen',
|
||||
jobId: job.id,
|
||||
@@ -190,38 +191,40 @@
|
||||
};
|
||||
review = r;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
let payload: {
|
||||
prompt?: string;
|
||||
type?: string;
|
||||
chapter?: number;
|
||||
content_type?: string;
|
||||
image_b64?: string;
|
||||
bytes?: number;
|
||||
} = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
let payload: {
|
||||
prompt?: string;
|
||||
type?: string;
|
||||
chapter?: number;
|
||||
content_type?: string;
|
||||
image_b64?: string;
|
||||
bytes?: number;
|
||||
} = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
|
||||
if (!payload.image_b64) {
|
||||
r.error = 'No image in job payload.';
|
||||
r.loading = false;
|
||||
return;
|
||||
}
|
||||
r.imageType = payload.type ?? 'cover';
|
||||
r.chapter = payload.chapter ?? 0;
|
||||
r.prompt = payload.prompt ?? '';
|
||||
r.contentType = payload.content_type ?? 'image/png';
|
||||
r.bytes = payload.bytes ?? 0;
|
||||
r.imageSrc = `data:${r.contentType};base64,${payload.image_b64}`;
|
||||
r.loading = false;
|
||||
} catch (e) {
|
||||
r.loading = false;
|
||||
r.error = String(e);
|
||||
if (!payload.image_b64) {
|
||||
review = { ...r, error: 'No image in job payload.', loading: false };
|
||||
return;
|
||||
}
|
||||
} else if (job.kind === 'description') {
|
||||
const contentType = payload.content_type ?? 'image/png';
|
||||
review = {
|
||||
...r,
|
||||
imageType: payload.type ?? 'cover',
|
||||
chapter: payload.chapter ?? 0,
|
||||
prompt: payload.prompt ?? '',
|
||||
contentType,
|
||||
bytes: payload.bytes ?? 0,
|
||||
imageSrc: `data:${contentType};base64,${payload.image_b64}`,
|
||||
loading: false
|
||||
};
|
||||
} catch (e) {
|
||||
review = { ...r, loading: false, error: String(e) };
|
||||
}
|
||||
} else if (job.kind === 'description') {
|
||||
const r: DescriptionReview = {
|
||||
kind: 'description',
|
||||
jobId: job.id,
|
||||
@@ -237,28 +240,30 @@
|
||||
};
|
||||
review = r;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
try {
|
||||
const res = await fetch(`/api/admin/ai-jobs/${job.id}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const data = await res.json();
|
||||
|
||||
let payload: {
|
||||
instructions?: string;
|
||||
old_description?: string;
|
||||
new_description?: string;
|
||||
} = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
let payload: {
|
||||
instructions?: string;
|
||||
old_description?: string;
|
||||
new_description?: string;
|
||||
} = {};
|
||||
try { payload = JSON.parse(data.payload ?? '{}'); } catch { /* ignore */ }
|
||||
|
||||
r.instructions = payload.instructions ?? '';
|
||||
r.oldDescription = payload.old_description ?? '';
|
||||
r.newDescription = payload.new_description ?? '';
|
||||
r.loading = false;
|
||||
} catch (e) {
|
||||
r.loading = false;
|
||||
r.error = String(e);
|
||||
}
|
||||
review = {
|
||||
...r,
|
||||
instructions: payload.instructions ?? '',
|
||||
oldDescription: payload.old_description ?? '',
|
||||
newDescription: payload.new_description ?? '',
|
||||
loading: false
|
||||
};
|
||||
} catch (e) {
|
||||
review = { ...r, loading: false, error: String(e) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function closeReview() {
|
||||
review = null;
|
||||
|
||||
@@ -18,23 +18,27 @@ const CACHE_KEY = 'admin:changelog:releases';
|
||||
const CACHE_TTL = 5 * 60; // 5 minutes
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
// Return cached data synchronously (no streaming needed — already fast).
|
||||
const cached = await cache.get<Release[]>(CACHE_KEY);
|
||||
if (cached) {
|
||||
return { releases: cached };
|
||||
return { releases: cached, error: undefined as string | undefined };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(GITEA_RELEASES_URL, {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { releases: [], error: `Gitea API returned ${res.status}` };
|
||||
// Cache miss: stream the external Gitea request so navigation isn't blocked.
|
||||
const releasesPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(GITEA_RELEASES_URL, {
|
||||
headers: { Accept: 'application/json' }
|
||||
});
|
||||
if (!res.ok) return [] as Release[];
|
||||
const releases: Release[] = await res.json();
|
||||
const filtered = releases.filter((r) => !r.draft);
|
||||
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
|
||||
return filtered;
|
||||
} catch {
|
||||
return [] as Release[];
|
||||
}
|
||||
const releases: Release[] = await res.json();
|
||||
const filtered = releases.filter((r) => !r.draft);
|
||||
await cache.set(CACHE_KEY, filtered, CACHE_TTL);
|
||||
return { releases: filtered };
|
||||
} catch (e) {
|
||||
return { releases: [], error: String(e) };
|
||||
}
|
||||
})();
|
||||
|
||||
return { releases: releasesPromise, error: undefined as string | undefined };
|
||||
};
|
||||
|
||||
@@ -32,29 +32,33 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if data.error}
|
||||
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: data.error })}</p>
|
||||
{:else if data.releases.length === 0}
|
||||
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
|
||||
{:else}
|
||||
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
|
||||
{#each data.releases as release}
|
||||
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
|
||||
<div class="flex items-baseline gap-3 flex-wrap">
|
||||
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
|
||||
{#if release.name && release.name !== release.tag_name}
|
||||
<span class="text-sm text-(--color-text)">{release.name}</span>
|
||||
{#await data.releases}
|
||||
<p class="text-sm text-(--color-muted) py-8 text-center">Loading releases…</p>
|
||||
{:then releases}
|
||||
{#if releases.length === 0}
|
||||
<p class="text-sm text-(--color-muted) py-8 text-center">{m.admin_changelog_no_releases()}</p>
|
||||
{:else}
|
||||
<div class="space-y-0 divide-y divide-(--color-border) border border-(--color-border) rounded-xl overflow-hidden">
|
||||
{#each releases as release}
|
||||
<div class="px-5 py-4 bg-(--color-surface) space-y-2">
|
||||
<div class="flex items-baseline gap-3 flex-wrap">
|
||||
<span class="font-mono text-sm font-semibold text-(--color-brand)">{release.tag_name}</span>
|
||||
{#if release.name && release.name !== release.tag_name}
|
||||
<span class="text-sm text-(--color-text)">{release.name}</span>
|
||||
{/if}
|
||||
{#if release.prerelease}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
|
||||
{/if}
|
||||
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
|
||||
</div>
|
||||
{#if release.body.trim()}
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
|
||||
{/if}
|
||||
{#if release.prerelease}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-(--color-surface-3) text-(--color-muted)">pre-release</span>
|
||||
{/if}
|
||||
<span class="text-xs text-(--color-muted) ml-auto">{fmtDate(release.published_at)}</span>
|
||||
</div>
|
||||
{#if release.body.trim()}
|
||||
<p class="text-sm text-(--color-muted) leading-relaxed whitespace-pre-wrap">{release.body.trim()}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{:catch}
|
||||
<p class="text-sm text-(--color-danger)">{m.admin_changelog_load_error({ error: 'Failed to load releases' })}</p>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
@@ -20,23 +20,29 @@ export interface BookSummary {
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// parent layout already guards admin role
|
||||
const [models, booksResult] = await Promise.allSettled([
|
||||
listImageModels<ImageModelInfo>(),
|
||||
listBooks()
|
||||
]);
|
||||
// Await models immediately — the page is unusable without them and the
|
||||
// backend returns this list instantly (in-memory, no I/O).
|
||||
// Books are streamed: the page renders at once and the book selector
|
||||
// populates a moment later without blocking navigation.
|
||||
const modelsResult = await listImageModels<ImageModelInfo>().catch((e) => {
|
||||
log.warn('admin/image-gen', 'failed to load models', { err: String(e) });
|
||||
return [] as ImageModelInfo[];
|
||||
});
|
||||
|
||||
if (models.status === 'rejected') {
|
||||
log.warn('admin/image-gen', 'failed to load models', { err: String(models.reason) });
|
||||
}
|
||||
const booksPromise = listBooks()
|
||||
.then((all) =>
|
||||
all.map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title,
|
||||
summary: b.summary ?? '',
|
||||
cover: b.cover ?? ''
|
||||
})) as BookSummary[]
|
||||
)
|
||||
.catch(() => [] as BookSummary[]);
|
||||
|
||||
return {
|
||||
models: models.status === 'fulfilled' ? models.value : ([] as ImageModelInfo[]),
|
||||
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title,
|
||||
summary: b.summary ?? '',
|
||||
cover: b.cover ?? ''
|
||||
})) as BookSummary[]
|
||||
models: modelsResult,
|
||||
// Streamed — SvelteKit resolves this after the initial HTML is sent.
|
||||
books: booksPromise
|
||||
};
|
||||
};
|
||||
|
||||
@@ -62,8 +62,11 @@
|
||||
});
|
||||
|
||||
// ── Book autocomplete ────────────────────────────────────────────────────────
|
||||
// svelte-ignore state_referenced_locally
|
||||
const books: BookSummary[] = data.books ?? [];
|
||||
// Books arrive as a streamed promise — start empty and populate on resolve.
|
||||
let books = $state<BookSummary[]>([]);
|
||||
$effect(() => {
|
||||
data.books.then((resolved) => { books = resolved; });
|
||||
});
|
||||
let slugInput = $state('');
|
||||
let slugFocused = $state(false);
|
||||
let selectedBook = $state<BookSummary | null>(null);
|
||||
|
||||
479
ui/src/routes/admin/import/+page.svelte
Normal file
479
ui/src/routes/admin/import/+page.svelte
Normal file
@@ -0,0 +1,479 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
interface ImportTask {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
file_name: string;
|
||||
file_type: string;
|
||||
author: string;
|
||||
cover_url: string;
|
||||
genres: string[];
|
||||
summary: string;
|
||||
book_status: string;
|
||||
status: string;
|
||||
chapters_done: number;
|
||||
chapters_total: number;
|
||||
error_message: string;
|
||||
started: string;
|
||||
finished: string;
|
||||
}
|
||||
|
||||
interface PendingImport {
|
||||
file: File;
|
||||
title: string;
|
||||
author: string;
|
||||
coverUrl: string;
|
||||
genres: string;
|
||||
summary: string;
|
||||
bookStatus: string;
|
||||
preview: { chapters: number; firstLines: string[] };
|
||||
}
|
||||
|
||||
let tasks = $state<ImportTask[]>([]);
|
||||
let loading = $state(true);
|
||||
let uploading = $state(false);
|
||||
let analyzing = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// Form fields
|
||||
let selectedFile = $state<File | null>(null);
|
||||
let title = $state('');
|
||||
let author = $state('');
|
||||
let coverUrl = $state('');
|
||||
let genres = $state('');
|
||||
let summary = $state('');
|
||||
let bookStatus = $state('completed');
|
||||
|
||||
let pendingImport = $state<PendingImport | null>(null);
|
||||
|
||||
// AI panel: slug of recently completed import
|
||||
let aiSlug = $state('');
|
||||
let aiTitle = $state('');
|
||||
let showAiPanel = $state(false);
|
||||
|
||||
async function loadTasks() {
|
||||
loading = true;
|
||||
try {
|
||||
const res = await fetch('/api/admin/import');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
tasks = data.tasks || [];
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load tasks:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleFileSelect(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
if (!input.files?.length) return;
|
||||
const file = input.files[0];
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
||||
if (ext !== 'pdf' && ext !== 'epub') {
|
||||
error = 'Please select a PDF or EPUB file';
|
||||
return;
|
||||
}
|
||||
error = '';
|
||||
selectedFile = file;
|
||||
// Auto-fill title from filename if empty
|
||||
if (!title.trim()) {
|
||||
title = file.name.replace(/\.(pdf|epub)$/i, '').replace(/[-_]/g, ' ');
|
||||
}
|
||||
}
|
||||
|
||||
async function analyzeFile() {
|
||||
if (!selectedFile || !title.trim()) return;
|
||||
analyzing = true;
|
||||
error = '';
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', selectedFile);
|
||||
formData.append('title', title.trim());
|
||||
formData.append('analyze', 'true');
|
||||
const res = await fetch('/api/admin/import', { method: 'POST', body: formData });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
pendingImport = {
|
||||
file: selectedFile,
|
||||
title: title.trim(),
|
||||
author: author.trim(),
|
||||
coverUrl: coverUrl.trim(),
|
||||
genres: genres.trim(),
|
||||
summary: summary.trim(),
|
||||
bookStatus,
|
||||
preview: data.preview || { chapters: 0, firstLines: [] }
|
||||
};
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
error = d.error || 'Failed to analyze file';
|
||||
}
|
||||
} catch {
|
||||
error = 'Failed to analyze file';
|
||||
} finally {
|
||||
analyzing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function startImport() {
|
||||
if (!pendingImport) return;
|
||||
uploading = true;
|
||||
error = '';
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', pendingImport.file);
|
||||
formData.append('title', pendingImport.title);
|
||||
formData.append('author', pendingImport.author);
|
||||
formData.append('cover_url', pendingImport.coverUrl);
|
||||
formData.append('genres', pendingImport.genres);
|
||||
formData.append('summary', pendingImport.summary);
|
||||
formData.append('book_status', pendingImport.bookStatus);
|
||||
const res = await fetch('/api/admin/import', { method: 'POST', body: formData });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
// Save for AI panel before clearing state
|
||||
const importedSlug = data.slug || '';
|
||||
const importedTitle = pendingImport.title;
|
||||
// Reset form
|
||||
pendingImport = null;
|
||||
selectedFile = null;
|
||||
title = '';
|
||||
author = '';
|
||||
coverUrl = '';
|
||||
genres = '';
|
||||
summary = '';
|
||||
bookStatus = 'completed';
|
||||
// Show AI panel for this slug
|
||||
aiSlug = importedSlug;
|
||||
aiTitle = importedTitle;
|
||||
showAiPanel = !!aiSlug;
|
||||
await loadTasks();
|
||||
} else {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
error = d.error || 'Import failed';
|
||||
}
|
||||
} catch {
|
||||
error = 'Import failed';
|
||||
} finally {
|
||||
uploading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelReview() {
|
||||
pendingImport = null;
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleString();
|
||||
}
|
||||
|
||||
function statusColor(status: string) {
|
||||
switch (status) {
|
||||
case 'pending': return 'text-yellow-400';
|
||||
case 'running': return 'text-blue-400';
|
||||
case 'done': return 'text-green-400';
|
||||
case 'failed': return 'text-red-400';
|
||||
default: return 'text-(--color-muted)';
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => { loadTasks(); });
|
||||
|
||||
// Poll every 3s while any task is active
|
||||
$effect(() => {
|
||||
const hasActive = tasks.some((t) => t.status === 'running' || t.status === 'pending');
|
||||
if (!hasActive) return;
|
||||
const timer = setInterval(() => { loadTasks(); }, 3000);
|
||||
return () => clearInterval(timer);
|
||||
});
|
||||
|
||||
// When a running task finishes, surface the AI panel for it
|
||||
$effect(() => {
|
||||
if (!showAiPanel) {
|
||||
const done = tasks.find((t) => t.status === 'done');
|
||||
if (done && !aiSlug) {
|
||||
aiSlug = done.slug;
|
||||
aiTitle = done.title;
|
||||
showAiPanel = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="max-w-3xl space-y-8">
|
||||
<h1 class="text-2xl font-bold">Import PDF/EPUB</h1>
|
||||
|
||||
{#if pendingImport}
|
||||
<!-- ── Review step ── -->
|
||||
<div class="p-6 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/30 space-y-4">
|
||||
<h2 class="text-lg font-semibold">Review Import</h2>
|
||||
<dl class="space-y-2 text-sm">
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-(--color-muted) shrink-0">Title</dt>
|
||||
<dd class="font-medium text-right">{pendingImport.title}</dd>
|
||||
</div>
|
||||
{#if pendingImport.author}
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-(--color-muted) shrink-0">Author</dt>
|
||||
<dd class="text-right">{pendingImport.author}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
{#if pendingImport.genres}
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-(--color-muted) shrink-0">Genres</dt>
|
||||
<dd class="text-right">{pendingImport.genres}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-(--color-muted) shrink-0">Status</dt>
|
||||
<dd class="capitalize text-right">{pendingImport.bookStatus}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-(--color-muted) shrink-0">File</dt>
|
||||
<dd class="text-right truncate max-w-xs">{pendingImport.file.name}</dd>
|
||||
</div>
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-(--color-muted) shrink-0">Size</dt>
|
||||
<dd>{(pendingImport.file.size / 1024 / 1024).toFixed(2)} MB</dd>
|
||||
</div>
|
||||
{#if pendingImport.preview.chapters > 0}
|
||||
<div class="flex justify-between gap-4">
|
||||
<dt class="text-(--color-muted) shrink-0">Detected chapters</dt>
|
||||
<dd class="text-green-400 font-semibold">{pendingImport.preview.chapters}</dd>
|
||||
</div>
|
||||
{/if}
|
||||
</dl>
|
||||
{#if pendingImport.preview.firstLines?.length}
|
||||
<div class="mt-2 space-y-1">
|
||||
<p class="text-xs text-(--color-muted) mb-1">First lines preview:</p>
|
||||
{#each pendingImport.preview.firstLines as line}
|
||||
<p class="text-xs text-(--color-muted) italic truncate">{line}</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button
|
||||
onclick={startImport}
|
||||
disabled={uploading}
|
||||
class="px-4 py-2 bg-green-600 hover:bg-green-500 text-white rounded font-medium disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{uploading ? 'Starting…' : 'Start Import'}
|
||||
</button>
|
||||
<button
|
||||
onclick={cancelReview}
|
||||
class="px-4 py-2 border border-(--color-border) rounded font-medium hover:bg-(--color-surface-3) transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- ── Upload form ── -->
|
||||
<form
|
||||
onsubmit={(e) => { e.preventDefault(); analyzeFile(); }}
|
||||
class="p-6 bg-(--color-surface-2) rounded-lg space-y-4"
|
||||
>
|
||||
<!-- File picker -->
|
||||
<div>
|
||||
<label for="import-file" class="block text-sm font-medium mb-1">File (PDF or EPUB)</label>
|
||||
<input
|
||||
id="import-file"
|
||||
type="file"
|
||||
accept=".pdf,.epub"
|
||||
onchange={handleFileSelect}
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div>
|
||||
<label for="import-title" class="block text-sm font-medium mb-1">Title <span class="text-red-400">*</span></label>
|
||||
<input
|
||||
id="import-title"
|
||||
type="text"
|
||||
bind:value={title}
|
||||
placeholder="Book title"
|
||||
required
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Author -->
|
||||
<div>
|
||||
<label for="import-author" class="block text-sm font-medium mb-1">Author</label>
|
||||
<input
|
||||
id="import-author"
|
||||
type="text"
|
||||
bind:value={author}
|
||||
placeholder="Author name"
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Cover URL -->
|
||||
<div>
|
||||
<label for="import-cover" class="block text-sm font-medium mb-1">Cover image URL</label>
|
||||
<input
|
||||
id="import-cover"
|
||||
type="url"
|
||||
bind:value={coverUrl}
|
||||
placeholder="https://…"
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Genres -->
|
||||
<div>
|
||||
<label for="import-genres" class="block text-sm font-medium mb-1">Genres <span class="text-xs text-(--color-muted)">(comma-separated)</span></label>
|
||||
<input
|
||||
id="import-genres"
|
||||
type="text"
|
||||
bind:value={genres}
|
||||
placeholder="Fantasy, Action, Romance"
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div>
|
||||
<label for="import-summary" class="block text-sm font-medium mb-1">Summary</label>
|
||||
<textarea
|
||||
id="import-summary"
|
||||
bind:value={summary}
|
||||
rows={3}
|
||||
placeholder="Short description of the book…"
|
||||
class="w-full px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm resize-y"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<label for="import-status" class="block text-sm font-medium mb-1">Book status</label>
|
||||
<select
|
||||
id="import-status"
|
||||
bind:value={bookStatus}
|
||||
class="px-3 py-2 rounded bg-(--color-surface) border border-(--color-border) text-(--color-text) text-sm"
|
||||
>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="ongoing">Ongoing</option>
|
||||
<option value="hiatus">Hiatus</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<p class="text-sm text-red-400">{error}</p>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={analyzing || !selectedFile || !title.trim()}
|
||||
class="px-5 py-2 bg-(--color-brand) text-(--color-surface) rounded font-semibold disabled:opacity-50 hover:brightness-110 transition-all"
|
||||
>
|
||||
{analyzing ? 'Analyzing…' : 'Review & Import'}
|
||||
</button>
|
||||
<p class="text-xs text-(--color-muted)">Detects chapter structure before committing.</p>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<!-- ── AI Tasks panel (shown after successful import) ── -->
|
||||
{#if showAiPanel && aiSlug}
|
||||
<div class="p-5 bg-(--color-surface-2) rounded-lg border border-(--color-brand)/20 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h2 class="text-base font-semibold">AI Tasks for <span class="text-(--color-brand)">{aiTitle || aiSlug}</span></h2>
|
||||
<button
|
||||
onclick={() => { showAiPanel = false; }}
|
||||
class="text-(--color-muted) hover:text-(--color-text) text-lg leading-none"
|
||||
aria-label="Dismiss"
|
||||
>×</button>
|
||||
</div>
|
||||
<p class="text-sm text-(--color-muted)">Run AI tasks on the imported book to enrich it:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a
|
||||
href="/admin/text-gen?slug={aiSlug}&tab=chapters"
|
||||
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
||||
>
|
||||
Generate chapter names
|
||||
</a>
|
||||
<a
|
||||
href="/admin/text-gen?slug={aiSlug}&tab=description"
|
||||
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
||||
>
|
||||
Generate description
|
||||
</a>
|
||||
<a
|
||||
href="/admin/image-gen?slug={aiSlug}"
|
||||
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
||||
>
|
||||
Generate cover image
|
||||
</a>
|
||||
<a
|
||||
href="/admin/text-gen?slug={aiSlug}&tab=tagline"
|
||||
class="px-3 py-1.5 text-sm rounded bg-(--color-surface-3) hover:bg-(--color-brand)/20 border border-(--color-border) transition-colors"
|
||||
>
|
||||
Generate tagline
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- ── Task list ── -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-3">Import Tasks</h2>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-(--color-muted) text-sm">Loading…</p>
|
||||
{:else if tasks.length === 0}
|
||||
<p class="text-(--color-muted) text-sm">No import tasks yet.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto rounded-lg border border-(--color-border)">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-left text-(--color-muted) border-b border-(--color-border) bg-(--color-surface-2)">
|
||||
<th class="px-3 py-2 font-medium">Title</th>
|
||||
<th class="px-3 py-2 font-medium">Type</th>
|
||||
<th class="px-3 py-2 font-medium">Status</th>
|
||||
<th class="px-3 py-2 font-medium">Chapters</th>
|
||||
<th class="px-3 py-2 font-medium">Started</th>
|
||||
<th class="px-3 py-2 font-medium">AI</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tasks as task}
|
||||
<tr class="border-b border-(--color-border)/50 hover:bg-(--color-surface-2)/50">
|
||||
<td class="px-3 py-2">
|
||||
<div class="font-medium">{task.title}</div>
|
||||
<div class="text-xs text-(--color-muted)">{task.slug}</div>
|
||||
{#if task.error_message}
|
||||
<div class="text-xs text-red-400 mt-0.5 truncate max-w-xs" title={task.error_message}>{task.error_message}</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-3 py-2 uppercase text-xs">{task.file_type}</td>
|
||||
<td class="px-3 py-2 {statusColor(task.status)} font-medium">{task.status}</td>
|
||||
<td class="px-3 py-2 text-(--color-muted)">
|
||||
{task.chapters_done}/{task.chapters_total}
|
||||
</td>
|
||||
<td class="px-3 py-2 text-(--color-muted) text-xs whitespace-nowrap">{formatDate(task.started)}</td>
|
||||
<td class="px-3 py-2">
|
||||
{#if task.status === 'done'}
|
||||
<button
|
||||
onclick={() => { aiSlug = task.slug; aiTitle = task.title; showAiPanel = true; }}
|
||||
class="text-xs px-2 py-1 rounded bg-(--color-brand)/20 hover:bg-(--color-brand)/40 text-(--color-brand) transition-colors"
|
||||
>
|
||||
AI tasks
|
||||
</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
16
ui/src/routes/admin/notifications/+page.server.ts
Normal file
16
ui/src/routes/admin/notifications/+page.server.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
const userId = locals.user!.id;
|
||||
try {
|
||||
const res = await backendFetch('/api/notifications?user_id=' + userId);
|
||||
const data = await res.json().catch(() => ({ notifications: [] }));
|
||||
return {
|
||||
userId,
|
||||
notifications: (data.notifications ?? []) as Array<{id: string; title: string; message: string; link: string; read: boolean}>
|
||||
};
|
||||
} catch {
|
||||
return { userId, notifications: [] };
|
||||
}
|
||||
};
|
||||
127
ui/src/routes/admin/notifications/+page.svelte
Normal file
127
ui/src/routes/admin/notifications/+page.svelte
Normal file
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
type Notification = { id: string; title: string; message: string; link: string; read: boolean };
|
||||
|
||||
let notifications = $state<Notification[]>(data.notifications);
|
||||
let filter = $state<'all' | 'unread'>('all');
|
||||
let busy = $state(false);
|
||||
|
||||
const filtered = $derived(
|
||||
filter === 'unread' ? notifications.filter(n => !n.read) : notifications
|
||||
);
|
||||
const unreadCount = $derived(notifications.filter(n => !n.read).length);
|
||||
|
||||
async function markRead(id: string) {
|
||||
await fetch('/api/notifications/' + id, { method: 'PATCH' }).catch(() => {});
|
||||
notifications = notifications.map(n => n.id === id ? { ...n, read: true } : n);
|
||||
}
|
||||
|
||||
async function dismiss(id: string) {
|
||||
await fetch('/api/notifications/' + id, { method: 'DELETE' }).catch(() => {});
|
||||
notifications = notifications.filter(n => n.id !== id);
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
busy = true;
|
||||
try {
|
||||
await fetch('/api/notifications?user_id=' + data.userId, { method: 'PATCH' });
|
||||
notifications = notifications.map(n => ({ ...n, read: true }));
|
||||
} finally { busy = false; }
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
if (!confirm('Clear all notifications?')) return;
|
||||
busy = true;
|
||||
try {
|
||||
await fetch('/api/notifications?user_id=' + data.userId, { method: 'DELETE' });
|
||||
notifications = [];
|
||||
} finally { busy = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Notifications — Admin</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto px-4 py-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-xl font-semibold">Notifications</h1>
|
||||
{#if unreadCount > 0}
|
||||
<p class="text-sm text-(--color-muted) mt-0.5">{unreadCount} unread</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{#if unreadCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={markAllRead}
|
||||
disabled={busy}
|
||||
class="text-sm px-3 py-1.5 rounded border border-(--color-border) text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2) transition-colors disabled:opacity-50"
|
||||
>Mark all read</button>
|
||||
{/if}
|
||||
{#if notifications.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
onclick={clearAll}
|
||||
disabled={busy}
|
||||
class="text-sm px-3 py-1.5 rounded border border-(--color-border) text-red-400 hover:bg-(--color-surface-2) transition-colors disabled:opacity-50"
|
||||
>Clear all</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter tabs -->
|
||||
<div class="flex gap-0 mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filter = 'all'}
|
||||
class="text-sm px-4 py-1.5 rounded-l border border-(--color-border) transition-colors {filter === 'all' ? 'bg-(--color-brand) text-black border-(--color-brand) font-medium' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'}"
|
||||
>All ({notifications.length})</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => filter = 'unread'}
|
||||
class="text-sm px-4 py-1.5 rounded-r border border-l-0 border-(--color-border) transition-colors {filter === 'unread' ? 'bg-(--color-brand) text-black border-(--color-brand) font-medium' : 'text-(--color-muted) hover:text-(--color-text) hover:bg-(--color-surface-2)'}"
|
||||
>Unread ({unreadCount})</button>
|
||||
</div>
|
||||
|
||||
<!-- List -->
|
||||
{#if filtered.length === 0}
|
||||
<div class="py-16 text-center text-(--color-muted)">
|
||||
{filter === 'unread' ? 'No unread notifications' : 'No notifications'}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="rounded-lg border border-(--color-border) overflow-hidden">
|
||||
{#each filtered as n (n.id)}
|
||||
<div class="flex items-start gap-2 border-b border-(--color-border)/40 last:border-b-0 hover:bg-(--color-surface-2) group transition-colors {n.read ? 'opacity-60' : ''}">
|
||||
<a
|
||||
href={n.link || '/admin'}
|
||||
onclick={() => markRead(n.id)}
|
||||
class="flex-1 p-4 min-w-0"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
{#if !n.read}
|
||||
<span class="w-2 h-2 rounded-full bg-(--color-brand) shrink-0"></span>
|
||||
{/if}
|
||||
<span class="font-medium text-sm">{n.title}</span>
|
||||
</div>
|
||||
<p class="text-sm text-(--color-muted) mt-1">{n.message}</p>
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => dismiss(n.id)}
|
||||
class="shrink-0 p-3 text-(--color-muted) hover:text-red-400 opacity-0 group-hover:opacity-100 transition-all"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -17,21 +17,23 @@ export interface TextModelInfo {
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
// Parent layout already guards admin role.
|
||||
const [models, booksResult] = await Promise.allSettled([
|
||||
listTextModels<TextModelInfo>(),
|
||||
listBooks()
|
||||
]);
|
||||
// Await models immediately — in-memory list, no I/O, returns instantly.
|
||||
// Books are streamed so the page renders at once and the selector
|
||||
// populates a moment later without blocking navigation.
|
||||
const modelsResult = await listTextModels<TextModelInfo>().catch((e) => {
|
||||
log.warn('admin/text-gen', 'failed to load models', { err: String(e) });
|
||||
return [] as TextModelInfo[];
|
||||
});
|
||||
|
||||
if (models.status === 'rejected') {
|
||||
log.warn('admin/text-gen', 'failed to load models', { err: String(models.reason) });
|
||||
}
|
||||
const booksPromise = listBooks()
|
||||
.then((all) =>
|
||||
all.map((b) => ({ slug: b.slug, title: b.title })) as BookSummary[]
|
||||
)
|
||||
.catch(() => [] as BookSummary[]);
|
||||
|
||||
return {
|
||||
models: models.status === 'fulfilled' ? models.value : ([] as TextModelInfo[]),
|
||||
books: (booksResult.status === 'fulfilled' ? booksResult.value : []).map((b) => ({
|
||||
slug: b.slug,
|
||||
title: b.title
|
||||
})) as BookSummary[]
|
||||
models: modelsResult,
|
||||
// Streamed — SvelteKit resolves this after the initial HTML is sent.
|
||||
books: booksPromise
|
||||
};
|
||||
};
|
||||
|
||||
@@ -9,8 +9,11 @@
|
||||
// Server data is static per page load — intentional one-time snapshot.
|
||||
// svelte-ignore state_referenced_locally
|
||||
const models: TextModelInfo[] = data.models ?? [];
|
||||
// svelte-ignore state_referenced_locally
|
||||
const books: BookSummary[] = data.books ?? [];
|
||||
// Books arrive as a streamed promise — start empty and populate on resolve.
|
||||
let books = $state<BookSummary[]>([]);
|
||||
$effect(() => {
|
||||
data.books.then((resolved) => { books = resolved; });
|
||||
});
|
||||
|
||||
// ── Config persistence ───────────────────────────────────────────────────────
|
||||
const CONFIG_KEY = 'admin_text_gen_config_v2';
|
||||
|
||||
@@ -9,16 +9,18 @@ export const load: PageServerLoad = async ({ locals }) => {
|
||||
redirect(302, '/');
|
||||
}
|
||||
|
||||
const [books, jobs] = await Promise.all([
|
||||
listBookSlugs().catch((e): Awaited<ReturnType<typeof listBookSlugs>> => {
|
||||
log.warn('admin/translation', 'failed to load book slugs', { err: String(e) });
|
||||
return [];
|
||||
}),
|
||||
listTranslationJobs().catch((e): TranslationJob[] => {
|
||||
log.warn('admin/translation', 'failed to load translation jobs', { err: String(e) });
|
||||
return [];
|
||||
})
|
||||
]);
|
||||
// Stream jobs — navigation is instant, list populates shortly after.
|
||||
const jobs = listTranslationJobs().catch((e): TranslationJob[] => {
|
||||
log.warn('admin/translation', 'failed to load translation jobs', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
|
||||
// Books list is needed immediately for the enqueue form, but use cache so
|
||||
// it's fast on repeat visits.
|
||||
const books = await listBookSlugs().catch((e): Awaited<ReturnType<typeof listBookSlugs>> => {
|
||||
log.warn('admin/translation', 'failed to load book slugs', { err: String(e) });
|
||||
return [];
|
||||
});
|
||||
|
||||
return { books, jobs };
|
||||
};
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
import type { TranslationJob } from '$lib/server/pocketbase';
|
||||
@@ -7,11 +6,11 @@
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let jobs = $state<TranslationJob[]>(untrack(() => data.jobs));
|
||||
let jobs = $state<TranslationJob[]>([]);
|
||||
|
||||
// Keep in sync on server reloads
|
||||
// Resolve streamed promise; re-runs on server reloads (invalidateAll)
|
||||
$effect(() => {
|
||||
jobs = data.jobs;
|
||||
Promise.resolve(data.jobs).then((resolved) => { jobs = resolved; });
|
||||
});
|
||||
|
||||
// ── Live-poll while any job is in-flight ─────────────────────────────────────
|
||||
|
||||
57
ui/src/routes/api/admin/import/+server.ts
Normal file
57
ui/src/routes/api/admin/import/+server.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
/**
|
||||
* GET /api/admin/import
|
||||
* List all import tasks.
|
||||
*/
|
||||
export const GET: RequestHandler = async ({ locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
const res = await backendFetch('/api/admin/import', { method: 'GET' });
|
||||
const data = await res.json().catch(() => ({ tasks: [] }));
|
||||
return json(data);
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/admin/import
|
||||
* Create a new import task. Supports both multipart/form-data (file upload)
|
||||
* and application/json (object key reference).
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ request, locals }) => {
|
||||
if (!locals.user || locals.user.role !== 'admin') {
|
||||
throw error(403, 'Forbidden');
|
||||
}
|
||||
|
||||
const ct = request.headers.get('content-type') ?? '';
|
||||
|
||||
let res: Response;
|
||||
|
||||
if (ct.includes('multipart/form-data')) {
|
||||
// Forward the raw FormData body; let the browser-set Content-Type
|
||||
// (which includes the boundary) pass through unchanged.
|
||||
const formData = await request.formData();
|
||||
res = await backendFetch('/api/admin/import', {
|
||||
method: 'POST',
|
||||
// Do NOT set Content-Type manually — the fetch API sets it
|
||||
// automatically with the correct boundary when given a FormData body.
|
||||
body: formData
|
||||
});
|
||||
} else {
|
||||
const body = await request.json();
|
||||
res = await backendFetch('/api/admin/import', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ error: 'Failed to create import task' }));
|
||||
throw error(res.status, err.error || 'Failed to create import task');
|
||||
}
|
||||
const data = await res.json();
|
||||
return json(data);
|
||||
};
|
||||
@@ -1,15 +1,24 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { parseAuthToken } from '../../../../hooks.server.js';
|
||||
import { deleteSessionByAuthId } from '$lib/server/pocketbase';
|
||||
|
||||
const AUTH_COOKIE = 'libnovel_auth';
|
||||
|
||||
/**
|
||||
* POST /api/auth/logout
|
||||
* Clears the auth cookie and returns { ok: true }.
|
||||
* Does not revoke the session record from PocketBase —
|
||||
* for full revocation use DELETE /api/sessions/[id] first.
|
||||
* Deletes the session row from PocketBase AND clears the auth cookie, so the
|
||||
* session doesn't linger as a phantom "active session" after sign-out.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ cookies }) => {
|
||||
const token = cookies.get(AUTH_COOKIE);
|
||||
if (token) {
|
||||
const user = parseAuthToken(token);
|
||||
if (user?.authSessionId) {
|
||||
// Best-effort — non-fatal if PocketBase is unreachable.
|
||||
deleteSessionByAuthId(user.authSessionId).catch(() => {});
|
||||
}
|
||||
}
|
||||
cookies.delete(AUTH_COOKIE, { path: '/' });
|
||||
return json({ ok: true });
|
||||
};
|
||||
|
||||
29
ui/src/routes/api/notifications/+server.ts
Normal file
29
ui/src/routes/api/notifications/+server.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const GET: RequestHandler = async ({ url }) => {
|
||||
const userId = url.searchParams.get('user_id');
|
||||
if (!userId) throw error(400, 'user_id required');
|
||||
const res = await backendFetch('/api/notifications?user_id=' + userId);
|
||||
const data = await res.json().catch(() => ({ notifications: [] }));
|
||||
return json(data);
|
||||
};
|
||||
|
||||
// PATCH /api/notifications?user_id=<id> — mark all read
|
||||
export const PATCH: RequestHandler = async ({ url }) => {
|
||||
const userId = url.searchParams.get('user_id');
|
||||
if (!userId) throw error(400, 'user_id required');
|
||||
const res = await backendFetch('/api/notifications?user_id=' + userId, { method: 'PATCH' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data);
|
||||
};
|
||||
|
||||
// DELETE /api/notifications?user_id=<id> — clear all
|
||||
export const DELETE: RequestHandler = async ({ url }) => {
|
||||
const userId = url.searchParams.get('user_id');
|
||||
if (!userId) throw error(400, 'user_id required');
|
||||
const res = await backendFetch('/api/notifications?user_id=' + userId, { method: 'DELETE' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data);
|
||||
};
|
||||
20
ui/src/routes/api/notifications/[id]/+server.ts
Normal file
20
ui/src/routes/api/notifications/[id]/+server.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { json, error } from '@sveltejs/kit';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { backendFetch } from '$lib/server/scraper';
|
||||
|
||||
export const PATCH: RequestHandler = async ({ params }) => {
|
||||
const id = params.id;
|
||||
if (!id) throw error(400, 'id required');
|
||||
const res = await backendFetch('/api/notifications/' + id, { method: 'PATCH' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data);
|
||||
};
|
||||
|
||||
// DELETE /api/notifications/[id] — dismiss a single notification
|
||||
export const DELETE: RequestHandler = async ({ params }) => {
|
||||
const id = params.id;
|
||||
if (!id) throw error(400, 'id required');
|
||||
const res = await backendFetch('/api/notifications/' + id, { method: 'DELETE' });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
return json(data);
|
||||
};
|
||||
@@ -3,7 +3,10 @@ import adapter from '@sveltejs/adapter-node';
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
adapter: adapter(),
|
||||
paths: {
|
||||
relative: false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user