notion_publish 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 +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE +21 -0
- data/README.md +221 -0
- data/docs/notion-publish-home-page.md +105 -0
- data/docs/usage.md +648 -0
- data/exe/notion-publish +6 -0
- data/lib/notion_publish/adopter.rb +77 -0
- data/lib/notion_publish/cli.rb +212 -0
- data/lib/notion_publish/client.rb +226 -0
- data/lib/notion_publish/commands/adopt.rb +114 -0
- data/lib/notion_publish/commands/command.rb +114 -0
- data/lib/notion_publish/commands/properties.rb +33 -0
- data/lib/notion_publish/commands/publish.rb +134 -0
- data/lib/notion_publish/commands/relink.rb +95 -0
- data/lib/notion_publish/commands/reporting.rb +57 -0
- data/lib/notion_publish/commands/republish.rb +156 -0
- data/lib/notion_publish/commands/status.rb +84 -0
- data/lib/notion_publish/commands/whoami.rb +27 -0
- data/lib/notion_publish/commands.rb +17 -0
- data/lib/notion_publish/decoration.rb +75 -0
- data/lib/notion_publish/document.rb +75 -0
- data/lib/notion_publish/errors.rb +62 -0
- data/lib/notion_publish/fixups.rb +139 -0
- data/lib/notion_publish/links.rb +65 -0
- data/lib/notion_publish/log.rb +49 -0
- data/lib/notion_publish/manifest.rb +224 -0
- data/lib/notion_publish/media.rb +90 -0
- data/lib/notion_publish/notion_digest.rb +23 -0
- data/lib/notion_publish/pool.rb +103 -0
- data/lib/notion_publish/progress.rb +103 -0
- data/lib/notion_publish/property_set.rb +116 -0
- data/lib/notion_publish/publisher.rb +475 -0
- data/lib/notion_publish/reference.rb +84 -0
- data/lib/notion_publish/resolver.rb +216 -0
- data/lib/notion_publish/schema.rb +206 -0
- data/lib/notion_publish/settings.rb +73 -0
- data/lib/notion_publish/sharing.rb +47 -0
- data/lib/notion_publish/status.rb +127 -0
- data/lib/notion_publish/target.rb +34 -0
- data/lib/notion_publish/uploader.rb +85 -0
- data/lib/notion_publish/users.rb +49 -0
- data/lib/notion_publish/version.rb +5 -0
- data/lib/notion_publish.rb +31 -0
- metadata +96 -0
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "yaml"
|
|
6
|
+
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
|
|
9
|
+
module NotionPublish
|
|
10
|
+
# Where each document was published, and enough about it to tell whether
|
|
11
|
+
# republishing has anything to do.
|
|
12
|
+
#
|
|
13
|
+
# Tool-written, never hand-edited, committed alongside the documents. One per
|
|
14
|
+
# repository so that links between documents in different directories can be
|
|
15
|
+
# resolved from a single place.
|
|
16
|
+
class Manifest
|
|
17
|
+
FILENAME = "notion-publish-manifest.yml"
|
|
18
|
+
|
|
19
|
+
HEADER = <<~TEXT
|
|
20
|
+
# Generated by notion-publish. Do not edit by hand.
|
|
21
|
+
# Maps each Markdown source to the Notion page it mirrors.
|
|
22
|
+
# To re-point an entry, delete it and publish again.
|
|
23
|
+
# Settings belong in .notion-publish.yml, which this tool never rewrites.
|
|
24
|
+
TEXT
|
|
25
|
+
|
|
26
|
+
ENTRY_FIELDS = %w[id url parent properties flag_properties title_override keep_h1 source_sha256
|
|
27
|
+
properties_sha256 flag_properties_sha256 notion_sha256 published_at].freeze
|
|
28
|
+
|
|
29
|
+
# One published document.
|
|
30
|
+
#
|
|
31
|
+
# +flag_properties+ names the properties whose values came from
|
|
32
|
+
# --property or --properties-json rather than front matter. Their values
|
|
33
|
+
# are not recorded, so `republish` leaves them alone. It is nil on entries
|
|
34
|
+
# written before it existed, which is how `republish` recognises them.
|
|
35
|
+
# +title_override+ and +keep_h1+ record --title and --keep-h1 for the
|
|
36
|
+
# same reason.
|
|
37
|
+
Entry = Data.define(:id, :url, :parent, :properties, :flag_properties, :title_override, :keep_h1,
|
|
38
|
+
:source_sha256, :properties_sha256, :flag_properties_sha256,
|
|
39
|
+
:notion_sha256, :published_at) do
|
|
40
|
+
# Only the page itself is required. Everything else accumulates: adopting
|
|
41
|
+
# records no source hash, and older entries predate later fields.
|
|
42
|
+
def initialize(id:, url:, parent: nil, properties: [], flag_properties: nil, title_override: nil,
|
|
43
|
+
keep_h1: nil, source_sha256: nil, properties_sha256: nil, flag_properties_sha256: nil,
|
|
44
|
+
notion_sha256: nil, published_at: nil)
|
|
45
|
+
super
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_h
|
|
49
|
+
ENTRY_FIELDS.to_h { |field| [field, public_send(field)] }.compact
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.from(hash)
|
|
53
|
+
new(**ENTRY_FIELDS.to_h { |field| [field.to_sym, hash[field]] }, properties: hash["properties"] || [])
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
attr_reader :path
|
|
58
|
+
|
|
59
|
+
# --manifest wins; then an existing manifest walking up from the
|
|
60
|
+
# document; then the repository root; then the document's own directory.
|
|
61
|
+
def self.locate(document_path, override: nil)
|
|
62
|
+
locate_in(File.dirname(File.expand_path(document_path)), override: override)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# The same search, starting from a directory rather than a document.
|
|
66
|
+
def self.locate_in(dir, override: nil)
|
|
67
|
+
return new(File.expand_path(override)) if override
|
|
68
|
+
|
|
69
|
+
found = walk_up(dir) { |d| File.file?(File.join(d, FILENAME)) }
|
|
70
|
+
return new(File.join(found, FILENAME)) if found
|
|
71
|
+
|
|
72
|
+
root = walk_up(dir) { |d| File.directory?(File.join(d, ".git")) }
|
|
73
|
+
new(File.join(root || dir, FILENAME))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.walk_up(dir)
|
|
77
|
+
loop do
|
|
78
|
+
return dir if yield(dir)
|
|
79
|
+
|
|
80
|
+
parent = File.dirname(dir)
|
|
81
|
+
return nil if parent == dir
|
|
82
|
+
|
|
83
|
+
dir = parent
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def initialize(path)
|
|
88
|
+
@path = path
|
|
89
|
+
@data = File.file?(path) ? load_file : {}
|
|
90
|
+
@created = !File.file?(path)
|
|
91
|
+
# Keys removed in memory, so the merge with disk does not put them back.
|
|
92
|
+
@forgotten = []
|
|
93
|
+
# Several pages are checked and recorded at once; the file lock guards
|
|
94
|
+
# other processes, this guards the threads of this one.
|
|
95
|
+
@lock = Monitor.new
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def dir = File.dirname(path)
|
|
99
|
+
def created? = @created
|
|
100
|
+
def pages = @data["pages"] ||= {}
|
|
101
|
+
def workspace_id = @data["workspace_id"]
|
|
102
|
+
|
|
103
|
+
def workspace_id=(value)
|
|
104
|
+
@lock.synchronize do
|
|
105
|
+
@data["workspace_id"] = value if value && @data["workspace_id"] != value
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def key_for(absolute_path)
|
|
110
|
+
Pathname.new(File.expand_path(absolute_path)).relative_path_from(Pathname.new(dir)).to_s
|
|
111
|
+
rescue ArgumentError
|
|
112
|
+
File.expand_path(absolute_path)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def entry(absolute_path)
|
|
116
|
+
@lock.synchronize do
|
|
117
|
+
raw = pages[key_for(absolute_path)]
|
|
118
|
+
raw && Entry.from(raw)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def url_for(absolute_path) = entry(absolute_path)&.url
|
|
123
|
+
|
|
124
|
+
# Recording a key that was forgotten earlier in this run takes it off the
|
|
125
|
+
# forgotten list, or the save would delete the entry it is writing.
|
|
126
|
+
def record(absolute_path, entry)
|
|
127
|
+
@lock.synchronize do
|
|
128
|
+
key = key_for(absolute_path)
|
|
129
|
+
@forgotten.delete(key)
|
|
130
|
+
pages[key] = entry.to_h
|
|
131
|
+
save
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def forget(absolute_path)
|
|
136
|
+
@lock.synchronize do
|
|
137
|
+
key = key_for(absolute_path)
|
|
138
|
+
pages.delete(key)
|
|
139
|
+
@forgotten << key
|
|
140
|
+
save
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# Entries whose source file no longer exists. Reported, never acted on: the
|
|
145
|
+
# tool cannot tell a retirement from a rename.
|
|
146
|
+
def orphans
|
|
147
|
+
pages.keys.reject { |key| File.file?(File.expand_path(key, dir)) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def writable?
|
|
151
|
+
return File.writable?(path) if File.exist?(path)
|
|
152
|
+
|
|
153
|
+
File.directory?(dir) && File.writable?(dir)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Read-modify-write under an exclusive lock, so parallel invocations across a
|
|
157
|
+
# corpus cannot drop each other's entries.
|
|
158
|
+
def save
|
|
159
|
+
@lock.synchronize do
|
|
160
|
+
raise Error, unwritable_message unless writable?
|
|
161
|
+
|
|
162
|
+
# Lock the manifest itself rather than a sidecar: a stray .lock file next to
|
|
163
|
+
# committed state is litter, and one more thing to gitignore.
|
|
164
|
+
File.open(path, File::RDWR | File::CREAT, 0o644) do |file|
|
|
165
|
+
file.flock(File::LOCK_EX)
|
|
166
|
+
merged = merge_with_disk
|
|
167
|
+
file.rewind
|
|
168
|
+
file.truncate(0)
|
|
169
|
+
file.write(HEADER + YAML.dump(sorted(merged)))
|
|
170
|
+
@data = merged
|
|
171
|
+
end
|
|
172
|
+
@created = false
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
private
|
|
177
|
+
|
|
178
|
+
def merge_with_disk
|
|
179
|
+
on_disk = File.file?(path) ? load_file : {}
|
|
180
|
+
merged = on_disk.merge(@data)
|
|
181
|
+
merged["pages"] = (on_disk["pages"] || {}).merge(@data["pages"] || {})
|
|
182
|
+
@forgotten.each { |key| merged["pages"].delete(key) }
|
|
183
|
+
merged.compact
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Sorted keys keep a one-document publish to a one-entry diff.
|
|
187
|
+
def sorted(data)
|
|
188
|
+
out = {}
|
|
189
|
+
out["workspace_id"] = data["workspace_id"] if data["workspace_id"]
|
|
190
|
+
out["pages"] = data["pages"].sort.to_h if data["pages"] && !data["pages"].empty?
|
|
191
|
+
out
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def load_file
|
|
195
|
+
loaded = YAML.safe_load_file(path, permitted_classes: [], aliases: false)
|
|
196
|
+
raise ConfigError, malformed_message("expected a YAML mapping") unless loaded.nil? || loaded.is_a?(Hash)
|
|
197
|
+
|
|
198
|
+
loaded || {}
|
|
199
|
+
rescue Psych::Exception => e
|
|
200
|
+
raise ConfigError, malformed_message(e.message)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Refuse rather than starting from an empty manifest, which would republish
|
|
204
|
+
# everything as new and duplicate the lot.
|
|
205
|
+
def malformed_message(detail)
|
|
206
|
+
<<~MSG.strip
|
|
207
|
+
#{path} could not be read: #{detail}
|
|
208
|
+
|
|
209
|
+
Publishing has stopped.
|
|
210
|
+
Starting from an empty manifest would duplicate every published document.
|
|
211
|
+
Fix the file, or delete it and re-adopt the existing pages deliberately.
|
|
212
|
+
MSG
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def unwritable_message
|
|
216
|
+
<<~MSG.strip
|
|
217
|
+
Cannot write #{path}.
|
|
218
|
+
|
|
219
|
+
Checked before publishing on purpose: creating pages and then failing to
|
|
220
|
+
record them is the one failure that cannot be retried safely.
|
|
221
|
+
MSG
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotionPublish
|
|
4
|
+
# Finds local image references in a Markdown body.
|
|
5
|
+
#
|
|
6
|
+
# Notion's `markdown` parameter can only express *external* file references.
|
|
7
|
+
# A local path, an upload id, or anything else produces an image block with an
|
|
8
|
+
# empty URL -- and it does so without an error, so a dropped diagram looks
|
|
9
|
+
# like a successful publish. Local images therefore have to be uploaded and
|
|
10
|
+
# attached through the block API instead.
|
|
11
|
+
#
|
|
12
|
+
# To keep the server-side Markdown parser for everything else, each local
|
|
13
|
+
# image is swapped for a sentinel paragraph before publishing. The sentinel is
|
|
14
|
+
# located afterwards and the real image block is inserted in its place.
|
|
15
|
+
class Media
|
|
16
|
+
FENCE = /\A\s*(?:`{3,}|~{3,})/
|
|
17
|
+
# An image alone on its line. Only these can become blocks: Notion has no
|
|
18
|
+
# inline image, so an image sitting inside a sentence has nowhere to go.
|
|
19
|
+
STANDALONE = /\A\s*!\[([^\]]*)\]\(\s*([^)\s]+?)\s*(?:"[^"]*")?\s*\)\s*\z/
|
|
20
|
+
ANY_IMAGE = /!\[[^\]]*\]\([^)]*\)/
|
|
21
|
+
EXTERNAL = %r{\Ahttps?://}i
|
|
22
|
+
|
|
23
|
+
Image = Data.define(:index, :alt, :path) do
|
|
24
|
+
def sentinel = format("NOTIONPUBLISHIMAGE%04d", index)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :images, :inline_paths
|
|
28
|
+
|
|
29
|
+
def self.scan(body, base_dir:) = new(body, base_dir: base_dir)
|
|
30
|
+
|
|
31
|
+
def initialize(body, base_dir:)
|
|
32
|
+
@body = body.to_s
|
|
33
|
+
@base_dir = base_dir
|
|
34
|
+
@images = []
|
|
35
|
+
@inline_paths = []
|
|
36
|
+
@rewritten = rewrite
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def any? = !images.empty?
|
|
40
|
+
|
|
41
|
+
# The body with each local image replaced by its sentinel, ready to send as
|
|
42
|
+
# the `markdown` parameter.
|
|
43
|
+
def body_with_sentinels = @rewritten
|
|
44
|
+
|
|
45
|
+
def resolved_path(image) = File.expand_path(image.path, @base_dir)
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def rewrite
|
|
50
|
+
in_fence = false
|
|
51
|
+
index = 0
|
|
52
|
+
|
|
53
|
+
lines = @body.lines.map do |line|
|
|
54
|
+
if line.match?(FENCE)
|
|
55
|
+
in_fence = !in_fence
|
|
56
|
+
next line
|
|
57
|
+
end
|
|
58
|
+
next line if in_fence
|
|
59
|
+
|
|
60
|
+
match = line.match(STANDALONE)
|
|
61
|
+
if match && local?(match[2])
|
|
62
|
+
image = Image.new(index: index, alt: match[1], path: unescape(match[2]))
|
|
63
|
+
@images << image
|
|
64
|
+
index += 1
|
|
65
|
+
next "#{line[/\A\s*/]}#{image.sentinel}\n"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# An image among other text cannot become a block. Note it so the caller
|
|
69
|
+
# can say so rather than letting it publish as an empty URL.
|
|
70
|
+
collect_inline(line) if match.nil? && line.match?(ANY_IMAGE)
|
|
71
|
+
line
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
lines.join
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def collect_inline(line)
|
|
78
|
+
line.scan(/!\[[^\]]*\]\(\s*([^)\s]+?)\s*(?:"[^"]*")?\s*\)/) do |(url)|
|
|
79
|
+
@inline_paths << url if local?(url)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Anything that is not a well-formed http(s) URL has to be treated as local.
|
|
84
|
+
# A malformed URL produces the same silent empty-URL block that a relative
|
|
85
|
+
# path does, so guessing charitably here would hide the failure.
|
|
86
|
+
def local?(url) = !url.to_s.match?(EXTERNAL)
|
|
87
|
+
|
|
88
|
+
def unescape(url) = url.to_s.gsub("%20", " ").delete_prefix("<").delete_suffix(">")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module NotionPublish
|
|
6
|
+
# The hash of a page as Notion returns it, used to notice edits made in
|
|
7
|
+
# Notion.
|
|
8
|
+
#
|
|
9
|
+
# An uploaded file comes back as a signed S3 URL whose query string is
|
|
10
|
+
# minted fresh on every read, so the raw Markdown of any page with an
|
|
11
|
+
# uploaded image differs from one read to the next. The signature is
|
|
12
|
+
# removed before hashing; the path, which names the file, is kept. Pages
|
|
13
|
+
# without such URLs hash exactly as their raw Markdown does.
|
|
14
|
+
module NotionDigest
|
|
15
|
+
SIGNED_QUERY = %r{(https://[^\s)"'<>]+?)\?[^\s)"'<>]*X-Amz-[^\s)"'<>]*}
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def of(markdown) = Digest::SHA256.hexdigest(stable(markdown))
|
|
20
|
+
|
|
21
|
+
def stable(markdown) = markdown.to_s.gsub(SIGNED_QUERY, '\1')
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotionPublish
|
|
4
|
+
# Runs work over a list a few items at a time, and hands the results back
|
|
5
|
+
# in the list's order.
|
|
6
|
+
#
|
|
7
|
+
# Checking a page is almost all waiting on Notion, so a few requests in
|
|
8
|
+
# flight cut the time roughly by that factor. Notion allows about three
|
|
9
|
+
# requests a second on average; more workers than that would mostly buy
|
|
10
|
+
# rate-limit retries.
|
|
11
|
+
#
|
|
12
|
+
# Only the work runs on worker threads. The +started+ and +finished+
|
|
13
|
+
# callbacks and the block run on the calling thread, so everything that
|
|
14
|
+
# prints does so from one thread, in order.
|
|
15
|
+
class Pool
|
|
16
|
+
SIZE = 3
|
|
17
|
+
|
|
18
|
+
# Calls +work+ with each item on a worker thread. On the calling thread,
|
|
19
|
+
# calls +started+ with an item when a worker picks it up, +finished+ with
|
|
20
|
+
# the number of items done so far, and yields each item with its result
|
|
21
|
+
# in the original order as soon as every item before it is done. Returns
|
|
22
|
+
# the results in order.
|
|
23
|
+
#
|
|
24
|
+
# An exception raised by +work+ stops the run and is raised here, once
|
|
25
|
+
# the items before it have been yielded.
|
|
26
|
+
def self.run(items, work:, size: SIZE, started: nil, finished: nil, &emit)
|
|
27
|
+
new(items, work: work, started: started, finished: finished, emit: emit).run(size)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def initialize(items, work:, started:, finished:, emit:)
|
|
31
|
+
@items = items
|
|
32
|
+
@work = work
|
|
33
|
+
@started = started
|
|
34
|
+
@finished = finished
|
|
35
|
+
@emit = emit
|
|
36
|
+
@results = Array.new(items.length)
|
|
37
|
+
@events = Queue.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run(size)
|
|
41
|
+
return [] if @items.empty?
|
|
42
|
+
|
|
43
|
+
workers = Array.new([size, @items.length].min) { worker(jobs) }
|
|
44
|
+
collect
|
|
45
|
+
@results.map { |_, value| value }
|
|
46
|
+
ensure
|
|
47
|
+
workers&.each(&:kill)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def jobs
|
|
53
|
+
@jobs ||= Queue.new.tap do |queue|
|
|
54
|
+
@items.each_with_index { |item, index| queue << [item, index] }
|
|
55
|
+
queue.close
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def worker(queue)
|
|
60
|
+
Thread.new do
|
|
61
|
+
while (job = queue.pop)
|
|
62
|
+
item, index = job
|
|
63
|
+
@events << [:started, index]
|
|
64
|
+
@results[index] = attempt(item)
|
|
65
|
+
@events << [:finished, index]
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def attempt(item)
|
|
71
|
+
[:ok, @work.call(item)]
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
[:raised, e]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Until every item has reported finishing, not merely until every result
|
|
77
|
+
# is in: a worker stores its result just before it says so.
|
|
78
|
+
def collect
|
|
79
|
+
done = 0
|
|
80
|
+
emitted = 0
|
|
81
|
+
while done < @items.length
|
|
82
|
+
kind, index = @events.pop
|
|
83
|
+
next @started&.call(@items[index]) if kind == :started
|
|
84
|
+
|
|
85
|
+
done += 1
|
|
86
|
+
@finished&.call(done)
|
|
87
|
+
emitted = emit_ready(emitted)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Yields every finished result that has no unfinished item before it.
|
|
92
|
+
def emit_ready(emitted)
|
|
93
|
+
while emitted < @items.length && @results[emitted]
|
|
94
|
+
status, value = @results[emitted]
|
|
95
|
+
raise value if status == :raised
|
|
96
|
+
|
|
97
|
+
@emit&.call(@items[emitted], value)
|
|
98
|
+
emitted += 1
|
|
99
|
+
end
|
|
100
|
+
emitted
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "delegate"
|
|
4
|
+
require "io/console"
|
|
5
|
+
|
|
6
|
+
module NotionPublish
|
|
7
|
+
# A single progress line on a terminal, such as "Checking 12/38 a.md":
|
|
8
|
+
# how many items are finished, and one that is in progress. It is rewritten
|
|
9
|
+
# in place and cleared when the run ends.
|
|
10
|
+
#
|
|
11
|
+
# It exists only when stderr is a terminal, following git, curl, and rsync,
|
|
12
|
+
# so CI logs and pipes never see it. Output written through #wrap clears the
|
|
13
|
+
# line first and redraws it afterwards, so results and the progress line
|
|
14
|
+
# never share a line. A disabled Progress does nothing and wraps nothing.
|
|
15
|
+
class Progress
|
|
16
|
+
CLEAR = "\r\e[K"
|
|
17
|
+
DEFAULT_WIDTH = 80
|
|
18
|
+
|
|
19
|
+
# An IO that keeps the progress line out of the way of what it writes.
|
|
20
|
+
class Output < SimpleDelegator
|
|
21
|
+
def initialize(io, progress)
|
|
22
|
+
super(io)
|
|
23
|
+
@progress = progress
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def puts(*) = @progress.around { __getobj__.puts(*) }
|
|
27
|
+
def print(*) = @progress.around { __getobj__.print(*) }
|
|
28
|
+
def write(*) = @progress.around { __getobj__.write(*) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(io, enabled:)
|
|
32
|
+
@io = io
|
|
33
|
+
@enabled = enabled
|
|
34
|
+
@line = nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def enabled? = @enabled
|
|
38
|
+
|
|
39
|
+
def wrap(io) = enabled? ? Output.new(io, self) : io
|
|
40
|
+
|
|
41
|
+
def start(verb, total)
|
|
42
|
+
@verb = verb
|
|
43
|
+
@total = total
|
|
44
|
+
@done = 0
|
|
45
|
+
@label = ""
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Names an item being worked on. With several in flight, the line shows
|
|
49
|
+
# the one picked up most recently.
|
|
50
|
+
def started(label)
|
|
51
|
+
@label = label
|
|
52
|
+
redraw
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Counts finished items, so the number only rises and reaches the total
|
|
56
|
+
# exactly when the work is done.
|
|
57
|
+
def finished(done)
|
|
58
|
+
@done = done
|
|
59
|
+
redraw
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Clears the line for good.
|
|
63
|
+
def finish
|
|
64
|
+
return unless enabled? && @line
|
|
65
|
+
|
|
66
|
+
@io.write(CLEAR)
|
|
67
|
+
@line = nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def around
|
|
71
|
+
return yield unless enabled? && @line
|
|
72
|
+
|
|
73
|
+
@io.write(CLEAR)
|
|
74
|
+
result = yield
|
|
75
|
+
draw
|
|
76
|
+
result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def redraw
|
|
82
|
+
return unless enabled?
|
|
83
|
+
|
|
84
|
+
@line = fit("#{@verb} #{@done}/#{@total} #{@label}")
|
|
85
|
+
draw
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def draw
|
|
89
|
+
@io.write("#{CLEAR}#{@line}")
|
|
90
|
+
@io.flush
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A line wider than the terminal wraps, and a wrapped line cannot be
|
|
94
|
+
# rewritten in place.
|
|
95
|
+
def fit(text)
|
|
96
|
+
width = (@io.winsize[1] if @io.respond_to?(:winsize)).to_i
|
|
97
|
+
width = DEFAULT_WIDTH unless width > 10
|
|
98
|
+
text.length < width ? text : "#{text[0, width - 4]}..."
|
|
99
|
+
rescue SystemCallError, IOError
|
|
100
|
+
text[0, DEFAULT_WIDTH - 1]
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
|
|
9
|
+
module NotionPublish
|
|
10
|
+
# The requested property values, before the schema has been consulted.
|
|
11
|
+
#
|
|
12
|
+
# Every value is held as an array of strings. Repeating --property with the
|
|
13
|
+
# same name accumulates; whether that is legal depends on the property's type,
|
|
14
|
+
# which Schema decides later.
|
|
15
|
+
class PropertySet
|
|
16
|
+
include Enumerable
|
|
17
|
+
|
|
18
|
+
FRONT_MATTER = "front matter"
|
|
19
|
+
|
|
20
|
+
def self.parse_pair(pair)
|
|
21
|
+
name, separator, value = pair.to_s.partition("=")
|
|
22
|
+
raise Error, pair_message(pair) if separator.empty? || name.strip.empty?
|
|
23
|
+
|
|
24
|
+
[name.strip, value]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.pair_message(pair)
|
|
28
|
+
<<~MSG.strip
|
|
29
|
+
Cannot read #{pair.to_s.inspect} as a property.
|
|
30
|
+
|
|
31
|
+
Use --property 'Name=Value'. Repeat it to give a multi-select or people
|
|
32
|
+
property more than one value:
|
|
33
|
+
|
|
34
|
+
--property 'Function=Operations' --property 'Function=Legal'
|
|
35
|
+
MSG
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Layered lowest to highest: front matter, then --properties-json, then
|
|
39
|
+
# --property. Each layer replaces a name outright rather than adding to it,
|
|
40
|
+
# so overriding one property never drags the old values along.
|
|
41
|
+
def self.build(front_matter: nil, json: nil, pairs: [])
|
|
42
|
+
set = new
|
|
43
|
+
set.merge_hash(front_matter, source: FRONT_MATTER)
|
|
44
|
+
set.merge_hash(parse_json(json), source: "--properties-json") if json
|
|
45
|
+
set.merge_pairs(pairs)
|
|
46
|
+
set
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.parse_json(json)
|
|
50
|
+
parsed = JSON.parse(json.to_s)
|
|
51
|
+
raise Error, "--properties-json must be a JSON object, got #{parsed.class}." unless parsed.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
parsed
|
|
54
|
+
rescue JSON::ParserError => e
|
|
55
|
+
raise Error, "Could not parse --properties-json: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def initialize
|
|
59
|
+
@values = {}
|
|
60
|
+
@explicit = []
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def merge_hash(hash, source:)
|
|
64
|
+
return self if hash.nil? || hash.empty?
|
|
65
|
+
raise Error, "#{source} properties must be a mapping." unless hash.is_a?(Hash)
|
|
66
|
+
|
|
67
|
+
hash.each do |name, value|
|
|
68
|
+
@values[name.to_s] = normalise(value)
|
|
69
|
+
@explicit << name.to_s if source != FRONT_MATTER
|
|
70
|
+
end
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Properties named on the command line were asked for by this invocation;
|
|
75
|
+
# properties from front matter belong to the document. That matters when a
|
|
76
|
+
# destination cannot hold them: an explicit one is an error, a document one
|
|
77
|
+
# is skipped with a warning.
|
|
78
|
+
def explicit?(name) = @explicit.include?(name.to_s)
|
|
79
|
+
|
|
80
|
+
def merge_pairs(pairs)
|
|
81
|
+
seen = {}
|
|
82
|
+
Array(pairs).each do |pair|
|
|
83
|
+
name, value = self.class.parse_pair(pair)
|
|
84
|
+
# First --property for a name replaces any lower layer; later ones add.
|
|
85
|
+
@values[name] = seen[name] ? @values[name] + [value] : [value]
|
|
86
|
+
seen[name] = true
|
|
87
|
+
@explicit << name
|
|
88
|
+
end
|
|
89
|
+
self
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def each(&) = @values.each(&)
|
|
93
|
+
def empty? = @values.empty?
|
|
94
|
+
def names = @values.keys
|
|
95
|
+
def [](name) = @values[name]
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def normalise(value)
|
|
100
|
+
case value
|
|
101
|
+
when nil then [""]
|
|
102
|
+
when Array then value.map { |v| stringify(v) }
|
|
103
|
+
else [stringify(value)]
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def stringify(value)
|
|
108
|
+
case value
|
|
109
|
+
when true then "true"
|
|
110
|
+
when false then "false"
|
|
111
|
+
when Date, Time then value.iso8601
|
|
112
|
+
else value.to_s
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|