Compare commits
33
Commits
54f1c2e355
...
v0.0.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be0c761cb3 | ||
|
|
4ebf3bd60f | ||
|
|
63547e74c2 | ||
|
|
79b630b622 | ||
|
|
4520ad3dfd | ||
|
|
4072ca4a45 | ||
|
|
ba81eecea8 | ||
|
|
287d60e978 | ||
|
|
e33682098a | ||
|
|
cbf00f3e33 | ||
|
|
ab670b3ecd | ||
|
|
8bfd6b22c5 | ||
|
|
adc276c28a | ||
|
|
73c2009139 | ||
|
|
71961f68da | ||
|
|
3ea14b91c0 | ||
|
|
a2afce3f8d | ||
|
|
2bbbf4e81e | ||
|
|
1f5243d953 | ||
|
|
95b5a7b70c | ||
|
|
7f05519f6f | ||
|
|
72bee49d3f | ||
|
|
1e913c784e | ||
|
|
bc2cc0b531 | ||
|
|
fa661300bf | ||
|
|
6eaf4727f9 | ||
|
|
55684367e6 | ||
|
|
a483371dc5 | ||
|
|
b60588c4a0 | ||
|
|
9951a5947f | ||
|
|
6fbb9bf983 | ||
|
|
8c39068453 | ||
|
|
bccf9bf198 |
@@ -1,15 +1,49 @@
|
||||
# dirmd
|
||||
|
||||
Generate markdown documentation from directory structure.
|
||||
Generate markdown documentation from directory structure. Optimized for LLM ingestion.
|
||||
|
||||
## Features
|
||||
|
||||
- Recursive directory traversal
|
||||
- Tree visualization (standard tree command format)
|
||||
- Full file contents with syntax-highlighted code blocks
|
||||
- ASCII tree visualization (LLM-friendly, minimal token overhead)
|
||||
- Full file contents with explicit delimiters and metadata
|
||||
- Filters dotfiles, binaries, and blacklisted extensions
|
||||
- Vertical slices mode: root file + one markdown per top-level directory
|
||||
- Append mode for single files or directories
|
||||
- Force overwrite of existing output files
|
||||
- Configurable depth, file size limits, ignore patterns
|
||||
- Optional upload to Proton Drive after generating output
|
||||
- Skip frontend files (`.html`, `.css`, `.js`, etc.)
|
||||
- Frontend only mode (include only frontend files and `README.md`)
|
||||
- Omit test files and inline test blocks (Go, Java, Rust)
|
||||
|
||||
## Installation
|
||||
Only for linux.
|
||||
Download binary from release page or install with go
|
||||
```bash
|
||||
go install git.flo.fo/FLO/dirmd@latest
|
||||
```
|
||||
|
||||
## Use with Git Post Hook
|
||||
combine with git post hook to sync with proton drive on each commit
|
||||
|
||||
add something like this in ./.git/hooks/post-commit
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if ! command -v dirmd &>/dev/null; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||
REPO_NAME=$(basename "$REPO_ROOT")
|
||||
|
||||
dirmd -o "/tmp/${REPO_NAME}" "$REPO_ROOT" -f --vertical-slices --proton-drive
|
||||
```
|
||||
|
||||
|
||||
## Build
|
||||
|
||||
@@ -19,65 +53,197 @@ Generate markdown documentation from directory structure.
|
||||
|
||||
# Create new documentation
|
||||
dirmd -o output.md ~/repos/myproject
|
||||
|
||||
|
||||
# Overwrite existing output file
|
||||
dirmd -o output.md ~/repos/myproject -f
|
||||
|
||||
# Append a single file
|
||||
dirmd -a -i README.md -o existing.md
|
||||
|
||||
|
||||
# Append another directory
|
||||
dirmd -a ~/repos/anotherproject -o existing.md
|
||||
|
||||
# Generate with vertical slices
|
||||
dirmd -o output.md ~/repos/myproject --vertical-slices
|
||||
|
||||
# Skip frontend files (.html, .css, .js, etc.)
|
||||
dirmd -o output.md ~/repos/myproject --skip-frontend
|
||||
|
||||
# Frontend only (include only frontend files and README.md)
|
||||
dirmd -o output.md ~/repos/myproject --frontend-only
|
||||
|
||||
# Omit test files and inline test blocks
|
||||
# Go: excludes *_test.go
|
||||
# Java: excludes *Test.java, *Tests.java, src/test/java/*
|
||||
# Rust: strips #[cfg(test)] mod tests { ... } blocks
|
||||
dirmd -o output.md ~/repos/myproject --omit-test
|
||||
|
||||
# Combine filters
|
||||
dirmd -o output.md ~/repos/myproject --frontend-only --omit-test
|
||||
|
||||
# Generate and upload to Proton Drive (default: /my-files/md/<filename>)
|
||||
dirmd -o output.md ~/repos/myproject --proton-drive
|
||||
|
||||
# Generate and upload to a specific remote path
|
||||
dirmd -o output.md ~/repos/myproject --proton-drive --drive-path /my-files/docs/api.md
|
||||
|
||||
## Test File Omission
|
||||
|
||||
The `--omit-test` flag removes test files and inline test blocks to reduce token overhead in LLM context windows. Behavior varies by language:
|
||||
|
||||
| Language | Exclusion Strategy | What Gets Removed |
|
||||
|----------|-------------------|-------------------|
|
||||
| **Go** | File exclusion | All `*_test.go` files |
|
||||
| **Java** | File exclusion | `*Test.java`, `*Tests.java`, files under `src/test/java/` |
|
||||
| **Rust** | Content stripping | Entire `#[cfg(test)] mod tests { ... }` blocks removed from `.rs` files |
|
||||
|
||||
The Rust stripping works by detecting the `#[cfg(test)]` attribute, then tracking brace depth until the module closes. Nested braces in string literals may occasionally throw off the count, but this is acceptable for RAG use cases.
|
||||
|
||||
## Frontend Filtering
|
||||
|
||||
Two complementary flags control frontend file inclusion:
|
||||
|
||||
| Flag | Effect |
|
||||
|------|--------|
|
||||
| `--skip-frontend` | Exclude frontend files, keep everything else |
|
||||
| `--frontend-only` | Include only frontend files and `README.md`, exclude everything else |
|
||||
|
||||
Frontend extensions covered:
|
||||
|
||||
| Type | Extensions |
|
||||
|------|------------|
|
||||
| Templates | `.html`, `.htm`, `.gohtml`, `.tmpl` |
|
||||
| Stylesheets | `.css`, `.scss`, `.sass`, `.less` |
|
||||
| JavaScript | `.js`, `.jsx`, `.mjs`, `.cjs` |
|
||||
| TypeScript | `.ts`, `.tsx` |
|
||||
| Frameworks | `.vue`, `.svelte` |
|
||||
|
||||
`--skip-frontend` and `--frontend-only` are mutually exclusive. Both can be combined with `--omit-test`.
|
||||
|
||||
## Vertical Slices Mode
|
||||
|
||||
When `--vertical-slices` is passed, dirmd splits output into multiple files instead of one monolithic document:
|
||||
|
||||
- **Root file** (`-o output.md`): Full directory tree with cross-references to slice files, a vertical slices index table (file counts, total bytes per directory), and full contents of all root-level files.
|
||||
- **Slice files** (`cmd.md`, `internal.md`, etc.): One per top-level directory, placed alongside the root file. Each contains its own tree, a back-reference to the root document, and full recursive file contents for that directory.
|
||||
|
||||
This mode cannot be combined with `--append` or `--input-file`.
|
||||
|
||||
Example output with `dirmd -o docs.md ~/repos/myproject --vertical-slices`:
|
||||
|
||||
docs/
|
||||
├── docs.md ← root: tree + slices index + root file contents
|
||||
├── cmd.md ← slice: tree + contents of cmd/
|
||||
└── internal.md ← slice: tree + contents of internal/
|
||||
|
||||
## LLM-Optimized Output
|
||||
|
||||
Output is designed for RAG ingestion and LLM context windows, not human presentation:
|
||||
|
||||
- **ASCII tree format** (`+-` instead of Unicode box-drawing) to reduce token overhead
|
||||
- **Explicit file delimiters**: `--- FILE: ./path (bytes, lines) ---` and `--- END FILE ---`
|
||||
- **Document type markers**: `[ROOT]` and `[SLICE]` in H1 headers
|
||||
- **Cross-references**: Directory entries in root tree show `-> see cmd.md`; slice files reference their root document
|
||||
- **Slices index table**: File counts and total bytes per slice for informed retrieval decisions
|
||||
- **Tilde fences** (`~~~`) to avoid conflicts with backticks in source files
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `-o`, `--output` | Output markdown file path (required) | |
|
||||
| `-a`, `--append` | Append to existing output file | false |
|
||||
| `-f`, `--force` | Overwrite existing output file (non-append mode) | false |
|
||||
| `-i`, `--input-file` | Single file input (append mode only) | |
|
||||
| `--vertical-slices` | Split output into root file + one markdown per top-level directory | false |
|
||||
| `--max-size` | Max file size in bytes | 524288 |
|
||||
| `--max-depth` | Max directory recursion depth | 20 |
|
||||
| `--ignore` | Additional ignore patterns (glob) | |
|
||||
| `--extensions` | Additional file extensions to skip | |
|
||||
| `--proton-drive` | Upload output to Proton Drive after writing locally | false |
|
||||
| `--drive-path` | Full remote path on Proton Drive (e.g. /my-files/md/report.md) | /my-files/md/<filename> |
|
||||
| `--skip-frontend` | Omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.) | false |
|
||||
| `--frontend-only` | Include only frontend file types and README.md | false |
|
||||
| `--omit-test` | Exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust | false |
|
||||
|
||||
## Proton Drive Integration
|
||||
|
||||
dirmd can optionally upload the generated markdown file(s) to Proton Drive using the [Proton Drive CLI](https://proton.me/support/drive-cli). The upload is performed only when `--proton-drive` is passed. Without it, dirmd behaves exactly as before — no Proton Drive dependency is required.
|
||||
|
||||
In vertical slices mode, all generated files (root + slices) are uploaded to `/my-files/md/`. The `--drive-path` flag is ignored in slices mode ; each file is uploaded by its basename.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Install the Proton Drive CLI — see the [official guide](https://proton.me/support/drive-cli) for download and installation instructions.
|
||||
2. Authenticate by running `proton-drive auth login`.
|
||||
3. Ensure `proton-drive` is in your PATH.
|
||||
|
||||
If the CLI is not installed or you are not logged in, dirmd will report the error to stderr and exit with a non-zero code. Local files are always written successfully before the upload is attempted.
|
||||
|
||||
For full documentation on the Proton Drive CLI, including installation, authentication, and troubleshooting, refer to the [official Proton Drive CLI support page](https://proton.me/support/drive-cli).
|
||||
|
||||
## Examples
|
||||
|
||||
# Custom depth and size limit
|
||||
dirmd -o output.md ~/repos/project --max-depth 5 --max-size 262144
|
||||
|
||||
|
||||
# Ignore specific directories
|
||||
dirmd -o output.md ~/repos/project --ignore __pycache__ --ignore .venv
|
||||
|
||||
|
||||
# Skip additional file extensions
|
||||
dirmd -o output.md ~/repos/project --extensions .log --extensions .tmp
|
||||
|
||||
# Vertical slices with overwrite
|
||||
dirmd -o docs.md ~/repos/project -f --vertical-slices
|
||||
|
||||
# Skip frontend and omit tests
|
||||
dirmd -o docs.md ~/repos/project -f --skip-frontend --omit-test
|
||||
|
||||
# Frontend only with test omission
|
||||
dirmd -o docs.md ~/repos/project -f --frontend-only --omit-test
|
||||
|
||||
# Generate, then upload to default remote location
|
||||
dirmd -o docs.md ~/repos/project --proton-drive
|
||||
|
||||
# Generate, then upload to a custom remote location
|
||||
dirmd -o docs.md ~/repos/project --proton-drive --drive-path /my-files/projects/docs.md
|
||||
|
||||
# Overwrite existing local file and upload to Drive
|
||||
dirmd -o docs.md ~/repos/project -f --proton-drive
|
||||
|
||||
## Output Format
|
||||
|
||||
Produces markdown with:
|
||||
|
||||
1. Absolute path as H1 header
|
||||
2. Directory tree (indented with 4 spaces per level)
|
||||
3. All text files with content in code blocks
|
||||
4. Language detection from file extensions
|
||||
1. Absolute path as H1 header with `[ROOT]` or `[SLICE]` marker
|
||||
2. ASCII directory tree (indented with `+-` and `|` characters)
|
||||
3. Vertical slices index table (sliced mode only)
|
||||
4. All text files with content in fenced code blocks
|
||||
5. Explicit `--- FILE: ---` and `--- END FILE ---` delimiters with byte and line counts
|
||||
6. Language detection from file extensions
|
||||
|
||||
Example output structure:
|
||||
Example output structure (non-sliced mode):
|
||||
|
||||
# /home/user/repos/myproject [ROOT]
|
||||
|
||||
# /home/user/repos/myproject
|
||||
|
||||
## Directory Tree
|
||||
|
||||
|
||||
~~~
|
||||
myproject/
|
||||
├── cmd/
|
||||
│ └── main.go
|
||||
└── internal/
|
||||
└── config.go
|
||||
|
||||
+- cmd/
|
||||
| +- main.go
|
||||
+- internal/
|
||||
| +- config.go
|
||||
~~~
|
||||
|
||||
## Contents
|
||||
|
||||
### ./cmd/main.go
|
||||
|
||||
```go
|
||||
|
||||
--- FILE: ./cmd/main.go (45 bytes, 3 lines) ---
|
||||
~~~go
|
||||
package main
|
||||
|
||||
|
||||
func main() {}
|
||||
```
|
||||
~~~
|
||||
--- END FILE ---
|
||||
|
||||
Note: Uses tilde fences (~~~) internally to avoid conflicts with nested backticks in source files.
|
||||
|
||||
+148
-40
@@ -7,42 +7,61 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"dirmd/internal/config"
|
||||
"dirmd/internal/drive"
|
||||
"dirmd/internal/renderer"
|
||||
"dirmd/internal/walker"
|
||||
"git.flo.fo/FLO/dirmd/internal/config"
|
||||
"git.flo.fo/FLO/dirmd/internal/drive"
|
||||
"git.flo.fo/FLO/dirmd/internal/renderer"
|
||||
"git.flo.fo/FLO/dirmd/internal/tui"
|
||||
"git.flo.fo/FLO/dirmd/internal/walker"
|
||||
)
|
||||
|
||||
var (
|
||||
rootCmd = &cobra.Command{
|
||||
Use: "dirmd [flags] <directory>",
|
||||
Short: "Generate markdown documentation from directory structure",
|
||||
Long: `dirmd walks a directory and generates a markdown file with:
|
||||
Long: `dirmd walks a directory and generates markdown files with:
|
||||
|
||||
- Directory tree structure
|
||||
- Directory tree structure
|
||||
- All readable text files with their contents
|
||||
|
||||
Output is always written to a directory specified by -o.
|
||||
|
||||
Examples:
|
||||
|
||||
# Create new documentation
|
||||
dirmd -o output.md ~/repos/myproject
|
||||
# Create new documentation (writes index.md inside ./docs/)
|
||||
dirmd -o ./docs ~/repos/myproject
|
||||
|
||||
# Append a single file
|
||||
dirmd -a -i README.md -o existing.md
|
||||
# Overwrite existing output directory
|
||||
dirmd -o ./docs ~/repos/myproject -f
|
||||
|
||||
# Append another directory
|
||||
dirmd -a -o existing.md ~/repos/anotherproject
|
||||
# Append a single file
|
||||
dirmd -a -i README.md -o ./docs
|
||||
|
||||
# Generate and upload to Proton Drive (default remote path /my-files/md/<filename>)
|
||||
dirmd -o output.md ~/repos/myproject --proton-drive
|
||||
# Vertical slices (root index.md + one markdown per top-level dir)
|
||||
dirmd -o ./docs ~/repos/myproject --vertical-slices
|
||||
|
||||
# Skip frontend files
|
||||
dirmd -o ./docs ~/repos/myproject --skip-frontend
|
||||
|
||||
# Frontend only
|
||||
dirmd -o ./docs ~/repos/myproject --frontend-only
|
||||
|
||||
# Omit test files and test blocks
|
||||
dirmd -o ./docs ~/repos/myproject --omit-test
|
||||
|
||||
# Generate and upload to Proton Drive
|
||||
dirmd -o ./docs ~/repos/myproject --proton-drive
|
||||
|
||||
# Generate and upload to a specific remote path
|
||||
dirmd -o output.md ~/repos/myproject --proton-drive --drive-path /my-files/docs/api.md`,
|
||||
dirmd -o ./docs ~/repos/myproject --proton-drive --drive-path /my-files/docs/api
|
||||
|
||||
# Interactive TUI mode
|
||||
dirmd --tui`,
|
||||
RunE: run,
|
||||
}
|
||||
|
||||
outputPath string
|
||||
appendMode bool
|
||||
force bool
|
||||
singleFile string
|
||||
maxSize int64
|
||||
maxDepth int
|
||||
@@ -50,6 +69,12 @@ Examples:
|
||||
extensions []string
|
||||
protonDrive bool
|
||||
drivePath string
|
||||
verticalSlices bool
|
||||
skipFrontend bool
|
||||
frontendOnly bool
|
||||
omitTest bool
|
||||
omitMd bool
|
||||
tuiMode bool
|
||||
)
|
||||
|
||||
func Execute() {
|
||||
@@ -59,33 +84,69 @@ func Execute() {
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output markdown file path (required)")
|
||||
rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing output file")
|
||||
rootCmd.Flags().StringVarP(&singleFile, "input-file", "i", "", "single file input (append mode only)")
|
||||
rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output directory (required)")
|
||||
rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing index.md")
|
||||
rootCmd.Flags().BoolVarP(&force, "force", "f", false, "overwrite existing output directory")
|
||||
rootCmd.Flags().StringVar(&singleFile, "input-file", "", "single file input (append mode only)")
|
||||
rootCmd.Flags().BoolVarP(&tuiMode, "interactive", "i", false, "launch interactive TUI configuration wizard")
|
||||
rootCmd.Flags().Int64Var(&maxSize, "max-size", config.DefaultMaxSize, "max file size in bytes")
|
||||
rootCmd.Flags().IntVar(&maxDepth, "max-depth", config.DefaultMaxDepth, "max directory recursion depth")
|
||||
rootCmd.Flags().StringArrayVar(&ignorePatterns, "ignore", nil, "additional ignore patterns (glob)")
|
||||
rootCmd.Flags().StringArrayVar(&extensions, "extensions", nil, "additional file extensions to skip (repeatable)")
|
||||
rootCmd.Flags().BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally")
|
||||
rootCmd.Flags().StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (e.g. /my-files/md/report.md). Requires --proton-drive")
|
||||
|
||||
rootCmd.MarkPersistentFlagRequired("output")
|
||||
rootCmd.Flags().BoolVar(&protonDrive, "proton-drive", false, "upload output directory to Proton Drive after writing locally")
|
||||
rootCmd.Flags().StringVar(&drivePath, "drive-path", "", "full remote directory on Proton Drive (e.g. /my-files/docs/api). Requires --proton-drive")
|
||||
rootCmd.Flags().BoolVar(&verticalSlices, "vertical-slices", false, "split output into root index.md + one markdown per top-level directory")
|
||||
rootCmd.Flags().BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.)")
|
||||
rootCmd.Flags().BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md")
|
||||
rootCmd.Flags().BoolVar(&omitTest, "omit-test", false, "exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust")
|
||||
rootCmd.Flags().BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md")
|
||||
}
|
||||
|
||||
func run(cmd *cobra.Command, args []string) error {
|
||||
if tuiMode {
|
||||
return tui.Run()
|
||||
}
|
||||
|
||||
if outputPath == "" {
|
||||
return fmt.Errorf("-o is required (or use --tui for interactive mode)")
|
||||
}
|
||||
|
||||
if drivePath != "" && !protonDrive {
|
||||
return fmt.Errorf("--drive-path requires --proton-drive")
|
||||
}
|
||||
|
||||
if verticalSlices && appendMode {
|
||||
return fmt.Errorf("--vertical-slices cannot be used with --append")
|
||||
}
|
||||
|
||||
if verticalSlices && singleFile != "" {
|
||||
return fmt.Errorf("--vertical-slices cannot be used with --input-file")
|
||||
}
|
||||
|
||||
if skipFrontend && frontendOnly {
|
||||
return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
|
||||
}
|
||||
|
||||
excludedExts := append(config.DefaultExts, extensions...)
|
||||
if skipFrontend {
|
||||
excludedExts = append(excludedExts, config.FrontendExts...)
|
||||
}
|
||||
|
||||
cfg := &config.Config{
|
||||
OutputPath: outputPath,
|
||||
AppendMode: appendMode,
|
||||
MaxSize: maxSize,
|
||||
MaxDepth: maxDepth,
|
||||
Ignores: append(config.DefaultIgnores, ignorePatterns...),
|
||||
Extensions: append(config.DefaultExts, extensions...),
|
||||
ProtonDrive: protonDrive,
|
||||
DrivePath: drivePath,
|
||||
OutputPath: outputPath,
|
||||
AppendMode: appendMode,
|
||||
Force: force,
|
||||
MaxSize: maxSize,
|
||||
MaxDepth: maxDepth,
|
||||
Ignores: append(config.DefaultIgnores, ignorePatterns...),
|
||||
Extensions: excludedExts,
|
||||
ProtonDrive: protonDrive,
|
||||
DrivePath: drivePath,
|
||||
VerticalSlices: verticalSlices,
|
||||
SkipFrontend: skipFrontend,
|
||||
FrontendOnly: frontendOnly,
|
||||
OmitTest: omitTest,
|
||||
OmitMd: omitMd,
|
||||
}
|
||||
|
||||
if singleFile != "" {
|
||||
@@ -107,13 +168,24 @@ func run(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
cfg.AbsRoot = abs
|
||||
|
||||
// Non-append mode: output must not exist
|
||||
absOutput, err := filepath.Abs(outputPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve output path: %w", err)
|
||||
}
|
||||
|
||||
if !appendMode {
|
||||
if _, err := os.Stat(outputPath); err == nil {
|
||||
return fmt.Errorf("output file %s already exists (use -a to append)", outputPath)
|
||||
if _, err := os.Stat(absOutput); err == nil && !force {
|
||||
return fmt.Errorf("output directory %s already exists (use -f to overwrite)", absOutput)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(absOutput, 0755); err != nil {
|
||||
return fmt.Errorf("cannot create output directory: %w", err)
|
||||
}
|
||||
|
||||
repoName := filepath.Base(cfg.AbsRoot)
|
||||
fmt.Printf("%s\n", absOutput)
|
||||
|
||||
result, err := walker.Run(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -123,16 +195,52 @@ func run(cmd *cobra.Command, args []string) error {
|
||||
return fmt.Errorf("no readable text files found in %s", cfg.AbsRoot)
|
||||
}
|
||||
|
||||
if err := renderer.Write(result, outputPath, appendMode, cfg.SingleFile); err != nil {
|
||||
indexBasename := "index_" + repoName + ".md"
|
||||
|
||||
if verticalSlices {
|
||||
generated, err := renderer.WriteSlices(result, absOutput, repoName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "generated %d files in %s\n", len(generated), absOutput)
|
||||
|
||||
if protonDrive {
|
||||
remoteDir := drivePath
|
||||
if remoteDir == "" {
|
||||
remoteDir = "/my-files/md/" + filepath.Base(absOutput)
|
||||
}
|
||||
|
||||
if err := drive.CheckAuth(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
fmt.Fprintln(os.Stderr, "local files were written successfully, but upload was skipped")
|
||||
return err
|
||||
}
|
||||
|
||||
if err := drive.UploadDir(absOutput, remoteDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
fmt.Fprintln(os.Stderr, "local files were written successfully, but upload failed")
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
indexPath := filepath.Join(absOutput, indexBasename)
|
||||
|
||||
if err := renderer.Write(result, indexPath, appendMode, cfg.SingleFile); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "wrote %d entries to %s\n", len(result.Entries), outputPath)
|
||||
fmt.Fprintf(os.Stderr, "wrote %d entries to %s\n", len(result.Entries), indexPath)
|
||||
|
||||
if protonDrive {
|
||||
remotePath := drivePath
|
||||
if remotePath == "" {
|
||||
remotePath = "/my-files/md/" + filepath.Base(outputPath)
|
||||
remoteDir := drivePath
|
||||
if remoteDir == "" {
|
||||
remoteDir = "/my-files/md/" + filepath.Base(absOutput)
|
||||
}
|
||||
|
||||
if err := drive.CheckAuth(); err != nil {
|
||||
@@ -141,13 +249,13 @@ func run(cmd *cobra.Command, args []string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := drive.Upload(outputPath, remotePath); err != nil {
|
||||
if err := drive.UploadDir(absOutput, remoteDir); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload failed")
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", outputPath, remotePath)
|
||||
fmt.Fprintf(os.Stderr, "uploaded %s to %s on Proton Drive\n", absOutput, remoteDir)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
module dirmd
|
||||
module git.flo.fo/FLO/dirmd
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require github.com/spf13/cobra v1.10.2
|
||||
require (
|
||||
charm.land/bubbles/v2 v2.2.1
|
||||
charm.land/huh/v2 v2.0.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
)
|
||||
|
||||
require (
|
||||
charm.land/bubbletea/v2 v2.0.9 // indirect
|
||||
charm.land/lipgloss/v2 v2.0.6 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/catppuccin/go v0.2.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.3 // indirect
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.8 // indirect
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/charmbracelet/x/termios v0.1.1 // indirect
|
||||
github.com/charmbracelet/x/windows v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.11.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.27 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,10 +1,76 @@
|
||||
charm.land/bubbles/v2 v2.2.1 h1:Fq1+qm5hV6GkvzLQDhCBpXXE5tLgvh1PRriCLwSvIQU=
|
||||
charm.land/bubbles/v2 v2.2.1/go.mod h1:wdMgn+sje1KNXdwFizIWjbf328fIUBxqEmJ/vYPo8yc=
|
||||
charm.land/bubbletea/v2 v2.0.9 h1:DpJCMWKgzQK8SJv4zbKKFHAI10ymWy/evClPFk0k0f8=
|
||||
charm.land/bubbletea/v2 v2.0.9/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss=
|
||||
charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
|
||||
charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
|
||||
charm.land/lipgloss/v2 v2.0.6 h1:EaGKeuA8FvF+v2BT5VmZd2LoYLaMZJXA5n34th8nCIQ=
|
||||
charm.land/lipgloss/v2 v2.0.6/go.mod h1:ipDDJNSGa1hlwDtSfW1s2/xR8Vdhbut4PXh2zEKZd0Q=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
|
||||
github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
|
||||
github.com/catppuccin/go v0.2.0 h1:ktBeIrIP42b/8FGiScP9sgrWOss3lw0Z5SktRoithGA=
|
||||
github.com/catppuccin/go v0.2.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
|
||||
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA=
|
||||
github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro=
|
||||
github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ=
|
||||
github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0=
|
||||
github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
|
||||
github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
|
||||
github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
|
||||
github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
|
||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
|
||||
github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
|
||||
github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
|
||||
github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
|
||||
github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
|
||||
github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
|
||||
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
|
||||
github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
|
||||
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1 h1:1EO+WB73+EH8EVbzlrG3KLAfEypQWVHIBqlTf+2hNss=
|
||||
github.com/lucasb-eyer/go-colorful v1.4.1/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.27 h1:Feg/Oou5zI/wnpgDF6omIU0OokC9GxLC/WRknhVlIR0=
|
||||
github.com/mattn/go-runewidth v0.0.27/go.mod h1:3qAiGCV4Koz/yuveO58qUefmUTRm8r0IGEXZ9jeHp/8=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
|
||||
+87
-34
@@ -19,11 +19,15 @@ var DefaultIgnores = []string{
|
||||
"node_modules",
|
||||
"vendor",
|
||||
"bin",
|
||||
"go.mod",
|
||||
"go.sum",
|
||||
"target",
|
||||
"build",
|
||||
"dist",
|
||||
"Cargo.lock",
|
||||
".idea",
|
||||
".vscode",
|
||||
"dependency-reduced-pom.xml",
|
||||
}
|
||||
|
||||
var DefaultExts = []string{
|
||||
@@ -35,12 +39,20 @@ var DefaultExts = []string{
|
||||
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
||||
".pem", ".key", ".cert",
|
||||
".db", ".sqlite", ".sqlite3",
|
||||
".class", // Java compiled bytecode
|
||||
".jar", // JAR archives
|
||||
".class",
|
||||
".jar",
|
||||
".war",
|
||||
".ear",
|
||||
}
|
||||
|
||||
var FrontendExts = []string{
|
||||
".html", ".htm", ".gohtml", ".tmpl",
|
||||
".css", ".scss", ".sass", ".less",
|
||||
".js", ".jsx", ".mjs", ".cjs",
|
||||
".ts", ".tsx",
|
||||
".vue", ".svelte",
|
||||
}
|
||||
|
||||
type stringSlice []string
|
||||
|
||||
func (s *stringSlice) String() string {
|
||||
@@ -53,17 +65,23 @@ func (s *stringSlice) Set(v string) error {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
InputPath string
|
||||
OutputPath string
|
||||
AppendMode bool
|
||||
SingleFile bool
|
||||
MaxSize int64
|
||||
MaxDepth int
|
||||
Ignores []string
|
||||
Extensions []string
|
||||
AbsRoot string
|
||||
ProtonDrive bool
|
||||
DrivePath string
|
||||
InputPath string
|
||||
OutputPath string
|
||||
AppendMode bool
|
||||
Force bool
|
||||
SingleFile bool
|
||||
MaxSize int64
|
||||
MaxDepth int
|
||||
Ignores []string
|
||||
Extensions []string
|
||||
AbsRoot string
|
||||
ProtonDrive bool
|
||||
DrivePath string
|
||||
VerticalSlices bool
|
||||
SkipFrontend bool
|
||||
FrontendOnly bool
|
||||
OmitTest bool
|
||||
OmitMd bool
|
||||
}
|
||||
|
||||
func PrintUsage() {
|
||||
@@ -76,28 +94,40 @@ func PrintUsage() {
|
||||
|
||||
func Parse() (*Config, error) {
|
||||
var (
|
||||
input string
|
||||
output string
|
||||
appendMd bool
|
||||
maxSize int64
|
||||
maxDepth int
|
||||
ignores stringSlice
|
||||
exts stringSlice
|
||||
showVer bool
|
||||
protonDrive bool
|
||||
drivePath string
|
||||
input string
|
||||
output string
|
||||
appendMd bool
|
||||
force bool
|
||||
maxSize int64
|
||||
maxDepth int
|
||||
ignores stringSlice
|
||||
exts stringSlice
|
||||
showVer bool
|
||||
protonDrive bool
|
||||
drivePath string
|
||||
verticalSlices bool
|
||||
skipFrontend bool
|
||||
frontendOnly bool
|
||||
omitTest bool
|
||||
omitMd bool
|
||||
)
|
||||
|
||||
flag.StringVar(&input, "i", "", "single file input (append mode only)")
|
||||
flag.StringVar(&output, "o", "", "output markdown file path (required)")
|
||||
flag.BoolVar(&appendMd, "a", false, "append to existing output file")
|
||||
flag.BoolVar(&force, "f", false, "overwrite existing output file (non-append mode)")
|
||||
flag.Int64Var(&maxSize, "max-size", DefaultMaxSize, "max file size in bytes")
|
||||
flag.IntVar(&maxDepth, "max-depth", DefaultMaxDepth, "max directory recursion depth")
|
||||
flag.Var(&ignores, "ignore", "additional ignore patterns (glob, repeatable)")
|
||||
flag.Var(&exts, "extensions", "additional file extensions to skip (repeatable)")
|
||||
flag.BoolVar(&showVer, "version", false, "print version and exit")
|
||||
flag.BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally")
|
||||
flag.StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (requires --proton-drive)")
|
||||
flag.StringVar(&drivePath, "drive-path", "", "full remote path on Proton Drive (requires --proton-drive")
|
||||
flag.BoolVar(&verticalSlices, "vertical-slices", false, "split output into root file + one markdown per top-level directory")
|
||||
flag.BoolVar(&skipFrontend, "skip-frontend", false, "omit frontend file types (.html, .css, .js, .ts, .vue, .svelte, etc.)")
|
||||
flag.BoolVar(&frontendOnly, "frontend-only", false, "include only frontend file types and README.md")
|
||||
flag.BoolVar(&omitTest, "omit-test", false, "exclude test files (Go *_test.go, Java *Test.java) and strip #[cfg(test)] blocks from Rust")
|
||||
flag.BoolVar(&omitMd, "omit-md", false, "omit all .md files except README.md")
|
||||
|
||||
flag.Usage = PrintUsage
|
||||
flag.Parse()
|
||||
@@ -115,15 +145,38 @@ func Parse() (*Config, error) {
|
||||
return nil, fmt.Errorf("--drive-path requires --proton-drive")
|
||||
}
|
||||
|
||||
if verticalSlices && appendMd {
|
||||
return nil, fmt.Errorf("--vertical-slices cannot be used with --append")
|
||||
}
|
||||
|
||||
if verticalSlices && input != "" {
|
||||
return nil, fmt.Errorf("--vertical-slices cannot be used with --input-file")
|
||||
}
|
||||
|
||||
if skipFrontend && frontendOnly {
|
||||
return nil, fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
|
||||
}
|
||||
|
||||
excludedExts := append(DefaultExts, exts...)
|
||||
if skipFrontend {
|
||||
excludedExts = append(excludedExts, FrontendExts...)
|
||||
}
|
||||
|
||||
cfg := &Config{
|
||||
OutputPath: output,
|
||||
AppendMode: appendMd,
|
||||
MaxSize: maxSize,
|
||||
MaxDepth: maxDepth,
|
||||
Ignores: append(DefaultIgnores, ignores...),
|
||||
Extensions: append(DefaultExts, exts...),
|
||||
ProtonDrive: protonDrive,
|
||||
DrivePath: drivePath,
|
||||
OutputPath: output,
|
||||
AppendMode: appendMd,
|
||||
Force: force,
|
||||
MaxSize: maxSize,
|
||||
MaxDepth: maxDepth,
|
||||
Ignores: append(DefaultIgnores, ignores...),
|
||||
Extensions: excludedExts,
|
||||
ProtonDrive: protonDrive,
|
||||
DrivePath: drivePath,
|
||||
VerticalSlices: verticalSlices,
|
||||
SkipFrontend: skipFrontend,
|
||||
FrontendOnly: frontendOnly,
|
||||
OmitTest: omitTest,
|
||||
OmitMd: omitMd,
|
||||
}
|
||||
|
||||
if input != "" {
|
||||
@@ -147,8 +200,8 @@ func Parse() (*Config, error) {
|
||||
cfg.AbsRoot = abs
|
||||
|
||||
if !appendMd {
|
||||
if _, err := os.Stat(output); err == nil {
|
||||
return nil, fmt.Errorf("output file %s already exists (use -a to append)", output)
|
||||
if _, err := os.Stat(output); err == nil && !force {
|
||||
return nil, fmt.Errorf("output file %s already exists (use -f to overwrite or -a to append)", output)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+51
-7
@@ -10,12 +10,10 @@ import (
|
||||
|
||||
const binaryName = "proton-drive"
|
||||
|
||||
// Lookup returns the full path to the proton-drive binary in PATH.
|
||||
func Lookup() (string, error) {
|
||||
return exec.LookPath(binaryName)
|
||||
}
|
||||
|
||||
// CheckAuth verifies that the user is authenticated by listing /my-files.
|
||||
func CheckAuth() error {
|
||||
binary, err := Lookup()
|
||||
if err != nil {
|
||||
@@ -32,11 +30,57 @@ func CheckAuth() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upload uploads localPath to remotePath on Proton Drive.
|
||||
// remotePath is a full path including filename (e.g. /my-files/md/report.md).
|
||||
// The CLI uploads to a parent directory and preserves the local filename,
|
||||
// so if the remote filename differs from the local one, a temp copy is staged.
|
||||
// Existing remote files are overwritten (--conflict-strategy replace).
|
||||
// EnsureRemoteParent creates the parent remote directory if it doesn't exist.
|
||||
func EnsureRemoteParent(remoteDir string) error {
|
||||
binary, err := Lookup()
|
||||
if err != nil {
|
||||
return fmt.Errorf("proton-drive not found in PATH: %w", err)
|
||||
}
|
||||
|
||||
parentDir := filepath.Dir(remoteDir)
|
||||
|
||||
// Try create-folder on the grandparent for the parent
|
||||
grandparent := filepath.Dir(parentDir)
|
||||
parentName := filepath.Base(parentDir)
|
||||
|
||||
cmd := exec.Command(binary, "filesystem", "create-folder", grandparent, parentName)
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.Run() // Ignore errors — folder may already exist
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UploadDir uploads an entire local directory to Proton Drive.
|
||||
// The local directory's basename becomes the remote directory name.
|
||||
func UploadDir(localDir, remoteDir string) error {
|
||||
binary, err := Lookup()
|
||||
if err != nil {
|
||||
return fmt.Errorf("proton-drive not found in PATH: %w", err)
|
||||
}
|
||||
|
||||
// Ensure parent of remote dir exists
|
||||
if err := EnsureRemoteParent(remoteDir); err != nil {
|
||||
return fmt.Errorf("cannot prepare remote directory: %w", err)
|
||||
}
|
||||
|
||||
// Upload to parent — the local dir's basename creates the remote dir
|
||||
remoteParent := filepath.Dir(remoteDir)
|
||||
|
||||
cmd := exec.Command(binary, "filesystem", "upload", localDir, remoteParent,
|
||||
"--conflict-strategy", "replace")
|
||||
|
||||
var stderr strings.Builder
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return fmt.Errorf("upload to proton-drive failed: %w: %s",
|
||||
err, strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upload uploads a single local file to a remote path on Proton Drive.
|
||||
func Upload(localPath, remotePath string) error {
|
||||
binary, err := Lookup()
|
||||
if err != nil {
|
||||
|
||||
@@ -4,12 +4,17 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.flo.fo/FLO/dirmd/internal/config"
|
||||
)
|
||||
|
||||
type Filter struct {
|
||||
MaxSize int64
|
||||
Extensions []string
|
||||
Ignores []string
|
||||
MaxSize int64
|
||||
Extensions []string
|
||||
Ignores []string
|
||||
OmitTest bool
|
||||
FrontendOnly bool
|
||||
OmitMd bool
|
||||
}
|
||||
|
||||
func (f *Filter) ShouldSkipDir(name string) bool {
|
||||
@@ -37,6 +42,22 @@ func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool {
|
||||
}
|
||||
}
|
||||
|
||||
if info.Size() > f.MaxSize {
|
||||
return true
|
||||
}
|
||||
|
||||
if f.OmitMd && isNonReadmeMarkdown(path, name) {
|
||||
return true
|
||||
}
|
||||
|
||||
if f.FrontendOnly && !isFrontendOrReadme(path, name) {
|
||||
return true
|
||||
}
|
||||
|
||||
if f.OmitTest && isTestFile(path, name) {
|
||||
return true
|
||||
}
|
||||
|
||||
ext := filepath.Ext(path)
|
||||
for _, bad := range f.Extensions {
|
||||
if strings.EqualFold(ext, bad) {
|
||||
@@ -44,8 +65,40 @@ func (f *Filter) ShouldSkipFile(path, name string, info os.FileInfo) bool {
|
||||
}
|
||||
}
|
||||
|
||||
if info.Size() > f.MaxSize {
|
||||
return true
|
||||
return false
|
||||
}
|
||||
|
||||
func isNonReadmeMarkdown(path, name string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
if ext != ".md" && ext != ".markdown" {
|
||||
return false
|
||||
}
|
||||
return !strings.EqualFold(name, "README.md")
|
||||
}
|
||||
|
||||
func isFrontendOrReadme(path, name string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
for _, fe := range config.FrontendExts {
|
||||
if strings.EqualFold(ext, fe) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return strings.EqualFold(name, "README.md")
|
||||
}
|
||||
|
||||
func isTestFile(path, name string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(path))
|
||||
|
||||
switch ext {
|
||||
case ".go":
|
||||
return strings.HasSuffix(strings.ToLower(name), "_test.go")
|
||||
case ".java":
|
||||
base := strings.TrimSuffix(name, filepath.Ext(name))
|
||||
if strings.HasSuffix(base, "Test") || strings.HasSuffix(base, "Tests") {
|
||||
return true
|
||||
}
|
||||
slashPath := filepath.ToSlash(path)
|
||||
return strings.Contains(slashPath, "/src/test/")
|
||||
}
|
||||
|
||||
return false
|
||||
|
||||
@@ -4,11 +4,12 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"dirmd/internal/tree"
|
||||
"dirmd/internal/walker"
|
||||
"git.flo.fo/FLO/dirmd/internal/tree"
|
||||
"git.flo.fo/FLO/dirmd/internal/walker"
|
||||
)
|
||||
|
||||
const fenceChars = "~~~"
|
||||
@@ -29,20 +30,19 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Determine the correct H1 path
|
||||
h1Path := result.AbsRoot
|
||||
if singleFile && len(result.Entries) > 0 {
|
||||
h1Path = filepath.Join(result.AbsRoot, result.Entries[0].RelPath)
|
||||
}
|
||||
|
||||
fmt.Fprintf(&sb, "# %s\n\n", h1Path)
|
||||
fmt.Fprintf(&sb, "# %s [ROOT]\n\n", h1Path)
|
||||
|
||||
if !singleFile {
|
||||
root := tree.Build(result.Entries)
|
||||
rootName := filepath.Base(result.AbsRoot)
|
||||
sb.WriteString("## Directory Tree\n\n")
|
||||
sb.WriteString(fenceChars + "\n")
|
||||
sb.WriteString(tree.Render(root, rootName))
|
||||
sb.WriteString(renderTreeForLLM(root, rootName))
|
||||
sb.WriteString(fenceChars + "\n\n")
|
||||
}
|
||||
|
||||
@@ -54,15 +54,16 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
|
||||
for _, entry := range result.Entries {
|
||||
fullPath := filepath.Join(result.AbsRoot, entry.RelPath)
|
||||
|
||||
content, err := readFile(fullPath)
|
||||
content, err := readFileForOutput(fullPath, result.OmitTest)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
language := extToLang(filepath.Ext(entry.RelPath))
|
||||
lines := countLines(content)
|
||||
|
||||
fmt.Fprintf(&sb, "### ./%s\n\n", entry.RelPath)
|
||||
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
|
||||
fmt.Fprintf(&sb, "%s", fenceChars)
|
||||
if language != "" {
|
||||
fmt.Fprintf(&sb, "%s", language)
|
||||
@@ -72,7 +73,7 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s\n\n", fenceChars)
|
||||
fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,6 +81,332 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
|
||||
return err
|
||||
}
|
||||
|
||||
func WriteSlices(result *walker.Result, outputDir, repoName string) ([]string, error) {
|
||||
entries := result.Entries
|
||||
if len(entries) == 0 {
|
||||
return nil, fmt.Errorf("no entries to write")
|
||||
}
|
||||
|
||||
absRoot := result.AbsRoot
|
||||
rootName := filepath.Base(absRoot)
|
||||
|
||||
topLevelDirs := make(map[string][]walker.Entry)
|
||||
rootFiles := make([]walker.Entry, 0)
|
||||
|
||||
for _, e := range entries {
|
||||
rel := e.RelPath
|
||||
parts := strings.Split(rel, string(filepath.Separator))
|
||||
if len(parts) == 1 {
|
||||
rootFiles = append(rootFiles, e)
|
||||
} else {
|
||||
firstPart := parts[0]
|
||||
topLevelDirs[firstPart] = append(topLevelDirs[firstPart], e)
|
||||
}
|
||||
}
|
||||
|
||||
var generated []string
|
||||
|
||||
indexBasename := "index_" + repoName + ".md"
|
||||
rootPath := filepath.Join(outputDir, indexBasename)
|
||||
generated = append(generated, rootPath)
|
||||
|
||||
if err := writeRootSlice(rootPath, absRoot, rootName, repoName, rootFiles, topLevelDirs, result.OmitTest); err != nil {
|
||||
return nil, fmt.Errorf("failed to write root slice: %w", err)
|
||||
}
|
||||
|
||||
var dirNames []string
|
||||
for k := range topLevelDirs {
|
||||
dirNames = append(dirNames, k)
|
||||
}
|
||||
sort.Strings(dirNames)
|
||||
|
||||
for _, dirName := range dirNames {
|
||||
sliceName := strings.TrimSuffix(dirName, "/") + "_" + repoName + ".md"
|
||||
sliceDir := filepath.Join(outputDir, dirName)
|
||||
if err := os.MkdirAll(sliceDir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("cannot create slice directory %s: %w", sliceDir, err)
|
||||
}
|
||||
slicePath := filepath.Join(sliceDir, sliceName)
|
||||
generated = append(generated, slicePath)
|
||||
|
||||
absDir := filepath.Join(absRoot, dirName)
|
||||
dirEntries := topLevelDirs[dirName]
|
||||
|
||||
treeEntries := make([]walker.Entry, len(dirEntries))
|
||||
for i, e := range dirEntries {
|
||||
sepIdx := strings.Index(e.RelPath, string(filepath.Separator))
|
||||
if sepIdx >= 0 {
|
||||
treeEntries[i] = walker.Entry{
|
||||
RelPath: e.RelPath[sepIdx+1:],
|
||||
Size: e.Size,
|
||||
}
|
||||
} else {
|
||||
treeEntries[i] = e
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeDirSlice(slicePath, absDir, absRoot, dirName, treeEntries, dirEntries, indexBasename, result.OmitTest); err != nil {
|
||||
return nil, fmt.Errorf("failed to write slice %s: %w", sliceName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return generated, nil
|
||||
}
|
||||
|
||||
func writeRootSlice(path, absRoot, rootName, repoName string, rootFiles []walker.Entry, topLevelDirs map[string][]walker.Entry, omitTest bool) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create root file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, "# %s [ROOT]\n\n", absRoot)
|
||||
|
||||
treeLines := buildTreeWithRefs(rootName, repoName, topLevelDirs, rootFiles)
|
||||
sb.WriteString("## Directory Tree\n\n")
|
||||
sb.WriteString(fenceChars + "\n")
|
||||
sb.WriteString(treeLines)
|
||||
sb.WriteString(fenceChars + "\n\n")
|
||||
|
||||
sb.WriteString("## Vertical Slices Index\n\n")
|
||||
sb.WriteString("These files contain the full contents of each top-level directory.\n")
|
||||
sb.WriteString("Read the relevant slice file for detailed source code.\n\n")
|
||||
sb.WriteString("| Slice File | Directory | Files | Total Bytes |\n")
|
||||
sb.WriteString("|------------|-----------|-------|-------------|\n")
|
||||
|
||||
var dirNames []string
|
||||
for k := range topLevelDirs {
|
||||
dirNames = append(dirNames, k)
|
||||
}
|
||||
sort.Strings(dirNames)
|
||||
|
||||
for _, dirName := range dirNames {
|
||||
entries := topLevelDirs[dirName]
|
||||
fileCount := len(entries)
|
||||
totalBytes := int64(0)
|
||||
for _, e := range entries {
|
||||
totalBytes += e.Size
|
||||
}
|
||||
sliceFile := filepath.Join(dirName, strings.TrimSuffix(dirName, "/")+"_"+repoName+".md")
|
||||
fmt.Fprintf(&sb, "| %s | %s/ | %d | %d |\n", sliceFile, dirName, fileCount, totalBytes)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
if len(rootFiles) > 0 {
|
||||
sb.WriteString("## Root Contents\n\n")
|
||||
|
||||
sort.Slice(rootFiles, func(i, j int) bool {
|
||||
return rootFiles[i].RelPath < rootFiles[j].RelPath
|
||||
})
|
||||
|
||||
for _, entry := range rootFiles {
|
||||
fullPath := filepath.Join(absRoot, entry.RelPath)
|
||||
content, err := readFileForOutput(fullPath, omitTest)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
language := extToLang(filepath.Ext(entry.RelPath))
|
||||
lines := countLines(content)
|
||||
|
||||
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
|
||||
fmt.Fprintf(&sb, "%s", fenceChars)
|
||||
if language != "" {
|
||||
fmt.Fprintf(&sb, "%s", language)
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
fmt.Fprintf(&sb, "%s", content)
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = f.WriteString(sb.String())
|
||||
return err
|
||||
}
|
||||
|
||||
func writeDirSlice(path, absDir, absRoot, dirName string, treeEntries, fileEntries []walker.Entry, rootFileName string, omitTest bool) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("cannot create slice file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
fmt.Fprintf(&sb, "# %s [SLICE]\n\n", absDir)
|
||||
fmt.Fprintf(&sb, "Root document: %s\n\n", rootFileName)
|
||||
|
||||
sb.WriteString("## Directory Tree\n\n")
|
||||
sb.WriteString(fenceChars + "\n")
|
||||
|
||||
sliceTree := buildTreeFromEntries(dirName, treeEntries)
|
||||
sb.WriteString(sliceTree)
|
||||
sb.WriteString(fenceChars + "\n\n")
|
||||
|
||||
if len(fileEntries) > 0 {
|
||||
sb.WriteString("## Contents\n\n")
|
||||
|
||||
sort.Slice(fileEntries, func(i, j int) bool {
|
||||
return fileEntries[i].RelPath < fileEntries[j].RelPath
|
||||
})
|
||||
|
||||
for _, entry := range fileEntries {
|
||||
fullPath := filepath.Join(absRoot, entry.RelPath)
|
||||
content, err := readFileForOutput(fullPath, omitTest)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
language := extToLang(filepath.Ext(entry.RelPath))
|
||||
lines := countLines(content)
|
||||
|
||||
fmt.Fprintf(&sb, "--- FILE: ./%s (%d bytes, %d lines) ---\n", entry.RelPath, entry.Size, lines)
|
||||
fmt.Fprintf(&sb, "%s", fenceChars)
|
||||
if language != "" {
|
||||
fmt.Fprintf(&sb, "%s", language)
|
||||
}
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
fmt.Fprintf(&sb, "%s", content)
|
||||
if !strings.HasSuffix(content, "\n") {
|
||||
fmt.Fprintf(&sb, "\n")
|
||||
}
|
||||
fmt.Fprintf(&sb, "%s\n--- END FILE ---\n\n", fenceChars)
|
||||
}
|
||||
}
|
||||
|
||||
_, err = f.WriteString(sb.String())
|
||||
return err
|
||||
}
|
||||
|
||||
func buildTreeWithRefs(rootName, repoName string, topLevelDirs map[string][]walker.Entry, rootFiles []walker.Entry) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(rootName)
|
||||
sb.WriteString("/\n")
|
||||
|
||||
var dirNames []string
|
||||
for k := range topLevelDirs {
|
||||
dirNames = append(dirNames, k)
|
||||
}
|
||||
sort.Strings(dirNames)
|
||||
|
||||
sort.Slice(rootFiles, func(i, j int) bool {
|
||||
return rootFiles[i].RelPath < rootFiles[j].RelPath
|
||||
})
|
||||
|
||||
type treeItem struct {
|
||||
name string
|
||||
isDir bool
|
||||
sliceRef string
|
||||
}
|
||||
|
||||
items := make([]treeItem, 0, len(dirNames)+len(rootFiles))
|
||||
for _, d := range dirNames {
|
||||
sliceRef := filepath.Join(d, strings.TrimSuffix(d, "/")+"_"+repoName+".md")
|
||||
items = append(items, treeItem{
|
||||
name: d,
|
||||
isDir: true,
|
||||
sliceRef: sliceRef,
|
||||
})
|
||||
}
|
||||
for _, rf := range rootFiles {
|
||||
items = append(items, treeItem{
|
||||
name: rf.RelPath,
|
||||
isDir: false,
|
||||
})
|
||||
}
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].name < items[j].name
|
||||
})
|
||||
|
||||
for _, item := range items {
|
||||
sb.WriteString("+- ")
|
||||
sb.WriteString(item.name)
|
||||
if item.isDir {
|
||||
sb.WriteString("/")
|
||||
sb.WriteString(" -> see ")
|
||||
sb.WriteString(item.sliceRef)
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func buildTreeFromEntries(rootName string, entries []walker.Entry) string {
|
||||
root := &tree.Node{IsDir: true, Children: make(map[string]*tree.Node)}
|
||||
|
||||
for _, e := range entries {
|
||||
parts := strings.Split(e.RelPath, string(filepath.Separator))
|
||||
cur := root
|
||||
|
||||
for i, part := range parts {
|
||||
isLeaf := i == len(parts)-1
|
||||
if _, ok := cur.Children[part]; !ok {
|
||||
node := &tree.Node{Name: part, IsDir: !isLeaf}
|
||||
if !isLeaf {
|
||||
node.Children = make(map[string]*tree.Node)
|
||||
}
|
||||
cur.Children[part] = node
|
||||
}
|
||||
cur = cur.Children[part]
|
||||
}
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(rootName)
|
||||
sb.WriteString("/\n")
|
||||
renderNodeLLM(&sb, root, "")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func renderTreeForLLM(root *tree.Node, rootName string) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(rootName)
|
||||
sb.WriteString("/\n")
|
||||
renderNodeLLM(&sb, root, "")
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func renderNodeLLM(sb *strings.Builder, node *tree.Node, prefix string) {
|
||||
keys := make([]string, 0, len(node.Children))
|
||||
for k := range node.Children {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
for i, key := range keys {
|
||||
child := node.Children[key]
|
||||
isLast := i == len(keys)-1
|
||||
|
||||
sb.WriteString(prefix)
|
||||
sb.WriteString("+- ")
|
||||
|
||||
sb.WriteString(child.Name)
|
||||
if child.IsDir {
|
||||
sb.WriteString("/")
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
if child.IsDir && child.Children != nil {
|
||||
newPrefix := prefix
|
||||
if isLast {
|
||||
newPrefix += " "
|
||||
} else {
|
||||
newPrefix += "| "
|
||||
}
|
||||
renderNodeLLM(sb, child, newPrefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(path string) (string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -103,6 +430,68 @@ func readFile(path string) (string, error) {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func readFileForOutput(fullPath string, omitTest bool) (string, error) {
|
||||
content, err := readFile(fullPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if omitTest && strings.EqualFold(filepath.Ext(fullPath), ".rs") {
|
||||
content = stripTestBlocks(content)
|
||||
}
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func stripTestBlocks(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
var result []string
|
||||
|
||||
i := 0
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
if isCfgTestAttr(trimmed) {
|
||||
braceDepth := 0
|
||||
foundBrace := false
|
||||
|
||||
for i < len(lines) {
|
||||
line := lines[i]
|
||||
|
||||
if !foundBrace {
|
||||
if strings.Contains(line, "{") {
|
||||
braceDepth += strings.Count(line, "{") - strings.Count(line, "}")
|
||||
foundBrace = true
|
||||
}
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
braceDepth += strings.Count(line, "{") - strings.Count(line, "}")
|
||||
i++
|
||||
|
||||
if braceDepth <= 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
result = append(result, line)
|
||||
i++
|
||||
}
|
||||
|
||||
if len(result) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
|
||||
func isCfgTestAttr(line string) bool {
|
||||
return strings.Contains(line, "#[cfg(test)]") ||
|
||||
strings.Contains(line, "#[cfg(all(test") ||
|
||||
strings.Contains(line, "#[cfg(any(test")
|
||||
}
|
||||
|
||||
func extToLang(ext string) string {
|
||||
mapping := map[string]string{
|
||||
".go": "go",
|
||||
@@ -131,3 +520,14 @@ func extToLang(ext string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func countLines(content string) int {
|
||||
if content == "" {
|
||||
return 0
|
||||
}
|
||||
count := strings.Count(content, "\n")
|
||||
if len(content) > 0 && content[len(content)-1] != '\n' {
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"dirmd/internal/walker"
|
||||
"git.flo.fo/FLO/dirmd/internal/walker"
|
||||
)
|
||||
|
||||
type Node struct {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.flo.fo/FLO/dirmd/internal/config"
|
||||
"git.flo.fo/FLO/dirmd/internal/drive"
|
||||
"git.flo.fo/FLO/dirmd/internal/renderer"
|
||||
"git.flo.fo/FLO/dirmd/internal/walker"
|
||||
)
|
||||
|
||||
type GenerateResult struct {
|
||||
Files []string
|
||||
Uploaded bool
|
||||
Error error
|
||||
}
|
||||
|
||||
func Generate(cfg *config.Config) GenerateResult {
|
||||
result := GenerateResult{}
|
||||
|
||||
abs, err := filepath.Abs(cfg.InputPath)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("resolve input path: %w", err)
|
||||
return result
|
||||
}
|
||||
cfg.AbsRoot = abs
|
||||
|
||||
absOutput, err := filepath.Abs(cfg.OutputPath)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("resolve output path: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
if _, err := os.Stat(absOutput); err == nil && !cfg.Force {
|
||||
if info, err2 := os.Stat(absOutput); err2 == nil && !info.IsDir() {
|
||||
result.Error = fmt.Errorf("output %s exists and is not a directory (enable force)", absOutput)
|
||||
return result
|
||||
}
|
||||
result.Error = fmt.Errorf("output %s exists (enable force)", absOutput)
|
||||
return result
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(absOutput, 0755); err != nil {
|
||||
result.Error = fmt.Errorf("create output dir: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
cfg.OutputPath = absOutput
|
||||
|
||||
walkResult, err := walker.Run(cfg)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
return result
|
||||
}
|
||||
|
||||
if len(walkResult.Entries) == 0 {
|
||||
result.Error = fmt.Errorf("no readable files in %s", cfg.AbsRoot)
|
||||
return result
|
||||
}
|
||||
|
||||
repoName := filepath.Base(cfg.AbsRoot)
|
||||
|
||||
if cfg.VerticalSlices {
|
||||
generated, err := renderer.WriteSlices(walkResult, cfg.OutputPath, repoName)
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
return result
|
||||
}
|
||||
result.Files = generated
|
||||
} else {
|
||||
indexPath := filepath.Join(cfg.OutputPath, "index_"+repoName+".md")
|
||||
if err := renderer.Write(walkResult, indexPath, false, false); err != nil {
|
||||
result.Error = err
|
||||
return result
|
||||
}
|
||||
result.Files = []string{indexPath}
|
||||
}
|
||||
|
||||
if cfg.ProtonDrive {
|
||||
remoteDir := cfg.DrivePath
|
||||
if remoteDir == "" {
|
||||
remoteDir = "/my-files/md/" + filepath.Base(cfg.OutputPath)
|
||||
}
|
||||
|
||||
if err := drive.CheckAuth(); err != nil {
|
||||
result.Error = fmt.Errorf("drive auth: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
if err := drive.UploadDir(cfg.OutputPath, remoteDir); err != nil {
|
||||
result.Error = fmt.Errorf("drive upload: %w", err)
|
||||
return result
|
||||
}
|
||||
result.Uploaded = true
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/huh/v2"
|
||||
"charm.land/huh/v2/spinner"
|
||||
|
||||
"git.flo.fo/FLO/dirmd/internal/config"
|
||||
)
|
||||
|
||||
func Run() error {
|
||||
clearScreen()
|
||||
|
||||
var (
|
||||
inputDir string
|
||||
outputDir string
|
||||
options []string
|
||||
drivePath string
|
||||
maxDepthStr string
|
||||
maxSizeStr string
|
||||
ignorePatterns string
|
||||
finalConfirm bool
|
||||
)
|
||||
|
||||
inputDir = "."
|
||||
maxDepthStr = "20"
|
||||
maxSizeStr = "512"
|
||||
defOut := defaultOutputDir()
|
||||
outputDir = defOut
|
||||
drivePath = "/my-files/md/"
|
||||
|
||||
km := huh.NewDefaultKeyMap()
|
||||
|
||||
km.Input.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next"))
|
||||
km.Input.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev"))
|
||||
km.Input.Submit = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit"))
|
||||
|
||||
km.Text.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next"))
|
||||
km.Text.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev"))
|
||||
km.Text.NewLine = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "new line"))
|
||||
km.Text.Submit = key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("ctrl+s", "submit"))
|
||||
|
||||
km.Confirm.Next = key.NewBinding(key.WithKeys("tab", "down"), key.WithHelp("tab/↓", "next"))
|
||||
km.Confirm.Prev = key.NewBinding(key.WithKeys("shift+tab", "up"), key.WithHelp("shift+tab/↑", "prev"))
|
||||
km.Confirm.Toggle = key.NewBinding(key.WithKeys("left", "right", "h", "l"), key.WithHelp("←/→", "toggle"))
|
||||
km.Confirm.Submit = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "confirm"))
|
||||
km.Confirm.Accept = key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "yes"))
|
||||
km.Confirm.Reject = key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "no"))
|
||||
|
||||
km.MultiSelect.Next = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next field"))
|
||||
km.MultiSelect.Prev = key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("shift+tab", "prev field"))
|
||||
km.MultiSelect.Up = key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up"))
|
||||
km.MultiSelect.Down = key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down"))
|
||||
km.MultiSelect.Toggle = key.NewBinding(key.WithKeys("enter", " "), key.WithHelp("enter/space", "toggle"))
|
||||
km.MultiSelect.Submit = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next"))
|
||||
km.MultiSelect.Filter = key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter"))
|
||||
km.MultiSelect.SelectAll = key.NewBinding(key.WithKeys("a"), key.WithHelp("a", "select all"))
|
||||
km.MultiSelect.SelectNone = key.NewBinding(key.WithKeys("A"), key.WithHelp("A", "select none"))
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewInput().Title("Input directory").Value(&inputDir).Placeholder("."),
|
||||
huh.NewInput().Title("Output directory").Value(&outputDir).Placeholder(defOut),
|
||||
huh.NewMultiSelect[string]().
|
||||
Title("Options").
|
||||
Description("↑↓ navigate • enter/space toggle • tab next field").
|
||||
Options(
|
||||
huh.NewOption("Force overwrite", "force"),
|
||||
huh.NewOption("Vertical slices", "vertical-slices"),
|
||||
huh.NewOption("Skip frontend files", "skip-frontend"),
|
||||
huh.NewOption("Frontend only", "frontend-only"),
|
||||
huh.NewOption("Omit test files", "omit-test"),
|
||||
huh.NewOption("Omit non-README markdown", "omit-md"),
|
||||
huh.NewOption("Upload to Proton Drive", "proton-drive"),
|
||||
).
|
||||
Value(&options).
|
||||
Height(10),
|
||||
huh.NewInput().
|
||||
Title("Proton Drive path").
|
||||
Description("Used if 'Upload to Proton Drive' is toggled").
|
||||
Value(&drivePath).
|
||||
Placeholder("/my-files/md/"),
|
||||
huh.NewInput().Title("Max depth").Value(&maxDepthStr).Placeholder("20"),
|
||||
huh.NewInput().Title("Max size (KB)").Value(&maxSizeStr).Placeholder("512"),
|
||||
huh.NewText().
|
||||
Title("Ignore patterns (one per line)").
|
||||
Description("enter = new line • tab = next field").
|
||||
Value(&ignorePatterns).
|
||||
Placeholder("One pattern per line...").
|
||||
Lines(4),
|
||||
huh.NewConfirm().
|
||||
Title("Confirm and proceed?").
|
||||
Description("←/→ or y/n to choose • enter to confirm").
|
||||
Value(&finalConfirm).
|
||||
Affirmative("Yes").
|
||||
Negative("No"),
|
||||
),
|
||||
).WithWidth(80).WithKeyMap(km)
|
||||
|
||||
if err := form.Run(); err != nil {
|
||||
return fmt.Errorf("form: %w", err)
|
||||
}
|
||||
|
||||
force := containsOption(options, "force")
|
||||
verticalSlices := containsOption(options, "vertical-slices")
|
||||
skipFrontend := containsOption(options, "skip-frontend")
|
||||
frontendOnly := containsOption(options, "frontend-only")
|
||||
omitTest := containsOption(options, "omit-test")
|
||||
omitMd := containsOption(options, "omit-md")
|
||||
protonDrive := containsOption(options, "proton-drive")
|
||||
|
||||
if skipFrontend && frontendOnly {
|
||||
return fmt.Errorf("--skip-frontend and --frontend-only are mutually exclusive")
|
||||
}
|
||||
|
||||
if !finalConfirm {
|
||||
fmt.Fprintln(os.Stderr, "Cancelled.")
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
maxDepth, err := strconv.Atoi(strings.TrimSpace(maxDepthStr))
|
||||
if err != nil || maxDepth <= 0 {
|
||||
maxDepth = 20
|
||||
}
|
||||
|
||||
maxSizeKB, err := strconv.Atoi(strings.TrimSpace(maxSizeStr))
|
||||
if err != nil || maxSizeKB <= 0 {
|
||||
maxSizeKB = 512
|
||||
}
|
||||
|
||||
var ignores []string
|
||||
raw := strings.TrimSpace(ignorePatterns)
|
||||
if raw != "" {
|
||||
for _, line := range strings.Split(raw, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
ignores = append(ignores, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
excludedExts := append([]string{}, config.DefaultExts...)
|
||||
if skipFrontend {
|
||||
excludedExts = append(excludedExts, config.FrontendExts...)
|
||||
}
|
||||
|
||||
allIgnores := append([]string{}, config.DefaultIgnores...)
|
||||
allIgnores = append(allIgnores, ignores...)
|
||||
|
||||
cfg := &config.Config{
|
||||
InputPath: inputDir,
|
||||
OutputPath: outputDir,
|
||||
Force: force,
|
||||
MaxSize: int64(maxSizeKB) * 1024,
|
||||
MaxDepth: maxDepth,
|
||||
Ignores: allIgnores,
|
||||
Extensions: excludedExts,
|
||||
ProtonDrive: protonDrive,
|
||||
DrivePath: drivePath,
|
||||
VerticalSlices: verticalSlices,
|
||||
SkipFrontend: skipFrontend,
|
||||
FrontendOnly: frontendOnly,
|
||||
OmitTest: omitTest,
|
||||
OmitMd: omitMd,
|
||||
}
|
||||
|
||||
cmd := buildCommand(cfg)
|
||||
fmt.Fprintf(os.Stderr, "%s\n", cmd)
|
||||
|
||||
var result GenerateResult
|
||||
if err := spinner.New().
|
||||
Title("Generating...").
|
||||
Action(func() {
|
||||
result = Generate(cfg)
|
||||
}).
|
||||
Run(); err != nil {
|
||||
return fmt.Errorf("interrupted: %w", err)
|
||||
}
|
||||
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
|
||||
for _, f := range result.Files {
|
||||
fmt.Fprintf(os.Stdout, " %s\n", f)
|
||||
}
|
||||
if result.Uploaded {
|
||||
fmt.Fprintln(os.Stdout, "Uploaded to Proton Drive")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func clearScreen() {
|
||||
cmd := exec.Command("clear")
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Run()
|
||||
}
|
||||
|
||||
func containsOption(opts []string, target string) bool {
|
||||
for _, o := range opts {
|
||||
if o == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultOutputDir() string {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "/tmp/output_dirmd"
|
||||
}
|
||||
base := filepath.Base(cwd)
|
||||
return filepath.Join("/tmp", base+"_dirmd")
|
||||
}
|
||||
|
||||
func buildCommand(cfg *config.Config) string {
|
||||
var parts []string
|
||||
parts = append(parts, "dirmd")
|
||||
parts = append(parts, "-o", cfg.OutputPath)
|
||||
if cfg.Force {
|
||||
parts = append(parts, "-f")
|
||||
}
|
||||
if cfg.VerticalSlices {
|
||||
parts = append(parts, "--vertical-slices")
|
||||
}
|
||||
if cfg.SkipFrontend {
|
||||
parts = append(parts, "--skip-frontend")
|
||||
}
|
||||
if cfg.FrontendOnly {
|
||||
parts = append(parts, "--frontend-only")
|
||||
}
|
||||
if cfg.OmitTest {
|
||||
parts = append(parts, "--omit-test")
|
||||
}
|
||||
if cfg.OmitMd {
|
||||
parts = append(parts, "--omit-md")
|
||||
}
|
||||
if cfg.ProtonDrive {
|
||||
parts = append(parts, "--proton-drive")
|
||||
if cfg.DrivePath != "" {
|
||||
parts = append(parts, "--drive-path", cfg.DrivePath)
|
||||
}
|
||||
}
|
||||
if cfg.MaxDepth != 20 {
|
||||
parts = append(parts, "--max-depth", strconv.Itoa(cfg.MaxDepth))
|
||||
}
|
||||
if cfg.MaxSize != 512*1024 {
|
||||
parts = append(parts, "--max-size", strconv.FormatInt(cfg.MaxSize, 10))
|
||||
}
|
||||
for _, ig := range extraIgnores(cfg) {
|
||||
parts = append(parts, "--ignore", ig)
|
||||
}
|
||||
parts = append(parts, cfg.InputPath)
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
|
||||
func extraIgnores(cfg *config.Config) []string {
|
||||
defaultSet := make(map[string]bool)
|
||||
for _, d := range config.DefaultIgnores {
|
||||
defaultSet[d] = true
|
||||
}
|
||||
var extra []string
|
||||
for _, ig := range cfg.Ignores {
|
||||
if !defaultSet[ig] {
|
||||
extra = append(extra, ig)
|
||||
}
|
||||
}
|
||||
return extra
|
||||
}
|
||||
+17
-10
@@ -7,8 +7,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"dirmd/internal/config"
|
||||
"dirmd/internal/filter"
|
||||
"git.flo.fo/FLO/dirmd/internal/config"
|
||||
"git.flo.fo/FLO/dirmd/internal/filter"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
@@ -20,6 +20,7 @@ type Result struct {
|
||||
Entries []Entry
|
||||
AbsRoot string
|
||||
SingleFile bool
|
||||
OmitTest bool
|
||||
}
|
||||
|
||||
func Run(cfg *config.Config) (*Result, error) {
|
||||
@@ -40,9 +41,12 @@ func processSingleFile(cfg *config.Config) (*Result, error) {
|
||||
}
|
||||
|
||||
flt := &filter.Filter{
|
||||
MaxSize: cfg.MaxSize,
|
||||
Extensions: cfg.Extensions,
|
||||
Ignores: cfg.Ignores,
|
||||
MaxSize: cfg.MaxSize,
|
||||
Extensions: cfg.Extensions,
|
||||
Ignores: cfg.Ignores,
|
||||
OmitTest: cfg.OmitTest,
|
||||
FrontendOnly: cfg.FrontendOnly,
|
||||
OmitMd: cfg.OmitMd,
|
||||
}
|
||||
|
||||
if flt.ShouldSkipFile(cfg.AbsRoot, filepath.Base(cfg.AbsRoot), info) {
|
||||
@@ -53,18 +57,21 @@ func processSingleFile(cfg *config.Config) (*Result, error) {
|
||||
Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}},
|
||||
AbsRoot: filepath.Dir(cfg.AbsRoot),
|
||||
SingleFile: true,
|
||||
OmitTest: cfg.OmitTest,
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
func walkDirectory(cfg *config.Config) (*Result, error) {
|
||||
flt := &filter.Filter{
|
||||
MaxSize: cfg.MaxSize,
|
||||
Extensions: cfg.Extensions,
|
||||
Ignores: cfg.Ignores,
|
||||
MaxSize: cfg.MaxSize,
|
||||
Extensions: cfg.Extensions,
|
||||
Ignores: cfg.Ignores,
|
||||
OmitTest: cfg.OmitTest,
|
||||
FrontendOnly: cfg.FrontendOnly,
|
||||
OmitMd: cfg.OmitMd,
|
||||
}
|
||||
|
||||
result := &Result{AbsRoot: cfg.AbsRoot}
|
||||
result := &Result{AbsRoot: cfg.AbsRoot, OmitTest: cfg.OmitTest}
|
||||
|
||||
err := filepath.WalkDir(cfg.AbsRoot, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user