@datagrok/bio 2.25.14 → 2.25.16
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 +6 -0
- package/css/monomer-collections.css +184 -0
- package/dist/package-test.js +3 -3
- package/dist/package-test.js.map +1 -1
- package/dist/package.js +3 -3
- package/dist/package.js.map +1 -1
- package/files/monomer-collections/Canonical AAs.json +27 -0
- package/package.json +2 -2
- package/src/package.g.ts +10 -0
- package/src/package.ts +11 -0
- package/src/utils/monomer-lib/lib-manager.ts +36 -1
- package/src/utils/monomer-lib/monomer-collections-view.ts +424 -0
- package/src/utils/monomer-lib/monomer-manager/monomer-manager.ts +67 -5
- package/test-console-output-1.log +474 -471
- package/test-record-1.mp4 +0 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"monomerSymbols": [
|
|
3
|
+
"A",
|
|
4
|
+
"C",
|
|
5
|
+
"D",
|
|
6
|
+
"E",
|
|
7
|
+
"F",
|
|
8
|
+
"G",
|
|
9
|
+
"H",
|
|
10
|
+
"I",
|
|
11
|
+
"K",
|
|
12
|
+
"L",
|
|
13
|
+
"M",
|
|
14
|
+
"N",
|
|
15
|
+
"P",
|
|
16
|
+
"Q",
|
|
17
|
+
"R",
|
|
18
|
+
"S",
|
|
19
|
+
"T",
|
|
20
|
+
"V",
|
|
21
|
+
"W",
|
|
22
|
+
"Y"
|
|
23
|
+
],
|
|
24
|
+
"description": "Canonical amino acids collection",
|
|
25
|
+
"updatedBy": "rizhi",
|
|
26
|
+
"updatedOn": "2026-01-01T12:00:00Z"
|
|
27
|
+
}
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"name": "Davit Rizhinashvili",
|
|
6
6
|
"email": "drizhinashvili@datagrok.ai"
|
|
7
7
|
},
|
|
8
|
-
"version": "2.25.
|
|
8
|
+
"version": "2.25.16",
|
|
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",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
],
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@biowasm/aioli": "^3.1.0",
|
|
47
|
-
"@datagrok-libraries/bio": "^5.
|
|
47
|
+
"@datagrok-libraries/bio": "^5.62.1",
|
|
48
48
|
"@datagrok-libraries/chem-meta": "^1.2.9",
|
|
49
49
|
"@datagrok-libraries/math": "^1.2.6",
|
|
50
50
|
"@datagrok-libraries/ml": "^6.10.9",
|
package/src/package.g.ts
CHANGED
|
@@ -550,6 +550,16 @@ export async function manageMonomerLibrariesViewTreeBrowser(treeNode: any) : Pro
|
|
|
550
550
|
await PackageFunctions.manageMonomerLibrariesViewTreeBrowser(treeNode);
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
+
//name: Monomer Collections
|
|
554
|
+
//tags: app
|
|
555
|
+
//output: view result
|
|
556
|
+
//meta.role: app
|
|
557
|
+
//meta.browsePath: Peptides
|
|
558
|
+
//meta.icon: files/icons/monomers.png
|
|
559
|
+
export async function monomerCollectionsApp() : Promise<any> {
|
|
560
|
+
return await PackageFunctions.monomerCollectionsApp();
|
|
561
|
+
}
|
|
562
|
+
|
|
553
563
|
//description: As FASTA...
|
|
554
564
|
//meta.role: fileExporter
|
|
555
565
|
export function saveAsFasta() : void {
|
package/src/package.ts
CHANGED
|
@@ -78,6 +78,7 @@ import {molecular3DStructureWidget, toAtomicLevelWidget, toAtomicLevelSingle} fr
|
|
|
78
78
|
import {handleSequenceHeaderRendering} from './widgets/sequence-scrolling-widget';
|
|
79
79
|
import {PolymerType} from '@datagrok-libraries/js-draw-lite/src/types/org';
|
|
80
80
|
import {BilnNotationProvider} from './utils/biln';
|
|
81
|
+
import {showMonomerCollectionsView} from './utils/monomer-lib/monomer-collections-view';
|
|
81
82
|
|
|
82
83
|
import * as api from './package-api';
|
|
83
84
|
export const _package = new BioPackage(/*{debug: true}/**/);
|
|
@@ -1186,6 +1187,16 @@ export class PackageFunctions {
|
|
|
1186
1187
|
});
|
|
1187
1188
|
}
|
|
1188
1189
|
|
|
1190
|
+
@grok.decorators.app({
|
|
1191
|
+
tags: ['app'],
|
|
1192
|
+
name: 'Monomer Collections',
|
|
1193
|
+
browsePath: 'Peptides',
|
|
1194
|
+
icon: 'files/icons/monomers.png',
|
|
1195
|
+
})
|
|
1196
|
+
static async monomerCollectionsApp(): Promise<DG.ViewBase> {
|
|
1197
|
+
return await showMonomerCollectionsView(false);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1189
1200
|
@grok.decorators.fileExporter({description: 'As FASTA...'})
|
|
1190
1201
|
static saveAsFasta() {
|
|
1191
1202
|
saveAsFastaUI();
|
|
@@ -5,7 +5,7 @@ import * as ui from 'datagrok-api/ui';
|
|
|
5
5
|
import * as DG from 'datagrok-api/dg';
|
|
6
6
|
|
|
7
7
|
import {ILogger} from '@datagrok-libraries/bio/src/utils/logger';
|
|
8
|
-
import {DEFAULT_FILES_LIB_PROVIDER_NAME, findProviderWithLibraryName, IMonomerLib, IMonomerSet} from '@datagrok-libraries/bio/src/types/monomer-library';
|
|
8
|
+
import {DEFAULT_FILES_LIB_PROVIDER_NAME, findProviderWithLibraryName, IMonomerLib, IMonomerSet, MonomerCollection} from '@datagrok-libraries/bio/src/types/monomer-library';
|
|
9
9
|
import {
|
|
10
10
|
getUserLibSettings, setUserLibSettings,
|
|
11
11
|
} from '@datagrok-libraries/bio/src/monomer-works/lib-settings';
|
|
@@ -19,6 +19,7 @@ import {_package} from '../../package';
|
|
|
19
19
|
import {IMonomerLibHelper, IMonomerLibProvider} from '@datagrok-libraries/bio/src/types/monomer-library';
|
|
20
20
|
import {merge, Observable, Subject} from 'rxjs';
|
|
21
21
|
import {MonomerLibFromFilesProvider} from './library-file-manager/monomers-lib-provider';
|
|
22
|
+
const MONOMER_COLLECTION_STORAGE_PATH = 'System:AppData/Bio/monomer-collections/';
|
|
22
23
|
|
|
23
24
|
type MonomerLibWindowType = Window & { $monomerLibHelperPromise?: Promise<MonomerLibManager> };
|
|
24
25
|
declare const window: MonomerLibWindowType;
|
|
@@ -409,6 +410,40 @@ export class MonomerLibManager implements IMonomerLibHelper {
|
|
|
409
410
|
}
|
|
410
411
|
}
|
|
411
412
|
|
|
413
|
+
async listMonomerCollections(): Promise<string[]> {
|
|
414
|
+
// these are provider less functions. coleections will be in files storage in txt format
|
|
415
|
+
const collections = (await grok.dapi.files.list(MONOMER_COLLECTION_STORAGE_PATH))
|
|
416
|
+
.filter((file) => file.extension === 'json' || file.name.endsWith('.json'));
|
|
417
|
+
return collections.map((file) => file.name);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
async deleteMonomerCollection(collectionName: string): Promise<void> {
|
|
421
|
+
if (!collectionName.endsWith('.json'))
|
|
422
|
+
collectionName += '.json';
|
|
423
|
+
if (await grok.dapi.files.exists(MONOMER_COLLECTION_STORAGE_PATH + collectionName))
|
|
424
|
+
await grok.dapi.files.delete(MONOMER_COLLECTION_STORAGE_PATH + collectionName);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
async readMonomerCollection(collectionName: string): Promise<MonomerCollection> {
|
|
428
|
+
if (!collectionName.endsWith('.json'))
|
|
429
|
+
collectionName += '.json';
|
|
430
|
+
const file = await grok.dapi.files.readAsText(MONOMER_COLLECTION_STORAGE_PATH + collectionName);
|
|
431
|
+
return JSON.parse(file) as MonomerCollection;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async addOrUpdateMonomerCollection(collectionName: string, monomerSymbols: string[], desc?: string, tags?: string[]): Promise<void> {
|
|
435
|
+
if (!collectionName.endsWith('.json'))
|
|
436
|
+
collectionName += '.json';
|
|
437
|
+
const content = JSON.stringify({
|
|
438
|
+
description: desc,
|
|
439
|
+
tags: tags,
|
|
440
|
+
monomerSymbols: monomerSymbols,
|
|
441
|
+
updatedBy: DG.User.current().login,
|
|
442
|
+
updatedOn: new Date().toISOString(),
|
|
443
|
+
} satisfies MonomerCollection, null, 2);
|
|
444
|
+
await grok.dapi.files.writeAsText(MONOMER_COLLECTION_STORAGE_PATH + collectionName, content);
|
|
445
|
+
}
|
|
446
|
+
|
|
412
447
|
// -- Instance singleton --
|
|
413
448
|
public static async getInstance(): Promise<MonomerLibManager> {
|
|
414
449
|
let res = window.$monomerLibHelperPromise;
|
|
@@ -0,0 +1,424 @@
|
|
|
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 {_package} from '../../package';
|
|
13
|
+
|
|
14
|
+
//@ts-ignore
|
|
15
|
+
import '../../../css/monomer-collections.css';
|
|
16
|
+
|
|
17
|
+
const PAGE_SIZE = 19;
|
|
18
|
+
|
|
19
|
+
export class MonomerCollectionsView {
|
|
20
|
+
private view: DG.ViewBase | null = null;
|
|
21
|
+
private libHelper: MonomerLibManager | null = null;
|
|
22
|
+
private monomerLib: IMonomerLib | null = null;
|
|
23
|
+
private collectionNames: string[] = [];
|
|
24
|
+
private filteredNames: string[] = [];
|
|
25
|
+
private displayedCount = 0;
|
|
26
|
+
private cardsContainer: HTMLDivElement | null = null;
|
|
27
|
+
private loadMoreContainer: HTMLDivElement | null = null;
|
|
28
|
+
private searchInput: DG.InputBase<string> | null = null;
|
|
29
|
+
private currentUser: string = '';
|
|
30
|
+
/** Cached collection data for search filtering */
|
|
31
|
+
private collectionsCache: Map<string, MonomerCollection> = new Map();
|
|
32
|
+
|
|
33
|
+
static readonly VIEW_NAME = 'Monomer Collections';
|
|
34
|
+
|
|
35
|
+
async init(): Promise<DG.ViewBase> {
|
|
36
|
+
this.libHelper = await MonomerLibManager.getInstance();
|
|
37
|
+
await this.libHelper.awaitLoaded();
|
|
38
|
+
this.monomerLib = this.libHelper.getMonomerLib();
|
|
39
|
+
this.currentUser = DG.User.current().login;
|
|
40
|
+
|
|
41
|
+
this.view = DG.View.create();
|
|
42
|
+
this.view.name = MonomerCollectionsView.VIEW_NAME;
|
|
43
|
+
|
|
44
|
+
// Ribbon
|
|
45
|
+
const addBtn = ui.icons.add(() => this.showAddCollectionDialog(), 'Create new monomer collection');
|
|
46
|
+
const refreshBtn = ui.iconFA('sync', () => this.refresh(), 'Refresh collections');
|
|
47
|
+
this.view.setRibbonPanels([[addBtn, refreshBtn]]);
|
|
48
|
+
|
|
49
|
+
// Context menu
|
|
50
|
+
this.view.root.addEventListener('contextmenu', (e: MouseEvent) => {
|
|
51
|
+
const target = e.target as HTMLElement;
|
|
52
|
+
if (target.closest('.monomer-collection-card') || target.closest('.monomer-collection-add-card'))
|
|
53
|
+
return;
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
const menu = DG.Menu.popup();
|
|
56
|
+
menu.item('New Collection...', () => this.showAddCollectionDialog());
|
|
57
|
+
menu.item('Refresh', () => this.refresh());
|
|
58
|
+
menu.show();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await this.buildContent();
|
|
62
|
+
return this.view;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async buildContent(): Promise<void> {
|
|
66
|
+
if (!this.view) return;
|
|
67
|
+
this.view.root.innerHTML = '';
|
|
68
|
+
|
|
69
|
+
const root = ui.div([], {classes: 'monomer-collections-view'});
|
|
70
|
+
|
|
71
|
+
// Search bar
|
|
72
|
+
this.searchInput = ui.input.string('Monomer Collections', {value: '', placeholder: 'Search by name or tag...'});
|
|
73
|
+
this.searchInput.root.classList.add('monomer-collections-search');
|
|
74
|
+
const searchEl = this.searchInput.input as HTMLInputElement;
|
|
75
|
+
searchEl.style.width = '100%';
|
|
76
|
+
searchEl.style.marginBottom = '12px';
|
|
77
|
+
let searchTimeout: ReturnType<typeof setTimeout> | null = null;
|
|
78
|
+
searchEl.addEventListener('input', () => {
|
|
79
|
+
if (searchTimeout) clearTimeout(searchTimeout);
|
|
80
|
+
searchTimeout = setTimeout(() => this.applyFilter(), 250);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
this.cardsContainer = ui.div([], {classes: 'monomer-collections-grid'}) as HTMLDivElement;
|
|
84
|
+
this.loadMoreContainer = ui.div([], {classes: 'monomer-collections-load-more'}) as HTMLDivElement;
|
|
85
|
+
|
|
86
|
+
root.appendChild(this.searchInput.root);
|
|
87
|
+
root.appendChild(this.cardsContainer);
|
|
88
|
+
root.appendChild(this.loadMoreContainer);
|
|
89
|
+
this.view.root.appendChild(root);
|
|
90
|
+
|
|
91
|
+
await this.loadCollections();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async loadCollections(): Promise<void> {
|
|
95
|
+
try {
|
|
96
|
+
this.collectionNames = await this.libHelper!.listMonomerCollections();
|
|
97
|
+
} catch (err: any) {
|
|
98
|
+
_package.logger.error(`Error listing monomer collections: ${err instanceof Error ? err.message : err.toString()}`);
|
|
99
|
+
grok.shell.error('Error loading monomer collections');
|
|
100
|
+
this.collectionNames = [];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Preload all collection metadata in the background for search filtering
|
|
104
|
+
this.collectionsCache.clear();
|
|
105
|
+
for (const name of this.collectionNames) {
|
|
106
|
+
this.libHelper!.readMonomerCollection(name).then((c) => {
|
|
107
|
+
this.collectionsCache.set(name, c);
|
|
108
|
+
}).catch(() => { /* ignore load errors for cache */ });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
this.applyFilter();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private applyFilter(): void {
|
|
115
|
+
const query = (this.searchInput?.value ?? '').trim().toLowerCase();
|
|
116
|
+
this.filteredNames = !query ? [...this.collectionNames] :
|
|
117
|
+
this.collectionNames.filter((name) => {
|
|
118
|
+
const displayName = name.replace(/\.json$/i, '').toLowerCase();
|
|
119
|
+
if (displayName.includes(query)) return true;
|
|
120
|
+
const cached = this.collectionsCache.get(name);
|
|
121
|
+
if (cached?.tags?.some((t) => t.toLowerCase().includes(query)))
|
|
122
|
+
return true;
|
|
123
|
+
return false;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
this.displayedCount = 0;
|
|
127
|
+
this.cardsContainer!.innerHTML = '';
|
|
128
|
+
this.showNextPage();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private showNextPage(): void {
|
|
132
|
+
const end = Math.min(this.displayedCount + PAGE_SIZE, this.filteredNames.length);
|
|
133
|
+
for (let i = this.displayedCount; i < end; i++)
|
|
134
|
+
this.cardsContainer!.appendChild(this.createCollectionCard(this.filteredNames[i]));
|
|
135
|
+
|
|
136
|
+
this.displayedCount = end;
|
|
137
|
+
|
|
138
|
+
// Add the "add new" card at the end (remove/re-add so it stays last)
|
|
139
|
+
const existingAddCard = this.cardsContainer!.querySelector('.monomer-collection-add-card');
|
|
140
|
+
if (existingAddCard) existingAddCard.remove();
|
|
141
|
+
this.cardsContainer!.appendChild(this.createAddCard());
|
|
142
|
+
|
|
143
|
+
// Show empty state if no collections
|
|
144
|
+
const existingEmpty = this.cardsContainer!.querySelector('.monomer-collection-empty-state');
|
|
145
|
+
if (existingEmpty) existingEmpty.remove();
|
|
146
|
+
if (this.filteredNames.length === 0) {
|
|
147
|
+
const msg = this.collectionNames.length === 0 ?
|
|
148
|
+
'No monomer collections found. Click "+" to create one.' :
|
|
149
|
+
'No collections match your search.';
|
|
150
|
+
const emptyState = ui.div([ui.divText(msg)], {classes: 'monomer-collection-empty-state'});
|
|
151
|
+
this.cardsContainer!.prepend(emptyState);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Load more button
|
|
155
|
+
this.loadMoreContainer!.innerHTML = '';
|
|
156
|
+
if (this.displayedCount < this.filteredNames.length) {
|
|
157
|
+
const remaining = this.filteredNames.length - this.displayedCount;
|
|
158
|
+
const loadMoreBtn = ui.button(`Load more (${remaining} remaining)`, () => this.showNextPage());
|
|
159
|
+
this.loadMoreContainer!.appendChild(loadMoreBtn);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private createCollectionCard(collectionName: string): HTMLDivElement {
|
|
164
|
+
const displayName = collectionName.replace(/\.json$/i, '');
|
|
165
|
+
|
|
166
|
+
// Header with name shown immediately
|
|
167
|
+
const titleEl = ui.div([displayName], {classes: 'monomer-collection-card-title'});
|
|
168
|
+
ui.tooltip.bind(titleEl, displayName);
|
|
169
|
+
const headerEl = ui.div([titleEl], {classes: 'monomer-collection-card-header'});
|
|
170
|
+
|
|
171
|
+
// Body: loaded asynchronously
|
|
172
|
+
const bodyEl = ui.div([], {classes: 'monomer-collection-card-body'});
|
|
173
|
+
|
|
174
|
+
// Actions placeholder
|
|
175
|
+
const actionsEl = ui.div([], {classes: 'monomer-collection-card-actions'});
|
|
176
|
+
|
|
177
|
+
const card = ui.div([headerEl, bodyEl, actionsEl], {classes: 'monomer-collection-card'}) as HTMLDivElement;
|
|
178
|
+
|
|
179
|
+
// Load content in background
|
|
180
|
+
const loadContent = async (): Promise<HTMLElement> => {
|
|
181
|
+
const collection = await this.libHelper!.readMonomerCollection(collectionName);
|
|
182
|
+
// Cache for search
|
|
183
|
+
this.collectionsCache.set(collectionName, collection);
|
|
184
|
+
|
|
185
|
+
// Description
|
|
186
|
+
if (collection.description) {
|
|
187
|
+
const descEl = ui.div([collection.description], {classes: 'monomer-collection-card-description'});
|
|
188
|
+
ui.tooltip.bind(descEl, collection.description);
|
|
189
|
+
headerEl.appendChild(descEl);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Collection tags (not monomer symbols - these are user-defined labels)
|
|
193
|
+
if (collection.tags && collection.tags.length > 0) {
|
|
194
|
+
const collTagsContainer = ui.div([], {classes: 'monomer-collection-card-tags'});
|
|
195
|
+
for (const t of collection.tags) {
|
|
196
|
+
const tagEl = ui.div([t], {classes: 'monomer-collection-card-tag'});
|
|
197
|
+
collTagsContainer.appendChild(tagEl);
|
|
198
|
+
}
|
|
199
|
+
headerEl.appendChild(collTagsContainer);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Meta info
|
|
203
|
+
const metaParts: string[] = [];
|
|
204
|
+
if (collection.updatedBy) metaParts.push(`by ${collection.updatedBy}`);
|
|
205
|
+
if (collection.updatedOn)
|
|
206
|
+
try { metaParts.push(new Date(collection.updatedOn).toLocaleDateString()); } catch { /* ignore */ }
|
|
207
|
+
if (metaParts.length > 0)
|
|
208
|
+
headerEl.appendChild(ui.div([metaParts.join(' | ')], {classes: 'monomer-collection-card-meta'}));
|
|
209
|
+
|
|
210
|
+
// Monomer tags
|
|
211
|
+
const monomerTagsContainer = ui.div([], {classes: 'monomer-collection-tags'});
|
|
212
|
+
const symbols = collection.monomerSymbols ?? [];
|
|
213
|
+
for (const symbol of symbols) {
|
|
214
|
+
const tag = ui.div([symbol], {classes: 'monomer-collection-tag'});
|
|
215
|
+
tag.addEventListener('mouseenter', () => {
|
|
216
|
+
if (this.monomerLib) {
|
|
217
|
+
const tooltipEl = this.getMonomerTooltipSafe(symbol);
|
|
218
|
+
if (tooltipEl) {
|
|
219
|
+
const rect = tag.getBoundingClientRect();
|
|
220
|
+
ui.tooltip.show(tooltipEl, rect.left, rect.bottom + 4);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
tag.addEventListener('mouseleave', () => ui.tooltip.hide());
|
|
225
|
+
monomerTagsContainer.appendChild(tag);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const countLabel = ui.divText(`${symbols.length} monomer(s)`, {style: {fontSize: '11px', color: 'var(--grey-4)', marginBottom: '6px'}});
|
|
229
|
+
const contentDiv = ui.divV([countLabel, monomerTagsContainer]);
|
|
230
|
+
|
|
231
|
+
// Actions: only show edit/delete if the current user matches updatedBy
|
|
232
|
+
const isOwner = collection.updatedBy === this.currentUser;
|
|
233
|
+
if (isOwner) {
|
|
234
|
+
const editBtn = ui.button('Edit', () => this.showEditCollectionDialog(collectionName, collection));
|
|
235
|
+
const deleteBtn = ui.button('Delete', () => this.confirmDeleteCollection(collectionName));
|
|
236
|
+
actionsEl.appendChild(editBtn);
|
|
237
|
+
actionsEl.appendChild(deleteBtn);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return contentDiv;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
bodyEl.appendChild(ui.wait(() => loadContent()));
|
|
244
|
+
return card;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private getMonomerTooltipSafe(symbol: string): HTMLElement | null {
|
|
248
|
+
if (!this.monomerLib) return null;
|
|
249
|
+
const helmTypes: HelmType[] = [HelmTypes.AA, HelmTypes.NUCLEOTIDE, HelmTypes.CHEM, HelmTypes.BLOB];
|
|
250
|
+
for (const ht of helmTypes) {
|
|
251
|
+
try {
|
|
252
|
+
const wem = this.monomerLib.getWebEditorMonomer(ht, symbol);
|
|
253
|
+
if (wem) return this.monomerLib.getTooltip(ht, symbol);
|
|
254
|
+
} catch (_) { /* skip */ }
|
|
255
|
+
}
|
|
256
|
+
return null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private createAddCard(): HTMLDivElement {
|
|
260
|
+
const icon = ui.iconFA('plus', () => {}, '');
|
|
261
|
+
icon.style.fontSize = '32px';
|
|
262
|
+
icon.style.marginBottom = '8px';
|
|
263
|
+
const label = ui.span(['New Collection']);
|
|
264
|
+
|
|
265
|
+
const content = ui.div([icon, label], {classes: 'monomer-collection-add-card-content'});
|
|
266
|
+
const card = ui.div([content], {classes: 'monomer-collection-add-card'}) as HTMLDivElement;
|
|
267
|
+
card.addEventListener('click', () => this.showAddCollectionDialog());
|
|
268
|
+
return card;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private showAddCollectionDialog(): void {
|
|
272
|
+
const nameInput = ui.input.string('Name', {nullable: false, placeholder: 'Enter collection name'});
|
|
273
|
+
const descInput = ui.input.string('Description', {nullable: true, placeholder: 'Optional description'});
|
|
274
|
+
const tagsInput = ui.input.string('Tags', {nullable: true, placeholder: 'Comma-separated tags'});
|
|
275
|
+
|
|
276
|
+
const polymerTypes: PolymerType[] = ['PEPTIDE', 'RNA', 'CHEM'];
|
|
277
|
+
const polymerTypeInput = ui.input.choice('Polymer Type', {items: polymerTypes, value: 'PEPTIDE' as PolymerType, nullable: false});
|
|
278
|
+
|
|
279
|
+
const monomerWidget = new MonomerSelectionWidget(
|
|
280
|
+
this.monomerLib!, polymerTypeInput.value as PolymerType, [], this.libHelper!,
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
// Update widget when polymer type changes
|
|
284
|
+
// eslint-disable-next-line rxjs/no-ignored-subscription
|
|
285
|
+
polymerTypeInput.onChanged.subscribe(() => {
|
|
286
|
+
monomerWidget.setPolymerType(polymerTypeInput.value as PolymerType);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
const dlg = ui.dialog({title: 'New Monomer Collection'})
|
|
290
|
+
.add(ui.divV([
|
|
291
|
+
nameInput,
|
|
292
|
+
descInput,
|
|
293
|
+
tagsInput,
|
|
294
|
+
polymerTypeInput,
|
|
295
|
+
ui.element('hr'),
|
|
296
|
+
monomerWidget.root,
|
|
297
|
+
]))
|
|
298
|
+
.onOK(async () => {
|
|
299
|
+
const name = nameInput.value?.trim();
|
|
300
|
+
if (!name) {
|
|
301
|
+
grok.shell.warning('Collection name is required');
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const selectedMonomers = monomerWidget.getSelectedMonomers();
|
|
305
|
+
if (selectedMonomers.length === 0) {
|
|
306
|
+
grok.shell.warning('Please select at least one monomer');
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
const tags = parseTags(tagsInput.value);
|
|
310
|
+
try {
|
|
311
|
+
await this.libHelper!.addOrUpdateMonomerCollection(name, selectedMonomers, descInput.value ?? undefined, tags.length > 0 ? tags : undefined);
|
|
312
|
+
grok.shell.info(`Collection "${name}" created successfully`);
|
|
313
|
+
await this.refresh();
|
|
314
|
+
} catch (err: any) {
|
|
315
|
+
grok.shell.error('Error creating collection');
|
|
316
|
+
_package.logger.error(`Error creating collection: ${err instanceof Error ? err.message : err.toString()}`);
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
.show({resizable: true});
|
|
320
|
+
dlg.root.style.width = '550px';
|
|
321
|
+
monomerWidget.focus();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private showEditCollectionDialog(collectionName: string, collection: MonomerCollection): void {
|
|
325
|
+
const displayName = collectionName.replace(/\.json$/i, '');
|
|
326
|
+
const nameInput = ui.input.string('Name', {value: displayName, nullable: false});
|
|
327
|
+
const descInput = ui.input.string('Description', {value: collection.description ?? '', nullable: true});
|
|
328
|
+
const tagsInput = ui.input.string('Tags', {value: (collection.tags ?? []).join(', '), nullable: true, placeholder: 'Comma-separated tags'});
|
|
329
|
+
|
|
330
|
+
const polymerTypes: PolymerType[] = ['PEPTIDE', 'RNA', 'CHEM'];
|
|
331
|
+
const polymerTypeInput = ui.input.choice('Polymer Type', {items: polymerTypes, value: 'PEPTIDE' as PolymerType, nullable: false});
|
|
332
|
+
|
|
333
|
+
const monomerWidget = new MonomerSelectionWidget(
|
|
334
|
+
this.monomerLib!, polymerTypeInput.value as PolymerType, [...(collection.monomerSymbols ?? [])], this.libHelper!,
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
// eslint-disable-next-line rxjs/no-ignored-subscription
|
|
338
|
+
polymerTypeInput.onChanged.subscribe(() => {
|
|
339
|
+
monomerWidget.setPolymerType(polymerTypeInput.value as PolymerType);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
const dlg = ui.dialog({title: `Edit Collection: ${displayName}`})
|
|
343
|
+
.add(ui.divV([
|
|
344
|
+
nameInput,
|
|
345
|
+
descInput,
|
|
346
|
+
tagsInput,
|
|
347
|
+
polymerTypeInput,
|
|
348
|
+
ui.element('hr'),
|
|
349
|
+
monomerWidget.root,
|
|
350
|
+
]))
|
|
351
|
+
.onOK(async () => {
|
|
352
|
+
const name = nameInput.value?.trim();
|
|
353
|
+
if (!name) {
|
|
354
|
+
grok.shell.warning('Collection name is required');
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
const selectedMonomers = monomerWidget.getSelectedMonomers();
|
|
358
|
+
if (selectedMonomers.length === 0) {
|
|
359
|
+
grok.shell.warning('Please select at least one monomer');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const tags = parseTags(tagsInput.value);
|
|
363
|
+
try {
|
|
364
|
+
if (name !== displayName)
|
|
365
|
+
await this.libHelper!.deleteMonomerCollection(collectionName);
|
|
366
|
+
|
|
367
|
+
await this.libHelper!.addOrUpdateMonomerCollection(name, selectedMonomers, descInput.value ?? undefined, tags.length > 0 ? tags : undefined);
|
|
368
|
+
grok.shell.info(`Collection "${name}" saved successfully`);
|
|
369
|
+
await this.refresh();
|
|
370
|
+
} catch (err: any) {
|
|
371
|
+
grok.shell.error('Error saving collection');
|
|
372
|
+
_package.logger.error(`Error saving collection: ${err instanceof Error ? err.message : err.toString()}`);
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
.show({resizable: true});
|
|
376
|
+
dlg.root.style.width = '550px';
|
|
377
|
+
monomerWidget.focus();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
private confirmDeleteCollection(collectionName: string): void {
|
|
381
|
+
const displayName = collectionName.replace(/\.json$/i, '');
|
|
382
|
+
ui.dialog({title: 'Delete Collection'})
|
|
383
|
+
.add(ui.divText(`Are you sure you want to delete the collection "${displayName}"?`))
|
|
384
|
+
.onOK(async () => {
|
|
385
|
+
try {
|
|
386
|
+
await this.libHelper!.deleteMonomerCollection(collectionName);
|
|
387
|
+
grok.shell.info(`Collection "${displayName}" deleted`);
|
|
388
|
+
await this.refresh();
|
|
389
|
+
} catch (err: any) {
|
|
390
|
+
grok.shell.error('Error deleting collection');
|
|
391
|
+
_package.logger.error(`Error deleting collection: ${err instanceof Error ? err.message : err.toString()}`);
|
|
392
|
+
}
|
|
393
|
+
})
|
|
394
|
+
.show();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async refresh(): Promise<void> {
|
|
398
|
+
await this.loadCollections();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function parseTags(input: string | null): string[] {
|
|
403
|
+
if (!input) return [];
|
|
404
|
+
return input.split(',').map((t) => t.trim()).filter((t) => t.length > 0);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Creates and returns the Monomer Collections management view */
|
|
408
|
+
export async function showMonomerCollectionsView(addView = true): Promise<DG.ViewBase> {
|
|
409
|
+
if (addView) {
|
|
410
|
+
const existingView = Array.from(grok.shell.views).find(
|
|
411
|
+
(v) => v.name === MonomerCollectionsView.VIEW_NAME,
|
|
412
|
+
);
|
|
413
|
+
if (existingView) {
|
|
414
|
+
grok.shell.v = existingView;
|
|
415
|
+
return existingView;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const manager = new MonomerCollectionsView();
|
|
420
|
+
const view = await manager.init();
|
|
421
|
+
if (addView)
|
|
422
|
+
grok.shell.addView(view);
|
|
423
|
+
return view;
|
|
424
|
+
}
|
|
@@ -255,6 +255,58 @@ export class MonomerManager implements IMonomerManager {
|
|
|
255
255
|
}
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
async createNewMonomersCollectionDialog(monomerSymbols: string[]) {
|
|
259
|
+
const existingCollections = (await this.monomerLibManamger.listMonomerCollections()).map((name) => name.toLowerCase());
|
|
260
|
+
const nameInput = ui.input.string('Collection Name', {tooltipText: 'Name of the monomer collection, should be unique', placeholder: 'Enter collection name', nullable: false});
|
|
261
|
+
const descriptionInput = ui.input.string('Description', {tooltipText: 'Description of the monomer collection', placeholder: 'Enter collection description', nullable: true});
|
|
262
|
+
const d = ui.dialog('Create New Monomer Collection')
|
|
263
|
+
.add(nameInput)
|
|
264
|
+
.add(descriptionInput)
|
|
265
|
+
.addButton('Add', async () => {
|
|
266
|
+
if (!nameInput.value || !nameInput.value.trim()) {
|
|
267
|
+
grok.shell.warning('Collection name cannot be empty');
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const saveAction = async (symbols: string[]) => {
|
|
271
|
+
await this.monomerLibManamger.addOrUpdateMonomerCollection(nameInput.value!, symbols, descriptionInput.value ?? undefined);
|
|
272
|
+
grok.shell.info(`Collection ${nameInput.value} saved successfully`);
|
|
273
|
+
};
|
|
274
|
+
if (existingCollections.includes(nameInput.value!.toLowerCase()) || existingCollections.includes(nameInput.value!.toLowerCase() + '.json')) {
|
|
275
|
+
const confD = ui.dialog('Collection already exists')
|
|
276
|
+
.add(ui.divText(`A collection with the name ${nameInput.value} already exists. Do you want to merge or overwrite it?`));
|
|
277
|
+
confD.addButton('Merge', async () => {
|
|
278
|
+
const existingCollection = await this.monomerLibManamger.readMonomerCollection(nameInput.value!);
|
|
279
|
+
const mergedSymbols = Array.from(new Set([...(existingCollection.monomerSymbols ?? []), ...monomerSymbols]));
|
|
280
|
+
try {
|
|
281
|
+
await saveAction(mergedSymbols);
|
|
282
|
+
} catch (e) {
|
|
283
|
+
grok.shell.error('Error merging monomer collection');
|
|
284
|
+
console.error(e);
|
|
285
|
+
}
|
|
286
|
+
confD.close();
|
|
287
|
+
});
|
|
288
|
+
confD.addButton('Overwrite', async () => {
|
|
289
|
+
try {
|
|
290
|
+
await saveAction(monomerSymbols);
|
|
291
|
+
} catch (e) {
|
|
292
|
+
grok.shell.error('Error overwriting monomer collection');
|
|
293
|
+
console.error(e);
|
|
294
|
+
}
|
|
295
|
+
confD.close();
|
|
296
|
+
});
|
|
297
|
+
confD.show();
|
|
298
|
+
} else {
|
|
299
|
+
try {
|
|
300
|
+
await saveAction(monomerSymbols);
|
|
301
|
+
} catch (e) {
|
|
302
|
+
grok.shell.error('Error creating monomer collection');
|
|
303
|
+
console.error(e);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
d.close();
|
|
307
|
+
}).show();
|
|
308
|
+
}
|
|
309
|
+
|
|
258
310
|
async createNewLibDialog(monomers?: Monomer[]) {
|
|
259
311
|
const monomerLibs = await this.monomerLibManamger.getAvaliableLibraryNames();
|
|
260
312
|
const libNameInput = ui.input.string('Library Name', {
|
|
@@ -321,26 +373,36 @@ export class MonomerManager implements IMonomerManager {
|
|
|
321
373
|
args.context.tableView.id !== (this.tv!.id ?? '') || !args.item || !args.item.isTableCell || (args.item.tableRowIndex ?? -1) < 0)
|
|
322
374
|
return;
|
|
323
375
|
const rowIdx = args.item.tableRowIndex;
|
|
324
|
-
args.menu
|
|
376
|
+
const menu = args.menu as DG.Menu;
|
|
377
|
+
menu.item('Edit Monomer', async () => {
|
|
325
378
|
await this.editMonomer(this.tv!.dataFrame.rows.get(rowIdx));
|
|
326
379
|
});
|
|
327
380
|
|
|
328
|
-
|
|
381
|
+
menu.item('Fix all monomers', () => {
|
|
329
382
|
this.fixAllMonomers();
|
|
330
383
|
});
|
|
331
384
|
if (this.tv!.dataFrame.selection.trueCount > 0) {
|
|
332
|
-
|
|
385
|
+
const group = menu.group('Selected Monomers');
|
|
386
|
+
group.item('Remove', async () => {
|
|
333
387
|
const monomers = await Promise.all(Array.from(this.tv!.dataFrame.selection.getSelectedIndexes())
|
|
334
388
|
.map((r) => monomerFromDfRow(this.tv!.dataFrame.rows.get(r))));
|
|
335
389
|
this._newMonomerForm.removeMonomers(monomers, this.libInput.value!);
|
|
336
390
|
});
|
|
337
|
-
|
|
391
|
+
group.item('Create Library', async () => {
|
|
338
392
|
const monomers = await Promise.all(Array.from(this.tv!.dataFrame.selection.getSelectedIndexes())
|
|
339
393
|
.map((r) => monomerFromDfRow(this.tv!.dataFrame.rows.get(r))));
|
|
340
394
|
this.createNewLibDialog(monomers);
|
|
341
395
|
});
|
|
396
|
+
group.item('Create Collection', async () => {
|
|
397
|
+
const monomerSymbols = Array.from(this.tv!.dataFrame.selection.getSelectedIndexes())
|
|
398
|
+
.map((r) => this.tv!.dataFrame.col(MONOMER_DF_COLUMN_NAMES.SYMBOL)!.get(r) as string)
|
|
399
|
+
.filter((s): s is string => !!s && s.trim().length > 0);
|
|
400
|
+
if (monomerSymbols.length === 0)
|
|
401
|
+
return grok.shell.warning('No valid monomer symbols found in selection');
|
|
402
|
+
this.createNewMonomersCollectionDialog(monomerSymbols);
|
|
403
|
+
});
|
|
342
404
|
} else {
|
|
343
|
-
|
|
405
|
+
menu.item('Remove Monomer', async () => {
|
|
344
406
|
const monomer = await monomerFromDfRow(this.tv!.dataFrame.rows.get(rowIdx));
|
|
345
407
|
this._newMonomerForm.removeMonomers([monomer], this.libInput.value!);
|
|
346
408
|
});
|