# retoor import sys from devplacepy.cli._shared import _audit_cli def _crc32(path): import zlib crc = 0 with open(path, "rb") as handle: while True: chunk = handle.read(1024 * 1024) if not chunk: break crc = zlib.crc32(chunk, crc) return crc & 0xFFFFFFFF def _migrate_file(source, dest, dry_run, report): import os import shutil if not source.exists(): return if source.resolve() == dest.resolve(): return size = source.stat().st_size if dest.exists(): if dest.stat().st_size == size and _crc32(dest) == _crc32(source): report.append(("done", source, dest, size)) if not dry_run: source.unlink() return report.append(("conflict", source, dest, size)) return report.append(("move", source, dest, size)) if dry_run: return dest.parent.mkdir(parents=True, exist_ok=True) tmp = dest.with_name(dest.name + ".migrating") shutil.copyfile(source, tmp) with open(tmp, "rb") as handle: os.fsync(handle.fileno()) if tmp.stat().st_size != size or _crc32(tmp) != _crc32(source): tmp.unlink(missing_ok=True) raise RuntimeError(f"verification failed copying {source} -> {dest}") os.replace(tmp, dest) source.unlink() def _prune_empty_dirs(root): if not root.exists(): return for path in sorted(root.rglob("*"), reverse=True): if path.is_dir(): try: path.rmdir() except OSError: pass try: root.rmdir() except OSError: pass def _migrate_tree(source, dest, dry_run, report): if not source.exists(): return if source.resolve() == dest.resolve(): return for child in sorted(source.rglob("*")): if child.is_file(): _migrate_file(child, dest / child.relative_to(source), dry_run, report) if not dry_run: _prune_empty_dirs(source) def _db_is_locked(path): import sqlite3 try: conn = sqlite3.connect(str(path), timeout=0.5) try: conn.execute("BEGIN IMMEDIATE") conn.rollback() return False finally: conn.close() except sqlite3.OperationalError: return True def _checkpoint(path): import sqlite3 conn = sqlite3.connect(str(path), timeout=5) try: conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") conn.commit() finally: conn.close() def _migrate_db(source, dest, dry_run, report): if not source.exists(): return if source.resolve() == dest.resolve(): return if _db_is_locked(source): raise RuntimeError( f"{source} is locked - stop the app before running migrate-data" ) if not dry_run: _checkpoint(source) _migrate_file(source, dest, dry_run, report) for suffix in ("-wal", "-shm"): _migrate_file( source.with_name(source.name + suffix), dest.with_name(dest.name + suffix), dry_run, report, ) def cmd_emoji_sync(args): from devplacepy.rendering import EMOJI_JS_PATH, write_emoji_module count = write_emoji_module() _audit_cli("cli.emoji.sync", f"CLI regenerated {count} emoji shortcodes", metadata={"count": count}) print(f"Wrote {count} emoji shortcodes to {EMOJI_JS_PATH}") def cmd_migrate_data(args): import os from pathlib import Path from collections import Counter from devplacepy import config base = config.BASE_DIR home = Path.home() dry = args.dry_run report = [] config.ensure_data_dirs() db_items = [] if config.DATABASE_URL == f"sqlite:///{config.DATA_DIR / 'devplace.db'}": db_items.append((base / "devplace.db", config.DATA_DIR / "devplace.db")) else: print("Skipping main DB: DEVPLACE_DATABASE_URL points outside the data dir.") if not os.environ.get("DEVII_TASKS_DB"): db_items.append((base / "devii_tasks.db", config.DEVII_TASKS_DB)) if not os.environ.get("DEVII_LESSONS_DB"): db_items.append((base / "devii_lessons.db", config.DEVII_LESSONS_DB)) file_items = [ (base / name, config.KEYS_DIR / name) for name in ( "notification-private.pem", "notification-private.pkcs8.pem", "notification-public.pem", ) ] registry_dest = config.BOT_DIR / "article_registry.json" registry_sources = [ path for path in ( home / ".dpbot_article_registry.json", base / ".dpbot_article_registry.json", ) if path.exists() ] registry_sources.sort(key=lambda path: path.stat().st_mtime, reverse=True) if registry_sources: file_items.append((registry_sources[0], registry_dest)) for stale in registry_sources[1:]: print(f"Leaving older duplicate registry untouched: {stale}") legacy_var = base / "var" tree_items = [ (base / "devplacepy" / "static" / "uploads", config.UPLOADS_DIR), (home / ".devplace_bots", config.BOT_DIR), ] for sub_name in ("container_workspaces", "zips", "zip_staging", "fork_staging"): tree_items.append((legacy_var / sub_name, config.DATA_PATHS[sub_name])) try: for source, dest in db_items: _migrate_db(source, dest, dry, report) for source, dest in file_items: _migrate_file(source, dest, dry, report) for source, dest in tree_items: _migrate_tree(source, dest, dry, report) except RuntimeError as exc: print(f"ERROR: {exc}") sys.exit(1) if not report: print("Nothing to migrate; the data directory is already consolidated.") return for status, source, dest, size in report: print(f" [{status}] {source} -> {dest} ({size} bytes)") counts = Counter(status for status, *_ in report) print() print( ("Planned: " if dry else "Migrated: ") + ", ".join(f"{count} {status}" for status, count in sorted(counts.items())) ) if any(status == "conflict" for status, *_ in report): print( "Conflicts left both source and destination untouched; resolve them by hand." ) if dry: print("Dry run - nothing changed. Re-run without --dry-run to apply.") def register_migrate(subparsers): subparsers.add_parser( "emoji-sync", help="Regenerate static/js/emoji-shortcodes.js from the emoji library", ).set_defaults(func=cmd_emoji_sync) migrate = subparsers.add_parser( "migrate-data", help="Relocate legacy runtime files into the consolidated data/ directory", ) migrate.add_argument( "--dry-run", action="store_true", help="Print the source-to-destination plan without changing anything", ) migrate.set_defaults(func=cmd_migrate_data)