kozeki 0.1.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 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