diff --git a/.dockerignore b/.dockerignore index 701feb1..0fd0e30 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,4 @@ .git data .env -topdeck +canon diff --git a/.env.example b/.env.example index 8add556..662b7e3 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,21 @@ # Copy to .env and fill in what you need. Everything here is optional. -# Create the owner account on first start. If unset, TopDeck prints a one-time +# Create the owner account on first start. If unset, Canon prints a one-time # setup code to the log and you create the account at http://:4242/setup. -TOPDECK_ADMIN_USERNAME= -TOPDECK_ADMIN_PASSWORD= +CANON_ADMIN_USERNAME= +CANON_ADMIN_PASSWORD= # API keys. You can also enter these in Settings → Integrations; values set # here take precedence and are locked in the web UI. -TOPDECK_TMDB_API_KEY= -TOPDECK_TVDB_API_KEY= -TOPDECK_TVDB_PIN= -TOPDECK_IGDB_CLIENT_ID= -TOPDECK_IGDB_CLIENT_SECRET= +CANON_TMDB_API_KEY= +CANON_TVDB_API_KEY= +CANON_TVDB_PIN= +CANON_IGDB_CLIENT_ID= +CANON_IGDB_CLIENT_SECRET= # Behind a reverse proxy that terminates HTTPS: -# TOPDECK_TRUST_PROXY=true # honor X-Forwarded-For / -Proto / -Host -# TOPDECK_SECURE_COOKIES=true # always mark the session cookie Secure +# CANON_TRUST_PROXY=true # honor X-Forwarded-For / -Proto / -Host +# CANON_SECURE_COOKIES=true # always mark the session cookie Secure -# TOPDECK_PORT=4242 -# TOPDECK_LOG_LEVEL=debug +# CANON_PORT=4242 +# CANON_LOG_LEVEL=debug diff --git a/.gitignore b/.gitignore index 9cff329..5bdbaf0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ /data/ -/topdeck +/canon .env diff --git a/Dockerfile b/Dockerfile index 4f04c4d..950676b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,18 +6,18 @@ COPY go.mod go.sum ./ RUN go mod download COPY . . ARG VERSION=dev -RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/topdeck ./cmd/topdeck +RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/canon ./cmd/canon FROM alpine:3 RUN apk add --no-cache ca-certificates tzdata \ - && adduser -D -u 1000 topdeck \ - && mkdir -p /data && chown topdeck:topdeck /data -COPY --from=build /out/topdeck /usr/local/bin/topdeck -USER topdeck -ENV TOPDECK_DATA_DIR=/data \ - TOPDECK_PORT=4242 + && adduser -D -u 1000 canon \ + && mkdir -p /data && chown canon:canon /data +COPY --from=build /out/canon /usr/local/bin/canon +USER canon +ENV CANON_DATA_DIR=/data \ + CANON_PORT=4242 VOLUME /data EXPOSE 4242 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \ - CMD wget -qO- http://127.0.0.1:${TOPDECK_PORT}/healthz >/dev/null || exit 1 -ENTRYPOINT ["topdeck"] + CMD wget -qO- http://127.0.0.1:${CANON_PORT}/healthz >/dev/null || exit 1 +ENTRYPOINT ["canon"] diff --git a/README.md b/README.md index 79044b2..bcbbf99 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ -# TopDeck +# Canon A self-hosted page for **your** ranked favorites: movies, TV shows, albums, songs, video games, theater, and anything else you care to rank in a custom list. One owner, as many lists as you like, each with its own size (Top 5, Top 100, … up to 65,535), with proper posters and cover art pulled from the best free metadata sources. +Your page is titled **"*Your name*'s Canon"** by default. Set your name under Settings → Profile, or give the site any title you like there. + Visitors just see your lists. There's no sign-up, no feed and no login button. You manage everything from `/login`. - **Ranked lists per category**: Movies, TV Shows, Albums, Songs, Video Games and Theater. Each list sets its own size, title, description and layout (artwork grid or ranked list). @@ -18,24 +20,24 @@ Visitors just see your lists. There's no sign-up, no feed and no login button. Y ## Quick start (Docker Compose) ```sh -git clone https://git.iamchrisama.com/cdnutter/topdeck && cd topdeck +git clone https://git.iamchrisama.com/cdnutter/canon && cd canon cp .env.example .env # optional: admin account + API keys docker compose up -d -docker compose logs topdeck # shows the one-time setup code if you didn't set an admin in .env +docker compose logs canon # shows the one-time setup code if you didn't set an admin in .env ``` Open `http://localhost:4242/setup`, enter the setup code and create your account. Then go to **Settings → Integrations** to add API keys. -To skip the setup page, set `TOPDECK_ADMIN_USERNAME` and `TOPDECK_ADMIN_PASSWORD` in `.env` before the first start. They're only used when no account exists yet. +To skip the setup page, set `CANON_ADMIN_USERNAME` and `CANON_ADMIN_PASSWORD` in `.env` before the first start. They're only used when no account exists yet. ### Without Compose ```sh -docker build -t topdeck . -docker run -d --name topdeck -p 4242:4242 -v topdeck-data:/data topdeck +docker build -t canon . +docker run -d --name canon -p 4242:4242 -v canon-data:/data canon ``` -Or from source (Go 1.26+): `go run ./cmd/topdeck` stores data in `./data`. +Or from source (Go 1.26+): `go run ./cmd/canon` stores data in `./data`. ## Data sources and API keys @@ -50,7 +52,7 @@ Or from source (Go 1.26+): `go run ./cmd/topdeck` stores data in `./data`. **Why these for music and theater?** Neither needs a key. - **Music:** Deezer's public catalogue API has no key, generous limits (about 50 requests per 5 seconds) and 1000×1000 covers for both tracks and albums. MusicBrainz is the fully open alternative, but it's limited to one request per second and art coverage is patchier. Spotify, Apple Music and Last.fm all need keys or OAuth and have stricter terms. -- **Theater:** there's no open theater database with an API (IBDB and Playbill don't offer one). Wikipedia covers nearly every musical, play and opera, usually with poster or logo art. TopDeck filters search to pages that use the musical, play or opera infobox. Wikipedia describes the *work*, so you pick the production yourself and can swap in that production's poster. +- **Theater:** there's no open theater database with an API (IBDB and Playbill don't offer one). Wikipedia covers nearly every musical, play and opera, usually with poster or logo art. Canon filters search to pages that use the musical, play or opera infobox. Wikipedia describes the *work*, so you pick the production yourself and can swap in that production's poster. Keys entered in the UI are stored in the SQLite database on your server. Keys set as environment variables take precedence and show as locked in the UI. @@ -58,23 +60,23 @@ Keys entered in the UI are stored in the SQLite database on your server. Keys se | Variable | Default | | |---|---|---| -| `TOPDECK_PORT` | `4242` | Listen port | -| `TOPDECK_DATA_DIR` | `/data` (Docker), `./data` | Database + cached images | -| `TOPDECK_ADMIN_USERNAME` / `_PASSWORD` | | Create the owner account on first start | -| `TOPDECK_TMDB_API_KEY` | | | -| `TOPDECK_TVDB_API_KEY` / `TOPDECK_TVDB_PIN` | | The PIN is only for user-supported keys | -| `TOPDECK_IGDB_CLIENT_ID` / `_SECRET` | | Twitch app credentials | -| `TOPDECK_TRUST_PROXY` | `false` | Trust `X-Forwarded-*` headers (set behind a reverse proxy) | -| `TOPDECK_SECURE_COOKIES` | `false` | Force `Secure` cookies (set if served over HTTPS) | -| `TOPDECK_LOG_LEVEL` | `info` | `debug` logs every request | +| `CANON_PORT` | `4242` | Listen port | +| `CANON_DATA_DIR` | `/data` (Docker), `./data` | Database + cached images | +| `CANON_ADMIN_USERNAME` / `_PASSWORD` | | Create the owner account on first start | +| `CANON_TMDB_API_KEY` | | | +| `CANON_TVDB_API_KEY` / `CANON_TVDB_PIN` | | The PIN is only for user-supported keys | +| `CANON_IGDB_CLIENT_ID` / `_SECRET` | | Twitch app credentials | +| `CANON_TRUST_PROXY` | `false` | Trust `X-Forwarded-*` headers (set behind a reverse proxy) | +| `CANON_SECURE_COOKIES` | `false` | Force `Secure` cookies (set if served over HTTPS) | +| `CANON_LOG_LEVEL` | `info` | `debug` logs every request | ### Reverse proxy -TopDeck serves plain HTTP. Put it behind Caddy, Traefik or nginx for HTTPS, and set `TOPDECK_TRUST_PROXY=true` so logins are rate-limited per real client and cookies are marked secure. Caddy example: +Canon serves plain HTTP. Put it behind Caddy, Traefik or nginx for HTTPS, and set `CANON_TRUST_PROXY=true` so logins are rate-limited per real client and cookies are marked secure. Caddy example: ``` favorites.example.com { - reverse_proxy topdeck:4242 + reverse_proxy canon:4242 } ``` @@ -107,25 +109,39 @@ Every list can be downloaded as its titles only, in rank order, one per line. Th The Export menu appears in the list editor and, when you're signed in, on the list's public page. The links follow the list's visibility: anyone can download a public list, and only you can download a hidden one. +## Upgrading from TopDeck + +Canon was called TopDeck, and upgrading keeps all your data: + +- The database is renamed from `topdeck.db` to `canon.db` automatically on first start. +- Environment variables changed prefix from `TOPDECK_` to `CANON_`. Rename them in your `.env`. +- You'll be signed out once (the session cookie was renamed). +- Docker Compose now always uses the project name `canon`. If you ran the old compose file, your data is in the `topdeck_topdeck-data` volume. Copy it into the new one before starting: + + ```sh + docker volume create canon_canon-data + docker run --rm -v topdeck_topdeck-data:/from -v canon_canon-data:/to alpine cp -a /from/. /to/ + ``` + ## Backups -Everything lives in the data volume: `topdeck.db` (SQLite) and `images/`. Copy the volume, or while running: +Everything lives in the data volume: `canon.db` (SQLite) and `images/`. Copy the volume, or while running: ```sh -docker compose exec topdeck sh -c 'cd /data && tar c topdeck.db* images' > topdeck-backup.tar +docker compose exec canon sh -c 'cd /data && tar c canon.db* images' > canon-backup.tar ``` ## Development ```sh -go run ./cmd/topdeck # http://localhost:4242 +go run ./cmd/canon # http://localhost:4242 go test ./... ``` Templates and static assets in `web/` are embedded into the binary, so rebuild after editing them. The front end is plain ES modules with no build step. ``` -cmd/topdeck entry point, first-run bootstrap +cmd/canon entry point, first-run bootstrap internal/store SQLite schema, lists, items, ranking, settings, sessions internal/providers TMDB, TheTVDB, IGDB, Deezer, MusicBrainz, Wikipedia internal/imgcache downloads and stores artwork diff --git a/cmd/topdeck/main.go b/cmd/canon/main.go similarity index 84% rename from cmd/topdeck/main.go rename to cmd/canon/main.go index b678740..cdf6278 100644 --- a/cmd/topdeck/main.go +++ b/cmd/canon/main.go @@ -1,4 +1,4 @@ -// Command topdeck serves a personal, self-hosted collection of ranked +// Command canon serves a personal, self-hosted collection of ranked // favorites. package main @@ -16,12 +16,12 @@ import ( "syscall" "time" - "github.com/cdnutter/topdeck/internal/auth" - "github.com/cdnutter/topdeck/internal/config" - "github.com/cdnutter/topdeck/internal/imgcache" - "github.com/cdnutter/topdeck/internal/providers" - "github.com/cdnutter/topdeck/internal/server" - "github.com/cdnutter/topdeck/internal/store" + "github.com/cdnutter/canon/internal/auth" + "github.com/cdnutter/canon/internal/config" + "github.com/cdnutter/canon/internal/imgcache" + "github.com/cdnutter/canon/internal/providers" + "github.com/cdnutter/canon/internal/server" + "github.com/cdnutter/canon/internal/store" ) // version is set at build time with -ldflags "-X main.version=…". @@ -29,7 +29,7 @@ var version = "dev" func main() { level := slog.LevelInfo - if strings.EqualFold(os.Getenv("TOPDECK_LOG_LEVEL"), "debug") { + if strings.EqualFold(os.Getenv("CANON_LOG_LEVEL"), "debug") { level = slog.LevelDebug } log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level})) @@ -92,7 +92,7 @@ func run(log *slog.Logger) error { } errc := make(chan error, 1) go func() { - log.Info("TopDeck is running", "addr", cfg.ListenAddr, "data", cfg.DataDir, "version", version) + log.Info("Canon is running", "addr", cfg.ListenAddr, "data", cfg.DataDir, "version", version) errc <- httpSrv.ListenAndServe() }() select { @@ -119,7 +119,7 @@ func bootstrapAdmin(ctx context.Context, cfg config.Config, st *store.Store, srv } if cfg.AdminUsername != "" && cfg.AdminPassword != "" { if len(cfg.AdminPassword) < auth.MinPasswordLength { - return fmt.Errorf("TOPDECK_ADMIN_PASSWORD must be at least %d characters", auth.MinPasswordLength) + return fmt.Errorf("CANON_ADMIN_PASSWORD must be at least %d characters", auth.MinPasswordLength) } hash, err := auth.HashPassword(cfg.AdminPassword) if err != nil { diff --git a/docker-compose.yml b/docker-compose.yml index f385665..d1ab838 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,18 @@ +name: canon + services: - topdeck: + canon: build: . - image: topdeck:latest - container_name: topdeck + image: canon:latest + container_name: canon restart: unless-stopped ports: - "4242:4242" volumes: - - topdeck-data:/data + - canon-data:/data env_file: - path: .env required: false volumes: - topdeck-data: + canon-data: diff --git a/go.mod b/go.mod index fa8a460..da184d8 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/cdnutter/topdeck +module github.com/cdnutter/canon go 1.26.0 diff --git a/internal/config/config.go b/internal/config/config.go index 22c7015..1f951b9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -28,19 +28,19 @@ type Config struct { } func Load() Config { - port := env("TOPDECK_PORT", "4242") + port := env("CANON_PORT", "4242") return Config{ - ListenAddr: env("TOPDECK_LISTEN", ":"+port), - DataDir: env("TOPDECK_DATA_DIR", "./data"), - AdminUsername: env("TOPDECK_ADMIN_USERNAME", ""), - AdminPassword: env("TOPDECK_ADMIN_PASSWORD", ""), - SecureCookies: envBool("TOPDECK_SECURE_COOKIES", false), - TrustProxy: envBool("TOPDECK_TRUST_PROXY", false), - TMDBKey: env("TOPDECK_TMDB_API_KEY", ""), - TVDBKey: env("TOPDECK_TVDB_API_KEY", ""), - TVDBPin: env("TOPDECK_TVDB_PIN", ""), - IGDBClientID: env("TOPDECK_IGDB_CLIENT_ID", ""), - IGDBClientSecret: env("TOPDECK_IGDB_CLIENT_SECRET", ""), + ListenAddr: env("CANON_LISTEN", ":"+port), + DataDir: env("CANON_DATA_DIR", "./data"), + AdminUsername: env("CANON_ADMIN_USERNAME", ""), + AdminPassword: env("CANON_ADMIN_PASSWORD", ""), + SecureCookies: envBool("CANON_SECURE_COOKIES", false), + TrustProxy: envBool("CANON_TRUST_PROXY", false), + TMDBKey: env("CANON_TMDB_API_KEY", ""), + TVDBKey: env("CANON_TVDB_API_KEY", ""), + TVDBPin: env("CANON_TVDB_PIN", ""), + IGDBClientID: env("CANON_IGDB_CLIENT_ID", ""), + IGDBClientSecret: env("CANON_IGDB_CLIENT_SECRET", ""), } } diff --git a/internal/providers/providers.go b/internal/providers/providers.go index f0372fe..a9cc44a 100644 --- a/internal/providers/providers.go +++ b/internal/providers/providers.go @@ -14,7 +14,7 @@ import ( "time" ) -const UserAgent = "TopDeck/0.1 (self-hosted favorites list; https://git.iamchrisama.com/cdnutter/topdeck)" +const UserAgent = "Canon/0.1 (self-hosted favorites list; https://git.iamchrisama.com/cdnutter/canon)" // Result is one search hit, already shaped like a list item. type Result struct { diff --git a/internal/server/admin.go b/internal/server/admin.go index e95d2b4..588550b 100644 --- a/internal/server/admin.go +++ b/internal/server/admin.go @@ -5,7 +5,7 @@ import ( "html/template" "net/http" - "github.com/cdnutter/topdeck/internal/store" + "github.com/cdnutter/canon/internal/store" ) // adminPage carries a JSON blob the page script boots from. diff --git a/internal/server/api.go b/internal/server/api.go index 4ada560..b1505e8 100644 --- a/internal/server/api.go +++ b/internal/server/api.go @@ -10,10 +10,10 @@ import ( "time" "unicode/utf8" - "github.com/cdnutter/topdeck/internal/auth" - "github.com/cdnutter/topdeck/internal/imgcache" - "github.com/cdnutter/topdeck/internal/providers" - "github.com/cdnutter/topdeck/internal/store" + "github.com/cdnutter/canon/internal/auth" + "github.com/cdnutter/canon/internal/imgcache" + "github.com/cdnutter/canon/internal/providers" + "github.com/cdnutter/canon/internal/store" ) // ---- lists ---- @@ -466,19 +466,20 @@ func (s *Server) apiSettings(w http.ResponseWriter, r *http.Request) { } u, _ := s.currentUser(r) writeJSON(w, http.StatusOK, map[string]any{ - "siteTitle": set[store.SettingSiteTitle], - "ownerName": site.OwnerName, - "tagline": site.Tagline, - "avatar": site.AvatarURL, - "theme": site.Theme, - "customCss": set[store.SettingCustomCSS], - "previewCount": site.PreviewCount, - "showNotes": site.ShowNotes, - "musicProvider": orDefault(set[store.SettingMusicProvider], "deezer"), - "tvProvider": orDefault(set[store.SettingTVProvider], "tvdb"), - "secrets": secrets, - "themes": Themes, - "username": u.Username, + "siteTitle": set[store.SettingSiteTitle], + "siteTitleShown": site.Title, + "ownerName": site.OwnerName, + "tagline": site.Tagline, + "avatar": site.AvatarURL, + "theme": site.Theme, + "customCss": set[store.SettingCustomCSS], + "previewCount": site.PreviewCount, + "showNotes": site.ShowNotes, + "musicProvider": orDefault(set[store.SettingMusicProvider], "deezer"), + "tvProvider": orDefault(set[store.SettingTVProvider], "tvdb"), + "secrets": secrets, + "themes": Themes, + "username": u.Username, }) } diff --git a/internal/server/authhandlers.go b/internal/server/authhandlers.go index fa1492c..4abbd48 100644 --- a/internal/server/authhandlers.go +++ b/internal/server/authhandlers.go @@ -6,7 +6,7 @@ import ( "strings" "time" - "github.com/cdnutter/topdeck/internal/auth" + "github.com/cdnutter/canon/internal/auth" ) type loginPage struct { @@ -143,7 +143,7 @@ func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) { } if s.setupCode == "" || subtle.ConstantTimeCompare([]byte(code), []byte(normalizeCode(s.setupCode))) != 1 { s.loginLimit.Fail(ip) - fail(http.StatusForbidden, "That setup code isn't right. It's printed in the server log (docker compose logs topdeck).") + fail(http.StatusForbidden, "That setup code isn't right. It's printed in the server log (docker compose logs canon).") return } if page.Username == "" || len(page.Username) > 64 { diff --git a/internal/server/public.go b/internal/server/public.go index d67bc7d..6c9bd79 100644 --- a/internal/server/public.go +++ b/internal/server/public.go @@ -5,7 +5,7 @@ import ( "net/http" "strconv" - "github.com/cdnutter/topdeck/internal/store" + "github.com/cdnutter/canon/internal/store" ) // ItemView is an item shaped for public display. diff --git a/internal/server/render.go b/internal/server/render.go index 0685e58..839fd84 100644 --- a/internal/server/render.go +++ b/internal/server/render.go @@ -11,8 +11,8 @@ import ( "strconv" "strings" - "github.com/cdnutter/topdeck/internal/store" - "github.com/cdnutter/topdeck/web" + "github.com/cdnutter/canon/internal/store" + "github.com/cdnutter/canon/web" ) // Site is the owner-configurable identity shown on every page. @@ -53,7 +53,7 @@ func (s *Server) site(ctx context.Context) Site { ShowNotes: set[store.SettingShowNotes] != "false", } if site.Title == "" { - site.Title = "TopDeck" + site.Title = DefaultTitle(site.OwnerName) } if !validTheme(site.Theme) { site.Theme = DefaultTheme @@ -71,6 +71,15 @@ func (s *Server) site(ctx context.Context) Site { return site } +// DefaultTitle is the site title when the owner hasn't set one: +// "Chris's Canon", or just "Canon" before they've entered a name. +func DefaultTitle(ownerName string) string { + if ownerName = strings.TrimSpace(ownerName); ownerName == "" { + return "Canon" + } + return ownerName + "'s Canon" +} + func (s *Server) handleCustomCSS(w http.ResponseWriter, r *http.Request) { set, err := s.store.Settings(r.Context()) if err != nil { diff --git a/internal/server/render_test.go b/internal/server/render_test.go new file mode 100644 index 0000000..80119d0 --- /dev/null +++ b/internal/server/render_test.go @@ -0,0 +1,11 @@ +package server + +import "testing" + +func TestDefaultTitle(t *testing.T) { + for in, want := range map[string]string{"": "Canon", " ": "Canon", "Chris": "Chris's Canon", " Ana ": "Ana's Canon"} { + if got := DefaultTitle(in); got != want { + t.Errorf("DefaultTitle(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/server/server.go b/internal/server/server.go index b7be7d4..368d868 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -18,16 +18,16 @@ import ( "strings" "time" - "github.com/cdnutter/topdeck/internal/auth" - "github.com/cdnutter/topdeck/internal/config" - "github.com/cdnutter/topdeck/internal/imgcache" - "github.com/cdnutter/topdeck/internal/providers" - "github.com/cdnutter/topdeck/internal/store" - "github.com/cdnutter/topdeck/web" + "github.com/cdnutter/canon/internal/auth" + "github.com/cdnutter/canon/internal/config" + "github.com/cdnutter/canon/internal/imgcache" + "github.com/cdnutter/canon/internal/providers" + "github.com/cdnutter/canon/internal/store" + "github.com/cdnutter/canon/web" ) const ( - sessionCookie = "topdeck_session" + sessionCookie = "canon_session" sessionTTL = 30 * 24 * time.Hour ) @@ -240,7 +240,7 @@ func (s *Server) requireUser(h http.HandlerFunc) http.HandlerFunc { // preflight, which blocks CSRF. func (s *Server) requireAPI(h http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - if r.Header.Get("X-TopDeck") != "1" || !s.sameOrigin(r) { + if r.Header.Get("X-Canon") != "1" || !s.sameOrigin(r) { writeError(w, http.StatusForbidden, "Missing request header") return } diff --git a/internal/store/store.go b/internal/store/store.go index 979fa9a..403fb70 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -6,6 +6,7 @@ import ( "database/sql" "errors" "fmt" + "os" "path/filepath" _ "modernc.org/sqlite" @@ -18,7 +19,10 @@ type Store struct { } func Open(dataDir string) (*Store, error) { - path := filepath.Join(dataDir, "topdeck.db") + path := filepath.Join(dataDir, "canon.db") + if err := adoptLegacyDB(dataDir, path); err != nil { + return nil, err + } dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)&_txlock=immediate", path) db, err := sql.Open("sqlite", dsn) if err != nil { @@ -35,6 +39,25 @@ func Open(dataDir string) (*Store, error) { func (s *Store) Close() error { return s.db.Close() } +// adoptLegacyDB renames a database from before the project was called +// Canon (topdeck.db, plus its WAL files) so upgrades keep their data. +func adoptLegacyDB(dataDir, path string) error { + legacy := filepath.Join(dataDir, "topdeck.db") + if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) { + return nil + } + if _, err := os.Stat(legacy); err != nil { + return nil + } + for _, suffix := range []string{"-wal", "-shm", ""} { + err := os.Rename(legacy+suffix, path+suffix) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("renaming %s to %s: %w", legacy+suffix, path+suffix, err) + } + } + return nil +} + // migrations are applied in order; the index into this slice (plus one) is // the schema version stored in PRAGMA user_version. var migrations = []string{ diff --git a/internal/store/store_test.go b/internal/store/store_test.go index da364d1..9f501f7 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "os" + "path/filepath" "strings" "testing" ) @@ -177,3 +179,31 @@ func TestCustomList(t *testing.T) { t.Errorf("movie aspect = %q", m.Aspect()) } } + +func TestAdoptLegacyDB(t *testing.T) { + dir := t.TempDir() + s, err := Open(dir) + if err != nil { + t.Fatal(err) + } + if _, err := s.CreateList(context.Background(), List{Title: "Keep me", Category: "movie", MaxItems: 5}); err != nil { + t.Fatal(err) + } + s.Close() + // Pretend this data dir predates the rename. + for _, suffix := range []string{"", "-wal", "-shm"} { + os.Rename(filepath.Join(dir, "canon.db"+suffix), filepath.Join(dir, "topdeck.db"+suffix)) + } + s, err = Open(dir) + if err != nil { + t.Fatal(err) + } + defer s.Close() + lists, err := s.Lists(context.Background(), true) + if err != nil || len(lists) != 1 || lists[0].Title != "Keep me" { + t.Fatalf("legacy data not adopted: %v %v", lists, err) + } + if _, err := os.Stat(filepath.Join(dir, "topdeck.db")); !os.IsNotExist(err) { + t.Errorf("topdeck.db should have been renamed") + } +} diff --git a/web/static/css/app.css b/web/static/css/app.css index 989caa9..e6f5433 100644 --- a/web/static/css/app.css +++ b/web/static/css/app.css @@ -1,5 +1,5 @@ /* ========================================================================== - TopDeck. Colors and fonts come from themes.css. + Canon. Colors and fonts come from themes.css. ========================================================================== */ *, *::before, *::after { box-sizing: border-box; } diff --git a/web/static/css/themes.css b/web/static/css/themes.css index 86b3ace..de44e4f 100644 --- a/web/static/css/themes.css +++ b/web/static/css/themes.css @@ -1,5 +1,5 @@ /* - * TopDeck theme presets. + * Canon theme presets. * * Every color on the site comes from these variables, so a custom theme only * needs to override them (Settings → Appearance → Custom CSS), e.g. diff --git a/web/static/js/admin-settings.js b/web/static/js/admin-settings.js index cb8bc86..b4f16d1 100644 --- a/web/static/js/admin-settings.js +++ b/web/static/js/admin-settings.js @@ -70,7 +70,10 @@ function profileSection() { }); const ownerName = h("input", { maxlength: 100, value: settings.ownerName, placeholder: "Your name" }); - const siteTitle = h("input", { maxlength: 100, value: settings.siteTitle, placeholder: "TopDeck" }); + // Mirrors DefaultTitle in render.go. + const defaultTitle = (name) => (name.trim() ? `${name.trim()}'s Canon` : "Canon"); + const siteTitle = h("input", { maxlength: 100, value: settings.siteTitle, placeholder: defaultTitle(settings.ownerName) }); + ownerName.addEventListener("input", () => { siteTitle.placeholder = defaultTitle(ownerName.value); }); const tagline = h("input", { maxlength: 300, value: settings.tagline, placeholder: "A line about you or your taste" }); const preview = h("input", { type: "number", min: 0, max: 50, value: settings.previewCount, style: { maxWidth: "120px" } }); const notes = h("input", { type: "checkbox", checked: settings.showNotes }); @@ -78,7 +81,7 @@ function profileSection() { const form = h("form", { class: "stack" }, h("div", { class: "avatar-edit" }, avatarBox, h("div", { class: "row-btns", style: { display: "flex", gap: "6px", flexWrap: "wrap" } }, uploadBtn, removeBtn, file)), - h("div", { class: "row" }, field("Your name", ownerName, "Shown as the page heading."), field("Site title", siteTitle, "Shown in the header and browser tab.")), + h("div", { class: "row" }, field("Your name", ownerName, "Shown as the page heading."), field("Site title", siteTitle, "Shown in the header and browser tab. Leave blank to use “Your name's Canon”.")), field("Tagline", tagline), field("Entries per list on the home page", preview, "Visitors see the full list on each list's page. 0 shows only the heading."), h("label", { class: "check" }, notes, "Show my notes to visitors"), @@ -93,7 +96,7 @@ function profileSection() { if (ok) { renderAvatar(); const brand = document.querySelector(".brand-name"); - if (brand) brand.textContent = settings.siteTitle || "TopDeck"; + if (brand) brand.textContent = settings.siteTitleShown; } }); renderAvatar(); @@ -181,7 +184,7 @@ function secretInput(key, label, hintHTML) { disabled: st.locked, }); const hint = h("span", { class: "field-hint" }); - if (st.locked) hint.textContent = `Set by the ${"TOPDECK_" + key.toUpperCase()} environment variable.`; + if (st.locked) hint.textContent = `Set by the ${"CANON_" + key.toUpperCase()} environment variable.`; else if (hintHTML) hint.append(...hintHTML); const wrap = field(label, input); wrap.append(hint); diff --git a/web/static/js/common.js b/web/static/js/common.js index eb58d7b..034a4fa 100644 --- a/web/static/js/common.js +++ b/web/static/js/common.js @@ -1,4 +1,4 @@ -// Shared helpers for TopDeck's pages. No build step, no dependencies. +// Shared helpers for Canon's pages. No build step, no dependencies. /** Create an element: h("button", { class: "btn", onclick }, "Save") */ export function h(tag, attrs = {}, ...children) { @@ -53,7 +53,7 @@ export class APIError extends Error { /** Call the admin JSON API. Throws APIError with a human message. */ export async function api(method, path, body) { - const opts = { method, headers: { "X-TopDeck": "1" }, credentials: "same-origin" }; + const opts = { method, headers: { "X-Canon": "1" }, credentials: "same-origin" }; if (body instanceof FormData) { opts.body = body; } else if (body !== undefined) { diff --git a/web/static/js/public.js b/web/static/js/public.js index 13867e9..32f71a4 100644 --- a/web/static/js/public.js +++ b/web/static/js/public.js @@ -75,7 +75,7 @@ document.addEventListener("click", (e) => { // Grid / list toggle on list pages, remembered per list in this browser. const toggle = document.querySelector("[data-layout-toggle]"); if (toggle) { - const key = "topdeck:layout:" + toggle.dataset.key; + const key = "canon:layout:" + toggle.dataset.key; const apply = (layout) => { for (const { ol } of lists) { ol.classList.toggle("items--grid", layout === "grid"); diff --git a/web/templates/layout.html b/web/templates/layout.html index e0bebc6..476e042 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -39,7 +39,7 @@ {{block "scripts" .}}{{end}} diff --git a/web/templates/pages/setup.html b/web/templates/pages/setup.html index e12a5c3..70de08a 100644 --- a/web/templates/pages/setup.html +++ b/web/templates/pages/setup.html @@ -2,14 +2,14 @@ {{define "content"}}
{{icon "deck"}}
-

Welcome to TopDeck

+

Welcome to Canon

Create the owner account. Only you can sign in; everyone else just sees your lists.

{{with .Data.Error}}{{end}}