From 7f2d0aebe945f9facb206f10dea0434a1e49409f Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sun, 23 Nov 2025 09:39:08 +0530 Subject: [PATCH] choose-files: Add calibre based previews for ebook/document file types --- kittens/choose_files/calibre.go | 227 ++++++++++++++++++++++++++ kittens/choose_files/image_preview.go | 7 +- kittens/choose_files/main.go | 1 + kittens/choose_files/preview.go | 18 +- 4 files changed, 249 insertions(+), 4 deletions(-) create mode 100644 kittens/choose_files/calibre.go diff --git a/kittens/choose_files/calibre.go b/kittens/choose_files/calibre.go new file mode 100644 index 000000000..7c7e175f6 --- /dev/null +++ b/kittens/choose_files/calibre.go @@ -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) + } +} diff --git a/kittens/choose_files/image_preview.go b/kittens/choose_files/image_preview.go index d538499ba..a3fcdf09f 100644 --- a/kittens/choose_files/image_preview.go +++ b/kittens/choose_files/image_preview.go @@ -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) diff --git a/kittens/choose_files/main.go b/kittens/choose_files/main.go index 31fb96ef2..c005832f5 100644 --- a/kittens/choose_files/main.go +++ b/kittens/choose_files/main.go @@ -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), }) diff --git a/kittens/choose_files/preview.go b/kittens/choose_files/preview.go index f77947c93..e87257320 100644 --- a/kittens/choose_files/preview.go +++ b/kittens/choose_files/preview.go @@ -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) }