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,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 .
|
|
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
|