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,475 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
require_relative "notion_digest"
|
|
8
|
+
require_relative "decoration"
|
|
9
|
+
require_relative "errors"
|
|
10
|
+
require_relative "fixups"
|
|
11
|
+
require_relative "links"
|
|
12
|
+
require_relative "media"
|
|
13
|
+
require_relative "manifest"
|
|
14
|
+
require_relative "property_set"
|
|
15
|
+
require_relative "schema"
|
|
16
|
+
require_relative "status"
|
|
17
|
+
require_relative "uploader"
|
|
18
|
+
require_relative "users"
|
|
19
|
+
|
|
20
|
+
module NotionPublish
|
|
21
|
+
# Creates or updates a Notion page from a Document.
|
|
22
|
+
#
|
|
23
|
+
# Uses POST /v1/pages with the `markdown` body parameter for a new page and
|
|
24
|
+
# PATCH /v1/pages/:id/markdown with `replace_content` for an existing one, so
|
|
25
|
+
# Notion does the Markdown-to-block conversion in both directions. Updating in
|
|
26
|
+
# place keeps the page's URL, which is what makes the manifest worth
|
|
27
|
+
# committing.
|
|
28
|
+
class Publisher
|
|
29
|
+
# What a run did, so the caller can report it and a script can branch on it.
|
|
30
|
+
Outcome = Data.define(:action, :page, :entry, :detail) do
|
|
31
|
+
def blocked? = action == :blocked
|
|
32
|
+
def url = page && page["url"]
|
|
33
|
+
def id = page && page["id"]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Everything one publish needs, gathered once so the steps below do not
|
|
37
|
+
# pass a dozen arguments between them. +title+ is already resolved.
|
|
38
|
+
#
|
|
39
|
+
# +preserve+ lists properties this run must neither set nor clear: on a
|
|
40
|
+
# republish, the ones an earlier run set from flags, whose values were
|
|
41
|
+
# never recorded.
|
|
42
|
+
Job = Data.define(:document, :target, :manifest, :properties, :title, :title_given,
|
|
43
|
+
:warnings, :upload, :keep_h1, :icon, :cover, :preserve, :republish) do
|
|
44
|
+
def source = File.expand_path(document.path)
|
|
45
|
+
def base_dir = File.dirname(source)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Built property payloads, split by where the values came from: the
|
|
49
|
+
# document (front matter, the derived title, a recorded --title) or flags.
|
|
50
|
+
Properties = Data.define(:document, :flags) do
|
|
51
|
+
def all = document.merge(flags)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# The Markdown to send, and the local images it stands in for.
|
|
55
|
+
Body = Data.define(:markdown, :media, :uploads)
|
|
56
|
+
|
|
57
|
+
def initialize(client)
|
|
58
|
+
@client = client
|
|
59
|
+
@schemas = {}
|
|
60
|
+
@lock = Mutex.new
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def publish(document, target:, manifest: nil, properties: PropertySet.new, title: nil,
|
|
64
|
+
title_given: false, warnings: [], upload: true, keep_h1: false,
|
|
65
|
+
icon: nil, cover: nil, force: false, force_properties: false)
|
|
66
|
+
job = Job.new(document: document, target: target, manifest: manifest, properties: properties,
|
|
67
|
+
title: title || document.title, title_given: title_given, warnings: warnings,
|
|
68
|
+
upload: upload, keep_h1: keep_h1, icon: icon, cover: cover, preserve: [], republish: false)
|
|
69
|
+
run(job, force: force, force_properties: force_properties)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Publishes a document again from what its entry recorded: front matter,
|
|
73
|
+
# plus any --title and --keep-h1 the last single-file publish was given.
|
|
74
|
+
# Properties that were set from flags are left as they are.
|
|
75
|
+
def republish(document, entry:, target:, manifest:, warnings: [], upload: true, icon: nil, cover: nil,
|
|
76
|
+
force: false, force_properties: false)
|
|
77
|
+
job = Job.new(document: document, target: target, manifest: manifest,
|
|
78
|
+
properties: PropertySet.build(front_matter: document.properties),
|
|
79
|
+
title: entry.title_override || document.title, title_given: !entry.title_override.nil?,
|
|
80
|
+
warnings: warnings, upload: upload, keep_h1: entry.keep_h1 == true, icon: icon, cover: cover,
|
|
81
|
+
preserve: entry.flag_properties || [], republish: true)
|
|
82
|
+
|
|
83
|
+
if (reason = unrecorded_flags(job, entry))
|
|
84
|
+
return Outcome.new(action: :skipped, page: { "url" => entry.url, "id" => entry.id }, entry: entry,
|
|
85
|
+
detail: reason)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
run(job, force: force, force_properties: force_properties)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def scan_media(document)
|
|
92
|
+
Media.scan(document.body, base_dir: File.dirname(File.expand_path(document.path)))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Cached per destination, since a republish reads the same schema for
|
|
96
|
+
# every document in it.
|
|
97
|
+
def schema_for(target)
|
|
98
|
+
@lock.synchronize { @schemas[target.id] ||= Schema.for(@client, target) }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Every property payload this run would set.
|
|
102
|
+
def build_properties(schema, set, title, title_given, warnings = [])
|
|
103
|
+
split_properties(schema, set, title, title_given, warnings).all
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# An explicit --title wins over a property of the same name. Otherwise the
|
|
107
|
+
# derived title only fills in when the properties did not set one. Keys in
|
|
108
|
+
# +skip+ are left out entirely.
|
|
109
|
+
def split_properties(schema, set, title, title_given, warnings = [], skip: [])
|
|
110
|
+
users = Users.new(@client)
|
|
111
|
+
document = {}
|
|
112
|
+
flags = {}
|
|
113
|
+
|
|
114
|
+
set.each do |name, values|
|
|
115
|
+
# A page parent holds nothing but a title. Front matter written for a
|
|
116
|
+
# database should not stop the document being published under a page,
|
|
117
|
+
# so it is skipped and reported; an explicit flag still fails.
|
|
118
|
+
if schema.page? && !name.casecmp?("title") && !set.explicit?(name)
|
|
119
|
+
warnings << "Skipped #{name.inspect}: a page parent has no properties."
|
|
120
|
+
next
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
key, payload = schema.build(name, values, users: users)
|
|
124
|
+
next if skip.include?(key)
|
|
125
|
+
|
|
126
|
+
(set.explicit?(name) ? flags : document)[key] = payload
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
add_title(document, flags, schema.title_key, title: title, title_given: title_given, skip: skip)
|
|
130
|
+
Properties.new(document: document.except(*flags.keys), flags: flags)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
def add_title(document, flags, key, title:, title_given:, skip:)
|
|
136
|
+
payload = { "title" => [{ "type" => "text", "text" => { "content" => title.to_s } }] }
|
|
137
|
+
if title_given
|
|
138
|
+
flags.delete(key)
|
|
139
|
+
document[key] = payload
|
|
140
|
+
elsif !document.key?(key) && !flags.key?(key) && !skip.include?(key)
|
|
141
|
+
document[key] = payload
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def run(job, force:, force_properties:)
|
|
146
|
+
source_hash = Digest::SHA256.hexdigest(File.binread(job.source))
|
|
147
|
+
existing = live_entry(job)
|
|
148
|
+
|
|
149
|
+
return write(job, existing, source_hash) if existing.nil? || force
|
|
150
|
+
|
|
151
|
+
update_existing(job, existing, source_hash, force_properties)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# An entry written before flag-set properties were recorded cannot say
|
|
155
|
+
# which of its properties came from flags. Republishing it from front
|
|
156
|
+
# matter alone would clear those, so it is only safe when front matter
|
|
157
|
+
# still produces exactly what was set last time.
|
|
158
|
+
def unrecorded_flags(job, entry)
|
|
159
|
+
return nil unless entry.flag_properties.nil?
|
|
160
|
+
return nil if entry.properties.empty?
|
|
161
|
+
|
|
162
|
+
props = split_properties(schema_for(job.target), job.properties, job.title, job.title_given)
|
|
163
|
+
return nil if entry.properties_sha256 && digest(props.document) == entry.properties_sha256
|
|
164
|
+
|
|
165
|
+
<<~MSG.strip
|
|
166
|
+
#{entry.url} was published before notion-publish recorded which properties
|
|
167
|
+
came from flags, and its front matter no longer produces the properties it
|
|
168
|
+
was given. Republishing it could clear a property that was set with
|
|
169
|
+
--property or change a title set with --title.
|
|
170
|
+
|
|
171
|
+
Publish this file on its own once, with whatever flags it needs. After
|
|
172
|
+
that, republish handles it.
|
|
173
|
+
MSG
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# The recorded entry for this document, unless its page has gone. An entry
|
|
177
|
+
# pointing at a page that no longer exists, or that someone moved to the
|
|
178
|
+
# trash, is stale rather than fatal: forget it and publish afresh.
|
|
179
|
+
def live_entry(job)
|
|
180
|
+
entry = job.manifest&.entry(job.source)
|
|
181
|
+
return nil unless entry
|
|
182
|
+
return entry unless Status.trashed?(@client.get("/v1/pages/#{entry.id}"))
|
|
183
|
+
|
|
184
|
+
forget(job, entry, "is in Notion's trash")
|
|
185
|
+
rescue ApiError => e
|
|
186
|
+
raise unless e.not_found?
|
|
187
|
+
|
|
188
|
+
forget(job, entry, "is gone from Notion")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def forget(job, entry, why)
|
|
192
|
+
job.warnings << "#{entry.url} #{why}. Publishing a new page and forgetting the old entry."
|
|
193
|
+
job.manifest.forget(job.source)
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# The page exists and --force was not given. The body and the properties
|
|
198
|
+
# are compared separately, because reworking only the properties should
|
|
199
|
+
# not mean rewriting every block on the page.
|
|
200
|
+
def update_existing(job, existing, source_hash, force_properties)
|
|
201
|
+
same_body = existing.source_sha256 == source_hash
|
|
202
|
+
same_properties = !force_properties && properties_unchanged?(job, existing)
|
|
203
|
+
|
|
204
|
+
# Checked even when there is nothing to send: an edit made only in
|
|
205
|
+
# Notion is exactly the divergence this tool exists to report.
|
|
206
|
+
blocker = drift(existing)
|
|
207
|
+
return Outcome.new(action: :blocked, page: nil, entry: existing, detail: blocker) if blocker
|
|
208
|
+
return unchanged(existing) if same_body && same_properties
|
|
209
|
+
return properties_only(job, existing, source_hash) if same_body
|
|
210
|
+
|
|
211
|
+
write(job, existing, source_hash)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def unchanged(entry)
|
|
215
|
+
Outcome.new(action: :unchanged, page: { "url" => entry.url, "id" => entry.id }, entry: entry, detail: nil)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# A hash of what this run would set, so a reworked set of properties is
|
|
219
|
+
# itself a change worth publishing. Names and values, canonicalised, without
|
|
220
|
+
# storing either in the manifest.
|
|
221
|
+
def digest(built) = Digest::SHA256.hexdigest(JSON.generate(built.sort.to_h))
|
|
222
|
+
|
|
223
|
+
# The document's properties and the flag-set ones are hashed separately,
|
|
224
|
+
# so a republish, which leaves flag-set properties alone, can still tell
|
|
225
|
+
# whether anything it owns has changed.
|
|
226
|
+
def properties_unchanged?(job, entry)
|
|
227
|
+
# An entry written before this hash existed, or by `adopt`, cannot prove
|
|
228
|
+
# its properties match. Apply them once; that records the digest and every
|
|
229
|
+
# later run can answer properly.
|
|
230
|
+
return false unless entry.properties_sha256
|
|
231
|
+
|
|
232
|
+
props = split_properties(schema_for(job.target), job.properties, job.title, job.title_given,
|
|
233
|
+
skip: job.preserve)
|
|
234
|
+
return false unless digest(props.document) == entry.properties_sha256
|
|
235
|
+
|
|
236
|
+
job.republish || flags_digest(props) == entry.flag_properties_sha256
|
|
237
|
+
rescue Error
|
|
238
|
+
false
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def flags_digest(props) = props.flags.empty? ? nil : digest(props.flags)
|
|
242
|
+
|
|
243
|
+
def split_for(job, schema)
|
|
244
|
+
split_properties(schema, job.properties, job.title, job.title_given, job.warnings, skip: job.preserve)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Same body, different properties: patch the properties and leave the blocks
|
|
248
|
+
# alone. Cheaper, and it does not disturb images or block ids.
|
|
249
|
+
def properties_only(job, existing, source_hash)
|
|
250
|
+
schema = schema_for(job.target)
|
|
251
|
+
props = split_for(job, schema)
|
|
252
|
+
page = update_properties(job, existing, props.all.merge(clearances(schema, existing, props.all, job.preserve)))
|
|
253
|
+
record(job, page, source_hash, props, existing)
|
|
254
|
+
Outcome.new(action: :properties, page: page, entry: job.manifest&.entry(job.source), detail: nil)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Compare Notion's own output against Notion's own output: a round trip is
|
|
258
|
+
# not byte-stable, so the sent form would never match a later read.
|
|
259
|
+
def drift(entry)
|
|
260
|
+
return nil unless entry.notion_sha256
|
|
261
|
+
return nil if NotionDigest.of(read_markdown(entry.id)) == entry.notion_sha256
|
|
262
|
+
|
|
263
|
+
<<~MSG.strip
|
|
264
|
+
#{entry.url} has changed in Notion since it was published.
|
|
265
|
+
|
|
266
|
+
Publishing replaces the page body and would discard that change. Move
|
|
267
|
+
the change into the Markdown if it should stay, then publish with
|
|
268
|
+
--force, which also puts back the Markdown's version when it should not.
|
|
269
|
+
|
|
270
|
+
(Every page reporting this at once usually means Notion changed how it
|
|
271
|
+
renders Markdown, not that anybody edited them.)
|
|
272
|
+
MSG
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
def read_markdown(page_id) = @client.get("/v1/pages/#{page_id}/markdown")["markdown"].to_s
|
|
276
|
+
|
|
277
|
+
def write(job, existing, source_hash)
|
|
278
|
+
body = prepare_body(job)
|
|
279
|
+
schema = schema_for(job.target)
|
|
280
|
+
props = split_for(job, schema)
|
|
281
|
+
desired = props.all
|
|
282
|
+
|
|
283
|
+
page = if existing
|
|
284
|
+
replace_body(existing, body.markdown)
|
|
285
|
+
update_properties(job, existing, desired.merge(clearances(schema, existing, desired, job.preserve)))
|
|
286
|
+
else
|
|
287
|
+
note_lost_flags(job)
|
|
288
|
+
create(job, body.markdown, desired)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
place_images(page["id"], body.media, body.uploads, job.warnings) unless body.uploads.empty?
|
|
292
|
+
record(job, page, source_hash, props, existing)
|
|
293
|
+
Outcome.new(action: existing ? :updated : :created, page: page, entry: job.manifest&.entry(job.source),
|
|
294
|
+
detail: nil)
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# Fixes, link rewriting, and image uploads. Uploads happen here, before any
|
|
298
|
+
# page is touched, so a missing or oversized file fails cleanly.
|
|
299
|
+
def prepare_body(job)
|
|
300
|
+
prepared = prepare(job)
|
|
301
|
+
media = scan_media(prepared)
|
|
302
|
+
note_inline(media, job.warnings)
|
|
303
|
+
return Body.new(markdown: prepared.body, media: media, uploads: {}) unless media.any?
|
|
304
|
+
|
|
305
|
+
unless job.upload
|
|
306
|
+
note_skipped_uploads(media, job.warnings)
|
|
307
|
+
return Body.new(markdown: prepared.body, media: media, uploads: {})
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
Body.new(markdown: media.body_with_sentinels, media: media, uploads: upload_all(media))
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# A republished page that had to be recreated starts without the
|
|
314
|
+
# properties an earlier run set from flags, and republish cannot restore
|
|
315
|
+
# them because their values were never recorded.
|
|
316
|
+
def note_lost_flags(job)
|
|
317
|
+
return if job.preserve.empty?
|
|
318
|
+
|
|
319
|
+
job.warnings << "The new page does not have #{job.preserve.join(', ')}, which were set with flags. " \
|
|
320
|
+
"Publish #{job.document.path} on its own with those flags to restore them."
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# Declarative, over the properties this tool set last time. A property it
|
|
324
|
+
# never managed belongs to somebody else and is left alone, and so is one
|
|
325
|
+
# being preserved.
|
|
326
|
+
def clearances(schema, existing, desired, preserve)
|
|
327
|
+
(existing.properties - desired.keys - preserve).each_with_object({}) do |name, cleared|
|
|
328
|
+
key, payload = schema.build(name, [""])
|
|
329
|
+
cleared[key] = payload
|
|
330
|
+
rescue Error
|
|
331
|
+
# The property no longer exists on the destination; nothing to clear.
|
|
332
|
+
nil
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def create(job, markdown, properties)
|
|
337
|
+
payload = { "parent" => job.target.parent_param, "markdown" => markdown, "properties" => properties }
|
|
338
|
+
@client.post("/v1/pages", payload.merge(decoration(job)))
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def update_properties(job, entry, properties)
|
|
342
|
+
@client.patch("/v1/pages/#{entry.id}", { "properties" => properties }.merge(decoration(job)))
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
def decoration(job)
|
|
346
|
+
out = {}
|
|
347
|
+
out["icon"] = Decoration.icon(job.icon, client: @client) if job.icon
|
|
348
|
+
out["cover"] = Decoration.cover(job.cover, client: @client) if job.cover
|
|
349
|
+
out
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def replace_body(entry, markdown)
|
|
353
|
+
@client.patch("/v1/pages/#{entry.id}/markdown",
|
|
354
|
+
{ "type" => "replace_content", "replace_content" => { "new_str" => markdown } })
|
|
355
|
+
rescue ApiError => e
|
|
356
|
+
raise unless e.status == 400 && e.notion_message.to_s.match?(/delet/i)
|
|
357
|
+
|
|
358
|
+
raise Error, <<~MSG.strip
|
|
359
|
+
#{entry.url} contains a child page or database, which replacing the body
|
|
360
|
+
would delete. notion-publish will not do that silently.
|
|
361
|
+
|
|
362
|
+
Move the child out of the page, or delete the page and publish afresh.
|
|
363
|
+
MSG
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def record(job, page, source_hash, props, existing)
|
|
367
|
+
return unless job.manifest
|
|
368
|
+
|
|
369
|
+
job.manifest.workspace_id = @client.me.dig("bot", "workspace_id") || @client.me["id"]
|
|
370
|
+
job.manifest.record(job.source, entry_for(job, page, source_hash, props, existing))
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def entry_for(job, page, source_hash, props, existing)
|
|
374
|
+
flag_names, flag_digest = recorded_flags(job, props, existing)
|
|
375
|
+
Manifest::Entry.new(
|
|
376
|
+
id: page["id"], url: page["url"],
|
|
377
|
+
parent: { "type" => job.target.page? ? "page_id" : "data_source_id",
|
|
378
|
+
"id" => job.target.id, "name" => job.target.title },
|
|
379
|
+
properties: (props.document.keys + flag_names).uniq.sort,
|
|
380
|
+
flag_properties: flag_names,
|
|
381
|
+
title_override: job.title_given ? job.title.to_s : nil,
|
|
382
|
+
keep_h1: job.keep_h1 || nil,
|
|
383
|
+
source_sha256: source_hash,
|
|
384
|
+
properties_sha256: digest(props.document),
|
|
385
|
+
flag_properties_sha256: flag_digest,
|
|
386
|
+
notion_sha256: NotionDigest.of(read_markdown(page["id"])),
|
|
387
|
+
published_at: Time.now.utc.iso8601
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# A republish of an existing page carries the flag-set properties over
|
|
392
|
+
# untouched. Anything else records what this run's flags set.
|
|
393
|
+
def recorded_flags(job, props, existing)
|
|
394
|
+
return [existing.flag_properties || [], existing.flag_properties_sha256] if job.republish && existing
|
|
395
|
+
|
|
396
|
+
[props.flags.keys.sort, flags_digest(props)]
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def prepare(job)
|
|
400
|
+
body = Fixups.apply(job.document.body)
|
|
401
|
+
body = Fixups.strip_leading_h1(body) unless job.keep_h1
|
|
402
|
+
body = rewrite_links(job, body) if job.manifest
|
|
403
|
+
job.document.with_body(body)
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def rewrite_links(job, body)
|
|
407
|
+
links = Links.new(registry: job.manifest, base_dir: job.base_dir)
|
|
408
|
+
rewritten = links.rewrite(body)
|
|
409
|
+
links.unresolved.uniq.each do |target|
|
|
410
|
+
job.warnings << "#{target} is not published yet, so that link will point at #{Links.mangled(target)}. " \
|
|
411
|
+
"Run `notion-publish relink` after publishing it."
|
|
412
|
+
end
|
|
413
|
+
rewritten
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def upload_all(media)
|
|
417
|
+
uploader = Uploader.new(@client)
|
|
418
|
+
media.images.to_h { |image| [image.index, uploader.upload(media.resolved_path(image))] }
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Each local image was published as a sentinel paragraph. Find it, insert
|
|
422
|
+
# the real image block after it, then delete the sentinel.
|
|
423
|
+
def place_images(page_id, media, uploads, warnings)
|
|
424
|
+
# Sentinel text => [its block id, the block it sits in]. The image has to
|
|
425
|
+
# be appended to that parent: Notion refuses an after_block position
|
|
426
|
+
# under any other block, including the page itself.
|
|
427
|
+
found = blocks_under(page_id).to_h { |block, parent| [plain_text(block), [block["id"], parent]] }
|
|
428
|
+
|
|
429
|
+
media.images.each do |image|
|
|
430
|
+
block_id, parent_id = found[image.sentinel]
|
|
431
|
+
unless block_id
|
|
432
|
+
warnings << "Could not place #{image.path}: its marker was not found on the page."
|
|
433
|
+
next
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
@client.patch("/v1/blocks/#{parent_id}/children", {
|
|
437
|
+
"children" => [Uploader.image_block(uploads[image.index], image.alt)],
|
|
438
|
+
"position" => { "type" => "after_block", "after_block" => { "id" => block_id } }
|
|
439
|
+
})
|
|
440
|
+
@client.delete("/v1/blocks/#{block_id}")
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Every block on the page as [block, parent id], including blocks nested
|
|
445
|
+
# in list items and toggles, since an image written under a list item is
|
|
446
|
+
# placed there. Child pages and databases are separate documents and are
|
|
447
|
+
# not entered.
|
|
448
|
+
def blocks_under(parent_id)
|
|
449
|
+
@client.get_all("/v1/blocks/#{parent_id}/children").flat_map do |block|
|
|
450
|
+
nested = block["has_children"] && !%w[child_page child_database].include?(block["type"])
|
|
451
|
+
nested ? [[block, parent_id], *blocks_under(block["id"])] : [[block, parent_id]]
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def plain_text(block)
|
|
456
|
+
content = block[block["type"]]
|
|
457
|
+
return nil unless content.is_a?(Hash)
|
|
458
|
+
|
|
459
|
+
(content["rich_text"] || []).map { |chunk| chunk["plain_text"] }.join
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
def note_inline(media, warnings)
|
|
463
|
+
media.inline_paths.uniq.each do |path|
|
|
464
|
+
warnings << "#{path} is an image inside a paragraph. Notion has no inline image, " \
|
|
465
|
+
"so it is left as written and will not render."
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def note_skipped_uploads(media, warnings)
|
|
470
|
+
media.images.each do |image|
|
|
471
|
+
warnings << "Not uploading #{image.path} (--no-upload). It will publish as a broken image."
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
end
|
|
475
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module NotionPublish
|
|
8
|
+
# Parses whatever a user types after --parent into a Notion UUID.
|
|
9
|
+
#
|
|
10
|
+
# Notion IDs appear in several shapes and every one of them shows up in the
|
|
11
|
+
# wild: bare 32-character hex, dashed UUID, and URLs from notion.so,
|
|
12
|
+
# app.notion.com, and workspace-prefixed paths, sometimes with a ?v= view ID
|
|
13
|
+
# appended. Rather than matching URL structure, we take the last ID-shaped run
|
|
14
|
+
# in the path, which is where Notion puts the object's own ID in every form.
|
|
15
|
+
class Reference
|
|
16
|
+
# Guard both ends so a 32-run inside a longer hex string does not match.
|
|
17
|
+
HEX32 = /(?<![0-9a-fA-F])[0-9a-fA-F]{32}(?![0-9a-fA-F])/
|
|
18
|
+
DASHED = /(?<![0-9a-fA-F-])\h{8}-\h{4}-\h{4}-\h{4}-\h{12}(?![0-9a-fA-F-])/
|
|
19
|
+
|
|
20
|
+
# A whole string that is nothing but an ID. Anchored on purpose: a loose
|
|
21
|
+
# scan would read "Q3 Report 2efab123cd45..." as an ID when it is a badly
|
|
22
|
+
# pasted name.
|
|
23
|
+
BARE_ID = /\A(?:[0-9a-fA-F]{32}|\h{8}-\h{4}-\h{4}-\h{4}-\h{12})\z/
|
|
24
|
+
URL_LIKE = %r{\A[a-z][a-z0-9+.-]*://|\Awww\.|notion\.(?:so|com)/}i
|
|
25
|
+
|
|
26
|
+
attr_reader :input, :uuid, :slug_title
|
|
27
|
+
|
|
28
|
+
def self.parse(input)
|
|
29
|
+
new(input)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Does this look like an ID or a URL, as opposed to a database name? Lets a
|
|
33
|
+
# single --parent flag accept all three without ambiguity.
|
|
34
|
+
def self.reference?(input)
|
|
35
|
+
candidate = input.to_s.strip
|
|
36
|
+
candidate.match?(BARE_ID) || candidate.match?(URL_LIKE)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize(input)
|
|
40
|
+
@input = input.to_s.strip
|
|
41
|
+
raise InvalidReference, @input if @input.empty?
|
|
42
|
+
|
|
43
|
+
path = strip_query(@input)
|
|
44
|
+
@uuid = extract_uuid(path) or raise InvalidReference, @input
|
|
45
|
+
@slug_title = extract_slug_title(path)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_s = uuid
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def strip_query(str)
|
|
53
|
+
str.split("#", 2).first.to_s.split("?", 2).first.to_s
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def extract_uuid(path)
|
|
57
|
+
if (dashed = path.scan(DASHED).last)
|
|
58
|
+
dashed.downcase
|
|
59
|
+
elsif (hex = path.scan(HEX32).last)
|
|
60
|
+
dash(hex.downcase)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def dash(hex)
|
|
65
|
+
[hex[0, 8], hex[8, 4], hex[12, 4], hex[16, 4], hex[20, 12]].join("-")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A Notion URL carries the page title in its slug. The API will not tell us
|
|
69
|
+
# the title of a page we cannot read, so this is the only way to name the
|
|
70
|
+
# object back to the user in a 404 message.
|
|
71
|
+
def extract_slug_title(path)
|
|
72
|
+
segment = path.split("/").reject(&:empty?).last.to_s
|
|
73
|
+
slug = segment.sub(HEX32, "").sub(DASHED, "").sub(/-+\z/, "")
|
|
74
|
+
return nil if slug.empty? || slug == segment
|
|
75
|
+
|
|
76
|
+
decoded = begin
|
|
77
|
+
URI.decode_www_form_component(slug)
|
|
78
|
+
rescue ArgumentError
|
|
79
|
+
slug
|
|
80
|
+
end
|
|
81
|
+
decoded.tr("-", " ").squeeze(" ").strip
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|