metanorma-release 0.2.1
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 +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +1 -0
- data/.rubocop_todo.yml +504 -0
- data/CHANGELOG.md +15 -0
- data/PROMPT.md +282 -0
- data/README.adoc +430 -0
- data/Rakefile +8 -0
- data/exe/mn-release +6 -0
- data/lib/metanorma/release/aggregation_interfaces.rb +33 -0
- data/lib/metanorma/release/aggregation_pipeline.rb +155 -0
- data/lib/metanorma/release/asset_processor.rb +58 -0
- data/lib/metanorma/release/cache_store.rb +86 -0
- data/lib/metanorma/release/change_detector.rb +20 -0
- data/lib/metanorma/release/channel.rb +64 -0
- data/lib/metanorma/release/channel_audience.rb +24 -0
- data/lib/metanorma/release/channel_config.rb +55 -0
- data/lib/metanorma/release/channel_filter.rb +26 -0
- data/lib/metanorma/release/channel_manifest.rb +192 -0
- data/lib/metanorma/release/channel_registry.rb +60 -0
- data/lib/metanorma/release/cli.rb +129 -0
- data/lib/metanorma/release/commands/aggregate.rb +126 -0
- data/lib/metanorma/release/commands/package.rb +46 -0
- data/lib/metanorma/release/commands/publish.rb +51 -0
- data/lib/metanorma/release/config_fetcher.rb +11 -0
- data/lib/metanorma/release/config_locator.rb +37 -0
- data/lib/metanorma/release/config_resolver.rb +37 -0
- data/lib/metanorma/release/content_hash.rb +51 -0
- data/lib/metanorma/release/delta_state.rb +108 -0
- data/lib/metanorma/release/document_id.rb +45 -0
- data/lib/metanorma/release/document_index.rb +183 -0
- data/lib/metanorma/release/document_metadata.rb +39 -0
- data/lib/metanorma/release/document_stage.rb +86 -0
- data/lib/metanorma/release/document_type.rb +55 -0
- data/lib/metanorma/release/document_version.rb +50 -0
- data/lib/metanorma/release/file_routing.rb +51 -0
- data/lib/metanorma/release/interfaces.rb +47 -0
- data/lib/metanorma/release/naming_strategy.rb +158 -0
- data/lib/metanorma/release/platform/github/config_fetcher.rb +40 -0
- data/lib/metanorma/release/platform/github/manifest_reader.rb +32 -0
- data/lib/metanorma/release/platform/github/publisher.rb +73 -0
- data/lib/metanorma/release/platform/github/release_fetcher.rb +52 -0
- data/lib/metanorma/release/platform/github/topic_discoverer.rb +29 -0
- data/lib/metanorma/release/platform/github.rb +25 -0
- data/lib/metanorma/release/platform/local/config_fetcher.rb +20 -0
- data/lib/metanorma/release/platform/local/directory_discoverer.rb +26 -0
- data/lib/metanorma/release/platform/local/fetcher.rb +76 -0
- data/lib/metanorma/release/platform/local/publisher.rb +44 -0
- data/lib/metanorma/release/platform/local.rb +14 -0
- data/lib/metanorma/release/platform/null/publisher.rb +17 -0
- data/lib/metanorma/release/platform/null.rb +11 -0
- data/lib/metanorma/release/platform.rb +11 -0
- data/lib/metanorma/release/platform_factory.rb +78 -0
- data/lib/metanorma/release/rake_tasks.rb +71 -0
- data/lib/metanorma/release/relaton_enricher.rb +138 -0
- data/lib/metanorma/release/release_metadata.rb +79 -0
- data/lib/metanorma/release/release_pipeline.rb +115 -0
- data/lib/metanorma/release/release_tag.rb +49 -0
- data/lib/metanorma/release/repo_ref.rb +34 -0
- data/lib/metanorma/release/rxl_extractor.rb +115 -0
- data/lib/metanorma/release/stage_filter.rb +18 -0
- data/lib/metanorma/release/version.rb +7 -0
- data/lib/metanorma/release/zip_packager.rb +37 -0
- data/lib/metanorma/release.rb +116 -0
- metadata +156 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metanorma
|
|
4
|
+
module Release
|
|
5
|
+
class AggregateCommand
|
|
6
|
+
Config = Struct.new(
|
|
7
|
+
:source, :organizations, :topic, :repos, :repo_pattern, :local_path,
|
|
8
|
+
:channels, :stages, :output_dir, :file_routing, :cache_dir,
|
|
9
|
+
:include_drafts, :concurrency, :min_documents, :token, :zip,
|
|
10
|
+
keyword_init: true
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@config = config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
result = run_aggregation
|
|
19
|
+
enrich(result) if result.documents.any?
|
|
20
|
+
zip_output if @config.zip
|
|
21
|
+
result
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def run_aggregation
|
|
27
|
+
adapters = PlatformFactory.build_aggregation_adapters(
|
|
28
|
+
source: @config.local_path ? "local:#{@config.local_path}" : @config.source,
|
|
29
|
+
organizations: @config.organizations,
|
|
30
|
+
topic: @config.topic,
|
|
31
|
+
repos: @config.repos,
|
|
32
|
+
token: @config.token
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
channel_filter = ChannelFilter.new(
|
|
36
|
+
channels: Channel.parse_list(@config.channels)
|
|
37
|
+
)
|
|
38
|
+
stage_filter = StageFilter.new(@config.stages || [])
|
|
39
|
+
routing = FileRoutingFactory.from_name(@config.file_routing)
|
|
40
|
+
asset_processor = AssetProcessor.new(
|
|
41
|
+
output_dir: @config.output_dir,
|
|
42
|
+
routing: routing,
|
|
43
|
+
canonicalize: true
|
|
44
|
+
)
|
|
45
|
+
delta_state = build_delta_state
|
|
46
|
+
|
|
47
|
+
deps = AggregationPipeline::Dependencies.new(
|
|
48
|
+
discoverer: adapters[:discoverer],
|
|
49
|
+
fetcher: adapters[:fetcher],
|
|
50
|
+
manifest_reader: adapters[:manifest_reader],
|
|
51
|
+
channel_filter: channel_filter,
|
|
52
|
+
stage_filter: stage_filter,
|
|
53
|
+
asset_processor: asset_processor,
|
|
54
|
+
delta_state: delta_state
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
config = AggregationPipeline::Config.new(
|
|
58
|
+
organizations: @config.organizations,
|
|
59
|
+
channels: @config.channels,
|
|
60
|
+
topic: @config.topic,
|
|
61
|
+
concurrency: @config.concurrency,
|
|
62
|
+
include_drafts: @config.include_drafts,
|
|
63
|
+
fail_on_error: false
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
AggregationPipeline.new(deps).run(config, @config.output_dir)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_delta_state
|
|
70
|
+
return NullDeltaState.new unless @config.cache_dir
|
|
71
|
+
|
|
72
|
+
DeltaState.new(
|
|
73
|
+
cache_store: FileCacheStore.new(@config.cache_dir),
|
|
74
|
+
output_dir: @config.output_dir
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def enrich(result)
|
|
79
|
+
index = DocumentIndex.from_documents(
|
|
80
|
+
result.documents,
|
|
81
|
+
parameters: IndexParameters.new(
|
|
82
|
+
organizations: @config.organizations,
|
|
83
|
+
channels: @config.channels || [],
|
|
84
|
+
topic: @config.topic,
|
|
85
|
+
repo_count: result.repo_count
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
enricher = RelatonEnricher.new
|
|
89
|
+
enrich_result = enricher.enrich(index, @config.output_dir)
|
|
90
|
+
return unless enrich_result
|
|
91
|
+
|
|
92
|
+
stamp_primary_identifiers(enrich_result.documents)
|
|
93
|
+
rescue LoadError
|
|
94
|
+
warn ' (relaton gem not available — bibliography skipped)'
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def stamp_primary_identifiers(documents)
|
|
98
|
+
documents.map do |doc|
|
|
99
|
+
next doc unless doc['bibliographic']
|
|
100
|
+
|
|
101
|
+
ids = doc['bibliographic']['docidentifier']
|
|
102
|
+
next doc unless ids&.any?
|
|
103
|
+
|
|
104
|
+
primary = ids.find { |di| di['primary'] == true } || ids.first
|
|
105
|
+
doc.merge('primary_identifier' => primary['content'])
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def zip_output
|
|
110
|
+
require 'zip'
|
|
111
|
+
|
|
112
|
+
dir = @config.output_dir
|
|
113
|
+
zip_path = "#{dir}.zip"
|
|
114
|
+
|
|
115
|
+
Zip::File.open(zip_path, Zip::File::CREATE) do |zipfile|
|
|
116
|
+
Dir.glob("#{dir}/**/*").each do |file|
|
|
117
|
+
next if File.directory?(file)
|
|
118
|
+
|
|
119
|
+
entry_name = file.sub("#{File.dirname(dir)}/", '')
|
|
120
|
+
zipfile.add(entry_name, file)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metanorma
|
|
4
|
+
module Release
|
|
5
|
+
class PackageCommand
|
|
6
|
+
Config = Struct.new(
|
|
7
|
+
:output_dir, :dest, :manifest, :config_source,
|
|
8
|
+
keyword_init: true
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
include ConfigResolver
|
|
12
|
+
|
|
13
|
+
def initialize(config)
|
|
14
|
+
@config = config
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call
|
|
18
|
+
manifest = load_manifest(@config.manifest)
|
|
19
|
+
channel_config = resolve_channel_config(@config.config_source, manifest)
|
|
20
|
+
|
|
21
|
+
deps = ReleasePipeline::Dependencies.new(
|
|
22
|
+
extractor: RxlExtractor.new,
|
|
23
|
+
filters: [],
|
|
24
|
+
change_detector: ContentHashChangeDetector.new(previous_releases: {}),
|
|
25
|
+
packager: ZipPackager.new,
|
|
26
|
+
publisher: PlatformFactory.build_publisher('null', {}),
|
|
27
|
+
naming_registry: NamingRegistry.default_registry,
|
|
28
|
+
manifest: manifest,
|
|
29
|
+
channel_override: nil,
|
|
30
|
+
channel_config: channel_config
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
pipeline_config = ReleasePipeline::Config.new(
|
|
34
|
+
output_dir: @config.output_dir,
|
|
35
|
+
manifest_path: @config.manifest,
|
|
36
|
+
force: false,
|
|
37
|
+
force_replace_patterns: nil,
|
|
38
|
+
concurrency: 4,
|
|
39
|
+
default_visibility: 'public'
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
ReleasePipeline.new(deps).run(pipeline_config)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metanorma
|
|
4
|
+
module Release
|
|
5
|
+
class PublishCommand
|
|
6
|
+
Config = Struct.new(
|
|
7
|
+
:output_dir, :platform, :manifest, :force,
|
|
8
|
+
:force_replace, :channels, :concurrency, :token, :config_source,
|
|
9
|
+
keyword_init: true
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
include ConfigResolver
|
|
13
|
+
|
|
14
|
+
def initialize(config)
|
|
15
|
+
@config = config
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
manifest = load_manifest(@config.manifest)
|
|
20
|
+
channel_config = resolve_channel_config(@config.config_source, manifest)
|
|
21
|
+
|
|
22
|
+
options = { token: @config.token }
|
|
23
|
+
publisher = PlatformFactory.build_publisher(@config.platform, options)
|
|
24
|
+
channel_override = Channel.parse_list(@config.channels) if @config.channels
|
|
25
|
+
|
|
26
|
+
deps = ReleasePipeline::Dependencies.new(
|
|
27
|
+
extractor: RxlExtractor.new,
|
|
28
|
+
filters: [],
|
|
29
|
+
change_detector: ContentHashChangeDetector.new(previous_releases: {}),
|
|
30
|
+
packager: ZipPackager.new,
|
|
31
|
+
publisher: publisher,
|
|
32
|
+
naming_registry: NamingRegistry.default_registry,
|
|
33
|
+
manifest: manifest,
|
|
34
|
+
channel_override: channel_override,
|
|
35
|
+
channel_config: channel_config
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
pipeline_config = ReleasePipeline::Config.new(
|
|
39
|
+
output_dir: @config.output_dir,
|
|
40
|
+
manifest_path: @config.manifest,
|
|
41
|
+
force: @config.force,
|
|
42
|
+
force_replace_patterns: @config.force_replace && !@config.force_replace.empty? ? @config.force_replace : nil,
|
|
43
|
+
concurrency: @config.concurrency,
|
|
44
|
+
default_visibility: 'public'
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
ReleasePipeline.new(deps).run(pipeline_config)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metanorma
|
|
4
|
+
module Release
|
|
5
|
+
class ConfigLocator
|
|
6
|
+
CONFIG_FILES = ['.metanorma.yml', '.metanorma.yaml'].freeze
|
|
7
|
+
CONFIG_DIRS = ['.metanorma'].freeze
|
|
8
|
+
|
|
9
|
+
def self.find(start_dir = Dir.pwd)
|
|
10
|
+
new.find(start_dir)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def find(start_dir)
|
|
14
|
+
dir = File.expand_path(start_dir)
|
|
15
|
+
loop do
|
|
16
|
+
CONFIG_FILES.each do |name|
|
|
17
|
+
path = File.join(dir, name)
|
|
18
|
+
return ChannelConfig.from_file(path) if File.exist?(path)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
CONFIG_DIRS.each do |name|
|
|
22
|
+
path = File.join(dir, name)
|
|
23
|
+
next unless File.directory?(path)
|
|
24
|
+
|
|
25
|
+
channels = File.join(path, 'channels.yml')
|
|
26
|
+
return ChannelConfig.from_file(channels) if File.exist?(channels)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
parent = File.dirname(dir)
|
|
30
|
+
return nil if parent == dir
|
|
31
|
+
|
|
32
|
+
dir = parent
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metanorma
|
|
4
|
+
module Release
|
|
5
|
+
module ConfigResolver
|
|
6
|
+
def resolve_channel_config(cli_source, manifest)
|
|
7
|
+
return fetch_config(cli_source) if cli_source
|
|
8
|
+
return fetch_config(manifest.config_source) if manifest&.config_source
|
|
9
|
+
|
|
10
|
+
found = ConfigLocator.find
|
|
11
|
+
return found if found
|
|
12
|
+
|
|
13
|
+
ChannelConfig.empty
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def load_manifest(path)
|
|
17
|
+
return nil unless path && File.exist?(path)
|
|
18
|
+
|
|
19
|
+
ChannelManifest.from_file(path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def fetch_config(source)
|
|
25
|
+
if source.start_with?('local:')
|
|
26
|
+
Platform::Local::ConfigFetcher.new.fetch(source)
|
|
27
|
+
elsif source.include?('/')
|
|
28
|
+
Platform::Local::ConfigFetcher.new.fetch("local:#{source}")
|
|
29
|
+
else
|
|
30
|
+
require 'octokit'
|
|
31
|
+
client = PlatformFactory.build_github_client(nil)
|
|
32
|
+
Platform::GitHub::ConfigFetcher.new(client: client).fetch(source)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'digest'
|
|
4
|
+
|
|
5
|
+
module Metanorma
|
|
6
|
+
module Release
|
|
7
|
+
class ContentHash
|
|
8
|
+
def self.from_hex(hex_string)
|
|
9
|
+
new(hex_string.to_s)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.of_content(data)
|
|
13
|
+
new(Digest::SHA256.hexdigest(data))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.of_file(path)
|
|
17
|
+
new(Digest::SHA256.file(path).hexdigest)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.of_files(paths)
|
|
21
|
+
sorted = paths.sort
|
|
22
|
+
digest = Digest::SHA256.new
|
|
23
|
+
sorted.each { |p| digest << File.binread(p) }
|
|
24
|
+
new(digest.hexdigest)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.of_directory(directory, base: nil)
|
|
28
|
+
pattern = base ? File.join(directory, "#{base}.*") : File.join(directory, '**', '*')
|
|
29
|
+
files = Dir.glob(pattern).reject { |f| File.directory?(f) || f.end_with?('.zip') }
|
|
30
|
+
of_files(files)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(hex)
|
|
34
|
+
@hex = hex
|
|
35
|
+
freeze
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def to_s
|
|
39
|
+
@hex
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def eql?(other)
|
|
43
|
+
other.is_a?(self.class) && @hex == other.to_s
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def hash
|
|
47
|
+
@hex.hash
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Metanorma
|
|
6
|
+
module Release
|
|
7
|
+
class DeltaState
|
|
8
|
+
def initialize(cache_store:, output_dir:)
|
|
9
|
+
@cache = cache_store
|
|
10
|
+
@output_dir = output_dir
|
|
11
|
+
@state = empty_state
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def load
|
|
15
|
+
raw = @cache.get('delta_state')
|
|
16
|
+
return if raw.nil?
|
|
17
|
+
|
|
18
|
+
@state = JSON.parse(raw)
|
|
19
|
+
rescue JSON::ParserError
|
|
20
|
+
@state = empty_state
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def save
|
|
24
|
+
@cache.set('delta_state', JSON.generate(@state))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def etag(repo_key)
|
|
28
|
+
repo_state(repo_key)['etag']
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def set_etag(repo_key, etag_value)
|
|
32
|
+
repo = ensure_repo(repo_key)
|
|
33
|
+
repo['etag'] = etag_value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def processed?(repo_key, tag, content_hash)
|
|
37
|
+
releases = repo_state(repo_key)['releases']
|
|
38
|
+
return false unless releases.key?(tag)
|
|
39
|
+
return false if content_hash.nil?
|
|
40
|
+
|
|
41
|
+
releases[tag]['content_hash'] == content_hash.to_s
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def release_files(repo_key, tag)
|
|
45
|
+
releases = repo_state(repo_key)['releases']
|
|
46
|
+
return [] unless releases.key?(tag)
|
|
47
|
+
|
|
48
|
+
releases[tag]['files'] || []
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def mark_processed(repo_key, tag, content_hash, files)
|
|
52
|
+
repo = ensure_repo(repo_key)
|
|
53
|
+
repo['releases'][tag] = {
|
|
54
|
+
'content_hash' => content_hash.to_s,
|
|
55
|
+
'files' => files
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def cleanup_stale(repo_key, current_tags)
|
|
60
|
+
repo = repo_state(repo_key)
|
|
61
|
+
releases = repo['releases']
|
|
62
|
+
removed = 0
|
|
63
|
+
|
|
64
|
+
releases.each do |tag, entry|
|
|
65
|
+
next if current_tags.include?(tag)
|
|
66
|
+
|
|
67
|
+
(entry['files'] || []).each do |file|
|
|
68
|
+
path = File.join(@output_dir, file)
|
|
69
|
+
if File.exist?(path)
|
|
70
|
+
File.delete(path)
|
|
71
|
+
removed += 1
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
releases.delete(tag)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
removed
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def empty_state
|
|
83
|
+
{ 'last_run' => Time.now.utc.iso8601, 'repos' => {} }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def repo_state(repo_key)
|
|
87
|
+
@state['repos'][repo_key] || { 'etag' => nil, 'releases' => {} }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def ensure_repo(repo_key)
|
|
91
|
+
@state['repos'][repo_key] ||= { 'etag' => nil, 'releases' => {} }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
class NullDeltaState
|
|
96
|
+
def initialize; end
|
|
97
|
+
|
|
98
|
+
def load; end
|
|
99
|
+
def save; end
|
|
100
|
+
def etag(_repo_key) = nil
|
|
101
|
+
def set_etag(_repo_key, _etag) = nil
|
|
102
|
+
def processed?(*_args) = false
|
|
103
|
+
def release_files(*_args) = []
|
|
104
|
+
def mark_processed(*_args); end
|
|
105
|
+
def cleanup_stale(*_args) = 0
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Metanorma
|
|
4
|
+
module Release
|
|
5
|
+
class DocumentId
|
|
6
|
+
def self.from_raw(raw_identifier)
|
|
7
|
+
normalized = raw_identifier.to_s.downcase.gsub(/[^a-z0-9]+/, '-').gsub(/^-|-$/, '')
|
|
8
|
+
raise ArgumentError, 'Document ID cannot be empty' if normalized.empty?
|
|
9
|
+
|
|
10
|
+
new(normalized)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.from_normalized(value)
|
|
14
|
+
raise ArgumentError, 'Document ID cannot be empty' if value.nil? || value.strip.empty?
|
|
15
|
+
|
|
16
|
+
new(value.to_s.strip)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(value)
|
|
20
|
+
@value = value
|
|
21
|
+
freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_s
|
|
25
|
+
@value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tag_prefix
|
|
29
|
+
@value
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def file_name
|
|
33
|
+
@value
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def eql?(other)
|
|
37
|
+
other.is_a?(self.class) && @value == other.to_s
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def hash
|
|
41
|
+
@value.hash
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Metanorma
|
|
6
|
+
module Release
|
|
7
|
+
DocumentFile = Struct.new(:name, :path, keyword_init: true) do
|
|
8
|
+
def extension
|
|
9
|
+
File.extname(name).delete_prefix('.')
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
DocumentSource = Struct.new(
|
|
14
|
+
:owner, :repo, :tag, :release_url, :release_date,
|
|
15
|
+
keyword_init: true
|
|
16
|
+
) do
|
|
17
|
+
def repo_key
|
|
18
|
+
"#{owner}/#{repo}"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
IndexParameters = Struct.new(
|
|
23
|
+
:organizations, :channels, :topic, :repo_count,
|
|
24
|
+
keyword_init: true
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
IndexSummary = Struct.new(
|
|
28
|
+
:repo_count, :document_count, :channels_found,
|
|
29
|
+
keyword_init: true
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
class AggregatedDocument
|
|
33
|
+
def self.from_h(hash)
|
|
34
|
+
files = (hash['files'] || []).map do |f|
|
|
35
|
+
DocumentFile.new(name: f['name'], path: f['path'])
|
|
36
|
+
end
|
|
37
|
+
source_data = hash['source'] || {}
|
|
38
|
+
source = DocumentSource.new(
|
|
39
|
+
owner: source_data['owner'], repo: source_data['repo'],
|
|
40
|
+
tag: source_data['tag'], release_url: source_data['releaseUrl'],
|
|
41
|
+
release_date: source_data['releaseDate']
|
|
42
|
+
)
|
|
43
|
+
new(
|
|
44
|
+
id: hash['id'], title: hash['title'], edition: hash['edition'],
|
|
45
|
+
stage: hash['stage'], doctype: hash.fetch('doctype', ''),
|
|
46
|
+
channels: hash['channels'] || [], formats: hash['formats'] || [],
|
|
47
|
+
flavor: hash['flavor'], content_hash: hash['contentHash'],
|
|
48
|
+
source: source, files: files
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
attr_reader :id, :title, :edition, :stage, :doctype, :channels,
|
|
53
|
+
:formats, :flavor, :content_hash, :source, :files
|
|
54
|
+
|
|
55
|
+
def initialize(id:, title:, edition:, stage:, doctype:, channels:,
|
|
56
|
+
formats:, flavor:, content_hash:, source:, files:)
|
|
57
|
+
@id = id
|
|
58
|
+
@title = title
|
|
59
|
+
@edition = edition
|
|
60
|
+
@stage = stage
|
|
61
|
+
@doctype = doctype
|
|
62
|
+
@channels = channels.freeze
|
|
63
|
+
@formats = formats.freeze
|
|
64
|
+
@flavor = flavor
|
|
65
|
+
@content_hash = content_hash
|
|
66
|
+
@source = source
|
|
67
|
+
@files = files.freeze
|
|
68
|
+
freeze
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_h
|
|
72
|
+
{
|
|
73
|
+
'id' => id, 'title' => title, 'edition' => edition,
|
|
74
|
+
'stage' => stage, 'doctype' => doctype,
|
|
75
|
+
'channels' => channels, 'formats' => formats,
|
|
76
|
+
'flavor' => flavor, 'contentHash' => content_hash,
|
|
77
|
+
'source' => {
|
|
78
|
+
'owner' => source.owner, 'repo' => source.repo,
|
|
79
|
+
'tag' => source.tag, 'releaseUrl' => source.release_url,
|
|
80
|
+
'releaseDate' => source.release_date
|
|
81
|
+
},
|
|
82
|
+
'files' => files.map { |f| { 'name' => f.name, 'path' => f.path } }
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
class DocumentIndex
|
|
88
|
+
SCHEMA_VERSION = 1
|
|
89
|
+
|
|
90
|
+
SchemaError = Class.new(StandardError)
|
|
91
|
+
|
|
92
|
+
def self.from_json(json_string)
|
|
93
|
+
data = JSON.parse(json_string)
|
|
94
|
+
validate!(data)
|
|
95
|
+
new(
|
|
96
|
+
documents: (data['documents'] || []).map { |d| AggregatedDocument.from_h(d) },
|
|
97
|
+
parameters: IndexParameters.new(
|
|
98
|
+
organizations: data.dig('parameters', 'organizations') || [],
|
|
99
|
+
channels: data.dig('parameters', 'channels') || [],
|
|
100
|
+
topic: data.dig('parameters', 'topic'),
|
|
101
|
+
repo_count: data.dig('parameters', 'repoCount') || 0
|
|
102
|
+
),
|
|
103
|
+
generated_at: data['generatedAt']
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.from_documents(documents, parameters:)
|
|
108
|
+
new(documents: documents, parameters: parameters)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def initialize(documents:, parameters:, generated_at: nil)
|
|
112
|
+
@documents = documents.freeze
|
|
113
|
+
@parameters = parameters
|
|
114
|
+
@generated_at = generated_at || Time.now.utc.iso8601
|
|
115
|
+
freeze
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
attr_reader :documents, :parameters
|
|
119
|
+
|
|
120
|
+
def summary
|
|
121
|
+
IndexSummary.new(
|
|
122
|
+
repo_count: @parameters.repo_count,
|
|
123
|
+
document_count: @documents.length,
|
|
124
|
+
channels_found: channels
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def channels
|
|
129
|
+
@documents.flat_map(&:channels).uniq.sort
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def document_count
|
|
133
|
+
@documents.length
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def empty?
|
|
137
|
+
@documents.empty?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def to_h
|
|
141
|
+
{
|
|
142
|
+
'version' => SCHEMA_VERSION,
|
|
143
|
+
'generatedAt' => @generated_at,
|
|
144
|
+
'parameters' => {
|
|
145
|
+
'organizations' => @parameters.organizations,
|
|
146
|
+
'channels' => @parameters.channels,
|
|
147
|
+
'topic' => @parameters.topic,
|
|
148
|
+
'repoCount' => @parameters.repo_count
|
|
149
|
+
},
|
|
150
|
+
'summary' => {
|
|
151
|
+
'repoCount' => summary.repo_count,
|
|
152
|
+
'documentCount' => summary.document_count,
|
|
153
|
+
'channelsFound' => summary.channels_found
|
|
154
|
+
},
|
|
155
|
+
'documents' => @documents.map(&:to_h)
|
|
156
|
+
}
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def to_json(*_args)
|
|
160
|
+
JSON.generate(to_h)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def write(path)
|
|
164
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
165
|
+
File.write(path, to_json)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.validate!(data)
|
|
169
|
+
raise SchemaError, "Missing 'version' field" unless data.key?('version')
|
|
170
|
+
unless data['version'] == SCHEMA_VERSION
|
|
171
|
+
raise SchemaError,
|
|
172
|
+
"Unsupported schema version: #{data['version']}. Expected #{SCHEMA_VERSION}"
|
|
173
|
+
end
|
|
174
|
+
raise SchemaError, "Missing 'documents' field" unless data.key?('documents')
|
|
175
|
+
|
|
176
|
+
data['documents'].each do |doc|
|
|
177
|
+
raise SchemaError, "Document missing required field 'id'" unless doc.key?('id')
|
|
178
|
+
raise SchemaError, "Document missing required field 'title'" unless doc.key?('title')
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|