Skip to content

Instantly share code, notes, and snippets.

@ttscoff
Created September 28, 2026 18:14
Show Gist options
  • Select an option

  • Save ttscoff/3117fe69e3b42ac4e1ec7da0f5eecb8c to your computer and use it in GitHub Desktop.

Select an option

Save ttscoff/3117fe69e3b42ac4e1ec7da0f5eecb8c to your computer and use it in GitHub Desktop.
Example client for Marked Share folder sync
#!/usr/bin/env ruby
# frozen_string_literal: true
# Example client for Marked Share folder sync. Pairs a local folder of Markdown
# with a Share library folder and keeps them in step in both directions:
#
# - GET /documents?view=index is the snapshot of the remote folder
# - tombstones mean deleted; an id missing from both lists moved elsewhere
# - renames and moves on Share rename the local file
# - new local files go up with POST /documents/import, edits with PATCH + If-Match
# - new and changed Share documents come down with GET /documents/:id
# - a conflict keeps the Share version and uploads local edits as "(conflicted)"
#
# The document id is the identity, never the filename. State lives in
# DIR/.marked-share-sync.json. Standard library only.
#
# Run `script/share_sync --help` for usage.
require "digest"
require "fileutils"
require "json"
require "net/http"
require "optparse"
require "set"
require "time"
require "uri"
module ShareSync
DEFAULT_API = "https://share.markedapp.com/api/v1"
STATE_FILE = ".marked-share-sync.json"
MARKDOWN_EXTENSIONS = %w[ .md .markdown .mdown .mkd .mmd ].freeze
IMPORT_CHUNK = 200
MAX_BODY_BYTES = 500_000
def self.content_hash(text)
Digest::SHA256.hexdigest(text.gsub("\r\n", "\n"))
end
class Client
MAX_RETRIES = 6
RETRYABLE_ERRORS = [ Net::OpenTimeout, Net::ReadTimeout, Errno::ECONNRESET, EOFError, IOError ].freeze
def initialize(api:, token:, device_key:)
@base = URI(api.chomp("/") + "/")
@token = token
@device_key = device_key
end
def get(path, query = {})
request(Net::HTTP::Get, path, query: query)
end
def post(path, body)
request(Net::HTTP::Post, path, body: body, write: true)
end
def patch(path, body, if_match:)
request(Net::HTTP::Patch, path, body: body, write: true, headers: { "If-Match" => if_match })
end
def delete(path, if_match:)
request(Net::HTTP::Delete, path, write: true, headers: { "If-Match" => if_match })
end
def finish
@http&.finish if @http&.started?
end
private
def request(klass, path, query: {}, body: nil, write: false, headers: {})
uri = URI.join(@base, path)
uri.query = URI.encode_www_form(query) unless query.empty?
attempts = 0
loop do
req = klass.new(uri)
req["Authorization"] = "Bearer #{@token}"
req["Accept"] = "application/json"
req["X-Device-Key"] = @device_key if write
headers.each { |key, value| req[key] = value }
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
response = http.request(req)
status = response.code.to_i
if (status == 429 || status >= 500) && attempts < MAX_RETRIES
attempts += 1
wait = retry_wait(response, attempts)
warn " #{status} on #{klass::METHOD} #{uri.path}; retrying in #{wait}s"
sleep wait
next
end
return [ status, parse(response.body) ]
rescue *RETRYABLE_ERRORS => error
raise if attempts >= MAX_RETRIES
attempts += 1
reset_http
wait = [ 2**attempts, 30 ].min
warn " #{error.class} on #{klass::METHOD} #{uri.path}; retrying in #{wait}s"
sleep wait
end
end
def parse(body)
return nil if body.nil? || body.empty?
JSON.parse(body)
rescue JSON::ParserError
body
end
def retry_wait(response, attempts)
after = response["Retry-After"].to_i
after.positive? ? after : [ 2**attempts, 60 ].min
end
def http
@http ||= begin
connection = Net::HTTP.new(@base.host, @base.port)
connection.use_ssl = @base.scheme == "https"
connection.open_timeout = 15
connection.read_timeout = 300
connection.keep_alive_timeout = 30
connection.start
end
end
def reset_http
finish
rescue IOError
nil
ensure
@http = nil
end
end
class LocalFile
attr_accessor :relative_path
attr_reader :body, :local_hash, :skip_reason
def initialize(root, relative_path)
@relative_path = relative_path
raw = File.binread(File.join(root, relative_path))
if raw.bytesize > MAX_BODY_BYTES
@skip_reason = "larger than #{MAX_BODY_BYTES} bytes"
else
@body = raw.dup.force_encoding(Encoding::UTF_8)
if !@body.valid_encoding?
@skip_reason = "not valid UTF-8"
elsif @body.strip.empty?
@skip_reason = "empty"
else
@local_hash = ShareSync.content_hash(@body)
end
end
end
def skipped?
!@skip_reason.nil?
end
def title
File.basename(relative_path, File.extname(relative_path))
end
def folder(prefix)
dir = File.dirname(relative_path)
dir == "." ? prefix : "#{prefix}/#{dir}"
end
end
class Syncer
def initialize(client:, root:, folder:, state_path:, dry_run:, delete:, pull:, restore:, verbose:)
@client = client
@root = root
@folder = folder
@state_path = state_path
@dry_run = dry_run
@delete = delete
@pull = pull
@restore = restore
@verbose = verbose
@counts = Hash.new(0)
end
def run
started = Time.now
@state = load_state
@local = scan
puts "Local: #{@local.size} Markdown files under #{@root}"
puts "Remote folder: #{@folder}#{' (dry run)' if @dry_run}"
@local.each_value { |file| note(:skipped, "skip #{file.relative_path}: #{file.skip_reason}") if file.skipped? }
ensure_remote_folder
@remote = fetch_index
apply_remote_structure if @remote
reconcile_tracked
handle_missing_local
add_new_remote if @remote && @pull
import(@local.values.reject { |file| file.skipped? || @state["files"].key?(file.relative_path) })
save_state
summary(Time.now - started)
ensure
@client.finish
end
private
# Tombstones, moves out of the folder, and renames, before any content is compared.
def apply_remote_structure
tracked = @state["files"].values
if tracked.any? && tracked.none? { |entry| remote_row(entry) || tombstoned?(entry) }
warn "None of the #{tracked.size} tracked documents are in #{@folder} on Share (was the folder renamed?). " \
"Leaving local files alone this run."
@remote = nil
return
end
@state["files"].keys.each do |path|
entry = @state["files"][path]
file = @local[path]
next if entry.nil? || file.nil? || file.skipped?
if tombstoned?(entry)
handle_tombstone(file, entry)
elsif moved_out?(entry)
handle_moved_out(file, entry)
else
follow_rename(file, entry)
end
end
end
def tombstoned?(entry)
@remote && @remote[:tombstones].include?(entry["id"])
end
# Only a tombstone means deleted. An id missing from both lists still
# exists on Share, somewhere outside the synced folder.
def moved_out?(entry)
@remote && !tombstoned?(entry) && remote_row(entry).nil?
end
def remote_row(entry)
@remote && @remote[:documents][entry["id"]]
end
def local_changed?(file, entry)
file.local_hash != entry["local_hash"]
end
def handle_tombstone(file, entry)
path = file.relative_path
if @restore || (@delete && local_changed?(file, entry))
forget(path)
note(:remote_deleted, "deleted on Share, uploading local copy as a new document: #{path}")
elsif @delete && @pull
remove_local(path)
forget(path)
note(:deleted, "#{@dry_run ? 'would delete' : 'deleted'} locally (deleted on Share): #{path}")
else
note(:remote_deleted, "deleted on Share, left alone (--delete removes it, --restore re-uploads): #{path}")
end
end
def handle_moved_out(file, entry)
path = file.relative_path
unless @pull
note(:moved_out, "moved out of #{@folder} on Share, left alone: #{path}")
return
end
if @dry_run
action = local_changed?(file, entry) ? "would push local edits, then remove" : "would remove"
note(:moved_out, "moved out of #{@folder} on Share, #{action} #{path}")
return
end
where = nil
if local_changed?(file, entry)
status, body = patch_document(file, entry)
case status
when 200
where = body["library_path"]
when 409
copy = upload_conflicted_copy(file)
return unless copy
remove_local(path)
forget(path)
note(:moved_out, "moved out of #{@folder} on Share and changed there too; local edits saved as #{copy}")
return
when 404
forget(path)
note(:remote_deleted, "gone from Share, uploading local copy as a new document: #{path}")
return
else
note(:errors, "update failed #{path}: HTTP #{status} #{body.inspect}")
return
end
end
remove_local(path)
forget(path)
note(:moved_out, "moved out of #{@folder} on Share#{" to #{where}" if where}, removed local copy: #{path}")
checkpoint
end
def follow_rename(file, entry)
library_path = remote_row(entry)["library_path"]
return if library_path == entry["library_path"]
target = local_path_for(library_path, File.extname(file.relative_path))
if target == file.relative_path
entry["library_path"] = library_path
elsif !@pull
note(:remote_changed, "renamed on Share to #{target}, left alone: #{file.relative_path}")
elsif rename_local(file, target)
entry["library_path"] = library_path
end
end
def reconcile_tracked
@state["files"].keys.each do |path|
entry = @state["files"][path]
file = @local[path]
next if entry.nil? || file.nil? || file.skipped?
next if tombstoned?(entry) || moved_out?(entry)
reconcile(file, entry)
end
end
def reconcile(file, entry)
row = remote_row(entry)
local_changed = local_changed?(file, entry)
remote_changed = row && row["content_hash"] != entry["content_hash"]
if remote_changed && local_changed
resolve_conflict(file, entry)
elsif remote_changed
@pull ? pull(file, entry) : note(:remote_changed, "changed on Share, left alone: #{file.relative_path}")
elsif local_changed
push(file, entry)
else
@counts[:unchanged] += 1
end
end
def push(file, entry)
if @dry_run
note(:updated, "would update #{file.relative_path}")
return
end
status, body = patch_document(file, entry)
case status
when 200
entry["content_hash"] = body.fetch("content_hash")
entry["local_hash"] = file.local_hash
entry["library_path"] = body["library_path"]
note(:updated, "updated #{file.relative_path}")
target = local_path_for(body["library_path"], File.extname(file.relative_path))
rename_local(file, target) if @pull && target && target != file.relative_path
checkpoint
when 409
@pull ? resolve_conflict(file, entry) : note(:conflicts, "conflict (changed on Share since last sync): #{file.relative_path}")
when 404
note(:remote_deleted, "gone from Share: #{file.relative_path}")
else
note(:errors, "update failed #{file.relative_path}: HTTP #{status} #{body.inspect}")
end
end
def patch_document(file, entry)
@client.patch("documents/#{entry['id']}", { document: { body_markdown: file.body } },
if_match: entry["content_hash"])
end
def pull(file, entry)
if @dry_run
note(:pulled, "would pull Share edits into #{file.relative_path}")
return
end
document = fetch_document(entry["id"], file.relative_path)
return unless document
write_local(file.relative_path, document["body_markdown"])
adopt_server_version(entry, document)
note(:pulled, "pulled Share edits into #{file.relative_path}")
checkpoint
end
# Share keeps its version at the original path. The local body is
# uploaded first as a "(conflicted)" document, so nothing is lost if a
# request fails partway.
def resolve_conflict(file, entry)
path = file.relative_path
unless @pull
note(:conflicts, "conflict (changed locally and on Share): #{path}")
return
end
if @dry_run
@state["files"][path] ||= entry
note(:conflicted, "conflict: would keep the Share version of #{path} and upload local edits as a conflicted copy")
return
end
document = fetch_document(entry["id"], path)
return unless document
copy = upload_conflicted_copy(file)
return unless copy
write_local(path, document["body_markdown"])
adopt_server_version(entry, document)
@state["files"][path] = entry
@local[path] = LocalFile.new(@root, path)
note(:conflicted, "conflict: kept the Share version of #{path}, local edits saved as #{copy}")
checkpoint
end
def upload_conflicted_copy(file)
payload = { title: "#{file.title} (conflicted)", body_markdown: file.body, library_path: file.folder(@folder) }
status, body = @client.post("documents/import", { documents: [ payload ] })
row = body.is_a?(Hash) && body["created"]&.first
unless row
note(:conflicts, "conflict, could not upload a conflicted copy of #{file.relative_path}: HTTP #{status} #{body.inspect}")
return nil
end
target = local_path_for(row.fetch("library_path"), File.extname(file.relative_path))
target = free_local_path(target)
write_local(target, file.body)
track(target, row, file.local_hash)
target
end
def fetch_document(id, label)
status, body = @client.get("documents/#{id}")
return body if status == 200
note(:errors, "download failed #{label}: HTTP #{status}")
nil
end
def adopt_server_version(entry, document)
entry["content_hash"] = document.fetch("content_hash")
entry["local_hash"] = ShareSync.content_hash(document.fetch("body_markdown"))
entry["library_path"] = document["library_path"]
end
# Documents in the index that the state file has never seen: created on
# Share, moved into the folder, or known before the state file was lost.
def add_new_remote
known = @state["files"].values.to_set { |entry| entry["id"] }
@remote[:documents].each_value do |row|
next if known.include?(row["id"])
path = local_path_for(row["library_path"], ".md")
next unless path
existing = untracked_local_for(path)
if existing&.skipped?
note(:conflicts, "can't download #{row['library_path']} over #{existing.relative_path} (#{existing.skip_reason})")
elsif existing && existing.local_hash == row["content_hash"]
track(existing.relative_path, row, existing.local_hash)
note(:adopted, "matched #{existing.relative_path} to #{row['library_path']}")
elsif existing
resolve_conflict(existing, { "id" => row["id"], "library_path" => row["library_path"] })
elsif @state["files"].key?(path) || File.exist?(File.join(@root, path))
note(:conflicts, "can't download #{row['library_path']}: #{path} is in use")
else
download(row, path)
end
end
end
# An untracked file at the mapped path with any Markdown extension.
def untracked_local_for(path)
base = path.delete_suffix(File.extname(path))
MARKDOWN_EXTENSIONS.map { |ext| @local[base + ext] }.compact
.find { |file| !@state["files"].key?(file.relative_path) }
end
def download(row, path)
if @dry_run
note(:downloaded, "would download #{row['library_path']} to #{path}")
return
end
document = fetch_document(row["id"], row["library_path"])
return unless document
write_local(path, document["body_markdown"])
track(path, document, ShareSync.content_hash(document["body_markdown"]))
note(:downloaded, "downloaded #{row['library_path']} to #{path}")
checkpoint
end
def track(path, document, local_hash)
@state["files"][path] = {
"id" => document.fetch("id"),
"private_id" => document["private_id"],
"content_hash" => document.fetch("content_hash"),
"local_hash" => local_hash,
"library_path" => document["library_path"]
}
@local[path] ||= LocalFile.new(@root, path) unless @dry_run
end
# "Work/Clients/Acme" under --folder Work maps to "Clients/Acme.md".
def local_path_for(library_path, ext)
prefix = "#{@folder}/"
return nil unless library_path.to_s.start_with?(prefix)
library_path.delete_prefix(prefix) + (ext.empty? ? ".md" : ext)
end
def free_local_path(path)
return path unless File.exist?(File.join(@root, path)) || @state["files"].key?(path)
ext = File.extname(path)
base = path.delete_suffix(ext)
suffix = 2
suffix += 1 while File.exist?(File.join(@root, "#{base} #{suffix}#{ext}"))
"#{base} #{suffix}#{ext}"
end
def rename_local(file, target)
from = file.relative_path
source = File.join(@root, from)
destination = File.join(@root, target)
taken = @local.key?(target) || @state["files"].key?(target) ||
(File.exist?(destination) && !File.identical?(source, destination))
if taken
note(:conflicts, "renamed on Share, but #{target} already exists; left #{from} alone")
return false
end
unless @dry_run
FileUtils.mkdir_p(File.dirname(destination))
File.rename(source, destination)
end
@state["files"][target] = @state["files"].delete(from)
@local[target] = @local.delete(from)
file.relative_path = target
note(:renamed, "#{@dry_run ? 'would rename' : 'renamed'} #{from} -> #{target}")
checkpoint
true
end
def write_local(path, markdown)
destination = File.join(@root, path)
FileUtils.mkdir_p(File.dirname(destination))
File.write(destination, markdown)
end
def remove_local(path)
File.delete(File.join(@root, path)) unless @dry_run
@local.delete(path)
end
def forget(path)
@state["files"].delete(path)
end
def handle_missing_local
(@state["files"].keys - @local.keys).each do |path|
entry = @state["files"][path]
if tombstoned?(entry)
forget(path)
note(:forgotten, "deleted locally and on Share: #{path}")
elsif moved_out?(entry)
forget(path)
note(:forgotten, "deleted locally, moved out of #{@folder} on Share (kept there): #{path}")
elsif !@delete
note(:missing_local, "deleted locally, kept on Share (use --delete to remove): #{path}")
elsif @dry_run
note(:deleted, "would delete #{path}")
else
delete_remote(path, entry)
end
end
end
def delete_remote(path, entry)
status, = @client.delete("documents/#{entry['id']}", if_match: entry["content_hash"])
case status
when 204, 404
@state["files"].delete(path)
note(:deleted, "deleted on Share: #{path}")
checkpoint
when 409
note(:conflicts, "not deleted, changed on Share since last sync: #{path}")
else
note(:errors, "delete failed #{path}: HTTP #{status}")
end
end
def import(files)
return if files.empty?
if @dry_run
files.each { |file| note(:imported, "would import #{file.relative_path}", quiet: true) }
puts "Would import #{files.size} files in #{(files.size / IMPORT_CHUNK.to_f).ceil} requests"
return
end
done = 0
files.each_slice(IMPORT_CHUNK) do |chunk|
chunk_started = Time.now
payload = chunk.map do |file|
{ title: file.title, body_markdown: file.body, library_path: file.folder(@folder) }
end
status, body = @client.post("documents/import", { documents: payload })
unless body.is_a?(Hash) && body.key?("created")
note(:errors, "import request failed: HTTP #{status} #{body.inspect}")
next
end
body["created"].each do |row|
file = chunk.fetch(row.fetch("index"))
if row["content_hash"] != file.local_hash
warn " hash mismatch for #{file.relative_path}; server #{row['content_hash']}"
end
@state["files"][file.relative_path] = {
"id" => row.fetch("id"),
"private_id" => row["private_id"],
"content_hash" => row.fetch("content_hash"),
"local_hash" => file.local_hash,
"library_path" => row["library_path"]
}
note(:imported, "imported #{file.relative_path} -> #{row['library_path']}")
end
body.fetch("errors", []).each do |row|
file = chunk[row["index"].to_i]
note(:errors, "import failed #{file&.relative_path}: #{row['error']}")
end
done += chunk.size
checkpoint(force: true)
puts format("Imported %d/%d (%.1fs for %d)", done, files.size, Time.now - chunk_started, chunk.size)
end
end
def scan
Dir.glob("**/*", base: @root).sort.each_with_object({}) do |relative, files|
next unless MARKDOWN_EXTENSIONS.include?(File.extname(relative).downcase)
next unless File.file?(File.join(@root, relative))
files[relative] = LocalFile.new(@root, relative)
end
end
def ensure_remote_folder
return if @dry_run
status, body = @client.post("folders", { folder: { path: @folder } })
return if [ 200, 201 ].include?(status)
abort "Could not create remote folder #{@folder.inspect}: HTTP #{status} #{body.inspect}"
end
def fetch_index
status, body = @client.get("documents", view: "index", folder: @folder)
case status
when 200
{
documents: body.fetch("documents").to_h { |row| [ row.fetch("id"), row ] },
tombstones: body.fetch("tombstones").map { |row| row.fetch("id") }.to_set
}
when 404
{ documents: {}, tombstones: Set.new }
when 413
warn "Remote index is over the server cap; skipping remote change detection."
nil
when 401
abort "Unauthorized. Check MARKED_SHARE_TOKEN."
else
abort "Could not read remote index: HTTP #{status} #{body.inspect}"
end
end
def load_state
state = File.exist?(@state_path) ? JSON.parse(File.read(@state_path)) : {}
state["files"] ||= {}
if state["folder"] && state["folder"] != @folder
abort "State file #{@state_path} is paired with #{state['folder'].inspect}, not #{@folder.inspect}. " \
"Use --state to keep a separate state file."
end
state["folder"] = @folder
state
end
def save_state
return if @dry_run
@state["synced_at"] = Time.now.utc.iso8601
tmp = "#{@state_path}.tmp"
File.write(tmp, JSON.pretty_generate(@state))
File.rename(tmp, @state_path)
end
def checkpoint(force: false)
@since_save = @since_save.to_i + 1
return unless force || @since_save >= 50
save_state
@since_save = 0
end
def note(kind, message, quiet: false)
@counts[kind] += 1
important = %i[ conflicted conflicts errors moved_out remote_deleted remote_changed missing_local ].include?(kind)
puts " #{message}" if (@verbose && !quiet) || important
end
def summary(elapsed)
puts
puts format("Done in %.1fs", elapsed)
%i[ imported updated downloaded pulled adopted renamed moved_out deleted unchanged skipped
remote_changed remote_deleted missing_local forgotten conflicted conflicts errors ].each do |kind|
next if @counts[kind].zero?
puts format(" %-15s %d", kind.to_s.tr("_", " "), @counts[kind])
end
exit 1 if @counts[:errors].positive?
end
end
module Corpus
WORDS = %w[
markdown folder sync library draft outline note idea project meeting agenda review
summary detail context plan budget research archive journal reading writing
].freeze
def self.generate(root, count)
FileUtils.mkdir_p(root)
count.times do |index|
dir = File.join(root, format("Topic %02d", index % 12), format("Sub %d", index % 3))
FileUtils.mkdir_p(dir)
path = File.join(dir, format("Note %05d.md", index))
File.write(path, document(index))
end
puts "Wrote #{count} Markdown files under #{root}"
end
def self.mutate(root, count)
files = Dir.glob("**/*.md", base: root).sample(count)
files.each do |relative|
File.open(File.join(root, relative), "a") do |file|
file.puts
file.puts "Edited #{Time.now.utc.iso8601}: #{sentence(rand(1_000))}"
end
end
puts "Appended a line to #{files.size} files under #{root}"
end
def self.document(index)
random = Random.new(index)
paragraphs = Array.new(random.rand(2..6)) { Array.new(random.rand(3..7)) { sentence(random.rand(10_000)) }.join(" ") }
body = +"# Note #{index}\n\n"
paragraphs.each_with_index do |paragraph, position|
body << "## Section #{position + 1}\n\n" if position.positive? && random.rand < 0.5
body << paragraph << "\n\n"
end
body << "> [!note]\n> Generated for bulk sync testing.\n" if index % 10 == 0
body
end
def self.sentence(seed)
random = Random.new(seed)
words = Array.new(random.rand(6..14)) { WORDS[random.rand(WORDS.size)] }
"#{words.join(' ').capitalize}."
end
end
module CLI
USAGE = <<~TEXT
Usage:
script/share_sync sync DIR --folder "Remote/Folder" [options]
script/share_sync generate DIR COUNT
script/share_sync mutate DIR COUNT
sync Two-way sync between DIR and a Share folder. New local files go up with
POST /documents/import (200 per request), local edits with PATCH + If-Match.
New, changed, renamed, and moved Share documents come down. A conflict keeps
the Share version and uploads local edits as a "(conflicted)" copy. State is
kept in DIR/#{STATE_FILE}.
generate Write COUNT sample Markdown files into nested folders under DIR.
mutate Append a line to COUNT random .md files under DIR.
Credentials (Settings > Connect an app on Marked Share):
MARKED_SHARE_TOKEN API token
MARKED_SHARE_DEVICE_KEY device key
MARKED_SHARE_API API base (default #{DEFAULT_API})
Sync options:
TEXT
def self.run(argv)
command = argv.shift
case command
when "sync" then sync(argv)
when "generate" then corpus(argv, :generate)
when "mutate" then corpus(argv, :mutate)
when "-h", "--help", nil then puts parser({}).help
else abort "Unknown command #{command.inspect}\n\n#{parser({}).help}"
end
end
def self.corpus(argv, action)
dir, count = argv
abort parser({}).help unless dir && count.to_i.positive?
Corpus.public_send(action, File.expand_path(dir), count.to_i)
end
def self.sync(argv)
options = {
api: ENV.fetch("MARKED_SHARE_API", DEFAULT_API),
token: ENV["MARKED_SHARE_TOKEN"],
device_key: ENV["MARKED_SHARE_DEVICE_KEY"]
}
parser(options).parse!(argv)
dir = argv.shift
abort parser(options).help unless dir
root = File.expand_path(dir)
abort "#{root} is not a directory" unless File.directory?(root)
folder = options[:folder].to_s.strip.gsub(%r{\A/+|/+\z}, "")
abort "--folder is required" if folder.empty?
abort "Set MARKED_SHARE_TOKEN and MARKED_SHARE_DEVICE_KEY" if options[:token].to_s.empty? ||
options[:device_key].to_s.empty?
client = Client.new(api: options[:api], token: options[:token], device_key: options[:device_key])
begin
run_sync(client, root, folder, options)
rescue Errno::ECONNREFUSED, SocketError => error
abort "Could not connect to #{options[:api]}: #{error.message}"
end
end
def self.run_sync(client, root, folder, options)
Syncer.new(
client: client,
root: root,
folder: folder,
state_path: options[:state] ? File.expand_path(options[:state]) : File.join(root, STATE_FILE),
dry_run: options[:dry_run],
delete: options[:delete],
pull: options.fetch(:pull, true),
restore: options[:restore],
verbose: options[:verbose]
).run
end
def self.parser(options)
OptionParser.new do |opts|
opts.banner = USAGE
opts.on("--folder PATH", "Share library folder to sync into (created if missing)") { |v| options[:folder] = v }
opts.on("--api URL", "API base, e.g. http://127.0.0.1:3000/api/v1") { |v| options[:api] = v }
opts.on("--token TOKEN", "API token (overrides MARKED_SHARE_TOKEN)") { |v| options[:token] = v }
opts.on("--device-key KEY", "Device key (overrides MARKED_SHARE_DEVICE_KEY)") { |v| options[:device_key] = v }
opts.on("--state PATH", "State file (default DIR/#{STATE_FILE})") { |v| options[:state] = v }
opts.on("--delete", "Propagate deletes both ways (unsynced local edits are never deleted)") { options[:delete] = true }
opts.on("--no-pull", "Push only: never change local files from Share") { options[:pull] = false }
opts.on("--restore", "Re-upload files whose Share document was deleted") { options[:restore] = true }
opts.on("-n", "--dry-run", "Report what would change without writing") { options[:dry_run] = true }
opts.on("-v", "--verbose", "Print every file action") { options[:verbose] = true }
end
end
end
end
if $PROGRAM_NAME == __FILE__
$stdout.sync = true
ShareSync::CLI.run(ARGV)
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment