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,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../manifest"
5
+ require_relative "../pool"
6
+ require_relative "../progress"
7
+ require_relative "../property_set"
8
+ require_relative "../reference"
9
+ require_relative "../resolver"
10
+ require_relative "../settings"
11
+ require_relative "../target"
12
+
13
+ module NotionPublish
14
+ module Commands
15
+ # Shared plumbing: the invocation's options and streams, and the lookups
16
+ # that more than one command needs.
17
+ class Command
18
+ PARENT_TYPES = %w[page_id database_id data_source_id].freeze
19
+ MAX_DEPTH = 10
20
+
21
+ def initialize(context)
22
+ @context = context
23
+ end
24
+
25
+ private
26
+
27
+ def options = @context.options
28
+ def stdout = @stdout ||= progress.wrap(@context.stdout)
29
+ def stderr = @stderr ||= progress.wrap(@context.stderr)
30
+ def stdin = @context.stdin
31
+ def client = @context.client
32
+ def progress = @progress ||= Progress.new(@context.stderr, enabled: progress_wanted?)
33
+ def jobs = options[:jobs] || Pool::SIZE
34
+
35
+ # Only on a terminal, and not when the request log is already showing
36
+ # activity or --json output is being read by a program.
37
+ def progress_wanted?
38
+ terminal = @context.stderr.respond_to?(:tty?) && @context.stderr.tty?
39
+ terminal && !options[:no_progress] && !logging_requests? && !options[:json]
40
+ end
41
+
42
+ # -v: list every file, including those where nothing happened.
43
+ def listing_everything? = options[:verbose].to_i >= 1
44
+ def logging_requests? = options[:verbose].to_i >= 2
45
+
46
+ def settings_for(path)
47
+ Settings.for(File.dirname(File.expand_path(path)))
48
+ end
49
+
50
+ # First match wins: command line, then front matter, then settings.
51
+ def resolve_destination(document, settings)
52
+ resolver = Resolver.new(client)
53
+
54
+ if (parent = options[:parent] || document.parent || settings.parent)
55
+ return resolver.resolve(parent)
56
+ end
57
+
58
+ name = options[:database] || document.database || settings.database
59
+ return resolver.resolve_database_name(name) if name
60
+
61
+ raise Error, no_destination_message
62
+ end
63
+
64
+ # The recorded parent is enough: the page is updated in place, and the
65
+ # parent only supplies the schema. An adopted entry records none, so ask
66
+ # Notion where the page lives.
67
+ def target_for(entry)
68
+ parent = entry.parent
69
+ return resolve_parent_of(entry) unless parent
70
+
71
+ Target.new(kind: parent["type"] == "page_id" ? :page : :data_source, id: parent["id"],
72
+ title: parent["name"], database_id: nil, inline: nil)
73
+ end
74
+
75
+ # A page's parent can be a block inside another page, such as a toggle
76
+ # heading the page was moved under. Walk up until a page, database, or
77
+ # data source appears; that is what supplies the schema.
78
+ def resolve_parent_of(entry)
79
+ parent = client.get("/v1/pages/#{entry.id}")["parent"] || {}
80
+ MAX_DEPTH.times do
81
+ type = parent["type"]
82
+ return Resolver.new(client).resolve_reference(parent[type]) if PARENT_TYPES.include?(type)
83
+ break unless type == "block_id"
84
+
85
+ parent = client.get("/v1/blocks/#{parent['block_id']}")["parent"] || {}
86
+ end
87
+ raise Error, "Cannot tell which page or database #{entry.url} sits under."
88
+ end
89
+
90
+ def property_set(document)
91
+ PropertySet.build(front_matter: document.properties, json: options[:properties_json],
92
+ pairs: options[:properties] || [])
93
+ end
94
+
95
+ def manifest_in(dir)
96
+ Manifest.locate_in(File.expand_path(dir), override: options[:manifest])
97
+ end
98
+
99
+ def no_destination_message
100
+ <<~MSG.strip
101
+ No destination. Notion needs a parent for every page, and an internal
102
+ connection cannot create pages at the workspace root.
103
+
104
+ Give one of these:
105
+ --parent <ID, Notion URL, or exact database name>
106
+ --database <exact database name>
107
+
108
+ Or set notion_parent / notion_database in the file's front matter, or
109
+ parent / database in #{Settings::FILENAME}.
110
+ MSG
111
+ end
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "command"
4
+ require_relative "../document"
5
+ require_relative "../schema"
6
+
7
+ module NotionPublish
8
+ module Commands
9
+ # Lists the destination's properties, their types, and the options a
10
+ # select can take, so a user can see what --property will accept.
11
+ class Properties < Command
12
+ def call
13
+ target = resolve_destination(Document.new("(none)", ""), Settings.for(Dir.pwd))
14
+ schema = Schema.for(client, target)
15
+
16
+ stdout.puts target.describe
17
+ schema.properties.each { |name, definition| print_property(name, definition) }
18
+ CLI::OK
19
+ end
20
+
21
+ private
22
+
23
+ def print_property(name, definition)
24
+ type = definition["type"]
25
+ note = Schema::COMPUTED.include?(type) ? " (read-only)" : ""
26
+ stdout.puts format(" %-24s %s%s", name, type, note)
27
+
28
+ choices = definition.dig(type, "options")
29
+ stdout.puts " #{choices.map { |o| o['name'] }.join(' | ')}" if choices && !choices.empty?
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "command"
6
+ require_relative "reporting"
7
+ require_relative "../decoration"
8
+ require_relative "../document"
9
+ require_relative "../publisher"
10
+
11
+ module NotionPublish
12
+ module Commands
13
+ # Publishes one Markdown file, or with --dry-run says what publishing it
14
+ # would do.
15
+ class Publish < Command
16
+ include Reporting
17
+
18
+ # How a dry run shows each kind of property value.
19
+ SUMMARIES = {
20
+ "title" => ->(value) { value.map { |v| v.dig("text", "content") }.join.inspect },
21
+ "rich_text" => ->(value) { value.map { |v| v.dig("text", "content") }.join.inspect },
22
+ "multi_select" => ->(value) { value.map { |v| v["name"] }.inspect },
23
+ "select" => ->(value) { value["name"].inspect },
24
+ "status" => ->(value) { value["name"].inspect },
25
+ "people" => ->(value) { value.map { |v| v["id"] }.inspect },
26
+ "relation" => ->(value) { value.map { |v| v["id"] }.inspect },
27
+ # The form --property takes, rather than a Hash#inspect, whose format
28
+ # differs between Ruby versions.
29
+ "date" => ->(value) { value && [value["start"], value["end"]].compact.join("..") }
30
+ }.freeze
31
+
32
+ def call(path)
33
+ document = Document.load(path)
34
+ settings = settings_for(path)
35
+ map = manifest_for(path)
36
+ target = destination(document, settings, map&.entry(File.expand_path(path)))
37
+ icon = options[:icon] || document.icon || settings.icon
38
+ cover = options[:cover] || document.cover || settings.cover
39
+ Decoration.check!(icon, :icon) if icon
40
+ Decoration.check!(cover, :cover) if cover
41
+
42
+ return dry_run(path, document, target, map, icon: icon, cover: cover) if options[:dry_run]
43
+
44
+ warnings = []
45
+ outcome = publisher.publish(document, target: target, manifest: map, properties: property_set(document),
46
+ warnings: warnings, icon: icon, cover: cover, **publish_flags)
47
+ report(path, outcome, target, warnings)
48
+ end
49
+
50
+ private
51
+
52
+ def publisher = @publisher ||= Publisher.new(client)
53
+
54
+ # A file the manifest already tracks is updated where its page is, so it needs
55
+ # no destination. One that is given anyway is still used.
56
+ def destination(document, settings, entry)
57
+ named = options[:parent] || options[:database] || document.parent || document.database ||
58
+ settings.parent || settings.database
59
+ return target_for(entry) if entry && !named
60
+
61
+ resolve_destination(document, settings)
62
+ end
63
+
64
+ def publish_flags
65
+ {
66
+ title: options[:title], title_given: !options[:title].nil?,
67
+ upload: options[:upload] != false, keep_h1: options[:keep_h1] == true,
68
+ force: options[:force] == true, force_properties: options[:force_properties] == true
69
+ }
70
+ end
71
+
72
+ # Without --link, a manifest is used only if one already exists; publishing
73
+ # never starts recording a corpus unasked.
74
+ def manifest_for(path)
75
+ return nil if options[:link] == false
76
+
77
+ map = Manifest.locate(path, override: options[:manifest])
78
+ return map unless map.created?
79
+ return nil unless options[:link] || options[:manifest]
80
+
81
+ stderr.puts "Creating #{map.path}"
82
+ map
83
+ end
84
+
85
+ # A dry run resolves, validates every property against the live schema,
86
+ # and says what a real run would do.
87
+ def dry_run(path, document, target, map, icon:, cover:)
88
+ warnings = []
89
+ built = preview_properties(document, target, warnings)
90
+ media = publisher.scan_media(document)
91
+ check_images(media)
92
+
93
+ warnings.each { |w| stderr.puts w }
94
+ stdout.puts "Would #{intended_action(path, map)} #{path} to #{target.describe}"
95
+ stdout.puts " title: #{(options[:title] || document.title).inspect}"
96
+ built.each { |key, payload| stdout.puts " #{key}: #{summarise(payload)}" }
97
+ media.images.each { |image| stdout.puts " upload: #{image.path}" }
98
+ stdout.puts " icon: #{icon}" if icon
99
+ stdout.puts " cover: #{cover}" if cover
100
+ CLI::OK
101
+ end
102
+
103
+ def preview_properties(document, target, warnings)
104
+ set = property_set(document)
105
+ return {} if set.empty?
106
+
107
+ publisher.build_properties(publisher.schema_for(target), set, options[:title] || document.title,
108
+ !options[:title].nil?, warnings)
109
+ end
110
+
111
+ def check_images(media)
112
+ media.images.each do |image|
113
+ resolved = media.resolved_path(image)
114
+ next if File.file?(resolved)
115
+
116
+ raise Error, "No such image: #{image.path} (looked in #{File.dirname(resolved)})"
117
+ end
118
+ end
119
+
120
+ def intended_action(path, map)
121
+ entry = map&.entry(File.expand_path(path))
122
+ return "publish" unless entry
123
+
124
+ same = entry.source_sha256 == Digest::SHA256.hexdigest(File.binread(File.expand_path(path)))
125
+ same && options[:force] != true ? "leave unchanged" : "update"
126
+ end
127
+
128
+ def summarise(payload)
129
+ type, value = payload.first
130
+ SUMMARIES.fetch(type, :inspect.to_proc).call(value)
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ require_relative "../notion_digest"
6
+ require_relative "command"
7
+ require_relative "../links"
8
+ require_relative "../pool"
9
+
10
+ module NotionPublish
11
+ module Commands
12
+ # The second pass after publishing a set. A document published before the
13
+ # one it links to could not resolve that link, so Notion stored it as
14
+ # https://<filename>.md. Now that every page has a URL, fix those.
15
+ class Relink < Command
16
+ def call(dir)
17
+ map = manifest_in(dir)
18
+ if map.created? || map.pages.empty?
19
+ stderr.puts "Nothing published yet: no #{Manifest::FILENAME} with pages in #{dir}."
20
+ return CLI::FAILURE
21
+ end
22
+
23
+ fixed = relink_all(map).sum
24
+ report_orphans(map)
25
+ stdout.puts fixed.zero? ? "No links needed fixing." : "Fixed #{fixed} #{plural(fixed, 'link')}."
26
+ CLI::OK
27
+ end
28
+
29
+ private
30
+
31
+ # Several pages at a time. Workers read the published URLs from a copy
32
+ # taken first, since the manifest itself changes as pages are rehashed.
33
+ def relink_all(map)
34
+ urls = map.pages.transform_values { |raw| raw["url"] }
35
+ client
36
+ progress.start("Checking", map.pages.length)
37
+ Pool.run(map.pages.to_a, size: jobs, work: lambda { |(key, raw)|
38
+ relink_page(map, urls, key, Manifest::Entry.from(raw))
39
+ },
40
+ started: ->((key, _)) { progress.started(key) },
41
+ finished: ->(done) { progress.finished(done) }) do |(key, _), count|
42
+ stdout.puts "#{File.basename(key)}: fixed #{count} #{plural(count, 'link')}" if count.positive?
43
+ end
44
+ ensure
45
+ progress.finish
46
+ end
47
+
48
+ # Returns the number of links fixed.
49
+ def relink_page(map, urls, key, entry)
50
+ before = read_markdown(entry.id)
51
+ updates = pending_updates(urls, before)
52
+ return 0 if updates.empty?
53
+
54
+ client.patch("/v1/pages/#{entry.id}/markdown",
55
+ { "type" => "update_content", "update_content" => { "content_updates" => updates } })
56
+ rehash(map, key, entry, before)
57
+ updates.length
58
+ end
59
+
60
+ # The page now differs from what was recorded after publishing, and it
61
+ # was this tool that changed it. Record the new content, or the next
62
+ # status would call it an edit made in Notion. Only when the page was in
63
+ # sync beforehand: an edit someone else made must stay visible.
64
+ def rehash(map, key, entry, before)
65
+ return unless entry.notion_sha256 == NotionDigest.of(before)
66
+
67
+ after = NotionDigest.of(read_markdown(entry.id))
68
+ map.record(File.expand_path(key, map.dir), entry.with(notion_sha256: after))
69
+ end
70
+
71
+ def read_markdown(page_id) = client.get("/v1/pages/#{page_id}/markdown")["markdown"].to_s
72
+
73
+ def pending_updates(urls, markdown)
74
+ urls.filter_map do |key, url|
75
+ mangled = Links.mangled(File.basename(key))
76
+ next unless markdown.include?("](#{mangled})")
77
+
78
+ { "old_str" => "](#{mangled})", "new_str" => "](#{url})", "replace_all_matches" => true }
79
+ end
80
+ end
81
+
82
+ # The tool cannot tell a retirement from a rename, so it reports and stops.
83
+ def report_orphans(map)
84
+ orphans = map.orphans
85
+ return if orphans.empty?
86
+
87
+ stderr.puts "#{orphans.length} #{orphans.length == 1 ? 'entry has' : 'entries have'} no source file:"
88
+ orphans.each { |key| stderr.puts " #{key}" }
89
+ stderr.puts "Their Notion pages are still live. Remove them there if they are retired."
90
+ end
91
+
92
+ def plural(count, word) = count == 1 ? word : "#{word}s"
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module NotionPublish
6
+ module Commands
7
+ # How a publish outcome is shown, shared by publish and republish so a
8
+ # script sees the same lines and the same JSON from either.
9
+ module Reporting
10
+ private
11
+
12
+ # A single file you named is always reported. In a run over many files,
13
+ # the ones where nothing happened are listed only with -v.
14
+ def show_unchanged? = true
15
+
16
+ # Prints the outcome and returns the exit code it deserves on its own.
17
+ def report(path, outcome, target, warnings)
18
+ warnings.each { |w| stderr.puts w }
19
+ report_json(path, outcome, target) if options[:json]
20
+
21
+ case outcome.action
22
+ when :blocked
23
+ stderr.puts "Blocked #{path}"
24
+ stderr.puts outcome.detail
25
+ CLI::BLOCKED
26
+ when :skipped
27
+ stderr.puts "Skipped #{path}"
28
+ stderr.puts outcome.detail
29
+ CLI::BLOCKED
30
+ else
31
+ report_text(path, outcome, target) unless options[:json]
32
+ CLI::OK
33
+ end
34
+ end
35
+
36
+ def report_json(path, outcome, target)
37
+ stdout.puts JSON.generate(
38
+ "source" => path, "action" => outcome.action.to_s,
39
+ "id" => outcome.id, "url" => outcome.url,
40
+ "parent" => target.id, "parent_name" => target.title
41
+ )
42
+ end
43
+
44
+ def report_text(path, outcome, target)
45
+ case outcome.action
46
+ when :unchanged
47
+ stdout.puts "Unchanged #{path}" if show_unchanged?
48
+ return
49
+ when :properties then stdout.puts "Updated properties on #{path}"
50
+ when :created then stdout.puts "Published #{path} to #{target.describe}"
51
+ else stdout.puts "Updated #{path} to #{target.describe}"
52
+ end
53
+ stdout.puts " #{outcome.url}"
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "command"
6
+ require_relative "reporting"
7
+ require_relative "../decoration"
8
+ require_relative "../document"
9
+ require_relative "../pool"
10
+ require_relative "../publisher"
11
+ require_relative "../target"
12
+
13
+ module NotionPublish
14
+ module Commands
15
+ # Publishes every document the manifest tracks, from what each entry
16
+ # recorded, without naming files one by one.
17
+ #
18
+ # Each page is updated where it already is, so no destination is needed
19
+ # and none is accepted. Files the manifest does not track are left alone:
20
+ # publishing a new document stays something you do on purpose.
21
+ class Republish < Command
22
+ include Reporting
23
+
24
+ # Options that describe one document or one destination. They make no
25
+ # sense applied to a whole set, so they are reported and ignored.
26
+ IGNORED = {
27
+ parent: "--parent", database: "--database", properties: "--property",
28
+ properties_json: "--properties-json", title: "--title", icon: "--icon", cover: "--cover",
29
+ keep_h1: "--keep-h1", link: "--link/--no-link", page: "--page", yes: "--yes", local: "--local",
30
+ untracked: "--untracked"
31
+ }.freeze
32
+
33
+ SUMMARY = {
34
+ unchanged: "unchanged", updated: "updated", properties: "properties updated", created: "recreated",
35
+ blocked: "changed in Notion", skipped: "skipped", failed: "failed", orphaned: "no source file"
36
+ }.freeze
37
+
38
+ def call(dir)
39
+ return dry_run_refused if options[:dry_run]
40
+
41
+ warn_ignored
42
+ map = manifest_in(dir)
43
+ if map.created? || map.pages.empty?
44
+ stderr.puts "Nothing published yet: no #{Manifest::FILENAME} with pages in #{dir}."
45
+ return CLI::FAILURE
46
+ end
47
+
48
+ entries = entries_under(map, File.expand_path(dir)).to_a
49
+ results = run_all(map, entries).map(&:action)
50
+ stdout.puts summary(results) unless options[:json]
51
+ exit_code(results)
52
+ end
53
+
54
+ private
55
+
56
+ def publisher = @publisher ||= Publisher.new(client)
57
+ def show_unchanged? = listing_everything?
58
+
59
+ def dry_run_refused
60
+ stderr.puts "republish has no --dry-run. `notion-publish status` shows what it would do."
61
+ CLI::USAGE
62
+ end
63
+
64
+ def warn_ignored
65
+ given = IGNORED.filter_map { |key, flag| flag if options.key?(key) }
66
+ return if given.empty?
67
+
68
+ stderr.puts "Ignoring #{given.join(', ')}: republish updates each page where it already is, " \
69
+ "from what its entry recorded."
70
+ end
71
+
72
+ def entries_under(map, root)
73
+ map.pages.select do |key, _|
74
+ path = File.expand_path(key, map.dir)
75
+ path == root || path.start_with?("#{root}#{File::SEPARATOR}")
76
+ end
77
+ end
78
+
79
+ # What republishing one file came to. Built on a worker thread and
80
+ # printed on the calling one, in file order.
81
+ Result = Data.define(:key, :action, :outcome, :target, :warnings, :message)
82
+
83
+ # Several files at a time. The client, the publisher, and the manifest are
84
+ # set up here first, so no worker races to create them.
85
+ def run_all(map, entries)
86
+ client
87
+ publisher
88
+ progress.start("Checking", entries.length)
89
+ Pool.run(entries, size: jobs, work: ->((key, raw)) { republish(map, key, raw) },
90
+ started: ->((key, _)) { progress.started(key) },
91
+ finished: ->(done) { progress.finished(done) }) do |_, result|
92
+ print_result(result)
93
+ end
94
+ ensure
95
+ progress.finish
96
+ end
97
+
98
+ # One document failing does not stop the rest, the same as a shell loop
99
+ # without `|| break`.
100
+ def republish(map, key, raw)
101
+ entry = Manifest::Entry.from(raw)
102
+ path = File.expand_path(key, map.dir)
103
+ return Result.new(key, :orphaned, nil, nil, [], nil) unless File.file?(path)
104
+
105
+ target = target_for(entry)
106
+ warnings = []
107
+ outcome = publish(path, entry, target, map, warnings)
108
+ Result.new(key, outcome.action, outcome, target, warnings, nil)
109
+ rescue Error => e
110
+ Result.new(key, :failed, nil, nil, [], e.message)
111
+ end
112
+
113
+ def print_result(result)
114
+ return report(result.key, result.outcome, result.target, result.warnings) if result.outcome
115
+
116
+ # A script reading --json must see every file, including those that
117
+ # never reached Notion.
118
+ if options[:json]
119
+ stdout.puts JSON.generate({ "source" => result.key, "action" => result.action.to_s,
120
+ "error" => result.message }.compact)
121
+ end
122
+ case result.action
123
+ when :orphaned then stderr.puts "Skipped #{result.key}: no source file. Its Notion page is still live."
124
+ when :failed then stderr.puts "Failed #{result.key}: #{result.message}"
125
+ end
126
+ end
127
+
128
+ def publish(path, entry, target, map, warnings)
129
+ document = Document.load(path)
130
+ settings = settings_for(path)
131
+ icon = document.icon || settings.icon
132
+ cover = document.cover || settings.cover
133
+ Decoration.check!(icon, :icon) if icon
134
+ Decoration.check!(cover, :cover) if cover
135
+
136
+ publisher.republish(document, entry: entry, target: target, manifest: map, warnings: warnings,
137
+ upload: options[:upload] != false, icon: icon, cover: cover,
138
+ force: options[:force] == true,
139
+ force_properties: options[:force_properties] == true)
140
+ end
141
+
142
+ def summary(results)
143
+ counts = results.tally
144
+ parts = SUMMARY.filter_map { |action, label| "#{counts[action]} #{label}" if counts[action] }
145
+ "#{results.length} #{results.length == 1 ? 'document' : 'documents'}: #{parts.join(', ')}"
146
+ end
147
+
148
+ def exit_code(results)
149
+ return CLI::FAILURE if results.include?(:failed)
150
+ return CLI::BLOCKED if results.intersect?(%i[blocked skipped])
151
+
152
+ CLI::OK
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "command"
6
+ require_relative "../status"
7
+
8
+ module NotionPublish
9
+ module Commands
10
+ # What would happen if you published everything. Exits 3 when a tracked
11
+ # document needs something done to it, so it works as a CI check. Files
12
+ # that were never published do not count: plenty are deliberately not
13
+ # mirrored.
14
+ class Status < Command
15
+ # Problems first, then what is in sync, then what was never published.
16
+ ORDER = (NotionPublish::Status::ACTIONABLE + %i[unchanged unpublished]).freeze
17
+ # States where the page itself is what needs looking at.
18
+ SHOW_URL = %i[drifted diverged missing trashed orphaned].freeze
19
+
20
+ def call(dir)
21
+ map = manifest_in(dir)
22
+ if map.created?
23
+ stderr.puts "Nothing published yet: no #{Manifest::FILENAME} at or above #{dir}."
24
+ return CLI::FAILURE
25
+ end
26
+
27
+ check = options[:local] != true
28
+ progress.start("Checking", map.pages.length)
29
+ rows = NotionPublish::Status.new(map, client: check ? client : nil)
30
+ .rows(dir: dir, check_notion: check, jobs: jobs,
31
+ started: ->(key) { progress.started(key) },
32
+ finished: ->(done) { progress.finished(done) })
33
+ progress.finish
34
+ options[:json] ? print_json(rows) : print_text(map, rows, check)
35
+ rows.any?(&:actionable?) ? CLI::BLOCKED : CLI::OK
36
+ end
37
+
38
+ private
39
+
40
+ def print_json(rows)
41
+ rows.each do |row|
42
+ stdout.puts JSON.generate("source" => row.source, "state" => row.state.to_s, "url" => row.url)
43
+ end
44
+ end
45
+
46
+ def print_text(map, rows, checked)
47
+ tracked = rows.count { |r| r.state != :unpublished }
48
+ stdout.puts "#{map.path} -- #{tracked} tracked"
49
+ stdout.puts "(not checked against Notion; --local was given)" unless checked
50
+
51
+ groups = rows.group_by(&:state)
52
+ ORDER.each { |state| print_group(state, groups[state]) if groups[state] }
53
+
54
+ stdout.puts
55
+ stdout.puts summary(groups.transform_values(&:length))
56
+ end
57
+
58
+ def print_group(state, rows)
59
+ return if state == :unchanged && !listing_everything?
60
+
61
+ label = NotionPublish::Status::STATES[state]
62
+ heading = "#{label[0].upcase}#{label[1..]} (#{rows.length})"
63
+ stdout.puts
64
+ if state == :unpublished && !options[:untracked]
65
+ stdout.puts "#{heading}: pass --untracked to list them"
66
+ return
67
+ end
68
+
69
+ stdout.puts heading
70
+ rows.each do |row|
71
+ stdout.puts " #{row.source}"
72
+ stdout.puts " #{row.url}" if row.url && SHOW_URL.include?(state)
73
+ end
74
+ end
75
+
76
+ def summary(counts)
77
+ return "Everything is in sync." if counts.empty? || counts.keys == [:unchanged]
78
+
79
+ NotionPublish::Status::STATES.filter_map { |state, label| "#{counts[state]} #{label}" if counts[state] }
80
+ .join(", ")
81
+ end
82
+ end
83
+ end
84
+ end