fix: python scripts now work
This commit is contained in:
@@ -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)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
80
tests/engine/BlogmarkTransformService.integration.test.ts
Normal file
80
tests/engine/BlogmarkTransformService.integration.test.ts
Normal 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);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user