@datagrok/proteomics 1.0.1 → 1.2.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.
- 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 +88 -63
- 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 +2073 -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,316 @@
|
|
|
1
|
+
import * as grok from 'datagrok-api/grok';
|
|
2
|
+
import * as DG from 'datagrok-api/dg';
|
|
3
|
+
|
|
4
|
+
import {reviewNamePrefix} from './publish-settings';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Single source of truth for Phase 15's published-tag namespace + pure helpers.
|
|
8
|
+
*
|
|
9
|
+
* Sibling pattern of `src/analysis/experiment-setup.ts`'s `getGroups`/`setGroups`:
|
|
10
|
+
* tag I/O lives in one module, every consumer imports from it, no other file
|
|
11
|
+
* inlines `proteomics.published*` tag string literals. A single typo in a tag
|
|
12
|
+
* string would silently break the reviewer panel; centralizing is the antidote.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** DE-pipeline completion gate. Exported here so Plan 05 dialog precondition
|
|
16
|
+
* and Plan 07 menu handler do not inline the string (I-10 fix). */
|
|
17
|
+
export const DE_COMPLETE_TAG = 'proteomics.de_complete' as const;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Canonical published-tag keyspace. Every tag uses the `proteomics.` prefix.
|
|
21
|
+
* Mirrored 1:1 by {@link META_COLUMNS} below — Plan 02 writes BOTH; Plan 06
|
|
22
|
+
* reads column-first / tag-second (Pitfall 3 belt-and-braces).
|
|
23
|
+
*/
|
|
24
|
+
export const PUBLISHED_TAGS = {
|
|
25
|
+
PUBLISHED: 'proteomics.published',
|
|
26
|
+
PUBLISHED_AT: 'proteomics.published_at',
|
|
27
|
+
PUBLISHED_BY: 'proteomics.published_by',
|
|
28
|
+
PUBLISHED_BY_EMAIL: 'proteomics.published_by_email',
|
|
29
|
+
PUBLISHED_TARGET: 'proteomics.published_target',
|
|
30
|
+
PUBLISHED_DE_METHOD: 'proteomics.published_de_method',
|
|
31
|
+
PUBLISHED_FC_THRESHOLD: 'proteomics.published_fc_threshold',
|
|
32
|
+
PUBLISHED_P_THRESHOLD: 'proteomics.published_p_threshold',
|
|
33
|
+
PUBLISHED_VERSION: 'proteomics.published_version',
|
|
34
|
+
PUBLISHED_ID: 'proteomics.published_id',
|
|
35
|
+
PUBLISHED_INCLUDES_ENRICHMENT: 'proteomics.published_includes_enrichment',
|
|
36
|
+
SUPERSEDED_BY: 'proteomics.superseded_by',
|
|
37
|
+
SUPERSEDES: 'proteomics.supersedes',
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Belt-and-braces metadata column names. One-to-one with {@link PUBLISHED_TAGS}.
|
|
42
|
+
* Plan 02 writes a single-row column per entry alongside the tag; Plan 06
|
|
43
|
+
* reads column FIRST, tag SECOND so a serialization path that strips tags but
|
|
44
|
+
* preserves columns (or vice versa) still recovers the metadata.
|
|
45
|
+
*/
|
|
46
|
+
export const META_COLUMNS = {
|
|
47
|
+
PUBLISHED: '_meta_published',
|
|
48
|
+
PUBLISHED_AT: '_meta_published_at',
|
|
49
|
+
PUBLISHED_BY: '_meta_published_by',
|
|
50
|
+
PUBLISHED_BY_EMAIL: '_meta_published_by_email',
|
|
51
|
+
PUBLISHED_TARGET: '_meta_published_target',
|
|
52
|
+
PUBLISHED_DE_METHOD: '_meta_published_de_method',
|
|
53
|
+
PUBLISHED_FC_THRESHOLD: '_meta_published_fc_threshold',
|
|
54
|
+
PUBLISHED_P_THRESHOLD: '_meta_published_p_threshold',
|
|
55
|
+
PUBLISHED_VERSION: '_meta_published_version',
|
|
56
|
+
PUBLISHED_ID: '_meta_published_id',
|
|
57
|
+
PUBLISHED_INCLUDES_ENRICHMENT: '_meta_published_includes_enrichment',
|
|
58
|
+
SUPERSEDED_BY: '_meta_superseded_by',
|
|
59
|
+
SUPERSEDES: '_meta_supersedes',
|
|
60
|
+
} as const;
|
|
61
|
+
|
|
62
|
+
/** Typed view of one published-analysis DataFrame's metadata. */
|
|
63
|
+
export interface PublishedMetadata {
|
|
64
|
+
/** Raw user input — never use directly for paths/URLs (use {@link slugifyTarget}). */
|
|
65
|
+
target: string;
|
|
66
|
+
/** Normalized to Date when read; ISO string accepted on write. */
|
|
67
|
+
publishedAt: Date;
|
|
68
|
+
publishedBy: string;
|
|
69
|
+
publishedByEmail: string | null;
|
|
70
|
+
deMethod: 'limma' | 'deqms' | 't-test' | 'spectronaut' | string;
|
|
71
|
+
fcThreshold: number;
|
|
72
|
+
pThreshold: number;
|
|
73
|
+
/** Monotonically increasing per (target, group); first share is version 1. */
|
|
74
|
+
version: number;
|
|
75
|
+
/** The published Project's id — set after `projects.save` returns. */
|
|
76
|
+
publishId: string;
|
|
77
|
+
includesEnrichment: boolean;
|
|
78
|
+
/** Prior Project id; null on the first share. */
|
|
79
|
+
supersedes: string | null;
|
|
80
|
+
/** Next Project id; set retroactively on the prior version when republishing. */
|
|
81
|
+
supersededBy: string | null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Input shape consumed by `share-dialog.ts` → `publishAnalysis(df, opts)`. */
|
|
85
|
+
export interface PublishOptions {
|
|
86
|
+
target: string;
|
|
87
|
+
reviewerGroup: DG.Group;
|
|
88
|
+
note: string;
|
|
89
|
+
/** Set by {@link findPriorShare} when this is a republish; null on first share. */
|
|
90
|
+
priorVersion: DG.Project | null;
|
|
91
|
+
/**
|
|
92
|
+
* Test-only override for the umbrella Space name.
|
|
93
|
+
* Defaults to `'Proteomics-Reviews'`. Production callers never set this;
|
|
94
|
+
* Plan 08 Test 5 uses it to inject a throwaway umbrella.
|
|
95
|
+
*/
|
|
96
|
+
umbrellaName?: string;
|
|
97
|
+
/**
|
|
98
|
+
* Whether to run the reopen-and-verify round trip after publishing. Set by the
|
|
99
|
+
* share dialog's "Verify published dashboard" checkbox (which defaults to the
|
|
100
|
+
* `verifyPublishedDashboard` package setting). When undefined (non-dialog callers),
|
|
101
|
+
* `publishAnalysis` falls back to the package setting.
|
|
102
|
+
*/
|
|
103
|
+
verify?: boolean;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Input shape for {@link buildMailtoUrl}. Pure data — no DOM. */
|
|
107
|
+
export interface MailtoOptions {
|
|
108
|
+
sharerEmail: string | null;
|
|
109
|
+
sharerName: string;
|
|
110
|
+
projectName: string;
|
|
111
|
+
publishedDateStr: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Returns true iff `proteomics.published === 'true'`. Mirror of the
|
|
115
|
+
* `proteomics.de_complete === 'true'` idiom in `viewers/heatmap.ts`. */
|
|
116
|
+
export function isPublished(df: DG.DataFrame): boolean {
|
|
117
|
+
return df.getTag(PUBLISHED_TAGS.PUBLISHED) === 'true';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Reads the published metadata from a DataFrame, column-FIRST and tag-SECOND
|
|
122
|
+
* (Pitfall 3 belt-and-braces). Returns `null` when the DataFrame is not a
|
|
123
|
+
* published analysis. Returns a populated object when at least the required
|
|
124
|
+
* load-bearing fields (target, publishId) are recoverable; per-field corruption
|
|
125
|
+
* falls back gracefully without crashing the whole read.
|
|
126
|
+
*/
|
|
127
|
+
export function getPublishedMetadata(df: DG.DataFrame): PublishedMetadata | null {
|
|
128
|
+
if (!isPublished(df)) return null;
|
|
129
|
+
|
|
130
|
+
const readString = (colKey: keyof typeof META_COLUMNS, tagKey: keyof typeof PUBLISHED_TAGS): string | null => {
|
|
131
|
+
try {
|
|
132
|
+
const col = df.col(META_COLUMNS[colKey]);
|
|
133
|
+
if (col != null) {
|
|
134
|
+
const v = col.get(0);
|
|
135
|
+
if (v != null && v !== '') return String(v);
|
|
136
|
+
}
|
|
137
|
+
} catch { /* fall through to tag */ }
|
|
138
|
+
try {
|
|
139
|
+
return df.getTag(PUBLISHED_TAGS[tagKey]) ?? null;
|
|
140
|
+
} catch {
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const readDate = (): Date | null => {
|
|
146
|
+
try {
|
|
147
|
+
const col = df.col(META_COLUMNS.PUBLISHED_AT);
|
|
148
|
+
if (col != null) {
|
|
149
|
+
const v = col.get(0);
|
|
150
|
+
if (v instanceof Date) return v;
|
|
151
|
+
if (typeof v === 'string' && v) return new Date(v);
|
|
152
|
+
if (typeof v === 'number') return new Date(v);
|
|
153
|
+
}
|
|
154
|
+
} catch { /* fall through */ }
|
|
155
|
+
try {
|
|
156
|
+
const raw = df.getTag(PUBLISHED_TAGS.PUBLISHED_AT);
|
|
157
|
+
if (raw) return new Date(raw);
|
|
158
|
+
} catch { /* return null */ }
|
|
159
|
+
return null;
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const readNumber = (colKey: keyof typeof META_COLUMNS, tagKey: keyof typeof PUBLISHED_TAGS): number => {
|
|
163
|
+
const s = readString(colKey, tagKey);
|
|
164
|
+
if (s == null) return NaN;
|
|
165
|
+
const n = parseFloat(s);
|
|
166
|
+
return Number.isFinite(n) ? n : NaN;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const readBool = (colKey: keyof typeof META_COLUMNS, tagKey: keyof typeof PUBLISHED_TAGS): boolean => {
|
|
170
|
+
const s = readString(colKey, tagKey);
|
|
171
|
+
return s === 'true';
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
const target = readString('PUBLISHED_TARGET', 'PUBLISHED_TARGET');
|
|
175
|
+
const publishId = readString('PUBLISHED_ID', 'PUBLISHED_ID');
|
|
176
|
+
if (target == null || publishId == null) return null;
|
|
177
|
+
|
|
178
|
+
const publishedAt = readDate();
|
|
179
|
+
const versionRaw = readString('PUBLISHED_VERSION', 'PUBLISHED_VERSION');
|
|
180
|
+
const versionNum = versionRaw != null ? parseInt(versionRaw, 10) : NaN;
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
target,
|
|
184
|
+
publishedAt: publishedAt ?? new Date(NaN),
|
|
185
|
+
publishedBy: readString('PUBLISHED_BY', 'PUBLISHED_BY') ?? '',
|
|
186
|
+
publishedByEmail: readString('PUBLISHED_BY_EMAIL', 'PUBLISHED_BY_EMAIL'),
|
|
187
|
+
deMethod: readString('PUBLISHED_DE_METHOD', 'PUBLISHED_DE_METHOD') ?? '',
|
|
188
|
+
fcThreshold: readNumber('PUBLISHED_FC_THRESHOLD', 'PUBLISHED_FC_THRESHOLD'),
|
|
189
|
+
pThreshold: readNumber('PUBLISHED_P_THRESHOLD', 'PUBLISHED_P_THRESHOLD'),
|
|
190
|
+
version: Number.isFinite(versionNum) ? versionNum : 1,
|
|
191
|
+
publishId,
|
|
192
|
+
includesEnrichment: readBool('PUBLISHED_INCLUDES_ENRICHMENT', 'PUBLISHED_INCLUDES_ENRICHMENT'),
|
|
193
|
+
supersedes: readString('SUPERSEDES', 'SUPERSEDES'),
|
|
194
|
+
supersededBy: readString('SUPERSEDED_BY', 'SUPERSEDED_BY'),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Writes every {@link PUBLISHED_TAGS} entry as a string tag on the DataFrame.
|
|
200
|
+
* Pitfall 3 mitigation: even though spike 15-00 showed all 14 tags survive the
|
|
201
|
+
* basic save→find→open path, `df.clone()` may not carry the full tag map;
|
|
202
|
+
* Plan 02 calls this AFTER cloning to guarantee a complete tag namespace.
|
|
203
|
+
*
|
|
204
|
+
* Null `supersedes` / `supersededBy` are intentionally NOT written so reader
|
|
205
|
+
* can disambiguate "missing" from "empty". The PUBLISHED tag is always set to
|
|
206
|
+
* 'true' regardless of the metadata input.
|
|
207
|
+
*/
|
|
208
|
+
export function setPublishedTags(df: DG.DataFrame, meta: PublishedMetadata): void {
|
|
209
|
+
df.setTag(PUBLISHED_TAGS.PUBLISHED, 'true');
|
|
210
|
+
df.setTag(PUBLISHED_TAGS.PUBLISHED_AT,
|
|
211
|
+
meta.publishedAt instanceof Date ? meta.publishedAt.toISOString() : String(meta.publishedAt));
|
|
212
|
+
df.setTag(PUBLISHED_TAGS.PUBLISHED_BY, meta.publishedBy);
|
|
213
|
+
if (meta.publishedByEmail != null)
|
|
214
|
+
df.setTag(PUBLISHED_TAGS.PUBLISHED_BY_EMAIL, meta.publishedByEmail);
|
|
215
|
+
df.setTag(PUBLISHED_TAGS.PUBLISHED_TARGET, meta.target);
|
|
216
|
+
df.setTag(PUBLISHED_TAGS.PUBLISHED_DE_METHOD, meta.deMethod);
|
|
217
|
+
df.setTag(PUBLISHED_TAGS.PUBLISHED_FC_THRESHOLD, String(meta.fcThreshold));
|
|
218
|
+
df.setTag(PUBLISHED_TAGS.PUBLISHED_P_THRESHOLD, String(meta.pThreshold));
|
|
219
|
+
df.setTag(PUBLISHED_TAGS.PUBLISHED_VERSION, String(meta.version));
|
|
220
|
+
df.setTag(PUBLISHED_TAGS.PUBLISHED_ID, meta.publishId);
|
|
221
|
+
df.setTag(PUBLISHED_TAGS.PUBLISHED_INCLUDES_ENRICHMENT, meta.includesEnrichment ? 'true' : 'false');
|
|
222
|
+
if (meta.supersedes != null)
|
|
223
|
+
df.setTag(PUBLISHED_TAGS.SUPERSEDES, meta.supersedes);
|
|
224
|
+
if (meta.supersededBy != null)
|
|
225
|
+
df.setTag(PUBLISHED_TAGS.SUPERSEDED_BY, meta.supersededBy);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Sanitizes a freeform target string per D-01 into a safe slug.
|
|
230
|
+
*
|
|
231
|
+
* Rules:
|
|
232
|
+
* - Charset `[A-Za-z0-9._-]` (case PRESERVED — do NOT lowercase)
|
|
233
|
+
* - Replace runs of disallowed chars with single `-`
|
|
234
|
+
* - Drop leading/trailing `-` or `.`
|
|
235
|
+
* - Cap at 64 chars
|
|
236
|
+
* - Empty result → `'unnamed'` (never produce empty slug — would break Project.name)
|
|
237
|
+
*/
|
|
238
|
+
export function slugifyTarget(raw: string): string {
|
|
239
|
+
if (raw == null) return 'unnamed';
|
|
240
|
+
let s = String(raw)
|
|
241
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
242
|
+
.replace(/-{2,}/g, '-')
|
|
243
|
+
.replace(/^[-.]+|[-.]+$/g, '')
|
|
244
|
+
.slice(0, 64)
|
|
245
|
+
.replace(/[-.]+$/g, '');
|
|
246
|
+
if (s.length === 0) s = 'unnamed';
|
|
247
|
+
return s;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Looks up the most recent prior published Project matching the slugified
|
|
252
|
+
* target. Used by Plan 05 (`share-dialog.ts`) for republish-detection banner
|
|
253
|
+
* and Plan 04 (`publish-project.ts`) for the supersede chain.
|
|
254
|
+
*
|
|
255
|
+
* Smart-filter `like` confirmed working by spike 15-00 (assumption A8); the
|
|
256
|
+
* fallback `list()` + client-side filter path is retained as defense in depth
|
|
257
|
+
* in case the platform smart-filter parser changes.
|
|
258
|
+
*
|
|
259
|
+
* NOT a security gate — purely informational. T-15-05 mitigation relies on
|
|
260
|
+
* the platform ACL implicitly scoping `dapi.projects.filter` to projects the
|
|
261
|
+
* current user can administer.
|
|
262
|
+
*/
|
|
263
|
+
export async function findPriorShare(target: string, _group: DG.Group | null): Promise<DG.Project | null> {
|
|
264
|
+
const slug = slugifyTarget(target);
|
|
265
|
+
const namePrefix = `${reviewNamePrefix()}-${slug}-v`;
|
|
266
|
+
const versionRe = /-v(\d+)-/;
|
|
267
|
+
|
|
268
|
+
const pickLatest = (cands: DG.Project[]): DG.Project | null => {
|
|
269
|
+
let best: DG.Project | null = null;
|
|
270
|
+
let bestV = -1;
|
|
271
|
+
for (const p of cands) {
|
|
272
|
+
const name = (p as any)?.name ?? '';
|
|
273
|
+
const m = versionRe.exec(name);
|
|
274
|
+
if (!m) continue;
|
|
275
|
+
const v = parseInt(m[1], 10);
|
|
276
|
+
if (Number.isFinite(v) && v > bestV) {
|
|
277
|
+
bestV = v;
|
|
278
|
+
best = p;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
return best;
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
const namePattern = `${namePrefix}%`;
|
|
286
|
+
const candidates = await grok.dapi.projects.filter(`name like "${namePattern}"`).list();
|
|
287
|
+
if (Array.isArray(candidates) && candidates.length > 0)
|
|
288
|
+
return pickLatest(candidates);
|
|
289
|
+
} catch { /* fall through to client-side filter */ }
|
|
290
|
+
|
|
291
|
+
try {
|
|
292
|
+
const all = await grok.dapi.projects.list();
|
|
293
|
+
const matching = (Array.isArray(all) ? all : [])
|
|
294
|
+
.filter((p) => ((p as any)?.name ?? '').startsWith(namePrefix));
|
|
295
|
+
if (matching.length > 0) return pickLatest(matching);
|
|
296
|
+
} catch { /* return null */ }
|
|
297
|
+
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Pure helper for the reviewer panel's "Request re-run" button (PUB-13).
|
|
303
|
+
* Builds an `encodeURIComponent`-safe `mailto:` URL with the sharer's email,
|
|
304
|
+
* a project-aware subject, and a body that names the project + share date.
|
|
305
|
+
*
|
|
306
|
+
* Co-located here rather than in `share-dialog.ts` because Plan 06 (wave 2)
|
|
307
|
+
* needs it BEFORE Plan 05 (wave 4) lands — importing wave 4 from wave 2 would
|
|
308
|
+
* break the wave graph. No DOM / UI / dialog dependency.
|
|
309
|
+
*/
|
|
310
|
+
export function buildMailtoUrl(opts: MailtoOptions): string {
|
|
311
|
+
const subject = `Re-run request: ${opts.projectName}`;
|
|
312
|
+
const body = `Hi ${opts.sharerName}, could you re-run with [different parameters]? ` +
|
|
313
|
+
`Looking at ${opts.projectName} (shared ${opts.publishedDateStr}).`;
|
|
314
|
+
const to = opts.sharerEmail ? encodeURIComponent(opts.sharerEmail) : '';
|
|
315
|
+
return `mailto:${to}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
|
|
316
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import * as grok from 'datagrok-api/grok';
|
|
2
|
+
import * as ui from 'datagrok-api/ui';
|
|
3
|
+
import * as DG from 'datagrok-api/dg';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
DE_COMPLETE_TAG, PublishOptions, findPriorShare, slugifyTarget,
|
|
7
|
+
} from './publish-state';
|
|
8
|
+
import {publishAnalysis} from './publish-project';
|
|
9
|
+
import {reviewNamePrefix, verifyPublishedDashboard} from './publish-settings';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Analyst-facing share dialog. Single entry point into the publish flow —
|
|
13
|
+
* every reviewer-touchable string flows through here (target label, group
|
|
14
|
+
* label, note placeholder, republish banner, confirmation summary, error
|
|
15
|
+
* messages, success state). The Pitfall 14 biologist-jargon audit is
|
|
16
|
+
* enforced here.
|
|
17
|
+
*
|
|
18
|
+
* Async (`Promise<void>`) per W-6 — loads reviewer groups via
|
|
19
|
+
* `dapi.groups.list()` before composing the dialog. Plan 07 menu handler
|
|
20
|
+
* must `await` (or `void`-fire) it.
|
|
21
|
+
*
|
|
22
|
+
* - Republish-detection banner reactively driven by {@link findPriorShare}
|
|
23
|
+
* - PUB-08 reactive confirmation summary (W-4): always-visible block that
|
|
24
|
+
* updates on every input change so the analyst sees the exact resulting
|
|
25
|
+
* Project name / team / supersede status BEFORE clicking OK
|
|
26
|
+
* - Mailto link in the success state (PUB-13, P2) uses {@link buildMailtoUrl}
|
|
27
|
+
* imported from `./publish-state` (NOT redefined here per B-1)
|
|
28
|
+
*/
|
|
29
|
+
export async function showShareForReviewDialog(df: DG.DataFrame): Promise<void> {
|
|
30
|
+
if (df.getTag(DE_COMPLETE_TAG) !== 'true') {
|
|
31
|
+
grok.shell.warning('Run Differential Expression first.');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const allGroups = await grok.dapi.groups.list();
|
|
36
|
+
const allUsersId = (DG.Group as any).defaultGroupsIds?.['All users'];
|
|
37
|
+
const filteredGroups = (allGroups ?? []).filter((g: any) => {
|
|
38
|
+
if (g == null) return false;
|
|
39
|
+
if (g.hidden) return false;
|
|
40
|
+
if (g.personal) return false;
|
|
41
|
+
if (allUsersId && g.id === allUsersId) return false;
|
|
42
|
+
return true;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (filteredGroups.length === 0) {
|
|
46
|
+
grok.shell.warning('No teams available to share with. Ask an admin to create a reviewer team.');
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const groupByName = new Map<string, DG.Group>();
|
|
51
|
+
for (const g of filteredGroups) {
|
|
52
|
+
const name = (g as any).friendlyName ?? (g as any).name ?? '';
|
|
53
|
+
if (name && !groupByName.has(name)) groupByName.set(name, g);
|
|
54
|
+
}
|
|
55
|
+
const groupItems = Array.from(groupByName.keys());
|
|
56
|
+
|
|
57
|
+
const targetInput = ui.input.string('Target', {value: ''});
|
|
58
|
+
targetInput.setTooltip('Free text — your team\'s name for this target (e.g., MYH7-DMD, muscle-atrophy panel)');
|
|
59
|
+
|
|
60
|
+
const groupInput = ui.input.choice('Share with team', {
|
|
61
|
+
value: groupItems[0],
|
|
62
|
+
items: groupItems,
|
|
63
|
+
nullable: false,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const noteInput = (ui.input as any).textArea
|
|
67
|
+
? (ui.input as any).textArea('Note for reviewers', {value: ''})
|
|
68
|
+
: ui.input.string('Note for reviewers', {value: ''});
|
|
69
|
+
|
|
70
|
+
// Per-share verification toggle. Defaults to the package setting
|
|
71
|
+
// (`verifyPublishedDashboard`), but lets the user skip the heavy reopen-and-check
|
|
72
|
+
// round trip for a fast demo share without changing the deployment default.
|
|
73
|
+
const verifyInput = ui.input.bool('Verify published dashboard', {value: verifyPublishedDashboard()});
|
|
74
|
+
verifyInput.setTooltip(
|
|
75
|
+
'Re-open the shared dashboard after publishing to confirm it survives a reload. ' +
|
|
76
|
+
'On = safer (recommended for client deliverables); off = faster share.');
|
|
77
|
+
|
|
78
|
+
const banner = ui.div();
|
|
79
|
+
banner.style.cssText = 'background:#fff3cd; padding:8px; border-radius:4px; margin-bottom:8px; display:none;';
|
|
80
|
+
|
|
81
|
+
const summary = ui.div();
|
|
82
|
+
summary.style.cssText = 'background:#f0f0f0; padding:8px; border-radius:4px; margin:8px 0; font-family: monospace; font-size:11px;';
|
|
83
|
+
|
|
84
|
+
let priorVersion: DG.Project | null = null;
|
|
85
|
+
|
|
86
|
+
const parsePriorVersionN = (priorName: string | null | undefined): number => {
|
|
87
|
+
if (!priorName) return 0;
|
|
88
|
+
const m = /-v(\d+)-/.exec(priorName);
|
|
89
|
+
if (!m) return 0;
|
|
90
|
+
const n = parseInt(m[1], 10);
|
|
91
|
+
return Number.isFinite(n) ? n : 0;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const updateBannerAndSummary = async (): Promise<void> => {
|
|
95
|
+
const target = (targetInput.value ?? '').trim();
|
|
96
|
+
const groupName = groupInput.value;
|
|
97
|
+
const group = groupName ? groupByName.get(groupName) ?? null : null;
|
|
98
|
+
const slug = target ? slugifyTarget(target) : '<no-target>';
|
|
99
|
+
const dateStr = new Date().toISOString().slice(0, 10);
|
|
100
|
+
|
|
101
|
+
if (target && group) {
|
|
102
|
+
try { priorVersion = await findPriorShare(target, group); }
|
|
103
|
+
catch { priorVersion = null; }
|
|
104
|
+
} else {
|
|
105
|
+
priorVersion = null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const priorN = parsePriorVersionN(priorVersion ? (priorVersion as any).name : null);
|
|
109
|
+
const nextVersion = priorN > 0 ? priorN + 1 : 1;
|
|
110
|
+
|
|
111
|
+
if (priorVersion) {
|
|
112
|
+
banner.textContent =
|
|
113
|
+
`⚠ This will share as v${nextVersion} and supersede "${(priorVersion as any).name}". ` +
|
|
114
|
+
`The previous version stays available for reference.`;
|
|
115
|
+
banner.style.display = 'block';
|
|
116
|
+
} else {
|
|
117
|
+
banner.style.display = 'none';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const projectName = `${reviewNamePrefix()}-${slug}-v${nextVersion}-${dateStr}`;
|
|
121
|
+
const groupLabel = group ? ((group as any).friendlyName ?? (group as any).name ?? '<unnamed team>') : '<pick a team>';
|
|
122
|
+
const statusLabel = priorVersion
|
|
123
|
+
? `Will supersede "${(priorVersion as any).name}" (v${nextVersion})`
|
|
124
|
+
: 'New share';
|
|
125
|
+
|
|
126
|
+
summary.innerHTML = '';
|
|
127
|
+
summary.appendChild(ui.divText(`Project: ${projectName}`));
|
|
128
|
+
summary.appendChild(ui.divText(`Will be visible to: ${groupLabel}`));
|
|
129
|
+
summary.appendChild(ui.divText(`Status: ${statusLabel}`));
|
|
130
|
+
summary.appendChild(ui.divText(`Date: ${dateStr}`));
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
targetInput.onChanged.subscribe(() => { void updateBannerAndSummary(); });
|
|
134
|
+
groupInput.onChanged.subscribe(() => { void updateBannerAndSummary(); });
|
|
135
|
+
|
|
136
|
+
await updateBannerAndSummary();
|
|
137
|
+
|
|
138
|
+
ui.dialog('Share Analysis for Review')
|
|
139
|
+
.add(targetInput)
|
|
140
|
+
.add(groupInput)
|
|
141
|
+
.add(noteInput)
|
|
142
|
+
.add(verifyInput)
|
|
143
|
+
.add(banner)
|
|
144
|
+
.add(summary)
|
|
145
|
+
.onOK(async () => {
|
|
146
|
+
const target = (targetInput.value ?? '').trim();
|
|
147
|
+
if (!target) { grok.shell.warning('Target is required.'); return; }
|
|
148
|
+
const group = groupInput.value ? groupByName.get(groupInput.value) : null;
|
|
149
|
+
if (!group) { grok.shell.warning('Pick a team to share with.'); return; }
|
|
150
|
+
|
|
151
|
+
const opts: PublishOptions = {
|
|
152
|
+
target,
|
|
153
|
+
reviewerGroup: group,
|
|
154
|
+
note: (noteInput.value ?? '') as string,
|
|
155
|
+
priorVersion,
|
|
156
|
+
verify: verifyInput.value ?? true,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const project = await publishAnalysis(df, opts);
|
|
161
|
+
const groupName = (group as any).friendlyName ?? (group as any).name ?? '(unnamed team)';
|
|
162
|
+
// Single non-modal confirmation — the published project is browsable in the
|
|
163
|
+
// Spaces tree, so no modal or action links are needed. publishAnalysis no
|
|
164
|
+
// longer emits its own toast, so this is the one and only confirmation.
|
|
165
|
+
grok.shell.info(`Shared as ${(project as any).name} with team ${groupName}.`);
|
|
166
|
+
} catch (e: any) {
|
|
167
|
+
grok.shell.error(`Share failed: ${e?.message ?? String(e)}`);
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
.show();
|
|
171
|
+
}
|