Compare commits
37 Commits
dev
...
ae1986cb81
Author | SHA1 | Date | |
---|---|---|---|
ae1986cb81 | |||
8c5d783d2c | |||
6359705714 | |||
62bfb91246 | |||
083d42a9dd | |||
71ea17122c | |||
83477d5f18 | |||
125bf78c16 | |||
edaa699c20 | |||
5b2da09eb2 | |||
1c23ad0cfd | |||
653e883742 | |||
41bac18525 | |||
02106824fd | |||
cd42bc1fb8 | |||
41123846d1 | |||
205f8314d6 | |||
290e6fdaba | |||
d98ecd787a | |||
1a6992e2a7 | |||
fc59878389 | |||
ff1af19088 | |||
e6bb1f0c53 | |||
e149103d21 | |||
ebccc49d34 | |||
55dc061c31 | |||
2c49b65502 | |||
1f21965288 | |||
b8e7bc3576 | |||
![]() |
595a11552c | ||
2a7740d8d7 | |||
7580ca5399 | |||
58cce74ce8 | |||
e022a838ba | |||
0a627ae9ca | |||
d72644aec3 | |||
eeb8dac3a0 |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
|
*.exe
|
||||||
main.exe
|
*.exe
|
||||||
|
cln
|
||||||
|
cln.log
|
||||||
|
89
README.md
Normal file
89
README.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# synclib
|
||||||
|
|
||||||
|
A small Go tool for creating symbolic links
|
||||||
|
|
||||||
|
Created out of infuriating difficulty of creating symbolic links on windows
|
||||||
|
|
||||||
|
## Instruction Formats
|
||||||
|
|
||||||
|
The tool supports two formats for defining symbolic links:
|
||||||
|
|
||||||
|
### 1. CSV Format (Legacy)
|
||||||
|
|
||||||
|
Simple comma-separated values with the format: `<source>,<destination>[,force][,hard][,delete]`
|
||||||
|
|
||||||
|
For example:
|
||||||
|
```
|
||||||
|
source_path,target_path
|
||||||
|
source_path,target_path,true
|
||||||
|
source_path,target_path,true,true
|
||||||
|
source_path,target_path,true,true,true
|
||||||
|
```
|
||||||
|
|
||||||
|
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`
|
||||||
|
- Run arguments
|
||||||
|
- `sync this,that foo,bar "foo 2","C:/bar"`
|
||||||
|
- Files
|
||||||
|
- `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", "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
|
||||||
|
|
||||||
|
I have a lot of folders (documents, projects, configurations) backed up via Seafile and to have the software using those folders find them at their usual location I'm creating soft symbolic links from the seafile drive to their original location
|
||||||
|
|
||||||
|
It would be problematic to have to redo all (or some part) of these symlinks when reinstalling the OS or having something somewhere explode (say software uninstalled) so I have all the instructions in sync files in individual folders in the seafile drive
|
||||||
|
|
||||||
|
Which means I can easily back up my configuration and `sync -r ~/Seafile` to symlink it where it belongs
|
99
colors.go
Normal file
99
colors.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math/rand/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Reset
|
||||||
|
Reset = "\033[0m" // Text Reset
|
||||||
|
|
||||||
|
// Regular Colors
|
||||||
|
Black = "\033[0;30m" // Black
|
||||||
|
Red = "\033[0;31m" // Red
|
||||||
|
Green = "\033[0;32m" // Green
|
||||||
|
Yellow = "\033[0;33m" // Yellow
|
||||||
|
Blue = "\033[0;34m" // Blue
|
||||||
|
Purple = "\033[0;35m" // Purple
|
||||||
|
Cyan = "\033[0;36m" // Cyan
|
||||||
|
White = "\033[0;37m" // White
|
||||||
|
|
||||||
|
// Bold
|
||||||
|
BBlack = "\033[1;30m" // Black
|
||||||
|
BRed = "\033[1;31m" // Red
|
||||||
|
BGreen = "\033[1;32m" // Green
|
||||||
|
BYellow = "\033[1;33m" // Yellow
|
||||||
|
BBlue = "\033[1;34m" // Blue
|
||||||
|
BPurple = "\033[1;35m" // Purple
|
||||||
|
BCyan = "\033[1;36m" // Cyan
|
||||||
|
BWhite = "\033[1;37m" // White
|
||||||
|
|
||||||
|
// Underline
|
||||||
|
UBlack = "\033[4;30m" // Black
|
||||||
|
URed = "\033[4;31m" // Red
|
||||||
|
UGreen = "\033[4;32m" // Green
|
||||||
|
UYellow = "\033[4;33m" // Yellow
|
||||||
|
UBlue = "\033[4;34m" // Blue
|
||||||
|
UPurple = "\033[4;35m" // Purple
|
||||||
|
UCyan = "\033[4;36m" // Cyan
|
||||||
|
UWhite = "\033[4;37m" // White
|
||||||
|
|
||||||
|
// Background
|
||||||
|
On_Black = "\033[40m" // Black
|
||||||
|
On_Red = "\033[41m" // Red
|
||||||
|
On_Green = "\033[42m" // Green
|
||||||
|
On_Yellow = "\033[43m" // Yellow
|
||||||
|
On_Blue = "\033[44m" // Blue
|
||||||
|
On_Purple = "\033[45m" // Purple
|
||||||
|
On_Cyan = "\033[46m" // Cyan
|
||||||
|
On_White = "\033[47m" // White
|
||||||
|
|
||||||
|
// High Intensty
|
||||||
|
IBlack = "\033[0;90m" // Black
|
||||||
|
IRed = "\033[0;91m" // Red
|
||||||
|
IGreen = "\033[0;92m" // Green
|
||||||
|
IYellow = "\033[0;93m" // Yellow
|
||||||
|
IBlue = "\033[0;94m" // Blue
|
||||||
|
IPurple = "\033[0;95m" // Purple
|
||||||
|
ICyan = "\033[0;96m" // Cyan
|
||||||
|
IWhite = "\033[0;97m" // White
|
||||||
|
|
||||||
|
// Bold High Intensty
|
||||||
|
BIBlack = "\033[1;90m" // Black
|
||||||
|
BIRed = "\033[1;91m" // Red
|
||||||
|
BIGreen = "\033[1;92m" // Green
|
||||||
|
BIYellow = "\033[1;93m" // Yellow
|
||||||
|
BIBlue = "\033[1;94m" // Blue
|
||||||
|
BIPurple = "\033[1;95m" // Purple
|
||||||
|
BICyan = "\033[1;96m" // Cyan
|
||||||
|
BIWhite = "\033[1;97m" // White
|
||||||
|
|
||||||
|
// High Intensty backgrounds
|
||||||
|
On_IBlack = "\033[0;100m" // Black
|
||||||
|
On_IRed = "\033[0;101m" // Red
|
||||||
|
On_IGreen = "\033[0;102m" // Green
|
||||||
|
On_IYellow = "\033[0;103m" // Yellow
|
||||||
|
On_IBlue = "\033[0;104m" // Blue
|
||||||
|
On_IPurple = "\033[10;95m" // Purple
|
||||||
|
On_ICyan = "\033[0;106m" // Cyan
|
||||||
|
On_IWhite = "\033[0;107m" // White
|
||||||
|
)
|
||||||
|
|
||||||
|
// The acceptable range is [16, 231] but here we remove some very dark colors
|
||||||
|
// That make text unreadable on a dark terminal
|
||||||
|
// See https://www.hackitu.de/termcolor256/
|
||||||
|
var colors = []int{22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 57, 62, 63, 64, 65, 67, 68, 69, 70, 71, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 103, 104, 105, 106, 107, 108, 109, 110, 111, 112, 113, 114, 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 148, 149, 150, 151, 152, 154, 155, 156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 166, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 177, 178, 179, 180, 181, 182, 184, 185, 186, 190, 191, 192, 193, 194, 195, 196, 197, 198, 199, 200, 201, 202, 203, 204, 205, 206, 207, 208, 209, 210, 211, 212, 213, 214, 215, 216, 217, 218, 219, 220, 221, 222, 223, 226, 227, 228, 229, 230}
|
||||||
|
var colorsIndex int = -1
|
||||||
|
var shuffled bool
|
||||||
|
|
||||||
|
func GenerateRandomAnsiColor() string {
|
||||||
|
if !shuffled {
|
||||||
|
rand.Shuffle(len(colors), func(i int, j int) {
|
||||||
|
colors[i], colors[j] = colors[j], colors[i]
|
||||||
|
})
|
||||||
|
shuffled = true
|
||||||
|
}
|
||||||
|
colorsIndex++
|
||||||
|
return fmt.Sprintf("\033[1;4;38;5;%dm", colors[colorsIndex%len(colors)])
|
||||||
|
}
|
@@ -1 +1,2 @@
|
|||||||
go build main && cp main.exe "/c/Program Files/Git/usr/bin/cln.exe"
|
GOOS=windows GOARCH=amd64 go build -o main.exe main && cp main.exe "/c/Program Files/Git/usr/bin/cln.exe"
|
||||||
|
GOOS=linux GOARCH=amd64 go build -o main_linux main
|
4
go.mod
4
go.mod
@@ -1,3 +1,5 @@
|
|||||||
module main
|
module cln
|
||||||
|
|
||||||
go 1.21.7
|
go 1.21.7
|
||||||
|
|
||||||
|
require gopkg.in/yaml.v3 v3.0.1
|
||||||
|
4
go.sum
Normal file
4
go.sum
Normal 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=
|
341
instruction.go
341
instruction.go
@@ -2,24 +2,89 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"path/filepath"
|
||||||
"strconv"
|
"slices"
|
||||||
"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 `yaml:"hard,omitempty"`
|
||||||
|
Delete bool `yaml:"delete,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type YAMLConfig struct {
|
||||||
|
Links []LinkInstruction `yaml:"links"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instruction *LinkInstruction) Tidy() {
|
||||||
|
instruction.Source = strings.ReplaceAll(instruction.Source, "\"", "")
|
||||||
|
instruction.Source = strings.ReplaceAll(instruction.Source, "\\", "/")
|
||||||
|
instruction.Source = strings.TrimSpace(instruction.Source)
|
||||||
|
|
||||||
|
instruction.Target = strings.ReplaceAll(instruction.Target, "\"", "")
|
||||||
|
instruction.Target = strings.ReplaceAll(instruction.Target, "\\", "/")
|
||||||
|
instruction.Target = strings.TrimSpace(instruction.Target)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (instruction *LinkInstruction) String() string {
|
func (instruction *LinkInstruction) String() string {
|
||||||
return fmt.Sprintf("%s%s%s%s%s%s%s%s%s", SourceColor, instruction.Source, DefaultColor, deliminer, TargetColor, instruction.Target, DefaultColor, deliminer, strconv.FormatBool(instruction.Force))
|
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",
|
||||||
|
FormatSourcePath(instruction.Source),
|
||||||
|
FormatTargetPath(instruction.Target),
|
||||||
|
flagsStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (instruction *LinkInstruction) Undo() {
|
||||||
|
if !FileExists(instruction.Target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isSymlink, err := IsSymlink(instruction.Target)
|
||||||
|
if err != nil {
|
||||||
|
LogError("could not determine whether %s is a sym link or not, stopping; err: %v",
|
||||||
|
FormatTargetPath(instruction.Target), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isSymlink {
|
||||||
|
LogInfo("Removing symlink at %s", FormatTargetPath(instruction.Target))
|
||||||
|
err = os.Remove(instruction.Target)
|
||||||
|
if err != nil {
|
||||||
|
LogError("could not remove symlink at %s; err: %v",
|
||||||
|
FormatTargetPath(instruction.Target), err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LogInfo("%s is not a symlink, skipping", FormatTargetPath(instruction.Target))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseInstruction(line, workdir string) (LinkInstruction, error) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "#") {
|
||||||
|
return LinkInstruction{}, fmt.Errorf("comment line")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ParseInstruction(line string) (LinkInstruction, error) {
|
|
||||||
parts := strings.Split(line, deliminer)
|
parts := strings.Split(line, deliminer)
|
||||||
instruction := LinkInstruction{}
|
instruction := LinkInstruction{}
|
||||||
|
|
||||||
@@ -27,72 +92,83 @@ func ParseInstruction(line string) (LinkInstruction, error) {
|
|||||||
return instruction, fmt.Errorf("invalid format - not enough parameters (must have at least source and target)")
|
return instruction, fmt.Errorf("invalid format - not enough parameters (must have at least source and target)")
|
||||||
}
|
}
|
||||||
|
|
||||||
instruction.Source = parts[0]
|
instruction.Source = strings.TrimSpace(parts[0])
|
||||||
instruction.Target = 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)T|TRUE$", parts[2])
|
flagPart := strings.TrimSpace(parts[i])
|
||||||
instruction.Force = res
|
|
||||||
|
// 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()
|
||||||
instruction.Source, _ = ConvertHome(instruction.Source)
|
instruction.Source, _ = ConvertHome(instruction.Source)
|
||||||
instruction.Target, _ = ConvertHome(instruction.Target)
|
instruction.Target, _ = ConvertHome(instruction.Target)
|
||||||
|
|
||||||
instruction.Source = NormalizePath(instruction.Source)
|
instruction.Source = NormalizePath(instruction.Source, workdir)
|
||||||
instruction.Target = NormalizePath(instruction.Target)
|
instruction.Target = NormalizePath(instruction.Target, workdir)
|
||||||
|
|
||||||
return instruction, nil
|
return instruction, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (instruction *LinkInstruction) RunSync() error {
|
func isTrue(value string) bool {
|
||||||
if !FileExists(instruction.Source) {
|
value = strings.ToLower(strings.TrimSpace(value))
|
||||||
return fmt.Errorf("instruction source %s%s%s does not exist", SourceColor, instruction.Source, DefaultColor)
|
return value == "true" || value == "t" || value == "yes" || value == "y" || value == "1"
|
||||||
}
|
|
||||||
|
|
||||||
if AreSame(instruction.Source, instruction.Target) {
|
|
||||||
log.Printf("Source %s%s%s and target %s%s%s are the same, %snothing to do...%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor, PathColor, DefaultColor)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if FileExists(instruction.Target) {
|
|
||||||
if instruction.Force {
|
|
||||||
isSymlink, err := IsSymlink(instruction.Target)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("could not determine whether %s%s%s is a sym link or not, stopping; err: %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isSymlink {
|
|
||||||
log.Printf("Removing symlink at %s%s%s", TargetColor, instruction.Target, DefaultColor)
|
|
||||||
err = os.Remove(instruction.Target)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed deleting %s%s%s due to %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("refusing to delte actual (non symlink) file %s%s%s", TargetColor, instruction.Target, DefaultColor)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return fmt.Errorf("target %s%s%s exists - handle manually or set the 'forced' flag (3rd field)", TargetColor, instruction.Target, DefaultColor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
err := os.Symlink(instruction.Source, instruction.Target)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed creating symlink between %s%s%s and %s%s%s with error %s%+v%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
|
|
||||||
}
|
|
||||||
log.Printf("Created symlink between %s%s%s and %s%s%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 undo {
|
||||||
status <- fmt.Errorf("instruction source %s%s%s does not exist", SourceColor, instruction.Source, DefaultColor)
|
instruction.Undo()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if AreSame(instruction.Source, instruction.Target) {
|
if !FileExists(instruction.Source) {
|
||||||
status <- fmt.Errorf("source %s%s%s and target %s%s%s are the same, %snothing to do...%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor, PathColor, DefaultColor)
|
status <- fmt.Errorf("instruction source %s does not exist", FormatSourcePath(instruction.Source))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !instruction.Force && AreSame(instruction.Source, instruction.Target) {
|
||||||
|
status <- fmt.Errorf("source %s and target %s are the same, nothing to do...",
|
||||||
|
FormatSourcePath(instruction.Source),
|
||||||
|
FormatTargetPath(instruction.Target))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,33 +176,166 @@ func (instruction *LinkInstruction) RunAsync(status chan (error)) {
|
|||||||
if instruction.Force {
|
if instruction.Force {
|
||||||
isSymlink, err := IsSymlink(instruction.Target)
|
isSymlink, err := IsSymlink(instruction.Target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status <- fmt.Errorf("could not determine whether %s%s%s is a sym link or not, stopping; err: %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
|
status <- fmt.Errorf("could not determine whether %s is a sym link or not, stopping; err: %v",
|
||||||
|
FormatTargetPath(instruction.Target), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if instruction.Hard {
|
||||||
|
info, err := os.Stat(instruction.Target)
|
||||||
|
if err != nil {
|
||||||
|
status <- fmt.Errorf("could not stat %s, stopping; err: %v",
|
||||||
|
FormatTargetPath(instruction.Target), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info.Mode().IsRegular() && info.Name() == filepath.Base(instruction.Source) {
|
||||||
|
LogTarget("Overwriting existing file %s", instruction.Target)
|
||||||
|
err := os.Remove(instruction.Target)
|
||||||
|
if err != nil {
|
||||||
|
status <- fmt.Errorf("could not remove existing file %s; err: %v",
|
||||||
|
FormatTargetPath(instruction.Target), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if isSymlink {
|
if isSymlink {
|
||||||
log.Printf("Removing symlink at %s%s%s", TargetColor, instruction.Target, DefaultColor)
|
LogTarget("Removing symlink at %s", instruction.Target)
|
||||||
err = os.Remove(instruction.Target)
|
err = os.Remove(instruction.Target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status <- fmt.Errorf("failed deleting %s%s%s due to %s%+v%s", TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
|
status <- fmt.Errorf("failed deleting %s due to %v",
|
||||||
|
FormatTargetPath(instruction.Target), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
status <- fmt.Errorf("refusing to delte actual (non symlink) file %s%s%s", TargetColor, instruction.Target, DefaultColor)
|
if !instruction.Delete {
|
||||||
|
status <- fmt.Errorf("refusing to delte actual (non symlink) file %s",
|
||||||
|
FormatTargetPath(instruction.Target))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
LogImportant("Deleting (!!!) %s", instruction.Target)
|
||||||
|
err = os.RemoveAll(instruction.Target)
|
||||||
|
if err != nil {
|
||||||
|
status <- fmt.Errorf("failed deleting %s due to %v",
|
||||||
|
FormatTargetPath(instruction.Target), err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
status <- fmt.Errorf("target %s%s%s exists - handle manually or set the 'forced' flag (3rd field)", TargetColor, instruction.Target, DefaultColor)
|
status <- fmt.Errorf("target %s exists - handle manually or set the 'forced' flag (3rd field)",
|
||||||
|
FormatTargetPath(instruction.Target))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
err := os.Symlink(instruction.Source, instruction.Target)
|
targetDir := filepath.Dir(instruction.Target)
|
||||||
|
if _, err := os.Stat(targetDir); os.IsNotExist(err) {
|
||||||
|
err = os.MkdirAll(targetDir, 0755)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status <- fmt.Errorf("failed creating symlink between %s%s%s and %s%s%s with error %s%+v%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor, ErrorColor, err, DefaultColor)
|
status <- fmt.Errorf("failed creating directory %s due to %v",
|
||||||
|
FormatTargetPath(targetDir), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Created symlink between %s%s%s and %s%s%s", SourceColor, instruction.Source, DefaultColor, TargetColor, instruction.Target, DefaultColor)
|
}
|
||||||
|
|
||||||
|
var err error
|
||||||
|
if instruction.Hard {
|
||||||
|
err = os.Link(instruction.Source, instruction.Target)
|
||||||
|
} else {
|
||||||
|
err = os.Symlink(instruction.Source, instruction.Target)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
status <- fmt.Errorf("failed creating symlink between %s and %s with error %v",
|
||||||
|
FormatSourcePath(instruction.Source),
|
||||||
|
FormatTargetPath(instruction.Target),
|
||||||
|
err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
LogInfo("Created symlink between %s and %s",
|
||||||
|
FormatSourcePath(instruction.Source),
|
||||||
|
FormatTargetPath(instruction.Target))
|
||||||
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
||||||
|
toRemove := []int{}
|
||||||
|
for i, link := range config.Links {
|
||||||
|
if strings.Contains(link.Source, "*") {
|
||||||
|
LogSource("Expanding wildcard source %s in YAML file %s", link.Source, filename)
|
||||||
|
newlinks, err := ExpandWildcard(link.Source, workdir, link.Target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error expanding wildcard: %w", err)
|
||||||
|
}
|
||||||
|
LogInfo("Expanded wildcard source %s in YAML file %s to %d links",
|
||||||
|
FormatSourcePath(link.Source), FormatSourcePath(filename), len(newlinks))
|
||||||
|
config.Links = append(config.Links, newlinks...)
|
||||||
|
toRemove = append(toRemove, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := len(toRemove) - 1; i >= 0; i-- {
|
||||||
|
config.Links = slices.Delete(config.Links, toRemove[i], 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range config.Links {
|
||||||
|
link := &config.Links[i]
|
||||||
|
|
||||||
|
link.Tidy()
|
||||||
|
link.Source, _ = ConvertHome(link.Source)
|
||||||
|
link.Target, _ = ConvertHome(link.Target)
|
||||||
|
link.Source = NormalizePath(link.Source, workdir)
|
||||||
|
link.Target = NormalizePath(link.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 ExpandWildcard(source, workdir, target string) (links []LinkInstruction, err error) {
|
||||||
|
dir := filepath.Dir(source)
|
||||||
|
pattern := filepath.Base(source)
|
||||||
|
|
||||||
|
files, err := filepath.Glob(filepath.Join(workdir, dir, pattern))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error expanding wildcard: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
link := LinkInstruction{
|
||||||
|
Source: file,
|
||||||
|
Target: filepath.Join(target, filepath.Base(file)),
|
||||||
|
}
|
||||||
|
links = append(links, link)
|
||||||
|
}
|
||||||
|
|
||||||
|
LogInfo("Expanded wildcard source %s to %d links", FormatSourcePath(source), len(links))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsYAMLFile(filename string) bool {
|
||||||
|
ext := strings.ToLower(filepath.Ext(filename))
|
||||||
|
return ext == ".yaml" || ext == ".yml"
|
||||||
|
}
|
||||||
|
92
logger.go
Normal file
92
logger.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Message type prefixes
|
||||||
|
const (
|
||||||
|
InfoPrefix = "INFO"
|
||||||
|
ErrorPrefix = "ERROR"
|
||||||
|
WarningPrefix = "WARN"
|
||||||
|
SourcePrefix = "SOURCE"
|
||||||
|
TargetPrefix = "TARGET"
|
||||||
|
PathPrefix = "PATH"
|
||||||
|
ImportantPrefix = "IMPORTANT"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogInfo logs an informational message
|
||||||
|
func LogInfo(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
log.Printf("%s[%s]%s %s", BGreen, InfoPrefix, Reset, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogSource logs a message about a source file/path with proper coloring
|
||||||
|
func LogSource(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
log.Printf("%s[%s]%s %s%s%s", BPurple, SourcePrefix, Reset, SourceColor, message, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogTarget logs a message about a target file/path with proper coloring
|
||||||
|
func LogTarget(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
log.Printf("%s[%s]%s %s%s%s", BYellow, TargetPrefix, Reset, TargetColor, message, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogPath logs a message about a path with proper coloring
|
||||||
|
func LogPath(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
log.Printf("%s[%s]%s %s%s%s", BGreen, PathPrefix, Reset, PathColor, message, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogImportant logs a message that needs attention with proper coloring
|
||||||
|
func LogImportant(format string, args ...interface{}) {
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
log.Printf("%s[%s]%s %s%s%s", BRed, ImportantPrefix, Reset, ImportantColor, message, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogError logs an error message with proper coloring that won't be cut off
|
||||||
|
func LogError(format string, args ...interface{}) {
|
||||||
|
// Format the message first with normal text (no red coloring)
|
||||||
|
message := fmt.Sprintf(format, args...)
|
||||||
|
|
||||||
|
// The Error prefix itself is bold red on a light background for maximum visibility
|
||||||
|
prefix := fmt.Sprintf("%s%s[%s]%s ", BRed, On_White, ErrorPrefix, Reset)
|
||||||
|
|
||||||
|
// The message is in default color (no red), only the [ERROR] prefix is colored
|
||||||
|
log.Printf("%s%s", prefix, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatSourcePath returns a source path with proper coloring
|
||||||
|
func FormatSourcePath(path string) string {
|
||||||
|
return fmt.Sprintf("%s%s%s", SourceColor, path, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatTargetPath returns a target path with proper coloring
|
||||||
|
func FormatTargetPath(path string) string {
|
||||||
|
return fmt.Sprintf("%s%s%s", TargetColor, path, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatPathValue returns a path with proper coloring
|
||||||
|
func FormatPathValue(path string) string {
|
||||||
|
return fmt.Sprintf("%s%s%s", PathColor, path, Reset)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatErrorValue returns an error value without any additional formatting
|
||||||
|
// Since error messages are no longer red, we don't need special formatting
|
||||||
|
func FormatErrorValue(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Just return the error string with no color formatting
|
||||||
|
return fmt.Sprintf("%v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FormatErrorMessage formats an error message with no additional color formatting,
|
||||||
|
// while preserving the special formatting of embedded source/target/path elements.
|
||||||
|
func FormatErrorMessage(format string, args ...interface{}) string {
|
||||||
|
// This just formats the message with no additional color formatting
|
||||||
|
// The path formatting will still be preserved
|
||||||
|
return fmt.Sprintf(format, args...)
|
||||||
|
}
|
235
main.go
235
main.go
@@ -6,43 +6,37 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
)
|
)
|
||||||
|
|
||||||
const deliminer = ","
|
const deliminer = ","
|
||||||
const (
|
const SourceColor = Purple
|
||||||
Black = "\033[30m"
|
|
||||||
Red = "\033[31m"
|
|
||||||
Green = "\033[32m"
|
|
||||||
Yellow = "\033[33m"
|
|
||||||
Blue = "\033[34m"
|
|
||||||
Magenta = "\033[35m"
|
|
||||||
Cyan = "\033[36m"
|
|
||||||
White = "\033[37m"
|
|
||||||
)
|
|
||||||
const SourceColor = Magenta
|
|
||||||
const TargetColor = Yellow
|
const TargetColor = Yellow
|
||||||
const ErrorColor = Red
|
const ErrorColor = Red
|
||||||
const DefaultColor = White
|
const ImportantColor = BRed
|
||||||
|
const DefaultColor = Reset
|
||||||
const PathColor = Green
|
const PathColor = Green
|
||||||
|
|
||||||
var DirRegex, _ = regexp.Compile(`^(.+?)[/\\]sync$`)
|
var FileRegex, _ = regexp.Compile(`sync\.ya?ml$`)
|
||||||
var FileRegex, _ = regexp.Compile(`^sync$`)
|
|
||||||
var programName = os.Args[0]
|
var programName = os.Args[0]
|
||||||
|
var undo = false
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
recurse := flag.String("r", "", "recurse into directories")
|
recurse := flag.String("r", "", "recurse into directories")
|
||||||
file := flag.String("f", "", "file to read instructions from")
|
file := flag.String("f", "", "file to read instructions from")
|
||||||
debug := flag.Bool("d", false, "debug")
|
debug := flag.Bool("d", false, "debug")
|
||||||
|
undoF := flag.Bool("u", false, "undo")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
undo = *undoF
|
||||||
|
|
||||||
if *debug {
|
if *debug {
|
||||||
log.SetFlags(log.Lmicroseconds | log.Lshortfile)
|
log.SetFlags(log.Lmicroseconds | log.Lshortfile)
|
||||||
logFile, err := os.Create("main.log")
|
logFile, err := os.Create(programName + ".log")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error creating log file: %v", err)
|
LogError("Error creating log file: %v", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
logger := io.MultiWriter(os.Stdout, logFile)
|
logger := io.MultiWriter(os.Stdout, logFile)
|
||||||
@@ -51,19 +45,44 @@ func main() {
|
|||||||
log.SetFlags(log.Lmicroseconds)
|
log.SetFlags(log.Lmicroseconds)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Recurse: %s", *recurse)
|
instructions := make(chan *LinkInstruction, 1000)
|
||||||
log.Printf("File: %s", *file)
|
|
||||||
|
|
||||||
instructions := make(chan LinkInstruction, 1000)
|
|
||||||
status := make(chan error)
|
status := make(chan error)
|
||||||
if *recurse != "" {
|
|
||||||
|
// Check input sources in priority order
|
||||||
|
switch {
|
||||||
|
case *recurse != "":
|
||||||
|
LogInfo("Recurse: %s", *recurse)
|
||||||
go ReadFromFilesRecursively(*recurse, instructions, status)
|
go ReadFromFilesRecursively(*recurse, instructions, status)
|
||||||
} else if *file != "" {
|
|
||||||
|
case *file != "":
|
||||||
|
LogInfo("File: %s", *file)
|
||||||
go ReadFromFile(*file, instructions, status, true)
|
go ReadFromFile(*file, instructions, status, true)
|
||||||
} else if len(os.Args) > 1 {
|
|
||||||
|
case len(flag.Args()) > 0:
|
||||||
|
LogInfo("Reading from command line arguments")
|
||||||
go ReadFromArgs(instructions, status)
|
go ReadFromArgs(instructions, status)
|
||||||
} else {
|
|
||||||
|
case IsPipeInput():
|
||||||
|
LogInfo("Reading from stdin pipe")
|
||||||
go ReadFromStdin(instructions, status)
|
go ReadFromStdin(instructions, status)
|
||||||
|
|
||||||
|
default:
|
||||||
|
if _, err := os.Stat("sync.yaml"); err == nil {
|
||||||
|
LogInfo("Using default sync.yaml file")
|
||||||
|
go ReadFromFile("sync.yaml", instructions, status, true)
|
||||||
|
} else if _, err := os.Stat("sync.yml"); err == nil {
|
||||||
|
LogInfo("Using default sync.yml file")
|
||||||
|
go ReadFromFile("sync.yml", instructions, status, true)
|
||||||
|
} else {
|
||||||
|
LogInfo("No input provided")
|
||||||
|
LogInfo("Provide input as: ")
|
||||||
|
LogInfo("Arguments - %s <source>,<target>,<force?>", programName)
|
||||||
|
LogInfo("File - %s -f <file>", programName)
|
||||||
|
LogInfo("YAML File - %s -f <file.yaml>", programName)
|
||||||
|
LogInfo("Folder (finding sync files in folder recursively) - %s -r <folder>", programName)
|
||||||
|
LogInfo("stdin - (cat <file> | %s)", programName)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@@ -73,166 +92,176 @@ func main() {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Println(err)
|
LogError("%v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var instructionsDone int32
|
var instructionsDone int32 = 0
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for {
|
for {
|
||||||
instruction, ok := <-instructions
|
instruction, ok := <-instructions
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("No more instructions to process")
|
LogInfo("No more instructions to process")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
log.Printf("Processing: %s", instruction.String())
|
LogInfo("Processing: %s", instruction.String())
|
||||||
status := make(chan error)
|
status := make(chan error)
|
||||||
go instruction.RunAsync(status)
|
go instruction.RunAsync(status)
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
err := <-status
|
err := <-status
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed parsing instruction %s%s%s due to %s%+v%s", SourceColor, instruction.String(), DefaultColor, ErrorColor, err, DefaultColor)
|
LogError("Failed processing instruction: %v", err)
|
||||||
}
|
}
|
||||||
atomic.AddInt32(&instructionsDone, 1)
|
atomic.AddInt32(&instructionsDone, 1)
|
||||||
wg.Done()
|
wg.Done()
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
log.Println("All done")
|
|
||||||
if instructionsDone == 0 {
|
if instructionsDone == 0 {
|
||||||
log.Printf("No input provided")
|
LogInfo("No instructions were processed")
|
||||||
log.Printf("Provide input as: ")
|
|
||||||
log.Printf("Arguments - %s <source>,<target>,<force?>", programName)
|
|
||||||
log.Printf("File - %s -f <file>", programName)
|
|
||||||
log.Printf("Folder (finding sync files in folder recursively) - %s -r <folder>", programName)
|
|
||||||
log.Printf("stdin - (cat <file> | %s)", programName)
|
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
LogInfo("All done")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReadFromFilesRecursively(input string, output chan LinkInstruction, status chan error) {
|
func IsPipeInput() bool {
|
||||||
|
info, err := os.Stdin.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return info.Mode()&os.ModeNamedPipe != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadFromFilesRecursively(input string, output chan *LinkInstruction, status chan error) {
|
||||||
defer close(output)
|
defer close(output)
|
||||||
defer close(status)
|
defer close(status)
|
||||||
|
|
||||||
input = NormalizePath(input)
|
workdir, _ := os.Getwd()
|
||||||
log.Printf("Reading input from files recursively starting in %s%s%s", PathColor, input, DefaultColor)
|
input = NormalizePath(input, workdir)
|
||||||
|
LogInfo("Reading input from files recursively starting in %s", FormatPathValue(input))
|
||||||
|
|
||||||
files := make(chan string, 128)
|
files := make(chan string, 128)
|
||||||
recurseStatus := make(chan error)
|
fileStatus := make(chan error)
|
||||||
go GetSyncFilesRecursively(input, files, recurseStatus)
|
var wg sync.WaitGroup
|
||||||
|
go GetSyncFilesRecursively(input, files, fileStatus)
|
||||||
|
go func() {
|
||||||
|
wg.Wait()
|
||||||
|
close(files)
|
||||||
|
}()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
err, ok := <-recurseStatus
|
err, ok := <-fileStatus
|
||||||
if !ok {
|
if !ok {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to get sync files recursively: %s%+v%s", ErrorColor, err, DefaultColor)
|
LogError("Failed to get sync files recursively: %v", err)
|
||||||
status <- err
|
status <- err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
for {
|
for {
|
||||||
file, ok := <-files
|
file, ok := <-files
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Printf("No more files to process")
|
LogInfo("No more files to process")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
log.Println(file)
|
LogInfo(file)
|
||||||
file = NormalizePath(file)
|
file = NormalizePath(file, workdir)
|
||||||
log.Printf("Processing file: %s%s%s", PathColor, file, DefaultColor)
|
LogInfo("Processing file: %s", FormatPathValue(file))
|
||||||
|
|
||||||
// This "has" to be done because instructions are resolved in relation to cwd
|
// This "has" to be done because instructions are resolved in relation to cwd
|
||||||
fileDir := DirRegex.FindStringSubmatch(file)
|
fileDir := FileRegex.FindStringSubmatch(file)
|
||||||
if fileDir == nil {
|
if fileDir == nil {
|
||||||
log.Printf("Failed to extract directory from %s%s%s", SourceColor, file, DefaultColor)
|
LogError("Failed to extract directory from %s", FormatSourcePath(file))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("Changing directory to %s%s%s (for %s%s%s)", PathColor, fileDir[1], DefaultColor, PathColor, file, DefaultColor)
|
LogInfo("Changing directory to %s (for %s)",
|
||||||
|
FormatPathValue(fileDir[1]),
|
||||||
|
FormatPathValue(file))
|
||||||
err := os.Chdir(fileDir[1])
|
err := os.Chdir(fileDir[1])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to change directory to %s%s%s: %s%+v%s", SourceColor, fileDir[1], DefaultColor, ErrorColor, err, DefaultColor)
|
LogError("Failed to change directory to %s: %v",
|
||||||
|
FormatSourcePath(fileDir[1]), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ReadFromFile(file, output, status, false)
|
ReadFromFile(file, output, status, false)
|
||||||
|
// Don't return directory, stay where we are
|
||||||
|
os.Chdir(workdir)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
wg.Wait()
|
|
||||||
}
|
}
|
||||||
func ReadFromFile(input string, output chan LinkInstruction, status chan error, doclose bool) {
|
|
||||||
|
func ReadFromFile(input string, output chan *LinkInstruction, status chan error, doclose bool) {
|
||||||
if doclose {
|
if doclose {
|
||||||
defer close(output)
|
defer close(output)
|
||||||
defer close(status)
|
defer close(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
input = NormalizePath(input)
|
input = NormalizePath(input, filepath.Dir(input))
|
||||||
log.Printf("Reading input from file: %s%s%s", PathColor, input, DefaultColor)
|
LogInfo("Reading input from file: %s", FormatPathValue(input))
|
||||||
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)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
|
|
||||||
scanner := bufio.NewScanner(file)
|
// Check if this is a YAML file
|
||||||
for scanner.Scan() {
|
if IsYAMLFile(input) {
|
||||||
line := scanner.Text()
|
LogInfo("Parsing as YAML file")
|
||||||
instruction, err := ParseInstruction(line)
|
instructions, err := ParseYAMLFile(input, 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)
|
LogError("Failed to parse YAML file %s: %v",
|
||||||
continue
|
FormatSourcePath(input), err)
|
||||||
}
|
|
||||||
log.Printf("Read instruction: %s", instruction.String())
|
|
||||||
output <- instruction
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func ReadFromArgs(output chan LinkInstruction, status chan error) {
|
|
||||||
defer close(output)
|
|
||||||
defer close(status)
|
|
||||||
|
|
||||||
log.Printf("Reading input from args")
|
|
||||||
for _, arg := range os.Args[1:] {
|
|
||||||
instruction, err := ParseInstruction(arg)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("Error parsing arg: %s'%s'%s, error: %s%+v%s", SourceColor, arg, DefaultColor, ErrorColor, err, DefaultColor)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
output <- instruction
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func ReadFromStdin(output chan LinkInstruction, status chan error) {
|
|
||||||
defer close(output)
|
|
||||||
defer close(status)
|
|
||||||
|
|
||||||
log.Printf("Reading input from stdin")
|
|
||||||
|
|
||||||
info, err := os.Stdin.Stat()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to stat stdin: %s%+v%s", ErrorColor, err, DefaultColor)
|
|
||||||
status <- err
|
status <- err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if info.Mode()&os.ModeNamedPipe != 0 {
|
|
||||||
|
for _, instruction := range instructions {
|
||||||
|
instr := instruction // Create a copy to avoid reference issues
|
||||||
|
LogInfo("Read YAML instruction: %s", instr.String())
|
||||||
|
output <- &instr
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadFromArgs(output chan *LinkInstruction, status chan error) {
|
||||||
|
defer close(output)
|
||||||
|
defer close(status)
|
||||||
|
|
||||||
|
workdir, _ := os.Getwd()
|
||||||
|
LogInfo("Reading input from args")
|
||||||
|
for _, arg := range flag.Args() {
|
||||||
|
instruction, err := ParseInstruction(arg, workdir)
|
||||||
|
if err != nil {
|
||||||
|
LogError("Error parsing arg '%s': %v", arg, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
output <- &instruction
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadFromStdin(output chan *LinkInstruction, status chan error) {
|
||||||
|
defer close(output)
|
||||||
|
defer close(status)
|
||||||
|
|
||||||
|
workdir, _ := os.Getwd()
|
||||||
|
LogInfo("Reading input from stdin")
|
||||||
|
|
||||||
scanner := bufio.NewScanner(os.Stdin)
|
scanner := bufio.NewScanner(os.Stdin)
|
||||||
for scanner.Scan() {
|
for scanner.Scan() {
|
||||||
line := scanner.Text()
|
line := scanner.Text()
|
||||||
instruction, err := ParseInstruction(line)
|
instruction, err := ParseInstruction(line, workdir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error parsing line: %s'%s'%s, error: %s%+v%s", SourceColor, line, DefaultColor, ErrorColor, err, DefaultColor)
|
LogError("Error parsing line '%s': %v", line, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
output <- instruction
|
output <- &instruction
|
||||||
}
|
}
|
||||||
if err := scanner.Err(); err != nil {
|
if err := scanner.Err(); err != nil {
|
||||||
log.Fatalf("Error reading from stdin: %s%+v%s", ErrorColor, err, DefaultColor)
|
LogError("Error reading from stdin: %v", err)
|
||||||
status <- err
|
status <- err
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
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
|
26
synclib.log
Normal file
26
synclib.log
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
18:24:37.967093 Using default sync.yaml file
|
||||||
|
18:24:37.967093 Input 'sync.yaml' is not absolute, prepending work dir '.'
|
||||||
|
18:24:37.967593 Reading input from file: [0;32mC:/Users/Administrator/Seafile/Projects-Go/GoProjects/synclib/sync.yaml[0m
|
||||||
|
18:24:37.967593 Parsing as YAML file
|
||||||
|
18:24:37.967593 [4;31mFailed to parse YAML file [0;35mC:/Users/Administrator/Seafile/Projects-Go/GoProjects/synclib/sync.yaml[0m: [4;31merror unmarshaling YAML: yaml: unmarshal errors:
|
||||||
|
line 1: cannot unmarshal !!seq into main.YAMLConfig[0m[0m
|
||||||
|
18:24:37.967593 No more instructions to process
|
||||||
|
18:24:37.968092 No instructions were processed
|
||||||
|
18:27:59.691333 Using default sync.yaml file
|
||||||
|
18:27:59.691333 Input 'sync.yaml' is not absolute, prepending work dir '.'
|
||||||
|
18:27:59.691834 Reading input from file: [0;32mC:/Users/Administrator/Seafile/Projects-Go/GoProjects/synclib/sync.yaml[0m
|
||||||
|
18:27:59.691834 Parsing as YAML file
|
||||||
|
18:27:59.692335 [0;35mExpanding wildcard source \* in YAML file C:/Users/Administrator/Seafile/Projects-Go/GoProjects/synclib/sync.yaml[0m
|
||||||
|
18:27:59.692335 Expanded wildcard source [0;35m\*[0m to 0 links
|
||||||
|
18:27:59.692836 Expanded wildcard source [0;35m\*[0m in YAML file [0;35mC:/Users/Administrator/Seafile/Projects-Go/GoProjects/synclib/sync.yaml[0m to 0 links
|
||||||
|
18:27:59.692836 No more instructions to process
|
||||||
|
18:27:59.692836 No instructions were processed
|
||||||
|
18:28:04.075821 Using default sync.yaml file
|
||||||
|
18:28:04.076320 Input 'sync.yaml' is not absolute, prepending work dir '.'
|
||||||
|
18:28:04.076320 Reading input from file: [0;32mC:/Users/Administrator/Seafile/Projects-Go/GoProjects/synclib/sync.yaml[0m
|
||||||
|
18:28:04.076320 Parsing as YAML file
|
||||||
|
18:28:04.076320 [0;35mExpanding wildcard source \* in YAML file C:/Users/Administrator/Seafile/Projects-Go/GoProjects/synclib/sync.yaml[0m
|
||||||
|
18:28:04.076821 Expanded wildcard source [0;35m\*[0m to 0 links
|
||||||
|
18:28:04.076821 Expanded wildcard source [0;35m\*[0m in YAML file [0;35mC:/Users/Administrator/Seafile/Projects-Go/GoProjects/synclib/sync.yaml[0m to 0 links
|
||||||
|
18:28:04.076821 No more instructions to process
|
||||||
|
18:28:04.076821 No instructions were processed
|
133
util.go
133
util.go
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -27,16 +26,25 @@ func FileExists(path string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func NormalizePath(input string) string {
|
func NormalizePath(input, workdir string) string {
|
||||||
workingdirectory, _ := os.Getwd()
|
input = filepath.Clean(input)
|
||||||
input = strings.ReplaceAll(input, "\\", "/")
|
input = filepath.ToSlash(input)
|
||||||
input = strings.ReplaceAll(input, "\"", "")
|
input = strings.ReplaceAll(input, "\"", "")
|
||||||
|
|
||||||
if !filepath.IsAbs(input) {
|
if !filepath.IsAbs(input) {
|
||||||
input = workingdirectory + "/" + input
|
LogInfo("Input '%s' is not absolute, prepending work dir '%s'", input, workdir)
|
||||||
|
var err error
|
||||||
|
input = filepath.Join(workdir, input)
|
||||||
|
input, err = filepath.Abs(input)
|
||||||
|
if err != nil {
|
||||||
|
LogError("Failed to get absolute path for %s: %v", FormatSourcePath(input), err)
|
||||||
|
return input
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return filepath.Clean(input)
|
input = filepath.Clean(input)
|
||||||
|
input = filepath.ToSlash(input)
|
||||||
|
return input
|
||||||
}
|
}
|
||||||
|
|
||||||
func AreSame(lhs string, rhs string) bool {
|
func AreSame(lhs string, rhs string) bool {
|
||||||
@@ -53,7 +61,7 @@ func AreSame(lhs string, rhs string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ConvertHome(input string) (string, error) {
|
func ConvertHome(input string) (string, error) {
|
||||||
if strings.Contains(input, "~") {
|
if strings.HasPrefix(input, "~/") {
|
||||||
homedir, err := os.UserHomeDir()
|
homedir, err := os.UserHomeDir()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return input, fmt.Errorf("unable to convert ~ to user directory with error %+v", err)
|
return input, fmt.Errorf("unable to convert ~ to user directory with error %+v", err)
|
||||||
@@ -70,63 +78,110 @@ func GetSyncFilesRecursively(input string, output chan string, status chan error
|
|||||||
|
|
||||||
var filesProcessed int32
|
var filesProcessed int32
|
||||||
var foldersProcessed int32
|
var foldersProcessed int32
|
||||||
|
var activeWorkers int32
|
||||||
|
|
||||||
progressTicker := time.NewTicker(200 * time.Millisecond)
|
progressTicker := time.NewTicker(200 * time.Millisecond)
|
||||||
defer progressTicker.Stop()
|
defer progressTicker.Stop()
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
done := make(chan struct{})
|
||||||
var initial sync.Once
|
defer close(done)
|
||||||
wg.Add(1)
|
|
||||||
directories := make(chan string, 100000)
|
directories := make(chan string, 100000)
|
||||||
workerPool := make(chan struct{}, 4000)
|
workerPool := make(chan struct{}, 4000)
|
||||||
directories <- input
|
directories <- input
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
for {
|
for {
|
||||||
fmt.Printf("\rFiles processed: %d; Folders processed: %d; Workers: %d; Directory Stack Size: %d;", filesProcessed, foldersProcessed, len(workerPool), len(directories))
|
select {
|
||||||
<-progressTicker.C
|
case <-progressTicker.C:
|
||||||
|
dirCount := len(directories)
|
||||||
|
workers := atomic.LoadInt32(&activeWorkers)
|
||||||
|
fmt.Printf("\rFiles processed: %d; Folders processed: %d; Active workers: %d; Directory queue: %d",
|
||||||
|
atomic.LoadInt32(&filesProcessed),
|
||||||
|
atomic.LoadInt32(&foldersProcessed),
|
||||||
|
workers,
|
||||||
|
dirCount)
|
||||||
|
case <-done:
|
||||||
|
// Final progress update
|
||||||
|
fmt.Printf("\nFiles processed: %d; Folders processed: %d; Completed successfully\n",
|
||||||
|
atomic.LoadInt32(&filesProcessed),
|
||||||
|
atomic.LoadInt32(&foldersProcessed))
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
log.Printf("%+v", len(workerPool))
|
allDone := make(chan struct{})
|
||||||
go func() {
|
|
||||||
for directory := range directories {
|
|
||||||
workerPool <- struct{}{}
|
|
||||||
wg.Add(1)
|
|
||||||
go func(directory string) {
|
|
||||||
atomic.AddInt32(&foldersProcessed, 1)
|
|
||||||
defer wg.Done()
|
|
||||||
defer func() { <-workerPool }()
|
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// WTF is this waitgroup?
|
||||||
|
// Nowhere is it added...
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
if atomic.LoadInt32(&activeWorkers) == 0 && len(directories) == 0 {
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
if atomic.LoadInt32(&activeWorkers) == 0 && len(directories) == 0 {
|
||||||
|
close(allDone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case directory, ok := <-directories:
|
||||||
|
if !ok {
|
||||||
|
wg.Wait()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
atomic.AddInt32(&activeWorkers, 1)
|
||||||
|
|
||||||
|
go func(dir string) {
|
||||||
|
workerPool <- struct{}{}
|
||||||
|
|
||||||
|
atomic.AddInt32(&foldersProcessed, 1)
|
||||||
|
processDirectory(dir, directories, output, &filesProcessed)
|
||||||
|
|
||||||
|
<-workerPool
|
||||||
|
atomic.AddInt32(&activeWorkers, -1)
|
||||||
|
}(directory)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
<-allDone
|
||||||
|
|
||||||
|
if atomic.LoadInt32(&filesProcessed) > 0 {
|
||||||
|
LogInfo("Files processed: %d; Folders processed: %d",
|
||||||
|
atomic.LoadInt32(&filesProcessed),
|
||||||
|
atomic.LoadInt32(&foldersProcessed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processDirectory(directory string, directories chan<- string, output chan<- string, filesProcessed *int32) {
|
||||||
files, err := os.ReadDir(directory)
|
files, err := os.ReadDir(directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error reading directory %s: %+v", directory, err)
|
LogError("Error reading directory %s: %v", directory, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
// log.Printf("Processing file %s", file.Name())
|
|
||||||
if file.IsDir() {
|
if file.IsDir() {
|
||||||
directories <- filepath.Join(directory, file.Name())
|
directories <- filepath.Join(directory, file.Name())
|
||||||
} else {
|
} else {
|
||||||
// log.Println(file.Name(), DirRegex.MatchString(file.Name()))
|
if IsYAMLSyncFile(file.Name()) {
|
||||||
if FileRegex.MatchString(file.Name()) {
|
|
||||||
// log.Printf("Writing")
|
|
||||||
output <- filepath.Join(directory, file.Name())
|
output <- filepath.Join(directory, file.Name())
|
||||||
}
|
}
|
||||||
atomic.AddInt32(&filesProcessed, 1)
|
atomic.AddInt32(filesProcessed, 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// log.Printf("Done reading directory %s", directory)
|
|
||||||
|
|
||||||
initial.Do(func() {
|
func IsYAMLSyncFile(filename string) bool {
|
||||||
// Parallelism is very difficult...
|
return filename == "sync.yaml" || filename == "sync.yml"
|
||||||
time.Sleep(250 * time.Millisecond)
|
|
||||||
wg.Done()
|
|
||||||
})
|
|
||||||
}(directory)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
log.Printf("Files processed: %d; Folders processed: %d", filesProcessed, foldersProcessed)
|
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user