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
|
# dirmd
|
||||||
|
|
||||||
Generate markdown documentation from directory structure.
|
Generate markdown documentation from directory structure. Optimized for LLM ingestion.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Recursive directory traversal
|
- Recursive directory traversal
|
||||||
- Tree visualization (standard tree command format)
|
- ASCII tree visualization (LLM-friendly, minimal token overhead)
|
||||||
- Full file contents with syntax-highlighted code blocks
|
- Full file contents with explicit delimiters and metadata
|
||||||
- Filters dotfiles, binaries, and blacklisted extensions
|
- Filters dotfiles, binaries, and blacklisted extensions
|
||||||
|
- Vertical slices mode: root file + one markdown per top-level directory
|
||||||
- Append mode for single files or directories
|
- Append mode for single files or directories
|
||||||
|
- Force overwrite of existing output files
|
||||||
- Configurable depth, file size limits, ignore patterns
|
- 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
|
## Build
|
||||||
|
|
||||||
@@ -19,65 +53,197 @@ Generate markdown documentation from directory structure.
|
|||||||
|
|
||||||
# Create new documentation
|
# Create new documentation
|
||||||
dirmd -o output.md ~/repos/myproject
|
dirmd -o output.md ~/repos/myproject
|
||||||
|
|
||||||
|
# Overwrite existing output file
|
||||||
|
dirmd -o output.md ~/repos/myproject -f
|
||||||
|
|
||||||
# Append a single file
|
# Append a single file
|
||||||
dirmd -a -i README.md -o existing.md
|
dirmd -a -i README.md -o existing.md
|
||||||
|
|
||||||
# Append another directory
|
# Append another directory
|
||||||
dirmd -a ~/repos/anotherproject -o existing.md
|
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
|
## Flags
|
||||||
|
|
||||||
| Flag | Description | Default |
|
| Flag | Description | Default |
|
||||||
|------|-------------|---------|
|
|------|-------------|---------|
|
||||||
| `-o`, `--output` | Output markdown file path (required) | |
|
| `-o`, `--output` | Output markdown file path (required) | |
|
||||||
| `-a`, `--append` | Append to existing output file | false |
|
| `-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) | |
|
| `-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-size` | Max file size in bytes | 524288 |
|
||||||
| `--max-depth` | Max directory recursion depth | 20 |
|
| `--max-depth` | Max directory recursion depth | 20 |
|
||||||
| `--ignore` | Additional ignore patterns (glob) | |
|
| `--ignore` | Additional ignore patterns (glob) | |
|
||||||
| `--extensions` | Additional file extensions to skip | |
|
| `--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
|
## Examples
|
||||||
|
|
||||||
# Custom depth and size limit
|
# Custom depth and size limit
|
||||||
dirmd -o output.md ~/repos/project --max-depth 5 --max-size 262144
|
dirmd -o output.md ~/repos/project --max-depth 5 --max-size 262144
|
||||||
|
|
||||||
# Ignore specific directories
|
# Ignore specific directories
|
||||||
dirmd -o output.md ~/repos/project --ignore __pycache__ --ignore .venv
|
dirmd -o output.md ~/repos/project --ignore __pycache__ --ignore .venv
|
||||||
|
|
||||||
# Skip additional file extensions
|
# Skip additional file extensions
|
||||||
dirmd -o output.md ~/repos/project --extensions .log --extensions .tmp
|
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
|
## Output Format
|
||||||
|
|
||||||
Produces markdown with:
|
Produces markdown with:
|
||||||
|
|
||||||
1. Absolute path as H1 header
|
1. Absolute path as H1 header with `[ROOT]` or `[SLICE]` marker
|
||||||
2. Directory tree (indented with 4 spaces per level)
|
2. ASCII directory tree (indented with `+-` and `|` characters)
|
||||||
3. All text files with content in code blocks
|
3. Vertical slices index table (sliced mode only)
|
||||||
4. Language detection from file extensions
|
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
|
## Directory Tree
|
||||||
|
|
||||||
|
~~~
|
||||||
myproject/
|
myproject/
|
||||||
├── cmd/
|
+- cmd/
|
||||||
│ └── main.go
|
| +- main.go
|
||||||
└── internal/
|
+- internal/
|
||||||
└── config.go
|
| +- config.go
|
||||||
|
~~~
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
### ./cmd/main.go
|
--- FILE: ./cmd/main.go (45 bytes, 3 lines) ---
|
||||||
|
~~~go
|
||||||
```go
|
|
||||||
package main
|
package main
|
||||||
|
|
||||||
func main() {}
|
func main() {}
|
||||||
```
|
~~~
|
||||||
|
--- END FILE ---
|
||||||
|
|
||||||
Note: Uses tilde fences (~~~) internally to avoid conflicts with nested backticks in source files.
|
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"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"dirmd/internal/config"
|
"git.flo.fo/FLO/dirmd/internal/config"
|
||||||
"dirmd/internal/drive"
|
"git.flo.fo/FLO/dirmd/internal/drive"
|
||||||
"dirmd/internal/renderer"
|
"git.flo.fo/FLO/dirmd/internal/renderer"
|
||||||
"dirmd/internal/walker"
|
"git.flo.fo/FLO/dirmd/internal/tui"
|
||||||
|
"git.flo.fo/FLO/dirmd/internal/walker"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
rootCmd = &cobra.Command{
|
rootCmd = &cobra.Command{
|
||||||
Use: "dirmd [flags] <directory>",
|
Use: "dirmd [flags] <directory>",
|
||||||
Short: "Generate markdown documentation from directory structure",
|
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
|
- All readable text files with their contents
|
||||||
|
|
||||||
|
Output is always written to a directory specified by -o.
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
# Create new documentation
|
# Create new documentation (writes index.md inside ./docs/)
|
||||||
dirmd -o output.md ~/repos/myproject
|
dirmd -o ./docs ~/repos/myproject
|
||||||
|
|
||||||
# Append a single file
|
# Overwrite existing output directory
|
||||||
dirmd -a -i README.md -o existing.md
|
dirmd -o ./docs ~/repos/myproject -f
|
||||||
|
|
||||||
# Append another directory
|
# Append a single file
|
||||||
dirmd -a -o existing.md ~/repos/anotherproject
|
dirmd -a -i README.md -o ./docs
|
||||||
|
|
||||||
# Generate and upload to Proton Drive (default remote path /my-files/md/<filename>)
|
# Vertical slices (root index.md + one markdown per top-level dir)
|
||||||
dirmd -o output.md ~/repos/myproject --proton-drive
|
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
|
# 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,
|
RunE: run,
|
||||||
}
|
}
|
||||||
|
|
||||||
outputPath string
|
outputPath string
|
||||||
appendMode bool
|
appendMode bool
|
||||||
|
force bool
|
||||||
singleFile string
|
singleFile string
|
||||||
maxSize int64
|
maxSize int64
|
||||||
maxDepth int
|
maxDepth int
|
||||||
@@ -50,6 +69,12 @@ Examples:
|
|||||||
extensions []string
|
extensions []string
|
||||||
protonDrive bool
|
protonDrive bool
|
||||||
drivePath string
|
drivePath string
|
||||||
|
verticalSlices bool
|
||||||
|
skipFrontend bool
|
||||||
|
frontendOnly bool
|
||||||
|
omitTest bool
|
||||||
|
omitMd bool
|
||||||
|
tuiMode bool
|
||||||
)
|
)
|
||||||
|
|
||||||
func Execute() {
|
func Execute() {
|
||||||
@@ -59,33 +84,69 @@ func Execute() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output markdown file path (required)")
|
rootCmd.PersistentFlags().StringVarP(&outputPath, "output", "o", "", "output directory (required)")
|
||||||
rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing output file")
|
rootCmd.Flags().BoolVarP(&appendMode, "append", "a", false, "append to existing index.md")
|
||||||
rootCmd.Flags().StringVarP(&singleFile, "input-file", "i", "", "single file input (append mode only)")
|
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().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().IntVar(&maxDepth, "max-depth", config.DefaultMaxDepth, "max directory recursion depth")
|
||||||
rootCmd.Flags().StringArrayVar(&ignorePatterns, "ignore", nil, "additional ignore patterns (glob)")
|
rootCmd.Flags().StringArrayVar(&ignorePatterns, "ignore", nil, "additional ignore patterns (glob)")
|
||||||
rootCmd.Flags().StringArrayVar(&extensions, "extensions", nil, "additional file extensions to skip (repeatable)")
|
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().BoolVar(&protonDrive, "proton-drive", false, "upload output directory 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.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.MarkPersistentFlagRequired("output")
|
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 {
|
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 {
|
if drivePath != "" && !protonDrive {
|
||||||
return fmt.Errorf("--drive-path requires --proton-drive")
|
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{
|
cfg := &config.Config{
|
||||||
OutputPath: outputPath,
|
OutputPath: outputPath,
|
||||||
AppendMode: appendMode,
|
AppendMode: appendMode,
|
||||||
MaxSize: maxSize,
|
Force: force,
|
||||||
MaxDepth: maxDepth,
|
MaxSize: maxSize,
|
||||||
Ignores: append(config.DefaultIgnores, ignorePatterns...),
|
MaxDepth: maxDepth,
|
||||||
Extensions: append(config.DefaultExts, extensions...),
|
Ignores: append(config.DefaultIgnores, ignorePatterns...),
|
||||||
ProtonDrive: protonDrive,
|
Extensions: excludedExts,
|
||||||
DrivePath: drivePath,
|
ProtonDrive: protonDrive,
|
||||||
|
DrivePath: drivePath,
|
||||||
|
VerticalSlices: verticalSlices,
|
||||||
|
SkipFrontend: skipFrontend,
|
||||||
|
FrontendOnly: frontendOnly,
|
||||||
|
OmitTest: omitTest,
|
||||||
|
OmitMd: omitMd,
|
||||||
}
|
}
|
||||||
|
|
||||||
if singleFile != "" {
|
if singleFile != "" {
|
||||||
@@ -107,13 +168,24 @@ func run(cmd *cobra.Command, args []string) error {
|
|||||||
}
|
}
|
||||||
cfg.AbsRoot = abs
|
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 !appendMode {
|
||||||
if _, err := os.Stat(outputPath); err == nil {
|
if _, err := os.Stat(absOutput); err == nil && !force {
|
||||||
return fmt.Errorf("output file %s already exists (use -a to append)", outputPath)
|
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)
|
result, err := walker.Run(cfg)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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)
|
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
|
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 {
|
if protonDrive {
|
||||||
remotePath := drivePath
|
remoteDir := drivePath
|
||||||
if remotePath == "" {
|
if remoteDir == "" {
|
||||||
remotePath = "/my-files/md/" + filepath.Base(outputPath)
|
remoteDir = "/my-files/md/" + filepath.Base(absOutput)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := drive.CheckAuth(); err != nil {
|
if err := drive.CheckAuth(); err != nil {
|
||||||
@@ -141,13 +249,13 @@ func run(cmd *cobra.Command, args []string) error {
|
|||||||
return err
|
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.Fprintf(os.Stderr, "error: %v\n", err)
|
||||||
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload failed")
|
fmt.Fprintln(os.Stderr, "local file was written successfully, but upload failed")
|
||||||
return err
|
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
|
return nil
|
||||||
|
|||||||
@@ -1,10 +1,37 @@
|
|||||||
module dirmd
|
module git.flo.fo/FLO/dirmd
|
||||||
|
|
||||||
go 1.26.4
|
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 (
|
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/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/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/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 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
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/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 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
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 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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=
|
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=
|
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",
|
"node_modules",
|
||||||
"vendor",
|
"vendor",
|
||||||
"bin",
|
"bin",
|
||||||
|
"go.mod",
|
||||||
|
"go.sum",
|
||||||
"target",
|
"target",
|
||||||
"build",
|
"build",
|
||||||
"dist",
|
"dist",
|
||||||
|
"Cargo.lock",
|
||||||
".idea",
|
".idea",
|
||||||
".vscode",
|
".vscode",
|
||||||
|
"dependency-reduced-pom.xml",
|
||||||
}
|
}
|
||||||
|
|
||||||
var DefaultExts = []string{
|
var DefaultExts = []string{
|
||||||
@@ -35,12 +39,20 @@ var DefaultExts = []string{
|
|||||||
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
".pdf", ".doc", ".docx", ".xls", ".xlsx",
|
||||||
".pem", ".key", ".cert",
|
".pem", ".key", ".cert",
|
||||||
".db", ".sqlite", ".sqlite3",
|
".db", ".sqlite", ".sqlite3",
|
||||||
".class", // Java compiled bytecode
|
".class",
|
||||||
".jar", // JAR archives
|
".jar",
|
||||||
".war",
|
".war",
|
||||||
".ear",
|
".ear",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var FrontendExts = []string{
|
||||||
|
".html", ".htm", ".gohtml", ".tmpl",
|
||||||
|
".css", ".scss", ".sass", ".less",
|
||||||
|
".js", ".jsx", ".mjs", ".cjs",
|
||||||
|
".ts", ".tsx",
|
||||||
|
".vue", ".svelte",
|
||||||
|
}
|
||||||
|
|
||||||
type stringSlice []string
|
type stringSlice []string
|
||||||
|
|
||||||
func (s *stringSlice) String() string {
|
func (s *stringSlice) String() string {
|
||||||
@@ -53,17 +65,23 @@ func (s *stringSlice) Set(v string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
InputPath string
|
InputPath string
|
||||||
OutputPath string
|
OutputPath string
|
||||||
AppendMode bool
|
AppendMode bool
|
||||||
SingleFile bool
|
Force bool
|
||||||
MaxSize int64
|
SingleFile bool
|
||||||
MaxDepth int
|
MaxSize int64
|
||||||
Ignores []string
|
MaxDepth int
|
||||||
Extensions []string
|
Ignores []string
|
||||||
AbsRoot string
|
Extensions []string
|
||||||
ProtonDrive bool
|
AbsRoot string
|
||||||
DrivePath string
|
ProtonDrive bool
|
||||||
|
DrivePath string
|
||||||
|
VerticalSlices bool
|
||||||
|
SkipFrontend bool
|
||||||
|
FrontendOnly bool
|
||||||
|
OmitTest bool
|
||||||
|
OmitMd bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func PrintUsage() {
|
func PrintUsage() {
|
||||||
@@ -76,28 +94,40 @@ func PrintUsage() {
|
|||||||
|
|
||||||
func Parse() (*Config, error) {
|
func Parse() (*Config, error) {
|
||||||
var (
|
var (
|
||||||
input string
|
input string
|
||||||
output string
|
output string
|
||||||
appendMd bool
|
appendMd bool
|
||||||
maxSize int64
|
force bool
|
||||||
maxDepth int
|
maxSize int64
|
||||||
ignores stringSlice
|
maxDepth int
|
||||||
exts stringSlice
|
ignores stringSlice
|
||||||
showVer bool
|
exts stringSlice
|
||||||
protonDrive bool
|
showVer bool
|
||||||
drivePath string
|
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(&input, "i", "", "single file input (append mode only)")
|
||||||
flag.StringVar(&output, "o", "", "output markdown file path (required)")
|
flag.StringVar(&output, "o", "", "output markdown file path (required)")
|
||||||
flag.BoolVar(&appendMd, "a", false, "append to existing output file")
|
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.Int64Var(&maxSize, "max-size", DefaultMaxSize, "max file size in bytes")
|
||||||
flag.IntVar(&maxDepth, "max-depth", DefaultMaxDepth, "max directory recursion depth")
|
flag.IntVar(&maxDepth, "max-depth", DefaultMaxDepth, "max directory recursion depth")
|
||||||
flag.Var(&ignores, "ignore", "additional ignore patterns (glob, repeatable)")
|
flag.Var(&ignores, "ignore", "additional ignore patterns (glob, repeatable)")
|
||||||
flag.Var(&exts, "extensions", "additional file extensions to skip (repeatable)")
|
flag.Var(&exts, "extensions", "additional file extensions to skip (repeatable)")
|
||||||
flag.BoolVar(&showVer, "version", false, "print version and exit")
|
flag.BoolVar(&showVer, "version", false, "print version and exit")
|
||||||
flag.BoolVar(&protonDrive, "proton-drive", false, "upload output to Proton Drive after writing locally")
|
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.Usage = PrintUsage
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@@ -115,15 +145,38 @@ func Parse() (*Config, error) {
|
|||||||
return nil, fmt.Errorf("--drive-path requires --proton-drive")
|
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{
|
cfg := &Config{
|
||||||
OutputPath: output,
|
OutputPath: output,
|
||||||
AppendMode: appendMd,
|
AppendMode: appendMd,
|
||||||
MaxSize: maxSize,
|
Force: force,
|
||||||
MaxDepth: maxDepth,
|
MaxSize: maxSize,
|
||||||
Ignores: append(DefaultIgnores, ignores...),
|
MaxDepth: maxDepth,
|
||||||
Extensions: append(DefaultExts, exts...),
|
Ignores: append(DefaultIgnores, ignores...),
|
||||||
ProtonDrive: protonDrive,
|
Extensions: excludedExts,
|
||||||
DrivePath: drivePath,
|
ProtonDrive: protonDrive,
|
||||||
|
DrivePath: drivePath,
|
||||||
|
VerticalSlices: verticalSlices,
|
||||||
|
SkipFrontend: skipFrontend,
|
||||||
|
FrontendOnly: frontendOnly,
|
||||||
|
OmitTest: omitTest,
|
||||||
|
OmitMd: omitMd,
|
||||||
}
|
}
|
||||||
|
|
||||||
if input != "" {
|
if input != "" {
|
||||||
@@ -147,8 +200,8 @@ func Parse() (*Config, error) {
|
|||||||
cfg.AbsRoot = abs
|
cfg.AbsRoot = abs
|
||||||
|
|
||||||
if !appendMd {
|
if !appendMd {
|
||||||
if _, err := os.Stat(output); err == nil {
|
if _, err := os.Stat(output); err == nil && !force {
|
||||||
return nil, fmt.Errorf("output file %s already exists (use -a to append)", output)
|
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"
|
const binaryName = "proton-drive"
|
||||||
|
|
||||||
// Lookup returns the full path to the proton-drive binary in PATH.
|
|
||||||
func Lookup() (string, error) {
|
func Lookup() (string, error) {
|
||||||
return exec.LookPath(binaryName)
|
return exec.LookPath(binaryName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CheckAuth verifies that the user is authenticated by listing /my-files.
|
|
||||||
func CheckAuth() error {
|
func CheckAuth() error {
|
||||||
binary, err := Lookup()
|
binary, err := Lookup()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -32,11 +30,57 @@ func CheckAuth() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload uploads localPath to remotePath on Proton Drive.
|
// EnsureRemoteParent creates the parent remote directory if it doesn't exist.
|
||||||
// remotePath is a full path including filename (e.g. /my-files/md/report.md).
|
func EnsureRemoteParent(remoteDir string) error {
|
||||||
// The CLI uploads to a parent directory and preserves the local filename,
|
binary, err := Lookup()
|
||||||
// so if the remote filename differs from the local one, a temp copy is staged.
|
if err != nil {
|
||||||
// Existing remote files are overwritten (--conflict-strategy replace).
|
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 {
|
func Upload(localPath, remotePath string) error {
|
||||||
binary, err := Lookup()
|
binary, err := Lookup()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.flo.fo/FLO/dirmd/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Filter struct {
|
type Filter struct {
|
||||||
MaxSize int64
|
MaxSize int64
|
||||||
Extensions []string
|
Extensions []string
|
||||||
Ignores []string
|
Ignores []string
|
||||||
|
OmitTest bool
|
||||||
|
FrontendOnly bool
|
||||||
|
OmitMd bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Filter) ShouldSkipDir(name string) 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)
|
ext := filepath.Ext(path)
|
||||||
for _, bad := range f.Extensions {
|
for _, bad := range f.Extensions {
|
||||||
if strings.EqualFold(ext, bad) {
|
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 false
|
||||||
return true
|
}
|
||||||
|
|
||||||
|
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
|
return false
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"dirmd/internal/tree"
|
"git.flo.fo/FLO/dirmd/internal/tree"
|
||||||
"dirmd/internal/walker"
|
"git.flo.fo/FLO/dirmd/internal/walker"
|
||||||
)
|
)
|
||||||
|
|
||||||
const fenceChars = "~~~"
|
const fenceChars = "~~~"
|
||||||
@@ -29,20 +30,19 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
|
|||||||
|
|
||||||
var sb strings.Builder
|
var sb strings.Builder
|
||||||
|
|
||||||
// Determine the correct H1 path
|
|
||||||
h1Path := result.AbsRoot
|
h1Path := result.AbsRoot
|
||||||
if singleFile && len(result.Entries) > 0 {
|
if singleFile && len(result.Entries) > 0 {
|
||||||
h1Path = filepath.Join(result.AbsRoot, result.Entries[0].RelPath)
|
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 {
|
if !singleFile {
|
||||||
root := tree.Build(result.Entries)
|
root := tree.Build(result.Entries)
|
||||||
rootName := filepath.Base(result.AbsRoot)
|
rootName := filepath.Base(result.AbsRoot)
|
||||||
sb.WriteString("## Directory Tree\n\n")
|
sb.WriteString("## Directory Tree\n\n")
|
||||||
sb.WriteString(fenceChars + "\n")
|
sb.WriteString(fenceChars + "\n")
|
||||||
sb.WriteString(tree.Render(root, rootName))
|
sb.WriteString(renderTreeForLLM(root, rootName))
|
||||||
sb.WriteString(fenceChars + "\n\n")
|
sb.WriteString(fenceChars + "\n\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,15 +54,16 @@ func Write(result *walker.Result, outputPath string, appendMode bool, singleFile
|
|||||||
for _, entry := range result.Entries {
|
for _, entry := range result.Entries {
|
||||||
fullPath := filepath.Join(result.AbsRoot, entry.RelPath)
|
fullPath := filepath.Join(result.AbsRoot, entry.RelPath)
|
||||||
|
|
||||||
content, err := readFile(fullPath)
|
content, err := readFileForOutput(fullPath, result.OmitTest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
fmt.Fprintf(os.Stderr, "warning: cannot read %s: %v\n", entry.RelPath, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
language := extToLang(filepath.Ext(entry.RelPath))
|
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)
|
fmt.Fprintf(&sb, "%s", fenceChars)
|
||||||
if language != "" {
|
if language != "" {
|
||||||
fmt.Fprintf(&sb, "%s", 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") {
|
if !strings.HasSuffix(content, "\n") {
|
||||||
fmt.Fprintf(&sb, "\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
|
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) {
|
func readFile(path string) (string, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,6 +430,68 @@ func readFile(path string) (string, error) {
|
|||||||
return content, nil
|
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 {
|
func extToLang(ext string) string {
|
||||||
mapping := map[string]string{
|
mapping := map[string]string{
|
||||||
".go": "go",
|
".go": "go",
|
||||||
@@ -131,3 +520,14 @@ func extToLang(ext string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
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"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"dirmd/internal/walker"
|
"git.flo.fo/FLO/dirmd/internal/walker"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Node struct {
|
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"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"dirmd/internal/config"
|
"git.flo.fo/FLO/dirmd/internal/config"
|
||||||
"dirmd/internal/filter"
|
"git.flo.fo/FLO/dirmd/internal/filter"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Entry struct {
|
type Entry struct {
|
||||||
@@ -20,6 +20,7 @@ type Result struct {
|
|||||||
Entries []Entry
|
Entries []Entry
|
||||||
AbsRoot string
|
AbsRoot string
|
||||||
SingleFile bool
|
SingleFile bool
|
||||||
|
OmitTest bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func Run(cfg *config.Config) (*Result, error) {
|
func Run(cfg *config.Config) (*Result, error) {
|
||||||
@@ -40,9 +41,12 @@ func processSingleFile(cfg *config.Config) (*Result, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
flt := &filter.Filter{
|
flt := &filter.Filter{
|
||||||
MaxSize: cfg.MaxSize,
|
MaxSize: cfg.MaxSize,
|
||||||
Extensions: cfg.Extensions,
|
Extensions: cfg.Extensions,
|
||||||
Ignores: cfg.Ignores,
|
Ignores: cfg.Ignores,
|
||||||
|
OmitTest: cfg.OmitTest,
|
||||||
|
FrontendOnly: cfg.FrontendOnly,
|
||||||
|
OmitMd: cfg.OmitMd,
|
||||||
}
|
}
|
||||||
|
|
||||||
if flt.ShouldSkipFile(cfg.AbsRoot, filepath.Base(cfg.AbsRoot), info) {
|
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()}},
|
Entries: []Entry{{RelPath: filepath.Base(cfg.AbsRoot), Size: info.Size()}},
|
||||||
AbsRoot: filepath.Dir(cfg.AbsRoot),
|
AbsRoot: filepath.Dir(cfg.AbsRoot),
|
||||||
SingleFile: true,
|
SingleFile: true,
|
||||||
|
OmitTest: cfg.OmitTest,
|
||||||
}, nil
|
}, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func walkDirectory(cfg *config.Config) (*Result, error) {
|
func walkDirectory(cfg *config.Config) (*Result, error) {
|
||||||
flt := &filter.Filter{
|
flt := &filter.Filter{
|
||||||
MaxSize: cfg.MaxSize,
|
MaxSize: cfg.MaxSize,
|
||||||
Extensions: cfg.Extensions,
|
Extensions: cfg.Extensions,
|
||||||
Ignores: cfg.Ignores,
|
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 {
|
err := filepath.WalkDir(cfg.AbsRoot, func(path string, d fs.DirEntry, err error) error {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user