Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/remote-tsv-file-import.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@platforma-open/milaboratories.immune-assay-data.workflow': minor
'@platforma-open/milaboratories.immune-assay-data.model': minor
'@platforma-open/milaboratories.immune-assay-data.ui': minor
'@platforma-open/milaboratories.immune-assay-data': minor
---

Fix assay file import to support remote (non-local) files

Previously, column detection used `lsDriver.getLocalFileContent()` which only works for locally-mounted files. Files from remote storages would silently fail, leaving the block unconfigurable.

Now uses a prerun workflow step to import the file and expose it as a blob, and `ReactiveFileContent` in the UI to read it via `blobDriver` — the same pattern used by samples-and-data. This works for both local and remote files.
5 changes: 5 additions & 0 deletions model/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ export const model = BlockModel.create()
});
})

.retentiveOutput(
'assayFileHandle',
(ctx) => ctx.prerun?.resolveAny({ field: 'assayFile' })?.getFileHandle(),
)
Comment thread
PaulNewling marked this conversation as resolved.

.output(
'dataImportHandle',
(ctx) => ctx.outputs?.resolve('dataImportHandle')?.getImportProgress(),
Expand Down
210 changes: 105 additions & 105 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,16 @@ catalog:
# SDK packages - EXACT VERSIONS (no ^ or ~)
'@milaboratories/ts-builder': 1.3.0
'@milaboratories/ts-configs': 1.2.2
'@platforma-sdk/workflow-tengo': 5.9.1
'@platforma-sdk/model': 1.58.22
'@platforma-sdk/ui-vue': 1.58.25
'@platforma-sdk/tengo-builder': 2.4.28
'@platforma-sdk/workflow-tengo': 5.10.1
'@platforma-sdk/model': 1.59.3
'@platforma-sdk/ui-vue': 1.59.4
'@platforma-sdk/tengo-builder': 2.4.30
'@platforma-sdk/package-builder': 3.11.6
'@platforma-sdk/block-tools': 2.6.68
'@platforma-sdk/block-tools': 2.6.70
'@platforma-sdk/eslint-config': 1.2.0
'@platforma-sdk/test': 1.58.24
'@platforma-sdk/test': 1.59.5
'@milaboratories/helpers': 1.13.7
'@milaboratories/graph-maker': 1.2.3
'@milaboratories/graph-maker': 1.2.4
'@milaboratories/multi-sequence-alignment': 1.47.3
'@milaboratories/strings': 0.1.2
"@platforma-sdk/blocks-deps-updater": ^2.1.0
Expand Down
27 changes: 0 additions & 27 deletions ui/src/fastaParser.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import type { LocalImportFileHandle } from '@platforma-sdk/model';
import { getRawPlatformaInstance } from '@platforma-sdk/model';

export interface FastaRecord {
header: string;
sequence: string;
Expand Down Expand Up @@ -124,27 +121,3 @@ export function fastaToTable(records: FastaRecord[]): string {
// Combine header and data
return [headerRow, ...dataRows].join('\n');
}

/**
* Process FASTA file and convert to table format
*/

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function combined file I/O (lsDriver.getLocalFileContent) with FASTA parsing in one place, which made it impossible to reuse for remote files. The logic has been split:

  • I/O moved into setFile in MainPage.vue: local files (upload://) are read immediately via lsDriver.getLocalFileContent(); remote files (index://) arrive later as bytes from the prerun blob via ReactiveFileContent.
  • Parsing (parseFastaContentfastaToTable → split into rawData) is now inlined in processFileBytes in importFile.ts, which receives Uint8Array bytes regardless of where they came from.

parseFastaContent and fastaToTable remain in this file unchanged.

export async function processFastaFile(file: LocalImportFileHandle): Promise<{ content: string; error?: string }> {
try {
const rawContent = await getRawPlatformaInstance().lsDriver.getLocalFileContent(file);
const content = new TextDecoder().decode(rawContent);

const parseResult = parseFastaContent(content);

if (parseResult.error) {
return { content: '', error: parseResult.error };
}

const tableContent = fastaToTable(parseResult.records);
return { content: tableContent };
} catch (error) {
return {
content: '',
error: `Failed to read FASTA file: ${error instanceof Error ? error.message : 'Unknown error'}`,
};
}
}
33 changes: 14 additions & 19 deletions ui/src/importFile.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { getFileNameFromHandle, getRawPlatformaInstance, type LocalImportFileHandle } from '@platforma-sdk/model';

import { useApp } from './app';

import type { ImportColumnInfo } from '@platforma-open/milaboratories.immune-assay-data.model';
import * as XLSX from 'xlsx';
import { processFastaFile } from './fastaParser';
import { parseFastaContent, fastaToTable } from './fastaParser';

// Define a more specific type for raw Excel data
type TableRow = string[];
Expand Down Expand Up @@ -136,38 +134,35 @@ function inferSequenceType(values: unknown[]): 'nucleotide' | 'aminoacid' | unde
}
}

export async function importFile(file: LocalImportFileHandle) {
/**
* Process raw file bytes to detect columns and update block args.
* Called reactively once the file bytes are available via ReactiveFileContent.
*/
export function processFileBytes(bytes: Uint8Array, extension: string | undefined): void {
const app = useApp();
Comment thread
PaulNewling marked this conversation as resolved.

app.model.args.fileHandle = file;

// clear state
app.model.args.importColumns = undefined;
app.model.ui.fileImportError = undefined;
const fileName = getFileNameFromHandle(file);
const extension = fileName.split('.').pop()?.toLowerCase();
app.model.args.fileExtension = extension;
app.model.args.detectedXsvType = undefined;

let rawData: TableData;

// Handle FASTA files
if (extension === 'fasta' || extension === 'fa') {
const fastaResult = await processFastaFile(file);
const content = new TextDecoder().decode(bytes);
const parseResult = parseFastaContent(content);

if (fastaResult.error) {
app.model.ui.fileImportError = fastaResult.error;
if (parseResult.error) {
app.model.ui.fileImportError = parseResult.error;
return;
}

// Convert tab-delimited string to table data
const lines = fastaResult.content.split('\n');
const tableContent = fastaToTable(parseResult.records);
const lines = tableContent.split('\n');
rawData = lines.map((line) => line.split('\t'));
} else {
// Handle Excel/CSV files as before
const data = await getRawPlatformaInstance().lsDriver.getLocalFileContent(file);

const wb = XLSX.read(data);
const wb = XLSX.read(bytes);

// @TODO: allow user to select worksheet
const worksheet = wb.Sheets[wb.SheetNames[0]];
Expand All @@ -182,7 +177,7 @@ export async function importFile(file: LocalImportFileHandle) {
// XLSX auto-detects internally via guess_sep but doesn't expose the result,
// so we check the first line of the already-in-memory data buffer.
if (extension === 'csv' || extension === 'tsv') {
const firstLine = new TextDecoder().decode(new Uint8Array(data).slice(0, 4096)).split('\n')[0] ?? '';
const firstLine = new TextDecoder().decode(bytes.slice(0, 4096)).split('\n')[0] ?? '';
app.model.args.detectedXsvType = firstLine.includes('\t') ? 'tsv' : 'csv';
}
}
Expand Down
76 changes: 57 additions & 19 deletions ui/src/pages/MainPage.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import type {
PTableKey,
} from '@platforma-sdk/model';
import {
getFileNameFromHandle,
getRawPlatformaInstance,
isImportFileHandleUpload,
} from '@platforma-sdk/model';
import {
PlAgDataTableV2,
Expand All @@ -27,6 +29,7 @@ import {
PlSectionSeparator,
PlSlideModal,
PlTooltip,
ReactiveFileContent,
usePlDataTableSettingsV2,
} from '@platforma-sdk/ui-vue';
import strings from '@milaboratories/strings';
Expand All @@ -41,13 +44,14 @@ import {
useApp,
} from '../app';

import { importFile } from '../importFile';
import { processFileBytes } from '../importFile';
import {
isAssayColumn,
isSequenceColumn,
} from '../util';

const app = useApp();
const reactiveFileContent = ReactiveFileContent.useGlobal();

function setDataset(ref: PlRef | undefined) {
app.model.args.datasetRef = ref;
Expand Down Expand Up @@ -112,38 +116,70 @@ const onRowDoubleClicked = reactive((key?: PTableKey) => {
multipleSequenceAlignmentClonotypesOpen.value = true;
});

// Reactive file bytes — available once the prerun imports the file (works for local + remote)
const assayFileBytes = computed(() => {
Comment thread
PaulNewling marked this conversation as resolved.
const handle = app.model.outputs.assayFileHandle;
if (!handle) return undefined;
return reactiveFileContent.getContentBytes(handle.handle).value;
});

// For remote files: detect columns once the prerun has imported the file and bytes arrive.
// Guarded so it doesn't re-run if a local file already processed bytes synchronously.
watch(assayFileBytes, (bytes) => {
Comment thread
PaulNewling marked this conversation as resolved.
if (!bytes || !app.model.args.fileHandle) return;
if (app.model.args.importColumns !== undefined) return;
processFileBytes(bytes, app.model.args.fileExtension);
});

const setFile = async (file: ImportFileHandle | undefined) => {
app.model.args.importColumns = undefined;
app.model.args.sequenceColumnHeader = undefined;
app.model.args.selectedColumns = [];
app.model.args.detectedXsvType = undefined;
app.model.ui.fileImportError = undefined;

if (!file) {
app.model.args.fileHandle = undefined;
app.model.args.fileExtension = undefined;
return;
}
importFile(file as LocalImportFileHandle);
};

// Watch for when the file is removed to reset dependent fields
watch(
() => app.model.args.fileHandle,
(newFileHandle) => {
if (!newFileHandle) {
app.model.args.sequenceColumnHeader = undefined;
app.model.args.selectedColumns = [];
const fileName = getFileNameFromHandle(file);
const extension = fileName.split('.').pop()?.toLowerCase();
app.model.args.fileExtension = extension;
// Setting fileHandle triggers the prerun (needed for remote files and the workflow)
app.model.args.fileHandle = file;

// For local (upload://) files: process bytes immediately from disk — no prerun round-trip needed.
// Remote (index://) files fall through to the assayFileBytes watch above.
if (isImportFileHandleUpload(file)) {
Comment thread
PaulNewling marked this conversation as resolved.
try {
const data = await getRawPlatformaInstance().lsDriver.getLocalFileContent(file as LocalImportFileHandle);
processFileBytes(data, extension);
} catch (e) {
console.error('Failed to read local file content:', e);
}
},
);
}
};

// Watch for when the user selects a sequence column to validate it
// Watch for when the user selects a sequence column to validate uniqueness
watch(
() => app.model.args.sequenceColumnHeader,
async (newHeader) => {
(newHeader) => {
if (!newHeader || !app.model.args.fileHandle) {
app.model.ui.fileImportError = undefined;
return;
}

// Skip uniqueness check for FASTA — bytes are FASTA-encoded, not XLSX-parseable
const ext = app.model.args.fileExtension;
if (ext === 'fasta' || ext === 'fa') return;

const bytes = assayFileBytes.value;
if (!bytes) return;

try {
const data = await getRawPlatformaInstance().lsDriver.getLocalFileContent(
app.model.args.fileHandle as LocalImportFileHandle,
);
const wb = XLSX.read(data);
const wb = XLSX.read(bytes);
const worksheet = wb.Sheets[wb.SheetNames[0]];
const rawData = XLSX.utils.sheet_to_json(worksheet, { header: 1, raw: true, blankrows: false }) as string[][];

Expand All @@ -161,12 +197,13 @@ watch(
}
} catch (e) {
console.error('Failed to validate sequence uniqueness:', e);
app.model.ui.fileImportError = 'Could not read file to validate sequence uniqueness.';
app.model.ui.fileImportError = 'Could not validate sequence uniqueness.';
}
},
);

const sequenceColumnOptions = computed(() => {
if (!app.model.args.fileHandle) return [];
Comment thread
PaulNewling marked this conversation as resolved.
return app.model.args.importColumns
?.filter((c) => c.sequenceType !== undefined)
?.map((c) => ({
Expand All @@ -176,6 +213,7 @@ const sequenceColumnOptions = computed(() => {
});

const otherColumnOptions = computed(() => {
if (!app.model.args.fileHandle) return [];
return app.model.args.importColumns
?.filter((c) => c.header !== app.model.args.sequenceColumnHeader)
?.map((c) => ({
Expand Down
3 changes: 3 additions & 0 deletions workflow/src/main.tpl.tengo
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ text := import("text")
render := import("@platforma-sdk/workflow-tengo:render")
strings := import("@platforma-sdk/workflow-tengo:strings")

prerunTpl := assets.importTemplate(":prerun")
analysisTpl := assets.importTemplate(":analysis")
processOutputsTpl := assets.importTemplate(":process-outputs")
checkContentEmptyTpl := assets.importTemplate(":check-content-empty")

wf.setPreRun(prerunTpl)
Comment thread
PaulNewling marked this conversation as resolved.

prepareFastaSw := assets.importSoftware("@platforma-open/milaboratories.immune-assay-data.prepare-fasta:main")
checkContentEmptySw := assets.importSoftware("@platforma-open/milaboratories.immune-assay-data.check-content-empty:main")

Expand Down
17 changes: 17 additions & 0 deletions workflow/src/prerun.tpl.tengo
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
wf := import("@platforma-sdk/workflow-tengo:workflow")
file := import("@platforma-sdk/workflow-tengo:file")

wf.body(func(args) {
if is_undefined(args.fileHandle) {
return { outputs: {}, exports: {} }
}

importedFile := file.importFile(args.fileHandle)
Comment thread
PaulNewling marked this conversation as resolved.

return {
outputs: {
assayFile: file.exportFile(importedFile.file)
},
exports: {}
}
})
Loading