diff --git a/.changeset/free-pets-turn.md b/.changeset/free-pets-turn.md new file mode 100644 index 0000000..d302015 --- /dev/null +++ b/.changeset/free-pets-turn.md @@ -0,0 +1,7 @@ +--- +'@platforma-open/milaboratories.samples-and-data.workflow': minor +'@platforma-open/milaboratories.samples-and-data.model': minor +'@platforma-open/milaboratories.samples-and-data.ui': minor +--- + +Added support for mtx files diff --git a/model/src/args.ts b/model/src/args.ts index ea68b96..229826b 100644 --- a/model/src/args.ts +++ b/model/src/args.ts @@ -166,13 +166,26 @@ export const DatasetContentTaggedXsv = z .strict(); export type DatasetContentTaggedXsv = z.infer; +export const DatasetContentMtx = z + .object({ + type: z.literal('Mtx'), + gzipped: z.boolean(), + data: z.record( + PlId, + ImportFileHandleSchema.nullable() /* null means sample is added to the dataset, but file is not yet set */ + ) + }) + .strict(); +export type DatasetContentMtx = z.infer; + export const DatasetContent = z.discriminatedUnion('type', [ DatasetContentFastq, DatasetContentMultilaneFastq, DatasetContentTaggedFastq, DatasetContentFasta, DatasetContentXsv, - DatasetContentTaggedXsv + DatasetContentTaggedXsv, + DatasetContentMtx ]); export type DatasetContent = z.infer; @@ -197,6 +210,8 @@ export const DatasetXsv = Dataset(DatasetContentXsv); export type DatasetXsv = z.infer; export const DatasetTaggedXsv = Dataset(DatasetContentTaggedXsv); export type DatasetTaggedXsv = z.infer; +export const DatasetMtx = Dataset(DatasetContentMtx); +export type DatasetMtx = z.infer; export type DatasetAny = z.infer; export type DatasetType = DatasetAny['content']['type']; diff --git a/ui/src/DatasetPage.vue b/ui/src/DatasetPage.vue index 0146b47..2c7eed9 100644 --- a/ui/src/DatasetPage.vue +++ b/ui/src/DatasetPage.vue @@ -20,6 +20,7 @@ import FastaDatasetPage from './FastaDatasetPage.vue'; import FastqDatasetPage from './FastqDatasetPage.vue'; import { argsModel } from './lens'; import MultilaneFastqDatasetPage from './MultilaneFastqDatasetPage.vue'; +import MtxDatasetPage from './MtxDatasetPage.vue'; import TaggedFastqDatasetPage from './TaggedFastqDatasetPage.vue'; import TaggedXsvDatasetPage from './TaggedXsvDatasetPage.vue'; import { UpdateDatasetDialog } from './UpdateDatasetDialog'; @@ -51,14 +52,14 @@ const readIndicesOptions: SimpleOption[] = [ const currentReadIndices = computed(() => JSON.stringify( - (dataset.value.content.type === 'Fasta' || dataset.value.content.type === 'TaggedXsv' || dataset.value.content.type === 'Xsv') ? undefined : dataset.value.content.readIndices + (dataset.value.content.type === 'Fasta' || dataset.value.content.type === 'TaggedXsv' || dataset.value.content.type === 'Xsv' || dataset.value.content.type === 'Mtx') ? undefined : dataset.value.content.readIndices ) ); function setReadIndices(newIndices: string) { const indicesArray = ReadIndices.parse(JSON.parse(newIndices)); dataset.update((ds) => { - if (ds.content.type !== 'Fasta' && ds.content.type !== 'TaggedXsv' && ds.content.type !== 'Xsv') ds.content.readIndices = indicesArray; + if (ds.content.type !== 'Fasta' && ds.content.type !== 'TaggedXsv' && ds.content.type !== 'Xsv' && ds.content.type !== 'Mtx') ds.content.readIndices = indicesArray; else throw new Error("Can't set read indices for fasta dataset."); }); } @@ -79,6 +80,7 @@ const datasetTypeOptions: ListOption[] = [ { value: 'TaggedFastq', label: "Tagged FASTQ" }, { value: 'Xsv', label: "XSV" }, { value: 'TaggedXsv', label: "Tagged XSV" }, + { value: 'Mtx', label: "MTX" }, ] @@ -113,6 +115,9 @@ const datasetTypeOptions: ListOption[] = [ + @@ -124,7 +129,7 @@ const datasetTypeOptions: ListOption[] = [ @update:model-value="(v) => dataset.update((ds) => (ds.content.gzipped = v))"> Gzipped - diff --git a/ui/src/ImportDatasetDialog/ImportDatasetDialog.vue b/ui/src/ImportDatasetDialog/ImportDatasetDialog.vue index f8c156a..83e034b 100644 --- a/ui/src/ImportDatasetDialog/ImportDatasetDialog.vue +++ b/ui/src/ImportDatasetDialog/ImportDatasetDialog.vue @@ -3,6 +3,7 @@ import { BlockArgs, DatasetContentFasta, DatasetContentFastq, + DatasetContentMtx, DatasetContentMultilaneFastq, DatasetContentTaggedFastq, DatasetContentTaggedXsv, @@ -126,6 +127,20 @@ function addFastaDatasetContent(args: BlockArgs, contentData: DatasetContentFast } } +function addMtxDatasetContent(args: BlockArgs, contentData: DatasetContentMtx['data']) { + const getOrCreateSample = createGetOrCreateSample(args); + + if (compiledPattern.value?.hasLaneMatcher || compiledPattern.value?.hasReadIndexMatcher) + throw new Error('Dataset has read or lane matcher, trying to add mtx dataset'); + + for (const f of parsedFiles.value) { + if (!f.match) continue; + const sample = f.match.sample.value; + const sampleId = getOrCreateSample(sample); + contentData[sampleId] = f.handle; + } +} + function addFastqDatasetContent(args: BlockArgs, contentData: DatasetContentFastq['data']) { const getOrCreateSample = createGetOrCreateSample(args); @@ -281,6 +296,7 @@ async function addToExistingDataset() { addMultilaneFastqDatasetContent(args, dataset.content.data); else if (dataset.content.type === 'Xsv') addXsvDatasetContent(args, dataset.content.data); else if (dataset.content.type === 'TaggedXsv') addTaggedXsvDatasetContent(args, dataset.content.data); + else if (dataset.content.type === 'Mtx') addMtxDatasetContent(args, dataset.content.data); else throw new Error('Unknown dataset type'); }); await app.navigateTo(`/dataset?id=${datasetId}`); @@ -291,6 +307,10 @@ const isXsv = () => { return data.files.map(f => getFileNameFromHandle(f)).every(f => f.endsWith('.csv') || f.endsWith('.tsv')) } +const isMtx = () => { + return data.files.map(f => getFileNameFromHandle(f)).every(f => f.endsWith('.mtx') || f.endsWith('.mtx.gz')) +} + const xsvType = (): 'csv' | 'tsv' => { const fileNames = data.files.map(f => getFileNameFromHandle(f)); if (fileNames.every(f => f.endsWith('.csv') || f.endsWith('.csv.gz'))) return 'csv'; @@ -330,6 +350,20 @@ async function createNewDataset() { } }); } + else if (isMtx()) { + const contentData: DatasetContentMtx['data'] = {}; + addMtxDatasetContent(args, contentData); + + args.datasets.push({ + label: data.newDatasetLabel, + id: newDatasetId, + content: { + type: 'Mtx', + gzipped: data.gzipped, + data: contentData + } + }); + } else if (data.readIndices.length === 0 /* fasta */) { const contentData: DatasetContentFasta['data'] = {}; addFastaDatasetContent(args, contentData); @@ -398,8 +432,10 @@ const canCreateOrAdd = computed( () => hasMatchedFiles.value && (data.mode === 'create-new-dataset' || data.targetAddDataset !== undefined) && - // This prevents selecting fasta as type while having read index matcher in pattern + // This prevents selecting fasta/mtx/xsv as type while having read index matcher in pattern (data.readIndices.length !== 0 || + isMtx() || + isXsv() || (compiledPattern.value?.hasReadIndexMatcher === false && compiledPattern.value?.hasLaneMatcher === false)) ); diff --git a/ui/src/MtxDatasetPage.vue b/ui/src/MtxDatasetPage.vue new file mode 100644 index 0000000..25542f7 --- /dev/null +++ b/ui/src/MtxDatasetPage.vue @@ -0,0 +1,195 @@ + + + + diff --git a/ui/src/datasets.ts b/ui/src/datasets.ts index 90652f4..cff4ab6 100644 --- a/ui/src/datasets.ts +++ b/ui/src/datasets.ts @@ -49,6 +49,9 @@ export function getDsReadIndices(ds: DatasetAny): string[] { case 'Xsv': case 'TaggedXsv': case 'Fasta': + case 'Mtx': + return []; + default: return []; } } diff --git a/ui/src/file_name_parser.ts b/ui/src/file_name_parser.ts index e2c164f..dd7c619 100644 --- a/ui/src/file_name_parser.ts +++ b/ui/src/file_name_parser.ts @@ -341,6 +341,18 @@ const wellKnownPattern: WellKnownPattern[] = [ defaultReadIndices: [], extensions: ['csv', 'tsv', 'csv.gz', 'tsv.gz'], minimalPercent: 0.99 + }, + { + patternWithoutExtension: '{{Sample}}_matrix', + defaultReadIndices: [], + extensions: ['mtx', 'mtx.gz'], + minimalPercent: 0.99 + }, + { + patternWithoutExtension: '{{Sample}}', + defaultReadIndices: [], + extensions: ['mtx', 'mtx.gz'], + minimalPercent: 0.99 } ]; diff --git a/workflow/src/main.tpl.tengo b/workflow/src/main.tpl.tengo index a99c7cc..a94f892 100644 --- a/workflow/src/main.tpl.tengo +++ b/workflow/src/main.tpl.tengo @@ -162,6 +162,33 @@ wf.body(func(args) { continue } + if dataset.content.type == "Mtx" { + columnSpec := maps.deepMerge(columnSpecParent, { + domain: { + "pl7.app/fileExtension": dataset.content.gzipped ? "mtx.gz" : "mtx" + }, + axesSpec: [sampleIdAxisSpec] + }) + + data := smart.structBuilder(_P_COLUMN_DATA_RESOURCE_MAP, json.encode({ + keyLength: 1 + })) + + for sampleId, importHandle in dataset.content.data { + if !importHandle { + ll.panic("File handle not set for sample %v", sampleId) + } + data.createInputField(json.encode([sampleId])).set(importFile(importHandle)) + } + + exports[exportKey] = { + spec: columnSpec, + data: data.lockAndBuild() + } + + continue + } + if dataset.content.type == "Xsv" { columnSpec := maps.deepMerge(columnSpecParent, { domain: {