pagecord-cli 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/LICENSE +21 -0
- data/README.md +131 -0
- data/bin/pagecord +6 -0
- data/lib/pagecord_cli/cli.rb +197 -0
- data/lib/pagecord_cli/client.rb +118 -0
- data/lib/pagecord_cli/config.rb +84 -0
- data/lib/pagecord_cli/image_uploads.rb +84 -0
- data/lib/pagecord_cli/post_file.rb +202 -0
- data/lib/pagecord_cli/version.rb +5 -0
- data/lib/pagecord_cli.rb +8 -0
- metadata +52 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: a1e156f74ff6f6b5860f2a21cae283aaccf1d3bec27ef02ed5bfc02002a1bdb3
|
|
4
|
+
data.tar.gz: d05ee18c97ee613d5903e76bf3508c20ed6536b8120a3a3f60dc279539048203
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: f02588d28a65bfd183cd5830f96d88395523960ef7a0ab4584be5e7e386b0f15530a1b5fa8b2c169b629a5cbabac138d77d428cffaeabaada09c2ffbe7a4d105
|
|
7
|
+
data.tar.gz: 5057321b2a894a091ab7f7e76cb8fb0e9724f3576b9c435f83dc06dac38e59cb78f0e240f942297f34fd4479b2caf8a9de3887b4bd1e7593c2f0f89b796b8e65
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Pagecord
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# Pagecord CLI
|
|
2
|
+
|
|
3
|
+
Publish local Markdown and HTML files to [Pagecord](https://pagecord.com).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
gem install pagecord-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Login
|
|
12
|
+
|
|
13
|
+
Generate an API key from **Settings > API** in Pagecord, then save it locally:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pagecord login myblog
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The name should be your Pagecord subdomain. The API key is stored in
|
|
20
|
+
`~/.pagecord.yml`.
|
|
21
|
+
|
|
22
|
+
For local testing there is an undocumented `--base-url` option:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pagecord login myblog --base-url http://localhost:3000
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Publishing
|
|
29
|
+
|
|
30
|
+
Publish a file:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pagecord publish hello.md
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Save or update a draft:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pagecord draft notes/idea.md
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The first publish creates a post and writes Pagecord metadata back into the
|
|
43
|
+
file. Later publishes update the same post.
|
|
44
|
+
|
|
45
|
+
If you have one blog configured, `publish`, `draft`, and `logout` can omit the
|
|
46
|
+
subdomain. If you have more than one, pass the subdomain as the final argument:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
pagecord publish hello.md myblog
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
See configured blogs:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pagecord list
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Remove a saved blog:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pagecord logout myblog
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Options
|
|
65
|
+
|
|
66
|
+
`publish` and `draft` accept:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
--title TITLE
|
|
70
|
+
--slug SLUG
|
|
71
|
+
--published-at TIME
|
|
72
|
+
--tags TAGS
|
|
73
|
+
--canonical-url URL
|
|
74
|
+
--hidden
|
|
75
|
+
--locale LOCALE
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Front Matter
|
|
79
|
+
|
|
80
|
+
Markdown files can include Pagecord-compatible front matter:
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
---
|
|
84
|
+
title: My Post
|
|
85
|
+
slug: my-post
|
|
86
|
+
tags:
|
|
87
|
+
- ruby
|
|
88
|
+
- cli
|
|
89
|
+
published_at: 2026-01-02T03:04:05Z
|
|
90
|
+
canonical_url: https://example.com/original
|
|
91
|
+
hidden: false
|
|
92
|
+
locale: en
|
|
93
|
+
---
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
If `title` is omitted, the CLI uses the filename. Use `title:` or
|
|
97
|
+
`title: ""` to publish without a title.
|
|
98
|
+
|
|
99
|
+
After publishing, the CLI manages the same front matter fields as the Obsidian
|
|
100
|
+
plugin:
|
|
101
|
+
|
|
102
|
+
```yaml
|
|
103
|
+
pagecord_token: 65b82933
|
|
104
|
+
pagecord_blog_fingerprint: c92376aeb770
|
|
105
|
+
pagecord_attachments:
|
|
106
|
+
status: published
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`pagecord_token` links the file to the remote post. Delete it if you want the
|
|
110
|
+
next publish to create a new post.
|
|
111
|
+
|
|
112
|
+
## Images
|
|
113
|
+
|
|
114
|
+
Markdown image references to local files are uploaded to Pagecord and sent as
|
|
115
|
+
Action Text attachments:
|
|
116
|
+
|
|
117
|
+
```markdown
|
|
118
|
+

|
|
119
|
+
![[photo.jpg]]
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Supported local image types are JPEG, PNG, GIF, and WebP. External image URLs
|
|
123
|
+
and HTML `<img>` tags are left alone.
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
rake test
|
|
129
|
+
ruby -Ilib bin/pagecord help
|
|
130
|
+
gem build pagecord-cli.gemspec
|
|
131
|
+
```
|
data/bin/pagecord
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "io/console"
|
|
4
|
+
require "optparse"
|
|
5
|
+
|
|
6
|
+
module PagecordCLI
|
|
7
|
+
class CLI
|
|
8
|
+
attr_reader :argv, :config, :input, :output, :error
|
|
9
|
+
|
|
10
|
+
def initialize(argv, config: Config.new, input: $stdin, output: $stdout, error: $stderr)
|
|
11
|
+
@argv = argv.dup
|
|
12
|
+
@config = config
|
|
13
|
+
@input = input
|
|
14
|
+
@output = output
|
|
15
|
+
@error = error
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
command = argv.shift
|
|
20
|
+
|
|
21
|
+
case command
|
|
22
|
+
when "login" then login
|
|
23
|
+
when "logout" then logout
|
|
24
|
+
when "list" then list
|
|
25
|
+
when "publish" then publish("published")
|
|
26
|
+
when "draft" then publish("draft")
|
|
27
|
+
when "help", nil, "-h", "--help" then help
|
|
28
|
+
else
|
|
29
|
+
fail_with("Unknown command: #{command}")
|
|
30
|
+
end
|
|
31
|
+
rescue Client::Error => e
|
|
32
|
+
fail_with(api_error_message(e))
|
|
33
|
+
rescue Config::Error => e
|
|
34
|
+
fail_with(e.message)
|
|
35
|
+
rescue Errno::ENOENT => e
|
|
36
|
+
fail_with(e.message)
|
|
37
|
+
rescue Interrupt
|
|
38
|
+
error.puts
|
|
39
|
+
fail_with("Cancelled")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def login
|
|
45
|
+
options = { base_url: Config::DEFAULT_BASE_URL }
|
|
46
|
+
parser = OptionParser.new do |opts|
|
|
47
|
+
opts.on("--base-url URL") { |value| options[:base_url] = value }
|
|
48
|
+
end
|
|
49
|
+
parser.parse!(argv)
|
|
50
|
+
|
|
51
|
+
subdomain = argv.shift
|
|
52
|
+
return fail_with("Usage: pagecord login SUBDOMAIN") unless subdomain
|
|
53
|
+
|
|
54
|
+
base_url = Config.normalize_base_url(options[:base_url])
|
|
55
|
+
api_key = prompt_api_key
|
|
56
|
+
client = Client.new(api_key: api_key, base_url: base_url)
|
|
57
|
+
client.verify!
|
|
58
|
+
config.save_blog(subdomain, api_key: api_key, base_url: base_url)
|
|
59
|
+
|
|
60
|
+
output.puts "Saved #{subdomain}"
|
|
61
|
+
0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def logout
|
|
65
|
+
subdomain = resolve_blog(argv.shift)
|
|
66
|
+
return fail_with("No blogs are configured") if config.blogs.empty?
|
|
67
|
+
return fail_with("Please specify a subdomain") unless subdomain
|
|
68
|
+
return fail_with("Unknown blog: #{subdomain}") unless config.blog(subdomain)
|
|
69
|
+
|
|
70
|
+
config.delete_blog(subdomain)
|
|
71
|
+
output.puts "Removed #{subdomain}"
|
|
72
|
+
0
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def list
|
|
76
|
+
return fail_with("No blogs are configured") if config.blogs.empty?
|
|
77
|
+
|
|
78
|
+
config.blogs.each do |subdomain, details|
|
|
79
|
+
suffix = details["base_url"] == Config::DEFAULT_BASE_URL ? "" : " (#{details["base_url"]})"
|
|
80
|
+
output.puts "#{subdomain}#{suffix}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
0
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def publish(status)
|
|
87
|
+
options = publish_options
|
|
88
|
+
parser = OptionParser.new do |opts|
|
|
89
|
+
opts.on("--title TITLE") { |value| options[:title] = value }
|
|
90
|
+
opts.on("--slug SLUG") { |value| options[:slug] = value }
|
|
91
|
+
opts.on("--published-at TIME") { |value| options[:published_at] = value }
|
|
92
|
+
opts.on("--tags TAGS") { |value| options[:tags] = value }
|
|
93
|
+
opts.on("--canonical-url URL") { |value| options[:canonical_url] = value }
|
|
94
|
+
opts.on("--hidden") { options[:hidden] = true }
|
|
95
|
+
opts.on("--locale LOCALE") { |value| options[:locale] = value }
|
|
96
|
+
end
|
|
97
|
+
parser.parse!(argv)
|
|
98
|
+
|
|
99
|
+
file_path = argv.shift
|
|
100
|
+
subdomain = resolve_blog(argv.shift)
|
|
101
|
+
|
|
102
|
+
return fail_with("Usage: pagecord #{status == "draft" ? "draft" : "publish"} FILE [SUBDOMAIN]") unless file_path
|
|
103
|
+
return fail_with("No blogs are configured. Run pagecord login SUBDOMAIN first.") if config.blogs.empty?
|
|
104
|
+
return fail_with("Please specify a subdomain") unless subdomain
|
|
105
|
+
return fail_with("Unknown blog: #{subdomain}") unless config.blog(subdomain)
|
|
106
|
+
|
|
107
|
+
post_file = PostFile.new(file_path)
|
|
108
|
+
return fail_with("Unsupported file type: #{file_path}") unless post_file.supported?
|
|
109
|
+
|
|
110
|
+
blog_config = config.blog(subdomain)
|
|
111
|
+
api_key = blog_config.fetch("api_key")
|
|
112
|
+
return fail_with("This file is linked to another configured blog.") if post_file.wrong_blog?(subdomain, api_key)
|
|
113
|
+
|
|
114
|
+
client = Client.new(api_key: blog_config.fetch("api_key"), base_url: blog_config.fetch("base_url", Config::DEFAULT_BASE_URL))
|
|
115
|
+
params = post_file.params.merge(options.compact).merge(
|
|
116
|
+
content: post_file.content_for(subdomain, client: client),
|
|
117
|
+
status: status
|
|
118
|
+
)
|
|
119
|
+
params[:content_format] = post_file.content_format if post_file.content_format
|
|
120
|
+
|
|
121
|
+
result = if (token = post_file.token_for(subdomain))
|
|
122
|
+
client.update_post(token, params)
|
|
123
|
+
else
|
|
124
|
+
client.create_post(params)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
post_file.write_token(subdomain, result.fetch("token"), api_key: api_key, status: status)
|
|
128
|
+
output.puts "#{status == "draft" ? "Saved draft" : "Published"} #{file_path} to #{subdomain}"
|
|
129
|
+
0
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def publish_options
|
|
133
|
+
{
|
|
134
|
+
title: nil,
|
|
135
|
+
slug: nil,
|
|
136
|
+
published_at: nil,
|
|
137
|
+
tags: nil,
|
|
138
|
+
canonical_url: nil,
|
|
139
|
+
hidden: nil,
|
|
140
|
+
locale: nil
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def resolve_blog(name)
|
|
145
|
+
return name if name
|
|
146
|
+
return config.blogs.keys.first if config.blogs.size == 1
|
|
147
|
+
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def prompt_api_key
|
|
152
|
+
output.print "API key: "
|
|
153
|
+
|
|
154
|
+
if input.tty?
|
|
155
|
+
key = input.noecho(&:gets).to_s.strip
|
|
156
|
+
output.puts
|
|
157
|
+
key
|
|
158
|
+
else
|
|
159
|
+
input.gets.to_s.strip
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def api_error_message(api_error)
|
|
164
|
+
if api_error.status == 404
|
|
165
|
+
"#{api_error.message}. If this file should create a new post, remove its saved Pagecord token."
|
|
166
|
+
else
|
|
167
|
+
api_error.message
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def help
|
|
172
|
+
output.puts <<~HELP
|
|
173
|
+
Usage:
|
|
174
|
+
pagecord login SUBDOMAIN
|
|
175
|
+
pagecord logout [SUBDOMAIN]
|
|
176
|
+
pagecord list
|
|
177
|
+
pagecord publish FILE [SUBDOMAIN] [options]
|
|
178
|
+
pagecord draft FILE [SUBDOMAIN] [options]
|
|
179
|
+
|
|
180
|
+
Publish options:
|
|
181
|
+
--title TITLE
|
|
182
|
+
--slug SLUG
|
|
183
|
+
--published-at TIME
|
|
184
|
+
--tags TAGS
|
|
185
|
+
--canonical-url URL
|
|
186
|
+
--hidden
|
|
187
|
+
--locale LOCALE
|
|
188
|
+
HELP
|
|
189
|
+
0
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def fail_with(message)
|
|
193
|
+
error.puts message
|
|
194
|
+
1
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
|
|
7
|
+
module PagecordCLI
|
|
8
|
+
class Client
|
|
9
|
+
class Error < StandardError
|
|
10
|
+
attr_reader :status
|
|
11
|
+
|
|
12
|
+
def initialize(status, message)
|
|
13
|
+
@status = status
|
|
14
|
+
super(message)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :api_key, :base_url
|
|
19
|
+
OPEN_TIMEOUT = 10
|
|
20
|
+
READ_TIMEOUT = 30
|
|
21
|
+
WRITE_TIMEOUT = 30
|
|
22
|
+
|
|
23
|
+
def initialize(api_key:, base_url:)
|
|
24
|
+
@api_key = api_key
|
|
25
|
+
@base_url = base_url
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def verify!
|
|
29
|
+
request(Net::HTTP::Get.new(uri_for("/posts")))
|
|
30
|
+
true
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def create_post(params)
|
|
34
|
+
request(json_request(Net::HTTP::Post, "/posts", params))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def update_post(token, params)
|
|
38
|
+
request(json_request(Net::HTTP::Patch, "/posts/#{token}", params))
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def upload_attachment(path)
|
|
42
|
+
uri = uri_for("/attachments")
|
|
43
|
+
http_request = Net::HTTP::Post.new(uri)
|
|
44
|
+
http_request["Authorization"] = "Bearer #{api_key}"
|
|
45
|
+
http_request.set_form([
|
|
46
|
+
[
|
|
47
|
+
"file",
|
|
48
|
+
File.open(path, "rb"),
|
|
49
|
+
{ filename: File.basename(path), content_type: content_type(path) }
|
|
50
|
+
]
|
|
51
|
+
], "multipart/form-data")
|
|
52
|
+
|
|
53
|
+
request(http_request)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def json_request(klass, path, params)
|
|
59
|
+
request = klass.new(uri_for(path))
|
|
60
|
+
request["Content-Type"] = "application/json"
|
|
61
|
+
request.body = JSON.dump(params)
|
|
62
|
+
request
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def request(http_request)
|
|
66
|
+
http_request["Authorization"] ||= "Bearer #{api_key}"
|
|
67
|
+
response = Net::HTTP.start(http_request.uri.hostname, http_request.uri.port, use_ssl: http_request.uri.scheme == "https") do |http|
|
|
68
|
+
http.open_timeout = OPEN_TIMEOUT
|
|
69
|
+
http.read_timeout = READ_TIMEOUT
|
|
70
|
+
http.write_timeout = WRITE_TIMEOUT if http.respond_to?(:write_timeout=)
|
|
71
|
+
http.request(http_request)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
handle_response(response)
|
|
75
|
+
ensure
|
|
76
|
+
http_request.body_stream&.close if http_request.respond_to?(:body_stream)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def handle_response(response)
|
|
80
|
+
body = response.body.to_s
|
|
81
|
+
parsed = body.empty? ? {} : JSON.parse(body)
|
|
82
|
+
|
|
83
|
+
case response
|
|
84
|
+
when Net::HTTPSuccess
|
|
85
|
+
parsed
|
|
86
|
+
else
|
|
87
|
+
raise Error.new(response.code.to_i, error_message(parsed, response.code))
|
|
88
|
+
end
|
|
89
|
+
rescue JSON::ParserError
|
|
90
|
+
raise Error.new(response.code.to_i, "Unexpected response from Pagecord")
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def error_message(parsed, status)
|
|
94
|
+
return parsed["error"] if parsed["error"]
|
|
95
|
+
return parsed["errors"].join(", ") if parsed["errors"].is_a?(Array)
|
|
96
|
+
|
|
97
|
+
"Pagecord API returned #{status}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def uri_for(path)
|
|
101
|
+
URI.join(base_url.end_with?("/") ? base_url : "#{base_url}/", path.delete_prefix("/"))
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def content_type(path)
|
|
105
|
+
case File.extname(path).downcase
|
|
106
|
+
when ".jpg", ".jpeg" then "image/jpeg"
|
|
107
|
+
when ".png" then "image/png"
|
|
108
|
+
when ".gif" then "image/gif"
|
|
109
|
+
when ".webp" then "image/webp"
|
|
110
|
+
when ".mp4" then "video/mp4"
|
|
111
|
+
when ".mov" then "video/quicktime"
|
|
112
|
+
when ".mp3" then "audio/mpeg"
|
|
113
|
+
when ".wav" then "audio/wav"
|
|
114
|
+
else "application/octet-stream"
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "date"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "yaml"
|
|
7
|
+
|
|
8
|
+
module PagecordCLI
|
|
9
|
+
class Config
|
|
10
|
+
class Error < StandardError; end
|
|
11
|
+
|
|
12
|
+
DEFAULT_PATH = File.expand_path("~/.pagecord.yml")
|
|
13
|
+
DEFAULT_BASE_URL = "https://api.pagecord.com"
|
|
14
|
+
|
|
15
|
+
attr_reader :path
|
|
16
|
+
|
|
17
|
+
def initialize(path = DEFAULT_PATH)
|
|
18
|
+
@path = path
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def self.normalize_base_url(url)
|
|
22
|
+
uri = URI(url.match?(%r{\Ahttps?://}) ? url : "https://#{url}")
|
|
23
|
+
return uri.to_s.delete_suffix("/") if api_or_local_host?(uri.host)
|
|
24
|
+
|
|
25
|
+
uri.host = "api.#{uri.host}"
|
|
26
|
+
uri.to_s.delete_suffix("/")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def blogs
|
|
30
|
+
data.fetch("blogs", {})
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def blog(name)
|
|
34
|
+
blogs[name]
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def save_blog(name, api_key:, base_url: DEFAULT_BASE_URL)
|
|
38
|
+
new_data = data
|
|
39
|
+
new_data["blogs"] ||= {}
|
|
40
|
+
new_data["blogs"][name] = {
|
|
41
|
+
"api_key" => api_key,
|
|
42
|
+
"base_url" => base_url
|
|
43
|
+
}
|
|
44
|
+
write(new_data)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def delete_blog(name)
|
|
48
|
+
new_data = data
|
|
49
|
+
new_data.fetch("blogs", {}).delete(name)
|
|
50
|
+
write(new_data)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def resolve_blog(name = nil)
|
|
54
|
+
return name if name && blog(name)
|
|
55
|
+
return name if name
|
|
56
|
+
|
|
57
|
+
return blogs.keys.first if blogs.size == 1
|
|
58
|
+
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def data
|
|
63
|
+
return { "blogs" => {} } unless File.exist?(path)
|
|
64
|
+
|
|
65
|
+
YAML.safe_load_file(path, permitted_classes: [ Time, Date ], aliases: false) || { "blogs" => {} }
|
|
66
|
+
rescue Psych::Exception => e
|
|
67
|
+
raise Error, "Could not read #{path}: #{e.message}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def self.api_or_local_host?(host)
|
|
73
|
+
host.start_with?("api.") || %w[localhost 127.0.0.1 ::1].include?(host)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def write(new_data)
|
|
77
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
78
|
+
File.write(path, YAML.dump(new_data))
|
|
79
|
+
File.chmod(0o600, path)
|
|
80
|
+
rescue NotImplementedError
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module PagecordCLI
|
|
6
|
+
class ImageUploads
|
|
7
|
+
MARKDOWN_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/
|
|
8
|
+
OBSIDIAN_IMAGE = /!\[\[([^\]]+)\]\]/
|
|
9
|
+
IMAGE_EXTENSIONS = /\.(jpe?g|png|gif|webp)\z/i
|
|
10
|
+
|
|
11
|
+
attr_reader :content, :file_path, :metadata, :blog, :client
|
|
12
|
+
|
|
13
|
+
def initialize(content, file_path:, metadata:, blog:, client:)
|
|
14
|
+
@content = content
|
|
15
|
+
@file_path = file_path
|
|
16
|
+
@metadata = metadata
|
|
17
|
+
@blog = blog
|
|
18
|
+
@client = client
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def process
|
|
22
|
+
with_markdown_images = content.gsub(MARKDOWN_IMAGE) do |match|
|
|
23
|
+
path = Regexp.last_match(2)
|
|
24
|
+
local_path?(path) ? attachment_tag_for(path) : match
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
with_markdown_images.gsub(OBSIDIAN_IMAGE) do |match|
|
|
28
|
+
path = Regexp.last_match(1)
|
|
29
|
+
local_path?(path) ? attachment_tag_for(path) : match
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def attachment_tag_for(path)
|
|
36
|
+
sgid = cached_sgid(path) || upload(path)
|
|
37
|
+
%(<action-text-attachment sgid="#{sgid}"></action-text-attachment>)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def cached_sgid(path)
|
|
41
|
+
cache = attachment_cache[filename(path)]
|
|
42
|
+
return unless cache
|
|
43
|
+
return unless cache["hash"] == checksum(absolute_path(path))
|
|
44
|
+
|
|
45
|
+
cache["sgid"]
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def upload(path)
|
|
49
|
+
result = client.upload_attachment(absolute_path(path))
|
|
50
|
+
sgid = result.fetch("attachable_sgid")
|
|
51
|
+
|
|
52
|
+
attachment_cache[filename(path)] = {
|
|
53
|
+
"hash" => checksum(absolute_path(path)),
|
|
54
|
+
"sgid" => sgid
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
sgid
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def attachment_cache
|
|
61
|
+
metadata["pagecord_attachments"] ||= {}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def local_path?(path)
|
|
65
|
+
return false if path.match?(%r{\A[a-z][a-z0-9+.-]*:}i)
|
|
66
|
+
return false if path.start_with?("#", "/")
|
|
67
|
+
return false unless path.match?(IMAGE_EXTENSIONS)
|
|
68
|
+
|
|
69
|
+
File.file?(absolute_path(path))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def absolute_path(path)
|
|
73
|
+
File.expand_path(path, File.dirname(file_path))
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def checksum(path)
|
|
77
|
+
Digest::SHA256.file(path).hexdigest[0, 16]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def filename(path)
|
|
81
|
+
File.basename(path)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "date"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module PagecordCLI
|
|
8
|
+
class PostFile
|
|
9
|
+
MARKDOWN_EXTENSIONS = [ ".md", ".markdown" ].freeze
|
|
10
|
+
HTML_EXTENSIONS = [ ".html", ".htm" ].freeze
|
|
11
|
+
FRONT_MATTER = /\A---[ \t]*\n(.*?)\n---[ \t]*\n?/m
|
|
12
|
+
HTML_METADATA = /\A\s*<!--\s*pagecord:\s*\n(.*?)\n-->\s*/m
|
|
13
|
+
|
|
14
|
+
attr_reader :path, :content, :metadata, :body
|
|
15
|
+
|
|
16
|
+
def initialize(path)
|
|
17
|
+
@path = path
|
|
18
|
+
@content = File.read(path)
|
|
19
|
+
@metadata, @body = extract_metadata
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def markdown?
|
|
23
|
+
MARKDOWN_EXTENSIONS.include?(File.extname(path).downcase)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def html?
|
|
27
|
+
HTML_EXTENSIONS.include?(File.extname(path).downcase)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def token_for(blog)
|
|
31
|
+
if markdown?
|
|
32
|
+
metadata["pagecord_token"]
|
|
33
|
+
else
|
|
34
|
+
metadata.dig(blog, "token") || metadata[blog]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def content_for(blog, client:)
|
|
39
|
+
return body unless markdown?
|
|
40
|
+
|
|
41
|
+
ImageUploads.new(
|
|
42
|
+
body,
|
|
43
|
+
file_path: path,
|
|
44
|
+
metadata: metadata,
|
|
45
|
+
blog: blog,
|
|
46
|
+
client: client
|
|
47
|
+
).process
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def write_token(blog, token, api_key: nil, status: nil)
|
|
51
|
+
if markdown?
|
|
52
|
+
metadata["pagecord_token"] = token
|
|
53
|
+
metadata["pagecord_blog_fingerprint"] = self.class.blog_fingerprint(api_key) if api_key
|
|
54
|
+
metadata["status"] = status if status
|
|
55
|
+
write_markdown
|
|
56
|
+
else
|
|
57
|
+
metadata[blog] = token
|
|
58
|
+
write_html
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def content_format
|
|
63
|
+
markdown? ? "markdown" : nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def params
|
|
67
|
+
publish_params = { title: title_from_metadata }
|
|
68
|
+
|
|
69
|
+
%w[slug published_at canonical_url locale].each do |key|
|
|
70
|
+
value = frontmatter_string(metadata[key])
|
|
71
|
+
publish_params[key.to_sym] = value if value
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
tags = tags_from_metadata
|
|
75
|
+
publish_params[:tags] = tags if tags
|
|
76
|
+
|
|
77
|
+
hidden = frontmatter_boolean(metadata["hidden"])
|
|
78
|
+
publish_params[:hidden] = hidden unless hidden.nil?
|
|
79
|
+
|
|
80
|
+
publish_params
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def wrong_blog?(blog, api_key)
|
|
84
|
+
return false unless markdown?
|
|
85
|
+
return false unless metadata["pagecord_token"]
|
|
86
|
+
return false unless metadata["pagecord_blog_fingerprint"]
|
|
87
|
+
|
|
88
|
+
metadata["pagecord_blog_fingerprint"].to_s != self.class.blog_fingerprint(api_key)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def self.blog_fingerprint(api_key)
|
|
92
|
+
Digest::SHA256.hexdigest(api_key)[0, 12]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def default_title
|
|
96
|
+
title = File.basename(path, File.extname(path)).tr("_-", " ").squeeze(" ").strip
|
|
97
|
+
title.empty? ? "" : title[0].upcase + title[1..].to_s
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def supported?
|
|
101
|
+
markdown? || html?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
private
|
|
105
|
+
|
|
106
|
+
def extract_metadata
|
|
107
|
+
if markdown?
|
|
108
|
+
extract_markdown_metadata
|
|
109
|
+
elsif html?
|
|
110
|
+
extract_html_metadata
|
|
111
|
+
else
|
|
112
|
+
[ {}, content ]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def extract_markdown_metadata
|
|
117
|
+
match = content.match(FRONT_MATTER)
|
|
118
|
+
return [ {}, content ] unless match
|
|
119
|
+
|
|
120
|
+
yaml = YAML.safe_load(match[1], permitted_classes: [ Date, Time ], aliases: false) || {}
|
|
121
|
+
[ stringify_keys(yaml), content[match[0].length..] || "" ]
|
|
122
|
+
rescue Psych::Exception
|
|
123
|
+
[ {}, content ]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def extract_html_metadata
|
|
127
|
+
match = content.match(HTML_METADATA)
|
|
128
|
+
return [ {}, content ] unless match
|
|
129
|
+
|
|
130
|
+
data = YAML.safe_load(match[1], aliases: false) || {}
|
|
131
|
+
[ normalize_html_metadata(data), content[match[0].length..] || "" ]
|
|
132
|
+
rescue Psych::Exception
|
|
133
|
+
[ {}, content ]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def title_from_metadata
|
|
137
|
+
return default_title unless metadata.key?("title")
|
|
138
|
+
return "" if metadata["title"].nil?
|
|
139
|
+
|
|
140
|
+
frontmatter_string(metadata["title"]).to_s
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def tags_from_metadata
|
|
144
|
+
tags = metadata["tags"]
|
|
145
|
+
return if tags.nil?
|
|
146
|
+
|
|
147
|
+
if tags.is_a?(Array)
|
|
148
|
+
tags.map { |tag| frontmatter_string(tag).to_s }.join(", ")
|
|
149
|
+
else
|
|
150
|
+
frontmatter_string(tags)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def frontmatter_string(value)
|
|
155
|
+
return if value.nil?
|
|
156
|
+
|
|
157
|
+
value = value.to_s
|
|
158
|
+
quoted = value.match(/\A(['"])(.*)\1\z/)
|
|
159
|
+
quoted ? quoted[2] : value
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def frontmatter_boolean(value)
|
|
163
|
+
return if value.nil?
|
|
164
|
+
return value if value == true || value == false
|
|
165
|
+
|
|
166
|
+
normalized = frontmatter_string(value).to_s.strip.downcase
|
|
167
|
+
return true if normalized == "true"
|
|
168
|
+
return false if normalized == "false"
|
|
169
|
+
|
|
170
|
+
!!value
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def write_markdown
|
|
174
|
+
File.write(path, "#{YAML.dump(metadata)}---\n#{body}")
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def write_html
|
|
178
|
+
File.write(path, "<!-- pagecord:\n#{YAML.dump(flat_html_metadata).delete_prefix("---\n")}-->\n#{body}")
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def flat_html_metadata
|
|
182
|
+
metadata.transform_values { |value| value.is_a?(Hash) ? value["token"] : value }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def normalize_html_metadata(data)
|
|
186
|
+
stringify_keys(data).transform_values do |value|
|
|
187
|
+
value.is_a?(Hash) ? value : { "token" => value }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def stringify_keys(value)
|
|
192
|
+
case value
|
|
193
|
+
when Hash
|
|
194
|
+
value.each_with_object({}) { |(key, item), hash| hash[key.to_s] = stringify_keys(item) }
|
|
195
|
+
when Array
|
|
196
|
+
value.map { |item| stringify_keys(item) }
|
|
197
|
+
else
|
|
198
|
+
value
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
data/lib/pagecord_cli.rb
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "pagecord_cli/client"
|
|
4
|
+
require_relative "pagecord_cli/cli"
|
|
5
|
+
require_relative "pagecord_cli/config"
|
|
6
|
+
require_relative "pagecord_cli/image_uploads"
|
|
7
|
+
require_relative "pagecord_cli/post_file"
|
|
8
|
+
require_relative "pagecord_cli/version"
|
metadata
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pagecord-cli
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Olly Headey
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
email:
|
|
13
|
+
- olly@pagecord.com
|
|
14
|
+
executables:
|
|
15
|
+
- pagecord
|
|
16
|
+
extensions: []
|
|
17
|
+
extra_rdoc_files: []
|
|
18
|
+
files:
|
|
19
|
+
- LICENSE
|
|
20
|
+
- README.md
|
|
21
|
+
- bin/pagecord
|
|
22
|
+
- lib/pagecord_cli.rb
|
|
23
|
+
- lib/pagecord_cli/cli.rb
|
|
24
|
+
- lib/pagecord_cli/client.rb
|
|
25
|
+
- lib/pagecord_cli/config.rb
|
|
26
|
+
- lib/pagecord_cli/image_uploads.rb
|
|
27
|
+
- lib/pagecord_cli/post_file.rb
|
|
28
|
+
- lib/pagecord_cli/version.rb
|
|
29
|
+
homepage: https://pagecord.com
|
|
30
|
+
licenses:
|
|
31
|
+
- MIT
|
|
32
|
+
metadata:
|
|
33
|
+
homepage_uri: https://pagecord.com
|
|
34
|
+
source_code_uri: https://github.com/lylo/pagecord-cli
|
|
35
|
+
rdoc_options: []
|
|
36
|
+
require_paths:
|
|
37
|
+
- lib
|
|
38
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
39
|
+
requirements:
|
|
40
|
+
- - ">="
|
|
41
|
+
- !ruby/object:Gem::Version
|
|
42
|
+
version: '3.2'
|
|
43
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - ">="
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '0'
|
|
48
|
+
requirements: []
|
|
49
|
+
rubygems_version: 4.0.10
|
|
50
|
+
specification_version: 4
|
|
51
|
+
summary: Publish local files to Pagecord
|
|
52
|
+
test_files: []
|