fix: python scripts now work

This commit is contained in:
2026-02-23 21:22:15 +01:00
parent 7213b64913
commit e68e845e70
4 changed files with 159 additions and 4 deletions

View File

@@ -181,7 +181,16 @@ _entrypoint = __bds_transform_entrypoint
_transform_fn = globals().get(_entrypoint) _transform_fn = globals().get(_entrypoint)
if _transform_fn is None or not callable(_transform_fn): if _transform_fn is None or not callable(_transform_fn):
raise RuntimeError(f"Transform entrypoint '{_entrypoint}' is not callable") raise RuntimeError(f"Transform entrypoint '{_entrypoint}' is not callable")
_result = _transform_fn(_payload) _post = _payload.get("post")
if not isinstance(_post, dict):
raise RuntimeError("Transform payload is missing a valid 'post' object")
_context = _payload.get("context")
try:
_result = _transform_fn(_post, _context)
except TypeError:
_result = _transform_fn(_post)
if _result is None:
_result = _post
json.dumps(_result) json.dumps(_result)
`); `);

View File

@@ -88,6 +88,8 @@ export const DocumentationView: React.FC = () => {
const { t: tr } = useI18n(); const { t: tr } = useI18n();
const { picoTheme } = useAppStore(); const { picoTheme } = useAppStore();
const resolvedTheme = getRendererPicoTheme(picoTheme); const resolvedTheme = getRendererPicoTheme(picoTheme);
const CODE_COPY_DEFAULT_ICON = '\u29c9';
const CODE_COPY_SUCCESS_ICON = '\u2713';
const scrollContainerRef = useRef<HTMLElement | null>(null); const scrollContainerRef = useRef<HTMLElement | null>(null);
const articleRef = useRef<HTMLElement | null>(null); const articleRef = useRef<HTMLElement | null>(null);
const headingSlugCounts = new Map<string, number>(); const headingSlugCounts = new Map<string, number>();
@@ -217,9 +219,9 @@ export const DocumentationView: React.FC = () => {
wrapper?.classList.add('code-copy-success'); wrapper?.classList.add('code-copy-success');
if (icon) { if (icon) {
icon.textContent = '✓'; icon.textContent = CODE_COPY_SUCCESS_ICON;
setTimeout(() => { setTimeout(() => {
icon.textContent = '⧉'; icon.textContent = CODE_COPY_DEFAULT_ICON;
wrapper?.classList.remove('code-copy-success'); wrapper?.classList.remove('code-copy-success');
}, 1200); }, 1200);
} }
@@ -234,7 +236,7 @@ export const DocumentationView: React.FC = () => {
}); });
}} }}
> >
<span className="code-copy-icon"></span> <span className="code-copy-icon">{CODE_COPY_DEFAULT_ICON}</span>
</button> </button>
<pre> <pre>
<code <code

View File

@@ -0,0 +1,80 @@
import { describe, expect, it } from 'vitest';
import type { ScriptData } from '../../src/main/shared/electronApi';
import {
BlogmarkTransformService,
type BlogmarkTransformInput,
type BlogmarkTransformScriptProvider,
} from '../../src/main/engine/BlogmarkTransformService';
function createInput(overrides: Partial<BlogmarkTransformInput> = {}): BlogmarkTransformInput {
return {
post: {
title: 'BoardGameGeek | Great Game',
content: 'Read this: BoardGameGeek | Great Game',
tags: ['inbox'],
categories: ['blogmark'],
},
context: {
source: 'blogmark',
url: 'https://boardgamegeek.com/boardgame/12345',
},
...overrides,
};
}
function createTransformScript(overrides: Partial<ScriptData> = {}): ScriptData {
return {
id: 'bgg-link-transform',
projectId: 'default',
slug: 'bgg_link',
title: 'BGG Link Transform',
kind: 'transform',
entrypoint: 'normalize_blogmark',
enabled: true,
version: 1,
filePath: '/tmp/bgg_link.py',
content: [
'def normalize_blogmark(post, context=None):',
' title = (post.get("title") or "").strip()',
' if title and "BoardGameGeek" in title:',
' clean_title = title.split(" | ")[0]',
' post["title"] = clean_title',
' post["content"] = (post.get("content") or "").replace(title, clean_title)',
' post["categories"] = ["spielelog", "asides"]',
' tags = post.get("tags") or []',
' tags.append("spielen")',
' post["tags"] = sorted({str(tag).strip().lower() for tag in tags if str(tag).strip()})',
' if context and context.get("url"):',
' toast(f"BGG transform applied: {post.get(' + "'title'" + ')} @ {context.get(' + "'url'" + ')}")',
' else:',
' toast(f"BGG transform applied: {post.get(' + "'title'" + ')}")',
' return post',
'',
].join('\n'),
createdAt: '2026-02-23T00:00:00.000Z',
updatedAt: '2026-02-23T00:00:00.000Z',
...overrides,
};
}
describe('BlogmarkTransformService (Pyodide integration)', () => {
it('executes transform scripts with real pyodide runtime and applies post mutations', async () => {
const provider: BlogmarkTransformScriptProvider = {
getScripts: async () => [createTransformScript()],
};
const service = new BlogmarkTransformService({ provider });
const result = await service.applyTransforms(createInput());
expect(result.errors).toEqual([]);
expect(result.appliedScriptIds).toEqual(['bgg-link-transform']);
expect(result.post.title).toBe('BoardGameGeek');
expect(result.post.content).toBe('Read this: BoardGameGeek');
expect(result.post.categories).toEqual(['spielelog', 'asides']);
expect(result.post.tags).toEqual(['inbox', 'spielen']);
expect(result.toasts).toHaveLength(1);
expect(result.toasts[0]).toContain('BGG transform applied: BoardGameGeek');
expect(result.toasts[0]).toContain('boardgamegeek.com/boardgame/12345');
}, 60000);
});

View File

@@ -246,4 +246,68 @@ describe('BlogmarkTransformService', () => {
expect(result.toasts).toEqual(['Step finished', 'Step finished']); expect(result.toasts).toEqual(['Step finished', 'Step finished']);
}); });
it('invokes python transform entrypoint with post payload shape', async () => {
const globalsStore = new Map<string, unknown>();
const runPythonAsync = vi.fn(async (code: string) => {
if (code.includes('json.dumps(_result)')) {
const payload = JSON.parse(String(globalsStore.get('__bds_transform_payload_json')));
if (code.includes('_transform_fn(_payload)')) {
return JSON.stringify(payload);
}
return JSON.stringify({
...payload.post,
title: 'Normalized',
categories: ['spielelog', 'asides'],
tags: ['inbox', 'spielen'],
});
}
return null;
});
vi.doMock('pyodide', () => ({
loadPyodide: vi.fn(async () => ({
globals: {
set: (key: string, value: unknown) => {
globalsStore.set(key, value);
},
},
runPythonAsync,
})),
}));
const provider: BlogmarkTransformScriptProvider = {
getScripts: vi.fn(async () => [
createScript({
id: 'pyodide-transform',
slug: 'pyodide-transform',
title: 'Pyodide Transform',
kind: 'transform',
entrypoint: 'normalize_blogmark',
content: 'def normalize_blogmark(post):\n return post',
}),
]),
};
const service = new BlogmarkTransformService({ provider });
const result = await service.applyTransforms(createInput());
const transformInvocationCode = runPythonAsync.mock.calls
.map((call) => call[0])
.find((code) => typeof code === 'string' && String(code).includes('json.dumps(_result)'));
expect(result.post.title).toBe('Normalized');
expect(result.post.categories).toEqual(['spielelog', 'asides']);
expect(result.post.tags).toEqual(['inbox', 'spielen']);
expect(transformInvocationCode).toBeDefined();
expect(String(transformInvocationCode)).not.toContain('import inspect');
expect(String(transformInvocationCode)).toContain('\ntry:\n');
expect(String(transformInvocationCode)).toContain('\nexcept TypeError:\n');
expect(String(transformInvocationCode)).not.toContain('\n try:\n');
expect(String(transformInvocationCode)).not.toContain('\n except TypeError:\n');
});
}); });