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.
- pagebuilder/__init__.py +0 -0
- pagebuilder/components/DiffSummary.tsx +107 -0
- pagebuilder/components/ScheduledBadge.tsx +34 -0
- pagebuilder/components/StatusBadge.tsx +21 -0
- pagebuilder/components/blockRegistry.test.ts +56 -0
- pagebuilder/components/blockRegistry.ts +80 -0
- pagebuilder/components/blocks/Button.tsx +142 -0
- pagebuilder/components/blocks/Columns.tsx +64 -0
- pagebuilder/components/blocks/Heading.tsx +92 -0
- pagebuilder/components/blocks/Image.tsx +125 -0
- pagebuilder/components/blocks/MediaPicker.tsx +178 -0
- pagebuilder/components/blocks/Spacer.tsx +31 -0
- pagebuilder/components/blocks/Text.tsx +80 -0
- pagebuilder/components/editor/PageEditorToolbar.tsx +169 -0
- pagebuilder/components/editor/RevisionHistoryPanel.tsx +95 -0
- pagebuilder/components/editor/SchedulePanel.tsx +85 -0
- pagebuilder/components/editor/SeoSettingsPanel.tsx +119 -0
- pagebuilder/components/layoutPuckConfig.tsx +109 -0
- pagebuilder/components/media/MediaFilters.tsx +83 -0
- pagebuilder/components/media/MediaFolderSidebar.tsx +97 -0
- pagebuilder/components/media/MediaGrid.tsx +76 -0
- pagebuilder/components/media/MediaHeader.tsx +37 -0
- pagebuilder/components/media/MediaUploadQueue.tsx +103 -0
- pagebuilder/components/media/types.ts +20 -0
- pagebuilder/components/migrateContent.test.ts +146 -0
- pagebuilder/components/migrateContent.ts +135 -0
- pagebuilder/components/puckConfig.test.tsx +118 -0
- pagebuilder/components/puckConfig.tsx +121 -0
- pagebuilder/components/widgetCatalog.test.tsx +123 -0
- pagebuilder/components/widgetCatalog.ts +208 -0
- pagebuilder/components/widgets/_internal/accent-text.tsx +60 -0
- pagebuilder/components/widgets/_internal/disclosure-chevron.tsx +14 -0
- pagebuilder/components/widgets/_internal/eyebrow-split-section.tsx +71 -0
- pagebuilder/components/widgets/_internal/link-arrow.tsx +23 -0
- pagebuilder/components/widgets/_internal/rich-text.tsx +243 -0
- pagebuilder/components/widgets/_internal/solid-icons.tsx +73 -0
- pagebuilder/components/widgets/_shared/background-media.tsx +74 -0
- pagebuilder/components/widgets/_shared/content-media.tsx +43 -0
- pagebuilder/components/widgets/_shared/cta-button.tsx +69 -0
- pagebuilder/components/widgets/_shared/empty-placeholder.tsx +15 -0
- pagebuilder/components/widgets/_shared/eyebrow-text.tsx +27 -0
- pagebuilder/components/widgets/_shared/heading.tsx +88 -0
- pagebuilder/components/widgets/_shared/index.ts +39 -0
- pagebuilder/components/widgets/_shared/mask.ts +54 -0
- pagebuilder/components/widgets/_shared/section.tsx +60 -0
- pagebuilder/components/widgets/_shared/social-icon.tsx +71 -0
- pagebuilder/components/widgets/_shared/text-link.tsx +47 -0
- pagebuilder/components/widgets/accordion-widget.tsx +64 -0
- pagebuilder/components/widgets/alert-widget.tsx +47 -0
- pagebuilder/components/widgets/app-store-badges-widget.tsx +91 -0
- pagebuilder/components/widgets/article-cards-render.tsx +132 -0
- pagebuilder/components/widgets/article-cards-widget.tsx +72 -0
- pagebuilder/components/widgets/call-to-action-render.tsx +285 -0
- pagebuilder/components/widgets/call-to-action-styles.ts +28 -0
- pagebuilder/components/widgets/call-to-action-widget.tsx +83 -0
- pagebuilder/components/widgets/carousel-widget.tsx +97 -0
- pagebuilder/components/widgets/centered-hero-widget.tsx +54 -0
- pagebuilder/components/widgets/colored-action-list-widget.tsx +117 -0
- pagebuilder/components/widgets/column-widget.tsx +63 -0
- pagebuilder/components/widgets/contact-cards-widget.tsx +172 -0
- pagebuilder/components/widgets/contact-form-render.tsx +293 -0
- pagebuilder/components/widgets/contact-form-widget.tsx +69 -0
- pagebuilder/components/widgets/container-widget.tsx +93 -0
- pagebuilder/components/widgets/definition-list-widget.tsx +144 -0
- pagebuilder/components/widgets/divider-widget.tsx +56 -0
- pagebuilder/components/widgets/eyebrow-section-widget.tsx +56 -0
- pagebuilder/components/widgets/faq-render.tsx +268 -0
- pagebuilder/components/widgets/faq-widget.tsx +64 -0
- pagebuilder/components/widgets/feature-card-widget.tsx +98 -0
- pagebuilder/components/widgets/feature-cards-render.tsx +258 -0
- pagebuilder/components/widgets/feature-cards-styles.ts +107 -0
- pagebuilder/components/widgets/feature-cards-widget.tsx +133 -0
- pagebuilder/components/widgets/feature-grid-widget.tsx +118 -0
- pagebuilder/components/widgets/feature-showcase-widget.tsx +118 -0
- pagebuilder/components/widgets/gallery-widget.tsx +139 -0
- pagebuilder/components/widgets/grid-widget.tsx +59 -0
- pagebuilder/components/widgets/hero-widget.tsx +292 -0
- pagebuilder/components/widgets/html-widget.tsx +42 -0
- pagebuilder/components/widgets/iframe-widget.tsx +44 -0
- pagebuilder/components/widgets/leaderboard-widget.tsx +203 -0
- pagebuilder/components/widgets/list-widget.tsx +56 -0
- pagebuilder/components/widgets/logo-cloud-widget.tsx +127 -0
- pagebuilder/components/widgets/markdown-widget.tsx +148 -0
- pagebuilder/components/widgets/media-object-layout.ts +156 -0
- pagebuilder/components/widgets/media-object-render.tsx +284 -0
- pagebuilder/components/widgets/media-object-text.tsx +202 -0
- pagebuilder/components/widgets/media-object-widget.tsx +176 -0
- pagebuilder/components/widgets/newsletter-widget.tsx +217 -0
- pagebuilder/components/widgets/page-header-widget.tsx +118 -0
- pagebuilder/components/widgets/prose-section-widget.tsx +99 -0
- pagebuilder/components/widgets/quote-widget.tsx +51 -0
- pagebuilder/components/widgets/row-widget.tsx +74 -0
- pagebuilder/components/widgets/section-intro-widget.tsx +143 -0
- pagebuilder/components/widgets/signup-banner-widget.tsx +50 -0
- pagebuilder/components/widgets/simple-action-list-widget.tsx +91 -0
- pagebuilder/components/widgets/site-footer-widget.tsx +97 -0
- pagebuilder/components/widgets/site-header-nav.tsx +153 -0
- pagebuilder/components/widgets/site-header-widget.tsx +182 -0
- pagebuilder/components/widgets/social-banner-widget.tsx +83 -0
- pagebuilder/components/widgets/stats-widget.tsx +200 -0
- pagebuilder/components/widgets/steps-widget.tsx +211 -0
- pagebuilder/components/widgets/story-cards-widget.tsx +182 -0
- pagebuilder/components/widgets/table-widget.tsx +86 -0
- pagebuilder/components/widgets/tabs-widget.tsx +95 -0
- pagebuilder/components/widgets/tags-widget.tsx +85 -0
- pagebuilder/components/widgets/testimonial-widget.tsx +102 -0
- pagebuilder/components/widgets/timeline-layout.ts +73 -0
- pagebuilder/components/widgets/timeline-render.tsx +294 -0
- pagebuilder/components/widgets/timeline-widget.tsx +106 -0
- pagebuilder/components/widgets/under-construction-widget.tsx +40 -0
- pagebuilder/components/widgets/video-widget.tsx +103 -0
- pagebuilder/components/widgets/welcome-widget.tsx +38 -0
- pagebuilder/contracts/__init__.py +57 -0
- pagebuilder/contracts/events.py +25 -0
- pagebuilder/contracts/schemas.py +239 -0
- pagebuilder/conversion/blocks-to-html.test.ts +105 -0
- pagebuilder/conversion/blocks-to-html.ts +102 -0
- pagebuilder/conversion/index.ts +2 -0
- pagebuilder/conversion/types.ts +5 -0
- pagebuilder/deps.py +26 -0
- pagebuilder/diff.py +129 -0
- pagebuilder/endpoints/__init__.py +0 -0
- pagebuilder/endpoints/api/__init__.py +21 -0
- pagebuilder/endpoints/api/_deps.py +21 -0
- pagebuilder/endpoints/api/layout.py +78 -0
- pagebuilder/endpoints/api/pages.py +85 -0
- pagebuilder/endpoints/api/revisions.py +79 -0
- pagebuilder/endpoints/api/uploads.py +92 -0
- pagebuilder/endpoints/api/workflow.py +124 -0
- pagebuilder/endpoints/seo.py +95 -0
- pagebuilder/endpoints/views.py +228 -0
- pagebuilder/fields/checkbox-field.tsx +22 -0
- pagebuilder/fields/file-picker/file-gallery.tsx +152 -0
- pagebuilder/fields/file-picker/image-picker-field.tsx +214 -0
- pagebuilder/fields/file-picker/index.ts +6 -0
- pagebuilder/fields/file-picker/types.ts +15 -0
- pagebuilder/fields/index.ts +9 -0
- pagebuilder/fields/media-adapter.ts +47 -0
- pagebuilder/hooks/useAutosave.ts +134 -0
- pagebuilder/hooks/useEditorForm.ts +193 -0
- pagebuilder/hooks/useMediaUploads.ts +96 -0
- pagebuilder/hooks/usePageRevisions.ts +59 -0
- pagebuilder/hooks/usePageSchedule.ts +72 -0
- pagebuilder/hooks/usePageWorkflow.ts +173 -0
- pagebuilder/layout_service.py +101 -0
- pagebuilder/media_images.py +185 -0
- pagebuilder/media_queries.py +92 -0
- pagebuilder/media_service.py +222 -0
- pagebuilder/models.py +209 -0
- pagebuilder/module.py +279 -0
- pagebuilder/package.json +18 -0
- pagebuilder/pages/LayoutEditor.tsx +183 -0
- pagebuilder/pages/MediaLibrary.tsx +249 -0
- pagebuilder/pages/PageEditor.tsx +181 -0
- pagebuilder/pages/PageList.tsx +129 -0
- pagebuilder/pages/PendingReview.tsx +128 -0
- pagebuilder/pages/PublicPage.tsx +126 -0
- pagebuilder/permissions.py +44 -0
- pagebuilder/py.typed +0 -0
- pagebuilder/security.py +230 -0
- pagebuilder/service/__init__.py +141 -0
- pagebuilder/service/_common.py +24 -0
- pagebuilder/service/_revisions.py +92 -0
- pagebuilder/service/_workflow.py +207 -0
- pagebuilder/services.py +19 -0
- pagebuilder/settings.py +161 -0
- pagebuilder/static/widgets-base.css +57 -0
- pagebuilder/translation/apply.ts +54 -0
- pagebuilder/translation/extract.ts +141 -0
- pagebuilder/translation/index.ts +20 -0
- pagebuilder/translation/translatable-fields.ts +65 -0
- pagebuilder/translation/translation.test.ts +167 -0
- pagebuilder/utils/api.ts +12 -0
- pagebuilder/utils/datetime.ts +25 -0
- pagebuilder/utils/decorative-image.ts +15 -0
- pagebuilder/utils/editorSnapshot.ts +39 -0
- pagebuilder/utils/markdown.ts +160 -0
- pagebuilder/utils/mediaApi.ts +112 -0
- pagebuilder/utils/mediaFormat.ts +19 -0
- pagebuilder/utils/pagesApi.ts +111 -0
- pagebuilder/utils/parse-list.ts +7 -0
- pagebuilder/utils/request.ts +52 -0
- pagebuilder/utils/slugify.ts +9 -0
- pagebuilder/utils/types.ts +171 -0
- pagebuilder/utils/widgetUtils.ts +11 -0
- simple_module_pagebuilder-0.0.1.dist-info/METADATA +152 -0
- simple_module_pagebuilder-0.0.1.dist-info/RECORD +189 -0
- simple_module_pagebuilder-0.0.1.dist-info/WHEEL +4 -0
- 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
|