ro 4.4.0 → 5.0.0
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.
- checksums.yaml +4 -4
- data/Gemfile.lock +42 -16
- data/MIGRATION.md +320 -0
- data/README.md +30 -18
- data/a.yml +60 -0
- data/bin/ro +10 -0
- data/lib/ro/_lib.rb +1 -1
- data/lib/ro/asset.rb +46 -5
- data/lib/ro/collection.rb +51 -13
- data/lib/ro/migrator.rb +285 -0
- data/lib/ro/node.rb +53 -13
- data/lib/ro/root.rb +75 -1
- data/lib/ro/script/migrate.rb +204 -0
- data/lib/ro/script/server.rb +1 -1
- data/lib/ro.rb +1 -0
- data/ro.gemspec +207 -16
- data/specs/001-simplify-asset-structure/IMPLEMENTATION_SUMMARY.md +212 -0
- data/specs/001-simplify-asset-structure/checklists/requirements.md +36 -0
- data/specs/001-simplify-asset-structure/contracts/collection_api.md +407 -0
- data/specs/001-simplify-asset-structure/contracts/migrator_api.md +461 -0
- data/specs/001-simplify-asset-structure/contracts/node_api.md +294 -0
- data/specs/001-simplify-asset-structure/data-model.md +381 -0
- data/specs/001-simplify-asset-structure/plan.md +90 -0
- data/specs/001-simplify-asset-structure/quickstart.md +575 -0
- data/specs/001-simplify-asset-structure/research.md +333 -0
- data/specs/001-simplify-asset-structure/spec.md +127 -0
- data/specs/001-simplify-asset-structure/tasks.md +349 -0
- data/test/fixtures/new_structure/mixed/test-json.json +5 -0
- data/test/fixtures/new_structure/mixed/test-yaml.yml +3 -0
- data/test/fixtures/new_structure/posts/metadata-only.yml +7 -0
- data/test/fixtures/new_structure/posts/nested-test/assets/subdirectory/image.png +2 -0
- data/test/fixtures/new_structure/posts/nested-test.yml +7 -0
- data/test/fixtures/new_structure/posts/sample-post/assets/body.md +5 -0
- data/test/fixtures/new_structure/posts/sample-post/assets/image.jpg +2 -0
- data/test/fixtures/new_structure/posts/sample-post.yml +7 -0
- data/test/fixtures/old_structure/posts/assets-only/assets/test.txt +1 -0
- data/test/fixtures/old_structure/posts/sample-post/assets/body.md +5 -0
- data/test/fixtures/old_structure/posts/sample-post/assets/image.jpg +2 -0
- data/test/fixtures/old_structure/posts/sample-post/attributes.yml +2 -0
- data/test/integration/ro_integration_test.rb +165 -0
- data/test/test_helper.rb +149 -0
- data/test/tmp/migration_test_1760746513.backup.20251018001513/migration_test_1760746513/posts/sample-post/assets/image.jpg +2 -0
- data/test/tmp/migration_test_1760746513.backup.20251018001513/migration_test_1760746513/posts/sample-post/attributes.yml +7 -0
- data/test/tmp/migration_test_1760746513.backup.20251018001513/migration_test_1760746513/posts/sample-post/body.md +5 -0
- data/test/tmp/migration_test_1760746513.backup.20251018001513/posts/sample-post/assets/image.jpg +2 -0
- data/test/tmp/migration_test_1760746513.backup.20251018001513/posts/sample-post/attributes.yml +7 -0
- data/test/tmp/migration_test_1760746513.backup.20251018001513/posts/sample-post/body.md +5 -0
- data/test/tmp/migration_test_1760746556.backup.20251018001556/migration_test_1760746556/posts/sample-post/assets/image.jpg +2 -0
- data/test/tmp/migration_test_1760746556.backup.20251018001556/migration_test_1760746556/posts/sample-post/attributes.yml +7 -0
- data/test/tmp/migration_test_1760746556.backup.20251018001556/migration_test_1760746556/posts/sample-post/body.md +5 -0
- data/test/tmp/migration_test_1760746556.backup.20251018001556/posts/sample-post/assets/image.jpg +2 -0
- data/test/tmp/migration_test_1760746556.backup.20251018001556/posts/sample-post/attributes.yml +7 -0
- data/test/tmp/migration_test_1760746556.backup.20251018001556/posts/sample-post/body.md +5 -0
- data/test/tmp/migration_test_1760755248.backup.20251018024048/migration_test_1760755248/posts/sample-post/assets/image.jpg +2 -0
- data/test/tmp/migration_test_1760755248.backup.20251018024048/migration_test_1760755248/posts/sample-post/attributes.yml +7 -0
- data/test/tmp/migration_test_1760755248.backup.20251018024048/migration_test_1760755248/posts/sample-post/body.md +5 -0
- data/test/tmp/migration_test_1760755248.backup.20251018024048/posts/sample-post/assets/image.jpg +2 -0
- data/test/tmp/migration_test_1760755248.backup.20251018024048/posts/sample-post/attributes.yml +7 -0
- data/test/tmp/migration_test_1760755248.backup.20251018024048/posts/sample-post/body.md +5 -0
- data/test/tmp/migration_test_1760758803.backup.20251018034003/migration_test_1760758803/posts/sample-post/body.md +5 -0
- data/test/tmp/migration_test_1760758803.backup.20251018034003/migration_test_1760758803/posts/sample-post/image.jpg +2 -0
- data/test/tmp/migration_test_1760758803.backup.20251018034003/migration_test_1760758803/posts/sample-post.yml +7 -0
- data/test/tmp/migration_test_1760758803.backup.20251018034003/posts/sample-post/body.md +5 -0
- data/test/tmp/migration_test_1760758803.backup.20251018034003/posts/sample-post/image.jpg +2 -0
- data/test/tmp/migration_test_1760758803.backup.20251018034003/posts/sample-post.yml +7 -0
- data/test/tmp/migration_test_1760758869.backup.20251018034109/migration_test_1760758869/posts/sample-post/assets/body.md +5 -0
- data/test/tmp/migration_test_1760758869.backup.20251018034109/migration_test_1760758869/posts/sample-post/assets/image.jpg +2 -0
- data/test/tmp/migration_test_1760758869.backup.20251018034109/migration_test_1760758869/posts/sample-post/attributes.yml +2 -0
- data/test/tmp/migration_test_1760758869.backup.20251018034109/posts/sample-post/assets/body.md +5 -0
- data/test/tmp/migration_test_1760758869.backup.20251018034109/posts/sample-post/assets/image.jpg +2 -0
- data/test/tmp/migration_test_1760758869.backup.20251018034109/posts/sample-post/attributes.yml +2 -0
- data/test/tmp/migration_test_1760758920.backup.20251018034200/migration_test_1760758920/posts/sample-post/assets/body.md +5 -0
- data/test/tmp/migration_test_1760758920.backup.20251018034200/migration_test_1760758920/posts/sample-post/assets/image.jpg +2 -0
- data/test/tmp/migration_test_1760758920.backup.20251018034200/migration_test_1760758920/posts/sample-post/attributes.yml +2 -0
- data/test/tmp/migration_test_1760758920.backup.20251018034200/posts/sample-post/assets/body.md +5 -0
- data/test/tmp/migration_test_1760758920.backup.20251018034200/posts/sample-post/assets/image.jpg +2 -0
- data/test/tmp/migration_test_1760758920.backup.20251018034200/posts/sample-post/attributes.yml +2 -0
- data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/assets-only/assets/test.txt +1 -0
- data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/sample-post/assets/body.md +5 -0
- data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/sample-post/assets/image.jpg +2 -0
- data/test/tmp/migration_test_1760824728.backup.20251018215848/migration_test_1760824728/posts/sample-post/attributes.yml +2 -0
- data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/assets-only/assets/test.txt +1 -0
- data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/sample-post/assets/body.md +5 -0
- data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/sample-post/assets/image.jpg +2 -0
- data/test/tmp/migration_test_1760824728.backup.20251018215848/posts/sample-post/attributes.yml +2 -0
- data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/assets-only/assets/test.txt +1 -0
- data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/sample-post/assets/body.md +5 -0
- data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/sample-post/assets/image.jpg +2 -0
- data/test/tmp/migration_test_1760844153.backup.20251019032233/migration_test_1760844153/posts/sample-post/attributes.yml +2 -0
- data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/assets-only/assets/test.txt +1 -0
- data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/sample-post/assets/body.md +5 -0
- data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/sample-post/assets/image.jpg +2 -0
- data/test/tmp/migration_test_1760844153.backup.20251019032233/posts/sample-post/attributes.yml +2 -0
- data/test/tmp/new_structure_test_1760746452/mixed/test-json.json +5 -0
- data/test/tmp/new_structure_test_1760746452/mixed/test-yaml.yml +3 -0
- data/test/tmp/new_structure_test_1760746452/posts/metadata-only.yml +7 -0
- data/test/tmp/new_structure_test_1760746452/posts/nested-test/subdirectory/image.png +2 -0
- data/test/tmp/new_structure_test_1760746452/posts/nested-test.yml +7 -0
- data/test/tmp/new_structure_test_1760746452/posts/sample-post/body.md +5 -0
- data/test/tmp/new_structure_test_1760746452/posts/sample-post/image.jpg +2 -0
- data/test/tmp/new_structure_test_1760746452/posts/sample-post.yml +7 -0
- data/test/unit/asset_test.rb +90 -0
- data/test/unit/collection_test.rb +127 -0
- data/test/unit/migrator_test.rb +209 -0
- data/test/unit/node_test.rb +138 -0
- metadata +111 -18
- /data/public/ro/nerd/{fastest-possible-embeddings/attributes.yml → fastest-possible-embeddings.yml} +0 -0
- /data/public/ro/nerd/{ima/attributes.yml → ima.yml} +0 -0
- /data/public/ro/nerd/{index/attributes.yml → index.yml} +0 -0
- /data/public/ro/pages/{contact/attributes.yml → contact.yml} +0 -0
- /data/public/ro/pages/{cv/attributes.yml → cv.yml} +0 -0
- /data/public/ro/pages/{disco/attributes.yml → disco.yml} +0 -0
- /data/public/ro/pages/{index/attributes.yml → index.yml} +0 -0
- /data/public/ro/pages/{jess/attributes.yml → jess.yml} +0 -0
- /data/public/ro/pages/{now/attributes.yml → now.yml} +0 -0
- /data/public/ro/posts/{almost-died-in-an-ice-cave/attributes.yml → almost-died-in-an-ice-cave.yml} +0 -0
- /data/public/ro/posts/{facebook-and-global-extremism/attributes.yml → facebook-and-global-extremism.yml} +0 -0
- /data/public/ro/posts/{lemmings-considered-harmful/attributes.yml → lemmings-considered-harmful.yml} +0 -0
- /data/public/ro/posts/{lost-in-the-desert/attributes.yml → lost-in-the-desert.yml} +0 -0
- /data/public/ro/posts/{mission/attributes.yml → mission.yml} +0 -0
- /data/public/ro/posts/{return-your-laptop/attributes.yml → return-your-laptop.yml} +0 -0
data/lib/ro/collection.rb
CHANGED
|
@@ -34,6 +34,20 @@ module Ro
|
|
|
34
34
|
Node.new(path)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# T021: Scan for metadata files in new structure format
|
|
38
|
+
def metadata_files
|
|
39
|
+
extensions = %w[yml yaml json toml]
|
|
40
|
+
files = []
|
|
41
|
+
|
|
42
|
+
extensions.each do |ext|
|
|
43
|
+
@path.glob("*.#{ext}").each do |file|
|
|
44
|
+
files << file if file.file?
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
files.sort
|
|
49
|
+
end
|
|
50
|
+
|
|
37
51
|
def subdirectories(...)
|
|
38
52
|
@path.subdirectories(...)
|
|
39
53
|
end
|
|
@@ -42,28 +56,33 @@ module Ro
|
|
|
42
56
|
@path.subdirectory_for(name)
|
|
43
57
|
end
|
|
44
58
|
|
|
59
|
+
# T020: Modified to discover nodes from metadata files (new structure)
|
|
45
60
|
def each(offset:nil, limit:nil, &block)
|
|
46
|
-
|
|
61
|
+
# Return enumerator if no block given and no offset/limit
|
|
62
|
+
return to_enum(:each, offset: offset, limit: limit) unless block_given?
|
|
63
|
+
|
|
64
|
+
# Use metadata files for new structure instead of subdirectories
|
|
65
|
+
files = metadata_files
|
|
47
66
|
|
|
48
67
|
if offset
|
|
49
68
|
i = -1
|
|
50
69
|
n = 0
|
|
51
|
-
|
|
70
|
+
files.each do |metadata_file|
|
|
52
71
|
i += 1
|
|
53
72
|
next if i < offset
|
|
54
|
-
node =
|
|
55
|
-
block
|
|
73
|
+
node = Node.new(self, metadata_file)
|
|
74
|
+
block.call(node)
|
|
56
75
|
n += 1
|
|
57
76
|
break if limit && n >= limit
|
|
58
77
|
end
|
|
59
78
|
else
|
|
60
|
-
|
|
61
|
-
node =
|
|
62
|
-
block
|
|
79
|
+
files.each do |metadata_file|
|
|
80
|
+
node = Node.new(self, metadata_file)
|
|
81
|
+
block.call(node)
|
|
63
82
|
end
|
|
64
83
|
end
|
|
65
84
|
|
|
66
|
-
|
|
85
|
+
self
|
|
67
86
|
end
|
|
68
87
|
|
|
69
88
|
class Page < ::Array
|
|
@@ -148,8 +167,10 @@ module Ro
|
|
|
148
167
|
block ? self : accum
|
|
149
168
|
end
|
|
150
169
|
|
|
151
|
-
def to_array(
|
|
152
|
-
|
|
170
|
+
def to_array(offset: nil, limit: nil)
|
|
171
|
+
accum = []
|
|
172
|
+
each(offset: offset, limit: limit) { |node| accum << node }
|
|
173
|
+
accum
|
|
153
174
|
end
|
|
154
175
|
|
|
155
176
|
alias to_a to_array
|
|
@@ -186,11 +207,28 @@ module Ro
|
|
|
186
207
|
]
|
|
187
208
|
end
|
|
188
209
|
|
|
210
|
+
# T022: Modified to find nodes by metadata filename
|
|
189
211
|
def get(name)
|
|
190
|
-
|
|
191
|
-
|
|
212
|
+
# Try to find metadata file for this node ID
|
|
213
|
+
extensions = %w[yml yaml json toml]
|
|
214
|
+
extensions.each do |ext|
|
|
215
|
+
metadata_file = @path.join("#{name}.#{ext}")
|
|
216
|
+
if metadata_file.exist? && metadata_file.file?
|
|
217
|
+
return Node.new(self, metadata_file)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
192
220
|
|
|
193
|
-
|
|
221
|
+
# Also try with slugified versions
|
|
222
|
+
[
|
|
223
|
+
Slug.for(name, :join => '-'),
|
|
224
|
+
Slug.for(name, :join => '_')
|
|
225
|
+
].each do |slug|
|
|
226
|
+
extensions.each do |ext|
|
|
227
|
+
metadata_file = @path.join("#{slug}.#{ext}")
|
|
228
|
+
if metadata_file.exist? && metadata_file.file?
|
|
229
|
+
return Node.new(self, metadata_file)
|
|
230
|
+
end
|
|
231
|
+
end
|
|
194
232
|
end
|
|
195
233
|
|
|
196
234
|
nil
|
data/lib/ro/migrator.rb
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
module Ro
|
|
2
|
+
class Migrator
|
|
3
|
+
attr_reader :root_path, :options
|
|
4
|
+
|
|
5
|
+
def initialize(root_path, options = {})
|
|
6
|
+
@root_path = Path.for(root_path)
|
|
7
|
+
@options = {
|
|
8
|
+
dry_run: false,
|
|
9
|
+
backup: false,
|
|
10
|
+
verbose: false,
|
|
11
|
+
force: false
|
|
12
|
+
}.merge(options)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def dry_run?
|
|
16
|
+
@options[:dry_run]
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def backup?
|
|
20
|
+
@options[:backup]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def verbose?
|
|
24
|
+
@options[:verbose]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def force?
|
|
28
|
+
@options[:force]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Validate the structure and return analysis
|
|
32
|
+
def validate
|
|
33
|
+
result = {
|
|
34
|
+
has_old_structure: false,
|
|
35
|
+
has_new_structure: false,
|
|
36
|
+
old_nodes: [],
|
|
37
|
+
new_nodes: [],
|
|
38
|
+
collections: []
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
root = Root.for(@root_path)
|
|
42
|
+
|
|
43
|
+
root.collections.each do |collection|
|
|
44
|
+
collection_name = collection.name
|
|
45
|
+
result[:collections] << collection_name
|
|
46
|
+
|
|
47
|
+
# Check for new structure (metadata files at collection level)
|
|
48
|
+
collection.metadata_files.each do |metadata_file|
|
|
49
|
+
node_id = metadata_file.basename.to_s.sub(/\.(yml|yaml|json|toml)$/, '')
|
|
50
|
+
result[:new_nodes] << {
|
|
51
|
+
collection: collection_name,
|
|
52
|
+
node_id: node_id,
|
|
53
|
+
metadata_file: metadata_file
|
|
54
|
+
}
|
|
55
|
+
result[:has_new_structure] = true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check for old structure (ALL subdirectories, with or without attributes)
|
|
59
|
+
# We need to migrate ALL nodes, even those without attributes.yml
|
|
60
|
+
collection.subdirectories.each do |subdir|
|
|
61
|
+
node_id = subdir.basename.to_s
|
|
62
|
+
|
|
63
|
+
# Skip if this node already has new-structure metadata
|
|
64
|
+
already_migrated = result[:new_nodes].any? { |n|
|
|
65
|
+
n[:collection] == collection_name && n[:node_id] == node_id
|
|
66
|
+
}
|
|
67
|
+
next if already_migrated
|
|
68
|
+
|
|
69
|
+
# Check if there's an attributes file (any format)
|
|
70
|
+
attributes_file = nil
|
|
71
|
+
has_attributes = false
|
|
72
|
+
%w[yml yaml json toml].each do |ext|
|
|
73
|
+
candidate = subdir.join("attributes.#{ext}")
|
|
74
|
+
if candidate.exist?
|
|
75
|
+
attributes_file = candidate
|
|
76
|
+
has_attributes = true
|
|
77
|
+
break
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
result[:old_nodes] << {
|
|
82
|
+
collection: collection_name,
|
|
83
|
+
node_id: node_id,
|
|
84
|
+
old_path: subdir,
|
|
85
|
+
attributes_file: attributes_file,
|
|
86
|
+
has_attributes: has_attributes
|
|
87
|
+
}
|
|
88
|
+
result[:has_old_structure] = true
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
result
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Preview migration without making changes
|
|
96
|
+
def preview
|
|
97
|
+
validation = validate
|
|
98
|
+
plan = []
|
|
99
|
+
|
|
100
|
+
validation[:old_nodes].each do |old_node|
|
|
101
|
+
collection_name = old_node[:collection]
|
|
102
|
+
node_id = old_node[:node_id]
|
|
103
|
+
old_path = old_node[:old_path]
|
|
104
|
+
has_attributes = old_node[:has_attributes]
|
|
105
|
+
attributes_file = old_node[:attributes_file]
|
|
106
|
+
|
|
107
|
+
collection_path = @root_path.join(collection_name)
|
|
108
|
+
new_metadata_file = collection_path.join("#{node_id}.yml")
|
|
109
|
+
new_asset_dir = collection_path.join(node_id)
|
|
110
|
+
|
|
111
|
+
actions = []
|
|
112
|
+
if has_attributes
|
|
113
|
+
actions << "Move #{attributes_file} → #{new_metadata_file}"
|
|
114
|
+
else
|
|
115
|
+
actions << "Create empty #{new_metadata_file} (node has no attributes)"
|
|
116
|
+
end
|
|
117
|
+
actions << "Assets remain in #{old_path}/assets/ (no change needed)"
|
|
118
|
+
|
|
119
|
+
plan << {
|
|
120
|
+
node_id: node_id,
|
|
121
|
+
collection: collection_name,
|
|
122
|
+
old_path: old_path,
|
|
123
|
+
new_metadata_file: new_metadata_file,
|
|
124
|
+
new_asset_dir: new_asset_dir,
|
|
125
|
+
has_attributes: has_attributes,
|
|
126
|
+
actions: actions
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
plan
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# Migrate a single node
|
|
134
|
+
def migrate_node(collection_name, node_id)
|
|
135
|
+
log "Migrating #{collection_name}/#{node_id}..."
|
|
136
|
+
|
|
137
|
+
collection_path = @root_path.join(collection_name)
|
|
138
|
+
old_node_path = collection_path.join(node_id)
|
|
139
|
+
|
|
140
|
+
unless old_node_path.directory?
|
|
141
|
+
return { success: false, error: "Node directory not found: #{old_node_path}" }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
new_metadata_file = collection_path.join("#{node_id}.yml")
|
|
145
|
+
|
|
146
|
+
# Look for attributes file in any format
|
|
147
|
+
old_attributes_file = nil
|
|
148
|
+
%w[yml yaml json toml].each do |ext|
|
|
149
|
+
candidate = old_node_path.join("attributes.#{ext}")
|
|
150
|
+
if candidate.exist?
|
|
151
|
+
old_attributes_file = candidate
|
|
152
|
+
break
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
unless dry_run?
|
|
157
|
+
if old_attributes_file
|
|
158
|
+
# Move existing attributes file to collection level
|
|
159
|
+
log " Moving #{old_attributes_file} → #{new_metadata_file}"
|
|
160
|
+
FileUtils.mv(old_attributes_file.to_s, new_metadata_file.to_s)
|
|
161
|
+
else
|
|
162
|
+
# Create empty metadata file for nodes without attributes
|
|
163
|
+
log " Creating empty metadata #{new_metadata_file} (node had no attributes)"
|
|
164
|
+
File.write(new_metadata_file.to_s, {}.to_yaml)
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Assets stay in assets/ subdirectory - no moving needed
|
|
169
|
+
# Old: collection/identifier/assets/foo.png
|
|
170
|
+
# New: collection/identifier/assets/foo.png (same location)
|
|
171
|
+
|
|
172
|
+
{ success: true, node_id: node_id, had_attributes: !old_attributes_file.nil? }
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Migrate an entire collection
|
|
176
|
+
def migrate_collection(collection_name)
|
|
177
|
+
log "Migrating collection: #{collection_name}"
|
|
178
|
+
|
|
179
|
+
validation = validate
|
|
180
|
+
collection_nodes = validation[:old_nodes].select { |n| n[:collection] == collection_name }
|
|
181
|
+
|
|
182
|
+
migrated_count = 0
|
|
183
|
+
errors = []
|
|
184
|
+
|
|
185
|
+
collection_nodes.each do |old_node|
|
|
186
|
+
result = migrate_node(collection_name, old_node[:node_id])
|
|
187
|
+
if result[:success]
|
|
188
|
+
migrated_count += 1
|
|
189
|
+
else
|
|
190
|
+
errors << result[:error]
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
{
|
|
195
|
+
success: errors.empty?,
|
|
196
|
+
migrated_count: migrated_count,
|
|
197
|
+
errors: errors
|
|
198
|
+
}
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Migrate entire root
|
|
202
|
+
def migrate
|
|
203
|
+
log "Starting full migration of #{@root_path}"
|
|
204
|
+
|
|
205
|
+
if backup?
|
|
206
|
+
backup_path = backup
|
|
207
|
+
log "Created backup at #{backup_path}"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
validation = validate
|
|
211
|
+
|
|
212
|
+
if validation[:old_nodes].empty?
|
|
213
|
+
log "No old structure nodes found to migrate"
|
|
214
|
+
return { success: true, nodes_migrated: 0, collections_migrated: 0 }
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
collections = validation[:old_nodes].map { |n| n[:collection] }.uniq
|
|
218
|
+
total_migrated = 0
|
|
219
|
+
collections_migrated = 0
|
|
220
|
+
|
|
221
|
+
collections.each do |collection_name|
|
|
222
|
+
result = migrate_collection(collection_name)
|
|
223
|
+
if result[:success]
|
|
224
|
+
collections_migrated += 1
|
|
225
|
+
total_migrated += result[:migrated_count]
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
log "Migration complete! Migrated #{total_migrated} nodes across #{collections_migrated} collections"
|
|
230
|
+
|
|
231
|
+
{
|
|
232
|
+
success: true,
|
|
233
|
+
nodes_migrated: total_migrated,
|
|
234
|
+
collections_migrated: collections_migrated
|
|
235
|
+
}
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# Create backup
|
|
239
|
+
def backup
|
|
240
|
+
timestamp = Time.now.strftime('%Y%m%d%H%M%S')
|
|
241
|
+
backup_name = "#{@root_path.basename}.backup.#{timestamp}"
|
|
242
|
+
backup_path = @root_path.parent.join(backup_name)
|
|
243
|
+
|
|
244
|
+
log "Creating backup: #{backup_path}"
|
|
245
|
+
|
|
246
|
+
unless dry_run?
|
|
247
|
+
FileUtils.cp_r(@root_path.to_s, backup_path.to_s)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
backup_path
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Rollback from backup
|
|
254
|
+
def rollback
|
|
255
|
+
# Find most recent backup
|
|
256
|
+
backup_pattern = "#{@root_path.basename}.backup.*"
|
|
257
|
+
backups = @root_path.parent.glob(backup_pattern).sort.reverse
|
|
258
|
+
|
|
259
|
+
if backups.empty?
|
|
260
|
+
return { success: false, error: "No backups found" }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
backup_path = backups.first
|
|
264
|
+
log "Rolling back from #{backup_path}"
|
|
265
|
+
|
|
266
|
+
unless dry_run?
|
|
267
|
+
# Remove current root
|
|
268
|
+
FileUtils.rm_rf(@root_path.to_s)
|
|
269
|
+
# Restore from backup
|
|
270
|
+
FileUtils.cp_r(backup_path.to_s, @root_path.to_s)
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
{
|
|
274
|
+
success: true,
|
|
275
|
+
restored_from: backup_path
|
|
276
|
+
}
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
private
|
|
280
|
+
|
|
281
|
+
def log(message)
|
|
282
|
+
puts message if verbose? || dry_run?
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
end
|
data/lib/ro/node.rb
CHANGED
|
@@ -2,16 +2,45 @@ module Ro
|
|
|
2
2
|
class Node
|
|
3
3
|
include Klass
|
|
4
4
|
|
|
5
|
-
attr_reader :path, :root
|
|
5
|
+
attr_reader :path, :root, :metadata_file
|
|
6
|
+
|
|
7
|
+
# T023: Updated to accept (collection, metadata_file) for new structure
|
|
8
|
+
def initialize(collection_or_path, metadata_file = nil)
|
|
9
|
+
if metadata_file
|
|
10
|
+
# New structure: collection + metadata_file
|
|
11
|
+
@collection = collection_or_path
|
|
12
|
+
@metadata_file = Path.for(metadata_file)
|
|
13
|
+
|
|
14
|
+
# Raise error if metadata file doesn't exist
|
|
15
|
+
unless @metadata_file.exist?
|
|
16
|
+
raise Errno::ENOENT, "No such file or directory - #{@metadata_file}"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
@root = @collection.root
|
|
20
|
+
|
|
21
|
+
# Derive node ID from metadata filename (without extension)
|
|
22
|
+
# T025: ID derived from metadata filename
|
|
23
|
+
node_id = @metadata_file.basename.to_s.sub(/\.(yml|yaml|json|toml)$/, '')
|
|
24
|
+
|
|
25
|
+
# Path is the node directory (sibling to metadata file)
|
|
26
|
+
@path = @collection.path.join(node_id)
|
|
27
|
+
else
|
|
28
|
+
# Old structure compatibility: just a path
|
|
29
|
+
@path = Path.for(collection_or_path)
|
|
30
|
+
@root = Root.for(@path.parent.parent)
|
|
31
|
+
@metadata_file = nil
|
|
32
|
+
end
|
|
6
33
|
|
|
7
|
-
def initialize(path)
|
|
8
|
-
@path = Path.for(path)
|
|
9
|
-
@root = Root.for(@path.parent.parent)
|
|
10
34
|
@attributes = :lazyload
|
|
11
35
|
end
|
|
12
36
|
|
|
13
37
|
def name
|
|
14
|
-
@
|
|
38
|
+
if @metadata_file
|
|
39
|
+
# T025: For new structure, name comes from metadata filename
|
|
40
|
+
@metadata_file.basename.to_s.sub(/\.(yml|yaml|json|toml)$/, '')
|
|
41
|
+
else
|
|
42
|
+
@path.name
|
|
43
|
+
end
|
|
15
44
|
end
|
|
16
45
|
|
|
17
46
|
def id
|
|
@@ -31,7 +60,7 @@ module Ro
|
|
|
31
60
|
end
|
|
32
61
|
|
|
33
62
|
def collection
|
|
34
|
-
@root.collection_for(type)
|
|
63
|
+
@collection || @root.collection_for(type)
|
|
35
64
|
end
|
|
36
65
|
|
|
37
66
|
def attributes
|
|
@@ -54,12 +83,20 @@ module Ro
|
|
|
54
83
|
@attributes
|
|
55
84
|
end
|
|
56
85
|
|
|
86
|
+
# T026: Modified to load from external metadata_file (new structure)
|
|
57
87
|
def _load_base_attributes
|
|
58
|
-
|
|
88
|
+
if @metadata_file && @metadata_file.exist?
|
|
89
|
+
# New structure: load from explicit metadata file
|
|
90
|
+
attrs = _render(@metadata_file)
|
|
91
|
+
update_attributes!(attrs, file: @metadata_file)
|
|
92
|
+
else
|
|
93
|
+
# Old structure: search for attributes.yml in node directory
|
|
94
|
+
glob = "attributes.{yml,yaml,json}"
|
|
59
95
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
96
|
+
@path.glob(glob) do |file|
|
|
97
|
+
attrs = _render(file)
|
|
98
|
+
update_attributes!(attrs, file:)
|
|
99
|
+
end
|
|
63
100
|
end
|
|
64
101
|
end
|
|
65
102
|
|
|
@@ -150,7 +187,9 @@ module Ro
|
|
|
150
187
|
@attributes.update(attrs)
|
|
151
188
|
end
|
|
152
189
|
|
|
190
|
+
# T028: Updated ignore patterns for new structure
|
|
153
191
|
def _ignored_files
|
|
192
|
+
# Both old and new structure: ignore attributes files and assets/ subdirectory
|
|
154
193
|
ignored_files =
|
|
155
194
|
%w[
|
|
156
195
|
attributes.yml
|
|
@@ -159,9 +198,7 @@ module Ro
|
|
|
159
198
|
./assets/**/**
|
|
160
199
|
].map do |glob|
|
|
161
200
|
@path.glob(glob).select(&:file?)
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
ignored_files.flatten
|
|
201
|
+
end.flatten
|
|
165
202
|
end
|
|
166
203
|
|
|
167
204
|
def _render(file)
|
|
@@ -206,7 +243,10 @@ module Ro
|
|
|
206
243
|
path.relative_to(root)
|
|
207
244
|
end
|
|
208
245
|
|
|
246
|
+
# T027: Updated to return assets/ subdirectory in both old and new structure
|
|
209
247
|
def asset_dir
|
|
248
|
+
# Both old and new structure use assets/ subdirectory
|
|
249
|
+
# This prevents files from being rendered as templates
|
|
210
250
|
path.join('assets')
|
|
211
251
|
end
|
|
212
252
|
|
data/lib/ro/root.rb
CHANGED
|
@@ -1,11 +1,18 @@
|
|
|
1
1
|
module Ro
|
|
2
2
|
class Root < Path
|
|
3
|
+
@@warned_paths = {}
|
|
4
|
+
|
|
3
5
|
def identifier
|
|
4
6
|
self
|
|
5
7
|
end
|
|
6
8
|
|
|
9
|
+
def initialize(*)
|
|
10
|
+
super
|
|
11
|
+
check_for_unmigrated_structure!
|
|
12
|
+
end
|
|
13
|
+
|
|
7
14
|
def collections(&block)
|
|
8
|
-
accum = Collection::List.for(self)
|
|
15
|
+
accum = Collection::List.for(self)
|
|
9
16
|
|
|
10
17
|
subdirectories do |subdirectory|
|
|
11
18
|
collection = collection_for(subdirectory)
|
|
@@ -69,5 +76,72 @@ module Ro
|
|
|
69
76
|
def method_missing(name, *args, **kws, &block)
|
|
70
77
|
get(name) || super
|
|
71
78
|
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def check_for_unmigrated_structure!
|
|
83
|
+
# Use absolute path for deduplication
|
|
84
|
+
begin
|
|
85
|
+
path_key = File.expand_path(self.to_s)
|
|
86
|
+
rescue
|
|
87
|
+
path_key = self.to_s
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Skip if we've already warned for this path
|
|
91
|
+
return if @@warned_paths[path_key]
|
|
92
|
+
|
|
93
|
+
# Mark as checked (even if no warning needed)
|
|
94
|
+
@@warned_paths[path_key] = true
|
|
95
|
+
|
|
96
|
+
# Quick check: look for old structure (subdirs with attributes.yml)
|
|
97
|
+
# but no new structure (metadata files at collection level)
|
|
98
|
+
has_old = false
|
|
99
|
+
has_new = false
|
|
100
|
+
|
|
101
|
+
subdirectories.each do |subdir|
|
|
102
|
+
# Check for new structure (metadata files)
|
|
103
|
+
has_new = true if subdir.glob('*.{yml,yaml,json,toml}').any? { |f| f.file? }
|
|
104
|
+
|
|
105
|
+
# Check for old structure (nested attributes.yml)
|
|
106
|
+
subdir.subdirectories.each do |node_dir|
|
|
107
|
+
if (node_dir.join('attributes.yml').exist? ||
|
|
108
|
+
node_dir.join('attributes.yaml').exist? ||
|
|
109
|
+
node_dir.join('attributes.json').exist?)
|
|
110
|
+
has_old = true
|
|
111
|
+
break
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
break if has_old && has_new
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
if has_old && !has_new
|
|
119
|
+
warn_unmigrated_structure!
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def warn_unmigrated_structure!
|
|
124
|
+
$stderr.puts ""
|
|
125
|
+
$stderr.puts "=" * 70
|
|
126
|
+
$stderr.puts "⚠️ WARNING: Old Ro asset structure detected!"
|
|
127
|
+
$stderr.puts "=" * 70
|
|
128
|
+
$stderr.puts ""
|
|
129
|
+
$stderr.puts "This Ro root contains assets in the OLD structure format:"
|
|
130
|
+
$stderr.puts " • identifier/attributes.yml"
|
|
131
|
+
$stderr.puts " • identifier/assets/"
|
|
132
|
+
$stderr.puts ""
|
|
133
|
+
$stderr.puts "Ro v5.0 uses a simplified NEW structure:"
|
|
134
|
+
$stderr.puts " • identifier.yml"
|
|
135
|
+
$stderr.puts " • identifier/"
|
|
136
|
+
$stderr.puts ""
|
|
137
|
+
$stderr.puts "Collections will NOT automatically discover old-structure nodes."
|
|
138
|
+
$stderr.puts ""
|
|
139
|
+
$stderr.puts "To migrate your data, run:"
|
|
140
|
+
$stderr.puts " #{$0.include?('bin/') ? './bin/ro' : 'ro'} migrate #{self}"
|
|
141
|
+
$stderr.puts ""
|
|
142
|
+
$stderr.puts "Or see MIGRATION.md for details."
|
|
143
|
+
$stderr.puts "=" * 70
|
|
144
|
+
$stderr.puts ""
|
|
145
|
+
end
|
|
72
146
|
end
|
|
73
147
|
end
|