simple-module-pagebuilder 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.
Files changed (189) hide show
  1. pagebuilder/__init__.py +0 -0
  2. pagebuilder/components/DiffSummary.tsx +107 -0
  3. pagebuilder/components/ScheduledBadge.tsx +34 -0
  4. pagebuilder/components/StatusBadge.tsx +21 -0
  5. pagebuilder/components/blockRegistry.test.ts +56 -0
  6. pagebuilder/components/blockRegistry.ts +80 -0
  7. pagebuilder/components/blocks/Button.tsx +142 -0
  8. pagebuilder/components/blocks/Columns.tsx +64 -0
  9. pagebuilder/components/blocks/Heading.tsx +92 -0
  10. pagebuilder/components/blocks/Image.tsx +125 -0
  11. pagebuilder/components/blocks/MediaPicker.tsx +178 -0
  12. pagebuilder/components/blocks/Spacer.tsx +31 -0
  13. pagebuilder/components/blocks/Text.tsx +80 -0
  14. pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
  15. pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
  16. pagebuilder/components/editor/SchedulePanel.tsx +85 -0
  17. pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
  18. pagebuilder/components/layoutPuckConfig.tsx +109 -0
  19. pagebuilder/components/media/MediaFilters.tsx +83 -0
  20. pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
  21. pagebuilder/components/media/MediaGrid.tsx +76 -0
  22. pagebuilder/components/media/MediaHeader.tsx +37 -0
  23. pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
  24. pagebuilder/components/media/types.ts +20 -0
  25. pagebuilder/components/migrateContent.test.ts +146 -0
  26. pagebuilder/components/migrateContent.ts +135 -0
  27. pagebuilder/components/puckConfig.test.tsx +118 -0
  28. pagebuilder/components/puckConfig.tsx +121 -0
  29. pagebuilder/components/widgetCatalog.test.tsx +123 -0
  30. pagebuilder/components/widgetCatalog.ts +208 -0
  31. pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
  32. pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
  33. pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
  34. pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
  35. pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
  36. pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
  37. pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
  38. pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
  39. pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
  40. pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
  41. pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
  42. pagebuilder/components/widgets/_shared/heading.tsx +88 -0
  43. pagebuilder/components/widgets/_shared/index.ts +39 -0
  44. pagebuilder/components/widgets/_shared/mask.ts +54 -0
  45. pagebuilder/components/widgets/_shared/section.tsx +60 -0
  46. pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
  47. pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
  48. pagebuilder/components/widgets/accordion-widget.tsx +64 -0
  49. pagebuilder/components/widgets/alert-widget.tsx +47 -0
  50. pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
  51. pagebuilder/components/widgets/article-cards-render.tsx +132 -0
  52. pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
  53. pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
  54. pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
  55. pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
  56. pagebuilder/components/widgets/carousel-widget.tsx +97 -0
  57. pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
  58. pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
  59. pagebuilder/components/widgets/column-widget.tsx +63 -0
  60. pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
  61. pagebuilder/components/widgets/contact-form-render.tsx +293 -0
  62. pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
  63. pagebuilder/components/widgets/container-widget.tsx +93 -0
  64. pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
  65. pagebuilder/components/widgets/divider-widget.tsx +56 -0
  66. pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
  67. pagebuilder/components/widgets/faq-render.tsx +268 -0
  68. pagebuilder/components/widgets/faq-widget.tsx +64 -0
  69. pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
  70. pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
  71. pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
  72. pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
  73. pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
  74. pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
  75. pagebuilder/components/widgets/gallery-widget.tsx +139 -0
  76. pagebuilder/components/widgets/grid-widget.tsx +59 -0
  77. pagebuilder/components/widgets/hero-widget.tsx +292 -0
  78. pagebuilder/components/widgets/html-widget.tsx +42 -0
  79. pagebuilder/components/widgets/iframe-widget.tsx +44 -0
  80. pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
  81. pagebuilder/components/widgets/list-widget.tsx +56 -0
  82. pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
  83. pagebuilder/components/widgets/markdown-widget.tsx +148 -0
  84. pagebuilder/components/widgets/media-object-layout.ts +156 -0
  85. pagebuilder/components/widgets/media-object-render.tsx +284 -0
  86. pagebuilder/components/widgets/media-object-text.tsx +202 -0
  87. pagebuilder/components/widgets/media-object-widget.tsx +176 -0
  88. pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
  89. pagebuilder/components/widgets/page-header-widget.tsx +118 -0
  90. pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
  91. pagebuilder/components/widgets/quote-widget.tsx +51 -0
  92. pagebuilder/components/widgets/row-widget.tsx +74 -0
  93. pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
  94. pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
  95. pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
  96. pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
  97. pagebuilder/components/widgets/site-header-nav.tsx +153 -0
  98. pagebuilder/components/widgets/site-header-widget.tsx +182 -0
  99. pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
  100. pagebuilder/components/widgets/stats-widget.tsx +200 -0
  101. pagebuilder/components/widgets/steps-widget.tsx +211 -0
  102. pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
  103. pagebuilder/components/widgets/table-widget.tsx +86 -0
  104. pagebuilder/components/widgets/tabs-widget.tsx +95 -0
  105. pagebuilder/components/widgets/tags-widget.tsx +85 -0
  106. pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
  107. pagebuilder/components/widgets/timeline-layout.ts +73 -0
  108. pagebuilder/components/widgets/timeline-render.tsx +294 -0
  109. pagebuilder/components/widgets/timeline-widget.tsx +106 -0
  110. pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
  111. pagebuilder/components/widgets/video-widget.tsx +103 -0
  112. pagebuilder/components/widgets/welcome-widget.tsx +38 -0
  113. pagebuilder/contracts/__init__.py +57 -0
  114. pagebuilder/contracts/events.py +25 -0
  115. pagebuilder/contracts/schemas.py +239 -0
  116. pagebuilder/conversion/blocks-to-html.test.ts +105 -0
  117. pagebuilder/conversion/blocks-to-html.ts +102 -0
  118. pagebuilder/conversion/index.ts +2 -0
  119. pagebuilder/conversion/types.ts +5 -0
  120. pagebuilder/deps.py +26 -0
  121. pagebuilder/diff.py +129 -0
  122. pagebuilder/endpoints/__init__.py +0 -0
  123. pagebuilder/endpoints/api/__init__.py +21 -0
  124. pagebuilder/endpoints/api/_deps.py +21 -0
  125. pagebuilder/endpoints/api/layout.py +78 -0
  126. pagebuilder/endpoints/api/pages.py +85 -0
  127. pagebuilder/endpoints/api/revisions.py +79 -0
  128. pagebuilder/endpoints/api/uploads.py +92 -0
  129. pagebuilder/endpoints/api/workflow.py +124 -0
  130. pagebuilder/endpoints/seo.py +95 -0
  131. pagebuilder/endpoints/views.py +228 -0
  132. pagebuilder/fields/checkbox-field.tsx +22 -0
  133. pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
  134. pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
  135. pagebuilder/fields/file-picker/index.ts +6 -0
  136. pagebuilder/fields/file-picker/types.ts +15 -0
  137. pagebuilder/fields/index.ts +9 -0
  138. pagebuilder/fields/media-adapter.ts +47 -0
  139. pagebuilder/hooks/useAutosave.ts +134 -0
  140. pagebuilder/hooks/useEditorForm.ts +193 -0
  141. pagebuilder/hooks/useMediaUploads.ts +96 -0
  142. pagebuilder/hooks/usePageRevisions.ts +59 -0
  143. pagebuilder/hooks/usePageSchedule.ts +72 -0
  144. pagebuilder/hooks/usePageWorkflow.ts +173 -0
  145. pagebuilder/layout_service.py +101 -0
  146. pagebuilder/media_images.py +185 -0
  147. pagebuilder/media_queries.py +92 -0
  148. pagebuilder/media_service.py +222 -0
  149. pagebuilder/models.py +209 -0
  150. pagebuilder/module.py +279 -0
  151. pagebuilder/package.json +18 -0
  152. pagebuilder/pages/LayoutEditor.tsx +183 -0
  153. pagebuilder/pages/MediaLibrary.tsx +249 -0
  154. pagebuilder/pages/PageEditor.tsx +181 -0
  155. pagebuilder/pages/PageList.tsx +129 -0
  156. pagebuilder/pages/PendingReview.tsx +128 -0
  157. pagebuilder/pages/PublicPage.tsx +126 -0
  158. pagebuilder/permissions.py +44 -0
  159. pagebuilder/py.typed +0 -0
  160. pagebuilder/security.py +230 -0
  161. pagebuilder/service/__init__.py +141 -0
  162. pagebuilder/service/_common.py +24 -0
  163. pagebuilder/service/_revisions.py +92 -0
  164. pagebuilder/service/_workflow.py +207 -0
  165. pagebuilder/services.py +19 -0
  166. pagebuilder/settings.py +161 -0
  167. pagebuilder/static/widgets-base.css +57 -0
  168. pagebuilder/translation/apply.ts +54 -0
  169. pagebuilder/translation/extract.ts +141 -0
  170. pagebuilder/translation/index.ts +20 -0
  171. pagebuilder/translation/translatable-fields.ts +65 -0
  172. pagebuilder/translation/translation.test.ts +167 -0
  173. pagebuilder/utils/api.ts +12 -0
  174. pagebuilder/utils/datetime.ts +25 -0
  175. pagebuilder/utils/decorative-image.ts +15 -0
  176. pagebuilder/utils/editorSnapshot.ts +39 -0
  177. pagebuilder/utils/markdown.ts +160 -0
  178. pagebuilder/utils/mediaApi.ts +112 -0
  179. pagebuilder/utils/mediaFormat.ts +19 -0
  180. pagebuilder/utils/pagesApi.ts +111 -0
  181. pagebuilder/utils/parse-list.ts +7 -0
  182. pagebuilder/utils/request.ts +52 -0
  183. pagebuilder/utils/slugify.ts +9 -0
  184. pagebuilder/utils/types.ts +171 -0
  185. pagebuilder/utils/widgetUtils.ts +11 -0
  186. simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
  187. simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
  188. simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
  189. simple_module_pagebuilder-0.0.1.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,173 @@
1
+ /** Save, publish, and approval-workflow actions for the page editor. */
2
+
3
+ import { router } from '@inertiajs/react';
4
+ import type { Data } from '@puckeditor/core';
5
+
6
+ import {
7
+ approvePage,
8
+ createPage,
9
+ promptAndReject,
10
+ publishPage,
11
+ savePage,
12
+ submitPage,
13
+ unpublishPage,
14
+ } from '../utils/api';
15
+ import type { EditorSnapshot } from '../utils/editorSnapshot';
16
+ import type { EditorForm } from './useEditorForm';
17
+
18
+ interface Params {
19
+ form: EditorForm;
20
+ setBusy: (v: boolean) => void;
21
+ setMessage: (v: string | null) => void;
22
+ markSaved: (payload: EditorSnapshot) => void;
23
+ }
24
+
25
+ export interface PageWorkflow {
26
+ handleSave: (newData: Data) => Promise<void>;
27
+ handlePublish: () => Promise<void>;
28
+ handleUnpublish: () => Promise<void>;
29
+ handleSubmitForReview: () => Promise<void>;
30
+ handleApprove: () => Promise<void>;
31
+ handleReject: () => Promise<void>;
32
+ }
33
+
34
+ export function usePageWorkflow({ form, setBusy, setMessage, markSaved }: Params): PageWorkflow {
35
+ const snapshotWith = (newData: Data): EditorSnapshot => ({
36
+ ...form.snapshotPayload,
37
+ data: newData,
38
+ });
39
+
40
+ const draftPayload = (newData: Data) => ({
41
+ ...form.writePayload(),
42
+ draft_data: newData as unknown as Record<string, unknown>,
43
+ });
44
+
45
+ const handleSave = async (newData: Data) => {
46
+ setBusy(true);
47
+ setMessage(null);
48
+ try {
49
+ if (form.pageId === null) {
50
+ const created = await createPage(draftPayload(newData) as Parameters<typeof createPage>[0]);
51
+ form.setPageId(created.id);
52
+ form.setSlug(created.slug);
53
+ form.setSlugTouched(true);
54
+ form.setStatus(created.status);
55
+ router.visit(`/pagebuilder/${created.id}/edit`, { preserveState: false });
56
+ } else {
57
+ const updated = await savePage(form.pageId, draftPayload(newData));
58
+ form.setStatus(updated.status);
59
+ markSaved(snapshotWith(newData));
60
+ setMessage('Draft saved.');
61
+ }
62
+ } catch (e) {
63
+ setMessage(e instanceof Error ? e.message : 'Save failed');
64
+ } finally {
65
+ setBusy(false);
66
+ }
67
+ };
68
+
69
+ const handlePublish = async () => {
70
+ if (form.pageId === null) {
71
+ await handleSave(form.data);
72
+ return;
73
+ }
74
+ // Prompt returns `null` on Cancel, `''` on bare-press Enter — only
75
+ // ``null`` should abort. An empty string means "publish, no note".
76
+ const noteInput = window.prompt(
77
+ 'Optional message describing this publish (leave blank to skip):',
78
+ '',
79
+ );
80
+ if (noteInput === null) return;
81
+ const note = noteInput.trim() || null;
82
+ setBusy(true);
83
+ setMessage(null);
84
+ try {
85
+ await savePage(form.pageId, draftPayload(form.data));
86
+ markSaved(snapshotWith(form.data));
87
+ const result = await publishPage(form.pageId, note);
88
+ form.setStatus(result.status);
89
+ setMessage('Published.');
90
+ router.reload({ only: ['revisions'] });
91
+ } catch (e) {
92
+ setMessage(e instanceof Error ? e.message : 'Publish failed');
93
+ } finally {
94
+ setBusy(false);
95
+ }
96
+ };
97
+
98
+ const handleUnpublish = async () => {
99
+ if (form.pageId === null) return;
100
+ setBusy(true);
101
+ setMessage(null);
102
+ try {
103
+ const result = await unpublishPage(form.pageId);
104
+ form.setStatus(result.status);
105
+ setMessage('Unpublished.');
106
+ } catch (e) {
107
+ setMessage(e instanceof Error ? e.message : 'Unpublish failed');
108
+ } finally {
109
+ setBusy(false);
110
+ }
111
+ };
112
+
113
+ const handleSubmitForReview = async () => {
114
+ if (form.pageId === null) {
115
+ setMessage('Save a draft first.');
116
+ return;
117
+ }
118
+ setBusy(true);
119
+ setMessage(null);
120
+ try {
121
+ await savePage(form.pageId, draftPayload(form.data));
122
+ markSaved(snapshotWith(form.data));
123
+ const result = await submitPage(form.pageId);
124
+ form.setStatus(result.status);
125
+ setMessage('Submitted for review.');
126
+ router.reload({ only: ['revisions', 'page'] });
127
+ } catch (e) {
128
+ setMessage(e instanceof Error ? e.message : 'Submit failed');
129
+ } finally {
130
+ setBusy(false);
131
+ }
132
+ };
133
+
134
+ const handleApprove = async () => {
135
+ if (form.pageId === null) return;
136
+ setBusy(true);
137
+ setMessage(null);
138
+ try {
139
+ const result = await approvePage(form.pageId);
140
+ form.setStatus(result.status);
141
+ setMessage('Approved and published.');
142
+ router.reload({ only: ['revisions', 'page'] });
143
+ } catch (e) {
144
+ setMessage(e instanceof Error ? e.message : 'Approve failed');
145
+ } finally {
146
+ setBusy(false);
147
+ }
148
+ };
149
+
150
+ const handleReject = async () => {
151
+ if (form.pageId === null) return;
152
+ setBusy(true);
153
+ setMessage(null);
154
+ const result = await promptAndReject(form.pageId);
155
+ setBusy(false);
156
+ if ('skipped' in result) {
157
+ if (result.skipped !== 'cancelled') setMessage(result.skipped);
158
+ return;
159
+ }
160
+ form.setStatus(result.status);
161
+ setMessage('Sent back to draft.');
162
+ router.reload({ only: ['revisions', 'page'] });
163
+ };
164
+
165
+ return {
166
+ handleSave,
167
+ handlePublish,
168
+ handleUnpublish,
169
+ handleSubmitForReview,
170
+ handleApprove,
171
+ handleReject,
172
+ };
173
+ }
@@ -0,0 +1,101 @@
1
+ from __future__ import annotations
2
+
3
+ from fastapi import HTTPException
4
+ from sqlalchemy.ext.asyncio import AsyncSession
5
+ from sqlmodel import select
6
+
7
+ from pagebuilder.models import Layout, LayoutRevision
8
+
9
+
10
+ def _has_content(data: dict | None) -> bool:
11
+ if not isinstance(data, dict):
12
+ return False
13
+ content = data.get("content")
14
+ return isinstance(content, list) and len(content) > 0
15
+
16
+
17
+ class LayoutService:
18
+ def __init__(self, db: AsyncSession) -> None:
19
+ self.db = db
20
+
21
+ async def get(self) -> Layout:
22
+ result = await self.db.execute(
23
+ select(Layout).order_by(Layout.id.asc()).limit(1)
24
+ )
25
+ layout = result.scalars().first()
26
+ if layout is None:
27
+ layout = Layout(header_data={}, footer_data={})
28
+ self.db.add(layout)
29
+ await self.db.flush()
30
+ await self.db.refresh(layout)
31
+ return layout
32
+
33
+ async def update(
34
+ self,
35
+ *,
36
+ header_data: dict | None = None,
37
+ footer_data: dict | None = None,
38
+ note: str | None = None,
39
+ ) -> Layout:
40
+ layout = await self.get()
41
+ header_changed = header_data is not None and header_data != layout.header_data
42
+ footer_changed = footer_data is not None and footer_data != layout.footer_data
43
+ # No-op save: skip the revision row so save-button-spam doesn't
44
+ # pollute history. The layout row itself is also untouched.
45
+ if not header_changed and not footer_changed:
46
+ return layout
47
+
48
+ if header_changed:
49
+ layout.header_data = header_data # type: ignore[assignment]
50
+ if footer_changed:
51
+ layout.footer_data = footer_data # type: ignore[assignment]
52
+ self.db.add(layout)
53
+
54
+ assert layout.id is not None
55
+ revision = LayoutRevision(
56
+ layout_id=layout.id,
57
+ header_data=layout.header_data,
58
+ footer_data=layout.footer_data,
59
+ note=note,
60
+ )
61
+ self.db.add(revision)
62
+
63
+ await self.db.flush()
64
+ await self.db.refresh(layout)
65
+ return layout
66
+
67
+ async def list_revisions(self) -> list[LayoutRevision]:
68
+ layout = await self.get()
69
+ result = await self.db.execute(
70
+ select(LayoutRevision)
71
+ .where(LayoutRevision.layout_id == layout.id)
72
+ .order_by(LayoutRevision.id.desc())
73
+ )
74
+ return list(result.scalars().all())
75
+
76
+ async def get_revision(self, revision_id: int) -> LayoutRevision:
77
+ revision = await self.db.get(LayoutRevision, revision_id)
78
+ if revision is None:
79
+ raise HTTPException(status_code=404, detail="Revision not found")
80
+ return revision
81
+
82
+ async def restore(self, revision_id: int) -> Layout:
83
+ revision = await self.get_revision(revision_id)
84
+ return await self.update(
85
+ header_data=revision.header_data,
86
+ footer_data=revision.footer_data,
87
+ note=f"Restored from revision #{revision.id}",
88
+ )
89
+
90
+
91
+ def public_layout_props(layout: Layout) -> dict[str, dict | None]:
92
+ # Empty slots surface as None so PublicPage can drop the wrapper —
93
+ # an empty <header> is worse than no header.
94
+ return {
95
+ "layout_header": layout.header_data
96
+ if _has_content(layout.header_data)
97
+ else None,
98
+ "layout_footer": layout.footer_data
99
+ if _has_content(layout.footer_data)
100
+ else None,
101
+ }
@@ -0,0 +1,185 @@
1
+ """Filename, content-sniffing, and image-processing helpers for uploads.
2
+
3
+ Extracted from :mod:`pagebuilder.media_service` so that module stays focused
4
+ on storage + DB operations. These are pure functions — they take everything
5
+ they need as arguments rather than reading service state — which also makes
6
+ them directly testable.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import re
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from fastapi import HTTPException
17
+ from PIL import Image, UnidentifiedImageError
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _EXT_RE = re.compile(r"\.[A-Za-z0-9]{1,8}$")
22
+
23
+ # Folders are user-supplied path-like strings, so we constrain the
24
+ # alphabet and reject traversal segments before they reach the DB.
25
+ _FOLDER_SEGMENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
26
+ _FOLDER_MAX_LENGTH = 300
27
+
28
+ # Header signatures for the formats this module accepts. We sniff the
29
+ # first bytes of every upload so a malicious client can't claim
30
+ # `image/png` while shipping HTML/JS/PHP that StaticFiles would later
31
+ # serve back under a same-origin URL. Pure-Python so no native dep.
32
+ SNIFF_BYTES = 64
33
+
34
+ _MAGIC_SIGNATURES: tuple[tuple[bytes, str], ...] = (
35
+ (b"\xff\xd8\xff", "image/jpeg"),
36
+ (b"\x89PNG\r\n\x1a\n", "image/png"),
37
+ (b"GIF87a", "image/gif"),
38
+ (b"GIF89a", "image/gif"),
39
+ )
40
+
41
+ # WebP needs a two-part check (RIFF + WEBP at offset 8); handled inline.
42
+ CONTENT_TYPE_EXTENSIONS: dict[str, str] = {
43
+ "image/jpeg": ".jpg",
44
+ "image/png": ".png",
45
+ "image/gif": ".gif",
46
+ "image/webp": ".webp",
47
+ }
48
+
49
+
50
+ def normalize_folder(raw: str | None) -> str | None:
51
+ """Canonicalize a folder path or raise 422.
52
+
53
+ Strips surrounding slashes/whitespace, collapses double slashes,
54
+ and validates each segment against ``_FOLDER_SEGMENT_RE``. An empty
55
+ or ``None`` input becomes ``None`` (the asset is unfiled).
56
+ """
57
+ if raw is None:
58
+ return None
59
+ cleaned = raw.strip().strip("/")
60
+ if not cleaned:
61
+ return None
62
+ if len(cleaned) > _FOLDER_MAX_LENGTH:
63
+ raise HTTPException(status_code=422, detail="Folder path is too long")
64
+ segments = [seg for seg in cleaned.split("/") if seg]
65
+ for segment in segments:
66
+ if segment in {".", ".."} or not _FOLDER_SEGMENT_RE.match(segment):
67
+ raise HTTPException(
68
+ status_code=422,
69
+ detail=(
70
+ "Folder segments must start with an alphanumeric and "
71
+ "contain only letters, digits, '.', '_' or '-'"
72
+ ),
73
+ )
74
+ return "/".join(segments)
75
+
76
+
77
+ def sniff_content_type(head: bytes) -> str | None:
78
+ for sig, ctype in _MAGIC_SIGNATURES:
79
+ if head.startswith(sig):
80
+ return ctype
81
+ if len(head) >= 12 and head[0:4] == b"RIFF" and head[8:12] == b"WEBP":
82
+ return "image/webp"
83
+ return None
84
+
85
+
86
+ def safe_extension(name: str) -> str:
87
+ match = _EXT_RE.search(name or "")
88
+ return match.group(0).lower() if match else ""
89
+
90
+
91
+ def is_animated(image: Image.Image) -> bool:
92
+ """Detect multi-frame images we should leave alone.
93
+
94
+ Pillow can downscale individual frames of animated GIF/WEBP but
95
+ re-encoding the animation into webp is a different code path and
96
+ the issue's acceptance criteria explicitly say animated GIFs skip
97
+ transcoding. ``getattr`` defends against formats where Pillow
98
+ doesn't set ``is_animated`` at all.
99
+ """
100
+ return bool(getattr(image, "is_animated", False))
101
+
102
+
103
+ def process_image(
104
+ source: Path,
105
+ *,
106
+ thumbnail_widths: tuple[int, ...],
107
+ webp_quality: int,
108
+ ) -> tuple[int | None, int | None, dict[str, Any]]:
109
+ """Extract dimensions and generate webp thumbnails for a raster image.
110
+
111
+ Returns ``(width, height, variants)``. On any failure (corrupt
112
+ file, format Pillow can't decode, animated image) returns
113
+ ``(None, None, {})`` so the upload still completes — the original
114
+ is served as-is and the Image block falls back to a plain
115
+ ``<img src>``.
116
+ """
117
+ try:
118
+ with Image.open(source) as image:
119
+ src_w, src_h = image.size
120
+ if src_w <= 0 or src_h <= 0:
121
+ return src_w or None, src_h or None, {}
122
+ # Capture dimensions even for animated images — the
123
+ # original is still served, and a known intrinsic size
124
+ # lets the Image block emit width/height to avoid CLS.
125
+ if is_animated(image):
126
+ return src_w, src_h, {}
127
+ image.load()
128
+ variants = generate_thumbnails(
129
+ image,
130
+ source,
131
+ src_w,
132
+ src_h,
133
+ thumbnail_widths=thumbnail_widths,
134
+ webp_quality=webp_quality,
135
+ )
136
+ except (UnidentifiedImageError, OSError, ValueError) as exc:
137
+ # Logged at INFO because this is expected for corrupt or
138
+ # exotic-but-valid uploads — not a code bug.
139
+ logger.info("Skipping image processing for %s: %s", source.name, exc)
140
+ return None, None, {}
141
+ return src_w, src_h, variants
142
+
143
+
144
+ def generate_thumbnails(
145
+ image: Image.Image,
146
+ source: Path,
147
+ src_w: int,
148
+ src_h: int,
149
+ *,
150
+ thumbnail_widths: tuple[int, ...],
151
+ webp_quality: int,
152
+ ) -> dict[str, Any]:
153
+ widths = sorted({w for w in thumbnail_widths if w > 0})
154
+ if not widths:
155
+ return {}
156
+ # Webp doesn't support paletted/CMYK directly the same way; convert
157
+ # once up front so each resize doesn't re-do the work.
158
+ if image.mode not in ("RGB", "RGBA"):
159
+ base = image.convert("RGBA" if "A" in image.mode else "RGB")
160
+ else:
161
+ base = image
162
+ stem = source.stem
163
+ out_dir = source.parent
164
+ variants: dict[str, Any] = {}
165
+ for width in widths:
166
+ if width >= src_w:
167
+ # No upscaling — skip widths at or above the source. The
168
+ # original file already covers visitors at full size.
169
+ continue
170
+ ratio = width / src_w
171
+ height = max(1, round(src_h * ratio))
172
+ resized = base.resize((width, height), Image.Resampling.LANCZOS)
173
+ out_name = f"{stem}_w{width}.webp"
174
+ out_path = out_dir / out_name
175
+ # method=4 is Google's recommended encoder default — method=6
176
+ # is ~5x slower for marginal byte-size gains at q=82.
177
+ resized.save(out_path, format="WEBP", quality=webp_quality, method=4)
178
+ variants[f"w{width}"] = {
179
+ "filename": out_name,
180
+ "content_type": "image/webp",
181
+ "width": width,
182
+ "height": height,
183
+ "size_bytes": out_path.stat().st_size,
184
+ }
185
+ return variants
@@ -0,0 +1,92 @@
1
+ """Listing and counting queries for media assets.
2
+
3
+ Split from :mod:`pagebuilder.media_service` so that module stays focused on
4
+ storage and uploads, and both files stay under the 300-line cap.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from sqlalchemy import func
10
+ from sqlalchemy.ext.asyncio import AsyncSession
11
+ from sqlmodel import select
12
+
13
+ from pagebuilder.models import MediaAsset
14
+
15
+ DEFAULT_PAGE_SIZE = 60
16
+ MAX_PAGE_SIZE = 200
17
+
18
+
19
+ def filtered_stmt(
20
+ *,
21
+ search: str | None = None,
22
+ content_type: str | None = None,
23
+ folder: str | None = None,
24
+ unfiled_only: bool = False,
25
+ min_size_bytes: int | None = None,
26
+ max_size_bytes: int | None = None,
27
+ ):
28
+ """Build the SELECT shared by listing and counting."""
29
+ stmt = select(MediaAsset)
30
+ if search:
31
+ # SQLite LIKE is case-insensitive for ASCII by default; this matches
32
+ # what users expect ("hero" finds "Hero.jpg").
33
+ stmt = stmt.where(MediaAsset.original_filename.like(f"%{search}%"))
34
+ if content_type:
35
+ if content_type.endswith("/*"):
36
+ stmt = stmt.where(MediaAsset.content_type.like(f"{content_type[:-1]}%"))
37
+ else:
38
+ stmt = stmt.where(MediaAsset.content_type == content_type)
39
+ if unfiled_only:
40
+ stmt = stmt.where(MediaAsset.folder.is_(None))
41
+ elif folder is not None:
42
+ stmt = stmt.where(MediaAsset.folder == folder)
43
+ if min_size_bytes is not None:
44
+ stmt = stmt.where(MediaAsset.size_bytes >= min_size_bytes)
45
+ if max_size_bytes is not None:
46
+ stmt = stmt.where(MediaAsset.size_bytes <= max_size_bytes)
47
+ return stmt
48
+
49
+
50
+ async def count_assets(db: AsyncSession, **filters) -> int:
51
+ """Total rows matching *filters* — for offset-paginated callers."""
52
+ inner = filtered_stmt(**filters).subquery()
53
+ result = await db.execute(select(func.count()).select_from(inner))
54
+ return int(result.scalar_one())
55
+
56
+
57
+ async def list_assets(
58
+ db: AsyncSession,
59
+ *,
60
+ cursor: int | None = None,
61
+ offset: int | None = None,
62
+ limit: int = DEFAULT_PAGE_SIZE,
63
+ **filters,
64
+ ) -> tuple[list[MediaAsset], int | None]:
65
+ """Return one page of assets newest-first, plus the next cursor.
66
+
67
+ Two paging modes. The default is cursor-based: the cursor is the smallest
68
+ ``id`` on the previous page, and the query asks for ``limit + 1`` rows so
69
+ the end of the list is detectable without a separate count.
70
+
71
+ Passing ``offset`` switches to offset paging, which the image-picker
72
+ gallery needs because it jumps to arbitrary pages. ``next_cursor`` is then
73
+ always ``None`` and callers pair it with :func:`count_assets`.
74
+ """
75
+ page_size = max(1, min(limit, MAX_PAGE_SIZE))
76
+ stmt = filtered_stmt(**filters)
77
+
78
+ if offset is not None:
79
+ stmt = stmt.order_by(MediaAsset.id.desc()).offset(max(0, offset)).limit(page_size)
80
+ result = await db.execute(stmt)
81
+ return list(result.scalars().all()), None
82
+
83
+ if cursor is not None:
84
+ stmt = stmt.where(MediaAsset.id < cursor)
85
+ stmt = stmt.order_by(MediaAsset.id.desc()).limit(page_size + 1)
86
+ result = await db.execute(stmt)
87
+ rows = list(result.scalars().all())
88
+ next_cursor: int | None = None
89
+ if len(rows) > page_size:
90
+ rows = rows[:page_size]
91
+ next_cursor = rows[-1].id
92
+ return rows, next_cursor