diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx
index ac21a095..34cb094a 100644
--- a/.storybook/preview.tsx
+++ b/.storybook/preview.tsx
@@ -1,7 +1,7 @@
import * as React from 'react';
import { ComponentType } from 'react';
-import { Provider } from '../src/Provider';
+import { Provider } from '../src';
export const decorators = [
(Story: ComponentType) => (
diff --git a/src/Plate/coordinateSystem2x16NoJ.ts b/src/Plate/coordinateSystem2x16NoJ.ts
index b383c881..ff012f9d 100644
--- a/src/Plate/coordinateSystem2x16NoJ.ts
+++ b/src/Plate/coordinateSystem2x16NoJ.ts
@@ -2,7 +2,7 @@ import { CoordinateSystem } from './types';
/**
* The Tecan MM block has no J on its rows.
- * Mirrors MLL\Utils\Microplate\CoordinateSystem2x16NoJ.
+ * Mirrors https://github.com/mll-lab/php-utils/blob/master/src/Microplate/CoordinateSystem2x16NoJ.php.
*/
export const COORDINATE_SYSTEM_2X16_NO_J = {
rows: [
diff --git a/src/TecanWorklist/GwlStepView.tsx b/src/TecanWorklist/GwlStepView.tsx
new file mode 100644
index 00000000..c1d11ce9
--- /dev/null
+++ b/src/TecanWorklist/GwlStepView.tsx
@@ -0,0 +1,93 @@
+import React, { ReactElement } from 'react';
+import styled from 'styled-components';
+
+import { Typography } from '../Typography';
+import { PALETTE } from '../theme';
+
+import { Separator } from './Separator';
+import { fieldStyle } from './fieldStyle';
+import { COMMAND, GwlStep } from './parseGwl';
+
+const Step = styled.div`
+ padding-bottom: 4px;
+`;
+
+const Line = styled.div`
+ display: flex;
+ white-space: pre;
+`;
+
+/**
+ * The line number is generated content, not text: `user-select: none` alone
+ * still lands in the clipboard, so a copied selection would not be valid GWL.
+ */
+const Gutter = styled.span`
+ background-color: ${PALETTE.gray1};
+ border-right: 1px solid ${PALETTE.gray3};
+ color: ${PALETTE.gray5};
+ flex-shrink: 0;
+ padding-right: 8px;
+ text-align: right;
+ width: 4em;
+
+ &::before {
+ content: attr(data-line-number);
+ }
+`;
+
+const Comment = styled.span`
+ padding-left: 8px;
+`;
+
+const Command = styled.span`
+ border-left: 2px solid ${PALETTE.gray3};
+ margin-left: 8px;
+ padding-left: 10px;
+`;
+
+export function GwlStepView({
+ step,
+ showCommands,
+}: {
+ step: GwlStep;
+ showCommands: boolean;
+}): ReactElement {
+ const isUndocumented = step.comment == null;
+
+ return (
+
+ {isUndocumented ? null : (
+
+
+ {/* The C; prefix stays so a copied selection is valid GWL again. */}
+
+
+ {COMMAND.COMMENT}
+
+
+ {step.comment}
+
+
+ )}
+ {showCommands || isUndocumented
+ ? step.commands.map((command) => (
+
+
+
+ {command.fields.map((field, index) => (
+ // A field is identified by its position, fields never reorder.
+ // eslint-disable-next-line react/no-array-index-key
+
+ {index === 0 ? null : }
+ {field.text}
+
+ ))}
+
+
+ ))
+ : null}
+
+ );
+}
diff --git a/src/TecanWorklist/Separator.tsx b/src/TecanWorklist/Separator.tsx
new file mode 100644
index 00000000..5d09b781
--- /dev/null
+++ b/src/TecanWorklist/Separator.tsx
@@ -0,0 +1,7 @@
+import React, { ReactElement } from 'react';
+
+import { Typography } from '../Typography';
+
+export function Separator(): ReactElement {
+ return ;;
+}
diff --git a/src/TecanWorklist/TecanWorklist.test.tsx b/src/TecanWorklist/TecanWorklist.test.tsx
new file mode 100644
index 00000000..a29d88ae
--- /dev/null
+++ b/src/TecanWorklist/TecanWorklist.test.tsx
@@ -0,0 +1,58 @@
+// TODO remove when we can upgrade to @testing-library/user-event:14, whose events actually are awaitable
+/* eslint-disable @typescript-eslint/await-thenable */
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+import { Provider } from '../Provider';
+
+import { TecanWorklist, TECAN_WORKLIST_CODE_ID } from './TecanWorklist';
+import { DILUTION_RUN_WORKLIST } from './exampleWorklist';
+
+const SHOW_COMMANDS_LABEL = 'Befehle anzeigen';
+
+describe('TecanWorklist', () => {
+ it('leads with the comments, holding the commands back until asked for and again after', async () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('User: mustermann')).toBeInTheDocument();
+ expect(screen.queryByText('198')).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByLabelText(SHOW_COMMANDS_LABEL));
+
+ expect(screen.getAllByText('198')).toHaveLength(10);
+
+ await userEvent.click(screen.getByLabelText(SHOW_COMMANDS_LABEL));
+
+ expect(screen.queryByText('198')).not.toBeInTheDocument();
+ });
+
+ it('shows the commands of a worklist documenting nothing, which has no comment to collapse into', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId(TECAN_WORKLIST_CODE_ID).textContent).toBe(
+ 'B;A;MM;;Eppis;1;;198;;;1W;',
+ );
+ });
+
+ it('keeps the line numbers out of the text, so a copied selection stays valid GWL', async () => {
+ render(
+
+
+ ,
+ );
+ await userEvent.click(screen.getByLabelText(SHOW_COMMANDS_LABEL));
+
+ expect(screen.getByTestId(TECAN_WORKLIST_CODE_ID).textContent).toBe(
+ 'C;TransferA;MM;;Eppis;1;;198;;;1W;',
+ );
+ });
+});
diff --git a/src/TecanWorklist/TecanWorklist.tsx b/src/TecanWorklist/TecanWorklist.tsx
new file mode 100644
index 00000000..6f003295
--- /dev/null
+++ b/src/TecanWorklist/TecanWorklist.tsx
@@ -0,0 +1,62 @@
+import React, { CSSProperties, ReactElement, ReactNode } from 'react';
+import styled from 'styled-components';
+
+import { Card } from '../Card';
+import { Checkbox } from '../Checkbox';
+import { Space } from '../Space';
+
+import { GwlStepView } from './GwlStepView';
+import { parseGwl } from './parseGwl';
+
+const CodeCard = styled(Card)`
+ font-family: monospace;
+`;
+
+const CODE_BODY_STYLE: CSSProperties = {
+ maxHeight: '400px',
+ overflow: 'auto',
+ padding: 0,
+};
+
+export const TECAN_WORKLIST_CODE_ID = 'tecan-worklist-code';
+
+export type TecanWorklistProps = {
+ gwl: string;
+ toolbar?: ReactNode;
+};
+
+export function TecanWorklist({
+ gwl,
+ toolbar,
+}: TecanWorklistProps): ReactElement {
+ const [showCommands, setShowCommands] = React.useState(false);
+
+ const steps = React.useMemo(() => parseGwl(gwl), [gwl]);
+
+ return (
+
+
+ {toolbar}
+ setShowCommands(event.target.checked)}
+ >
+ Befehle anzeigen
+
+
+
+ {steps.map((step) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/TecanWorklist/TecanWorklistPreview.test.tsx b/src/TecanWorklist/TecanWorklistPreview.test.tsx
new file mode 100644
index 00000000..46692720
--- /dev/null
+++ b/src/TecanWorklist/TecanWorklistPreview.test.tsx
@@ -0,0 +1,37 @@
+// TODO remove when we can upgrade to @testing-library/user-event:14, which currently does not work with Select
+/* eslint-disable @typescript-eslint/await-thenable */
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+import { Provider } from '../Provider';
+
+import { TecanWorklistPreview } from './TecanWorklistPreview';
+import { DILUTION_RUN_WORKLIST } from './exampleWorklist';
+
+const TIP_COUNT_OPTIONS = [
+ { tipCount: 4, device: 'A' },
+ { tipCount: 8, device: 'E' },
+];
+
+describe('TecanWorklistPreview', () => {
+ it('offers tip counts and reports the device serving the chosen one', async () => {
+ const onDeviceChange = jest.fn();
+
+ render(
+
+
+ ,
+ );
+
+ await userEvent.click(screen.getByRole('combobox'));
+ await userEvent.click(screen.getByText('8 Tip'));
+
+ expect(onDeviceChange).toHaveBeenCalledWith('E');
+ });
+});
diff --git a/src/TecanWorklist/TecanWorklistPreview.tsx b/src/TecanWorklist/TecanWorklistPreview.tsx
new file mode 100644
index 00000000..3a018405
--- /dev/null
+++ b/src/TecanWorklist/TecanWorklistPreview.tsx
@@ -0,0 +1,48 @@
+import React, { ReactElement } from 'react';
+
+import { Select } from '../Select';
+
+import { TecanWorklist } from './TecanWorklist';
+
+/**
+ * The tip count is what shapes the worklist, so that is what the user picks.
+ * Which device serves a tip count is the consumer's business.
+ */
+export type TecanTipCountOption = {
+ tipCount: number;
+ device: TDevice;
+};
+
+export type TecanWorklistPreviewProps = {
+ /** Worklist generated for the selected device. */
+ gwl: string;
+ device: TDevice;
+ tipCountOptions: Array>;
+ onDeviceChange: (device: TDevice) => void;
+};
+
+/** For a worklist that already exists, render TecanWorklist directly. */
+export function TecanWorklistPreview({
+ gwl,
+ device,
+ tipCountOptions,
+ onDeviceChange,
+}: TecanWorklistPreviewProps): ReactElement {
+ return (
+
+ size="small"
+ options={tipCountOptions.map((option) => ({
+ label: `${option.tipCount} Tip`,
+ value: option.device,
+ }))}
+ value={device}
+ // Wrapped because Select also passes the option, which the consumer has no use for.
+ onChange={(selected) => onDeviceChange(selected)}
+ />
+ }
+ />
+ );
+}
diff --git a/src/TecanWorklist/exampleWorklist.ts b/src/TecanWorklist/exampleWorklist.ts
new file mode 100644
index 00000000..e981a92a
--- /dev/null
+++ b/src/TecanWorklist/exampleWorklist.ts
@@ -0,0 +1,35 @@
+/** Structurally realistic, with placeholder user, timestamp and run number. */
+export const DILUTION_RUN_WORKLIST = `C;Created by mll-lab/php-utils v6.14.0
+C;Date: 2000-01-01 00:00:00
+C;User: mustermann
+C;Protocol name: 2000-01-01_00-00-00_DilutionRun1.gwl
+C;Transfer von 990 µl von MM-Rack (A1) nach MM-Rack (Q2)
+B;
+S;21
+A;MM;;Eppis 32x1.5 ml Cooled;1;;198;Dilution_Run_No_Mix;;1
+D;MM;;Eppis 32x1.5 ml Cooled;32;;198;Dilution_Run_No_Mix;;1
+W;
+A;MM;;Eppis 32x1.5 ml Cooled;1;;198;Dilution_Run_No_Mix;;2
+D;MM;;Eppis 32x1.5 ml Cooled;32;;198;Dilution_Run_No_Mix;;2
+W;
+A;MM;;Eppis 32x1.5 ml Cooled;1;;198;Dilution_Run_No_Mix;;4
+D;MM;;Eppis 32x1.5 ml Cooled;32;;198;Dilution_Run_No_Mix;;4
+W;
+A;MM;;Eppis 32x1.5 ml Cooled;1;;198;Dilution_Run_No_Mix;;8
+D;MM;;Eppis 32x1.5 ml Cooled;32;;198;Dilution_Run_No_Mix;;8
+W;
+A;MM;;Eppis 32x1.5 ml Cooled;1;;198;Dilution_Run_No_Mix;;16
+D;MM;;Eppis 32x1.5 ml Cooled;32;;198;Dilution_Run_No_Mix;;16
+W;
+C;Transfer von 110 µl von MM-Rack (B1) nach MM-Rack (Q2)
+A;MM;;Eppis 32x1.5 ml Cooled;2;;110;Dilution_Run_Mix_High_Dispense;;32
+D;MM;;Eppis 32x1.5 ml Cooled;32;;110;Dilution_Run_Mix_High_Dispense;;32
+W;
+B;
+S;21
+C;Verteilen von je 250 µl von MM-Rack (Q2) nach FluidX-Rack (A1, B1, C1, D1)
+R;MM;;Eppis 32x1.5 ml Cooled;32;32;FluidX;;96FluidX;1;4;125;Dilution_Run_No_Mix;6;1;0;
+R;MM;;Eppis 32x1.5 ml Cooled;32;32;FluidX;;96FluidX;1;4;125;Dilution_Run_No_Mix;6;1;0;
+W;
+B;
+`;
diff --git a/src/TecanWorklist/fieldStyle.ts b/src/TecanWorklist/fieldStyle.ts
new file mode 100644
index 00000000..59c99ecf
--- /dev/null
+++ b/src/TecanWorklist/fieldStyle.ts
@@ -0,0 +1,30 @@
+import { CSSProperties } from 'react';
+
+import { PALETTE } from '../theme';
+
+import { COMMAND, GwlField, GwlFieldRole } from './parseGwl';
+
+const PIPETTING_COMMAND_COLOR: Record = {
+ [COMMAND.ASPIRATE]: PALETTE.red,
+ [COMMAND.DISPENSE]: PALETTE.gold,
+ [COMMAND.REAGENT_DISTRIBUTION]: PALETTE.blue,
+};
+
+const FIELD_STYLE: Record = {
+ command: { fontWeight: 'bold' },
+ plain: { color: PALETTE.gray6 },
+ position: { color: PALETTE.tableHeaderBackgroundColor, fontWeight: 'bold' },
+ tubeID: { color: PALETTE.gray9, fontWeight: 'bold' },
+ volume: { color: PALETTE.green, fontWeight: 'bold' },
+};
+
+export function fieldStyle({ role, text }: GwlField): CSSProperties {
+ if (role !== 'command') {
+ return FIELD_STYLE[role];
+ }
+
+ return {
+ ...FIELD_STYLE.command,
+ color: PIPETTING_COMMAND_COLOR[text] ?? PALETTE.gray7,
+ };
+}
diff --git a/src/TecanWorklist/index.stories.tsx b/src/TecanWorklist/index.stories.tsx
new file mode 100644
index 00000000..d3a38e6d
--- /dev/null
+++ b/src/TecanWorklist/index.stories.tsx
@@ -0,0 +1,37 @@
+import React, { ReactElement } from 'react';
+
+import { TecanWorklist } from './TecanWorklist';
+import { TecanWorklistPreview } from './TecanWorklistPreview';
+import { DILUTION_RUN_WORKLIST } from './exampleWorklist';
+
+const TIP_COUNT_OPTIONS = [
+ { tipCount: 4, device: 'A' },
+ { tipCount: 8, device: 'E' },
+];
+
+export default {
+ title: 'TecanWorklist',
+};
+
+export function Worklist(): ReactElement {
+ return ;
+}
+
+export function WorklistOfKnownDevice(): ReactElement {
+ return (
+ Tecan C} />
+ );
+}
+
+export function Preview(): ReactElement {
+ const [device, setDevice] = React.useState('A');
+
+ return (
+
+ );
+}
diff --git a/src/TecanWorklist/index.tsx b/src/TecanWorklist/index.tsx
new file mode 100644
index 00000000..86cbf106
--- /dev/null
+++ b/src/TecanWorklist/index.tsx
@@ -0,0 +1,8 @@
+export { TecanWorklist } from './TecanWorklist';
+export type { TecanWorklistProps } from './TecanWorklist';
+
+export { TecanWorklistPreview } from './TecanWorklistPreview';
+export type {
+ TecanWorklistPreviewProps,
+ TecanTipCountOption,
+} from './TecanWorklistPreview';
diff --git a/src/TecanWorklist/parseGwl.test.ts b/src/TecanWorklist/parseGwl.test.ts
new file mode 100644
index 00000000..f9a785b7
--- /dev/null
+++ b/src/TecanWorklist/parseGwl.test.ts
@@ -0,0 +1,117 @@
+import { DILUTION_RUN_WORKLIST } from './exampleWorklist';
+import { parseGwl } from './parseGwl';
+
+describe('parseGwl', () => {
+ it('groups commands under the comment preceding them', () => {
+ const steps = parseGwl('C;Transfer\nW;\nB;\nC;Verteilen\nW;');
+
+ expect(steps.map((step) => [step.comment, step.commands.length])).toEqual([
+ ['Transfer', 2],
+ ['Verteilen', 1],
+ ]);
+ });
+
+ it('keeps commands preceding the first comment', () => {
+ const steps = parseGwl('W;\nC;Transfer');
+
+ expect(steps[0]?.comment).toBeNull();
+ expect(steps[0]?.commands).toHaveLength(1);
+ });
+
+ it('numbers lines as they appear in the source, blank lines skipped', () => {
+ const steps = parseGwl('C;Transfer\n\nW;');
+
+ expect(steps[0]?.lineNumber).toBe(1);
+ expect(steps[0]?.commands[0]?.lineNumber).toBe(3);
+ });
+
+ it('keeps separators inside a comment', () => {
+ expect(parseGwl('C;Transfer;von;990 µl')[0]?.comment).toBe(
+ 'Transfer;von;990 µl',
+ );
+ });
+
+ it('marks volume and position of an aspirate command', () => {
+ const steps = parseGwl(
+ 'A;MM;;Eppis 32x1.5 ml Cooled;1;;198;Dilution_Run_No_Mix;;1',
+ );
+
+ expect(steps[0]?.commands[0]?.fields).toEqual([
+ { role: 'command', text: 'A' },
+ { role: 'plain', text: 'MM' },
+ { role: 'plain', text: '' },
+ { role: 'plain', text: 'Eppis 32x1.5 ml Cooled' },
+ { role: 'position', text: '1' },
+ { role: 'tubeID', text: '' },
+ { role: 'volume', text: '198' },
+ { role: 'plain', text: 'Dilution_Run_No_Mix' },
+ { role: 'plain', text: '' },
+ { role: 'plain', text: '1' },
+ ]);
+ });
+
+ it('marks source and target positions of a reagent distribution', () => {
+ const steps = parseGwl(
+ 'R;MM;;Eppis 32x1.5 ml Cooled;32;32;FluidX;;96FluidX;1;4;125;Dilution_Run_No_Mix;6;1;0;',
+ );
+ const fields = steps[0]?.commands[0]?.fields;
+
+ expect(fields?.[4]).toEqual({ role: 'position', text: '32' });
+ expect(fields?.[5]).toEqual({ role: 'position', text: '32' });
+ expect(fields?.[9]).toEqual({ role: 'position', text: '1' });
+ expect(fields?.[10]).toEqual({ role: 'position', text: '4' });
+ expect(fields?.[11]).toEqual({ role: 'volume', text: '125' });
+ });
+
+ it('marks the barcode of an aspirate without a position', () => {
+ const steps = parseGwl('A;FluidX;;96FluidX;;SA00012345;198;;;1');
+ const fields = steps[0]?.commands[0]?.fields;
+
+ expect(fields?.[4]).toEqual({ role: 'position', text: '' });
+ expect(fields?.[5]).toEqual({ role: 'tubeID', text: 'SA00012345' });
+ });
+
+ it('leaves fields plain when a command carries more of them than it serializes', () => {
+ const steps = parseGwl('A;MM;;Eppis 32x1.5 ml; Cooled;1;;198;lc;;1');
+ const fields = steps[0]?.commands[0]?.fields ?? [];
+
+ expect(fields.filter((field) => field.role !== 'plain')).toEqual([
+ { role: 'command', text: 'A' },
+ ]);
+ });
+
+ it('leaves fields of an unknown command plain', () => {
+ const steps = parseGwl('X;21');
+
+ expect(steps[0]?.commands[0]?.fields).toEqual([
+ { role: 'command', text: 'X' },
+ { role: 'plain', text: '21' },
+ ]);
+ });
+
+ it('strips the carriage return MLL\\Utils\\Tecan writes', () => {
+ const steps = parseGwl('C;Transfer\r\nS;21\r\n');
+
+ expect(steps[0]?.comment).toBe('Transfer');
+ expect(steps[0]?.commands[0]?.fields[1]).toEqual({
+ role: 'plain',
+ text: '21',
+ });
+ });
+
+ // Fails the day the expected field counts drift from what MLL\Utils\Tecan
+ // writes, which the fallback to plain fields would otherwise hide.
+ it('highlights a volume in every pipetting command of a worklist', () => {
+ const unhighlighted = parseGwl(DILUTION_RUN_WORKLIST)
+ .flatMap((step) => step.commands)
+ .filter((command) =>
+ ['A', 'D', 'R'].includes(command.fields[0]?.text ?? ''),
+ )
+ .filter(
+ (command) => !command.fields.some((field) => field.role === 'volume'),
+ )
+ .map((command) => command.lineNumber);
+
+ expect(unhighlighted).toEqual([]);
+ });
+});
diff --git a/src/TecanWorklist/parseGwl.ts b/src/TecanWorklist/parseGwl.ts
new file mode 100644
index 00000000..7492f128
--- /dev/null
+++ b/src/TecanWorklist/parseGwl.ts
@@ -0,0 +1,149 @@
+import { Maybe } from '@mll-lab/js-utils';
+
+export type GwlFieldRole =
+ | 'command'
+ | 'plain'
+ | 'position'
+ | 'tubeID'
+ | 'volume';
+
+export type GwlField = {
+ role: GwlFieldRole;
+ text: string;
+};
+
+type GwlCommandLine = {
+ lineNumber: number;
+ fields: Array;
+};
+
+/** `comment` is null only for commands preceding the first comment. */
+export type GwlStep = {
+ lineNumber: number;
+ comment: Maybe;
+ commands: Array;
+};
+
+const FIELD_SEPARATOR = ';';
+
+export const COMMAND = {
+ ASPIRATE: 'A',
+ COMMENT: 'C',
+ DISPENSE: 'D',
+ REAGENT_DISTRIBUTION: 'R',
+} as const;
+
+const COMMENT_PREFIX = `${COMMAND.COMMENT}${FIELD_SEPARATOR}`;
+
+/** All field indexes below mirror the serialization in MLL\Utils\Tecan\BasicCommands. */
+const VOLUME_FIELD: Record = {
+ [COMMAND.ASPIRATE]: 6,
+ [COMMAND.DISPENSE]: 6,
+ [COMMAND.REAGENT_DISTRIBUTION]: 11,
+};
+
+const POSITION_FIELDS: Record> = {
+ [COMMAND.ASPIRATE]: [4],
+ [COMMAND.DISPENSE]: [4],
+ // source start and end, then target start and end
+ [COMMAND.REAGENT_DISTRIBUTION]: [4, 5, 9, 10],
+};
+
+/** A barcode location carries no position, the barcode identifies the tube. */
+const TUBE_ID_FIELD: Record = {
+ [COMMAND.ASPIRATE]: 5,
+ [COMMAND.DISPENSE]: 5,
+};
+
+const SERIALIZES_INTO_FIELD_COUNT: Record boolean> =
+ {
+ [COMMAND.ASPIRATE]: (count) => count === 10,
+ [COMMAND.DISPENSE]: (count) => count === 10,
+ // excluded target wells are appended
+ [COMMAND.REAGENT_DISTRIBUTION]: (count) => count >= 16,
+ };
+
+function fieldRole({
+ commandLetter,
+ index,
+ fieldCount,
+}: {
+ commandLetter: string;
+ index: number;
+ fieldCount: number;
+}): GwlFieldRole {
+ if (index === 0) {
+ return 'command';
+ }
+
+ // A line of an unexpected shape gets no highlighting rather than a wrong one.
+ const serializesIntoFieldCount = SERIALIZES_INTO_FIELD_COUNT[commandLetter];
+ if (serializesIntoFieldCount && !serializesIntoFieldCount(fieldCount)) {
+ return 'plain';
+ }
+
+ if (index === VOLUME_FIELD[commandLetter]) {
+ return 'volume';
+ }
+
+ if (index === TUBE_ID_FIELD[commandLetter]) {
+ return 'tubeID';
+ }
+
+ if (POSITION_FIELDS[commandLetter]?.includes(index)) {
+ return 'position';
+ }
+
+ return 'plain';
+}
+
+function parseCommand(line: string, lineNumber: number): GwlCommandLine {
+ const fieldTexts = line.split(FIELD_SEPARATOR);
+ const commandLetter = fieldTexts[0] ?? '';
+
+ return {
+ lineNumber,
+ fields: fieldTexts.map((text, index) => ({
+ text,
+ role: fieldRole({ commandLetter, index, fieldCount: fieldTexts.length }),
+ })),
+ };
+}
+
+/**
+ * A worklist documents itself: each `C;` comment describes what the commands
+ * following it do, which makes the comment a step and the commands its detail.
+ */
+export function parseGwl(gwl: string): Array {
+ const steps: Array = [];
+
+ // MLL\Utils\Tecan writes CRLF, so a lone \n split would leave \r in the last field.
+ gwl.split(/\r?\n/).forEach((line, index) => {
+ const lineNumber = index + 1;
+
+ if (line.trim() === '') {
+ return;
+ }
+
+ if (line.startsWith(COMMENT_PREFIX)) {
+ steps.push({
+ lineNumber,
+ comment: line.slice(COMMENT_PREFIX.length),
+ commands: [],
+ });
+
+ return;
+ }
+
+ const openStep = steps.at(-1);
+ const command = parseCommand(line, lineNumber);
+
+ if (openStep) {
+ openStep.commands.push(command);
+ } else {
+ steps.push({ lineNumber, comment: null, commands: [command] });
+ }
+ });
+
+ return steps;
+}
diff --git a/src/index.ts b/src/index.ts
index e69690bb..b1c09e40 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -56,6 +56,7 @@ export * from './Table';
export * from './Tabs';
export * from './Tag';
export * from './TecanDeckView';
+export * from './TecanWorklist';
export * from './ThermoCyclerProtocol';
export * from './Timeline';
export * from './Tooltip';