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,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "client"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
require_relative "reference"
|
|
6
|
+
require_relative "sharing"
|
|
7
|
+
require_relative "target"
|
|
8
|
+
|
|
9
|
+
module NotionPublish
|
|
10
|
+
# Turns --parent and --database into a Target.
|
|
11
|
+
class Resolver
|
|
12
|
+
class Unreachable < Error; end
|
|
13
|
+
class Ambiguous < Error; end
|
|
14
|
+
class NotNamed < Error; end
|
|
15
|
+
class WrongKind < Error; end
|
|
16
|
+
|
|
17
|
+
def initialize(client)
|
|
18
|
+
@client = client
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# --parent takes an ID, a URL, or a database name. Nothing else has to be
|
|
22
|
+
# said: an ID and a URL are recognisable by shape, and anything else is a
|
|
23
|
+
# name. A user cannot tell a page ID from a database ID by looking at it --
|
|
24
|
+
# both are /p/<hex> -- so making them declare the type would be asking for
|
|
25
|
+
# a guess.
|
|
26
|
+
def resolve(input)
|
|
27
|
+
if Reference.reference?(input)
|
|
28
|
+
resolve_reference(input)
|
|
29
|
+
else
|
|
30
|
+
resolve_database_name(input)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Identify first, fetch second. A page is also a block, so /v1/blocks says
|
|
35
|
+
# what an ID names in one call and hands back the title with it. That beats
|
|
36
|
+
# probing typed endpoints in turn, which took up to three calls and had to
|
|
37
|
+
# treat /v1/databases' 400 ("is a page, not a database") as a miss.
|
|
38
|
+
def resolve_reference(input)
|
|
39
|
+
ref = Reference.parse(input)
|
|
40
|
+
target = identify(ref.uuid)
|
|
41
|
+
return target if target
|
|
42
|
+
|
|
43
|
+
raise Unreachable, Sharing.unreachable(
|
|
44
|
+
id: ref.uuid,
|
|
45
|
+
title: ref.slug_title,
|
|
46
|
+
connection_name: @client.connection_name
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Notion's search is fuzzy and relevance-ranked, so a single hit does not
|
|
51
|
+
# mean an exact match: querying "a" returns three of thirteen data sources.
|
|
52
|
+
# Publishing into a database the user did not name is silent and annoying to
|
|
53
|
+
# undo, so only an exact title match is accepted.
|
|
54
|
+
def resolve_database_name(name)
|
|
55
|
+
candidates = search_data_sources(name)
|
|
56
|
+
exact = candidates.select { |c| c[:title].casecmp?(name.strip) }
|
|
57
|
+
|
|
58
|
+
case exact.length
|
|
59
|
+
when 1 then to_data_source_target(exact.first)
|
|
60
|
+
when 0 then raise NotNamed, no_exact_match_message(name, candidates)
|
|
61
|
+
else raise Ambiguous, ambiguous_message(name, exact)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def identify(uuid)
|
|
68
|
+
block = fetch_block(uuid)
|
|
69
|
+
|
|
70
|
+
case block && block["type"]
|
|
71
|
+
when "child_page"
|
|
72
|
+
Target.new(kind: :page, id: uuid, title: block.dig("child_page", "title"),
|
|
73
|
+
database_id: nil, inline: false)
|
|
74
|
+
when "child_database"
|
|
75
|
+
database_target(uuid)
|
|
76
|
+
when nil
|
|
77
|
+
# Data sources are not blocks, so a 404 here means either a data source
|
|
78
|
+
# or something we cannot reach. Only the typed call tells them apart.
|
|
79
|
+
data_source_target(uuid)
|
|
80
|
+
else
|
|
81
|
+
raise WrongKind, "#{uuid} is a #{block['type'].tr('_', ' ')} block, not a page or database."
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fetch_block(uuid)
|
|
86
|
+
@client.get("/v1/blocks/#{uuid}")
|
|
87
|
+
rescue ApiError => e
|
|
88
|
+
raise unless e.not_found?
|
|
89
|
+
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# A database is a container; the schema and the rows live on its data
|
|
94
|
+
# sources. One source resolves cleanly. Two or more is genuinely ambiguous
|
|
95
|
+
# and Notion itself rejects a bare database parent in that case, so we stop
|
|
96
|
+
# and make the user choose rather than guessing.
|
|
97
|
+
def database_target(uuid)
|
|
98
|
+
body = @client.get("/v1/databases/#{uuid}")
|
|
99
|
+
sources = body["data_sources"] || []
|
|
100
|
+
db_title = plain_title(body["title"])
|
|
101
|
+
|
|
102
|
+
case sources.length
|
|
103
|
+
when 1
|
|
104
|
+
Target.new(
|
|
105
|
+
kind: :data_source,
|
|
106
|
+
id: sources.first["id"],
|
|
107
|
+
title: sources.first["name"].to_s.empty? ? db_title : sources.first["name"],
|
|
108
|
+
database_id: body["id"],
|
|
109
|
+
inline: body["is_inline"] == true
|
|
110
|
+
)
|
|
111
|
+
when 0
|
|
112
|
+
raise Unreachable, "Database #{db_title.inspect} (#{uuid}) has no data sources."
|
|
113
|
+
else
|
|
114
|
+
raise Ambiguous, multi_source_message(db_title, uuid, sources)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def data_source_target(uuid)
|
|
119
|
+
body = @client.get("/v1/data_sources/#{uuid}")
|
|
120
|
+
Target.new(
|
|
121
|
+
kind: :data_source,
|
|
122
|
+
id: body["id"],
|
|
123
|
+
title: plain_title(body["title"]),
|
|
124
|
+
database_id: body.dig("parent", "database_id"),
|
|
125
|
+
# Only the database object carries is_inline. A data source whose
|
|
126
|
+
# parent is a block_id is not necessarily inline: a full-page database
|
|
127
|
+
# can have a block_id parent and is_inline false.
|
|
128
|
+
inline: nil
|
|
129
|
+
)
|
|
130
|
+
rescue ApiError => e
|
|
131
|
+
raise unless e.not_found?
|
|
132
|
+
|
|
133
|
+
nil
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Every data source the search returns, across pages. Search ranks by
|
|
137
|
+
# relevance, so an exact match is not guaranteed to be on the first page.
|
|
138
|
+
def search_data_sources(name)
|
|
139
|
+
search_results(name.to_s.strip).map do |result|
|
|
140
|
+
{
|
|
141
|
+
id: result["id"],
|
|
142
|
+
title: plain_title(result["title"]).to_s,
|
|
143
|
+
database_id: result.dig("parent", "database_id"),
|
|
144
|
+
inline: nil
|
|
145
|
+
}
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def search_results(query)
|
|
150
|
+
results = []
|
|
151
|
+
cursor = nil
|
|
152
|
+
loop do
|
|
153
|
+
body = @client.post("/v1/search", {
|
|
154
|
+
"query" => query, "page_size" => Client::PAGE_SIZE, "start_cursor" => cursor,
|
|
155
|
+
"filter" => { "property" => "object", "value" => "data_source" }
|
|
156
|
+
}.compact)
|
|
157
|
+
results.concat(body["results"] || [])
|
|
158
|
+
cursor = body["next_cursor"]
|
|
159
|
+
break unless body["has_more"] && cursor
|
|
160
|
+
end
|
|
161
|
+
results
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def to_data_source_target(hit)
|
|
165
|
+
Target.new(
|
|
166
|
+
kind: :data_source,
|
|
167
|
+
id: hit[:id],
|
|
168
|
+
title: hit[:title],
|
|
169
|
+
database_id: hit[:database_id],
|
|
170
|
+
inline: hit[:inline]
|
|
171
|
+
)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def plain_title(rich_text)
|
|
175
|
+
return nil unless rich_text.is_a?(Array)
|
|
176
|
+
|
|
177
|
+
rich_text.map { |chunk| chunk["plain_text"] }.join
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def no_exact_match_message(name, candidates)
|
|
181
|
+
lines = ["No database is named #{name.inspect}.", ""]
|
|
182
|
+
if candidates.empty?
|
|
183
|
+
lines << "Nothing similar is shared with the #{@client.connection_name.inspect} connection."
|
|
184
|
+
lines << Sharing.instructions(nil)
|
|
185
|
+
else
|
|
186
|
+
lines << "Similar names that are shared with this connection:"
|
|
187
|
+
candidates.first(10).each do |c|
|
|
188
|
+
shown = c[:title].empty? ? "(untitled)" : c[:title]
|
|
189
|
+
lines << " #{shown} --parent #{c[:id]}"
|
|
190
|
+
end
|
|
191
|
+
lines << ""
|
|
192
|
+
lines << "Names must match exactly. Use --parent with an ID to be unambiguous."
|
|
193
|
+
end
|
|
194
|
+
lines.join("\n")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def ambiguous_message(name, matches)
|
|
198
|
+
lines = ["More than one database is named #{name.inspect}:", ""]
|
|
199
|
+
matches.each { |m| lines << " --parent #{m[:id]}" }
|
|
200
|
+
lines << ""
|
|
201
|
+
lines << "Use --parent with the ID you want."
|
|
202
|
+
lines.join("\n")
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def multi_source_message(db_title, uuid, sources)
|
|
206
|
+
lines = ["Database #{db_title.inspect} (#{uuid}) has #{sources.length} data sources:", ""]
|
|
207
|
+
sources.each do |s|
|
|
208
|
+
name = s["name"].to_s.empty? ? "(untitled)" : s["name"]
|
|
209
|
+
lines << " #{name} --parent #{s['id']}"
|
|
210
|
+
end
|
|
211
|
+
lines << ""
|
|
212
|
+
lines << "Point --parent at one of them."
|
|
213
|
+
lines.join("\n")
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "did_you_mean"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
require_relative "reference"
|
|
7
|
+
|
|
8
|
+
module NotionPublish
|
|
9
|
+
# The property definitions of a publishing target, and the rules for turning
|
|
10
|
+
# command-line strings into Notion property values.
|
|
11
|
+
#
|
|
12
|
+
# Because the schema is fetched anyway, the CLI syntax carries no type or
|
|
13
|
+
# arity information: --property 'Due Date=2026-09-15' is a date because the
|
|
14
|
+
# schema says so, and repeating --property is a list only where the property
|
|
15
|
+
# can hold one.
|
|
16
|
+
class Schema
|
|
17
|
+
# Notion computes these. Writing them is an error, so say so plainly rather
|
|
18
|
+
# than letting the API reject the whole request.
|
|
19
|
+
COMPUTED = %w[created_time created_by last_edited_time last_edited_by formula rollup unique_id].freeze
|
|
20
|
+
|
|
21
|
+
# Everything else holds exactly one value.
|
|
22
|
+
MULTI_VALUED = %w[multi_select people relation].freeze
|
|
23
|
+
|
|
24
|
+
OPTION_TYPES = %w[select status multi_select].freeze
|
|
25
|
+
|
|
26
|
+
attr_reader :properties
|
|
27
|
+
|
|
28
|
+
def self.for(client, target)
|
|
29
|
+
return new({}, page: true) if target.page?
|
|
30
|
+
|
|
31
|
+
body = client.get("/v1/data_sources/#{target.id}")
|
|
32
|
+
new(body["properties"] || {})
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def initialize(properties, page: false)
|
|
36
|
+
@properties = properties
|
|
37
|
+
@page = page
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def page? = @page
|
|
41
|
+
|
|
42
|
+
def title_key
|
|
43
|
+
return "title" if page?
|
|
44
|
+
|
|
45
|
+
key, = properties.find { |_, definition| definition["type"] == "title" }
|
|
46
|
+
key or raise Error, "This data source has no title property."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def names = properties.keys
|
|
50
|
+
|
|
51
|
+
# Property names are matched case-insensitively, because nobody remembers
|
|
52
|
+
# whether it is "Due Date" or "Due date".
|
|
53
|
+
def lookup(name)
|
|
54
|
+
wanted = name.to_s.strip
|
|
55
|
+
properties.find { |key, _| key.casecmp?(wanted) }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def type_of(name)
|
|
59
|
+
_, definition = lookup(name)
|
|
60
|
+
definition && definition["type"]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def multi_valued?(name) = MULTI_VALUED.include?(type_of(name))
|
|
64
|
+
|
|
65
|
+
# Returns [notion_key, value_payload] ready to drop into a page's
|
|
66
|
+
# properties. +values+ is always an array; arity is checked here.
|
|
67
|
+
def build(name, values, users: nil)
|
|
68
|
+
if page?
|
|
69
|
+
raise Error, page_parent_message(name) unless name.casecmp?("title")
|
|
70
|
+
|
|
71
|
+
return ["title", text_payload("title", values.first.to_s)]
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
key, definition = lookup(name)
|
|
75
|
+
raise Error, unknown_property_message(name) unless key
|
|
76
|
+
|
|
77
|
+
type = definition["type"]
|
|
78
|
+
raise Error, computed_message(key, type) if COMPUTED.include?(type)
|
|
79
|
+
|
|
80
|
+
return [key, empty_payload(type)] if cleared?(values)
|
|
81
|
+
raise Error, arity_message(key, type, values) if values.length > 1 && !MULTI_VALUED.include?(type)
|
|
82
|
+
|
|
83
|
+
[key, payload(key, definition, type, values, users)]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
def cleared?(values) = values.length == 1 && values.first.to_s.empty?
|
|
89
|
+
|
|
90
|
+
# One branch per property type Notion defines; splitting it would only scatter the table.
|
|
91
|
+
def payload(key, definition, type, values, users) # rubocop:disable Metrics/CyclomaticComplexity
|
|
92
|
+
case type
|
|
93
|
+
when "title", "rich_text" then text_payload(type, values.first.to_s)
|
|
94
|
+
when "select", "status" then { type => { "name" => option!(key, definition, type, values.first) } }
|
|
95
|
+
when "multi_select"
|
|
96
|
+
{ "multi_select" => values.map { |v| { "name" => option!(key, definition, type, v) } } }
|
|
97
|
+
when "people" then { "people" => values.map { |v| { "object" => "user", "id" => user!(v, users) } } }
|
|
98
|
+
when "relation" then { "relation" => values.map { |v| { "id" => Reference.parse(v).uuid } } }
|
|
99
|
+
when "date" then { "date" => date_payload(key, values.first) }
|
|
100
|
+
when "checkbox" then { "checkbox" => checkbox!(key, values.first) }
|
|
101
|
+
when "number" then { "number" => number!(key, values.first) }
|
|
102
|
+
when "url", "email", "phone_number" then { type => values.first.to_s }
|
|
103
|
+
when "files"
|
|
104
|
+
raise Error, "#{key} is a files property. notion-publish cannot set file properties yet."
|
|
105
|
+
else
|
|
106
|
+
raise Error, "#{key} is a #{type} property, which notion-publish cannot set."
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def text_payload(type, string)
|
|
111
|
+
{ type => [{ "type" => "text", "text" => { "content" => string } }] }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def empty_payload(type)
|
|
115
|
+
case type
|
|
116
|
+
when "title", "rich_text" then { type => [] }
|
|
117
|
+
when "multi_select" then { "multi_select" => [] }
|
|
118
|
+
when "people" then { "people" => [] }
|
|
119
|
+
when "relation" then { "relation" => [] }
|
|
120
|
+
when "checkbox" then { "checkbox" => false }
|
|
121
|
+
else { type => nil }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Notion *creates* an unrecognised select option instead of rejecting it, so
|
|
126
|
+
# a typo would silently grow the schema. Match against the existing options
|
|
127
|
+
# and return their canonical spelling.
|
|
128
|
+
def option!(key, definition, type, value)
|
|
129
|
+
wanted = value.to_s.strip
|
|
130
|
+
options = definition.dig(type, "options") || []
|
|
131
|
+
match = options.find { |o| o["name"].casecmp?(wanted) }
|
|
132
|
+
return match["name"] if match
|
|
133
|
+
|
|
134
|
+
raise Error, unknown_option_message(key, wanted, options.map { |o| o["name"] })
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# A people value may be a user ID or a name, which needs the workspace's
|
|
138
|
+
# user list to resolve. That list is fetched lazily, only when needed.
|
|
139
|
+
def user!(value, users)
|
|
140
|
+
candidate = value.to_s.strip
|
|
141
|
+
return candidate if candidate.match?(Reference::BARE_ID)
|
|
142
|
+
raise Error, "Cannot resolve #{candidate.inspect} to a user." unless users
|
|
143
|
+
|
|
144
|
+
users.resolve(candidate)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def date_payload(key, value)
|
|
148
|
+
start, finish = value.to_s.split("..", 2).map(&:strip)
|
|
149
|
+
raise Error, "#{key} needs a date, got an empty value." if start.to_s.empty?
|
|
150
|
+
|
|
151
|
+
payload = { "start" => start }
|
|
152
|
+
payload["end"] = finish if finish && !finish.empty?
|
|
153
|
+
payload
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def checkbox!(key, value)
|
|
157
|
+
case value.to_s.strip.downcase
|
|
158
|
+
when "true", "yes", "y", "1", "checked" then true
|
|
159
|
+
when "false", "no", "n", "0", "unchecked" then false
|
|
160
|
+
else raise Error, "#{key} is a checkbox. Use true or false, not #{value.to_s.inspect}."
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def number!(key, value)
|
|
165
|
+
string = value.to_s.strip
|
|
166
|
+
return Integer(string) if string.match?(/\A-?\d+\z/)
|
|
167
|
+
|
|
168
|
+
Float(string)
|
|
169
|
+
rescue ArgumentError, TypeError
|
|
170
|
+
raise Error, "#{key} is a number. #{value.to_s.inspect} is not one."
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def unknown_property_message(name)
|
|
174
|
+
close = DidYouMean::SpellChecker.new(dictionary: names).correct(name.to_s)
|
|
175
|
+
lines = ["There is no property named #{name.to_s.inspect}."]
|
|
176
|
+
lines << "" << "Did you mean: #{close.join(', ')}" unless close.empty?
|
|
177
|
+
lines << "" << "Run `notion-publish properties` to see the schema."
|
|
178
|
+
lines.join("\n")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def unknown_option_message(key, value, options)
|
|
182
|
+
<<~MSG.strip
|
|
183
|
+
#{key} has no option named #{value.inspect}.
|
|
184
|
+
|
|
185
|
+
Options are: #{options.join(' | ')}
|
|
186
|
+
|
|
187
|
+
Notion would create a new option rather than reject this, so
|
|
188
|
+
notion-publish refuses it. Add the option in Notion first.
|
|
189
|
+
MSG
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def computed_message(key, type)
|
|
193
|
+
"#{key} is a #{type} property. Notion computes it and it cannot be set."
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def arity_message(key, type, values)
|
|
197
|
+
"#{key} is a #{type} property and takes one value; you gave #{values.length}: " \
|
|
198
|
+
"#{values.map(&:inspect).join(', ')}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def page_parent_message(name)
|
|
202
|
+
"A page parent accepts only a title, so #{name.to_s.inspect} cannot be set. " \
|
|
203
|
+
"Publish into a database to set properties."
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
require_relative "errors"
|
|
6
|
+
|
|
7
|
+
module NotionPublish
|
|
8
|
+
# Hand-written configuration. The tool reads it and never writes it, so
|
|
9
|
+
# comments and formatting survive -- Ruby's YAML discards comments on read and
|
|
10
|
+
# cannot put them back, which is why anything the tool rewrites lives in
|
|
11
|
+
# Manifest instead.
|
|
12
|
+
#
|
|
13
|
+
# Every .notion-publish.yml from the repository root down to the document's
|
|
14
|
+
# own directory is merged, closest winning, the way .rubocop.yml and
|
|
15
|
+
# .editorconfig behave.
|
|
16
|
+
class Settings
|
|
17
|
+
FILENAME = ".notion-publish.yml"
|
|
18
|
+
KEYS = %w[parent database icon cover].freeze
|
|
19
|
+
|
|
20
|
+
attr_reader :data, :files
|
|
21
|
+
|
|
22
|
+
def self.for(dir)
|
|
23
|
+
dirs = ancestors(File.expand_path(dir))
|
|
24
|
+
files = dirs.map { |d| File.join(d, FILENAME) }.select { |f| File.file?(f) }
|
|
25
|
+
new(files)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Outermost first, so nearer files overwrite farther ones on merge. Stops at
|
|
29
|
+
# a git repository root when there is one.
|
|
30
|
+
def self.ancestors(dir)
|
|
31
|
+
chain = []
|
|
32
|
+
loop do
|
|
33
|
+
chain << dir
|
|
34
|
+
break if File.directory?(File.join(dir, ".git"))
|
|
35
|
+
|
|
36
|
+
parent = File.dirname(dir)
|
|
37
|
+
break if parent == dir
|
|
38
|
+
|
|
39
|
+
dir = parent
|
|
40
|
+
end
|
|
41
|
+
chain.reverse
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def initialize(files)
|
|
45
|
+
@files = files
|
|
46
|
+
@data = files.reduce({}) { |merged, file| merged.merge(load_file(file)) }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
KEYS.each { |key| define_method(key) { data[key] } }
|
|
50
|
+
|
|
51
|
+
def empty? = data.empty?
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def load_file(path)
|
|
56
|
+
loaded = YAML.safe_load_file(path, permitted_classes: [], aliases: false) || {}
|
|
57
|
+
raise ConfigError, "#{path} must contain a YAML mapping" unless loaded.is_a?(Hash)
|
|
58
|
+
|
|
59
|
+
check_keys!(path, loaded.keys)
|
|
60
|
+
loaded.slice(*KEYS)
|
|
61
|
+
rescue Psych::Exception => e
|
|
62
|
+
raise ConfigError, "Could not parse #{path}: #{e.message}"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def check_keys!(path, keys)
|
|
66
|
+
unknown = keys - KEYS
|
|
67
|
+
return if unknown.empty?
|
|
68
|
+
|
|
69
|
+
raise ConfigError, "#{path}: unknown setting#{'s' if unknown.length > 1} " \
|
|
70
|
+
"#{unknown.map(&:inspect).join(', ')}. Known settings: #{KEYS.join(', ')}."
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module NotionPublish
|
|
4
|
+
# Notion returns object_not_found both for objects that do not exist and for
|
|
5
|
+
# objects that exist but are not shared with the calling connection. The API
|
|
6
|
+
# gives us no way to tell those apart, so these messages must not claim to.
|
|
7
|
+
module Sharing
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def unreachable(id:, title:, connection_name:, kind: nil)
|
|
11
|
+
name = title && !title.empty? ? "#{title.inspect} (#{id})" : id.to_s
|
|
12
|
+
<<~MSG.strip
|
|
13
|
+
Cannot reach #{[kind, name].compact.join(' ')}.
|
|
14
|
+
|
|
15
|
+
Either it does not exist, or it is not shared with the
|
|
16
|
+
#{connection_name.inspect} connection. Notion returns the same error for
|
|
17
|
+
both, so there is no way to tell which from here.
|
|
18
|
+
|
|
19
|
+
#{instructions(kind)}
|
|
20
|
+
MSG
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def instructions(kind)
|
|
24
|
+
case kind
|
|
25
|
+
when "inline database"
|
|
26
|
+
<<~MSG.strip
|
|
27
|
+
An inline database has no connection menu of its own. Share the page it
|
|
28
|
+
lives on:
|
|
29
|
+
1. Open that page in Notion
|
|
30
|
+
2. ••• menu (top right) -> Connections
|
|
31
|
+
3. Add the connection
|
|
32
|
+
MSG
|
|
33
|
+
else
|
|
34
|
+
<<~MSG.strip
|
|
35
|
+
To share it:
|
|
36
|
+
1. Open it in Notion
|
|
37
|
+
2. ••• menu (top right) -> Connections
|
|
38
|
+
3. Add the connection
|
|
39
|
+
|
|
40
|
+
Anything nested underneath is shared automatically.
|
|
41
|
+
|
|
42
|
+
An inline database has no menu of its own: share the page it sits on.
|
|
43
|
+
MSG
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
require_relative "notion_digest"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
require_relative "manifest"
|
|
8
|
+
require_relative "pool"
|
|
9
|
+
|
|
10
|
+
module NotionPublish
|
|
11
|
+
# What would happen if you published everything.
|
|
12
|
+
#
|
|
13
|
+
# Answers the question the manifest exists to make answerable: which
|
|
14
|
+
# documents are in sync, which have changed locally, which changed in Notion,
|
|
15
|
+
# and which entries no longer have a source file.
|
|
16
|
+
class Status
|
|
17
|
+
STATES = {
|
|
18
|
+
unchanged: "in sync",
|
|
19
|
+
modified: "changed locally",
|
|
20
|
+
drifted: "changed in Notion",
|
|
21
|
+
diverged: "changed in both",
|
|
22
|
+
missing: "page is gone from Notion",
|
|
23
|
+
trashed: "page is in Notion's trash",
|
|
24
|
+
orphaned: "no source file",
|
|
25
|
+
unpublished: "never published"
|
|
26
|
+
}.freeze
|
|
27
|
+
|
|
28
|
+
# States that mean something needs doing to a document the manifest tracks.
|
|
29
|
+
ACTIONABLE = %i[modified drifted diverged missing trashed orphaned].freeze
|
|
30
|
+
|
|
31
|
+
SKIP_DIRS = %w[.git node_modules vendor tmp .bundle].freeze
|
|
32
|
+
|
|
33
|
+
Row = Data.define(:source, :state, :url) do
|
|
34
|
+
def actionable? = ACTIONABLE.include?(state)
|
|
35
|
+
def label = STATES[state]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Whether a page object is in Notion's trash. Older API versions called
|
|
39
|
+
# this "archived".
|
|
40
|
+
def self.trashed?(page) = page["in_trash"] == true || page["archived"] == true
|
|
41
|
+
|
|
42
|
+
def initialize(map, client: nil)
|
|
43
|
+
@map = map
|
|
44
|
+
@client = client
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Pages are checked a few at a time. +started+ is called with each key as
|
|
48
|
+
# it is picked up and +finished+ with the count done so far, both on the
|
|
49
|
+
# calling thread, so a caller can show progress.
|
|
50
|
+
def rows(dir: nil, check_notion: true, started: nil, finished: nil, jobs: Pool::SIZE)
|
|
51
|
+
@client&.me if check_notion
|
|
52
|
+
tracked = Pool.run(@map.pages.to_a, size: jobs,
|
|
53
|
+
work: lambda { |(key, raw)|
|
|
54
|
+
tracked_row(key, Manifest::Entry.from(raw), check_notion)
|
|
55
|
+
},
|
|
56
|
+
started: started && ->((key, _)) { started.call(key) }, finished: finished)
|
|
57
|
+
tracked.sort_by { |r| [ACTIONABLE.index(r.state) || 99, r.source] } + untracked(dir)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def tracked_row(key, entry, check_notion)
|
|
63
|
+
path = File.expand_path(key, @map.dir)
|
|
64
|
+
return Row.new(source: key, state: :orphaned, url: entry.url) unless File.file?(path)
|
|
65
|
+
|
|
66
|
+
local = entry.source_sha256 && entry.source_sha256 != Digest::SHA256.hexdigest(File.binread(path))
|
|
67
|
+
remote = check_notion ? remote_state(entry) : nil
|
|
68
|
+
return Row.new(source: key, state: remote, url: entry.url) if %i[missing trashed].include?(remote)
|
|
69
|
+
|
|
70
|
+
Row.new(source: key, state: state_for(local, remote == :drifted), url: entry.url)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def state_for(local, remote)
|
|
74
|
+
return :diverged if local && remote
|
|
75
|
+
return :modified if local
|
|
76
|
+
return :drifted if remote
|
|
77
|
+
|
|
78
|
+
:unchanged
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# :missing, :trashed, :drifted, or nil when the page is as published.
|
|
82
|
+
#
|
|
83
|
+
# Deleting a page in Notion moves it to the trash, and the API still
|
|
84
|
+
# returns a trashed page, Markdown and all. Only the page object says so,
|
|
85
|
+
# which is why it is read for every page, not only for those whose
|
|
86
|
+
# content changed: a page trashed without being edited still matches.
|
|
87
|
+
def remote_state(entry)
|
|
88
|
+
return nil unless @client
|
|
89
|
+
return :trashed if Status.trashed?(@client.get("/v1/pages/#{entry.id}"))
|
|
90
|
+
return :drifted if drifted?(entry)
|
|
91
|
+
|
|
92
|
+
nil
|
|
93
|
+
rescue ApiError => e
|
|
94
|
+
raise unless e.not_found?
|
|
95
|
+
|
|
96
|
+
:missing
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Notion's own output on both sides: a round trip is not byte-stable, so the
|
|
100
|
+
# sent form would never match a later read.
|
|
101
|
+
def drifted?(entry)
|
|
102
|
+
return false unless entry.notion_sha256
|
|
103
|
+
|
|
104
|
+
markdown = @client.get("/v1/pages/#{entry.id}/markdown")["markdown"].to_s
|
|
105
|
+
NotionDigest.of(markdown) != entry.notion_sha256
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Markdown files under the manifest that have never been published. Reported for
|
|
109
|
+
# information; they are not counted as needing action, because plenty of
|
|
110
|
+
# files are deliberately not mirrored.
|
|
111
|
+
def untracked(dir)
|
|
112
|
+
root = dir ? File.expand_path(dir) : @map.dir
|
|
113
|
+
return [] unless File.directory?(root)
|
|
114
|
+
|
|
115
|
+
known = @map.pages.keys.map { |k| File.expand_path(k, @map.dir) }
|
|
116
|
+
markdown_under(root).reject { |p| known.include?(p) }
|
|
117
|
+
.sort
|
|
118
|
+
.map { |p| Row.new(source: @map.key_for(p), state: :unpublished, url: nil) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def markdown_under(root)
|
|
122
|
+
Dir.glob("**/*.md", base: root)
|
|
123
|
+
.reject { |rel| rel.split(File::SEPARATOR).any? { |part| SKIP_DIRS.include?(part) || part.start_with?(".") } }
|
|
124
|
+
.map { |rel| File.join(root, rel) }
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|