carddb 0.3.15 → 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.
@@ -409,6 +409,16 @@ module CardDB
409
409
  end
410
410
  end
411
411
 
412
+ # Fetch publisher-defined scan regions for this game.
413
+ def scan_regions(include_inactive: nil)
414
+ raise Error, 'No client available to fetch scan regions' unless client
415
+
416
+ publisher_slug = publisher&.[]('slug')
417
+ raise Error, 'Publisher slug not available on this game' unless publisher_slug
418
+
419
+ client.games.get_regions(publisher_slug: publisher_slug, game_key: key, include_inactive: include_inactive)
420
+ end
421
+
412
422
  private
413
423
 
414
424
  def fetch_datasets(purpose: nil, search: nil, first: nil, after: nil)
@@ -932,6 +942,257 @@ module CardDB
932
942
  end
933
943
  end
934
944
 
945
+ class ScanJobPayload < Resource
946
+ def created? = !!data['created']
947
+
948
+ def job
949
+ @job ||= data['job'] ? ScanJob.new(data['job'], client: client) : nil
950
+ end
951
+ end
952
+
953
+ class ScanBestMatch < Resource
954
+ def record_id = data['recordId']
955
+ def score = data['score']
956
+ def confidence = data['confidence']
957
+ end
958
+
959
+ class ScanCandidate < Resource
960
+ def record_id = data['recordId']
961
+ def rank = data['rank']
962
+ def score = data['score']
963
+ def confidence = data['confidence']
964
+ def reasons = data['reasons'] || []
965
+ end
966
+
967
+ class ScanJobError < Resource
968
+ def code = data['code']
969
+ def message = data['message']
970
+ end
971
+
972
+ class ScanWebhookState < Resource
973
+ def configured? = !!data['configured']
974
+ def url = data['url']
975
+ def latest_delivery_id = data['latestDeliveryId']
976
+ def status = data['status']
977
+ def attempt_count = data['attemptCount']
978
+ def next_attempt_at = parse_time(data['nextAttemptAt'])
979
+ def last_attempt_at = parse_time(data['lastAttemptAt'])
980
+ def delivered_at = parse_time(data['deliveredAt'])
981
+ def last_status_code = data['lastStatusCode']
982
+ def last_error = data['lastError']
983
+ end
984
+
985
+ class ScanStageMetrics < Resource
986
+ def name = data['name']
987
+ def status = data['status']
988
+ def duration_ms = data['durationMs']
989
+ def completed_at = parse_time(data['completedAt'])
990
+ def details = data['details'] || {}
991
+ end
992
+
993
+ class ScanJobMetrics < Resource
994
+ def feature_version = data['featureVersion']
995
+ def candidate_count = data['candidateCount']
996
+ def confidence_score = data['confidenceScore']
997
+ def confidence_label = data['confidenceLabel']
998
+ def timings = data['timings'] || {}
999
+
1000
+ def stages
1001
+ @stages ||= (data['stages'] || []).map { |stage| ScanStageMetrics.new(stage, client: client) }
1002
+ end
1003
+ end
1004
+
1005
+ class ScanJob < Resource
1006
+ def job_id = data['jobId']
1007
+ def status = data['status']
1008
+ def publisher_slug = data['publisherSlug']
1009
+ def game_key = data['gameKey']
1010
+ def dataset_key = data['datasetKey']
1011
+ def file_id = data['fileId']
1012
+ def client_request_id = data['clientRequestId']
1013
+ def template_id = data['templateId']
1014
+ def template_version_id = data['templateVersionId']
1015
+ def extracted = data['extracted'] || {}
1016
+ def created_at = parse_time(data['createdAt'])
1017
+ def updated_at = parse_time(data['updatedAt'])
1018
+ def started_at = parse_time(data['startedAt'])
1019
+ def completed_at = parse_time(data['completedAt'])
1020
+ def completed? = status.to_s.downcase == 'completed'
1021
+ def failed? = status.to_s.downcase == 'failed'
1022
+ def cancelled? = status.to_s.downcase == 'cancelled'
1023
+
1024
+ def best_match
1025
+ @best_match ||= data['bestMatch'] ? ScanBestMatch.new(data['bestMatch'], client: client) : nil
1026
+ end
1027
+
1028
+ def candidates
1029
+ @candidates ||= (data['candidates'] || []).map { |candidate| ScanCandidate.new(candidate, client: client) }
1030
+ end
1031
+
1032
+ def metrics
1033
+ @metrics ||= data['metrics'] ? ScanJobMetrics.new(data['metrics'], client: client) : nil
1034
+ end
1035
+
1036
+ def webhook
1037
+ @webhook ||= data['webhook'] ? ScanWebhookState.new(data['webhook'], client: client) : nil
1038
+ end
1039
+
1040
+ def error
1041
+ @error ||= data['error'] ? ScanJobError.new(data['error'], client: client) : nil
1042
+ end
1043
+ end
1044
+
1045
+ class ScanFeedbackPayload < Resource
1046
+ def feedback_id = data['feedbackId']
1047
+ def job_id = data['jobId']
1048
+ def status = data['status']
1049
+ def persisted? = !!data['persisted']
1050
+ def correct = data['correct']
1051
+ def predicted_record_id = data['predictedRecordId']
1052
+ def selected_record_id = data['selectedRecordId']
1053
+ def feature_version = data['featureVersion']
1054
+ def message = data['message']
1055
+ end
1056
+
1057
+ class ScanFeedbackMetrics < Resource
1058
+ def total = data['total']
1059
+ def correct = data['correct']
1060
+ def corrected = data['corrected']
1061
+ def unknown = data['unknown']
1062
+ def accuracy = data['accuracy']
1063
+ end
1064
+
1065
+ class ScanFeatureVersionMetrics < Resource
1066
+ def feature_version = data['featureVersion']
1067
+ def job_count = data['jobCount']
1068
+ def completed_jobs = data['completedJobs']
1069
+ def failed_jobs = data['failedJobs']
1070
+ def average_confidence = data['averageConfidence']
1071
+ def average_shortlist_size = data['averageShortlistSize']
1072
+ def feedback_count = data['feedbackCount']
1073
+ def correct_feedback = data['correctFeedback']
1074
+ def corrected_feedback = data['correctedFeedback']
1075
+ def feedback_accuracy = data['feedbackAccuracy']
1076
+ end
1077
+
1078
+ class ScanMetrics < Resource
1079
+ def publisher_slug = data['publisherSlug']
1080
+ def game_key = data['gameKey']
1081
+ def dataset_key = data['datasetKey']
1082
+ def since = parse_time(data['since'])
1083
+ def until = parse_time(data['until'])
1084
+ def generated_at = parse_time(data['generatedAt'])
1085
+ def total_jobs = data['totalJobs']
1086
+ def completed_jobs = data['completedJobs']
1087
+ def failed_jobs = data['failedJobs']
1088
+ def failure_rate = data['failureRate']
1089
+ def average_shortlist_size = data['averageShortlistSize']
1090
+ def confidence_distribution = data['confidenceDistribution'] || {}
1091
+
1092
+ def feedback
1093
+ @feedback ||= data['feedback'] ? ScanFeedbackMetrics.new(data['feedback'], client: client) : nil
1094
+ end
1095
+
1096
+ def feature_versions
1097
+ @feature_versions ||= (data['featureVersions'] || []).map do |version|
1098
+ ScanFeatureVersionMetrics.new(version, client: client)
1099
+ end
1100
+ end
1101
+ end
1102
+
1103
+ class ScanTemplateWarning < Resource
1104
+ def code = data['code']
1105
+ def region = data['region']
1106
+ def message = data['message']
1107
+ end
1108
+
1109
+ class ScanTemplateRegion < Resource
1110
+ def id = data['id']
1111
+ def key = data['key']
1112
+ def label = data['label']
1113
+ def sort_order = data['sortOrder']
1114
+ def shape_type = data['shapeType']
1115
+ def geometry = data['geometry'] || {}
1116
+ def semantic_type = data['semanticType']
1117
+ def extraction_mode = data['extractionMode']
1118
+ def lookup_mode = data['lookupMode']
1119
+ def required? = !!data['isRequired']
1120
+ def weight = data['weight']
1121
+ def config = data['config'] || {}
1122
+ end
1123
+
1124
+ class ScanTemplate < Resource
1125
+ def id = data['id']
1126
+ def key = data['key']
1127
+ def name = data['name']
1128
+ def status = data['status']
1129
+ def version = data['version']
1130
+ def default? = !!data['isDefault']
1131
+ def publisher_slug = data['publisherSlug']
1132
+ def game_key = data['gameKey']
1133
+ def dataset_key = data['datasetKey']
1134
+ def example_file_id = data['exampleFileId']
1135
+ def coordinate_space = data['coordinateSpace']
1136
+ def card_aspect_ratio = data['cardAspectRatio']
1137
+ def notes = data['notes']
1138
+ def config = data['config'] || {}
1139
+ def created_at = parse_time(data['createdAt'])
1140
+ def updated_at = parse_time(data['updatedAt'])
1141
+
1142
+ def regions
1143
+ @regions ||= (data['regions'] || []).map { |region| ScanTemplateRegion.new(region, client: client) }
1144
+ end
1145
+ end
1146
+
1147
+ class ScanTemplateResolution < Resource
1148
+ def template
1149
+ @template ||= data['template'] ? ScanTemplate.new(data['template'], client: client) : nil
1150
+ end
1151
+
1152
+ def warnings
1153
+ @warnings ||= (data['warnings'] || []).map { |warning| ScanTemplateWarning.new(warning, client: client) }
1154
+ end
1155
+ end
1156
+
1157
+ class ScanTemplatePayload < ScanTemplateResolution; end
1158
+
1159
+ class ScanTemplateRegionPreview < Resource
1160
+ def key = data['key']
1161
+ def label = data['label']
1162
+ def semantic_type = data['semanticType']
1163
+ def extraction_mode = data['extractionMode']
1164
+ def lookup_mode = data['lookupMode']
1165
+ def raw_text = data['rawText']
1166
+ def normalized_value = data['normalizedValue']
1167
+ def source = data['source']
1168
+ def status = data['status']
1169
+ def confidence = data['confidence']
1170
+ end
1171
+
1172
+ class ScanTemplatePreview < Resource
1173
+ def message = data['message']
1174
+
1175
+ def warnings
1176
+ @warnings ||= (data['warnings'] || []).map { |warning| ScanTemplateWarning.new(warning, client: client) }
1177
+ end
1178
+
1179
+ def regions
1180
+ @regions ||= (data['regions'] || []).map { |region| ScanTemplateRegionPreview.new(region, client: client) }
1181
+ end
1182
+ end
1183
+
1184
+ class GameScanRegion < Resource
1185
+ def id = data['id']
1186
+ def key = data['key']
1187
+ def name = data['name']
1188
+ def description = data['description']
1189
+ def sort_order = data['sortOrder']
1190
+ def active? = !!data['isActive']
1191
+ def metadata = data['metadata'] || {}
1192
+ def created_at = parse_time(data['createdAt'])
1193
+ def updated_at = parse_time(data['updatedAt'])
1194
+ end
1195
+
935
1196
  class ObjectField < Resource
936
1197
  def id = data['id']
937
1198
  def object_type_id = data['objectTypeId']
@@ -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