This commit is contained in:
Evan Buss
2024-07-06 17:59:54 -04:00
parent c63b20551f
commit 7e066fafee
8 changed files with 270 additions and 130 deletions

53
.gitignore vendored Normal file
View File

@@ -0,0 +1,53 @@
.DS_Store
*.[56789ao]
*.a[56789o]
*.so
*.pyc
._*
.nfs.*
[56789a].out
*~
*.orig
*.rej
*.exe
.*.swp
core
*.cgo*.go
*.cgo*.c
_cgo_*
_obj
_test
_testmain.go
tmp
/VERSION.cache
/bin/
/build.out
/doc/articles/wiki/*.bin
/goinstall.log
/last-change
/misc/cgo/life/run.out
/misc/cgo/stdio/run.out
/misc/cgo/testso/main
/pkg/
/src/*.*/
/src/cmd/cgo/zdefaultcc.go
/src/cmd/dist/dist
/src/cmd/go/internal/cfg/zdefaultcc.go
/src/cmd/internal/objabi/zbootstrap.go
/src/go/build/zcgo.go
/src/go/doc/headscan
/src/internal/buildcfg/zbootstrap.go
/src/runtime/internal/sys/zversion.go
/src/unicode/maketables
/src/time/tzdata/zzipdata.go
/test.out
/test/garbage/*.out
/test/pass.out
/test/run.out
/test/times.out
# This file includes artifacts of Go build that should not be checked in.
# For files created by specific development environment (e.g. editor),
# use alternative ways to exclude files from git.
# For example, set up .git/info/exclude or use a global .gitignore.

109
html/feed.go Normal file
View File

@@ -0,0 +1,109 @@
package html
import (
"fmt"
"log"
"net/url"
"strings"
"github.com/evan-buss/kobo-opds-proxy/opds"
)
type FeedViewModel struct {
Title string
Search string
Navigation []NavigationViewModel
Links []LinkViewModel
}
type NavigationViewModel struct {
Href string
Label string
}
type LinkViewModel struct {
Title string
Author string
ImageURL string
Content string
Href string
IsDownload bool
}
func convertFeed(p *FeedParams) FeedViewModel {
vm := FeedViewModel{
Title: p.Feed.Title,
Search: "",
Links: make([]LinkViewModel, 0),
Navigation: make([]NavigationViewModel, 0),
}
for _, link := range p.Feed.Links {
if link.Rel == "search" {
vm.Search = resolveHref(p.URL, link.Href)
}
if link.TypeLink == "application/atom+xml;type=feed;profile=opds-catalog" {
vm.Navigation = append(vm.Navigation, NavigationViewModel{
Href: resolveHref(p.URL, link.Href),
Label: strings.ToUpper(link.Rel[:1]) + link.Rel[1:],
})
}
}
for _, entry := range p.Feed.Entries {
vm.Links = append(vm.Links, constructLink(p.URL, entry))
}
return vm
}
func constructLink(url string, entry opds.Entry) LinkViewModel {
vm := LinkViewModel{
Title: entry.Title,
Content: entry.Content.Content,
}
authors := make([]string, 0)
for _, author := range entry.Author {
authors = append(authors, author.Name)
}
vm.Author = strings.Join(authors, " & ")
for _, link := range entry.Links {
vm.IsDownload = link.IsDownload()
if link.IsNavigation() || link.IsDownload() {
vm.Href = resolveHref(url, link.Href)
}
// Prefer the first "thumbnail" image we find
if vm.ImageURL == "" && link.IsImage("thumbnail") {
vm.ImageURL = resolveHref(url, link.Href)
}
}
// If we didn't find a thumbnail, use the first image we find
if vm.ImageURL == "" {
for _, link := range entry.Links {
if link.IsImage("") {
vm.ImageURL = resolveHref(url, link.Href)
break
}
}
}
return vm
}
func resolveHref(feedUrl string, relativePath string) string {
baseUrl, err := url.Parse(feedUrl)
if err != nil {
log.Fatal(err)
}
relativeUrl, err := url.Parse(relativePath)
if err != nil {
log.Fatal(err)
}
resolved := baseUrl.ResolveReference(relativeUrl).String()
fmt.Println("Resolved URL: ", resolved)
return resolved
}

View File

@@ -3,7 +3,11 @@
<nav class="navigation">
{{if .Search }}
<div class="search">
<a href="{{.Search}}" tabindex="-1">
<a
href="{{.Search}}"
tabindex="-1"
hx-get="?q={{.Search}}&partial=search.html"
>
<svg
width="30"
height="30"
@@ -50,4 +54,5 @@
</li>
{{end}}
</ul>
{{end}}

View File

@@ -2,12 +2,8 @@ package html
import (
"embed"
"fmt"
"html/template"
"io"
"log"
"net/url"
"strings"
"github.com/evan-buss/kobo-opds-proxy/opds"
)
@@ -17,11 +13,12 @@ var files embed.FS
var (
home = parse("home.html")
feed = parse("feed.html")
feed = parse("feed.html", "partials/search.html")
)
func parse(file string) *template.Template {
return template.Must(template.New("layout.html").ParseFS(files, "layout.html", file))
func parse(file ...string) *template.Template {
file = append(file, "layout.html")
return template.Must(template.New("layout.html").ParseFS(files, file...))
}
type FeedParams struct {
@@ -29,111 +26,12 @@ type FeedParams struct {
Feed *opds.Feed
}
type FeedViewModel struct {
Title string
Search string
Navigation []NavigationViewModel
Links []LinkViewModel
}
type NavigationViewModel struct {
Href string
Label string
}
type LinkViewModel struct {
Title string
Author string
ImageURL string
Content string
Href string
IsDownload bool
}
func convertFeed(url string, feed *opds.Feed) FeedViewModel {
vm := FeedViewModel{
Title: feed.Title,
Search: "",
Links: make([]LinkViewModel, 0),
Navigation: make([]NavigationViewModel, 0),
}
for _, link := range feed.Links {
if link.Rel == "search" {
vm.Search = resolveHref(url, link.Href)
}
if link.TypeLink == "application/atom+xml;type=feed;profile=opds-catalog" {
vm.Navigation = append(vm.Navigation, NavigationViewModel{
Href: resolveHref(url, link.Href),
Label: strings.ToUpper(link.Rel[:1]) + link.Rel[1:],
})
}
}
for _, entry := range feed.Entries {
vm.Links = append(vm.Links, constructLink(url, entry))
}
return vm
}
func constructLink(url string, entry opds.Entry) LinkViewModel {
vm := LinkViewModel{
Title: entry.Title,
Content: entry.Content.Content,
}
authors := make([]string, 0)
for _, author := range entry.Author {
authors = append(authors, author.Name)
}
vm.Author = strings.Join(authors, " & ")
for _, link := range entry.Links {
vm.IsDownload = link.IsDownload()
if link.IsNavigation() || link.IsDownload() {
vm.Href = resolveHref(url, link.Href)
}
// Prefer the first "thumbnail" image we find
if vm.ImageURL == "" && link.IsImage("thumbnail") {
vm.ImageURL = resolveHref(url, link.Href)
}
}
// If we didn't find a thumbnail, use the first image we find
if vm.ImageURL == "" {
for _, link := range entry.Links {
if link.IsImage("") {
vm.ImageURL = resolveHref(url, link.Href)
break
}
}
}
return vm
}
func resolveHref(feedUrl string, relativePath string) string {
baseUrl, err := url.Parse(feedUrl)
if err != nil {
log.Fatal(err)
}
relativeUrl, err := url.Parse(relativePath)
if err != nil {
log.Fatal(err)
}
resolved := baseUrl.ResolveReference(relativeUrl).String()
fmt.Println("Resolved URL: ", resolved)
return resolved
}
func Feed(w io.Writer, p FeedParams, partial string) error {
if partial == "" {
partial = "layout.html"
}
vm := convertFeed(p.URL, p.Feed)
vm := convertFeed(&p)
return feed.ExecuteTemplate(w, partial, vm)
}
@@ -143,8 +41,11 @@ type FeedInfo struct {
URL string
}
func Home(w io.Writer, p []FeedInfo) error {
return home.ExecuteTemplate(w, "layout.html", p)
func Home(w io.Writer, vm []FeedInfo, partial string) error {
if partial == "" {
partial = "layout.html"
}
return home.ExecuteTemplate(w, partial, vm)
}
func StaticFiles() embed.FS {

View File

@@ -1,13 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<head>
<meta charset="UTF-8" />
<title>{{block "title" .}}Kobo OPDS Proxy{{end}}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main id="container">
{{block "content" .}}{{end}}
</main>
</body>
</html>
<link rel="stylesheet" href="/static/style.css" />
<script
src="https://unpkg.com/htmx.org@2.0.0"
integrity="sha384-wS5l5IKJBvK6sPTKa2WZ1js3d947pvWXbPJ1OmWfEuxLgeHcEbjUUA5i9V5ZkpCw"
crossorigin="anonymous"
></script>
</head>
<body>
<main id="container">{{block "content" .}}{{end}}</main>
</body>
</html>

16
html/partials/search.html Normal file
View File

@@ -0,0 +1,16 @@
{{define "search-partial"}}
<form method="get">
<input type="hidden" name="q" value="{{.Search}}" />
<input type="search" placeholder="Search" name="search" />
<script>
const searchInput = document.querySelector("input[name=search]");
const searchParams = new URLSearchParams(window.location.search);
if (searchParams.has("search") && !!searchInput) {
searchInput.value = searchParams.get("search");
}
</script>
</form>
{{end}}

43
main.go
View File

@@ -1,11 +1,14 @@
package main
import (
"fmt"
"io"
"log"
"log/slog"
"mime"
"net/http"
"net/url"
"strings"
"time"
"github.com/evan-buss/kobo-opds-proxy/html"
@@ -13,13 +16,10 @@ import (
)
func main() {
router := http.NewServeMux()
router.HandleFunc("GET /{$}", handleHome())
router.HandleFunc("GET /feed", handleFeed())
router.Handle("GET /static/", http.FileServer(http.FS(html.StaticFiles())))
server := NewServer()
slog.Info("Starting server", slog.String("port", "8080"))
log.Fatal(http.ListenAndServe(":8080", router))
slog.Info("Starting server", slog.String("port", server.addr))
log.Fatal(http.ListenAndServe(server.addr, server.router))
}
func handleHome() http.HandlerFunc {
@@ -35,7 +35,7 @@ func handleHome() http.HandlerFunc {
URL: "https://m.gutenberg.org/ebooks.opds/",
},
}
html.Home(w, feeds)
html.Home(w, feeds, partial(r))
}
}
@@ -47,7 +47,18 @@ func handleFeed() http.HandlerFunc {
return
}
slog.Info("Fetching feed", slog.String("path", queryURL))
parsedUrl, err := url.PathUnescape(queryURL)
if err != nil {
handleError(r, w, "Failed to parse URL", err)
return
}
queryURL = parsedUrl
searchTerm := r.URL.Query().Get("search")
if searchTerm != "" {
fmt.Println("Search term", searchTerm)
queryURL = replaceSearchPlaceHolder(queryURL, searchTerm)
}
resp, err := fetchFromUrl(queryURL)
if err != nil {
@@ -65,13 +76,17 @@ func handleFeed() http.HandlerFunc {
if mimeType == "application/atom+xml" {
feed, err := opds.ParseFeed(resp.Body)
if err != nil {
handleError(r, w, "Failed to parse feed", err)
return
}
err = html.Feed(w, html.FeedParams{URL: queryURL, Feed: feed}, "")
feedParams := html.FeedParams{
URL: queryURL,
Feed: feed,
}
err = html.Feed(w, feedParams, partial(r))
if err != nil {
handleError(r, w, "Failed to render feed", err)
return
@@ -105,3 +120,11 @@ func handleError(r *http.Request, w http.ResponseWriter, message string, err err
slog.Error(message, slog.String("path", r.URL.RawPath), slog.Any("error", err))
http.Error(w, "An unexpected error occurred", http.StatusInternalServerError)
}
func replaceSearchPlaceHolder(url string, searchTerm string) string {
return strings.Replace(url, "{searchTerms}", searchTerm, 1)
}
func partial(req *http.Request) string {
return req.URL.Query().Get("partial")
}

30
server.go Normal file
View File

@@ -0,0 +1,30 @@
package main
import (
"net/http"
"os"
"github.com/evan-buss/kobo-opds-proxy/html"
)
type Server struct {
addr string
router *http.ServeMux
}
func NewServer() *Server {
port := os.Getenv("PORT")
if os.Getenv("PORT") == "" {
port = "8080"
}
router := http.NewServeMux()
router.HandleFunc("GET /{$}", handleHome())
router.HandleFunc("GET /feed", handleFeed())
router.Handle("GET /static/", http.FileServer(http.FS(html.StaticFiles())))
return &Server{
addr: ":" + port,
router: router,
}
}