Implement yaml format
This commit is contained in:
76
README.md
76
README.md
@@ -4,27 +4,81 @@ A small Go tool for creating symbolic links
|
|||||||
|
|
||||||
Created out of infuriating difficulty of creating symbolic links on windows
|
Created out of infuriating difficulty of creating symbolic links on windows
|
||||||
|
|
||||||
## Custom syntax
|
## Instruction Formats
|
||||||
|
|
||||||
The tool works with "instructions" that describe symbolic links
|
The tool supports two formats for defining symbolic links:
|
||||||
|
|
||||||
They are, in any form, \<source>,\<destination>,\<force?>
|
### 1. CSV Format (Legacy)
|
||||||
|
|
||||||
|
Simple comma-separated values with the format: `<source>,<destination>[,force][,hard][,delete]`
|
||||||
|
|
||||||
For example:
|
For example:
|
||||||
`sync this,that`
|
```
|
||||||
|
source_path,target_path
|
||||||
|
source_path,target_path,true
|
||||||
|
source_path,target_path,true,true
|
||||||
|
source_path,target_path,true,true,true
|
||||||
|
```
|
||||||
|
|
||||||
It supports input of these instructions through:
|
Or with named flags:
|
||||||
|
```
|
||||||
|
source_path,target_path,force=true,hard=true,delete=true
|
||||||
|
source_path,target_path,f=true,h=true,d=true
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. YAML Format (Recommended)
|
||||||
|
|
||||||
|
A more readable format using YAML:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
links:
|
||||||
|
- source: ~/Documents/config.ini
|
||||||
|
target: ~/.config/app/config.ini
|
||||||
|
force: true
|
||||||
|
|
||||||
|
- source: ~/Pictures
|
||||||
|
target: ~/Documents/Pictures
|
||||||
|
hard: true
|
||||||
|
force: true
|
||||||
|
|
||||||
|
- source: ~/Scripts/script.sh
|
||||||
|
target: ~/bin/script.sh
|
||||||
|
delete: true
|
||||||
|
```
|
||||||
|
|
||||||
|
Alternatively, you can use an array directly:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- source: ~/Documents/config.ini
|
||||||
|
target: ~/.config/app/config.ini
|
||||||
|
force: true
|
||||||
|
|
||||||
|
- source: ~/Pictures
|
||||||
|
target: ~/Documents/Pictures
|
||||||
|
hard: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Input Methods
|
||||||
|
|
||||||
|
The tool supports input of these instructions through:
|
||||||
|
|
||||||
- Stdin
|
- Stdin
|
||||||
- `echo "this,that" | sync`
|
- `echo "this,that" | sync`
|
||||||
- Run arguments
|
- Run arguments
|
||||||
- `sync this,that foo,bar "foo 2","C:/bar"`
|
- `sync this,that foo,bar "foo 2","C:/bar"`
|
||||||
- Files
|
- Files
|
||||||
- `sync -f <file>`
|
- `sync -f <file>` (CSV format)
|
||||||
- Where the file contains instructions, one instruction per line
|
- `sync -f <file.yaml>` or `sync -f <file.yml>` (YAML format)
|
||||||
|
- Where the file contains instructions, one per line for CSV or structured YAML
|
||||||
- Directories
|
- Directories
|
||||||
- `sync -r <directory>`
|
- `sync -r <directory>`
|
||||||
- This mode will look for "sync" files recursively in directories and run their instructions
|
- This mode will look for "sync", "sync.yaml", or "sync.yml" files recursively in directories and run their instructions
|
||||||
|
|
||||||
|
## Options
|
||||||
|
|
||||||
|
- `force: true` - Overwrite an existing symbolic link at the target location
|
||||||
|
- `hard: true` - Create a hard link instead of a symbolic link
|
||||||
|
- `delete: true` - Delete a non-symlink file at the target location (implies `force: true`)
|
||||||
|
|
||||||
## Use case
|
## Use case
|
||||||
|
|
||||||
|
2
go.mod
2
go.mod
@@ -1,3 +1,5 @@
|
|||||||
module cln
|
module cln
|
||||||
|
|
||||||
go 1.21.7
|
go 1.21.7
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
4
go.sum
4
go.sum
@@ -0,0 +1,4 @@
|
|||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
140
instruction.go
140
instruction.go
@@ -5,17 +5,21 @@ import (
|
|||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LinkInstruction struct {
|
type LinkInstruction struct {
|
||||||
Source string
|
Source string `yaml:"source"`
|
||||||
Target string
|
Target string `yaml:"target"`
|
||||||
Force bool
|
Force bool `yaml:"force,omitempty"`
|
||||||
Hard bool
|
Hard bool `yaml:"hard,omitempty"`
|
||||||
Delete bool
|
Delete bool `yaml:"delete,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type YAMLConfig struct {
|
||||||
|
Links []LinkInstruction `yaml:"links"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (instruction *LinkInstruction) Tidy() {
|
func (instruction *LinkInstruction) Tidy() {
|
||||||
@@ -29,7 +33,26 @@ func (instruction *LinkInstruction) Tidy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (instruction *LinkInstruction) String() string {
|
func (instruction *LinkInstruction) String() string {
|
||||||
return fmt.Sprintf("%s%s%s%s%s%s%s%s%s%s%s%s%s", SourceColor, instruction.Source, DefaultColor, deliminer, TargetColor, instruction.Target, DefaultColor, deliminer, strconv.FormatBool(instruction.Force), deliminer, strconv.FormatBool(instruction.Hard), deliminer, strconv.FormatBool(instruction.Delete))
|
var flags []string
|
||||||
|
if instruction.Force {
|
||||||
|
flags = append(flags, "force=true")
|
||||||
|
}
|
||||||
|
if instruction.Hard {
|
||||||
|
flags = append(flags, "hard=true")
|
||||||
|
}
|
||||||
|
if instruction.Delete {
|
||||||
|
flags = append(flags, "delete=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
flagsStr := ""
|
||||||
|
if len(flags) > 0 {
|
||||||
|
flagsStr = " [" + strings.Join(flags, ", ") + "]"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s%s%s → %s%s%s%s",
|
||||||
|
SourceColor, instruction.Source, DefaultColor,
|
||||||
|
TargetColor, instruction.Target, DefaultColor,
|
||||||
|
flagsStr)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseInstruction(line, workdir string) (LinkInstruction, error) {
|
func ParseInstruction(line, workdir string) (LinkInstruction, error) {
|
||||||
@@ -37,6 +60,7 @@ func ParseInstruction(line, workdir string) (LinkInstruction, error) {
|
|||||||
if strings.HasPrefix(line, "#") {
|
if strings.HasPrefix(line, "#") {
|
||||||
return LinkInstruction{}, fmt.Errorf("comment line")
|
return LinkInstruction{}, fmt.Errorf("comment line")
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := strings.Split(line, deliminer)
|
parts := strings.Split(line, deliminer)
|
||||||
instruction := LinkInstruction{}
|
instruction := LinkInstruction{}
|
||||||
|
|
||||||
@@ -46,19 +70,48 @@ func ParseInstruction(line, workdir string) (LinkInstruction, error) {
|
|||||||
|
|
||||||
instruction.Source = strings.TrimSpace(parts[0])
|
instruction.Source = strings.TrimSpace(parts[0])
|
||||||
instruction.Target = strings.TrimSpace(parts[1])
|
instruction.Target = strings.TrimSpace(parts[1])
|
||||||
instruction.Force = false
|
|
||||||
if len(parts) > 2 {
|
for i := 2; i < len(parts); i++ {
|
||||||
res, _ := regexp.MatchString(`^(?i)\s*T|TRUE\s*$`, parts[2])
|
flagPart := strings.TrimSpace(parts[i])
|
||||||
instruction.Force = res
|
|
||||||
}
|
// Support for legacy format (backward compatibility)
|
||||||
if len(parts) > 3 {
|
if !strings.Contains(flagPart, "=") {
|
||||||
res, _ := regexp.MatchString(`^(?i)\s*T|TRUE\s*$`, parts[3])
|
// Legacy format: positional boolean flags
|
||||||
instruction.Hard = res
|
switch i {
|
||||||
}
|
case 2: // Force flag (3rd position)
|
||||||
if len(parts) > 4 {
|
instruction.Force = isTrue(flagPart)
|
||||||
res, _ := regexp.MatchString(`^(?i)\s*T|TRUE\s*$`, parts[4])
|
case 3: // Hard flag (4th position)
|
||||||
instruction.Delete = res
|
instruction.Hard = isTrue(flagPart)
|
||||||
instruction.Force = res
|
case 4: // Delete flag (5th position)
|
||||||
|
instruction.Delete = isTrue(flagPart)
|
||||||
|
if instruction.Delete {
|
||||||
|
instruction.Force = true // Delete implies Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// New format: named flags (name=value)
|
||||||
|
nameValue := strings.SplitN(flagPart, "=", 2)
|
||||||
|
if len(nameValue) != 2 {
|
||||||
|
// Skip malformed flags
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
flagName := strings.ToLower(strings.TrimSpace(nameValue[0]))
|
||||||
|
flagValue := strings.TrimSpace(nameValue[1])
|
||||||
|
|
||||||
|
switch flagName {
|
||||||
|
case "force", "f":
|
||||||
|
instruction.Force = isTrue(flagValue)
|
||||||
|
case "hard", "h":
|
||||||
|
instruction.Hard = isTrue(flagValue)
|
||||||
|
case "delete", "d":
|
||||||
|
instruction.Delete = isTrue(flagValue)
|
||||||
|
if instruction.Delete {
|
||||||
|
instruction.Force = true // Delete implies Force
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
instruction.Tidy()
|
instruction.Tidy()
|
||||||
@@ -71,6 +124,11 @@ func ParseInstruction(line, workdir string) (LinkInstruction, error) {
|
|||||||
return instruction, nil
|
return instruction, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isTrue(value string) bool {
|
||||||
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
|
return value == "true" || value == "t" || value == "yes" || value == "y" || value == "1"
|
||||||
|
}
|
||||||
|
|
||||||
func (instruction *LinkInstruction) RunAsync(status chan (error)) {
|
func (instruction *LinkInstruction) RunAsync(status chan (error)) {
|
||||||
defer close(status)
|
defer close(status)
|
||||||
if !FileExists(instruction.Source) {
|
if !FileExists(instruction.Source) {
|
||||||
@@ -155,3 +213,43 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) {
|
|||||||
|
|
||||||
status <- nil
|
status <- nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ParseYAMLFile(filename, workdir string) ([]LinkInstruction, error) {
|
||||||
|
data, err := os.ReadFile(filename)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error reading YAML file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First try to parse as a list of link instructions
|
||||||
|
var config YAMLConfig
|
||||||
|
err = yaml.Unmarshal(data, &config)
|
||||||
|
if err != nil || len(config.Links) == 0 {
|
||||||
|
// If that fails, try parsing as a direct list of instructions
|
||||||
|
var instructions []LinkInstruction
|
||||||
|
err = yaml.Unmarshal(data, &instructions)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error parsing YAML: %w", err)
|
||||||
|
}
|
||||||
|
config.Links = instructions
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range config.Links {
|
||||||
|
config.Links[i].Tidy()
|
||||||
|
config.Links[i].Source, _ = ConvertHome(config.Links[i].Source)
|
||||||
|
config.Links[i].Target, _ = ConvertHome(config.Links[i].Target)
|
||||||
|
config.Links[i].Source = NormalizePath(config.Links[i].Source, workdir)
|
||||||
|
config.Links[i].Target = NormalizePath(config.Links[i].Target, workdir)
|
||||||
|
|
||||||
|
// If Delete is true, Force must also be true
|
||||||
|
if config.Links[i].Delete {
|
||||||
|
config.Links[i].Force = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.Links, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsYAMLFile(filename string) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
return ext == ".yaml" || ext == ".yml"
|
||||||
|
}
|
||||||
|
38
main.go
38
main.go
@@ -20,8 +20,8 @@ const ImportantColor = BRed
|
|||||||
const DefaultColor = White
|
const DefaultColor = White
|
||||||
const PathColor = Green
|
const PathColor = Green
|
||||||
|
|
||||||
var DirRegex, _ = regexp.Compile(`^(.+?)[/\\]sync$`)
|
var DirRegex, _ = regexp.Compile(`^(.+?)[/\\]sync(?:\.ya?ml)?$`)
|
||||||
var FileRegex, _ = regexp.Compile(`^sync$`)
|
var FileRegex, _ = regexp.Compile(`^sync(?:\.ya?ml)?$`)
|
||||||
var programName = os.Args[0]
|
var programName = os.Args[0]
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -68,11 +68,18 @@ func main() {
|
|||||||
if _, err := os.Stat("sync"); err == nil {
|
if _, err := os.Stat("sync"); err == nil {
|
||||||
log.Printf("Using default sync file")
|
log.Printf("Using default sync file")
|
||||||
go ReadFromFile("sync", instructions, status, true)
|
go ReadFromFile("sync", instructions, status, true)
|
||||||
|
} else if _, err := os.Stat("sync.yaml"); err == nil {
|
||||||
|
log.Printf("Using default sync.yaml file")
|
||||||
|
go ReadFromFile("sync.yaml", instructions, status, true)
|
||||||
|
} else if _, err := os.Stat("sync.yml"); err == nil {
|
||||||
|
log.Printf("Using default sync.yml file")
|
||||||
|
go ReadFromFile("sync.yml", instructions, status, true)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("No input provided")
|
log.Printf("No input provided")
|
||||||
log.Printf("Provide input as: ")
|
log.Printf("Provide input as: ")
|
||||||
log.Printf("Arguments - %s <source>,<target>,<force?>", programName)
|
log.Printf("Arguments - %s <source>,<target>,<force?>", programName)
|
||||||
log.Printf("File - %s -f <file>", programName)
|
log.Printf("File - %s -f <file>", programName)
|
||||||
|
log.Printf("YAML File - %s -f <file.yaml>", programName)
|
||||||
log.Printf("Folder (finding sync files in folder recursively) - %s -r <folder>", programName)
|
log.Printf("Folder (finding sync files in folder recursively) - %s -r <folder>", programName)
|
||||||
log.Printf("stdin - (cat <file> | %s)", programName)
|
log.Printf("stdin - (cat <file> | %s)", programName)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -191,9 +198,31 @@ func ReadFromFile(input string, output chan *LinkInstruction, status chan error,
|
|||||||
|
|
||||||
input = NormalizePath(input, filepath.Dir(input))
|
input = NormalizePath(input, filepath.Dir(input))
|
||||||
log.Printf("Reading input from file: %s%s%s", PathColor, input, DefaultColor)
|
log.Printf("Reading input from file: %s%s%s", PathColor, input, DefaultColor)
|
||||||
|
|
||||||
|
// Check if this is a YAML file
|
||||||
|
if IsYAMLFile(input) {
|
||||||
|
log.Printf("Parsing as YAML file")
|
||||||
|
instructions, err := ParseYAMLFile(input, filepath.Dir(input))
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to parse YAML file %s%s%s: %s%+v%s",
|
||||||
|
SourceColor, input, DefaultColor, ErrorColor, err, DefaultColor)
|
||||||
|
status <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, instruction := range instructions {
|
||||||
|
instr := instruction // Create a copy to avoid reference issues
|
||||||
|
log.Printf("Read YAML instruction: %s", instr.String())
|
||||||
|
output <- &instr
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle CSV format (legacy)
|
||||||
file, err := os.Open(input)
|
file, err := os.Open(input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to open file %s%s%s: %s%+v%s", SourceColor, input, DefaultColor, ErrorColor, err, DefaultColor)
|
log.Fatalf("Failed to open file %s%s%s: %s%+v%s",
|
||||||
|
SourceColor, input, DefaultColor, ErrorColor, err, DefaultColor)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
@@ -203,7 +232,8 @@ func ReadFromFile(input string, output chan *LinkInstruction, status chan error,
|
|||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
instruction, err := ParseInstruction(line, filepath.Dir(input))
|
instruction, err := ParseInstruction(line, filepath.Dir(input))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error parsing line: %s'%s'%s, error: %s%+v%s", SourceColor, line, DefaultColor, ErrorColor, err, DefaultColor)
|
log.Printf("Error parsing line: %s'%s'%s, error: %s%+v%s",
|
||||||
|
SourceColor, line, DefaultColor, ErrorColor, err, DefaultColor)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
log.Printf("Read instruction: %s", instruction.String())
|
log.Printf("Read instruction: %s", instruction.String())
|
||||||
|
10
sync.yaml
Normal file
10
sync.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
- source: main.go
|
||||||
|
target: test/main.go
|
||||||
|
|
||||||
|
- source: README.md
|
||||||
|
target: test/README.md
|
||||||
|
|
||||||
|
- source: sync.yaml
|
||||||
|
target: test/sync.yaml
|
||||||
|
|
||||||
|
|
26
sync.yaml.example
Normal file
26
sync.yaml.example
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Example sync.yaml file
|
||||||
|
# You can use this format to define symbolic links
|
||||||
|
# Each link specifies source, target, and optional flags
|
||||||
|
links:
|
||||||
|
- source: ~/Documents/config.ini
|
||||||
|
target: ~/.config/app/config.ini
|
||||||
|
# This will create a symbolic link, overwriting any existing symlink
|
||||||
|
force: true
|
||||||
|
|
||||||
|
- source: ~/Pictures
|
||||||
|
target: ~/Documents/Pictures
|
||||||
|
# This will create a hard link instead of a symbolic link
|
||||||
|
hard: true
|
||||||
|
force: true
|
||||||
|
|
||||||
|
- source: ~/Scripts/script.sh
|
||||||
|
target: ~/bin/script.sh
|
||||||
|
# This will delete a non-symlink file at the target location
|
||||||
|
# 'delete: true' implies 'force: true'
|
||||||
|
delete: true
|
||||||
|
|
||||||
|
# Alternative format:
|
||||||
|
# Instead of using the 'links' property, you can define an array directly:
|
||||||
|
# - source: ~/Documents/config.ini
|
||||||
|
# target: ~/.config/app/config.ini
|
||||||
|
# force: true
|
6
util.go
6
util.go
@@ -119,7 +119,7 @@ func GetSyncFilesRecursively(input string, output chan string, status chan error
|
|||||||
directories <- filepath.Join(directory, file.Name())
|
directories <- filepath.Join(directory, file.Name())
|
||||||
} else {
|
} else {
|
||||||
// log.Println(file.Name(), DirRegex.MatchString(file.Name()))
|
// log.Println(file.Name(), DirRegex.MatchString(file.Name()))
|
||||||
if FileRegex.MatchString(file.Name()) {
|
if FileRegex.MatchString(file.Name()) || IsYAMLSyncFile(file.Name()) {
|
||||||
// log.Printf("Writing")
|
// log.Printf("Writing")
|
||||||
output <- filepath.Join(directory, file.Name())
|
output <- filepath.Join(directory, file.Name())
|
||||||
}
|
}
|
||||||
@@ -150,3 +150,7 @@ func GetSyncFilesRecursively(input string, output chan string, status chan error
|
|||||||
wg.Wait()
|
wg.Wait()
|
||||||
log.Printf("Files processed: %d; Folders processed: %d", filesProcessed, foldersProcessed)
|
log.Printf("Files processed: %d; Folders processed: %d", filesProcessed, foldersProcessed)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func IsYAMLSyncFile(filename string) bool {
|
||||||
|
return filename == "sync.yaml" || filename == "sync.yml"
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user