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,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "reference"
|
|
5
|
+
require_relative "schema"
|
|
6
|
+
require_relative "sharing"
|
|
7
|
+
|
|
8
|
+
module NotionPublish
|
|
9
|
+
# Finds the Notion page that a local file already corresponds to.
|
|
10
|
+
#
|
|
11
|
+
# Two ways to say it. Naming the page is exact. Searching by title is a guess,
|
|
12
|
+
# which is why this is a separate command rather than something publishing
|
|
13
|
+
# does on anyone's behalf.
|
|
14
|
+
class Adopter
|
|
15
|
+
Candidate = Data.define(:id, :url, :title, :last_edited_time, :last_edited_by)
|
|
16
|
+
|
|
17
|
+
def initialize(client)
|
|
18
|
+
@client = client
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# The exact form: the caller named the page.
|
|
22
|
+
def by_page(input)
|
|
23
|
+
uuid = Reference.parse(input).uuid
|
|
24
|
+
page = @client.get("/v1/pages/#{uuid}")
|
|
25
|
+
candidate(page)
|
|
26
|
+
rescue ApiError => e
|
|
27
|
+
raise unless e.not_found?
|
|
28
|
+
|
|
29
|
+
raise Error, Sharing.unreachable(id: uuid, title: Reference.parse(input).slug_title,
|
|
30
|
+
connection_name: @client.connection_name)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The guess: every page in the destination whose title matches exactly.
|
|
34
|
+
def by_title(target, title)
|
|
35
|
+
pages = target.page? ? children_of(target.id) : rows_of(target, title)
|
|
36
|
+
pages.select { |page| candidate(page).title.to_s.casecmp?(title.to_s.strip) }
|
|
37
|
+
.map { |page| candidate(page) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def rows_of(target, title)
|
|
43
|
+
key = Schema.for(@client, target).title_key
|
|
44
|
+
body = { "filter" => { "property" => key, "title" => { "equals" => title.to_s } }, "page_size" => 25 }
|
|
45
|
+
@client.post("/v1/data_sources/#{target.id}/query", body)["results"] || []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def children_of(page_id)
|
|
49
|
+
@client.get_all("/v1/blocks/#{page_id}/children")
|
|
50
|
+
.select { |b| b["type"] == "child_page" }
|
|
51
|
+
.map { |b| @client.get("/v1/pages/#{b['id']}") }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def candidate(page)
|
|
55
|
+
Candidate.new(
|
|
56
|
+
id: page["id"], url: page["url"], title: title_of(page),
|
|
57
|
+
last_edited_time: page["last_edited_time"],
|
|
58
|
+
last_edited_by: user_name(page.dig("last_edited_by", "id"))
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def title_of(page)
|
|
63
|
+
prop = (page["properties"] || {}).values.find { |v| v["type"] == "title" }
|
|
64
|
+
return nil unless prop
|
|
65
|
+
|
|
66
|
+
(prop["title"] || []).map { |chunk| chunk["plain_text"] }.join
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def user_name(id)
|
|
70
|
+
return nil unless id
|
|
71
|
+
|
|
72
|
+
(@names ||= {})[id] ||= @client.get("/v1/users/#{id}")["name"]
|
|
73
|
+
rescue ApiError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
|
|
5
|
+
require_relative "client"
|
|
6
|
+
require_relative "commands"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
require_relative "log"
|
|
9
|
+
require_relative "manifest"
|
|
10
|
+
require_relative "pool"
|
|
11
|
+
require_relative "version"
|
|
12
|
+
|
|
13
|
+
module NotionPublish
|
|
14
|
+
# The notion-publish command line: parses options, dispatches to a command,
|
|
15
|
+
# and turns failures into messages and exit codes.
|
|
16
|
+
class CLI
|
|
17
|
+
OK = 0
|
|
18
|
+
FAILURE = 1
|
|
19
|
+
USAGE = 2
|
|
20
|
+
# Stopped and needs a decision, so a dry run in CI can gate a merge on
|
|
21
|
+
# "this would need a human" without that reading as a crash.
|
|
22
|
+
BLOCKED = 3
|
|
23
|
+
# The shell convention for a process stopped by SIGINT.
|
|
24
|
+
INTERRUPTED = 130
|
|
25
|
+
|
|
26
|
+
SUBCOMMANDS = %w[adopt properties relink republish status].freeze
|
|
27
|
+
# More than Notion's rate limit allows mostly buys retries; past this it
|
|
28
|
+
# is a mistake.
|
|
29
|
+
MAX_JOBS = 10
|
|
30
|
+
|
|
31
|
+
# Everything a command needs from the invocation. The client is built on
|
|
32
|
+
# first use, so a usage error never demands a token.
|
|
33
|
+
class Context
|
|
34
|
+
attr_reader :options, :stdout, :stderr, :stdin
|
|
35
|
+
|
|
36
|
+
def initialize(options:, stdout:, stderr:, stdin:, client: nil)
|
|
37
|
+
@options = options
|
|
38
|
+
@stdout = stdout
|
|
39
|
+
@stderr = stderr
|
|
40
|
+
@stdin = stdin
|
|
41
|
+
@client = client
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Under -v, say up front which connection and workspace the token
|
|
45
|
+
# belongs to. That is the first question when a page cannot be found and
|
|
46
|
+
# more than one token is in use.
|
|
47
|
+
def client
|
|
48
|
+
@client ||= Client.new(token: options[:token], log: log).tap { |c| c.me if log }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def log
|
|
52
|
+
# -v lists every file; request logging starts at -vv.
|
|
53
|
+
Log.new(stderr, options[:verbose] - 1) if options[:verbose].to_i >= 2
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.run(argv, stdout: $stdout, stderr: $stderr, stdin: $stdin)
|
|
58
|
+
new(stdout: stdout, stderr: stderr, stdin: stdin).run(argv)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(stdout: $stdout, stderr: $stderr, stdin: $stdin, client: nil)
|
|
62
|
+
@options = {}
|
|
63
|
+
@context = Context.new(options: @options, stdout: stdout, stderr: stderr, stdin: stdin, client: client)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def run(argv)
|
|
67
|
+
argv = Array(argv).dup
|
|
68
|
+
subcommand = SUBCOMMANDS.include?(argv.first) ? argv.shift : nil
|
|
69
|
+
args = parser.parse(argv)
|
|
70
|
+
|
|
71
|
+
return print_help if @options[:help]
|
|
72
|
+
return usage("--jobs must be between 1 and #{MAX_JOBS}.") unless (1..MAX_JOBS).cover?(@options.fetch(:jobs, 1))
|
|
73
|
+
return print_version if @options[:version]
|
|
74
|
+
return Commands::Whoami.new(@context).call if @options[:whoami]
|
|
75
|
+
|
|
76
|
+
dispatch(subcommand, args)
|
|
77
|
+
rescue OptionParser::ParseError => e
|
|
78
|
+
usage(e.message)
|
|
79
|
+
rescue ApiError => e
|
|
80
|
+
# ApiError is an Error, so it has to be rescued first.
|
|
81
|
+
stderr.puts api_failure(e)
|
|
82
|
+
FAILURE
|
|
83
|
+
rescue Error => e
|
|
84
|
+
stderr.puts e.message
|
|
85
|
+
FAILURE
|
|
86
|
+
rescue Interrupt
|
|
87
|
+
stderr.puts "Interrupted."
|
|
88
|
+
INTERRUPTED
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def stdout = @context.stdout
|
|
94
|
+
def stderr = @context.stderr
|
|
95
|
+
|
|
96
|
+
def dispatch(subcommand, args)
|
|
97
|
+
case subcommand
|
|
98
|
+
when "properties" then Commands::Properties.new(@context).call
|
|
99
|
+
when "status" then Commands::Status.new(@context).call(args.first || Dir.pwd)
|
|
100
|
+
when "relink" then Commands::Relink.new(@context).call(args.first || Dir.pwd)
|
|
101
|
+
when "republish" then Commands::Republish.new(@context).call(args.first || Dir.pwd)
|
|
102
|
+
when "adopt"
|
|
103
|
+
return usage("Give one Markdown file to adopt.") unless args.length == 1
|
|
104
|
+
|
|
105
|
+
Commands::Adopt.new(@context).call(args.first)
|
|
106
|
+
else publish(args)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def publish(files)
|
|
111
|
+
case files.length
|
|
112
|
+
when 1 then Commands::Publish.new(@context).call(files.first)
|
|
113
|
+
when 0 then usage("No Markdown file given.")
|
|
114
|
+
else usage("Give one Markdown file at a time (got #{files.length}).")
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def print_help
|
|
119
|
+
stdout.puts parser.help
|
|
120
|
+
OK
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def print_version
|
|
124
|
+
stdout.puts "notion-publish #{VERSION}"
|
|
125
|
+
OK
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def usage(message)
|
|
129
|
+
stderr.puts message
|
|
130
|
+
stderr.puts
|
|
131
|
+
stderr.puts parser.help
|
|
132
|
+
USAGE
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def api_failure(error)
|
|
136
|
+
return error.message unless error.restricted?
|
|
137
|
+
|
|
138
|
+
<<~MSG.strip
|
|
139
|
+
#{@context.client.connection_name.inspect} is not allowed to do that (#{error.code}).
|
|
140
|
+
|
|
141
|
+
Check the connection's capabilities in the Notion developer portal.
|
|
142
|
+
Publishing needs "Insert content".
|
|
143
|
+
MSG
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# One line per option is easier to scan than any attempt to shorten it.
|
|
147
|
+
# rubocop:disable-next Metrics/AbcSize, Metrics/MethodLength, Metrics/BlockLength
|
|
148
|
+
def parser
|
|
149
|
+
@parser ||= OptionParser.new do |o|
|
|
150
|
+
o.banner = <<~BANNER.strip
|
|
151
|
+
Usage: notion-publish FILE [options]
|
|
152
|
+
notion-publish republish [DIR] update every page notion-publish-manifest.yml tracks
|
|
153
|
+
notion-publish properties [options] show the destination's schema
|
|
154
|
+
notion-publish relink [DIR] fix links to documents published later
|
|
155
|
+
notion-publish adopt FILE [options] record a page this file already corresponds to
|
|
156
|
+
notion-publish status [DIR] what would happen if you published everything
|
|
157
|
+
BANNER
|
|
158
|
+
o.separator ""
|
|
159
|
+
o.separator "Destination (first one given wins):"
|
|
160
|
+
o.on("-p", "--parent ID_OR_URL_OR_NAME", "Where to publish: an ID, a Notion URL, or a database name") do |v|
|
|
161
|
+
@options[:parent] = v
|
|
162
|
+
end
|
|
163
|
+
o.on("-d", "--database NAME", "Force name lookup (for a database named like an ID)") do |v|
|
|
164
|
+
@options[:database] = v
|
|
165
|
+
end
|
|
166
|
+
o.separator ""
|
|
167
|
+
o.separator "Options:"
|
|
168
|
+
o.on("-P", "--property NAME=VALUE", "Set a property; repeat for multi-valued ones") do |v|
|
|
169
|
+
(@options[:properties] ||= []) << v
|
|
170
|
+
end
|
|
171
|
+
o.on("--properties-json JSON", "Set properties from a JSON object") { |v| @options[:properties_json] = v }
|
|
172
|
+
o.on("--no-upload", "Do not upload local images (they will not render)") { @options[:upload] = false }
|
|
173
|
+
o.on("--icon ICON", "Page icon: an emoji, an image URL, or a path to an image") { |v| @options[:icon] = v }
|
|
174
|
+
o.on("--cover COVER", "Page cover: an image URL or a path to an image") { |v| @options[:cover] = v }
|
|
175
|
+
o.on("--keep-h1", "Keep the leading H1 in the body as well as the title") { @options[:keep_h1] = true }
|
|
176
|
+
o.on("--link", "Record this page so it can be updated and linked to") { @options[:link] = true }
|
|
177
|
+
o.on("--no-link", "Do not record or update; always create a new page") { @options[:link] = false }
|
|
178
|
+
o.on("--manifest PATH", "Manifest to use (default: #{Manifest::FILENAME} at the repo root)") do |v|
|
|
179
|
+
@options[:manifest] = v
|
|
180
|
+
end
|
|
181
|
+
o.on("--local", "status: do not check Notion, use the recorded hashes only") { @options[:local] = true }
|
|
182
|
+
o.on("--untracked", "status: list files that were never published") { @options[:untracked] = true }
|
|
183
|
+
o.on("--page URL_OR_ID", "adopt: the page this file already corresponds to") { |v| @options[:page] = v }
|
|
184
|
+
o.on("-y", "--yes", "adopt: accept a title match without confirming") { @options[:yes] = true }
|
|
185
|
+
o.on("--force-properties", "Reapply properties even if nothing else changed") do
|
|
186
|
+
@options[:force_properties] = true
|
|
187
|
+
end
|
|
188
|
+
o.on("-f", "--force", "Publish even if the Notion page changed since it was published") do
|
|
189
|
+
@options[:force] = true
|
|
190
|
+
end
|
|
191
|
+
o.on("-t", "--title TITLE", "Page title (default: front matter, first heading, or filename)") do |v|
|
|
192
|
+
@options[:title] = v
|
|
193
|
+
end
|
|
194
|
+
o.on("-n", "--dry-run", "Resolve and validate, write nothing") { @options[:dry_run] = true }
|
|
195
|
+
o.on("--json", "Emit one JSON object per document on stdout") { @options[:json] = true }
|
|
196
|
+
o.on("--token TOKEN", "API token (default: $#{Client::TOKEN_ENV_VARS.join(', $')})") do |v|
|
|
197
|
+
@options[:token] = v
|
|
198
|
+
end
|
|
199
|
+
o.on("--whoami", "Show what the token authenticates as") { @options[:whoami] = true }
|
|
200
|
+
o.on("--no-progress", "Do not show a progress line in a terminal") { @options[:no_progress] = true }
|
|
201
|
+
o.on("-j", "--jobs N", Integer, "Pages to check at once (default #{Pool::SIZE}; 1 runs one at a time)") do |v|
|
|
202
|
+
@options[:jobs] = v
|
|
203
|
+
end
|
|
204
|
+
o.on("-v", "--verbose", "List every file; -vv logs API requests; -vvv adds bodies") do
|
|
205
|
+
@options[:verbose] = (@options[:verbose] || 0) + 1
|
|
206
|
+
end
|
|
207
|
+
o.on("--version", "Show version") { @options[:version] = true }
|
|
208
|
+
o.on("-h", "--help", "Show this message") { @options[:help] = true }
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "monitor"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
require_relative "errors"
|
|
9
|
+
require_relative "log"
|
|
10
|
+
require_relative "version"
|
|
11
|
+
|
|
12
|
+
module NotionPublish
|
|
13
|
+
# A small hand-rolled Notion client.
|
|
14
|
+
#
|
|
15
|
+
# There is no official Ruby SDK, and every community gem on RubyGems predates
|
|
16
|
+
# both the data-source split (2025-09-03) and the markdown endpoints, so
|
|
17
|
+
# wrapping one would mean fighting a client that models an older API. The
|
|
18
|
+
# surface we need is five endpoints, so this is Net::HTTP and no dependencies.
|
|
19
|
+
class Client
|
|
20
|
+
API_ORIGIN = "https://api.notion.com"
|
|
21
|
+
API_VERSION = "2026-03-11"
|
|
22
|
+
|
|
23
|
+
# Env vars are checked in this order. NOTION_API_TOKEN is what Notion's own
|
|
24
|
+
# `ntn` CLI reads; NOTION_API_KEY is what its quickstart tells people to set.
|
|
25
|
+
TOKEN_ENV_VARS = %w[NOTION_API_TOKEN NOTION_API_KEY].freeze
|
|
26
|
+
|
|
27
|
+
RETRYABLE_STATUSES = [429, 502, 503, 504, 529].freeze
|
|
28
|
+
MAX_ATTEMPTS = 4
|
|
29
|
+
PAGE_SIZE = 100
|
|
30
|
+
USER_AGENT = "notion-publish/#{VERSION} (+https://github.com/outsidecto/notion-publish)".freeze
|
|
31
|
+
|
|
32
|
+
attr_reader :api_version
|
|
33
|
+
|
|
34
|
+
def self.token_from_env(env = ENV)
|
|
35
|
+
TOKEN_ENV_VARS.each do |name|
|
|
36
|
+
value = env[name]
|
|
37
|
+
return value unless value.nil? || value.strip.empty?
|
|
38
|
+
end
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# +log+ is a Log, or nil for silence.
|
|
43
|
+
def initialize(token: nil, api_version: API_VERSION, sleeper: method(:sleep), log: nil)
|
|
44
|
+
@token = token || self.class.token_from_env
|
|
45
|
+
raise MissingToken, TOKEN_ENV_VARS if @token.nil? || @token.strip.empty?
|
|
46
|
+
|
|
47
|
+
@api_version = api_version
|
|
48
|
+
@sleeper = sleeper
|
|
49
|
+
@log = log
|
|
50
|
+
# Net::HTTP is not safe to share between threads, so each thread that
|
|
51
|
+
# makes requests gets its own kept-alive connection.
|
|
52
|
+
@connections = {}
|
|
53
|
+
@lock = Monitor.new
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def delete(path) = request(Net::HTTP::Delete, path)
|
|
57
|
+
def get(path, query = nil) = request(Net::HTTP::Get, path, query: query)
|
|
58
|
+
def post(path, body = nil) = request(Net::HTTP::Post, path, body: body)
|
|
59
|
+
def patch(path, body = nil) = request(Net::HTTP::Patch, path, body: body)
|
|
60
|
+
|
|
61
|
+
# Every result of a paginated GET, following next_cursor to the end.
|
|
62
|
+
def get_all(path, query = {})
|
|
63
|
+
results = []
|
|
64
|
+
cursor = nil
|
|
65
|
+
loop do
|
|
66
|
+
page = get(path, query.merge("page_size" => PAGE_SIZE, "start_cursor" => cursor))
|
|
67
|
+
results.concat(page["results"] || [])
|
|
68
|
+
cursor = page["next_cursor"]
|
|
69
|
+
break unless page["has_more"] && cursor
|
|
70
|
+
end
|
|
71
|
+
results
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Sends file bytes to the upload URL handed back by POST /v1/file_uploads.
|
|
75
|
+
# That endpoint wants multipart/form-data rather than JSON, so it does not
|
|
76
|
+
# go through #request.
|
|
77
|
+
def post_file(url, path:, content_type:)
|
|
78
|
+
uri = URI(url)
|
|
79
|
+
boundary = "notion-publish-#{Time.now.to_i}-#{rand(1 << 32)}"
|
|
80
|
+
|
|
81
|
+
req = authorized(Net::HTTP::Post.new(uri))
|
|
82
|
+
req["Content-Type"] = "multipart/form-data; boundary=#{boundary}"
|
|
83
|
+
req.body = multipart(boundary, path, content_type)
|
|
84
|
+
|
|
85
|
+
started = monotonic
|
|
86
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 120) do |http|
|
|
87
|
+
http.request(req)
|
|
88
|
+
end
|
|
89
|
+
# The body is file bytes, so describe it rather than print it.
|
|
90
|
+
trace(req, response, monotonic - started, sent: "(#{File.size(path)} bytes of #{content_type})")
|
|
91
|
+
parsed = parse(response)
|
|
92
|
+
return parsed if response.code.to_i.between?(200, 299)
|
|
93
|
+
|
|
94
|
+
raise ApiError.new(status: response.code.to_i, body: parsed)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Keeps the token out of logs and exception reports.
|
|
98
|
+
def inspect = "#<#{self.class.name} api_version=#{@api_version}>"
|
|
99
|
+
|
|
100
|
+
# Identity of the token itself. Cached: the CLI asks for it when building
|
|
101
|
+
# error messages, which can happen more than once per run.
|
|
102
|
+
def me
|
|
103
|
+
@lock.synchronize { @me ||= fetch_me }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Human name of the connection or person this token authenticates as, for
|
|
107
|
+
# error messages that have to tell someone what to share a page with.
|
|
108
|
+
def connection_name
|
|
109
|
+
me["name"] || "your integration"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# What kind of credential this is: :person for a personal access token,
|
|
113
|
+
# :bot_user for a connection a user owns, or :bot_workspace for an internal
|
|
114
|
+
# connection owned by the workspace. Only the last cannot act as anyone.
|
|
115
|
+
def credential_kind
|
|
116
|
+
case me["type"]
|
|
117
|
+
when "person" then :person
|
|
118
|
+
when "bot"
|
|
119
|
+
me.dig("bot", "owner", "type") == "user" ? :bot_user : :bot_workspace
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def fetch_me
|
|
126
|
+
get("/v1/users/me").tap do |found|
|
|
127
|
+
@log&.note("authenticated as #{found['name'].inspect} in #{found.dig('bot', 'workspace_name').inspect}")
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def multipart(boundary, path, content_type)
|
|
132
|
+
# Force binary: joining image bytes with UTF-8 strings raises otherwise.
|
|
133
|
+
[
|
|
134
|
+
"--#{boundary}\r\n",
|
|
135
|
+
%(Content-Disposition: form-data; name="file"; filename="#{File.basename(path)}"\r\n),
|
|
136
|
+
"Content-Type: #{content_type}\r\n\r\n",
|
|
137
|
+
File.binread(path),
|
|
138
|
+
"\r\n--#{boundary}--\r\n"
|
|
139
|
+
].map { |part| part.dup.force_encoding(Encoding::BINARY) }.join
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def authorized(req)
|
|
143
|
+
req["Authorization"] = "Bearer #{@token}"
|
|
144
|
+
req["Notion-Version"] = @api_version
|
|
145
|
+
req["User-Agent"] = USER_AGENT
|
|
146
|
+
req
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def build(klass, path, body, query)
|
|
150
|
+
uri = URI.join(API_ORIGIN, path)
|
|
151
|
+
uri.query = URI.encode_www_form(query.compact) if query && !query.empty?
|
|
152
|
+
|
|
153
|
+
req = authorized(klass.new(uri))
|
|
154
|
+
req["Accept"] = "application/json"
|
|
155
|
+
if body
|
|
156
|
+
req["Content-Type"] = "application/json"
|
|
157
|
+
req.body = JSON.generate(body)
|
|
158
|
+
end
|
|
159
|
+
req
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def request(klass, path, body: nil, query: nil, attempt: 1)
|
|
163
|
+
req = build(klass, path, body, query)
|
|
164
|
+
started = monotonic
|
|
165
|
+
response = http.request(req)
|
|
166
|
+
status = response.code.to_i
|
|
167
|
+
trace(req, response, monotonic - started)
|
|
168
|
+
|
|
169
|
+
if RETRYABLE_STATUSES.include?(status) && attempt < MAX_ATTEMPTS
|
|
170
|
+
delay = retry_delay(response, attempt)
|
|
171
|
+
@log&.retrying(status, delay, attempt, MAX_ATTEMPTS)
|
|
172
|
+
@sleeper.call(delay)
|
|
173
|
+
return request(klass, path, body: body, query: query, attempt: attempt + 1)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
parsed = parse(response)
|
|
177
|
+
return parsed if status.between?(200, 299)
|
|
178
|
+
|
|
179
|
+
error_class = status == 429 ? RateLimited : ApiError
|
|
180
|
+
raise error_class.new(status: status, body: parsed)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def trace(req, response, seconds, sent: req.body)
|
|
184
|
+
return unless @log
|
|
185
|
+
|
|
186
|
+
@log.request(req.method, req.uri, response.code, seconds)
|
|
187
|
+
@log.body(">", sent)
|
|
188
|
+
@log.body("<", response.body)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
192
|
+
|
|
193
|
+
# Notion sends Retry-After on 429. Everything else gets exponential backoff.
|
|
194
|
+
def retry_delay(response, attempt)
|
|
195
|
+
after = response["Retry-After"].to_f
|
|
196
|
+
return after if after.positive?
|
|
197
|
+
|
|
198
|
+
2**(attempt - 1)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def parse(response)
|
|
202
|
+
body = response.body.to_s
|
|
203
|
+
return {} if body.empty?
|
|
204
|
+
|
|
205
|
+
JSON.parse(body)
|
|
206
|
+
rescue JSON::ParserError
|
|
207
|
+
{ "code" => "invalid_response", "message" => body[0, 200] }
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# One connection per thread, kept open for the run: a publish makes a
|
|
211
|
+
# dozen calls.
|
|
212
|
+
def http
|
|
213
|
+
@lock.synchronize { @connections[Thread.current] ||= connect }
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def connect
|
|
217
|
+
uri = URI(API_ORIGIN)
|
|
218
|
+
connection = Net::HTTP.new(uri.host, uri.port)
|
|
219
|
+
connection.use_ssl = true
|
|
220
|
+
connection.open_timeout = 10
|
|
221
|
+
connection.read_timeout = 60
|
|
222
|
+
connection.start
|
|
223
|
+
connection
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
require_relative "../notion_digest"
|
|
6
|
+
require_relative "command"
|
|
7
|
+
require_relative "../adopter"
|
|
8
|
+
require_relative "../document"
|
|
9
|
+
|
|
10
|
+
module NotionPublish
|
|
11
|
+
module Commands
|
|
12
|
+
# Records that a local file and an existing Notion page are the same
|
|
13
|
+
# document. Changes neither.
|
|
14
|
+
#
|
|
15
|
+
# Deliberately does not record source_sha256: adoption asserts identity,
|
|
16
|
+
# not that the page already holds this content, so the next publish always
|
|
17
|
+
# runs rather than reporting it unchanged.
|
|
18
|
+
class Adopt < Command
|
|
19
|
+
def call(path)
|
|
20
|
+
document = Document.load(path)
|
|
21
|
+
map = Manifest.locate(path, override: options[:manifest])
|
|
22
|
+
source = File.expand_path(path)
|
|
23
|
+
|
|
24
|
+
if (existing = map.entry(source))
|
|
25
|
+
raise Error, already_adopted(path, existing)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
candidate = choose(document, path)
|
|
29
|
+
return CLI::FAILURE unless candidate
|
|
30
|
+
|
|
31
|
+
record(map, source, candidate)
|
|
32
|
+
stdout.puts "Adopted #{path}"
|
|
33
|
+
stdout.puts " #{candidate.url}"
|
|
34
|
+
stdout.puts "Run notion-publish to update it."
|
|
35
|
+
CLI::OK
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def record(map, source, candidate)
|
|
41
|
+
markdown = client.get("/v1/pages/#{candidate.id}/markdown")["markdown"].to_s
|
|
42
|
+
map.workspace_id = client.me.dig("bot", "workspace_id")
|
|
43
|
+
map.record(source, Manifest::Entry.new(id: candidate.id, url: candidate.url,
|
|
44
|
+
notion_sha256: NotionDigest.of(markdown),
|
|
45
|
+
flag_properties: []))
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def choose(document, path)
|
|
49
|
+
adopter = Adopter.new(client)
|
|
50
|
+
return adopter.by_page(options[:page]) if options[:page]
|
|
51
|
+
|
|
52
|
+
target = resolve_destination(document, settings_for(path))
|
|
53
|
+
title = options[:title] || document.title
|
|
54
|
+
found = adopter.by_title(target, title)
|
|
55
|
+
|
|
56
|
+
case found.length
|
|
57
|
+
when 1 then confirm(found.first, target, title)
|
|
58
|
+
when 0 then raise Error, nothing_matches(title, target)
|
|
59
|
+
else raise Error, too_many(title, target, found)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def confirm(candidate, target, title)
|
|
64
|
+
return candidate if options[:yes]
|
|
65
|
+
raise Error, needs_a_terminal(candidate, title) unless stdin.tty?
|
|
66
|
+
|
|
67
|
+
stdout.puts "One page in #{target.title.inspect} is titled #{title.inspect}:"
|
|
68
|
+
stdout.puts " #{candidate.url}"
|
|
69
|
+
stdout.puts " last edited #{candidate.last_edited_time.to_s[0, 10]} by #{candidate.last_edited_by}"
|
|
70
|
+
stdout.puts
|
|
71
|
+
stdout.puts "Adopting means the next publish will replace that page's contents."
|
|
72
|
+
stdout.print "Adopt it? [y/N] "
|
|
73
|
+
candidate if stdin.gets.to_s.strip.casecmp?("y")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def already_adopted(path, entry)
|
|
77
|
+
<<~MSG.strip
|
|
78
|
+
#{path} already points at a page:
|
|
79
|
+
#{entry.url}
|
|
80
|
+
|
|
81
|
+
Delete that entry from #{Manifest::FILENAME} first if you meant to point it
|
|
82
|
+
somewhere else.
|
|
83
|
+
MSG
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def nothing_matches(title, target)
|
|
87
|
+
<<~MSG.strip
|
|
88
|
+
No page in #{target.title.inspect} is titled #{title.inspect}.
|
|
89
|
+
|
|
90
|
+
Nothing to adopt. Publish it instead:
|
|
91
|
+
notion-publish <file> --parent #{target.title.inspect}
|
|
92
|
+
MSG
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def too_many(title, target, found)
|
|
96
|
+
lines = ["More than one page in #{target.title.inspect} is titled #{title.inspect}:", ""]
|
|
97
|
+
found.each { |c| lines << " #{c.url}" }
|
|
98
|
+
lines << "" << "Choose one with --page <url>."
|
|
99
|
+
lines.join("\n")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def needs_a_terminal(candidate, title)
|
|
103
|
+
<<~MSG.strip
|
|
104
|
+
Found one page titled #{title.inspect}:
|
|
105
|
+
#{candidate.url}
|
|
106
|
+
|
|
107
|
+
Adopting replaces that page's contents on the next publish, and there is
|
|
108
|
+
no terminal here to confirm it. Pass --yes to answer in advance, or
|
|
109
|
+
--page <url> to name the page outright.
|
|
110
|
+
MSG
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|