mirror of
https://github.com/kovidgoyal/kitty
synced 2026-06-08 22:28:24 +02:00
choose-files: Add calibre based previews for ebook/document file types
This commit is contained in:
227
kittens/choose_files/calibre.go
Normal file
227
kittens/choose_files/calibre.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package choose_files
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/kovidgoyal/kitty/tools/icons"
|
||||
"github.com/kovidgoyal/kitty/tools/utils"
|
||||
"github.com/kovidgoyal/kitty/tools/utils/humanize"
|
||||
"github.com/kovidgoyal/kitty/tools/utils/images"
|
||||
)
|
||||
|
||||
var _ = fmt.Print
|
||||
var calibre_needs_cleanup atomic.Bool
|
||||
|
||||
type calibre_server_process struct {
|
||||
proc *exec.Cmd
|
||||
stdout io.ReadCloser
|
||||
stdin io.WriteCloser
|
||||
file_extensions *utils.Set[string]
|
||||
}
|
||||
|
||||
type CalibreMetadata struct {
|
||||
Title string `json:"title"`
|
||||
Authors []string `json:"authors"`
|
||||
Series string `json:"series"`
|
||||
Series_index float64 `json:"series_index"`
|
||||
Tags []string `json:"tags"`
|
||||
Rating float64 `json:"rating"`
|
||||
Published time.Time `json:"pubdate"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
type CalibreResponse struct {
|
||||
Path string `json:"path"`
|
||||
Filetypes []string `json:"filetypes"`
|
||||
Cover string `json:"cover"`
|
||||
Error string `json:"error"`
|
||||
Metadata CalibreMetadata `json:"metadata"`
|
||||
}
|
||||
|
||||
func ReadLineWithTimeout(r io.Reader, timeout time.Duration) (string, error) {
|
||||
type result struct {
|
||||
line string
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
|
||||
go func() {
|
||||
br := bufio.NewReader(r)
|
||||
line, err := br.ReadString('\n')
|
||||
ch <- result{strings.TrimRight(line, "\n"), err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-ch:
|
||||
return res.line, res.err
|
||||
case <-time.After(timeout):
|
||||
return "", os.ErrDeadlineExceeded
|
||||
}
|
||||
}
|
||||
|
||||
var calibre_server = sync.OnceValues(func() (ans *calibre_server_process, err error) {
|
||||
ans = &calibre_server_process{}
|
||||
ans.proc = exec.Command("calibre-debug", "-c", "from calibre.ebooks.metadata.cli import *; simple_metadata_server()")
|
||||
ans.proc.Stderr = nil
|
||||
if ans.stdout, err = ans.proc.StdoutPipe(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ans.stdin, err = ans.proc.StdinPipe(); err != nil {
|
||||
ans.stdout.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err = ans.proc.Start(); err != nil {
|
||||
err = fmt.Errorf("calibre-debug executable not found in PATH, you must install the calibre program to preview these file types: %w", err)
|
||||
return
|
||||
}
|
||||
calibre_needs_cleanup.Store(true)
|
||||
payload, _ := json.Marshal(map[string]string{"path": ""})
|
||||
if _, err = ans.stdin.Write(append(payload, '\n')); err != nil {
|
||||
err = fmt.Errorf("error writing to calibre metadata server: %w", err)
|
||||
return
|
||||
}
|
||||
line, err := ReadLineWithTimeout(ans.stdout, 2*time.Second)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrDeadlineExceeded) {
|
||||
err = fmt.Errorf("timed out waiting for response from calibre metadata server, make sure you are using calibre version >= 8.16")
|
||||
} else {
|
||||
err = fmt.Errorf("error reading from calibre metadata server: %w", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var r CalibreResponse
|
||||
if err = json.Unmarshal([]byte(line), &r); err != nil {
|
||||
err = fmt.Errorf("unexpected response from calibre metadata server: %#v", line)
|
||||
return
|
||||
}
|
||||
ans.file_extensions = utils.NewSet[string](len(r.Filetypes))
|
||||
for _, ft := range r.Filetypes {
|
||||
ans.file_extensions.Add("." + ft)
|
||||
}
|
||||
return
|
||||
})
|
||||
|
||||
func calibre_cleanup() {
|
||||
if !calibre_needs_cleanup.Load() {
|
||||
return
|
||||
}
|
||||
calibre_needs_cleanup.Store(false)
|
||||
calibre, _ := calibre_server()
|
||||
if calibre.stdin != nil {
|
||||
calibre.stdin.Close()
|
||||
}
|
||||
if calibre.stdout != nil {
|
||||
calibre.stdout.Close()
|
||||
}
|
||||
if calibre.proc != nil {
|
||||
calibre.proc.Wait()
|
||||
}
|
||||
}
|
||||
|
||||
func IsSupportedByCalibre(path string) bool {
|
||||
if calibre, err := calibre_server(); err == nil {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
return ext != "" && calibre.file_extensions.Has(ext)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const CALIBRE_METADATA_KEY = "calibre-metadata"
|
||||
|
||||
func (c *calibre_server_process) Unmarshall(m map[string]string) (any, error) {
|
||||
data, err := os.ReadFile(m[CALIBRE_METADATA_KEY])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ans CalibreResponse
|
||||
if err = json.Unmarshal(data, &ans); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ans, nil
|
||||
}
|
||||
|
||||
func (c *calibre_server_process) Render(path string) (m map[string][]byte, mi metadata, img *images.ImageData, err error) {
|
||||
cpath, err := os.CreateTemp("", "")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
cpath.Close()
|
||||
os.Remove(cpath.Name())
|
||||
|
||||
}()
|
||||
calibre, _ := calibre_server()
|
||||
payload, err := json.Marshal(map[string]string{"path": path, "cover": cpath.Name()})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
calibre.stdin.Write(append(payload, '\n'))
|
||||
line, err := ReadLineWithTimeout(calibre.stdout, 30*time.Second)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
lb := []byte(line)
|
||||
var reply CalibreResponse
|
||||
if err = json.Unmarshal(lb, &reply); err != nil {
|
||||
return
|
||||
}
|
||||
if reply.Cover == cpath.Name() {
|
||||
var ip ImagePreviewRenderer
|
||||
if m, mi, img, err = ip.Render(cpath.Name()); err != nil {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
m = make(map[string][]byte)
|
||||
}
|
||||
mi.custom = &reply
|
||||
m[CALIBRE_METADATA_KEY] = lb
|
||||
return
|
||||
}
|
||||
|
||||
func (c *calibre_server_process) ShowMetadata(h *Handler, s ShowData) (offset int) {
|
||||
w := func(text string, center bool) {
|
||||
if s.height > offset {
|
||||
offset += h.render_wrapped_text_in_region(text, s.x, s.y+offset, s.width, s.height-offset, center)
|
||||
}
|
||||
}
|
||||
ext := filepath.Ext(s.abspath)
|
||||
text := fmt.Sprintf("%s: %s", ext, humanize.Bytes(uint64(s.metadata.Size())))
|
||||
icon := icons.IconForPath(s.abspath)
|
||||
w(icon+" "+text, true)
|
||||
r := s.custom_metadata.custom.(*CalibreResponse)
|
||||
w("Title: "+r.Metadata.Title, false)
|
||||
w("Authors: "+strings.Join(r.Metadata.Authors, " & "), false)
|
||||
if r.Metadata.Series != "" {
|
||||
w(fmt.Sprintf("Series: Book %g of %s", r.Metadata.Series_index, r.Metadata.Series), false)
|
||||
}
|
||||
if len(r.Metadata.Tags) > 0 {
|
||||
w("Tags: "+strings.Join(r.Metadata.Authors, ", "), false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func NewCalibrePreview(
|
||||
abspath string, metadata fs.FileInfo, opts Settings, WakeupMainThread func() bool,
|
||||
) Preview {
|
||||
calibre, err := calibre_server()
|
||||
if err != nil {
|
||||
return NewFileMetadataPreviewWithError(abspath, metadata, err)
|
||||
}
|
||||
if ans, err := NewImagePreview(abspath, metadata, opts, WakeupMainThread, calibre); err == nil {
|
||||
return ans
|
||||
} else {
|
||||
return NewErrorPreview(err)
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,9 @@ func (p *ImagePreview) render_image(h *Handler, x, y, width, height int) {
|
||||
abspath: p.abspath, metadata: p.metadata, x: x, y: y, width: width, height: height, cached_data: p.cached_data,
|
||||
custom_metadata: p.custom_metadata,
|
||||
})
|
||||
h.graphics_handler.RenderImagePreview(h, p, x, y+offset, width, height-offset)
|
||||
if p.custom_metadata.image != nil {
|
||||
h.graphics_handler.RenderImagePreview(h, p, x, y+offset, width, height-offset)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ImagePreview) Render(h *Handler, x, y, width, height int) {
|
||||
@@ -229,10 +231,9 @@ func (p ImagePreviewRenderer) Render(abspath string) (ans map[string][]byte, m m
|
||||
func (p ImagePreviewRenderer) Unmarshall(map[string]string) (any, error) { return nil, nil }
|
||||
|
||||
func (p ImagePreviewRenderer) ShowMetadata(h *Handler, s ShowData) int {
|
||||
text := ""
|
||||
offset := 0
|
||||
if m := s.custom_metadata.image; m != nil {
|
||||
text = fmt.Sprintf("%s: %dx%d %s", m.Format_uppercase, m.Width, m.Height, humanize.Bytes(uint64(s.metadata.Size())))
|
||||
text := fmt.Sprintf("%s: %dx%d %s", m.Format_uppercase, m.Width, m.Height, humanize.Bytes(uint64(s.metadata.Size())))
|
||||
icon := icons.IconForPath("/a.gif")
|
||||
text = icon + " " + text
|
||||
offset += h.render_wrapped_text_in_region(text, s.x, s.y, s.width, s.height, true)
|
||||
|
||||
@@ -798,6 +798,7 @@ func main(_ *cli.Command, opts *Options, args []string) (rc int, err error) {
|
||||
lp.ColorSchemeChangeNotifications()
|
||||
handler := Handler{lp: lp, err_chan: make(chan error, 8), msg_printer: message.NewPrinter(utils.LanguageTag()), spinner: tui.NewSpinner("dots")}
|
||||
defer handler.graphics_handler.Cleanup()
|
||||
defer calibre_cleanup()
|
||||
handler.rl = readline.New(lp, readline.RlInit{
|
||||
Prompt: "> ", ContinuationPrompt: ". ", Completer: FilePromptCompleter(handler.state.CurrentDir),
|
||||
})
|
||||
|
||||
@@ -175,7 +175,7 @@ func NewDirectoryPreview(abspath string, metadata fs.FileInfo) Preview {
|
||||
return &MessagePreview{title: title, msg: header, trailers: extra}
|
||||
}
|
||||
|
||||
func NewFileMetadataPreview(abspath string, metadata fs.FileInfo) Preview {
|
||||
func NewFileMetadataPreview(abspath string, metadata fs.FileInfo) *MessagePreview {
|
||||
ext := filepath.Ext(abspath)
|
||||
if ext == "" {
|
||||
ext = "File"
|
||||
@@ -185,6 +185,19 @@ func NewFileMetadataPreview(abspath string, metadata fs.FileInfo) Preview {
|
||||
return &MessagePreview{title: title, msg: h, trailers: t}
|
||||
}
|
||||
|
||||
func NewFileMetadataPreviewWithError(abspath string, metadata fs.FileInfo, err error) *MessagePreview {
|
||||
ext := filepath.Ext(abspath)
|
||||
if ext == "" {
|
||||
ext = "File"
|
||||
}
|
||||
title := icons.IconForFileWithMode(filepath.Base(abspath), metadata.Mode().Type(), false) + " " + ext
|
||||
h, t := write_file_metadata(abspath, metadata, nil)
|
||||
ans := &MessagePreview{title: title, msg: h, trailers: t}
|
||||
lines := style.WrapTextAsLines(err.Error(), 30, style.WrapOptions{})
|
||||
ans.trailers = append(ans.trailers, lines...)
|
||||
return ans
|
||||
}
|
||||
|
||||
type highlighed_data struct {
|
||||
text string
|
||||
light bool
|
||||
@@ -327,6 +340,9 @@ func (pm *PreviewManager) preview_for(abspath string, ftype fs.FileMode) (ans Pr
|
||||
return NewErrorPreview(err)
|
||||
}
|
||||
}
|
||||
if IsSupportedByCalibre(abspath) {
|
||||
return NewCalibrePreview(abspath, s, pm.settings, pm.WakeupMainThread)
|
||||
}
|
||||
return NewFileMetadataPreview(abspath, s)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user