strapi-kit 0.0.1__py3-none-any.whl
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.
- strapi_kit/__init__.py +97 -0
- strapi_kit/__version__.py +15 -0
- strapi_kit/_version.py +34 -0
- strapi_kit/auth/__init__.py +7 -0
- strapi_kit/auth/api_token.py +48 -0
- strapi_kit/cache/__init__.py +5 -0
- strapi_kit/cache/schema_cache.py +211 -0
- strapi_kit/client/__init__.py +11 -0
- strapi_kit/client/async_client.py +1032 -0
- strapi_kit/client/base.py +460 -0
- strapi_kit/client/sync_client.py +980 -0
- strapi_kit/config_provider.py +368 -0
- strapi_kit/exceptions/__init__.py +37 -0
- strapi_kit/exceptions/errors.py +205 -0
- strapi_kit/export/__init__.py +10 -0
- strapi_kit/export/exporter.py +384 -0
- strapi_kit/export/importer.py +619 -0
- strapi_kit/export/media_handler.py +322 -0
- strapi_kit/export/relation_resolver.py +172 -0
- strapi_kit/models/__init__.py +104 -0
- strapi_kit/models/bulk.py +69 -0
- strapi_kit/models/config.py +174 -0
- strapi_kit/models/enums.py +97 -0
- strapi_kit/models/export_format.py +166 -0
- strapi_kit/models/import_options.py +142 -0
- strapi_kit/models/request/__init__.py +1 -0
- strapi_kit/models/request/fields.py +65 -0
- strapi_kit/models/request/filters.py +611 -0
- strapi_kit/models/request/pagination.py +168 -0
- strapi_kit/models/request/populate.py +281 -0
- strapi_kit/models/request/query.py +429 -0
- strapi_kit/models/request/sort.py +147 -0
- strapi_kit/models/response/__init__.py +1 -0
- strapi_kit/models/response/base.py +75 -0
- strapi_kit/models/response/component.py +67 -0
- strapi_kit/models/response/media.py +91 -0
- strapi_kit/models/response/meta.py +44 -0
- strapi_kit/models/response/normalized.py +168 -0
- strapi_kit/models/response/relation.py +48 -0
- strapi_kit/models/response/v4.py +70 -0
- strapi_kit/models/response/v5.py +57 -0
- strapi_kit/models/schema.py +93 -0
- strapi_kit/operations/__init__.py +16 -0
- strapi_kit/operations/media.py +226 -0
- strapi_kit/operations/streaming.py +144 -0
- strapi_kit/parsers/__init__.py +5 -0
- strapi_kit/parsers/version_detecting.py +171 -0
- strapi_kit/protocols.py +455 -0
- strapi_kit/utils/__init__.py +15 -0
- strapi_kit/utils/rate_limiter.py +201 -0
- strapi_kit/utils/uid.py +88 -0
- strapi_kit-0.0.1.dist-info/METADATA +1098 -0
- strapi_kit-0.0.1.dist-info/RECORD +55 -0
- strapi_kit-0.0.1.dist-info/WHEEL +4 -0
- strapi_kit-0.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
"""Main import orchestration for Strapi data.
|
|
2
|
+
|
|
3
|
+
This module coordinates the import of content types, entities,
|
|
4
|
+
and media files into a Strapi instance.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from strapi_kit.cache.schema_cache import InMemorySchemaCache
|
|
12
|
+
from strapi_kit.exceptions import (
|
|
13
|
+
ImportExportError,
|
|
14
|
+
NotFoundError,
|
|
15
|
+
StrapiError,
|
|
16
|
+
ValidationError,
|
|
17
|
+
)
|
|
18
|
+
from strapi_kit.export.media_handler import MediaHandler
|
|
19
|
+
from strapi_kit.export.relation_resolver import RelationResolver
|
|
20
|
+
from strapi_kit.models.export_format import ExportData
|
|
21
|
+
from strapi_kit.models.import_options import ConflictResolution, ImportOptions, ImportResult
|
|
22
|
+
from strapi_kit.models.schema import ContentTypeSchema
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from strapi_kit.client.sync_client import SyncClient
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class StrapiImporter:
|
|
31
|
+
"""Import Strapi content and media from exported format.
|
|
32
|
+
|
|
33
|
+
This class handles the complete import process including:
|
|
34
|
+
- Validation of export data
|
|
35
|
+
- Relation resolution
|
|
36
|
+
- Media file upload
|
|
37
|
+
- Entity creation with proper ordering
|
|
38
|
+
- Progress tracking
|
|
39
|
+
|
|
40
|
+
Example:
|
|
41
|
+
>>> from strapi_kit import SyncClient
|
|
42
|
+
>>> from strapi_kit.export import StrapiImporter, StrapiExporter
|
|
43
|
+
>>>
|
|
44
|
+
>>> # Load export data
|
|
45
|
+
>>> export_data = StrapiExporter.load_from_file("export.json")
|
|
46
|
+
>>>
|
|
47
|
+
>>> # Import to new instance
|
|
48
|
+
>>> with SyncClient(target_config) as client:
|
|
49
|
+
... importer = StrapiImporter(client)
|
|
50
|
+
... result = importer.import_data(export_data)
|
|
51
|
+
... print(f"Imported {result.entities_imported} entities")
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, client: "SyncClient"):
|
|
55
|
+
"""Initialize importer with Strapi client.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
client: Synchronous Strapi client
|
|
59
|
+
"""
|
|
60
|
+
self.client = client
|
|
61
|
+
self._schema_cache = InMemorySchemaCache(client)
|
|
62
|
+
|
|
63
|
+
def import_data(
|
|
64
|
+
self,
|
|
65
|
+
export_data: ExportData,
|
|
66
|
+
options: ImportOptions | None = None,
|
|
67
|
+
media_dir: Path | str | None = None,
|
|
68
|
+
) -> ImportResult:
|
|
69
|
+
"""Import export data into Strapi instance.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
export_data: Export data to import
|
|
73
|
+
options: Import options (uses defaults if None)
|
|
74
|
+
media_dir: Directory containing media files from export
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
ImportResult with statistics and any errors
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ImportExportError: If import fails critically
|
|
81
|
+
|
|
82
|
+
Example:
|
|
83
|
+
>>> options = ImportOptions(
|
|
84
|
+
... dry_run=True,
|
|
85
|
+
... conflict_resolution=ConflictResolution.SKIP
|
|
86
|
+
... )
|
|
87
|
+
>>> result = importer.import_data(
|
|
88
|
+
... export_data,
|
|
89
|
+
... options,
|
|
90
|
+
... media_dir="export/media"
|
|
91
|
+
... )
|
|
92
|
+
>>> if result.success:
|
|
93
|
+
... print("Import successful!")
|
|
94
|
+
"""
|
|
95
|
+
if options is None:
|
|
96
|
+
options = ImportOptions()
|
|
97
|
+
|
|
98
|
+
result = ImportResult(success=False, dry_run=options.dry_run)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
# Step 1: Validate export data
|
|
102
|
+
if options.progress_callback:
|
|
103
|
+
options.progress_callback(0, 100, "Validating export data")
|
|
104
|
+
|
|
105
|
+
self._validate_export_data(export_data, result)
|
|
106
|
+
|
|
107
|
+
if result.errors and not options.dry_run:
|
|
108
|
+
result.success = False
|
|
109
|
+
return result
|
|
110
|
+
|
|
111
|
+
# Step 1.5: Load schemas from export metadata
|
|
112
|
+
self._load_schemas_from_export(export_data)
|
|
113
|
+
|
|
114
|
+
# Step 2: Filter content types if specified
|
|
115
|
+
content_types_to_import = self._get_content_types_to_import(export_data, options)
|
|
116
|
+
|
|
117
|
+
if not content_types_to_import:
|
|
118
|
+
result.add_warning("No content types to import")
|
|
119
|
+
result.success = True
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
# Step 3: Import media first (if requested)
|
|
123
|
+
media_id_mapping: dict[int, int] = {}
|
|
124
|
+
if options.import_media and export_data.media:
|
|
125
|
+
if options.progress_callback:
|
|
126
|
+
options.progress_callback(20, 100, "Importing media files")
|
|
127
|
+
|
|
128
|
+
media_id_mapping = self._import_media(export_data, media_dir, options, result)
|
|
129
|
+
|
|
130
|
+
# Step 4: Import entities (with updated media references)
|
|
131
|
+
if options.progress_callback:
|
|
132
|
+
options.progress_callback(40, 100, "Importing entities")
|
|
133
|
+
|
|
134
|
+
self._import_entities(
|
|
135
|
+
export_data,
|
|
136
|
+
content_types_to_import,
|
|
137
|
+
media_id_mapping,
|
|
138
|
+
options,
|
|
139
|
+
result,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
# Step 5: Import relations (if not skipped)
|
|
143
|
+
if not options.skip_relations:
|
|
144
|
+
if options.progress_callback:
|
|
145
|
+
options.progress_callback(60, 100, "Importing relations")
|
|
146
|
+
|
|
147
|
+
self._import_relations(
|
|
148
|
+
export_data,
|
|
149
|
+
content_types_to_import,
|
|
150
|
+
options,
|
|
151
|
+
result,
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if options.progress_callback:
|
|
155
|
+
options.progress_callback(100, 100, "Import complete")
|
|
156
|
+
|
|
157
|
+
result.success = result.entities_failed == 0
|
|
158
|
+
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
except Exception as e:
|
|
162
|
+
result.add_error(f"Import failed: {e}")
|
|
163
|
+
raise ImportExportError(f"Import failed: {e}") from e
|
|
164
|
+
|
|
165
|
+
def _validate_export_data(self, export_data: ExportData, result: ImportResult) -> None:
|
|
166
|
+
"""Validate export data format and compatibility.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
export_data: Export data to validate
|
|
170
|
+
result: Result object to add errors/warnings to
|
|
171
|
+
"""
|
|
172
|
+
# Check format version
|
|
173
|
+
if not export_data.metadata.version.startswith("1."):
|
|
174
|
+
result.add_warning(
|
|
175
|
+
f"Export format version {export_data.metadata.version} may not be fully compatible"
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# Check Strapi version compatibility
|
|
179
|
+
target_version = self.client.api_version
|
|
180
|
+
source_version = export_data.metadata.strapi_version
|
|
181
|
+
|
|
182
|
+
if target_version and source_version != target_version:
|
|
183
|
+
result.add_warning(
|
|
184
|
+
f"Source version ({source_version}) differs from target ({target_version}). "
|
|
185
|
+
"Some data may require transformation."
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Check if we have any data
|
|
189
|
+
if export_data.get_entity_count() == 0:
|
|
190
|
+
result.add_warning("No entities to import")
|
|
191
|
+
|
|
192
|
+
def _get_content_types_to_import(
|
|
193
|
+
self, export_data: ExportData, options: ImportOptions
|
|
194
|
+
) -> list[str]:
|
|
195
|
+
"""Determine which content types to import based on options.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
export_data: Export data
|
|
199
|
+
options: Import options
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
List of content type UIDs to import
|
|
203
|
+
"""
|
|
204
|
+
available = list(export_data.entities.keys())
|
|
205
|
+
|
|
206
|
+
if options.content_types:
|
|
207
|
+
# Only import specified content types
|
|
208
|
+
return [ct for ct in options.content_types if ct in available]
|
|
209
|
+
|
|
210
|
+
return available
|
|
211
|
+
|
|
212
|
+
def _import_entities(
|
|
213
|
+
self,
|
|
214
|
+
export_data: ExportData,
|
|
215
|
+
content_types: list[str],
|
|
216
|
+
media_id_mapping: dict[int, int],
|
|
217
|
+
options: ImportOptions,
|
|
218
|
+
result: ImportResult,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Import entities for specified content types.
|
|
221
|
+
|
|
222
|
+
Handles conflict resolution based on options:
|
|
223
|
+
- SKIP: Skip entities that already exist
|
|
224
|
+
- UPDATE: Update existing entities with imported data
|
|
225
|
+
- FAIL: Fail import if conflicts are detected
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
export_data: Export data
|
|
229
|
+
content_types: Content types to import
|
|
230
|
+
media_id_mapping: Mapping of old media IDs to new IDs
|
|
231
|
+
options: Import options
|
|
232
|
+
result: Result object to update
|
|
233
|
+
"""
|
|
234
|
+
for content_type in content_types:
|
|
235
|
+
entities = export_data.entities.get(content_type, [])
|
|
236
|
+
|
|
237
|
+
# Get endpoint from schema (prefers plural_name) or fallback to UID
|
|
238
|
+
endpoint = self._get_endpoint(content_type)
|
|
239
|
+
|
|
240
|
+
for entity in entities:
|
|
241
|
+
try:
|
|
242
|
+
# Update media references if we have mappings
|
|
243
|
+
entity_data = entity.data
|
|
244
|
+
if media_id_mapping:
|
|
245
|
+
entity_data = MediaHandler.update_media_references(
|
|
246
|
+
entity.data, media_id_mapping
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if options.dry_run:
|
|
250
|
+
# Just validate, don't actually create
|
|
251
|
+
result.entities_imported += 1
|
|
252
|
+
continue
|
|
253
|
+
|
|
254
|
+
# Check for existing entity if document_id is available (for conflict handling)
|
|
255
|
+
existing_id: int | None = None
|
|
256
|
+
if entity.document_id:
|
|
257
|
+
existing_id = self._check_entity_exists(endpoint, entity.document_id)
|
|
258
|
+
|
|
259
|
+
if existing_id is not None:
|
|
260
|
+
# Entity already exists - handle according to conflict resolution
|
|
261
|
+
if options.conflict_resolution == ConflictResolution.SKIP:
|
|
262
|
+
result.entities_skipped += 1
|
|
263
|
+
# Still track the ID mapping for relations
|
|
264
|
+
if content_type not in result.id_mapping:
|
|
265
|
+
result.id_mapping[content_type] = {}
|
|
266
|
+
result.id_mapping[content_type][entity.id] = existing_id
|
|
267
|
+
continue
|
|
268
|
+
|
|
269
|
+
elif options.conflict_resolution == ConflictResolution.FAIL:
|
|
270
|
+
raise ImportExportError(
|
|
271
|
+
f"Entity already exists: {content_type} with documentId "
|
|
272
|
+
f"{entity.document_id}. Use conflict_resolution=SKIP or UPDATE."
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
elif options.conflict_resolution == ConflictResolution.UPDATE:
|
|
276
|
+
# Update existing entity
|
|
277
|
+
response = self.client.update(
|
|
278
|
+
f"{endpoint}/{entity.document_id}",
|
|
279
|
+
entity_data,
|
|
280
|
+
)
|
|
281
|
+
if response.data:
|
|
282
|
+
if content_type not in result.id_mapping:
|
|
283
|
+
result.id_mapping[content_type] = {}
|
|
284
|
+
result.id_mapping[content_type][entity.id] = response.data.id
|
|
285
|
+
result.entities_updated += 1
|
|
286
|
+
continue
|
|
287
|
+
|
|
288
|
+
# Create new entity
|
|
289
|
+
response = self.client.create(endpoint, entity_data)
|
|
290
|
+
|
|
291
|
+
if response.data:
|
|
292
|
+
# Track ID mapping for relation resolution
|
|
293
|
+
if content_type not in result.id_mapping:
|
|
294
|
+
result.id_mapping[content_type] = {}
|
|
295
|
+
|
|
296
|
+
result.id_mapping[content_type][entity.id] = response.data.id
|
|
297
|
+
result.entities_imported += 1
|
|
298
|
+
|
|
299
|
+
except ValidationError as e:
|
|
300
|
+
result.add_error(f"Validation error importing {content_type} #{entity.id}: {e}")
|
|
301
|
+
result.entities_failed += 1
|
|
302
|
+
|
|
303
|
+
except ImportExportError:
|
|
304
|
+
# Re-raise ImportExportError (e.g., from FAIL conflict resolution)
|
|
305
|
+
raise
|
|
306
|
+
|
|
307
|
+
except StrapiError as e:
|
|
308
|
+
# Catch Strapi-specific errors (API errors, network issues, etc.)
|
|
309
|
+
result.add_error(f"Failed to import {content_type} #{entity.id}: {e}")
|
|
310
|
+
result.entities_failed += 1
|
|
311
|
+
|
|
312
|
+
def _check_entity_exists(self, endpoint: str, document_id: str) -> int | None:
|
|
313
|
+
"""Check if an entity exists by document ID.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
endpoint: API endpoint
|
|
317
|
+
document_id: Document ID to check
|
|
318
|
+
|
|
319
|
+
Returns:
|
|
320
|
+
Entity's numeric ID if exists, None otherwise
|
|
321
|
+
"""
|
|
322
|
+
try:
|
|
323
|
+
response = self.client.get_one(f"{endpoint}/{document_id}")
|
|
324
|
+
if response.data:
|
|
325
|
+
return response.data.id
|
|
326
|
+
except NotFoundError:
|
|
327
|
+
pass
|
|
328
|
+
except Exception as e:
|
|
329
|
+
logger.warning(f"Error checking entity existence: {e}")
|
|
330
|
+
return None
|
|
331
|
+
|
|
332
|
+
def _import_relations(
|
|
333
|
+
self,
|
|
334
|
+
export_data: ExportData,
|
|
335
|
+
content_types: list[str],
|
|
336
|
+
options: ImportOptions,
|
|
337
|
+
result: ImportResult,
|
|
338
|
+
) -> None:
|
|
339
|
+
"""Import relations for entities.
|
|
340
|
+
|
|
341
|
+
This is done as a second pass after entities are created,
|
|
342
|
+
so that all entities exist before relations are added.
|
|
343
|
+
|
|
344
|
+
Args:
|
|
345
|
+
export_data: Export data
|
|
346
|
+
content_types: Content types to import relations for
|
|
347
|
+
options: Import options
|
|
348
|
+
result: Result object to update
|
|
349
|
+
"""
|
|
350
|
+
for content_type in content_types:
|
|
351
|
+
entities = export_data.entities.get(content_type, [])
|
|
352
|
+
endpoint = self._get_endpoint(content_type)
|
|
353
|
+
|
|
354
|
+
for entity in entities:
|
|
355
|
+
# Skip if no relations
|
|
356
|
+
if not entity.relations:
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
# Get the new ID from mapping
|
|
360
|
+
if content_type not in result.id_mapping:
|
|
361
|
+
continue
|
|
362
|
+
|
|
363
|
+
old_id = entity.id
|
|
364
|
+
if old_id not in result.id_mapping[content_type]:
|
|
365
|
+
logger.warning(
|
|
366
|
+
f"Cannot import relations for {content_type} #{old_id}: "
|
|
367
|
+
"entity not in ID mapping"
|
|
368
|
+
)
|
|
369
|
+
continue
|
|
370
|
+
|
|
371
|
+
new_id = result.id_mapping[content_type][old_id]
|
|
372
|
+
|
|
373
|
+
# Get schema for this content type
|
|
374
|
+
try:
|
|
375
|
+
schema = self._schema_cache.get_schema(content_type)
|
|
376
|
+
except Exception as e:
|
|
377
|
+
logger.warning(f"Could not load schema for {content_type}: {e}")
|
|
378
|
+
continue
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
if options.dry_run:
|
|
382
|
+
continue
|
|
383
|
+
|
|
384
|
+
# FIXED: Resolve relations using schema
|
|
385
|
+
resolved_relations = self._resolve_relations_with_schema(
|
|
386
|
+
entity.relations, schema, result.id_mapping
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
if not resolved_relations:
|
|
390
|
+
continue
|
|
391
|
+
|
|
392
|
+
# Build relation payload
|
|
393
|
+
relation_payload = RelationResolver.build_relation_payload(resolved_relations)
|
|
394
|
+
|
|
395
|
+
if relation_payload:
|
|
396
|
+
# Update entity with relations
|
|
397
|
+
# Note: update() already wraps data in {"data": ...}
|
|
398
|
+
self.client.update(
|
|
399
|
+
f"{endpoint}/{new_id}",
|
|
400
|
+
relation_payload,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
except Exception as e:
|
|
404
|
+
result.add_warning(
|
|
405
|
+
f"Failed to import relations for {content_type} #{new_id}: {e}"
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
def _import_media(
|
|
409
|
+
self,
|
|
410
|
+
export_data: ExportData,
|
|
411
|
+
media_dir: Path | str | None,
|
|
412
|
+
options: ImportOptions,
|
|
413
|
+
result: ImportResult,
|
|
414
|
+
) -> dict[int, int]:
|
|
415
|
+
"""Import media files from export.
|
|
416
|
+
|
|
417
|
+
Args:
|
|
418
|
+
export_data: Export data containing media metadata
|
|
419
|
+
media_dir: Directory containing downloaded media files
|
|
420
|
+
options: Import options
|
|
421
|
+
result: Result object to update
|
|
422
|
+
|
|
423
|
+
Returns:
|
|
424
|
+
Mapping of old media IDs to new media IDs
|
|
425
|
+
"""
|
|
426
|
+
media_id_mapping: dict[int, int] = {}
|
|
427
|
+
|
|
428
|
+
if not export_data.media:
|
|
429
|
+
return media_id_mapping
|
|
430
|
+
|
|
431
|
+
if media_dir is None:
|
|
432
|
+
logger.warning(
|
|
433
|
+
"Media directory not specified - skipping media import. "
|
|
434
|
+
"Media references in entities will not be updated."
|
|
435
|
+
)
|
|
436
|
+
return media_id_mapping
|
|
437
|
+
|
|
438
|
+
media_path = Path(media_dir)
|
|
439
|
+
if not media_path.exists():
|
|
440
|
+
result.add_error(f"Media directory not found: {media_dir}")
|
|
441
|
+
return media_id_mapping
|
|
442
|
+
|
|
443
|
+
for exported_media in export_data.media:
|
|
444
|
+
try:
|
|
445
|
+
if options.dry_run:
|
|
446
|
+
result.media_imported += 1
|
|
447
|
+
continue
|
|
448
|
+
|
|
449
|
+
# Find local file with path traversal protection
|
|
450
|
+
file_path = (media_path / exported_media.local_path).resolve()
|
|
451
|
+
|
|
452
|
+
# Security: Ensure resolved path stays within media_path
|
|
453
|
+
if not file_path.is_relative_to(media_path.resolve()):
|
|
454
|
+
result.add_error(
|
|
455
|
+
f"Security: Invalid media path {exported_media.local_path} - "
|
|
456
|
+
"path traversal detected"
|
|
457
|
+
)
|
|
458
|
+
result.media_skipped += 1
|
|
459
|
+
continue
|
|
460
|
+
|
|
461
|
+
if not file_path.exists():
|
|
462
|
+
result.add_warning(
|
|
463
|
+
f"Media file not found: {file_path.name} (ID: {exported_media.id})"
|
|
464
|
+
)
|
|
465
|
+
result.media_skipped += 1
|
|
466
|
+
continue
|
|
467
|
+
|
|
468
|
+
# Upload file
|
|
469
|
+
uploaded = MediaHandler.upload_media_file(self.client, file_path, exported_media)
|
|
470
|
+
|
|
471
|
+
# Track ID mapping
|
|
472
|
+
media_id_mapping[exported_media.id] = uploaded.id
|
|
473
|
+
result.media_imported += 1
|
|
474
|
+
|
|
475
|
+
except Exception as e:
|
|
476
|
+
result.add_warning(f"Failed to import media {exported_media.name}: {e}")
|
|
477
|
+
result.media_skipped += 1
|
|
478
|
+
|
|
479
|
+
logger.info(f"Imported {result.media_imported}/{len(export_data.media)} media files")
|
|
480
|
+
return media_id_mapping
|
|
481
|
+
|
|
482
|
+
def _load_schemas_from_export(self, export_data: ExportData) -> None:
|
|
483
|
+
"""Load schemas from export metadata into cache.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
export_data: Export data containing schemas
|
|
487
|
+
"""
|
|
488
|
+
# Load all schemas into cache
|
|
489
|
+
for content_type, schema in export_data.metadata.schemas.items():
|
|
490
|
+
self._schema_cache.cache_schema(content_type, schema)
|
|
491
|
+
|
|
492
|
+
logger.info(f"Loaded {self._schema_cache.cache_size} schemas from export")
|
|
493
|
+
|
|
494
|
+
def _resolve_relations_with_schema(
|
|
495
|
+
self,
|
|
496
|
+
relations: dict[str, list[int | str]],
|
|
497
|
+
schema: ContentTypeSchema,
|
|
498
|
+
id_mapping: dict[str, dict[int, int]],
|
|
499
|
+
) -> dict[str, list[int]]:
|
|
500
|
+
"""Resolve relation IDs using schema information.
|
|
501
|
+
|
|
502
|
+
Uses content type schemas to determine relation targets, enabling
|
|
503
|
+
proper ID mapping during import.
|
|
504
|
+
|
|
505
|
+
Args:
|
|
506
|
+
relations: Raw relations from export (field -> [old_ids])
|
|
507
|
+
schema: Schema for the content type
|
|
508
|
+
id_mapping: Full ID mapping (content_type -> {old_id: new_id})
|
|
509
|
+
|
|
510
|
+
Returns:
|
|
511
|
+
Resolved relations with new IDs
|
|
512
|
+
"""
|
|
513
|
+
resolved: dict[str, list[int]] = {}
|
|
514
|
+
|
|
515
|
+
for field_name, old_ids in relations.items():
|
|
516
|
+
# Get target content type from schema
|
|
517
|
+
target_content_type = schema.get_field_target(field_name)
|
|
518
|
+
|
|
519
|
+
if not target_content_type:
|
|
520
|
+
logger.warning(f"Field {field_name} is not a relation. Skipping.")
|
|
521
|
+
continue
|
|
522
|
+
|
|
523
|
+
# Get ID mapping for target content type
|
|
524
|
+
if target_content_type not in id_mapping:
|
|
525
|
+
logger.warning(
|
|
526
|
+
f"No ID mapping for {target_content_type}. "
|
|
527
|
+
f"Relations in {field_name} cannot be resolved."
|
|
528
|
+
)
|
|
529
|
+
continue
|
|
530
|
+
|
|
531
|
+
target_mapping = id_mapping[target_content_type]
|
|
532
|
+
|
|
533
|
+
# Resolve old IDs to new IDs
|
|
534
|
+
new_ids = []
|
|
535
|
+
for old_id in old_ids:
|
|
536
|
+
if isinstance(old_id, int) and old_id in target_mapping:
|
|
537
|
+
new_ids.append(target_mapping[old_id])
|
|
538
|
+
else:
|
|
539
|
+
logger.warning(
|
|
540
|
+
f"Could not resolve {target_content_type} ID {old_id} "
|
|
541
|
+
f"for field {field_name}"
|
|
542
|
+
)
|
|
543
|
+
|
|
544
|
+
# Preserve empty lists only when source relation was explicitly empty.
|
|
545
|
+
# If old_ids had values but none resolved, skip to avoid clearing relations.
|
|
546
|
+
if new_ids or len(old_ids) == 0:
|
|
547
|
+
resolved[field_name] = new_ids
|
|
548
|
+
|
|
549
|
+
return resolved
|
|
550
|
+
|
|
551
|
+
def _get_endpoint(self, uid: str) -> str:
|
|
552
|
+
"""Get API endpoint for a content type.
|
|
553
|
+
|
|
554
|
+
Prefers schema.plural_name when available to handle custom plural
|
|
555
|
+
names correctly (e.g., "person" -> "people"). Falls back to
|
|
556
|
+
hardcoded pluralization rules for basic cases.
|
|
557
|
+
|
|
558
|
+
Args:
|
|
559
|
+
uid: Content type UID (e.g., "api::article.article")
|
|
560
|
+
|
|
561
|
+
Returns:
|
|
562
|
+
API endpoint (e.g., "articles")
|
|
563
|
+
"""
|
|
564
|
+
# Try to get plural_name from cached schema
|
|
565
|
+
if self._schema_cache.has_schema(uid):
|
|
566
|
+
schema = self._schema_cache.get_schema(uid)
|
|
567
|
+
if schema.plural_name:
|
|
568
|
+
return schema.plural_name
|
|
569
|
+
|
|
570
|
+
# Fallback to hardcoded pluralization
|
|
571
|
+
return self._uid_to_endpoint_fallback(uid)
|
|
572
|
+
|
|
573
|
+
@staticmethod
|
|
574
|
+
def _uid_to_endpoint_fallback(uid: str) -> str:
|
|
575
|
+
"""Fallback pluralization for content type UID.
|
|
576
|
+
|
|
577
|
+
Handles common English pluralization patterns. Used when schema
|
|
578
|
+
metadata is not available.
|
|
579
|
+
|
|
580
|
+
Args:
|
|
581
|
+
uid: Content type UID (e.g., "api::article.article", "api::blog.post")
|
|
582
|
+
|
|
583
|
+
Returns:
|
|
584
|
+
API endpoint (e.g., "articles", "posts")
|
|
585
|
+
"""
|
|
586
|
+
# Extract the model name (after the dot) and pluralize it
|
|
587
|
+
# For "api::blog.post", we want "post" -> "posts", not "blog" -> "blogs"
|
|
588
|
+
parts = uid.split("::")
|
|
589
|
+
if len(parts) == 2:
|
|
590
|
+
api_model = parts[1]
|
|
591
|
+
# Get model name (after the dot if present)
|
|
592
|
+
if "." in api_model:
|
|
593
|
+
name = api_model.split(".")[1]
|
|
594
|
+
else:
|
|
595
|
+
name = api_model
|
|
596
|
+
# Handle common irregular plurals
|
|
597
|
+
if name.endswith("y") and not name.endswith(("ay", "ey", "oy", "uy")):
|
|
598
|
+
return name[:-1] + "ies" # category -> categories
|
|
599
|
+
if name.endswith(("s", "x", "z", "ch", "sh")):
|
|
600
|
+
return name + "es" # class -> classes
|
|
601
|
+
if not name.endswith("s"):
|
|
602
|
+
return name + "s"
|
|
603
|
+
return name
|
|
604
|
+
return uid
|
|
605
|
+
|
|
606
|
+
# Keep for backward compatibility
|
|
607
|
+
@staticmethod
|
|
608
|
+
def _uid_to_endpoint(uid: str) -> str:
|
|
609
|
+
"""Convert content type UID to API endpoint.
|
|
610
|
+
|
|
611
|
+
Deprecated: Use _get_endpoint() instead which uses schema metadata.
|
|
612
|
+
|
|
613
|
+
Args:
|
|
614
|
+
uid: Content type UID (e.g., "api::article.article")
|
|
615
|
+
|
|
616
|
+
Returns:
|
|
617
|
+
API endpoint (e.g., "articles")
|
|
618
|
+
"""
|
|
619
|
+
return StrapiImporter._uid_to_endpoint_fallback(uid)
|