feat: implement database migration on startup and update dependencies for FastAPI
This commit is contained in:
parent
c3b3f15924
commit
2d462d8df4
34 changed files with 1460 additions and 200 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
257
electron/app/utils/update-checker.ts
Normal file
257
electron/app/utils/update-checker.ts
Normal file
|
|
@ -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<VersionResponse | null> {
|
||||
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
|
||||
? `<span style="color:#a6adc8;margin-right:8px;">${releaseNotes}</span>`
|
||||
: "";
|
||||
|
||||
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 = \`
|
||||
<span style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;">
|
||||
<span style="font-size:16px;">✨</span>
|
||||
<span>
|
||||
Presenton <strong>${latest}</strong> is available
|
||||
— you have <strong>${CURRENT_VERSION}</strong>
|
||||
</span>
|
||||
${notesHtml}
|
||||
</span>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-shrink:0;">
|
||||
<a
|
||||
href="${downloadUrl}"
|
||||
target="_blank"
|
||||
style="color:#89b4fa;text-decoration:none;border:1px solid #89b4fa;padding:4px 14px;border-radius:5px;font-size:12px;white-space:nowrap;"
|
||||
>Download update</a>
|
||||
<button
|
||||
onclick="document.getElementById('__presenton_update_banner__').remove()"
|
||||
title="Dismiss"
|
||||
style="background:none;border:none;color:#6c7086;cursor:pointer;font-size:20px;line-height:1;padding:0 2px;"
|
||||
>×</button>
|
||||
</div>
|
||||
\`;
|
||||
|
||||
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<void> {
|
||||
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<void>((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;
|
||||
}
|
||||
4
electron/package-lock.json
generated
4
electron/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
41
electron/servers/fastapi/alembic.ini
Normal file
41
electron/servers/fastapi/alembic.ini
Normal file
|
|
@ -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
|
||||
94
electron/servers/fastapi/alembic/env.py
Normal file
94
electron/servers/fastapi/alembic/env.py
Normal file
|
|
@ -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()
|
||||
27
electron/servers/fastapi/alembic/script.py.mako
Normal file
27
electron/servers/fastapi/alembic/script.py.mako
Normal file
|
|
@ -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"}
|
||||
0
electron/servers/fastapi/alembic/versions/.gitkeep
Normal file
0
electron/servers/fastapi/alembic/versions/.gitkeep
Normal file
135
electron/servers/fastapi/alembic/versions/00b3c27a13bc_init.py
Normal file
135
electron/servers/fastapi/alembic/versions/00b3c27a13bc_init.py
Normal file
|
|
@ -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 ###
|
||||
|
|
@ -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
|
||||
|
|
|
|||
40
electron/servers/fastapi/migrations.py
Normal file
40
electron/servers/fastapi/migrations.py
Normal file
|
|
@ -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")
|
||||
BIN
electron/servers/fastapi/placeholder
Normal file
BIN
electron/servers/fastapi/placeholder
Normal file
Binary file not shown.
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
28
electron/servers/fastapi/uv.lock
generated
28
electron/servers/fastapi/uv.lock
generated
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
41
servers/fastapi/alembic.ini
Normal file
41
servers/fastapi/alembic.ini
Normal file
|
|
@ -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
|
||||
94
servers/fastapi/alembic/env.py
Normal file
94
servers/fastapi/alembic/env.py
Normal file
|
|
@ -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()
|
||||
27
servers/fastapi/alembic/script.py.mako
Normal file
27
servers/fastapi/alembic/script.py.mako
Normal file
|
|
@ -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"}
|
||||
0
servers/fastapi/alembic/versions/.gitkeep
Normal file
0
servers/fastapi/alembic/versions/.gitkeep
Normal file
136
servers/fastapi/alembic/versions/fd2ab04834cc_init.py
Normal file
136
servers/fastapi/alembic/versions/fd2ab04834cc_init.py
Normal file
|
|
@ -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 ###
|
||||
|
|
@ -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
|
||||
|
|
|
|||
40
servers/fastapi/migrations.py
Normal file
40
servers/fastapi/migrations.py
Normal file
|
|
@ -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")
|
||||
BIN
servers/fastapi/placeholder
Normal file
BIN
servers/fastapi/placeholder
Normal file
Binary file not shown.
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
143
servers/fastapi/uv.lock
generated
143
servers/fastapi/uv.lock
generated
|
|
@ -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]]
|
||||
|
|
|
|||
78
test_server.py
Normal file
78
test_server.py
Normal file
|
|
@ -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()
|
||||
Loading…
Add table
Reference in a new issue