zotero-rb 0.1.1

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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4726b345f1c3ad888f6ea08fd69fefc997527aa509972db1f0aa3c38ed6e58c6
4
+ data.tar.gz: a4b6623d56034130a91b8067c1927120d60731aae6d71d7855c253df2bd57aa0
5
+ SHA512:
6
+ metadata.gz: fc34f7ac321a12c9cda48ca5615cbabd617f8981dea664a87aa9243e5e7d677b0b6acde40108bd2813f44e34048cafb668afaa4385dfe8ecdd27eb171782b583
7
+ data.tar.gz: 611cd7bc28d93c0c4365aea57e833d687d65571d147a6d14197b8cab0194defdb9f29383958aa3ff87ebbc985050d82a02c35f6111c74ed83180bb52a38f2097
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.rubocop.yml ADDED
@@ -0,0 +1,18 @@
1
+ AllCops:
2
+ TargetRubyVersion: 3.2
3
+ NewCops: enable
4
+ SuggestExtensions: false
5
+
6
+ Layout/LineLength:
7
+ Max: 120
8
+
9
+ Style/StringLiterals:
10
+ EnforcedStyle: double_quotes
11
+
12
+ Style/Documentation:
13
+ Enabled: false
14
+
15
+ Metrics/BlockLength:
16
+ Exclude:
17
+ - "spec/**/*"
18
+ - "*.gemspec"
data/.yardopts ADDED
@@ -0,0 +1,12 @@
1
+ --protected
2
+ --private
3
+ --readme README.md
4
+ --markup markdown
5
+ --markup-provider kramdown
6
+ --charset utf-8
7
+ --embed-mixins
8
+ --hide-void-return
9
+ lib/**/*.rb
10
+ -
11
+ CHANGELOG.md
12
+ CONTRIBUTING.md
data/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0](https://github.com/andrewhwaller/zotero-rb/compare/v0.0.0...v0.1.0) (2025-09-04)
11
+
12
+ ### Added
13
+ - Initial release of zotero-rb gem
14
+ - Full Zotero Web API v3 client with API key authentication
15
+ - Complete CRUD operations for items, collections, tags, and searches
16
+ - File upload and download support
17
+ - Fulltext content access
18
+ - Library synchronization features
19
+ - Metadata retrieval (item types, fields, creator types)
20
+ - Comprehensive error handling with custom exception classes
21
+ - Support for both user and group libraries
22
+ - Ruby 3.2+ compatibility
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Andrew Waller
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
13
+ all 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
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # Zotero Ruby Gem
2
+
3
+ [![Gem Version](https://badge.fury.io/rb/zotero-rb.svg)](https://badge.fury.io/rb/zotero-rb)
4
+ [![CI](https://github.com/andrewhwaller/zotero-rb/actions/workflows/main.yml/badge.svg)](https://github.com/andrewhwaller/zotero-rb/actions/workflows/main.yml)
5
+
6
+ A comprehensive Ruby client for the [Zotero Web API v3](https://www.zotero.org/support/dev/web_api/v3/start).
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.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ gem install zotero-rb
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ```ruby
19
+ require 'zotero'
20
+
21
+ # Create a client with your API key
22
+ client = Zotero.new(api_key: 'your-api-key')
23
+
24
+ # Get a library (user or group)
25
+ library = client.user_library(12345)
26
+ group_library = client.group_library(67890)
27
+
28
+ # Work with items
29
+ items = library.items
30
+ new_item = library.create_item(itemType: 'book', title: 'My Book')
31
+ library.update_item('ITEM123', { title: 'Updated Title' }, version: 150)
32
+ library.delete_item('ITEM123', version: 151)
33
+
34
+ # Work with collections
35
+ collections = library.collections
36
+ new_collection = library.create_collection(name: 'My Collection')
37
+
38
+ # Upload files
39
+ library.upload_file('ITEM123', '/path/to/file.pdf')
40
+
41
+ # Access metadata
42
+ item_types = client.item_types
43
+ book_fields = client.item_type_fields('book')
44
+ ```
45
+
46
+ ## Authentication
47
+
48
+ 1. Create a new Zotero API key or use an existing one in your [Zotero settings](https://www.zotero.org/settings/security)
49
+ 2. Ensure your key has the appropriate permissions (read library, write library, etc.)
50
+ 3. Pass it to the client as shown above
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ bundle install
56
+ bundle exec rake spec
57
+ bundle exec rubocop
58
+ ```
59
+
60
+ ## Releases
61
+
62
+ This project uses [Release Please](https://github.com/googleapis/release-please) for automated releases:
63
+
64
+ 1. **Use conventional commits**: `feat: add new feature`, `fix: resolve bug`, etc.
65
+ 2. **Release Please creates PRs** automatically with version bumps and changelog updates
66
+ 3. **Merge the release PR** when ready to publish
67
+ 4. **Automatic publication** to RubyGems happens after merge
68
+
69
+ ### Repository Setup (for maintainers)
70
+
71
+ To enable automated publishing, add this secret to the GitHub repository:
72
+ - `RUBYGEMS_API_KEY`: Your RubyGems API token from https://rubygems.org/profile/edit
73
+
74
+ ## License
75
+
76
+ [MIT License](LICENSE.txt)
data/Rakefile ADDED
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+ require "rubocop/rake_task"
6
+ require "yard"
7
+
8
+ RSpec::Core::RakeTask.new(:spec)
9
+ RuboCop::RakeTask.new
10
+ YARD::Rake::YardocTask.new(:doc)
11
+
12
+ desc "Start an interactive console"
13
+ task :console do
14
+ require "bundler/setup"
15
+ require "irb"
16
+ require "zotero"
17
+ ARGV.clear
18
+ IRB.start
19
+ end
20
+
21
+ task default: %i[spec rubocop]
data/TASKS.md ADDED
@@ -0,0 +1,209 @@
1
+ # Zotero Ruby Gem Development Tasks
2
+
3
+ This document tracks the development tasks for building a Ruby gem client for the Zotero Web API v3.
4
+
5
+ ## Phase 1: Project Setup and Foundation
6
+
7
+ ### 1.1 Initialize Ruby gem structure
8
+ - [ ] Set up basic gem skeleton with `bundle gem zotero-rb`
9
+ - [ ] Configure gemspec with proper metadata, dependencies, and Ruby version requirements
10
+ - [ ] Set up lib/ directory structure with main module and version file
11
+ - [ ] Create basic executable/CLI structure if needed
12
+
13
+ ### 1.2 Configure development dependencies
14
+ - [ ] Add RSpec for testing framework
15
+ - [ ] Add RuboCop for code linting and style enforcement
16
+ - [ ] Add development gems: pry, byebug, yard for documentation
17
+ - [ ] Configure .rubocop.yml with appropriate rules
18
+ - [ ] Set up Rake tasks for common development workflows
19
+
20
+ ### 1.3 Set up CI/CD pipeline
21
+ - [ ] Create GitHub Actions workflow for testing
22
+ - [ ] Configure matrix testing across Ruby versions (3.1, 3.2, 3.3)
23
+ - [ ] Add code coverage reporting with SimpleCov
24
+ - [ ] Set up automated RuboCop checks in CI
25
+ - [ ] Configure automatic gem publishing on release
26
+
27
+ ### 1.4 Create basic project documentation
28
+ - [ ] Write comprehensive README with installation and basic usage
29
+ - [ ] Create CHANGELOG.md following keepachangelog.com format
30
+ - [ ] Add proper LICENSE file (MIT recommended)
31
+ - [ ] Set up YARD documentation configuration
32
+ - [ ] Create CONTRIBUTING.md with development guidelines
33
+
34
+ ## Phase 2: Core API Client Infrastructure
35
+
36
+ ### 2.1 Implement HTTP client foundation
37
+ - [ ] Create base `Zotero::Client` class
38
+ - [ ] Choose HTTP library (Net::HTTP, Faraday, or HTTParty) and implement adapter pattern
39
+ - [ ] Implement request/response wrapper classes
40
+ - [ ] Add JSON parsing and serialization
41
+ - [ ] Create configuration class for API settings
42
+
43
+ ### 2.2 Add authentication support
44
+ - [ ] Implement API key authentication via `Zotero-API-Key` header
45
+ - [ ] Add Bearer token authentication support
46
+ - [ ] Implement OAuth 1.0a flow for getting API keys
47
+ - [ ] Create OAuth client for handling authorization workflow
48
+ - [ ] Add configuration for client credentials and callback URLs
49
+
50
+ ### 2.3 Implement rate limiting and retries
51
+ - [ ] Add `Backoff` header handling
52
+ - [ ] Implement exponential backoff for rate limit responses (429)
53
+ - [ ] Create configurable retry policy
54
+ - [ ] Add request queuing/throttling mechanism
55
+ - [ ] Implement circuit breaker pattern for API failures
56
+
57
+ ### 2.4 Create error handling system
58
+ - [ ] Define custom exception hierarchy (`Zotero::Error` base class)
59
+ - [ ] Create specific exceptions for different HTTP status codes
60
+ - [ ] Add authentication error handling (`Zotero::AuthenticationError`)
61
+ - [ ] Implement rate limit exception (`Zotero::RateLimitError`)
62
+ - [ ] Add validation error handling for malformed requests
63
+
64
+ ### 2.5 Add request/response logging
65
+ - [ ] Implement configurable logging system
66
+ - [ ] Add request logging with sanitized sensitive data
67
+ - [ ] Create response logging with configurable detail levels
68
+ - [ ] Add performance timing logs
69
+ - [ ] Support for different log levels and custom loggers
70
+
71
+ ## Phase 3: Core Zotero API Features
72
+
73
+ ### 3.1 Implement library access
74
+ - [ ] Create `Zotero::Library` class for library operations
75
+ - [ ] Add user library access (`/users/<userID>`)
76
+ - [ ] Implement group library access (`/groups/<groupID>`)
77
+ - [ ] Add library metadata retrieval
78
+ - [ ] Create library permissions checking
79
+
80
+ ### 3.2 Add items management
81
+ - [ ] Create `Zotero::Item` model class with proper attributes
82
+ - [ ] Implement item creation (POST) with validation
83
+ - [ ] Add item retrieval (GET) with include parameters
84
+ - [ ] Implement item updates (PUT/PATCH)
85
+ - [ ] Add item deletion with proper error handling
86
+ - [ ] Support for item versions and conditional requests
87
+
88
+ ### 3.3 Implement collections support
89
+ - [ ] Create `Zotero::Collection` model class
90
+ - [ ] Add collection CRUD operations
91
+ - [ ] Implement nested collection handling
92
+ - [ ] Add collection membership management for items
93
+ - [ ] Support for collection ordering and hierarchy
94
+
95
+ ### 3.4 Add tags functionality
96
+ - [ ] Create `Zotero::Tag` model class
97
+ - [ ] Implement tag creation and management
98
+ - [ ] Add tag filtering and searching
99
+ - [ ] Support for colored tags
100
+ - [ ] Implement tag assignment to items
101
+
102
+ ### 3.5 Implement search functionality
103
+ - [ ] Create `Zotero::Search` class for saved searches
104
+ - [ ] Add search query building with proper parameter encoding
105
+ - [ ] Implement result filtering and sorting
106
+ - [ ] Add search result pagination
107
+ - [ ] Support for different search formats (json, keys, etc.)
108
+
109
+ ## Phase 4: Advanced Features
110
+
111
+ ### 4.1 Add file upload support
112
+ - [ ] Implement file attachment upload workflow
113
+ - [ ] Add support for different file types and validation
114
+ - [ ] Create progress tracking for large file uploads
115
+ - [ ] Implement file metadata handling
116
+ - [ ] Add file download capabilities
117
+
118
+ ### 4.2 Implement syncing capabilities
119
+ - [ ] Add support for library version checking
120
+ - [ ] Implement incremental sync with version tracking
121
+ - [ ] Create conflict resolution strategies
122
+ - [ ] Add sync status reporting
123
+ - [ ] Support for partial syncing of specific resources
124
+
125
+ ### 4.3 Add full-text content access
126
+ - [ ] Implement full-text content retrieval for items
127
+ - [ ] Add full-text search capabilities
128
+ - [ ] Support for different content formats
129
+ - [ ] Add content indexing status checking
130
+ - [ ] Implement content caching strategies
131
+
132
+ ### 4.4 Create streaming API support
133
+ - [ ] Research and implement Zotero streaming API endpoints
134
+ - [ ] Add real-time update notifications
135
+ - [ ] Create event-based update handling
136
+ - [ ] Implement connection management for streaming
137
+ - [ ] Add streaming API error recovery
138
+
139
+ ### 4.5 Add pagination handling
140
+ - [ ] Implement automatic pagination with `Link` header parsing
141
+ - [ ] Create iterator pattern for paginated results
142
+ - [ ] Add configurable page size limits
143
+ - [ ] Support for cursor-based pagination where available
144
+ - [ ] Implement efficient pagination caching
145
+
146
+ ## Phase 5: Developer Experience & Polish
147
+
148
+ ### 5.1 Create comprehensive test suite
149
+ - [ ] Write unit tests for all major classes and methods
150
+ - [ ] Add integration tests using VCR for API mocking
151
+ - [ ] Create test fixtures for different Zotero data types
152
+ - [ ] Implement contract tests for API compatibility
153
+ - [ ] Add performance benchmarks and regression tests
154
+ - [ ] Achieve >90% test coverage
155
+
156
+ ### 5.2 Add configuration management
157
+ - [ ] Create global configuration system (`Zotero.configure`)
158
+ - [ ] Add environment variable support for common settings
159
+ - [ ] Implement per-client configuration overrides
160
+ - [ ] Add validation for configuration options
161
+ - [ ] Create configuration presets for common use cases
162
+
163
+ ### 5.3 Write detailed documentation
164
+ - [ ] Generate comprehensive API reference with YARD
165
+ - [ ] Create usage guides and tutorials
166
+ - [ ] Add code examples for common patterns
167
+ - [ ] Document authentication setup and OAuth flow
168
+ - [ ] Create troubleshooting guide and FAQ
169
+
170
+ ### 5.4 Performance optimization
171
+ - [ ] Implement HTTP connection pooling
172
+ - [ ] Add request batching where possible
173
+ - [ ] Optimize JSON parsing and object creation
174
+ - [ ] Implement intelligent caching strategies
175
+ - [ ] Add memory usage profiling and optimization
176
+
177
+ ### 5.5 Add Ruby 3+ compatibility
178
+ - [ ] Ensure compatibility with Ruby 3.1+ features
179
+ - [ ] Add support for keyword arguments
180
+ - [ ] Update code to use modern Ruby idioms
181
+ - [ ] Test with Ruby 3.3+ and handle deprecations
182
+ - [ ] Add Ractor safety where applicable
183
+
184
+ ## Quality Gates
185
+
186
+ Each major phase should meet these criteria before proceeding:
187
+
188
+ - [ ] All tests passing
189
+ - [ ] RuboCop violations resolved
190
+ - [ ] Documentation updated
191
+ - [ ] CHANGELOG updated
192
+ - [ ] Version bumped appropriately
193
+ - [ ] Manual testing completed
194
+
195
+ ## Future Considerations
196
+
197
+ - **GraphQL Support**: If Zotero adds GraphQL endpoints
198
+ - **WebSocket Integration**: For real-time updates
199
+ - **Bulk Operations**: Optimized bulk import/export
200
+ - **Plugin System**: Allow third-party extensions
201
+ - **CLI Tool**: Command-line interface for common operations
202
+ - **Rails Integration**: ActiveRecord-style models and associations
203
+
204
+ ---
205
+
206
+ **Last Updated**: 2025-08-31
207
+ **Gem Name**: zotero-rb
208
+ **Target Ruby Version**: 3.1+
209
+ **Zotero API Version**: v3
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "httparty"
4
+ require_relative "item_types"
5
+ require_relative "fields"
6
+ require_relative "file_upload"
7
+ require_relative "http_errors"
8
+ require_relative "syncing"
9
+
10
+ module Zotero
11
+ # The main HTTP client for interacting with the Zotero Web API v3.
12
+ # Provides authentication, request handling, and access to library operations.
13
+ #
14
+ # @example Create a client with API key
15
+ # client = Zotero::Client.new(api_key: 'your-api-key-here')
16
+ # library = client.user_library(12345)
17
+ #
18
+ class Client
19
+ include HTTParty
20
+ include ItemTypes
21
+ include Fields
22
+ include FileUpload
23
+ include HTTPErrors
24
+ include Syncing
25
+
26
+ base_uri "https://api.zotero.org"
27
+
28
+ # Initialize a new Zotero API client.
29
+ #
30
+ # @param api_key [String] Your Zotero API key from https://www.zotero.org/settings/keys
31
+ def initialize(api_key:)
32
+ @api_key = api_key
33
+ end
34
+
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
+ # Get a Library instance for a specific user.
78
+ #
79
+ # @param user_id [Integer, String] The Zotero user ID
80
+ # @return [Library] A Library instance for the specified user
81
+ def user_library(user_id)
82
+ Library.new(client: self, type: :user, id: user_id)
83
+ end
84
+
85
+ # Get a Library instance for a specific group.
86
+ #
87
+ # @param group_id [Integer, String] The Zotero group ID
88
+ # @return [Library] A Library instance for the specified group
89
+ def group_library(group_id)
90
+ Library.new(client: self, type: :group, id: group_id)
91
+ end
92
+
93
+ private
94
+
95
+ attr_reader :api_key
96
+
97
+ def auth_headers
98
+ { "Zotero-API-Key" => api_key }
99
+ end
100
+
101
+ def default_headers
102
+ { "Zotero-API-Version" => "3" }
103
+ end
104
+
105
+ def build_write_headers(version: nil, write_token: nil)
106
+ headers = auth_headers.merge(default_headers)
107
+ headers["Content-Type"] = "application/json"
108
+ headers["If-Unmodified-Since-Version"] = version.to_s if version
109
+ headers["Zotero-Write-Token"] = write_token if write_token
110
+ headers
111
+ end
112
+
113
+ def handle_response(response, format = nil)
114
+ return parse_response_body(response, format) if response.code.between?(200, 299)
115
+
116
+ raise_error_for_status(response)
117
+ end
118
+
119
+ def handle_write_response(response)
120
+ case response.code
121
+ when 200
122
+ response.parsed_response
123
+ when 204
124
+ true
125
+ else
126
+ raise_error_for_status(response)
127
+ end
128
+ end
129
+
130
+ def parse_response_body(response, format)
131
+ case format&.to_s
132
+ when "json", nil
133
+ response.parsed_response
134
+ else
135
+ response.body
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ class Error < StandardError; end
5
+ class AuthenticationError < Error; end
6
+ class RateLimitError < Error; end
7
+ class NotFoundError < Error; end
8
+ class BadRequestError < Error; end
9
+ class ServerError < Error; end
10
+ class ConflictError < Error; end
11
+ class PreconditionFailedError < Error; end
12
+ class PreconditionRequiredError < Error; end
13
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ # Field discovery methods
5
+ module Fields
6
+ def item_fields(locale: nil)
7
+ get("/itemFields", params: build_locale_params(locale))
8
+ end
9
+
10
+ def creator_fields(locale: nil)
11
+ get("/creatorFields", params: build_locale_params(locale))
12
+ end
13
+
14
+ private
15
+
16
+ def build_locale_params(locale)
17
+ locale ? { locale: locale } : {}
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ # File upload methods for handling attachment uploads
5
+ module FileUpload
6
+ def post_form(path, form_data:, if_match: nil, if_none_match: nil, params: {})
7
+ headers = auth_headers.merge(default_headers)
8
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
9
+ headers["If-Match"] = if_match if if_match
10
+ headers["If-None-Match"] = if_none_match if if_none_match
11
+
12
+ response = self.class.post(path, headers: headers, body: form_data, query: params)
13
+ handle_response(response)
14
+ end
15
+
16
+ def external_post(url, multipart_data:)
17
+ response = self.class.post(url, body: multipart_data, multipart: true, format: :plain)
18
+
19
+ case response.code
20
+ when 200..299
21
+ response.body
22
+ else
23
+ raise Error, "External upload failed: HTTP #{response.code} - #{response.message}"
24
+ end
25
+ end
26
+
27
+ def request_upload_authorization(path, filename:, md5: nil, mtime: nil, existing_file: false)
28
+ form_data = { upload: filename, md5: md5, mtime: mtime }.compact
29
+
30
+ if existing_file
31
+ post_form(path, form_data: form_data, if_match: md5.to_s)
32
+ else
33
+ post_form(path, form_data: form_data, if_none_match: "*")
34
+ end
35
+ end
36
+
37
+ def register_upload(path, upload_key:)
38
+ post_form(path, form_data: { upload: upload_key })
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module Fulltext
5
+ def fulltext_since(since:)
6
+ @client.get("#{@base_path}/fulltext", params: { since: since })
7
+ end
8
+
9
+ def item_fulltext(item_key)
10
+ @client.get("#{@base_path}/items/#{item_key}/fulltext")
11
+ end
12
+
13
+ def set_item_fulltext(item_key, content_data, version: nil)
14
+ @client.put("#{@base_path}/items/#{item_key}/fulltext", data: content_data, version: version)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ # HTTP error handling methods
5
+ module HTTPErrors
6
+ def raise_error_for_status(response)
7
+ case response.code
8
+ when 400..428 then raise_client_error(response)
9
+ when 429 then raise_rate_limit_error(response)
10
+ else raise_server_or_unknown_error(response)
11
+ end
12
+ end
13
+
14
+ def raise_client_error(response)
15
+ case response.code
16
+ when 400, 413 then raise BadRequestError, "Bad request: #{response.body}"
17
+ when 401, 403 then raise AuthenticationError, "Authentication failed - check your API key"
18
+ when 404 then raise NotFoundError, "Resource not found: #{response.request.path}"
19
+ when 409 then raise ConflictError, "Conflict: #{response.body}"
20
+ when 412 then raise PreconditionFailedError, "Precondition failed: #{response.body}"
21
+ when 428 then raise PreconditionRequiredError, "Precondition required: #{response.body}"
22
+ end
23
+ end
24
+
25
+ def raise_rate_limit_error(response)
26
+ backoff = response.headers["backoff"]&.to_i
27
+ retry_after = response.headers["retry-after"]&.to_i
28
+ message = "Rate limited."
29
+ message += " Backoff: #{backoff}s" if backoff
30
+ message += " Retry after: #{retry_after}s" if retry_after
31
+ raise RateLimitError, message
32
+ end
33
+
34
+ def raise_server_or_unknown_error(response)
35
+ case response.code
36
+ when 500..599
37
+ raise ServerError, "Server error: HTTP #{response.code} - #{response.message}"
38
+ else
39
+ raise Error, "Unexpected response: HTTP #{response.code} - #{response.message}"
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ # Item type discovery and template methods
5
+ module ItemTypes
6
+ def item_types(locale: nil)
7
+ get("/itemTypes", params: build_locale_params(locale))
8
+ end
9
+
10
+ def item_type_fields(item_type, locale: nil)
11
+ params = { itemType: item_type }
12
+ params.merge!(build_locale_params(locale))
13
+ get("/itemTypeFields", params: params)
14
+ end
15
+
16
+ def item_type_creator_types(item_type)
17
+ get("/itemTypeCreatorTypes", params: { itemType: item_type })
18
+ end
19
+
20
+ def new_item_template(item_type)
21
+ get("/items/new", params: { itemType: item_type })
22
+ end
23
+
24
+ private
25
+
26
+ def build_locale_params(locale)
27
+ locale ? { locale: locale } : {}
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "library_file_operations"
4
+ require_relative "fulltext"
5
+ require_relative "syncing"
6
+
7
+ module Zotero
8
+ # Represents a Zotero library (user or group) and provides methods for
9
+ # managing items, collections, tags, searches, and file operations.
10
+ #
11
+ # @example Working with a user library
12
+ # client = Zotero.new(api_key: 'your-key')
13
+ # library = client.user_library(12345)
14
+ # items = library.items
15
+ # collections = library.collections
16
+ #
17
+ class Library
18
+ # TODO: rename this module, LibraryFileOperations sounds weird
19
+ include LibraryFileOperations
20
+ include Fulltext
21
+ include Syncing
22
+
23
+ VALID_TYPES = %w[user group].freeze
24
+
25
+ # Initialize a new Library instance.
26
+ #
27
+ # @param client [Client] The Zotero client instance
28
+ # @param type [String, Symbol] The library type (:user or :group)
29
+ # @param id [Integer, String] The library ID (user ID or group ID)
30
+ def initialize(client:, type:, id:)
31
+ @client = client
32
+ @type = validate_type(type)
33
+ @id = id
34
+ @base_path = "/#{@type}s/#{@id}"
35
+ end
36
+
37
+ # Get collections in this library.
38
+ #
39
+ # @param params [Hash] Query parameters for the request
40
+ # @return [Array, Hash] Collections data from the API
41
+ def collections(**params)
42
+ @client.get("#{@base_path}/collections", params: params)
43
+ end
44
+
45
+ # Get items in this library.
46
+ #
47
+ # @param params [Hash] Query parameters for the request
48
+ # @return [Array, Hash] Items data from the API
49
+ def items(**params)
50
+ @client.get("#{@base_path}/items", params: params)
51
+ end
52
+
53
+ def searches(**params)
54
+ @client.get("#{@base_path}/searches", params: params)
55
+ end
56
+
57
+ def tags(**params)
58
+ @client.get("#{@base_path}/tags", params: params)
59
+ end
60
+
61
+ # Create a new item in this library.
62
+ #
63
+ # @param item_data [Hash] The item data
64
+ # @param version [Integer] Optional version for conditional requests
65
+ # @param write_token [String] Optional write token for batch operations
66
+ # @return [Hash] The API response
67
+ def create_item(item_data, version: nil, write_token: nil)
68
+ create_single("items", item_data, version: version, write_token: write_token)
69
+ end
70
+
71
+ def create_items(items_array, version: nil, write_token: nil)
72
+ create_multiple("items", items_array, version: version, write_token: write_token)
73
+ end
74
+
75
+ def update_item(item_key, item_data, version: nil)
76
+ @client.patch("#{@base_path}/items/#{item_key}", data: item_data, version: version)
77
+ end
78
+
79
+ def delete_item(item_key, version: nil)
80
+ @client.delete("#{@base_path}/items/#{item_key}", version: version)
81
+ end
82
+
83
+ def delete_items(item_keys, version: nil)
84
+ @client.delete("#{@base_path}/items", version: version, params: { itemKey: item_keys.join(",") })
85
+ end
86
+
87
+ def create_collection(collection_data, version: nil, write_token: nil)
88
+ create_single("collections", collection_data, version: version, write_token: write_token)
89
+ end
90
+
91
+ def create_collections(collections_array, version: nil, write_token: nil)
92
+ create_multiple("collections", collections_array, version: version, write_token: write_token)
93
+ end
94
+
95
+ def update_collection(collection_key, collection_data, version: nil)
96
+ @client.patch("#{@base_path}/collections/#{collection_key}", data: collection_data, version: version)
97
+ end
98
+
99
+ def delete_collection(collection_key, version: nil)
100
+ @client.delete("#{@base_path}/collections/#{collection_key}", version: version)
101
+ end
102
+
103
+ def delete_collections(collection_keys, version: nil)
104
+ @client.delete("#{@base_path}/collections", version: version,
105
+ params: { collectionKey: collection_keys.join(",") })
106
+ end
107
+
108
+ private
109
+
110
+ attr_reader :client, :type, :id, :base_path
111
+
112
+ def create_single(resource, data, version: nil, write_token: nil)
113
+ @client.post("#{@base_path}/#{resource}", data: [data], version: version, write_token: write_token)
114
+ end
115
+
116
+ 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)
118
+ end
119
+
120
+ def validate_type(type)
121
+ type_str = type.to_s
122
+ unless VALID_TYPES.include?(type_str)
123
+ raise ArgumentError, "Invalid library type: #{type_str}. Must be one of: #{VALID_TYPES.join(', ')}"
124
+ end
125
+
126
+ type_str
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ # File upload operations for library items
5
+ module LibraryFileOperations
6
+ def create_attachment(attachment_data, version: nil, write_token: nil)
7
+ create_single("items", attachment_data, version: version, write_token: write_token)
8
+ end
9
+
10
+ def get_file_info(item_key)
11
+ @client.get("#{@base_path}/items/#{item_key}/file")
12
+ end
13
+
14
+ def upload_file(item_key, file_path)
15
+ perform_file_upload(item_key, file_path, existing_file: false)
16
+ end
17
+
18
+ def update_file(item_key, file_path)
19
+ perform_file_upload(item_key, file_path, existing_file: true)
20
+ end
21
+
22
+ private
23
+
24
+ def perform_file_upload(item_key, file_path, existing_file:)
25
+ file_metadata = extract_file_metadata(file_path)
26
+ upload_path = file_upload_path(item_key)
27
+
28
+ # Step 1: Request upload authorization
29
+ auth_response = @client.request_upload_authorization(
30
+ upload_path,
31
+ **file_metadata,
32
+ existing_file: existing_file
33
+ )
34
+
35
+ perform_external_upload(auth_response, file_path, upload_path)
36
+ end
37
+
38
+ def extract_file_metadata(file_path)
39
+ require "digest"
40
+
41
+ {
42
+ filename: File.basename(file_path),
43
+ md5: Digest::MD5.file(file_path).hexdigest,
44
+ mtime: File.mtime(file_path).to_i * 1000 # Convert to milliseconds
45
+ }
46
+ end
47
+
48
+ def file_upload_path(item_key)
49
+ "#{@base_path}/items/#{item_key}/file"
50
+ end
51
+
52
+ def perform_external_upload(auth_response, file_path, upload_path)
53
+ if auth_response["url"]
54
+ upload_params = build_upload_params(auth_response, file_path)
55
+ @client.external_post(auth_response["url"], multipart_data: upload_params)
56
+ end
57
+
58
+ if auth_response["uploadKey"]
59
+ @client.register_upload(upload_path, upload_key: auth_response["uploadKey"])
60
+ else
61
+ true
62
+ end
63
+ end
64
+
65
+ def build_upload_params(auth_response, file_path)
66
+ file_data = File.open(file_path, "rb")
67
+
68
+ if auth_response["params"]
69
+ auth_response["params"].merge("file" => file_data)
70
+ else
71
+ {
72
+ "prefix" => auth_response["prefix"],
73
+ "file" => file_data,
74
+ "suffix" => auth_response["suffix"]
75
+ }
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ module Syncing
5
+ def verify_api_key
6
+ @client ? @client.get("/keys/current") : get("/keys/current")
7
+ end
8
+
9
+ def user_groups(user_id, format: "versions")
10
+ client = @client || self
11
+ client.get("/users/#{user_id}/groups", params: { format: format })
12
+ end
13
+
14
+ def deleted_items(since: nil)
15
+ params = since ? { since: since } : {}
16
+ @client.get("#{@base_path}/deleted", params: params)
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Zotero
4
+ VERSION = "0.1.1"
5
+ end
data/lib/zotero.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "zotero/version"
4
+ require_relative "zotero/client"
5
+ require_relative "zotero/library"
6
+ require_relative "zotero/error"
7
+
8
+ # Ruby client library for the Zotero Web API v3.
9
+ #
10
+ # Provides a comprehensive interface for interacting with Zotero libraries,
11
+ # including full CRUD operations for items, collections, tags, searches,
12
+ # file uploads, and synchronization.
13
+ #
14
+ # @example Basic usage
15
+ # client = Zotero.new(api_key: 'your-api-key')
16
+ # library = client.user_library(12345)
17
+ # items = library.items
18
+ #
19
+ # @see https://www.zotero.org/support/dev/web_api/v3/start Zotero Web API v3 Documentation
20
+ module Zotero
21
+ # Create a new Zotero API client.
22
+ #
23
+ # @param api_key [String] Your Zotero API key
24
+ # @return [Client] A new Zotero client instance
25
+ def self.new(api_key:)
26
+ Client.new(api_key: api_key)
27
+ end
28
+ end
data/sig/zotero/rb.rbs ADDED
@@ -0,0 +1,6 @@
1
+ module Zotero
2
+ module Rb
3
+ VERSION: String
4
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
5
+ end
6
+ end
metadata ADDED
@@ -0,0 +1,100 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: zotero-rb
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
5
+ platform: ruby
6
+ authors:
7
+ - Andrew Waller
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: httparty
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: 0.21.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.21.0
26
+ - !ruby/object:Gem::Dependency
27
+ name: csv
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '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'
40
+ description: A feature-complete Ruby client for the Zotero Web API v3, providing full
41
+ CRUD operations for items, collections, tags, and searches, plus file uploads, fulltext
42
+ content access, and library synchronization. Built with a modular architecture and
43
+ comprehensive error handling.
44
+ email:
45
+ - 48367637+andrewhwaller@users.noreply.github.com
46
+ executables: []
47
+ extensions: []
48
+ extra_rdoc_files: []
49
+ files:
50
+ - ".rspec"
51
+ - ".rubocop.yml"
52
+ - ".yardopts"
53
+ - CHANGELOG.md
54
+ - LICENSE.txt
55
+ - README.md
56
+ - Rakefile
57
+ - TASKS.md
58
+ - lib/zotero.rb
59
+ - lib/zotero/client.rb
60
+ - lib/zotero/error.rb
61
+ - lib/zotero/fields.rb
62
+ - lib/zotero/file_upload.rb
63
+ - lib/zotero/fulltext.rb
64
+ - lib/zotero/http_errors.rb
65
+ - lib/zotero/item_types.rb
66
+ - lib/zotero/library.rb
67
+ - lib/zotero/library_file_operations.rb
68
+ - lib/zotero/syncing.rb
69
+ - lib/zotero/version.rb
70
+ - sig/zotero/rb.rbs
71
+ homepage: https://github.com/andrewhwaller/zotero-rb
72
+ licenses:
73
+ - MIT
74
+ metadata:
75
+ allowed_push_host: https://rubygems.org
76
+ bug_tracker_uri: https://github.com/andrewhwaller/zotero-rb/issues
77
+ documentation_uri: https://rubydoc.info/gems/zotero-rb
78
+ wiki_uri: https://github.com/andrewhwaller/zotero-rb/wiki
79
+ homepage_uri: https://github.com/andrewhwaller/zotero-rb
80
+ source_code_uri: https://github.com/andrewhwaller/zotero-rb
81
+ changelog_uri: https://github.com/andrewhwaller/zotero-rb/blob/main/CHANGELOG.md
82
+ rubygems_mfa_required: 'true'
83
+ rdoc_options: []
84
+ require_paths:
85
+ - lib
86
+ required_ruby_version: !ruby/object:Gem::Requirement
87
+ requirements:
88
+ - - ">="
89
+ - !ruby/object:Gem::Version
90
+ version: 3.2.0
91
+ required_rubygems_version: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '0'
96
+ requirements: []
97
+ rubygems_version: 3.6.9
98
+ specification_version: 4
99
+ summary: A comprehensive Ruby client for the Zotero Web API v3
100
+ test_files: []