Skip to content

Instantly share code, notes, and snippets.

@eth-p
Last active April 14, 2018 23:33
Show Gist options
  • Save eth-p/368bf9649fbec3e4093b29f905136f5c to your computer and use it in GitHub Desktop.
Save eth-p/368bf9649fbec3e4093b29f905136f5c to your computer and use it in GitHub Desktop.
A script designed to unidirectionally symlink files between two directories (and move new files back).

Symc

A script designed to unidirectionally symlink files between two directories (and move new files back).

Example

Before:

Source Directory:

~/Dropbox/Work/Meetings/2018/sales.xlsx
~/Dropbox/Work/Assets/2018/Apr/campaign_banner.psd

Destination Directory:

~/Work/Assets/2018/Apr/campaign_flyer.indd
~/Work/Assets/2018/Apr/campaign_flyer.tmp

After:

Source Directory:

~/Dropbox/Work/Meetings/2018/sales.xlsx
~/Dropbox/Work/Assets/2018/Apr/campaign_banner.psd
~/Dropbox/Work/Assets/2018/Apr/campaign_flyer.indd

Destination Directory:

~/Work/Meetings/2018/sales.xlsx            --> ~/Dropbox/Work/Meetings/2018/sales.xlsx
~/Work/Assets/2018/Apr/campaign_banner.psd --> ~/Dropbox/Work/Assets/2018/Apr/campaign_banner.psd
~/Work/Assets/2018/Apr/campaign_flyer.indd --> ~/Dropbox/Work/Assets/2018/Apr/campaign_flyer.indd
~/Work/Assets/2018/Apr/campaign_flyer.tmp

Usage

./symc.sh

Why?

  • It's configurable.
  • It's fast (uses find instead of recursive loops).
  • It's easy to modify.

Or don't. I won't be losing sleep over it ¯\(ツ)

#!/usr/bin/env bash
# -----------------------------------------------------------------------------
# SYMC: A directory <-> directory syncing utility using symlinks.
# Copyright (C) 2018 eth-p
#
# This tool is designed to symlink files between two directories,
# which is useful for syncing a subset of files with Dropbox/GDrive.
# -----------------------------------------------------------------------------
# Configuration:
SYMC_SOURCE="$HOME/Dropbox/School"
SYMC_DESTINATION="$HOME/School"
# -----------------------------------------------------------------------------
# Ignore:
# Do not symc files that match these patterns.
IGNORE=(
[name] '.*'
[path] '*/.git/*'
)
# -----------------------------------------------------------------------------
# Move:
# Move newly-created files that match these patterns back to the source directory.
MOVE=(
[ext~] .c .cpp .h .hpp .m # Source Code: C / C++ / Obj-C
[ext~] .cs # Source Code: CSharp
[ext~] .java # Source Code: Java
[ext~] .js .json # Source Code: JavaScript
[ext~] .html .pug .jade # Source Code: Markup
[ext~] .css .scss .sass .less # Source Code: Stylesheets
[ext~] .sh .py .rb # Source Code: Scripts
[ext~] .png .jpg .jpeg .raw # Media: Images
[ext~] .psd .ai .indd # Documents: Adobe
[ext~] .docx .xlsx .pptx # Documents: Office
[ext~] .pdf # Documents: PDF
[ext~] .md # Documents: Markdown
[ext~] .txt .rtf # Documents: Text
)
# -----------------------------------------------------------------------------
# On Complete:
# Executed after the symc is completed.
SYMC_ON_COMPLETE() {
:
}
#!/usr/bin/env bash
#|-----------------------------------------------------------------------------
#| SYMC: A directory <-> directory syncing utility using symlinks.
#| Copyright (C) 2018 eth-p
#|
#| This tool is designed to symlink files between two directories,
#| which is useful for syncing a subset of files with Dropbox/GDrive.
#|-----------------------------------------------------------------------------
# Utilities:
# Parses a file name from a file path.
# @param $1 - file: The file path.
filename() {
local _file="$1"
echo "${_file%.*}"
}
# Parses a file extension from a file path.
# @param $1 - file: The file path.
extname() {
local _file="$1"
echo "${_file##*.}"
}
# Determines a file type.
# @param $1 - file: The file path.
#
# Types:
# f - File
# d - Directory
# l - Symlink
ftype() {
if [ -L "$1" ]; then
echo "l"
return
elif [ -f "$1" ]; then
echo "f"
return
elif [ -d "$1" ]; then
echo "d"
return
fi
echo "?"
}
# Converts a string to lower case.
# @param $1 - string: The string.
tolower() {
local _string="$1"
echo "$1" | tr '[:upper:]' '[:lower:]'
}
# -----------------------------------------------------------------------------
# Patterns:
# Generates find(1) arguments for patterns.
#
# If the first argument is a "!", the function will generate arguments for
# excluding the matching patterns.
#
# Types:
# [name] - Matches file names
# [name~] - Matches file names (insensitive)
# [path] - Matches file paths
# [path~] - Matches file paths (insensitive)
# [ext] - Matches file extensions
# [ext~] - Matches file extensions (insensitive)
# [regex] - Matches using a regular expression.
pgen() {
local _negate="-o "
if [ "$1" = "!" ]; then
_negate="! "
shift
fi
# Generate patterns.
local patterns=()
local pattern
local method="-iname"
local first=true
for pattern in "$@"; do
case "$(tolower "$pattern")" in
"[path~]")
method="-ipath "
continue;;
"[path]")
method="-path "
continue;;
"[name~]")
method="-iname "
continue;;
"[name]")
method="-name "
continue;;
"[ext]")
method="-name *"
continue;;
"[ext~]")
method="-iname *"
continue;;
"[regex]")
method="-regex "
continue;;
esac
if $first && [ "$_negate" = "-o " ]; then
first=false
printf "%s%q\n" "$method" "$pattern"
continue
fi
printf "%s%s%q\n" "$_negate" "$method" "$pattern"
done
}
# -----------------------------------------------------------------------------
# Logging:
log() {
local COLOR="$1"
local LEVEL="$2"
local PATTERN="$3"
local STRS=()
shift 3
local entry;
for entry in "$@"; do
STRS+=($'\033[0m'"${entry}${COLOR}")
done
printf "${COLOR}${LEVEL}${PATTERN}\033[0m\n" "${STRS[@]}"
}
err() {
log $'\033[31m' "[ERR] " "$@"
}
inf() {
log $'\033[33m' "[INF] " "$@"
}
# -----------------------------------------------------------------------------
# Colors:
MSG_RESET=$'\x1b[0m'
MSG_SYS=$'\x1b[7;32m'
MSG_ERR=$'\x1B[31m'
MSG_CREATED=$'\x1B[33m'
MSG_MOVED=$'\x1B[35m'
MSG_LINKED=$'\x1B[36m'
MSG_SKIPPED=$'\x1B[37m'
# -----------------------------------------------------------------------------
# Stats:
STATS_SKIPPED=0
STATS_MOVED=0
STATS_LINKED=0
STATS_ERRORS=0
# -----------------------------------------------------------------------------
# Symc:
cb_act() {
local _act="$1"
local _srcfile="$2"
local _destfile="$3"
local _file="$4"
local _cb_error="$5"
shift
if [ "$_act" = "skip" ]; then
#log "$MSG_SKIPPED" '' "- Skipped %s." "$_file"
return
fi
# Make directory structure:
local dir="$(dirname "$_destfile")"
if ! [ -d "$dir" ]; then
mkdir -p "$dir"
if [ $? -eq 0 ]; then
log "$MSG_CREATED" '' "- Created %s." "$(dirname "$_file")"
else
"$_cb_error" "Could not create directory" "$@"
fi
fi
# Take appropriate action:
case "$_act" in
"mv")
mv "$_srcfile" "$_destfile"
if [ $? -eq 0 ]; then
((STATS_MOVED++))
log "$MSG_MOVED" '' "- Moved %s." "$_file"
else
((STATS_ERRORS++))
"$_cb_error" "Could not move file" "$@"
return 1
fi
;;
"ln")
ln -s "$_srcfile" "$_destfile"
if [ $? -eq 0 ]; then
((STATS_LINKED++));
log "$MSG_LINKED" '' "- Linked %s." "$_file"
else
((STATS_ERRORS++));
"$_cb_error" "Could not symlink file" "$@"
return 1
fi
;;
esac
}
cb_error() {
local _msg="$1"
local _src="$2"
local _dest="$3"
local _file="$4"
local src_type="[$(ftype "$_src")]"
local dest_type="[$(ftype "$_dest")]"
log "$MSG_ERR" '' "- Error: %s.\n Source: ${src_type} %s\n Destination: ${dest_type} %s" $'\x1B[1;31m'"$_msg"$'\x1B[0m' "$_src" "$_dest"
}
scan() {
local SELF="$(basename "${BASH_SOURCE[0]}")"
local _src="$1"
local _dest="$2"
local _cb_act="$3"
local _cb_error="$4"
local entry
local patterns
# Mover:
declare -a patterns="( $(pgen "${MOVE[@]}") )"
while read -r entry; do
# Built-in ignore list.
case "$(basename "$entry")" in
.DS_Store|._*|$'Icon\r')
continue;;
esac
# Parse file name.
local file="${entry:2}"
local src_file="$_src/$file"
local dest_file="$_dest/$file"
# Prevent overwriting.
if [ -f "$src_file" ]; then
"$_cb_error" "Merge conflict (dest -> src)" "$dest_file" "$src_file" "$file"
continue
fi
# Run callback using 'mv' directive.
"$_cb_act" "mv" "$dest_file" "$src_file" "$file" "$_cb_error"
done < <({ cd "$_dest" && find . -depth -type f \( "${patterns[@]}" \) \( ! -path "./$SELF" ! -path "./$CONFIG" \); })
# Symlinker:
declare -a patterns="( $(pgen ! "${IGNORE[@]}") )"
while read -r entry; do
# Parse file name.
local file="${entry:2}"
local src_file="$_src/$file"
local dest_file="$_dest/$file"
# Built-in ignore list.
case "$(basename "$entry")" in
.DS_Store|._*|$'Icon\r')
continue;;
esac
# If a link already exists:
# Run callback using 'skip' directive.
if [ -L "$dest_file" ]; then
"$_cb_act" "skip" "$src_file" "$dest_file" "$file" "$_cb_error"
continue;
elif [ -f "$dest_file" ]; then
"$_cb_error" "Merge conflict (src -> dest)" "$dest_file" "$src_file" "$file"
continue
fi
# Run callback using 'ln' directive.
"$_cb_act" "ln" "$src_file" "$dest_file" "$file" "$_cb_error"
done < <({ cd "$_src" && find . -depth -type f \( "${patterns[@]}" \) \( ! -path "./$SELF" ! -path "./$CONFIG" \); })
}
# -----------------------------------------------------------------------------
# Main:
main() {
local _src="$1"
local _dest="$2"
# Banner:
local line
tail -n +2 < "${BASH_SOURCE[0]}" | while read -r line; do
if ! [ "${line:0:1}" = "#" ]; then
break
fi
if [ "${line:0:2}" = "#|" ]; then
printf "\x1B[33m%s\x1B[0m\n" "$(sed -e 's/^[[:space:]]*//' <<< "${line:2}")"
fi
done
# Scan:
printf "${MSG_SYS}%s${MSG_RESET}\n" "Symcing..."
scan "$_src" "$_dest" "cb_act" "cb_error"
printf "${MSG_SYS}%s${MSG_RESET}\n" "Symced!"
# Callback:
if declare -f SYMC_ON_COMPLETE > /dev/null; then
SYMC_ON_COMPLETE
fi
}
SELF="$(basename "${BASH_SOURCE[0]}")"
CONFIG="$(filename "${SELF}").cfg"
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$CONFIG"
main "${SYMC_SOURCE}" "${SYMC_DESTINATION}"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment