@opencloning/store 1.0.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 ADDED
@@ -0,0 +1,11 @@
1
+ # @opencloning/store
2
+
3
+ ## 1.0.0
4
+
5
+ ### Major Changes
6
+
7
+ - babe2f9: Switch to monorepo structure and use changesets
8
+
9
+ ### Minor Changes
10
+
11
+ - 8cd33bb: Rearrange dependencies
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@opencloning/store",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "main": "./src/index.js",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./cloning": "./src/cloning.js",
9
+ "./cloning_utils": "./src/cloning_utils.js"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "https://github.com/manulera/OpenCloning_frontend.git",
14
+ "directory": "packages/store"
15
+ },
16
+ "dependencies": {
17
+ "@reduxjs/toolkit": "^1.9.7",
18
+ "@teselagen/bio-parsers": "^0.4.32",
19
+ "@teselagen/range-utils": "^0.3.13",
20
+ "redux": "^4.2.1",
21
+ "redux-form": "^8.3.10",
22
+ "redux-thunk": "^2.4.2"
23
+ },
24
+ "peerDependencies": {
25
+ "react": "^18.3.1",
26
+ "react-redux": "^8.1.3"
27
+ }
28
+ }
package/src/cloning.js ADDED
@@ -0,0 +1,456 @@
1
+ import { createSlice } from '@reduxjs/toolkit';
2
+ import { doesSourceHaveOutput, getNextUniqueId } from './cloning_utils';
3
+ import { convertToTeselaJson } from '@opencloning/utils/readNwrite';
4
+
5
+ function deleteFilesFromSessionStorage(sequenceId, fileName = null) {
6
+ Object.keys(sessionStorage)
7
+ .filter((key) => {
8
+ let query = `verification-${sequenceId}-`;
9
+ if (fileName) {
10
+ query += fileName;
11
+ }
12
+ return key.startsWith(query);
13
+ }).forEach((key) => sessionStorage.removeItem(key));
14
+ }
15
+
16
+ const initialState = {
17
+ mainSequenceId: null,
18
+ mainSequenceSelection: {},
19
+ sources: [
20
+ {
21
+ id: 1,
22
+ input: [],
23
+ type: null,
24
+ },
25
+ ],
26
+ sequences: [],
27
+ network: null,
28
+ currentTab: 0,
29
+ description: '',
30
+ selectedRegions: [],
31
+ knownErrors: {},
32
+ primers: [],
33
+ config: {
34
+ loaded: false,
35
+ backendUrl: null,
36
+ enableAssembler: false,
37
+ enablePlannotate: false,
38
+ },
39
+ sourcesWithHiddenAncestors: [],
40
+ teselaJsonCache: {},
41
+ alerts: [],
42
+ files: [],
43
+ appInfo: {},
44
+ // Global primer design settings affecting Tm calculations
45
+ globalPrimerSettings: {
46
+ primer_dna_conc: 50, // default 50 nM
47
+ primer_salt_monovalent: 50, // e.g. Na+ / K+
48
+ primer_salt_divalent: 1.5, // e.g. Mg2+
49
+ },
50
+ };
51
+
52
+
53
+ const reducer = {
54
+
55
+ setCurrentTab(state, action) {
56
+ state.currentTab = action.payload;
57
+ },
58
+
59
+ setMainSequenceId(state, action) {
60
+ state.mainSequenceId = action.payload;
61
+ state.mainSequenceSelection = {};
62
+ },
63
+
64
+ setMainSequenceSelection(state, action) {
65
+ state.mainSequenceSelection = action.payload;
66
+ },
67
+
68
+ addEmptySource(state, action) {
69
+ const inputSequencesIds = action.payload;
70
+ const { sources } = state;
71
+ const nextUniqueId = getNextUniqueId(state);
72
+ sources.push({
73
+ id: nextUniqueId,
74
+ input: inputSequencesIds.map((id) => ({ sequence: id })),
75
+ type: null,
76
+ });
77
+ },
78
+
79
+ restoreSource(state, action) {
80
+ // This is used to roll back a source that was deleted
81
+ const source = action.payload;
82
+ const { sources } = state;
83
+ sources.push(source);
84
+ },
85
+
86
+ addTemplateChildAndSubsequentSource(state, action) {
87
+ // This is used by the Hom. Rec. primer design. You pass a
88
+ // sourceId for which you want to add a template sequence as
89
+ // an output, and then a source that will take that template
90
+ // sequence as input. The source can also have existing sequences
91
+ // as input.
92
+ const { sourceId, newSequence, newSource } = action.payload;
93
+ const { sources, sequences } = state;
94
+ const source2update = sources.find((s) => s.id === sourceId);
95
+ if (source2update === undefined) {
96
+ throw new Error('Source not found');
97
+ }
98
+
99
+ // Add the template sequence
100
+ sequences.push({
101
+ id: sourceId,
102
+ ...newSequence,
103
+ });
104
+
105
+ // Add the source that will take the template sequence as input
106
+ sources.push({
107
+ id: getNextUniqueId(state),
108
+ ...newSource,
109
+ input: [...(newSource.input || []), { sequence: sourceId }],
110
+ });
111
+ },
112
+
113
+ addPCRsAndSubsequentSourcesForAssembly(state, action) {
114
+ // This is used by the PCR primer design for Gibson Assemblies. You pass a
115
+ // sourceId (PCR from which the primer design was started),
116
+ // and a list of templateIds to be amplified by PCR. Their outputs
117
+ // will be used as input for a subsequent assembly reaction.
118
+ const { sourceId, templateIds, sourceType, newSequence } = action.payload;
119
+ const { sources, sequences } = state;
120
+
121
+ if (sources.find((s) => s.id === sourceId) === undefined) {
122
+ throw new Error('Source not found');
123
+ }
124
+ const sources2update = [sourceId];
125
+ // Add the PCR sources
126
+ templateIds.forEach((templateId) => {
127
+ const nextId = getNextUniqueId(state);
128
+ const newSource = {
129
+ id: nextId,
130
+ input: [{ sequence: templateId }],
131
+ type: 'PCRSource',
132
+ };
133
+ sources.push(newSource);
134
+ sources2update.push(newSource.id);
135
+ });
136
+
137
+ // Add the output sequences
138
+ sources2update.forEach((id) => {
139
+ sequences.push({
140
+ ...newSequence,
141
+ id,
142
+ });
143
+ });
144
+
145
+ if (sourceType !== null) {
146
+ // Add the Assembly that takes the PCR outputs as input
147
+ sources.push({
148
+ id: getNextUniqueId(state),
149
+ input: sources2update.map((id) => ({ sequence: id })),
150
+ type: sourceType,
151
+ });
152
+ }
153
+ },
154
+
155
+ addSourceAndItsOutputSequence(state, action) {
156
+ const { source, sequence } = action.payload;
157
+ const { sources, sequences } = state;
158
+
159
+ const sourceId = getNextUniqueId(state);
160
+ const sequenceId = sourceId;
161
+ const newSequence = {
162
+ ...sequence,
163
+ id: sequenceId,
164
+ };
165
+ const newSource = {
166
+ ...source,
167
+ id: sourceId
168
+ };
169
+ sequences.push(newSequence);
170
+ sources.push(newSource);
171
+ state.teselaJsonCache[sequenceId] = convertToTeselaJson(newSequence);
172
+ },
173
+
174
+ addSequenceInBetween(state, action) {
175
+ const existingSourceId = action.payload;
176
+ const existingSource = state.sources.find((s) => s.id === existingSourceId);
177
+ const newSourceId = getNextUniqueId(state);
178
+ const newSequence = {
179
+ id: newSourceId,
180
+ type: 'TemplateSequence',
181
+ };
182
+ const newSource = {
183
+ id: newSourceId,
184
+ input: existingSource.input,
185
+ type: null,
186
+ };
187
+ existingSource.input = [{ sequence: newSequence.id }];
188
+ state.sources.push(newSource);
189
+ state.sequences.push(newSequence);
190
+ },
191
+
192
+ addSequenceAndUpdateItsSource(state, action) {
193
+ const { newSequence, newSource } = action.payload;
194
+ const { sequences, sources } = state;
195
+ newSequence.id = newSource.id;
196
+
197
+ const sourceIndex = sources.findIndex((s) => s.id === newSource.id);
198
+ if (sourceIndex === -1) {
199
+ throw new Error('Source not found');
200
+ }
201
+ sources.splice(sourceIndex, 1, newSource);
202
+ sequences.push(newSequence);
203
+ state.teselaJsonCache[newSequence.id] = convertToTeselaJson(newSequence);
204
+ },
205
+
206
+ updateSequenceAndItsSource(state, action) {
207
+ const { newSequence, newSource } = action.payload;
208
+ const { sequences, sources } = state;
209
+
210
+ const sourceIndex = sources.findIndex((s) => s.id === newSource.id);
211
+ if (sourceIndex === -1) {
212
+ throw new Error('Source not found');
213
+ }
214
+ sources.splice(sourceIndex, 1, newSource);
215
+
216
+ newSequence.id = newSource.id;
217
+ const sequenceIndex = sequences.findIndex((e) => e.id === newSequence.id);
218
+ if (sequenceIndex === -1) {
219
+ throw new Error('Sequence not found');
220
+ }
221
+ sequences.splice(sequenceIndex, 1, newSequence);
222
+ state.teselaJsonCache[newSequence.id] = convertToTeselaJson(newSequence);
223
+ },
224
+
225
+ updateSource(state, action) {
226
+ const newSource = action.payload;
227
+ const { sources } = state;
228
+ const source = sources.find((s) => s.id === newSource.id);
229
+ Object.assign(source, newSource);
230
+ },
231
+
232
+ updateSequence(state, action) {
233
+ const newSequence = action.payload;
234
+ const { sequences } = state;
235
+ const sequence = sequences.find((s) => s.id === newSequence.id);
236
+ Object.assign(sequence, newSequence);
237
+ state.teselaJsonCache[newSequence.id] = convertToTeselaJson(newSequence);
238
+ },
239
+
240
+ replaceSource(state, action) {
241
+ const newSource = action.payload;
242
+ const { sources } = state;
243
+ const sourceIndex = sources.findIndex((s) => s.id === newSource.id);
244
+ if (sourceIndex === -1) {
245
+ throw new Error('Source not found');
246
+ }
247
+ sources.splice(sourceIndex, 1, newSource);
248
+ },
249
+
250
+ deleteSourceAndItsChildren(state, action) {
251
+ const sourceId = action.payload;
252
+ const { sources, sequences } = state;
253
+ const sources2delete = [];
254
+ const sequences2delete = [];
255
+ const currentSources = [sources.find((s) => s.id === sourceId)];
256
+ while (currentSources.length > 0) {
257
+ const currentSource = currentSources.pop();
258
+ sources2delete.push(currentSource.id);
259
+ if (doesSourceHaveOutput(state, currentSource.id)) {
260
+ sequences2delete.push(currentSource.id);
261
+ currentSources.push(...sources.filter((ss) => ss.input.some(({sequence}) => sequence === currentSource.id)));
262
+ }
263
+ }
264
+ state.sources = sources.filter((s) => !sources2delete.includes(s.id));
265
+ state.sequences = sequences.filter((e) => !sequences2delete.includes(e.id));
266
+ state.files = state.files.filter((f) => !sequences2delete.includes(f.sequence_id));
267
+ sequences2delete.forEach((e) => {
268
+ delete state.teselaJsonCache[e];
269
+ deleteFilesFromSessionStorage(e);
270
+ });
271
+ },
272
+
273
+ setState(state, action) {
274
+ const { sources, sequences, primers, description, files, sourcesWithHiddenAncestors } = action.payload;
275
+ if (!Array.isArray(sources) || !Array.isArray(sequences)) {
276
+ throw new Error('Cloning strategy not valid: fields `sources` and `sequences` should exist and be arrays');
277
+ }
278
+ const ids = [...sources.map((s) => s.id), ...sequences.map((e) => e.id)];
279
+ // They should all be positive integers
280
+ if (ids.some((id) => id < 1 || !Number.isInteger(id))) {
281
+ throw new Error('Some ids are not positive integers');
282
+ }
283
+ // None should be repeated
284
+ const sourceIds = sources.map((s) => s.id);
285
+ const sequenceIds = sequences.map((e) => e.id);
286
+ if (new Set(sourceIds).size !== sourceIds.length || new Set(sequenceIds).size !== sequenceIds.length) {
287
+ throw new Error('Repeated ids in sources or sequences');
288
+ }
289
+ state.sources = sources;
290
+ state.sequences = sequences;
291
+ state.teselaJsonCache = {};
292
+ state.primers = primers || [];
293
+ state.description = description || '';
294
+ state.files = files || [];
295
+ state.sourcesWithHiddenAncestors = sourcesWithHiddenAncestors || [];
296
+ sequences.forEach((e) => {
297
+ if (e.type === 'TextFileSequence') {
298
+ state.teselaJsonCache[e.id] = convertToTeselaJson(e);
299
+ }
300
+ });
301
+ },
302
+
303
+ setDescription(state, action) {
304
+ state.description = action.payload;
305
+ },
306
+
307
+ setSelectedRegions(state, action) {
308
+ state.selectedRegions = [...action.payload];
309
+ },
310
+
311
+ setKnownErrors(state, action) {
312
+ state.knownErrors = action.payload;
313
+ },
314
+
315
+ setConfig(state, action) {
316
+ Object.assign(state.config, action.payload);
317
+ state.config.loaded = true;
318
+ },
319
+
320
+ addPrimer(state, action) {
321
+ const primer = action.payload;
322
+ const { primers } = state;
323
+ const nextPrimerId = getNextUniqueId(state);
324
+ primers.push({ ...primer, id: nextPrimerId });
325
+ },
326
+
327
+ setPrimers(state, action) {
328
+ const primers = action.payload;
329
+ // Ids are unique and all are positive integers
330
+ const ids = primers.map((p) => p.id);
331
+ if (ids.some((id) => id < 1 || !Number.isInteger(id))) {
332
+ throw new Error('Some ids are not positive integers');
333
+ }
334
+ // None should be repeated
335
+ if (new Set(ids).size !== ids.length) {
336
+ throw new Error('Repeated ids in the primers');
337
+ }
338
+ state.primers = primers;
339
+ },
340
+
341
+ deletePrimer(state, action) {
342
+ const primerId = action.payload;
343
+ state.primers = state.primers.filter((p) => p.id !== primerId);
344
+ },
345
+
346
+ editPrimer(state, action) {
347
+ const editedPrimer = action.payload;
348
+ const targetPrimer = state.primers.find((p) => p.id === editedPrimer.id);
349
+ if (!targetPrimer) {
350
+ throw new Error('Primer not found');
351
+ }
352
+ Object.assign(targetPrimer, editedPrimer);
353
+ },
354
+
355
+ addPrimersToPCRSource(state, action) {
356
+ const { sourceId, fwdPrimer, revPrimer } = action.payload;
357
+ const { sources, primers } = state;
358
+ const nextId = getNextUniqueId(state);
359
+ // For now, primers were coming with id=0 from the backend
360
+ const copyFwdPrimer = { ...fwdPrimer };
361
+ const copyRevPrimer = { ...revPrimer };
362
+ copyFwdPrimer.id = nextId;
363
+ copyRevPrimer.id = nextId + 1;
364
+ primers.push(copyFwdPrimer);
365
+ primers.push(copyRevPrimer);
366
+
367
+ const source = sources.find((s) => s.id === sourceId);
368
+ if (!source) {
369
+ throw new Error('Source not found');
370
+ }
371
+ source.input = [ {sequence: nextId}, ...source.input, {sequence: nextId + 1} ];
372
+ },
373
+
374
+ addToSourcesWithHiddenAncestors(state, action) {
375
+ const sourceId = action.payload;
376
+ state.sourcesWithHiddenAncestors.push(sourceId);
377
+ },
378
+
379
+ removeFromSourcesWithHiddenAncestors(state, action) {
380
+ const sourceId = action.payload;
381
+ state.sourcesWithHiddenAncestors = state.sourcesWithHiddenAncestors.filter((id) => id !== sourceId);
382
+ },
383
+
384
+ addAlert(state, action) {
385
+ state.alerts.push(action.payload);
386
+ },
387
+
388
+ removeAlert(state, action) {
389
+ const message = action.payload;
390
+ state.alerts = state.alerts.filter((a) => a.message !== message);
391
+ },
392
+
393
+ addFile(state, action) {
394
+ state.files.push(action.payload);
395
+ },
396
+
397
+ setFiles(state, action) {
398
+ state.files = action.payload;
399
+ },
400
+
401
+ removeFile(state, action) {
402
+ const { fileName, sequenceId } = action.payload;
403
+ state.files = state.files.filter((f) => f.file_name !== fileName || f.sequence_id !== sequenceId);
404
+ },
405
+
406
+ removeFilesAssociatedToSequence(state, action) {
407
+ const sequenceId = action.payload;
408
+ state.files = state.files.filter((f) => f.sequence_id !== sequenceId);
409
+ deleteFilesFromSessionStorage(sequenceId);
410
+ },
411
+
412
+ addDatabaseIdToSequence(state, action) {
413
+ const { databaseId, id } = action.payload;
414
+ const sequence = state.sequences.find((e) => e.id === id);
415
+ if (!sequence) {
416
+ throw new Error('Sequence not found');
417
+ }
418
+ const source = state.sources.find((s) => s.id === id);
419
+ if (!source) {
420
+ throw new Error('Source not found');
421
+ }
422
+ source.database_id = databaseId;
423
+ },
424
+ addDatabaseIdToPrimer(state, action) {
425
+ const { databaseId, localId } = action.payload;
426
+ const primer = state.primers.find((p) => p.id === localId);
427
+ if (!primer) {
428
+ throw new Error('Primer not found');
429
+ }
430
+ primer.database_id = databaseId;
431
+ },
432
+
433
+ updateAppInfo(state, action) {
434
+ state.appInfo = { ...state.appInfo, ...action.payload };
435
+ },
436
+
437
+ // Update global primer settings
438
+ setGlobalPrimerSettings(state, action) {
439
+ const updates = action.payload;
440
+ state.globalPrimerSettings = {
441
+ ...state.globalPrimerSettings,
442
+ ...updates,
443
+ };
444
+ },
445
+ };
446
+
447
+
448
+
449
+ const cloningSlice = createSlice({
450
+ name: 'cloning',
451
+ initialState: { ...initialState },
452
+ reducers: reducer,
453
+ });
454
+
455
+ export const cloningActions = cloningSlice.actions;
456
+ export default cloningSlice.reducer;
@@ -0,0 +1,298 @@
1
+ import { parseFeatureLocation } from '@teselagen/bio-parsers';
2
+ import { flipContainedRange, getRangeLength } from '@teselagen/range-utils';
3
+
4
+ export const isSequenceInputOfAnySource = (id, sources) => (sources.find((source) => source.input.some(({sequence}) => sequence === id))) !== undefined;
5
+
6
+ export function getSourcesTakingSequenceAsInput(sources, sequenceId) {
7
+ return sources.filter((s) => s.input.some(({sequence}) => sequence === sequenceId));
8
+ }
9
+
10
+ export function isCompletePCRSource(source) {
11
+ return source.type === 'PCRSource' && source.input.length === 3 && source.input[0].type === 'AssemblyFragment';
12
+ }
13
+
14
+ export function isCompleteOligoHybridizationSource(source) {
15
+ return source.type === 'OligoHybridizationSource' && source.input.length === 2 && source.overhang_crick_3prime !== undefined && source.overhang_crick_3prime !== null;
16
+ }
17
+
18
+ export function getPcrTemplateSequenceId(source) {
19
+ return source.input.length === 1 ? source.input[0].sequence : source.input[1].sequence;
20
+ }
21
+
22
+ export function getIdsOfSequencesWithoutChildSource(sources, sequences) {
23
+ let idsSequencesWithChildSource = [];
24
+ sources.forEach((source) => {
25
+ idsSequencesWithChildSource = idsSequencesWithChildSource.concat(source.input.map(({sequence}) => sequence));
26
+ });
27
+ const sequencesNotChildSource = [];
28
+
29
+ sequences.forEach((sequence) => {
30
+ if (!idsSequencesWithChildSource.includes(sequence.id)) {
31
+ sequencesNotChildSource.push(sequence);
32
+ }
33
+ });
34
+ return sequencesNotChildSource.map((sequence) => sequence.id);
35
+ }
36
+
37
+ export function getInputSequencesFromSourceId(state, sourceId) {
38
+ const thisSource = state.cloning.sources.find((s) => s.id === sourceId);
39
+ // Sequences must be returned in the same order as in the source input, primers (undefined) are filtered out
40
+ return thisSource.input.map(({sequence}) => state.cloning.sequences.find((e) => e.id === sequence)).filter((e) => e !== undefined);
41
+ }
42
+
43
+ export function isSourceATemplate({ sources, sequences }, sourceId) {
44
+ // Get the output sequence
45
+ const source = sources.find((s) => s.id === sourceId);
46
+ const sequences2 = [...sequences.filter((e) => e.id === source.id), ...sequences.filter((e) => source.input.some((i) => i.sequence === e.id))];
47
+ return sequences2.some((s) => s.type === 'TemplateSequence');
48
+ }
49
+
50
+ export function getPrimerDesignObject({ sources, sequences }) {
51
+ // Find sequences that are templates and have primer_design set
52
+ const outputSequences = sequences.filter((e) => e.type === 'TemplateSequence' && Boolean(e.primer_design));
53
+ if (outputSequences.length === 0) {
54
+ // return 'No primer design sequence templates found';
55
+ return { finalSource: null, otherInputIds: [], pcrSources: [], outputSequences: [] };
56
+ }
57
+ const mockSequenceIds = outputSequences.map((s) => s.id);
58
+
59
+ // Find the PCRs from which the mock sequences are outputs
60
+ const pcrSources = sources.filter((s) => s.type === 'PCRSource' && mockSequenceIds.includes(s.id));
61
+ if (pcrSources.length === 0) {
62
+ return { finalSource: null, otherInputIds: [], pcrSources: [], outputSequences: [] };
63
+ }
64
+
65
+ // Find the template sequences for those PCRs
66
+ const templateSequences = sequences.filter((e) => pcrSources.some((ps) => ps.input.some((i) => i.sequence === e.id)));
67
+
68
+ // They should not be mock sequences
69
+ if (templateSequences.some((ts) => ts.type === 'TemplateSequence')) {
70
+ // return 'TemplateSequence input to final source is a TemplateSequence';
71
+ return { finalSource: null, otherInputIds: [], pcrSources: [], outputSequences: [] };
72
+ }
73
+
74
+ // Find the source they are input to (there should be zero or one)
75
+ const finalSources = sources.filter((s) => s.input.some((i) => mockSequenceIds.includes(i.sequence)));
76
+
77
+ if (finalSources.length === 0) {
78
+ // return as is
79
+ return { finalSource: null, otherInputIds: [], pcrSources, outputSequences };
80
+ }
81
+ if (finalSources.length > 1) {
82
+ // error
83
+ return { finalSource: null, otherInputIds: [], pcrSources: [], outputSequences: [] };
84
+ }
85
+
86
+ const finalSource = finalSources[0];
87
+
88
+ // Inputs to the finalSource that are not mock sequences with primer_design
89
+ const otherInputIds = finalSource.input.map((i) => i.sequence).filter((i) => !mockSequenceIds.includes(i));
90
+ const otherInputs = sequences.filter((e) => otherInputIds.includes(e.id));
91
+ // There should be no TemplateSequence as an input that does not have primer_design set
92
+ if (otherInputs.some((i) => i.type === 'TemplateSequence' && !Boolean(i.primer_design))) {
93
+ // return 'TemplateSequence input to final source does not have primer_design set';
94
+ return { finalSource: null, otherInputIds: [], pcrSources: [], outputSequences: [] };
95
+ }
96
+
97
+ return { finalSource, otherInputIds, pcrSources, outputSequences };
98
+ }
99
+
100
+ const formatPrimer = (primer, position) => {
101
+ const { name, sequence, id } = primer;
102
+ return {
103
+ id: `${id}`,
104
+ name,
105
+ ...position,
106
+ type: 'primer_bind',
107
+ primerBindsOn: '3prime',
108
+ forward: position.strand === 1,
109
+ bases: sequence,
110
+ };
111
+ };
112
+
113
+ export function pcrPrimerPositionsInInput(source, sequenceData) {
114
+ if (source.type !== 'PCRSource') {
115
+ throw new Error('Source is not a PCRSource');
116
+ }
117
+ const { size } = sequenceData;
118
+
119
+ const fwd = parseFeatureLocation(source.input[1].left_location, 0, 0, 0, 1, size)[0];
120
+ const rvs = parseFeatureLocation(source.input[1].right_location, 0, 0, 0, 1, size)[0];
121
+
122
+ if (!source.input[1].reverse_complemented) {
123
+ fwd.strand = 1;
124
+ rvs.strand = -1;
125
+ return [fwd, rvs];
126
+ }
127
+
128
+ const fwd2 = flipContainedRange(fwd, { start: 0, end: size - 1 }, size);
129
+ const rvs2 = flipContainedRange(rvs, { start: 0, end: size - 1 }, size);
130
+
131
+ fwd2.strand = -1;
132
+ rvs2.strand = 1;
133
+ return [fwd2, rvs2];
134
+ }
135
+
136
+ export function pcrPrimerPositionsInOutput(primers, sequenceData) {
137
+ const [fwdPrimer, rvsPrimer] = primers;
138
+ return [
139
+ { start: 0, end: fwdPrimer.sequence.length - 1, strand: 1 },
140
+ { start: sequenceData.size - rvsPrimer.sequence.length, end: sequenceData.size - 1, strand: -1 },
141
+ ];
142
+ }
143
+
144
+ export function getPCRPrimers({ primers, sources, teselaJsonCache }, sequenceId) {
145
+ let out = [];
146
+
147
+ // Get PCRs that have this sequence as input
148
+ const sourcesInput = getSourcesTakingSequenceAsInput(sources, sequenceId);
149
+ const sequenceData = teselaJsonCache[sequenceId];
150
+
151
+ sourcesInput.forEach((sourceInput) => {
152
+ if (isCompletePCRSource(sourceInput)) {
153
+ const pcrPrimers = [sourceInput.input[0].sequence, sourceInput.input[2].sequence].map((id) => primers.find((p) => p.id === id));
154
+ const primerPositions = pcrPrimerPositionsInInput(sourceInput, sequenceData);
155
+ out = out.concat(pcrPrimers.map((primer, i) => formatPrimer(primer, primerPositions[i])));
156
+ }
157
+ });
158
+
159
+ // Get the PCR that have this sequence as output (if any)
160
+ const sourceOutput = sources.find((s) => s.id === sequenceId);
161
+ if (sourceOutput?.type === 'PCRSource') {
162
+ const pcrPrimers = [sourceOutput.input[0].sequence, sourceOutput.input[2].sequence].map((id) => primers.find((p) => p.id === id));
163
+ const primerPositions = pcrPrimerPositionsInOutput(pcrPrimers, sequenceData);
164
+ out = out.concat(pcrPrimers.map((primer, i) => formatPrimer(primer, primerPositions[i])));
165
+ }
166
+ return out;
167
+ }
168
+
169
+ export function getNextUniqueId({ sources, sequences, primers }) {
170
+ const allIds = [...sources.map((s) => s.id), ...sequences.map((e) => e.id), ...primers.map((p) => p.id)];
171
+ if (allIds.length === 0) {
172
+ return 1;
173
+ }
174
+ return Math.max(...allIds) + 1;
175
+ }
176
+
177
+ export function shiftSource(source, idShift) {
178
+ const newSource = { ...source };
179
+ // Common part
180
+ newSource.id += idShift;
181
+ newSource.input = newSource.input.map((sourceInput) => ({ ...sourceInput, sequence: sourceInput.sequence + idShift }));
182
+ return newSource;
183
+ }
184
+
185
+ export function mergePrimersInSource(source, keepId, removeId) {
186
+ const newSource = { ...source };
187
+ if (isCompletePCRSource(newSource)) {
188
+ if (newSource.input[0].sequence === removeId) {
189
+ newSource.input[0].sequence = keepId;
190
+ }
191
+ if (newSource.input[2].sequence === removeId) {
192
+ newSource.input[2].sequence = keepId;
193
+ }
194
+ } else if (newSource.type === 'OligoHybridizationSource' || newSource.type === 'CRISPRSource') {
195
+ newSource.input = newSource.input.map((sourceInput) => {
196
+ if (sourceInput.type === 'SourceInput' && sourceInput.sequence === removeId) {
197
+ return { ...sourceInput, sequence: keepId };
198
+ }
199
+ return { ...sourceInput };
200
+ });
201
+ }
202
+
203
+ return newSource;
204
+ }
205
+
206
+ export function shiftStateIds(newState, oldState, skipPrimers = false) {
207
+ const { sources: newSources, sequences: newSequences, primers: newPrimers, files: newFiles } = newState;
208
+ const { sources: oldSources, sequences: oldSequences, primers: oldPrimers } = oldState;
209
+ let idShift = getNextUniqueId({ sources: oldSources, sequences: oldSequences, primers: oldPrimers });
210
+ // Substract the smallest id to minimize the starting id
211
+ idShift -= Math.min(...[...newSources.map((s) => s.id), ...newSequences.map((e) => e.id), ...newPrimers.map((p) => p.id)]);
212
+
213
+ return {
214
+ shiftedState: {
215
+ sequences: newSequences.map((e) => ({ ...e, id: e.id + idShift })),
216
+ primers: newPrimers.map((p) => ({ ...p, id: p.id + idShift })),
217
+ sources: newSources.map((s) => shiftSource(s, idShift)),
218
+ files: newFiles ? newFiles.map((f) => ({ ...f, sequence_id: f.sequence_id + idShift })) : [],
219
+ },
220
+ idShift, };
221
+ }
222
+
223
+ export function stringIsNotDNA(str) {
224
+ return str.match(/[^agct]/i) !== null;
225
+ }
226
+
227
+ export function formatGatewaySites(sites) {
228
+ const foundSites = [];
229
+ Object.keys(sites).forEach((siteName) => {
230
+ sites[siteName].forEach((location) => {
231
+ foundSites.push({ siteName, location });
232
+ });
233
+ });
234
+ return foundSites;
235
+ }
236
+
237
+ export function getSourceDatabaseId(sources, sequenceId) {
238
+ const source = sources.find((s) => s.id === sequenceId);
239
+ return source?.database_id;
240
+ }
241
+
242
+ export function primersInSource(source) {
243
+ if (isCompletePCRSource(source)) {
244
+ return [source.input[0].sequence, source.input[2].sequence];
245
+ }
246
+ if (source.type === 'OligoHybridizationSource' && source.input.length === 2) {
247
+ return source.input.map(({sequence}) => sequence);
248
+ }
249
+ if (source.type === 'CRISPRSource') {
250
+ return source.input.filter(({type}) => type === 'SourceInput').map(({sequence}) => sequence);
251
+ }
252
+ return [];
253
+ }
254
+
255
+ export function isAssemblyComplete(source) {
256
+ return source.input?.some(({type}) => type === 'AssemblyFragment');
257
+ }
258
+
259
+ export function getUsedPrimerIds(sources) {
260
+ return sources.flatMap((s) => primersInSource(s));
261
+ }
262
+
263
+ export function getSourcesWherePrimerIsUsed(sources, primerId) {
264
+ return sources.filter((s) => primersInSource(s).includes(primerId));
265
+ }
266
+
267
+ export function getPrimerBindingInfoFromSource(primers, source, sequenceLength) {
268
+ let fwdPrimer = null;
269
+ let rvsPrimer = null;
270
+ let fwdLength = 0;
271
+ let rvsLength = 0;
272
+
273
+ if (isCompletePCRSource(source)) {
274
+ fwdPrimer = primers.find((p) => p.id === source.input[0].sequence);
275
+ rvsPrimer = primers.find((p) => p.id === source.input[2].sequence);
276
+ const fwdLocation = parseFeatureLocation(source.input[0].right_location, 0, 0, 0, 1, sequenceLength)[0];
277
+ const rvsLocation = parseFeatureLocation(source.input[2].left_location, 0, 0, 0, 1, sequenceLength)[0];
278
+ fwdLength = getRangeLength(fwdLocation, sequenceLength);
279
+ rvsLength = getRangeLength(rvsLocation, sequenceLength);
280
+ if (fwdLength < 0) {
281
+ fwdLength += sequenceLength;
282
+ }
283
+ if (rvsLength < 0) {
284
+ rvsLength += sequenceLength;
285
+ }
286
+ } else if (isCompleteOligoHybridizationSource(source)) {
287
+ fwdPrimer = primers.find((p) => p.id === source.input[0].sequence);
288
+ rvsPrimer = primers.find((p) => p.id === source.input[1].sequence);
289
+ fwdLength = fwdPrimer.sequence.length + source.overhang_crick_3prime;
290
+ rvsLength = fwdLength;
291
+ }
292
+ return { sourceId: source.id, sourceType: source.type, fwdPrimer, rvsPrimer, fwdLength, rvsLength };
293
+ }
294
+
295
+ export function doesSourceHaveOutput(cloningState, sourceId) {
296
+ return Boolean(cloningState.sequences.find((s) => s.id === sourceId));
297
+ }
298
+
@@ -0,0 +1,66 @@
1
+ import { it, describe } from 'vitest';
2
+ import { pcrPrimerPositionsInInput } from './cloning_utils';
3
+
4
+ // Template sequence fwd
5
+ const source1 = {
6
+ type: 'PCRSource',
7
+ input: [
8
+ {
9
+ sequence: 1,
10
+ left_location: null,
11
+ right_location: '1..6',
12
+ reverse_complemented: false,
13
+ },
14
+ {
15
+ sequence: 2,
16
+ left_location: 'join(21..23,1..3)',
17
+ right_location: '10..15',
18
+ reverse_complemented: false,
19
+ },
20
+ {
21
+ sequence: 1,
22
+ left_location: '1..6',
23
+ right_location: null,
24
+ reverse_complemented: true,
25
+ },
26
+ ],
27
+ };
28
+
29
+ // Template sequence rvs
30
+ const source2 = {
31
+ type: 'PCRSource',
32
+ input: [
33
+ {
34
+ sequence: 1,
35
+ left_location: null,
36
+ right_location: '1..7',
37
+ reverse_complemented: false,
38
+ },
39
+ {
40
+ sequence: 4,
41
+ left_location: 'join(21..23,1..4)',
42
+ right_location: '10..15',
43
+ reverse_complemented: true,
44
+ },
45
+ {
46
+ sequence: 2,
47
+ left_location: '1..6',
48
+ right_location: null,
49
+ reverse_complemented: true,
50
+ },
51
+ ],
52
+ };
53
+
54
+ describe('test pcrPrimerPositionsInInput', () => {
55
+ it('test normal case', () => {
56
+ const primerPos1 = pcrPrimerPositionsInInput(source1, { size: 23 });
57
+ expect(primerPos1).toEqual([{ start: 20, end: 2, strand: 1 }, { start: 9, end: 14, strand: -1 }]);
58
+
59
+ const primerPos2 = pcrPrimerPositionsInInput(source2, { size: 23 });
60
+ expect(primerPos2).toEqual([{ end: 2, start: 19, strand: -1 }, { end: 13, start: 8, strand: 1 }]);
61
+ });
62
+ it('raises error if source is not a PCRSource', () => {
63
+ const source = { type: 'dummy' };
64
+ expect(() => pcrPrimerPositionsInInput(source, { size: 23 })).toThrow('Source is not a PCRSource');
65
+ });
66
+ });
package/src/index.js ADDED
@@ -0,0 +1,34 @@
1
+ // optionally connect to the redux store
2
+ import { createStore, combineReducers, applyMiddleware, compose } from 'redux';
3
+ import { tg_modalState } from '@teselagen/ui';
4
+ import {
5
+ vectorEditorReducer as VectorEditor,
6
+ vectorEditorMiddleware,
7
+ } from '@teselagen/ove';
8
+ import thunk from 'redux-thunk';
9
+ import { reducer as form } from 'redux-form';
10
+ import cloningReducer from './cloning';
11
+
12
+ const composeEnhancer = (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__
13
+ && window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__({
14
+ actionsBlacklist: ['HOVEREDANNOTATIONUPDATE', 'HOVEREDANNOTATIONCLEAR'],
15
+ // actionSanitizer,
16
+ latency: 1000,
17
+ name: 'openVE',
18
+ }))
19
+ || compose;
20
+
21
+ const store = createStore(
22
+ combineReducers({
23
+ form,
24
+ tg_modalState,
25
+ VectorEditor: VectorEditor(),
26
+ cloning: cloningReducer,
27
+ }),
28
+ undefined,
29
+ composeEnhancer(
30
+ applyMiddleware(thunk, vectorEditorMiddleware), // your store should be redux-thunk connected for the VectorEditor component to work
31
+ ),
32
+ );
33
+
34
+ export default store;
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from 'vitest/config';
2
+ import { resolve } from 'path';
3
+
4
+ export default defineConfig({
5
+ test: {
6
+ globals: true,
7
+ environment: 'jsdom',
8
+ setupFiles: '../../tests/setup.js',
9
+ include: ['src/**/*.{test,spec}.{js,jsx}'],
10
+ },
11
+ resolve: {
12
+ alias: {
13
+ '@opencloning/store': resolve(__dirname, './src'),
14
+ '@opencloning/utils': resolve(__dirname, '../utils/src/utils'),
15
+ },
16
+ },
17
+ });