@datagrok/proteomics 1.0.0 → 1.2.0
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.
- package/CHANGELOG.md +52 -3
- package/CLAUDE.md +178 -0
- package/README.md +195 -3
- package/detectors.js +152 -9
- package/dist/package-test.js +1 -1744
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +1 -146
- package/dist/package.js.map +1 -1
- package/docs/personas-and-capabilities.md +165 -0
- package/files/demo/README.md +264 -0
- package/files/demo/cptac-spike-in.txt +1571 -0
- package/files/demo/enrichment-demo.csv +120 -0
- package/files/demo/fragpipe-smoke-test.tsv +11 -0
- package/files/demo/proteinGroups.txt +28 -0
- package/files/demo/spectronaut-hye-candidates.tsv +94 -0
- package/files/demo/spectronaut-hye-demo.tsv +8761 -0
- package/files/demo/spectronaut-hye-mix.tsv +8761 -0
- package/files/demo/spectronaut-hye-precursor-golden.json +938 -0
- package/files/demo/spectronaut-hye-precursor-golden.tsv +235 -0
- package/files/demo/spectronaut-hye-precursor.tsv +493 -0
- package/images/enrichment-crosslink.png +0 -0
- package/images/enrichment-term-selected.png +0 -0
- package/images/hero.png +0 -0
- package/images/pipeline.svg +80 -0
- package/package.json +87 -62
- package/scripts/deqms_de.R +60 -0
- package/scripts/limma_de.R +42 -0
- package/scripts/vsn_normalize.R +19 -0
- package/src/analysis/differential-expression.ts +450 -0
- package/src/analysis/enrichment-export.ts +101 -0
- package/src/analysis/enrichment.ts +602 -0
- package/src/analysis/experiment-setup.ts +199 -0
- package/src/analysis/imputation.ts +407 -0
- package/src/analysis/log2-scale.ts +139 -0
- package/src/analysis/normalization.ts +255 -0
- package/src/analysis/pca.ts +254 -0
- package/src/analysis/spc-storage.ts +515 -0
- package/src/analysis/spc.ts +544 -0
- package/src/analysis/subcellular-location.ts +431 -0
- package/src/demo/enrichment-demo.ts +94 -0
- package/src/demo/proteomics-demo.ts +123 -0
- package/src/global.d.ts +15 -0
- package/src/menu.ts +133 -0
- package/src/package-api.ts +136 -14
- package/src/package-test.ts +45 -20
- package/src/package.g.ts +161 -0
- package/src/package.ts +1029 -17
- package/src/panels/protein-focus.ts +63 -0
- package/src/panels/published-analysis-panel.ts +151 -0
- package/src/panels/uniprot-panel.ts +349 -0
- package/src/parsers/fragpipe-parser.ts +200 -0
- package/src/parsers/generic-parser.ts +197 -0
- package/src/parsers/maxquant-parser.ts +162 -0
- package/src/parsers/shared-utils.ts +163 -0
- package/src/parsers/spectronaut-candidates-parser.ts +307 -0
- package/src/parsers/spectronaut-parser.ts +604 -0
- package/src/publishing/assert-published-shape.ts +260 -0
- package/src/publishing/post-open-recovery.ts +104 -0
- package/src/publishing/publish-project.ts +515 -0
- package/src/publishing/publish-settings.ts +59 -0
- package/src/publishing/publish-state.ts +316 -0
- package/src/publishing/share-dialog.ts +171 -0
- package/src/publishing/trim-dataframe.ts +247 -0
- package/src/tests/analysis.ts +658 -0
- package/src/tests/enrichment-export.ts +61 -0
- package/src/tests/enrichment-visualization.ts +340 -0
- package/src/tests/enrichment.ts +224 -0
- package/src/tests/fragpipe-e2e.ts +74 -0
- package/src/tests/fragpipe-parser.ts +147 -0
- package/src/tests/gene-label-resolver.ts +387 -0
- package/src/tests/generic-parser.ts +152 -0
- package/src/tests/group-mean-correlation.ts +139 -0
- package/src/tests/log2-scale.ts +93 -0
- package/src/tests/organisms.ts +56 -0
- package/src/tests/parsers.ts +182 -0
- package/src/tests/publish-roundtrip.ts +584 -0
- package/src/tests/publish-spike.ts +377 -0
- package/src/tests/qc-dashboard.ts +210 -0
- package/src/tests/smart-pathway-filter.ts +193 -0
- package/src/tests/spc-formula-lines-spike.ts +129 -0
- package/src/tests/spc.ts +640 -0
- package/src/tests/spectronaut-candidates-e2e.ts +140 -0
- package/src/tests/spectronaut-candidates-parser.ts +398 -0
- package/src/tests/spectronaut-parser.ts +668 -0
- package/src/tests/subcellular-location.ts +361 -0
- package/src/tests/uniprot-panel.ts +202 -0
- package/src/tests/volcano.ts +603 -0
- package/src/utils/column-detection.ts +28 -0
- package/src/utils/gene-label-resolver.ts +443 -0
- package/src/utils/organisms.ts +82 -0
- package/src/utils/proteomics-types.ts +30 -0
- package/src/viewers/enrichment-viewers.ts +274 -0
- package/src/viewers/group-mean-correlation.ts +218 -0
- package/src/viewers/heatmap.ts +168 -0
- package/src/viewers/pca-plot.ts +169 -0
- package/src/viewers/qc-computations.ts +266 -0
- package/src/viewers/qc-dashboard.ts +176 -0
- package/src/viewers/spc-dashboard.ts +755 -0
- package/src/viewers/volcano.ts +690 -0
- package/test-console-output-1.log +2055 -0
- package/test-record-1.mp4 +0 -0
- package/tools/derive-precursor-golden-sidecar.mjs +81 -0
- package/tools/generate-enrichment-fixture.sh +160 -0
- package/tools/generate-spectronaut-candidates-fixture.mjs +212 -0
- package/tools/generate-spectronaut-precursor-fixture.mjs +128 -0
- package/tools/spectronaut-aggregate.sh +46 -0
- package/tools/spectronaut-aggregate.sql +80 -0
- package/tsconfig.json +18 -71
- package/webpack.config.js +86 -45
- package/.eslintignore +0 -1
- package/.eslintrc.json +0 -56
- package/LICENSE +0 -674
- package/package.png +0 -0
- package/scripts/number_antibody.py +0 -190
- package/scripts/number_antibody_abnumber.py +0 -177
- package/scripts/number_antibody_anarci.py +0 -200
|
@@ -0,0 +1,515 @@
|
|
|
1
|
+
import * as grok from 'datagrok-api/grok';
|
|
2
|
+
import * as DG from 'datagrok-api/dg';
|
|
3
|
+
import {awaitCheck, delay} from '@datagrok-libraries/test/src/test';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
META_COLUMNS, PUBLISHED_TAGS, PublishOptions, PublishedMetadata,
|
|
7
|
+
slugifyTarget,
|
|
8
|
+
} from './publish-state';
|
|
9
|
+
import {trimEnrichmentForPublish, trimForPublish} from './trim-dataframe';
|
|
10
|
+
import {
|
|
11
|
+
FORMULA_LINE_ASSERTION_PREFIX, PublishedShapeContract, assertPublishedShape,
|
|
12
|
+
} from './assert-published-shape';
|
|
13
|
+
import {createVolcanoPlot} from '../viewers/volcano';
|
|
14
|
+
import {dockEnrichmentCharts, openEnrichmentVisualization} from '../viewers/enrichment-viewers';
|
|
15
|
+
import {reviewSpaceName, reviewNamePrefix, verifyPublishedDashboard} from './publish-settings';
|
|
16
|
+
import {DEFAULT_FC_THRESHOLD, DEFAULT_P_THRESHOLD} from '../utils/proteomics-types';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Pure helper: applies (or replaces) the volcano threshold formula lines on
|
|
20
|
+
* the DataFrame attached to {@link viewer}. Writes to `df.meta.formulaLines`
|
|
21
|
+
* since that is where `applyThresholdLines` in `src/viewers/volcano.ts` stores
|
|
22
|
+
* them (the platform's canonical location for scatter-plot formula lines).
|
|
23
|
+
*
|
|
24
|
+
* Exported so Plan 07's post-open recovery hook can re-apply the lines on a
|
|
25
|
+
* reopened published Project's volcano when the serializer strips look config
|
|
26
|
+
* (Phase 13 e527d07ba1 evidence). Idempotent — removes existing FC/y-axis
|
|
27
|
+
* threshold lines before re-adding so repeat calls do not stack.
|
|
28
|
+
*/
|
|
29
|
+
export function applyVolcanoFormulaLines(
|
|
30
|
+
viewer: DG.Viewer,
|
|
31
|
+
fcThreshold: number,
|
|
32
|
+
pThreshold: number,
|
|
33
|
+
): void {
|
|
34
|
+
const df = (viewer as any).dataFrame as DG.DataFrame | undefined;
|
|
35
|
+
if (!df) return;
|
|
36
|
+
|
|
37
|
+
let yColName: string | null = null;
|
|
38
|
+
try {
|
|
39
|
+
const opts: any = (viewer as any).getOptions?.();
|
|
40
|
+
yColName = opts?.look?.yColumnName ?? null;
|
|
41
|
+
} catch { /* fall through */ }
|
|
42
|
+
if (!yColName) {
|
|
43
|
+
try { yColName = (viewer as any).props?.yColumnName ?? null; } catch { /* fall through */ }
|
|
44
|
+
}
|
|
45
|
+
if (!yColName) return;
|
|
46
|
+
|
|
47
|
+
const yPrefix = `\${${yColName}}`;
|
|
48
|
+
const fcPrefix = '${log2FC}';
|
|
49
|
+
const hLine = -Math.log10(pThreshold);
|
|
50
|
+
|
|
51
|
+
const meta: any = (df as any).meta;
|
|
52
|
+
const formulaLines: any = meta?.formulaLines;
|
|
53
|
+
if (!formulaLines) return;
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const items: any[] = Array.isArray(formulaLines.items) ? formulaLines.items : [];
|
|
57
|
+
formulaLines.items = items.filter((line: any) => {
|
|
58
|
+
const f = line?.formula ?? '';
|
|
59
|
+
return !(typeof f === 'string' && (f.startsWith(yPrefix) || f.startsWith(fcPrefix)));
|
|
60
|
+
});
|
|
61
|
+
} catch { /* best effort */ }
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
formulaLines.addLine({formula: `${yPrefix} = ${hLine}`, color: '#888888', width: 1, visible: true});
|
|
65
|
+
formulaLines.addLine({formula: `${fcPrefix} = ${fcThreshold}`, color: '#888888', width: 1, visible: true});
|
|
66
|
+
formulaLines.addLine({formula: `${fcPrefix} = ${-fcThreshold}`, color: '#888888', width: 1, visible: true});
|
|
67
|
+
} catch { /* best effort */ }
|
|
68
|
+
|
|
69
|
+
try { (viewer as any).props.showViewerFormulaLines = true; } catch { /* best effort */ }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parsePriorVersion(priorName: string | null | undefined): number {
|
|
73
|
+
if (!priorName) return 0;
|
|
74
|
+
const m = /-v(\d+)-/.exec(priorName);
|
|
75
|
+
if (!m) return 0;
|
|
76
|
+
const n = parseInt(m[1], 10);
|
|
77
|
+
return Number.isFinite(n) ? n : 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function generateUuid(): string {
|
|
81
|
+
try {
|
|
82
|
+
const c = (globalThis as any)?.crypto;
|
|
83
|
+
if (c && typeof c.randomUUID === 'function') return c.randomUUID();
|
|
84
|
+
} catch { /* fall through */ }
|
|
85
|
+
// RFC4122 v4 fallback
|
|
86
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
|
87
|
+
const r = (Math.random() * 16) | 0;
|
|
88
|
+
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
|
89
|
+
return v.toString(16);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Orchestrates the read-only publish flow. Source DataFrame is NEVER mutated
|
|
95
|
+
* (Pitfall 1 / T-15-02 guarantee — Plan 02's `trimForPublish` owns the clone
|
|
96
|
+
* boundary). Two non-negotiable gates: Step 7 verifies reviewer-group ACL via
|
|
97
|
+
* `permissions.get` and rolls back on Edit-leak; Step 8 calls
|
|
98
|
+
* {@link assertPublishedShape} against the reopened Project and rolls back on
|
|
99
|
+
* contract violation — with a one-shot W-7 self-heal pass for the
|
|
100
|
+
* formula-line-stripped failure mode.
|
|
101
|
+
*
|
|
102
|
+
* Publish-side belt-and-braces for PUB-06 / SC-2 formula lines:
|
|
103
|
+
* #1 (this orchestrator, step 6.5) — apply formula lines on the trimmed
|
|
104
|
+
* volcano BEFORE saving the view
|
|
105
|
+
* #2 (Plan 01 setPublishedTags via Plan 02 trimForPublish) — persist FC and
|
|
106
|
+
* p threshold values as `proteomics.published_fc_threshold` /
|
|
107
|
+
* `proteomics.published_p_threshold` tags on the trimmed DataFrame so
|
|
108
|
+
* the recovery hook can re-apply them later
|
|
109
|
+
* #3 (Plan 07 post-open recovery hook) — re-apply formula lines from the
|
|
110
|
+
* tags on reopen if the serializer stripped them; reuses the exported
|
|
111
|
+
* {@link applyVolcanoFormulaLines} helper
|
|
112
|
+
*
|
|
113
|
+
* Per spike 15-00 (`15-00-SUMMARY.md`):
|
|
114
|
+
* - A1: `permissions.get` returns `["view", "edit"]` keys only (no share/delete)
|
|
115
|
+
* - A2: Space-inheritance grants do NOT propagate to `permissions.get(project)`
|
|
116
|
+
* → Step 7 grants View directly on the Project, not via the parent Space
|
|
117
|
+
* - A4: `project.options[*]` survives round-trip → supersede pointer uses options
|
|
118
|
+
*/
|
|
119
|
+
export async function publishAnalysis(df: DG.DataFrame, opts: PublishOptions): Promise<DG.Project> {
|
|
120
|
+
const pi = DG.TaskBarProgressIndicator.create('Publishing snapshot...');
|
|
121
|
+
try {
|
|
122
|
+
// ─── Step 1 — assemble PublishedMetadata (server-authoritative identity) ────
|
|
123
|
+
pi.description = 'Preparing metadata...';
|
|
124
|
+
const publishedBy = (grok.shell.user as any)?.friendlyName ?? '';
|
|
125
|
+
const publishedByEmail = (grok.shell.user as any)?.email ?? null;
|
|
126
|
+
const deMethod = (df.getTag('proteomics.de_method') ?? 't-test') as PublishedMetadata['deMethod'];
|
|
127
|
+
|
|
128
|
+
let fcThreshold = DEFAULT_FC_THRESHOLD;
|
|
129
|
+
let pThreshold = DEFAULT_P_THRESHOLD;
|
|
130
|
+
try {
|
|
131
|
+
const optsAny = opts as any;
|
|
132
|
+
if (typeof optsAny.fcThreshold === 'number' && Number.isFinite(optsAny.fcThreshold))
|
|
133
|
+
fcThreshold = optsAny.fcThreshold;
|
|
134
|
+
if (typeof optsAny.pThreshold === 'number' && Number.isFinite(optsAny.pThreshold))
|
|
135
|
+
pThreshold = optsAny.pThreshold;
|
|
136
|
+
} catch { /* defaults */ }
|
|
137
|
+
|
|
138
|
+
const slug = slugifyTarget(opts.target);
|
|
139
|
+
const priorVersionN = parsePriorVersion((opts.priorVersion as any)?.name);
|
|
140
|
+
const version = priorVersionN > 0 ? priorVersionN + 1 : 1;
|
|
141
|
+
const publishId = generateUuid();
|
|
142
|
+
const publishedAt = new Date();
|
|
143
|
+
const dateStr = publishedAt.toISOString().slice(0, 10);
|
|
144
|
+
|
|
145
|
+
const enrichSource: DG.DataFrame | null = (() => {
|
|
146
|
+
const tables = (grok.shell as any).tables as DG.DataFrame[] | undefined;
|
|
147
|
+
if (!Array.isArray(tables)) return null;
|
|
148
|
+
return tables.find((t) => t.getTag('proteomics.enrichment') === 'true') ?? null;
|
|
149
|
+
})();
|
|
150
|
+
|
|
151
|
+
const meta: PublishedMetadata = {
|
|
152
|
+
target: opts.target,
|
|
153
|
+
publishedAt,
|
|
154
|
+
publishedBy,
|
|
155
|
+
publishedByEmail,
|
|
156
|
+
deMethod,
|
|
157
|
+
fcThreshold,
|
|
158
|
+
pThreshold,
|
|
159
|
+
version,
|
|
160
|
+
publishId,
|
|
161
|
+
includesEnrichment: !!enrichSource,
|
|
162
|
+
supersedes: opts.priorVersion?.id ?? null,
|
|
163
|
+
supersededBy: null,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// ─── Step 2 — trim protein DF (Pitfall 1: source not mutated) ────────────────
|
|
167
|
+
pi.description = 'Trimming snapshot...';
|
|
168
|
+
const frozen = trimForPublish(df, meta);
|
|
169
|
+
const expectedName = frozen.name;
|
|
170
|
+
|
|
171
|
+
// ─── Step 3 — opportunistic enrichment trim (D-05) ──────────────────────────
|
|
172
|
+
let frozenEnrich: DG.DataFrame | null = null;
|
|
173
|
+
if (enrichSource) {
|
|
174
|
+
pi.description = 'Trimming enrichment...';
|
|
175
|
+
frozenEnrich = trimEnrichmentForPublish(enrichSource, meta);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ─── Step 4 — ensure umbrella Space (opts.umbrellaName override supported) ──
|
|
179
|
+
pi.description = 'Ensuring umbrella Space...';
|
|
180
|
+
const umbrellaName = opts.umbrellaName ?? reviewSpaceName();
|
|
181
|
+
const dapiAny = grok.dapi as any;
|
|
182
|
+
let umbrella: any = null;
|
|
183
|
+
|
|
184
|
+
// Try create-first; if the Space already exists, fall back to enumeration.
|
|
185
|
+
// The Datagrok smart-filter API does not reliably surface Space-flagged
|
|
186
|
+
// Projects via `dapi.projects.filter(... and isSpace = true).first()`,
|
|
187
|
+
// so we rely on `spaces.list()` enumeration as the lookup path.
|
|
188
|
+
try {
|
|
189
|
+
umbrella = await dapiAny.spaces.createRootSpace(umbrellaName);
|
|
190
|
+
} catch (createErr: any) {
|
|
191
|
+
const msg = String(createErr?.message ?? createErr ?? '');
|
|
192
|
+
if (!/already exists|exists/i.test(msg)) throw createErr;
|
|
193
|
+
try {
|
|
194
|
+
const all: any[] = await dapiAny.spaces.list();
|
|
195
|
+
umbrella = (all ?? []).find((p) =>
|
|
196
|
+
p?.name === umbrellaName || (p as any)?.friendlyName === umbrellaName) ?? null;
|
|
197
|
+
} catch { /* fall through */ }
|
|
198
|
+
if (!umbrella) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`Umbrella Space '${umbrellaName}' exists on the server but could not be resolved ` +
|
|
201
|
+
`by name from spaces.list() (msg: ${msg}). Ask an admin to inspect the conflicting Space.`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const umbrellaClient = dapiAny.spaces.id(umbrella.id);
|
|
205
|
+
|
|
206
|
+
// ─── Step 5 — ensure per-target child Space ─────────────────────────────────
|
|
207
|
+
pi.description = 'Ensuring per-target Space...';
|
|
208
|
+
const childName = `${reviewNamePrefix()}-${slug}`;
|
|
209
|
+
let childSpace: any = null;
|
|
210
|
+
|
|
211
|
+
const findChildByName = async (): Promise<any> => {
|
|
212
|
+
try {
|
|
213
|
+
const kids: any[] = await umbrellaClient.children.filter('Project', false).list();
|
|
214
|
+
return (kids ?? []).find((k) =>
|
|
215
|
+
k?.friendlyName === childName || k?.name === childName) ?? null;
|
|
216
|
+
} catch { return null; }
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
if (await umbrellaClient.subspaceExists(childName)) {
|
|
221
|
+
childSpace = await findChildByName();
|
|
222
|
+
}
|
|
223
|
+
} catch { /* fall through to addSubspace */ }
|
|
224
|
+
|
|
225
|
+
if (!childSpace) {
|
|
226
|
+
try {
|
|
227
|
+
childSpace = await umbrellaClient.addSubspace(childName);
|
|
228
|
+
} catch (subErr: any) {
|
|
229
|
+
const msg = String(subErr?.message ?? subErr ?? '');
|
|
230
|
+
if (!/already exists|exists/i.test(msg)) throw subErr;
|
|
231
|
+
childSpace = await findChildByName();
|
|
232
|
+
if (!childSpace) {
|
|
233
|
+
throw new Error(
|
|
234
|
+
`Per-target child Space '${childName}' exists but could not be resolved via ` +
|
|
235
|
+
`umbrella children.list() (msg: ${msg}).`);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const childClient = dapiAny.spaces.id(childSpace.id);
|
|
240
|
+
|
|
241
|
+
// ─── Step 6 — create Project + addChild ─────────────────────────────────────
|
|
242
|
+
pi.description = 'Saving Project...';
|
|
243
|
+
const project = DG.Project.create();
|
|
244
|
+
const projectName = `${reviewNamePrefix()}-${slug}-v${version}-${dateStr}`;
|
|
245
|
+
(project as any).name = projectName;
|
|
246
|
+
// Set friendlyName explicitly so the platform doesn't "humanize" the slug
|
|
247
|
+
// (e.g. DMD → "DM D"). Use the original target verbatim for a clean label,
|
|
248
|
+
// and derive the label's leading words from the (configurable) name prefix.
|
|
249
|
+
(project as any).friendlyName =
|
|
250
|
+
`${reviewNamePrefix().replace(/-/g, ' ')} ${opts.target} v${version} ${dateStr}`;
|
|
251
|
+
|
|
252
|
+
try { (project as any).options[PUBLISHED_TAGS.PUBLISHED_ID] = publishId; } catch { /* swallow */ }
|
|
253
|
+
// Step 9 writes the supersede pointer on the prior project; the NEW
|
|
254
|
+
// project's `supersedes` pointer must be written BEFORE save so the
|
|
255
|
+
// round-trip assertion in Step 8 sees it on reopen.
|
|
256
|
+
if (opts.priorVersion != null) {
|
|
257
|
+
try {
|
|
258
|
+
(project as any).options[PUBLISHED_TAGS.SUPERSEDES] = (opts.priorVersion as any).id;
|
|
259
|
+
} catch { /* swallow */ }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
project.addChild(frozen.getTableInfo());
|
|
263
|
+
if (frozenEnrich) project.addChild(frozenEnrich.getTableInfo());
|
|
264
|
+
|
|
265
|
+
// ─── Step 6.5 — REVISION B-2: re-create volcano on trimmed DF + apply lines ─
|
|
266
|
+
pi.description = 'Re-rendering volcano on trimmed snapshot...';
|
|
267
|
+
const trimmedTv = grok.shell.addTableView(frozen);
|
|
268
|
+
await delay(100);
|
|
269
|
+
const volcano = createVolcanoPlot(frozen, {fcThreshold, pThreshold});
|
|
270
|
+
trimmedTv.addViewer(volcano);
|
|
271
|
+
applyVolcanoFormulaLines(volcano, fcThreshold, pThreshold);
|
|
272
|
+
await delay(100);
|
|
273
|
+
|
|
274
|
+
const viewInfo = trimmedTv.getInfo();
|
|
275
|
+
project.addChild(viewInfo);
|
|
276
|
+
|
|
277
|
+
// ─── Step 6.6 — re-create the enrichment Up/Down 2×2 on the published
|
|
278
|
+
// enrichment view. The charts bind to frozenEnrich itself (dockEnrichmentCharts
|
|
279
|
+
// adds the derived negLog10FDR / ~enrichChartTop columns onto it and splits
|
|
280
|
+
// Up/Down via per-viewer formula filters), so there are no chart-backing
|
|
281
|
+
// subset tables to bundle — the enrichment frame is fully self-contained and
|
|
282
|
+
// the charts survive the project round-trip. ─────────────────────────────
|
|
283
|
+
let enrichViewInfo: any = null;
|
|
284
|
+
if (frozenEnrich) {
|
|
285
|
+
pi.description = 'Re-rendering enrichment charts...';
|
|
286
|
+
const enrichTv = grok.shell.addTableView(frozenEnrich);
|
|
287
|
+
await delay(100);
|
|
288
|
+
dockEnrichmentCharts(enrichTv, frozenEnrich);
|
|
289
|
+
await delay(100);
|
|
290
|
+
enrichViewInfo = enrichTv.getInfo();
|
|
291
|
+
project.addChild(enrichViewInfo);
|
|
292
|
+
// Land the user (and downstream readers of grok.shell.tv) back on the
|
|
293
|
+
// protein/volcano view — the primary deliverable — not the enrichment tab.
|
|
294
|
+
grok.shell.v = trimmedTv;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
await grok.dapi.tables.uploadDataFrame(frozen);
|
|
298
|
+
await grok.dapi.tables.save(frozen.getTableInfo());
|
|
299
|
+
if (frozenEnrich) {
|
|
300
|
+
// Upload AFTER dockEnrichmentCharts so the derived chart columns persist.
|
|
301
|
+
await grok.dapi.tables.uploadDataFrame(frozenEnrich);
|
|
302
|
+
await grok.dapi.tables.save(frozenEnrich.getTableInfo());
|
|
303
|
+
}
|
|
304
|
+
await grok.dapi.views.save(viewInfo);
|
|
305
|
+
if (enrichViewInfo) await grok.dapi.views.save(enrichViewInfo);
|
|
306
|
+
await grok.dapi.projects.save(project);
|
|
307
|
+
// meta.publishId stays at the originally-generated UUID — it identifies the
|
|
308
|
+
// *publish event* in the DataFrame's tag/column, distinct from project.id
|
|
309
|
+
// (the Datagrok entity id). Step 9 supersede pointers use project.id directly.
|
|
310
|
+
|
|
311
|
+
try { await childClient.addEntity(project.id); } catch (mvErr: any) {
|
|
312
|
+
// Project move to child Space failed — surface but do not abort, since
|
|
313
|
+
// the verify-and-rollback gate below grants directly on the Project
|
|
314
|
+
// (not via Space inheritance per spike A2).
|
|
315
|
+
grok.shell.warning(
|
|
316
|
+
`Could not move Project into child Space "${childName}": ${mvErr?.message ?? mvErr}. ` +
|
|
317
|
+
`Continuing — reviewer view grant is applied directly to the Project.`);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ─── Step 7a — grant View directly on the Project (spike A2 — inheritance not visible) ──
|
|
321
|
+
pi.description = 'Granting reviewer view-only access...';
|
|
322
|
+
try {
|
|
323
|
+
await grok.dapi.permissions.grant(project, opts.reviewerGroup, false);
|
|
324
|
+
} catch (grantErr: any) {
|
|
325
|
+
try { await grok.dapi.projects.delete(project); } catch (rb: any) {
|
|
326
|
+
grok.shell.error(`Manual cleanup required: project id ${project.id} could not be deleted: ${rb?.message ?? rb}`);
|
|
327
|
+
}
|
|
328
|
+
throw new Error(`Reviewer group view grant failed: ${grantErr?.message ?? grantErr}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// ─── Step 7b — verify-and-rollback gate (T-15-01, NON-NEGOTIABLE) ───────────
|
|
332
|
+
// Spike A2: `permissions.get(project)` does NOT surface Space-inherited grants.
|
|
333
|
+
// So we check three rings: project, child Space, umbrella Space. Any Edit/Share/
|
|
334
|
+
// Delete on the reviewer group at any level is a leak that fails the gate.
|
|
335
|
+
pi.description = 'Verifying view-only access...';
|
|
336
|
+
const reviewerId = (opts.reviewerGroup as any)?.id;
|
|
337
|
+
const matchesGroup = (g: any): boolean => {
|
|
338
|
+
if (g == null) return false;
|
|
339
|
+
if (typeof g === 'string') return g === reviewerId;
|
|
340
|
+
if (g?.id != null) return g.id === reviewerId;
|
|
341
|
+
return false;
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
const projPerm: any = await grok.dapi.permissions.get(project);
|
|
345
|
+
let childSpacePerm: any = null;
|
|
346
|
+
let umbrellaPerm: any = null;
|
|
347
|
+
try { if (childSpace) childSpacePerm = await grok.dapi.permissions.get(childSpace); } catch { /* swallow */ }
|
|
348
|
+
try { if (umbrella) umbrellaPerm = await grok.dapi.permissions.get(umbrella); } catch { /* swallow */ }
|
|
349
|
+
|
|
350
|
+
const inAny = (key: string): boolean => {
|
|
351
|
+
for (const perm of [projPerm, childSpacePerm, umbrellaPerm]) {
|
|
352
|
+
const arr = (perm as any)?.[key];
|
|
353
|
+
if (Array.isArray(arr) && arr.some(matchesGroup)) return true;
|
|
354
|
+
}
|
|
355
|
+
return false;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const inView = inAny('view');
|
|
359
|
+
const inEdit = inAny('edit');
|
|
360
|
+
const inShare = inAny('share');
|
|
361
|
+
const inDelete = inAny('delete');
|
|
362
|
+
|
|
363
|
+
if (!inView || inEdit || inShare || inDelete) {
|
|
364
|
+
try { await grok.dapi.projects.delete(project); } catch (rb: any) {
|
|
365
|
+
grok.shell.error(`Manual cleanup required: project id ${project.id} could not be deleted: ${rb?.message ?? rb}`);
|
|
366
|
+
}
|
|
367
|
+
throw new Error(
|
|
368
|
+
'Reviewer group already has elevated access via Space inheritance — ' +
|
|
369
|
+
'publish aborted; ask an admin to scope the umbrella Space\'s permissions');
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ─── Step 8 — round-trip verification (optional; `verifyPublishedDashboard`,
|
|
373
|
+
// default ON) with W-7 self-heal on formula lines. This is the heavy part:
|
|
374
|
+
// closeAll + reopen the project + assert it survives a reload. Keep it on for
|
|
375
|
+
// client deliverables; turn it off for faster demo shares. The reviewer-access
|
|
376
|
+
// verify-and-rollback gate (Step 7b) is separate and always runs. ────────────
|
|
377
|
+
// Per-share checkbox (opts.verify) wins; fall back to the package setting for
|
|
378
|
+
// non-dialog callers.
|
|
379
|
+
if (opts.verify ?? verifyPublishedDashboard()) {
|
|
380
|
+
pi.description = 'Verifying round-trip survival...';
|
|
381
|
+
// expectedAllowlist captures the actual post-trim post-volcano column state
|
|
382
|
+
// (volcano factory in step 6.5 adds derived columns like '-log10(adj.p-value)'),
|
|
383
|
+
// not just the trim allowlist. The trim allowlist is the floor; the orchestrator
|
|
384
|
+
// may add derived columns; the contract reflects what actually ships.
|
|
385
|
+
// Exclude _meta_* belt-and-braces columns AND ~-prefixed technical columns
|
|
386
|
+
// (e.g. the volcano's '~Volcano label') — assertPublishedShape ignores both
|
|
387
|
+
// on the reopened side, so the expected list must too or the count mismatches.
|
|
388
|
+
const expectedAllowlist = frozen.columns.toList()
|
|
389
|
+
.map((c) => c.name)
|
|
390
|
+
.filter((n) => !n.startsWith('_meta_') && !n.startsWith('~'));
|
|
391
|
+
const contract: PublishedShapeContract = {
|
|
392
|
+
expectedName,
|
|
393
|
+
expectedProjectName: projectName,
|
|
394
|
+
expectedAllowlist,
|
|
395
|
+
expectedMeta: {...meta},
|
|
396
|
+
expectVolcano: true,
|
|
397
|
+
expectEnrichment: !!frozenEnrich,
|
|
398
|
+
expectFormulaLines: true,
|
|
399
|
+
};
|
|
400
|
+
|
|
401
|
+
let healedOnce = false;
|
|
402
|
+
while (true) {
|
|
403
|
+
try {
|
|
404
|
+
await assertPublishedShape(project, contract);
|
|
405
|
+
break;
|
|
406
|
+
} catch (assertErr: any) {
|
|
407
|
+
const msg = String(assertErr?.message ?? assertErr ?? '');
|
|
408
|
+
if (!healedOnce && msg.startsWith(FORMULA_LINE_ASSERTION_PREFIX)) {
|
|
409
|
+
healedOnce = true;
|
|
410
|
+
const tv = grok.shell.tv;
|
|
411
|
+
const reopenedVolcano = tv
|
|
412
|
+
? Array.from(tv.viewers).find((v) => v.type === DG.VIEWER.SCATTER_PLOT)
|
|
413
|
+
: null;
|
|
414
|
+
if (reopenedVolcano && tv) {
|
|
415
|
+
const reDf = tv.dataFrame;
|
|
416
|
+
const fcFromTag = parseFloat(reDf.getTag(PUBLISHED_TAGS.PUBLISHED_FC_THRESHOLD) ?? String(fcThreshold));
|
|
417
|
+
const pFromTag = parseFloat(reDf.getTag(PUBLISHED_TAGS.PUBLISHED_P_THRESHOLD) ?? String(pThreshold));
|
|
418
|
+
applyVolcanoFormulaLines(
|
|
419
|
+
reopenedVolcano,
|
|
420
|
+
Number.isFinite(fcFromTag) ? fcFromTag : fcThreshold,
|
|
421
|
+
Number.isFinite(pFromTag) ? pFromTag : pThreshold,
|
|
422
|
+
);
|
|
423
|
+
try { await grok.dapi.views.save(tv.getInfo()); } catch { /* best-effort */ }
|
|
424
|
+
await delay(100);
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
try { await grok.dapi.projects.delete(project); } catch (rb: any) {
|
|
429
|
+
grok.shell.error(`Manual cleanup required: project id ${project.id} could not be deleted: ${rb?.message ?? rb}`);
|
|
430
|
+
}
|
|
431
|
+
throw new Error(`Round-trip shape verification failed: ${assertErr?.message ?? assertErr}`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ─── Step 9 — supersede chain (D-04 + W-8 dual-write, non-destructive) ──────
|
|
437
|
+
if (opts.priorVersion != null) {
|
|
438
|
+
pi.description = 'Updating supersede chain...';
|
|
439
|
+
const priorProjectId = (opts.priorVersion as any).id;
|
|
440
|
+
try {
|
|
441
|
+
const priorReloaded = await grok.dapi.projects.find(priorProjectId);
|
|
442
|
+
(priorReloaded as any).options[PUBLISHED_TAGS.SUPERSEDED_BY] = project.id;
|
|
443
|
+
await grok.dapi.projects.save(priorReloaded);
|
|
444
|
+
} catch (priorOptsErr: any) {
|
|
445
|
+
grok.shell.warning(
|
|
446
|
+
`Supersede pointer on prior version's options failed: ${priorOptsErr?.message ?? priorOptsErr} ` +
|
|
447
|
+
`(DataFrame-tag dual-write path below provides fallback).`);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
try {
|
|
451
|
+
grok.shell.closeAll();
|
|
452
|
+
await delay(100);
|
|
453
|
+
const priorReopened = await grok.dapi.projects.find(priorProjectId);
|
|
454
|
+
await priorReopened.open();
|
|
455
|
+
await awaitCheck(
|
|
456
|
+
() => !!grok.shell.tv && !!grok.shell.tv.dataFrame,
|
|
457
|
+
'supersede stamp: prior project DF did not materialize',
|
|
458
|
+
5000,
|
|
459
|
+
);
|
|
460
|
+
const priorDf = grok.shell.tv!.dataFrame;
|
|
461
|
+
priorDf.setTag(PUBLISHED_TAGS.SUPERSEDED_BY, project.id);
|
|
462
|
+
const supersededByCol = priorDf.col(META_COLUMNS.SUPERSEDED_BY);
|
|
463
|
+
if (supersededByCol) {
|
|
464
|
+
try { supersededByCol.set(0, project.id); } catch { /* best-effort */ }
|
|
465
|
+
}
|
|
466
|
+
await grok.dapi.tables.uploadDataFrame(priorDf);
|
|
467
|
+
await grok.dapi.tables.save(priorDf.getTableInfo());
|
|
468
|
+
} catch (stampErr: any) {
|
|
469
|
+
grok.shell.warning(
|
|
470
|
+
`Supersede tag could not be stamped on prior version DF: ${stampErr?.message ?? stampErr} ` +
|
|
471
|
+
`(Project.options pointer was still written — Plan 06 panel falls back to that path)`);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
try { (project as any).options[PUBLISHED_TAGS.SUPERSEDES] = priorProjectId; } catch { /* swallow */ }
|
|
475
|
+
try { await grok.dapi.projects.save(project); } catch (e) {
|
|
476
|
+
grok.shell.warning(`Could not write supersedes pointer on new project options: ${(e as Error)?.message ?? e}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ─── Step 10 — restore the user's workspace ─────────────────────────────────
|
|
481
|
+
// Round-trip verification (assertPublishedShape) does closeAll() + reopens the
|
|
482
|
+
// published project to prove it survives a reload, and a self-heal step re-saves
|
|
483
|
+
// a viewer (leaving that reopened project dirty → a SAVE badge). The supersede
|
|
484
|
+
// path likewise closes everything and opens the PRIOR version. Either way the
|
|
485
|
+
// user's original analysis is gone and the workspace is cluttered with
|
|
486
|
+
// verification artifacts. Tear all that down and put them back on their source
|
|
487
|
+
// analysis (table + volcano, plus the enrichment view + charts they had open);
|
|
488
|
+
// the shared copy is one click away in the success dialog's "Open shared analysis".
|
|
489
|
+
pi.description = 'Restoring your analysis...';
|
|
490
|
+
try {
|
|
491
|
+
grok.shell.closeAll();
|
|
492
|
+
await delay(100);
|
|
493
|
+
// Restore the enrichment results view + charts (and its volcano cross-link)
|
|
494
|
+
// FIRST if the user had it open. openEnrichmentVisualization focuses the
|
|
495
|
+
// enrichment tab; adding the protein view next lands the user back on the
|
|
496
|
+
// volcano — the primary deliverable — with enrichment one tab away, exactly
|
|
497
|
+
// as it was pre-publish. Uses enrichSource (the original untrimmed table),
|
|
498
|
+
// not the trimmed publish copy.
|
|
499
|
+
if (enrichSource) {
|
|
500
|
+
try { openEnrichmentVisualization(enrichSource, df); } catch { /* best-effort */ }
|
|
501
|
+
}
|
|
502
|
+
const restoredTv = grok.shell.addTableView(df);
|
|
503
|
+
try {
|
|
504
|
+
restoredTv.addViewer(createVolcanoPlot(df));
|
|
505
|
+
} catch { /* volcano is best-effort — the table view is what matters */ }
|
|
506
|
+
} catch { /* restore is best-effort — never fail a successful publish on cleanup */ }
|
|
507
|
+
|
|
508
|
+
pi.description = 'Done.';
|
|
509
|
+
// No toast here — the share-dialog caller emits the single user-facing
|
|
510
|
+
// confirmation, so publishing stays quiet to avoid a duplicate.
|
|
511
|
+
return project;
|
|
512
|
+
} finally {
|
|
513
|
+
pi.close();
|
|
514
|
+
}
|
|
515
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configurable destination for shared review snapshots.
|
|
3
|
+
*
|
|
4
|
+
* Both values are package settings (declared in `package.json` under `properties`, edited
|
|
5
|
+
* from the package's **Settings** panel in the platform). They are read at share time, so
|
|
6
|
+
* an admin can retarget where reviews land without a code change.
|
|
7
|
+
*
|
|
8
|
+
* What is and isn't configurable, and why:
|
|
9
|
+
* - The **umbrella Space** ({@link reviewSpaceName}) and the **name prefix**
|
|
10
|
+
* ({@link reviewNamePrefix}) are free text.
|
|
11
|
+
* - The structural suffix `-<target-slug>-v<version>-<date>` is NOT configurable: the
|
|
12
|
+
* republish flow finds the prior version by matching `"<prefix>-<slug>-v"` and parsing
|
|
13
|
+
* the `-v<n>-` segment (see `findPriorShare` in `publish-state.ts`). Letting the suffix
|
|
14
|
+
* vary would break version detection. So we expose the prefix, keep the skeleton fixed.
|
|
15
|
+
*
|
|
16
|
+
* Note: changing the prefix after shares already exist orphans the old-prefix versions from
|
|
17
|
+
* republish detection (a fresh share starts at v1 again). That's the expected cost of
|
|
18
|
+
* retargeting; the defaults reproduce the original hard-coded behaviour.
|
|
19
|
+
*/
|
|
20
|
+
import {_package} from '../package';
|
|
21
|
+
|
|
22
|
+
export const DEFAULT_REVIEW_SPACE = 'Proteomics-Reviews';
|
|
23
|
+
export const DEFAULT_REVIEW_NAME_PREFIX = 'Proteomics-Review';
|
|
24
|
+
|
|
25
|
+
/** Reads a string package setting, falling back when unset/blank/not-yet-loaded. */
|
|
26
|
+
function readSetting(key: string, fallback: string): string {
|
|
27
|
+
try {
|
|
28
|
+
const v = (_package?.settings as Record<string, unknown> | undefined)?.[key];
|
|
29
|
+
if (typeof v === 'string' && v.trim().length > 0)
|
|
30
|
+
return v.trim();
|
|
31
|
+
} catch { /* settings not ready — use fallback */ }
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Umbrella Space the per-target review Spaces live under. Setting: `reviewSpaceName`. */
|
|
36
|
+
export function reviewSpaceName(): string {
|
|
37
|
+
return readSetting('reviewSpaceName', DEFAULT_REVIEW_SPACE);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Prefix for the per-target child Space and the published project name.
|
|
41
|
+
* Setting: `reviewNamePrefix`. The `-<slug>-v<version>-<date>` suffix stays fixed. */
|
|
42
|
+
export function reviewNamePrefix(): string {
|
|
43
|
+
return readSetting('reviewNamePrefix', DEFAULT_REVIEW_NAME_PREFIX);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Whether to re-open each published dashboard and assert it survives a reload
|
|
48
|
+
* (the heavy round-trip check). Default ON — keep it for client deliverables;
|
|
49
|
+
* turn it off via the `verifyPublishedDashboard` package setting for faster demo
|
|
50
|
+
* shares. The reviewer-access verify-and-rollback gate is separate and always runs.
|
|
51
|
+
*/
|
|
52
|
+
export function verifyPublishedDashboard(): boolean {
|
|
53
|
+
try {
|
|
54
|
+
const v = (_package?.settings as Record<string, unknown> | undefined)?.['verifyPublishedDashboard'];
|
|
55
|
+
if (v === false || v === 'false') return false;
|
|
56
|
+
if (v === true || v === 'true') return true;
|
|
57
|
+
} catch { /* settings not ready — use default */ }
|
|
58
|
+
return true;
|
|
59
|
+
}
|