Instantly share code, notes, and snippets.
Created
September 28, 2026 18:14
-
Star
0
(0)
You must be signed in to star a gist -
Fork
0
(0)
You must be signed in to fork a gist
-
-
Save ttscoff/3117fe69e3b42ac4e1ec7da0f5eecb8c to your computer and use it in GitHub Desktop.
Example client for Marked Share folder sync
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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