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