From 2d462d8df401f7684f078656d4038c19911dff9d Mon Sep 17 00:00:00 2001 From: sudipnext Date: Sun, 8 Mar 2026 20:05:44 +0545 Subject: [PATCH] feat: implement database migration on startup and update dependencies for FastAPI --- Dockerfile | 2 +- Dockerfile.dev | 4 +- docker-compose.yml | 4 + electron/app/main.ts | 21 +- electron/app/utils/update-checker.ts | 257 +++++++++++++++++ electron/package-lock.json | 4 +- electron/package.json | 4 +- electron/servers/fastapi/alembic.ini | 41 +++ electron/servers/fastapi/alembic/env.py | 94 +++++++ .../servers/fastapi/alembic/script.py.mako | 27 ++ .../servers/fastapi/alembic/versions/.gitkeep | 0 .../alembic/versions/00b3c27a13bc_init.py | 135 +++++++++ electron/servers/fastapi/api/lifespan.py | 7 +- electron/servers/fastapi/migrations.py | 40 +++ electron/servers/fastapi/placeholder | Bin 0 -> 12288 bytes electron/servers/fastapi/pyproject.toml | 1 + electron/servers/fastapi/server.spec | 2 + .../services/pptx_presentation_creator.py | 266 +++++++++--------- electron/servers/fastapi/utils/get_env.py | 4 + electron/servers/fastapi/uv.lock | 28 ++ electron/version.json | 8 +- servers/fastapi/alembic.ini | 41 +++ servers/fastapi/alembic/env.py | 94 +++++++ servers/fastapi/alembic/script.py.mako | 27 ++ servers/fastapi/alembic/versions/.gitkeep | 0 .../alembic/versions/fd2ab04834cc_init.py | 136 +++++++++ servers/fastapi/api/lifespan.py | 7 +- servers/fastapi/migrations.py | 40 +++ servers/fastapi/placeholder | Bin 0 -> 12288 bytes servers/fastapi/pyproject.toml | 4 + .../services/pptx_presentation_creator.py | 137 +++++++++ servers/fastapi/utils/get_env.py | 4 + servers/fastapi/uv.lock | 143 ++++++---- test_server.py | 78 +++++ 34 files changed, 1460 insertions(+), 200 deletions(-) create mode 100644 electron/app/utils/update-checker.ts create mode 100644 electron/servers/fastapi/alembic.ini create mode 100644 electron/servers/fastapi/alembic/env.py create mode 100644 electron/servers/fastapi/alembic/script.py.mako create mode 100644 electron/servers/fastapi/alembic/versions/.gitkeep create mode 100644 electron/servers/fastapi/alembic/versions/00b3c27a13bc_init.py create mode 100644 electron/servers/fastapi/migrations.py create mode 100644 electron/servers/fastapi/placeholder create mode 100644 servers/fastapi/alembic.ini create mode 100644 servers/fastapi/alembic/env.py create mode 100644 servers/fastapi/alembic/script.py.mako create mode 100644 servers/fastapi/alembic/versions/.gitkeep create mode 100644 servers/fastapi/alembic/versions/fd2ab04834cc_init.py create mode 100644 servers/fastapi/migrations.py create mode 100644 servers/fastapi/placeholder create mode 100644 test_server.py diff --git a/Dockerfile b/Dockerfile index 59fe7945..b9c1bf32 100644 --- a/Dockerfile +++ b/Dockerfile @@ -28,7 +28,7 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium RUN curl -fsSL https://ollama.com/install.sh | sh # Install dependencies for FastAPI -RUN pip install aiohttp aiomysql aiosqlite asyncpg fastapi[standard] \ +RUN pip install alembic aiohttp aiomysql aiosqlite asyncpg fastapi[standard] \ pathvalidate pdfplumber chromadb sqlmodel \ anthropic google-genai openai fastmcp dirtyjson RUN pip install docling --extra-index-url https://download.pytorch.org/whl/cpu diff --git a/Dockerfile.dev b/Dockerfile.dev index 873e5355..72732d7e 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -25,10 +25,10 @@ ENV TEMP_DIRECTORY=/tmp/presenton ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium # Install ollama -RUN curl -fsSL http://ollama.com/install.sh | sh +# RUN curl -fsSL http://ollama.com/install.sh | sh # Install dependencies for FastAPI -RUN pip install aiohttp aiomysql aiosqlite asyncpg fastapi[standard] \ +RUN pip install alembic aiohttp aiomysql aiosqlite asyncpg fastapi[standard] \ pathvalidate pdfplumber chromadb sqlmodel \ anthropic google-genai openai fastmcp dirtyjson RUN pip install docling --extra-index-url https://download.pytorch.org/whl/cpu diff --git a/docker-compose.yml b/docker-compose.yml index 1376740b..05c189f8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: volumes: - ./app_data:/app_data environment: + - MIGRATE_DATABASE_ON_STARTUP=true - CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS} - LLM=${LLM} - OPENAI_API_KEY=${OPENAI_API_KEY} @@ -56,6 +57,7 @@ services: volumes: - ./app_data:/app_data environment: + - MIGRATE_DATABASE_ON_STARTUP=true - CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS} - LLM=${LLM} - OPENAI_API_KEY=${OPENAI_API_KEY} @@ -92,6 +94,7 @@ services: - .:/app - ./app_data:/app_data environment: + - MIGRATE_DATABASE_ON_STARTUP=true - CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS} - LLM=${LLM} - OPENAI_API_KEY=${OPENAI_API_KEY} @@ -135,6 +138,7 @@ services: - .:/app - ./app_data:/app_data environment: + - MIGRATE_DATABASE_ON_STARTUP=true - CAN_CHANGE_KEYS=${CAN_CHANGE_KEYS} - LLM=${LLM} - OPENAI_API_KEY=${OPENAI_API_KEY} diff --git a/electron/app/main.ts b/electron/app/main.ts index e86b9f24..5e6dea13 100644 --- a/electron/app/main.ts +++ b/electron/app/main.ts @@ -1,5 +1,5 @@ require("dotenv").config(); -import { app, BrowserWindow } from "electron"; +import { app, BrowserWindow, shell } from "electron"; import path from "path"; import fs from "fs"; import { findUnusedPorts, killProcess, setupEnv, setUserConfig } from "./utils"; @@ -9,6 +9,7 @@ import { appDataDir, baseDir, ensureDirectoriesExist, fastapiDir, isDev, localho import { setupIpcHandlers } from "./ipc"; import { setupLibreOfficeInstallHandlers } from "./ipc/libreoffice_install_handlers"; import { checkLibreOfficeBeforeWindow, getSofficePath } from "./utils/libreoffice-check"; +import { startUpdateChecker, stopUpdateChecker } from "./utils/update-checker"; var win: BrowserWindow | undefined; @@ -51,6 +52,16 @@ const createWindow = () => { preload: path.join(__dirname, 'preloads/index.js'), }, }); + + // Open external links (e.g. "Download update") in the system browser so the user + // sees download progress and can manage downloads normally. + win.webContents.setWindowOpenHandler(({ url }) => { + if (url.startsWith("http://") || url.startsWith("https://")) { + shell.openExternal(url); + return { action: "deny" }; + } + return { action: "allow" }; + }); }; async function startServers(fastApiPort: number, nextjsPort: number) { @@ -90,6 +101,7 @@ async function startServers(fastApiPort: number, nextjsPort: number) { APP_DATA_DIRECTORY: appDataDir, TEMP_DIRECTORY: tempDir, USER_CONFIG_PATH: userConfigPath, + MIGRATE_DATABASE_ON_STARTUP: "True", // Resolved by libreoffice-check.ts at startup; lets Python invoke the // exact binary path instead of relying on the system PATH. SOFFICE_PATH: getSofficePath(), @@ -180,9 +192,16 @@ app.whenReady().then(async () => { await startServers(fastApiPort, nextjsPort); win?.loadURL(`${localhost}:${nextjsPort}`); + + // Begin polling the version server for available updates + if (win) { + process.stderr.write("[Presenton] Starting update checker...\n"); + startUpdateChecker(win); + } }); app.on("window-all-closed", async () => { + stopUpdateChecker(); await stopServers(); app.quit(); }); diff --git a/electron/app/utils/update-checker.ts b/electron/app/utils/update-checker.ts new file mode 100644 index 00000000..c2caf67d --- /dev/null +++ b/electron/app/utils/update-checker.ts @@ -0,0 +1,257 @@ +import { net } from "electron"; +import { app, BrowserWindow } from "electron"; +import { isDev } from "./constants"; + +/** + * Version check URL — GitHub raw version.json (no API required). + * Override with UPDATE_SERVER_URL for local testing. + */ +const VERSION_JSON_URL = + process.env.UPDATE_SERVER_URL || + "https://raw.githubusercontent.com/presenton/presenton/refs/heads/main/electron/version.json"; + +const CURRENT_VERSION = app.getVersion(); + +/** Maximum number of fetch attempts (polls). */ +const MAX_ATTEMPTS = 3; + +/** Wait 2 minutes after load before first poll (10s in dev for testing). */ +const INITIAL_DELAY_MS = isDev ? 10 * 1_000 : 2 * 60 * 1_000; + +/** 1 minute between poll attempts (5s in dev for testing). */ +const POLL_INTERVAL_MS = isDev ? 5 * 1_000 : 1 * 60 * 1_000; + +/** Short delay before injecting banner to allow React/Next.js to mount. */ +const INJECT_DELAY_MS = isDev ? 500 : 1_000; + +function log(msg: string): void { + const line = `[UpdateChecker] ${msg}\n`; + process.stderr.write(line); + console.log(`[UpdateChecker] ${msg}`); +} + +interface VersionResponse { + version: string; + downloads: { + linux: string; + mac: string; + windows: string; + }; +} + +function getDownloadUrlForPlatform(downloads: VersionResponse["downloads"]): string { + const platform = process.platform; + if (platform === "darwin") return downloads.mac; + if (platform === "win32") return downloads.windows; + return downloads.linux; +} + +/** + * Simple semver comparison that strips pre-release labels for numeric comparison. + * Returns true if `remote` is strictly newer than `current`. + */ +function isNewerVersion(current: string, remote: string): boolean { + const toNumbers = (v: string) => + v + .replace(/[^0-9.]/g, "") + .split(".") + .map(Number); + + const curr = toNumbers(current); + const rem = toNumbers(remote); + const len = Math.max(curr.length, rem.length); + + for (let i = 0; i < len; i++) { + const c = curr[i] ?? 0; + const r = rem[i] ?? 0; + if (r > c) return true; + if (r < c) return false; + } + return false; +} + +async function fetchVersionInfo(): Promise { + try { + log(`Fetching ${VERSION_JSON_URL}...`); + const response = await net.fetch(VERSION_JSON_URL, { + method: "GET", + headers: { "User-Agent": `Presenton/${CURRENT_VERSION}` }, + }); + if (!response.ok) { + log(`Fetch failed: HTTP ${response.status}`); + return null; + } + const data = (await response.json()) as VersionResponse; + log(`Fetched version: ${data.version}`); + return data; + } catch (err) { + log(`Fetch error: ${err}`); + return null; + } +} + +/** Pending update to re-inject on navigation (production: React/Next.js may replace DOM). */ +let pendingUpdate: { version: string; downloadUrl: string } | null = null; + +/** + * Schedules banner injection after INJECT_DELAY_MS so React/Next.js can mount first. + * In production (.deb), the DOM may not be ready when did-finish-load fires. + */ +function scheduleBannerInjection( + win: BrowserWindow, + version: string, + downloadUrl: string +): void { + pendingUpdate = { version, downloadUrl }; + setTimeout(() => { + if (win.isDestroyed() || !pendingUpdate) return; + log(`Injecting banner now`); + injectUpdateBanner(win, pendingUpdate.version, pendingUpdate.downloadUrl); + }, INJECT_DELAY_MS); +} + +/** + * Injects a Cursor-style update banner at the bottom of the renderer window. + * Safe to call multiple times; a second call while the banner is visible is a no-op. + */ +function injectUpdateBanner( + win: BrowserWindow, + latest: string, + downloadUrl: string, + releaseNotes?: string +): void { + const notesHtml = releaseNotes + ? `${releaseNotes}` + : ""; + + const script = /* js */ ` + (function () { + if (document.getElementById('__presenton_update_banner__')) return; + + const banner = document.createElement('div'); + banner.id = '__presenton_update_banner__'; + banner.style.cssText = [ + 'position:fixed', + 'bottom:0', + 'left:0', + 'right:0', + 'background:#1e1e2e', + 'color:#cdd6f4', + 'display:flex', + 'align-items:center', + 'justify-content:space-between', + 'padding:10px 18px', + 'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif', + 'font-size:13px', + 'z-index:2147483647', + 'border-top:1px solid #313244', + 'box-shadow:0 -4px 16px rgba(0,0,0,0.35)', + 'gap:12px', + ].join(';'); + + banner.innerHTML = \` + + + + Presenton ${latest} is available + — you have ${CURRENT_VERSION} + + ${notesHtml} + +
+ Download update + +
+ \`; + + document.body.appendChild(banner); + })(); + `; + + win.webContents.executeJavaScript(script).catch((err) => { + log(`Banner injection failed: ${err}`); + }); +} + +/** + * Polls for version info up to MAX_ATTEMPTS times with 1 min between attempts. + * Stops as soon as a successful response is received or all attempts are exhausted. + */ +async function checkForUpdatesWithRetry(win: BrowserWindow): Promise { + log(`Starting check (current: ${CURRENT_VERSION})`); + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + if (win.isDestroyed()) { + log("Window destroyed, aborting"); + return; + } + + log(`Attempt ${attempt}/${MAX_ATTEMPTS}`); + const data = await fetchVersionInfo(); + + if (data) { + const newer = isNewerVersion(CURRENT_VERSION, data.version); + log(`Remote ${data.version} vs current ${CURRENT_VERSION} -> newer? ${newer}`); + if (newer) { + const downloadUrl = getDownloadUrlForPlatform(data.downloads); + log(`Injecting banner for ${data.version} (after ${INJECT_DELAY_MS}ms delay)`); + scheduleBannerInjection(win, data.version, downloadUrl); + } else { + log("No update needed, skipping banner"); + } + return; + } + + // Wait 1 minute before the next poll (skip delay after the last attempt) + if (attempt < MAX_ATTEMPTS) { + log(`Next poll in ${POLL_INTERVAL_MS / 1_000}s...`); + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + } + } + log("All attempts failed, no update info"); +} + +/** + * Starts the update checker. + * Waits 2 minutes after load, then polls 3 times with 1 min interval. + * Re-injects banner on every navigation (handles Next.js client routing). + */ +export function startUpdateChecker(win: BrowserWindow): void { + log("Registered, waiting for did-finish-load"); + let hasRunCheck = false; + + const onLoad = () => { + if (pendingUpdate) { + log("did-finish-load (navigation), re-injecting banner"); + scheduleBannerInjection(win, pendingUpdate.version, pendingUpdate.downloadUrl); + } else if (!hasRunCheck) { + hasRunCheck = true; + log(`did-finish-load fired, first poll in ${INITIAL_DELAY_MS / 1_000}s`); + setTimeout(() => { + if (win.isDestroyed()) return; + checkForUpdatesWithRetry(win); + }, INITIAL_DELAY_MS); + } + }; + + if (!win.webContents.isLoading()) { + log(`Page already loaded, first poll in ${INITIAL_DELAY_MS / 1_000}s`); + hasRunCheck = true; + setTimeout(() => { + if (win.isDestroyed()) return; + checkForUpdatesWithRetry(win); + }, INITIAL_DELAY_MS); + } + win.webContents.on("did-finish-load", onLoad); +} + +export function stopUpdateChecker(): void { + pendingUpdate = null; +} diff --git a/electron/package-lock.json b/electron/package-lock.json index ce9ae9d8..59e05b9f 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -1,12 +1,12 @@ { "name": "presenton", - "version": "0.6.1-beta", + "version": "0.6.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "presenton", - "version": "0.6.1-beta", + "version": "0.6.0-beta", "hasInstallScript": true, "dependencies": { "@tailwindcss/cli": "^4.1.5", diff --git a/electron/package.json b/electron/package.json index 59480128..f0352d6c 100644 --- a/electron/package.json +++ b/electron/package.json @@ -1,7 +1,7 @@ { "name": "presenton", "productName": "Presenton Open Source", - "version": "0.6.1-beta", + "version": "0.6.0-beta", "main": "app_dist/main.js", "description": "Open-Source AI Presentation Generator", "homepage": "https://presenton.ai", @@ -35,7 +35,7 @@ "build:css": "tailwindcss -i ./resources/ui/assets/css/tailwind.import.css -o ./resources/ui/assets/css/tailwind.css --watch", "build:vectorstore": "cd servers/fastapi && uv run python build_vectorstore.py", "build:nextjs": "rm -rf resources/nextjs && cd servers/nextjs && cross-env BUILD_TARGET=electron npm run build && cp -r .next-build ../../resources/nextjs && cp -r app/presentation-templates ../../resources/nextjs/presentation-templates", - "build:fastapi": "rm -rf resources/fastapi && npm run build:vectorstore && cd servers/fastapi && uv run python -m PyInstaller --distpath ../../resources server.spec", + "build:fastapi": "rm -rf resources/fastapi && npm run build:vectorstore && (cp ../servers/fastapi/alembic/versions/*.py servers/fastapi/alembic/versions/ 2>/dev/null || true) && cd servers/fastapi && uv run python -m PyInstaller --distpath ../../resources server.spec", "generate:version": "node generate_update.js", "build:electron": "npm run generate:version && rm -rf app_dist && tsc && node build.js", "build:all": "npm run clean:build && npm run setup:env && npm run build:ts && npm run install:pyinstaller && npm run build:nextjs && npm run build:fastapi && npm run build:electron", diff --git a/electron/servers/fastapi/alembic.ini b/electron/servers/fastapi/alembic.ini new file mode 100644 index 00000000..816710ec --- /dev/null +++ b/electron/servers/fastapi/alembic.ini @@ -0,0 +1,41 @@ +# Alembic configuration for CLI usage (e.g. alembic revision --autogenerate) +# migrations.py sets these programmatically when running at app startup. + +[alembic] +script_location = alembic +# sqlalchemy.url is overridden by env.py (uses DATABASE_URL or APP_DATA_DIRECTORY) +sqlalchemy.url = sqlite:///placeholder + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/electron/servers/fastapi/alembic/env.py b/electron/servers/fastapi/alembic/env.py new file mode 100644 index 00000000..8f77371d --- /dev/null +++ b/electron/servers/fastapi/alembic/env.py @@ -0,0 +1,94 @@ +import os +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlmodel import SQLModel + +# Make sure all models can be imported when alembic runs standalone. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +# Import every SQL model so they register with SQLModel.metadata before +# autogenerate or migration execution reads it. +from models.sql.async_presentation_generation_status import ( # noqa: F401, E402 + AsyncPresentationGenerationTaskModel, +) +from models.sql.image_asset import ImageAsset # noqa: F401, E402 +from models.sql.key_value import KeyValueSqlModel # noqa: F401, E402 +from models.sql.ollama_pull_status import OllamaPullStatus # noqa: F401, E402 +from models.sql.presentation import PresentationModel # noqa: F401, E402 +from models.sql.presentation_layout_code import ( # noqa: F401, E402 + PresentationLayoutCodeModel, +) +from models.sql.slide import SlideModel # noqa: F401, E402 +from models.sql.template import TemplateModel # noqa: F401, E402 +from models.sql.webhook_subscription import WebhookSubscription # noqa: F401, E402 + +alembic_config = context.config + +if alembic_config.config_file_name is not None: + fileConfig(alembic_config.config_file_name) + +target_metadata = SQLModel.metadata + + +def _get_url() -> str: + """ + Prefer the URL injected by migrations.py via config.set_main_option, + falling back to the DATABASE_URL environment variable or a local SQLite DB. + """ + configured = alembic_config.get_main_option("sqlalchemy.url") + if configured: + return configured + + from utils.db_utils import get_database_url_and_connect_args + + url, _ = get_database_url_and_connect_args() + return ( + url + .replace("sqlite+aiosqlite://", "sqlite:///") + .replace("postgresql+asyncpg://", "postgresql://") + .replace("mysql+aiomysql://", "mysql://") + ) + + +def run_migrations_offline() -> None: + """Generate SQL script without connecting to the database.""" + url = _get_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations against the live database.""" + configuration = dict(alembic_config.get_section(alembic_config.config_ini_section) or {}) + configuration["sqlalchemy.url"] = _get_url() + + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/electron/servers/fastapi/alembic/script.py.mako b/electron/servers/fastapi/alembic/script.py.mako new file mode 100644 index 00000000..6ce33510 --- /dev/null +++ b/electron/servers/fastapi/alembic/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/electron/servers/fastapi/alembic/versions/.gitkeep b/electron/servers/fastapi/alembic/versions/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/electron/servers/fastapi/alembic/versions/00b3c27a13bc_init.py b/electron/servers/fastapi/alembic/versions/00b3c27a13bc_init.py new file mode 100644 index 00000000..17fb3cc2 --- /dev/null +++ b/electron/servers/fastapi/alembic/versions/00b3c27a13bc_init.py @@ -0,0 +1,135 @@ +"""init + +Revision ID: 00b3c27a13bc +Revises: +Create Date: 2026-03-08 19:12:45.478149 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = '00b3c27a13bc' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('async_presentation_generation_tasks', + sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('message', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('error', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('data', sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('imageasset', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('is_uploaded', sa.Boolean(), nullable=False), + sa.Column('path', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('extras', sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('keyvaluesqlmodel', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('value', sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_keyvaluesqlmodel_key'), 'keyvaluesqlmodel', ['key'], unique=False) + op.create_table('ollamapullstatus', + sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('last_updated', sa.DateTime(), nullable=True), + sa.Column('status', sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('presentation_layout_codes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('presentation', sa.Uuid(), nullable=False), + sa.Column('layout_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('layout_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('layout_code', sa.Text(), nullable=True), + sa.Column('fonts', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_presentation_layout_codes_presentation'), 'presentation_layout_codes', ['presentation'], unique=False) + op.create_table('presentations', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('content', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('n_slides', sa.Integer(), nullable=False), + sa.Column('language', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('file_paths', sa.JSON(), nullable=True), + sa.Column('outlines', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('layout', sa.JSON(), nullable=True), + sa.Column('structure', sa.JSON(), nullable=True), + sa.Column('instructions', sa.String(), nullable=True), + sa.Column('tone', sa.String(), nullable=True), + sa.Column('verbosity', sa.String(), nullable=True), + sa.Column('include_table_of_contents', sa.Boolean(), nullable=True), + sa.Column('include_title_slide', sa.Boolean(), nullable=True), + sa.Column('web_search', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('templates', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('webhook_subscriptions', + sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('url', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('secret', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('event', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_webhook_subscriptions_event'), 'webhook_subscriptions', ['event'], unique=False) + op.create_table('slides', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('presentation', sa.Uuid(), nullable=True), + sa.Column('layout_group', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('layout', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('index', sa.Integer(), nullable=False), + sa.Column('content', sa.JSON(), nullable=True), + sa.Column('html_content', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('speaker_note', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('properties', sa.JSON(), nullable=True), + sa.ForeignKeyConstraint(['presentation'], ['presentations.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_slides_presentation'), 'slides', ['presentation'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_slides_presentation'), table_name='slides') + op.drop_table('slides') + op.drop_index(op.f('ix_webhook_subscriptions_event'), table_name='webhook_subscriptions') + op.drop_table('webhook_subscriptions') + op.drop_table('templates') + op.drop_table('presentations') + op.drop_index(op.f('ix_presentation_layout_codes_presentation'), table_name='presentation_layout_codes') + op.drop_table('presentation_layout_codes') + op.drop_table('ollamapullstatus') + op.drop_index(op.f('ix_keyvaluesqlmodel_key'), table_name='keyvaluesqlmodel') + op.drop_table('keyvaluesqlmodel') + op.drop_table('imageasset') + op.drop_table('async_presentation_generation_tasks') + # ### end Alembic commands ### diff --git a/electron/servers/fastapi/api/lifespan.py b/electron/servers/fastapi/api/lifespan.py index 86f55379..6fe4e6c4 100644 --- a/electron/servers/fastapi/api/lifespan.py +++ b/electron/servers/fastapi/api/lifespan.py @@ -3,6 +3,7 @@ import os from fastapi import FastAPI +from migrations import migrate_database_on_startup from services.database import create_db_and_tables from utils.get_env import get_app_data_directory_env from utils.model_availability import ( @@ -14,10 +15,12 @@ from utils.model_availability import ( async def app_lifespan(_: FastAPI): """ Lifespan context manager for FastAPI application. - Initializes the application data directory and checks LLM model availability. - + Initializes the application data directory, runs Alembic migrations when + MIGRATE_DATABASE_ON_STARTUP=true, creates any missing tables, and checks + LLM model availability. """ os.makedirs(get_app_data_directory_env(), exist_ok=True) + await migrate_database_on_startup() await create_db_and_tables() await check_llm_and_image_provider_api_or_model_availability() yield diff --git a/electron/servers/fastapi/migrations.py b/electron/servers/fastapi/migrations.py new file mode 100644 index 00000000..0b2b578e --- /dev/null +++ b/electron/servers/fastapi/migrations.py @@ -0,0 +1,40 @@ +import asyncio +from pathlib import Path + +from alembic import command +from alembic.config import Config + +from utils.db_utils import get_database_url_and_connect_args +from utils.get_env import get_migrate_database_on_startup_env + + +async def migrate_database_on_startup() -> None: + if get_migrate_database_on_startup_env() not in ["true", "True"]: + return + + try: + await asyncio.to_thread(_run_migrations) + print("Migrations run successfully", flush=True) + except Exception as exc: + print(f"Error running migrations: {exc}", flush=True) + + +def _run_migrations() -> None: + # migrations.py lives at servers/fastapi/migrations.py + # so parents[0] = servers/fastapi/, where alembic/ lives alongside it. + base_dir = Path(__file__).resolve().parents[0] + config = Config() + config.set_main_option("script_location", str(base_dir / "alembic")) + + database_url, _ = get_database_url_and_connect_args() + + # Alembic uses synchronous engines; strip async driver prefixes. + database_url = ( + database_url + .replace("sqlite+aiosqlite://", "sqlite:///") + .replace("postgresql+asyncpg://", "postgresql://") + .replace("mysql+aiomysql://", "mysql://") + ) + + config.set_main_option("sqlalchemy.url", database_url) + command.upgrade(config, "head") diff --git a/electron/servers/fastapi/placeholder b/electron/servers/fastapi/placeholder new file mode 100644 index 0000000000000000000000000000000000000000..aa73729d84185a6c6e30386b3f7d5b2846669bf4 GIT binary patch literal 12288 zcmeI#KTE?v7zXgWDry2D-MYSGLPXHcf@cU=jH$gwq0^Dp2*Lc(HZ5Is^CS7q9L*IA zlBJVNd0w~+PmViszfA9V6lQfw&(*w0YU;Bt&uMp<={@p3Ec)0a{ip>n(k%is5kOB%gU#8y?D!;E?y!Dq!l1q5t34--~-e00Izz00bZa0SG_< L0uX=z1U3RccL-Qe literal 0 HcmV?d00001 diff --git a/electron/servers/fastapi/pyproject.toml b/electron/servers/fastapi/pyproject.toml index 286cfac3..18efd476 100644 --- a/electron/servers/fastapi/pyproject.toml +++ b/electron/servers/fastapi/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "Add your description here" requires-python = ">=3.11,<3.12" dependencies = [ + "alembic>=1.14.0", "aiohttp>=3.12.15", "aiomysql>=0.2.0", "aiosqlite>=0.21.0", diff --git a/electron/servers/fastapi/server.spec b/electron/servers/fastapi/server.spec index 940ac1e4..7fd17588 100644 --- a/electron/servers/fastapi/server.spec +++ b/electron/servers/fastapi/server.spec @@ -41,9 +41,11 @@ a = Analysis( datas=[ ('assets', 'assets'), ('static', 'static'), + ('alembic', 'alembic'), ] + datas_fastembed_cache + datas_fastembed + datas_fastembed_vs + datas_onnx + datas_pptx + datas_docx2everything + datas_greenlet + datas_docling + datas_docling_core + datas_docling_parse + datas_docling_ibm + datas_docx, hiddenimports=[ 'aiosqlite', + 'alembic', 'sqlite3', 'numpy', 'pandas', diff --git a/electron/servers/fastapi/services/pptx_presentation_creator.py b/electron/servers/fastapi/services/pptx_presentation_creator.py index cc939820..b27c6145 100644 --- a/electron/servers/fastapi/services/pptx_presentation_creator.py +++ b/electron/servers/fastapi/services/pptx_presentation_creator.py @@ -1,10 +1,11 @@ import os from typing import List, Optional -from urllib.parse import unquote from lxml import etree from services.html_to_text_runs_service import ( parse_html_text_to_text_runs as parse_inline_html_to_runs, ) +import tempfile +import zipfile from pptx import Presentation from pptx.shapes.autoshape import Shape @@ -17,7 +18,6 @@ from pptx.oxml.xmlchemy import OxmlElement from pptx.util import Pt from pptx.dml.color import RGBColor -from utils.path_helpers import get_resource_path from models.pptx_models import ( PptxAutoShapeBoxModel, @@ -50,29 +50,6 @@ import uuid BLANK_SLIDE_LAYOUT = 6 -def _http_url_to_local_path(image_path: str) -> Optional[str]: - """ - If image_path is an HTTP(S) URL that actually points to a local file path - (e.g. http://127.0.0.1:40001/C:/Users/.../image.png or http://host/home/user/...), - return the resolved absolute path; otherwise return None. - """ - if not (image_path.startswith("http://") or image_path.startswith("https://")): - return None - parts = image_path.split("/", 3) - if len(parts) < 4: - return None - decoded = unquote(parts[3]) - # Windows: path after domain is like "C:/Users/..." or "D:/..." - if len(decoded) >= 2 and decoded[1] == ":": - potential_path = os.path.normpath(decoded) - else: - # Unix: path after domain is like "home/user/..." -> /home/user/... - potential_path = "/" + decoded if not decoded.startswith("/") else decoded - if os.path.isabs(potential_path) and os.path.exists(potential_path): - return potential_path - return None - - class PptxPresentationCreator: def __init__(self, ppt_model: PptxPresentationModel, temp_dir: str): self._temp_dir = temp_dir @@ -91,6 +68,140 @@ class PptxPresentationCreator: parent.append(element) return element + + def fix_keynote_compatibility(self, pptx_path: str): + """Patch pptx XML for stricter parsers like Keynote.""" + PRESENTATION_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" + DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" + REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" + NOTES_MASTER_REL_TYPE = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" + ) + + def ensure_grp_sppr_xfrm(slide_path: str): + slide_tree = etree.parse(slide_path) + slide_root = slide_tree.getroot() + grp_sppr_elements = slide_root.findall( + f".//{{{PRESENTATION_NS}}}grpSpPr" + ) + changed = False + for grp_sppr in grp_sppr_elements: + xfrm = grp_sppr.find(f"{{{DRAWING_NS}}}xfrm") + if xfrm is None: + xfrm = etree.SubElement(grp_sppr, f"{{{DRAWING_NS}}}xfrm") + etree.SubElement(xfrm, f"{{{DRAWING_NS}}}off", x="0", y="0") + etree.SubElement(xfrm, f"{{{DRAWING_NS}}}ext", cx="0", cy="0") + etree.SubElement(xfrm, f"{{{DRAWING_NS}}}chOff", x="0", y="0") + etree.SubElement(xfrm, f"{{{DRAWING_NS}}}chExt", cx="0", cy="0") + changed = True + if changed: + slide_tree.write( + slide_path, + xml_declaration=True, + encoding="UTF-8", + standalone="yes", + ) + + with tempfile.TemporaryDirectory() as temp_dir: + extract_dir = os.path.join(temp_dir, "pptx_contents") + os.makedirs(extract_dir, exist_ok=True) + with zipfile.ZipFile(pptx_path, "r") as existing_zip: + existing_zip.extractall(extract_dir) + + ppt_dir = os.path.join(extract_dir, "ppt") + slides_dir = os.path.join(ppt_dir, "slides") + if os.path.isdir(slides_dir): + for file_name in os.listdir(slides_dir): + if file_name.endswith(".xml"): + ensure_grp_sppr_xfrm(os.path.join(slides_dir, file_name)) + + rels_path = os.path.join(ppt_dir, "_rels", "presentation.xml.rels") + presentation_path = os.path.join(ppt_dir, "presentation.xml") + if os.path.exists(rels_path) and os.path.exists(presentation_path): + rels_tree = etree.parse(rels_path) + rels_root = rels_tree.getroot() + rel_tag = f"{{{PACKAGE_REL_NS}}}Relationship" + notes_master_rel = None + existing_ids = set() + for rel in rels_root.findall(rel_tag): + rel_id = rel.get("Id") + if rel_id: + existing_ids.add(rel_id) + if rel.get("Type") == NOTES_MASTER_REL_TYPE: + notes_master_rel = rel + + notes_masters_dir = os.path.join(ppt_dir, "notesMasters") + has_notes_master = ( + os.path.isdir(notes_masters_dir) + and any( + name.endswith(".xml") for name in os.listdir(notes_masters_dir) + ) + ) + + if has_notes_master and notes_master_rel is None: + next_id = 1 + while f"rId{next_id}" in existing_ids: + next_id += 1 + notes_master_rel = etree.SubElement(rels_root, rel_tag) + notes_master_rel.set("Id", f"rId{next_id}") + notes_master_rel.set("Type", NOTES_MASTER_REL_TYPE) + notes_master_rel.set( + "Target", "notesMasters/notesMaster1.xml" + ) + rels_tree.write( + rels_path, + xml_declaration=True, + encoding="UTF-8", + standalone="yes", + ) + + if has_notes_master and notes_master_rel is not None: + presentation_tree = etree.parse(presentation_path) + presentation_root = presentation_tree.getroot() + notes_master_id_lst = presentation_root.find( + f"{{{PRESENTATION_NS}}}notesMasterIdLst" + ) + if notes_master_id_lst is None: + notes_master_id_lst = etree.Element( + f"{{{PRESENTATION_NS}}}notesMasterIdLst" + ) + sld_master_id_lst = presentation_root.find( + f"{{{PRESENTATION_NS}}}sldMasterIdLst" + ) + if sld_master_id_lst is not None: + insert_index = list(presentation_root).index( + sld_master_id_lst + ) + 1 + presentation_root.insert(insert_index, notes_master_id_lst) + else: + presentation_root.insert(0, notes_master_id_lst) + if not notes_master_id_lst.findall( + f"{{{PRESENTATION_NS}}}notesMasterId" + ): + notes_master_id = etree.SubElement( + notes_master_id_lst, + f"{{{PRESENTATION_NS}}}notesMasterId", + ) + notes_master_id.set( + f"{{{REL_NS}}}id", + notes_master_rel.get("Id"), + ) + presentation_tree.write( + presentation_path, + xml_declaration=True, + encoding="UTF-8", + standalone="yes", + ) + + with zipfile.ZipFile(pptx_path, "w", zipfile.ZIP_DEFLATED) as new_zip: + for root, _, files in os.walk(extract_dir): + for file_name in files: + full_path = os.path.join(root, file_name) + archive_name = os.path.relpath(full_path, extract_dir) + new_zip.write(full_path, archive_name) + + async def fetch_network_assets(self): image_urls = [] models_with_network_asset: List[PptxPictureBoxModel] = [] @@ -99,28 +210,6 @@ class PptxPresentationCreator: for each_shape in self._ppt_model.shapes: if isinstance(each_shape, PptxPictureBoxModel): image_path = each_shape.picture.path - - # Handle file:// URLs by converting to local path - if image_path.startswith("file://"): - image_path = image_path.replace("file:///", "") - # URL-decode the path (e.g. %20 -> space for macOS "Application Support") - image_path = unquote(image_path) - # Check if it's a Windows path (has colon at index 1) - if not (len(image_path) > 1 and image_path[1] == ':'): - image_path = '/' + image_path - each_shape.picture.path = image_path - each_shape.picture.is_network = False - continue - - # Handle /static/ URLs by converting to file path - if image_path.startswith("/static/"): - relative_static_path = image_path[len("/static/"):] - local_path = get_resource_path(os.path.join("static", relative_static_path)) - each_shape.picture.path = local_path - each_shape.picture.is_network = False - print(f"[PPTX] Converted /static/ URL: {image_path} -> {local_path}") - continue - if image_path.startswith("http"): if "app_data/" in image_path: relative_path = image_path.split("app_data/")[1] @@ -129,24 +218,6 @@ class PptxPresentationCreator: ) each_shape.picture.is_network = False continue - - # Handle HTTP URLs that point to local files (from Next.js server) - # Example: http://127.0.0.1:40001/home/user/.config/presenton/images/file.png - # Should become: /home/user/.config/presenton/images/file.png - if image_path.startswith('http://') or image_path.startswith('https://'): - # Extract the path after the domain - # Format: http://domain:port/actual/file/path - parts = image_path.split('/', 3) # ['http:', '', 'domain:port', 'actual/file/path'] - if len(parts) >= 4: - # Get the part after the domain and URL-decode it - potential_path = '/' + unquote(parts[3]) - # Check if it's an absolute file path - if os.path.isabs(potential_path) and os.path.exists(potential_path): - each_shape.picture.path = potential_path - each_shape.picture.is_network = False - print(f"[PPTX] Converted HTTP URL to local path: {image_path} -> {potential_path}") - continue - image_urls.append(image_path) models_with_network_asset.append(each_shape) @@ -154,34 +225,6 @@ class PptxPresentationCreator: for each_shape in each_slide.shapes: if isinstance(each_shape, PptxPictureBoxModel): image_path = each_shape.picture.path - - print(f"[PPTX] Processing image path: {image_path}") - - # Handle file:// URLs by converting to local path - if image_path.startswith("file://"): - original_path = image_path - image_path = image_path.replace("file:///", "") - # URL-decode the path (e.g. %20 -> space for macOS "Application Support") - image_path = unquote(image_path) - # Check if it's a Windows path (has colon at index 1) - if not (len(image_path) > 1 and image_path[1] == ':'): - image_path = '/' + image_path - each_shape.picture.path = image_path - each_shape.picture.is_network = False - print(f"[PPTX] Converted file:// URL: {original_path} -> {image_path}") - print(f"[PPTX] File exists after conversion: {os.path.exists(image_path)}") - continue - - # Handle /static/ URLs by converting to file path - if image_path.startswith("/static/"): - relative_static_path = image_path[len("/static/"):] - local_path = get_resource_path(os.path.join("static", relative_static_path)) - each_shape.picture.path = local_path - each_shape.picture.is_network = False - print(f"[PPTX] Converted /static/ URL: {image_path} -> {local_path}") - print(f"[PPTX] File exists after conversion: {os.path.exists(local_path)}") - continue - if image_path.startswith("http"): if "app_data" in image_path: relative_path = image_path.split("app_data/")[1] @@ -190,16 +233,6 @@ class PptxPresentationCreator: ) each_shape.picture.is_network = False continue - - # Handle HTTP URLs that point to local files (from Next.js server) - # e.g. http://127.0.0.1:40001/C:/Users/.../image.png (Windows) or http://host/home/user/... (Unix) - local_path = _http_url_to_local_path(image_path) - if local_path is not None: - each_shape.picture.path = local_path - each_shape.picture.is_network = False - print(f"[PPTX] Converted HTTP URL to local path: {image_path} -> {local_path}") - continue - image_urls.append(image_path) models_with_network_asset.append(each_shape) @@ -245,8 +278,6 @@ class PptxPresentationCreator: def add_and_populate_slide(self, slide_model: PptxSlideModel): slide = self._ppt.slides.add_slide(self._ppt.slide_layouts[BLANK_SLIDE_LAYOUT]) - - print(f"[PPTX] Adding slide with {len(slide_model.shapes)} shapes") if slide_model.background: self.apply_fill_to_shape(slide.background, slide_model.background) @@ -256,8 +287,6 @@ class PptxPresentationCreator: for shape_model in slide_model.shapes: model_type = type(shape_model) - - print(f"[PPTX] Processing shape type: {model_type.__name__}") if model_type is PptxPictureBoxModel: self.add_picture(slide, shape_model) @@ -283,15 +312,6 @@ class PptxPresentationCreator: def add_picture(self, slide: Slide, picture_model: PptxPictureBoxModel): image_path = picture_model.picture.path - - # Handle HTTP URLs that point to local files (e.g. Windows: http://host/C:/Users/.../image.png) - resolved = _http_url_to_local_path(image_path) - if resolved is not None: - image_path = resolved - - print(f"[PPTX] Adding picture: {image_path}") - print(f"[PPTX] File exists: {os.path.exists(image_path)}") - if ( picture_model.clip or picture_model.border_radius @@ -302,9 +322,8 @@ class PptxPresentationCreator: ): try: image = Image.open(image_path) - except Exception as e: - print(f"[PPTX] ERROR: Could not open image: {image_path}") - print(f"[PPTX] ERROR: Exception: {e}") + except Exception: + print(f"Could not open image: {image_path}") return image = image.convert("RGBA") @@ -339,13 +358,7 @@ class PptxPresentationCreator: picture_model.position, picture_model.margin ) - try: - slide.shapes.add_picture(image_path, *margined_position.to_pt_list()) - print(f"[PPTX] Successfully added picture to slide") - except Exception as e: - print(f"[PPTX] ERROR: Failed to add picture to slide: {e}") - print(f"[PPTX] ERROR: Image path: {image_path}") - print(f"[PPTX] ERROR: File exists: {os.path.exists(image_path)}") + slide.shapes.add_picture(image_path, *margined_position.to_pt_list()) def add_autoshape(self, slide: Slide, autoshape_box_model: PptxAutoShapeBoxModel): position = autoshape_box_model.position @@ -607,3 +620,4 @@ class PptxPresentationCreator: def save(self, path: str): self._ppt.save(path) + self.fix_keynote_compatibility(path) diff --git a/electron/servers/fastapi/utils/get_env.py b/electron/servers/fastapi/utils/get_env.py index e7454f87..74cf2e1f 100644 --- a/electron/servers/fastapi/utils/get_env.py +++ b/electron/servers/fastapi/utils/get_env.py @@ -138,3 +138,7 @@ def get_codex_account_id_env(): def get_codex_model_env(): return os.getenv("CODEX_MODEL") + + +def get_migrate_database_on_startup_env(): + return os.getenv("MIGRATE_DATABASE_ON_STARTUP") diff --git a/electron/servers/fastapi/uv.lock b/electron/servers/fastapi/uv.lock index 684fd397..e31671a0 100644 --- a/electron/servers/fastapi/uv.lock +++ b/electron/servers/fastapi/uv.lock @@ -106,6 +106,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, ] +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + [[package]] name = "altgraph" version = "0.17.5" @@ -1129,6 +1143,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" }, ] +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -1655,6 +1681,7 @@ dependencies = [ { name = "aiohttp" }, { name = "aiomysql" }, { name = "aiosqlite" }, + { name = "alembic" }, { name = "anthropic" }, { name = "asyncpg" }, { name = "dirtyjson" }, @@ -1681,6 +1708,7 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.12.15" }, { name = "aiomysql", specifier = ">=0.2.0" }, { name = "aiosqlite", specifier = ">=0.21.0" }, + { name = "alembic", specifier = ">=1.14.0" }, { name = "anthropic", specifier = ">=0.60.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "dirtyjson", specifier = ">=1.0.8" }, diff --git a/electron/version.json b/electron/version.json index fe7799fb..20020a9c 100644 --- a/electron/version.json +++ b/electron/version.json @@ -1,8 +1,8 @@ { - "version": "0.6.1-beta", + "version": "0.6.0-beta", "downloads": { - "linux": "https://github.com/presenton/presenton/releases/download/electron-v0.6.1-beta/Presenton-0.6.1-beta.deb", - "mac": "https://github.com/presenton/presenton/releases/download/electron-v0.6.1-beta/Presenton-0.6.1-beta.dmg", - "windows": "https://github.com/presenton/presenton/releases/download/electron-v0.6.1-beta/Presenton-0.6.1-beta.exe" + "linux": "https://github.com/presenton/presenton/releases/download/electron-v0.6.0-beta/Presenton-0.6.0-beta.deb", + "mac": "https://github.com/presenton/presenton/releases/download/electron-v0.6.0-beta/Presenton-0.6.0-beta.dmg", + "windows": "https://github.com/presenton/presenton/releases/download/electron-v0.6.0-beta/Presenton-0.6.0-beta.exe" } } \ No newline at end of file diff --git a/servers/fastapi/alembic.ini b/servers/fastapi/alembic.ini new file mode 100644 index 00000000..816710ec --- /dev/null +++ b/servers/fastapi/alembic.ini @@ -0,0 +1,41 @@ +# Alembic configuration for CLI usage (e.g. alembic revision --autogenerate) +# migrations.py sets these programmatically when running at app startup. + +[alembic] +script_location = alembic +# sqlalchemy.url is overridden by env.py (uses DATABASE_URL or APP_DATA_DIRECTORY) +sqlalchemy.url = sqlite:///placeholder + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARNING +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARNING +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/servers/fastapi/alembic/env.py b/servers/fastapi/alembic/env.py new file mode 100644 index 00000000..8f77371d --- /dev/null +++ b/servers/fastapi/alembic/env.py @@ -0,0 +1,94 @@ +import os +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import engine_from_config, pool +from sqlmodel import SQLModel + +# Make sure all models can be imported when alembic runs standalone. +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +# Import every SQL model so they register with SQLModel.metadata before +# autogenerate or migration execution reads it. +from models.sql.async_presentation_generation_status import ( # noqa: F401, E402 + AsyncPresentationGenerationTaskModel, +) +from models.sql.image_asset import ImageAsset # noqa: F401, E402 +from models.sql.key_value import KeyValueSqlModel # noqa: F401, E402 +from models.sql.ollama_pull_status import OllamaPullStatus # noqa: F401, E402 +from models.sql.presentation import PresentationModel # noqa: F401, E402 +from models.sql.presentation_layout_code import ( # noqa: F401, E402 + PresentationLayoutCodeModel, +) +from models.sql.slide import SlideModel # noqa: F401, E402 +from models.sql.template import TemplateModel # noqa: F401, E402 +from models.sql.webhook_subscription import WebhookSubscription # noqa: F401, E402 + +alembic_config = context.config + +if alembic_config.config_file_name is not None: + fileConfig(alembic_config.config_file_name) + +target_metadata = SQLModel.metadata + + +def _get_url() -> str: + """ + Prefer the URL injected by migrations.py via config.set_main_option, + falling back to the DATABASE_URL environment variable or a local SQLite DB. + """ + configured = alembic_config.get_main_option("sqlalchemy.url") + if configured: + return configured + + from utils.db_utils import get_database_url_and_connect_args + + url, _ = get_database_url_and_connect_args() + return ( + url + .replace("sqlite+aiosqlite://", "sqlite:///") + .replace("postgresql+asyncpg://", "postgresql://") + .replace("mysql+aiomysql://", "mysql://") + ) + + +def run_migrations_offline() -> None: + """Generate SQL script without connecting to the database.""" + url = _get_url() + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + """Run migrations against the live database.""" + configuration = dict(alembic_config.get_section(alembic_config.config_ini_section) or {}) + configuration["sqlalchemy.url"] = _get_url() + + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=target_metadata, + compare_type=True, + ) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/servers/fastapi/alembic/script.py.mako b/servers/fastapi/alembic/script.py.mako new file mode 100644 index 00000000..6ce33510 --- /dev/null +++ b/servers/fastapi/alembic/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/servers/fastapi/alembic/versions/.gitkeep b/servers/fastapi/alembic/versions/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/servers/fastapi/alembic/versions/fd2ab04834cc_init.py b/servers/fastapi/alembic/versions/fd2ab04834cc_init.py new file mode 100644 index 00000000..cc863720 --- /dev/null +++ b/servers/fastapi/alembic/versions/fd2ab04834cc_init.py @@ -0,0 +1,136 @@ +"""init + +Revision ID: fd2ab04834cc +Revises: +Create Date: 2026-03-08 19:10:59.637680 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +import sqlmodel + + +# revision identifiers, used by Alembic. +revision: str = 'fd2ab04834cc' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('async_presentation_generation_tasks', + sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('status', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('message', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('error', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('data', sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('imageasset', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('is_uploaded', sa.Boolean(), nullable=False), + sa.Column('path', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('extras', sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('keyvaluesqlmodel', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('key', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('value', sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_keyvaluesqlmodel_key'), 'keyvaluesqlmodel', ['key'], unique=False) + op.create_table('ollamapullstatus', + sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('last_updated', sa.DateTime(), nullable=True), + sa.Column('status', sa.JSON(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('presentation_layout_codes', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('presentation', sa.Uuid(), nullable=False), + sa.Column('layout_id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('layout_name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('layout_code', sa.Text(), nullable=True), + sa.Column('fonts', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_presentation_layout_codes_presentation'), 'presentation_layout_codes', ['presentation'], unique=False) + op.create_table('presentations', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('content', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('n_slides', sa.Integer(), nullable=False), + sa.Column('language', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('title', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('file_paths', sa.JSON(), nullable=True), + sa.Column('outlines', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('layout', sa.JSON(), nullable=True), + sa.Column('structure', sa.JSON(), nullable=True), + sa.Column('theme', sa.JSON(), nullable=True), + sa.Column('instructions', sa.String(), nullable=True), + sa.Column('tone', sa.String(), nullable=True), + sa.Column('verbosity', sa.String(), nullable=True), + sa.Column('include_table_of_contents', sa.Boolean(), nullable=True), + sa.Column('include_title_slide', sa.Boolean(), nullable=True), + sa.Column('web_search', sa.Boolean(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('templates', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('name', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('description', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('webhook_subscriptions', + sa.Column('id', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('url', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('secret', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('event', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_webhook_subscriptions_event'), 'webhook_subscriptions', ['event'], unique=False) + op.create_table('slides', + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('presentation', sa.Uuid(), nullable=True), + sa.Column('layout_group', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('layout', sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column('index', sa.Integer(), nullable=False), + sa.Column('content', sa.JSON(), nullable=True), + sa.Column('html_content', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('speaker_note', sqlmodel.sql.sqltypes.AutoString(), nullable=True), + sa.Column('properties', sa.JSON(), nullable=True), + sa.ForeignKeyConstraint(['presentation'], ['presentations.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_slides_presentation'), 'slides', ['presentation'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_slides_presentation'), table_name='slides') + op.drop_table('slides') + op.drop_index(op.f('ix_webhook_subscriptions_event'), table_name='webhook_subscriptions') + op.drop_table('webhook_subscriptions') + op.drop_table('templates') + op.drop_table('presentations') + op.drop_index(op.f('ix_presentation_layout_codes_presentation'), table_name='presentation_layout_codes') + op.drop_table('presentation_layout_codes') + op.drop_table('ollamapullstatus') + op.drop_index(op.f('ix_keyvaluesqlmodel_key'), table_name='keyvaluesqlmodel') + op.drop_table('keyvaluesqlmodel') + op.drop_table('imageasset') + op.drop_table('async_presentation_generation_tasks') + # ### end Alembic commands ### diff --git a/servers/fastapi/api/lifespan.py b/servers/fastapi/api/lifespan.py index 86f55379..6fe4e6c4 100644 --- a/servers/fastapi/api/lifespan.py +++ b/servers/fastapi/api/lifespan.py @@ -3,6 +3,7 @@ import os from fastapi import FastAPI +from migrations import migrate_database_on_startup from services.database import create_db_and_tables from utils.get_env import get_app_data_directory_env from utils.model_availability import ( @@ -14,10 +15,12 @@ from utils.model_availability import ( async def app_lifespan(_: FastAPI): """ Lifespan context manager for FastAPI application. - Initializes the application data directory and checks LLM model availability. - + Initializes the application data directory, runs Alembic migrations when + MIGRATE_DATABASE_ON_STARTUP=true, creates any missing tables, and checks + LLM model availability. """ os.makedirs(get_app_data_directory_env(), exist_ok=True) + await migrate_database_on_startup() await create_db_and_tables() await check_llm_and_image_provider_api_or_model_availability() yield diff --git a/servers/fastapi/migrations.py b/servers/fastapi/migrations.py new file mode 100644 index 00000000..0b2b578e --- /dev/null +++ b/servers/fastapi/migrations.py @@ -0,0 +1,40 @@ +import asyncio +from pathlib import Path + +from alembic import command +from alembic.config import Config + +from utils.db_utils import get_database_url_and_connect_args +from utils.get_env import get_migrate_database_on_startup_env + + +async def migrate_database_on_startup() -> None: + if get_migrate_database_on_startup_env() not in ["true", "True"]: + return + + try: + await asyncio.to_thread(_run_migrations) + print("Migrations run successfully", flush=True) + except Exception as exc: + print(f"Error running migrations: {exc}", flush=True) + + +def _run_migrations() -> None: + # migrations.py lives at servers/fastapi/migrations.py + # so parents[0] = servers/fastapi/, where alembic/ lives alongside it. + base_dir = Path(__file__).resolve().parents[0] + config = Config() + config.set_main_option("script_location", str(base_dir / "alembic")) + + database_url, _ = get_database_url_and_connect_args() + + # Alembic uses synchronous engines; strip async driver prefixes. + database_url = ( + database_url + .replace("sqlite+aiosqlite://", "sqlite:///") + .replace("postgresql+asyncpg://", "postgresql://") + .replace("mysql+aiomysql://", "mysql://") + ) + + config.set_main_option("sqlalchemy.url", database_url) + command.upgrade(config, "head") diff --git a/servers/fastapi/placeholder b/servers/fastapi/placeholder new file mode 100644 index 0000000000000000000000000000000000000000..aa73729d84185a6c6e30386b3f7d5b2846669bf4 GIT binary patch literal 12288 zcmeI#KTE?v7zXgWDry2D-MYSGLPXHcf@cU=jH$gwq0^Dp2*Lc(HZ5Is^CS7q9L*IA zlBJVNd0w~+PmViszfA9V6lQfw&(*w0YU;Bt&uMp<={@p3Ec)0a{ip>n(k%is5kOB%gU#8y?D!;E?y!Dq!l1q5t34--~-e00Izz00bZa0SG_< L0uX=z1U3RccL-Qe literal 0 HcmV?d00001 diff --git a/servers/fastapi/pyproject.toml b/servers/fastapi/pyproject.toml index c76aecaf..71922634 100644 --- a/servers/fastapi/pyproject.toml +++ b/servers/fastapi/pyproject.toml @@ -4,6 +4,7 @@ version = "0.1.0" description = "Add your description here" requires-python = ">=3.11,<3.12" dependencies = [ + "alembic>=1.14.0", "aiohttp>=3.12.15", "aiomysql>=0.2.0", "aiosqlite>=0.21.0", @@ -25,6 +26,9 @@ dependencies = [ "sqlmodel>=0.0.24", ] +[tool.uv] +index-strategy = "unsafe-best-match" + [[tool.uv.index]] url = "https://download.pytorch.org/whl/cpu" diff --git a/servers/fastapi/services/pptx_presentation_creator.py b/servers/fastapi/services/pptx_presentation_creator.py index 26b20b27..b27c6145 100644 --- a/servers/fastapi/services/pptx_presentation_creator.py +++ b/servers/fastapi/services/pptx_presentation_creator.py @@ -4,6 +4,8 @@ from lxml import etree from services.html_to_text_runs_service import ( parse_html_text_to_text_runs as parse_inline_html_to_runs, ) +import tempfile +import zipfile from pptx import Presentation from pptx.shapes.autoshape import Shape @@ -66,6 +68,140 @@ class PptxPresentationCreator: parent.append(element) return element + + def fix_keynote_compatibility(self, pptx_path: str): + """Patch pptx XML for stricter parsers like Keynote.""" + PRESENTATION_NS = "http://schemas.openxmlformats.org/presentationml/2006/main" + DRAWING_NS = "http://schemas.openxmlformats.org/drawingml/2006/main" + REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships" + PACKAGE_REL_NS = "http://schemas.openxmlformats.org/package/2006/relationships" + NOTES_MASTER_REL_TYPE = ( + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/notesMaster" + ) + + def ensure_grp_sppr_xfrm(slide_path: str): + slide_tree = etree.parse(slide_path) + slide_root = slide_tree.getroot() + grp_sppr_elements = slide_root.findall( + f".//{{{PRESENTATION_NS}}}grpSpPr" + ) + changed = False + for grp_sppr in grp_sppr_elements: + xfrm = grp_sppr.find(f"{{{DRAWING_NS}}}xfrm") + if xfrm is None: + xfrm = etree.SubElement(grp_sppr, f"{{{DRAWING_NS}}}xfrm") + etree.SubElement(xfrm, f"{{{DRAWING_NS}}}off", x="0", y="0") + etree.SubElement(xfrm, f"{{{DRAWING_NS}}}ext", cx="0", cy="0") + etree.SubElement(xfrm, f"{{{DRAWING_NS}}}chOff", x="0", y="0") + etree.SubElement(xfrm, f"{{{DRAWING_NS}}}chExt", cx="0", cy="0") + changed = True + if changed: + slide_tree.write( + slide_path, + xml_declaration=True, + encoding="UTF-8", + standalone="yes", + ) + + with tempfile.TemporaryDirectory() as temp_dir: + extract_dir = os.path.join(temp_dir, "pptx_contents") + os.makedirs(extract_dir, exist_ok=True) + with zipfile.ZipFile(pptx_path, "r") as existing_zip: + existing_zip.extractall(extract_dir) + + ppt_dir = os.path.join(extract_dir, "ppt") + slides_dir = os.path.join(ppt_dir, "slides") + if os.path.isdir(slides_dir): + for file_name in os.listdir(slides_dir): + if file_name.endswith(".xml"): + ensure_grp_sppr_xfrm(os.path.join(slides_dir, file_name)) + + rels_path = os.path.join(ppt_dir, "_rels", "presentation.xml.rels") + presentation_path = os.path.join(ppt_dir, "presentation.xml") + if os.path.exists(rels_path) and os.path.exists(presentation_path): + rels_tree = etree.parse(rels_path) + rels_root = rels_tree.getroot() + rel_tag = f"{{{PACKAGE_REL_NS}}}Relationship" + notes_master_rel = None + existing_ids = set() + for rel in rels_root.findall(rel_tag): + rel_id = rel.get("Id") + if rel_id: + existing_ids.add(rel_id) + if rel.get("Type") == NOTES_MASTER_REL_TYPE: + notes_master_rel = rel + + notes_masters_dir = os.path.join(ppt_dir, "notesMasters") + has_notes_master = ( + os.path.isdir(notes_masters_dir) + and any( + name.endswith(".xml") for name in os.listdir(notes_masters_dir) + ) + ) + + if has_notes_master and notes_master_rel is None: + next_id = 1 + while f"rId{next_id}" in existing_ids: + next_id += 1 + notes_master_rel = etree.SubElement(rels_root, rel_tag) + notes_master_rel.set("Id", f"rId{next_id}") + notes_master_rel.set("Type", NOTES_MASTER_REL_TYPE) + notes_master_rel.set( + "Target", "notesMasters/notesMaster1.xml" + ) + rels_tree.write( + rels_path, + xml_declaration=True, + encoding="UTF-8", + standalone="yes", + ) + + if has_notes_master and notes_master_rel is not None: + presentation_tree = etree.parse(presentation_path) + presentation_root = presentation_tree.getroot() + notes_master_id_lst = presentation_root.find( + f"{{{PRESENTATION_NS}}}notesMasterIdLst" + ) + if notes_master_id_lst is None: + notes_master_id_lst = etree.Element( + f"{{{PRESENTATION_NS}}}notesMasterIdLst" + ) + sld_master_id_lst = presentation_root.find( + f"{{{PRESENTATION_NS}}}sldMasterIdLst" + ) + if sld_master_id_lst is not None: + insert_index = list(presentation_root).index( + sld_master_id_lst + ) + 1 + presentation_root.insert(insert_index, notes_master_id_lst) + else: + presentation_root.insert(0, notes_master_id_lst) + if not notes_master_id_lst.findall( + f"{{{PRESENTATION_NS}}}notesMasterId" + ): + notes_master_id = etree.SubElement( + notes_master_id_lst, + f"{{{PRESENTATION_NS}}}notesMasterId", + ) + notes_master_id.set( + f"{{{REL_NS}}}id", + notes_master_rel.get("Id"), + ) + presentation_tree.write( + presentation_path, + xml_declaration=True, + encoding="UTF-8", + standalone="yes", + ) + + with zipfile.ZipFile(pptx_path, "w", zipfile.ZIP_DEFLATED) as new_zip: + for root, _, files in os.walk(extract_dir): + for file_name in files: + full_path = os.path.join(root, file_name) + archive_name = os.path.relpath(full_path, extract_dir) + new_zip.write(full_path, archive_name) + + async def fetch_network_assets(self): image_urls = [] models_with_network_asset: List[PptxPictureBoxModel] = [] @@ -484,3 +620,4 @@ class PptxPresentationCreator: def save(self, path: str): self._ppt.save(path) + self.fix_keynote_compatibility(path) diff --git a/servers/fastapi/utils/get_env.py b/servers/fastapi/utils/get_env.py index e7454f87..74cf2e1f 100644 --- a/servers/fastapi/utils/get_env.py +++ b/servers/fastapi/utils/get_env.py @@ -138,3 +138,7 @@ def get_codex_account_id_env(): def get_codex_model_env(): return os.getenv("CODEX_MODEL") + + +def get_migrate_database_on_startup_env(): + return os.getenv("MIGRATE_DATABASE_ON_STARTUP") diff --git a/servers/fastapi/uv.lock b/servers/fastapi/uv.lock index d3dcccac..f21c3a60 100644 --- a/servers/fastapi/uv.lock +++ b/servers/fastapi/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = "==3.11.*" resolution-markers = [ "platform_machine == 'aarch64' and sys_platform == 'linux'", @@ -106,6 +106,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" }, ] +[[package]] +name = "alembic" +version = "1.18.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -781,10 +795,10 @@ wheels = [ [[package]] name = "filelock" version = "3.18.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075, upload-time = "2025-03-14T07:11:40.47Z" } +source = { registry = "https://download.pytorch.org/whl/cpu" } +sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, + { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de" }, ] [[package]] @@ -834,10 +848,10 @@ wheels = [ [[package]] name = "fsspec" version = "2025.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8b/02/0835e6ab9cfc03916fe3f78c0956cfcdb6ff2669ffa6651065d5ebf7fc98/fsspec-2025.7.0.tar.gz", hash = "sha256:786120687ffa54b8283d942929540d8bc5ccfa820deb555a2b5d0ed2b737bf58", size = 304432, upload-time = "2025-07-15T16:05:21.19Z" } +source = { registry = "https://download.pytorch.org/whl/cpu" } +sdist = { url = "https://files.pythonhosted.org/packages/8b/02/0835e6ab9cfc03916fe3f78c0956cfcdb6ff2669ffa6651065d5ebf7fc98/fsspec-2025.7.0.tar.gz", hash = "sha256:786120687ffa54b8283d942929540d8bc5ccfa820deb555a2b5d0ed2b737bf58" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2f/e0/014d5d9d7a4564cf1c40b5039bc882db69fd881111e03ab3657ac0b218e2/fsspec-2025.7.0-py3-none-any.whl", hash = "sha256:8b012e39f63c7d5f10474de957f3ab793b47b45ae7d39f2fb735f8bbe25c0e21", size = 199597, upload-time = "2025-07-15T16:05:19.529Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e0/014d5d9d7a4564cf1c40b5039bc882db69fd881111e03ab3657ac0b218e2/fsspec-2025.7.0-py3-none-any.whl", hash = "sha256:8b012e39f63c7d5f10474de957f3ab793b47b45ae7d39f2fb735f8bbe25c0e21" }, ] [[package]] @@ -894,7 +908,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fc/2e/d4fcb2978f826358b673f779f78fa8a32ee37df11920dc2bb5589cbeecef/greenlet-3.2.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:784ae58bba89fa1fa5733d170d42486580cab9decda3484779f4759345b29822", size = 270219, upload-time = "2025-06-05T16:10:10.414Z" }, { url = "https://files.pythonhosted.org/packages/16/24/929f853e0202130e4fe163bc1d05a671ce8dcd604f790e14896adac43a52/greenlet-3.2.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0921ac4ea42a5315d3446120ad48f90c3a6b9bb93dd9b3cf4e4d84a66e42de83", size = 630383, upload-time = "2025-06-05T16:38:51.785Z" }, { url = "https://files.pythonhosted.org/packages/d1/b2/0320715eb61ae70c25ceca2f1d5ae620477d246692d9cc284c13242ec31c/greenlet-3.2.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:d2971d93bb99e05f8c2c0c2f4aa9484a18d98c4c3bd3c62b65b7e6ae33dfcfaf", size = 642422, upload-time = "2025-06-05T16:41:35.259Z" }, - { url = "https://files.pythonhosted.org/packages/bd/49/445fd1a210f4747fedf77615d941444349c6a3a4a1135bba9701337cd966/greenlet-3.2.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:c667c0bf9d406b77a15c924ef3285e1e05250948001220368e039b6aa5b5034b", size = 638375, upload-time = "2025-06-05T16:48:18.235Z" }, { url = "https://files.pythonhosted.org/packages/7e/c8/ca19760cf6eae75fa8dc32b487e963d863b3ee04a7637da77b616703bc37/greenlet-3.2.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:592c12fb1165be74592f5de0d70f82bc5ba552ac44800d632214b76089945147", size = 637627, upload-time = "2025-06-05T16:13:02.858Z" }, { url = "https://files.pythonhosted.org/packages/65/89/77acf9e3da38e9bcfca881e43b02ed467c1dedc387021fc4d9bd9928afb8/greenlet-3.2.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29e184536ba333003540790ba29829ac14bb645514fbd7e32af331e8202a62a5", size = 585502, upload-time = "2025-06-05T16:12:49.642Z" }, { url = "https://files.pythonhosted.org/packages/97/c6/ae244d7c95b23b7130136e07a9cc5aadd60d59b5951180dc7dc7e8edaba7/greenlet-3.2.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:93c0bb79844a367782ec4f429d07589417052e621aa39a5ac1fb99c5aa308edc", size = 1114498, upload-time = "2025-06-05T16:36:46.598Z" }, @@ -1091,13 +1104,12 @@ wheels = [ [[package]] name = "jinja2" version = "3.1.6" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://download.pytorch.org/whl/cpu" } dependencies = [ { name = "markupsafe" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, + { url = "https://download.pytorch.org/whl/jinja2-3.1.6-py3-none-any.whl" }, ] [[package]] @@ -1271,6 +1283,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fa/f9/1f0964c4f6c2be861c50db380c554fb8befbea98c6404744ce243a3c87ef/lxml-5.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:fd3be6481ef54b8cfd0e1e953323b7aa9d9789b94842d0e5b142ef4bb7999539", size = 3815197, upload-time = "2025-04-23T01:46:01.096Z" }, ] +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -1397,8 +1421,9 @@ dill = [ name = "mpmath" version = "1.3.0" source = { registry = "https://download.pytorch.org/whl/cpu" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f" } wheels = [ - { url = "https://download.pytorch.org/whl/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c" }, + { url = "https://files.pythonhosted.org/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c" }, ] [[package]] @@ -1449,10 +1474,10 @@ wheels = [ [[package]] name = "networkx" version = "3.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload-time = "2025-05-29T11:35:07.804Z" } +source = { registry = "https://download.pytorch.org/whl/cpu" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec", size = 2034406, upload-time = "2025-05-29T11:35:04.961Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/776adee7bbf76365fdd7f2552710282c79a4ead5d2a46408c9043a2b70ba/networkx-3.5-py3-none-any.whl", hash = "sha256:0030d386a9a06dee3565298b4a734b68589749a544acbb6c412dc9e2489ec6ec" }, ] [[package]] @@ -1497,27 +1522,27 @@ wheels = [ [[package]] name = "numpy" version = "2.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48", size = 20489306, upload-time = "2025-07-24T21:32:07.553Z" } +source = { registry = "https://download.pytorch.org/whl/cpu" } +sdist = { url = "https://files.pythonhosted.org/packages/37/7d/3fec4199c5ffb892bed55cff901e4f39a58c81df9c44c280499e92cad264/numpy-2.3.2.tar.gz", hash = "sha256:e0486a11ec30cdecb53f184d496d1c6a20786c81e55e41640270130056f8ee48" } wheels = [ - { url = "https://files.pythonhosted.org/packages/96/26/1320083986108998bd487e2931eed2aeedf914b6e8905431487543ec911d/numpy-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:852ae5bed3478b92f093e30f785c98e0cb62fa0a939ed057c31716e18a7a22b9", size = 21259016, upload-time = "2025-07-24T20:24:35.214Z" }, - { url = "https://files.pythonhosted.org/packages/c4/2b/792b341463fa93fc7e55abbdbe87dac316c5b8cb5e94fb7a59fb6fa0cda5/numpy-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a0e27186e781a69959d0230dd9909b5e26024f8da10683bd6344baea1885168", size = 14451158, upload-time = "2025-07-24T20:24:58.397Z" }, - { url = "https://files.pythonhosted.org/packages/b7/13/e792d7209261afb0c9f4759ffef6135b35c77c6349a151f488f531d13595/numpy-2.3.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f0a1a8476ad77a228e41619af2fa9505cf69df928e9aaa165746584ea17fed2b", size = 5379817, upload-time = "2025-07-24T20:25:07.746Z" }, - { url = "https://files.pythonhosted.org/packages/49/ce/055274fcba4107c022b2113a213c7287346563f48d62e8d2a5176ad93217/numpy-2.3.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cbc95b3813920145032412f7e33d12080f11dc776262df1712e1638207dde9e8", size = 6913606, upload-time = "2025-07-24T20:25:18.84Z" }, - { url = "https://files.pythonhosted.org/packages/17/f2/e4d72e6bc5ff01e2ab613dc198d560714971900c03674b41947e38606502/numpy-2.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75018be4980a7324edc5930fe39aa391d5734531b1926968605416ff58c332d", size = 14589652, upload-time = "2025-07-24T20:25:40.356Z" }, - { url = "https://files.pythonhosted.org/packages/c8/b0/fbeee3000a51ebf7222016e2939b5c5ecf8000a19555d04a18f1e02521b8/numpy-2.3.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b8200721840f5621b7bd03f8dcd78de33ec522fc40dc2641aa09537df010c3", size = 16938816, upload-time = "2025-07-24T20:26:05.721Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ec/2f6c45c3484cc159621ea8fc000ac5a86f1575f090cac78ac27193ce82cd/numpy-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f91e5c028504660d606340a084db4b216567ded1056ea2b4be4f9d10b67197f", size = 16370512, upload-time = "2025-07-24T20:26:30.545Z" }, - { url = "https://files.pythonhosted.org/packages/b5/01/dd67cf511850bd7aefd6347aaae0956ed415abea741ae107834aae7d6d4e/numpy-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fb1752a3bb9a3ad2d6b090b88a9a0ae1cd6f004ef95f75825e2f382c183b2097", size = 18884947, upload-time = "2025-07-24T20:26:58.24Z" }, - { url = "https://files.pythonhosted.org/packages/a7/17/2cf60fd3e6a61d006778735edf67a222787a8c1a7842aed43ef96d777446/numpy-2.3.2-cp311-cp311-win32.whl", hash = "sha256:4ae6863868aaee2f57503c7a5052b3a2807cf7a3914475e637a0ecd366ced220", size = 6599494, upload-time = "2025-07-24T20:27:09.786Z" }, - { url = "https://files.pythonhosted.org/packages/d5/03/0eade211c504bda872a594f045f98ddcc6caef2b7c63610946845e304d3f/numpy-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:240259d6564f1c65424bcd10f435145a7644a65a6811cfc3201c4a429ba79170", size = 13087889, upload-time = "2025-07-24T20:27:29.558Z" }, - { url = "https://files.pythonhosted.org/packages/13/32/2c7979d39dafb2a25087e12310fc7f3b9d3c7d960df4f4bc97955ae0ce1d/numpy-2.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:4209f874d45f921bde2cff1ffcd8a3695f545ad2ffbef6d3d3c6768162efab89", size = 10459560, upload-time = "2025-07-24T20:27:46.803Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ea/50ebc91d28b275b23b7128ef25c3d08152bc4068f42742867e07a870a42a/numpy-2.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:14a91ebac98813a49bc6aa1a0dfc09513dcec1d97eaf31ca21a87221a1cdcb15", size = 21130338, upload-time = "2025-07-24T20:57:54.37Z" }, - { url = "https://files.pythonhosted.org/packages/9f/57/cdd5eac00dd5f137277355c318a955c0d8fb8aa486020c22afd305f8b88f/numpy-2.3.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:71669b5daae692189540cffc4c439468d35a3f84f0c88b078ecd94337f6cb0ec", size = 14375776, upload-time = "2025-07-24T20:58:16.303Z" }, - { url = "https://files.pythonhosted.org/packages/83/85/27280c7f34fcd305c2209c0cdca4d70775e4859a9eaa92f850087f8dea50/numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:69779198d9caee6e547adb933941ed7520f896fd9656834c300bdf4dd8642712", size = 5304882, upload-time = "2025-07-24T20:58:26.199Z" }, - { url = "https://files.pythonhosted.org/packages/48/b4/6500b24d278e15dd796f43824e69939d00981d37d9779e32499e823aa0aa/numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2c3271cc4097beb5a60f010bcc1cc204b300bb3eafb4399376418a83a1c6373c", size = 6818405, upload-time = "2025-07-24T20:58:37.341Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c9/142c1e03f199d202da8e980c2496213509291b6024fd2735ad28ae7065c7/numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446acd11fe3dc1830568c941d44449fd5cb83068e5c70bd5a470d323d448296", size = 14419651, upload-time = "2025-07-24T20:58:59.048Z" }, - { url = "https://files.pythonhosted.org/packages/8b/95/8023e87cbea31a750a6c00ff9427d65ebc5fef104a136bfa69f76266d614/numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa098a5ab53fa407fded5870865c6275a5cd4101cfdef8d6fafc48286a96e981", size = 16760166, upload-time = "2025-07-24T21:28:56.38Z" }, - { url = "https://files.pythonhosted.org/packages/78/e3/6690b3f85a05506733c7e90b577e4762517404ea78bab2ca3a5cb1aeb78d/numpy-2.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6936aff90dda378c09bea075af0d9c675fe3a977a9d2402f95a87f440f59f619", size = 12977811, upload-time = "2025-07-24T21:29:18.234Z" }, + { url = "https://files.pythonhosted.org/packages/96/26/1320083986108998bd487e2931eed2aeedf914b6e8905431487543ec911d/numpy-2.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:852ae5bed3478b92f093e30f785c98e0cb62fa0a939ed057c31716e18a7a22b9" }, + { url = "https://files.pythonhosted.org/packages/c4/2b/792b341463fa93fc7e55abbdbe87dac316c5b8cb5e94fb7a59fb6fa0cda5/numpy-2.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7a0e27186e781a69959d0230dd9909b5e26024f8da10683bd6344baea1885168" }, + { url = "https://files.pythonhosted.org/packages/b7/13/e792d7209261afb0c9f4759ffef6135b35c77c6349a151f488f531d13595/numpy-2.3.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f0a1a8476ad77a228e41619af2fa9505cf69df928e9aaa165746584ea17fed2b" }, + { url = "https://files.pythonhosted.org/packages/49/ce/055274fcba4107c022b2113a213c7287346563f48d62e8d2a5176ad93217/numpy-2.3.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cbc95b3813920145032412f7e33d12080f11dc776262df1712e1638207dde9e8" }, + { url = "https://files.pythonhosted.org/packages/17/f2/e4d72e6bc5ff01e2ab613dc198d560714971900c03674b41947e38606502/numpy-2.3.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75018be4980a7324edc5930fe39aa391d5734531b1926968605416ff58c332d" }, + { url = "https://files.pythonhosted.org/packages/c8/b0/fbeee3000a51ebf7222016e2939b5c5ecf8000a19555d04a18f1e02521b8/numpy-2.3.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20b8200721840f5621b7bd03f8dcd78de33ec522fc40dc2641aa09537df010c3" }, + { url = "https://files.pythonhosted.org/packages/a9/ec/2f6c45c3484cc159621ea8fc000ac5a86f1575f090cac78ac27193ce82cd/numpy-2.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1f91e5c028504660d606340a084db4b216567ded1056ea2b4be4f9d10b67197f" }, + { url = "https://files.pythonhosted.org/packages/b5/01/dd67cf511850bd7aefd6347aaae0956ed415abea741ae107834aae7d6d4e/numpy-2.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:fb1752a3bb9a3ad2d6b090b88a9a0ae1cd6f004ef95f75825e2f382c183b2097" }, + { url = "https://files.pythonhosted.org/packages/a7/17/2cf60fd3e6a61d006778735edf67a222787a8c1a7842aed43ef96d777446/numpy-2.3.2-cp311-cp311-win32.whl", hash = "sha256:4ae6863868aaee2f57503c7a5052b3a2807cf7a3914475e637a0ecd366ced220" }, + { url = "https://files.pythonhosted.org/packages/d5/03/0eade211c504bda872a594f045f98ddcc6caef2b7c63610946845e304d3f/numpy-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:240259d6564f1c65424bcd10f435145a7644a65a6811cfc3201c4a429ba79170" }, + { url = "https://files.pythonhosted.org/packages/13/32/2c7979d39dafb2a25087e12310fc7f3b9d3c7d960df4f4bc97955ae0ce1d/numpy-2.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:4209f874d45f921bde2cff1ffcd8a3695f545ad2ffbef6d3d3c6768162efab89" }, + { url = "https://files.pythonhosted.org/packages/cf/ea/50ebc91d28b275b23b7128ef25c3d08152bc4068f42742867e07a870a42a/numpy-2.3.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:14a91ebac98813a49bc6aa1a0dfc09513dcec1d97eaf31ca21a87221a1cdcb15" }, + { url = "https://files.pythonhosted.org/packages/9f/57/cdd5eac00dd5f137277355c318a955c0d8fb8aa486020c22afd305f8b88f/numpy-2.3.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:71669b5daae692189540cffc4c439468d35a3f84f0c88b078ecd94337f6cb0ec" }, + { url = "https://files.pythonhosted.org/packages/83/85/27280c7f34fcd305c2209c0cdca4d70775e4859a9eaa92f850087f8dea50/numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:69779198d9caee6e547adb933941ed7520f896fd9656834c300bdf4dd8642712" }, + { url = "https://files.pythonhosted.org/packages/48/b4/6500b24d278e15dd796f43824e69939d00981d37d9779e32499e823aa0aa/numpy-2.3.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:2c3271cc4097beb5a60f010bcc1cc204b300bb3eafb4399376418a83a1c6373c" }, + { url = "https://files.pythonhosted.org/packages/9b/c9/142c1e03f199d202da8e980c2496213509291b6024fd2735ad28ae7065c7/numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8446acd11fe3dc1830568c941d44449fd5cb83068e5c70bd5a470d323d448296" }, + { url = "https://files.pythonhosted.org/packages/8b/95/8023e87cbea31a750a6c00ff9427d65ebc5fef104a136bfa69f76266d614/numpy-2.3.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa098a5ab53fa407fded5870865c6275a5cd4101cfdef8d6fafc48286a96e981" }, + { url = "https://files.pythonhosted.org/packages/78/e3/6690b3f85a05506733c7e90b577e4762517404ea78bab2ca3a5cb1aeb78d/numpy-2.3.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6936aff90dda378c09bea075af0d9c675fe3a977a9d2402f95a87f440f59f619" }, ] [[package]] @@ -1858,27 +1883,27 @@ wheels = [ [[package]] name = "pillow" version = "11.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" } +source = { registry = "https://download.pytorch.org/whl/cpu" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523" } wheels = [ - { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722", size = 5316531, upload-time = "2025-07-01T09:13:59.203Z" }, - { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288", size = 4686560, upload-time = "2025-07-01T09:14:01.101Z" }, - { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d", size = 5870978, upload-time = "2025-07-03T13:09:55.638Z" }, - { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494", size = 7641168, upload-time = "2025-07-03T13:10:00.37Z" }, - { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58", size = 5973053, upload-time = "2025-07-01T09:14:04.491Z" }, - { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f", size = 6640273, upload-time = "2025-07-01T09:14:06.235Z" }, - { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e", size = 6082043, upload-time = "2025-07-01T09:14:07.978Z" }, - { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94", size = 6715516, upload-time = "2025-07-01T09:14:10.233Z" }, - { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0", size = 6274768, upload-time = "2025-07-01T09:14:11.921Z" }, - { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac", size = 6986055, upload-time = "2025-07-01T09:14:13.623Z" }, - { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd", size = 2423079, upload-time = "2025-07-01T09:14:15.268Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6", size = 5270566, upload-time = "2025-07-01T09:16:19.801Z" }, - { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438", size = 4654618, upload-time = "2025-07-01T09:16:21.818Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3", size = 4874248, upload-time = "2025-07-03T13:11:20.738Z" }, - { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c", size = 6583963, upload-time = "2025-07-03T13:11:26.283Z" }, - { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361", size = 4957170, upload-time = "2025-07-01T09:16:23.762Z" }, - { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7", size = 5581505, upload-time = "2025-07-01T09:16:25.593Z" }, - { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8", size = 6984598, upload-time = "2025-07-01T09:16:27.732Z" }, + { url = "https://files.pythonhosted.org/packages/db/26/77f8ed17ca4ffd60e1dcd220a6ec6d71210ba398cfa33a13a1cd614c5613/pillow-11.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:1cd110edf822773368b396281a2293aeb91c90a2db00d78ea43e7e861631b722" }, + { url = "https://files.pythonhosted.org/packages/cb/39/ee475903197ce709322a17a866892efb560f57900d9af2e55f86db51b0a5/pillow-11.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:9c412fddd1b77a75aa904615ebaa6001f169b26fd467b4be93aded278266b288" }, + { url = "https://files.pythonhosted.org/packages/d5/90/442068a160fd179938ba55ec8c97050a612426fae5ec0a764e345839f76d/pillow-11.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1aa4de119a0ecac0a34a9c8bde33f34022e2e8f99104e47a3ca392fd60e37d" }, + { url = "https://files.pythonhosted.org/packages/13/92/dcdd147ab02daf405387f0218dcf792dc6dd5b14d2573d40b4caeef01059/pillow-11.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:91da1d88226663594e3f6b4b8c3c8d85bd504117d043740a8e0ec449087cc494" }, + { url = "https://files.pythonhosted.org/packages/6e/db/839d6ba7fd38b51af641aa904e2960e7a5644d60ec754c046b7d2aee00e5/pillow-11.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:643f189248837533073c405ec2f0bb250ba54598cf80e8c1e043381a60632f58" }, + { url = "https://files.pythonhosted.org/packages/f2/2f/d7675ecae6c43e9f12aa8d58b6012683b20b6edfbdac7abcb4e6af7a3784/pillow-11.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:106064daa23a745510dabce1d84f29137a37224831d88eb4ce94bb187b1d7e5f" }, + { url = "https://files.pythonhosted.org/packages/45/ad/931694675ede172e15b2ff03c8144a0ddaea1d87adb72bb07655eaffb654/pillow-11.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd8ff254faf15591e724dc7c4ddb6bf4793efcbe13802a4ae3e863cd300b493e" }, + { url = "https://files.pythonhosted.org/packages/3a/04/ba8f2b11fc80d2dd462d7abec16351b45ec99cbbaea4387648a44190351a/pillow-11.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:932c754c2d51ad2b2271fd01c3d121daaa35e27efae2a616f77bf164bc0b3e94" }, + { url = "https://files.pythonhosted.org/packages/48/59/8cd06d7f3944cc7d892e8533c56b0acb68399f640786313275faec1e3b6f/pillow-11.3.0-cp311-cp311-win32.whl", hash = "sha256:b4b8f3efc8d530a1544e5962bd6b403d5f7fe8b9e08227c6b255f98ad82b4ba0" }, + { url = "https://files.pythonhosted.org/packages/f1/cc/29c0f5d64ab8eae20f3232da8f8571660aa0ab4b8f1331da5c2f5f9a938e/pillow-11.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:1a992e86b0dd7aeb1f053cd506508c0999d710a8f07b4c791c63843fc6a807ac" }, + { url = "https://files.pythonhosted.org/packages/c6/df/90bd886fabd544c25addd63e5ca6932c86f2b701d5da6c7839387a076b4a/pillow-11.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:30807c931ff7c095620fe04448e2c2fc673fcbb1ffe2a7da3fb39613489b1ddd" }, + { url = "https://files.pythonhosted.org/packages/9e/e3/6fa84033758276fb31da12e5fb66ad747ae83b93c67af17f8c6ff4cc8f34/pillow-11.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7c8ec7a017ad1bd562f93dbd8505763e688d388cde6e4a010ae1486916e713e6" }, + { url = "https://files.pythonhosted.org/packages/5b/ee/e8d2e1ab4892970b561e1ba96cbd59c0d28cf66737fc44abb2aec3795a4e/pillow-11.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9ab6ae226de48019caa8074894544af5b53a117ccb9d3b3dcb2871464c829438" }, + { url = "https://files.pythonhosted.org/packages/f2/6d/17f80f4e1f0761f02160fc433abd4109fa1548dcfdca46cfdadaf9efa565/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fe27fb049cdcca11f11a7bfda64043c37b30e6b91f10cb5bab275806c32f6ab3" }, + { url = "https://files.pythonhosted.org/packages/de/5f/c22340acd61cef960130585bbe2120e2fd8434c214802f07e8c03596b17e/pillow-11.3.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:465b9e8844e3c3519a983d58b80be3f668e2a7a5db97f2784e7079fbc9f9822c" }, + { url = "https://files.pythonhosted.org/packages/31/5e/03966aedfbfcbb4d5f8aa042452d3361f325b963ebbadddac05b122e47dd/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5418b53c0d59b3824d05e029669efa023bbef0f3e92e75ec8428f3799487f361" }, + { url = "https://files.pythonhosted.org/packages/cc/2d/e082982aacc927fc2cab48e1e731bdb1643a1406acace8bed0900a61464e/pillow-11.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:504b6f59505f08ae014f724b6207ff6222662aab5cc9542577fb084ed0676ac7" }, + { url = "https://files.pythonhosted.org/packages/34/e7/ae39f538fd6844e982063c3a5e4598b8ced43b9633baa3a85ef33af8c05c/pillow-11.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c84d689db21a1c397d001aa08241044aa2069e7587b398c8cc63020390b1c1b8" }, ] [[package]] @@ -1914,6 +1939,7 @@ dependencies = [ { name = "aiohttp" }, { name = "aiomysql" }, { name = "aiosqlite" }, + { name = "alembic" }, { name = "anthropic" }, { name = "asyncpg" }, { name = "chromadb" }, @@ -1937,6 +1963,7 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.12.15" }, { name = "aiomysql", specifier = ">=0.2.0" }, { name = "aiosqlite", specifier = ">=0.21.0" }, + { name = "alembic", specifier = ">=1.14.0" }, { name = "anthropic", specifier = ">=0.60.0" }, { name = "asyncpg", specifier = ">=0.30.0" }, { name = "chromadb", specifier = ">=1.0.15" }, @@ -2802,13 +2829,13 @@ wheels = [ [[package]] name = "sympy" version = "1.14.0" -source = { registry = "https://pypi.org/simple" } +source = { registry = "https://download.pytorch.org/whl/cpu" } dependencies = [ { name = "mpmath" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" } +sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, + { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5" }, ] [[package]] diff --git a/test_server.py b/test_server.py new file mode 100644 index 00000000..e1893671 --- /dev/null +++ b/test_server.py @@ -0,0 +1,78 @@ +""" +Presenton Version Server (test/dev stub) + +This simulates the remote version-check endpoint that the Electron app polls. +In production, replace UPDATE_SERVER_URL in the Electron app with your hosted URL. + +Usage: + python test_server.py [--port 8765] + +Endpoint: + GET /versions -> JSON with latest version and download info +""" + +import json +import argparse +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import urlparse + +VERSIONS = { + "latest": "0.7.0", + "versions": [ + "0.5.0", + "0.6.0", + "0.6.1-beta", + "0.7.0", + ], + "download_url": "https://github.com/presenton/presenton/releases/latest", + "release_notes": "Bug fixes, performance improvements, and new AI model support.", +} + + +class VersionHandler(BaseHTTPRequestHandler): + def do_GET(self): + parsed = urlparse(self.path) + + if parsed.path == "/versions": + body = json.dumps(VERSIONS, indent=2).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body) + else: + self.send_response(404) + self.end_headers() + self.wfile.write(b'{"error": "Not found"}') + + def do_OPTIONS(self): + self.send_response(200) + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, OPTIONS") + self.end_headers() + + def log_message(self, format, *args): + print(f"[VersionServer] {self.address_string()} - {format % args}", flush=True) + + +def main(): + parser = argparse.ArgumentParser(description="Presenton version check server") + parser.add_argument("--port", type=int, default=8765, help="Port to listen on") + parser.add_argument("--host", type=str, default="0.0.0.0", help="Host to bind to") + args = parser.parse_args() + + server = HTTPServer((args.host, args.port), VersionHandler) + print(f"Presenton version server running at http://{args.host}:{args.port}", flush=True) + print(f" GET /versions -> version information", flush=True) + print(f" Current 'latest' set to: {VERSIONS['latest']}", flush=True) + print("Press Ctrl+C to stop.", flush=True) + + try: + server.serve_forever() + except KeyboardInterrupt: + print("\nServer stopped.", flush=True) + + +if __name__ == "__main__": + main()