zotero-rb 0.1.3 → 0.1.5

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: 4766ba9e6a279552e06f893d7a30c0689ada6c8166dbf59a1a8eaeaf56189887
4
- data.tar.gz: 4f37812a884e8079d1ff2643a0e21a40f05f487e9261bc5fb902959a16c39d6c
3
+ metadata.gz: 6480cd849dc46519c7a122f454e93f34befae86b340703dedc69781c59cb9a37
4
+ data.tar.gz: c9890904401b2589eef07693b5e42fb798bbf81779fc2449e941cd6a1a33110b
5
5
  SHA512:
6
- metadata.gz: d03e2f9b7eb2323eca3a6e896a22d295b80e999037c7a345c5e10bc60cc10fe79218b6f801b71ad89dffb657aa63f023047e7518cd5cc2c7105ddfbc763b32fa
7
- data.tar.gz: 25b58f33822bcae33b0f65a6090e744fc0f1f8198bc16ba23b87cfcc2703c679b7dc9f76d9139e1a257661265dc2bf907052130efdff1158975e15322621d0fd
6
+ metadata.gz: 5c15301b1d99903ee690fca6dc77de020122fdf82ea46fe156238e16ef792b44e0ab6847548ff6bf9a563264a255719fb1caada748adbf9d8c0e1b0f35aaa4a7
7
+ data.tar.gz: a25a0ad4d3e46e8577380c653fc25f472232c5d631451135f5c3acba468d8970f9cf21f4789b9ad691a1f2b2a5e4ca8651d06cef1f7e7081d42f619da404b2f5
data/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.1.5](https://github.com/andrewhwaller/zotero-rb/compare/v0.1.4...v0.1.5) (2025-09-11)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * correct test expectations for refactored parameter signature ([eaad0bb](https://github.com/andrewhwaller/zotero-rb/commit/eaad0bbd0ce87cbe640b4ca7190759170ac11494))
14
+ * Correct test method calls to match API signatures ([76dd63e](https://github.com/andrewhwaller/zotero-rb/commit/76dd63e4d6e36c18629c377d7084efc8c6159560))
15
+
16
+ ## [1.0.0](https://github.com/andrewhwaller/zotero-rb/compare/v0.1.2...v1.0.0) (2025-09-04)
17
+
18
+
19
+ ### ⚠ BREAKING CHANGES
20
+
21
+ * Internal HTTP implementation migrated from HTTParty to Net::HTTP
22
+
23
+ ### Features
24
+
25
+ * replace HTTParty with Net::HTTP to eliminate dependencies ([f1af7cc](https://github.com/andrewhwaller/zotero-rb/commit/f1af7ccd27cf401bd963062763b701f2b32ea923))
26
+
27
+
28
+ ### Bug Fixes
29
+
30
+ * avoid Digest mocking to resolve CI RSpec environment issues ([94adc4a](https://github.com/andrewhwaller/zotero-rb/commit/94adc4a7edd6fd8362c9794f1b1826fd8dda5ec9))
31
+ * move digest require to top level for test compatibility ([c88a64b](https://github.com/andrewhwaller/zotero-rb/commit/c88a64b8972018b9e01682cd9f405f0d3b5f4fec))
32
+
8
33
  ## [0.1.2](https://github.com/andrewhwaller/zotero-rb/compare/v0.1.1...v0.1.2) (2025-09-04)
9
34
 
10
35
 
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  A comprehensive Ruby client for the [Zotero Web API v3](https://www.zotero.org/support/dev/web_api/v3/start).
7
7
 
8
- NOTE: This gem is experimental and has not been fully tested with real data. So far, the gem has been set up to cover Zotero's web API documentation as much as possible, but testing is still ongoing. Do not use this gem for production applications without exercising due caution.
8
+ NOTE: This gem is experimental and has not been fully tested with real data. So far, the gem has been set up to cover Zotero's web API documentation as much as possible, but testing is still ongoing. Do not use this gem for production applications without exercising due caution. Having said that, if you come across something that doesn't work, open up an issue or even a PR and I'd be happy to get a fix going.
9
9
 
10
10
  ## Installation
11
11
 
data/lib/zotero/client.rb CHANGED
@@ -1,11 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "httparty"
3
+ require "net/http"
4
+ require "uri"
5
+ require "json"
6
+ require "cgi"
4
7
  require_relative "item_types"
5
8
  require_relative "fields"
6
9
  require_relative "file_upload"
7
10
  require_relative "http_errors"
8
11
  require_relative "syncing"
12
+ require_relative "http_config"
13
+ require_relative "http_connection"
14
+ require_relative "network_errors"
9
15
 
10
16
  module Zotero
11
17
  # The main HTTP client for interacting with the Zotero Web API v3.
@@ -15,65 +21,25 @@ module Zotero
15
21
  # client = Zotero::Client.new(api_key: 'your-api-key-here')
16
22
  # library = client.user_library(12345)
17
23
  #
24
+ # rubocop:disable Metrics/ClassLength
18
25
  class Client
19
- include HTTParty
20
26
  include ItemTypes
21
27
  include Fields
22
28
  include FileUpload
23
29
  include HTTPErrors
24
30
  include Syncing
31
+ include NetworkErrors
25
32
 
26
- base_uri "https://api.zotero.org"
33
+ BASE_URI = "https://api.zotero.org"
27
34
 
28
35
  # Initialize a new Zotero API client.
29
36
  #
30
37
  # @param api_key [String] Your Zotero API key from https://www.zotero.org/settings/keys
38
+ # @raise [ArgumentError] if api_key is nil or empty
31
39
  def initialize(api_key:)
32
40
  @api_key = api_key
33
41
  end
34
42
 
35
- def get(path, params: {})
36
- response = self.class.get(path,
37
- headers: auth_headers.merge(default_headers),
38
- query: params)
39
- handle_response(response, params[:format])
40
- end
41
-
42
- def post(path, data:, version: nil, write_token: nil, params: {})
43
- headers = build_write_headers(version: version, write_token: write_token)
44
- response = self.class.post(path,
45
- headers: headers,
46
- body: data,
47
- query: params)
48
- handle_write_response(response)
49
- end
50
-
51
- def patch(path, data:, version: nil, params: {})
52
- headers = build_write_headers(version: version)
53
- response = self.class.patch(path,
54
- headers: headers,
55
- body: data,
56
- query: params)
57
- handle_write_response(response)
58
- end
59
-
60
- def put(path, data:, version: nil, params: {})
61
- headers = build_write_headers(version: version)
62
- response = self.class.put(path,
63
- headers: headers,
64
- body: data,
65
- query: params)
66
- handle_write_response(response)
67
- end
68
-
69
- def delete(path, version: nil, params: {})
70
- headers = build_write_headers(version: version)
71
- response = self.class.delete(path,
72
- headers: headers,
73
- query: params)
74
- handle_write_response(response)
75
- end
76
-
77
43
  # Get a Library instance for a specific user.
78
44
  #
79
45
  # @param user_id [Integer, String] The Zotero user ID
@@ -90,9 +56,46 @@ module Zotero
90
56
  Library.new(client: self, type: :group, id: group_id)
91
57
  end
92
58
 
93
- private
59
+ # Make a GET request to the Zotero API.
60
+ # This is the main public interface for read operations.
61
+ #
62
+ # @param path [String] The API endpoint path
63
+ # @param params [Hash] Query parameters for the request
64
+ # @return [Array, Hash] The parsed response data
65
+ def make_get_request(path, params: {})
66
+ headers = auth_headers.merge(default_headers)
67
+ response = http_request(:get, path, headers: headers, params: params)
68
+ handle_response(response, params[:format])
69
+ end
94
70
 
95
- attr_reader :api_key
71
+ # Make a write request (POST, PATCH, PUT, DELETE) to the Zotero API.
72
+ # This is the main public interface for write operations.
73
+ #
74
+ # @param method [Symbol] The HTTP method (:post, :patch, :put, :delete)
75
+ # @param path [String] The API endpoint path
76
+ # @param data [Hash, Array] Optional request body data
77
+ # @param options [Hash] Write options (version: Integer, write_token: String)
78
+ # @param params [Hash] Query parameters for the request
79
+ # @return [Hash, Boolean] The parsed response data or success status
80
+ def make_write_request(method, path, data: nil, options: {}, params: {})
81
+ headers = build_write_headers(version: options[:version], write_token: options[:write_token])
82
+ response = http_request(method, path, headers: headers, body: data, params: params)
83
+ handle_write_response(response)
84
+ end
85
+
86
+ protected
87
+
88
+ def http_request(method, path, **options)
89
+ request_options = build_request_options(options)
90
+ uri = build_uri(path, request_options[:params])
91
+
92
+ handle_network_errors do
93
+ connection = HTTPConnection.new(uri)
94
+ request = build_request(method, uri, request_options[:headers], request_options[:body], request_options)
95
+
96
+ connection.request(request)
97
+ end
98
+ end
96
99
 
97
100
  def auth_headers
98
101
  { "Zotero-API-Key" => api_key }
@@ -111,15 +114,15 @@ module Zotero
111
114
  end
112
115
 
113
116
  def handle_response(response, format = nil)
114
- return parse_response_body(response, format) if response.code.between?(200, 299)
117
+ return parse_response_body(response, format) if response.code.to_i.between?(200, 299)
115
118
 
116
119
  raise_error_for_status(response)
117
120
  end
118
121
 
119
122
  def handle_write_response(response)
120
- case response.code
123
+ case response.code.to_i
121
124
  when 200
122
- response.parsed_response
125
+ parse_json_response(response)
123
126
  when 204
124
127
  true
125
128
  else
@@ -127,13 +130,85 @@ module Zotero
127
130
  end
128
131
  end
129
132
 
133
+ private
134
+
135
+ attr_reader :api_key
136
+
137
+ def build_request_options(options)
138
+ {
139
+ headers: options[:headers] || {},
140
+ body: options[:body],
141
+ params: options[:params] || {},
142
+ multipart: options[:multipart],
143
+ format: options[:format]
144
+ }
145
+ end
146
+
147
+ def build_uri(path, params = {})
148
+ base = path.start_with?("http") ? path : "#{BASE_URI}#{path}"
149
+ uri = URI(base)
150
+
151
+ unless params.empty?
152
+ query_params = params.map { |k, v| "#{CGI.escape(k.to_s)}=#{CGI.escape(v.to_s)}" }.join("&")
153
+ uri.query = uri.query ? "#{uri.query}&#{query_params}" : query_params
154
+ end
155
+
156
+ uri
157
+ end
158
+
159
+ def build_request(method, uri, headers, body, request_options)
160
+ request = create_request(method, uri)
161
+ set_headers(request, headers)
162
+ set_request_body(request, method, body, headers, request_options) if body
163
+ request
164
+ end
165
+
166
+ def create_request(method, uri)
167
+ request_class = case method
168
+ when :get then Net::HTTP::Get
169
+ when :post then Net::HTTP::Post
170
+ when :put then Net::HTTP::Put
171
+ when :patch then Net::HTTP::Patch
172
+ when :delete then Net::HTTP::Delete
173
+ else raise ArgumentError, "Unsupported HTTP method: #{method}"
174
+ end
175
+
176
+ request_class.new(uri)
177
+ end
178
+
179
+ def set_headers(request, headers)
180
+ headers.each { |key, value| request[key] = value }
181
+ end
182
+
183
+ def set_request_body(request, method, body, headers, request_options)
184
+ return unless %i[post put patch].include?(method)
185
+
186
+ if request_options[:multipart]
187
+ request.set_form(body, "multipart/form-data")
188
+ elsif headers["Content-Type"] == "application/x-www-form-urlencoded"
189
+ request.set_form_data(body)
190
+ else
191
+ request.body = body.is_a?(String) ? body : JSON.generate(body)
192
+ request["Content-Type"] = "application/json" unless headers["Content-Type"]
193
+ end
194
+ end
195
+
130
196
  def parse_response_body(response, format)
131
197
  case format&.to_s
132
198
  when "json", nil
133
- response.parsed_response
199
+ parse_json_response(response)
134
200
  else
135
201
  response.body
136
202
  end
137
203
  end
204
+
205
+ def parse_json_response(response)
206
+ return nil if response.body.nil? || response.body.empty?
207
+
208
+ JSON.parse(response.body)
209
+ rescue JSON::ParserError
210
+ response.body
211
+ end
138
212
  end
213
+ # rubocop:enable Metrics/ClassLength
139
214
  end
data/lib/zotero/fields.rb CHANGED
@@ -1,14 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zotero
4
- # Field discovery methods
4
+ # Field discovery methods for Zotero items and creators
5
5
  module Fields
6
+ # Get all available item fields.
7
+ #
8
+ # @param locale [String] Optional locale for localized field names (e.g. 'en-US', 'fr-FR')
9
+ # @return [Array<Hash>] Array of field definitions with field names and localized labels
6
10
  def item_fields(locale: nil)
7
- get("/itemFields", params: build_locale_params(locale))
11
+ params = build_locale_params(locale)
12
+ make_get_request("/itemFields", params: params)
8
13
  end
9
14
 
15
+ # Get all available creator fields.
16
+ #
17
+ # @param locale [String] Optional locale for localized field names (e.g. 'en-US', 'fr-FR')
18
+ # @return [Array<Hash>] Array of creator field definitions with field names and localized labels
10
19
  def creator_fields(locale: nil)
11
- get("/creatorFields", params: build_locale_params(locale))
20
+ params = build_locale_params(locale)
21
+ make_get_request("/creatorFields", params: params)
12
22
  end
13
23
 
14
24
  private
@@ -1,20 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Zotero
4
- # File upload operations for library items
5
- module LibraryFileOperations
6
+ # File attachment operations for library items
7
+ module FileAttachments
8
+ # Create a new attachment item in the library.
9
+ #
10
+ # @param attachment_data [Hash] The attachment data including itemType, contentType, etc.
11
+ # @param version [Integer] Optional version for conditional requests
12
+ # @param write_token [String] Optional write token for batch operations
13
+ # @return [Hash] The API response with created attachment
6
14
  def create_attachment(attachment_data, version: nil, write_token: nil)
7
15
  create_single("items", attachment_data, version: version, write_token: write_token)
8
16
  end
9
17
 
18
+ # Get file information for an attachment item.
19
+ #
20
+ # @param item_key [String] The attachment item key
21
+ # @return [Hash] File information including filename, md5, mtime
10
22
  def get_file_info(item_key)
11
- @client.get("#{@base_path}/items/#{item_key}/file")
23
+ @client.make_get_request("#{@base_path}/items/#{item_key}/file")
12
24
  end
13
25
 
26
+ # Upload a file to an attachment item.
27
+ #
28
+ # @param item_key [String] The attachment item key
29
+ # @param file_path [String] Local path to the file to upload
30
+ # @return [Boolean] Success status
14
31
  def upload_file(item_key, file_path)
15
32
  perform_file_upload(item_key, file_path, existing_file: false)
16
33
  end
17
34
 
35
+ # Update the file content of an existing attachment.
36
+ #
37
+ # @param item_key [String] The attachment item key
38
+ # @param file_path [String] Local path to the new file
39
+ # @return [Boolean] Success status
18
40
  def update_file(item_key, file_path)
19
41
  perform_file_upload(item_key, file_path, existing_file: true)
20
42
  end
@@ -36,8 +58,6 @@ module Zotero
36
58
  end
37
59
 
38
60
  def extract_file_metadata(file_path)
39
- require "digest"
40
-
41
61
  {
42
62
  filename: File.basename(file_path),
43
63
  md5: Digest::MD5.file(file_path).hexdigest,
@@ -9,18 +9,19 @@ module Zotero
9
9
  headers["If-Match"] = if_match if if_match
10
10
  headers["If-None-Match"] = if_none_match if if_none_match
11
11
 
12
- response = self.class.post(path, headers: headers, body: form_data, query: params)
12
+ response = http_request(:post, path, headers: headers, body: form_data, params: params)
13
13
  handle_response(response)
14
14
  end
15
15
 
16
16
  def external_post(url, multipart_data:)
17
- response = self.class.post(url, body: multipart_data, multipart: true, format: :plain)
17
+ response = http_request(:post, url, body: multipart_data, multipart: true, format: :plain)
18
18
 
19
- case response.code
19
+ code = response.code.to_i
20
+ case code
20
21
  when 200..299
21
22
  response.body
22
23
  else
23
- raise Error, "External upload failed: HTTP #{response.code} - #{response.message}"
24
+ raise Error, "External upload failed: HTTP #{code} - #{response.message}"
24
25
  end
25
26
  end
26
27
 
@@ -1,17 +1,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zotero
4
+ # Fulltext search and content methods
4
5
  module Fulltext
6
+ # Get fulltext content that has been modified since a given version.
7
+ #
8
+ # @param since [Integer] Version number to get changes since
9
+ # @return [Hash] Object mapping item keys to version numbers
5
10
  def fulltext_since(since:)
6
- @client.get("#{@base_path}/fulltext", params: { since: since })
11
+ params = { since: since }
12
+ @client.make_get_request("#{@base_path}/fulltext", params: params)
7
13
  end
8
14
 
15
+ # Get the fulltext content for a specific item.
16
+ #
17
+ # @param item_key [String] The item key to get fulltext for
18
+ # @return [Hash] Fulltext content data including content, indexedChars, and totalChars
9
19
  def item_fulltext(item_key)
10
- @client.get("#{@base_path}/items/#{item_key}/fulltext")
20
+ @client.make_get_request("#{@base_path}/items/#{item_key}/fulltext")
11
21
  end
12
22
 
23
+ # Set the fulltext content for a specific item.
24
+ #
25
+ # @param item_key [String] The item key to set fulltext for
26
+ # @param content_data [Hash] Fulltext content data with content, indexedChars, totalChars
27
+ # @param version [Integer] Optional version for optimistic concurrency control
28
+ # @return [Boolean] Success status
13
29
  def set_item_fulltext(item_key, content_data, version: nil)
14
- @client.put("#{@base_path}/items/#{item_key}/fulltext", data: content_data, version: version)
30
+ @client.make_write_request(:put, "#{@base_path}/items/#{item_key}/fulltext", data: content_data,
31
+ options: { version: version })
15
32
  end
16
33
  end
17
34
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ # Configuration for HTTP requests
5
+ class HTTPConfig
6
+ attr_accessor :open_timeout, :read_timeout, :verify_ssl
7
+
8
+ def initialize(open_timeout: 30, read_timeout: 60, verify_ssl: true)
9
+ @open_timeout = open_timeout
10
+ @read_timeout = read_timeout
11
+ @verify_ssl = verify_ssl
12
+ end
13
+
14
+ def self.default
15
+ @default ||= new
16
+ end
17
+
18
+ def self.configure
19
+ yield(default) if block_given?
20
+ default
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+
6
+ module Zotero
7
+ # Manages HTTP connections with proper SSL configuration and timeouts
8
+ class HTTPConnection
9
+ DEFAULT_OPEN_TIMEOUT = 30
10
+ DEFAULT_READ_TIMEOUT = 60
11
+
12
+ def initialize(uri, config = nil)
13
+ @uri = uri
14
+ @config = config || HTTPConfig.default
15
+ @http = build_connection
16
+ end
17
+
18
+ def request(net_request)
19
+ configure_connection unless @configured
20
+ @http.request(net_request)
21
+ end
22
+
23
+ private
24
+
25
+ attr_reader :uri, :config, :http
26
+
27
+ def build_connection
28
+ Net::HTTP.new(@uri.host, @uri.port)
29
+ end
30
+
31
+ def configure_connection
32
+ configure_ssl if @uri.scheme == "https"
33
+ configure_timeouts
34
+ @configured = true
35
+ end
36
+
37
+ def configure_ssl
38
+ @http.use_ssl = true
39
+ @http.verify_mode = OpenSSL::SSL::VERIFY_PEER
40
+ @http.ca_file = OpenSSL::X509::DEFAULT_CERT_FILE
41
+ end
42
+
43
+ def configure_timeouts
44
+ @http.open_timeout = @config.open_timeout
45
+ @http.read_timeout = @config.read_timeout
46
+ end
47
+ end
48
+ end
@@ -4,7 +4,8 @@ module Zotero
4
4
  # HTTP error handling methods
5
5
  module HTTPErrors
6
6
  def raise_error_for_status(response)
7
- case response.code
7
+ code = response.code.to_i
8
+ case code
8
9
  when 400..428 then raise_client_error(response)
9
10
  when 429 then raise_rate_limit_error(response)
10
11
  else raise_server_or_unknown_error(response)
@@ -12,10 +13,11 @@ module Zotero
12
13
  end
13
14
 
14
15
  def raise_client_error(response)
15
- case response.code
16
+ code = response.code.to_i
17
+ case code
16
18
  when 400, 413 then raise BadRequestError, "Bad request: #{response.body}"
17
19
  when 401, 403 then raise AuthenticationError, "Authentication failed - check your API key"
18
- when 404 then raise NotFoundError, "Resource not found: #{response.request.path}"
20
+ when 404 then raise NotFoundError, "Resource not found"
19
21
  when 409 then raise ConflictError, "Conflict: #{response.body}"
20
22
  when 412 then raise PreconditionFailedError, "Precondition failed: #{response.body}"
21
23
  when 428 then raise PreconditionRequiredError, "Precondition required: #{response.body}"
@@ -23,8 +25,9 @@ module Zotero
23
25
  end
24
26
 
25
27
  def raise_rate_limit_error(response)
26
- backoff = response.headers["backoff"]&.to_i
27
- retry_after = response.headers["retry-after"]&.to_i
28
+ headers = response.to_hash.transform_values(&:first)
29
+ backoff = headers["backoff"]&.to_i
30
+ retry_after = headers["retry-after"]&.to_i
28
31
  message = "Rate limited."
29
32
  message += " Backoff: #{backoff}s" if backoff
30
33
  message += " Retry after: #{retry_after}s" if retry_after
@@ -32,11 +35,12 @@ module Zotero
32
35
  end
33
36
 
34
37
  def raise_server_or_unknown_error(response)
35
- case response.code
38
+ code = response.code.to_i
39
+ case code
36
40
  when 500..599
37
- raise ServerError, "Server error: HTTP #{response.code} - #{response.message}"
41
+ raise ServerError, "Server error: HTTP #{code} - #{response.message}"
38
42
  else
39
- raise Error, "Unexpected response: HTTP #{response.code} - #{response.message}"
43
+ raise Error, "Unexpected response: HTTP #{code} - #{response.message}"
40
44
  end
41
45
  end
42
46
  end
@@ -3,22 +3,42 @@
3
3
  module Zotero
4
4
  # Item type discovery and template methods
5
5
  module ItemTypes
6
+ # Get all available item types.
7
+ #
8
+ # @param locale [String] Optional locale for localized type names (e.g. 'en-US', 'fr-FR')
9
+ # @return [Array<Hash>] Array of item type definitions
6
10
  def item_types(locale: nil)
7
- get("/itemTypes", params: build_locale_params(locale))
11
+ params = build_locale_params(locale)
12
+ make_get_request("/itemTypes", params: params)
8
13
  end
9
14
 
15
+ # Get all fields available for a specific item type.
16
+ #
17
+ # @param item_type [String] The item type name (e.g. 'book', 'journalArticle')
18
+ # @param locale [String] Optional locale for localized field names
19
+ # @return [Array<Hash>] Array of field definitions for the item type
10
20
  def item_type_fields(item_type, locale: nil)
11
21
  params = { itemType: item_type }
12
22
  params.merge!(build_locale_params(locale))
13
- get("/itemTypeFields", params: params)
23
+ make_get_request("/itemTypeFields", params: params)
14
24
  end
15
25
 
26
+ # Get all creator types available for a specific item type.
27
+ #
28
+ # @param item_type [String] The item type name (e.g. 'book', 'journalArticle')
29
+ # @return [Array<Hash>] Array of creator type definitions for the item type
16
30
  def item_type_creator_types(item_type)
17
- get("/itemTypeCreatorTypes", params: { itemType: item_type })
31
+ params = { itemType: item_type }
32
+ make_get_request("/itemTypeCreatorTypes", params: params)
18
33
  end
19
34
 
35
+ # Get a new item template for a specific item type.
36
+ #
37
+ # @param item_type [String] The item type name (e.g. 'book', 'journalArticle')
38
+ # @return [Hash] Template object with empty fields for the item type
20
39
  def new_item_template(item_type)
21
- get("/items/new", params: { itemType: item_type })
40
+ params = { itemType: item_type }
41
+ make_get_request("/items/new", params: params)
22
42
  end
23
43
 
24
44
  private
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "library_file_operations"
3
+ require_relative "file_attachments"
4
4
  require_relative "fulltext"
5
5
  require_relative "syncing"
6
6
 
@@ -15,8 +15,7 @@ module Zotero
15
15
  # collections = library.collections
16
16
  #
17
17
  class Library
18
- # TODO: rename this module, LibraryFileOperations sounds weird
19
- include LibraryFileOperations
18
+ include FileAttachments
20
19
  include Fulltext
21
20
  include Syncing
22
21
 
@@ -39,7 +38,7 @@ module Zotero
39
38
  # @param params [Hash] Query parameters for the request
40
39
  # @return [Array, Hash] Collections data from the API
41
40
  def collections(**params)
42
- @client.get("#{@base_path}/collections", params: params)
41
+ @client.make_get_request("#{@base_path}/collections", params: params)
43
42
  end
44
43
 
45
44
  # Get items in this library.
@@ -47,15 +46,23 @@ module Zotero
47
46
  # @param params [Hash] Query parameters for the request
48
47
  # @return [Array, Hash] Items data from the API
49
48
  def items(**params)
50
- @client.get("#{@base_path}/items", params: params)
49
+ @client.make_get_request("#{@base_path}/items", params: params)
51
50
  end
52
51
 
52
+ # Get saved searches in this library.
53
+ #
54
+ # @param params [Hash] Query parameters for the request
55
+ # @return [Array, Hash] Saved searches data from the API
53
56
  def searches(**params)
54
- @client.get("#{@base_path}/searches", params: params)
57
+ @client.make_get_request("#{@base_path}/searches", params: params)
55
58
  end
56
59
 
60
+ # Get tags in this library.
61
+ #
62
+ # @param params [Hash] Query parameters for the request
63
+ # @return [Array, Hash] Tags data from the API
57
64
  def tags(**params)
58
- @client.get("#{@base_path}/tags", params: params)
65
+ @client.make_get_request("#{@base_path}/tags", params: params)
59
66
  end
60
67
 
61
68
  # Create a new item in this library.
@@ -68,41 +75,95 @@ module Zotero
68
75
  create_single("items", item_data, version: version, write_token: write_token)
69
76
  end
70
77
 
78
+ # Create multiple items in this library.
79
+ #
80
+ # @param items_array [Array<Hash>] Array of item data objects
81
+ # @param version [Integer] Optional version for conditional requests
82
+ # @param write_token [String] Optional write token for batch operations
83
+ # @return [Hash] The API response with created items
71
84
  def create_items(items_array, version: nil, write_token: nil)
72
85
  create_multiple("items", items_array, version: version, write_token: write_token)
73
86
  end
74
87
 
88
+ # Update an existing item in this library.
89
+ #
90
+ # @param item_key [String] The item key to update
91
+ # @param item_data [Hash] The updated item data
92
+ # @param version [Integer] Version for optimistic concurrency control
93
+ # @return [Hash] The API response
75
94
  def update_item(item_key, item_data, version: nil)
76
- @client.patch("#{@base_path}/items/#{item_key}", data: item_data, version: version)
95
+ @client.make_write_request(:patch, "#{@base_path}/items/#{item_key}", data: item_data,
96
+ options: { version: version })
77
97
  end
78
98
 
99
+ # Delete an item from this library.
100
+ #
101
+ # @param item_key [String] The item key to delete
102
+ # @param version [Integer] Version for optimistic concurrency control
103
+ # @return [Boolean] Success status
79
104
  def delete_item(item_key, version: nil)
80
- @client.delete("#{@base_path}/items/#{item_key}", version: version)
105
+ @client.make_write_request(:delete, "#{@base_path}/items/#{item_key}", options: { version: version })
81
106
  end
82
107
 
108
+ # Delete multiple items from this library.
109
+ #
110
+ # @param item_keys [Array<String>] Array of item keys to delete
111
+ # @param version [Integer] Version for optimistic concurrency control
112
+ # @return [Boolean] Success status
83
113
  def delete_items(item_keys, version: nil)
84
- @client.delete("#{@base_path}/items", version: version, params: { itemKey: item_keys.join(",") })
114
+ @client.make_write_request(:delete, "#{@base_path}/items", options: { version: version },
115
+ params: { itemKey: item_keys.join(",") })
85
116
  end
86
117
 
118
+ # Create a new collection in this library.
119
+ #
120
+ # @param collection_data [Hash] The collection data
121
+ # @param version [Integer] Optional version for conditional requests
122
+ # @param write_token [String] Optional write token for batch operations
123
+ # @return [Hash] The API response
87
124
  def create_collection(collection_data, version: nil, write_token: nil)
88
125
  create_single("collections", collection_data, version: version, write_token: write_token)
89
126
  end
90
127
 
128
+ # Create multiple collections in this library.
129
+ #
130
+ # @param collections_array [Array<Hash>] Array of collection data objects
131
+ # @param version [Integer] Optional version for conditional requests
132
+ # @param write_token [String] Optional write token for batch operations
133
+ # @return [Hash] The API response with created collections
91
134
  def create_collections(collections_array, version: nil, write_token: nil)
92
135
  create_multiple("collections", collections_array, version: version, write_token: write_token)
93
136
  end
94
137
 
138
+ # Update an existing collection in this library.
139
+ #
140
+ # @param collection_key [String] The collection key to update
141
+ # @param collection_data [Hash] The updated collection data
142
+ # @param version [Integer] Version for optimistic concurrency control
143
+ # @return [Hash] The API response
95
144
  def update_collection(collection_key, collection_data, version: nil)
96
- @client.patch("#{@base_path}/collections/#{collection_key}", data: collection_data, version: version)
145
+ @client.make_write_request(:patch, "#{@base_path}/collections/#{collection_key}", data: collection_data,
146
+ options: { version: version })
97
147
  end
98
148
 
149
+ # Delete a collection from this library.
150
+ #
151
+ # @param collection_key [String] The collection key to delete
152
+ # @param version [Integer] Version for optimistic concurrency control
153
+ # @return [Boolean] Success status
99
154
  def delete_collection(collection_key, version: nil)
100
- @client.delete("#{@base_path}/collections/#{collection_key}", version: version)
155
+ @client.make_write_request(:delete, "#{@base_path}/collections/#{collection_key}", options: { version: version })
101
156
  end
102
157
 
158
+ # Delete multiple collections from this library.
159
+ #
160
+ # @param collection_keys [Array<String>] Array of collection keys to delete
161
+ # @param version [Integer] Version for optimistic concurrency control
162
+ # @return [Boolean] Success status
103
163
  def delete_collections(collection_keys, version: nil)
104
- @client.delete("#{@base_path}/collections", version: version,
105
- params: { collectionKey: collection_keys.join(",") })
164
+ @client.make_write_request(:delete, "#{@base_path}/collections",
165
+ options: { version: version },
166
+ params: { collectionKey: collection_keys.join(",") })
106
167
  end
107
168
 
108
169
  private
@@ -110,11 +171,15 @@ module Zotero
110
171
  attr_reader :client, :type, :id, :base_path
111
172
 
112
173
  def create_single(resource, data, version: nil, write_token: nil)
113
- @client.post("#{@base_path}/#{resource}", data: [data], version: version, write_token: write_token)
174
+ @client.make_write_request(:post, "#{@base_path}/#{resource}",
175
+ data: [data],
176
+ options: { version: version, write_token: write_token })
114
177
  end
115
178
 
116
179
  def create_multiple(resource, data_array, version: nil, write_token: nil)
117
- @client.post("#{@base_path}/#{resource}", data: data_array, version: version, write_token: write_token)
180
+ @client.make_write_request(:post, "#{@base_path}/#{resource}",
181
+ data: data_array,
182
+ options: { version: version, write_token: write_token })
118
183
  end
119
184
 
120
185
  def validate_type(type)
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "openssl"
5
+
6
+ module Zotero
7
+ # Handles network errors and translates them to appropriate Zotero exceptions
8
+ module NetworkErrors
9
+ ERROR_MESSAGES = {
10
+ Errno::ECONNREFUSED => "Connection refused - server may be down",
11
+ Errno::EHOSTUNREACH => "Host unreachable - check network connectivity",
12
+ Errno::ENETUNREACH => "Host unreachable - check network connectivity",
13
+ SocketError => "DNS resolution failed - check hostname",
14
+ Net::OpenTimeout => "Connection timeout - server took too long to respond",
15
+ Net::ReadTimeout => "Read timeout - server response was too slow",
16
+ OpenSSL::SSL::SSLError => "SSL error - certificate verification failed",
17
+ Timeout::Error => "Request timeout"
18
+ }.freeze
19
+
20
+ def handle_network_errors
21
+ yield
22
+ rescue *network_error_classes => e
23
+ raise translate_network_error(e)
24
+ end
25
+
26
+ private
27
+
28
+ def network_error_classes
29
+ [
30
+ Errno::ECONNREFUSED,
31
+ Errno::EHOSTUNREACH,
32
+ Errno::ENETUNREACH,
33
+ SocketError,
34
+ Net::OpenTimeout,
35
+ Net::ReadTimeout,
36
+ OpenSSL::SSL::SSLError,
37
+ Timeout::Error
38
+ ]
39
+ end
40
+
41
+ def translate_network_error(error)
42
+ message = error_message_for(error)
43
+ Error.new("#{message} (#{error.class})")
44
+ end
45
+
46
+ def error_message_for(error)
47
+ ERROR_MESSAGES.fetch(error.class) { "Network error: #{error.message}" }
48
+ end
49
+ end
50
+ end
@@ -1,19 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zotero
4
+ # Syncing and API key verification methods
4
5
  module Syncing
6
+ # Verify that the current API key is valid.
7
+ #
8
+ # @return [Hash] API key information including userID and username
5
9
  def verify_api_key
6
- @client ? @client.get("/keys/current") : get("/keys/current")
10
+ if @client
11
+ @client.make_get_request("/keys/current")
12
+ else
13
+ make_get_request("/keys/current")
14
+ end
7
15
  end
8
16
 
17
+ # Get groups for a specific user.
18
+ #
19
+ # @param user_id [Integer, String] The user ID to get groups for
20
+ # @param format [String] Response format ('versions' or 'json')
21
+ # @return [Hash, Array] Groups data in the requested format
9
22
  def user_groups(user_id, format: "versions")
10
- client = @client || self
11
- client.get("/users/#{user_id}/groups", params: { format: format })
23
+ params = { format: format }
24
+ if @client
25
+ @client.make_get_request("/users/#{user_id}/groups", params: params)
26
+ else
27
+ make_get_request("/users/#{user_id}/groups", params: params)
28
+ end
12
29
  end
13
30
 
31
+ # Get items that have been deleted from this library.
32
+ #
33
+ # @param since [Integer] Optional version to get deletions since
34
+ # @return [Hash] Object with deleted collections and items arrays
14
35
  def deleted_items(since: nil)
15
36
  params = since ? { since: since } : {}
16
- @client.get("#{@base_path}/deleted", params: params)
37
+ @client.make_get_request("#{@base_path}/deleted", params: params)
17
38
  end
18
39
  end
19
40
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Zotero
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.5"
5
5
  end
metadata CHANGED
@@ -1,42 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: zotero-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Waller
8
8
  bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: csv
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '0'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '0'
26
- - !ruby/object:Gem::Dependency
27
- name: httparty
28
- requirement: !ruby/object:Gem::Requirement
29
- requirements:
30
- - - "~>"
31
- - !ruby/object:Gem::Version
32
- version: 0.21.0
33
- type: :runtime
34
- prerelease: false
35
- version_requirements: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: 0.21.0
11
+ dependencies: []
40
12
  description: A feature-complete Ruby client for the Zotero Web API v3, providing full
41
13
  CRUD operations for items, collections, tags, and searches, plus file uploads, fulltext
42
14
  content access, and library synchronization. Built with a modular architecture and
@@ -60,12 +32,15 @@ files:
60
32
  - lib/zotero/client.rb
61
33
  - lib/zotero/error.rb
62
34
  - lib/zotero/fields.rb
35
+ - lib/zotero/file_attachments.rb
63
36
  - lib/zotero/file_upload.rb
64
37
  - lib/zotero/fulltext.rb
38
+ - lib/zotero/http_config.rb
39
+ - lib/zotero/http_connection.rb
65
40
  - lib/zotero/http_errors.rb
66
41
  - lib/zotero/item_types.rb
67
42
  - lib/zotero/library.rb
68
- - lib/zotero/library_file_operations.rb
43
+ - lib/zotero/network_errors.rb
69
44
  - lib/zotero/syncing.rb
70
45
  - lib/zotero/version.rb
71
46
  - sig/zotero/rb.rbs
@@ -95,7 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
95
70
  - !ruby/object:Gem::Version
96
71
  version: '0'
97
72
  requirements: []
98
- rubygems_version: 3.6.9
73
+ rubygems_version: 3.7.1
99
74
  specification_version: 4
100
75
  summary: A comprehensive Ruby client for the Zotero Web API v3
101
76
  test_files: []