async-vikunja 1.0.0 → 1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8d1420cbccb11b7d3c779e16482c9e2e7181d924eb148f15abb8d663d2776047
4
- data.tar.gz: 409c2841ca1b637ff139b48ff0cadd35ab27b29c1fa2727847a00dcf0a97b691
3
+ metadata.gz: 542a774d84fa94455ddbe6fad372b4af80be46ba16d792598efcd921af1a5b8d
4
+ data.tar.gz: c88c89e32e48806172a5329c4684b224bc7ef7f76636167bff3c58eededc1122
5
5
  SHA512:
6
- metadata.gz: a6332a41f269ff1df2d09747524b04934eec02165b530b4d9b189a08e977442523b62aef0e500de019f0d3c0f4a761841b8f460bb9ab98c7e5aee1012f40df2d
7
- data.tar.gz: 1f75d82c9811c6f9518c2999808850e97a146f524b16653a10fcc95874c561e5c9a4a525490a9c33c03f2bb16471bc773d996d2ce33b4662dc861d7bdc2430ec
6
+ metadata.gz: a1f4eff3b457265fef50003cb8e98be725a219648bb5b2f0a268288e1e17177ed129a7c573dd9682114dc1ee8b2ede58c44219da785cdcec20008cd8a8a2b47a
7
+ data.tar.gz: d14784601ab95bef94f217bbba4a86b83ec97fd4cacfd811573082e746d1dfc4e27f59bc2467410fdefffe2f3fd06621ff3264c09a075024cce5967a54f7ff49
data/README.md CHANGED
@@ -175,21 +175,69 @@ vikunja.project(1).search_users("test").map(&:username)
175
175
  vikunja.project(1).remove_user("tester")
176
176
  ```
177
177
 
178
+ Bytes go up as they are, so read a file in binary and say what it is. Several parts may share
179
+ one field name.
180
+
178
181
  ```ruby
179
182
  result = vikunja.task(7).upload_attachments([
180
- {name: "files", filename: "notes.txt", content_type: "text/plain", body: File.read("notes.txt")},
183
+ {name: "files", filename: "notes.txt", content_type: "text/plain", body: File.binread("notes.txt")},
184
+ {name: "files", filename: "diagram.png", content_type: "image/png", body: File.binread("diagram.png")},
181
185
  ])
182
186
 
183
187
  result.success.map{|attachment| attachment.file.name}
184
188
  result.errors.map(&:message)
189
+ ```
190
+
191
+ An attachment knows its own address wherever it arrived, so it downloads and deletes itself.
192
+
193
+ ```ruby
194
+ stored = result.success.first
195
+
196
+ stored.download.bytes
197
+ stored.delete
185
198
 
186
199
  vikunja.task(7).task_attachments.map{|attachment| attachment.file.size}
187
- vikunja.task(7).download_attachment(3).bytes
200
+ vikunja.task(7).attachments.map{|attachment| attachment.download.bytes.bytesize}
201
+ ```
202
+
203
+ A download is the bytes the API answered with, not a document. It is fetched when it is first
204
+ read, and the headers arrive with it.
205
+
206
+ ```ruby
207
+ file = vikunja.task(7).download_attachment(3)
208
+
209
+ file.media_type
210
+ file.bytes
211
+
212
+ vikunja.task(7).attachment(3).preview("sm").bytes
188
213
  vikunja.task(7).attachment(3).delete
189
214
  ```
190
215
 
216
+ A file is as large as it is. Give a download a block and the body arrives a chunk at a time, so
217
+ nothing holds the whole of it:
218
+
219
+ ```ruby
220
+ File.open("notes.txt", "wb") do |out|
221
+ vikunja.task(7).download_attachment(3) do |response|
222
+ response.body.each{|chunk| out.write(chunk)}
223
+ end
224
+ end
225
+ ```
226
+
227
+ Every download takes a block the same way: `avatar`, `background`, `preview`, `totp_qrcode`,
228
+ `notifications_atom` and `download_export`.
229
+
230
+ An upload streams the other way. Hand a part an open file rather than its bytes, and it is read
231
+ from disk as it goes on the wire:
232
+
233
+ ```ruby
234
+ vikunja.task(7).upload_attachments([
235
+ {name: "files", filename: "recording.mp4", content_type: "video/mp4", body: File.open("recording.mp4", "rb")},
236
+ ])
237
+ ```
238
+
191
239
  ```ruby
192
- vikunja.project(1).upload_background({name: "background", filename: "wall.jpg", content_type: "image/jpeg", body: File.read("wall.jpg")})
240
+ vikunja.project(1).upload_background({name: "background", filename: "wall.jpg", content_type: "image/jpeg", body: File.binread("wall.jpg")})
193
241
 
194
242
  background = vikunja.project(1).background
195
243
 
@@ -248,7 +296,7 @@ vikunja.create_caldav_token.token
248
296
  ```ruby
249
297
  vikunja.user.settings.timezone
250
298
  vikunja.update_settings({name: "Tester", week_start: 1, timezone: "Europe/London"}).message
251
- vikunja.upload_avatar({name: "avatar", filename: "me.png", content_type: "image/png", body: File.read("me.png")})
299
+ vikunja.upload_avatar({name: "avatar", filename: "me.png", content_type: "image/png", body: File.binread("me.png")})
252
300
  vikunja.avatar("tester", size: 64).bytes
253
301
  vikunja.set_avatar_provider(avatar_provider: "initials")
254
302
 
@@ -25,9 +25,9 @@ module Async
25
25
 
26
26
  def file = self.nested(File, self.value[:file])
27
27
 
28
- def download = Download.new(@resource)
28
+ def download(&block) = Download.open(@resource, &block)
29
29
 
30
- def preview(size) = Download.new(@resource.with(parameters: {preview_size: size}))
30
+ def preview(size, &block) = Download.open(@resource.with(parameters: {preview_size: size}), &block)
31
31
 
32
32
  def delete = self.discard(@resource)
33
33
  end
@@ -39,7 +39,7 @@ module Async
39
39
  end
40
40
 
41
41
  class AttachmentUploadResult < Representation
42
- def success = self.nested_list(TaskAttachment, self.value[:success])
42
+ def success = self.nested_list(TaskAttachment, self.value[:success], identify: true)
43
43
 
44
44
  def errors = self.nested_list(AttachmentUploadError, self.value[:errors])
45
45
  end
@@ -38,6 +38,8 @@ module Async
38
38
 
39
39
  def refresh_token = self.submit(AuthToken, self.with(path: "user/token/refresh"), nil)
40
40
 
41
+ def register(payload) = self.submit(User, self.with(path: "register"), payload, identify: nil)
42
+
41
43
  def confirm_email(payload) = self.submit(Message, self.with(path: "user/confirm"), payload)
42
44
 
43
45
  def reset_password(payload) = self.submit(Message, self.with(path: "user/password/reset"), payload)
@@ -9,7 +9,9 @@ require_relative "buckets"
9
9
  require_relative "comments"
10
10
  require_relative "error"
11
11
  require_relative "filters"
12
+ require_relative "invite_links"
12
13
  require_relative "labels"
14
+ require_relative "mcp"
13
15
  require_relative "migration"
14
16
  require_relative "notifications"
15
17
  require_relative "page"
@@ -43,7 +45,9 @@ module Async
43
45
  include Auth
44
46
  include Bots
45
47
  include Filters
48
+ include InviteLinks
46
49
  include Labels
50
+ include MCP
47
51
  include Migration
48
52
  include Notifications
49
53
  include Projects
@@ -0,0 +1,69 @@
1
+ require_relative "page"
2
+
3
+ module Async
4
+ module Vikunja
5
+ class InviteLinkTeam < Representation
6
+ def id = self.value[:id]
7
+
8
+ def name = self.value[:name]
9
+ end
10
+
11
+ class UserInviteLink < Representation
12
+ def id = self.value[:id]
13
+
14
+ def name = self.value[:name]
15
+
16
+ def token = self.value[:token]
17
+
18
+ def max_uses = self.value[:max_uses]
19
+
20
+ def uses = self.value[:uses]
21
+
22
+ def expires_at = self.value[:expires_at]
23
+
24
+ def skip_email_confirm = self.value[:skip_email_confirm]
25
+
26
+ def created = self.value[:created]
27
+
28
+ def updated = self.value[:updated]
29
+
30
+ def created_by = self.nested(User, self.value[:created_by])
31
+
32
+ def teams = self.nested_list(InviteLinkTeam, self.value[:teams])
33
+
34
+ def spent? = self.max_uses.to_i.positive? && self.uses.to_i >= self.max_uses.to_i
35
+
36
+ def delete = self.discard(@resource)
37
+ end
38
+
39
+ class PublicInviteLink < Representation
40
+ def name = self.value[:name]
41
+
42
+ def skip_email_confirm = self.value[:skip_email_confirm]
43
+
44
+ def teams = self.nested_list(InviteLinkTeam, self.value[:teams])
45
+ end
46
+
47
+ class UserInviteLinkPage < Page
48
+ MEMBER = UserInviteLink
49
+ end
50
+
51
+ class InviteLinkTeamPage < Page
52
+ MEMBER = InviteLinkTeam
53
+
54
+ def member_resource(record) = @resource
55
+ end
56
+
57
+ module InviteLinks
58
+ def invite_links(**parameters) = Collection.for(self, "admin/invite-links", UserInviteLinkPage, **parameters)
59
+
60
+ def create_invite_link(payload) = self.submit(UserInviteLink, self.with(path: "admin/invite-links"), payload, identify: true)
61
+
62
+ def remove_invite_link(id) = self.discard(self.with(path: "admin/invite-links/" + Vikunja.escape(id)))
63
+
64
+ def invite_link_teams(**parameters) = Collection.for(self, "admin/teams", InviteLinkTeamPage, **parameters)
65
+
66
+ def check_invite_link(token) = self.submit(PublicInviteLink, self.with(path: "invite-links/check"), {token: token})
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,25 @@
1
+ require_relative "page"
2
+
3
+ module Async
4
+ module Vikunja
5
+ class TokenPresets < Representation
6
+ def full = self.value[:full]
7
+
8
+ def read_only = self.value[:read_only]
9
+
10
+ def typed = self.value[:typed]
11
+ end
12
+
13
+ class ConnectionSettings < Representation
14
+ def endpoint = self.value[:endpoint]
15
+
16
+ def routes = self.value[:routes]
17
+
18
+ def presets = self.nested(TokenPresets, self.value[:presets])
19
+ end
20
+
21
+ module MCP
22
+ def mcp_info = ConnectionSettings.new(self.with(path: "mcp/info"))
23
+ end
24
+ end
25
+ end
@@ -58,6 +58,12 @@ module Async
58
58
  def started_at = self.value[:started_at]
59
59
 
60
60
  def finished_at = self.value[:finished_at]
61
+
62
+ def error_kind = self.value[:error_kind]
63
+
64
+ def error_message = self.value[:error_message]
65
+
66
+ def failed? = !self.error_kind.nil?
61
67
  end
62
68
 
63
69
  module Migration
@@ -61,7 +61,7 @@ module Async
61
61
 
62
62
  def task_by_index(index) = TaskReadOneBody.new(@resource.with(path: "tasks/by-index/" + Vikunja.escape(index)))
63
63
 
64
- def background = Download.new(@resource.with(path: "background"))
64
+ def background(&block) = Download.open(@resource.with(path: "background"), &block)
65
65
 
66
66
  def search_users(query) = UserSearchPage.new(@resource.with(path: "users/search", parameters: {q: query}))
67
67
 
@@ -41,15 +41,15 @@ module Async
41
41
  end
42
42
  end
43
43
 
44
- def nested(klass, value)
44
+ def nested(klass, value, identify: nil)
45
45
  if value
46
- klass.new(@resource, value: value, metadata: self.metadata)
46
+ klass.new(self.identified(@resource, identify, value), value: value, metadata: self.metadata)
47
47
  end
48
48
  end
49
49
 
50
- def nested_list(klass, values)
50
+ def nested_list(klass, values, identify: nil)
51
51
  Array(values).map do |value|
52
- self.nested(klass, value)
52
+ self.nested(klass, value, identify: identify)
53
53
  end
54
54
  end
55
55
  end
@@ -63,11 +63,25 @@ module Async
63
63
  end
64
64
 
65
65
  class Download < Representation
66
- WRAPPER = Wrapper.new
66
+ WRAPPER = Transfer.new
67
+
68
+ def self.open(resource, &block)
69
+ if block
70
+ self.get(resource) do |_resource, response, _klass|
71
+ block.call(response)
72
+ end
73
+ else
74
+ self.new(resource)
75
+ end
76
+ end
67
77
 
68
78
  def bytes = self.value
69
79
 
70
- def media_type = self.metadata["content-type"]
80
+ def media_type
81
+ self.bytes
82
+
83
+ self.metadata["content-type"]
84
+ end
71
85
  end
72
86
  end
73
87
  end
@@ -131,7 +131,7 @@ module Async
131
131
 
132
132
  def routes = Representation.new(self.with(path: "routes"))
133
133
 
134
- def notifications_atom = Download.new(self.with(path: "notifications.atom"))
134
+ def notifications_atom(&block) = Download.open(self.with(path: "notifications.atom"), &block)
135
135
  end
136
136
  end
137
137
  end
@@ -43,8 +43,12 @@ module Async
43
43
  end
44
44
 
45
45
  module Settings
46
+ def settings = UserGeneralSettings.new(self.with(path: "user/settings/general"))
47
+
46
48
  def update_settings(payload) = self.submit(UserActionMessageBody, self.with(path: "user/settings/general"), payload, method: :put)
47
49
 
50
+ def patch_settings(payload) = self.merge_patch(UserActionMessageBody, self.with(path: "user/settings/general"), payload)
51
+
48
52
  def avatar_provider = UserAvatarProviderBody.new(self.with(path: "user/settings/avatar/provider"))
49
53
 
50
54
  def set_avatar_provider(avatar_provider:) = self.submit(UserAvatarProviderBody, self.with(path: "user/settings/avatar/provider"), {avatar_provider: avatar_provider}, method: :put)
@@ -61,7 +65,7 @@ module Async
61
65
 
62
66
  def disable_totp(password:) = self.submit(Message, self.with(path: "user/settings/totp/disable"), {password: password})
63
67
 
64
- def totp_qrcode = Download.new(self.with(path: "user/settings/totp/qrcode"))
68
+ def totp_qrcode(&block) = Download.open(self.with(path: "user/settings/totp/qrcode"), &block)
65
69
  end
66
70
  end
67
71
  end
@@ -53,6 +53,18 @@ module Async
53
53
  self.submit(klass, resource, parts, method: method, through: UploadRepresentation)
54
54
  end
55
55
 
56
+ def fetch(klass, resource, payload = nil, method: :post, &block)
57
+ if block
58
+ self.with_retry do
59
+ klass.public_send(method, resource, payload) do |_target, response|
60
+ block.call(response)
61
+ end
62
+ end
63
+ else
64
+ self.submit(klass, resource, payload, method: method)
65
+ end
66
+ end
67
+
56
68
  def discard(resource, payload = nil, method: :delete)
57
69
  self.with_retry do
58
70
  Representation.public_send(method, resource, payload) do |target, response|
@@ -65,7 +65,7 @@ module Async
65
65
 
66
66
  def assignees = self.nested_list(User, self.value[:assignees])
67
67
 
68
- def attachments = self.nested_list(TaskAttachment, self.value[:attachments])
68
+ def attachments = self.nested_list(TaskAttachment, self.value[:attachments], identify: "attachments/")
69
69
 
70
70
  def buckets = self.nested_list(Bucket, self.value[:buckets])
71
71
 
@@ -97,7 +97,7 @@ module Async
97
97
 
98
98
  def attachment(id) = TaskAttachment.new(@resource.with(path: "attachments/" + Vikunja.escape(id)))
99
99
 
100
- def download_attachment(id) = Download.new(@resource.with(path: "attachments/" + Vikunja.escape(id)))
100
+ def download_attachment(id, &block) = Download.open(@resource.with(path: "attachments/" + Vikunja.escape(id)), &block)
101
101
 
102
102
  def create_comment(payload) = self.submit(TaskComment, @resource.with(path: "comments"), payload, identify: true)
103
103
 
@@ -79,7 +79,7 @@ module Async
79
79
 
80
80
  def users(query) = UserSearchPage.new(self.with(path: "users", parameters: {q: query}))
81
81
 
82
- def avatar(username, **parameters) = Download.new(self.with(path: "avatar/" + Vikunja.escape(username), parameters: parameters))
82
+ def avatar(username, **parameters, &block) = Download.open(self.with(path: "avatar/" + Vikunja.escape(username), parameters: parameters), &block)
83
83
 
84
84
  def timezones = Representation.new(self.with(path: "user/timezones"))
85
85
 
@@ -95,7 +95,7 @@ module Async
95
95
 
96
96
  def request_export(password:) = self.submit(Message, self.with(path: "user/export/request"), {password: password})
97
97
 
98
- def download_export(password:) = self.submit(Download, self.with(path: "user/export/download"), {password: password})
98
+ def download_export(password:, &block) = self.fetch(Download, self.with(path: "user/export/download"), {password: password}, &block)
99
99
 
100
100
  def request_deletion(password:) = self.discard(self.with(path: "user/deletion/request"), {password: password}, method: :post)
101
101
 
@@ -5,6 +5,6 @@
5
5
 
6
6
  module Async
7
7
  module Vikunja
8
- VERSION = "1.0.0"
8
+ VERSION = "1.1.0"
9
9
  end
10
10
  end
@@ -1,5 +1,9 @@
1
1
  require "async/rest/wrapper/json"
2
2
  require "protocol/http/body/buffered"
3
+ require "protocol/http/body/file"
4
+ require "protocol/http/body/readable"
5
+
6
+ require_relative "error"
3
7
 
4
8
  module Async
5
9
  module Vikunja
@@ -30,6 +34,29 @@ module Async
30
34
  end
31
35
  end
32
36
 
37
+ class Transfer < Wrapper
38
+ ANY = "*/*"
39
+
40
+ def prepare_request(request, payload)
41
+ request.headers["accept"] ||= ANY
42
+
43
+ if payload
44
+ request.headers["content-type"] = self.content_type
45
+ request.body = ::Protocol::HTTP::Body::Buffered.new([::JSON.dump(payload)])
46
+ end
47
+ end
48
+
49
+ def process_response(request, response)
50
+ super
51
+
52
+ unless response.success?
53
+ raise Failure.for(response)
54
+ end
55
+
56
+ response
57
+ end
58
+ end
59
+
33
60
  class Multipart < Wrapper
34
61
  MULTIPART_FORM_DATA = "multipart/form-data"
35
62
 
@@ -39,30 +66,103 @@ module Async
39
66
 
40
67
  CRLF = "\r\n"
41
68
 
69
+ class Body < ::Protocol::HTTP::Body::Readable
70
+ def initialize(bodies)
71
+ @bodies = bodies
72
+ @index = 0
73
+ end
74
+
75
+ def length
76
+ lengths = @bodies.map(&:length)
77
+
78
+ if lengths.include?(nil)
79
+ nil
80
+ else
81
+ lengths.sum
82
+ end
83
+ end
84
+
85
+ def empty? = @index >= @bodies.size
86
+
87
+ def ready? = true
88
+
89
+ def read
90
+ chunk = nil
91
+
92
+ while chunk.nil? && @index < @bodies.size
93
+ chunk = @bodies[@index].read
94
+
95
+ if chunk.nil?
96
+ @index += 1
97
+ end
98
+ end
99
+
100
+ chunk
101
+ end
102
+
103
+ def close(error = nil)
104
+ @bodies.each do |body|
105
+ body.close(error)
106
+ end
107
+
108
+ @index = @bodies.size
109
+
110
+ super
111
+ end
112
+ end
113
+
42
114
  def prepare_request(request, payload)
43
115
  request.headers["accept"] ||= self.content_type
44
116
 
45
117
  if payload
46
118
  request.headers["content-type"] = MULTIPART_FORM_DATA + "; boundary=" + BOUNDARY
47
- request.body = ::Protocol::HTTP::Body::Buffered.new([self.encode(payload)])
119
+ request.body = Body.new(self.bodies_for(payload))
48
120
  end
49
121
  end
50
122
 
51
- def encode(parts)
52
- String.new.tap do |encoded|
123
+ def bodies_for(parts)
124
+ [].tap do |bodies|
53
125
  Array(parts).each do |part|
54
- encoded << "--" + BOUNDARY + CRLF
55
- encoded << "content-disposition: form-data; " + self.disposition_of(part) + CRLF
56
- encoded << "content-type: " + (part[:content_type] || OCTET_STREAM) + CRLF
57
- encoded << CRLF
58
- encoded << part.fetch(:body).to_s
59
- encoded << CRLF
126
+ bodies << self.buffered(self.preamble_of(part))
127
+ bodies << self.body_of(part)
128
+ bodies << self.buffered(CRLF)
60
129
  end
61
130
 
62
- encoded << "--" + BOUNDARY + "--" + CRLF
131
+ bodies << self.buffered("--" + BOUNDARY + "--" + CRLF)
132
+ end
133
+ end
134
+
135
+ def buffered(text) = ::Protocol::HTTP::Body::Buffered.new([self.binary(text)])
136
+
137
+ def binary(text)
138
+ case text.encoding
139
+ when ::Encoding::BINARY
140
+ text
141
+ else
142
+ text.b
63
143
  end
64
144
  end
65
145
 
146
+ def body_of(part)
147
+ body = part.fetch(:body)
148
+
149
+ case body
150
+ when ::Protocol::HTTP::Body::Readable
151
+ body
152
+ when ::IO, ::StringIO
153
+ ::Protocol::HTTP::Body::File.new(body)
154
+ else
155
+ self.buffered(body.to_s)
156
+ end
157
+ end
158
+
159
+ def preamble_of(part)
160
+ "--" + BOUNDARY + CRLF +
161
+ "content-disposition: form-data; " + self.disposition_of(part) + CRLF +
162
+ "content-type: " + (part[:content_type] || OCTET_STREAM) + CRLF +
163
+ CRLF
164
+ end
165
+
66
166
  def disposition_of(part)
67
167
  ("name=" + part.fetch(:name).to_s.inspect).tap do |disposition|
68
168
  if part[:filename]
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: async-vikunja
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Kidd
@@ -56,7 +56,9 @@ files:
56
56
  - lib/async/vikunja/error.rb
57
57
  - lib/async/vikunja/escape.rb
58
58
  - lib/async/vikunja/filters.rb
59
+ - lib/async/vikunja/invite_links.rb
59
60
  - lib/async/vikunja/labels.rb
61
+ - lib/async/vikunja/mcp.rb
60
62
  - lib/async/vikunja/migration.rb
61
63
  - lib/async/vikunja/notifications.rb
62
64
  - lib/async/vikunja/page.rb