go-mdtopdf-helper/main.go

332 lines
7.6 KiB
Go

package main
import (
"bufio"
"bytes"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"github.com/SebastiaanKlippert/go-wkhtmltopdf"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/parser"
)
const (
Windows = "windows"
Linux = "linux"
MacOS = "darwin"
)
type Converter struct {
inputDir string
recursive bool
parallel bool
hookMode bool
}
func init() {
if err := ensureWkhtmltopdfInPath(); err != nil {
fmt.Fprintf(os.Stderr, "Error setting up PATH: %v\n", err)
os.Exit(1)
}
}
func ensureWkhtmltopdfInPath() error {
var wkhtmlPath string
pathSeparator := string(os.PathListSeparator)
switch runtime.GOOS {
case Windows:
wkhtmlPath = `C:\Program Files\wkhtmltopdf\bin`
case Linux:
// Common Linux installation paths
possiblePaths := []string{
"/usr/local/bin",
"/usr/bin",
"/opt/wkhtmltopdf/bin",
}
for _, path := range possiblePaths {
if _, err := os.Stat(filepath.Join(path, "wkhtmltopdf")); err == nil {
wkhtmlPath = path
break
}
}
case MacOS:
// Common MacOS installation paths
possiblePaths := []string{
"/usr/local/bin",
"/opt/homebrew/bin",
"/opt/local/bin",
}
for _, path := range possiblePaths {
if _, err := os.Stat(filepath.Join(path, "wkhtmltopdf")); err == nil {
wkhtmlPath = path
break
}
}
default:
return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
}
if wkhtmlPath == "" {
return fmt.Errorf("wkhtmltopdf not found in common installation paths")
}
// Check if the directory exists
if _, err := os.Stat(wkhtmlPath); os.IsNotExist(err) {
return fmt.Errorf("wkhtmltopdf directory not found at: %s", wkhtmlPath)
}
// Get current PATH
currentPath := os.Getenv("PATH")
// Check if the path is already in PATH
if strings.Contains(currentPath, wkhtmlPath) {
return nil // Already in PATH
}
// Add to PATH using OS-specific path separator
newPath := currentPath + pathSeparator + wkhtmlPath
if err := os.Setenv("PATH", newPath); err != nil {
return fmt.Errorf("failed to update PATH: %w", err)
}
fmt.Printf("Added wkhtmltopdf to PATH: %s\n", wkhtmlPath)
return nil
}
// Add this helper function to get the executable name based on OS
func getExecutableName() string {
if runtime.GOOS == Windows {
return "wkhtmltopdf.exe"
}
return "wkhtmltopdf"
}
func main() {
conv := &Converter{}
// Parse command line flags
flag.StringVar(&conv.inputDir, "dir", ".", "Directory to scan for markdown files")
flag.BoolVar(&conv.recursive, "recursive", true, "Scan directories recursively")
flag.BoolVar(&conv.parallel, "parallel", true, "Convert files in parallel")
flag.BoolVar(&conv.hookMode, "hook", false, "Run as git pre-commit hook")
flag.Parse()
if conv.hookMode {
if err := conv.runAsHook(); err != nil {
fmt.Fprintf(os.Stderr, "Hook error: %v\n", err)
os.Exit(1)
}
} else {
if err := conv.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
}
func (c *Converter) runAsHook() error {
// Ask user if they want to proceed
if !c.confirmConversion() {
fmt.Println("Skipping PDF conversion")
return nil
}
// Get staged markdown files
files, err := c.getStagedMarkdownFiles()
if err != nil {
return fmt.Errorf("failed to get staged files: %w", err)
}
if len(files) == 0 {
return nil
}
// Convert files
if err := c.convertFiles(files); err != nil {
return err
}
// Stage generated PDFs
return c.stageGeneratedPDFs(files)
}
func (c *Converter) confirmConversion() bool {
fmt.Print("Convert Markdown files to PDF? [Y/n] ")
reader := bufio.NewReader(os.Stdin)
response, err := reader.ReadString('\n')
if err != nil {
return false
}
response = strings.ToLower(strings.TrimSpace(response))
return response == "" || response == "y" || response == "yes"
}
func (c *Converter) getStagedMarkdownFiles() ([]string, error) {
cmd := exec.Command("git", "diff", "--cached", "--name-only", "--diff-filter=d")
output, err := cmd.Output()
if err != nil {
return nil, err
}
var files []string
scanner := bufio.NewScanner(bytes.NewReader(output))
for scanner.Scan() {
file := scanner.Text()
if strings.HasSuffix(file, ".md") || strings.HasSuffix(file, ".markdown") {
files = append(files, file)
}
}
return files, scanner.Err()
}
func (c *Converter) stageGeneratedPDFs(files []string) error {
for _, file := range files {
pdfFile := strings.TrimSuffix(file, filepath.Ext(file)) + ".pdf"
// Convert to OS-specific path
pdfFile = filepath.FromSlash(pdfFile)
cmd := exec.Command("git", "add", pdfFile)
if err := cmd.Run(); err != nil {
fmt.Printf("Warning: Could not stage %s\n", pdfFile)
}
}
return nil
}
func (c *Converter) Run() error {
files, err := c.findMarkdownFiles()
if err != nil {
return fmt.Errorf("failed to find markdown files: %w", err)
}
if len(files) == 0 {
fmt.Println("No markdown files found")
return nil
}
return c.convertFiles(files)
}
func (c *Converter) convertFiles(files []string) error {
if c.parallel {
return c.convertFilesParallel(files)
}
return c.convertFilesSerial(files)
}
func (c *Converter) findMarkdownFiles() ([]string, error) {
var files []string
walkFn := func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() && !c.recursive && path != c.inputDir {
return filepath.SkipDir
}
if !info.IsDir() && (strings.HasSuffix(path, ".md") || strings.HasSuffix(path, ".markdown")) {
files = append(files, path)
}
return nil
}
if err := filepath.Walk(c.inputDir, walkFn); err != nil {
return nil, err
}
return files, nil
}
func (c *Converter) convertFilesParallel(files []string) error {
var wg sync.WaitGroup
errors := make(chan error, len(files))
for _, file := range files {
wg.Add(1)
go func(f string) {
defer wg.Done()
if err := c.convertFile(f); err != nil {
errors <- fmt.Errorf("failed to convert %s: %w", f, err)
}
}(file)
}
wg.Wait()
close(errors)
for err := range errors {
if err != nil {
return err
}
}
return nil
}
func (c *Converter) convertFilesSerial(files []string) error {
for _, file := range files {
if err := c.convertFile(file); err != nil {
return fmt.Errorf("failed to convert %s: %w", file, err)
}
}
return nil
}
func (c *Converter) convertFile(inputFile string) error {
mdContent, err := os.ReadFile(inputFile)
if err != nil {
return fmt.Errorf("failed to read file: %w", err)
}
extensions := parser.CommonExtensions | parser.AutoHeadingIDs
p := parser.NewWithExtensions(extensions)
html := markdown.ToHTML(mdContent, p, nil)
outputFile := strings.TrimSuffix(inputFile, filepath.Ext(inputFile)) + ".pdf"
pdfg, err := wkhtmltopdf.NewPDFGenerator()
if err != nil {
if runtime.GOOS == Windows {
// Check if wkhtmltopdf exists in the expected path
expectedPath := filepath.Join(`C:\Program Files\wkhtmltopdf\bin`, "wkhtmltopdf.exe")
if _, err := os.Stat(expectedPath); os.IsNotExist(err) {
return fmt.Errorf("wkhtmltopdf not found at %s. Please ensure it's installed correctly", expectedPath)
}
}
return fmt.Errorf("failed to create PDF generator: %w", err)
}
page := wkhtmltopdf.NewPageReader(strings.NewReader(string(html)))
page.EnableLocalFileAccess.Set(true)
pdfg.AddPage(page)
pdfg.Dpi.Set(300)
pdfg.MarginTop.Set(15)
pdfg.MarginBottom.Set(15)
pdfg.MarginLeft.Set(15)
pdfg.MarginRight.Set(15)
if err := pdfg.Create(); err != nil {
return fmt.Errorf("failed to create PDF: %w", err)
}
if err := pdfg.WriteFile(outputFile); err != nil {
return fmt.Errorf("failed to write PDF file: %w", err)
}
fmt.Printf("Successfully converted %s to %s\n", inputFile, outputFile)
return nil
}