treet 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/.gitignore +18 -0
- data/Gemfile +6 -0
- data/Guardfile +9 -0
- data/LICENSE +22 -0
- data/README.md +90 -0
- data/Rakefile +8 -0
- data/bin/treet +64 -0
- data/lib/treet/farm.rb +70 -0
- data/lib/treet/hash.rb +219 -0
- data/lib/treet/repo.rb +141 -0
- data/lib/treet/version.rb +3 -0
- data/lib/treet.rb +13 -0
- data/spec/json/bob1.json +24 -0
- data/spec/json/bob2.json +20 -0
- data/spec/json/group.json +15 -0
- data/spec/json/master.json +55 -0
- data/spec/json/one.json +5 -0
- data/spec/json/three.json +17 -0
- data/spec/json/two.json +17 -0
- data/spec/lib/farm_spec.rb +65 -0
- data/spec/lib/hash_spec.rb +132 -0
- data/spec/lib/repo_spec.rb +91 -0
- data/spec/repos/farm1/one/name +1 -0
- data/spec/repos/farm1/two/emails/0 +4 -0
- data/spec/repos/farm1/two/emails/1 +4 -0
- data/spec/repos/farm1/two/name +5 -0
- data/spec/repos/four/emails/0 +4 -0
- data/spec/repos/four/emails/1 +4 -0
- data/spec/repos/four/name +5 -0
- data/spec/repos/one/name +1 -0
- data/spec/repos/three/emails/0 +4 -0
- data/spec/repos/three/emails/1 +4 -0
- data/spec/repos/three/name +5 -0
- data/spec/repos/two/emails/0 +4 -0
- data/spec/repos/two/emails/1 +4 -0
- data/spec/repos/two/name +5 -0
- data/spec/spec_helper.rb +17 -0
- data/treet.gemspec +24 -0
- metadata +195 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
data/Guardfile
ADDED
@@ -0,0 +1,9 @@
|
|
1
|
+
# A sample Guardfile
|
2
|
+
# More info at https://github.com/guard/guard#readme
|
3
|
+
|
4
|
+
guard 'rspec', :version => 2 do
|
5
|
+
watch(%r{^spec/.+_spec\.rb$})
|
6
|
+
watch(%r{^lib/treet/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" }
|
7
|
+
watch(%r{^(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" }
|
8
|
+
watch('spec/spec_helper.rb') { "spec" }
|
9
|
+
end
|
data/LICENSE
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2012 Jason May
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,90 @@
|
|
1
|
+
# Treet
|
2
|
+
|
3
|
+
Comparisons and transformation between trees of files and JSON blobs
|
4
|
+
|
5
|
+
The "JSON blobs" that are supported are not unlimited in structure, but must define:
|
6
|
+
|
7
|
+
* hashes, where are the values are either {hashes where the values are all scalars} or {arrays of hashes where the values are all scalars}
|
8
|
+
* or arrays of hashes as described above.
|
9
|
+
|
10
|
+
## Installation
|
11
|
+
|
12
|
+
Add this line to your application's Gemfile:
|
13
|
+
|
14
|
+
gem 'treet'
|
15
|
+
|
16
|
+
And then execute:
|
17
|
+
|
18
|
+
$ bundle
|
19
|
+
|
20
|
+
Or install it yourself as:
|
21
|
+
|
22
|
+
$ gem install treet
|
23
|
+
|
24
|
+
## Usage - Command Line
|
25
|
+
|
26
|
+
treet expand [path] [jsonfile]
|
27
|
+
treet explode [jsonfile] [rootdir]
|
28
|
+
treet import [rootdir] [xrefkey]
|
29
|
+
|
30
|
+
## Usage - API
|
31
|
+
|
32
|
+
require 'treet'
|
33
|
+
|
34
|
+
hash = Treet::Hash.new(jsonfile)
|
35
|
+
repo = Treet::Repo.new(directory)
|
36
|
+
farm = Treet::Farm.new(rootdir, :xref => 'label')
|
37
|
+
|
38
|
+
hash = repo.to_hash
|
39
|
+
repo = hash.to_repo(root)
|
40
|
+
hash = farm.export
|
41
|
+
|
42
|
+
Treet.init(jsonfile, root) # when jsonfile contains an array which is exploded to multiple files
|
43
|
+
|
44
|
+
## Concepts
|
45
|
+
|
46
|
+
A *repo* is a directory that contains other files & directories. Any text files in this tree structure must contain JSON-formatted data.
|
47
|
+
|
48
|
+
A *farm* is a directory containing one or more repos. When a farm is exported to JSON, each record is augmented with an xref value that contains the root filename of that repo.
|
49
|
+
|
50
|
+
For example:
|
51
|
+
|
52
|
+
farm = Treet::Farm.new(rootdir, :xref => 'keycode')
|
53
|
+
puts farm.export
|
54
|
+
|
55
|
+
should produce something like:
|
56
|
+
|
57
|
+
{
|
58
|
+
"subdir1": {
|
59
|
+
"field": "value"
|
60
|
+
},
|
61
|
+
"subdir2": {
|
62
|
+
"field": "value",
|
63
|
+
"field2": "value2"
|
64
|
+
},
|
65
|
+
"xref": {
|
66
|
+
"keycode": "repo-dir-name"
|
67
|
+
}
|
68
|
+
}
|
69
|
+
|
70
|
+
## Structures
|
71
|
+
|
72
|
+
All the nodes at the top level are mapped to subdirectories.
|
73
|
+
|
74
|
+
At the second level, arrays elements are converted to individual subdirectories. Subdirectories are
|
75
|
+
named with unique digest values computed from the data contents. This means that duplicate entries
|
76
|
+
are not allowed.
|
77
|
+
|
78
|
+
## Contributing
|
79
|
+
|
80
|
+
1. Fork it
|
81
|
+
2. Create your feature branch (`git checkout -b my-new-feature`)
|
82
|
+
3. Commit your changes (`git commit -am 'Added some feature'`)
|
83
|
+
4. Push to the branch (`git push origin my-new-feature`)
|
84
|
+
5. Create new Pull Request
|
85
|
+
|
86
|
+
## TODO
|
87
|
+
|
88
|
+
* Enforce limitation on structure depth (top-level elements can contain flat hashes or arrays, nothing else)
|
89
|
+
* refac: move diff stuff from hash.rb to Treet::Diff class, to encapsulate the structure of a diff (array of arrays); create methods for hunting for special stuff in a diff
|
90
|
+
* Check all exceptions for explicit classes
|
data/Rakefile
ADDED
data/bin/treet
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require 'treet'
|
4
|
+
require "thor"
|
5
|
+
|
6
|
+
class TreetCommand < Thor
|
7
|
+
desc "export ROOTDIR", "convert a collection of repositories to a single JSON blob"
|
8
|
+
method_option :xref, :desc => "optional fieldname to be added under `xref` with basename of repository"
|
9
|
+
def export(path)
|
10
|
+
# if !File.directory?(path)
|
11
|
+
# raise "treet export: could not find #{path}"
|
12
|
+
# end
|
13
|
+
|
14
|
+
farm = Treet::Farm.new(:root => path, :xref => options[:xref])
|
15
|
+
jj farm.export
|
16
|
+
end
|
17
|
+
|
18
|
+
|
19
|
+
desc "create JSONFILE", "build a single repository from a JSON file"
|
20
|
+
method_option :root, :required => true
|
21
|
+
def create(jsonfile)
|
22
|
+
hash = Treet::Hash.new(jsonfile)
|
23
|
+
hash.to_repo(options[:root])
|
24
|
+
$stderr.puts "Wrote repository to #{options[:root]}"
|
25
|
+
end
|
26
|
+
|
27
|
+
|
28
|
+
desc "show DIRECTORY", "convert a single repository to a JSON blob"
|
29
|
+
def show(path)
|
30
|
+
repo = Treet::Repo.new(path)
|
31
|
+
jj repo.to_hash
|
32
|
+
end
|
33
|
+
|
34
|
+
|
35
|
+
desc "explode JSONFILE", "Build a collection of repositories from a JSON file"
|
36
|
+
method_option :root, :required => true, :desc => "where to create repositories (will be created if does not exist)"
|
37
|
+
def explode(jsonfile)
|
38
|
+
if !File.directory?(options[:root])
|
39
|
+
Dir.mkdir(options[:root])
|
40
|
+
end
|
41
|
+
|
42
|
+
farm = Treet::Farm.plant(:json => jsonfile, :root => options[:root])
|
43
|
+
filecount = Dir.glob("#{farm.root}/*").count
|
44
|
+
$stderr.puts "Wrote #{filecount} files to #{farm.root}"
|
45
|
+
end
|
46
|
+
|
47
|
+
|
48
|
+
desc "patch JSONFILE", "Apply patches from file to specified repository tree"
|
49
|
+
method_option :root, :required => true, :desc => "where to find repositories to patch"
|
50
|
+
method_option :xref, :required => true, :desc => "fieldname under `xref` to use for repository identification"
|
51
|
+
def patch(patchfile)
|
52
|
+
patches = JSON.load(File.open(patchfile))
|
53
|
+
farm = Treet::Farm.new(:root => options[:root], :xref => options[:xref])
|
54
|
+
results = farm.patch(patches)
|
55
|
+
$stderr.puts "Patched #{results.count} records."
|
56
|
+
end
|
57
|
+
|
58
|
+
desc "version", "show Treet version"
|
59
|
+
def version
|
60
|
+
puts "Treet #{Treet::VERSION}"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
TreetCommand.start
|
data/lib/treet/farm.rb
ADDED
@@ -0,0 +1,70 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
require "uuidtools"
|
4
|
+
|
5
|
+
class Treet::Farm
|
6
|
+
attr_reader :root, :xrefkey
|
7
|
+
|
8
|
+
def initialize(opts)
|
9
|
+
raise Errno::ENOENT unless File.directory?(opts[:root])
|
10
|
+
|
11
|
+
@root = opts[:root]
|
12
|
+
@xrefkey = opts[:xref]
|
13
|
+
end
|
14
|
+
|
15
|
+
def repos
|
16
|
+
@repos_cache ||= Dir.glob("#{root}/*").each_with_object({}) do |subdir,h|
|
17
|
+
# in a Farm we are looking for repositories under the root
|
18
|
+
if File.directory?(subdir)
|
19
|
+
xref = File.basename(subdir)
|
20
|
+
h[xref] = Treet::Repo.new(subdir, :xrefkey => xrefkey, :xref => xref)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def reset
|
26
|
+
@repos_cache = nil
|
27
|
+
end
|
28
|
+
|
29
|
+
# export as an array, not as a hash
|
30
|
+
# the xref for each repo will be included under `xref.{xrefkey}`
|
31
|
+
def export
|
32
|
+
repos.map {|xref,repo| repo.to_hash}
|
33
|
+
end
|
34
|
+
|
35
|
+
# "plant" a new farm: given an array of hashes (in JSON), create a directory
|
36
|
+
# of Treet repositories, one per hash. Generate directory names for each repo.
|
37
|
+
def self.plant(opts)
|
38
|
+
jsonfile = opts[:json]
|
39
|
+
rootdir = opts[:root]
|
40
|
+
|
41
|
+
array_of_hashes = JSON.load(File.open(jsonfile))
|
42
|
+
Dir.chdir(rootdir) do
|
43
|
+
array_of_hashes.each do |h|
|
44
|
+
uuid = UUIDTools::UUID.random_create.to_s
|
45
|
+
thash = Treet::Hash.new(h)
|
46
|
+
thash.to_repo(uuid)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
Treet::Farm.new(:root => rootdir, :xref => opts[:xref])
|
51
|
+
end
|
52
|
+
|
53
|
+
# apply patches to a farm of repos
|
54
|
+
def patch(patches)
|
55
|
+
patches.map do |k,diffs|
|
56
|
+
repos[k].patch(diffs)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def [](xref)
|
61
|
+
repos[xref]
|
62
|
+
end
|
63
|
+
|
64
|
+
# add a new repo, with data from an input hash
|
65
|
+
def add(hash)
|
66
|
+
uuid = UUIDTools::UUID.random_create.to_s
|
67
|
+
thash = Treet::Hash.new(hash)
|
68
|
+
thash.to_repo("#{root}/#{uuid}")
|
69
|
+
end
|
70
|
+
end
|
data/lib/treet/hash.rb
ADDED
@@ -0,0 +1,219 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "digest/sha1"
|
5
|
+
|
6
|
+
class Treet::Hash
|
7
|
+
attr_reader :data
|
8
|
+
|
9
|
+
# when loading an Array (at the top level), members are always sorted
|
10
|
+
# so that array comparisons will be order-independent
|
11
|
+
def initialize(source)
|
12
|
+
d = case source
|
13
|
+
when Hash
|
14
|
+
source
|
15
|
+
when String
|
16
|
+
# treat as filename
|
17
|
+
JSON.load(File.read(source))
|
18
|
+
else
|
19
|
+
raise "Invalid source data type #{source.class} for Treet::Hash"
|
20
|
+
end
|
21
|
+
|
22
|
+
@data = normalize(d)
|
23
|
+
end
|
24
|
+
|
25
|
+
def to_repo(root)
|
26
|
+
construct(data, root)
|
27
|
+
Treet::Repo.new(root)
|
28
|
+
end
|
29
|
+
|
30
|
+
def to_hash
|
31
|
+
data.to_hash
|
32
|
+
end
|
33
|
+
|
34
|
+
def compare(target)
|
35
|
+
# HashDiff.diff(data, target.to_hash)
|
36
|
+
Treet::Hash.diff(data.to_hash, target.to_hash)
|
37
|
+
end
|
38
|
+
|
39
|
+
# apply diffs (created via the `#compare` function) to create a new object
|
40
|
+
def patch(diffs)
|
41
|
+
newhash = Treet::Hash.patch(self.to_hash, diffs)
|
42
|
+
Treet::Hash.new(newhash)
|
43
|
+
end
|
44
|
+
|
45
|
+
def self.digestify(hash)
|
46
|
+
Digest::SHA1.hexdigest(hash.to_a.sort.flatten.join)
|
47
|
+
end
|
48
|
+
|
49
|
+
private
|
50
|
+
|
51
|
+
def construct(data, filename)
|
52
|
+
unless filename == '.'
|
53
|
+
# create the root of the repository tree
|
54
|
+
Dir.mkdir(filename) rescue nil
|
55
|
+
end
|
56
|
+
|
57
|
+
Dir.chdir(filename) do
|
58
|
+
data.each do |k,v|
|
59
|
+
case v
|
60
|
+
when Hash
|
61
|
+
File.open(k, "w") {|f| f << JSON.pretty_generate(v)}
|
62
|
+
|
63
|
+
when Array
|
64
|
+
Dir.mkdir(k.to_s)
|
65
|
+
v.each do |v2|
|
66
|
+
case v2
|
67
|
+
when String
|
68
|
+
# create empty file with this name
|
69
|
+
File.open("#{k}/#{v2}", "w")
|
70
|
+
|
71
|
+
else
|
72
|
+
# store object contents as JSON into a generated filename
|
73
|
+
subfile = "#{k}/#{Treet::Hash.digestify(v2)}"
|
74
|
+
File.open(subfile, "w") {|f| f << JSON.pretty_generate(v2)}
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
when String
|
79
|
+
File.open(k.to_s, "w") {|f| f << v}
|
80
|
+
|
81
|
+
else
|
82
|
+
raise "Unsupported object type #{v.class} for '#{k}'"
|
83
|
+
end
|
84
|
+
end
|
85
|
+
end
|
86
|
+
end
|
87
|
+
|
88
|
+
def normalize(hash)
|
89
|
+
hash.each_with_object({}) do |(k,v),h|
|
90
|
+
case v
|
91
|
+
when Array
|
92
|
+
if v.map(&:class).uniq == Hash
|
93
|
+
# all elements are Hashes
|
94
|
+
h[k] = v.sort do |a,b|
|
95
|
+
a.to_a.sort_by(&:first).flatten <=> b.to_a.sort_by(&:first).flatten
|
96
|
+
end
|
97
|
+
else
|
98
|
+
h[k] =v
|
99
|
+
end
|
100
|
+
|
101
|
+
else
|
102
|
+
h[k] = v
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# Diffs need to be idempotent when applied via patch.
|
108
|
+
# Therefore we can't specify individual index positions for an array, because items can move.
|
109
|
+
# Instead, we must include the entire contents of the sub-hash, and during the patch process
|
110
|
+
# compare that against each element in the array.
|
111
|
+
# This means that an array cannot have exact duplicate entries.
|
112
|
+
def self.diff(hash1, hash2)
|
113
|
+
diffs = []
|
114
|
+
|
115
|
+
keys = hash1.keys | hash2.keys
|
116
|
+
keys.each do |k|
|
117
|
+
# if a value is missing from hash1, create a dummy of the same type that appears in hash2
|
118
|
+
v1 = hash1[k] || hash2[k].class.new
|
119
|
+
v2 = hash2[k] || hash1[k].class.new
|
120
|
+
|
121
|
+
case v1
|
122
|
+
when Hash
|
123
|
+
(v2.keys - v1.keys).each do |k2|
|
124
|
+
# new sub-elements: (-, key, after-value)
|
125
|
+
diffs << ['+', "#{k}.#{k2}", v2[k2]]
|
126
|
+
end
|
127
|
+
(v1.keys - v2.keys).each do |k2|
|
128
|
+
# deleted sub-elements: (-, key, before-value)
|
129
|
+
diffs << ['-', "#{k}.#{k2}", v1[k2]]
|
130
|
+
end
|
131
|
+
(v1.keys & v2.keys).each do |k2|
|
132
|
+
if v1[k2] != v2[k2]
|
133
|
+
# altered sub-elements: (~, key, after-value, before-value-for-reference)
|
134
|
+
diffs << ['~', "#{k}.#{k2}", v2[k2], v1[k2]]
|
135
|
+
end
|
136
|
+
end
|
137
|
+
|
138
|
+
when Array
|
139
|
+
v1.each do |e1|
|
140
|
+
if !v2.include?(e1)
|
141
|
+
# element has been removed
|
142
|
+
diffs << ['-', "#{k}[]", e1]
|
143
|
+
end
|
144
|
+
end
|
145
|
+
|
146
|
+
(v2 - v1).each do |e2|
|
147
|
+
# new array element
|
148
|
+
diffs << ['+', "#{k}[]", e2]
|
149
|
+
end
|
150
|
+
|
151
|
+
else # scalar values
|
152
|
+
if v1 != v2
|
153
|
+
if v1.nil?
|
154
|
+
diffs << ['+', k, v2]
|
155
|
+
elsif v2.nil?
|
156
|
+
diffs << ['-', k, v1]
|
157
|
+
else
|
158
|
+
diffs << ['~', k, v2, v1]
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
165
|
+
diffs
|
166
|
+
end
|
167
|
+
|
168
|
+
def self.patch(hash, diffs)
|
169
|
+
result = hash.dup
|
170
|
+
|
171
|
+
diffs.each do |diff|
|
172
|
+
flag, key, v1, v2 = diff
|
173
|
+
if key =~ /\[/
|
174
|
+
keyname, is_array = key.match(/^(.*)(\[\])$/).captures
|
175
|
+
elsif key =~ /\./
|
176
|
+
keyname, subkey = key.match(/^(.*)\.(.*)$/).captures
|
177
|
+
else
|
178
|
+
keyname = key
|
179
|
+
end
|
180
|
+
|
181
|
+
case flag
|
182
|
+
when '~'
|
183
|
+
# change a value in place
|
184
|
+
|
185
|
+
if subkey
|
186
|
+
result[keyname][subkey] = v1
|
187
|
+
else
|
188
|
+
result[keyname] = v1
|
189
|
+
end
|
190
|
+
|
191
|
+
when '+'
|
192
|
+
# add something
|
193
|
+
if subkey
|
194
|
+
result[keyname] ||= {}
|
195
|
+
result[keyname][subkey] = v1
|
196
|
+
elsif is_array
|
197
|
+
result[keyname] ||= []
|
198
|
+
result[keyname] << v1
|
199
|
+
else
|
200
|
+
result[keyname] = v1
|
201
|
+
end
|
202
|
+
|
203
|
+
when '-'
|
204
|
+
# remove something
|
205
|
+
if subkey
|
206
|
+
result[keyname].delete(subkey)
|
207
|
+
elsif is_array
|
208
|
+
result[keyname].delete_if {|v| v == v1}
|
209
|
+
else
|
210
|
+
result.delete(keyname)
|
211
|
+
end
|
212
|
+
end
|
213
|
+
end
|
214
|
+
|
215
|
+
result.delete_if {|k,v| v.nil? || v.empty?}
|
216
|
+
|
217
|
+
result
|
218
|
+
end
|
219
|
+
end
|
data/lib/treet/repo.rb
ADDED
@@ -0,0 +1,141 @@
|
|
1
|
+
# encoding: UTF-8
|
2
|
+
|
3
|
+
# require 'hashdiff'
|
4
|
+
|
5
|
+
class Treet::Repo
|
6
|
+
attr_reader :root, :hash, :opts
|
7
|
+
|
8
|
+
def initialize(path, opts = {})
|
9
|
+
# TODO: validate that path exists and is a directory (symlinks should work)
|
10
|
+
|
11
|
+
@root = path
|
12
|
+
raise "Missing or invalid source path #{path}" unless File.directory?(path)
|
13
|
+
@opts = opts
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_hash
|
17
|
+
@hash ||= expand(root)
|
18
|
+
end
|
19
|
+
|
20
|
+
def compare(target)
|
21
|
+
Treet::Hash.diff(to_hash, target.to_hash)
|
22
|
+
# HashDiff.diff(to_hash, hash)
|
23
|
+
end
|
24
|
+
|
25
|
+
# patch keys can look like
|
26
|
+
# name.first
|
27
|
+
# emails[]
|
28
|
+
# (address[1] syntax has been eliminated, we recognize array elements by matching the entire content)
|
29
|
+
def self.filefor(keyname)
|
30
|
+
if keyname =~ /\[/
|
31
|
+
keyname, is_array, index = keyname.match(/^(.*)(\[\])$/).captures
|
32
|
+
[keyname, '', nil]
|
33
|
+
elsif keyname =~ /\./
|
34
|
+
# subelement
|
35
|
+
filename,field = keyname.split('.')
|
36
|
+
['.', filename, field]
|
37
|
+
else
|
38
|
+
[nil, keyname]
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Patching a repo is not the same as patching a hash. Make the changes
|
43
|
+
# directly to the data files.
|
44
|
+
def patch(diffs)
|
45
|
+
@hash = nil # invalidate any cached image
|
46
|
+
|
47
|
+
Dir.chdir(root) do
|
48
|
+
diffs.each do |diff|
|
49
|
+
flag, key, v1, v2 = diff
|
50
|
+
if key =~ /\[/
|
51
|
+
keyname, is_array = key.match(/^(.*)(\[\])$/).captures
|
52
|
+
elsif key =~ /\./
|
53
|
+
keyname, subkey = key.match(/^(.*)\.(.*)$/).captures
|
54
|
+
else
|
55
|
+
keyname = key
|
56
|
+
end
|
57
|
+
|
58
|
+
dirname, filename, fieldname = Treet::Repo.filefor(key)
|
59
|
+
filepath = "#{dirname}/#{filename}"
|
60
|
+
case flag
|
61
|
+
when '~'
|
62
|
+
# change a value in place
|
63
|
+
# load the current data & overwrite with the new value
|
64
|
+
# idempotent: this will overwrite the file with the same contents
|
65
|
+
data = File.exists?(filepath) ? JSON.load(File.open(filepath)) : {}
|
66
|
+
data[fieldname] = v1
|
67
|
+
File.open(filepath, "w") {|f| f << JSON.pretty_generate(data)}
|
68
|
+
|
69
|
+
when '+'
|
70
|
+
# add something
|
71
|
+
if fieldname
|
72
|
+
# writing a value into a hash
|
73
|
+
# idempotent: this will overwrite the file with the same contents
|
74
|
+
data = File.exists?(filepath) ? JSON.load(File.open(filepath)) : {}
|
75
|
+
data[fieldname] = v1
|
76
|
+
Dir.mkdir(dirname) unless Dir.exists?(dirname)
|
77
|
+
File.open(filepath, "w") {|f| f << JSON.pretty_generate(data)}
|
78
|
+
else
|
79
|
+
# writing an entire hash into an array entry
|
80
|
+
# idempotent: this will overwrite the file with the same contents
|
81
|
+
subfile = "#{dirname}/#{Treet::Hash.digestify(v1)}"
|
82
|
+
Dir.mkdir(dirname) unless Dir.exists?(dirname)
|
83
|
+
File.open(subfile, "w") {|f| f << JSON.pretty_generate(v1)}
|
84
|
+
end
|
85
|
+
|
86
|
+
when '-'
|
87
|
+
# remove something
|
88
|
+
if fieldname
|
89
|
+
data = JSON.load(File.open(filepath))
|
90
|
+
data.delete(fieldname)
|
91
|
+
if data.empty?
|
92
|
+
File.delete(filename)
|
93
|
+
else
|
94
|
+
File.open(filepath, "w") {|f| f << JSON.pretty_generate(data)}
|
95
|
+
end
|
96
|
+
else
|
97
|
+
# this is an array, we look for a match on the entire contents via digest
|
98
|
+
subfile = "#{dirname}/#{Treet::Hash.digestify(v1)}"
|
99
|
+
File.delete(subfile) if File.exists?(subfile) # need the existence check for idempotence
|
100
|
+
# TODO: if dirname is now empty, should it be removed? is that worthwhile?
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
104
|
+
end
|
105
|
+
|
106
|
+
to_hash # ?? return the patched data? or no return value? true/false for success?
|
107
|
+
end
|
108
|
+
|
109
|
+
private
|
110
|
+
|
111
|
+
def expand_json(path)
|
112
|
+
if File.file?(path)
|
113
|
+
if File.zero?(path)
|
114
|
+
# empty files are just keys or string elements in an array
|
115
|
+
File.basename(path)
|
116
|
+
else
|
117
|
+
# if file contents is JSON, then parse it
|
118
|
+
# otherwise treat it as a raw string value
|
119
|
+
s = File.read(path)
|
120
|
+
JSON.load(s) rescue s
|
121
|
+
end
|
122
|
+
else
|
123
|
+
# should be a subdirectory containing files named with numbers, each containing JSON
|
124
|
+
files = Dir.entries(path).select {|f| f !~ /^\./}
|
125
|
+
files.sort_by(&:to_i).each_with_object([]) do |f, ary|
|
126
|
+
ary << expand_json("#{path}/#{f}")
|
127
|
+
end
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
131
|
+
def expand(path)
|
132
|
+
files = Dir.entries(path).select {|f| f !~ /^\./}
|
133
|
+
hash = files.each_with_object({}) {|f,h| h[f] = expand_json("#{path}/#{f}")}
|
134
|
+
|
135
|
+
if opts[:xrefkey]
|
136
|
+
hash['xref'] ||= {}
|
137
|
+
hash['xref'][opts[:xrefkey]] = opts[:xref]
|
138
|
+
end
|
139
|
+
hash
|
140
|
+
end
|
141
|
+
end
|
data/lib/treet.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
unless Kernel.respond_to?(:require_relative)
|
2
|
+
module Kernel
|
3
|
+
def require_relative(path)
|
4
|
+
require File.join(File.dirname(caller[0]), path.to_str)
|
5
|
+
end
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
require_relative "treet/version"
|
10
|
+
|
11
|
+
require_relative "treet/repo"
|
12
|
+
require_relative "treet/hash"
|
13
|
+
require_relative "treet/farm"
|
data/spec/json/bob1.json
ADDED
@@ -0,0 +1,24 @@
|
|
1
|
+
{
|
2
|
+
"name": {
|
3
|
+
"first": "Bob",
|
4
|
+
"last": "Smith",
|
5
|
+
"full": "Bob Smith"
|
6
|
+
},
|
7
|
+
"emails": [
|
8
|
+
{
|
9
|
+
"label": "home",
|
10
|
+
"email": "bob@home.com"
|
11
|
+
},
|
12
|
+
{
|
13
|
+
"label": "work",
|
14
|
+
"email": "bob@work.com"
|
15
|
+
},
|
16
|
+
{
|
17
|
+
"label": "other",
|
18
|
+
"email": "bob@vacation.com"
|
19
|
+
}
|
20
|
+
],
|
21
|
+
"other": {
|
22
|
+
"notes": "some commentary"
|
23
|
+
}
|
24
|
+
}
|