Pure s-expression block editor replacing React/Koenig: single hover + button, slash commands, full card edit modes (image/gallery/video/audio/file/embed/ bookmark/callout/toggle/button/HTML/code), inline format toolbar, keyboard shortcuts, drag-drop uploads, oEmbed/bookmark metadata fetching. Includes lexical_to_sx converter for backfilling existing posts, KG card components matching Ghost's card CSS, migration for sx_content column, and 31 converter tests. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
70 lines
1.9 KiB
Python
70 lines
1.9 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Backfill sx_content from lexical JSON for all posts that have lexical but no sx_content.
|
|
|
|
Usage:
|
|
python -m blog.scripts.backfill_sx_content [--dry-run]
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import asyncio
|
|
import sys
|
|
|
|
from sqlalchemy import select, and_
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
|
|
async def backfill(dry_run: bool = False) -> int:
|
|
from shared.db.sessions import get_session_factory
|
|
from blog.models.content import Post
|
|
from blog.bp.blog.ghost.lexical_to_sx import lexical_to_sx
|
|
|
|
session_factory = get_session_factory("blog")
|
|
converted = 0
|
|
errors = 0
|
|
|
|
async with session_factory() as sess:
|
|
stmt = select(Post).where(
|
|
and_(
|
|
Post.lexical.isnot(None),
|
|
Post.lexical != "",
|
|
(Post.sx_content.is_(None)) | (Post.sx_content == ""),
|
|
)
|
|
)
|
|
result = await sess.execute(stmt)
|
|
posts = result.scalars().all()
|
|
|
|
print(f"Found {len(posts)} posts to convert")
|
|
|
|
for post in posts:
|
|
try:
|
|
sx = lexical_to_sx(post.lexical)
|
|
if dry_run:
|
|
print(f" [DRY RUN] {post.slug}: {len(sx)} chars")
|
|
else:
|
|
post.sx_content = sx
|
|
print(f" Converted: {post.slug} ({len(sx)} chars)")
|
|
converted += 1
|
|
except Exception as e:
|
|
print(f" ERROR: {post.slug}: {e}", file=sys.stderr)
|
|
errors += 1
|
|
|
|
if not dry_run:
|
|
await sess.commit()
|
|
|
|
print(f"\nDone: {converted} converted, {errors} errors")
|
|
return converted
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="Backfill sx_content from lexical JSON")
|
|
parser.add_argument("--dry-run", action="store_true", help="Don't write to database")
|
|
args = parser.parse_args()
|
|
|
|
asyncio.run(backfill(dry_run=args.dry_run))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|