forked from Ivasoft/opds-proxy
refactor
This commit is contained in:
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal 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
109
html/feed.go
Normal 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
|
||||||
|
}
|
||||||
@@ -3,7 +3,11 @@
|
|||||||
<nav class="navigation">
|
<nav class="navigation">
|
||||||
{{if .Search }}
|
{{if .Search }}
|
||||||
<div class="search">
|
<div class="search">
|
||||||
<a href="{{.Search}}" tabindex="-1">
|
<a
|
||||||
|
href="{{.Search}}"
|
||||||
|
tabindex="-1"
|
||||||
|
hx-get="?q={{.Search}}&partial=search.html"
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
width="30"
|
width="30"
|
||||||
height="30"
|
height="30"
|
||||||
@@ -50,4 +54,5 @@
|
|||||||
</li>
|
</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
119
html/html.go
119
html/html.go
@@ -2,12 +2,8 @@ package html
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
|
||||||
"html/template"
|
"html/template"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
|
||||||
"net/url"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/evan-buss/kobo-opds-proxy/opds"
|
"github.com/evan-buss/kobo-opds-proxy/opds"
|
||||||
)
|
)
|
||||||
@@ -17,11 +13,12 @@ var files embed.FS
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
home = parse("home.html")
|
home = parse("home.html")
|
||||||
feed = parse("feed.html")
|
feed = parse("feed.html", "partials/search.html")
|
||||||
)
|
)
|
||||||
|
|
||||||
func parse(file string) *template.Template {
|
func parse(file ...string) *template.Template {
|
||||||
return template.Must(template.New("layout.html").ParseFS(files, "layout.html", file))
|
file = append(file, "layout.html")
|
||||||
|
return template.Must(template.New("layout.html").ParseFS(files, file...))
|
||||||
}
|
}
|
||||||
|
|
||||||
type FeedParams struct {
|
type FeedParams struct {
|
||||||
@@ -29,111 +26,12 @@ type FeedParams struct {
|
|||||||
Feed *opds.Feed
|
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 {
|
func Feed(w io.Writer, p FeedParams, partial string) error {
|
||||||
if partial == "" {
|
if partial == "" {
|
||||||
partial = "layout.html"
|
partial = "layout.html"
|
||||||
}
|
}
|
||||||
|
|
||||||
vm := convertFeed(p.URL, p.Feed)
|
vm := convertFeed(&p)
|
||||||
|
|
||||||
return feed.ExecuteTemplate(w, partial, vm)
|
return feed.ExecuteTemplate(w, partial, vm)
|
||||||
}
|
}
|
||||||
@@ -143,8 +41,11 @@ type FeedInfo struct {
|
|||||||
URL string
|
URL string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Home(w io.Writer, p []FeedInfo) error {
|
func Home(w io.Writer, vm []FeedInfo, partial string) error {
|
||||||
return home.ExecuteTemplate(w, "layout.html", p)
|
if partial == "" {
|
||||||
|
partial = "layout.html"
|
||||||
|
}
|
||||||
|
return home.ExecuteTemplate(w, partial, vm)
|
||||||
}
|
}
|
||||||
|
|
||||||
func StaticFiles() embed.FS {
|
func StaticFiles() embed.FS {
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<title>{{block "title" .}}Kobo OPDS Proxy{{end}}</title>
|
<title>{{block "title" .}}Kobo OPDS Proxy{{end}}</title>
|
||||||
<link rel="stylesheet" href="/static/style.css">
|
<link rel="stylesheet" href="/static/style.css" />
|
||||||
</head>
|
<script
|
||||||
<body>
|
src="https://unpkg.com/htmx.org@2.0.0"
|
||||||
<main id="container">
|
integrity="sha384-wS5l5IKJBvK6sPTKa2WZ1js3d947pvWXbPJ1OmWfEuxLgeHcEbjUUA5i9V5ZkpCw"
|
||||||
{{block "content" .}}{{end}}
|
crossorigin="anonymous"
|
||||||
</main>
|
></script>
|
||||||
</body>
|
</head>
|
||||||
</html>
|
<body>
|
||||||
|
<main id="container">{{block "content" .}}{{end}}</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
16
html/partials/search.html
Normal file
16
html/partials/search.html
Normal 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
43
main.go
@@ -1,11 +1,14 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/evan-buss/kobo-opds-proxy/html"
|
"github.com/evan-buss/kobo-opds-proxy/html"
|
||||||
@@ -13,13 +16,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
router := http.NewServeMux()
|
server := NewServer()
|
||||||
router.HandleFunc("GET /{$}", handleHome())
|
|
||||||
router.HandleFunc("GET /feed", handleFeed())
|
|
||||||
router.Handle("GET /static/", http.FileServer(http.FS(html.StaticFiles())))
|
|
||||||
|
|
||||||
slog.Info("Starting server", slog.String("port", "8080"))
|
slog.Info("Starting server", slog.String("port", server.addr))
|
||||||
log.Fatal(http.ListenAndServe(":8080", router))
|
log.Fatal(http.ListenAndServe(server.addr, server.router))
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleHome() http.HandlerFunc {
|
func handleHome() http.HandlerFunc {
|
||||||
@@ -35,7 +35,7 @@ func handleHome() http.HandlerFunc {
|
|||||||
URL: "https://m.gutenberg.org/ebooks.opds/",
|
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
|
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)
|
resp, err := fetchFromUrl(queryURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -65,13 +76,17 @@ func handleFeed() http.HandlerFunc {
|
|||||||
|
|
||||||
if mimeType == "application/atom+xml" {
|
if mimeType == "application/atom+xml" {
|
||||||
feed, err := opds.ParseFeed(resp.Body)
|
feed, err := opds.ParseFeed(resp.Body)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
handleError(r, w, "Failed to parse feed", err)
|
handleError(r, w, "Failed to parse feed", err)
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
handleError(r, w, "Failed to render feed", err)
|
handleError(r, w, "Failed to render feed", err)
|
||||||
return
|
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))
|
slog.Error(message, slog.String("path", r.URL.RawPath), slog.Any("error", err))
|
||||||
http.Error(w, "An unexpected error occurred", http.StatusInternalServerError)
|
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
30
server.go
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user