skrift 0.1.0a14__py3-none-any.whl → 0.1.0a15__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.
@@ -1,12 +1,18 @@
1
1
  """Page service for CRUD operations on pages."""
2
2
 
3
- from datetime import datetime
3
+ from datetime import datetime, UTC
4
+ from typing import Literal
4
5
  from uuid import UUID
5
6
 
6
- from sqlalchemy import select, and_
7
+ from sqlalchemy import select, and_, or_
7
8
  from sqlalchemy.ext.asyncio import AsyncSession
8
9
 
9
10
  from skrift.db.models import Page
11
+ from skrift.db.services import revision_service
12
+ from skrift.lib.hooks import hooks, BEFORE_PAGE_SAVE, AFTER_PAGE_SAVE, BEFORE_PAGE_DELETE, AFTER_PAGE_DELETE
13
+
14
+
15
+ OrderBy = Literal["order", "created", "published", "title"]
10
16
 
11
17
 
12
18
  async def list_pages(
@@ -15,15 +21,17 @@ async def list_pages(
15
21
  user_id: UUID | None = None,
16
22
  limit: int | None = None,
17
23
  offset: int = 0,
24
+ order_by: OrderBy = "order",
18
25
  ) -> list[Page]:
19
26
  """List pages with optional filtering.
20
27
 
21
28
  Args:
22
29
  db_session: Database session
23
- published_only: Only return published pages
30
+ published_only: Only return published pages (respects scheduling)
24
31
  user_id: Filter by user ID (author)
25
32
  limit: Maximum number of results
26
33
  offset: Number of results to skip
34
+ order_by: Sort order - "order" (default), "created", "published", "title"
27
35
 
28
36
  Returns:
29
37
  List of Page objects
@@ -33,15 +41,25 @@ async def list_pages(
33
41
  # Build filters
34
42
  filters = []
35
43
  if published_only:
44
+ now = datetime.now(UTC)
36
45
  filters.append(Page.is_published == True)
46
+ # Respect scheduling: either no publish_at set, or publish_at is in the past
47
+ filters.append(or_(Page.publish_at.is_(None), Page.publish_at <= now))
37
48
  if user_id:
38
49
  filters.append(Page.user_id == user_id)
39
50
 
40
51
  if filters:
41
52
  query = query.where(and_(*filters))
42
53
 
43
- # Order by published date (newest first), then created date
44
- query = query.order_by(Page.published_at.desc().nullslast(), Page.created_at.desc())
54
+ # Apply ordering
55
+ if order_by == "order":
56
+ query = query.order_by(Page.order.asc(), Page.created_at.desc())
57
+ elif order_by == "created":
58
+ query = query.order_by(Page.created_at.desc())
59
+ elif order_by == "published":
60
+ query = query.order_by(Page.published_at.desc().nullslast(), Page.created_at.desc())
61
+ elif order_by == "title":
62
+ query = query.order_by(Page.title.asc())
45
63
 
46
64
  # Apply pagination
47
65
  if offset:
@@ -63,7 +81,7 @@ async def get_page_by_slug(
63
81
  Args:
64
82
  db_session: Database session
65
83
  slug: Page slug
66
- published_only: Only return if published
84
+ published_only: Only return if published (respects scheduling)
67
85
 
68
86
  Returns:
69
87
  Page object or None if not found
@@ -71,7 +89,10 @@ async def get_page_by_slug(
71
89
  query = select(Page).where(Page.slug == slug)
72
90
 
73
91
  if published_only:
92
+ now = datetime.now(UTC)
74
93
  query = query.where(Page.is_published == True)
94
+ # Respect scheduling: either no publish_at set, or publish_at is in the past
95
+ query = query.where(or_(Page.publish_at.is_(None), Page.publish_at <= now))
75
96
 
76
97
  result = await db_session.execute(query)
77
98
  return result.scalar_one_or_none()
@@ -102,6 +123,13 @@ async def create_page(
102
123
  is_published: bool = False,
103
124
  published_at: datetime | None = None,
104
125
  user_id: UUID | None = None,
126
+ order: int = 0,
127
+ publish_at: datetime | None = None,
128
+ meta_description: str | None = None,
129
+ og_title: str | None = None,
130
+ og_description: str | None = None,
131
+ og_image: str | None = None,
132
+ meta_robots: str | None = None,
105
133
  ) -> Page:
106
134
  """Create a new page.
107
135
 
@@ -113,6 +141,13 @@ async def create_page(
113
141
  is_published: Whether page is published
114
142
  published_at: Publication timestamp
115
143
  user_id: Author user ID (optional)
144
+ order: Display order (lower numbers first)
145
+ publish_at: Scheduled publish datetime
146
+ meta_description: SEO meta description
147
+ og_title: OpenGraph title override
148
+ og_description: OpenGraph description override
149
+ og_image: OpenGraph image URL
150
+ meta_robots: Meta robots directive
116
151
 
117
152
  Returns:
118
153
  Created Page object
@@ -124,13 +159,31 @@ async def create_page(
124
159
  is_published=is_published,
125
160
  published_at=published_at,
126
161
  user_id=user_id,
162
+ order=order,
163
+ publish_at=publish_at,
164
+ meta_description=meta_description,
165
+ og_title=og_title,
166
+ og_description=og_description,
167
+ og_image=og_image,
168
+ meta_robots=meta_robots,
127
169
  )
170
+
171
+ # Fire before_page_save action (is_new=True for creates)
172
+ await hooks.do_action(BEFORE_PAGE_SAVE, page, is_new=True)
173
+
128
174
  db_session.add(page)
129
175
  await db_session.commit()
130
176
  await db_session.refresh(page)
177
+
178
+ # Fire after_page_save action
179
+ await hooks.do_action(AFTER_PAGE_SAVE, page, is_new=True)
180
+
131
181
  return page
132
182
 
133
183
 
184
+ _UNSET = object() # Sentinel for distinguishing None from "not provided"
185
+
186
+
134
187
  async def update_page(
135
188
  db_session: AsyncSession,
136
189
  page_id: UUID,
@@ -139,6 +192,15 @@ async def update_page(
139
192
  content: str | None = None,
140
193
  is_published: bool | None = None,
141
194
  published_at: datetime | None = None,
195
+ order: int | None = None,
196
+ publish_at: datetime | None | object = _UNSET,
197
+ meta_description: str | None | object = _UNSET,
198
+ og_title: str | None | object = _UNSET,
199
+ og_description: str | None | object = _UNSET,
200
+ og_image: str | None | object = _UNSET,
201
+ meta_robots: str | None | object = _UNSET,
202
+ create_revision: bool = True,
203
+ user_id: UUID | None = None,
142
204
  ) -> Page | None:
143
205
  """Update an existing page.
144
206
 
@@ -150,6 +212,15 @@ async def update_page(
150
212
  content: New content (optional)
151
213
  is_published: New published status (optional)
152
214
  published_at: New publication timestamp (optional)
215
+ order: New display order (optional)
216
+ publish_at: New scheduled publish datetime (optional, use None to clear)
217
+ meta_description: New SEO meta description (optional, use None to clear)
218
+ og_title: New OpenGraph title (optional, use None to clear)
219
+ og_description: New OpenGraph description (optional, use None to clear)
220
+ og_image: New OpenGraph image URL (optional, use None to clear)
221
+ meta_robots: New meta robots directive (optional, use None to clear)
222
+ create_revision: Whether to create a revision before updating (default True)
223
+ user_id: ID of user making the change (for revision tracking)
153
224
 
154
225
  Returns:
155
226
  Updated Page object or None if not found
@@ -158,6 +229,17 @@ async def update_page(
158
229
  if not page:
159
230
  return None
160
231
 
232
+ # Create revision before making changes (if title or content is changing)
233
+ if create_revision and (title is not None or content is not None):
234
+ # Only create revision if title or content actually differs
235
+ title_changed = title is not None and title != page.title
236
+ content_changed = content is not None and content != page.content
237
+ if title_changed or content_changed:
238
+ await revision_service.create_revision(db_session, page, user_id)
239
+
240
+ # Fire before_page_save action (is_new=False for updates)
241
+ await hooks.do_action(BEFORE_PAGE_SAVE, page, is_new=False)
242
+
161
243
  if slug is not None:
162
244
  page.slug = slug
163
245
  if title is not None:
@@ -168,9 +250,27 @@ async def update_page(
168
250
  page.is_published = is_published
169
251
  if published_at is not None:
170
252
  page.published_at = published_at
253
+ if order is not None:
254
+ page.order = order
255
+ if publish_at is not _UNSET:
256
+ page.publish_at = publish_at
257
+ if meta_description is not _UNSET:
258
+ page.meta_description = meta_description
259
+ if og_title is not _UNSET:
260
+ page.og_title = og_title
261
+ if og_description is not _UNSET:
262
+ page.og_description = og_description
263
+ if og_image is not _UNSET:
264
+ page.og_image = og_image
265
+ if meta_robots is not _UNSET:
266
+ page.meta_robots = meta_robots
171
267
 
172
268
  await db_session.commit()
173
269
  await db_session.refresh(page)
270
+
271
+ # Fire after_page_save action
272
+ await hooks.do_action(AFTER_PAGE_SAVE, page, is_new=False)
273
+
174
274
  return page
175
275
 
176
276
 
@@ -191,8 +291,15 @@ async def delete_page(
191
291
  if not page:
192
292
  return False
193
293
 
294
+ # Fire before_page_delete action
295
+ await hooks.do_action(BEFORE_PAGE_DELETE, page)
296
+
194
297
  await db_session.delete(page)
195
298
  await db_session.commit()
299
+
300
+ # Fire after_page_delete action
301
+ await hooks.do_action(AFTER_PAGE_DELETE, page)
302
+
196
303
  return True
197
304
 
198
305
 
@@ -0,0 +1,146 @@
1
+ """Revision service for page history management."""
2
+
3
+ from uuid import UUID
4
+
5
+ from sqlalchemy import select, func
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+
8
+ from skrift.db.models import Page, PageRevision
9
+
10
+
11
+ async def create_revision(
12
+ db_session: AsyncSession,
13
+ page: Page,
14
+ user_id: UUID | None = None,
15
+ ) -> PageRevision:
16
+ """Create a revision snapshot of the current page state.
17
+
18
+ Args:
19
+ db_session: Database session
20
+ page: The page to snapshot
21
+ user_id: ID of user making the change (optional)
22
+
23
+ Returns:
24
+ The created PageRevision object
25
+ """
26
+ # Get the next revision number for this page
27
+ result = await db_session.execute(
28
+ select(func.coalesce(func.max(PageRevision.revision_number), 0))
29
+ .where(PageRevision.page_id == page.id)
30
+ )
31
+ max_revision = result.scalar()
32
+ next_revision = (max_revision or 0) + 1
33
+
34
+ revision = PageRevision(
35
+ page_id=page.id,
36
+ user_id=user_id,
37
+ revision_number=next_revision,
38
+ title=page.title,
39
+ content=page.content,
40
+ )
41
+
42
+ db_session.add(revision)
43
+ await db_session.commit()
44
+ await db_session.refresh(revision)
45
+
46
+ return revision
47
+
48
+
49
+ async def list_revisions(
50
+ db_session: AsyncSession,
51
+ page_id: UUID,
52
+ limit: int | None = None,
53
+ ) -> list[PageRevision]:
54
+ """List revisions for a page, newest first.
55
+
56
+ Args:
57
+ db_session: Database session
58
+ page_id: The page ID to get revisions for
59
+ limit: Maximum number of revisions to return (None for all)
60
+
61
+ Returns:
62
+ List of PageRevision objects ordered by revision_number descending
63
+ """
64
+ query = (
65
+ select(PageRevision)
66
+ .where(PageRevision.page_id == page_id)
67
+ .order_by(PageRevision.revision_number.desc())
68
+ )
69
+
70
+ if limit:
71
+ query = query.limit(limit)
72
+
73
+ result = await db_session.execute(query)
74
+ return list(result.scalars().all())
75
+
76
+
77
+ async def get_revision(
78
+ db_session: AsyncSession,
79
+ revision_id: UUID,
80
+ ) -> PageRevision | None:
81
+ """Get a specific revision by ID.
82
+
83
+ Args:
84
+ db_session: Database session
85
+ revision_id: The revision ID
86
+
87
+ Returns:
88
+ PageRevision object or None if not found
89
+ """
90
+ result = await db_session.execute(
91
+ select(PageRevision).where(PageRevision.id == revision_id)
92
+ )
93
+ return result.scalar_one_or_none()
94
+
95
+
96
+ async def restore_revision(
97
+ db_session: AsyncSession,
98
+ page: Page,
99
+ revision: PageRevision,
100
+ user_id: UUID | None = None,
101
+ ) -> Page:
102
+ """Restore a page to a previous revision state.
103
+
104
+ This creates a new revision first (to preserve current state),
105
+ then updates the page content to match the target revision.
106
+
107
+ Args:
108
+ db_session: Database session
109
+ page: The page to restore
110
+ revision: The revision to restore to
111
+ user_id: ID of user performing the restore (optional)
112
+
113
+ Returns:
114
+ The updated Page object
115
+ """
116
+ # Create a revision of the current state before restoring
117
+ await create_revision(db_session, page, user_id)
118
+
119
+ # Update the page to match the revision
120
+ page.title = revision.title
121
+ page.content = revision.content
122
+
123
+ await db_session.commit()
124
+ await db_session.refresh(page)
125
+
126
+ return page
127
+
128
+
129
+ async def get_revision_count(
130
+ db_session: AsyncSession,
131
+ page_id: UUID,
132
+ ) -> int:
133
+ """Get the total number of revisions for a page.
134
+
135
+ Args:
136
+ db_session: Database session
137
+ page_id: The page ID
138
+
139
+ Returns:
140
+ Number of revisions
141
+ """
142
+ result = await db_session.execute(
143
+ select(func.count(PageRevision.id))
144
+ .where(PageRevision.page_id == page_id)
145
+ )
146
+ return result.scalar() or 0
@@ -126,6 +126,7 @@ SITE_NAME_KEY = "site_name"
126
126
  SITE_TAGLINE_KEY = "site_tagline"
127
127
  SITE_COPYRIGHT_HOLDER_KEY = "site_copyright_holder"
128
128
  SITE_COPYRIGHT_START_YEAR_KEY = "site_copyright_start_year"
129
+ SITE_BASE_URL_KEY = "site_base_url"
129
130
 
130
131
  # Setup wizard key
131
132
  SETUP_COMPLETED_AT_KEY = "setup_completed_at"
@@ -136,6 +137,7 @@ SITE_DEFAULTS = {
136
137
  SITE_TAGLINE_KEY: "Welcome to my site",
137
138
  SITE_COPYRIGHT_HOLDER_KEY: "",
138
139
  SITE_COPYRIGHT_START_YEAR_KEY: "",
140
+ SITE_BASE_URL_KEY: "",
139
141
  }
140
142
 
141
143
 
@@ -204,3 +206,8 @@ def get_cached_site_copyright_start_year() -> str | int | None:
204
206
  if value and value.isdigit():
205
207
  return int(value)
206
208
  return None
209
+
210
+
211
+ def get_cached_site_base_url() -> str:
212
+ """Get the cached site base URL for use in SEO/sitemap."""
213
+ return _site_settings_cache.get(SITE_BASE_URL_KEY, SITE_DEFAULTS[SITE_BASE_URL_KEY])
skrift/lib/__init__.py CHANGED
@@ -1,4 +1,15 @@
1
+ from skrift.lib.hooks import hooks, action, filter, add_action, add_filter, do_action, apply_filters
1
2
  from skrift.lib.markdown import render_markdown
2
3
  from skrift.lib.template import Template
3
4
 
4
- __all__ = ["Template", "render_markdown"]
5
+ __all__ = [
6
+ "Template",
7
+ "render_markdown",
8
+ "hooks",
9
+ "action",
10
+ "filter",
11
+ "add_action",
12
+ "add_filter",
13
+ "do_action",
14
+ "apply_filters",
15
+ ]
skrift/lib/flash.py ADDED
@@ -0,0 +1,103 @@
1
+ """Enhanced flash message system with types and multiple messages support."""
2
+
3
+ from dataclasses import dataclass
4
+ from enum import Enum
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from litestar import Request
9
+
10
+
11
+ class FlashType(str, Enum):
12
+ """Types of flash messages with corresponding CSS classes."""
13
+
14
+ SUCCESS = "success"
15
+ ERROR = "error"
16
+ WARNING = "warning"
17
+ INFO = "info"
18
+
19
+
20
+ @dataclass
21
+ class FlashMessage:
22
+ """A flash message with type and dismissibility."""
23
+
24
+ message: str
25
+ type: FlashType = FlashType.INFO
26
+ dismissible: bool = True
27
+
28
+
29
+ def add_flash(
30
+ request: "Request",
31
+ message: str,
32
+ flash_type: FlashType = FlashType.INFO,
33
+ dismissible: bool = True,
34
+ ) -> None:
35
+ """Add a flash message to the session queue.
36
+
37
+ Args:
38
+ request: The Litestar request object
39
+ message: The message text to display
40
+ flash_type: Type of message (success, error, warning, info)
41
+ dismissible: Whether the message can be dismissed by the user
42
+ """
43
+ if "flash_messages" not in request.session:
44
+ request.session["flash_messages"] = []
45
+
46
+ request.session["flash_messages"].append({
47
+ "message": message,
48
+ "type": flash_type.value,
49
+ "dismissible": dismissible,
50
+ })
51
+
52
+
53
+ def get_flash_messages(request: "Request") -> list[FlashMessage]:
54
+ """Get and clear all flash messages from the session.
55
+
56
+ Also handles backwards compatibility with old single-string flash.
57
+
58
+ Args:
59
+ request: The Litestar request object
60
+
61
+ Returns:
62
+ List of FlashMessage objects
63
+ """
64
+ messages = request.session.pop("flash_messages", [])
65
+
66
+ # Backwards compatibility: convert old single-string flash
67
+ old_flash = request.session.pop("flash", None)
68
+ if old_flash:
69
+ messages.insert(0, {
70
+ "message": old_flash,
71
+ "type": FlashType.INFO.value,
72
+ "dismissible": True,
73
+ })
74
+
75
+ return [
76
+ FlashMessage(
77
+ message=m["message"],
78
+ type=FlashType(m["type"]),
79
+ dismissible=m.get("dismissible", True),
80
+ )
81
+ for m in messages
82
+ ]
83
+
84
+
85
+ # Convenience functions for common flash types
86
+ def flash_success(request: "Request", message: str, dismissible: bool = True) -> None:
87
+ """Add a success flash message."""
88
+ add_flash(request, message, FlashType.SUCCESS, dismissible)
89
+
90
+
91
+ def flash_error(request: "Request", message: str, dismissible: bool = True) -> None:
92
+ """Add an error flash message."""
93
+ add_flash(request, message, FlashType.ERROR, dismissible)
94
+
95
+
96
+ def flash_warning(request: "Request", message: str, dismissible: bool = True) -> None:
97
+ """Add a warning flash message."""
98
+ add_flash(request, message, FlashType.WARNING, dismissible)
99
+
100
+
101
+ def flash_info(request: "Request", message: str, dismissible: bool = True) -> None:
102
+ """Add an info flash message."""
103
+ add_flash(request, message, FlashType.INFO, dismissible)