lots of fixes and additions:

- add color and color config support
- fix epub parsing
- add variable margin (left and right arrow keys)
- add help screen (hit `?`)
- add config file support
This commit is contained in:
2025-10-15 14:36:43 +02:00
parent 0d4c44ee11
commit 7544f8877a
12 changed files with 337 additions and 65 deletions

63
cmd/colors.go Normal file
View File

@@ -0,0 +1,63 @@
package cmd
import (
"github.com/charmbracelet/lipgloss"
)
type ColorSetting struct {
Title string `koanf:"title"`
Chapter string `koanf:"chapter"`
Body string `koanf:"body"`
}
type Colors struct {
Title lipgloss.Style
Chapter lipgloss.Style
Body lipgloss.Style
}
func SetColorconfig(defaultdark, defaultlight ColorSetting, conf *Config) Colors {
var defaults, user ColorSetting
switch conf.Darkmode {
case true:
defaults = defaultdark
user = conf.ColorDark
default:
defaults = defaultlight
user = conf.ColorLight
}
var colors Colors
var fg string
border := lipgloss.RoundedBorder()
border.Right = "├"
styletitle := lipgloss.NewStyle().BorderStyle(border).Padding(0, 1)
if user.Title != "" {
fg = user.Title
} else {
fg = defaults.Title
}
colors.Title = styletitle.Foreground(lipgloss.Color(fg))
if user.Chapter != "" {
fg = user.Chapter
} else {
fg = defaults.Chapter
}
colors.Chapter = lipgloss.NewStyle().Foreground(lipgloss.Color(fg))
if user.Body != "" {
fg = user.Body
} else {
fg = defaults.Body
}
colors.Body = lipgloss.NewStyle().Foreground(lipgloss.Color(fg))
return colors
}

View File

@@ -7,20 +7,29 @@ import (
"os"
"path/filepath"
"github.com/alecthomas/repr"
"github.com/knadh/koanf/parsers/toml"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/providers/posflag"
"github.com/knadh/koanf/v2"
flag "github.com/spf13/pflag"
)
const (
Version string = `v0.0.1`
Version string = `v0.0.2`
Usage string = `epuppy [-vd] <epub file>`
)
type Config struct {
Showversion bool `koanf:"version"` // -v
Debug bool `koanf:"debug"` // -d
StoreProgress bool `koanf:"store-progress"` // -s
Showversion bool `koanf:"version"` // -v
Debug bool `koanf:"debug"` // -d
StoreProgress bool `koanf:"store-progress"` // -s
Darkmode bool `koanf:"dark"` // -D
Config string `koanf:"config"` // -c
ColorDark ColorSetting `koanf:"colordark"` // comes from config file only
ColorLight ColorSetting `koanf:"colorlight"` // comes from config file only
Colors Colors // generated from user config file or internal defaults, respects dark mode
Document string
InitialProgress int // lines
}
@@ -41,12 +50,41 @@ func InitConfig(output io.Writer) (*Config, error) {
// parse commandline flags
flagset.BoolP("version", "v", false, "show program version")
flagset.BoolP("debug", "d", false, "enable debugging")
flagset.BoolP("dark", "D", false, "enable dark mode")
flagset.BoolP("store-progress", "s", false, "store reading progress")
flagset.StringP("config", "c", "", "read config from file")
if err := flagset.Parse(os.Args[1:]); err != nil {
return nil, fmt.Errorf("failed to parse program arguments: %w", err)
}
// generate a list of config files to try to load, including the
// one provided via -c, if any
var configfiles []string
configfile, _ := flagset.GetString("config")
home, _ := os.UserHomeDir()
if configfile != "" {
configfiles = []string{configfile}
} else {
configfiles = []string{
"/etc/epuppy.toml", "/usr/local/etc/epuppy.toml", // unix variants
filepath.Join(home, ".config", "epuppy", "config.toml"),
}
}
// Load the config file[s]
for _, cfgfile := range configfiles {
if path, err := os.Stat(cfgfile); !os.IsNotExist(err) {
if !path.IsDir() {
if err := kloader.Load(file.Provider(cfgfile), toml.Parser()); err != nil {
return nil, fmt.Errorf("error loading config file: %w", err)
}
}
} // else: we ignore the file if it doesn't exists
}
// command line setup
if err := kloader.Load(posflag.Provider(flagset, ".", kloader), nil); err != nil {
return nil, fmt.Errorf("error loading flags: %w", err)
@@ -63,6 +101,24 @@ func InitConfig(output io.Writer) (*Config, error) {
conf.Document = flagset.Args()[0]
}
if conf.Debug {
repr.Println(conf)
}
// setup color config
conf.Colors = SetColorconfig(
ColorSetting{ // Dark
Title: "#ff4500",
Chapter: "#ff4500",
Body: "#cdb79e",
},
ColorSetting{ // Light
Title: "#ff0000",
Chapter: "#8b0000",
Body: "#696969",
},
conf)
return conf, nil
}

View File

@@ -9,6 +9,8 @@ import (
"os"
"strings"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -32,6 +34,11 @@ var (
viewstyle = lipgloss.NewStyle()
)
const (
MarginStep = 5
MinSize = 40
)
type Meta struct {
lines int
currentline int
@@ -39,13 +46,67 @@ type Meta struct {
document string
}
type keyMap struct {
Up key.Binding
Down key.Binding
Left key.Binding
Right key.Binding
Help key.Binding
Quit key.Binding
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Left, k.Right}, // first column
{k.Help, k.Quit}, // second column
}
}
func (k keyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Help, k.Quit}
}
var keys = keyMap{
Up: key.NewBinding(
key.WithKeys("up", "k"),
key.WithHelp("↑", "move up"),
),
Down: key.NewBinding(
key.WithKeys("down", "j"),
key.WithHelp("↓", "move down"),
),
Left: key.NewBinding(
key.WithKeys("left", "h"),
key.WithHelp("←", "decrease text width"),
),
Right: key.NewBinding(
key.WithKeys("right", "l"),
key.WithHelp("→", "increase text width"),
),
Help: key.NewBinding(
key.WithKeys("?"),
key.WithHelp("?", "toggle help"),
),
Quit: key.NewBinding(
key.WithKeys("q", "esc", "ctrl+c"),
key.WithHelp("q", "quit"),
),
}
type Doc struct {
content string
title string
ready bool
viewport viewport.Model
initialwidth int
lastwidth int
margin int
marginMod bool
meta *Meta
config *Config
keys keyMap
help help.Model
}
func (m Doc) Init() tea.Cmd {
@@ -60,8 +121,21 @@ func (m Doc) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if k := msg.String(); k == "ctrl+c" || k == "q" || k == "esc" {
switch {
case key.Matches(msg, m.keys.Help):
m.help.ShowAll = !m.help.ShowAll
case key.Matches(msg, m.keys.Quit):
return m, tea.Quit
case key.Matches(msg, m.keys.Left):
if m.lastwidth-(m.margin*2) >= MinSize {
m.margin += MarginStep
m.marginMod = true
}
case key.Matches(msg, m.keys.Right):
if m.margin >= MarginStep {
m.margin -= MarginStep
m.marginMod = true
}
}
case tea.WindowSizeMsg:
@@ -85,10 +159,11 @@ func (m Doc) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else {
m.viewport.Width = msg.Width
m.viewport.Height = msg.Height - verticalMarginHeight
m.viewport.SetContent(wordwrap.String(m.content, msg.Width))
}
}
m.Rewrap()
// Handle keyboard and mouse events in the viewport
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
@@ -96,6 +171,17 @@ func (m Doc) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
// re-calculate word wrapping, also add left margin
func (m *Doc) Rewrap() {
if m.lastwidth != m.viewport.Width || m.marginMod {
m.viewport.SetContent(wordwrap.String(m.content, m.viewport.Width-(m.margin*2)))
m.lastwidth = m.viewport.Width
m.marginMod = false
m.viewport.Style = viewstyle.MarginLeft(m.margin)
}
}
func (m Doc) View() string {
if !m.ready {
return "\n Initializing..."
@@ -105,17 +191,23 @@ func (m Doc) View() string {
// FIXME: doesn't work correctly yet
m.meta.currentline = int(float64(m.meta.lines) * m.viewport.ScrollPercent())
return fmt.Sprintf("%s\n%s\n%s", m.headerView(), m.viewport.View(), m.footerView())
var helpView string
if m.help.ShowAll {
helpView = "\n" + m.help.View(m.keys)
}
return fmt.Sprintf("%s\n%s\n%s%s", m.headerView(), m.viewport.View(), m.footerView(), helpView)
}
func (m Doc) headerView() string {
title := titleStyle.Render(m.title)
title := m.config.Colors.Title.Render(m.title)
line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(title)))
return lipgloss.JoinHorizontal(lipgloss.Center, title, line)
}
func (m Doc) footerView() string {
info := infoStyle.Render(fmt.Sprintf("%3.f%%", m.viewport.ScrollPercent()*100))
line := strings.Repeat("─", max(0, m.viewport.Width-lipgloss.Width(info)))
return lipgloss.JoinHorizontal(lipgloss.Center, line, info)
}
@@ -148,7 +240,7 @@ func Pager(conf *Config, title, message string) (int, error) {
}
p := tea.NewProgram(
Doc{content: message, title: title, initialwidth: width, meta: &meta},
Doc{content: message, title: title, initialwidth: width, meta: &meta, config: conf, keys: keys},
tea.WithAltScreen(), // use the full size of the terminal in its "alternate screen buffer"
tea.WithMouseCellMotion(), // turn on mouse support so we can track the mouse wheel
)

View File

@@ -3,11 +3,11 @@ package cmd
import (
"fmt"
"io"
"log"
"os"
)
func Die(err error) int {
log.Fatal("Error: ", err.Error())
fmt.Fprintln(os.Stderr, "Error: ", err.Error())
return 1
}

View File

@@ -1,6 +1,7 @@
package cmd
import (
"fmt"
"strings"
"github.com/tlinden/epuppy/pkg/epub"
@@ -28,12 +29,18 @@ func View(conf *Config) (int, error) {
head.WriteString(" ")
}
for _, point := range book.Ncx.Points {
if len(point.Content.Body) > 0 {
buf.WriteString("### " + point.Content.Title)
// FIXME: since the switch to book.Files() in epub.Open() this
// returns invalid chapter numbering
chapter := 1
for _, content := range book.Content {
if len(content.Body) > 0 {
buf.WriteString(conf.Colors.Chapter.
Render(fmt.Sprintf("Chapter %d: %s", chapter, content.Title)))
buf.WriteString("\r\n\r\n")
buf.WriteString(point.Content.Body)
buf.WriteString(conf.Colors.Body.Render(content.Body))
buf.WriteString("\r\n\r\n\r\n\r\n")
chapter++
}
}

16
config.toml Normal file
View File

@@ -0,0 +1,16 @@
# color setting for dark mode
colordark = {
body = "#ffffff",
title = "#7cfc00",
chapter = "#ffff00"
}
# color setting for light mode
colorlight = {
body = "#000000",
title = "#8470ff",
chapter = "#00008b"
}
# always use dark mode
dark = true

4
go.mod
View File

@@ -35,12 +35,16 @@ require (
require (
github.com/alecthomas/repr v0.5.2 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/knadh/koanf/parsers/toml v0.1.0 // indirect
github.com/knadh/koanf/providers/file v1.2.0 // indirect
github.com/knadh/koanf/providers/posflag v1.0.1 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
golang.org/x/term v0.36.0 // indirect
)

8
go.sum
View File

@@ -70,6 +70,8 @@ github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
@@ -170,6 +172,10 @@ github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs=
github.com/knadh/koanf v1.5.0/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/toml v0.1.0 h1:S2hLqS4TgWZYj4/7mI5m1CQQcWurxUz6ODgOub/6LCI=
github.com/knadh/koanf/parsers/toml v0.1.0/go.mod h1:yUprhq6eo3GbyVXFFMdbfZSo928ksS+uo0FFqNMnO18=
github.com/knadh/koanf/providers/file v1.2.0 h1:hrUJ6Y9YOA49aNu/RSYzOTFlqzXSCpmYIDXI7OJU6+U=
github.com/knadh/koanf/providers/file v1.2.0/go.mod h1:bp1PM5f83Q+TOUu10J/0ApLBd9uIzg+n9UgthfY+nRA=
github.com/knadh/koanf/providers/posflag v1.0.1 h1:EnMxHSrPkYCFnKgBUl5KBgrjed8gVFrcXDzaW4l/C6Y=
github.com/knadh/koanf/providers/posflag v1.0.1/go.mod h1:3Wn3+YG3f4ljzRyCUgIwH7G0sZ1pMjCOsNBovrbKmAk=
github.com/knadh/koanf/v2 v2.3.0 h1:Qg076dDRFHvqnKG97ZEsi9TAg2/nFTa9hCdcSa1lvlM=
@@ -237,6 +243,8 @@ github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FI
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=

View File

@@ -14,6 +14,7 @@ type Book struct {
Opf Opf `json:"opf"`
Container Container `json:"-"`
Mimetype string `json:"-"`
Content []Content
fd *zip.ReadCloser
}
@@ -49,7 +50,12 @@ func (p *Book) readXML(n string, v interface{}) error {
}
defer fd.Close()
dec := xml.NewDecoder(fd)
return dec.Decode(v)
if err := dec.Decode(v); err != nil {
return fmt.Errorf("XML decoder error %w", err)
}
return nil
}
func (p *Book) readBytes(n string) ([]byte, error) {
@@ -59,8 +65,12 @@ func (p *Book) readBytes(n string) ([]byte, error) {
}
defer fd.Close()
return io.ReadAll(fd)
data, err := io.ReadAll(fd)
if err != nil {
return nil, fmt.Errorf("failed to read contents from %s %w", n, err)
}
return data, nil
}
func (p *Book) open(n string) (io.ReadCloser, error) {
@@ -69,5 +79,6 @@ func (p *Book) open(n string) (io.ReadCloser, error) {
return f.Open()
}
}
return nil, fmt.Errorf("file %s not exist", n)
}

46
pkg/epub/content.go Normal file
View File

@@ -0,0 +1,46 @@
package epub
import (
"encoding/xml"
"fmt"
"strings"
)
// Content nav-point content
type Content struct {
Src string `xml:"src,attr" json:"src"`
Empty bool
Body string
Title string
XML []byte
}
func (c *Content) String(content []byte) error {
title := Title{}
err := xml.Unmarshal(content, &title)
if err != nil {
if !strings.HasPrefix(err.Error(), "XML syntax error") {
return fmt.Errorf("XML parser error %w", err)
}
}
c.Title = strings.TrimSpace(title.Content)
txt := cleantitle.ReplaceAllString(string(content), "")
txt = cleanh1.ReplaceAllString(txt, "")
txt = cleanmarkup.ReplaceAllString(txt, "")
txt = cleanentities.ReplaceAllString(txt, " ")
txt = cleancomments.ReplaceAllString(txt, "")
txt = strings.TrimSpace(txt)
c.Body = cleanspace.ReplaceAllString(txt, "")
c.XML = content
if len(c.Body) == 0 {
c.Empty = true
}
return nil
}

View File

@@ -1,9 +1,7 @@
package epub
import (
"encoding/xml"
"regexp"
"strings"
)
var (
@@ -22,50 +20,10 @@ type Ncx struct {
// NavPoint nav point
type NavPoint struct {
Text string `xml:"navLabel>text" json:"text"`
Content Content `xml:"content" json:"content"`
Points []NavPoint `xml:"navPoint" json:"points"`
Text string `xml:"navLabel>text" json:"text"`
Points []NavPoint `xml:"navPoint" json:"points"`
}
type Title struct {
Content string `xml:"head>title"`
}
// Content nav-point content
type Content struct {
Src string `xml:"src,attr" json:"src"`
Empty bool
Body string
Title string
XML []byte
}
func (c *Content) String(content []byte) error {
title := Title{}
err := xml.Unmarshal(content, &title)
if err != nil {
if !strings.HasPrefix(err.Error(), "XML syntax error") {
return err
}
}
c.Title = title.Content
txt := cleantitle.ReplaceAllString(string(content), "")
txt = cleanh1.ReplaceAllString(txt, "")
txt = cleanmarkup.ReplaceAllString(txt, "")
txt = cleanentities.ReplaceAllString(txt, " ")
txt = cleancomments.ReplaceAllString(txt, "")
txt = strings.TrimSpace(txt)
c.Body = cleanspace.ReplaceAllString(txt, "")
c.XML = content
if len(c.Body) == 0 {
c.Empty = true
}
return nil
}

View File

@@ -2,6 +2,7 @@ package epub
import (
"archive/zip"
"strings"
)
// Open open a epub file
@@ -34,19 +35,29 @@ func Open(fn string) (*Book, error) {
for _, mf := range bk.Opf.Manifest {
if mf.ID == bk.Opf.Spine.Toc {
err = bk.readXML(bk.filename(mf.Href), &bk.Ncx)
if err != nil {
return &bk, err
}
break
}
}
for _, ncx := range bk.Ncx.Points {
content, err := bk.readBytes(bk.filename(ncx.Content.Src))
for _, file := range bk.Files() {
content, err := bk.readBytes(file)
if err != nil {
return &bk, err
}
if err := ncx.Content.String(content); err != nil {
return &bk, err
ct := Content{Src: file}
if strings.Contains(string(content), "DOCTYPE") {
if err := ct.String(content); err != nil {
return &bk, err
}
}
bk.Content = append(bk.Content, ct)
}
return &bk, nil