diff --git a/Scripts/cloudkit_import.py b/Scripts/cloudkit_import.py index 78e93e0..67cda2c 100755 --- a/Scripts/cloudkit_import.py +++ b/Scripts/cloudkit_import.py @@ -1315,6 +1315,318 @@ def verify_sync(ck, data_dir, verbose=False, deep=False): return total_mismatches == 0 +# Valid record types for individual record management +VALID_RECORD_TYPES = ['Stadium', 'Team', 'Game', 'LeagueStructure', 'TeamAlias', 'StadiumAlias'] + + +def validate_record_type(record_type): + """Validate record type and return normalized version.""" + # Allow case-insensitive matching + for valid_type in VALID_RECORD_TYPES: + if record_type.lower() == valid_type.lower(): + return valid_type + return None + + +def get_record(ck, record_type, record_id, verbose=False): + """Get and display a single record by ID.""" + normalized_type = validate_record_type(record_type) + if not normalized_type: + print(f"Error: Unknown record type '{record_type}'. Valid types: {', '.join(VALID_RECORD_TYPES)}") + return False + + print(f"\nLooking up {normalized_type} with id '{record_id}'...") + + # Try to look up by recordName directly + records = ck.lookup(normalized_type, [record_id], verbose=verbose) + + if isinstance(records, dict) and 'error' in records: + print(f"Error: {records['error']}") + return False + + # Filter out records with errors (NOT_FOUND) + found_records = [r for r in records if 'serverErrorCode' not in r] + + # If not found, try deterministic_uuid lookup + if not found_records: + uuid_name = deterministic_uuid(record_id) + records = ck.lookup(normalized_type, [uuid_name], verbose=verbose) + + if isinstance(records, dict) and 'error' in records: + print(f"Error: {records['error']}") + return False + + found_records = [r for r in records if 'serverErrorCode' not in r] + + # If still not found, try query by canonicalId field + if not found_records: + if verbose: + print(f" Trying query by canonicalId field...") + # Query by canonicalId (works for Stadium, Team, Game) + query_records = ck.query(normalized_type, [['canonicalId', 'EQUALS', record_id]]) + if isinstance(query_records, dict) and 'error' in query_records: + pass # Ignore error, will report not found below + elif query_records: + result_records = query_records.get('records', []) + found_records = [r for r in result_records if 'serverErrorCode' not in r] + + if not found_records: + print(f"Error: No {normalized_type} with id '{record_id}' found in CloudKit") + return False + + record = found_records[0] + + # Display record + print(f"\n{'='*50}") + print(f"Record: {record.get('recordName', 'N/A')}") + print(f"Type: {record.get('recordType', 'N/A')}") + print(f"ChangeTag: {record.get('recordChangeTag', 'N/A')}") + print(f"{'='*50}") + print("Fields:") + fields = record.get('fields', {}) + for field_name, field_data in sorted(fields.items()): + value = field_data.get('value', 'N/A') + field_type = field_data.get('type', '') + if field_type: + print(f" {field_name}: {value} ({field_type})") + else: + print(f" {field_name}: {value}") + print() + return True + + +def list_records(ck, record_type, count_only=False, verbose=False): + """List all recordNames for a record type.""" + normalized_type = validate_record_type(record_type) + if not normalized_type: + print(f"Error: Unknown record type '{record_type}'. Valid types: {', '.join(VALID_RECORD_TYPES)}") + return False + + print(f"\nQuerying {normalized_type} records...") + records = ck.query_all(normalized_type, verbose=verbose) + + if isinstance(records, dict) and 'error' in records: + print(f"Error: {records['error']}") + return False + + if count_only: + print(f"\n{normalized_type}: {len(records)} records") + else: + print(f"\n{normalized_type} ({len(records)} records):") + print("-" * 40) + for record_name in sorted(records.keys()): + print(record_name) + return True + + +def update_record(ck, record_type, record_id, field_updates, verbose=False): + """Update a single record with field changes.""" + normalized_type = validate_record_type(record_type) + if not normalized_type: + print(f"Error: Unknown record type '{record_type}'. Valid types: {', '.join(VALID_RECORD_TYPES)}") + return False + + if not field_updates: + print("Error: No field updates specified. Use format: field=value") + return False + + # Parse field updates + updates = {} + for update in field_updates: + if '=' not in update: + print(f"Error: Invalid update format '{update}'. Use: field=value") + return False + field_name, value = update.split('=', 1) + + # Try to parse value as number if possible + try: + if '.' in value: + value = float(value) + else: + value = int(value) + except ValueError: + pass # Keep as string + + updates[field_name] = value + + print(f"\nLooking up {normalized_type} with id '{record_id}'...") + + # Look up record to get recordChangeTag + records = ck.lookup(normalized_type, [record_id], verbose=verbose) + + if isinstance(records, dict) and 'error' in records: + print(f"Error: {records['error']}") + return False + + # Filter out NOT_FOUND records + found_records = [r for r in records if 'serverErrorCode' not in r] + + if not found_records: + # Try with deterministic_uuid + uuid_name = deterministic_uuid(record_id) + records = ck.lookup(normalized_type, [uuid_name], verbose=verbose) + + if isinstance(records, dict) and 'error' in records: + print(f"Error: {records['error']}") + return False + + found_records = [r for r in records if 'serverErrorCode' not in r] + if not found_records: + print(f"Error: No {normalized_type} with id '{record_id}' found in CloudKit") + return False + record_id = uuid_name + + record = found_records[0] + record_change_tag = record.get('recordChangeTag', '') + + print(f"Found record: {record_id}") + print(f"Current recordChangeTag: {record_change_tag}") + print(f"\nUpdating fields: {updates}") + + # Build update operation + updated_record = { + 'recordType': normalized_type, + 'recordName': record_id, + 'recordChangeTag': record_change_tag, + 'fields': {field: {'value': value} for field, value in updates.items()} + } + + result = ck.modify([{'operationType': 'update', 'record': updated_record}]) + + if 'error' in result: + print(f"Error: {result['error']}") + return False + + result_records = result.get('records', []) + if not result_records: + print("Error: No response from CloudKit") + return False + + result_record = result_records[0] + if 'serverErrorCode' in result_record: + error_code = result_record.get('serverErrorCode') + reason = result_record.get('reason', 'Unknown') + + if error_code == 'CONFLICT': + print(f"\nConflict detected: Record was modified since lookup.") + print("Retrying with forceReplace...") + + # Retry with forceReplace + updated_record.pop('recordChangeTag', None) + retry_result = ck.modify([{'operationType': 'forceReplace', 'record': updated_record}]) + + if 'error' in retry_result: + print(f"Error: {retry_result['error']}") + return False + + retry_records = retry_result.get('records', []) + if retry_records and 'serverErrorCode' not in retry_records[0]: + print(f"\n✓ Record updated successfully (forceReplace)") + return True + else: + print(f"Error: {retry_records[0].get('reason', 'Unknown error')}") + return False + else: + print(f"Error: {error_code}: {reason}") + return False + + print(f"\n✓ Record updated successfully") + print(f"New recordChangeTag: {result_record.get('recordChangeTag', 'N/A')}") + return True + + +def delete_record(ck, record_type, record_id, force=False, verbose=False): + """Delete a single record by ID.""" + normalized_type = validate_record_type(record_type) + if not normalized_type: + print(f"Error: Unknown record type '{record_type}'. Valid types: {', '.join(VALID_RECORD_TYPES)}") + return False + + print(f"\nLooking up {normalized_type} with id '{record_id}'...") + + # Look up record to get recordChangeTag + records = ck.lookup(normalized_type, [record_id], verbose=verbose) + + if isinstance(records, dict) and 'error' in records: + print(f"Error: {records['error']}") + return False + + # Filter out NOT_FOUND records + found_records = [r for r in records if 'serverErrorCode' not in r] + + if not found_records: + # Try with deterministic_uuid + uuid_name = deterministic_uuid(record_id) + records = ck.lookup(normalized_type, [uuid_name], verbose=verbose) + + if isinstance(records, dict) and 'error' in records: + print(f"Error: {records['error']}") + return False + + found_records = [r for r in records if 'serverErrorCode' not in r] + if not found_records: + print(f"Error: No {normalized_type} with id '{record_id}' found in CloudKit") + return False + record_id = uuid_name + + record = found_records[0] + record_change_tag = record.get('recordChangeTag', '') + + print(f"Found record: {record_id}") + + # Show record details + fields = record.get('fields', {}) + print("\nRecord details:") + for field_name, field_data in list(fields.items())[:5]: # Show first 5 fields + print(f" {field_name}: {field_data.get('value', 'N/A')}") + if len(fields) > 5: + print(f" ... and {len(fields) - 5} more fields") + + # Confirm deletion + if not force: + try: + confirm = input("\nAre you sure you want to delete this record? (yes/no): ").strip().lower() + if confirm not in ['yes', 'y']: + print("Deletion cancelled.") + return False + except (EOFError, KeyboardInterrupt): + print("\nDeletion cancelled.") + return False + + print("\nDeleting record...") + + # Build delete operation + delete_op = { + 'operationType': 'delete', + 'record': { + 'recordName': record_id, + 'recordType': normalized_type, + 'recordChangeTag': record_change_tag + } + } + + result = ck.modify([delete_op]) + + if 'error' in result: + print(f"Error: {result['error']}") + return False + + result_records = result.get('records', []) + if not result_records: + print("Error: No response from CloudKit") + return False + + result_record = result_records[0] + if 'serverErrorCode' in result_record: + error_code = result_record.get('serverErrorCode') + reason = result_record.get('reason', 'Unknown') + print(f"Error: {error_code}: {reason}") + return False + + print(f"\n✓ Record deleted successfully") + return True + + def main(): p = argparse.ArgumentParser(description='Import JSON to CloudKit') p.add_argument('--key-id', default=DEFAULT_KEY_ID) @@ -1336,6 +1648,13 @@ def main(): p.add_argument('--delete-orphans', action='store_true', help='With --smart-sync, also delete records not in local data') p.add_argument('--verify', action='store_true', help='Verify CloudKit matches local data (quick: counts + spot-check)') p.add_argument('--verify-deep', action='store_true', help='Verify CloudKit matches local data (deep: full field comparison)') + # Individual record management + p.add_argument('--get', nargs=2, metavar=('TYPE', 'ID'), help='Get a single record (e.g., --get Stadium stadium_nba_td_garden)') + p.add_argument('--list', metavar='TYPE', help='List all recordNames for a type (e.g., --list Stadium)') + p.add_argument('--count', action='store_true', help='With --list, show only the count') + p.add_argument('--update-record', nargs='+', metavar='ARG', help='Update a record: TYPE ID FIELD=VALUE [FIELD=VALUE ...] (e.g., --update-record Stadium id123 capacity=19156)') + p.add_argument('--delete-record', nargs=2, metavar=('TYPE', 'ID'), help='Delete a single record (e.g., --delete-record Game game_mlb_2025_xxx)') + p.add_argument('--force', action='store_true', help='Skip confirmation for --delete-record') p.add_argument('--dry-run', action='store_true') p.add_argument('--verbose', '-v', action='store_true') p.add_argument('--interactive', '-i', action='store_true', help='Show interactive menu') @@ -1346,7 +1665,7 @@ def main(): args.stadiums_only, args.games_only, args.games_files, args.league_structure_only, args.team_aliases_only, args.stadium_aliases_only, args.canonical_only, args.delete_all, args.delete_only, args.dry_run, args.diff, args.smart_sync, - args.verify, args.verify_deep + args.verify, args.verify_deep, args.get, args.list, args.update_record, args.delete_record ]) # Track selected game files (for option 4 or --games-files) @@ -1491,6 +1810,67 @@ def main(): verify_sync(ck, args.data_dir, verbose=args.verbose, deep=args.verify_deep) return + # Handle individual record operations + if args.get: + record_type, record_id = args.get + if not ck: + if not HAS_CRYPTO: + sys.exit("Error: pip install cryptography") + if not os.path.exists(args.key_file): + sys.exit(f"Error: Key file not found: {args.key_file}") + ck = CloudKit(args.key_id, open(args.key_file, 'rb').read(), args.container, args.env) + get_record(ck, record_type, record_id, verbose=args.verbose) + return + + if args.list: + if not ck: + if not HAS_CRYPTO: + sys.exit("Error: pip install cryptography") + if not os.path.exists(args.key_file): + sys.exit(f"Error: Key file not found: {args.key_file}") + ck = CloudKit(args.key_id, open(args.key_file, 'rb').read(), args.container, args.env) + list_records(ck, args.list, count_only=args.count, verbose=args.verbose) + return + + if args.update_record: + if len(args.update_record) < 3: + sys.exit("Error: --update-record requires TYPE ID FIELD=VALUE [FIELD=VALUE ...]") + record_type = args.update_record[0] + record_id = args.update_record[1] + field_updates = {} + for update in args.update_record[2:]: + if '=' not in update: + sys.exit(f"Error: Invalid field update '{update}'. Format: FIELD=VALUE") + field, value = update.split('=', 1) + # Try to parse as number + try: + value = int(value) + except ValueError: + try: + value = float(value) + except ValueError: + pass # Keep as string + field_updates[field] = value + if not ck: + if not HAS_CRYPTO: + sys.exit("Error: pip install cryptography") + if not os.path.exists(args.key_file): + sys.exit(f"Error: Key file not found: {args.key_file}") + ck = CloudKit(args.key_id, open(args.key_file, 'rb').read(), args.container, args.env) + update_record(ck, record_type, record_id, field_updates, verbose=args.verbose) + return + + if args.delete_record: + record_type, record_id = args.delete_record + if not ck: + if not HAS_CRYPTO: + sys.exit("Error: pip install cryptography") + if not os.path.exists(args.key_file): + sys.exit(f"Error: Key file not found: {args.key_file}") + ck = CloudKit(args.key_id, open(args.key_file, 'rb').read(), args.container, args.env) + delete_record(ck, record_type, record_id, force=args.force, verbose=args.verbose) + return + # Handle smart sync mode (differential upload) if args.smart_sync: if not ck: