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