kozeki 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 030de1696d26b4f3cf6ed9616dd273f1610cbc63dea404e0250950d587fcd581
4
+ data.tar.gz: 3a6c8652c52482442ed17dc5c057912c6792b65f64989954b9497e9c7a6aaa04
5
+ SHA512:
6
+ metadata.gz: 4e177182d9a52715721706494b858183c42c4391b4e8ac37890173bc15bcf1f6ec674d16ae5b00634f3ecefc9d211f08e10cacbf62e4db4ac60804ca408fa6da
7
+ data.tar.gz: e288bd18e158851513a2264ba5573e7980dfa8afc618633b805e8177473c2f8d51e7656b6a9ac3cfb23d20b183a1cf70887db355a00b7ccae20dc582de375229
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2023-08-06
4
+
5
+ - Initial release
data/Rakefile ADDED
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec
data/bin/kozeki ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'kozeki'
4
+
5
+ Kozeki::Cli.start
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+ require 'json'
3
+ require 'kozeki/state'
4
+ require 'kozeki/item'
5
+ require 'kozeki/collection'
6
+ require 'kozeki/collection_list'
7
+
8
+ module Kozeki
9
+ class Build
10
+
11
+ # @param state [State]
12
+ def initialize(state:, source_filesystem:, destination_filesystem:, collection_list_included_prefix: nil, collection_options: [], loader:, events: nil, incremental_build:, logger: nil)
13
+ @state = state
14
+
15
+ @source_filesystem = source_filesystem
16
+ @destination_filesystem = destination_filesystem
17
+ @collection_list_included_prefix = collection_list_included_prefix
18
+ @collection_options = collection_options
19
+ @loader = loader
20
+ @loader_cache = {}
21
+ @logger = logger
22
+
23
+ @events = events
24
+ @incremental_build = incremental_build
25
+
26
+ @updated_files = []
27
+ @build_id = nil
28
+ end
29
+
30
+ attr_accessor :events, :incremental_build
31
+ attr_reader :updated_files
32
+
33
+ def inspect
34
+ "#<#{self.class.name}#{self.__id__}>"
35
+ end
36
+
37
+ def incremental_build_possible?
38
+ @state.build_exist?
39
+ end
40
+
41
+ def incremental_build?
42
+ incremental_build_possible? && @incremental_build
43
+ end
44
+
45
+ def full_build?
46
+ !incremental_build?
47
+ end
48
+
49
+ def perform
50
+ raise "can't reuse" if @build_id
51
+ if full_build?
52
+ @logger&.info "Starting full build"
53
+ else
54
+ @logger&.info "Starting incremental build"
55
+ end
56
+
57
+ @state.transaction do
58
+ process_prepare
59
+ process_events
60
+ end
61
+ @state.transaction do
62
+ process_items_remove
63
+ process_items_update
64
+ end
65
+ @state.transaction do
66
+ process_garbage
67
+ end
68
+ @state.transaction do
69
+ process_collections
70
+ end
71
+ @destination_filesystem.flush
72
+ @state.transaction do
73
+ process_commit
74
+ end
75
+ @destination_filesystem.flush
76
+ end
77
+
78
+ private def process_prepare
79
+ @logger&.debug "=== Prepare ==="
80
+ @state.clear! if full_build?
81
+ @build_id = @state.create_build
82
+ @logger&.debug "Build ID: #{@build_id.inspect}"
83
+ end
84
+
85
+ private def process_events
86
+ @logger&.debug "=== Process incoming events ==="
87
+ events = if full_build? || @events.nil?
88
+ fs_list = @source_filesystem.list_entries()
89
+ fs_list.map do |entry|
90
+ Filesystem::Event.new(op: :update, path: entry.path, time: full_build? ? nil : entry.mtime.floor(4))
91
+ end
92
+ else
93
+ @events.dup
94
+ end
95
+
96
+ if fs_list
97
+ seen_paths = {}
98
+ events.each do |event|
99
+ next unless event.op == :update
100
+ seen_paths[event.path] = true
101
+ end
102
+ @state.list_record_paths.reject do |path|
103
+ seen_paths[path]
104
+ end.each do |path|
105
+ events.push(Filesystem::Event.new(op: :delete, path:, time: nil))
106
+ end
107
+ end
108
+
109
+ events.each do |event|
110
+ @logger&.debug "> #{event.inspect}" if incremental_build? && @events
111
+ case event.op
112
+ when :update
113
+ if event.time
114
+ begin
115
+ record = @state.find_record_by_path!(event.path)
116
+ diff = event.time.to_f.floor(3) - record.mtime.to_f.floor(3)
117
+ if diff > 0.005
118
+ @logger&.debug "> #{event.inspect}"
119
+ @logger&.debug " #{record.mtime} (#{record.mtime.to_f.floor(3)}) < #{event.time} (#{event.time.to_f.floor(3)})"
120
+ else
121
+ next
122
+ end
123
+ rescue State::NotFound
124
+ end
125
+ end
126
+
127
+ source = load_source(event.path)
128
+ record = @state.save_record(source.to_record)
129
+ @state.set_record_pending_build_action(record, :update)
130
+ @logger&.info "ID change: #{event.path.inspect}; #{record.id_was.inspect} => #{record.id.inspect}" if record.id_was
131
+ when :delete
132
+ begin
133
+ record = @state.find_record_by_path!(event.path)
134
+ @state.set_record_pending_build_action(record, :remove)
135
+ rescue State::NotFound
136
+ end
137
+ else
138
+ raise "unknown op #{event.inspect}"
139
+ end
140
+ end
141
+ end
142
+
143
+ private def process_items_remove
144
+ @logger&.debug "=== Delete items for removed sources ==="
145
+ removed_records = @state.list_records_by_pending_build_action(:remove)
146
+ removed_records.each do |record|
147
+ if @state.list_records_by_id(record.id).any? { _1.pending_build_action == :update }
148
+ @logger&.warn "Skip deletion: #{record.id.inspect} (#{record.path.inspect})"
149
+ next
150
+ end
151
+ @logger&.info "Delete: #{record.id.inspect} (#{record.path.inspect})"
152
+ @destination_filesystem.delete(['items', "#{record.id}.json"])
153
+ @state.set_record_collections_pending(record.id, [])
154
+ end
155
+ end
156
+
157
+ private def process_items_update
158
+ @logger&.debug "=== Render items for updated sources ==="
159
+ updating_records = @state.list_records_by_pending_build_action(:update)
160
+ updating_records.each do |record|
161
+ @logger&.info "Render: #{record.id.inspect} (#{record.path.inspect})"
162
+ source = load_source(record.path)
163
+
164
+ # ID uniqueness check
165
+ _existing_record = begin
166
+ @state.find_record!(source.id)
167
+ rescue State::NotFound; nil
168
+ end
169
+
170
+ item = build_item(source)
171
+ @destination_filesystem.write(source.item_path, "#{JSON.generate(item.as_json)}\n")
172
+ @state.set_record_collections_pending(item.id, item.meta.fetch(:collections, []))
173
+ @updated_files << source.item_path
174
+ end
175
+ end
176
+
177
+ private def process_garbage
178
+ @logger&.debug "=== Collect garbages; items without source ==="
179
+ item_ids = @state.list_item_ids_for_garbage_collection
180
+ item_ids.each do |item_id|
181
+ @logger&.debug "Checking: #{item_id.inspect}"
182
+ records = @state.list_records_by_id(item_id)
183
+ if records.empty?
184
+ @logger&.info "Garbage: #{item_id.inspect}"
185
+ @state.mark_item_id_to_remove(item_id)
186
+ @state.set_record_collections_pending(item_id, [])
187
+ @destination_filesystem.delete(['items', "#{item_id}.json"])
188
+ end
189
+ end
190
+ end
191
+
192
+ private def process_collections
193
+ @logger&.debug "=== Render updated collections ==="
194
+ collections = @state.list_collection_names_pending
195
+ return if collections.empty?
196
+
197
+ collections.each do |collection_name|
198
+ records = @state.list_collection_records(collection_name)
199
+ record_count_was = @state.count_collection_records(collection_name)
200
+
201
+ collection = make_collection(collection_name, records)
202
+ collection.pages.each do |page|
203
+ @logger&.info "Render: Collection #{collection_name.inspect} (#{page.item_path.inspect})"
204
+ @destination_filesystem.write(page.item_path, "#{JSON.generate(page.as_json)}\n")
205
+ @updated_files << page.item_path
206
+ end
207
+ collection.item_paths_for_missing_pages(record_count_was).each do |path|
208
+ @logger&.info "Delete: Collection #{collection.inspect} (#{path.inspect})"
209
+ @destination_filesystem.delete(path)
210
+ end
211
+ end
212
+
213
+ @logger&.info "Render: CollectionList"
214
+ collection_names = @state.list_collection_names_with_prefix(*@collection_list_included_prefix)
215
+ collection_list = CollectionList.new(collection_names)
216
+ @destination_filesystem.write(collection_list.item_path, "#{JSON.generate(collection_list.as_json)}\n")
217
+ @updated_files << collection_list.item_path
218
+ end
219
+
220
+ private def process_commit
221
+ @logger&.debug "=== Finishing build ==="
222
+ @logger&.debug "Flush pending actions"
223
+ @state.process_markers!
224
+ if full_build?
225
+ @logger&.info "Delete: untouched files from destination"
226
+ @destination_filesystem.retain_only(@updated_files)
227
+ end
228
+ @logger&.debug "Mark build completed"
229
+ @state.mark_build_completed(@build_id)
230
+ # TODO: @state.remove_old_builds
231
+ end
232
+
233
+ private def load_source(path)
234
+ @loader_cache[path] ||= begin
235
+ @logger&.debug("Load: #{path.inspect}")
236
+ @loader.try_read(path: path, filesystem: @source_filesystem) or raise "can't read #{path.inspect}"
237
+ end
238
+ end
239
+
240
+ private def build_item(source)
241
+ source.build_item
242
+ end
243
+
244
+ private def make_collection(name, records)
245
+ Collection.new(name, records, options: collection_option_for(name))
246
+ end
247
+
248
+ private def collection_option_for(collection_name)
249
+ retval = nil
250
+ len = -1
251
+ @collection_options.each do |x|
252
+ if collection_name.start_with?(x.prefix) && x.prefix.size > len
253
+ retval = x
254
+ len = x.prefix.size
255
+ end
256
+ end
257
+ retval
258
+ end
259
+ end
260
+ end
data/lib/kozeki/cli.rb ADDED
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+ require 'thor'
3
+ require 'json'
4
+
5
+ require 'kozeki/client'
6
+ require 'kozeki/config'
7
+ require 'kozeki/filesystem'
8
+
9
+ module Kozeki
10
+ class Cli < Thor
11
+ package_name 'kozeki'
12
+
13
+ desc 'build CONFIG_FILE', 'run a one-off build using CONFIG_FILE'
14
+ method_options :full => :boolean
15
+ method_options :events_from_stdin => :boolean
16
+ def build(config_file)
17
+ client = make_client(config_file)
18
+ client.build(
19
+ incremental_build: !options[:full],
20
+ events: options[:events_from_stdin] ? load_events_from_stdin() : nil,
21
+ )
22
+ end
23
+
24
+ desc 'watch CONFIG_FILE', 'run a continuous build by watching source filesystem using CONFIG_FILE'
25
+ def watch(config_file)
26
+ client = make_client(config_file)
27
+ client.watch
28
+ end
29
+
30
+ desc 'debug-state CONFIG_FILE', ''
31
+ def debug_state(config_file)
32
+ client = make_client(config_file)
33
+ state = State.open(path: client.config.state_path)
34
+ state.db.execute(%{select * from "records" order by "id" asc}).map { p record: _1 }
35
+ state.db.execute(%{select * from "collection_memberships" order by "collection" asc, "record_id" asc}).map { p collection_membership: _1 }
36
+ state.db.execute(%{select * from "item_ids" order by "id" asc}).map { p item_id: _1 }
37
+ end
38
+
39
+ no_commands do
40
+ private def make_client(config_file)
41
+ config = Config.load_file(config_file)
42
+ Client.new(config:)
43
+ end
44
+
45
+ private def load_events_from_stdin
46
+ j = JSON.parse($stdin.read, symbolize_names: true)
47
+ j.map do |x|
48
+ Filesystem::Event.new(
49
+ op: x.fetch(:op).to_sym,
50
+ path: x.fetch(:path),
51
+ time: nil,
52
+ )
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+ require 'kozeki/state'
3
+ require 'kozeki/build'
4
+
5
+ module Kozeki
6
+ class Client
7
+ # @param config [Config]
8
+ def initialize(config:)
9
+ @config = config
10
+ end
11
+
12
+ attr_reader :config
13
+
14
+ def build(incremental_build: true, events: nil)
15
+ begin
16
+ state = State.open(path: config.state_path)
17
+ build = Build.new(
18
+ state: state,
19
+ source_filesystem: @config.source_filesystem,
20
+ destination_filesystem: @config.destination_filesystem,
21
+ collection_list_included_prefix: @config.collection_list_included_prefix,
22
+ collection_options: @config.collection_options,
23
+ loader: @config.loader,
24
+ incremental_build:,
25
+ events:,
26
+ logger: @config.logger,
27
+ )
28
+ build.perform
29
+ ensure
30
+ state&.close
31
+ end
32
+
33
+ @config.after_build_callbacks.each do |cb|
34
+ cb.call(build)
35
+ end
36
+ end
37
+
38
+ def watch
39
+ build(incremental_build: true, events: nil)
40
+ stop = @config.source_filesystem.watch do |events|
41
+ build(incremental_build: true, events: events)
42
+ end
43
+ sleep
44
+ ensure
45
+ stop&.call
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+ require 'time'
3
+
4
+ module Kozeki
5
+ class Collection
6
+ def initialize(name, records, options: nil)
7
+ raise ArgumentError, "name cannot include /" if name.include?('/')
8
+ @name = name
9
+ @records = records
10
+ @options = options
11
+
12
+ @records_sorted = nil
13
+ end
14
+
15
+ attr_reader :name, :records, :options
16
+
17
+ def records_sorted
18
+ @records_sorted ||= @records.sort_by do |record|
19
+ record.timestamp&.then { -_1.to_i } || 0
20
+ end
21
+ end
22
+
23
+ def total_pages
24
+ @total_pages ||= calculate_total_pages(records.size)
25
+ end
26
+
27
+ def pages
28
+ case
29
+ when records.empty?
30
+ []
31
+ when options&.paginate && options&.max_items
32
+ total_pages.times.map do |i|
33
+ Page.new(parent: self, page: i+1)
34
+ end
35
+ else
36
+ [Page.new(parent: self, page: nil)]
37
+ end
38
+ end
39
+
40
+ def item_path_for_page(pagenum)
41
+ case pagenum
42
+ when 0
43
+ raise "[bug] page is 1-origin"
44
+ when nil, 1
45
+ ['collections', "#{name}.json"]
46
+ else
47
+ ['collections', "#{name}", "page-#{pagenum}.json"]
48
+ end
49
+ end
50
+
51
+ def item_paths_for_missing_pages(item_count_was)
52
+ total_pages_was = calculate_total_pages(item_count_was)
53
+ if (total_pages_was - total_pages) > 0
54
+ (total_pages+1..total_pages_was).map do |pagenum|
55
+ item_path_for_page(pagenum)
56
+ end
57
+ else
58
+ []
59
+ end
60
+ end
61
+
62
+ def calculate_total_pages(count)
63
+ if options&.paginate && options&.max_items
64
+ count.divmod(options.max_items).then {|(a,b)| a + (b>0 ? 1 : 0) }
65
+ else
66
+ count > 0 ? 1 : 0
67
+ end
68
+ end
69
+
70
+ class Page
71
+ def initialize(parent:, page:)
72
+ @parent = parent
73
+ @page = page
74
+
75
+ @total_pages = nil
76
+ end
77
+
78
+ def name; @parent.name; end
79
+ def options; @parent.options; end
80
+ def total_pages; @parent.total_pages; end
81
+
82
+ def records
83
+ case @page
84
+ when nil
85
+ @parent.records_sorted
86
+ when 0
87
+ raise "[bug] page is 1-origin"
88
+ else
89
+ @parent.records_sorted[(@page - 1) * options.max_items, options.max_items]
90
+ end
91
+ end
92
+
93
+ def as_json
94
+ {
95
+ kind: 'collection',
96
+ name: name,
97
+ items: records.map do |record|
98
+ {
99
+ id: record.id,
100
+ path: ['items', "#{record.id}.json"].join('/'),
101
+ meta: options&.meta_keys ? record.meta.slice(*options.meta_keys) : record.meta,
102
+ }
103
+ end.then do |page|
104
+ options&.max_items ? page[0, options.max_items] : page
105
+ end,
106
+ }.tap do |j|
107
+ j[:page] = page_info if @page
108
+ end
109
+ end
110
+
111
+ def item_path
112
+ @parent.item_path_for_page(@page)
113
+ end
114
+
115
+ def page_info
116
+ return nil unless @page
117
+ prev_page = 1 < @page ? @parent.item_path_for_page(@page-1) : nil
118
+ next_page = @page < total_pages ? @parent.item_path_for_page(@page+1) : nil
119
+ i = {
120
+ self: @page,
121
+ total_pages:,
122
+ first: @parent.item_path_for_page(1).join('/'),
123
+ last: @parent.item_path_for_page(total_pages).join('/'),
124
+ prev: prev_page&.join('/'),
125
+ next: next_page&.join('/'),
126
+ }
127
+ if @page == 1
128
+ i[:pages] = (1..total_pages).map do |pagenum|
129
+ @parent.item_path_for_page(pagenum).join('/')
130
+ end
131
+ end
132
+ i
133
+ end
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Kozeki
4
+ class CollectionList
5
+ def initialize(names)
6
+ @names = names
7
+ end
8
+
9
+ attr_reader :names
10
+
11
+ def as_json
12
+ {
13
+ kind: 'collection_list',
14
+ collections: names.sort.map do |name|
15
+ {
16
+ name:,
17
+ path: ['collections', "#{name}.json"].join('/'),
18
+ }
19
+ end,
20
+ }
21
+ end
22
+
23
+ def item_path
24
+ ['collections.json']
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+ require 'kozeki/dsl'
3
+ require 'kozeki/loader_chain'
4
+ require 'kozeki/local_filesystem'
5
+ require 'kozeki/markdown_loader'
6
+ require 'logger'
7
+
8
+ module Kozeki
9
+ class Config
10
+ CollectionOptions = Struct.new(:prefix, :max_items, :paginate, :meta_keys)
11
+
12
+ def initialize(options)
13
+ @options = options
14
+ @source_filesystem = nil
15
+ @destination_filesystem = nil
16
+ @loader = nil
17
+ @logger = nil
18
+ end
19
+
20
+ def self.load_file(path)
21
+ new(Dsl.load_file(path).options)
22
+ end
23
+
24
+ def self.configure(&block)
25
+ new(Dsl.eval(&block).options)
26
+ end
27
+
28
+ def [](k)
29
+ @options[k]
30
+ end
31
+
32
+ def fetch(k,*args)
33
+ @options.fetch(k,*args)
34
+ end
35
+
36
+ def base_directory
37
+ @options.fetch(:base_directory, '.')
38
+ end
39
+
40
+ def source_directory
41
+ @options.fetch(:source_directory)
42
+ end
43
+
44
+ def destination_directory
45
+ @options.fetch(:destination_directory)
46
+ end
47
+
48
+ def cache_directory
49
+ @options.fetch(:cache_directory, nil)
50
+ end
51
+
52
+ def collection_list_included_prefix
53
+ @options.fetch(:collection_list_included_prefix, nil)
54
+ end
55
+
56
+ def collection_options
57
+ @options.fetch(:collection_options, [])
58
+ end
59
+
60
+ def metadata_decorators
61
+ @options.fetch(:metadata_decorators, [])
62
+ end
63
+
64
+ def after_build_callbacks
65
+ @options.fetch(:after_build_callbacks, [])
66
+ end
67
+
68
+ def state_path
69
+ cache_directory ? File.join(File.expand_path(cache_directory, base_directory), 'state.sqlite3') : ':memory:'
70
+ end
71
+
72
+ def source_filesystem
73
+ @source_filesystem ||= @options.fetch(:source_filesystem) { LocalFilesystem.new(File.expand_path(source_directory, base_directory)) }
74
+ end
75
+
76
+ def destination_filesystem
77
+ @destination_filesystem ||= @options.fetch(:destination_filesystem) { LocalFilesystem.new(File.expand_path(destination_directory, base_directory)) }
78
+ end
79
+
80
+ def loader
81
+ @loader ||= LoaderChain.new(
82
+ loaders: [MarkdownLoader],
83
+ decorators: metadata_decorators,
84
+ )
85
+ end
86
+
87
+ def logger
88
+ @logger ||= Logger.new($stdout)
89
+ end
90
+ end
91
+ end