Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
9c1bedd
Added biblioreads support
svera Oct 29, 2026
5c8e4e5
Fix tests
svera Oct 29, 2026
56ecf9e
Store more fields
svera Oct 29, 2026
962ae9f
Retrieve author info from biblioreads
svera Oct 29, 2026
663c3e6
Add goodreads author link, migrate authors index to v2
svera Oct 29, 2026
90516c9
Use goodreads icon
svera Oct 29, 2026
5e52b9c
erge branch 'develop' of github.com:svera/coreander into biblioreads
svera Oct 30, 2026
ab3fe77
Use interface for reviews and links services
svera Oct 30, 2026
a45fc3f
Improve title recognition in goodreads
svera Oct 30, 2026
201696f
Simplified changes
svera Oct 30, 2026
ac2e465
Improve biblioreads request performance, review language dropdown
svera Oct 31, 2026
669eed5
Simplify
svera Oct 31, 2026
7b5c9e1
Fix title matching
svera Nov 1, 2026
d141e84
WIP
svera Nov 2, 2026
b8f2073
WIP
svera Nov 2, 2026
bde5fb5
WIP
svera Nov 2, 2026
cd39940
WIP
svera Nov 2, 2026
0d57615
WIP
svera Nov 3, 2026
1a9473b
WIP
svera Nov 3, 2026
30e37a3
WIP
svera Nov 3, 2026
2ae6d69
WIP
svera Nov 3, 2026
8d65382
WIP
svera Nov 3, 2026
d28c722
WIP
svera Nov 3, 2026
a2268a7
WIP
svera Nov 3, 2026
80a5df1
WIP
svera Nov 3, 2026
0857c0e
WIP
svera Nov 3, 2026
15c1d7e
WIP
svera Nov 3, 2026
6375109
WIP
svera Nov 4, 2026
7887f24
Rename to biblioreads
svera Nov 4, 2026
107a0f8
Refactor
svera Nov 4, 2026
8a13245
Refactor
svera Nov 4, 2026
93b7ab2
Refactor
svera Nov 4, 2026
42b3dc9
Refactor
svera Nov 4, 2026
39521ab
Refactor
svera Nov 4, 2026
1eceb35
Refactor
svera Nov 4, 2026
31df204
Refactor
svera Nov 4, 2026
ddf41cd
Added progress bar for biblioreads
svera Nov 4, 2026
7b41fea
Added progress bar for biblioreads
svera Nov 4, 2026
02707d0
Added progress bar for biblioreads
svera Nov 4, 2026
f3d56c6
Code review fixes
svera Nov 4, 2026
62e9e95
Code review fixes
svera Nov 4, 2026
f2f6026
Code review fixes
svera Nov 4, 2026
704d9a8
Added docs index migration from v13
svera Nov 5, 2026
3967e78
Fix docs index migration
svera Nov 5, 2026
3e315eb
Hide score filters
svera Nov 5, 2026
d8761f0
Consolidate file
svera Nov 5, 2026
6beb9c4
WIP
svera Nov 5, 2026
4af5758
Hide score sorting
svera Nov 5, 2026
35c83f4
Consolidation
svera Nov 5, 2026
6c07ad1
A failed document lookup no longer cancels the batch
svera Nov 5, 2026
ddeeb56
Changed readme
svera Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ A personal documents server, Coreander indexes the documents (EPUBs and PDFs wit
* Upload documents through the web interface.
* Download as kepub (epub for Kobo devices) converted on the fly thanks to [Kepubify](https://github.com/pgaskin/kepubify).
* Gather information about authors from [Wikidata](https://wikidata.org).
* Show Goodreads ratings and book and author links through [BiblioReads](#biblioreads-integration).

## Installation

Expand Down Expand Up @@ -85,6 +86,18 @@ Refer to [Caddy documentation](https://caddyserver.com/docs) for more informatio

When using a reverse proxy, include its client-facing port in `FQDN`, for example `FQDN=coreander.example.com:443` for the HTTPS setup above (or `:80` for HTTP), so generated links do not use Coreander's internal listening port.

### BiblioReads integration

Coreander can use [BiblioReads](https://github.com/nesaku/biblioreads), an alternative Goodreads front-end, to find Goodreads book and author links and display book ratings on document detail pages. Lookups are disabled by default and require explicitly configured endpoints. BiblioReads asks API and automation users to host their own instance; configure an instance you host, or one whose operator explicitly permits automated library-wide requests. No API key or Goodreads account is required. **You can find a list of public endpoints at https://github.com/nesaku/BiblioReads#instances**.

To enable lookups, pass your permitted endpoints as a comma-separated list. Requests are spread across only these endpoints, with automatic failover when one is unavailable. Configuring multiple permitted instances can improve lookup throughput by spreading requests across them; actual performance depends on each instance's availability and capacity. There are no built-in public endpoints or public-instance fallback:

```sh
coreander --review-source-instances=https://br.example.org,https://br2.example.org /absolute/path/to/library
```

Alternatively, set `REVIEW_SOURCE_INSTANCES` to the same comma-separated list. The flag may also be repeated and overrides the environment variable. Instance URLs must use HTTP or HTTPS and serve the BiblioReads API.

### Email

Some features rely on having an SMTP email service set up, and won't be available otherwise:
Expand Down Expand Up @@ -175,6 +188,8 @@ In case both a flag and its equivalent environment variable are passed, flag tak
|`--client-dynamic-image-cache-ttl` |`CLIENT_DYNAMIC_IMAGE_CACHE_TTL` | Client-side cache duration for dynamically generated images (covers, author images) in seconds. Defaults to 86400 (24 hours).
|`--server-static-cache-ttl` |`SERVER_STATIC_CACHE_TTL` | Server-side cache duration for static assets (CSS, JS, images) in seconds. Defaults to 31536000 (1 year).
|`--server-dynamic-image-cache-ttl` |`SERVER_DYNAMIC_IMAGE_CACHE_TTL` | Server-side cache duration for dynamically generated images (covers, author images) in seconds. Defaults to 86400 (24 hours).
|`--review-source-instances` |`REVIEW_SOURCE_INSTANCES` | Comma-separated HTTP or HTTPS BiblioReads endpoints you host or whose operators permit automation. Multiple instances can improve lookup throughput. Required to enable lookups; defaults to none. Omit and unset the environment variable to disable lookups. The flag may be repeated. For example: `--review-source-instances=http://localhost:8080`.
|`--review-source-cache-ttl` |`REVIEW_SOURCE_CACHE_TTL` | Duration in seconds for which review-source responses are cached in memory. Set to 0 to disable caching. Defaults to 86400 (24 hours).
|`-f` or `--force-indexing` |`FORCE_INDEXING` | Whether to force indexing already indexed documents or not. Defaults to false.
|`-s` or `--jwt-secret` |`JWT_SECRET` | String to use to sign JWTs.
|`-a` or `--require-auth` |`REQUIRE_AUTH` | Require authentication to access the application if true. Defaults to false.
Expand Down
765 changes: 765 additions & 0 deletions biblioreads.json

Large diffs are not rendered by default.

17 changes: 16 additions & 1 deletion cli_input.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"fmt"
"net/url"

"github.com/alecthomas/kong"
)
Expand Down Expand Up @@ -81,7 +82,10 @@ type CLIInput struct {
// ServerStaticCacheTTL defines the server-side cache duration for static assets (CSS, JS, images) in seconds. Defaults to 1 year.
ServerStaticCacheTTL int `env:"SERVER_STATIC_CACHE_TTL" default:"31536000" name:"server-static-cache-ttl" help:"Server-side cache duration for static assets (CSS, JS, images) in seconds. Defaults to 1 year (31536000 seconds)."`
// ServerDynamicImageCacheTTL defines the server-side cache duration for dynamically generated images (covers, author images) in seconds. Defaults to 24 hours.
ServerDynamicImageCacheTTL int `env:"SERVER_DYNAMIC_IMAGE_CACHE_TTL" default:"86400" name:"server-dynamic-image-cache-ttl" help:"Server-side cache duration for dynamically generated images (covers, author images) in seconds. Defaults to 24 hours (86400 seconds)."`
ServerDynamicImageCacheTTL int `env:"SERVER_DYNAMIC_IMAGE_CACHE_TTL" default:"86400" name:"server-dynamic-image-cache-ttl" help:"Server-side cache duration for dynamically generated images (covers, author images) in seconds. Defaults to 24 hours (86400 seconds)."`
ReviewSourceInstances []string `env:"REVIEW_SOURCE_INSTANCES" name:"review-source-instances" sep:"," help:"Comma-separated HTTP or HTTPS BiblioReads endpoints you host or whose operators permit automation. Multiple instances can improve lookup throughput. Required to enable review lookups; omit to disable. May be repeated."`
// ReviewSourceCacheTTL defines how long review-source responses are cached in memory, in seconds. Defaults to 24 hours.
ReviewSourceCacheTTL int `env:"REVIEW_SOURCE_CACHE_TTL" default:"86400" name:"review-source-cache-ttl" help:"Duration in seconds for which review-source responses are cached in memory. Set to 0 to disable caching. Defaults to 24 hours (86400 seconds)."`
// ShareCommentMaxSize defines the maximum length for share comments in characters. Defaults to 280.
ShareCommentMaxSize int `env:"SHARE_COMMENT_MAX_SIZE" short:"m" default:"280" name:"share-comment-max-size" help:"Maximum length for share comments in characters. Defaults to 280."`
// ShareMaxRecipients defines the maximum number of recipients allowed when sharing a document. Defaults to 10.
Expand Down Expand Up @@ -120,6 +124,17 @@ func (c CLIInput) Validate() error {
if c.ShareCommentMaxSize < 0 {
return fmt.Errorf("share-comment-max-size must not be negative, got %v", c.ShareCommentMaxSize)
}
// ReviewSourceCacheTTL must not be negative: 0 is the documented way to
// disable caching, so a negative value is most likely a typo.
if c.ReviewSourceCacheTTL < 0 {
return fmt.Errorf("review-source-cache-ttl must not be negative, got %v", c.ReviewSourceCacheTTL)
}
for _, instance := range c.ReviewSourceInstances {
u, err := url.Parse(instance)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
return fmt.Errorf("review-source-instances must contain absolute HTTP or HTTPS URLs, got %q", instance)
}
}
// MinPasswordLength must be positive: 0 or negative disables the minimum
// length check entirely, silently allowing empty passwords.
if c.MinPasswordLength <= 0 {
Expand Down
121 changes: 121 additions & 0 deletions cli_input_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package main

import (
"os"
"reflect"
"strings"
"testing"

"github.com/alecthomas/kong"
)

func TestReviewSourceInstances(t *testing.T) {
tests := []struct {
name string
args []string
env string
want []string
}{
{name: "defaults"},
{name: "single", args: []string{"--review-source-instances=https://br.example.org"}, want: []string{"https://br.example.org"}},
{name: "comma separated", args: []string{"--review-source-instances=https://br.example.org,http://localhost:8080/"}, want: []string{"https://br.example.org", "http://localhost:8080/"}},
{name: "repeated", args: []string{"--review-source-instances=https://br.example.org", "--review-source-instances=https://br2.example.org"}, want: []string{"https://br.example.org", "https://br2.example.org"}},
{name: "environment", env: "https://br.example.org,https://br2.example.org", want: []string{"https://br.example.org", "https://br2.example.org"}},
{name: "flag overrides environment", env: "https://env.example.org", args: []string{"--review-source-instances=https://cli.example.org"}, want: []string{"https://cli.example.org"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("REVIEW_SOURCE_INSTANCES", tt.env)
var input CLIInput
parser := newCLIParser(t, &input)
if _, err := parser.Parse(append(tt.args, t.TempDir())); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(input.ReviewSourceInstances, tt.want) {
t.Errorf("instances = %v, want %v", input.ReviewSourceInstances, tt.want)
}
})
}
}

func TestReviewSourceInstancesRejectInvalidURLs(t *testing.T) {
for _, instance := range []string{"br.example.org", "/relative", "ftp://br.example.org", "https://", "https://bad host", "https://br.example.org,invalid"} {
t.Run(instance, func(t *testing.T) {
var input CLIInput
parser := newCLIParser(t, &input)
_, err := parser.Parse([]string{"--review-source-instances=" + instance, t.TempDir()})
if err == nil || !strings.Contains(err.Error(), "review-source-instances") {
t.Fatalf("expected review-source-instances validation error, got %v", err)
}
})
}
}

func TestReviewSourceSettings(t *testing.T) {
for _, tt := range []struct {
name string
args []string
ttl string
wantTTL int
}{
{name: "defaults", wantTTL: 86400},
{name: "flags", args: []string{"--review-source-cache-ttl=120"}, wantTTL: 120},
{name: "environment", ttl: "300", wantTTL: 300},
{name: "flags override environment", ttl: "300", args: []string{"--review-source-cache-ttl=0"}},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("REVIEW_SOURCE_CACHE_TTL", tt.ttl)
for key, value := range map[string]string{
"REVIEW_SOURCE_CACHE_TTL": tt.ttl,
} {
if value == "" {
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
}
}
var input CLIInput
parser := newCLIParser(t, &input)
if _, err := parser.Parse(append(tt.args, t.TempDir())); err != nil {
t.Fatal(err)
}
if input.ReviewSourceCacheTTL != tt.wantTTL {
t.Fatalf("TTL = %d, want %d", input.ReviewSourceCacheTTL, tt.wantTTL)
}
})
}
}

func TestDisableReviewsFlagRemoved(t *testing.T) {
var input CLIInput
parser := newCLIParser(t, &input)
if _, err := parser.Parse([]string{"--disable-reviews", t.TempDir()}); err == nil ||
!strings.Contains(err.Error(), "unknown flag") {
t.Fatalf("expected removed flag to be rejected, got %v", err)
}
}

func TestReviewSourceCacheTTLRejectsNegative(t *testing.T) {
var input CLIInput
parser := newCLIParser(t, &input)
_, err := parser.Parse([]string{"--review-source-cache-ttl=-1", t.TempDir()})
if err == nil || !strings.Contains(err.Error(), "review-source-cache-ttl") {
t.Fatalf("expected review-source-cache-ttl validation error, got %v", err)
}
}

func newCLIParser(t *testing.T, input *CLIInput) *kong.Kong {
t.Helper()
parser, err := kong.New(input, kong.Vars{
"defaultMaxSimilarityCandidates": "0",
"defaultMinSimilarityScoreRatio": "0",
"defaultMaxSimilarityPhrases": "0",
"defaultCommonTextRankEntryRatio": "0",
"defaultMinCommonTextRankAbsoluteCount": "0",
"defaultPruneChangeTriggerRatio": "0",
})
if err != nil {
t.Fatal(err)
}
return parser
}
Loading
Loading