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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE +21 -0
  4. data/README.md +221 -0
  5. data/docs/notion-publish-home-page.md +105 -0
  6. data/docs/usage.md +648 -0
  7. data/exe/notion-publish +6 -0
  8. data/lib/notion_publish/adopter.rb +77 -0
  9. data/lib/notion_publish/cli.rb +212 -0
  10. data/lib/notion_publish/client.rb +226 -0
  11. data/lib/notion_publish/commands/adopt.rb +114 -0
  12. data/lib/notion_publish/commands/command.rb +114 -0
  13. data/lib/notion_publish/commands/properties.rb +33 -0
  14. data/lib/notion_publish/commands/publish.rb +134 -0
  15. data/lib/notion_publish/commands/relink.rb +95 -0
  16. data/lib/notion_publish/commands/reporting.rb +57 -0
  17. data/lib/notion_publish/commands/republish.rb +156 -0
  18. data/lib/notion_publish/commands/status.rb +84 -0
  19. data/lib/notion_publish/commands/whoami.rb +27 -0
  20. data/lib/notion_publish/commands.rb +17 -0
  21. data/lib/notion_publish/decoration.rb +75 -0
  22. data/lib/notion_publish/document.rb +75 -0
  23. data/lib/notion_publish/errors.rb +62 -0
  24. data/lib/notion_publish/fixups.rb +139 -0
  25. data/lib/notion_publish/links.rb +65 -0
  26. data/lib/notion_publish/log.rb +49 -0
  27. data/lib/notion_publish/manifest.rb +224 -0
  28. data/lib/notion_publish/media.rb +90 -0
  29. data/lib/notion_publish/notion_digest.rb +23 -0
  30. data/lib/notion_publish/pool.rb +103 -0
  31. data/lib/notion_publish/progress.rb +103 -0
  32. data/lib/notion_publish/property_set.rb +116 -0
  33. data/lib/notion_publish/publisher.rb +475 -0
  34. data/lib/notion_publish/reference.rb +84 -0
  35. data/lib/notion_publish/resolver.rb +216 -0
  36. data/lib/notion_publish/schema.rb +206 -0
  37. data/lib/notion_publish/settings.rb +73 -0
  38. data/lib/notion_publish/sharing.rb +47 -0
  39. data/lib/notion_publish/status.rb +127 -0
  40. data/lib/notion_publish/target.rb +34 -0
  41. data/lib/notion_publish/uploader.rb +85 -0
  42. data/lib/notion_publish/users.rb +49 -0
  43. data/lib/notion_publish/version.rb +5 -0
  44. data/lib/notion_publish.rb +31 -0
  45. 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