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,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command"
4
+
5
+ module NotionPublish
6
+ module Commands
7
+ # Says what the token authenticates as, which is the first thing to check
8
+ # when a page cannot be reached.
9
+ class Whoami < Command
10
+ KINDS = {
11
+ person: "personal access token (acts as a person)",
12
+ bot_user: "connection owned by a user",
13
+ bot_workspace: "internal connection owned by the workspace"
14
+ }.freeze
15
+
16
+ def call
17
+ stdout.puts "#{client.connection_name} -- #{KINDS.fetch(client.credential_kind, 'unknown credential')}"
18
+ stdout.puts " id: #{client.me['id']}"
19
+ if (workspace = client.me.dig("bot", "workspace_name"))
20
+ stdout.puts " workspace: #{workspace}"
21
+ end
22
+ stdout.puts " API version: #{client.api_version}"
23
+ CLI::OK
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "commands/command"
4
+ require_relative "commands/adopt"
5
+ require_relative "commands/properties"
6
+ require_relative "commands/publish"
7
+ require_relative "commands/relink"
8
+ require_relative "commands/republish"
9
+ require_relative "commands/status"
10
+ require_relative "commands/whoami"
11
+
12
+ module NotionPublish
13
+ # One class per subcommand. Each takes a CLI::Context and returns an exit
14
+ # code from #call.
15
+ module Commands
16
+ end
17
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "errors"
4
+ require_relative "uploader"
5
+
6
+ module NotionPublish
7
+ # Page icon and cover.
8
+ #
9
+ # Both are top-level fields on a page rather than properties, and both are
10
+ # writable at creation. Notion's own stock covers are ordinary external URLs,
11
+ # so a URL, a local file, or (for an icon) an emoji all work.
12
+ module Decoration
13
+ URL = %r{\Ahttps?://}i
14
+ # Anything with a path separator, or a leading . or ~, is meant as a file.
15
+ PATH = %r{\A[.~]|/}
16
+
17
+ module_function
18
+
19
+ # Validate without uploading, so a dry run can reject a bad icon or a
20
+ # missing file without spending a round trip or leaving an orphan upload.
21
+ def check!(value, kind)
22
+ raw = value.to_s.strip
23
+ return if raw.empty? || raw.match?(URL)
24
+
25
+ if raw.match?(PATH) || kind == :cover
26
+ expanded = File.expand_path(raw)
27
+ raise Error, "No such #{kind} file: #{raw}" unless File.file?(expanded)
28
+
29
+ return
30
+ end
31
+
32
+ raise Error, icon_message(raw) if raw.ascii_only? || raw.length > 8
33
+ end
34
+
35
+ def icon(value, client:)
36
+ raw = value.to_s.strip
37
+ return nil if raw.empty?
38
+ return external(raw) if raw.match?(URL)
39
+ return file_upload(raw, client, "icon") if raw.match?(PATH)
40
+
41
+ # Emoji are never ASCII. Catching that here gives a better message than
42
+ # Notion's for the common mistakes: a bare word, or a filename written
43
+ # without a path so it was not recognised as one.
44
+ raise Error, icon_message(raw) if raw.ascii_only? || raw.length > 8
45
+
46
+ { "type" => "emoji", "emoji" => raw }
47
+ end
48
+
49
+ def cover(value, client:)
50
+ raw = value.to_s.strip
51
+ return nil if raw.empty?
52
+ return external(raw) if raw.match?(URL)
53
+
54
+ file_upload(raw, client, "cover")
55
+ end
56
+
57
+ def external(url) = { "type" => "external", "external" => { "url" => url } }
58
+
59
+ def file_upload(path, client, what)
60
+ expanded = File.expand_path(path)
61
+ raise Error, "No such #{what} file: #{path}" unless File.file?(expanded)
62
+
63
+ { "type" => "file_upload", "file_upload" => { "id" => Uploader.new(client).upload(expanded) } }
64
+ end
65
+
66
+ def icon_message(raw)
67
+ <<~MSG.strip
68
+ #{raw.inspect} is not an emoji, a URL, or a path to a file.
69
+
70
+ An icon can be an emoji (--icon 🔒), an image URL, or a local image file
71
+ given with a path (--icon ./logo.png).
72
+ MSG
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "yaml"
5
+
6
+ require_relative "errors"
7
+
8
+ module NotionPublish
9
+ # A Markdown file, split into optional YAML front matter and a body.
10
+ class Document
11
+ FRONT_MATTER = /\A---[ \t]*\r?\n(.*?\r?\n)---[ \t]*(?:\r?\n|\z)/m
12
+
13
+ attr_reader :path, :front_matter, :body
14
+
15
+ def self.load(path)
16
+ raise Error, "No such file: #{path}" unless File.file?(path)
17
+
18
+ new(path, File.read(path))
19
+ end
20
+
21
+ def initialize(path, source)
22
+ @path = path
23
+ @front_matter, @body = split(source)
24
+ end
25
+
26
+ # A copy with a transformed body, keeping the path and front matter.
27
+ def with_body(body)
28
+ dup.tap { |copy| copy.body = body }
29
+ end
30
+
31
+ def title
32
+ from_front_matter = front_matter["title"]
33
+ return from_front_matter.to_s if from_front_matter && !from_front_matter.to_s.empty?
34
+
35
+ first_heading || File.basename(path, File.extname(path))
36
+ end
37
+
38
+ # Notion property values live under a "properties:" key so they can never
39
+ # collide with the tool's own front-matter keys.
40
+ def properties = front_matter["properties"]
41
+
42
+ def icon = front_matter["notion_icon"]
43
+ def cover = front_matter["notion_cover"]
44
+
45
+ def parent = front_matter["notion_parent"]
46
+ def database = front_matter["notion_database"]
47
+
48
+ protected
49
+
50
+ attr_writer :body
51
+
52
+ private
53
+
54
+ def first_heading
55
+ body.each_line do |line|
56
+ return Regexp.last_match(1).strip if line =~ /\A\#\s+(.+)/
57
+ end
58
+ nil
59
+ end
60
+
61
+ def split(source)
62
+ match = source.match(FRONT_MATTER)
63
+ return [{}, source] unless match
64
+
65
+ parsed = begin
66
+ YAML.safe_load(match[1], permitted_classes: [Date, Time], aliases: false) || {}
67
+ rescue Psych::Exception => e
68
+ raise Error, "Could not parse front matter in #{path}: #{e.message}"
69
+ end
70
+ raise Error, "Front matter in #{path} must be a YAML mapping" unless parsed.is_a?(Hash)
71
+
72
+ [parsed, match.post_match]
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotionPublish
4
+ # Base for everything this gem raises on purpose. The CLI rescues this and
5
+ # prints +message+ without a backtrace, so messages are user-facing prose.
6
+ class Error < StandardError; end
7
+
8
+ # No token was passed and none is set in the environment.
9
+ class MissingToken < Error
10
+ def initialize(var_names)
11
+ super(<<~MSG.strip)
12
+ No Notion API token found.
13
+
14
+ Set one of these environment variables:
15
+ #{var_names.join("\n ")}
16
+
17
+ Create a token at https://www.notion.so/profile/integrations
18
+ MSG
19
+ end
20
+ end
21
+
22
+ # A --parent or --page value that contains no Notion ID.
23
+ class InvalidReference < Error
24
+ def initialize(input)
25
+ super(<<~MSG.strip)
26
+ Could not find a Notion ID in: #{input}
27
+
28
+ Expected a 32-character ID, a dashed UUID, or a Notion URL such as
29
+ https://www.notion.so/Some-Page-32dab123cd45803f94c2d29516bd0188
30
+ MSG
31
+ end
32
+ end
33
+
34
+ # A settings file or the manifest could not be read.
35
+ class ConfigError < Error; end
36
+
37
+ # Raised for any non-2xx response. Carries the parsed Notion error body so
38
+ # callers can branch on +code+ rather than parsing messages.
39
+ class ApiError < Error
40
+ attr_reader :status, :code, :notion_message, :additional_data, :request_id
41
+
42
+ def initialize(status:, body:)
43
+ @status = status
44
+ @code = body["code"]
45
+ @notion_message = body["message"]
46
+ @additional_data = body["additional_data"] || {}
47
+ @request_id = body["request_id"]
48
+ super("Notion API error #{status} (#{@code}): #{@notion_message}")
49
+ end
50
+
51
+ def not_found? = code == "object_not_found"
52
+ def unauthorized? = code == "unauthorized"
53
+ def restricted? = code == "restricted_resource"
54
+ def rate_limited? = code == "rate_limited"
55
+
56
+ # The connection that was refused, when Notion tells us.
57
+ def integration_id = additional_data["integration_id"]
58
+ end
59
+
60
+ # Still rate-limited after every retry.
61
+ class RateLimited < ApiError; end
62
+ end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotionPublish
4
+ # Line-level corrections applied to a Markdown body before Notion parses it.
5
+ #
6
+ # Notion accepts CommonMark and GFM well -- tables, nesting, task lists, code
7
+ # fences all survive -- with one systematic exception: it does not honour soft
8
+ # line breaks. CommonMark treats consecutive non-blank lines as one paragraph;
9
+ # Notion makes a separate block out of each line. Any document wrapped at a
10
+ # column width therefore arrives looking double-spaced, and wrapped list items
11
+ # break out of their list entirely.
12
+ #
13
+ # So each run of lines that CommonMark would treat as one block is joined back
14
+ # into one line before sending. A space, not <br>: the wrapping is an artifact
15
+ # of how the file is stored, not something the author meant to be seen. Only
16
+ # an explicit hard break -- two trailing spaces, or a trailing backslash --
17
+ # becomes <br>, which is what Notion's own markdown output uses.
18
+ module Fixups
19
+ FENCE = /\A\s*(?:`{3,}|~{3,})/
20
+ BLANK = /\A\s*\z/
21
+ HEADING = /\A {0,3}\#{1,6}(?:\s|\z)/
22
+ THEMATIC = /\A {0,3}([-*_])(?:\s*\1){2,}\s*\z/
23
+ TABLE = /\A {0,3}\|/
24
+ HTML = /\A {0,3}</
25
+ INDENTED_CODE = /\A {4,}\S/
26
+ # An image alone on its line. Joined into the line above, it would become
27
+ # an image inside a sentence, which Notion cannot show.
28
+ IMAGE_LINE = /\A\s*!\[[^\]]*\]\([^)]*\)\s*\z/
29
+ QUOTE = /\A {0,3}>[ \t]?(.*)\z/
30
+ LIST = /\A(\s*(?:[-*+]|\d+[.)])\s+)(.*)\z/
31
+ HARD_BREAK = /(?: {2,}|\\)\z/
32
+
33
+ module_function
34
+
35
+ def apply(body) = join_soft_wraps(body)
36
+
37
+ # Notion consumes a leading H1 as the page title -- but only when it is the
38
+ # document's *only* H1. A second one anywhere (a "# Revision History" at the
39
+ # foot is enough) makes it keep both, and the title then appears twice: once
40
+ # as the page title, once as a heading. Removing it here makes the result
41
+ # the same either way.
42
+ def strip_leading_h1(body)
43
+ lines = body.to_s.lines
44
+ index = lines.index { |line| !line.match?(BLANK) }
45
+ return body unless index && lines[index].match?(/\A {0,3}\#(?!\#)\s/)
46
+
47
+ rest = lines[(index + 1)..] || []
48
+ rest.shift while rest.first&.match?(BLANK)
49
+ # Blank lines ahead of the title go with it.
50
+ rest.join
51
+ end
52
+
53
+ def join_soft_wraps(body)
54
+ out = []
55
+ run = nil
56
+ in_fence = false
57
+
58
+ body.to_s.lines.each do |raw|
59
+ line = raw.chomp
60
+
61
+ if line.match?(FENCE)
62
+ out.concat(flush(run))
63
+ run = nil
64
+ in_fence = !in_fence
65
+ out << raw
66
+ next
67
+ end
68
+
69
+ if in_fence
70
+ out << raw
71
+ next
72
+ end
73
+
74
+ run, emitted = classify(line, raw, run)
75
+ out.concat(emitted)
76
+ end
77
+
78
+ out.concat(flush(run))
79
+ out.join
80
+ end
81
+
82
+ # Returns [new_run, lines_to_emit].
83
+ def classify(line, raw, run)
84
+ case line
85
+ when IMAGE_LINE
86
+ # Set apart from the text above, keeping its indentation, so an image
87
+ # written under a list item stays inside that item.
88
+ [nil, flush(run) + (run ? ["\n", raw] : [raw])]
89
+ when BLANK, THEMATIC, HEADING, TABLE, HTML
90
+ [nil, flush(run) + [raw]]
91
+ when QUOTE
92
+ quote(Regexp.last_match(1), line, run)
93
+ when LIST
94
+ [{ kind: :list, prefix: Regexp.last_match(1), parts: [part(Regexp.last_match(2), line)] }, flush(run)]
95
+ when INDENTED_CODE
96
+ run ? [continue(run, line), []] : [nil, flush(run) + [raw]]
97
+ else
98
+ continuation(line, run)
99
+ end
100
+ end
101
+
102
+ # A bare ">" separates paragraphs inside a quote, so it ends the run rather
103
+ # than joining the halves together.
104
+ def quote(text, line, run)
105
+ return [nil, flush(run) + ["#{line}\n"]] if text.strip.empty?
106
+ return [continue(run, line, text), []] if run && run[:kind] == :quote
107
+
108
+ [{ kind: :quote, prefix: "> ", parts: [part(text, line)] }, flush(run)]
109
+ end
110
+
111
+ def continuation(line, run)
112
+ return [continue(run, line), []] if run
113
+
114
+ [{ kind: :paragraph, prefix: "", parts: [part(line, line)] }, []]
115
+ end
116
+
117
+ def continue(run, line, text = line)
118
+ run.merge(parts: run[:parts] + [part(text, line)])
119
+ end
120
+
121
+ def part(text, line)
122
+ hard = line.match?(HARD_BREAK)
123
+ # A trailing backslash is the break marker itself, not content.
124
+ { text: text.strip.sub(/\\\z/, "").rstrip, hard: hard }
125
+ end
126
+
127
+ def flush(run)
128
+ return [] unless run
129
+
130
+ joined = +""
131
+ run[:parts].each_with_index do |piece, index|
132
+ joined << (run[:parts][index - 1][:hard] ? "<br>" : " ") unless index.zero?
133
+ joined << piece[:text]
134
+ end
135
+
136
+ ["#{run[:prefix]}#{joined}\n"]
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotionPublish
4
+ # Rewrites links between documents in the same set.
5
+ #
6
+ # A relative link is not merely dead in Notion: the API turns
7
+ # `](data-management-policy.md)` into `](https://data-management-policy.md)`,
8
+ # an absolute URL to a hostname nobody owns. Rewriting them to the published
9
+ # Notion URL before sending avoids that entirely; anything still unresolved is
10
+ # reported rather than shipped.
11
+ class Links
12
+ FENCE = /\A\s*(?:`{3,}|~{3,})/
13
+ # A link, not an image: the negative lookbehind drops ![alt](...).
14
+ LINK = /(?<!!)\[([^\]]*)\]\(\s*([^)\s]+?)\s*(?:"[^"]*")?\s*\)/
15
+ ABSOLUTE = %r{\A(?:https?://|mailto:|#)}i
16
+
17
+ # What a relative target becomes once Notion has mangled it. Used by the
18
+ # relink pass to find links on an already-published page.
19
+ def self.mangled(target) = "https://#{target}"
20
+
21
+ attr_reader :unresolved
22
+
23
+ def initialize(registry:, base_dir:)
24
+ @registry = registry
25
+ @base_dir = base_dir
26
+ @unresolved = []
27
+ end
28
+
29
+ def rewrite(body)
30
+ in_fence = false
31
+
32
+ body.to_s.lines.map do |line|
33
+ if line.match?(FENCE)
34
+ in_fence = !in_fence
35
+ next line
36
+ end
37
+ next line if in_fence
38
+
39
+ line.gsub(LINK) do
40
+ text = Regexp.last_match(1)
41
+ target = Regexp.last_match(2)
42
+ replacement = resolve(target)
43
+ replacement ? "[#{text}](#{replacement})" : Regexp.last_match(0)
44
+ end
45
+ end.join
46
+ end
47
+
48
+ private
49
+
50
+ def resolve(target)
51
+ return nil if target.match?(ABSOLUTE)
52
+
53
+ path, fragment = target.split("#", 2)
54
+ return nil if path.to_s.empty?
55
+
56
+ url = @registry.url_for(File.expand_path(path, @base_dir))
57
+ unless url
58
+ @unresolved << target
59
+ return nil
60
+ end
61
+
62
+ fragment ? "#{url}##{fragment}" : url
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module NotionPublish
4
+ # Request logging for -vv and -vvv, written to stderr so it never mixes with
5
+ # --json on stdout.
6
+ #
7
+ # Level 1 is one line per request: method, path, status, and time taken,
8
+ # plus a line for each retry. Level 2 adds request and response bodies,
9
+ # shortened. The token is never logged at any level: it travels only in the
10
+ # Authorization header, and headers are not logged.
11
+ class Log
12
+ BODY_LIMIT = 300
13
+
14
+ attr_reader :level
15
+
16
+ def initialize(io, level)
17
+ @io = io
18
+ @level = level
19
+ # Requests run on several threads; keep each line whole.
20
+ @lock = Mutex.new
21
+ end
22
+
23
+ def request(method, uri, status, seconds)
24
+ line("#{method} #{uri.request_uri} -> #{status} (#{(seconds * 1000).round} ms)")
25
+ end
26
+
27
+ def retrying(status, delay, attempt, max)
28
+ line(" #{status}: retrying in #{delay}s (attempt #{attempt + 1} of #{max})")
29
+ end
30
+
31
+ def body(direction, text)
32
+ return unless level >= 2
33
+ return if text.nil? || text.empty?
34
+
35
+ line(" #{direction} #{shorten(text)}")
36
+ end
37
+
38
+ def note(message) = line(message)
39
+
40
+ private
41
+
42
+ def line(message) = @lock.synchronize { @io.puts("notion-publish: #{message}") }
43
+
44
+ def shorten(text)
45
+ flat = text.to_s.gsub(/\s+/, " ")
46
+ flat.length > BODY_LIMIT ? "#{flat[0, BODY_LIMIT]}... (#{flat.length} chars)" : flat
47
+ end
48
+ end
49
+ end