From 125bf78c1671452bfce0fe09b8b686f3ef1d6d10 Mon Sep 17 00:00:00 2001 From: PhatPhuckDave Date: Wed, 26 Feb 2025 11:14:36 +0100 Subject: [PATCH] Implement yaml format --- README.md | 76 +++++++++++++++++++++---- go.mod | 2 + go.sum | 4 ++ instruction.go | 140 +++++++++++++++++++++++++++++++++++++++------- main.go | 46 ++++++++++++--- sync | 1 - sync.yaml | 10 ++++ sync.yaml.example | 26 +++++++++ util.go | 6 +- 9 files changed, 269 insertions(+), 42 deletions(-) delete mode 100644 sync create mode 100644 sync.yaml create mode 100644 sync.yaml.example diff --git a/README.md b/README.md index b3d7515..820542e 100644 --- a/README.md +++ b/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 -## 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, \,\,\ +### 1. CSV Format (Legacy) + +Simple comma-separated values with the format: `,[,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 ` - - Where the file contains instructions, one instruction per line + - `sync -f ` (CSV format) + - `sync -f ` or `sync -f ` (YAML format) + - Where the file contains instructions, one per line for CSV or structured YAML - Directories - - `sync -r ` - - This mode will look for "sync" files recursively in directories and run their instructions + - `sync -r ` + - 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 diff --git a/go.mod b/go.mod index 894464a..c124068 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module cln go 1.21.7 + +require gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index e69de29..a62c313 100644 --- a/go.sum +++ b/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= diff --git a/instruction.go b/instruction.go index 96b1501..d72de8c 100644 --- a/instruction.go +++ b/instruction.go @@ -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" +} diff --git a/main.go b/main.go index 09bbc2e..02595a7 100644 --- a/main.go +++ b/main.go @@ -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 ,,", programName) log.Printf("File - %s -f ", programName) + log.Printf("YAML File - %s -f ", programName) log.Printf("Folder (finding sync files in folder recursively) - %s -r ", programName) log.Printf("stdin - (cat | %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()) diff --git a/sync b/sync deleted file mode 100644 index 6039d88..0000000 --- a/sync +++ /dev/null @@ -1 +0,0 @@ -foo,"~/bar" \ No newline at end of file diff --git a/sync.yaml b/sync.yaml new file mode 100644 index 0000000..fac5f43 --- /dev/null +++ b/sync.yaml @@ -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 + + diff --git a/sync.yaml.example b/sync.yaml.example new file mode 100644 index 0000000..3d4f95a --- /dev/null +++ b/sync.yaml.example @@ -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 \ No newline at end of file diff --git a/util.go b/util.go index b040c6f..2fcba80 100644 --- a/util.go +++ b/util.go @@ -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" +}