@morscherlab/mint-sdk 1.3.0-beta.9 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/dist/__tests__/components/AutoGroupModal.anatomy.test.d.ts +1 -0
  2. package/dist/__tests__/components/AutoGroupModal.wiring.test.d.ts +1 -0
  3. package/dist/__tests__/composables/autoGroup/fieldEdits.test.d.ts +1 -0
  4. package/dist/__tests__/composables/autoGroup/golden.test.d.ts +1 -0
  5. package/dist/components/AutoGroupModal.adapter.d.ts +27 -4
  6. package/dist/components/SmartGroup.types.d.ts +22 -0
  7. package/dist/components/SmartGroupFieldRecipe.groups.d.ts +10 -2
  8. package/dist/components/SmartGroupFieldRecipe.vue.d.ts +3 -0
  9. package/dist/components/index.js +1 -1
  10. package/dist/{components-GDDvof8D.js → components-bbanLd1u.js} +510 -287
  11. package/dist/components-bbanLd1u.js.map +1 -0
  12. package/dist/composables/autoGroup/classify.d.ts +7 -1
  13. package/dist/composables/autoGroup/columns.d.ts +16 -1
  14. package/dist/composables/autoGroup/index.d.ts +5 -7
  15. package/dist/composables/autoGroup/tokenize.d.ts +36 -1
  16. package/dist/composables/index.js +2 -2
  17. package/dist/composables/useAutoGroup.d.ts +13 -2
  18. package/dist/{composables-D6uqp0UF.js → composables-BbHozlA7.js} +2 -2
  19. package/dist/{composables-D6uqp0UF.js.map → composables-BbHozlA7.js.map} +1 -1
  20. package/dist/index.js +3 -3
  21. package/dist/install.js +1 -1
  22. package/dist/permissions.js +2 -2
  23. package/dist/permissions.js.map +1 -1
  24. package/dist/styles.css +129 -96
  25. package/dist/types/auto-group.d.ts +1 -1
  26. package/dist/{validation-Dmp9Q5W0.js → validation-CukfLjy-.js} +323 -187
  27. package/dist/validation-CukfLjy-.js.map +1 -0
  28. package/package.json +2 -2
  29. package/src/__tests__/components/AppTopBarPageSelector.test.ts +4 -4
  30. package/src/__tests__/components/AutoGroupModal.anatomy.test.ts +86 -0
  31. package/src/__tests__/components/AutoGroupModal.wiring.test.ts +53 -0
  32. package/src/__tests__/components/SampleSelector.test.ts +14 -16
  33. package/src/__tests__/components/SmartGroupFieldRecipe.groups.test.ts +23 -0
  34. package/src/__tests__/components/SmartGroupFieldRecipe.test.ts +176 -0
  35. package/src/__tests__/composables/autoGroup/columns.test.ts +100 -11
  36. package/src/__tests__/composables/autoGroup/fieldEdits.test.ts +74 -0
  37. package/src/__tests__/composables/autoGroup/fingerprint.test.ts +9 -0
  38. package/src/__tests__/composables/autoGroup/golden.test.ts +187 -0
  39. package/src/__tests__/composables/autoGroup/groupByOrder.test.ts +7 -7
  40. package/src/__tests__/composables/autoGroup/tokenLength.test.ts +7 -0
  41. package/src/__tests__/composables/autoGroup/tokenize.test.ts +78 -1
  42. package/src/__tests__/composables/useAutoGroup.test.ts +2 -1
  43. package/src/__tests__/fixtures/auto-group/share-exp021.txt +103 -0
  44. package/src/__tests__/fixtures/auto-group/share-exp031.txt +39 -0
  45. package/src/__tests__/fixtures/auto-group/share-exp114.txt +123 -0
  46. package/src/__tests__/fixtures/auto-group/share-exp214.txt +63 -0
  47. package/src/__tests__/utils/permissions.test.ts +15 -0
  48. package/src/components/AutoGroupModal.adapter.ts +80 -9
  49. package/src/components/AutoGroupModal.vue +41 -8
  50. package/src/components/SmartGroup.types.ts +23 -0
  51. package/src/components/SmartGroupFieldRecipe.groups.ts +14 -2
  52. package/src/components/SmartGroupFieldRecipe.vue +289 -169
  53. package/src/components/internal/AppTopBarPageSelectorInternal.vue +3 -6
  54. package/src/composables/autoGroup/classify.ts +26 -7
  55. package/src/composables/autoGroup/columns.ts +144 -36
  56. package/src/composables/autoGroup/compose.ts +8 -10
  57. package/src/composables/autoGroup/fingerprint.ts +1 -1
  58. package/src/composables/autoGroup/index.ts +5 -12
  59. package/src/composables/autoGroup/tokenLength.ts +8 -0
  60. package/src/composables/autoGroup/tokenize.ts +148 -1
  61. package/src/composables/autoGroup/vocab.json +5 -11
  62. package/src/composables/useAutoGroup.ts +92 -79
  63. package/src/permissions.ts +3 -2
  64. package/src/styles/components/app-page-selector.css +58 -14
  65. package/src/styles/components/smart-group.css +71 -82
  66. package/src/types/auto-group.ts +1 -0
  67. package/dist/components-GDDvof8D.js.map +0 -1
  68. package/dist/composables/autoGroup/replicatePreGroup.d.ts +0 -36
  69. package/dist/validation-Dmp9Q5W0.js.map +0 -1
  70. package/src/composables/autoGroup/replicatePreGroup.ts +0 -124
@@ -3743,41 +3743,6 @@ function useRackEditor(initialRacks, options) {
3743
3743
  };
3744
3744
  }
3745
3745
  //#endregion
3746
- //#region src/composables/autoGroup/tokenize.ts
3747
- var DELIMITER_CANDIDATES = [
3748
- "_",
3749
- "-",
3750
- "."
3751
- ];
3752
- /** Pick the delimiter whose split produces the most consistent field count across `lines`. */
3753
- function pickPrimaryDelimiter(lines) {
3754
- if (lines.length === 0) return "_";
3755
- let best = "_";
3756
- let bestScore = -1;
3757
- for (const candidate of DELIMITER_CANDIDATES) {
3758
- const counts = lines.map((l) => l.split(candidate).length);
3759
- const frequency = /* @__PURE__ */ new Map();
3760
- for (const c of counts) frequency.set(c, (frequency.get(c) ?? 0) + 1);
3761
- let mode = 1;
3762
- let modeFreq = 0;
3763
- for (const [c, f] of frequency) if (f > modeFreq || f === modeFreq && c > mode) {
3764
- mode = c;
3765
- modeFreq = f;
3766
- }
3767
- const score = mode > 1 ? modeFreq / lines.length : 0;
3768
- if (score > bestScore) {
3769
- best = candidate;
3770
- bestScore = score;
3771
- }
3772
- }
3773
- return best;
3774
- }
3775
- /** Split a single sample name on `delimiter`. Empty input returns `['']` so callers
3776
- * always receive at least one token. */
3777
- function splitMulti(name, delimiter) {
3778
- return name.split(delimiter);
3779
- }
3780
- //#endregion
3781
3746
  //#region src/composables/autoGroup/classKey.ts
3782
3747
  function classKey(c) {
3783
3748
  const base = c.subKind ? `${c.kind}:${c.subKind}` : c.kind;
@@ -3792,22 +3757,20 @@ var vocab_default = {
3792
3757
  "matrixVocab": "Lowercase token → canonical display name. Tokens are normalised to lowercase before lookup, so adding 'tissues' handles 'Tissues' / 'TISSUES' / 'tissues' uniformly. Multiple aliases map to one canonical (e.g. cell + cells → Cells).",
3793
3758
  "ionizationModes": "Lowercase polarity tokens (pos / positive / neg / negative). Suffixed to the subKind so POS and NEG acquisitions get independent schemas.",
3794
3759
  "tissueParentTokens": "Tokens that flag the immediately-following token as the organ subKind (so `tissues / kidney` becomes Biological/Tissues, tagging both positions).",
3795
- "replicateStripPatterns": "Regex sources stripped from each sample name during the replicate pre-grouping pass. Samples whose stripped (base) names match are treated as replicates of one another and grouped together before tokenisation. Default set covers _T<n>, _B<n>, and _Rep<n> markers. The trailing injection / run-order number is intentionally NOT stripped here — it survives tokenisation and is surfaced as a 'run-order' column (named 'Injection #'), then excluded from the default group key so replicates still collapse."
3760
+ "replicateToken": "Regex source for a token that names a replicate: rep1, Rep2, replicate3, R1, T1 (technical replicate). A column whose values all match is a replicate column: it stays a visible field and is left out of the default group key. B<n> is not in the pattern: on the lab share B13 / S13 are label tokens."
3796
3761
  },
3797
3762
  patterns: {
3798
3763
  "blank": "^(blank|blk|solv?blank|matrix.?blank)$",
3799
- "iqc": "^iqc$",
3764
+ "iqc": "^(iqc|qc|pqc|pooled.?qc)$",
3800
3765
  "eqc": "^eqc$",
3801
- "standard": "^(standard|std\\d*|cal(?:ibrator)?\\d*|.+standard)$",
3766
+ "standard": "^(standards?|stds?\\d*|istd|cal(?:ibrator)?\\d*|.+standard)$",
3802
3767
  "spikeIn": "spike.?in",
3803
3768
  "stock": "^stock$"
3804
3769
  },
3805
3770
  standardAllow: [
3806
3771
  "ExtractionS",
3807
3772
  "IS",
3808
- "internal.standard",
3809
- "PQC",
3810
- "pooledQC"
3773
+ "internal.standard"
3811
3774
  ],
3812
3775
  matrixVocab: {
3813
3776
  "plasma": "Plasma",
@@ -3837,11 +3800,7 @@ var vocab_default = {
3837
3800
  "negative": "NEG"
3838
3801
  },
3839
3802
  tissueParentTokens: ["tissue", "tissues"],
3840
- replicateStripPatterns: [
3841
- "[_-]T\\d+(?=[_-]|$)",
3842
- "[_-]B\\d+(?=[_-]|$)",
3843
- "[_-](?:rep(?:licate)?)\\d+(?=[_-]|$)"
3844
- ]
3803
+ replicateToken: "^(?:rep(?:licate)?|r|t)\\d+$"
3845
3804
  };
3846
3805
  //#endregion
3847
3806
  //#region src/composables/autoGroup/classify.ts
@@ -3866,6 +3825,14 @@ function matrixCanonical(token, vocab) {
3866
3825
  function isTissueParentToken(token, vocab) {
3867
3826
  return vocab.tissueParentTokens.has(token.toLowerCase());
3868
3827
  }
3828
+ /** A token that names a QC-type class (blank, QC, standard, spike-in, stock). */
3829
+ function isClassMarker(token, vocab = classVocab) {
3830
+ return vocab.blankPattern.test(token) || vocab.iqcPattern.test(token) || vocab.eqcPattern.test(token) || vocab.standardPattern.test(token) || vocab.standardAllow.has(token) || vocab.spikeInPattern.test(token) || vocab.stockPattern.test(token);
3831
+ }
3832
+ /** Kinds that form experimental groups; all other kinds are QC-type overlay classes. */
3833
+ function isExperimentalKind(kind) {
3834
+ return kind === "biological" || kind === "unknown";
3835
+ }
3869
3836
  var DEFAULT_DISPOSITION = {
3870
3837
  biological: "group",
3871
3838
  iqc: "overlay",
@@ -3876,28 +3843,28 @@ var DEFAULT_DISPOSITION = {
3876
3843
  blank: "overlay",
3877
3844
  unknown: "group"
3878
3845
  };
3879
- function classifyTokens(tokens, vocab) {
3880
- for (let i = 0; i < tokens.length; i++) if (vocab.blankPattern.test(tokens[i])) return {
3846
+ function classifyTokens(tokens, vocab, qcFrom) {
3847
+ for (let i = qcFrom; i < tokens.length; i++) if (vocab.blankPattern.test(tokens[i])) return {
3881
3848
  kind: "blank",
3882
3849
  tagPositions: [i]
3883
3850
  };
3884
- for (let i = 0; i < tokens.length; i++) if (vocab.iqcPattern.test(tokens[i])) return {
3851
+ for (let i = qcFrom; i < tokens.length; i++) if (vocab.iqcPattern.test(tokens[i])) return {
3885
3852
  kind: "iqc",
3886
3853
  tagPositions: [i]
3887
3854
  };
3888
- for (let i = 0; i < tokens.length; i++) if (vocab.eqcPattern.test(tokens[i])) return {
3855
+ for (let i = qcFrom; i < tokens.length; i++) if (vocab.eqcPattern.test(tokens[i])) return {
3889
3856
  kind: "eqc",
3890
3857
  tagPositions: [i]
3891
3858
  };
3892
- for (let i = 0; i < tokens.length; i++) if (vocab.standardPattern.test(tokens[i]) || vocab.standardAllow.has(tokens[i])) return {
3859
+ for (let i = qcFrom; i < tokens.length; i++) if (vocab.standardPattern.test(tokens[i]) || vocab.standardAllow.has(tokens[i])) return {
3893
3860
  kind: "standard",
3894
3861
  tagPositions: [i]
3895
3862
  };
3896
- for (let i = 0; i < tokens.length; i++) if (vocab.spikeInPattern.test(tokens[i])) return {
3863
+ for (let i = qcFrom; i < tokens.length; i++) if (vocab.spikeInPattern.test(tokens[i])) return {
3897
3864
  kind: "spike-in",
3898
3865
  tagPositions: [i]
3899
3866
  };
3900
- for (let i = 0; i < tokens.length; i++) if (vocab.stockPattern.test(tokens[i])) return {
3867
+ for (let i = qcFrom; i < tokens.length; i++) if (vocab.stockPattern.test(tokens[i])) return {
3901
3868
  kind: "stock",
3902
3869
  tagPositions: [i]
3903
3870
  };
@@ -3982,7 +3949,7 @@ function detectClass(tokenizedSamples, options) {
3982
3949
  kind = "blank";
3983
3950
  tagPositions = [];
3984
3951
  } else {
3985
- const r = classifyTokens(tokenizedSamples[i], vocab);
3952
+ const r = classifyTokens(tokenizedSamples[i], vocab, options.sharedPrefix ?? 0);
3986
3953
  kind = r.kind;
3987
3954
  subKind = r.subKind;
3988
3955
  tagPositions = r.tagPositions;
@@ -4012,11 +3979,174 @@ function detectClass(tokenizedSamples, options) {
4012
3979
  return [...buckets.values()];
4013
3980
  }
4014
3981
  //#endregion
3982
+ //#region src/composables/autoGroup/tokenize.ts
3983
+ var DELIMITER_CANDIDATES = [
3984
+ "_",
3985
+ "-",
3986
+ " ",
3987
+ "."
3988
+ ];
3989
+ /** Pick the delimiter whose split produces the most consistent field count across `lines`. */
3990
+ function pickPrimaryDelimiter(lines) {
3991
+ if (lines.length === 0) return "_";
3992
+ let best = "_";
3993
+ let bestScore = -1;
3994
+ for (const candidate of DELIMITER_CANDIDATES) {
3995
+ const counts = lines.map((l) => l.split(candidate).length);
3996
+ const frequency = /* @__PURE__ */ new Map();
3997
+ for (const c of counts) frequency.set(c, (frequency.get(c) ?? 0) + 1);
3998
+ let mode = 1;
3999
+ let modeFreq = 0;
4000
+ for (const [c, f] of frequency) if (f > modeFreq || f === modeFreq && c > mode) {
4001
+ mode = c;
4002
+ modeFreq = f;
4003
+ }
4004
+ const score = mode > 1 ? modeFreq / lines.length : 0;
4005
+ if (score > bestScore) {
4006
+ best = candidate;
4007
+ bestScore = score;
4008
+ }
4009
+ }
4010
+ return best;
4011
+ }
4012
+ var HEADER_FIELDS = [
4013
+ "code",
4014
+ "date",
4015
+ "operator"
4016
+ ];
4017
+ var OPERATOR_AGREEMENT = .9;
4018
+ var FIRST_PLAUSIBLE_YEAR = 2015;
4019
+ function isoDate(year, month, day) {
4020
+ const lastYear = (/* @__PURE__ */ new Date()).getFullYear() + 1;
4021
+ if (year < FIRST_PLAUSIBLE_YEAR || year > lastYear) return void 0;
4022
+ const date = new Date(Date.UTC(year, month - 1, day));
4023
+ if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) return void 0;
4024
+ return date.toISOString().slice(0, 10);
4025
+ }
4026
+ /** Read a 6- or 8-digit date token; year-first is tried before day-first. */
4027
+ function parseDateToken(token) {
4028
+ const n = (from, to) => Number(token.slice(from, to));
4029
+ if (/^\d{8}$/.test(token)) return isoDate(n(0, 4), n(4, 6), n(6, 8)) ?? isoDate(n(4, 8), n(2, 4), n(0, 2));
4030
+ if (/^\d{6}$/.test(token)) return isoDate(2e3 + n(0, 2), n(2, 4), n(4, 6)) ?? isoDate(2e3 + n(4, 6), n(2, 4), n(0, 2));
4031
+ }
4032
+ /**
4033
+ * Read the experiment code, date and operator from the left of a name, in any
4034
+ * order. `position` holds the token index of each field. A name with no code
4035
+ * and no date has no header, so `WT_Ctrl_1` keeps `WT` as a sample token.
4036
+ */
4037
+ function readHeader(tokens) {
4038
+ const fields = {};
4039
+ const position = {};
4040
+ let i = 0;
4041
+ if (tokens.length > 1 && /^exp\d+$/i.test(tokens[0])) {
4042
+ fields.code = tokens[0].toLowerCase();
4043
+ position.code = i++;
4044
+ }
4045
+ for (; i < tokens.length - 1; i++) {
4046
+ const date = fields.date ? void 0 : parseDateToken(tokens[i]);
4047
+ if (date) {
4048
+ fields.date = date;
4049
+ position.date = i;
4050
+ } else if (!fields.operator && /^[A-Z]{2,3}$/.test(tokens[i]) && !isClassMarker(tokens[i])) {
4051
+ fields.operator = tokens[i];
4052
+ position.operator = i;
4053
+ } else break;
4054
+ }
4055
+ if (!fields.code && !fields.date) return {
4056
+ fields: {},
4057
+ position: {}
4058
+ };
4059
+ return {
4060
+ fields,
4061
+ position
4062
+ };
4063
+ }
4064
+ var MS_FILE_EXTENSION = /\.(raw|mzml|mzxml|mzdata|wiff2?|d|mgf|csv|tsv|txt)$/i;
4065
+ /** Drop a mass-spec file extension from each name, and any other extension that all names share. */
4066
+ function stripExtensions(names) {
4067
+ const bare = names.map((name) => name.replace(MS_FILE_EXTENSION, ""));
4068
+ const ext = /\.[A-Za-z][A-Za-z0-9]{0,4}$/.exec(bare[0] ?? "")?.[0];
4069
+ return ext && bare.every((name) => name.endsWith(ext)) ? bare.map((name) => name.slice(0, -ext.length)) : bare;
4070
+ }
4071
+ /**
4072
+ * Sample names → token rows.
4073
+ *
4074
+ * 1. Trim; drop a file extension that all names share.
4075
+ * 2. Split on runs of the primary delimiter.
4076
+ * 3. Drop a trailing 14-digit acquisition stamp.
4077
+ * 4. Read the experiment header. A field moves to `experiment` when the batch
4078
+ * has one value for it. Any other field stays in the rows as written, so
4079
+ * `QC` or `WT` after the date in some names is not taken for operator
4080
+ * initials.
4081
+ * 5. Count the tokens that all names share after the header (the experiment
4082
+ * title), so a title word such as `std` cannot classify the batch as QC.
4083
+ */
4084
+ function tokenizeBatch(names) {
4085
+ const cleaned = stripExtensions(names.map((name) => name.trim()));
4086
+ const delimiter = pickPrimaryDelimiter(cleaned);
4087
+ const parsed = cleaned.map((name) => {
4088
+ const split = name.split(delimiter).filter(Boolean);
4089
+ if (split.length > 1 && /^\d{14}$/.test(split[split.length - 1])) split.pop();
4090
+ const tokens = split.length > 0 ? split : [name];
4091
+ return {
4092
+ tokens,
4093
+ ...readHeader(tokens)
4094
+ };
4095
+ });
4096
+ const experiment = {};
4097
+ for (const field of ["code", "date"]) {
4098
+ const values = new Set(parsed.map(({ fields }) => fields[field]).filter(Boolean));
4099
+ if (values.size === 1) experiment[field] = [...values][0];
4100
+ }
4101
+ const dated = parsed.filter(({ fields }) => fields.date);
4102
+ const complete = dated.length > 0 ? dated : parsed.filter(({ fields }) => fields.code);
4103
+ const operatorCounts = /* @__PURE__ */ new Map();
4104
+ for (const { fields } of complete) if (fields.operator) operatorCounts.set(fields.operator, (operatorCounts.get(fields.operator) ?? 0) + 1);
4105
+ const [operator, count] = [...operatorCounts].sort((a, b) => b[1] - a[1])[0] ?? [];
4106
+ if (operator && count >= complete.length * OPERATOR_AGREEMENT) experiment.operator = operator;
4107
+ const rows = parsed.map(({ tokens, fields, position }) => {
4108
+ const moved = new Set(HEADER_FIELDS.filter((field) => experiment[field] && fields[field] === experiment[field]).map((field) => position[field]));
4109
+ return tokens.filter((_, index) => !moved.has(index));
4110
+ });
4111
+ let sharedPrefix = 0;
4112
+ if (rows.length > 1 && Object.keys(experiment).length > 0) {
4113
+ while (rows.every((row) => row.length > sharedPrefix + 1 && row[sharedPrefix] === rows[0][sharedPrefix])) sharedPrefix++;
4114
+ if (rows.every((row) => row.slice(sharedPrefix).every((token) => /^\d+[A-Za-z]?$/.test(token)))) sharedPrefix = 0;
4115
+ }
4116
+ return {
4117
+ delimiter,
4118
+ experiment,
4119
+ rows,
4120
+ sharedPrefix
4121
+ };
4122
+ }
4123
+ //#endregion
4015
4124
  //#region src/composables/autoGroup/columns.ts
4016
- var NUMERIC_VALUE_UNIT = /^(\d+(?:dot\d+)?)([A-Za-z%/μ]+)$/;
4125
+ var NUMERIC_VALUE_UNIT = /^(\d+(?:(?:dot|\.)\d+)?)([A-Za-z%/μ]+)$/;
4017
4126
  var RATIO = /^(\d+)to(\d+)$/;
4018
4127
  var RUN_ORDER_PATTERN = /^\d{2,4}[A-Z]?$/;
4019
- var REPLICATE_TOKEN_PATTERN = /^(?:rep(?:licate)?|r)\d+$/i;
4128
+ var REPLICATE_TOKEN_PATTERN = new RegExp(vocab_default.replicateToken, "i");
4129
+ /**
4130
+ * A replicate counter is a dense run of small numbers or letters: `1, 2, 3`,
4131
+ * `01, 02, 03` or `A, B, C`. A quarter of the run can be missing (a failed
4132
+ * sample). `1, 5, 10` is a dose and `1, 2, 6, 7` an animal number: too sparse.
4133
+ */
4134
+ function isCounterRun(values) {
4135
+ if (!values.every((value) => /^(\d{1,2}|[A-Za-z])$/.test(value))) return false;
4136
+ const ranks = [...new Set(values.map((value) => /\d/.test(value) ? Number(value) : value.toUpperCase().charCodeAt(0) - 64))];
4137
+ const allowedGaps = Math.max(1, Math.floor(ranks.length / 4));
4138
+ return Math.min(...ranks) >= 1 && Math.max(...ranks) <= ranks.length + allowedGaps;
4139
+ }
4140
+ var STEM_COUNTER_PATTERN = /^([A-Za-z]+)(\d{1,2})$/;
4141
+ var COLUMN_AGREEMENT = .9;
4142
+ var ID_MIN_CLASS_SIZE = 4;
4143
+ /** Roles the Field Recipe offers as grouping fields. `replicate` and `id` are off by default. */
4144
+ var GROUPING_FIELD_ROLES = /* @__PURE__ */ new Set([
4145
+ "factor",
4146
+ "numeric",
4147
+ "replicate",
4148
+ "id"
4149
+ ]);
4020
4150
  var DEFAULT_FACTOR_CARDINALITY_MAX = 50;
4021
4151
  var LOW_CARDINALITY_NUMERIC_AS_FACTOR_MAX = 5;
4022
4152
  function parseNumeric(token) {
@@ -4044,8 +4174,12 @@ function parseNumeric(token) {
4044
4174
  }
4045
4175
  function inferRole(input) {
4046
4176
  const { values, position, totalPositions, classSize } = input;
4177
+ const tail = input.tail ?? position === totalPositions - 1;
4047
4178
  const cardinality = new Set(values).size;
4048
4179
  if (cardinality === 1) return "constant";
4180
+ const present = values.filter(Boolean);
4181
+ if (present.every((v) => REPLICATE_TOKEN_PATTERN.test(v))) return "replicate";
4182
+ if (tail && cardinality * 2 <= values.length && isCounterRun(present)) return "replicate";
4049
4183
  if (position === totalPositions - 1) {
4050
4184
  if (values.every((v) => RUN_ORDER_PATTERN.test(v)) && cardinality >= classSize * .5) return "run-order";
4051
4185
  }
@@ -4053,13 +4187,56 @@ function inferRole(input) {
4053
4187
  if (cardinality <= LOW_CARDINALITY_NUMERIC_AS_FACTOR_MAX) return "factor";
4054
4188
  return "numeric";
4055
4189
  }
4056
- if (values.every((v) => REPLICATE_TOKEN_PATTERN.test(v))) return "replicate";
4190
+ if (values.length >= ID_MIN_CLASS_SIZE && cardinality >= values.length * COLUMN_AGREEMENT && values.filter((v) => /\d/.test(v)).length >= values.length * COLUMN_AGREEMENT) return "id";
4057
4191
  if (cardinality <= DEFAULT_FACTOR_CARDINALITY_MAX) return "factor";
4058
4192
  return "ignore";
4059
4193
  }
4060
4194
  /**
4195
+ * How a column splits when most of its values agree: on `-` into equal parts
4196
+ * (`2-1` → `2`, `1`), or into stem + counter (`C1` → `C`, `1`) when two or more
4197
+ * stems repeat with different counters. `D1`/`D3` (one stem) and `B13`/`S13`
4198
+ * (one counter for each stem) stay whole.
4199
+ */
4200
+ function columnSplitter(values) {
4201
+ const agree = (test) => values.filter(test).length >= values.length * COLUMN_AGREEMENT;
4202
+ const partCounts = /* @__PURE__ */ new Map();
4203
+ for (const value of values) {
4204
+ const parts = value.split("-").length;
4205
+ partCounts.set(parts, (partCounts.get(parts) ?? 0) + 1);
4206
+ }
4207
+ const parts = [...partCounts].sort((a, b) => b[1] - a[1])[0]?.[0] ?? 1;
4208
+ if (parts > 1 && agree((value) => value.split("-").length === parts)) return (value) => {
4209
+ const pieces = value.split("-");
4210
+ return pieces.length === parts ? pieces : [value, ...Array(parts - 1).fill("")];
4211
+ };
4212
+ const countersByStem = /* @__PURE__ */ new Map();
4213
+ for (const value of values) {
4214
+ const match = STEM_COUNTER_PATTERN.exec(value);
4215
+ if (match) countersByStem.set(match[1], (countersByStem.get(match[1]) ?? /* @__PURE__ */ new Set()).add(match[2]));
4216
+ }
4217
+ const repeated = [...countersByStem.values()].filter((counters) => counters.size > 1).length;
4218
+ if (countersByStem.size > 1 && repeated * 2 >= countersByStem.size && agree((value) => STEM_COUNTER_PATTERN.test(value))) return (value) => STEM_COUNTER_PATTERN.exec(value)?.slice(1) ?? [value, ""];
4219
+ }
4220
+ /** Split the columns of one class (rows of equal length) that hold two fields in one token. */
4221
+ function splitColumns(rows) {
4222
+ const width = rows[0]?.length ?? 0;
4223
+ const splitters = Array.from({ length: width }, (_, col) => columnSplitter(rows.map((row) => row[col] ?? "")));
4224
+ const origin = [];
4225
+ const tail = [];
4226
+ splitters.forEach((splitter, col) => {
4227
+ const parts = splitter ? splitter(rows[0][col]).length : 1;
4228
+ for (let part = 0; part < parts; part++) origin.push(col);
4229
+ if (parts > 1) tail.push(origin.length - 1);
4230
+ });
4231
+ return {
4232
+ rows: rows.map((row) => row.flatMap((token, col) => splitters[col]?.(token) ?? [token])),
4233
+ origin,
4234
+ tail
4235
+ };
4236
+ }
4237
+ /**
4061
4238
  * Auto-detect a friendlier column name from the column's values + role.
4062
- * Returns null when nothing matches, so callers can fall back to "Token N".
4239
+ * Returns null when nothing matches, so callers can fall back to "Field N".
4063
4240
  *
4064
4241
  * Heuristics are content-derived and deterministic. They cover the common
4065
4242
  * LC-MS / proteomics naming conventions; users can override anything via
@@ -4134,15 +4311,18 @@ function inferColumnName(values, uniqueValues, role) {
4134
4311
  if (values.every((v) => /^(pos|neg|positive|negative)$/i.test(v))) return "Polarity";
4135
4312
  if (values.every((v) => /^(plasma|serum|tissue|tissues|kidney|liver|tumor|urine|brain|muscle|adipose|csf|feces|faeces|cells?|media|medium|extract)$/i.test(v))) return "Matrix";
4136
4313
  if (values.every((v) => /^(exp|expt|study)\d+$/i.test(v))) return "Experiment";
4137
- if (uniqueValues.length <= 4 && values.every((v) => /^[A-Z]{2,3}$/.test(v))) return "Username";
4314
+ if (uniqueValues.length === 1 && /^[A-Z]{2,3}$/.test(uniqueValues[0])) return "Username";
4315
+ if (role === "replicate") return "Replicate";
4316
+ if (role === "id") return "Sample ID";
4138
4317
  return null;
4139
4318
  }
4140
- function buildClassSchema(tokenizedMembers, classKind, subKind, classTagPositions) {
4319
+ function buildClassSchema(tokenizedMembers, classKind, subKind, classTagPositions, tailPositions = []) {
4141
4320
  const totalPositions = tokenizedMembers[0]?.length ?? 0;
4142
4321
  const classSize = tokenizedMembers.length;
4143
4322
  const columns = [];
4144
4323
  const tagSet = new Set(classTagPositions);
4145
- for (let pos = 0; pos < totalPositions; pos++) {
4324
+ const tailSet = /* @__PURE__ */ new Set([...tailPositions, totalPositions - 1]);
4325
+ for (let pos = totalPositions - 1; pos >= 0; pos--) {
4146
4326
  const values = tokenizedMembers.map((t) => t[pos] ?? "");
4147
4327
  const uniqueValues = [...new Set(values)];
4148
4328
  let role;
@@ -4151,13 +4331,16 @@ function buildClassSchema(tokenizedMembers, classKind, subKind, classTagPosition
4151
4331
  values,
4152
4332
  position: pos,
4153
4333
  totalPositions,
4154
- classSize
4334
+ classSize,
4335
+ tail: tailSet.has(pos)
4155
4336
  });
4337
+ const lastPosition = pos === totalPositions - 1;
4338
+ if (role === "run-order" || lastPosition && role === "id") tailSet.add(pos - 1);
4156
4339
  const numeric = role === "numeric" ? { byValue: Object.fromEntries(uniqueValues.map((v) => [v, parseNumeric(v)]).filter((e) => e[1] !== null)) } : void 0;
4157
4340
  const inferredName = inferColumnName(values, uniqueValues, role);
4158
- columns.push({
4341
+ columns.unshift({
4159
4342
  index: pos,
4160
- name: inferredName ?? `Token ${pos + 1}`,
4343
+ name: inferredName ?? `Field ${pos + 1}`,
4161
4344
  sourceIndices: [pos],
4162
4345
  uniqueValues,
4163
4346
  cardinality: uniqueValues.length,
@@ -4166,16 +4349,18 @@ function buildClassSchema(tokenizedMembers, classKind, subKind, classTagPosition
4166
4349
  type: pos === 0 ? "prefix" : "suffix"
4167
4350
  });
4168
4351
  }
4169
- let usernameAssigned = false;
4170
- for (const c of columns) if (c.name === "Username") {
4171
- if (usernameAssigned) c.name = "Project";
4172
- else usernameAssigned = true;
4352
+ const nameCounts = /* @__PURE__ */ new Map();
4353
+ const isField = (c) => GROUPING_FIELD_ROLES.has(c.role);
4354
+ for (const c of [...columns.filter(isField), ...columns.filter((c) => !isField(c))]) {
4355
+ const count = (nameCounts.get(c.name) ?? 0) + 1;
4356
+ nameCounts.set(c.name, count);
4357
+ if (count > 1) c.name = `${c.name} ${count}`;
4173
4358
  }
4174
4359
  return {
4175
4360
  classKind,
4176
4361
  subKind,
4177
4362
  columns,
4178
- groupBy: columns.filter((c) => c.role === "factor").map((c) => c.index)
4363
+ groupBy: columns.filter((c) => c.role === "factor" || c.role === "numeric").map((c) => c.index)
4179
4364
  };
4180
4365
  }
4181
4366
  //#endregion
@@ -4315,8 +4500,8 @@ function composeGroups(input) {
4315
4500
  return fieldValues[col.displayName ?? col.name];
4316
4501
  }) : [cls.label];
4317
4502
  const hasMissingGroupPart = input.missingGroupName && cls.disposition === "group" && keyParts.some((part) => part.trim().length === 0);
4318
- const prefix = cls.kind !== "unknown" && keyParts.length > 0 ? classPrefix.get(cls) : void 0;
4319
- const groupKey = hasMissingGroupPart ? input.missingGroupName : prefix ? `${prefix} / ${keyParts.join(" / ")}` : keyParts.length > 0 ? keyParts.join(" / ") : cls.label;
4503
+ const prefix = cls.kind !== "unknown" ? classPrefix.get(cls) : void 0;
4504
+ const groupKey = hasMissingGroupPart ? input.missingGroupName : [...prefix ? [prefix] : [], ...keyParts].join(" / ") || cls.label;
4320
4505
  const bucket = groupMap.get(groupKey);
4321
4506
  if (bucket) bucket.samples.push(rowName);
4322
4507
  else groupMap.set(groupKey, {
@@ -4414,7 +4599,7 @@ function serializeFingerprint(schemas) {
4414
4599
  }
4415
4600
  function restoreFingerprint(fp, current) {
4416
4601
  return fp.classes.map((snap) => {
4417
- const candidates = current.filter((c) => c.classKind === snap.kind && c.subKind === snap.subKind && (snap.tokenLength == null || c.tokenLength === snap.tokenLength));
4602
+ const candidates = current.filter((c) => c.classKind === snap.kind && c.subKind === snap.subKind && (snap.tokenLength == null || c.tokenLength == null || c.tokenLength === snap.tokenLength));
4418
4603
  const target = candidates.find((c) => c.columns.length === snap.columns.length) ?? candidates[0];
4419
4604
  if (!target) throw new Error(`Fingerprint class not present in current input: ${snap.kind}${snap.subKind ? "/" + snap.subKind : ""}`);
4420
4605
  if (target.columns.length !== snap.columns.length) throw new Error(`Saved schema expects ${snap.columns.length} columns, current data has ${target.columns.length} for class ${snap.kind}${snap.subKind ? "/" + snap.subKind : ""}. column count mismatch`);
@@ -4538,91 +4723,6 @@ function composeTemplate(samples, schemas, classes, options) {
4538
4723
  };
4539
4724
  }
4540
4725
  //#endregion
4541
- //#region src/composables/autoGroup/replicatePreGroup.ts
4542
- var STRIP_PATTERNS = vocab_default.replicateStripPatterns.map((src) => new RegExp(src, "gi"));
4543
- /**
4544
- * Iteratively strip every replicate / injection-number marker from `name`.
4545
- * Each pattern is applied repeatedly until it stops matching, so a sample like
4546
- * `study_T1_Rep2_004` collapses to `study` in one call.
4547
- */
4548
- function stripReplicateTokens(name) {
4549
- let base = name;
4550
- for (const re of STRIP_PATTERNS) {
4551
- let prev;
4552
- do {
4553
- prev = base;
4554
- base = base.replace(re, "");
4555
- re.lastIndex = 0;
4556
- } while (base !== prev);
4557
- }
4558
- return base;
4559
- }
4560
- function preGroupReplicates(samples) {
4561
- const map = /* @__PURE__ */ new Map();
4562
- const baseOrder = [];
4563
- for (let i = 0; i < samples.length; i++) {
4564
- const base = stripReplicateTokens(samples[i]);
4565
- const bucket = map.get(base);
4566
- if (bucket) bucket.push(i);
4567
- else {
4568
- map.set(base, [i]);
4569
- baseOrder.push(base);
4570
- }
4571
- }
4572
- return {
4573
- baseNames: baseOrder,
4574
- membersByBase: baseOrder.map((b) => map.get(b)),
4575
- originalSamples: samples
4576
- };
4577
- }
4578
- /**
4579
- * Expand groups produced over base names back to groups of original sample
4580
- * names. Each entry in `group.samples` is treated as a base name and replaced
4581
- * by the list of original samples that collapsed onto it.
4582
- */
4583
- function expandGroupsWithReplicates(groups, preGrouping) {
4584
- const baseToMembers = /* @__PURE__ */ new Map();
4585
- for (let i = 0; i < preGrouping.baseNames.length; i++) baseToMembers.set(preGrouping.baseNames[i], preGrouping.membersByBase[i]);
4586
- return groups.map((g) => ({
4587
- ...g,
4588
- samples: g.samples.flatMap((baseName) => {
4589
- const indices = baseToMembers.get(baseName);
4590
- return indices ? indices.map((i) => preGrouping.originalSamples[i]) : [baseName];
4591
- })
4592
- }));
4593
- }
4594
- /**
4595
- * Expand a group hierarchy (built over base names) back to original samples.
4596
- * Each leaf node's `label` is a base name; it is replaced by one leaf per
4597
- * original sample that collapsed onto it (sharing the leaf's injection badge,
4598
- * since collapsed replicates have identical base tokens). Every ancestor's
4599
- * `badge` (sample count) is recomputed from the expanded leaf count so the tree
4600
- * agrees with the flat groups produced by `expandGroupsWithReplicates`.
4601
- */
4602
- function expandTreeWithReplicates(nodes, preGrouping) {
4603
- const baseToOriginals = /* @__PURE__ */ new Map();
4604
- for (let i = 0; i < preGrouping.baseNames.length; i++) baseToOriginals.set(preGrouping.baseNames[i], preGrouping.membersByBase[i].map((m) => preGrouping.originalSamples[m]));
4605
- const leafCount = (n) => n.children && n.children.length ? n.children.reduce((acc, c) => acc + leafCount(c), 0) : 1;
4606
- const expand = (node) => {
4607
- if (node.children && node.children.length) {
4608
- const children = node.children.flatMap(expand);
4609
- return [{
4610
- ...node,
4611
- children,
4612
- badge: children.reduce((acc, c) => acc + leafCount(c), 0)
4613
- }];
4614
- }
4615
- const originals = baseToOriginals.get(node.label);
4616
- if (!originals || originals.length === 0) return [node];
4617
- return originals.map((name, k) => ({
4618
- ...node,
4619
- id: `${node.id}${k}`,
4620
- label: name
4621
- }));
4622
- };
4623
- return nodes.flatMap(expand);
4624
- }
4625
- //#endregion
4626
4726
  //#region src/composables/autoGroup/tokenLength.ts
4627
4727
  /**
4628
4728
  * Token-length pre-grouping.
@@ -4642,6 +4742,10 @@ function expandTreeWithReplicates(nodes, preGrouping) {
4642
4742
  function splitByTokenLength(classes, tokenized) {
4643
4743
  const out = [];
4644
4744
  for (const cls of classes) {
4745
+ if (!isExperimentalKind(cls.kind)) {
4746
+ out.push(cls);
4747
+ continue;
4748
+ }
4645
4749
  const byLength = /* @__PURE__ */ new Map();
4646
4750
  const lengthOrder = [];
4647
4751
  for (const m of cls.members) {
@@ -4793,7 +4897,7 @@ function useAutoGroup() {
4793
4897
  });
4794
4898
  const tokenized = ref([]);
4795
4899
  const delimiter = ref("_");
4796
- const preGrouping = ref(null);
4900
+ const experiment = ref({});
4797
4901
  function parseInput() {
4798
4902
  if ((inputMode.value === "csv" || inputMode.value === "experiment") && csvData.value) {
4799
4903
  parseInputFromCsv();
@@ -4802,7 +4906,7 @@ function useAutoGroup() {
4802
4906
  parseInputFromPaste();
4803
4907
  }
4804
4908
  function parseInputFromCsv() {
4805
- preGrouping.value = null;
4909
+ experiment.value = {};
4806
4910
  const csv = csvData.value;
4807
4911
  const nonSampleCols = csv.columns.filter((c) => c !== csv.sampleColumn && c !== "sample_type");
4808
4912
  if (nonSampleCols.length === 0) {
@@ -4847,25 +4951,40 @@ function useAutoGroup() {
4847
4951
  if (lines.length === 0) {
4848
4952
  classes.value = [];
4849
4953
  schemas.value = {};
4850
- preGrouping.value = null;
4954
+ experiment.value = {};
4851
4955
  return;
4852
4956
  }
4853
- const pre = preGroupReplicates(lines);
4854
- preGrouping.value = pre;
4855
- const d = pickPrimaryDelimiter(pre.baseNames);
4856
- delimiter.value = d;
4857
- tokenized.value = pre.baseNames.map((l) => splitMulti(l, d));
4858
- const hints = pre.membersByBase.map((members) => sampleTypeHints.value[members[0]]);
4859
- const detected = splitByTokenLength(detectClass(tokenized.value, { sampleTypeHints: hints }), tokenized.value);
4860
- classes.value = detected;
4957
+ const batch = tokenizeBatch(lines);
4958
+ delimiter.value = batch.delimiter;
4959
+ experiment.value = batch.experiment;
4960
+ const tokens = batch.rows;
4961
+ const detected = splitByTokenLength(detectClass(tokens, {
4962
+ sampleTypeHints: sampleTypeHints.value,
4963
+ sharedPrefix: batch.sharedPrefix
4964
+ }), tokens);
4861
4965
  const newSchemas = {};
4862
4966
  for (const cls of detected) {
4863
- const memberTokens = cls.members.map((i) => tokenized.value[i]);
4967
+ let memberTokens = cls.members.map((i) => tokens[i]);
4968
+ let tailPositions = [];
4969
+ const experimental = isExperimentalKind(cls.kind);
4970
+ if (experimental) {
4971
+ const split = splitColumns(memberTokens);
4972
+ cls.members.forEach((member, row) => {
4973
+ tokens[member] = split.rows[row];
4974
+ });
4975
+ cls.classTagPositions = split.origin.flatMap((source, index) => cls.classTagPositions.includes(source) ? [index] : []);
4976
+ memberTokens = split.rows;
4977
+ tailPositions = split.tail;
4978
+ }
4979
+ const schema = buildClassSchema(memberTokens, cls.kind, cls.subKind, cls.classTagPositions, tailPositions);
4980
+ if (!experimental) schema.groupBy = [];
4864
4981
  newSchemas[classKey(cls)] = {
4865
- ...buildClassSchema(memberTokens, cls.kind, cls.subKind, cls.classTagPositions),
4982
+ ...schema,
4866
4983
  tokenLength: cls.tokenLength
4867
4984
  };
4868
4985
  }
4986
+ tokenized.value = tokens;
4987
+ classes.value = detected;
4869
4988
  schemas.value = newSchemas;
4870
4989
  if (detected.length > 0) activeClassKey.value = classKey(detected[0]);
4871
4990
  }
@@ -4877,25 +4996,17 @@ function useAutoGroup() {
4877
4996
  if (!activeClass) return [];
4878
4997
  return findMerges(schema, activeClass.members.map((i) => tokenized.value[i]));
4879
4998
  });
4880
- const result = computed(() => {
4881
- const pre = preGrouping.value;
4882
- const composed = composeGroups({
4999
+ function composeWith(withSchemas) {
5000
+ return composeGroups({
4883
5001
  tokenizedSamples: tokenized.value,
4884
- sampleNames: pre ? pre.baseNames : samples.value,
4885
- schemas: schemas.value,
5002
+ sampleNames: samples.value,
5003
+ schemas: withSchemas,
4886
5004
  classes: classes.value,
4887
5005
  prefixClassLabels: !(csvData.value && (inputMode.value === "csv" || inputMode.value === "experiment")),
4888
5006
  missingGroupName: csvData.value && (inputMode.value === "csv" || inputMode.value === "experiment") ? STRUCTURED_MISSING_GROUP : void 0
4889
5007
  });
4890
- if (!pre) return composed;
4891
- return {
4892
- ...composed,
4893
- groups: expandGroupsWithReplicates(composed.groups, pre),
4894
- experimentalGroups: composed.experimentalGroups ? expandGroupsWithReplicates(composed.experimentalGroups, pre) : void 0,
4895
- qcGroups: composed.qcGroups ? expandGroupsWithReplicates(composed.qcGroups, pre) : void 0,
4896
- groupTree: composed.groupTree ? expandTreeWithReplicates(composed.groupTree, pre) : composed.groupTree
4897
- };
4898
- });
5008
+ }
5009
+ const result = computed(() => composeWith(schemas.value));
4899
5010
  const fingerprint = computed(() => serializeFingerprint(result.value.schemas ?? []));
4900
5011
  const groups = computed(() => result.value.groups);
4901
5012
  const qcGroups = computed(() => result.value.qcGroups ?? []);
@@ -4955,7 +5066,8 @@ function useAutoGroup() {
4955
5066
  }
4956
5067
  function schemasExposing(fieldKey) {
4957
5068
  return Object.entries(schemas.value).flatMap(([key, schema]) => {
4958
- const col = schema.columns.find((c) => (c.displayName ?? c.name) === fieldKey && (c.role === "factor" || c.role === "replicate" || schema.groupBy.includes(c.index)));
5069
+ if (!isExperimentalKind(schema.classKind)) return [];
5070
+ const col = schema.columns.find((c) => (c.displayName ?? c.name) === fieldKey && (GROUPING_FIELD_ROLES.has(c.role) || schema.groupBy.includes(c.index)));
4959
5071
  return col ? [{
4960
5072
  key,
4961
5073
  index: col.index
@@ -4982,6 +5094,26 @@ function useAutoGroup() {
4982
5094
  }
4983
5095
  }
4984
5096
  /**
5097
+ * Rename a grouping field in every class exposing it. A blank name, or a
5098
+ * name that a column of any experimental class has, changes nothing: the
5099
+ * name is the field's identity across classes, and the cards merge by it.
5100
+ */
5101
+ function renameGroupByField(fieldKey, name) {
5102
+ const next = name.trim();
5103
+ const targets = schemasExposing(fieldKey);
5104
+ if (!next || next === fieldKey || targets.length === 0) return;
5105
+ if (Object.values(schemas.value).some((schema) => isExperimentalKind(schema.classKind) && schema.columns.some((c) => (c.displayName ?? c.name) === next))) return;
5106
+ for (const { key, index } of targets) setColumnName(key, index, next);
5107
+ }
5108
+ /** Number of experimental groups that remain when the field is switched off in every class. */
5109
+ function groupCountWithout(fieldKey) {
5110
+ const targets = new Map(schemasExposing(fieldKey).map(({ key, index }) => [key, index]));
5111
+ return composeWith(Object.fromEntries(Object.entries(schemas.value).map(([key, schema]) => [key, targets.has(key) ? {
5112
+ ...schema,
5113
+ groupBy: schema.groupBy.filter((i) => i !== targets.get(key))
5114
+ } : schema]))).experimentalGroups?.length ?? 0;
5115
+ }
5116
+ /**
4985
5117
  * Move a grouping field in the active schema, then project that visible field
4986
5118
  * order across every class. Class-only fields keep their relative order after
4987
5119
  * the shared fields because the Field Recipe has no card with which to place
@@ -5176,6 +5308,7 @@ function useAutoGroup() {
5176
5308
  schemas.value = {};
5177
5309
  activeClassKey.value = "";
5178
5310
  tokenized.value = [];
5311
+ experiment.value = {};
5179
5312
  outliers.value = [];
5180
5313
  sampleTypeHints.value = [];
5181
5314
  }
@@ -5188,6 +5321,7 @@ function useAutoGroup() {
5188
5321
  schemas,
5189
5322
  activeClassKey,
5190
5323
  delimiter,
5324
+ experiment,
5191
5325
  dominantFieldCount,
5192
5326
  minFieldCount,
5193
5327
  samples,
@@ -5215,6 +5349,8 @@ function useAutoGroup() {
5215
5349
  moveGroupBy,
5216
5350
  toggleGroupByField,
5217
5351
  moveGroupByField,
5352
+ renameGroupByField,
5353
+ groupCountWithout,
5218
5354
  setRole,
5219
5355
  setColumnName,
5220
5356
  setValueOps,
@@ -7858,6 +7994,6 @@ function pickerFormat(name) {
7858
7994
  return compound ? `${compound[1]}.gz` : name.includes(".") ? name.split(".").pop() : "";
7859
7995
  }
7860
7996
  //#endregion
7861
- export { pluginFormDataFromPayload as $, parseDelimitedText as A, getPresenceClientId as At, useRackEditor as B, useRovingFocus as Bt, useExperimentSamples as C, controlValuesToComponentBindingsById as Ct, getSampleGroupMajorPrefix as D, useAppExperiment as Dt, useSampleGroups as E, APP_EXPERIMENT_KEY as Et, useAutoGroup as F, candidateMatchesSearch as Ft, useDoseCalculator as G, reorderList as H, extractSampleNamesFromDesignData as I, normalizeSearchQuery as It, usePluginJobCenter as J, combinePluginJobSources as K, extractSampleOptionsFromDesignData as L, useTextSearch as Lt, useFileImport as M, usePlatformContext as Mt, validateImportFile as N, useTheme as Nt, getSampleGroupParentPath as O, getInjectedFrontendRevision as Ot, extractSamplesFromDesignData as P, useToast as Pt, getPluginPageSelectorItems as Q, unwrapExperimentDesignData as R, compareSortValues as Rt, useScheduleDrag as S, controlValuesToComponentBindings as St, useExpansionSet as T, mergeControlWorkspaceOptions as Tt, useListReorder as U, REORDER_ANNOUNCER_STYLE as V, useReorderAnnouncer as W, downloadBlob as X, createPluginClient as Y, downloadPluginEndpoint as Z, toBioTemplateComponentPropsByComponent as _, defineWellPlateDoseComponentBindings as _t, useExperimentSelector as a, buildPluginEndpointUrl as at, useTemplateCollection as b, defineControlModel as bt, DEFAULT_PRESETS as c, usePluginWorkspace as ct, useReagentSeries as d, useFormBuilder as dt, uploadPluginEndpoint as et, useGroupAssignment as f, evaluateCondition as ft, getBioTemplateComponentProps as g, defineWellPlateControlProps as gt, useBioTemplateWorkspace as h, defineDoseDesignControlModel as ht, useAnalysisArtifacts as i, usePluginSettings as it, readFileAsText as j, usePresenceHeartbeat as jt, detectDelimiter as k, useRuntimeAlignment as kt, DEFAULT_UNITS as l, DEFAULT_MOBILE_VIEWPORT_QUERY as lt, useBioTemplatePackWorkspace as m, defineDoseCalculatorControlProps as mt, pickerFormat as n, usePluginClient as nt, useDebouncedWatch as o, resolvePluginBaseUrl as ot, useBioTemplatePresetWorkspace as p, useForm as pt, useJobsStatusTray as q, pickerReason as r, usePluginEventStream as rt, useProtocolTemplates as s, useRequestSyncState as st, pickerExclusion as t, useCurrentExperiment as tt, generateDilutionSeries as u, useMobileSupportGate as ut, useBioTemplateComponents as v, defineWellPlateDoseControlProps as vt, useExperimentData as w, controlValuesToComponentProps as wt, useExperimentSave as x, resolveControlModel as xt, useBioTemplateControls as y, useControlWorkspace as yt, DEFAULT_COLORS as z, useSortedItems as zt };
7997
+ export { downloadBlob as $, parseDelimitedText as A, useAppExperiment as At, GROUPING_FIELD_ROLES as B, useTextSearch as Bt, useExperimentSamples as C, defineControlModel as Ct, getSampleGroupMajorPrefix as D, controlValuesToComponentProps as Dt, useSampleGroups as E, controlValuesToComponentBindingsById as Et, useAutoGroup as F, usePlatformContext as Ft, reorderList as G, classKey as H, useSortedItems as Ht, extractSampleNamesFromDesignData as I, useTheme as It, useDoseCalculator as J, useListReorder as K, extractSampleOptionsFromDesignData as L, useToast as Lt, useFileImport as M, useRuntimeAlignment as Mt, validateImportFile as N, getPresenceClientId as Nt, getSampleGroupParentPath as O, mergeControlWorkspaceOptions as Ot, extractSamplesFromDesignData as P, usePresenceHeartbeat as Pt, createPluginClient as Q, unwrapExperimentDesignData as R, candidateMatchesSearch as Rt, useScheduleDrag as S, useControlWorkspace as St, useExpansionSet as T, controlValuesToComponentBindings as Tt, useRackEditor as U, useRovingFocus as Ut, isExperimentalKind as V, compareSortValues as Vt, REORDER_ANNOUNCER_STYLE as W, useJobsStatusTray as X, combinePluginJobSources as Y, usePluginJobCenter as Z, toBioTemplateComponentPropsByComponent as _, defineDoseCalculatorControlProps as _t, useExperimentSelector as a, usePluginClient as at, useTemplateCollection as b, defineWellPlateDoseComponentBindings as bt, DEFAULT_PRESETS as c, buildPluginEndpointUrl as ct, useReagentSeries as d, usePluginWorkspace as dt, downloadPluginEndpoint as et, useGroupAssignment as f, DEFAULT_MOBILE_VIEWPORT_QUERY as ft, getBioTemplateComponentProps as g, useForm as gt, useBioTemplateWorkspace as h, evaluateCondition as ht, useAnalysisArtifacts as i, useCurrentExperiment as it, readFileAsText as j, getInjectedFrontendRevision as jt, detectDelimiter as k, APP_EXPERIMENT_KEY as kt, DEFAULT_UNITS as l, resolvePluginBaseUrl as lt, useBioTemplatePackWorkspace as m, useFormBuilder as mt, pickerFormat as n, pluginFormDataFromPayload as nt, useDebouncedWatch as o, usePluginEventStream as ot, useBioTemplatePresetWorkspace as p, useMobileSupportGate as pt, useReorderAnnouncer as q, pickerReason as r, uploadPluginEndpoint as rt, useProtocolTemplates as s, usePluginSettings as st, pickerExclusion as t, getPluginPageSelectorItems as tt, generateDilutionSeries as u, useRequestSyncState as ut, useBioTemplateComponents as v, defineDoseDesignControlModel as vt, useExperimentData as w, resolveControlModel as wt, useExperimentSave as x, defineWellPlateDoseControlProps as xt, useBioTemplateControls as y, defineWellPlateControlProps as yt, DEFAULT_COLORS as z, normalizeSearchQuery as zt };
7862
7998
 
7863
- //# sourceMappingURL=validation-Dmp9Q5W0.js.map
7999
+ //# sourceMappingURL=validation-CukfLjy-.js.map