carddb 0.3.10 → 0.4.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.
@@ -680,6 +680,140 @@ module CardDB
680
680
  GRAPHQL
681
681
  end
682
682
 
683
+ def create_scan_job
684
+ <<~GRAPHQL
685
+ mutation CreateScanJob($input: CreateScanJobInput!) {
686
+ createScanJob(input: $input) {
687
+ job {
688
+ #{scan_job_fields}
689
+ }
690
+ created
691
+ }
692
+ }
693
+ GRAPHQL
694
+ end
695
+
696
+ def scan_job
697
+ <<~GRAPHQL
698
+ query ScanJob($id: UUID!) {
699
+ scanJob(id: $id) {
700
+ #{scan_job_fields}
701
+ }
702
+ }
703
+ GRAPHQL
704
+ end
705
+
706
+ def submit_scan_feedback
707
+ <<~GRAPHQL
708
+ mutation SubmitScanFeedback($input: SubmitScanFeedbackInput!) {
709
+ submitScanFeedback(input: $input) {
710
+ feedbackId
711
+ jobId
712
+ status
713
+ persisted
714
+ correct
715
+ predictedRecordId
716
+ selectedRecordId
717
+ featureVersion
718
+ message
719
+ }
720
+ }
721
+ GRAPHQL
722
+ end
723
+
724
+ def scan_metrics
725
+ <<~GRAPHQL
726
+ query ScanMetrics($input: ScanMetricsInput!) {
727
+ scanMetrics(input: $input) {
728
+ #{scan_metrics_fields}
729
+ }
730
+ }
731
+ GRAPHQL
732
+ end
733
+
734
+ def resolve_scan_template
735
+ <<~GRAPHQL
736
+ query ResolveScanTemplate($input: ResolveScanTemplateInput!) {
737
+ resolveScanTemplate(input: $input) {
738
+ template {
739
+ #{scan_template_fields}
740
+ }
741
+ warnings {
742
+ #{scan_template_warning_fields}
743
+ }
744
+ }
745
+ }
746
+ GRAPHQL
747
+ end
748
+
749
+ def scan_templates
750
+ <<~GRAPHQL
751
+ query ScanTemplates($input: ScanTemplatesInput!) {
752
+ scanTemplates(input: $input) {
753
+ templates {
754
+ #{scan_template_fields}
755
+ }
756
+ }
757
+ }
758
+ GRAPHQL
759
+ end
760
+
761
+ def create_scan_template
762
+ <<~GRAPHQL
763
+ mutation CreateScanTemplate($input: SaveScanTemplateInput!) {
764
+ createScanTemplate(input: $input) {
765
+ template {
766
+ #{scan_template_fields}
767
+ }
768
+ warnings {
769
+ #{scan_template_warning_fields}
770
+ }
771
+ }
772
+ }
773
+ GRAPHQL
774
+ end
775
+
776
+ def update_scan_template
777
+ <<~GRAPHQL
778
+ mutation UpdateScanTemplate($id: UUID!, $input: SaveScanTemplateInput!) {
779
+ updateScanTemplate(id: $id, input: $input) {
780
+ template {
781
+ #{scan_template_fields}
782
+ }
783
+ warnings {
784
+ #{scan_template_warning_fields}
785
+ }
786
+ }
787
+ }
788
+ GRAPHQL
789
+ end
790
+
791
+ def preview_scan_template
792
+ <<~GRAPHQL
793
+ mutation PreviewScanTemplate($input: PreviewScanTemplateInput!) {
794
+ previewScanTemplate(input: $input) {
795
+ warnings {
796
+ #{scan_template_warning_fields}
797
+ }
798
+ regions {
799
+ #{scan_template_preview_region_fields}
800
+ }
801
+ message
802
+ }
803
+ }
804
+ GRAPHQL
805
+ end
806
+
807
+ def game_scan_regions
808
+ <<~GRAPHQL
809
+ query GameScanRegions($publisherSlug: String!, $gameKey: String!, $includeInactive: Boolean) {
810
+ gameScanRegions(publisherSlug: $publisherSlug, gameKey: $gameKey, includeInactive: $includeInactive) {
811
+ #{game_scan_region_fields}
812
+ }
813
+ }
814
+ GRAPHQL
815
+ end
816
+
683
817
  def export_job
684
818
  <<~GRAPHQL
685
819
  query ExportJob($id: UUID!) {
@@ -2252,6 +2386,181 @@ module CardDB
2252
2386
  FIELDS
2253
2387
  end
2254
2388
 
2389
+ def scan_job_fields
2390
+ <<~FIELDS
2391
+ jobId
2392
+ status
2393
+ publisherSlug
2394
+ gameKey
2395
+ datasetKey
2396
+ fileId
2397
+ clientRequestId
2398
+ templateId
2399
+ templateVersionId
2400
+ bestMatch {
2401
+ recordId
2402
+ score
2403
+ confidence
2404
+ }
2405
+ candidates {
2406
+ recordId
2407
+ rank
2408
+ score
2409
+ confidence
2410
+ reasons
2411
+ }
2412
+ extracted
2413
+ metrics {
2414
+ featureVersion
2415
+ candidateCount
2416
+ confidenceScore
2417
+ confidenceLabel
2418
+ stages {
2419
+ name
2420
+ status
2421
+ durationMs
2422
+ completedAt
2423
+ details
2424
+ }
2425
+ timings
2426
+ }
2427
+ webhook {
2428
+ configured
2429
+ url
2430
+ latestDeliveryId
2431
+ status
2432
+ attemptCount
2433
+ nextAttemptAt
2434
+ lastAttemptAt
2435
+ deliveredAt
2436
+ lastStatusCode
2437
+ lastError
2438
+ }
2439
+ error {
2440
+ code
2441
+ message
2442
+ }
2443
+ createdAt
2444
+ updatedAt
2445
+ startedAt
2446
+ completedAt
2447
+ FIELDS
2448
+ end
2449
+
2450
+ def scan_metrics_fields
2451
+ <<~FIELDS
2452
+ publisherSlug
2453
+ gameKey
2454
+ datasetKey
2455
+ since
2456
+ until
2457
+ generatedAt
2458
+ totalJobs
2459
+ completedJobs
2460
+ failedJobs
2461
+ failureRate
2462
+ averageShortlistSize
2463
+ confidenceDistribution
2464
+ feedback {
2465
+ total
2466
+ correct
2467
+ corrected
2468
+ unknown
2469
+ accuracy
2470
+ }
2471
+ featureVersions {
2472
+ featureVersion
2473
+ jobCount
2474
+ completedJobs
2475
+ failedJobs
2476
+ averageConfidence
2477
+ averageShortlistSize
2478
+ feedbackCount
2479
+ correctFeedback
2480
+ correctedFeedback
2481
+ feedbackAccuracy
2482
+ }
2483
+ FIELDS
2484
+ end
2485
+
2486
+ def scan_template_warning_fields
2487
+ <<~FIELDS
2488
+ code
2489
+ region
2490
+ message
2491
+ FIELDS
2492
+ end
2493
+
2494
+ def scan_template_region_fields
2495
+ <<~FIELDS
2496
+ id
2497
+ key
2498
+ label
2499
+ sortOrder
2500
+ shapeType
2501
+ geometry
2502
+ semanticType
2503
+ extractionMode
2504
+ lookupMode
2505
+ isRequired
2506
+ weight
2507
+ config
2508
+ FIELDS
2509
+ end
2510
+
2511
+ def scan_template_fields
2512
+ <<~FIELDS
2513
+ id
2514
+ key
2515
+ name
2516
+ status
2517
+ version
2518
+ isDefault
2519
+ publisherSlug
2520
+ gameKey
2521
+ datasetKey
2522
+ exampleFileId
2523
+ coordinateSpace
2524
+ cardAspectRatio
2525
+ notes
2526
+ config
2527
+ regions {
2528
+ #{scan_template_region_fields}
2529
+ }
2530
+ createdAt
2531
+ updatedAt
2532
+ FIELDS
2533
+ end
2534
+
2535
+ def scan_template_preview_region_fields
2536
+ <<~FIELDS
2537
+ key
2538
+ label
2539
+ semanticType
2540
+ extractionMode
2541
+ lookupMode
2542
+ rawText
2543
+ normalizedValue
2544
+ source
2545
+ status
2546
+ confidence
2547
+ FIELDS
2548
+ end
2549
+
2550
+ def game_scan_region_fields
2551
+ <<~FIELDS
2552
+ id
2553
+ key
2554
+ name
2555
+ description
2556
+ sortOrder
2557
+ isActive
2558
+ metadata
2559
+ createdAt
2560
+ updatedAt
2561
+ FIELDS
2562
+ end
2563
+
2255
2564
  def import_job_connection_fields
2256
2565
  connection_fields(import_job_fields)
2257
2566
  end
@@ -29,6 +29,39 @@ module CardDB
29
29
  File.new(data['fileUploadConfirm'], client: client)
30
30
  end
31
31
 
32
+ # Request a presigned URL, upload bytes directly to storage, and confirm the file.
33
+ def upload(
34
+ filename:,
35
+ content_type:,
36
+ body:,
37
+ size: nil,
38
+ purpose: nil,
39
+ is_public: false,
40
+ entity_type: nil,
41
+ entity_id: nil,
42
+ publisher_id: nil,
43
+ dataset_id: nil
44
+ )
45
+ upload_size = size || infer_upload_size(body)
46
+ raise ArgumentError, 'files.upload requires a positive size or an inferable body size' unless upload_size&.positive?
47
+
48
+ upload = request_upload(
49
+ input: {
50
+ filename: filename,
51
+ contentType: content_type,
52
+ size: upload_size,
53
+ isPublic: is_public,
54
+ entityType: entity_type || entity_type_for_purpose(purpose),
55
+ entityId: entity_id,
56
+ publisherId: publisher_id,
57
+ datasetId: dataset_id
58
+ }.compact
59
+ )
60
+
61
+ put_upload(upload.upload_url, content_type, body)
62
+ confirm_upload(id: upload.file.id)
63
+ end
64
+
32
65
  # Delete a file. Requires a server-side secret credential.
33
66
  # rubocop:disable Naming/PredicateMethod
34
67
  def delete(id:)
@@ -38,6 +71,48 @@ module CardDB
38
71
  !!data['fileDelete']
39
72
  end
40
73
  # rubocop:enable Naming/PredicateMethod
74
+
75
+ private
76
+
77
+ def put_upload(upload_url, content_type, body)
78
+ response = Faraday.put(upload_url) do |request|
79
+ request.headers['Content-Type'] = content_type
80
+ request.body = body
81
+ end
82
+
83
+ return if response.success?
84
+
85
+ raise Error, "file upload failed with #{response.status}: #{response.body}"
86
+ end
87
+
88
+ def infer_upload_size(body)
89
+ return body.bytesize if body.is_a?(String)
90
+ return body.bytesize if body.respond_to?(:bytesize)
91
+ return body.size if body.respond_to?(:size) && body.size.is_a?(Integer)
92
+ return body.stat.size if body.respond_to?(:stat)
93
+
94
+ infer_io_size(body)
95
+ end
96
+
97
+ def infer_io_size(body)
98
+ return nil unless body.respond_to?(:tell) && body.respond_to?(:seek)
99
+
100
+ original_position = body.tell
101
+ body.seek(0, IO::SEEK_END)
102
+ size = body.tell
103
+ body.seek(original_position, IO::SEEK_SET)
104
+ size
105
+ rescue IOError, SystemCallError
106
+ nil
107
+ end
108
+
109
+ def entity_type_for_purpose(purpose)
110
+ return nil if purpose.nil? || purpose.to_s == 'scan_image'
111
+ return 'import' if purpose.to_s == 'import'
112
+ return 'scan_template_example' if purpose.to_s == 'scan_template_example'
113
+
114
+ purpose.to_s
115
+ end
41
116
  end
42
117
  end
43
118
  end
@@ -74,6 +74,25 @@ module CardDB
74
74
  Game.new(data['gameUpdate'], client: client)
75
75
  end
76
76
 
77
+ # Get publisher-defined game region metadata for scan-capable clients.
78
+ def get_regions(publisher_slug: nil, game_key: nil, include_inactive: nil, cache: nil)
79
+ resolved_publisher = resolve_publisher(publisher_slug)
80
+ resolved_game = resolve_game(game_key)
81
+ validate_access!(resolved_publisher, resolved_game)
82
+
83
+ variables = build_variables(
84
+ publisherSlug: resolved_publisher,
85
+ gameKey: resolved_game,
86
+ includeInactive: include_inactive
87
+ )
88
+ key = cache_key('games', 'get_regions', **variables)
89
+
90
+ with_cache(key, resource: :games, cache: cache) do
91
+ data = connection.execute(QueryBuilder.game_scan_regions, variables)
92
+ (data['gameScanRegions'] || []).map { |region| GameScanRegion.new(region, client: client) }
93
+ end
94
+ end
95
+
77
96
  # Search for games
78
97
  #
79
98
  # @param publisher_slug [String, nil] Filter by publisher slug
@@ -18,9 +18,9 @@ module CardDB
18
18
  before: nil,
19
19
  validate_schema: nil,
20
20
  cache: nil,
21
- &block
21
+ &
22
22
  )
23
- final_filter = block_given? ? FilterBuilder.build(&block) : filter
23
+ final_filter = block_given? ? FilterBuilder.build(&) : filter
24
24
  query = QueryBuilder.list_dataset_records(
25
25
  dataset_id: dataset_id,
26
26
  filter: final_filter,
@@ -206,7 +206,7 @@ module CardDB
206
206
  after: nil,
207
207
  validate_schema: nil,
208
208
  include_pricing: false,
209
- &block
209
+ &
210
210
  )
211
211
  resolved_publisher = resolve_publisher(publisher_slug)
212
212
  resolved_game = resolve_game(game_key)
@@ -215,7 +215,7 @@ module CardDB
215
215
 
216
216
  # Build filter from DSL block if provided
217
217
  final_filter = if block_given?
218
- FilterBuilder.build(&block)
218
+ FilterBuilder.build(&)
219
219
  else
220
220
  filter
221
221
  end
@@ -27,22 +27,22 @@ module CardDB
27
27
  #
28
28
  # @param dataset_key [String] Rules dataset key (defaults to "rules")
29
29
  # @return [Collection<Record>] Collection of rule records
30
- def list(dataset_key: 'rules', **options, &block)
31
- search_records(dataset_key: dataset_key, **options, &block)
30
+ def list(dataset_key: 'rules', **, &)
31
+ search_records(dataset_key: dataset_key, **, &)
32
32
  end
33
33
 
34
34
  # List deck/game formats from the game's formats dataset.
35
35
  #
36
36
  # @param dataset_key [String] Formats dataset key (defaults to "formats")
37
37
  # @return [Collection<Record>] Collection of format records
38
- def formats(dataset_key: 'formats', **options, &block)
39
- search_records(dataset_key: dataset_key, **options, &block)
38
+ def formats(dataset_key: 'formats', **, &)
39
+ search_records(dataset_key: dataset_key, **, &)
40
40
  end
41
41
 
42
42
  private
43
43
 
44
- def search_records(dataset_key:, **options, &block)
45
- client.records.search(dataset_key: dataset_key, **options, &block)
44
+ def search_records(dataset_key:, **, &)
45
+ client.records.search(dataset_key: dataset_key, **, &)
46
46
  end
47
47
  end
48
48
  end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module CardDB
4
+ module Resources
5
+ # Scan template resource for publisher-managed scan layouts.
6
+ class ScanTemplates < Base
7
+ SCOPE_KEY_MAP = {
8
+ publisher_slug: 'publisherSlug',
9
+ game_key: 'gameKey',
10
+ dataset_key: 'datasetKey',
11
+ template_id: 'templateId',
12
+ include_archived: 'includeArchived'
13
+ }.freeze
14
+
15
+ TEMPLATE_KEY_MAP = SCOPE_KEY_MAP.merge(
16
+ is_default: 'isDefault',
17
+ example_file_id: 'exampleFileId',
18
+ card_aspect_ratio: 'cardAspectRatio',
19
+ region_ocr_hints: 'regionOcrHints'
20
+ ).freeze
21
+
22
+ REGION_KEY_MAP = {
23
+ sort_order: 'sortOrder',
24
+ shape_type: 'shapeType',
25
+ semantic_type: 'semanticType',
26
+ extraction_mode: 'extractionMode',
27
+ lookup_mode: 'lookupMode',
28
+ is_required: 'isRequired'
29
+ }.freeze
30
+
31
+ # Resolve the active scan template for a game or dataset scope.
32
+ def resolve(input = nil, **params)
33
+ request = scoped_input(graphql_input(coerce_input(input, params), SCOPE_KEY_MAP))
34
+ key = cache_key('scan_templates', 'resolve', **request)
35
+
36
+ with_cache(key, resource: :scan_templates, cache: params[:cache]) do
37
+ data = connection.execute(QueryBuilder.resolve_scan_template, { input: request })
38
+ ScanTemplateResolution.new(data['resolveScanTemplate'], client: client)
39
+ end
40
+ end
41
+
42
+ # List publisher-managed scan templates for a game or dataset scope.
43
+ def list(input = nil, **params)
44
+ request = scoped_input(graphql_input(coerce_input(input, params), SCOPE_KEY_MAP))
45
+ key = cache_key('scan_templates', 'list', **request)
46
+
47
+ with_cache(key, resource: :scan_templates, cache: params[:cache]) do
48
+ data = connection.execute(QueryBuilder.scan_templates, { input: request })
49
+ (data.dig('scanTemplates', 'templates') || []).map do |template|
50
+ ScanTemplate.new(template, client: client)
51
+ end
52
+ end
53
+ end
54
+
55
+ # Create a publisher-managed scan template. Requires a server-side secret credential.
56
+ def create(input = nil, **params)
57
+ config.require_secret_credential!('scan_templates.create')
58
+
59
+ request = template_input(coerce_input(input, params))
60
+ data = connection.execute(QueryBuilder.create_scan_template, { input: request })
61
+
62
+ ScanTemplatePayload.new(data['createScanTemplate'], client: client)
63
+ end
64
+
65
+ # Replace a publisher-managed scan template. Requires a server-side secret credential.
66
+ def update(id = nil, input = nil, **params)
67
+ config.require_secret_credential!('scan_templates.update')
68
+
69
+ id, request_source = update_args(id, input, params)
70
+ raise ArgumentError, 'scan template id is required' unless id
71
+
72
+ request = template_input(request_source)
73
+ data = connection.execute(QueryBuilder.update_scan_template, { id: id, input: request })
74
+
75
+ ScanTemplatePayload.new(data['updateScanTemplate'], client: client)
76
+ end
77
+
78
+ # Validate a draft scan template and normalize provided region OCR hints.
79
+ def preview(input = nil, **params)
80
+ request = template_input(coerce_input(input, params))
81
+ data = connection.execute(QueryBuilder.preview_scan_template, { input: request })
82
+
83
+ ScanTemplatePreview.new(data['previewScanTemplate'], client: client)
84
+ end
85
+
86
+ private
87
+
88
+ def update_args(id, input, params)
89
+ if id.is_a?(Hash) && input.nil?
90
+ source = id.dup
91
+ template_id = source.delete(:id) || source.delete('id')
92
+ return [template_id, source]
93
+ end
94
+
95
+ source = coerce_input(input, params)
96
+ [id || params[:id] || params['id'], source]
97
+ end
98
+
99
+ def template_input(input)
100
+ request = scoped_input(graphql_input(input, TEMPLATE_KEY_MAP))
101
+ request['regions'] = Array(request['regions']).map { |region| graphql_input(region, REGION_KEY_MAP) }
102
+ request
103
+ end
104
+
105
+ def scoped_input(input)
106
+ publisher_slug = input['publisherSlug'] || resolve_publisher(nil)
107
+ game_key = input['gameKey'] || resolve_game(nil)
108
+ validate_access!(publisher_slug, game_key)
109
+
110
+ input.merge('publisherSlug' => publisher_slug, 'gameKey' => game_key)
111
+ end
112
+
113
+ def coerce_input(input, params)
114
+ params = params.dup
115
+ input = params.delete(:input) || params.delete('input') if input.nil?
116
+ (input || {}).merge(params.reject { |key, _value| %i[cache id].include?(key) || %w[cache id].include?(key) })
117
+ end
118
+
119
+ def graphql_input(input, key_map)
120
+ input.each_with_object({}) do |(key, value), output|
121
+ next if value.nil?
122
+
123
+ output[key_map[key.to_sym] || key.to_s] = value
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end