diff --git a/README.md b/README.md index 8f6735d..304f94c 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ Table of Contents * [Remote files](#remote-files) * [Multiple files](#multiple-files) * [Combo](#combo) + * [Auto insert and update TOC](#auto-insert-and-update-toc) * [Starting Depth](#starting-depth) * [Depth](#depth) * [No Escape](#no-escape) @@ -91,6 +92,8 @@ Flags: --token=TOKEN GitHub personal token --indent=2 Indent space of generated list --debug Show debug info + --insert Insert TOC into file (auto-insert at top or between and markers) + --no-backup Skip creating backup file when using --insert --version Show application version. Args: @@ -298,6 +301,45 @@ You can easily combine both ways: Created by [gh-md-toc](https://github.com/ekalinin/github-markdown-toc) ``` +Auto insert and update TOC +---------- + +You can easily insert a TOC into an existing Markdown file. Just add the following placeholder in your document: + +```markdown + + +``` + +Now run the tool: + +```bash +$ ./gh-md-toc --insert README.md + +Table of Contents +================= + + * [gh-md-toc](#gh-md-toc) + * [Installation](#installation) + * [Usage](#usage) + +Created by [gh-md-toc](https://github.com/ekalinin/github-markdown-toc.go) +``` + +The TOC will be automatically inserted between the `` and `` markers. + +If your file doesn't have these markers, the TOC will be auto-inserted at the top (before the first heading). + +Next time when your file will be changed just repeat the command (`./gh-md-toc --insert ...`) and TOC will be refreshed again. + +A backup of your original file will be created with the `.YYYY-MM-DD_HHMMSS` suffix. + +If you don't want to create a backup, use `--no-backup` option: + +```bash +$ ./gh-md-toc --insert --no-backup README.md +``` + Starting Depth -------------- diff --git a/cmd/gh-md-toc/main.go b/cmd/gh-md-toc/main.go index 247b87e..b882edc 100644 --- a/cmd/gh-md-toc/main.go +++ b/cmd/gh-md-toc/main.go @@ -24,6 +24,8 @@ var ( debug = kingpin.Flag("debug", "Show debug info").Bool() ghurl = kingpin.Flag("github-url", "GitHub URL, default=https://api.github.com").Default("https://api.github.com").String() reVersion = kingpin.Flag("re-version", "RegExp version, default=0").Default(version.GH_2024_03).String() + insert = kingpin.Flag("insert", "Insert TOC into file (auto-insert at top or between and markers)").Bool() + noBackup = kingpin.Flag("no-backup", "Skip creating backup file when using --insert").Bool() ) // Entry point @@ -52,6 +54,8 @@ func main() { GHToken: *token, GHUrl: *ghurl, GHVersion: *reVersion, + Insert: *insert, + NoBackup: *noBackup, } if err := app.New(cfg).Run(os.Stdout); err != nil { diff --git a/internal/adapters/filebackup.go b/internal/adapters/filebackup.go new file mode 100644 index 0000000..06e7526 --- /dev/null +++ b/internal/adapters/filebackup.go @@ -0,0 +1,41 @@ +package adapters + +import ( + "fmt" + "io" + "os" + "time" +) + +type FileBackupper struct{} + +func NewFileBackupper() *FileBackupper { + return &FileBackupper{} +} + +func (fb *FileBackupper) CreateBackup(filepath string) (string, error) { + timestamp := time.Now().Format("2006-01-02_150405") + backupPath := fmt.Sprintf("%s.%s", filepath, timestamp) + + src, err := os.Open(filepath) + if err != nil { + return "", err + } + defer func() { + _ = src.Close() + }() + + dst, err := os.Create(backupPath) + if err != nil { + return "", err + } + defer func() { + _ = dst.Close() + }() + + if _, err := io.Copy(dst, src); err != nil { + return "", err + } + + return backupPath, nil +} diff --git a/internal/adapters/tocinserter.go b/internal/adapters/tocinserter.go new file mode 100644 index 0000000..f21a103 --- /dev/null +++ b/internal/adapters/tocinserter.go @@ -0,0 +1,174 @@ +package adapters + +import ( + "bytes" + "fmt" + "os" + "os/user" + "strings" + "time" + + "github.com/ekalinin/github-markdown-toc.go/v2/internal/utils" +) + +const ( + MarkerStart = "" + MarkerEnd = "" +) + +type TocInserter struct { + hideHeader bool + hideFooter bool +} + +func NewTocInserter(hideHeader, hideFooter bool) *TocInserter { + return &TocInserter{ + hideHeader: hideHeader, + hideFooter: hideFooter, + } +} + +func (ti *TocInserter) InsertToc(filepath string, toc string) error { + stat, err := os.Stat(filepath) + if err != nil { + return err + } + + content, err := os.ReadFile(filepath) + if err != nil { + return err + } + + contentStr := string(content) + + if err := ti.validateMarkers(contentStr); err != nil { + return fmt.Errorf("invalid markers in %s: %w", filepath, err) + } + + var newContent string + if ti.hasMarkers(contentStr) { + newContent, err = ti.replaceContent(contentStr, toc) + if err != nil { + return err + } + } else { + newContent = ti.insertAtTop(contentStr, toc) + } + + return os.WriteFile(filepath, []byte(newContent), stat.Mode().Perm()) +} + +func (ti *TocInserter) hasMarkers(content string) bool { + return strings.Contains(content, MarkerStart) && strings.Contains(content, MarkerEnd) +} + +func (ti *TocInserter) validateMarkers(content string) error { + startCount := strings.Count(content, MarkerStart) + endCount := strings.Count(content, MarkerEnd) + + if startCount != endCount { + return fmt.Errorf("mismatched markers: found %d start markers and %d end markers", startCount, endCount) + } + if startCount > 1 { + return fmt.Errorf("multiple marker pairs found (%d pairs), only one pair is supported", startCount) + } + if startCount == 0 && endCount == 0 { + return nil + } + return nil +} + +func (ti *TocInserter) replaceContent(content, toc string) (string, error) { + lines := strings.Split(content, "\n") + var result []string + var insideMarkers bool + var markerFound bool + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + + if trimmed == MarkerStart { + result = append(result, line) + insideMarkers = true + markerFound = true + result = append(result, ti.formatToc(toc)) + continue + } + + if trimmed == MarkerEnd { + result = append(result, line) + insideMarkers = false + continue + } + + if !insideMarkers { + result = append(result, line) + } + } + + if !markerFound { + return "", fmt.Errorf("markers not found") + } + + return strings.Join(result, "\n"), nil +} + +func (ti *TocInserter) formatToc(toc string) string { + var buf bytes.Buffer + + if !ti.hideHeader { + buf.WriteString(utils.GetHeaderText()) + } + + buf.WriteString("\n") + buf.WriteString(toc) + + if !ti.hideFooter { + buf.WriteString("\n") + buf.WriteString(ti.generateTimestamp()) + buf.WriteString("\n") + buf.WriteString(utils.GetFooterText()) + buf.WriteString("\n") + } + + return buf.String() +} + +func (ti *TocInserter) generateTimestamp() string { + username := "unknown" + if u, err := user.Current(); err == nil { + username = u.Username + } + + timestamp := time.Now().Format("2006-01-02T15:04-07:00") + return fmt.Sprintf("", username, timestamp) +} + +func (ti *TocInserter) insertAtTop(content, toc string) string { + lines := strings.Split(content, "\n") + var result []string + var insertIndex int + var foundHeading bool + + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if strings.HasPrefix(trimmed, "#") { + insertIndex = i + foundHeading = true + break + } + } + + if !foundHeading { + insertIndex = 0 + } + + result = append(result, lines[:insertIndex]...) + result = append(result, MarkerStart) + result = append(result, ti.formatToc(toc)) + result = append(result, MarkerEnd) + result = append(result, "") + result = append(result, lines[insertIndex:]...) + + return strings.Join(result, "\n") +} diff --git a/internal/app/config.go b/internal/app/config.go index 28b5401..0dadb9f 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -19,6 +19,8 @@ type Config struct { GHToken string GHUrl string GHVersion string + Insert bool + NoBackup bool } func (c Config) ToControllerConfig() controller.Config { @@ -35,6 +37,8 @@ func (c Config) ToControllerConfig() controller.Config { GHToken: c.GHToken, GHUrl: c.GHUrl, GHVersion: c.GHVersion, + Insert: c.Insert, + NoBackup: c.NoBackup, } } diff --git a/internal/app/new.go b/internal/app/new.go index 2fd9ccd..337e9a3 100644 --- a/internal/app/new.go +++ b/internal/app/new.go @@ -32,6 +32,8 @@ func New(cfg Config) *App { grabberJson := adapters.NewJsonGrabber(cfg.ToGrabberConfig()) getter := adapters.NewRemoteGetter(true) temper := adapters.NewFileTemper() + fileBackupper := adapters.NewFileBackupper() + tocInserter := adapters.NewTocInserter(cfg.HideHeader, cfg.HideFooter) log.Info("App.New: init usecases ...") ucLocalMD, ucRemoteMD, ucRemoteHTML := usecase.New( @@ -40,7 +42,7 @@ func New(cfg Config) *App { ) log.Info("App.New: init controller ...") - ctl := controller.New(ctlCfg, ucLocalMD, ucRemoteMD, ucRemoteHTML, log) + ctl := controller.New(ctlCfg, ucLocalMD, ucRemoteMD, ucRemoteHTML, log, fileBackupper, tocInserter) log.Info("App.New: done.") return &App{ diff --git a/internal/controller/config.go b/internal/controller/config.go index 8bd9ded..627879b 100644 --- a/internal/controller/config.go +++ b/internal/controller/config.go @@ -17,6 +17,8 @@ type Config struct { GHToken string GHUrl string GHVersion string + Insert bool + NoBackup bool } func (c Config) ToUseCaseConfig() config.Config { @@ -33,5 +35,7 @@ func (c Config) ToUseCaseConfig() config.Config { GHUrl: c.GHUrl, GHVersion: c.GHVersion, AbsPathInToc: len(c.Files) > 1, + Insert: c.Insert, + NoBackup: c.NoBackup, } } diff --git a/internal/controller/file.go b/internal/controller/file.go index b074e18..c3f86e9 100644 --- a/internal/controller/file.go +++ b/internal/controller/file.go @@ -27,7 +27,12 @@ func (ctl *Controller) ProcessFiles(stdout io.Writer, files ...string) error { ctl.log.Info("Controller.ProcessFiles: start", "files", files) cnt := len(files) - ch := make(chan *entity.Toc, cnt) + type result struct { + file string + toc *entity.Toc + } + ch := make(chan result, cnt) + for _, file := range files { ctl.log.Info("Controller.ProcessFiles: processing", "file", file) uc := ctl.getUseCase(file) @@ -36,22 +41,59 @@ func (ctl *Controller) ProcessFiles(stdout io.Writer, files ...string) error { } if ctl.cfg.Serial { - ch <- uc.Do(file) + ch <- result{file: file, toc: uc.Do(file)} } else { go func(ucc useCase, path string) { - ch <- ucc.Do(path) + ch <- result{file: path, toc: ucc.Do(path)} }(uc, file) } } for i := 0; i < cnt; i++ { - toc := <-ch + res := <-ch // #14, check if there's really TOC? - if toc != nil { - if err := toc.Print(stdout); err != nil { - return err + if res.toc != nil { + if ctl.cfg.Insert { + if entity.GetType(res.file) != entity.TypeLocalMD { + ctl.log.Info("Skipping insert for non-local file, printing to stdout instead", "file", res.file) + if err := res.toc.Print(stdout); err != nil { + return err + } + continue + } + + if err := ctl.insertTocToFile(res.file, res.toc); err != nil { + return err + } + if err := res.toc.Print(stdout); err != nil { + return err + } + } else { + if err := res.toc.Print(stdout); err != nil { + return err + } } } } return nil } + +func (ctl *Controller) insertTocToFile(filepath string, toc *entity.Toc) error { + if !ctl.cfg.NoBackup { + backupPath, err := ctl.fileBackupper.CreateBackup(filepath) + if err != nil { + ctl.log.Info("Failed to create backup", "error", err) + return err + } + ctl.log.Info("Created backup", "path", backupPath) + } + + tocStr := toc.String() + if err := ctl.tocInserter.InsertToc(filepath, tocStr); err != nil { + ctl.log.Info("Failed to insert TOC", "error", err) + return err + } + + ctl.log.Info("TOC inserted", "file", filepath) + return nil +} diff --git a/internal/controller/new.go b/internal/controller/new.go index d882769..772db62 100644 --- a/internal/controller/new.go +++ b/internal/controller/new.go @@ -13,20 +13,24 @@ type useCase interface { } type Controller struct { - cfg Config - ucLocalMd useCase - ucRemoteMD useCase - ucRemoteHTML useCase - log ports.Logger + cfg Config + ucLocalMd useCase + ucRemoteMD useCase + ucRemoteHTML useCase + log ports.Logger + fileBackupper ports.FileBackupper + tocInserter ports.TocInserter } -func New(cfg Config, ucLocalMD useCase, ucRemoteMD useCase, ucRemoteHTML useCase, log ports.Logger) *Controller { +func New(cfg Config, ucLocalMD useCase, ucRemoteMD useCase, ucRemoteHTML useCase, log ports.Logger, fileBackupper ports.FileBackupper, tocInserter ports.TocInserter) *Controller { return &Controller{ - cfg: cfg, - ucLocalMd: ucLocalMD, - ucRemoteMD: ucRemoteMD, - ucRemoteHTML: ucRemoteHTML, - log: log, + cfg: cfg, + ucLocalMd: ucLocalMD, + ucRemoteMD: ucRemoteMD, + ucRemoteHTML: ucRemoteHTML, + log: log, + fileBackupper: fileBackupper, + tocInserter: tocInserter, } } diff --git a/internal/core/entity/toc.go b/internal/core/entity/toc.go index 30eee49..c6b329d 100644 --- a/internal/core/entity/toc.go +++ b/internal/core/entity/toc.go @@ -3,6 +3,7 @@ package entity import ( "fmt" "io" + "strings" ) type TocPrinter interface { @@ -39,3 +40,12 @@ func (toc Toc) At(idx int) string { ss := []string(toc) return ss[idx] } + +func (toc *Toc) String() string { + var buf strings.Builder + for _, tocItem := range *toc { + buf.WriteString(tocItem) + buf.WriteByte('\n') + } + return buf.String() +} diff --git a/internal/core/ports/ports.go b/internal/core/ports/ports.go index 315a507..7c5232a 100644 --- a/internal/core/ports/ports.go +++ b/internal/core/ports/ports.go @@ -37,3 +37,11 @@ type FileTemper interface { type RemotePoster interface { Post(url, token, path string) (string, error) } + +type FileBackupper interface { + CreateBackup(filepath string) (string, error) +} + +type TocInserter interface { + InsertToc(filepath string, toc string) error +} diff --git a/internal/core/usecase/config/config.go b/internal/core/usecase/config/config.go index db41a89..d757ea2 100644 --- a/internal/core/usecase/config/config.go +++ b/internal/core/usecase/config/config.go @@ -13,4 +13,6 @@ type Config struct { GHUrl string GHVersion string AbsPathInToc bool + Insert bool + NoBackup bool } diff --git a/internal/utils/utils.go b/internal/utils/utils.go index 36cedca..5778546 100644 --- a/internal/utils/utils.go +++ b/internal/utils/utils.go @@ -113,15 +113,19 @@ func EscapeSpecChars(s string) string { return res } -// ShowHeader shows header befor TOC. +func GetHeaderText() string { + return "\nTable of Contents\n=================\n" +} + +func GetFooterText() string { + return "Created by [gh-md-toc](https://github.com/ekalinin/github-markdown-toc.go)" +} + func ShowHeader(w io.Writer) { - _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, "Table of Contents") - _, _ = fmt.Fprintln(w, "=================") + _, _ = fmt.Fprint(w, GetHeaderText()) _, _ = fmt.Fprintln(w) } -// ShowFooter shows footer after TOC. func ShowFooter(w io.Writer) { - _, _ = fmt.Fprintln(w, "Created by [gh-md-toc](https://github.com/ekalinin/github-markdown-toc.go)") + _, _ = fmt.Fprintln(w, GetFooterText()) }