Module: SplttyCLI::NotesSync

Defined in:
lib/spltty_cli/notes_sync.rb

Overview

Two-way sync of ledger config between each ledger's *.notes.md frontmatter (the spltty: sub-map) and the ledgers block of config.json.

The notes file is the source of truth. A per-ledger synced_at timestamp in config is compared against the notes file's mtime to pick the direction:

* notes edited more recently (or config never synced) -> notes -> config
* config as-new-or-newer, or notes carries no managed field -> config -> notes

After a config -> notes write, synced_at is stamped to the file's NEW mtime so the write does not re-trigger a notes -> config on the next run.

Every managed field is optional: only keys present on the winning side are merged; an absent key is left untouched on the other side (never cleared).

Constant Summary collapse

MANAGED =
%w[title default_responsible default_currency reference groups].freeze

Class Method Summary collapse

Class Method Details

.config_to_notes(path, entry, fm_full, spltty, body) ⇒ Object

Write config's managed fields into the notes header. Returns true when the file content actually changed (and stamps synced_at to the new mtime).



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/spltty_cli/notes_sync.rb', line 96

def config_to_notes(path, entry, fm_full, spltty, body)
  cfg_fields = MANAGED.each_with_object({}) do |k, h|
    v = entry[k]
    h[k] = v unless v.nil? || v.to_s.empty?
  end
  return false if cfg_fields.empty? && spltty.empty?

  new_spltty = spltty.dup
  cfg_fields.each { |k, v| new_spltty[k] = v }
  new_fm = fm_full.dup
  if new_spltty.empty?
    new_fm.delete("spltty")
  else
    new_fm["spltty"] = new_spltty
  end

  old_raw = File.read(path)
  new_raw = render(new_fm, body)
  return false if new_raw == old_raw

  File.write(path, new_raw)
  entry["synced_at"] = File.mtime(path).utc.iso8601(9)
  true
end

.globalize(config) ⇒ Object

Cross-ledger promotion: any group definition (name + participants + percentages) that appears identically in >= 2 ledgers is recorded in the global config.groups. Additive only — per-ledger copies and headers are left intact, and globals are never written back into any header. Returns the list of names newly added to config.groups.



63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
# File 'lib/spltty_cli/notes_sync.rb', line 63

def globalize(config)
  seen = Hash.new { |h, k| h[k] = { count: 0, name: nil, hash: nil } }
  config.ledgers.each_value do |entry|
    groups = entry["groups"]
    next unless groups.is_a?(Hash)

    groups.each do |name, hash|
      next unless hash.is_a?(Hash)

      slot = seen[Groups.canonical(name, hash)]
      slot[:count] += 1
      slot[:name] = name
      slot[:hash] = hash
    end
  end

  # Only touch config.groups when there is a shared def — avoids the lazy
  # accessor materializing an empty "groups": {} on every sync.
  candidates = seen.each_value.select { |slot| slot[:count] >= 2 }
  return [] if candidates.empty?

  promoted = []
  candidates.each do |slot|
    next if config.groups[slot[:name]] == slot[:hash]

    config.groups[slot[:name]] = slot[:hash]
    promoted << slot[:name]
  end
  promoted
end

.parse_time(str) ⇒ Object



129
130
131
132
133
134
135
# File 'lib/spltty_cli/notes_sync.rb', line 129

def parse_time(str)
  return nil if str.nil? || str.to_s.empty?

  Time.iso8601(str.to_s)
rescue ArgumentError
  nil
end

.render(frontmatter, body) ⇒ Object



121
122
123
124
125
126
127
# File 'lib/spltty_cli/notes_sync.rb', line 121

def render(frontmatter, body)
  if frontmatter.nil? || frontmatter.empty?
    body
  else
    "#{YAML.dump(frontmatter)}#{Notes::FENCE}\n#{body}"
  end
end

.run(config) ⇒ Object

Sync all ledgers in config. Mutates config in place (caller saves). Returns { to_config: [names], to_notes: [names], globalized: [names] }.



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
52
53
54
55
56
# File 'lib/spltty_cli/notes_sync.rb', line 25

def run(config)
  to_config = []
  to_notes = []

  config.ledgers.each do |name, entry|
    next if entry["missing"]

    path = Notes.path_for(config.accounts_dir, name, entry)
    next unless File.exist?(path)

    parsed = Notes.parse(path)
    fm_full = parsed[:frontmatter]
    spltty = fm_full["spltty"].is_a?(Hash) ? fm_full["spltty"] : {}
    header_fields = MANAGED.each_with_object({}) do |k, h|
      h[k] = spltty[k] if spltty.key?(k) && !spltty[k].nil?
    end

    s = parse_time(entry["synced_at"])
    mtime = parsed[:mtime]
    notes_wins = !header_fields.empty? && (s.nil? || mtime > s)

    if notes_wins
      header_fields.each { |k, v| entry[k] = v }
      entry["synced_at"] = mtime.utc.iso8601(9)
      to_config << name
    elsif config_to_notes(path, entry, fm_full, spltty, parsed[:body])
      to_notes << name
    end
  end

  { to_config: to_config, to_notes: to_notes, globalized: globalize(config) }
end