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