diff --git a/apps/studio-desktop/README.md b/apps/studio-desktop/README.md index 1bf14abe..667cdf09 100644 --- a/apps/studio-desktop/README.md +++ b/apps/studio-desktop/README.md @@ -43,8 +43,8 @@ Linux build once (`e2e/packaged.e2e.test.ts`). It publishes nothing. - **Opens a recipe folder.** `File > Open Folder…` (Ctrl/Cmd+O), `File > Open Recent`, or a folder on the command line. With none given it opens the most recent folder that still exists. -- **Picks folders with the native dialog.** In the app the toolbar has "Open folder…" instead of a folder - box and "Open" (the page asks the main process through the preload; a browser tab keeps the box). +- **Picks folders with the native dialog.** In the app **Recipe ▾ → Open folder…** opens the native dialog directly + (the page asks the main process through the preload); a browser tab shows a folder box with Browse… instead. - **Runs hooks.** `--hooks ` (or `--plugins`, or `OPENCRAW_PLUGINS`/`OPENCRAW_HOOKS`) loads a hooks or plugins module on the launch's own authority, exactly like `opencraw studio --hooks`. A file that fails to load stops the app with the reason. A second launch's `--hooks` is ignored: the running server keeps its own. diff --git a/apps/studio-desktop/e2e/launch.e2e.test.ts b/apps/studio-desktop/e2e/launch.e2e.test.ts index 1f68501f..d5954787 100644 --- a/apps/studio-desktop/e2e/launch.e2e.test.ts +++ b/apps/studio-desktop/e2e/launch.e2e.test.ts @@ -38,11 +38,12 @@ describeBuilt('studio desktop: launch', () => { expect(url.hostname).toBe('127.0.0.1') expect(url.searchParams.get('folder')).toBe(folder) await page.getByText('Run sample', { exact: true }).first().waitFor() - await page.locator('option', { hasText: 'books' }).waitFor({ state: 'attached' }) + await page.getByRole('button', { name: 'Recipe: books ▾' }).waitFor() }) it('offers the native folder dialog instead of a folder box', async () => { - await page.getByRole('button', { name: 'Open folder…' }).waitFor() + await page.getByRole('button', { name: /^Recipe/ }).click() + await page.getByRole('menuitem', { name: 'Open folder…' }).waitFor() expect(await page.getByPlaceholder('recipe folder').count()).toBe(0) expect(await page.evaluate('typeof window.opencrawDesktop.chooseFolder')).toBe('function') diff --git a/apps/studio-desktop/e2e/packaged.e2e.test.ts b/apps/studio-desktop/e2e/packaged.e2e.test.ts index d6b31948..c994c9ed 100644 --- a/apps/studio-desktop/e2e/packaged.e2e.test.ts +++ b/apps/studio-desktop/e2e/packaged.e2e.test.ts @@ -34,7 +34,7 @@ describePackaged('studio desktop: packaged app', () => { try { const page = await app.firstWindow() await page.getByText('Run sample', { exact: true }).first().waitFor() - await page.locator('option', { hasText: 'books' }).waitFor({ state: 'attached' }) + await page.getByRole('button', { name: 'Recipe: books ▾' }).waitFor() expect(new URL(page.url()).searchParams.get('folder')).toBe(folder) } finally { diff --git a/apps/studio-ui/src/app/app.view.test.tsx b/apps/studio-ui/src/app/app.view.test.tsx index 27ebb585..8b3bb08c 100644 --- a/apps/studio-ui/src/app/app.view.test.tsx +++ b/apps/studio-ui/src/app/app.view.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { resetRunSessionStore, resetStudioUiStore } from '../studio-store' import App from './app.view' @@ -30,11 +30,11 @@ describe('App shell', () => { it('renders the toolbar, the split layout and the preview strip', () => { render() expect(screen.getByText('OpenCraw Studio')).toBeTruthy() - expect(screen.getByPlaceholderText('recipe folder')).toBeTruthy() - expect(screen.getByRole('button', { name: 'Open' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Recipe ▾' })).toBeTruthy() + expect(screen.getByRole('button', { name: 'Load ▾' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Run sample' })).toBeTruthy() expect(screen.getByRole('button', { name: 'Stop' })).toBeTruthy() - expect(screen.getByText(/select a recipe/i)).toBeTruthy() + expect(screen.getByRole('status').textContent).toContain('All changes saved') expect(screen.getByText(/no records yet/i)).toBeTruthy() }) @@ -48,17 +48,31 @@ describe('App shell', () => { expect(screen.getByText('Record')).toBeTruthy() }) - it('disables "+ New recipe" until a workspace folder is open (issue #110)', () => { + it('keeps "New recipe…" disabled until a workspace folder is open (issue #110)', () => { render() - expect(screen.getByRole('button', { name: '+ New recipe' }).hasAttribute('disabled')).toBe(true) + fireEvent.click(screen.getByRole('button', { name: 'Recipe ▾' })) + expect(screen.getByRole('menuitem', { name: 'New recipe…' }).getAttribute('aria-disabled')).toBe('true') }) - it('offers "Open document…" and a drop zone over the whole shell, from the very first screen (issue #120)', () => { + it('offers Load ▾ Document… and Web page (URL)… and a drop zone over the whole shell, from the very first screen (issue #120)', async () => { render() - expect(screen.getByRole('button', { name: 'Open document…' }).hasAttribute('disabled')).toBe(false) - expect(screen.getByLabelText('Open document').getAttribute('accept')).toContain('.pdf') + expect(screen.getByRole('button', { name: 'Load ▾' }).hasAttribute('disabled')).toBe(false) + expect(screen.getByLabelText('Load document').getAttribute('accept')).toContain('.pdf') + fireEvent.click(screen.getByRole('button', { name: 'Load ▾' })) + expect(await screen.findByRole('menuitem', { name: 'Document…' })).toBeTruthy() + expect(screen.getByRole('menuitem', { name: 'Web page (URL)…' })).toBeTruthy() expect(screen.getByTestId('document-drop-zone')).toBeTruthy() }) + + it('asks the server for its folder dialog from Recipe ▾ → Open folder…, and falls back to the folder box when there is none', async () => { + const fetchMock = jest.fn().mockResolvedValue({ ok: false, status: 500, json: async () => ({ error: 'there is no folder dialog on this machine: type the folder instead' }) }) + Object.defineProperty(globalThis, 'fetch', { value: fetchMock, configurable: true }) + history.replaceState({}, '', '/?token=abc') + render() + fireEvent.click(screen.getByRole('button', { name: 'Recipe ▾' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Open folder…' })) + expect(await screen.findByPlaceholderText('recipe folder')).toBeTruthy() + }) }) describe('App shell inside the desktop app', () => { @@ -69,9 +83,9 @@ describe('App shell inside the desktop app', () => { Object.defineProperty(globalThis, 'opencrawDesktop', { value: { chooseFolder }, configurable: true }) render() + fireEvent.click(screen.getByRole('button', { name: 'Recipe ▾' })) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Open folder…' })) expect(screen.queryByPlaceholderText('recipe folder')).toBeNull() - expect(screen.queryByRole('button', { name: 'Open' })).toBeNull() - screen.getByRole('button', { name: 'Open folder…' }).click() expect(chooseFolder).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/studio-ui/src/app/app.view.tsx b/apps/studio-ui/src/app/app.view.tsx index 3f9af2d6..1b93482b 100644 --- a/apps/studio-ui/src/app/app.view.tsx +++ b/apps/studio-ui/src/app/app.view.tsx @@ -9,6 +9,7 @@ import { RecordEditor, fieldToStepIdOf } from '../record-editor' import type { MappingRuleJson } from '../record-editor' import { HookNamesContext } from '../hook-names' import { activeStubs, useHookStubsStore } from '../hook-stubs' +import { DocumentDropZone, useImportDocumentFlow, useLoadUrlFlow, useOpenPathFromLink } from '../recipe-start' import { StepsOutline } from '../steps-outline' import { createStudioQueryClient, @@ -24,12 +25,9 @@ import { useWorkspaceQuery, } from '../studio-client' import { useRunSessionStore, useStudioUiStore } from '../studio-store' -import { DocumentDropZone } from './document-drop-zone.component' import { desktopBridge } from './desktop-bridge.client' import { HooksBar } from './hooks-bar.component' import { useBrowseFolderFlow } from './browse-folder.hook' -import { useImportDocumentFlow } from './import-document.hook' -import { useOpenPathFromLink } from './open-path.hook' import { Toolbar } from './toolbar.component' /** The studio's shell: the toolbar, the resizable content/editor split, and the preview strip along the bottom. */ @@ -85,6 +83,7 @@ function AppShell () { const saveOutline = useSaveOutlineMutation() const explainWhy = useExplainWhyMutation() const importFlow = useImportDocumentFlow() + const urlFlow = useLoadUrlFlow() const browseFlow = useBrowseFolderFlow() const linkError = useOpenPathFromLink() @@ -130,7 +129,9 @@ function AppShell () { const askWhy = (target: WhyTarget): void => { explainWhy.mutate(target) } - const workspaceError = linkError ?? importFlow.error ?? browseFlow.error ?? messageOf(workspace.error) ?? runError + const workspaceError = linkError ?? importFlow.error ?? urlFlow.error ?? browseFlow.error ?? messageOf(workspace.error) ?? runError + + const loadNotice = importFlow.notice ?? urlFlow.notice return ( @@ -140,7 +141,7 @@ function AppShell () { onFolderChange={setFolder} onOpen={() => { commitFolder(folder) }} onChooseFolder={chooseFolder} - onBrowse={() => { void browseFlow.browse() }} + onBrowse={browseFlow.browse} browsing={browseFlow.pending} openFolder={openFolder} inputs={inputs} @@ -148,12 +149,16 @@ function AppShell () { onSelectRecipe={selectRecipe} onRecipeCreated={selectRecipe} onImportFile={file => { void importFlow.importFile(file) }} - importing={importFlow.pending} + onLoadUrl={url => { void urlFlow.loadUrl(url) }} + importing={importFlow.pending || urlFlow.pending} running={running} onRunSample={() => { if (selectedRecipeId !== undefined) runSample.mutate({ recipeId: selectedRecipeId, stubs: activeStubs(stubEntries) }) }} onStop={() => { stopRun.mutate() }} /> + {loadNotice !== undefined && workspaceError === undefined && ( + {loadNotice} + )} {workspaceError !== undefined && ( {workspaceError} )} diff --git a/apps/studio-ui/src/app/browse-folder.hook.test.tsx b/apps/studio-ui/src/app/browse-folder.hook.test.tsx index a3f2d515..76e6d073 100644 --- a/apps/studio-ui/src/app/browse-folder.hook.test.tsx +++ b/apps/studio-ui/src/app/browse-folder.hook.test.tsx @@ -26,8 +26,10 @@ describe('useBrowseFolderFlow', () => { const fetchMock = answerWith(200, { folder: '/home/ana/recipes' }) const { result } = renderHook(() => useBrowseFolderFlow(), { wrapper }) - await act(async () => { await result.current.browse() }) + let outcome: string | undefined + await act(async () => { outcome = await result.current.browse() }) + expect(outcome).toBe('picked') expect(JSON.parse((fetchMock.mock.calls[0]?.[1] as RequestInit).body as string)).toEqual({ type: 'choose-folder' }) expect(useStudioUiStore.getState().openFolder).toBe('/home/ana/recipes') expect(useStudioUiStore.getState().folder).toBe('/home/ana/recipes') @@ -39,8 +41,10 @@ describe('useBrowseFolderFlow', () => { act(() => { useStudioUiStore.getState().commitFolder('/r') }) const { result } = renderHook(() => useBrowseFolderFlow(), { wrapper }) - await act(async () => { await result.current.browse() }) + let outcome: string | undefined + await act(async () => { outcome = await result.current.browse() }) + expect(outcome).toBe('cancelled') expect(useStudioUiStore.getState().openFolder).toBe('/r') }) @@ -48,8 +52,10 @@ describe('useBrowseFolderFlow', () => { answerWith(500, { error: 'there is no folder dialog on this machine: type the folder instead' }) const { result } = renderHook(() => useBrowseFolderFlow(), { wrapper }) - await act(async () => { await result.current.browse() }) + let outcome: string | undefined + await act(async () => { outcome = await result.current.browse() }) + expect(outcome).toBe('unavailable') expect(result.current.error).toContain('type the folder instead') expect(result.current.pending).toBe(false) }) diff --git a/apps/studio-ui/src/app/browse-folder.hook.ts b/apps/studio-ui/src/app/browse-folder.hook.ts index ed060e58..58252c24 100644 --- a/apps/studio-ui/src/app/browse-folder.hook.ts +++ b/apps/studio-ui/src/app/browse-folder.hook.ts @@ -2,18 +2,22 @@ import { useState } from 'react' import { useStudioClient } from '../studio-client' import { useStudioUiStore } from '../studio-store' +/** How a browse ended: a folder was opened, the dialog was closed without one, or there is no dialog (the caller offers the folder box). */ +export type BrowseOutcome = 'picked' | 'cancelled' | 'unavailable' + export interface BrowseFolderFlow { /** Opens the server's folder dialog; once a folder is picked it becomes the open workspace, as if it had been typed and confirmed. */ - browse: () => Promise + browse: () => Promise pending: boolean /** Why no dialog opened (no display, no dialog program installed); the folder box keeps working. */ error?: string } /** - * "Browse…": a browser cannot open the folder picker, so the server does it - * on the machine it runs on, which is where the folder is. The answer goes - * through the same `commitFolder` the Open button uses. + * "Open folder…": a browser cannot open the folder picker, so the server does it + * on the machine it runs on, which is where the folder is: the operating + * system's dialog, or the editor's own when Studio runs in the VS Code + * extension. The answer goes through the same `commitFolder` the Open button uses. */ export function useBrowseFolderFlow (): BrowseFolderFlow { const client = useStudioClient() @@ -21,14 +25,19 @@ export function useBrowseFolderFlow (): BrowseFolderFlow { const [pending, setPending] = useState(false) const [error, setError] = useState() - async function browse (): Promise { + async function browse (): Promise { setError(undefined) setPending(true) try { const folder = await client.chooseFolder() - if (folder !== undefined) commitFolder(folder) + if (folder === undefined) return 'cancelled' + commitFolder(folder) + + return 'picked' } catch (browseError) { setError(browseError instanceof Error ? browseError.message : String(browseError)) + + return 'unavailable' } finally { setPending(false) } diff --git a/apps/studio-ui/src/app/dropdown-menu.component.test.tsx b/apps/studio-ui/src/app/dropdown-menu.component.test.tsx new file mode 100644 index 00000000..64ec8493 --- /dev/null +++ b/apps/studio-ui/src/app/dropdown-menu.component.test.tsx @@ -0,0 +1,42 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { ChakraProvider, defaultSystem } from '@chakra-ui/react' +import { DropdownMenu } from './dropdown-menu.component' + +function renderMenu (onSelect = jest.fn(), onOther = jest.fn()) { + render( + + + , + ) + + return { onSelect, onOther } +} + +describe('DropdownMenu', () => { + it('lists its items only once opened, and runs the one chosen and closes', () => { + const { onSelect } = renderMenu() + expect(screen.queryByRole('menuitem')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Load ▾' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Document…' })) + expect(onSelect).toHaveBeenCalledTimes(1) + expect(screen.queryByRole('menuitem')).toBeNull() + }) + + it('does nothing for a disabled item and stays open', () => { + const { onOther } = renderMenu() + fireEvent.click(screen.getByRole('button', { name: 'Load ▾' })) + fireEvent.click(screen.getByRole('menuitem', { name: 'Web page…' })) + expect(onOther).not.toHaveBeenCalled() + expect(screen.getAllByRole('menuitem')).toHaveLength(2) + }) + + it('closes on Escape and on a click elsewhere', () => { + renderMenu() + fireEvent.click(screen.getByRole('button', { name: 'Load ▾' })) + fireEvent.keyDown(document, { key: 'Escape' }) + expect(screen.queryByRole('menuitem')).toBeNull() + fireEvent.click(screen.getByRole('button', { name: 'Load ▾' })) + fireEvent.mouseDown(document.body) + expect(screen.queryByRole('menuitem')).toBeNull() + }) +}) diff --git a/apps/studio-ui/src/app/dropdown-menu.component.tsx b/apps/studio-ui/src/app/dropdown-menu.component.tsx new file mode 100644 index 00000000..f5ed567f --- /dev/null +++ b/apps/studio-ui/src/app/dropdown-menu.component.tsx @@ -0,0 +1,91 @@ +import { useEffect, useRef, useState } from 'react' +import { Box, Button } from '@chakra-ui/react' + +export interface DropdownItem { + /** What the item is called, and what it is found by. */ + label: string + onSelect: () => void + disabled?: boolean + /** A heading drawn above this item, to group the list. */ + heading?: string + /** A rule drawn above this item. */ + divider?: boolean +} + +export interface DropdownMenuProps { + /** The button's text, the arrow included. */ + label: string + title?: string + disabled?: boolean + loading?: boolean + items: DropdownItem[] +} + +/** + * A button that opens a short list under it: a plain popover of buttons rather + * than a menu library, which keeps the toolbar free of an extra dependency and + * behaves the same in the browser, the desktop app and the test DOM. Closes on + * a choice, Escape or a click anywhere else. + */ +export function DropdownMenu ({ label, title, disabled, loading, items }: DropdownMenuProps) { + const [open, setOpen] = useState(false) + const rootRef = useRef(null) + + useEffect(() => { + if (!open) return + const close = (event: MouseEvent | KeyboardEvent): void => { + if (event instanceof KeyboardEvent) { + if (event.key === 'Escape') setOpen(false) + + return + } + if (rootRef.current !== null && !rootRef.current.contains(event.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', close) + document.addEventListener('keydown', close) + + return () => { + document.removeEventListener('mousedown', close) + document.removeEventListener('keydown', close) + } + }, [open]) + + return ( + + + {open && ( + + {items.map(item => ( + + {item.divider === true && } + {item.heading !== undefined && {item.heading}} + { + if (item.disabled === true) return + setOpen(false) + item.onSelect() + }} + > + {item.label} + + + ))} + + )} + + ) +} diff --git a/apps/studio-ui/src/app/load-menu.component.tsx b/apps/studio-ui/src/app/load-menu.component.tsx new file mode 100644 index 00000000..01abe091 --- /dev/null +++ b/apps/studio-ui/src/app/load-menu.component.tsx @@ -0,0 +1,28 @@ +import { DropdownMenu } from './dropdown-menu.component' + +export interface LoadMenuProps { + /** A load is in progress: the button shows it and refuses a second one meanwhile. */ + loading: boolean + onLoadDocument: () => void + onLoadUrl: () => void +} + +/** + * "Load ▾": what the recipe reads. A document or a page is the sample the + * recipe is built against; with a recipe of that kind open it takes the + * recipe's start address, otherwise it starts a recipe of its own. + */ +export function LoadMenu ({ loading, onLoadDocument, onLoadUrl }: LoadMenuProps) { + return ( + + ) +} diff --git a/apps/studio-ui/src/app/open-folder-dialog.component.tsx b/apps/studio-ui/src/app/open-folder-dialog.component.tsx new file mode 100644 index 00000000..9c38943c --- /dev/null +++ b/apps/studio-ui/src/app/open-folder-dialog.component.tsx @@ -0,0 +1,58 @@ +import { Button, CloseButton, Dialog, HStack, Input, Portal, Text } from '@chakra-ui/react' + +export interface OpenFolderDialogProps { + open: boolean + onClose: () => void + folder: string + onFolderChange: (folder: string) => void + /** The typed folder is confirmed. */ + onOpen: () => void + /** The server's folder dialog; absent when the server has none. */ + onBrowse?: () => void + browsing?: boolean +} + +/** "Recipe ▾ → Open folder…" in a browser: the folder to read recipes from, typed or picked in the operating system's dialog. The desktop app uses its own native dialog and never shows this. */ +export function OpenFolderDialog ({ open, onClose, folder, onFolderChange, onOpen, onBrowse, browsing }: OpenFolderDialogProps) { + function confirm (): void { + if (folder.trim() === '') return + onOpen() + onClose() + } + + return ( + { if (!details.open) onClose() }}> + + + + + + Open folder + + + + + + { onFolderChange(event.target.value) }} + onKeyDown={event => { if (event.key === 'Enter') confirm() }} + /> + + A folder of .ocw-in.json and .ocw-out.json recipes, or an empty one to start in. + + + {onBrowse !== undefined && ( + + )} + + + + + + + + ) +} diff --git a/apps/studio-ui/src/app/recipe-menu.component.tsx b/apps/studio-ui/src/app/recipe-menu.component.tsx new file mode 100644 index 00000000..324e2a92 --- /dev/null +++ b/apps/studio-ui/src/app/recipe-menu.component.tsx @@ -0,0 +1,42 @@ +import type { RecipeListing } from '@opencraw/studio' +import { DropdownMenu } from './dropdown-menu.component' +import type { DropdownItem } from './dropdown-menu.component' + +export interface RecipeMenuProps { + /** The input recipes in the open folder: opening one opens its output with it (the input names it). */ + inputs: RecipeListing[] + selectedRecipeId?: string + /** A workspace folder is open: "New recipe…" needs somewhere to write. */ + hasFolder: boolean + onSelectRecipe: (recipeId: string) => void + onNewRecipe: () => void + onOpenFolder: () => void +} + +/** + * "Recipe ▾": which recipe is being edited. Lists the recipes of the open + * folder (each opens together with the output recipe it names), and starts a + * new one or opens another folder. What the recipe reads is the Load menu's + * job, not this one's. + */ +export function RecipeMenu ({ inputs, selectedRecipeId, hasFolder, onSelectRecipe, onNewRecipe, onOpenFolder }: RecipeMenuProps) { + const recipes: DropdownItem[] = inputs.length === 0 + ? [{ label: hasFolder ? 'No recipes in this folder' : 'Open a folder first', onSelect: () => {}, disabled: true, heading: 'Open recipe' }] + : inputs.map((input, index) => { + const id = input.id ?? input.file + + return { label: id === selectedRecipeId ? `✓ ${id}` : id, onSelect: () => { onSelectRecipe(id) }, ...(index === 0 && { heading: 'Open recipe' }) } + }) + + return ( + + ) +} diff --git a/apps/studio-ui/src/app/save-status.component.tsx b/apps/studio-ui/src/app/save-status.component.tsx new file mode 100644 index 00000000..e49b5b4b --- /dev/null +++ b/apps/studio-ui/src/app/save-status.component.tsx @@ -0,0 +1,13 @@ +import { Text } from '@chakra-ui/react' +import { useIsMutating } from '@tanstack/react-query' + +/** Edits are written as they are made; this says so, and says when a write is still on its way. */ +export function SaveStatus () { + const writing = useIsMutating() > 0 + + return ( + + {writing ? 'Saving…' : 'All changes saved'} + + ) +} diff --git a/apps/studio-ui/src/app/toolbar.component.tsx b/apps/studio-ui/src/app/toolbar.component.tsx index 612a8bd7..e599e7b1 100644 --- a/apps/studio-ui/src/app/toolbar.component.tsx +++ b/apps/studio-ui/src/app/toolbar.component.tsx @@ -1,92 +1,85 @@ -import { useRef } from 'react' -import { Button, HStack, Input, NativeSelect, Text } from '@chakra-ui/react' +import { useRef, useState } from 'react' +import { Button, HStack, Text } from '@chakra-ui/react' import type { RecipeListing } from '@opencraw/studio' -import { NewRecipeDialog } from './new-recipe-dialog.component' +import { LoadUrlDialog, NewRecipeDialog } from '../recipe-start' +import type { BrowseOutcome } from './browse-folder.hook' +import { LoadMenu } from './load-menu.component' +import { OpenFolderDialog } from './open-folder-dialog.component' +import { RecipeMenu } from './recipe-menu.component' +import { SaveStatus } from './save-status.component' -/** What "Open document…" offers: every format the engine reads off disk (core's `formatOfFile`), as the file picker's filter. */ +/** What "Load ▾ Document…" offers: every format the engine reads off disk (core's `formatOfFile`), as the file picker's filter. */ const DOCUMENT_EXTENSIONS = '.pdf,.xlsx,.xlsm,.xls,.csv,.tsv,.pptx,.docx,.md,.markdown,.html,.htm,.json,.jsonl,.yaml,.yml,.xml' export interface ToolbarProps { folder: string onFolderChange: (folder: string) => void onOpen: () => void - /** Set in the desktop app: the native folder dialog. The folder box and "Open" give way to "Open folder…". */ + /** Set in the desktop app: the native folder dialog, used directly by "Open folder…". */ onChooseFolder?: () => void - /** In a browser: the server's folder dialog, next to the folder box (which keeps working when there is no dialog). */ - onBrowse?: () => void + /** In a browser: the server's folder dialog, which "Open folder…" asks first; the typed box (also with a Browse… button) is the fallback when the server has none. */ + onBrowse?: () => Promise /** The dialog is open, waiting for an answer. */ browsing?: boolean - /** The folder a workspace is actually open on, for "+ New recipe" — `undefined` before one is opened (nothing to write into yet). */ + /** The folder a workspace is actually open on — `undefined` before one is opened (nothing to write into yet). */ openFolder?: string inputs: RecipeListing[] selectedRecipeId?: string onSelectRecipe: (recipeId: string) => void - /** A new recipe pair was created and saved: select it, the same as picking it from the list below. */ + /** A new recipe pair was created and saved: select it, the same as picking it from the Recipe menu. */ onRecipeCreated: (recipeId: string) => void - /** "Open document…" picked a file: start a recipe from it (issue #120, the same flow as dropping it on the shell). */ + /** "Load ▾ Document…" picked a file (the same flow as dropping it on the shell). */ onImportFile: (file: File) => void - /** A document import is in progress: the button shows it and refuses a second one meanwhile. */ + /** "Load ▾ Web page…" confirmed an address. */ + onLoadUrl: (url: string) => void + /** A load is in progress: the Load button shows it and refuses a second one meanwhile. */ importing: boolean running: boolean onRunSample: () => void onStop: () => void } -/** The last segment of a folder path, for showing next to the button; the whole path is its tooltip. */ +/** The last segment of a folder path, for showing beside the menus; the whole path is its tooltip. */ function folderName (path: string): string { return /[^/\\]+(?=[/\\]*$)/u.exec(path)?.[0] ?? '' } -/** The shell's toolbar: the workspace folder, a recipe picker, "+ New recipe", "Open document…", and Run sample / Stop. */ -export function Toolbar ({ folder, onFolderChange, onOpen, onChooseFolder, onBrowse, browsing, openFolder, inputs, selectedRecipeId, onSelectRecipe, onRecipeCreated, onImportFile, importing, running, onRunSample, onStop }: ToolbarProps) { +/** + * The shell's toolbar, three jobs and no more: **Recipe ▾** (which recipe is being edited: open, new, another + * folder), **Load ▾** (what it reads: a document or a page), and Run sample / Stop; with a status that says edits + * are saved as they are made. + */ +export function Toolbar ({ folder, onFolderChange, onOpen, onChooseFolder, onBrowse, browsing, openFolder, inputs, selectedRecipeId, onSelectRecipe, onRecipeCreated, onImportFile, onLoadUrl, importing, running, onRunSample, onStop }: ToolbarProps) { const fileInputRef = useRef(null) + const [newRecipeOpen, setNewRecipeOpen] = useState(false) + const [urlOpen, setUrlOpen] = useState(false) + const [folderOpen, setFolderOpen] = useState(false) return ( OpenCraw Studio - {onChooseFolder === undefined - ? ( - <> - { onFolderChange(event.target.value) }} - onKeyDown={event => { if (event.key === 'Enter') onOpen() }} - /> - - {onBrowse !== undefined && ( - - )} - - ) - : ( - <> - - {folderName(openFolder ?? folder)} - - )} - - { onSelectRecipe(event.target.value) }} - > - - {inputs.map(input => )} - - - - + { setNewRecipeOpen(true) }} + onOpenFolder={() => { + if (onChooseFolder !== undefined) onChooseFolder() + else if (onBrowse === undefined) setFolderOpen(true) + else void onBrowse().then(outcome => { if (outcome === 'unavailable') setFolderOpen(true) }) + }} + /> + { fileInputRef.current?.click() }} + onLoadUrl={() => { setUrlOpen(true) }} + /> { const file = event.target.files?.[0] @@ -94,7 +87,11 @@ export function Toolbar ({ folder, onFolderChange, onOpen, onChooseFolder, onBro if (file !== undefined) onImportFile(file) }} /> - + {openFolder !== undefined && ( + {folderName(openFolder)} + )} + + @@ -102,6 +99,17 @@ export function Toolbar ({ folder, onFolderChange, onOpen, onChooseFolder, onBro Stop + { setNewRecipeOpen(false) }} onCreated={onRecipeCreated} /> + { setUrlOpen(false) }} onLoad={onLoadUrl} /> + { setFolderOpen(false) }} + folder={folder} + onFolderChange={onFolderChange} + onOpen={onOpen} + onBrowse={onBrowse === undefined ? undefined : () => { void onBrowse() }} + browsing={browsing} + /> ) } diff --git a/apps/studio-ui/src/app/document-drop-zone.component.test.tsx b/apps/studio-ui/src/recipe-start/document-drop-zone.component.test.tsx similarity index 99% rename from apps/studio-ui/src/app/document-drop-zone.component.test.tsx rename to apps/studio-ui/src/recipe-start/document-drop-zone.component.test.tsx index ce862263..1918afb4 100644 --- a/apps/studio-ui/src/app/document-drop-zone.component.test.tsx +++ b/apps/studio-ui/src/recipe-start/document-drop-zone.component.test.tsx @@ -28,7 +28,7 @@ describe('DocumentDropZone (issue #120)', () => { const zone = screen.getByTestId('document-drop-zone') fireEvent.dragEnter(zone, { dataTransfer: fileTransfer(new File(['x'], 'report.pdf')) }) - expect(screen.getByRole('status').textContent).toMatch(/drop to start a recipe/i) + expect(screen.getByRole('status').textContent).toMatch(/drop to load this file/i) fireEvent.dragLeave(zone, { dataTransfer: fileTransfer(new File(['x'], 'report.pdf')) }) expect(screen.queryByRole('status')).toBeNull() diff --git a/apps/studio-ui/src/app/document-drop-zone.component.tsx b/apps/studio-ui/src/recipe-start/document-drop-zone.component.tsx similarity index 98% rename from apps/studio-ui/src/app/document-drop-zone.component.tsx rename to apps/studio-ui/src/recipe-start/document-drop-zone.component.tsx index 3c50f054..6ece1f12 100644 --- a/apps/studio-ui/src/app/document-drop-zone.component.tsx +++ b/apps/studio-ui/src/recipe-start/document-drop-zone.component.tsx @@ -70,7 +70,7 @@ export function DocumentDropZone ({ onFile, children, ...boxProps }: DocumentDro borderColor='blue.solid' pointerEvents='none' > - Drop to start a recipe from this file + Drop to load this file )} diff --git a/apps/studio-ui/src/app/import-document.hook.test.tsx b/apps/studio-ui/src/recipe-start/import-document.hook.test.tsx similarity index 100% rename from apps/studio-ui/src/app/import-document.hook.test.tsx rename to apps/studio-ui/src/recipe-start/import-document.hook.test.tsx diff --git a/apps/studio-ui/src/app/import-document.hook.ts b/apps/studio-ui/src/recipe-start/import-document.hook.ts similarity index 80% rename from apps/studio-ui/src/app/import-document.hook.ts rename to apps/studio-ui/src/recipe-start/import-document.hook.ts index 3bc7d1fe..3a915ebb 100644 --- a/apps/studio-ui/src/app/import-document.hook.ts +++ b/apps/studio-ui/src/recipe-start/import-document.hook.ts @@ -1,6 +1,7 @@ import { useState } from 'react' import { useImportDocumentMutation } from '../studio-client' import { useStudioUiStore } from '../studio-store' +import { useLoadSource } from './load-source.hook' import { useStartRecipeFromDocument } from './start-recipe.hook' export interface ImportDocumentFlow { @@ -8,11 +9,13 @@ export interface ImportDocumentFlow { importFile: (file: File) => Promise pending: boolean error?: string + /** What loading did to the recipe that was open, when it is worth saying (replaced its source, or left it alone and started another). */ + notice?: string } /** - * Drop a file, get a recipe (issue #120): the whole flow behind the drop zone - * and the toolbar's "Open document…" button. The file is copied into the + * Drop a file, load it (issue #120): the whole flow behind the drop zone + * and the toolbar's "Load ▾ Document…" item. The file is copied into the * workspace folder (`import-document`, which also creates the folder), the * folder is listed to find a free recipe id, an `api` recipe pair reading * the copy by its `file:` URL is saved (the same `save-recipe` the JSON tab @@ -27,10 +30,12 @@ export interface ImportDocumentFlow { export function useImportDocumentFlow (): ImportDocumentFlow { const importDocument = useImportDocumentMutation() const startRecipe = useStartRecipeFromDocument() + const loadSource = useLoadSource() const folder = useStudioUiStore(state => state.folder) const openFolder = useStudioUiStore(state => state.openFolder) const [error, setError] = useState() const [pending, setPending] = useState(false) + const [notice, setNotice] = useState() async function importFile (file: File): Promise { const target = (openFolder ?? folder).trim() @@ -40,10 +45,11 @@ export function useImportDocumentFlow (): ImportDocumentFlow { return } setError(undefined) + setNotice(undefined) setPending(true) try { const { url } = await importDocument.mutateAsync({ folder: target, name: file.name, bytes: await fileToBase64(file) }) - await startRecipe(target, file.name, url) + setNotice(await loadSource({ kind: 'document', url }, file.name, async () => { await startRecipe(target, file.name, url) })) } catch (importError) { setError(importError instanceof Error ? importError.message : String(importError)) } finally { @@ -51,7 +57,7 @@ export function useImportDocumentFlow (): ImportDocumentFlow { } } - return { importFile, pending, error } + return { importFile, pending, error, notice } } /** The file's content as base64, via the browser's own data-URL reading: no `btoa` over a megabytes-long string of char codes. */ diff --git a/apps/studio-ui/src/recipe-start/index.ts b/apps/studio-ui/src/recipe-start/index.ts new file mode 100644 index 00000000..8f4ff848 --- /dev/null +++ b/apps/studio-ui/src/recipe-start/index.ts @@ -0,0 +1,10 @@ +export { NewRecipeDialog } from './new-recipe-dialog.component' +export type { NewRecipeDialogProps } from './new-recipe-dialog.component' +export { DocumentDropZone } from './document-drop-zone.component' +export { useImportDocumentFlow } from './import-document.hook' +export type { ImportDocumentFlow } from './import-document.hook' +export { useOpenPathFromLink } from './open-path.hook' +export { LoadUrlDialog } from './load-url-dialog.component' +export type { LoadUrlDialogProps } from './load-url-dialog.component' +export { useLoadUrlFlow } from './load-url.hook' +export type { LoadUrlFlow } from './load-url.hook' diff --git a/apps/studio-ui/src/recipe-start/load-source.algorithm.test.ts b/apps/studio-ui/src/recipe-start/load-source.algorithm.test.ts new file mode 100644 index 00000000..3d527c38 --- /dev/null +++ b/apps/studio-ui/src/recipe-start/load-source.algorithm.test.ts @@ -0,0 +1,27 @@ +import { planLoad } from './load-source.algorithm' + +const webRecipe = { kind: 'input', id: 'books', output: 'books', mode: 'web', start: [{ url: 'https://old.test/' }], steps: [{ type: 'goto', url: '{{start.url}}' }], mapping: { title: { from: 'title' } } } +const documentRecipe = { kind: 'input', id: 'q3', output: 'q3', mode: 'api', start: [{ url: 'file:///r/q3.pdf' }], steps: [{ type: 'request', id: 'doc', url: '{{start.url}}' }], mapping: {} } + +describe('planLoad', () => { + it('replaces only the start address of a web recipe when a page is loaded', () => { + const plan = planLoad(webRecipe, { kind: 'url', url: 'https://new.test/' }) + expect(plan).toEqual({ kind: 'replace', recipe: { ...webRecipe, start: [{ url: 'https://new.test/' }] }, previous: 'https://old.test/' }) + }) + + it('replaces the start address of a document recipe when a document is loaded', () => { + const plan = planLoad(documentRecipe, { kind: 'document', url: 'file:///r/q4.pdf' }) + expect(plan).toMatchObject({ kind: 'replace', previous: 'file:///r/q3.pdf', recipe: { start: [{ url: 'file:///r/q4.pdf' }], mapping: {} } }) + }) + + it('starts a new recipe when the source is of the other kind', () => { + expect(planLoad(webRecipe, { kind: 'document', url: 'file:///r/q4.pdf' })).toEqual({ kind: 'new' }) + expect(planLoad(documentRecipe, { kind: 'url', url: 'https://new.test/' })).toEqual({ kind: 'new' }) + }) + + it('starts a new recipe when none is open, or it is not an input recipe with a start', () => { + expect(planLoad(undefined, { kind: 'url', url: 'https://new.test/' })).toEqual({ kind: 'new' }) + expect(planLoad({ kind: 'output', id: 'books' }, { kind: 'url', url: 'https://new.test/' })).toEqual({ kind: 'new' }) + expect(planLoad({ ...webRecipe, start: [] }, { kind: 'url', url: 'https://new.test/' })).toEqual({ kind: 'new' }) + }) +}) diff --git a/apps/studio-ui/src/recipe-start/load-source.algorithm.ts b/apps/studio-ui/src/recipe-start/load-source.algorithm.ts new file mode 100644 index 00000000..ffa722c1 --- /dev/null +++ b/apps/studio-ui/src/recipe-start/load-source.algorithm.ts @@ -0,0 +1,41 @@ +/** What "Load" brings in: a page address, or a document already copied into the workspace and named by its `file:` URL. */ +export type LoadSource = { kind: 'url', url: string } | { kind: 'document', url: string } + +/** What loading does to the recipe that is open. */ +export type LoadPlan = + /** `replace`: the recipe reads this kind of source, only its start address changes (`previous` is what it read before). */ + { kind: 'replace', recipe: Record, previous?: string } | + /** `new`: the recipe reads another kind of source, or none is open: the load starts a recipe of its own, and the open one is left untouched. */ + { kind: 'new' } + +function isRecord (value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Whether the recipe is a document recipe as `documentRecipeFiles` writes it: an `api` recipe whose first step requests the start address. */ +function readsDocument (recipe: Record): boolean { + const first = Array.isArray(recipe.steps) ? recipe.steps[0] : undefined + + return recipe.mode === 'api' && isRecord(first) && first.type === 'request' && first.url === '{{start.url}}' +} + +/** + * Decides what "Load" does when a recipe is open: the document or page is the + * sample the recipe is built against, so a source of the kind the recipe + * already reads replaces its start address and nothing else (steps and mapping + * stay). A source of the other kind would need different steps, which is a + * different recipe, so it starts a new one instead of rewriting this one. + * + * @param recipe - The open input recipe's parsed JSON; `undefined` when none is open or it does not parse. + * @param source - What is being loaded. + */ +export function planLoad (recipe: unknown, source: LoadSource): LoadPlan { + if (!isRecord(recipe) || recipe.kind !== 'input' || !Array.isArray(recipe.start)) return { kind: 'new' } + const [first, ...rest] = recipe.start as unknown[] + if (!isRecord(first)) return { kind: 'new' } + const readsThisKind = source.kind === 'document' ? readsDocument(recipe) : recipe.mode === 'web' + if (!readsThisKind) return { kind: 'new' } + const previous = typeof first.url === 'string' ? first.url : undefined + + return { kind: 'replace', recipe: { ...recipe, start: [{ ...first, url: source.url }, ...rest] }, previous } +} diff --git a/apps/studio-ui/src/recipe-start/load-source.hook.test.tsx b/apps/studio-ui/src/recipe-start/load-source.hook.test.tsx new file mode 100644 index 00000000..bdc0286f --- /dev/null +++ b/apps/studio-ui/src/recipe-start/load-source.hook.test.tsx @@ -0,0 +1,117 @@ +import { act, renderHook } from '@testing-library/react' +import { QueryClientProvider } from '@tanstack/react-query' +import type { QueryClient } from '@tanstack/react-query' +import type { PropsWithChildren } from 'react' +import { createStudioQueryClient, workspaceQueryKey } from '../studio-client' +import { resetStudioUiStore, useStudioUiStore } from '../studio-store' +import { useImportDocumentFlow } from './import-document.hook' +import { useLoadUrlFlow } from './load-url.hook' + +interface SentCommand { type: string, [key: string]: unknown } + +const webRecipe = { kind: 'input', id: 'books', output: 'books', mode: 'web', start: [{ url: 'https://old.test/' }], steps: [{ type: 'goto', url: '{{start.url}}' }], mapping: {} } +const documentRecipe = { kind: 'input', id: 'q3', output: 'q3', mode: 'api', start: [{ url: 'file:///r/q3.pdf' }], steps: [{ type: 'request', id: 'doc', url: '{{start.url}}' }], mapping: {} } + +function listing (recipe: { id: string }) { + return { file: `/r/${recipe.id}.ocw-in.json`, kind: 'input', id: recipe.id, issues: [], text: JSON.stringify(recipe) } +} + +function mockFetch (): jest.Mock { + const fetchMock = jest.fn().mockImplementation(async (_url: string, init: RequestInit) => { + const command = JSON.parse(init.body as string) as SentCommand + const body = command.type === 'import-document' + ? { path: `${command.folder as string}/${command.name as string}`, url: `file://${command.folder as string}/${command.name as string}` } + : (command.type === 'open-workspace' ? { folder: command.folder, recipes: [] } : { saved: true }) + + return { ok: true, status: 200, json: async () => body } + }) + Object.defineProperty(globalThis, 'fetch', { value: fetchMock, configurable: true }) + + return fetchMock +} + +function saved (fetchMock: jest.Mock): SentCommand[] { + return fetchMock.mock.calls + .map(([, init]) => JSON.parse((init as RequestInit).body as string) as SentCommand) + .filter(command => command.type === 'save-recipe') +} + +/** A workspace on `/r` with `recipe` open and selected, already in the query cache as the shell has it. */ +function openWith (recipe: { id: string }): QueryClient { + const queryClient = createStudioQueryClient() + queryClient.setQueryData(workspaceQueryKey('/r'), { folder: '/r', recipes: [listing(recipe)] }) + act(() => { + useStudioUiStore.getState().commitFolder('/r') + useStudioUiStore.getState().selectRecipe(recipe.id) + }) + + return queryClient +} + +function wrapperFor (queryClient: QueryClient) { + return function Wrapper ({ children }: PropsWithChildren): React.ReactElement { + return {children} + } +} + +beforeEach(() => { + history.replaceState({}, '', '/?token=abc123') + resetStudioUiStore() +}) + +describe('Load into the open recipe', () => { + it('a page replaces only the start address of the open web recipe, and says what it replaced', async () => { + const fetchMock = mockFetch() + const { result } = renderHook(() => useLoadUrlFlow(), { wrapper: wrapperFor(openWith(webRecipe)) }) + + await act(async () => { await result.current.loadUrl('https://new.test/catalog') }) + + expect(saved(fetchMock)).toHaveLength(1) + expect(saved(fetchMock)[0]).toMatchObject({ path: '/r/books.ocw-in.json', recipe: { id: 'books', steps: webRecipe.steps, start: [{ url: 'https://new.test/catalog' }] } }) + expect(result.current.notice).toBe('Loaded https://new.test/catalog into “books”, replacing https://old.test/.') + expect(useStudioUiStore.getState().selectedRecipeId).toBe('books') + }) + + it('a page starts a new web recipe named after its host when the open recipe reads a document, leaving that one alone', async () => { + const fetchMock = mockFetch() + const { result } = renderHook(() => useLoadUrlFlow(), { wrapper: wrapperFor(openWith(documentRecipe)) }) + + await act(async () => { await result.current.loadUrl('https://shop.test/catalog') }) + + expect(saved(fetchMock).map(command => command.path)).toEqual(['/r/shop.ocw-out.json', '/r/shop.ocw-in.json']) + expect(useStudioUiStore.getState().selectedRecipeId).toBe('shop') + expect(result.current.notice).toMatch(/“q3” reads a document.*“q3” is unchanged/) + }) + + it('a document replaces the start address of the open document recipe', async () => { + const fetchMock = mockFetch() + const { result } = renderHook(() => useImportDocumentFlow(), { wrapper: wrapperFor(openWith(documentRecipe)) }) + + await act(async () => { await result.current.importFile(new File(['%PDF-1.4'], 'q4.pdf')) }) + + expect(saved(fetchMock)).toHaveLength(1) + expect(saved(fetchMock)[0]).toMatchObject({ path: '/r/q3.ocw-in.json', recipe: { id: 'q3', start: [{ url: 'file:///r/q4.pdf' }] } }) + expect(result.current.notice).toBe('Loaded q4.pdf into “q3”, replacing file:///r/q3.pdf.') + }) + + it('a document starts a new recipe when the open recipe reads a web page', async () => { + const fetchMock = mockFetch() + const { result } = renderHook(() => useImportDocumentFlow(), { wrapper: wrapperFor(openWith(webRecipe)) }) + + await act(async () => { await result.current.importFile(new File(['a,b'], 'rows.csv')) }) + + expect(saved(fetchMock).map(command => command.path)).toEqual(['/r/rows.ocw-out.json', '/r/rows.ocw-in.json']) + expect(useStudioUiStore.getState().selectedRecipeId).toBe('rows') + expect(result.current.notice).toMatch(/“books” reads a web page.*“books” is unchanged/) + }) + + it('refuses a page with no folder to write into', async () => { + const fetchMock = mockFetch() + const { result } = renderHook(() => useLoadUrlFlow(), { wrapper: wrapperFor(createStudioQueryClient()) }) + + await act(async () => { await result.current.loadUrl('https://shop.test/') }) + + expect(result.current.error).toMatch(/Open a workspace folder first/) + expect(fetchMock).not.toHaveBeenCalled() + }) +}) diff --git a/apps/studio-ui/src/recipe-start/load-source.hook.ts b/apps/studio-ui/src/recipe-start/load-source.hook.ts new file mode 100644 index 00000000..1ece0278 --- /dev/null +++ b/apps/studio-ui/src/recipe-start/load-source.hook.ts @@ -0,0 +1,51 @@ +import { useQueryClient } from '@tanstack/react-query' +import type { RecipeListing, WorkspaceView } from '@opencraw/studio' +import { useSaveRecipeMutation, workspaceQueryKey } from '../studio-client' +import { useStudioUiStore } from '../studio-store' +import { planLoad } from './load-source.algorithm' +import type { LoadSource } from './load-source.algorithm' + +function parsed (text: string | undefined): unknown { + if (text === undefined) return undefined + try { + return JSON.parse(text) as unknown + } catch { + return undefined + } +} + +/** The file name of a `file:` URL or the host of a page address: what the notice calls the thing that was loaded. */ +function labelOf (source: LoadSource, label: string): string { + return source.kind === 'document' ? label : source.url +} + +/** + * What "Load" does with the recipe that is open (see {@link planLoad}): a + * source of the kind it already reads replaces its start address and is saved; + * anything else, or no open recipe, runs `startNew`, which makes a recipe of + * its own. + * + * @returns The function: the source, its label for messages and how to start a new recipe from it; it resolves to a note for the person when the open recipe was changed or deliberately left alone, `undefined` otherwise. + */ +export function useLoadSource (): (source: LoadSource, label: string, startNew: () => Promise) => Promise { + const queryClient = useQueryClient() + const saveRecipe = useSaveRecipeMutation() + + return async (source, label, startNew) => { + // Read from the workspace cache when asked, not subscribed to it: a flow that only runs on a drop or a choice must not fetch the folder on mount. + const { openFolder, selectedRecipeId } = useStudioUiStore.getState() + const selected: RecipeListing | undefined = openFolder === undefined + ? undefined + : queryClient.getQueryData(workspaceQueryKey(openFolder))?.recipes.find(recipe => recipe.id === selectedRecipeId) + const plan = planLoad(parsed(selected?.text), source) + if (selected !== undefined && plan.kind === 'replace') { + await saveRecipe.mutateAsync({ path: selected.file, recipe: plan.recipe }) + + return `Loaded ${labelOf(source, label)} into “${selected.id ?? selected.file}”${plan.previous === undefined ? '' : `, replacing ${plan.previous}`}.` + } + await startNew() + if (selected === undefined) return + + return `“${selected.id ?? selected.file}” reads ${source.kind === 'document' ? 'a web page' : 'a document'}, so ${labelOf(source, label)} started a new recipe instead. “${selected.id ?? selected.file}” is unchanged.` + } +} diff --git a/apps/studio-ui/src/recipe-start/load-url-dialog.component.tsx b/apps/studio-ui/src/recipe-start/load-url-dialog.component.tsx new file mode 100644 index 00000000..ad71c74a --- /dev/null +++ b/apps/studio-ui/src/recipe-start/load-url-dialog.component.tsx @@ -0,0 +1,61 @@ +import { useState } from 'react' +import { Button, CloseButton, Dialog, Field, Input, Portal } from '@chakra-ui/react' +import { startUrlError } from './new-recipe.mapper' + +export interface LoadUrlDialogProps { + open: boolean + onClose: () => void + /** A valid http(s) address was entered. */ + onLoad: (url: string) => void +} + +/** "Load ▾ URL…": asks for the page address (blank is not accepted: there is nothing to load). */ +export function LoadUrlDialog ({ open, onClose, onLoad }: LoadUrlDialogProps) { + const [url, setUrl] = useState('') + const problem = url.trim() === '' ? undefined : startUrlError(url) + const ready = url.trim() !== '' && problem === undefined + + function close (): void { + setUrl('') + onClose() + } + + function submit (): void { + if (!ready) return + onLoad(url.trim()) + close() + } + + return ( + { if (!details.open) close() }}> + + + + + + Load a web page + + + + + + + Page address + { setUrl(event.target.value) }} + onKeyDown={event => { if (event.key === 'Enter') submit() }} + /> + {problem !== undefined && {problem}} + Becomes the open recipe's start page, or a new recipe when none reads a web page. + + + + + + + + ) +} diff --git a/apps/studio-ui/src/recipe-start/load-url.hook.ts b/apps/studio-ui/src/recipe-start/load-url.hook.ts new file mode 100644 index 00000000..3ed2193b --- /dev/null +++ b/apps/studio-ui/src/recipe-start/load-url.hook.ts @@ -0,0 +1,67 @@ +import { useState } from 'react' +import { useSaveRecipeMutation, useStudioClient } from '../studio-client' +import { useStudioUiStore } from '../studio-store' +import { useLoadSource } from './load-source.hook' +import { newRecipeFiles, recipeIdFromFileName, uniqueRecipeId } from './new-recipe.mapper' + +export interface LoadUrlFlow { + /** Loads a page address; resolves once the recipe is saved and selected, or once `error` is set. */ + loadUrl: (url: string) => Promise + pending: boolean + error?: string + /** What loading did to the recipe that was open, when it is worth saying. */ + notice?: string +} + +/** + * "Load ▾ URL…": a web recipe's start address is the page it is built + * against, so with a web recipe open the address replaces its start URL and + * the steps and mapping stay. With no recipe open (or a document recipe) a new + * `web` recipe pair named after the host is written and selected. The folder + * is the open one, else the toolbar's folder box, as for a dropped file. + */ +export function useLoadUrlFlow (): LoadUrlFlow { + const client = useStudioClient() + const saveRecipe = useSaveRecipeMutation() + const loadSource = useLoadSource() + const folder = useStudioUiStore(state => state.folder) + const openFolder = useStudioUiStore(state => state.openFolder) + const commitFolder = useStudioUiStore(state => state.commitFolder) + const selectRecipe = useStudioUiStore(state => state.selectRecipe) + const [pending, setPending] = useState(false) + const [error, setError] = useState() + const [notice, setNotice] = useState() + + async function startNew (target: string, url: string): Promise { + const listing = await client.openWorkspace(target) + const taken = listing.recipes.map(recipe => recipe.id).filter((id): id is string => id !== undefined) + const id = uniqueRecipeId(recipeIdFromFileName(new URL(url).hostname), taken) + const files = newRecipeFiles(target, id, url) + await saveRecipe.mutateAsync({ path: files.outputPath, recipe: files.output }) + await saveRecipe.mutateAsync({ path: files.inputPath, recipe: files.input }) + if (useStudioUiStore.getState().openFolder !== target) commitFolder(target) + selectRecipe(id) + } + + async function loadUrl (url: string): Promise { + const target = (openFolder ?? folder).trim() + if (target === '') { + setError('Open a workspace folder first (Recipe ▾ → Open folder…): that is where the recipe is written.') + + return + } + setError(undefined) + setNotice(undefined) + setPending(true) + try { + const address = url.trim() + setNotice(await loadSource({ kind: 'url', url: address }, address, async () => { await startNew(target, address) })) + } catch (loadError) { + setError(loadError instanceof Error ? loadError.message : String(loadError)) + } finally { + setPending(false) + } + } + + return { loadUrl, pending, error, notice } +} diff --git a/apps/studio-ui/src/app/new-recipe-dialog.component.test.tsx b/apps/studio-ui/src/recipe-start/new-recipe-dialog.component.test.tsx similarity index 86% rename from apps/studio-ui/src/app/new-recipe-dialog.component.test.tsx rename to apps/studio-ui/src/recipe-start/new-recipe-dialog.component.test.tsx index d303fd9c..9f23dcad 100644 --- a/apps/studio-ui/src/app/new-recipe-dialog.component.test.tsx +++ b/apps/studio-ui/src/recipe-start/new-recipe-dialog.component.test.tsx @@ -15,13 +15,13 @@ function mockFetch (): jest.Mock { return fetchMock } -function renderDialog (props: Partial<{ folder?: string, onCreated: (id: string) => void }> = {}) { +function renderDialog (props: Partial<{ folder?: string, onCreated: (id: string) => void, onClose: () => void }> = {}) { const queryClient = createStudioQueryClient() return render( - {}} {...props} /> + {}} onCreated={() => {}} {...props} /> , ) @@ -30,14 +30,14 @@ function renderDialog (props: Partial<{ folder?: string, onCreated: (id: string) beforeEach(() => { withUrl('?token=abc123') }) describe('NewRecipeDialog', () => { - it('disables the trigger when no workspace folder is open', () => { + it('keeps Create disabled when no workspace folder is open', async () => { renderDialog({ folder: undefined }) - expect(screen.getByRole('button', { name: '+ New recipe' }).hasAttribute('disabled')).toBe(true) + fireEvent.change(await screen.findByPlaceholderText('books'), { target: { value: 'widgets' } }) + expect(screen.getByRole('button', { name: 'Create' }).hasAttribute('disabled')).toBe(true) }) - it('opens the dialog and flags an invalid id, keeping Create disabled', async () => { + it('flags an invalid id, keeping Create disabled', async () => { renderDialog() - fireEvent.click(screen.getByRole('button', { name: '+ New recipe' })) fireEvent.change(await screen.findByPlaceholderText('books'), { target: { value: 'Not Valid!' } }) expect(screen.getByText(/lowercase letters, digits and hyphens/)).toBeTruthy() expect(screen.getByRole('button', { name: 'Create' }).hasAttribute('disabled')).toBe(true) @@ -48,7 +48,6 @@ describe('NewRecipeDialog', () => { const onCreated = jest.fn() renderDialog({ onCreated }) - fireEvent.click(screen.getByRole('button', { name: '+ New recipe' })) fireEvent.change(await screen.findByPlaceholderText('books'), { target: { value: 'widgets' } }) fireEvent.click(screen.getByRole('button', { name: 'Create' })) @@ -64,7 +63,6 @@ describe('NewRecipeDialog', () => { const fetchMock = mockFetch() renderDialog({ onCreated: jest.fn() }) - fireEvent.click(screen.getByRole('button', { name: '+ New recipe' })) fireEvent.change(await screen.findByPlaceholderText('books'), { target: { value: 'widgets' } }) fireEvent.change(screen.getByPlaceholderText('https://example.com/books'), { target: { value: 'shop.test' } }) expect(screen.getByRole('button', { name: 'Create' })).toHaveProperty('disabled', true) @@ -81,7 +79,6 @@ describe('NewRecipeDialog', () => { Object.defineProperty(globalThis, 'fetch', { value: fetchMock, configurable: true }) renderDialog() - fireEvent.click(screen.getByRole('button', { name: '+ New recipe' })) fireEvent.change(await screen.findByPlaceholderText('books'), { target: { value: 'widgets' } }) fireEvent.click(screen.getByRole('button', { name: 'Create' })) diff --git a/apps/studio-ui/src/app/new-recipe-dialog.component.tsx b/apps/studio-ui/src/recipe-start/new-recipe-dialog.component.tsx similarity index 87% rename from apps/studio-ui/src/app/new-recipe-dialog.component.tsx rename to apps/studio-ui/src/recipe-start/new-recipe-dialog.component.tsx index dc2f20d3..dc6fa7d9 100644 --- a/apps/studio-ui/src/app/new-recipe-dialog.component.tsx +++ b/apps/studio-ui/src/recipe-start/new-recipe-dialog.component.tsx @@ -4,22 +4,24 @@ import { useSaveRecipeMutation } from '../studio-client' import { RECIPE_ID_PATTERN, newRecipeFiles, startUrlError } from './new-recipe.mapper' export interface NewRecipeDialogProps { - /** The open workspace folder the new files are written into; the button is disabled without one. */ + /** The open workspace folder the new files are written into; "Create" is refused without one. */ folder?: string + /** Whether the dialog is showing: the toolbar's Recipe menu opens it. */ + open: boolean + onClose: () => void /** The new recipe's id was saved: select it (the same as picking it from the toolbar's recipe list). */ onCreated: (recipeId: string) => void } /** - * "+ New recipe" (issue #110): composes a minimal, valid `web` input/output + * "Recipe ▾ → New recipe…" (issue #110): composes a minimal, valid `web` input/output * recipe pair from just an id, and saves both — the way to start a recipe * from scratch in a folder that has none yet (or add another to one that * already does). Reuses `saveRecipe`, the same use-case the JSON tab's Save * button calls; nothing server-side needed to know about "new" versus * "existing" (`fs.writeFile` already creates a file that is not there yet). */ -export function NewRecipeDialog ({ folder, onCreated }: NewRecipeDialogProps) { - const [open, setOpen] = useState(false) +export function NewRecipeDialog ({ folder, open, onClose, onCreated }: NewRecipeDialogProps) { const [id, setId] = useState('') const [startUrl, setStartUrl] = useState('') const [error, setError] = useState() @@ -29,7 +31,7 @@ export function NewRecipeDialog ({ folder, onCreated }: NewRecipeDialogProps) { const urlError = startUrlError(startUrl) function reset (): void { - setOpen(false) + onClose() setId('') setStartUrl('') setError(undefined) @@ -50,10 +52,7 @@ export function NewRecipeDialog ({ folder, onCreated }: NewRecipeDialogProps) { } return ( - { if (details.open) setOpen(true); else reset() }}> - - - + { if (!details.open) reset() }}> @@ -98,7 +97,7 @@ export function NewRecipeDialog ({ folder, onCreated }: NewRecipeDialogProps) { colorPalette='blue' alignSelf='flex-end' onClick={() => { void create() }} - disabled={id.length === 0 || idError !== undefined || urlError !== undefined || saveRecipe.isPending} + disabled={folder === undefined || id.length === 0 || idError !== undefined || urlError !== undefined || saveRecipe.isPending} loading={saveRecipe.isPending} > Create diff --git a/apps/studio-ui/src/app/new-recipe.mapper.test.ts b/apps/studio-ui/src/recipe-start/new-recipe.mapper.test.ts similarity index 100% rename from apps/studio-ui/src/app/new-recipe.mapper.test.ts rename to apps/studio-ui/src/recipe-start/new-recipe.mapper.test.ts diff --git a/apps/studio-ui/src/app/new-recipe.mapper.ts b/apps/studio-ui/src/recipe-start/new-recipe.mapper.ts similarity index 100% rename from apps/studio-ui/src/app/new-recipe.mapper.ts rename to apps/studio-ui/src/recipe-start/new-recipe.mapper.ts diff --git a/apps/studio-ui/src/app/open-path.hook.test.tsx b/apps/studio-ui/src/recipe-start/open-path.hook.test.tsx similarity index 100% rename from apps/studio-ui/src/app/open-path.hook.test.tsx rename to apps/studio-ui/src/recipe-start/open-path.hook.test.tsx diff --git a/apps/studio-ui/src/app/open-path.hook.ts b/apps/studio-ui/src/recipe-start/open-path.hook.ts similarity index 100% rename from apps/studio-ui/src/app/open-path.hook.ts rename to apps/studio-ui/src/recipe-start/open-path.hook.ts diff --git a/apps/studio-ui/src/app/start-recipe.hook.ts b/apps/studio-ui/src/recipe-start/start-recipe.hook.ts similarity index 100% rename from apps/studio-ui/src/app/start-recipe.hook.ts rename to apps/studio-ui/src/recipe-start/start-recipe.hook.ts diff --git a/apps/studio-ui/src/record-editor/field-row.component.tsx b/apps/studio-ui/src/record-editor/field-row.component.tsx index c046671e..0473b750 100644 --- a/apps/studio-ui/src/record-editor/field-row.component.tsx +++ b/apps/studio-ui/src/record-editor/field-row.component.tsx @@ -1,7 +1,8 @@ import { useState } from 'react' import { Badge, Box, Button, Checkbox, HStack, IconButton, Input, NativeSelect, Stack, Text } from '@chakra-ui/react' import type { FieldTraceView } from '@opencraw/studio' -import { PILL_DRAG_MIME } from '../steps-outline' +import { PILL_DRAG_MIME, Pill } from '../steps-outline' +import { typeColor } from './field-type-color.mapper' import { FIELD_NAME_PATTERN, FIELD_TYPES, MISSING_POLICIES } from './field-spec.model' import type { FieldSpecJson, FromRuleJson } from './field-spec.model' import { TransformChain } from './transform-chain.component' @@ -16,6 +17,8 @@ export interface FieldRowProps { scopeIds: string[] /** The selected sample record's trace for this field, when it was mapped and a sample has run. */ trace?: FieldTraceView + /** The selected sample record's value for this field, shown beside its source so a mapping reads as "pill → value" without running anything else; `undefined` before a sample has run. */ + value?: unknown /** `true` when the selected record's value for this field is `null` (the missing-value policy applied) — the row's Why? affordance. */ missing?: boolean /** How many of the last sample's records this field rejected, and why (its most recent reason); `undefined` when none did. */ @@ -41,7 +44,7 @@ export interface FieldRowProps { * Steps tab), the transform chain with real trace values, key/required, and * an expanded row for `nullable`/`default`/`onMissing`/validation options. */ -export function FieldRow ({ name, field, rule, scopeIds, trace, missing, rejected, depth = 0, stepId, highlighted = false, onHoverStepId, onFieldChange, onRuleChange, onRename, onRemove, onExplainMissing, onExplainRejected }: FieldRowProps) { +export function FieldRow ({ name, field, rule, scopeIds, trace, value, missing, rejected, depth = 0, stepId, highlighted = false, onHoverStepId, onFieldChange, onRuleChange, onRename, onRemove, onExplainMissing, onExplainRejected }: FieldRowProps) { const [expanded, setExpanded] = useState(false) const [nameText, setNameText] = useState(name) const [nameError, setNameError] = useState() @@ -72,6 +75,16 @@ export function FieldRow ({ name, field, rule, scopeIds, trace, missing, rejecte onMouseEnter={stepId === undefined || onHoverStepId === undefined ? undefined : () => { onHoverStepId(stepId) }} onMouseLeave={stepId === undefined || onHoverStepId === undefined ? undefined : () => { onHoverStepId(undefined) }} > + + {field.type} + {generated + ? generated + : (stepId === undefined + ? {rule === undefined ? 'not mapped' : 'from a template'} + : <>←)} + {value !== undefined && = {previewOf(value)}} + + { setNameText(event.target.value) }} onBlur={commitName} /> @@ -226,6 +239,13 @@ export function FieldRow ({ name, field, rule, scopeIds, trace, missing, rejecte ) } +/** A value as one short line: text as it is, anything else as JSON, cut at 80 characters. */ +function previewOf (value: unknown): string { + const text = typeof value === 'string' ? value : JSON.stringify(value) + + return text.length > 80 ? `${text.slice(0, 79)}…` : text +} + /** A small read-only badge for a `key`/`required` field, used by rows that only summarise a nested field rather than editing it fully. Exported for `record-editor.component.tsx`'s each-item header. */ export function FieldBadges ({ field }: { field: FieldSpecJson }) { return ( diff --git a/apps/studio-ui/src/record-editor/field-suggestions.component.tsx b/apps/studio-ui/src/record-editor/field-suggestions.component.tsx new file mode 100644 index 00000000..0be5f3da --- /dev/null +++ b/apps/studio-ui/src/record-editor/field-suggestions.component.tsx @@ -0,0 +1,52 @@ +import { Badge, Box, Button, HStack, Stack, Text } from '@chakra-ui/react' +import { Pill } from '../steps-outline' +import { typeColor } from './field-type-color.mapper' +import type { FieldSuggestion } from './suggest-fields.algorithm' + +export interface FieldSuggestionsProps { + suggestions: FieldSuggestion[] + /** Adds one proposal: its field and the rule that reads its pill. */ + onAdd: (suggestion: FieldSuggestion) => void + onAddAll: () => void +} + +/** + * What the steps read that no output field uses yet, one chip each: the pill, + * the field it would become and its guessed type. A click adds the field and + * its mapping in one go, so building the output is picking, not typing. + * Renders nothing when everything read is already used. + */ +export function FieldSuggestions ({ suggestions, onAdd, onAddAll }: FieldSuggestionsProps) { + if (suggestions.length === 0) return null + + return ( + + + Read by the steps, not in the output yet + + + + {suggestions.map(suggestion => ( + + + → + {suggestion.name} + {suggestion.field.type} + + + ))} + + + ) +} diff --git a/apps/studio-ui/src/record-editor/field-type-color.mapper.ts b/apps/studio-ui/src/record-editor/field-type-color.mapper.ts new file mode 100644 index 00000000..a2241ff5 --- /dev/null +++ b/apps/studio-ui/src/record-editor/field-type-color.mapper.ts @@ -0,0 +1,25 @@ +const TYPE_COLORS: Record = { + string: 'gray', + number: 'blue', + integer: 'blue', + boolean: 'green', + date: 'purple', + datetime: 'purple', + currency: 'orange', + url: 'cyan', + enum: 'pink', + array: 'teal', + object: 'teal', + json: 'yellow', +} + +/** + * The colour palette a field type's badge is drawn with, so a record's shape + * reads at a glance: text grey, numbers blue, dates purple, links cyan. + * + * @param type - A field type. + * @returns A Chakra colour palette name; grey for an unknown type. + */ +export function typeColor (type: string): string { + return TYPE_COLORS[type] ?? 'gray' +} diff --git a/apps/studio-ui/src/record-editor/index.ts b/apps/studio-ui/src/record-editor/index.ts index 549ceb23..3e99963d 100644 --- a/apps/studio-ui/src/record-editor/index.ts +++ b/apps/studio-ui/src/record-editor/index.ts @@ -7,3 +7,9 @@ export type { TransformChainProps, TransformJson } from './transform-chain.compo export { scopeIdsOf } from './scope-ids.algorithm' export { FIELD_NAME_PATTERN, FIELD_TYPES, GENERATED_VALUES, MISSING_POLICIES, fieldToStepIdOf, isEachRule } from './field-spec.model' export type { EachRuleJson, FieldSpecJson, FromRuleJson, MappingRuleJson } from './field-spec.model' + +export { FieldSuggestions } from './field-suggestions.component' +export type { FieldSuggestionsProps } from './field-suggestions.component' +export { suggestFields } from './suggest-fields.algorithm' +export type { FieldSuggestion } from './suggest-fields.algorithm' +export { typeColor } from './field-type-color.mapper' diff --git a/apps/studio-ui/src/record-editor/record-editor.component.tsx b/apps/studio-ui/src/record-editor/record-editor.component.tsx index 4a555b32..8e995b4f 100644 --- a/apps/studio-ui/src/record-editor/record-editor.component.tsx +++ b/apps/studio-ui/src/record-editor/record-editor.component.tsx @@ -5,9 +5,12 @@ import type { PreviewRecord } from '../preview' import type { RejectedRecord } from '../studio-store' import { useStudioUiStore } from '../studio-store' import { FieldRow } from './field-row.component' +import { FieldSuggestions } from './field-suggestions.component' import type { FieldSpecJson, MappingRuleJson } from './field-spec.model' import { isEachRule, MISSING_POLICIES } from './field-spec.model' import { scopeIdsOf } from './scope-ids.algorithm' +import { suggestFields } from './suggest-fields.algorithm' +import type { FieldSuggestion } from './suggest-fields.algorithm' export interface RecordEditorProps { /** The selected input recipe (its `mapping`), or `undefined` before one is selected. */ @@ -75,6 +78,7 @@ export function RecordEditor ({ inputRecipe, outputRecipe, records, rejected, on } const scopeIds = scopeIdsOf(input) + const suggestions = suggestFields(input, output.fields, input.mapping) const record = records[recordIndex] as PreviewRecord | undefined const dirty = JSON.stringify(output) !== savedOutputJson || JSON.stringify(input) !== savedInputJson @@ -102,6 +106,17 @@ export function RecordEditor ({ inputRecipe, outputRecipe, records, rejected, on else mapping[target] = rule setInput({ ...input, mapping }) } + /** Adds proposals as fields with their mapping, one state update so the two files change together. */ + const addSuggestions = (chosen: FieldSuggestion[]): void => { + const fields = { ...output.fields } + const mapping = { ...input.mapping } + for (const suggestion of chosen) { + fields[suggestion.name] = suggestion.field + mapping[suggestion.name] = suggestion.rule + } + setOutput({ ...output, fields }) + setInput({ ...input, mapping }) + } const addField = (): void => { let name = 'field' let n = 1 @@ -155,6 +170,16 @@ export function RecordEditor ({ inputRecipe, outputRecipe, records, rejected, on + { addSuggestions([suggestion]) }} + onAddAll={() => { addSuggestions(suggestions) }} + /> + + {Object.keys(output.fields).length === 0 && suggestions.length === 0 && ( + No output fields yet. Pick values in the content pane (Steps tab) and they show up here as proposals, or add a field by hand. + )} + {Object.entries(output.fields).map(([name, field]) => ( { + it('proposes a field for each value read in a loop, never for the list it loops over', () => { + const suggestions = suggestFields(books, {}, {}) + expect(suggestions.map(s => [s.source, s.name, s.field.type])).toEqual([ + ['title', 'title', 'string'], + ['price', 'price', 'number'], + ['availability', 'availability', 'boolean'], + ['detailLink', 'detailLink', 'url'], + ]) + }) + + it('makes a relative link absolute, since a url field refuses a relative one', () => { + const link = suggestFields(books, {}, {}).find(s => s.source === 'detailLink') + expect(link?.rule).toEqual({ from: 'detailLink', transform: [{ op: 'absoluteUrl' }] }) + }) + + it('leaves out what a field already reads, a list id an each rule uses, and what the mapping names in a template path', () => { + const mapping = { title: { from: 'title' }, offers: { each: 'books', fields: { cost: { from: 'price' } } }, link: { from: ['detailLink.href'] } } + expect(suggestFields(books, { title: { type: 'string' } }, mapping).map(s => s.source)).toEqual(['availability']) + }) + + it('finds values nested in paginate and if bodies and in else', () => { + const recipe = { + steps: [ + { type: 'paginate', steps: [{ type: 'extract', id: 'headline', kind: 'css', selector: 'h1' }] }, + { type: 'if', steps: [], else: [{ type: 'set', id: 'note', value: 'x' }] }, + ], + } + expect(suggestFields(recipe, {}, {}).map(s => s.source)).toEqual(['headline', 'note']) + }) + + it('proposes a list of text for an extract of every match that nothing loops over', () => { + const recipe = { steps: [{ type: 'extract', id: 'genres', kind: 'css', selector: '.genre', many: true }] } + const [suggestion] = suggestFields(recipe, {}, {}) + expect(suggestion.field).toEqual({ type: 'array', items: { type: 'string' } }) + }) + + it('skips tables, requests and navigation: they are not values', () => { + const recipe = { + steps: [ + { type: 'request', id: 'doc', url: '{{start.url}}', as: 'pdf' }, + { type: 'extract', id: 'sheet', kind: 'table', selector: '^Model', many: true }, + { type: 'extract', id: 'validUntil', kind: 'anchor', selector: 'right of ^Valid until' }, + ], + } + expect(suggestFields(recipe, {}, {}).map(s => [s.source, s.field.type])).toEqual([['validUntil', 'date']]) + }) + + it('names a field after its id, legal and unused', () => { + const recipe = { + steps: [ + { type: 'extract', id: 'title', kind: 'css', selector: 'h1' }, + { type: 'extract', id: 'a.b', kind: 'css', selector: 'h2' }, + { type: 'extract', id: '9lives', kind: 'css', selector: 'h3' }, + ], + } + expect(suggestFields(recipe, { title: { type: 'string' } }, {}).map(s => s.name)).toEqual(['title2', 'a_b', 'field_9lives']) + }) + + it('reads an empty or half-written recipe without throwing', () => { + expect(suggestFields(undefined, {}, {})).toEqual([]) + expect(suggestFields({ steps: [null, 4, { type: 'extract' }] }, {}, {})).toEqual([]) + }) +}) diff --git a/apps/studio-ui/src/record-editor/suggest-fields.algorithm.ts b/apps/studio-ui/src/record-editor/suggest-fields.algorithm.ts new file mode 100644 index 00000000..5afa06c8 --- /dev/null +++ b/apps/studio-ui/src/record-editor/suggest-fields.algorithm.ts @@ -0,0 +1,151 @@ +import { FIELD_NAME_PATTERN } from './field-spec.model' +import type { FieldSpecJson, FromRuleJson, MappingRuleJson } from './field-spec.model' + +/** One output field the Record tab proposes for a value the steps read and no field uses yet. */ +export interface FieldSuggestion { + /** The step id the field would read (its pill). */ + source: string + /** The proposed field name: the id made a legal, unused field name. */ + name: string + field: FieldSpecJson + rule: FromRuleJson + /** Why this type, in a few words (a tooltip). */ + reason: string +} + +interface Typed { + type: string + reason: string + items?: FieldSpecJson + transform?: { op: string }[] +} + +type StepJson = Record + +/** The attributes a link or an image source is taken from. */ +const LINK_TAKES: ReadonlySet = new Set(['attr:href', 'attr:src', 'attr:data-src']) + +/** Words in a step id that say what its value is. First match wins, so a more specific kind comes first. */ +const WORD_TYPES: readonly (readonly [readonly string[], Typed])[] = [ + [['url', 'link', 'href', 'website', 'homepage', 'image', 'img', 'photo', 'thumbnail', 'src'], { type: 'url', reason: 'its name says a link' }], + [['datetime', 'timestamp'], { type: 'datetime', reason: 'its name says a moment in time' }], + [['date', 'day', 'published', 'created', 'updated', 'released', 'expires', 'expiry', 'until', 'since'], { type: 'date', reason: 'its name says a date' }], + [['available', 'availability', 'active', 'enabled', 'instock', 'is', 'has'], { type: 'boolean', reason: 'its name says yes or no' }], + [['qty', 'quantity', 'count', 'pages', 'year', 'age', 'rank', 'position', 'votes', 'reviews'], { type: 'integer', reason: 'its name says a whole number' }], + [['price', 'cost', 'amount', 'total', 'fee', 'discount', 'salary', 'rate', 'rating', 'score', 'weight', 'height', 'width', 'length', 'percent', 'sconto'], { type: 'number', reason: 'its name says an amount' }], +] + +/** + * The output fields to propose for what a recipe's steps read: every `extract` + * (and `set`) with an id that no mapping rule uses yet and that no `forEach` + * loops over. Each gets a legal, unused name, a type guessed from how the value + * was taken and from the words in its id, and the rule that reads it. + * + * @param input - The input recipe's parsed JSON (possibly mid-edit). + * @param fields - The output recipe's fields so far. + * @param mapping - The input recipe's mapping so far. + * @returns The proposals, in the order the steps read them. + */ +export function suggestFields (input: unknown, fields: Record, mapping: Record): FieldSuggestion[] { + const steps = flatten(stepsOf(input)) + const looped = new Set(steps.flatMap(step => (typeof step.over === 'string' ? [step.over] : []))) + const used = mappedIds(mapping) + const taken = new Set(Object.keys(fields)) + const suggestions: FieldSuggestion[] = [] + const seen = new Set() + for (const step of steps) { + const id = step.id + if (typeof id !== 'string' || id === '' || !isReadStep(step) || looped.has(id) || used.has(id) || seen.has(id)) continue + seen.add(id) + const typed = inferType(id, step) + const name = freeName(id, taken) + taken.add(name) + suggestions.push({ + source: id, + name, + field: { type: typed.type, ...(typed.items !== undefined && { items: typed.items }) }, + rule: { from: id, ...(typed.transform !== undefined && { transform: typed.transform }) }, + reason: typed.reason, + }) + } + + return suggestions +} + +/** The type a value's id and way of being read suggest. Never `currency` (it fails without a code); `number` reads a price with a symbol in it. */ +function inferType (id: string, step: StepJson): Typed { + if (step.many === true) return { type: 'array', items: { type: 'string' }, reason: 'it reads every match, a list' } + const take = typeof step.take === 'string' ? step.take : undefined + if (take !== undefined && LINK_TAKES.has(take)) return { type: 'url', transform: [{ op: 'absoluteUrl' }], reason: `it takes ${take}, a link (made absolute)` } + const words = wordsOf(id) + for (const [keys, typed] of WORD_TYPES) { + if (words.some(word => keys.includes(word))) return typed + } + + return { type: 'string', reason: 'text, the safe default' } +} + +function isReadStep (step: StepJson): boolean { + if (step.type === 'set') return true + + // A table or a whole document is a collection the person loops over, not a value. + return step.type === 'extract' && step.kind !== 'table' +} + +/** Every step id a mapping rule reads: a plain id, a list of ids, an `each` list's id and its members' ids. */ +function mappedIds (mapping: Record): Set { + const ids = new Set() + const add = (rule: FromRuleJson): void => { + const sources = Array.isArray(rule.from) ? rule.from : [rule.from] + for (const from of sources) ids.add(from.split('.', 1)[0]) + } + for (const rule of Object.values(mapping)) { + if ('each' in rule) { + ids.add(rule.each) + for (const member of Object.values(rule.fields)) add(member) + } else { + add(rule) + } + } + + return ids +} + +/** An id as lowercase words: `bookTitle`, `book_title` and `book-title` are `book`, `title`. */ +function wordsOf (id: string): string[] { + // eslint-disable-next-line unicorn/prefer-string-replace-all -- apps/studio-ui's tsconfig lib is ["dom"] only (no ES2021 String#replaceAll). + return id.replace(/([a-z\d])([A-Z])/g, '$1 $2').toLowerCase().split(/[^a-z\d]+/).filter(word => word !== '') +} + +/** The id as a field name no other field has: illegal characters become `_`, a clash gets `2`, `3`... */ +function freeName (id: string, taken: ReadonlySet): string { + // eslint-disable-next-line unicorn/prefer-string-replace-all -- apps/studio-ui's tsconfig lib is ["dom"] only (no ES2021 String#replaceAll). + let base = id.replace(/[^\w-]/g, '_') + if (!FIELD_NAME_PATTERN.test(base)) base = `field_${base}` + let name = base + let n = 2 + while (taken.has(name)) { + name = `${base}${String(n)}` + n += 1 + } + + return name +} + +function stepsOf (value: unknown): unknown[] { + if (typeof value !== 'object' || value === null) return [] + const steps = (value as { steps?: unknown }).steps + + return Array.isArray(steps) ? steps : [] +} + +/** Every step, nested ones (`forEach`, `paginate`, `if` bodies and `else`) included, in reading order. */ +function flatten (steps: unknown[]): StepJson[] { + return steps.flatMap((raw) => { + if (typeof raw !== 'object' || raw === null) return [] + const step = raw as StepJson + const elseSteps = Array.isArray(step.else) ? step.else : [] + + return [step, ...flatten(stepsOf(step)), ...flatten(elseSteps)] + }) +} diff --git a/apps/vscode-extension/README.md b/apps/vscode-extension/README.md index c5051ee4..35e5f2d2 100644 --- a/apps/vscode-extension/README.md +++ b/apps/vscode-extension/README.md @@ -35,9 +35,10 @@ editor (extension host) Studio process browser - `src/open-on-opencraw`: the command's outcome, the only place that touches the editor API. - `src/extension.ts`: the entry point the editor requires. -The folder dialog is the server's own (`choose-folder`), so the **Browse…** button in the browser works the -same as everywhere else; in a remote window the dialog opens on the remote machine, which has no display, and -the message says to type the folder instead. +**Recipe ▾ → Open folder…** in Studio shows the editor's own folder dialog (`vscode.window.showOpenDialog`): the +Studio process asks the extension over its IPC channel (`choose-folder` / `folder`) and the answer becomes the +open workspace. In a remote window the dialog is the remote machine's, which is where the server runs and the +recipe folder is. ## Develop diff --git a/apps/vscode-extension/src/open-on-opencraw/open-on-opencraw.use-case.ts b/apps/vscode-extension/src/open-on-opencraw/open-on-opencraw.use-case.ts index a5481314..475ceedb 100644 --- a/apps/vscode-extension/src/open-on-opencraw/open-on-opencraw.use-case.ts +++ b/apps/vscode-extension/src/open-on-opencraw/open-on-opencraw.use-case.ts @@ -21,6 +21,25 @@ function workspaceFolders (): string[] { return (vscode.workspace.workspaceFolders ?? []).filter(folder => folder.uri.scheme === 'file').map(folder => folder.uri.fsPath) } +/** + * Studio's Open folder…, answered with the editor's own folder dialog: the operating system's picker, or the + * remote machine's in a remote window, where the server runs and the recipe folder is. + * + * @returns The folder picked, or `undefined` when the dialog was closed without a choice. + */ +async function pickFolder (): Promise { + const [picked] = await vscode.window.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: 'Choose the recipe folder', + openLabel: 'Open folder', + defaultUri: vscode.workspace.workspaceFolders?.[0]?.uri, + }) ?? [] + + return picked?.fsPath +} + /** * The hooks module the person named in their user settings, if any. * @@ -51,7 +70,7 @@ export function createStudioRunner (extensionPath: string): StudioRunner { async function start (): Promise { try { - const process = await startStudioProcess({ script, workspace: workspaceFolders()[0], hooks: hooksFile() }) + const process = await startStudioProcess({ script, workspace: workspaceFolders()[0], hooks: hooksFile(), chooseFolder: pickFolder }) void process.ended.then(() => { running = undefined }) return process diff --git a/apps/vscode-extension/src/studio-host/index.ts b/apps/vscode-extension/src/studio-host/index.ts index f5bd71d2..acc157a8 100644 --- a/apps/vscode-extension/src/studio-host/index.ts +++ b/apps/vscode-extension/src/studio-host/index.ts @@ -1,3 +1,3 @@ export { startStudioProcess } from './studio-process.client' -export type { StudioProcess, SpawnHost, HostChild } from './studio-process.client' +export type { StudioProcess, SpawnHost, HostChild, ChooseFolder } from './studio-process.client' export type { HostConfig, LinkTarget } from './studio-host.contract' diff --git a/apps/vscode-extension/src/studio-host/studio-host.contract.ts b/apps/vscode-extension/src/studio-host/studio-host.contract.ts index 87b33540..1854721f 100644 --- a/apps/vscode-extension/src/studio-host/studio-host.contract.ts +++ b/apps/vscode-extension/src/studio-host/studio-host.contract.ts @@ -12,14 +12,13 @@ export interface LinkTarget { workspace?: string } -/** What the extension sends the Studio process (`studio-host.process.mjs` reads the same shape). */ -export interface HostRequest { - type: 'link' - id: number - target?: LinkTarget -} +/** What the extension sends the Studio process (`studio-host.process.mjs` reads the same shape): a request for a link, or the answer to a folder dialog the process asked for. */ +export type HostRequest = { type: 'link', id: number, target?: LinkTarget } | + /** The editor's folder dialog answered; `folder` is absent when it was closed without a choice. */ + { type: 'folder', id: number, folder?: string } -/** What the Studio process answers: it is serving, here is a link, or it could not start. */ +/** What the Studio process says: it is serving, here is a link, it could not start, or it needs the editor's folder dialog (Studio's Open folder…). */ export type HostReply = { type: 'ready', url: string } | { type: 'link', id: number, url: string } | + { type: 'choose-folder', id: number } | { type: 'failed', message: string } diff --git a/apps/vscode-extension/src/studio-host/studio-host.process.mjs b/apps/vscode-extension/src/studio-host/studio-host.process.mjs index c308a13e..82c8f1d2 100644 --- a/apps/vscode-extension/src/studio-host/studio-host.process.mjs +++ b/apps/vscode-extension/src/studio-host/studio-host.process.mjs @@ -16,10 +16,22 @@ try { const { workspace, hooks } = JSON.parse(process.argv[2] ?? '{}') // The hooks file runs as the person's own code, named in their user settings (never a workspace's). const plugins = hooks === undefined ? undefined : await loadHooksFile(hooks) - const server = await startStudioServer({ initialFolder: workspace, plugins }) + // Studio's Open folder… asks the editor, which owns the operating system's dialog (and, in a remote window, the remote machine's). + const askingFolder = new Map() + let lastAsk = 0 + const chooseFolder = async () => await new Promise(resolve => { + lastAsk += 1 + askingFolder.set(lastAsk, resolve) + process.send({ type: 'choose-folder', id: lastAsk }) + }) + const server = await startStudioServer({ initialFolder: workspace, plugins, chooseFolder }) process.send({ type: 'ready', url: server.url }) process.on('message', message => { if (message?.type === 'link') process.send({ type: 'link', id: message.id, url: server.link(message.target) }) + if (message?.type === 'folder') { + askingFolder.get(message.id)?.(message.folder) + askingFolder.delete(message.id) + } }) process.on('disconnect', () => { void server.close() diff --git a/apps/vscode-extension/src/studio-host/studio-process.client.test.ts b/apps/vscode-extension/src/studio-host/studio-process.client.test.ts index 25f94fc0..d176b533 100644 --- a/apps/vscode-extension/src/studio-host/studio-process.client.test.ts +++ b/apps/vscode-extension/src/studio-host/studio-process.client.test.ts @@ -74,6 +74,37 @@ describe('startStudioProcess', () => { expect(await link).toBe('http://127.0.0.1:1/?token=t&open=%2Fwork%2Fa.pdf') }) + it('answers a folder request with the editor dialog choice, and with none when it fails or is closed', async () => { + const fake = fakeChild() + const answers = [Promise.resolve('/picked'), Promise.resolve(undefined), Promise.reject(new Error('no display'))] + const pending = startStudioProcess({ script: 's', chooseFolder: async () => await answers.shift(), spawn: () => fake.child }) + fake.reply({ type: 'ready', url: 'u' }) + await pending + + fake.reply({ type: 'choose-folder', id: 1 }) + fake.reply({ type: 'choose-folder', id: 2 }) + fake.reply({ type: 'choose-folder', id: 3 }) + await new Promise(resolve => { setTimeout(resolve, 0) }) + + expect(fake.sent).toEqual([ + { type: 'folder', id: 1, folder: '/picked' }, + { type: 'folder', id: 2, folder: undefined }, + { type: 'folder', id: 3, folder: undefined }, + ]) + }) + + it('answers a folder request with none when the editor offered no dialog', async () => { + const fake = fakeChild() + const pending = startStudioProcess({ script: 's', spawn: () => fake.child }) + fake.reply({ type: 'ready', url: 'u' }) + await pending + + fake.reply({ type: 'choose-folder', id: 7 }) + await new Promise(resolve => { setTimeout(resolve, 0) }) + + expect(fake.sent).toEqual([{ type: 'folder', id: 7, folder: undefined }]) + }) + it('answers each overlapping link request with its own reply', async () => { const fake = fakeChild() const pending = startStudioProcess({ script: 's', spawn: () => fake.child }) diff --git a/apps/vscode-extension/src/studio-host/studio-process.client.ts b/apps/vscode-extension/src/studio-host/studio-process.client.ts index 12227af9..d58f5ea8 100644 --- a/apps/vscode-extension/src/studio-host/studio-process.client.ts +++ b/apps/vscode-extension/src/studio-host/studio-process.client.ts @@ -12,6 +12,9 @@ export interface HostChild { /** Starts the Studio process: the script to run and what it starts with. */ export type SpawnHost = (script: string, config: HostConfig) => HostChild +/** Shows the editor's folder dialog and resolves to the folder picked, or `undefined` when it was closed without a choice. */ +export type ChooseFolder = () => Promise + export interface StudioProcess { /** The link that opens `target` in the browser (`/?token=…&open=…`). Rejects when the process has ended. */ link: (target?: LinkTarget) => Promise @@ -34,6 +37,17 @@ export const forkHost: SpawnHost = (script, config) => fork(script, [JSON.string /** How much of the process's error output is kept for the message of a failed start. */ const STDERR_KEPT = 2000 +/** Asks the editor's dialog (when it has one) and sends the process what it chose; a dialog that fails answers like one that was closed. */ +async function answerFolder (child: HostChild, id: number, chooseFolder: ChooseFolder | undefined): Promise { + let folder: string | undefined + try { + folder = await chooseFolder?.() + } catch { + folder = undefined + } + child.send({ type: 'folder', id, folder }) +} + interface PendingLink { resolve: (url: string) => void reject: (error: Error) => void @@ -45,11 +59,12 @@ interface PendingLink { * @param options.script - The `studio-host.process.mjs` to run. * @param options.workspace - The folder Studio opens first, typically the editor's workspace folder. * @param options.hooks - A hooks or plugins module to run recipes with (the `opencraw.hooksFile` setting). + * @param options.chooseFolder - The editor's folder dialog, which Studio's Open folder… uses; without it the process answers that there is none. * @param options.spawn - How the process is started; `forkHost` by default. * @returns The running process. * @throws Error when the process ends or reports a failure before it is serving; the message carries what it printed. */ -export async function startStudioProcess (options: { script: string, workspace?: string, hooks?: string, spawn?: SpawnHost }): Promise { +export async function startStudioProcess (options: { script: string, workspace?: string, hooks?: string, chooseFolder?: ChooseFolder, spawn?: SpawnHost }): Promise { const child = (options.spawn ?? forkHost)(options.script, { workspace: options.workspace, hooks: options.hooks }) let printed = '' child.stderr?.on('data', chunk => { printed = (printed + String(chunk)).slice(-STDERR_KEPT) }) @@ -61,14 +76,25 @@ export async function startStudioProcess (options: { script: string, workspace?: const ready = new Promise((resolve, reject) => { child.on('message', message => { - if (message.type === 'ready') { - serving = true - resolve() - } else if (message.type === 'link') { - waiting.get(message.id)?.resolve(message.url) - waiting.delete(message.id) - } else if (!serving) { - reject(new Error(message.message)) + switch (message.type) { + case 'ready': { + serving = true + resolve() + break + } + case 'link': { + waiting.get(message.id)?.resolve(message.url) + waiting.delete(message.id) + break + } + case 'choose-folder': { + void answerFolder(child, message.id, options.chooseFolder) + break + } + case 'failed': { + if (!serving) reject(new Error(message.message)) + break + } } }) child.on('exit', code => { diff --git a/apps/vscode-extension/tsconfig.tsbuildinfo b/apps/vscode-extension/tsconfig.tsbuildinfo new file mode 100644 index 00000000..a79294d7 --- /dev/null +++ b/apps/vscode-extension/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"version":"7.0.2","fileInfos":[],"options":{"composite":true,"emitDeclarationOnly":true,"declarationMap":true,"importHelpers":true,"module":199,"noFallthroughCasesInSwitch":true,"noImplicitReturns":true,"noEmitOnError":true,"noUnusedLocals":true,"noImplicitOverride":true,"noUncheckedSideEffectImports":false,"skipLibCheck":true,"strict":true,"target":9}} \ No newline at end of file diff --git a/docs/studio/manual.md b/docs/studio/manual.md index 7fc32ab1..39fa4fa9 100644 --- a/docs/studio/manual.md +++ b/docs/studio/manual.md @@ -59,22 +59,44 @@ so a plain crawl install stays light. ## 2. Opening a workspace -The toolbar's folder box takes any folder — one that already has `.input.json`/`.output.json` files -in it, or a brand new one. Type a path (or start with one already open, via `opencraw studio `) and -click **Open**. +The toolbar has three jobs, and each has one place: -**Starting from nothing?** Click **+ New recipe**, type an id (lowercase letters, digits and hyphens — the -same pattern every recipe id follows), and **Create**. That writes a minimal, valid pair — -`.input.json` and `.output.json` — into the open folder and selects it: a `web` recipe with one +| Button | Question it answers | What is in it | +|---|---|---| +| **Recipe ▾** | Which recipe am I editing? | the recipes in the open folder, **New recipe…**, **Open folder…** | +| **Load ▾** | What does it read? | **Document…**, **Web page (URL)…** | +| **Run sample** / **Stop** | Does it work? | runs the open recipe | + +Next to them, **All changes saved** says edits are written as you make them (there is no Save step outside the +JSON tab); it reads **Saving…** while a write is on its way. + +**A recipe is an input and its output.** The input recipe (`.ocw-in.json`) names its output recipe +(`.ocw-out.json`, by the input's `output` id). Opening a recipe from **Recipe ▾** opens both: Steps edits the +input, Record edits the output and the mapping between them. You never open the pair separately. + +**Opening a folder.** **Recipe ▾ → Open folder…** takes any folder — one that already has `.ocw-in.json` / +`.ocw-out.json` files in it, or a brand new one. It opens the operating system's folder dialog (the desktop app's native one, or the VS Code extension's own +dialog when Studio runs from there); on a machine with no dialog, a box to type the path in appears instead. Starting Studio +with `opencraw studio ` opens that folder already. + +**Starting from nothing?** **Recipe ▾ → New recipe…**: type an id (lowercase letters, digits and hyphens — the +same pattern every recipe id follows), optionally a start URL, and **Create**. That writes a minimal, valid +pair — `.ocw-in.json` and `.ocw-out.json` — into the open folder and selects it: a `web` recipe with one `goto` step, an `emit`, and no fields mapped yet. Everything from here builds on top of that. -**Starting from a file?** Drop a PDF, spreadsheet, CSV, PowerPoint, Word, Markdown, JSON, YAML or XML -file anywhere on the window — or click **Open document…** and pick one. The studio copies it into the -workspace folder (next to the recipes, so the folder stays self-contained), writes a pair named after the -file (`Q3 Report.pdf` → `q3-report`; a second `q3-report` becomes `q3-report-2`) whose one step reads the -copy, and selects it. The content pane then shows the document on its own canvas -([§8](#8-document-canvases)) — no URL to type, no placeholder to edit. No folder open yet? Type one in -the folder box first; it's created on the drop. +**Loading what it reads.** A page or a document is the sample a recipe is built against, so **Load ▾** puts it +into the recipe that is open: + +- **Web page (URL)…** with a web recipe open replaces its start page; the steps and the mapping stay. +- **Document…** (or dropping a PDF, spreadsheet, CSV, PowerPoint, Word, Markdown, JSON, YAML or XML file + anywhere on the window) with a document recipe open replaces the file it reads. The studio copies the file + into the workspace folder, next to the recipes, so the folder stays self-contained. +- If the open recipe reads the other kind of thing — a document into a web recipe, or a page into a document + recipe — or no recipe is open, Load starts a new recipe instead and leaves the open one untouched. A bar + under the toolbar says which happened. New recipes are named after the file (`Q3 Report.pdf` → `q3-report`; + a second one becomes `q3-report-2`) or the page's host, and a document opens on its own canvas + ([§8](#8-document-canvases)). +- No folder open yet? Load needs one to write into: open one first. For this guide's example, open any folder and create a recipe with the id `books`. @@ -149,8 +171,16 @@ Switch to the **Record** tab. This is the paired **output** recipe: the fields a with a type, and the input recipe's **mapping** — which pill each field reads from, and what happens to the value on the way in. -Add fields for `title` (`string`), `price` (`currency`), `available` (`boolean`); drag the **Steps** tab's -`title`/`price`/`availability` pills onto their matching rows to bind each field's source (or type the pill +A strip at the top of the tab lists every value the steps read that no field uses yet, one chip each: +the pill, the field it would become and a guessed type (`price` becomes a `number`, `availability` a +`boolean`, a link taken from `href` a `url` made absolute; hover a chip for why). Click a chip's **+** to add +the field **and** its mapping at once, or **Add all**. Nothing is guessed silently: change a type or a name +afterwards like any field. + +Each field row opens with a one-line summary: a coloured **type** badge, the **pill** it reads from (or +*not mapped*, *generated*) and, once a sample has run, the **value** the selected record got. + +You can still add a field by hand with **+ field** and drag a **Steps** pill onto its row (or type the pill name directly). Mark `title` as the record's **key**, so a duplicate never gets emitted twice. Each field's **transform chain** is a row of small blocks — `trim`, `currency`, `boolean`, and so on diff --git a/packages/studio/README.md b/packages/studio/README.md index 85922670..8ae914ad 100644 --- a/packages/studio/README.md +++ b/packages/studio/README.md @@ -27,8 +27,8 @@ OpenCraw Studio: http://127.0.0.1:52341/?token=8f2a1c9e4b7d3f6a0e5c8b1d2a4f7e9c Workspace: /home/you/project/recipes ``` -`recipes/` can already have `.input.json`/`.output.json` files in it, or be empty — the studio's own -**+ New recipe** writes a minimal, valid pair to start from. +`recipes/` can already have `.ocw-in.json`/`.ocw-out.json` files in it, or be empty — the studio's own +**Recipe ▾ → New recipe…** writes a minimal, valid pair to start from. ## Learn more