strapi-kit 0.0.2__py3-none-any.whl → 0.0.4__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 +1 -1
- strapi_kit/_version.py +2 -2
- strapi_kit/client/async_client.py +87 -1
- strapi_kit/client/base.py +99 -3
- strapi_kit/client/sync_client.py +87 -1
- strapi_kit/config_provider.py +1 -16
- strapi_kit/exceptions/__init__.py +2 -0
- strapi_kit/exceptions/errors.py +13 -0
- strapi_kit/export/exporter.py +6 -5
- strapi_kit/models/__init__.py +8 -0
- strapi_kit/models/content_type.py +148 -0
- strapi_kit/models/export_format.py +5 -3
- strapi_kit/models/request/query.py +9 -6
- strapi_kit/operations/media.py +13 -6
- strapi_kit/operations/streaming.py +5 -4
- strapi_kit/utils/__init__.py +20 -1
- strapi_kit/utils/rate_limiter.py +4 -2
- strapi_kit/utils/seo.py +294 -0
- strapi_kit/utils/uid.py +129 -0
- {strapi_kit-0.0.2.dist-info → strapi_kit-0.0.4.dist-info}/METADATA +204 -10
- {strapi_kit-0.0.2.dist-info → strapi_kit-0.0.4.dist-info}/RECORD +23 -21
- {strapi_kit-0.0.2.dist-info → strapi_kit-0.0.4.dist-info}/WHEEL +0 -0
- {strapi_kit-0.0.2.dist-info → strapi_kit-0.0.4.dist-info}/licenses/LICENSE +0 -0
|
@@ -36,6 +36,7 @@ from __future__ import annotations
|
|
|
36
36
|
import copy
|
|
37
37
|
from typing import Any
|
|
38
38
|
|
|
39
|
+
from strapi_kit.exceptions import ValidationError
|
|
39
40
|
from strapi_kit.models.enums import PublicationState, SortDirection
|
|
40
41
|
from strapi_kit.models.request.fields import FieldSelection
|
|
41
42
|
from strapi_kit.models.request.filters import FilterBuilder
|
|
@@ -220,7 +221,9 @@ class StrapiQuery:
|
|
|
220
221
|
Self for method chaining
|
|
221
222
|
|
|
222
223
|
Raises:
|
|
223
|
-
|
|
224
|
+
strapi_kit.exceptions.ValidationError: If mixing page-based and
|
|
225
|
+
offset-based parameters, or if pagination values are invalid
|
|
226
|
+
(page < 1, page_size < 1, start < 0, limit < 1)
|
|
224
227
|
|
|
225
228
|
Examples:
|
|
226
229
|
>>> # Page-based
|
|
@@ -234,15 +237,15 @@ class StrapiQuery:
|
|
|
234
237
|
offset_based = start is not None or limit is not None
|
|
235
238
|
|
|
236
239
|
if page_based and offset_based:
|
|
237
|
-
raise
|
|
240
|
+
raise ValidationError("Cannot mix page-based and offset-based pagination")
|
|
238
241
|
|
|
239
242
|
if page_based:
|
|
240
243
|
# Validate page value if explicitly provided
|
|
241
244
|
if page is not None and page < 1:
|
|
242
|
-
raise
|
|
245
|
+
raise ValidationError("page must be >= 1")
|
|
243
246
|
# Validate page_size value if explicitly provided
|
|
244
247
|
if page_size is not None and page_size < 1:
|
|
245
|
-
raise
|
|
248
|
+
raise ValidationError("page_size must be >= 1")
|
|
246
249
|
self._pagination = PagePagination(
|
|
247
250
|
page=1 if page is None else page,
|
|
248
251
|
page_size=25 if page_size is None else page_size,
|
|
@@ -251,10 +254,10 @@ class StrapiQuery:
|
|
|
251
254
|
elif offset_based:
|
|
252
255
|
# Validate start value if explicitly provided
|
|
253
256
|
if start is not None and start < 0:
|
|
254
|
-
raise
|
|
257
|
+
raise ValidationError("start must be >= 0")
|
|
255
258
|
# Validate limit value if explicitly provided
|
|
256
259
|
if limit is not None and limit < 1:
|
|
257
|
-
raise
|
|
260
|
+
raise ValidationError("limit must be >= 1")
|
|
258
261
|
self._pagination = OffsetPagination(
|
|
259
262
|
start=0 if start is None else start,
|
|
260
263
|
limit=25 if limit is None else limit,
|
strapi_kit/operations/media.py
CHANGED
|
@@ -5,10 +5,12 @@ and response normalization across sync and async clients.
|
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
|
+
import mimetypes
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import IO, Any, Literal
|
|
10
11
|
from urllib.parse import urljoin, urlparse
|
|
11
12
|
|
|
13
|
+
from strapi_kit.exceptions import MediaError
|
|
12
14
|
from strapi_kit.models.response.media import MediaFile
|
|
13
15
|
|
|
14
16
|
|
|
@@ -43,19 +45,24 @@ class UploadPayload:
|
|
|
43
45
|
self._file_handle: IO[bytes] | None = None
|
|
44
46
|
|
|
45
47
|
@property
|
|
46
|
-
def files_tuple(self) -> tuple[str, IO[bytes],
|
|
48
|
+
def files_tuple(self) -> tuple[str, IO[bytes], str]:
|
|
47
49
|
"""Get the files tuple for httpx multipart upload.
|
|
48
50
|
|
|
49
51
|
Returns:
|
|
50
|
-
Tuple of (filename, file_handle,
|
|
51
|
-
|
|
52
|
+
Tuple of (filename, file_handle, mime_type) where:
|
|
53
|
+
- filename: The actual file name from the path
|
|
54
|
+
- file_handle: Open file handle for reading
|
|
55
|
+
- mime_type: Detected MIME type or 'application/octet-stream' as fallback
|
|
52
56
|
|
|
53
57
|
Raises:
|
|
54
|
-
|
|
58
|
+
MediaError: If accessed outside of context manager
|
|
55
59
|
"""
|
|
56
60
|
if self._file_handle is None:
|
|
57
|
-
raise
|
|
58
|
-
|
|
61
|
+
raise MediaError("UploadPayload must be used as a context manager")
|
|
62
|
+
filename = self._file_path.name
|
|
63
|
+
mime_type, _ = mimetypes.guess_type(filename)
|
|
64
|
+
mime_type = mime_type or "application/octet-stream"
|
|
65
|
+
return (filename, self._file_handle, mime_type)
|
|
59
66
|
|
|
60
67
|
@property
|
|
61
68
|
def data(self) -> dict[str, Any] | None:
|
|
@@ -7,6 +7,7 @@ allowing memory-efficient iteration over large datasets.
|
|
|
7
7
|
from collections.abc import AsyncGenerator, Generator
|
|
8
8
|
from typing import TYPE_CHECKING
|
|
9
9
|
|
|
10
|
+
from ..exceptions import ValidationError
|
|
10
11
|
from ..models import StrapiQuery
|
|
11
12
|
from ..models.response.normalized import NormalizedEntity
|
|
12
13
|
|
|
@@ -36,7 +37,7 @@ def stream_entities(
|
|
|
36
37
|
NormalizedEntity objects one at a time
|
|
37
38
|
|
|
38
39
|
Raises:
|
|
39
|
-
|
|
40
|
+
ValidationError: If page_size < 1
|
|
40
41
|
|
|
41
42
|
Example:
|
|
42
43
|
>>> with SyncClient(config) as client:
|
|
@@ -45,7 +46,7 @@ def stream_entities(
|
|
|
45
46
|
... # Process one at a time without loading all into memory
|
|
46
47
|
"""
|
|
47
48
|
if page_size < 1:
|
|
48
|
-
raise
|
|
49
|
+
raise ValidationError("page_size must be >= 1")
|
|
49
50
|
|
|
50
51
|
current_page = 1
|
|
51
52
|
|
|
@@ -100,7 +101,7 @@ async def stream_entities_async(
|
|
|
100
101
|
NormalizedEntity objects one at a time
|
|
101
102
|
|
|
102
103
|
Raises:
|
|
103
|
-
|
|
104
|
+
ValidationError: If page_size < 1
|
|
104
105
|
|
|
105
106
|
Example:
|
|
106
107
|
>>> async with AsyncClient(config) as client:
|
|
@@ -109,7 +110,7 @@ async def stream_entities_async(
|
|
|
109
110
|
... # Process asynchronously without loading all into memory
|
|
110
111
|
"""
|
|
111
112
|
if page_size < 1:
|
|
112
|
-
raise
|
|
113
|
+
raise ValidationError("page_size must be >= 1")
|
|
113
114
|
|
|
114
115
|
current_page = 1
|
|
115
116
|
|
strapi_kit/utils/__init__.py
CHANGED
|
@@ -3,13 +3,32 @@
|
|
|
3
3
|
This package contains helper utilities including:
|
|
4
4
|
- Rate limiting
|
|
5
5
|
- UID handling
|
|
6
|
+
- SEO detection
|
|
6
7
|
"""
|
|
7
8
|
|
|
8
9
|
from strapi_kit.utils.rate_limiter import AsyncTokenBucketRateLimiter, TokenBucketRateLimiter
|
|
9
|
-
from strapi_kit.utils.
|
|
10
|
+
from strapi_kit.utils.seo import SEOConfiguration, detect_seo_configuration
|
|
11
|
+
from strapi_kit.utils.uid import (
|
|
12
|
+
api_id_to_singular,
|
|
13
|
+
extract_model_name,
|
|
14
|
+
is_api_content_type,
|
|
15
|
+
uid_to_admin_url,
|
|
16
|
+
uid_to_api_id,
|
|
17
|
+
uid_to_endpoint,
|
|
18
|
+
)
|
|
10
19
|
|
|
11
20
|
__all__ = [
|
|
21
|
+
# Rate limiting
|
|
12
22
|
"TokenBucketRateLimiter",
|
|
13
23
|
"AsyncTokenBucketRateLimiter",
|
|
24
|
+
# UID utilities
|
|
14
25
|
"uid_to_endpoint",
|
|
26
|
+
"uid_to_api_id",
|
|
27
|
+
"api_id_to_singular",
|
|
28
|
+
"uid_to_admin_url",
|
|
29
|
+
"extract_model_name",
|
|
30
|
+
"is_api_content_type",
|
|
31
|
+
# SEO utilities
|
|
32
|
+
"detect_seo_configuration",
|
|
33
|
+
"SEOConfiguration",
|
|
15
34
|
]
|
strapi_kit/utils/rate_limiter.py
CHANGED
|
@@ -9,6 +9,8 @@ import logging
|
|
|
9
9
|
import threading
|
|
10
10
|
import time
|
|
11
11
|
|
|
12
|
+
from strapi_kit.exceptions import ValidationError
|
|
13
|
+
|
|
12
14
|
logger = logging.getLogger(__name__)
|
|
13
15
|
|
|
14
16
|
|
|
@@ -37,7 +39,7 @@ class TokenBucketRateLimiter:
|
|
|
37
39
|
capacity: Bucket capacity (defaults to rate for 1 second burst)
|
|
38
40
|
"""
|
|
39
41
|
if rate <= 0:
|
|
40
|
-
raise
|
|
42
|
+
raise ValidationError("Rate must be positive")
|
|
41
43
|
|
|
42
44
|
self._rate = rate
|
|
43
45
|
self._capacity = capacity if capacity is not None else rate
|
|
@@ -121,7 +123,7 @@ class AsyncTokenBucketRateLimiter:
|
|
|
121
123
|
capacity: Bucket capacity (defaults to rate for 1 second burst)
|
|
122
124
|
"""
|
|
123
125
|
if rate <= 0:
|
|
124
|
-
raise
|
|
126
|
+
raise ValidationError("Rate must be positive")
|
|
125
127
|
|
|
126
128
|
self._rate = rate
|
|
127
129
|
self._capacity = capacity if capacity is not None else rate
|
strapi_kit/utils/seo.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""SEO configuration detection utilities.
|
|
2
|
+
|
|
3
|
+
This module provides functions for detecting SEO configurations
|
|
4
|
+
in Strapi content type schemas.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any, Literal
|
|
9
|
+
|
|
10
|
+
from ..models.schema import ContentTypeSchema
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SEOConfiguration:
|
|
15
|
+
"""SEO configuration detected from a content type schema.
|
|
16
|
+
|
|
17
|
+
Attributes:
|
|
18
|
+
has_seo: Whether SEO configuration was detected
|
|
19
|
+
seo_type: Type of SEO setup - "component" (shared SEO component),
|
|
20
|
+
"flat" (individual fields), or None if not detected
|
|
21
|
+
seo_field_name: Name of the SEO field/component (e.g., "seo", "meta")
|
|
22
|
+
seo_component_uid: Component UID if seo_type is "component"
|
|
23
|
+
fields: Mapping of SEO purpose to field path
|
|
24
|
+
(e.g., {"title": "seo.metaTitle", "description": "seo.metaDescription"})
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
has_seo: bool = False
|
|
28
|
+
seo_type: Literal["component", "flat"] | None = None
|
|
29
|
+
seo_field_name: str | None = None
|
|
30
|
+
seo_component_uid: str | None = None
|
|
31
|
+
fields: dict[str, str] = field(default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Common SEO field name patterns (case-insensitive)
|
|
35
|
+
_SEO_COMPONENT_NAMES = {"seo", "meta", "metadata", "metatags", "seometa"}
|
|
36
|
+
|
|
37
|
+
# Common SEO field patterns for component detection
|
|
38
|
+
_SEO_COMPONENT_UIDS = {
|
|
39
|
+
"shared.seo",
|
|
40
|
+
"seo.seo",
|
|
41
|
+
"shared.meta",
|
|
42
|
+
"shared.metadata",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
# Flat SEO field patterns (field_name -> purpose)
|
|
46
|
+
_FLAT_SEO_FIELD_PATTERNS: dict[str, str] = {
|
|
47
|
+
"metatitle": "title",
|
|
48
|
+
"meta_title": "title",
|
|
49
|
+
"seotitle": "title",
|
|
50
|
+
"seo_title": "title",
|
|
51
|
+
"ogtitle": "og_title",
|
|
52
|
+
"og_title": "og_title",
|
|
53
|
+
"metadescription": "description",
|
|
54
|
+
"meta_description": "description",
|
|
55
|
+
"seodescription": "description",
|
|
56
|
+
"seo_description": "description",
|
|
57
|
+
"ogdescription": "og_description",
|
|
58
|
+
"og_description": "og_description",
|
|
59
|
+
"metakeywords": "keywords",
|
|
60
|
+
"meta_keywords": "keywords",
|
|
61
|
+
"seokeywords": "keywords",
|
|
62
|
+
"seo_keywords": "keywords",
|
|
63
|
+
"metaimage": "image",
|
|
64
|
+
"meta_image": "image",
|
|
65
|
+
"seoimage": "image",
|
|
66
|
+
"seo_image": "image",
|
|
67
|
+
"ogimage": "og_image",
|
|
68
|
+
"og_image": "og_image",
|
|
69
|
+
"canonicalurl": "canonical_url",
|
|
70
|
+
"canonical_url": "canonical_url",
|
|
71
|
+
"canonical": "canonical_url",
|
|
72
|
+
"noindex": "no_index",
|
|
73
|
+
"no_index": "no_index",
|
|
74
|
+
"nofollow": "no_follow",
|
|
75
|
+
"no_follow": "no_follow",
|
|
76
|
+
"robots": "robots",
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def detect_seo_configuration(
|
|
81
|
+
schema: ContentTypeSchema | dict[str, Any],
|
|
82
|
+
) -> SEOConfiguration:
|
|
83
|
+
"""Detect SEO configuration in a content type schema.
|
|
84
|
+
|
|
85
|
+
Analyzes the schema to detect:
|
|
86
|
+
1. SEO components (shared.seo, seo.seo, etc.)
|
|
87
|
+
2. Flat SEO fields (metaTitle, meta_description, etc.)
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
schema: Content type schema (ContentTypeSchema or raw dict)
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
SEOConfiguration with detection results
|
|
94
|
+
|
|
95
|
+
Examples:
|
|
96
|
+
>>> # Schema with SEO component
|
|
97
|
+
>>> schema = ContentTypeSchema(
|
|
98
|
+
... uid="api::article.article",
|
|
99
|
+
... display_name="Article",
|
|
100
|
+
... fields={
|
|
101
|
+
... "seo": FieldSchema(type=FieldType.COMPONENT),
|
|
102
|
+
... }
|
|
103
|
+
... )
|
|
104
|
+
>>> config = detect_seo_configuration(schema)
|
|
105
|
+
>>> config.has_seo
|
|
106
|
+
True
|
|
107
|
+
>>> config.seo_type
|
|
108
|
+
'component'
|
|
109
|
+
|
|
110
|
+
>>> # Schema with flat SEO fields
|
|
111
|
+
>>> schema_dict = {
|
|
112
|
+
... "uid": "api::page.page",
|
|
113
|
+
... "attributes": {
|
|
114
|
+
... "metaTitle": {"type": "string"},
|
|
115
|
+
... "metaDescription": {"type": "text"},
|
|
116
|
+
... }
|
|
117
|
+
... }
|
|
118
|
+
>>> config = detect_seo_configuration(schema_dict)
|
|
119
|
+
>>> config.has_seo
|
|
120
|
+
True
|
|
121
|
+
>>> config.seo_type
|
|
122
|
+
'flat'
|
|
123
|
+
"""
|
|
124
|
+
config = SEOConfiguration()
|
|
125
|
+
|
|
126
|
+
# Extract attributes from schema
|
|
127
|
+
if isinstance(schema, ContentTypeSchema):
|
|
128
|
+
attributes = {name: _field_to_dict(field) for name, field in schema.fields.items()}
|
|
129
|
+
elif isinstance(schema, dict):
|
|
130
|
+
# Handle both {"fields": ...} and {"attributes": ...} formats
|
|
131
|
+
attributes = schema.get("attributes") or schema.get("fields") or {}
|
|
132
|
+
else:
|
|
133
|
+
return config
|
|
134
|
+
|
|
135
|
+
# First, try to detect SEO component
|
|
136
|
+
component_result = _detect_seo_component(attributes)
|
|
137
|
+
if component_result:
|
|
138
|
+
config.has_seo = True
|
|
139
|
+
config.seo_type = "component"
|
|
140
|
+
config.seo_field_name = component_result["field_name"]
|
|
141
|
+
config.seo_component_uid = component_result.get("component_uid")
|
|
142
|
+
config.fields = component_result.get("fields", {})
|
|
143
|
+
return config
|
|
144
|
+
|
|
145
|
+
# If no component, look for flat SEO fields
|
|
146
|
+
flat_result = _detect_flat_seo_fields(attributes)
|
|
147
|
+
if flat_result:
|
|
148
|
+
config.has_seo = True
|
|
149
|
+
config.seo_type = "flat"
|
|
150
|
+
config.fields = flat_result
|
|
151
|
+
return config
|
|
152
|
+
|
|
153
|
+
return config
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _field_to_dict(field_schema: Any) -> dict[str, Any]:
|
|
157
|
+
"""Convert a FieldSchema to dict for processing.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
field_schema: FieldSchema instance
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Dictionary representation
|
|
164
|
+
"""
|
|
165
|
+
if hasattr(field_schema, "type"):
|
|
166
|
+
result: dict[str, Any] = {"type": field_schema.type.value}
|
|
167
|
+
if hasattr(field_schema, "target") and field_schema.target:
|
|
168
|
+
result["component"] = field_schema.target
|
|
169
|
+
return result
|
|
170
|
+
return {}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _detect_seo_component(attributes: dict[str, Any]) -> dict[str, Any] | None:
|
|
174
|
+
"""Detect SEO component in attributes.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
attributes: Field attributes dictionary
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Detection result or None if not found
|
|
181
|
+
"""
|
|
182
|
+
for field_name, field_config in attributes.items():
|
|
183
|
+
# Check if field type is component
|
|
184
|
+
field_type = _get_field_type(field_config)
|
|
185
|
+
if field_type != "component":
|
|
186
|
+
continue
|
|
187
|
+
|
|
188
|
+
# Check if field name suggests SEO
|
|
189
|
+
field_name_lower = field_name.lower()
|
|
190
|
+
is_seo_name = field_name_lower in _SEO_COMPONENT_NAMES
|
|
191
|
+
|
|
192
|
+
# Check if component UID suggests SEO
|
|
193
|
+
component_uid = _get_component_uid(field_config)
|
|
194
|
+
is_seo_component = False
|
|
195
|
+
if component_uid:
|
|
196
|
+
component_uid_lower = component_uid.lower()
|
|
197
|
+
is_seo_component = (
|
|
198
|
+
any(seo_uid in component_uid_lower for seo_uid in _SEO_COMPONENT_UIDS)
|
|
199
|
+
or "seo" in component_uid_lower
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
if is_seo_name or is_seo_component:
|
|
203
|
+
# Build field mappings based on common SEO component structure
|
|
204
|
+
fields = _build_component_field_mappings(field_name)
|
|
205
|
+
return {
|
|
206
|
+
"field_name": field_name,
|
|
207
|
+
"component_uid": component_uid,
|
|
208
|
+
"fields": fields,
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
return None
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _detect_flat_seo_fields(attributes: dict[str, Any]) -> dict[str, str] | None:
|
|
215
|
+
"""Detect flat SEO fields in attributes.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
attributes: Field attributes dictionary
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Field mappings or None if not found
|
|
222
|
+
"""
|
|
223
|
+
fields: dict[str, str] = {}
|
|
224
|
+
|
|
225
|
+
for field_name, _field_config in attributes.items():
|
|
226
|
+
field_name_lower = field_name.lower().replace("-", "_")
|
|
227
|
+
|
|
228
|
+
# Check if field name matches known SEO patterns
|
|
229
|
+
if field_name_lower in _FLAT_SEO_FIELD_PATTERNS:
|
|
230
|
+
purpose = _FLAT_SEO_FIELD_PATTERNS[field_name_lower]
|
|
231
|
+
fields[purpose] = field_name
|
|
232
|
+
|
|
233
|
+
return fields if fields else None
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _get_field_type(field_config: Any) -> str | None:
|
|
237
|
+
"""Extract field type from field configuration.
|
|
238
|
+
|
|
239
|
+
Args:
|
|
240
|
+
field_config: Field configuration (dict or object)
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Field type string or None
|
|
244
|
+
"""
|
|
245
|
+
if isinstance(field_config, dict):
|
|
246
|
+
return field_config.get("type")
|
|
247
|
+
if hasattr(field_config, "type"):
|
|
248
|
+
field_type = field_config.type
|
|
249
|
+
return field_type.value if hasattr(field_type, "value") else str(field_type)
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _get_component_uid(field_config: Any) -> str | None:
|
|
254
|
+
"""Extract component UID from field configuration.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
field_config: Field configuration (dict or object)
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Component UID string or None
|
|
261
|
+
"""
|
|
262
|
+
if isinstance(field_config, dict):
|
|
263
|
+
component = field_config.get("component")
|
|
264
|
+
return str(component) if component else None
|
|
265
|
+
if hasattr(field_config, "target"):
|
|
266
|
+
target = field_config.target
|
|
267
|
+
return str(target) if target else None
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _build_component_field_mappings(field_name: str) -> dict[str, str]:
|
|
272
|
+
"""Build standard field mappings for an SEO component.
|
|
273
|
+
|
|
274
|
+
Args:
|
|
275
|
+
field_name: Name of the SEO component field
|
|
276
|
+
|
|
277
|
+
Returns:
|
|
278
|
+
Field mappings with component prefix
|
|
279
|
+
"""
|
|
280
|
+
return {
|
|
281
|
+
"title": f"{field_name}.metaTitle",
|
|
282
|
+
"description": f"{field_name}.metaDescription",
|
|
283
|
+
"keywords": f"{field_name}.keywords",
|
|
284
|
+
"image": f"{field_name}.metaImage",
|
|
285
|
+
"canonical_url": f"{field_name}.canonicalURL",
|
|
286
|
+
"og_title": f"{field_name}.ogTitle",
|
|
287
|
+
"og_description": f"{field_name}.ogDescription",
|
|
288
|
+
"og_image": f"{field_name}.ogImage",
|
|
289
|
+
"twitter_title": f"{field_name}.twitterTitle",
|
|
290
|
+
"twitter_description": f"{field_name}.twitterDescription",
|
|
291
|
+
"twitter_image": f"{field_name}.twitterImage",
|
|
292
|
+
"robots": f"{field_name}.robots",
|
|
293
|
+
"structured_data": f"{field_name}.structuredData",
|
|
294
|
+
}
|
strapi_kit/utils/uid.py
CHANGED
|
@@ -4,6 +4,29 @@ This module provides centralized functions for handling Strapi content type UIDs
|
|
|
4
4
|
including conversion to API endpoints with proper pluralization.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
# Irregular plurals mapping (plural -> singular)
|
|
8
|
+
_IRREGULAR_PLURALS: dict[str, str] = {
|
|
9
|
+
"people": "person",
|
|
10
|
+
"children": "child",
|
|
11
|
+
"men": "man",
|
|
12
|
+
"women": "woman",
|
|
13
|
+
"feet": "foot",
|
|
14
|
+
"teeth": "tooth",
|
|
15
|
+
"geese": "goose",
|
|
16
|
+
"mice": "mouse",
|
|
17
|
+
"oxen": "ox",
|
|
18
|
+
"indices": "index",
|
|
19
|
+
"matrices": "matrix",
|
|
20
|
+
"vertices": "vertex",
|
|
21
|
+
"analyses": "analysis",
|
|
22
|
+
"crises": "crisis",
|
|
23
|
+
"theses": "thesis",
|
|
24
|
+
"phenomena": "phenomenon",
|
|
25
|
+
"criteria": "criterion",
|
|
26
|
+
"data": "datum",
|
|
27
|
+
"media": "medium",
|
|
28
|
+
}
|
|
29
|
+
|
|
7
30
|
|
|
8
31
|
def uid_to_endpoint(uid: str) -> str:
|
|
9
32
|
"""Convert content type UID to API endpoint.
|
|
@@ -86,3 +109,109 @@ def is_api_content_type(uid: str) -> bool:
|
|
|
86
109
|
False
|
|
87
110
|
"""
|
|
88
111
|
return uid.startswith("api::")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def api_id_to_singular(api_id: str) -> str:
|
|
115
|
+
"""Convert plural API ID to singular form.
|
|
116
|
+
|
|
117
|
+
Handles common English pluralization patterns and irregular plurals.
|
|
118
|
+
For custom pluralization, you may need to handle edge cases manually.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
api_id: Plural API ID (e.g., "articles", "categories", "people")
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Singular form (e.g., "article", "category", "person")
|
|
125
|
+
|
|
126
|
+
Examples:
|
|
127
|
+
>>> api_id_to_singular("articles")
|
|
128
|
+
'article'
|
|
129
|
+
>>> api_id_to_singular("categories")
|
|
130
|
+
'category'
|
|
131
|
+
>>> api_id_to_singular("classes")
|
|
132
|
+
'class'
|
|
133
|
+
>>> api_id_to_singular("people")
|
|
134
|
+
'person'
|
|
135
|
+
>>> api_id_to_singular("children")
|
|
136
|
+
'child'
|
|
137
|
+
"""
|
|
138
|
+
# Normalize to lowercase for comparison
|
|
139
|
+
name = api_id.lower()
|
|
140
|
+
|
|
141
|
+
# Check irregular plurals first
|
|
142
|
+
if name in _IRREGULAR_PLURALS:
|
|
143
|
+
return _IRREGULAR_PLURALS[name]
|
|
144
|
+
|
|
145
|
+
# Handle -ies -> -y (categories -> category)
|
|
146
|
+
if name.endswith("ies"):
|
|
147
|
+
return name[:-3] + "y"
|
|
148
|
+
|
|
149
|
+
# Handle -zzes specifically
|
|
150
|
+
# Words with single z double it when pluralized: quiz -> quizzes (remove -zes, keep 1 z)
|
|
151
|
+
# Words with double z just add es: buzz -> buzzes, fizz -> fizzes (remove -es, keep zz)
|
|
152
|
+
if name.endswith("zzes"):
|
|
153
|
+
# Common double-z words: buzz, fizz, fuzz, jazz, razz, etc. (pattern: consonant + vowel + zz)
|
|
154
|
+
# Common single-z words that double: quiz, whiz (pattern: vowel + i + z or similar)
|
|
155
|
+
# Heuristic: 4-letter bases (buzz, fizz, jazz, fuzz) become 6-letter plurals
|
|
156
|
+
# 4-letter bases like quiz become 7-letter plurals
|
|
157
|
+
# So length 6 -> likely double-z base (remove -es)
|
|
158
|
+
# length 7+ -> likely single-z base that was doubled (remove -zes)
|
|
159
|
+
if len(name) <= 6:
|
|
160
|
+
return name[:-2] # buzzes -> buzz, fizzes -> fizz
|
|
161
|
+
else:
|
|
162
|
+
return name[:-3] # quizzes -> quiz, whizzes -> whiz
|
|
163
|
+
|
|
164
|
+
# Handle -es for words ending in s, x, z, ch, sh (classes -> class, buses -> bus)
|
|
165
|
+
if name.endswith("es"):
|
|
166
|
+
base = name[:-2]
|
|
167
|
+
if base.endswith(("s", "x", "z", "ch", "sh")):
|
|
168
|
+
return base
|
|
169
|
+
|
|
170
|
+
# Handle standard -s removal (articles -> article)
|
|
171
|
+
if name.endswith("s") and len(name) > 1:
|
|
172
|
+
return name[:-1]
|
|
173
|
+
|
|
174
|
+
# Already singular or unrecognized
|
|
175
|
+
return name
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def uid_to_admin_url(
|
|
179
|
+
uid: str,
|
|
180
|
+
base_url: str,
|
|
181
|
+
kind: str = "collectionType",
|
|
182
|
+
) -> str:
|
|
183
|
+
"""Build Strapi admin panel URL from content type UID.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
uid: Content type UID (e.g., "api::article.article")
|
|
187
|
+
base_url: Strapi base URL (e.g., "http://localhost:1337")
|
|
188
|
+
kind: Content type kind - "collectionType" or "singleType"
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Admin panel URL for the content type
|
|
192
|
+
|
|
193
|
+
Examples:
|
|
194
|
+
>>> uid_to_admin_url("api::article.article", "http://localhost:1337")
|
|
195
|
+
'http://localhost:1337/admin/content-manager/collection-types/api::article.article'
|
|
196
|
+
|
|
197
|
+
>>> uid_to_admin_url(
|
|
198
|
+
... "api::homepage.homepage",
|
|
199
|
+
... "http://localhost:1337",
|
|
200
|
+
... kind="singleType"
|
|
201
|
+
... )
|
|
202
|
+
'http://localhost:1337/admin/content-manager/single-types/api::homepage.homepage'
|
|
203
|
+
"""
|
|
204
|
+
# Remove trailing slash from base_url
|
|
205
|
+
base_url = base_url.rstrip("/")
|
|
206
|
+
|
|
207
|
+
# Determine the path segment based on kind
|
|
208
|
+
if kind == "singleType":
|
|
209
|
+
type_segment = "single-types"
|
|
210
|
+
else:
|
|
211
|
+
type_segment = "collection-types"
|
|
212
|
+
|
|
213
|
+
return f"{base_url}/admin/content-manager/{type_segment}/{uid}"
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# Alias for clarity - uid_to_endpoint already exists, this makes the intent explicit
|
|
217
|
+
uid_to_api_id = uid_to_endpoint
|