@datagrok/bio 2.26.6 → 2.26.7

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/package.json CHANGED
@@ -5,7 +5,7 @@
5
5
  "name": "Davit Rizhinashvili",
6
6
  "email": "drizhinashvili@datagrok.ai"
7
7
  },
8
- "version": "2.26.6",
8
+ "version": "2.26.7",
9
9
  "description": "Bioinformatics support (import/export of sequences, conversion, visualization, analysis). [See more](https://github.com/datagrok-ai/public/blob/master/packages/Bio/README.md) for details.",
10
10
  "repository": {
11
11
  "type": "git",
@@ -121,6 +121,10 @@ export namespace funcs {
121
121
  return await grok.functions.call('Bio:CompositionAnalysisWidget', { sequence });
122
122
  }
123
123
 
124
+ export async function monomerInfoPanel(monomerSv: any ): Promise<any> {
125
+ return await grok.functions.call('Bio:MonomerInfoPanel', { monomerSv });
126
+ }
127
+
124
128
  export async function macromoleculeDifferenceCellRenderer(): Promise<any> {
125
129
  return await grok.functions.call('Bio:MacromoleculeDifferenceCellRenderer', {});
126
130
  }
@@ -380,8 +384,8 @@ export namespace funcs {
380
384
  return await grok.functions.call('Bio:ManageMonomersView', {});
381
385
  }
382
386
 
383
- export async function manageMonomerLibrariesView(): Promise<DG.View> {
384
- return await grok.functions.call('Bio:ManageMonomerLibrariesView', {});
387
+ export async function manageMonomerLibrariesView(path?: string ): Promise<DG.View> {
388
+ return await grok.functions.call('Bio:ManageMonomerLibrariesView', { path });
385
389
  }
386
390
 
387
391
  export async function manageMonomerLibrariesViewTreeBrowser(treeNode: any ): Promise<void> {
package/src/package.g.ts CHANGED
@@ -160,6 +160,16 @@ export function compositionAnalysisWidget(sequence: DG.SemanticValue) : any {
160
160
  return PackageFunctions.compositionAnalysisWidget(sequence);
161
161
  }
162
162
 
163
+ //name: Monomer
164
+ //tags: bio, panel
165
+ //input: semantic_value monomerSv { semType: Monomer }
166
+ //output: widget result
167
+ //meta.domain: bio
168
+ //meta.role: panel
169
+ export function monomerInfoPanel(monomerSv: DG.SemanticValue) : any {
170
+ return PackageFunctions.monomerInfoPanel(monomerSv);
171
+ }
172
+
163
173
  //name: MacromoleculeDifferenceCellRenderer
164
174
  //tags: cellRenderer
165
175
  //output: grid_cell_renderer result
@@ -599,12 +609,13 @@ export async function manageMonomersView() : Promise<void> {
599
609
 
600
610
  //name: Manage Monomer Libraries
601
611
  //tags: app
612
+ //input: string path { meta.url: true; optional: true }
602
613
  //output: view result
603
614
  //meta.role: app
604
615
  //meta.browsePath: Peptides
605
616
  //meta.icon: files/icons/monomers.png
606
- export async function manageMonomerLibrariesView() : Promise<any> {
607
- return await PackageFunctions.manageMonomerLibrariesView();
617
+ export async function manageMonomerLibrariesView(path?: string) : Promise<any> {
618
+ return await PackageFunctions.manageMonomerLibrariesView(path);
608
619
  }
609
620
 
610
621
  //name: Monomer Manager Tree Browser
package/src/package.ts CHANGED
@@ -44,10 +44,12 @@ import {SequenceDiversityViewer} from './analysis/sequence-diversity-viewer';
44
44
  import {invalidateMols, MONOMERIC_COL_TAGS, SubstructureSearchDialog} from './substructure-search/substructure-search';
45
45
  import {convert} from './utils/convert';
46
46
  import {getMacromoleculeColumnPropertyPanel} from './widgets/representations';
47
+ import {getMonomerInfoWidget} from './widgets/monomer-info-widget';
47
48
  import {saveAsFastaUI} from './utils/save-as-fasta';
48
49
  import {BioSubstructureFilter} from './widgets/bio-substructure-filter';
49
50
  import {WebLogoViewer} from './viewers/web-logo-viewer';
50
51
  import {MonomerLibManager} from './utils/monomer-lib/lib-manager';
52
+ import {MonomerCollectionHandler} from './utils/monomer-lib/monomer-collection-handler';
51
53
  import {getMonomerLibraryManagerLink, showManageLibrariesDialog, showManageLibrariesView} from './utils/monomer-lib/library-file-manager/ui';
52
54
  import {demoBioSimDiv} from './demo/bio01-similarity-diversity';
53
55
  import {demoSeqSpace} from './demo/bio01a-hierarchical-clustering-and-sequence-space';
@@ -382,6 +384,15 @@ export class PackageFunctions {
382
384
  return getCompositionAnalysisWidget(sequence, _package.monomerLib, _package.seqHelper);
383
385
  }
384
386
 
387
+ @grok.decorators.panel({name: 'Monomer',
388
+ tags: ['bio', 'panel'],
389
+ meta: {domain: 'bio'},
390
+ })
391
+ static monomerInfoPanel(
392
+ @grok.decorators.param({options: {semType: 'Monomer'}}) monomerSv: DG.SemanticValue): DG.Widget {
393
+ return getMonomerInfoWidget(monomerSv.value as string, _package.monomerLib);
394
+ }
395
+
385
396
  @grok.decorators.func({
386
397
  name: 'MacromoleculeDifferenceCellRenderer',
387
398
  tags: ['cellRenderer'],
@@ -1309,8 +1320,12 @@ export class PackageFunctions {
1309
1320
  browsePath: 'Peptides',
1310
1321
  icon: 'files/icons/monomers.png',
1311
1322
  })
1312
- static async manageMonomerLibrariesView(): Promise<DG.View> {
1313
- return await showManageLibrariesView(false);
1323
+ static async manageMonomerLibrariesView(
1324
+ @grok.decorators.param({options: {metaUrl: true, optional: true}}) path?: string
1325
+ ): Promise<DG.View> {
1326
+ const res = await showManageLibrariesView(false);
1327
+ res.parentCall = grok.functions.getCurrentCall();
1328
+ return res;
1314
1329
  }
1315
1330
 
1316
1331
  // @grok.decorators.func({tags: ['monomer-lib-provider'], result: {type: 'object', name: 'result'}})
@@ -1327,7 +1342,8 @@ export class PackageFunctions {
1327
1342
  // eslint-disable-next-line rxjs/no-ignored-subscription, rxjs/no-async-subscribe
1328
1343
  libNode.onSelected.subscribe(async () => {
1329
1344
  const monomerManager = await MonomerManager.getInstance();
1330
- await monomerManager.getViewRoot(libName, true);
1345
+ const res = await monomerManager.getViewRoot(libName, true);
1346
+ res.parentCall = grok.functions.getCurrentCall();
1331
1347
  monomerManager.resetCurrentRowFollowing();
1332
1348
  });
1333
1349
  });
@@ -1667,6 +1683,9 @@ async function initBioInt() {
1667
1683
 
1668
1684
  // hydrophobPalette = new SeqPaletteCustom(palette);
1669
1685
 
1686
+ // Register object handlers
1687
+ DG.ObjectHandler.register(new MonomerCollectionHandler());
1688
+
1670
1689
  _package.logger.debug(`${logPrefix}, end`);
1671
1690
  handleSequenceHeaderRendering();
1672
1691
  }
@@ -14,11 +14,8 @@ import {undefinedColor} from '@datagrok-libraries/bio/src/utils/cell-renderer-mo
14
14
  import {HelmType, HelmTypes, PolymerType, PolymerTypes} from '@datagrok-libraries/js-draw-lite/src/types/org';
15
15
  import {polymerTypeToHelmType} from '@datagrok-libraries/bio/src/utils/macromolecule/utils';
16
16
 
17
- const Tags = new class {
18
- tooltipHandlerTemp = 'tooltip-handler.Monomer';
19
- }();
20
-
21
17
  const DASH_GAP_SYMBOL = '-';
18
+ const MONOMER_MARGIN = 2;
22
19
 
23
20
  export class MonomerCellRendererBack extends CellRendererWithMonomerLibBackBase {
24
21
  constructor(gridCol: DG.GridColumn | null, tableCol: DG.Column) {
@@ -68,6 +65,8 @@ export class MonomerCellRendererBack extends CellRendererWithMonomerLibBackBase
68
65
  g.font = `12px monospace`;
69
66
  g.textBaseline = 'middle';
70
67
  g.textAlign = 'left';
68
+ const mMet = g.measureText('M');
69
+ const lineHeight = mMet.actualBoundingBoxAscent + mMet.actualBoundingBoxDescent + MONOMER_MARGIN * 4;
71
70
 
72
71
  let value: string = gridCell.cell.value;
73
72
  // render original value
@@ -80,10 +79,11 @@ export class MonomerCellRendererBack extends CellRendererWithMonomerLibBackBase
80
79
  //cell width of monomer should dictate how many characters can be displayed
81
80
  // for width 40, 6 characters can be displayed (0.15 is 6 / 40)
82
81
  const shortSymbols = symbols.map((s) => monomerToShort(s, Math.max(2, Math.floor(w * 0.15 / symbols.length))));
83
- const symbolWidths = shortSymbols.map((s) => g.measureText(s).width);
82
+ const symbolWidths = shortSymbols.map((s) => g.measureText(s).width + MONOMER_MARGIN * 2);
83
+ // symbolWidths[symbolWidths.length - 1] -= MONOMER_MARGIN;
84
84
  const totalWidth = symbolWidths.reduce((a, b) => a + b, 0);
85
85
  const xOffset = (w - totalWidth) / 2;
86
- let xPos = x + xOffset;
86
+ let xPos = x + xOffset + MONOMER_MARGIN;
87
87
  const alphabet = this.tableCol.getTag(bioTAGS.alphabet);
88
88
  const biotype = alphabet === ALPHABET.RNA || alphabet === ALPHABET.DNA ? HelmTypes.NUCLEOTIDE : HelmTypes.AA;
89
89
  for (let i = 0; i < shortSymbols.length; i++) {
@@ -100,9 +100,15 @@ export class MonomerCellRendererBack extends CellRendererWithMonomerLibBackBase
100
100
  } else
101
101
  textcolor = this.monomerLib.getMonomerTextColor(actBioType, symbol);
102
102
  }
103
- if (applyToBackground && symbols.length == 1) {
103
+ if (applyToBackground) {
104
104
  g.fillStyle = backgroundcolor;
105
- g.fillRect(x, y, w, h);
105
+ if (w < 30 || h < 30)
106
+ g.fillRect(x + i * w / shortSymbols.length, y, w / shortSymbols.length, h);
107
+ else {
108
+ const radius = MONOMER_MARGIN;
109
+ g.roundRect(xPos - MONOMER_MARGIN, y + (h / 2) - lineHeight / 2, symbolWidths[i], lineHeight, radius);
110
+ g.fill();
111
+ }
106
112
  }
107
113
  g.fillStyle = textcolor;
108
114
  g.fillText(shortSymbols[i], xPos, y + (h / 2), w);
@@ -83,7 +83,8 @@ class MonomerLibraryManagerWidget {
83
83
 
84
84
  private async getWidgetContent(): Promise<HTMLElement> {
85
85
  const libControlsForm = await LibraryControlsManager.createControlsForm();
86
- $(libControlsForm).addClass('monomer-lib-controls-form');
86
+ $(libControlsForm).addClass('monomer-lib-controls-form')
87
+ .addClass('d4-dialog-contents');
87
88
  setTimeout(() => {
88
89
  libControlsForm && $(libControlsForm) && $(libControlsForm).removeClass('ui-form-condensed');
89
90
  }, 200);
@@ -174,7 +175,7 @@ class LibraryControlsManager {
174
175
  private async _createControlsForm(): Promise<HTMLElement> {
175
176
  const libraryControls = await this.createLibraryControls();
176
177
  const inputsForm = ui.wideForm(libraryControls, undefined);
177
- $(inputsForm).addClass('monomer-lib-controls-form');
178
+ $(inputsForm).addClass('monomer-lib-controls-form').addClass('d4-dialog-contents');
178
179
 
179
180
  return inputsForm;
180
181
  }
@@ -0,0 +1,359 @@
1
+ /* eslint-disable max-len */
2
+ import * as grok from 'datagrok-api/grok';
3
+ import * as ui from 'datagrok-api/ui';
4
+ import * as DG from 'datagrok-api/dg';
5
+
6
+ import {MonomerCollection, IMonomerLib} from '@datagrok-libraries/bio/src/types/monomer-library';
7
+ import {HelmType, PolymerType} from '@datagrok-libraries/bio/src/helm/types';
8
+ import {HelmTypes} from '@datagrok-libraries/bio/src/helm/consts';
9
+ import {MonomerSelectionWidget} from '@datagrok-libraries/bio/src/utils/monomer-selection-dialog';
10
+
11
+ import {MonomerLibManager} from './lib-manager';
12
+ import {SEM_TYPES} from '../constants';
13
+ import {_package} from '../../package';
14
+
15
+ /** Wrapper that pairs a collection name with its loaded data. */
16
+ export interface MonomerCollectionInfo {
17
+ name: string; // file name (with .json)
18
+ displayName: string; // name without .json
19
+ data: MonomerCollection;
20
+ }
21
+
22
+ const HANDLER_TYPE = 'MonomerCollection';
23
+
24
+ /**
25
+ * ObjectHandler for one or more monomer collections.
26
+ * When a single collection is selected it renders details in the context panel.
27
+ * When multiple collections are selected it renders merge / bulk-delete actions.
28
+ */
29
+ export class MonomerCollectionHandler extends DG.ObjectHandler<MonomerCollectionInfo | MonomerCollectionInfo[]> {
30
+ get type() { return HANDLER_TYPE; }
31
+ get name() { return 'Monomer Collection'; }
32
+
33
+ isApplicable(x: any): boolean {
34
+ if (Array.isArray(x))
35
+ return x.length > 0 && x.every((item: any) => this.isSingleCollection(item));
36
+ return this.isSingleCollection(x);
37
+ }
38
+
39
+ private isSingleCollection(x: any): boolean {
40
+ return x != null && typeof x === 'object' && 'name' in x && 'displayName' in x && 'data' in x &&
41
+ typeof (x as MonomerCollectionInfo).data?.monomerSymbols !== 'undefined';
42
+ }
43
+
44
+ getCaption(x: MonomerCollectionInfo | MonomerCollectionInfo[]): string {
45
+ if (Array.isArray(x))
46
+ return `${x.length} collections`;
47
+ return x.displayName;
48
+ }
49
+
50
+ renderProperties(x: MonomerCollectionInfo | MonomerCollectionInfo[], context: any = null): HTMLElement {
51
+ if (Array.isArray(x))
52
+ return this.renderMultiProperties(x);
53
+ return this.renderSingleProperties(x);
54
+ }
55
+
56
+ // -- Single collection panel --
57
+
58
+ private renderSingleProperties(info: MonomerCollectionInfo): HTMLElement {
59
+ const acc = ui.accordion('Monomer Collection');
60
+ const currentUser = DG.User.current().login;
61
+ const isOwner = info.data.updatedBy === currentUser;
62
+
63
+ // Details pane
64
+ acc.addPane('Details', () => {
65
+ const map: {[key: string]: any} = {
66
+ 'Name': info.displayName,
67
+ };
68
+ if (info.data.description)
69
+ map['Description'] = info.data.description;
70
+ if (info.data.tags && info.data.tags.length > 0)
71
+ map['Tags'] = info.data.tags.join(', ');
72
+ map['Monomers'] = `${info.data.monomerSymbols.length}`;
73
+ if (info.data.updatedBy)
74
+ map['Updated by'] = info.data.updatedBy;
75
+ if (info.data.updatedOn) {
76
+ try { map['Updated on'] = new Date(info.data.updatedOn).toLocaleString(); } catch { /* ignore */ }
77
+ }
78
+ return ui.tableFromMap(map);
79
+ }, true);
80
+
81
+ // Monomers pane
82
+ acc.addPane('Monomers', () => {
83
+ const container = ui.div([], {style: {display: 'flex', flexWrap: 'wrap', gap: '4px'}});
84
+ const monomerLib = this.getMonomerLib();
85
+ for (const symbol of info.data.monomerSymbols) {
86
+ const tag = ui.div([symbol], {
87
+ style: {
88
+ display: 'inline-block', padding: '2px 8px', borderRadius: '4px',
89
+ background: 'var(--grey-1)', border: '1px solid var(--grey-2)',
90
+ fontSize: '11px', color: 'var(--grey-5)', cursor: 'pointer',
91
+ },
92
+ });
93
+ tag.addEventListener('mouseenter', () => {
94
+ if (monomerLib) {
95
+ const tooltipEl = MonomerCollectionHandler.getMonomerTooltipSafe(monomerLib, symbol);
96
+ if (tooltipEl) {
97
+ const rect = tag.getBoundingClientRect();
98
+ ui.tooltip.show(tooltipEl, rect.left, rect.bottom + 4);
99
+ }
100
+ }
101
+ });
102
+ tag.addEventListener('mouseleave', () => ui.tooltip.hide());
103
+ tag.addEventListener('click', () => {
104
+ grok.shell.o = DG.SemanticValue.fromValueType(symbol, SEM_TYPES.MONOMER);
105
+ });
106
+ container.appendChild(tag);
107
+ }
108
+ return container;
109
+ }, true);
110
+
111
+ // Actions pane
112
+ acc.addPane('Actions', () => {
113
+ const actions: HTMLElement[] = [];
114
+
115
+ const editBtn = ui.button('Edit', async () => {
116
+ if (isOwner) await this.editCollection(info);
117
+ }, isOwner ? 'Edit this collection' : 'Only the author can edit');
118
+ if (!isOwner) MonomerCollectionHandler.disableButton(editBtn);
119
+ actions.push(editBtn);
120
+
121
+ const deleteBtn = ui.button('Delete', async () => {
122
+ if (isOwner) await this.deleteCollection(info);
123
+ }, isOwner ? 'Delete this collection' : 'Only the author can delete');
124
+ if (!isOwner) MonomerCollectionHandler.disableButton(deleteBtn);
125
+ actions.push(deleteBtn);
126
+
127
+ actions.push(ui.button('Duplicate', async () => {
128
+ await this.duplicateCollection(info);
129
+ }, 'Create a copy of this collection'));
130
+
131
+ return ui.divH(actions);
132
+ }, true);
133
+
134
+ return acc.root;
135
+ }
136
+
137
+ // -- Multi-collection panel --
138
+
139
+ private renderMultiProperties(items: MonomerCollectionInfo[]): HTMLElement {
140
+ const acc = ui.accordion('Monomer Collections');
141
+ const currentUser = DG.User.current().login;
142
+ const allOwned = items.every((c) => c.data.updatedBy === currentUser);
143
+
144
+ // Summary pane
145
+ acc.addPane('Selection', () => {
146
+ const list = ui.list(items.map((c) => c.displayName));
147
+ return ui.divV([
148
+ ui.divText(`${items.length} collections selected`),
149
+ list,
150
+ ]);
151
+ }, true);
152
+
153
+ // Actions pane
154
+ acc.addPane('Actions', () => {
155
+ const actions: HTMLElement[] = [];
156
+
157
+ actions.push(ui.button('Merge', async () => {
158
+ await this.mergeCollections(items);
159
+ }, 'Merge selected collections into one'));
160
+
161
+ const deleteAllBtn = ui.button('Delete All', async () => {
162
+ if (allOwned) await this.deleteMultipleCollections(items);
163
+ }, allOwned ? 'Delete all selected collections' : 'Only the author can delete (you must be author of all)');
164
+ if (!allOwned) MonomerCollectionHandler.disableButton(deleteAllBtn);
165
+ actions.push(deleteAllBtn);
166
+
167
+ return ui.divH(actions);
168
+ }, true);
169
+
170
+ return acc.root;
171
+ }
172
+
173
+ // -- Actions --
174
+
175
+ private async editCollection(info: MonomerCollectionInfo): Promise<void> {
176
+ // Find existing view and trigger edit
177
+ const view = Array.from(grok.shell.views).find((v) => v.name === 'Monomer Collections');
178
+ if (view && (view as any)._mcView) {
179
+ const mcView = (view as any)._mcView as import('./monomer-collections-view').MonomerCollectionsView;
180
+ mcView.editCollectionPublic(info.name, info.data);
181
+ } else {
182
+ // Fallback: open the edit dialog directly
183
+ const libHelper = await MonomerLibManager.getInstance();
184
+ await libHelper.awaitLoaded();
185
+ const monomerLib = libHelper.getMonomerLib();
186
+ this.showEditDialog(info, libHelper, monomerLib!);
187
+ }
188
+ }
189
+
190
+ private showEditDialog(info: MonomerCollectionInfo, libHelper: MonomerLibManager, monomerLib: IMonomerLib): void {
191
+ const polymerTypes: PolymerType[] = ['PEPTIDE', 'RNA', 'CHEM'];
192
+ const nameInput = ui.input.string('Name', {value: info.displayName, nullable: false});
193
+ const descInput = ui.input.string('Description', {value: info.data.description ?? '', nullable: true});
194
+ const tagsInput = ui.input.string('Tags', {value: (info.data.tags ?? []).join(', '), nullable: true, placeholder: 'Comma-separated tags'});
195
+ const polymerTypeInput = ui.input.choice('Polymer Type', {items: [...polymerTypes], value: 'PEPTIDE', nullable: false});
196
+ const monomerWidget = new MonomerSelectionWidget(monomerLib, polymerTypeInput.value as PolymerType, [...(info.data.monomerSymbols ?? [])], libHelper);
197
+
198
+ // eslint-disable-next-line rxjs/no-ignored-subscription
199
+ polymerTypeInput.onChanged.subscribe(() => monomerWidget.setPolymerType(polymerTypeInput.value as PolymerType));
200
+
201
+ const dlg = ui.dialog({title: `Edit Collection: ${info.displayName}`})
202
+ .add(ui.divV([nameInput, descInput, tagsInput, polymerTypeInput, ui.element('hr'), monomerWidget.root]))
203
+ .onOK(async () => {
204
+ const name = nameInput.value?.trim();
205
+ if (!name) { grok.shell.warning('Collection name is required'); return; }
206
+ const selectedMonomers = monomerWidget.getSelectedMonomers();
207
+ if (selectedMonomers.length === 0) { grok.shell.warning('Please select at least one monomer'); return; }
208
+ const tags = this.parseTags(tagsInput.value);
209
+ try {
210
+ if (name !== info.displayName)
211
+ await libHelper.deleteMonomerCollection(info.name);
212
+ await libHelper.addOrUpdateMonomerCollection(name, selectedMonomers, descInput.value ?? undefined, tags.length > 0 ? tags : undefined);
213
+ grok.shell.info(`Collection "${name}" saved successfully`);
214
+ await this.refreshView();
215
+ } catch (err: any) {
216
+ grok.shell.error('Error saving collection');
217
+ }
218
+ })
219
+ .show({resizable: true});
220
+ dlg.root.style.width = '550px';
221
+ monomerWidget.focus();
222
+ }
223
+
224
+ private async deleteCollection(info: MonomerCollectionInfo): Promise<void> {
225
+ ui.dialog({title: 'Delete Collection'})
226
+ .add(ui.divText(`Are you sure you want to delete "${info.displayName}"?`))
227
+ .onOK(async () => {
228
+ try {
229
+ const libHelper = await MonomerLibManager.getInstance();
230
+ await libHelper.deleteMonomerCollection(info.name);
231
+ grok.shell.info(`Collection "${info.displayName}" deleted`);
232
+ await this.refreshView();
233
+ } catch (err: any) {
234
+ grok.shell.error('Error deleting collection');
235
+ }
236
+ })
237
+ .show();
238
+ }
239
+
240
+ private async duplicateCollection(info: MonomerCollectionInfo): Promise<void> {
241
+ const nameInput = ui.input.string('New Name', {value: `${info.displayName} (copy)`, nullable: false});
242
+ ui.dialog({title: 'Duplicate Collection'})
243
+ .add(nameInput)
244
+ .onOK(async () => {
245
+ const name = nameInput.value?.trim();
246
+ if (!name) { grok.shell.warning('Name is required'); return; }
247
+ try {
248
+ const libHelper = await MonomerLibManager.getInstance();
249
+ await libHelper.addOrUpdateMonomerCollection(name, [...info.data.monomerSymbols], info.data.description, info.data.tags ? [...info.data.tags] : undefined);
250
+ grok.shell.info(`Collection "${name}" created`);
251
+ await this.refreshView();
252
+ } catch (err: any) {
253
+ grok.shell.error('Error duplicating collection');
254
+ }
255
+ })
256
+ .show();
257
+ }
258
+
259
+ private async mergeCollections(items: MonomerCollectionInfo[]): Promise<void> {
260
+ const nameInput = ui.input.string('Merged Name', {value: 'Merged Collection', nullable: false});
261
+ const descInput = ui.input.string('Description', {nullable: true, placeholder: 'Optional description'});
262
+ ui.dialog({title: `Merge ${items.length} Collections`})
263
+ .add(ui.divV([
264
+ ui.divText(`Merging: ${items.map((c) => c.displayName).join(', ')}`),
265
+ nameInput,
266
+ descInput,
267
+ ]))
268
+ .onOK(async () => {
269
+ const name = nameInput.value?.trim();
270
+ if (!name) { grok.shell.warning('Name is required'); return; }
271
+ try {
272
+ const allSymbols = [...new Set(items.flatMap((c) => c.data.monomerSymbols))];
273
+ const allTags = [...new Set(items.flatMap((c) => c.data.tags ?? []))];
274
+ const libHelper = await MonomerLibManager.getInstance();
275
+ await libHelper.addOrUpdateMonomerCollection(name, allSymbols, descInput.value ?? undefined, allTags.length > 0 ? allTags : undefined);
276
+ grok.shell.info(`Merged collection "${name}" created with ${allSymbols.length} monomers`);
277
+ await this.refreshView();
278
+ } catch (err: any) {
279
+ grok.shell.error('Error merging collections');
280
+ }
281
+ })
282
+ .show();
283
+ }
284
+
285
+ private async deleteMultipleCollections(items: MonomerCollectionInfo[]): Promise<void> {
286
+ ui.dialog({title: 'Delete Collections'})
287
+ .add(ui.divText(`Are you sure you want to delete ${items.length} collections?\n${items.map((c) => c.displayName).join(', ')}`))
288
+ .onOK(async () => {
289
+ try {
290
+ const libHelper = await MonomerLibManager.getInstance();
291
+ for (const item of items)
292
+ await libHelper.deleteMonomerCollection(item.name);
293
+ grok.shell.info(`${items.length} collections deleted`);
294
+ await this.refreshView();
295
+ } catch (err: any) {
296
+ grok.shell.error('Error deleting collections');
297
+ }
298
+ })
299
+ .show();
300
+ }
301
+
302
+ private async refreshView(): Promise<void> {
303
+ const view = Array.from(grok.shell.views).find((v) => v.name === 'Monomer Collections');
304
+ if (view && (view as any)._mcView) {
305
+ const mcView = (view as any)._mcView as import('./monomer-collections-view').MonomerCollectionsView;
306
+ await mcView.refresh();
307
+ }
308
+ }
309
+
310
+ private parseTags(input: string | null): string[] {
311
+ if (!input) return [];
312
+ return input.split(',').map((t) => t.trim()).filter((t) => t.length > 0);
313
+ }
314
+
315
+ /** Gets the current monomer lib (synchronous, returns null if not loaded). */
316
+ private getMonomerLib(): IMonomerLib | null {
317
+ return _package.monomerLib ?? null;
318
+ }
319
+
320
+ /** Gets a monomer tooltip element, searching across all HELM types. */
321
+ private static getMonomerTooltipSafe(monomerLib: IMonomerLib, symbol: string): HTMLElement | null {
322
+ const helmTypes: HelmType[] = [HelmTypes.AA, HelmTypes.NUCLEOTIDE, HelmTypes.CHEM, HelmTypes.BLOB];
323
+ for (const ht of helmTypes) {
324
+ try {
325
+ const wem = monomerLib.getWebEditorMonomer(ht, symbol);
326
+ if (wem) return monomerLib.getTooltip(ht, symbol);
327
+ } catch { /* skip */ }
328
+ }
329
+ return null;
330
+ }
331
+
332
+ /** Applies custom disabled styling to a button (greyed out, not clickable). */
333
+ private static disableButton(btn: HTMLElement): void {
334
+ btn.style.opacity = '0.4';
335
+ btn.style.cursor = 'default';
336
+ }
337
+
338
+ /** Builds a context menu for a single collection. */
339
+ static buildContextMenu(menu: DG.Menu, info: MonomerCollectionInfo, handler: MonomerCollectionHandler): void {
340
+ const currentUser = DG.User.current().login;
341
+ const isOwner = info.data.updatedBy === currentUser;
342
+
343
+ menu.item('Edit', () => { if (isOwner) handler.editCollection(info); }, null,
344
+ {isEnabled: () => isOwner ? null : 'Only the author can edit'});
345
+ menu.item('Delete', () => { if (isOwner) handler.deleteCollection(info); }, null,
346
+ {isEnabled: () => isOwner ? null : 'Only the author can delete'});
347
+ menu.item('Duplicate', () => handler.duplicateCollection(info));
348
+ }
349
+
350
+ /** Builds a context menu for multiple collections. */
351
+ static buildMultiContextMenu(menu: DG.Menu, items: MonomerCollectionInfo[], handler: MonomerCollectionHandler): void {
352
+ const currentUser = DG.User.current().login;
353
+ const allOwned = items.every((c) => c.data.updatedBy === currentUser);
354
+
355
+ menu.item('Merge', () => handler.mergeCollections(items));
356
+ menu.item('Delete All', () => { if (allOwned) handler.deleteMultipleCollections(items); }, null,
357
+ {isEnabled: () => allOwned ? null : 'Only the author can delete (you must be author of all)'});
358
+ }
359
+ }