feat: hooked the APIs of the app into the pyoide core.
This commit is contained in:
17
.github/copilot-instructions.md
vendored
17
.github/copilot-instructions.md
vendored
@@ -102,6 +102,23 @@ See the [TDD Requirements](#test-driven-development-tdd-requirements) section fo
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## ⚠️ MANDATORY: Keep Python API Bindings and API Docs in Sync
|
||||||
|
|
||||||
|
**Whenever any app API is added, removed, or changed, you MUST update the Python API bridge and API documentation in the same change set.**
|
||||||
|
|
||||||
|
- Update the Python API contract/bindings used by embedded Pyodide (`bds_api`)
|
||||||
|
- Regenerate and commit `API.md`
|
||||||
|
- Ensure every API entry documents:
|
||||||
|
- Parameter names, types, and required/optional status
|
||||||
|
- Return type/response specification
|
||||||
|
- At least one sample Python call
|
||||||
|
- Maintain a shared **Data Structures** section in `API.md` for canonical objects (for example `PostData`, `MediaData`) so users can see expected attributes in one place
|
||||||
|
- Keep docs sync tests passing (documentation and generator output must match)
|
||||||
|
|
||||||
|
> **No API contract drift between app APIs, Python bindings, and API.md. No exceptions.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architecture Principles
|
## Architecture Principles
|
||||||
|
|
||||||
### Separation of Concerns
|
### Separation of Concerns
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"test:coverage": "vitest run --coverage",
|
"test:coverage": "vitest run --coverage",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
"bench:python-runtime": "node ./node_modules/tsx/dist/cli.mjs scripts/python-runtime-benchmark.ts",
|
"bench:python-runtime": "node ./node_modules/tsx/dist/cli.mjs scripts/python-runtime-benchmark.ts",
|
||||||
|
"docs:api": "node ./node_modules/tsx/dist/cli.mjs scripts/generate-api-docs.ts",
|
||||||
"lint": "eslint \"src/renderer/**/*.{ts,tsx}\" --max-warnings 0",
|
"lint": "eslint \"src/renderer/**/*.{ts,tsx}\" --max-warnings 0",
|
||||||
"lint:i18n": "eslint \"src/renderer/**/*.{ts,tsx}\" --max-warnings 0",
|
"lint:i18n": "eslint \"src/renderer/**/*.{ts,tsx}\" --max-warnings 0",
|
||||||
"db:generate": "node ./node_modules/drizzle-kit/bin.cjs generate",
|
"db:generate": "node ./node_modules/drizzle-kit/bin.cjs generate",
|
||||||
@@ -136,6 +137,14 @@
|
|||||||
"from": "drizzle",
|
"from": "drizzle",
|
||||||
"to": "drizzle"
|
"to": "drizzle"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"from": "API.md",
|
||||||
|
"to": "docs/API.md"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"from": "DOCUMENTATION.md",
|
||||||
|
"to": "docs/DOCUMENTATION.md"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"from": "src/main/engine/templates",
|
"from": "src/main/engine/templates",
|
||||||
"to": "templates"
|
"to": "templates"
|
||||||
|
|||||||
12
scripts/generate-api-docs.ts
Normal file
12
scripts/generate-api-docs.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { generateApiDocumentationMarkdownV1 } from '../src/renderer/python/generateApiDocumentationMarkdownV1';
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const outputPath = resolve(process.cwd(), 'API.md');
|
||||||
|
const markdown = generateApiDocumentationMarkdownV1();
|
||||||
|
await writeFile(outputPath, markdown, 'utf8');
|
||||||
|
console.log(`Generated API documentation at ${outputPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
void main();
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"menu.item.validateSite": "Website validieren",
|
"menu.item.validateSite": "Website validieren",
|
||||||
"menu.item.about": "Über Blogging Desktop Server",
|
"menu.item.about": "Über Blogging Desktop Server",
|
||||||
"menu.item.openDocumentation": "Dokumentation öffnen",
|
"menu.item.openDocumentation": "Dokumentation öffnen",
|
||||||
|
"menu.item.openApiDocumentation": "API-Dokumentation",
|
||||||
"menu.item.viewOnGitHub": "Auf GitHub ansehen",
|
"menu.item.viewOnGitHub": "Auf GitHub ansehen",
|
||||||
"menu.item.reportIssue": "Problem melden",
|
"menu.item.reportIssue": "Problem melden",
|
||||||
"render.archive": "Archiv",
|
"render.archive": "Archiv",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"menu.item.validateSite": "Validate Site",
|
"menu.item.validateSite": "Validate Site",
|
||||||
"menu.item.about": "About Blogging Desktop Server",
|
"menu.item.about": "About Blogging Desktop Server",
|
||||||
"menu.item.openDocumentation": "Open Documentation",
|
"menu.item.openDocumentation": "Open Documentation",
|
||||||
|
"menu.item.openApiDocumentation": "API documentation",
|
||||||
"menu.item.viewOnGitHub": "View on GitHub",
|
"menu.item.viewOnGitHub": "View on GitHub",
|
||||||
"menu.item.reportIssue": "Report Issue",
|
"menu.item.reportIssue": "Report Issue",
|
||||||
"render.archive": "Archive",
|
"render.archive": "Archive",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"menu.item.validateSite": "Validar sitio",
|
"menu.item.validateSite": "Validar sitio",
|
||||||
"menu.item.about": "Acerca de Blogging Desktop Server",
|
"menu.item.about": "Acerca de Blogging Desktop Server",
|
||||||
"menu.item.openDocumentation": "Abrir documentación",
|
"menu.item.openDocumentation": "Abrir documentación",
|
||||||
|
"menu.item.openApiDocumentation": "Documentación API",
|
||||||
"menu.item.viewOnGitHub": "Ver en GitHub",
|
"menu.item.viewOnGitHub": "Ver en GitHub",
|
||||||
"menu.item.reportIssue": "Reportar problema",
|
"menu.item.reportIssue": "Reportar problema",
|
||||||
"render.archive": "Archivo",
|
"render.archive": "Archivo",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"menu.item.validateSite": "Valider le site",
|
"menu.item.validateSite": "Valider le site",
|
||||||
"menu.item.about": "À propos de Blogging Desktop Server",
|
"menu.item.about": "À propos de Blogging Desktop Server",
|
||||||
"menu.item.openDocumentation": "Ouvrir la documentation",
|
"menu.item.openDocumentation": "Ouvrir la documentation",
|
||||||
|
"menu.item.openApiDocumentation": "Documentation API",
|
||||||
"menu.item.viewOnGitHub": "Voir sur GitHub",
|
"menu.item.viewOnGitHub": "Voir sur GitHub",
|
||||||
"menu.item.reportIssue": "Signaler un problème",
|
"menu.item.reportIssue": "Signaler un problème",
|
||||||
"render.archive": "Archives",
|
"render.archive": "Archives",
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"menu.item.validateSite": "Valida sito",
|
"menu.item.validateSite": "Valida sito",
|
||||||
"menu.item.about": "Informazioni su Blogging Desktop Server",
|
"menu.item.about": "Informazioni su Blogging Desktop Server",
|
||||||
"menu.item.openDocumentation": "Apri documentazione",
|
"menu.item.openDocumentation": "Apri documentazione",
|
||||||
|
"menu.item.openApiDocumentation": "Documentazione API",
|
||||||
"menu.item.viewOnGitHub": "Visualizza su GitHub",
|
"menu.item.viewOnGitHub": "Visualizza su GitHub",
|
||||||
"menu.item.reportIssue": "Segnala problema",
|
"menu.item.reportIssue": "Segnala problema",
|
||||||
"render.archive": "Archivio",
|
"render.archive": "Archivio",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ export type AppMenuAction =
|
|||||||
| 'regenerateCalendar'
|
| 'regenerateCalendar'
|
||||||
| 'validateSite'
|
| 'validateSite'
|
||||||
| 'openDocumentation'
|
| 'openDocumentation'
|
||||||
|
| 'openApiDocumentation'
|
||||||
| 'about'
|
| 'about'
|
||||||
| 'viewOnGitHub'
|
| 'viewOnGitHub'
|
||||||
| 'reportIssue';
|
| 'reportIssue';
|
||||||
@@ -136,6 +137,7 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ label: 'menu.item.about', action: 'about' },
|
{ label: 'menu.item.about', action: 'about' },
|
||||||
{ label: 'menu.item.openDocumentation', action: 'openDocumentation' },
|
{ label: 'menu.item.openDocumentation', action: 'openDocumentation' },
|
||||||
|
{ label: 'menu.item.openApiDocumentation', action: 'openApiDocumentation' },
|
||||||
{ label: '', action: 'help-separator-1', separator: true },
|
{ label: '', action: 'help-separator-1', separator: true },
|
||||||
{ label: 'menu.item.viewOnGitHub', action: 'viewOnGitHub' },
|
{ label: 'menu.item.viewOnGitHub', action: 'viewOnGitHub' },
|
||||||
{ label: 'menu.item.reportIssue', action: 'reportIssue' },
|
{ label: 'menu.item.reportIssue', action: 'reportIssue' },
|
||||||
@@ -165,6 +167,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
|
|||||||
regenerateCalendar: 'menu:regenerateCalendar',
|
regenerateCalendar: 'menu:regenerateCalendar',
|
||||||
validateSite: 'menu:validateSite',
|
validateSite: 'menu:validateSite',
|
||||||
openDocumentation: 'menu:openDocumentation',
|
openDocumentation: 'menu:openDocumentation',
|
||||||
|
openApiDocumentation: 'menu:openApiDocumentation',
|
||||||
about: 'menu:about',
|
about: 'menu:about',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -494,6 +494,12 @@ const App: React.FC = () => {
|
|||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
unsubscribers.push(
|
||||||
|
window.electronAPI?.on('menu:openApiDocumentation', () => {
|
||||||
|
openSingletonToolTab(openTab, 'api-documentation');
|
||||||
|
}) || (() => {})
|
||||||
|
);
|
||||||
|
|
||||||
// Import completion event - refresh posts and media stores
|
// Import completion event - refresh posts and media stores
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
window.electronAPI?.import.onComplete(async (data) => {
|
window.electronAPI?.import.onComplete(async (data) => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react';
|
|||||||
import Markdown from 'marked-react';
|
import Markdown from 'marked-react';
|
||||||
import hljs from '@highlightjs/cdn-assets/es/highlight.min.js';
|
import hljs from '@highlightjs/cdn-assets/es/highlight.min.js';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import documentationContent from '../../../../DOCUMENTATION.md?raw';
|
import defaultDocumentationContent from '../../../../DOCUMENTATION.md?raw';
|
||||||
import { useAppStore } from '../../store';
|
import { useAppStore } from '../../store';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from '../../utils/picoTheme';
|
import { ensureRendererPicoThemeStylesheet, getRendererPicoTheme } from '../../utils/picoTheme';
|
||||||
@@ -84,7 +84,17 @@ function resolveTargetHeadingInArticle(articleElement: HTMLElement, targetId: st
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DocumentationView: React.FC = () => {
|
interface DocumentationViewProps {
|
||||||
|
content?: string;
|
||||||
|
titleKey?: string;
|
||||||
|
subtitleKey?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DocumentationView: React.FC<DocumentationViewProps> = ({
|
||||||
|
content = defaultDocumentationContent,
|
||||||
|
titleKey = 'docs.title',
|
||||||
|
subtitleKey = 'docs.subtitle',
|
||||||
|
}) => {
|
||||||
const { t: tr } = useI18n();
|
const { t: tr } = useI18n();
|
||||||
const { picoTheme } = useAppStore();
|
const { picoTheme } = useAppStore();
|
||||||
const resolvedTheme = getRendererPicoTheme(picoTheme);
|
const resolvedTheme = getRendererPicoTheme(picoTheme);
|
||||||
@@ -258,8 +268,8 @@ export const DocumentationView: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="documentation-view">
|
<div className="documentation-view">
|
||||||
<div className="documentation-header">
|
<div className="documentation-header">
|
||||||
<h1>{tr('docs.title')}</h1>
|
<h1>{tr(titleKey)}</h1>
|
||||||
<p>{tr('docs.subtitle')}</p>
|
<p>{tr(subtitleKey)}</p>
|
||||||
</div>
|
</div>
|
||||||
<main
|
<main
|
||||||
className="documentation-scroll"
|
className="documentation-scroll"
|
||||||
@@ -267,7 +277,7 @@ export const DocumentationView: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<div className="documentation-content markdown-body pico" data-theme="auto" data-pico-theme={resolvedTheme}>
|
<div className="documentation-content markdown-body pico" data-theme="auto" data-pico-theme={resolvedTheme}>
|
||||||
<article className="documentation-article" ref={articleRef}>
|
<article className="documentation-article" ref={articleRef}>
|
||||||
<Markdown renderer={markdownRenderer}>{documentationContent}</Markdown>
|
<Markdown renderer={markdownRenderer}>{content}</Markdown>
|
||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISugge
|
|||||||
import { openEntityTab } from '../../navigation/tabPolicy';
|
import { openEntityTab } from '../../navigation/tabPolicy';
|
||||||
import { EditorRoute, resolveEditorRoute } from '../../navigation/editorRouting';
|
import { EditorRoute, resolveEditorRoute } from '../../navigation/editorRouting';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
|
import documentationContent from '../../../../DOCUMENTATION.md?raw';
|
||||||
|
import apiDocumentationContent from '../../../../API.md?raw';
|
||||||
import './Editor.css';
|
import './Editor.css';
|
||||||
|
|
||||||
const UI_DATE_LOCALE: Record<string, string> = {
|
const UI_DATE_LOCALE: Record<string, string> = {
|
||||||
@@ -1781,7 +1783,20 @@ export const Editor: React.FC = () => {
|
|||||||
editorRoute.tabId && editorRoute.gitDiffResource
|
editorRoute.tabId && editorRoute.gitDiffResource
|
||||||
? <GitDiffView key={editorRoute.tabId} filePath={editorRoute.gitDiffResource} />
|
? <GitDiffView key={editorRoute.tabId} filePath={editorRoute.gitDiffResource} />
|
||||||
: <Dashboard />,
|
: <Dashboard />,
|
||||||
documentation: () => <DocumentationView />,
|
documentation: () => (
|
||||||
|
<DocumentationView
|
||||||
|
content={documentationContent}
|
||||||
|
titleKey="docs.title"
|
||||||
|
subtitleKey="docs.subtitle"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
'api-documentation': () => (
|
||||||
|
<DocumentationView
|
||||||
|
content={apiDocumentationContent}
|
||||||
|
titleKey="docs.apiTitle"
|
||||||
|
subtitleKey="docs.apiSubtitle"
|
||||||
|
/>
|
||||||
|
),
|
||||||
'site-validation': () => <SiteValidationView />,
|
'site-validation': () => <SiteValidationView />,
|
||||||
scripts: () => <ScriptsView scriptId={editorRoute.tabId} />,
|
scripts: () => <ScriptsView scriptId={editorRoute.tabId} />,
|
||||||
post: () => (editorRoute.tabId ? <PostEditor key={editorRoute.tabId} postId={editorRoute.tabId} /> : <Dashboard />),
|
post: () => (editorRoute.tabId ? <PostEditor key={editorRoute.tabId} postId={editorRoute.tabId} /> : <Dashboard />),
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ const getTabTitle = (
|
|||||||
return tr('docs.title');
|
return tr('docs.title');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tab.type === 'api-documentation') {
|
||||||
|
return tr('docs.apiTitle');
|
||||||
|
}
|
||||||
|
|
||||||
if (tab.type === 'site-validation') {
|
if (tab.type === 'site-validation') {
|
||||||
return tr('siteValidation.tabTitle');
|
return tr('siteValidation.tabTitle');
|
||||||
}
|
}
|
||||||
@@ -157,6 +161,13 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
|
|||||||
<path d="M5 4h6v1H5V4zm0 2h6v1H5V6zm0 2h6v1H5V8zm0 2h4v1H5v-1z"/>
|
<path d="M5 4h6v1H5V4zm0 2h6v1H5V6zm0 2h6v1H5V8zm0 2h4v1H5v-1z"/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case 'api-documentation':
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M2.5 2A1.5 1.5 0 0 0 1 3.5v9A1.5 1.5 0 0 0 2.5 14h11a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 13.5 2h-11zm0 1h11a.5.5 0 0 1 .5.5v9a.5.5 0 0 1-.5.5h-11a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5z"/>
|
||||||
|
<path d="M4 5h2v1H4V5zm3 0h5v1H7V5zM4 7.5h2v1H4v-1zm3 0h5v1H7v-1zM4 10h2v1H4v-1zm3 0h5v1H7v-1z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case 'site-validation':
|
case 'site-validation':
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
|||||||
@@ -253,6 +253,8 @@
|
|||||||
"postLinks.openTitle": "Öffnen: {title}",
|
"postLinks.openTitle": "Öffnen: {title}",
|
||||||
"docs.title": "Dokumentation",
|
"docs.title": "Dokumentation",
|
||||||
"docs.subtitle": "Benutzerhandbuch für diese installierte bDS-Version.",
|
"docs.subtitle": "Benutzerhandbuch für diese installierte bDS-Version.",
|
||||||
|
"docs.apiTitle": "API-Dokumentation",
|
||||||
|
"docs.apiSubtitle": "Vollständige Referenz aller Python-Runtime-API-Aufrufe.",
|
||||||
"docs.copyCode": "Code kopieren",
|
"docs.copyCode": "Code kopieren",
|
||||||
"gitDiff.header": "Unterschied: {target}",
|
"gitDiff.header": "Unterschied: {target}",
|
||||||
"gitDiff.noProject": "Kein aktives Projekt ausgewählt.",
|
"gitDiff.noProject": "Kein aktives Projekt ausgewählt.",
|
||||||
|
|||||||
@@ -253,6 +253,8 @@
|
|||||||
"postLinks.openTitle": "Open: {title}",
|
"postLinks.openTitle": "Open: {title}",
|
||||||
"docs.title": "Documentation",
|
"docs.title": "Documentation",
|
||||||
"docs.subtitle": "User guide for this installed bDS version.",
|
"docs.subtitle": "User guide for this installed bDS version.",
|
||||||
|
"docs.apiTitle": "API Documentation",
|
||||||
|
"docs.apiSubtitle": "Complete reference of Python runtime API calls.",
|
||||||
"docs.copyCode": "Copy code",
|
"docs.copyCode": "Copy code",
|
||||||
"gitDiff.header": "Diff: {target}",
|
"gitDiff.header": "Diff: {target}",
|
||||||
"gitDiff.noProject": "No active project selected.",
|
"gitDiff.noProject": "No active project selected.",
|
||||||
|
|||||||
@@ -253,6 +253,8 @@
|
|||||||
"postLinks.openTitle": "Abrir: {title}",
|
"postLinks.openTitle": "Abrir: {title}",
|
||||||
"docs.title": "Documentación",
|
"docs.title": "Documentación",
|
||||||
"docs.subtitle": "Guía de usuario para esta versión instalada de bDS.",
|
"docs.subtitle": "Guía de usuario para esta versión instalada de bDS.",
|
||||||
|
"docs.apiTitle": "Documentación API",
|
||||||
|
"docs.apiSubtitle": "Referencia completa de llamadas API del runtime de Python.",
|
||||||
"docs.copyCode": "Copiar código",
|
"docs.copyCode": "Copiar código",
|
||||||
"gitDiff.header": "Diferencia: {target}",
|
"gitDiff.header": "Diferencia: {target}",
|
||||||
"gitDiff.noProject": "No hay un proyecto activo seleccionado.",
|
"gitDiff.noProject": "No hay un proyecto activo seleccionado.",
|
||||||
|
|||||||
@@ -253,6 +253,8 @@
|
|||||||
"postLinks.openTitle": "Ouvrir: {title}",
|
"postLinks.openTitle": "Ouvrir: {title}",
|
||||||
"docs.title": "Guide utilisateur",
|
"docs.title": "Guide utilisateur",
|
||||||
"docs.subtitle": "Guide utilisateur pour cette version installée de bDS.",
|
"docs.subtitle": "Guide utilisateur pour cette version installée de bDS.",
|
||||||
|
"docs.apiTitle": "Documentation API",
|
||||||
|
"docs.apiSubtitle": "Référence complète des appels API Python Runtime.",
|
||||||
"docs.copyCode": "Copier le code",
|
"docs.copyCode": "Copier le code",
|
||||||
"gitDiff.header": "Diff : {target}",
|
"gitDiff.header": "Diff : {target}",
|
||||||
"gitDiff.noProject": "Aucun projet actif sélectionné.",
|
"gitDiff.noProject": "Aucun projet actif sélectionné.",
|
||||||
|
|||||||
@@ -253,6 +253,8 @@
|
|||||||
"postLinks.openTitle": "Apri: {title}",
|
"postLinks.openTitle": "Apri: {title}",
|
||||||
"docs.title": "Documentazione",
|
"docs.title": "Documentazione",
|
||||||
"docs.subtitle": "Guida utente per questa versione installata di bDS.",
|
"docs.subtitle": "Guida utente per questa versione installata di bDS.",
|
||||||
|
"docs.apiTitle": "Documentazione API",
|
||||||
|
"docs.apiSubtitle": "Riferimento completo delle chiamate API del runtime Python.",
|
||||||
"docs.copyCode": "Copia codice",
|
"docs.copyCode": "Copia codice",
|
||||||
"gitDiff.header": "Differenza: {target}",
|
"gitDiff.header": "Differenza: {target}",
|
||||||
"gitDiff.noProject": "Nessun progetto attivo selezionato.",
|
"gitDiff.noProject": "Nessun progetto attivo selezionato.",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export type EditorRoute =
|
|||||||
| 'metadata-diff'
|
| 'metadata-diff'
|
||||||
| 'git-diff'
|
| 'git-diff'
|
||||||
| 'documentation'
|
| 'documentation'
|
||||||
|
| 'api-documentation'
|
||||||
| 'site-validation'
|
| 'site-validation'
|
||||||
| 'scripts';
|
| 'scripts';
|
||||||
|
|
||||||
@@ -29,6 +30,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'da
|
|||||||
'metadata-diff': 'metadata-diff',
|
'metadata-diff': 'metadata-diff',
|
||||||
'git-diff': 'git-diff',
|
'git-diff': 'git-diff',
|
||||||
documentation: 'documentation',
|
documentation: 'documentation',
|
||||||
|
'api-documentation': 'api-documentation',
|
||||||
'site-validation': 'site-validation',
|
'site-validation': 'site-validation',
|
||||||
scripts: 'scripts',
|
scripts: 'scripts',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type SingletonToolTabKey =
|
|||||||
| 'scripts'
|
| 'scripts'
|
||||||
| 'menu-editor'
|
| 'menu-editor'
|
||||||
| 'documentation'
|
| 'documentation'
|
||||||
|
| 'api-documentation'
|
||||||
| 'metadata-diff'
|
| 'metadata-diff'
|
||||||
| 'site-validation';
|
| 'site-validation';
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec>
|
|||||||
scripts: { type: 'scripts', id: 'scripts', isTransient: false },
|
scripts: { type: 'scripts', id: 'scripts', isTransient: false },
|
||||||
'menu-editor': { type: 'menu-editor', id: 'menu-editor', isTransient: false },
|
'menu-editor': { type: 'menu-editor', id: 'menu-editor', isTransient: false },
|
||||||
documentation: { type: 'documentation', id: 'documentation', isTransient: false },
|
documentation: { type: 'documentation', id: 'documentation', isTransient: false },
|
||||||
|
'api-documentation': { type: 'api-documentation', id: 'api-documentation', isTransient: false },
|
||||||
'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false },
|
'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false },
|
||||||
'site-validation': { type: 'site-validation', id: 'site-validation', isTransient: false },
|
'site-validation': { type: 'site-validation', id: 'site-validation', isTransient: false },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ import { createPythonRuntimeWorker } from './createPythonRuntimeWorker';
|
|||||||
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
|
import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol';
|
||||||
import type { PythonSyntaxError } from './runtimeProtocol';
|
import type { PythonSyntaxError } from './runtimeProtocol';
|
||||||
import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1';
|
import { parseMacroContextV1, parseMacroResultV1, type MacroContextV1, type MacroResultV1 } from './abiV1';
|
||||||
|
import { invokePythonApiMethodV1 } from './pythonApiInvokerV1';
|
||||||
|
|
||||||
type WorkerFactory = () => Worker;
|
type WorkerFactory = () => Worker;
|
||||||
|
type PythonApiInvoker = (method: string, args: unknown) => Promise<unknown>;
|
||||||
|
|
||||||
|
interface PythonRuntimeManagerOptions {
|
||||||
|
invokeApiCall?: PythonApiInvoker;
|
||||||
|
}
|
||||||
|
|
||||||
interface InitializeDeferred {
|
interface InitializeDeferred {
|
||||||
resolve: () => void;
|
resolve: () => void;
|
||||||
@@ -57,8 +63,14 @@ export class PythonRuntimeManager {
|
|||||||
private requestQueue: PythonWorkerRequest[] = [];
|
private requestQueue: PythonWorkerRequest[] = [];
|
||||||
private activeRequestId: string | null = null;
|
private activeRequestId: string | null = null;
|
||||||
private requestCounter = 0;
|
private requestCounter = 0;
|
||||||
|
private readonly invokeApiCall: PythonApiInvoker;
|
||||||
|
|
||||||
constructor(private readonly workerFactory: WorkerFactory = createPythonRuntimeWorker) {}
|
constructor(
|
||||||
|
private readonly workerFactory: WorkerFactory = createPythonRuntimeWorker,
|
||||||
|
options: PythonRuntimeManagerOptions = {}
|
||||||
|
) {
|
||||||
|
this.invokeApiCall = options.invokeApiCall ?? invokePythonApiMethodV1;
|
||||||
|
}
|
||||||
|
|
||||||
initialize(): Promise<void> {
|
initialize(): Promise<void> {
|
||||||
if (this.ready) {
|
if (this.ready) {
|
||||||
@@ -262,6 +274,11 @@ export class PythonRuntimeManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (payload.type === 'apiCall') {
|
||||||
|
void this.handleApiCall(payload);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pendingRun = this.pendingRuns.get(payload.requestId);
|
const pendingRun = this.pendingRuns.get(payload.requestId);
|
||||||
if (!pendingRun) {
|
if (!pendingRun) {
|
||||||
if (this.activeRequestId === payload.requestId && payload.type !== 'stdout') {
|
if (this.activeRequestId === payload.requestId && payload.type !== 'stdout') {
|
||||||
@@ -335,6 +352,33 @@ export class PythonRuntimeManager {
|
|||||||
this.finishRequest(payload.requestId);
|
this.finishRequest(payload.requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleApiCall(payload: Extract<PythonWorkerMessage, { type: 'apiCall' }>): Promise<void> {
|
||||||
|
if (!this.worker || !this.ready) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await this.invokeApiCall(payload.method, payload.args);
|
||||||
|
const response: PythonWorkerRequest = {
|
||||||
|
type: 'apiResult',
|
||||||
|
requestId: payload.requestId,
|
||||||
|
callId: payload.callId,
|
||||||
|
ok: true,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
this.worker.postMessage(response);
|
||||||
|
} catch (error) {
|
||||||
|
const response: PythonWorkerRequest = {
|
||||||
|
type: 'apiResult',
|
||||||
|
requestId: payload.requestId,
|
||||||
|
callId: payload.callId,
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
this.worker.postMessage(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private handleWorkerError(error: Error): void {
|
private handleWorkerError(error: Error): void {
|
||||||
if (this.initializeDeferred) {
|
if (this.initializeDeferred) {
|
||||||
this.initializeDeferred.reject(error);
|
this.initializeDeferred.reject(error);
|
||||||
|
|||||||
327
src/renderer/python/generateApiDocumentationMarkdownV1.ts
Normal file
327
src/renderer/python/generateApiDocumentationMarkdownV1.ts
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
import {
|
||||||
|
BDS_PYTHON_API_CONTRACT_V1,
|
||||||
|
type PythonApiDataStructureContractV1,
|
||||||
|
type PythonApiParamContractV1,
|
||||||
|
} from './pythonApiContractV1';
|
||||||
|
|
||||||
|
function toSnakeCase(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||||
|
.replace(/[^a-zA-Z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDocumentationHeadingSlug(value: string): string {
|
||||||
|
return value
|
||||||
|
.normalize('NFKD')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
.replace(/[^a-z0-9\s-]/g, '')
|
||||||
|
.trim()
|
||||||
|
.replace(/\s+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.replace(/-+/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPythonTypeName(type: PythonApiParamContractV1['type']): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'string':
|
||||||
|
return 'str';
|
||||||
|
case 'number':
|
||||||
|
return 'int | float';
|
||||||
|
case 'boolean':
|
||||||
|
return 'bool';
|
||||||
|
case 'object':
|
||||||
|
return 'dict';
|
||||||
|
case 'array':
|
||||||
|
return 'list';
|
||||||
|
case 'stringOrNull':
|
||||||
|
return 'str | None';
|
||||||
|
case 'any':
|
||||||
|
default:
|
||||||
|
return 'Any';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleValueForParam(param: PythonApiParamContractV1): string {
|
||||||
|
const snakeName = toSnakeCase(param.name);
|
||||||
|
|
||||||
|
switch (param.type) {
|
||||||
|
case 'string':
|
||||||
|
if (snakeName.includes('id')) {
|
||||||
|
return `'${snakeName.replace(/_id$/, '')}-1'`;
|
||||||
|
}
|
||||||
|
if (snakeName.includes('query')) {
|
||||||
|
return `'search phrase'`;
|
||||||
|
}
|
||||||
|
return `'${snakeName}'`;
|
||||||
|
case 'number':
|
||||||
|
return '10';
|
||||||
|
case 'boolean':
|
||||||
|
return 'True';
|
||||||
|
case 'object':
|
||||||
|
return '{}';
|
||||||
|
case 'array':
|
||||||
|
return '[]';
|
||||||
|
case 'stringOrNull':
|
||||||
|
return 'None';
|
||||||
|
case 'any':
|
||||||
|
default:
|
||||||
|
return 'None';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildExampleCall(namespace: string, method: string, params: PythonApiParamContractV1[]): string {
|
||||||
|
const pythonMethod = toSnakeCase(method);
|
||||||
|
const requiredParams = params.filter((param) => param.required);
|
||||||
|
|
||||||
|
if (requiredParams.length === 0) {
|
||||||
|
return `result = await bds.${namespace}.${pythonMethod}()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const args = requiredParams
|
||||||
|
.map((param) => `${toSnakeCase(param.name)}=${sampleValueForParam(param)}`)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
return `result = await bds.${namespace}.${pythonMethod}(${args})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResponseExample(returnsType: string): string {
|
||||||
|
const value = returnsType.trim();
|
||||||
|
|
||||||
|
if (value === 'boolean') {
|
||||||
|
return 'True';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'void') {
|
||||||
|
return 'None';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.endsWith('[]') || value.startsWith('Array<')) {
|
||||||
|
return '[]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.includes(' | null')) {
|
||||||
|
return 'None # or dict-like object when found';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'string') {
|
||||||
|
return "'value'";
|
||||||
|
}
|
||||||
|
|
||||||
|
return '{}';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDataStructureNames(typeSignature: string): string[] {
|
||||||
|
const matches = typeSignature.match(/\b[A-Z][A-Za-z0-9_]*\b/g) ?? [];
|
||||||
|
const names = matches.filter((name) =>
|
||||||
|
BDS_PYTHON_API_CONTRACT_V1.dataStructures.some((entry) => entry.name === name)
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...new Set(names)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleValueForField(type: string): string {
|
||||||
|
const normalized = type.trim();
|
||||||
|
|
||||||
|
if (normalized.includes('string')) {
|
||||||
|
return "'value'";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('number')) {
|
||||||
|
return '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('boolean')) {
|
||||||
|
return 'False';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('[]')) {
|
||||||
|
return '[]';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalized.includes('object') || normalized.includes('Record<')) {
|
||||||
|
return '{}';
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'None';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDataStructureExample(structure: PythonApiDataStructureContractV1): string {
|
||||||
|
const lines: string[] = ['{'];
|
||||||
|
|
||||||
|
structure.fields.forEach((field, index) => {
|
||||||
|
const comma = index < structure.fields.length - 1 ? ',' : '';
|
||||||
|
lines.push(` '${field.name}': ${sampleValueForField(field.type)}${comma}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
lines.push('}');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResponseExampleWithStructure(returnsType: string): string {
|
||||||
|
const structureNames = extractDataStructureNames(returnsType);
|
||||||
|
|
||||||
|
if (structureNames.length > 0) {
|
||||||
|
const structure = BDS_PYTHON_API_CONTRACT_V1.dataStructures.find((entry) => entry.name === structureNames[0]);
|
||||||
|
if (structure) {
|
||||||
|
if (returnsType.includes('[]') || returnsType.startsWith('Array<')) {
|
||||||
|
return `[\n${buildDataStructureExample(structure)}\n]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (returnsType.includes(' | null')) {
|
||||||
|
return `None # or\n${buildDataStructureExample(structure)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDataStructureExample(structure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildResponseExample(returnsType);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generateApiDocumentationMarkdownV1(): string {
|
||||||
|
const sections: string[] = [];
|
||||||
|
const namespaceOrder: string[] = [];
|
||||||
|
const grouped = new Map<string, typeof BDS_PYTHON_API_CONTRACT_V1.methods>();
|
||||||
|
|
||||||
|
for (const method of BDS_PYTHON_API_CONTRACT_V1.methods) {
|
||||||
|
const [namespace] = method.method.split('.');
|
||||||
|
if (!namespace) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!grouped.has(namespace)) {
|
||||||
|
grouped.set(namespace, []);
|
||||||
|
namespaceOrder.push(namespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped.get(namespace)?.push(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push('# API Documentation');
|
||||||
|
sections.push('');
|
||||||
|
sections.push(`Contract version: ${BDS_PYTHON_API_CONTRACT_V1.version}`);
|
||||||
|
sections.push('');
|
||||||
|
sections.push('This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.');
|
||||||
|
sections.push('');
|
||||||
|
sections.push('## Usage');
|
||||||
|
sections.push('');
|
||||||
|
sections.push('```python');
|
||||||
|
sections.push('from bds_api import bds');
|
||||||
|
sections.push('');
|
||||||
|
sections.push('# inside an async Python function in bDS runtime:');
|
||||||
|
sections.push("project = await bds.meta.get_project_metadata()");
|
||||||
|
sections.push('```');
|
||||||
|
sections.push('');
|
||||||
|
sections.push('## Table of contents');
|
||||||
|
sections.push('');
|
||||||
|
|
||||||
|
for (const namespace of namespaceOrder) {
|
||||||
|
const moduleAnchor = toDocumentationHeadingSlug(namespace);
|
||||||
|
sections.push(`- [${namespace}](#${moduleAnchor})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push('- [Data Structures](#data-structures)');
|
||||||
|
|
||||||
|
for (const namespace of namespaceOrder) {
|
||||||
|
const namespaceMethods = grouped.get(namespace) ?? [];
|
||||||
|
sections.push('');
|
||||||
|
sections.push(`## ${namespace}`);
|
||||||
|
sections.push('');
|
||||||
|
sections.push('**Module APIs**');
|
||||||
|
sections.push('');
|
||||||
|
|
||||||
|
for (const method of namespaceMethods) {
|
||||||
|
const apiAnchor = toDocumentationHeadingSlug(method.method);
|
||||||
|
sections.push(`- [${method.method}](#${apiAnchor})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const method of namespaceMethods) {
|
||||||
|
const [, member] = method.method.split('.');
|
||||||
|
if (!member) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push('');
|
||||||
|
sections.push(`### ${method.method}`);
|
||||||
|
sections.push('');
|
||||||
|
sections.push(method.description);
|
||||||
|
sections.push('');
|
||||||
|
sections.push('**Parameters**');
|
||||||
|
sections.push('');
|
||||||
|
|
||||||
|
if (method.params.length === 0) {
|
||||||
|
sections.push('- None');
|
||||||
|
} else {
|
||||||
|
for (const param of method.params) {
|
||||||
|
const requiredState = param.required ? 'required' : 'optional';
|
||||||
|
sections.push(`- ${param.name} (${toPythonTypeName(param.type)}, ${requiredState})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push('');
|
||||||
|
sections.push('**Response specification**');
|
||||||
|
sections.push('');
|
||||||
|
sections.push(`- Return type: \`${method.returns}\``);
|
||||||
|
|
||||||
|
if (method.returns.includes(' | null')) {
|
||||||
|
sections.push('- Nullability: Returns `None` when no matching value exists.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const referencedStructures = extractDataStructureNames(method.returns);
|
||||||
|
if (referencedStructures.length > 0) {
|
||||||
|
sections.push(`- Data structures: ${referencedStructures.map((name) => `\`${name}\``).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push('');
|
||||||
|
sections.push('**Example call**');
|
||||||
|
sections.push('');
|
||||||
|
sections.push('```python');
|
||||||
|
sections.push('from bds_api import bds');
|
||||||
|
sections.push(buildExampleCall(namespace, member, method.params));
|
||||||
|
sections.push('```');
|
||||||
|
sections.push('');
|
||||||
|
sections.push('**Example response**');
|
||||||
|
sections.push('');
|
||||||
|
sections.push('```python');
|
||||||
|
sections.push(buildResponseExampleWithStructure(method.returns));
|
||||||
|
sections.push('```');
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push('');
|
||||||
|
sections.push('[↑ Back to Table of contents](#table-of-contents)');
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push('');
|
||||||
|
sections.push('## Data Structures');
|
||||||
|
sections.push('');
|
||||||
|
sections.push('Shared structures referenced by response types are defined once here.');
|
||||||
|
|
||||||
|
for (const structure of BDS_PYTHON_API_CONTRACT_V1.dataStructures) {
|
||||||
|
sections.push('');
|
||||||
|
sections.push(`### ${structure.name}`);
|
||||||
|
sections.push('');
|
||||||
|
sections.push(structure.description);
|
||||||
|
sections.push('');
|
||||||
|
sections.push('**Fields**');
|
||||||
|
sections.push('');
|
||||||
|
|
||||||
|
for (const field of structure.fields) {
|
||||||
|
const requiredState = field.required ? 'required' : 'optional';
|
||||||
|
sections.push(`- ${field.name} (\`${field.type}\`, ${requiredState}): ${field.description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push('');
|
||||||
|
sections.push('[↑ Back to Table of contents](#table-of-contents)');
|
||||||
|
}
|
||||||
|
|
||||||
|
sections.push('');
|
||||||
|
sections.push('---');
|
||||||
|
sections.push('');
|
||||||
|
sections.push(`Generated from contract at ${BDS_PYTHON_API_CONTRACT_V1.generatedAt}.`);
|
||||||
|
sections.push('');
|
||||||
|
|
||||||
|
return `${sections.join('\n').trim()}\n`;
|
||||||
|
}
|
||||||
128
src/renderer/python/generatePythonApiModuleV1.ts
Normal file
128
src/renderer/python/generatePythonApiModuleV1.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { BDS_PYTHON_API_CONTRACT_V1 } from './pythonApiContractV1';
|
||||||
|
|
||||||
|
function toSnakeCase(value: string): string {
|
||||||
|
return value
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
||||||
|
.replace(/[^a-zA-Z0-9]+/g, '_')
|
||||||
|
.replace(/^_+|_+$/g, '')
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function quotePython(value: string): string {
|
||||||
|
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPythonMethod(method: {
|
||||||
|
method: string;
|
||||||
|
description: string;
|
||||||
|
params: Array<{ name: string; required: boolean }>;
|
||||||
|
}): string {
|
||||||
|
const [namespace, member] = method.method.split('.');
|
||||||
|
if (!namespace || !member) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const pythonMethodName = toSnakeCase(member);
|
||||||
|
const pythonParams = method.params.map((param) => ({
|
||||||
|
sourceName: param.name,
|
||||||
|
pythonName: toSnakeCase(param.name),
|
||||||
|
required: param.required,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const signature = pythonParams.length > 0
|
||||||
|
? `, ${pythonParams.map((param) => (param.required ? param.pythonName : `${param.pythonName}=None`)).join(', ')}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const argsDict = method.params.length > 0
|
||||||
|
? `{ ${method.params.map((param, index) => `"${param.name}": ${pythonParams[index]?.pythonName}`).join(', ')} }`
|
||||||
|
: '{}';
|
||||||
|
|
||||||
|
return [
|
||||||
|
` async def ${pythonMethodName}(self${signature}):`,
|
||||||
|
` \"\"\"${quotePython(method.description)}\"\"\"`,
|
||||||
|
` return await self._transport.call("${method.method}", ${argsDict})`,
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPythonNamespaceClass(
|
||||||
|
namespace: string,
|
||||||
|
methods: Array<{ method: string; description: string; params: Array<{ name: string; required: boolean }> }>
|
||||||
|
): string {
|
||||||
|
const className = `${namespace[0].toUpperCase()}${namespace.slice(1)}Api`;
|
||||||
|
const methodBlocks = methods.map((method) => buildPythonMethod(method)).join('');
|
||||||
|
|
||||||
|
return [
|
||||||
|
`class ${className}:`,
|
||||||
|
' def __init__(self, transport):',
|
||||||
|
' self._transport = transport',
|
||||||
|
'',
|
||||||
|
methodBlocks.trimEnd(),
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePythonApiModuleV1(): string {
|
||||||
|
const namespaceMap = new Map<string, Array<{ method: string; description: string; params: Array<{ name: string; required: boolean }> }>>();
|
||||||
|
|
||||||
|
for (const method of BDS_PYTHON_API_CONTRACT_V1.methods) {
|
||||||
|
const [namespace] = method.method.split('.');
|
||||||
|
if (!namespace) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = namespaceMap.get(namespace) ?? [];
|
||||||
|
entries.push({
|
||||||
|
method: method.method,
|
||||||
|
description: method.description,
|
||||||
|
params: method.params.map((param) => ({
|
||||||
|
name: param.name,
|
||||||
|
required: param.required,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
namespaceMap.set(namespace, entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
const namespaceBlocks = Array.from(namespaceMap.entries())
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([namespace, methods]) => buildPythonNamespaceClass(namespace, methods))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
const namespaceAssignments = Array.from(namespaceMap.keys())
|
||||||
|
.sort((left, right) => left.localeCompare(right))
|
||||||
|
.map((namespace) => ` self.${toSnakeCase(namespace)} = ${namespace[0].toUpperCase()}${namespace.slice(1)}Api(transport)`)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'# Auto-generated by generatePythonApiModuleV1.ts',
|
||||||
|
`# Contract version: ${BDS_PYTHON_API_CONTRACT_V1.version}`,
|
||||||
|
'',
|
||||||
|
'import json',
|
||||||
|
'',
|
||||||
|
'class BdsApiError(Exception):',
|
||||||
|
' pass',
|
||||||
|
'',
|
||||||
|
namespaceBlocks.trimEnd(),
|
||||||
|
'',
|
||||||
|
'class BdsApi:',
|
||||||
|
' def __init__(self, transport):',
|
||||||
|
' self._transport = transport',
|
||||||
|
namespaceAssignments,
|
||||||
|
'',
|
||||||
|
'class _Transport:',
|
||||||
|
' def __init__(self, call_impl):',
|
||||||
|
' self._call_impl = call_impl',
|
||||||
|
'',
|
||||||
|
' async def call(self, method, args):',
|
||||||
|
' raw_result = await self._call_impl(method, json.dumps(args))',
|
||||||
|
' if raw_result is None or raw_result == "":',
|
||||||
|
' return None',
|
||||||
|
' return json.loads(raw_result)',
|
||||||
|
'',
|
||||||
|
'def install_bds_api(call_impl):',
|
||||||
|
' _transport = _Transport(call_impl)',
|
||||||
|
' bds = BdsApi(_transport)',
|
||||||
|
' return bds',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
381
src/renderer/python/pythonApiContractV1.ts
Normal file
381
src/renderer/python/pythonApiContractV1.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import type { ElectronAPI } from '../../main/shared/electronApi';
|
||||||
|
|
||||||
|
type PythonPromiseMethodPath = {
|
||||||
|
[Group in keyof ElectronAPI]: ElectronAPI[Group] extends Record<string, (...args: never[]) => unknown>
|
||||||
|
? {
|
||||||
|
[Method in keyof ElectronAPI[Group]]: ElectronAPI[Group][Method] extends (...args: never[]) => Promise<unknown>
|
||||||
|
? `${Extract<Group, string>}.${Extract<Method, string>}`
|
||||||
|
: never;
|
||||||
|
}[keyof ElectronAPI[Group]]
|
||||||
|
: never;
|
||||||
|
}[keyof ElectronAPI];
|
||||||
|
|
||||||
|
export type PythonApiParamType = 'string' | 'number' | 'boolean' | 'object' | 'array' | 'any' | 'stringOrNull';
|
||||||
|
|
||||||
|
export interface PythonApiParamContractV1 {
|
||||||
|
name: string;
|
||||||
|
type: PythonApiParamType;
|
||||||
|
required: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PythonApiMethodContractV1 {
|
||||||
|
method: PythonPromiseMethodPath;
|
||||||
|
description: string;
|
||||||
|
params: PythonApiParamContractV1[];
|
||||||
|
returns: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PythonApiDataStructureFieldContractV1 {
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
required: boolean;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PythonApiDataStructureContractV1 {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
fields: PythonApiDataStructureFieldContractV1[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PythonApiContractV1 {
|
||||||
|
version: string;
|
||||||
|
generatedAt: string;
|
||||||
|
methods: PythonApiMethodContractV1[];
|
||||||
|
dataStructures: PythonApiDataStructureContractV1[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredString = (name: string): PythonApiParamContractV1 => ({ name, type: 'string', required: true });
|
||||||
|
const optionalString = (name: string): PythonApiParamContractV1 => ({ name, type: 'string', required: false });
|
||||||
|
const optionalNumber = (name: string): PythonApiParamContractV1 => ({ name, type: 'number', required: false });
|
||||||
|
const requiredObject = (name: string): PythonApiParamContractV1 => ({ name, type: 'object', required: true });
|
||||||
|
const optionalObject = (name: string): PythonApiParamContractV1 => ({ name, type: 'object', required: false });
|
||||||
|
const requiredArray = (name: string): PythonApiParamContractV1 => ({ name, type: 'array', required: true });
|
||||||
|
const requiredAny = (name: string): PythonApiParamContractV1 => ({ name, type: 'any', required: true });
|
||||||
|
const requiredStringOrNull = (name: string): PythonApiParamContractV1 => ({ name, type: 'stringOrNull', required: true });
|
||||||
|
|
||||||
|
function method(
|
||||||
|
methodName: PythonPromiseMethodPath,
|
||||||
|
description: string,
|
||||||
|
params: PythonApiParamContractV1[],
|
||||||
|
returns: string
|
||||||
|
): PythonApiMethodContractV1 {
|
||||||
|
return {
|
||||||
|
method: methodName,
|
||||||
|
description,
|
||||||
|
params,
|
||||||
|
returns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const METHODS_V1: PythonApiMethodContractV1[] = [
|
||||||
|
method('projects.create', 'Create a project.', [requiredObject('data')], 'ProjectData'),
|
||||||
|
method('projects.update', 'Update a project by id.', [requiredString('id'), requiredObject('data')], 'ProjectData | null'),
|
||||||
|
method('projects.delete', 'Delete a project by id.', [requiredString('id')], 'boolean'),
|
||||||
|
method('projects.deleteWithData', 'Delete a project and data by id.', [requiredString('id')], 'boolean'),
|
||||||
|
method('projects.get', 'Fetch one project by id.', [requiredString('id')], 'ProjectData | null'),
|
||||||
|
method('projects.getAll', 'Fetch all projects.', [], 'ProjectData[]'),
|
||||||
|
method('projects.getActive', 'Fetch active project.', [], 'ProjectData | null'),
|
||||||
|
method('projects.setActive', 'Set active project by id.', [requiredString('id')], 'ProjectData | null'),
|
||||||
|
|
||||||
|
method('posts.create', 'Create a post.', [requiredObject('data')], 'PostData'),
|
||||||
|
method('posts.update', 'Update a post by id.', [requiredString('id'), requiredObject('data')], 'PostData | null'),
|
||||||
|
method('posts.delete', 'Delete a post by id.', [requiredString('id')], 'boolean'),
|
||||||
|
method('posts.get', 'Fetch one post by id.', [requiredString('postId')], 'PostData | null'),
|
||||||
|
method('posts.getPreviewUrl', 'Get preview URL for post.', [requiredString('id'), optionalObject('options')], 'string | null'),
|
||||||
|
method('posts.getAll', 'Fetch posts with pagination.', [optionalObject('options')], 'PaginatedPostsResult'),
|
||||||
|
method('posts.getByStatus', 'Fetch posts by status.', [requiredString('status')], 'PostData[]'),
|
||||||
|
method('posts.publish', 'Publish a post by id.', [requiredString('id')], 'PostData | null'),
|
||||||
|
method('posts.discard', 'Discard draft changes for post.', [requiredString('id')], 'PostData | null'),
|
||||||
|
method('posts.hasPublishedVersion', 'Check if post has published version.', [requiredString('id')], 'boolean'),
|
||||||
|
method('posts.rebuildFromFiles', 'Rebuild posts database from files.', [], 'void'),
|
||||||
|
method('posts.reindexText', 'Reindex post search text.', [], 'void'),
|
||||||
|
method('posts.search', 'Search posts by free-text query.', [requiredString('query')], 'SearchResult[]'),
|
||||||
|
method('posts.filter', 'Filter posts by criteria.', [requiredObject('filter')], 'PostData[]'),
|
||||||
|
method('posts.getTags', 'Get all post tags.', [], 'string[]'),
|
||||||
|
method('posts.getCategories', 'Get all post categories.', [], 'string[]'),
|
||||||
|
method('posts.getByYearMonth', 'Get post counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
|
||||||
|
method('posts.getDashboardStats', 'Get post dashboard stats.', [], 'DashboardStats'),
|
||||||
|
method('posts.getTagsWithCounts', 'Get post tags with counts.', [], 'TagCount[]'),
|
||||||
|
method('posts.getCategoriesWithCounts', 'Get post categories with counts.', [], 'CategoryCount[]'),
|
||||||
|
method('posts.getLinksTo', 'Get posts linked to given post.', [requiredString('id')], 'PostData[]'),
|
||||||
|
method('posts.getLinkedBy', 'Get posts linking to given post.', [requiredString('id')], 'PostData[]'),
|
||||||
|
method('posts.rebuildLinks', 'Rebuild post link graph.', [], 'void'),
|
||||||
|
method('posts.isSlugAvailable', 'Check if post slug is available.', [requiredString('slug'), optionalString('excludePostId')], 'boolean'),
|
||||||
|
method('posts.generateUniqueSlug', 'Generate unique slug from title.', [requiredString('title'), optionalString('excludePostId')], 'string'),
|
||||||
|
|
||||||
|
method('media.import', 'Import media file.', [requiredString('sourcePath'), optionalObject('metadata')], 'MediaData'),
|
||||||
|
method('media.update', 'Update media metadata by id.', [requiredString('id'), requiredObject('data')], 'MediaData | null'),
|
||||||
|
method('media.replaceFile', 'Replace media file by id.', [requiredString('id'), requiredString('newSourcePath')], 'MediaData | null'),
|
||||||
|
method('media.delete', 'Delete media by id.', [requiredString('id')], 'boolean'),
|
||||||
|
method('media.get', 'Fetch one media by id.', [requiredString('id')], 'MediaData | null'),
|
||||||
|
method('media.getUrl', 'Get media URL by id.', [requiredString('id')], 'string | null'),
|
||||||
|
method('media.getFilePath', 'Get media file path by id.', [requiredString('id')], 'string | null'),
|
||||||
|
method('media.getAll', 'Fetch all media.', [], 'MediaData[]'),
|
||||||
|
method('media.rebuildFromFiles', 'Rebuild media database from files.', [], 'void'),
|
||||||
|
method('media.reindexText', 'Reindex media search text.', [], 'void'),
|
||||||
|
method('media.getThumbnail', 'Get media thumbnail URL.', [requiredString('id'), optionalString('size')], 'string | null'),
|
||||||
|
method('media.regenerateThumbnails', 'Regenerate thumbnails for media.', [requiredString('id')], 'Record<string, string> | null'),
|
||||||
|
method('media.regenerateMissingThumbnails', 'Regenerate all missing thumbnails.', [], '{ processed: number; generated: number; failed: number }'),
|
||||||
|
method('media.filter', 'Filter media by criteria.', [requiredObject('filter')], 'MediaData[]'),
|
||||||
|
method('media.search', 'Search media by free-text query.', [requiredString('query')], 'MediaSearchResult[]'),
|
||||||
|
method('media.getByYearMonth', 'Get media counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
|
||||||
|
method('media.getTags', 'Get all media tags.', [], 'string[]'),
|
||||||
|
method('media.getTagsWithCounts', 'Get media tags with counts.', [], 'TagCount[]'),
|
||||||
|
|
||||||
|
method('scripts.create', 'Create script.', [requiredObject('data')], 'ScriptData'),
|
||||||
|
method('scripts.update', 'Update script by id.', [requiredString('id'), requiredObject('data')], 'ScriptData | null'),
|
||||||
|
method('scripts.delete', 'Delete script by id.', [requiredString('id')], 'boolean'),
|
||||||
|
method('scripts.get', 'Fetch script by id.', [requiredString('id')], 'ScriptData | null'),
|
||||||
|
method('scripts.getAll', 'Fetch all scripts.', [], 'ScriptData[]'),
|
||||||
|
method('scripts.rebuildFromFiles', 'Rebuild scripts from files.', [], 'void'),
|
||||||
|
|
||||||
|
method('tasks.getAll', 'Fetch all tasks.', [], 'TaskProgress[]'),
|
||||||
|
method('tasks.getRunning', 'Fetch running tasks.', [], 'TaskProgress[]'),
|
||||||
|
method('tasks.cancel', 'Cancel task by id.', [requiredString('taskId')], 'boolean'),
|
||||||
|
method('tasks.clearCompleted', 'Clear completed tasks.', [], 'void'),
|
||||||
|
|
||||||
|
method('app.getDataPaths', 'Get app data paths.', [], '{ database: string; posts: string; media: string }'),
|
||||||
|
method('app.getSystemLanguage', 'Get system language.', [], 'string'),
|
||||||
|
method('app.getTitleBarMetrics', 'Get title bar metrics.', [], '{ macosLeftInset: number } | null'),
|
||||||
|
method('app.openFolder', 'Open folder in system file manager.', [requiredString('folderPath')], 'string'),
|
||||||
|
method('app.showItemInFolder', 'Reveal item in system file manager.', [requiredString('itemPath')], 'void'),
|
||||||
|
method('app.selectFolder', 'Show folder picker dialog.', [optionalString('title')], 'string | null'),
|
||||||
|
method('app.getDefaultProjectPath', 'Get default project path.', [requiredString('projectId')], 'string'),
|
||||||
|
method('app.readProjectMetadata', 'Read project metadata from path.', [requiredString('folderPath')], '{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null'),
|
||||||
|
method('app.getBlogmarkBookmarklet', 'Get blogmark bookmarklet script.', [], 'string'),
|
||||||
|
method('app.copyToClipboard', 'Copy text to clipboard.', [requiredString('text')], 'boolean'),
|
||||||
|
method('app.notifyRendererReady', 'Notify main process renderer is ready.', [], 'boolean'),
|
||||||
|
method('app.setPreviewPostTarget', 'Set preview post target.', [requiredStringOrNull('postId')], 'void'),
|
||||||
|
method('app.triggerMenuAction', 'Trigger menu action.', [requiredString('action')], 'void'),
|
||||||
|
|
||||||
|
method('meta.getTags', 'Get project tags.', [], 'string[]'),
|
||||||
|
method('meta.getCategories', 'Get project categories.', [], 'string[]'),
|
||||||
|
method('meta.addTag', 'Add project tag.', [requiredString('tag')], 'string[]'),
|
||||||
|
method('meta.removeTag', 'Remove project tag.', [requiredString('tag')], 'string[]'),
|
||||||
|
method('meta.addCategory', 'Add project category.', [requiredString('category')], 'string[]'),
|
||||||
|
method('meta.removeCategory', 'Remove project category.', [requiredString('category')], 'string[]'),
|
||||||
|
method('meta.syncOnStartup', 'Sync meta values on startup.', [], '{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }'),
|
||||||
|
method('meta.getProjectMetadata', 'Read active project metadata.', [], 'ProjectMetadata | null'),
|
||||||
|
method('meta.setProjectMetadata', 'Set project metadata.', [requiredObject('metadata')], 'ProjectMetadata | null'),
|
||||||
|
method('meta.updateProjectMetadata', 'Update project metadata.', [requiredObject('updates')], 'ProjectMetadata | null'),
|
||||||
|
|
||||||
|
method('tags.getAll', 'Fetch all tags.', [], 'TagData[]'),
|
||||||
|
method('tags.getWithCounts', 'Fetch tags with counts.', [], 'TagWithCount[]'),
|
||||||
|
method('tags.get', 'Fetch tag by id.', [requiredString('id')], 'TagData | null'),
|
||||||
|
method('tags.getByName', 'Fetch tag by name.', [requiredString('name')], 'TagData | null'),
|
||||||
|
method('tags.create', 'Create tag.', [requiredObject('data')], 'TagData'),
|
||||||
|
method('tags.update', 'Update tag by id.', [requiredString('id'), requiredObject('data')], 'TagData | null'),
|
||||||
|
method('tags.delete', 'Delete tag by id.', [requiredString('id')], 'DeleteTagResult'),
|
||||||
|
method('tags.merge', 'Merge tags into target tag.', [requiredArray('sourceTagIds'), requiredString('targetTagId')], 'MergeTagsResult'),
|
||||||
|
method('tags.rename', 'Rename tag by id.', [requiredString('id'), requiredString('newName')], 'RenameTagResult'),
|
||||||
|
method('tags.getPostsWithTag', 'Get posts using a tag.', [requiredString('tagId')], 'string[]'),
|
||||||
|
method('tags.syncFromPosts', 'Sync tag index from posts.', [], 'SyncTagsResult'),
|
||||||
|
|
||||||
|
method('chat.checkReady', 'Check chat backend readiness.', [], 'ChatReadyStatus'),
|
||||||
|
method('chat.validateApiKey', 'Validate chat API key and list available models.', [requiredString('apiKey')], '{ isValid: boolean; models: ChatModel[] }'),
|
||||||
|
method('chat.setApiKey', 'Store chat API key.', [requiredString('apiKey')], '{ success: boolean; error?: string }'),
|
||||||
|
method('chat.getApiKey', 'Get stored chat API key status.', [], 'ChatApiKeyStatus'),
|
||||||
|
method('chat.getAvailableModels', 'Get available chat models and selected default.', [], '{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }'),
|
||||||
|
method('chat.setDefaultModel', 'Set default chat model.', [requiredString('modelId')], '{ success: boolean; error?: string }'),
|
||||||
|
method('chat.getSystemPrompt', 'Get configured system prompt.', [], '{ success: boolean; prompt?: string; error?: string }'),
|
||||||
|
method('chat.setSystemPrompt', 'Set system prompt.', [requiredString('prompt')], '{ success: boolean; error?: string }'),
|
||||||
|
method('chat.getConversations', 'Fetch all chat conversations.', [], 'ChatConversation[]'),
|
||||||
|
method('chat.createConversation', 'Create a chat conversation.', [optionalString('title'), optionalString('model')], 'ChatConversation'),
|
||||||
|
method('chat.getConversation', 'Fetch one chat conversation by id.', [requiredString('id')], 'ChatConversation | null'),
|
||||||
|
method('chat.updateConversation', 'Update chat conversation metadata.', [requiredString('id'), requiredObject('updates')], 'ChatConversation | null'),
|
||||||
|
method('chat.deleteConversation', 'Delete chat conversation by id.', [requiredString('id')], 'boolean'),
|
||||||
|
method('chat.sendMessage', 'Send message to chat conversation.', [requiredString('conversationId'), requiredString('message')], '{ success: boolean; message?: string; error?: string }'),
|
||||||
|
method('chat.abortMessage', 'Abort active streaming chat response.', [requiredString('conversationId')], 'void'),
|
||||||
|
method('chat.getHistory', 'Get message history for conversation.', [requiredString('conversationId')], 'ChatMessage[]'),
|
||||||
|
method('chat.clearMessages', 'Clear messages for conversation.', [requiredString('conversationId')], 'void'),
|
||||||
|
method('chat.setConversationModel', 'Set model for a conversation.', [requiredString('conversationId'), requiredString('modelId')], 'void'),
|
||||||
|
method('chat.analyzeTaxonomy', 'Analyze categories and tags using AI.', [requiredArray('categories'), requiredArray('tags'), requiredString('modelId')], '{ success: boolean; categoryMappings?: Record<string, string>; tagMappings?: Record<string, string>; error?: string }'),
|
||||||
|
method('chat.analyzeMediaImage', 'Analyze media image and propose metadata.', [requiredString('mediaId'), optionalString('language')], '{ success: boolean; title?: string; alt?: string; caption?: string; error?: string }'),
|
||||||
|
|
||||||
|
method('sync.configure', 'Configure sync.', [requiredObject('config')], 'void'),
|
||||||
|
method('sync.start', 'Start sync operation.', [optionalString('direction')], 'SyncResult'),
|
||||||
|
method('sync.getStatus', 'Get sync status.', [], "'idle' | 'syncing' | 'error'"),
|
||||||
|
method('sync.isConfigured', 'Check if sync is configured.', [], 'boolean'),
|
||||||
|
method('sync.getPendingCount', 'Get pending sync item count.', [], '{ posts: number; media: number }'),
|
||||||
|
method('sync.getLog', 'Get sync log.', [optionalNumber('limit')], 'unknown[]'),
|
||||||
|
method('sync.stopAutoSync', 'Stop automatic sync.', [], 'void'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||||
|
{
|
||||||
|
name: 'ProjectData',
|
||||||
|
description: 'Project metadata stored in the app database.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'id', type: 'string', required: true, description: 'Unique project identifier.' },
|
||||||
|
{ name: 'name', type: 'string', required: true, description: 'Human-readable project name.' },
|
||||||
|
{ name: 'slug', type: 'string', required: true, description: 'URL-friendly project slug.' },
|
||||||
|
{ name: 'description', type: 'string', required: false, description: 'Optional project description.' },
|
||||||
|
{ name: 'dataPath', type: 'string', required: false, description: 'Filesystem path for project data.' },
|
||||||
|
{ name: 'isActive', type: 'boolean', required: true, description: 'Whether this project is currently active.' },
|
||||||
|
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
|
||||||
|
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'PostData',
|
||||||
|
description: 'Canonical post object used across editor and generation flows.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'id', type: 'string', required: true, description: 'Unique post identifier.' },
|
||||||
|
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
|
||||||
|
{ name: 'title', type: 'string', required: true, description: 'Post title.' },
|
||||||
|
{ name: 'slug', type: 'string', required: true, description: 'URL slug used for generated routes.' },
|
||||||
|
{ name: 'excerpt', type: 'string', required: false, description: 'Optional short summary.' },
|
||||||
|
{ name: 'content', type: 'string', required: true, description: 'Markdown body content.' },
|
||||||
|
{ name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Publication lifecycle state.' },
|
||||||
|
{ name: 'author', type: 'string', required: false, description: 'Optional author name.' },
|
||||||
|
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
|
||||||
|
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
|
||||||
|
{ name: 'publishedAt', type: 'string', required: false, description: 'Publication timestamp for published posts.' },
|
||||||
|
{ name: 'tags', type: 'string[]', required: true, description: 'List of tag names.' },
|
||||||
|
{ name: 'categories', type: 'string[]', required: true, description: 'List of category names.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'MediaData',
|
||||||
|
description: 'Canonical media object representing imported files and metadata.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'id', type: 'string', required: true, description: 'Unique media identifier.' },
|
||||||
|
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
|
||||||
|
{ name: 'filename', type: 'string', required: true, description: 'Stored filename in project media folder.' },
|
||||||
|
{ name: 'originalName', type: 'string', required: true, description: 'Original imported filename.' },
|
||||||
|
{ name: 'mimeType', type: 'string', required: true, description: 'Detected MIME type.' },
|
||||||
|
{ name: 'size', type: 'number', required: true, description: 'File size in bytes.' },
|
||||||
|
{ name: 'width', type: 'number', required: false, description: 'Image width in pixels when available.' },
|
||||||
|
{ name: 'height', type: 'number', required: false, description: 'Image height in pixels when available.' },
|
||||||
|
{ name: 'title', type: 'string', required: false, description: 'Optional display title.' },
|
||||||
|
{ name: 'alt', type: 'string', required: false, description: 'Optional alternative text.' },
|
||||||
|
{ name: 'caption', type: 'string', required: false, description: 'Optional caption text.' },
|
||||||
|
{ name: 'author', type: 'string', required: false, description: 'Optional author credit.' },
|
||||||
|
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
|
||||||
|
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
|
||||||
|
{ name: 'tags', type: 'string[]', required: true, description: 'List of media tags.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ScriptData',
|
||||||
|
description: 'Script definition for Python macros, utilities, and transforms.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'id', type: 'string', required: true, description: 'Unique script identifier.' },
|
||||||
|
{ name: 'projectId', type: 'string', required: true, description: 'Owning project id.' },
|
||||||
|
{ name: 'slug', type: 'string', required: true, description: 'Stable script slug.' },
|
||||||
|
{ name: 'title', type: 'string', required: true, description: 'Human-readable script title.' },
|
||||||
|
{ name: 'kind', type: "'macro' | 'utility' | 'transform'", required: true, description: 'Script category.' },
|
||||||
|
{ name: 'entrypoint', type: 'string', required: true, description: 'Python entrypoint function name.' },
|
||||||
|
{ name: 'enabled', type: 'boolean', required: true, description: 'Whether script is enabled.' },
|
||||||
|
{ name: 'version', type: 'number', required: true, description: 'Incrementing script version.' },
|
||||||
|
{ name: 'filePath', type: 'string', required: true, description: 'Filesystem path to script file.' },
|
||||||
|
{ name: 'content', type: 'string', required: true, description: 'Script source code.' },
|
||||||
|
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
|
||||||
|
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'TaskProgress',
|
||||||
|
description: 'Task queue status object for long-running operations.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'taskId', type: 'string', required: true, description: 'Unique task identifier.' },
|
||||||
|
{ name: 'name', type: 'string', required: true, description: 'Task display name.' },
|
||||||
|
{ name: 'status', type: "'pending' | 'running' | 'completed' | 'failed' | 'cancelled'", required: true, description: 'Current task status.' },
|
||||||
|
{ name: 'progress', type: 'number', required: true, description: 'Progress percentage from 0-100.' },
|
||||||
|
{ name: 'message', type: 'string', required: true, description: 'Current progress message.' },
|
||||||
|
{ name: 'startTime', type: 'string', required: true, description: 'Task start time (ISO string).' },
|
||||||
|
{ name: 'endTime', type: 'string', required: false, description: 'Task completion time (ISO string).' },
|
||||||
|
{ name: 'error', type: 'string', required: false, description: 'Error message when failed.' },
|
||||||
|
{ name: 'groupId', type: 'string', required: false, description: 'Optional grouping id.' },
|
||||||
|
{ name: 'groupName', type: 'string', required: false, description: 'Optional grouping label.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ProjectMetadata',
|
||||||
|
description: 'Extended project metadata from project settings.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'name', type: 'string', required: true, description: 'Project display name.' },
|
||||||
|
{ name: 'description', type: 'string', required: false, description: 'Optional project description.' },
|
||||||
|
{ name: 'dataPath', type: 'string', required: false, description: 'Optional custom data path.' },
|
||||||
|
{ name: 'publicUrl', type: 'string', required: false, description: 'Optional public site URL.' },
|
||||||
|
{ name: 'mainLanguage', type: 'string', required: false, description: 'Main render language code.' },
|
||||||
|
{ name: 'defaultAuthor', type: 'string', required: false, description: 'Default author for new posts.' },
|
||||||
|
{ name: 'maxPostsPerPage', type: 'number', required: false, description: 'Pagination size for generated lists.' },
|
||||||
|
{ name: 'blogmarkCategory', type: 'string', required: false, description: 'Default category for blogmark imports.' },
|
||||||
|
{ name: 'pythonRuntimeMode', type: "'webworker' | 'main-thread'", required: false, description: 'Python runtime execution mode.' },
|
||||||
|
{ name: 'picoTheme', type: 'string', required: false, description: 'Preferred Pico theme token.' },
|
||||||
|
{ name: 'categoryMetadata', type: 'object', required: false, description: 'Category metadata keyed by category slug.' },
|
||||||
|
{ name: 'categorySettings', type: 'object', required: false, description: 'Category render settings keyed by category slug.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ChatConversation',
|
||||||
|
description: 'Chat conversation container.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'id', type: 'string', required: true, description: 'Unique conversation identifier.' },
|
||||||
|
{ name: 'title', type: 'string', required: true, description: 'Conversation title.' },
|
||||||
|
{ name: 'model', type: 'string', required: false, description: 'Optional model id used by this conversation.' },
|
||||||
|
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
|
||||||
|
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp (ISO string).' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ChatMessage',
|
||||||
|
description: 'Single message entry in a conversation history.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'id', type: 'string', required: true, description: 'Unique message identifier.' },
|
||||||
|
{ name: 'conversationId', type: 'string', required: true, description: 'Owning conversation id.' },
|
||||||
|
{ name: 'role', type: "'user' | 'assistant' | 'system' | 'tool'", required: true, description: 'Message author role.' },
|
||||||
|
{ name: 'content', type: 'string', required: true, description: 'Message text content.' },
|
||||||
|
{ name: 'toolCallId', type: 'string', required: false, description: 'Tool call id when associated with tool output.' },
|
||||||
|
{ name: 'toolCalls', type: 'string', required: false, description: 'Serialized tool call payload when present.' },
|
||||||
|
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp (ISO string).' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ChatModel',
|
||||||
|
description: 'Available chat model descriptor.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'id', type: 'string', required: true, description: 'Model identifier.' },
|
||||||
|
{ name: 'name', type: 'string', required: true, description: 'Human-readable model name.' },
|
||||||
|
{ name: 'provider', type: 'string', required: false, description: 'Model provider name.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ChatReadyStatus',
|
||||||
|
description: 'Chat backend readiness status.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'ready', type: 'boolean', required: true, description: 'Whether chat backend is ready.' },
|
||||||
|
{ name: 'error', type: 'string', required: false, description: 'Error description when not ready.' },
|
||||||
|
{ name: 'backend', type: 'string', required: false, description: 'Selected backend identifier.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'ChatApiKeyStatus',
|
||||||
|
description: 'Stored API key state for chat provider.',
|
||||||
|
fields: [
|
||||||
|
{ name: 'hasKey', type: 'boolean', required: true, description: 'Whether a key is configured.' },
|
||||||
|
{ name: 'maskedKey', type: 'string', required: true, description: 'Masked key representation for UI display.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
|
||||||
|
version: '1.3.0',
|
||||||
|
generatedAt: '2026-02-24T00:00:00.000Z',
|
||||||
|
methods: METHODS_V1,
|
||||||
|
dataStructures: DATA_STRUCTURES_V1,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listPythonApiMethodNames(): string[] {
|
||||||
|
return BDS_PYTHON_API_CONTRACT_V1.methods.map((entry) => entry.method);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPythonApiMethodContract(methodName: string): PythonApiMethodContractV1 | undefined {
|
||||||
|
return BDS_PYTHON_API_CONTRACT_V1.methods.find((entry) => entry.method === methodName);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPythonApiDataStructureContracts(): PythonApiDataStructureContractV1[] {
|
||||||
|
return BDS_PYTHON_API_CONTRACT_V1.dataStructures;
|
||||||
|
}
|
||||||
102
src/renderer/python/pythonApiInvokerV1.ts
Normal file
102
src/renderer/python/pythonApiInvokerV1.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { getPythonApiMethodContract, type PythonApiParamContractV1 } from './pythonApiContractV1';
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getElectronApi(): Window['electronAPI'] {
|
||||||
|
if (typeof window === 'undefined' || !window.electronAPI) {
|
||||||
|
throw new Error('electronAPI is not available in renderer context');
|
||||||
|
}
|
||||||
|
return window.electronAPI;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateParamValue(methodName: string, param: PythonApiParamContractV1, value: unknown): void {
|
||||||
|
if (param.type === 'stringOrNull') {
|
||||||
|
if (value === null || (typeof value === 'string' && value.length > 0)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`${methodName} requires stringOrNull arg ${param.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
if (!param.required) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`${methodName} requires ${param.type} arg ${param.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (param.type === 'any') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (param.type === 'string') {
|
||||||
|
if (typeof value === 'string' && value.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`${methodName} requires string arg ${param.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (param.type === 'number') {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`${methodName} requires number arg ${param.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (param.type === 'boolean') {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`${methodName} requires boolean arg ${param.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (param.type === 'array') {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`${methodName} requires array arg ${param.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (param.type === 'object') {
|
||||||
|
if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`${methodName} requires object arg ${param.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function invokePythonApiMethodV1(method: string, args: unknown): Promise<unknown> {
|
||||||
|
const contract = getPythonApiMethodContract(method);
|
||||||
|
if (!contract) {
|
||||||
|
throw new Error(`Unsupported Python API method: ${method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedArgs = asRecord(args);
|
||||||
|
const electronApi = getElectronApi();
|
||||||
|
const [namespace, member] = contract.method.split('.');
|
||||||
|
if (!namespace || !member) {
|
||||||
|
throw new Error(`Unsupported Python API method: ${method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const namespaceRecord = (electronApi as unknown as Record<string, unknown>)[namespace];
|
||||||
|
if (!namespaceRecord || typeof namespaceRecord !== 'object') {
|
||||||
|
throw new Error(`Unsupported Python API namespace: ${namespace}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const callable = (namespaceRecord as Record<string, unknown>)[member];
|
||||||
|
if (typeof callable !== 'function') {
|
||||||
|
throw new Error(`Unsupported Python API method: ${method}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const orderedArgs = contract.params.map((param) => {
|
||||||
|
const value = normalizedArgs[param.name];
|
||||||
|
validateParamValue(contract.method, param, value);
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (callable as (...values: unknown[]) => Promise<unknown>)(...orderedArgs);
|
||||||
|
}
|
||||||
@@ -3,9 +3,19 @@ import type { PythonWorkerMessage, PythonWorkerRequest } from './runtimeProtocol
|
|||||||
import { parseMacroContextV1, parseMacroResultV1 } from './abiV1';
|
import { parseMacroContextV1, parseMacroResultV1 } from './abiV1';
|
||||||
import { resolvePyodideIndexURL } from './pyodideAssetUrl';
|
import { resolvePyodideIndexURL } from './pyodideAssetUrl';
|
||||||
import { runPythonSyntaxCheck } from './pythonSyntaxCheck';
|
import { runPythonSyntaxCheck } from './pythonSyntaxCheck';
|
||||||
|
import { generatePythonApiModuleV1 } from './generatePythonApiModuleV1';
|
||||||
|
|
||||||
let runtime: PyodideInterface | null = null;
|
let runtime: PyodideInterface | null = null;
|
||||||
let activeRequestId: string | null = null;
|
let activeRequestId: string | null = null;
|
||||||
|
let apiCallCounter = 0;
|
||||||
|
|
||||||
|
interface PendingApiCall {
|
||||||
|
requestId: string;
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingApiCalls = new Map<string, PendingApiCall>();
|
||||||
|
|
||||||
function postRuntimeMessage(message: PythonWorkerMessage): void {
|
function postRuntimeMessage(message: PythonWorkerMessage): void {
|
||||||
self.postMessage(message);
|
self.postMessage(message);
|
||||||
@@ -21,6 +31,64 @@ function toResultString(result: unknown): string {
|
|||||||
return String(result);
|
return String(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toRecord(value: unknown): Record<string, unknown> {
|
||||||
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectPendingApiCallsForRequest(requestId: string, message: string): void {
|
||||||
|
for (const [callId, pendingCall] of pendingApiCalls.entries()) {
|
||||||
|
if (pendingCall.requestId !== requestId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pendingApiCalls.delete(callId);
|
||||||
|
pendingCall.reject(new Error(message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestHostApi(requestId: string, method: string, args: Record<string, unknown>): Promise<unknown> {
|
||||||
|
apiCallCounter += 1;
|
||||||
|
const callId = `api-${apiCallCounter}`;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pendingApiCalls.set(callId, {
|
||||||
|
requestId,
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
});
|
||||||
|
|
||||||
|
postRuntimeMessage({
|
||||||
|
type: 'apiCall',
|
||||||
|
requestId,
|
||||||
|
callId,
|
||||||
|
method,
|
||||||
|
args,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleApiResultMessage(request: PythonWorkerRequest): void {
|
||||||
|
if (request.type !== 'apiResult') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingCall = pendingApiCalls.get(request.callId);
|
||||||
|
if (!pendingCall) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingApiCalls.delete(request.callId);
|
||||||
|
|
||||||
|
if (request.ok) {
|
||||||
|
pendingCall.resolve(request.result);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingCall.reject(new Error(request.error ?? 'Host API call failed'));
|
||||||
|
}
|
||||||
|
|
||||||
async function runPythonCode(code: string, cacheKey?: string): Promise<unknown> {
|
async function runPythonCode(code: string, cacheKey?: string): Promise<unknown> {
|
||||||
if (!runtime) {
|
if (!runtime) {
|
||||||
throw new Error('Python runtime is not ready');
|
throw new Error('Python runtime is not ready');
|
||||||
@@ -83,9 +151,11 @@ __bds_target()
|
|||||||
result: toResultString(result),
|
result: toResultString(result),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
rejectPendingApiCallsForRequest(request.requestId, 'Python script execution failed');
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
|
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
|
||||||
} finally {
|
} finally {
|
||||||
|
rejectPendingApiCallsForRequest(request.requestId, 'Python script execution finished');
|
||||||
activeRequestId = null;
|
activeRequestId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -125,9 +195,11 @@ json.dumps(render(__bds_context_v1))
|
|||||||
result: parsedResult,
|
result: parsedResult,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
rejectPendingApiCallsForRequest(request.requestId, 'Python macro execution failed');
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
|
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
|
||||||
} finally {
|
} finally {
|
||||||
|
rejectPendingApiCallsForRequest(request.requestId, 'Python macro execution finished');
|
||||||
activeRequestId = null;
|
activeRequestId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,9 +247,11 @@ json.dumps(__bds_entrypoints)
|
|||||||
entrypoints,
|
entrypoints,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
rejectPendingApiCallsForRequest(request.requestId, 'Entrypoint inspection failed');
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
|
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
|
||||||
} finally {
|
} finally {
|
||||||
|
rejectPendingApiCallsForRequest(request.requestId, 'Entrypoint inspection finished');
|
||||||
activeRequestId = null;
|
activeRequestId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,9 +282,11 @@ async function syntaxCheck(request: PythonWorkerRequest): Promise<void> {
|
|||||||
errors,
|
errors,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
rejectPendingApiCallsForRequest(request.requestId, 'Syntax check failed');
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
|
postRuntimeMessage({ type: 'runError', requestId: request.requestId, error: message });
|
||||||
} finally {
|
} finally {
|
||||||
|
rejectPendingApiCallsForRequest(request.requestId, 'Syntax check finished');
|
||||||
activeRequestId = null;
|
activeRequestId = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -230,6 +306,41 @@ async function bootstrapRuntime(): Promise<void> {
|
|||||||
if (!runtime) {
|
if (!runtime) {
|
||||||
throw new Error('Pyodide initialization returned no runtime');
|
throw new Error('Pyodide initialization returned no runtime');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runtime.registerJsModule('__bds_transport', {
|
||||||
|
call_host_api: async (method: unknown, argsJson: unknown) => {
|
||||||
|
if (!activeRequestId) {
|
||||||
|
throw new Error('No active Python request for host API bridge');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof method !== 'string' || method.length === 0) {
|
||||||
|
throw new Error('Host API method must be a non-empty string');
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedArgs: Record<string, unknown> = {};
|
||||||
|
if (typeof argsJson === 'string' && argsJson.length > 0) {
|
||||||
|
const decoded = JSON.parse(argsJson);
|
||||||
|
parsedArgs = toRecord(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await requestHostApi(activeRequestId, method, parsedArgs);
|
||||||
|
return JSON.stringify(result ?? null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.globals.set('__bds_api_module_source', generatePythonApiModuleV1());
|
||||||
|
await runtime.runPythonAsync(`
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
|
||||||
|
__bds_api_module = types.ModuleType("bds_api")
|
||||||
|
exec(__bds_api_module_source, __bds_api_module.__dict__)
|
||||||
|
|
||||||
|
from __bds_transport import call_host_api as __bds_call_host_api
|
||||||
|
__bds_api_module.bds = __bds_api_module.install_bds_api(__bds_call_host_api)
|
||||||
|
sys.modules["bds_api"] = __bds_api_module
|
||||||
|
`);
|
||||||
|
|
||||||
postRuntimeMessage({ type: 'ready' });
|
postRuntimeMessage({ type: 'ready' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
@@ -239,6 +350,11 @@ async function bootstrapRuntime(): Promise<void> {
|
|||||||
|
|
||||||
self.onmessage = (event: MessageEvent<PythonWorkerRequest>) => {
|
self.onmessage = (event: MessageEvent<PythonWorkerRequest>) => {
|
||||||
const request = event.data;
|
const request = event.data;
|
||||||
|
if (request.type === 'apiResult') {
|
||||||
|
handleApiResultMessage(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (request.type === 'run') {
|
if (request.type === 'run') {
|
||||||
void runScript(request);
|
void runScript(request);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ export type PythonWorkerRequest =
|
|||||||
cacheKey?: string;
|
cacheKey?: string;
|
||||||
entrypoint?: string;
|
entrypoint?: string;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
type: 'apiResult';
|
||||||
|
requestId: string;
|
||||||
|
callId: string;
|
||||||
|
ok: boolean;
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'renderMacroV1';
|
type: 'renderMacroV1';
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -40,6 +48,7 @@ export type PythonWorkerMessage =
|
|||||||
| { type: 'ready' }
|
| { type: 'ready' }
|
||||||
| { type: 'error'; error: string }
|
| { type: 'error'; error: string }
|
||||||
| { type: 'stdout'; requestId: string; chunk: string }
|
| { type: 'stdout'; requestId: string; chunk: string }
|
||||||
|
| { type: 'apiCall'; requestId: string; callId: string; method: string; args: Record<string, unknown> }
|
||||||
| { type: 'runResult'; requestId: string; result: string }
|
| { type: 'runResult'; requestId: string; result: string }
|
||||||
| { type: 'entrypoints'; requestId: string; entrypoints: string[] }
|
| { type: 'entrypoints'; requestId: string; entrypoints: string[] }
|
||||||
| { type: 'syntaxResult'; requestId: string; errors: PythonSyntaxError[] }
|
| { type: 'syntaxResult'; requestId: string; errors: PythonSyntaxError[] }
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
const STORAGE_KEY = 'bds-app-state';
|
const STORAGE_KEY = 'bds-app-state';
|
||||||
|
|
||||||
// Tab types
|
// Tab types
|
||||||
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'site-validation' | 'scripts';
|
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'scripts';
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
type: TabType;
|
type: TabType;
|
||||||
|
|||||||
@@ -52,4 +52,28 @@ describe('package.json packaging configuration', () => {
|
|||||||
expect(dependencies.uuid).toBeTypeOf('string');
|
expect(dependencies.uuid).toBeTypeOf('string');
|
||||||
expect(devDependencies.uuid).toBeUndefined();
|
expect(devDependencies.uuid).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('copies API documentation into packaged app resources', () => {
|
||||||
|
const build = packageJson.build as Record<string, any>;
|
||||||
|
const extraResources = build.extraResources as Array<{ from: string; to: string }>;
|
||||||
|
|
||||||
|
expect(Array.isArray(extraResources)).toBe(true);
|
||||||
|
expect(extraResources).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
from: 'API.md',
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('copies user documentation into packaged app resources', () => {
|
||||||
|
const build = packageJson.build as Record<string, any>;
|
||||||
|
const extraResources = build.extraResources as Array<{ from: string; to: string }>;
|
||||||
|
|
||||||
|
expect(Array.isArray(extraResources)).toBe(true);
|
||||||
|
expect(extraResources).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({
|
||||||
|
from: 'DOCUMENTATION.md',
|
||||||
|
}),
|
||||||
|
]));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,17 @@ describe('Help menu documentation entry', () => {
|
|||||||
expect(APP_MENU_ACTION_EVENT_MAP.openDocumentation).toBe('menu:openDocumentation');
|
expect(APP_MENU_ACTION_EVENT_MAP.openDocumentation).toBe('menu:openDocumentation');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes an API documentation action in Help menu', () => {
|
||||||
|
const helpGroup = APP_MENU_GROUPS.find((group) => group.label === 'Help');
|
||||||
|
|
||||||
|
expect(helpGroup).toBeDefined();
|
||||||
|
expect(helpGroup?.items.some((item) => item.action === 'openApiDocumentation')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps API documentation to a renderer menu event', () => {
|
||||||
|
expect(APP_MENU_ACTION_EVENT_MAP.openApiDocumentation).toBe('menu:openApiDocumentation');
|
||||||
|
});
|
||||||
|
|
||||||
it('includes Open in Browser and Open Data Folder actions in File menu', () => {
|
it('includes Open in Browser and Open Data Folder actions in File menu', () => {
|
||||||
const fileGroup = APP_MENU_GROUPS.find((group) => group.label === 'File');
|
const fileGroup = APP_MENU_GROUPS.find((group) => group.label === 'File');
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ describe('editorRouting', () => {
|
|||||||
'metadata-diff': 'metadata-diff',
|
'metadata-diff': 'metadata-diff',
|
||||||
'git-diff': 'git-diff',
|
'git-diff': 'git-diff',
|
||||||
documentation: 'documentation',
|
documentation: 'documentation',
|
||||||
|
'api-documentation': 'api-documentation',
|
||||||
'site-validation': 'site-validation',
|
'site-validation': 'site-validation',
|
||||||
scripts: 'scripts',
|
scripts: 'scripts',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -421,4 +421,85 @@ describe('PythonRuntimeManager', () => {
|
|||||||
|
|
||||||
await expect(runPromise).rejects.toThrow('Invalid macro result');
|
await expect(runPromise).rejects.toThrow('Invalid macro result');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('handles worker apiCall by invoking host bridge and returning apiResult', async () => {
|
||||||
|
const worker = new MockWorker();
|
||||||
|
const invokeApiCall = vi.fn().mockResolvedValue({ id: 'post-1', title: 'Hello' });
|
||||||
|
const manager = new PythonRuntimeManager(
|
||||||
|
() => worker as unknown as Worker,
|
||||||
|
{
|
||||||
|
invokeApiCall,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const initPromise = manager.initialize();
|
||||||
|
worker.emitMessage({ type: 'ready' });
|
||||||
|
await initPromise;
|
||||||
|
|
||||||
|
const runPromise = manager.execute('print("hello")');
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const runRequest = worker.postedMessages[0] as { requestId: string };
|
||||||
|
worker.emitMessage({
|
||||||
|
type: 'apiCall',
|
||||||
|
requestId: runRequest.requestId,
|
||||||
|
callId: 'call-1',
|
||||||
|
method: 'posts.get',
|
||||||
|
args: { postId: 'post-1' },
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(invokeApiCall).toHaveBeenCalledWith('posts.get', { postId: 'post-1' });
|
||||||
|
expect(worker.postedMessages[1]).toEqual({
|
||||||
|
type: 'apiResult',
|
||||||
|
requestId: runRequest.requestId,
|
||||||
|
callId: 'call-1',
|
||||||
|
ok: true,
|
||||||
|
result: { id: 'post-1', title: 'Hello' },
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.emitMessage({ type: 'runResult', requestId: runRequest.requestId, result: 'done' });
|
||||||
|
await expect(runPromise).resolves.toEqual({ result: 'done', stdout: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns apiResult with error when host bridge invocation fails', async () => {
|
||||||
|
const worker = new MockWorker();
|
||||||
|
const invokeApiCall = vi.fn().mockRejectedValue(new Error('unknown api method'));
|
||||||
|
const manager = new PythonRuntimeManager(
|
||||||
|
() => worker as unknown as Worker,
|
||||||
|
{
|
||||||
|
invokeApiCall,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const initPromise = manager.initialize();
|
||||||
|
worker.emitMessage({ type: 'ready' });
|
||||||
|
await initPromise;
|
||||||
|
|
||||||
|
const runPromise = manager.execute('print("hello")');
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const runRequest = worker.postedMessages[0] as { requestId: string };
|
||||||
|
worker.emitMessage({
|
||||||
|
type: 'apiCall',
|
||||||
|
requestId: runRequest.requestId,
|
||||||
|
callId: 'call-2',
|
||||||
|
method: 'posts.nonExisting',
|
||||||
|
args: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(worker.postedMessages[1]).toEqual({
|
||||||
|
type: 'apiResult',
|
||||||
|
requestId: runRequest.requestId,
|
||||||
|
callId: 'call-2',
|
||||||
|
ok: false,
|
||||||
|
error: 'unknown api method',
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.emitMessage({ type: 'runResult', requestId: runRequest.requestId, result: 'done' });
|
||||||
|
await expect(runPromise).resolves.toEqual({ result: 'done', stdout: '' });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
14
tests/renderer/python/apiDocumentationSync.test.ts
Normal file
14
tests/renderer/python/apiDocumentationSync.test.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
import { generateApiDocumentationMarkdownV1 } from '../../../src/renderer/python/generateApiDocumentationMarkdownV1';
|
||||||
|
|
||||||
|
describe('API documentation markdown sync', () => {
|
||||||
|
it('matches generated contract documentation', () => {
|
||||||
|
const apiMarkdownPath = resolve(process.cwd(), 'API.md');
|
||||||
|
const committedMarkdown = readFileSync(apiMarkdownPath, 'utf8');
|
||||||
|
const generatedMarkdown = generateApiDocumentationMarkdownV1();
|
||||||
|
|
||||||
|
expect(committedMarkdown).toBe(generatedMarkdown);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { generateApiDocumentationMarkdownV1 } from '../../../src/renderer/python/generateApiDocumentationMarkdownV1';
|
||||||
|
|
||||||
|
describe('generateApiDocumentationMarkdownV1', () => {
|
||||||
|
it('includes a top-level table of contents with module jump links', () => {
|
||||||
|
const markdown = generateApiDocumentationMarkdownV1();
|
||||||
|
|
||||||
|
expect(markdown).toContain('## Table of contents');
|
||||||
|
expect(markdown).toContain('- [projects](#projects)');
|
||||||
|
expect(markdown).toContain('- [posts](#posts)');
|
||||||
|
expect(markdown).toContain('- [media](#media)');
|
||||||
|
expect(markdown).toContain('- [Data Structures](#data-structures)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes per-module API sub-table of contents with endpoint jump links', () => {
|
||||||
|
const markdown = generateApiDocumentationMarkdownV1();
|
||||||
|
|
||||||
|
expect(markdown).toContain('**Module APIs**');
|
||||||
|
expect(markdown).toContain('- [projects.create](#projectscreate)');
|
||||||
|
expect(markdown).toContain('- [posts.getAll](#postsgetall)');
|
||||||
|
expect(markdown).toContain('- [media.import](#mediaimport)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes quick links back to top navigation sections', () => {
|
||||||
|
const markdown = generateApiDocumentationMarkdownV1();
|
||||||
|
|
||||||
|
expect(markdown).toContain('[↑ Back to Table of contents](#table-of-contents)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('documents chat APIs in a dedicated module section', () => {
|
||||||
|
const markdown = generateApiDocumentationMarkdownV1();
|
||||||
|
|
||||||
|
expect(markdown).toContain('## chat');
|
||||||
|
expect(markdown).toContain('### chat.getConversations');
|
||||||
|
expect(markdown).toContain('### chat.sendMessage');
|
||||||
|
expect(markdown).toContain('- [chat](#chat)');
|
||||||
|
expect(markdown).toContain('- [chat.sendMessage](#chatsendmessage)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes a dedicated Data Structures section with core object shapes', () => {
|
||||||
|
const markdown = generateApiDocumentationMarkdownV1();
|
||||||
|
|
||||||
|
expect(markdown).toContain('## Data Structures');
|
||||||
|
expect(markdown).toContain('### PostData');
|
||||||
|
expect(markdown).toContain('- id (`string`, required)');
|
||||||
|
expect(markdown).toContain('- title (`string`, required)');
|
||||||
|
expect(markdown).toContain('- content (`string`, required)');
|
||||||
|
expect(markdown).toContain('### MediaData');
|
||||||
|
expect(markdown).toContain('- filename (`string`, required)');
|
||||||
|
expect(markdown).toContain('- mimeType (`string`, required)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('documents return type details in the response section', () => {
|
||||||
|
const markdown = generateApiDocumentationMarkdownV1();
|
||||||
|
|
||||||
|
expect(markdown).toContain('**Response specification**');
|
||||||
|
expect(markdown).toContain('- Return type: `PostData | null`');
|
||||||
|
expect(markdown).toContain('- Return type: `MediaData[]`');
|
||||||
|
});
|
||||||
|
});
|
||||||
81
tests/renderer/python/pythonApiContractV1.test.ts
Normal file
81
tests/renderer/python/pythonApiContractV1.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
BDS_PYTHON_API_CONTRACT_V1,
|
||||||
|
getPythonApiMethodContract,
|
||||||
|
listPythonApiMethodNames,
|
||||||
|
} from '../../../src/renderer/python/pythonApiContractV1';
|
||||||
|
import { generatePythonApiModuleV1 } from '../../../src/renderer/python/generatePythonApiModuleV1';
|
||||||
|
|
||||||
|
describe('pythonApiContractV1', () => {
|
||||||
|
it('exposes broad stable method names for v1 contract', () => {
|
||||||
|
const methodNames = listPythonApiMethodNames();
|
||||||
|
|
||||||
|
expect(methodNames.length).toBeGreaterThan(40);
|
||||||
|
expect(methodNames).toEqual(expect.arrayContaining([
|
||||||
|
'projects.getAll',
|
||||||
|
'posts.get',
|
||||||
|
'posts.getAll',
|
||||||
|
'posts.search',
|
||||||
|
'media.get',
|
||||||
|
'media.search',
|
||||||
|
'meta.getProjectMetadata',
|
||||||
|
'tags.getAll',
|
||||||
|
'scripts.getAll',
|
||||||
|
'tasks.getAll',
|
||||||
|
'app.getSystemLanguage',
|
||||||
|
'chat.getConversations',
|
||||||
|
'chat.sendMessage',
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns method contract metadata by name', () => {
|
||||||
|
expect(getPythonApiMethodContract('posts.get')).toEqual({
|
||||||
|
method: 'posts.get',
|
||||||
|
description: 'Fetch one post by id.',
|
||||||
|
params: [
|
||||||
|
{
|
||||||
|
name: 'postId',
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
returns: 'PostData | null',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains semantic version metadata for compatibility checks', () => {
|
||||||
|
expect(BDS_PYTHON_API_CONTRACT_V1).toMatchObject({
|
||||||
|
version: '1.3.0',
|
||||||
|
generatedAt: expect.any(String),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes canonical data structures for response documentation', () => {
|
||||||
|
expect(BDS_PYTHON_API_CONTRACT_V1.dataStructures).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ name: 'PostData' }),
|
||||||
|
expect.objectContaining({ name: 'MediaData' }),
|
||||||
|
expect.objectContaining({ name: 'ProjectData' }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('generatePythonApiModuleV1', () => {
|
||||||
|
it('generates python facade that hides transport details', () => {
|
||||||
|
const moduleCode = generatePythonApiModuleV1();
|
||||||
|
|
||||||
|
expect(moduleCode).toContain('class BdsApiError(Exception):');
|
||||||
|
expect(moduleCode).toContain('class ProjectsApi:');
|
||||||
|
expect(moduleCode).toContain('class PostsApi:');
|
||||||
|
expect(moduleCode).toContain('class MediaApi:');
|
||||||
|
expect(moduleCode).toContain('class MetaApi:');
|
||||||
|
expect(moduleCode).toContain('class ChatApi:');
|
||||||
|
expect(moduleCode).toContain('async def get(self, post_id):');
|
||||||
|
expect(moduleCode).toContain('async def get_all(self, options=None):');
|
||||||
|
expect(moduleCode).toContain('async def search(self, query):');
|
||||||
|
expect(moduleCode).toContain('async def get_project_metadata(self):');
|
||||||
|
expect(moduleCode).toContain('async def get_conversations(self):');
|
||||||
|
expect(moduleCode).toContain('async def send_message(self, conversation_id, message):');
|
||||||
|
expect(moduleCode).toContain('class BdsApi:');
|
||||||
|
expect(moduleCode).toContain('bds = BdsApi(_transport)');
|
||||||
|
});
|
||||||
|
});
|
||||||
80
tests/renderer/python/pythonApiInvokerV1.test.ts
Normal file
80
tests/renderer/python/pythonApiInvokerV1.test.ts
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { invokePythonApiMethodV1 } from '../../../src/renderer/python/pythonApiInvokerV1';
|
||||||
|
|
||||||
|
describe('invokePythonApiMethodV1', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes posts.get via electronAPI with validated args', async () => {
|
||||||
|
const getPost = vi.fn().mockResolvedValue({ id: 'p1', title: 'Post 1' });
|
||||||
|
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
electronAPI: {
|
||||||
|
posts: {
|
||||||
|
get: getPost,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(invokePythonApiMethodV1('posts.get', { postId: 'p1' })).resolves.toEqual({
|
||||||
|
id: 'p1',
|
||||||
|
title: 'Post 1',
|
||||||
|
});
|
||||||
|
expect(getPost).toHaveBeenCalledWith('p1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes methods from multiple namespaces via contract metadata', async () => {
|
||||||
|
const searchPosts = vi.fn().mockResolvedValue([{ id: 'p1', title: 'Hit' }]);
|
||||||
|
const getProjectMetadata = vi.fn().mockResolvedValue({ name: 'My Project' });
|
||||||
|
const getAllProjects = vi.fn().mockResolvedValue([{ id: 'prj-1', name: 'Main' }]);
|
||||||
|
const getAllPosts = vi.fn().mockResolvedValue({ items: [], hasMore: false, total: 0 });
|
||||||
|
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
electronAPI: {
|
||||||
|
projects: {
|
||||||
|
getAll: getAllProjects,
|
||||||
|
},
|
||||||
|
posts: {
|
||||||
|
search: searchPosts,
|
||||||
|
getAll: getAllPosts,
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
getProjectMetadata,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(invokePythonApiMethodV1('projects.getAll', {})).resolves.toEqual([{ id: 'prj-1', name: 'Main' }]);
|
||||||
|
await expect(invokePythonApiMethodV1('posts.getAll', { options: { limit: 10, offset: 5 } })).resolves.toEqual({ items: [], hasMore: false, total: 0 });
|
||||||
|
await expect(invokePythonApiMethodV1('posts.search', { query: 'hit' })).resolves.toEqual([{ id: 'p1', title: 'Hit' }]);
|
||||||
|
await expect(invokePythonApiMethodV1('meta.getProjectMetadata', {})).resolves.toEqual({ name: 'My Project' });
|
||||||
|
expect(getAllProjects).toHaveBeenCalledWith();
|
||||||
|
expect(getAllPosts).toHaveBeenCalledWith({ limit: 10, offset: 5 });
|
||||||
|
expect(searchPosts).toHaveBeenCalledWith('hit');
|
||||||
|
expect(getProjectMetadata).toHaveBeenCalledWith();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown methods and malformed args', async () => {
|
||||||
|
vi.stubGlobal('window', {
|
||||||
|
electronAPI: {
|
||||||
|
posts: {
|
||||||
|
get: vi.fn(),
|
||||||
|
search: vi.fn(),
|
||||||
|
getAll: vi.fn(),
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
getAll: vi.fn(),
|
||||||
|
},
|
||||||
|
meta: {
|
||||||
|
getProjectMetadata: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(invokePythonApiMethodV1('posts.unknown', {})).rejects.toThrow('Unsupported Python API method');
|
||||||
|
await expect(invokePythonApiMethodV1('posts.get', {})).rejects.toThrow('posts.get requires string arg postId');
|
||||||
|
await expect(invokePythonApiMethodV1('posts.search', { query: 1 })).rejects.toThrow('posts.search requires string arg query');
|
||||||
|
await expect(invokePythonApiMethodV1('posts.getAll', { options: 1 })).rejects.toThrow('posts.getAll requires object arg options');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user