Implement yaml format

This commit is contained in:
2025-02-26 11:14:36 +01:00
parent edaa699c20
commit 125bf78c16
9 changed files with 269 additions and 42 deletions

View File

@@ -4,27 +4,81 @@ A small Go tool for creating symbolic links
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:
`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
- `echo "this,that" | sync`
- `echo "this,that" | sync`
- Run arguments
- `sync this,that foo,bar "foo 2","C:/bar"`
- `sync this,that foo,bar "foo 2","C:/bar"`
- Files
- `sync -f <file>`
- Where the file contains instructions, one instruction per line
- `sync -f <file>` (CSV format)
- `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
- `sync -r <directory>`
- This mode will look for "sync" files recursively in directories and run their instructions
- `sync -r <directory>`
- 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

2
go.mod
View File

@@ -1,3 +1,5 @@
module cln
go 1.21.7
require gopkg.in/yaml.v3 v3.0.1

4
go.sum
View File

@@ -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=

View File

@@ -5,17 +5,21 @@ import (
"log"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
type LinkInstruction struct {
Source string
Target string
Force bool
Hard bool
Delete bool
Source string `yaml:"source"`
Target string `yaml:"target"`
Force bool `yaml:"force,omitempty"`
Hard bool `yaml:"hard,omitempty"`
Delete bool `yaml:"delete,omitempty"`
}
type YAMLConfig struct {
Links []LinkInstruction `yaml:"links"`
}
func (instruction *LinkInstruction) Tidy() {
@@ -29,7 +33,26 @@ func (instruction *LinkInstruction) Tidy() {
}
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) {
@@ -37,6 +60,7 @@ func ParseInstruction(line, workdir string) (LinkInstruction, error) {
if strings.HasPrefix(line, "#") {
return LinkInstruction{}, fmt.Errorf("comment line")
}
parts := strings.Split(line, deliminer)
instruction := LinkInstruction{}
@@ -46,19 +70,48 @@ func ParseInstruction(line, workdir string) (LinkInstruction, error) {
instruction.Source = strings.TrimSpace(parts[0])
instruction.Target = strings.TrimSpace(parts[1])
instruction.Force = false
if len(parts) > 2 {
res, _ := regexp.MatchString(`^(?i)\s*T|TRUE\s*$`, parts[2])
instruction.Force = res
}
if len(parts) > 3 {
res, _ := regexp.MatchString(`^(?i)\s*T|TRUE\s*$`, parts[3])
instruction.Hard = res
}
if len(parts) > 4 {
res, _ := regexp.MatchString(`^(?i)\s*T|TRUE\s*$`, parts[4])
instruction.Delete = res
instruction.Force = res
for i := 2; i < len(parts); i++ {
flagPart := strings.TrimSpace(parts[i])
// Support for legacy format (backward compatibility)
if !strings.Contains(flagPart, "=") {
// Legacy format: positional boolean flags
switch i {
case 2: // Force flag (3rd position)
instruction.Force = isTrue(flagPart)
case 3: // Hard flag (4th position)
instruction.Hard = isTrue(flagPart)
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()
@@ -71,6 +124,11 @@ func ParseInstruction(line, workdir string) (LinkInstruction, error) {
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)) {
defer close(status)
if !FileExists(instruction.Source) {
@@ -155,3 +213,43 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) {
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"
}

46
main.go
View File

@@ -20,8 +20,8 @@ const ImportantColor = BRed
const DefaultColor = White
const PathColor = Green
var DirRegex, _ = regexp.Compile(`^(.+?)[/\\]sync$`)
var FileRegex, _ = regexp.Compile(`^sync$`)
var DirRegex, _ = regexp.Compile(`^(.+?)[/\\]sync(?:\.ya?ml)?$`)
var FileRegex, _ = regexp.Compile(`^sync(?:\.ya?ml)?$`)
var programName = os.Args[0]
func main() {
@@ -51,28 +51,35 @@ func main() {
case *recurse != "":
log.Printf("Recurse: %s", *recurse)
go ReadFromFilesRecursively(*recurse, instructions, status)
case *file != "":
log.Printf("File: %s", *file)
go ReadFromFile(*file, instructions, status, true)
case len(flag.Args()) > 0:
log.Printf("Reading from command line arguments")
go ReadFromArgs(instructions, status)
case IsPipeInput():
log.Printf("Reading from stdin pipe")
go ReadFromStdin(instructions, status)
default:
if _, err := os.Stat("sync"); err == nil {
log.Printf("Using default sync file")
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 {
log.Printf("No input provided")
log.Printf("Provide input as: ")
log.Printf("Arguments - %s <source>,<target>,<force?>", 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("stdin - (cat <file> | %s)", programName)
os.Exit(1)
@@ -191,9 +198,31 @@ func ReadFromFile(input string, output chan *LinkInstruction, status chan error,
input = NormalizePath(input, filepath.Dir(input))
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)
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
}
defer file.Close()
@@ -203,7 +232,8 @@ func ReadFromFile(input string, output chan *LinkInstruction, status chan error,
line := scanner.Text()
instruction, err := ParseInstruction(line, filepath.Dir(input))
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
}
log.Printf("Read instruction: %s", instruction.String())

1
sync
View File

@@ -1 +0,0 @@
foo,"~/bar"

10
sync.yaml Normal file
View 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
View 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

View File

@@ -119,7 +119,7 @@ func GetSyncFilesRecursively(input string, output chan string, status chan error
directories <- filepath.Join(directory, file.Name())
} else {
// log.Println(file.Name(), DirRegex.MatchString(file.Name()))
if FileRegex.MatchString(file.Name()) {
if FileRegex.MatchString(file.Name()) || IsYAMLSyncFile(file.Name()) {
// log.Printf("Writing")
output <- filepath.Join(directory, file.Name())
}
@@ -150,3 +150,7 @@ func GetSyncFilesRecursively(input string, output chan string, status chan error
wg.Wait()
log.Printf("Files processed: %d; Folders processed: %d", filesProcessed, foldersProcessed)
}
func IsYAMLSyncFile(filename string) bool {
return filename == "sync.yaml" || filename == "sync.yml"
}