Passed
Push — main ( 9ab29e...5f4a75 )
by Eduardo
02:10
created

index.ts ➔ catch   A

Complexity

Conditions 1

Size

Total Lines 3
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 3
dl 0
loc 3
rs 10
c 0
b 0
f 0
cc 1
1
import { randomUUID } from 'crypto'
2
import JSZip from 'jszip'
3
import { xml2js } from 'xml-js'
4
import { CustomisationsXml } from './customisations'
5
import { SolutionXml } from './solution'
6
import { Base64, FileInput, FlowCopyT, PrivateWorkflowT, WorkflowT, Xml } from './types'
7
8
export * from './types'
9
10
export class SoFloC {
11
  /**
12
   * Creates a new SoFloC instance. To be able to use it you need to run `await soFloC.load()`
13
   * @param file The file data to be open
14
   * @param name The name of the file
15
   */
16
  constructor (file: FileInput, name: string) {
17
    this.#wasLoaded = false
18
    this.#file = file
19
    this.name = name
20
  }
21
22
  /**
23
   * Loads a ***Solution*** zip file and make it ready to get the existing flows and the version, copy flows and update the version. Sets #wasLoaded to true
24
   */
25
  async load () {
26
    if (!this.#wasLoaded) {
27
      this.#zip = await this.#unzip(this.#file)
28
29
      const [customisations, customisationsData] = await this.#getCustomisations(this.#zip)
30
      this.#customisations = customisations
31
      this.#customisationsData = customisationsData
32
33
      const [solution, solutionData] = await this.#getSolution(this.#zip)
34
      this.#solution = solution
35
      this.#solutionData = solutionData
36
37
      this.version = this.#getCurrentVersion(this.#solutionData)
38
      this.originalVersion = this.version
39
40
      this.#workflows = this.#getWorkflows(this.#customisationsData, this.#solutionData, this.#zip)
41
      this.data = await this.#getData(this.#zip)
42
43
      this.#wasLoaded = true
44
    }
45
  }
46
47
  /**
48
   * Copies a flow in the ***Solution***.
49
   * @param flowGuid The GUID of the flow to be copied
50
   * @param newFlowName The name of the copy
51
   * @param newVersion The new ***Solution*** version
52
   */
53
  async copyFlow (flowGuid: string, newFlowName: string, newVersion?: string) {
54
    await this.load()
55
    this.#worflowExists(flowGuid)
56
57
    if (newVersion) await this.updateVersion(newVersion)
58
59
    const copyData = this.#getCopyData(newFlowName)
60
61
    const [customisations, customisationsData] = this.#copyOnCustomisations(flowGuid, copyData)
62
    this.#customisations = customisations
63
    this.#customisationsData = customisationsData
64
65
    const [solution, solutionData] = this.#copyOnSolution(flowGuid, copyData)
66
    this.#solution = solution
67
    this.#solutionData = solutionData
68
69
    await this.#copyFile(flowGuid, copyData)
70
  }
71
72
  /**
73
   * Deletes a flow in the ***Solution***.
74
   * @param flowGuid The GUID of the flow to be copied
75
   */
76
  async deleteFlow (flowGuid: string) {
77
    await this.load()
78
    this.#worflowExists(flowGuid)
79
80
    const [customisations, customisationsData] = this.#deleteOnCustomisations(flowGuid)
81
    this.#customisations = customisations
82
    this.#customisationsData = customisationsData
83
84
    const [solution, solutionData] = this.#deleteOnSolution(flowGuid)
85
    this.#solution = solution
86
    this.#solutionData = solutionData
87
88
    await this.#deleteFile(flowGuid)
89
  }
90
91
  /**
92
   * Updates the ***Solution*** version. The new version must be bigger than the previous.
93
   * @param newVersion The new ***Solution*** version
94
   */
95
  async updateVersion (newVersion: string) {
96
    await this.load()
97
    this.validateVersion(newVersion)
98
99
    this.name = this.name
100
      .replace(this.#snake(this.version), this.#snake(newVersion))
101
    this.#solution = this.#solution
102
      .replace(`<Version>${this.version}</Version>`, `<Version>${newVersion}</Version>`)
103
    this.version = newVersion
104
105
    this.#zip.file('solution.xml', this.#solution)
106
107
    this.data = await this.#getData(this.#zip)
108
  }
109
110
  /**
111
   * The list of workflows in the solution. To be able to get the list you need to run `await soFloC.load()` first.
112
   */
113
  get workflows () {
114
    if (!this.#wasLoaded) return []
115
    return this.#workflows.map(workflow => ({
116
      name: workflow.name,
117
      id:   workflow.id,
118
    })) as WorkflowT[]
119
  }
120
121
  /* #region LOAD METHODS */
122
  /**
123
   * Resets the loaded data
124
   */
125
  /**
126
   * Retrieves the ***Solution*** zip content
127
   * @param file The ***Solution*** zip file (base64, string, text, binarystring, array, uint8array, arraybuffer, blob or stream)
128
   */
129
  async #unzip (file: FileInput) {
130
    try {
131
      const options = typeof file === 'string'
132
        ? { base64: true }
133
        : {}
134
      return await JSZip.loadAsync(file, options)
135
    } catch (error) {
136
      console.log(error)
137
      throw new Error('Failed to unzip the file')
138
    }
139
  }
140
141
  /**
142
   * Retrieves the customization.xml string
143
   * @param zip The ***Solution*** JSZip content
144
   */
145
  async #getCustomisations (zip: JSZip): Promise<[Xml, CustomisationsXml]> {
146
    return (await this.#getXmlContentFromZip('customizations', zip)) as [Xml, CustomisationsXml]
147
  }
148
149
  /**
150
   * Retrieves the customization.xml string
151
   * @param zip The ***Solution*** JSZip content
152
   */
153
  async #getSolution (zip: JSZip): Promise<[Xml, SolutionXml]> {
154
    return (await this.#getXmlContentFromZip('solution', zip)) as [Xml, SolutionXml]
155
  }
156
157
  /**
158
   * Retrieves a XML from the ***Solution*** zip.
159
   * @param xmlName The name of the XML to be retrieved (without extension)
160
   * @returns The string content of the XML
161
   */
162
  async #getXmlContentFromZip (xmlName: string, zipContents: JSZip): Promise<[Xml, CustomisationsXml | SolutionXml]> {
163
    try {
164
      const file = zipContents.files[`${xmlName}.xml`]
165
      const xml = await file.async('string')
166
      const data = xml2js(xml, { compact: true }) as CustomisationsXml
167
168
      return [
169
        xml,
170
        data,
171
      ]
172
    } catch (error) {
173
      console.log(error)
174
      throw new Error(`'${xmlName}.xml' was not found in the Solution zip`)
175
    }
176
  }
177
178
  /**
179
   * Retrieves the ***Solution*** current version from solution.xml
180
   * @param solution The solution.xml
181
   */
182
  #getCurrentVersion (solution: SolutionXml) {
183
    try {
184
      return solution.ImportExportXml.SolutionManifest.Version._text
185
    } catch (error) {
186
      console.log(error)
187
      throw new Error('Failed to retrieve the version')
188
    }
189
  }
190
191
  /**
192
   * Retrieves the list of workflows found in the ***Solution*** zip
193
   * @param customisations The customizations.xml
194
   * @param zip The ***Solution*** JSZip content
195
   * @returns The workflows list
196
   */
197
  #getWorkflows (customisations: CustomisationsXml, solution: SolutionXml, zip: JSZip) {
198
    const workflowFiles = Object.entries(zip.files).filter(([name]) => name.match(/Workflows\/.+\.json/)).map(file => file[1])
199
200
    const wfs = Array.isArray(customisations.ImportExportXml.Workflows.Workflow)
201
      ? customisations.ImportExportXml.Workflows.Workflow
202
      : [customisations.ImportExportXml.Workflows.Workflow]
203
    const workflows = wfs
204
      .map(workflow => {
205
        const id = workflow._attributes.WorkflowId.replace(/{|}/g, '')
206
        const rcs = Array.isArray(solution.ImportExportXml.SolutionManifest.RootComponents.RootComponent)
207
          ? solution.ImportExportXml.SolutionManifest.RootComponents.RootComponent
208
          : [solution.ImportExportXml.SolutionManifest.RootComponents.RootComponent]
209
        const isOnSolution = rcs.findIndex(wf => wf._attributes.id.includes(id)) >= 0
210
        const file = workflowFiles.find(workflowFile => workflowFile.name.includes(id.toUpperCase())) as JSZip.JSZipObject
211
        return !!file && !!id && isOnSolution
212
          ? {
213
              name: workflow._attributes.Name,
214
              id,
215
              file,
216
            }
217
          : null
218
      })
219
    return workflows.filter(workflow => workflow !== null) as PrivateWorkflowT[]
220
  }
221
222
  /**
223
   * Retrieves the zip data
224
   * @param zip The ***Solution*** zip
225
   * @returns The generated base64 zip
226
   */
227
  async #getData (zip: JSZip) {
228
    return await zip.generateAsync({
229
      type:               'base64',
230
      compression:        'DEFLATE',
231
      compressionOptions: {
232
        level: 9,
233
      },
234
    })
235
  }
236
  /* #endregion */
237
238
  /* #region COPY FLOW METHODS */
239
  /**
240
   * Retrieves an object containing the information of the flow copy
241
   * @param newFlowName The name of the flow copy
242
   * @returns The flow copy data
243
   */
244
  #getCopyData (newFlowName: string) {
245
    const guid = randomUUID()
246
    const upperGuid = guid.toUpperCase()
247
    const fileName = `Workflows/${newFlowName.replace(/\s/g, '')}-${upperGuid}.json`
248
249
    return {
250
      guid,
251
      upperGuid,
252
      name: newFlowName,
253
      fileName,
254
    }
255
  }
256
257
  /**
258
   * Copies the flow inside the customizations.xml
259
   * @param flowGuid The GUID of the original flow to be copied
260
   * @param copyData The data of the flow copy
261
   * @returns The customisations.xml data
262
   */
263
  #copyOnCustomisations (flowGuid: string, copyData: FlowCopyT): [Xml, CustomisationsXml] {
264
    const flow = this.workflows.find(wf => wf.id === flowGuid) as WorkflowT
265
    const workflowComponent = `<Workflow WorkflowId="{${flowGuid}}" Name=".+?">(.|\r|\n)+?<\/Workflow>`
266
    const workflowRegEx = new RegExp(`\r?\n?.+?${workflowComponent}`, 'gm')
267
268
    const workflow = this.#customisations.match(workflowRegEx)?.[0] as string
269
270
    const jsonFileNameRegEx = /<JsonFileName>(.|\r|\n)+?<\/JsonFileName>/gi
271
    const introducedVersionRegEx = /<IntroducedVersion>(.|\r|\n)+?<\/IntroducedVersion>/gi
272
    const localisedNameComponent = `<LocalizedName languagecode="(\\d+?)" description="${flow.name}" />`
273
    const localisedNameRegEx = new RegExp(localisedNameComponent)
274
275
    const copy = workflow
276
      .replace(flowGuid, copyData.guid)
277
      .replace(/Name=".+?"/, `Name="${copyData.name}"`)
278
      .replace(jsonFileNameRegEx, `<JsonFileName>/${copyData.fileName}</JsonFileName>`)
279
      .replace(introducedVersionRegEx, `<IntroducedVersion>${this.version}</IntroducedVersion>`)
280
      .replace(localisedNameRegEx, `<LocalizedName languagecode="$1" description=\"${copyData.name}\" \/>`)
281
282
    const customisations = this.#customisations.replace(workflow, `${workflow}${copy}`)
283
    const data = xml2js(customisations, { compact: true }) as CustomisationsXml
284
285
    return [
286
      customisations,
287
      data,
288
    ]
289
  }
290
291
  /**
292
   * Copies the flow inside solution.xml
293
   * @param flowGuid The GUID of the original flow to be copied
294
   * @param copyData The data of the flow copy
295
   * @returns The solution.xml data
296
   */
297
  #copyOnSolution (flowGuid: string, copyData: FlowCopyT): [Xml, SolutionXml] {
298
    const rootComponent = `<RootComponent type="29" id="{${flowGuid}}" behavior="0" />`
299
    const rootRegEx = new RegExp(`\r?\n?.+?${rootComponent}`, 'gm')
300
301
    const root = this.#solution.match(rootRegEx)?.[0] as string
302
303
    const copy = root
304
      .replace(flowGuid, copyData.guid)
305
306
    const solution = this.#solution
307
      .replace(root, `${root}${copy}`)
308
    const data = xml2js(solution, { compact: true }) as SolutionXml
309
310
    return [
311
      solution,
312
      data,
313
    ]
314
  }
315
316
  /**
317
   * Copies the flow inside the ***Solution*** zip and updates data and #workflows properties
318
   * @param flowGuid The GUID of the original flow to be copied
319
   * @param copyData The data of the flow copy
320
   */
321
  async #copyFile (flowGuid: string, copyData: FlowCopyT) {
322
    const fileToCopy = this.#workflows.find(wf => wf.id === flowGuid.toLowerCase()) as PrivateWorkflowT
323
324
    this.#zip.file(copyData.fileName, await fileToCopy.file.async('string'))
325
    this.#zip.file('solution.xml', this.#solution)
326
    this.#zip.file('customizations.xml', this.#customisations)
327
328
    this.data = await this.#getData(this.#zip)
329
    this.#workflows = this.#getWorkflows(this.#customisationsData, this.#solutionData, this.#zip)
330
  }
331
  /* #endregion */
332
333
  /* #region DELETE FLOW METHODS */
334
  /**
335
   * Deletes the flow inside the customizations.xml
336
   * @param flowGuid The GUID of the flow to be deleted
337
   * @returns The customisations.xml data
338
   */
339
  #deleteOnCustomisations (flowGuid: string): [Xml, CustomisationsXml] {
340
    const workflowComponent = `<Workflow WorkflowId="{${flowGuid}}" Name=".+?">(.|\r|\n)+?<\/Workflow>`
341
    const workflowRegEx = new RegExp(`\r?\n?.+?${workflowComponent}`, 'gm')
342
343
    const workflow = this.#customisations.match(workflowRegEx)?.[0] as string
344
345
    const customisations = this.#customisations.replace(workflow, '')
346
    const data = xml2js(customisations, { compact: true }) as CustomisationsXml
347
348
    return [
349
      customisations,
350
      data,
351
    ]
352
  }
353
354
  /**
355
   * Deletes the flow inside solution.xml
356
   * @param flowGuid The GUID of the flow to be deleted
357
   * @returns The solution.xml data
358
   */
359
  #deleteOnSolution (flowGuid: string): [Xml, SolutionXml] {
360
    const rootComponent = `<RootComponent type="29" id="{${flowGuid}}" behavior="0" />`
361
    const rootRegEx = new RegExp(`\r?\n?.+?${rootComponent}`, 'gm')
362
363
    const root = this.#solution.match(rootRegEx)?.[0] as string
364
365
    const solution = this.#solution.replace(root, '')
366
    const data = xml2js(solution, { compact: true }) as SolutionXml
367
368
    return [
369
      solution,
370
      data,
371
    ]
372
  }
373
374
  /**
375
   * Deletes the flow inside the ***Solution*** zip and updates data and #workflows properties
376
   * @param flowGuid The GUID of the flow to be deleted
377
   */
378
  async #deleteFile (flowGuid: string) {
379
    const fileToDelete = this.#workflows.find(wf => wf.id === flowGuid.toLowerCase()) as PrivateWorkflowT
380
381
    this.#zip.remove(fileToDelete.file.name)
382
    this.#zip.file('solution.xml', this.#solution)
383
    this.#zip.file('customizations.xml', this.#customisations)
384
385
    this.data = await this.#getData(this.#zip)
386
    this.#workflows = this.#getWorkflows(this.#customisationsData, this.#solutionData, this.#zip)
387
  }
388
  /* #endregion */
389
390
  /* #region UPDATE VERION METHODS */
391
  /**
392
   * Validates if the new version is valid
393
   * @param newVersion The new ***Solution*** version
394
   */
395
  validateVersion (newVersion: string) {
396
    const validRegEx = /^((\d+\.)+\d+)$/
397
    if (!validRegEx.exec(newVersion)) {
398
      throw new Error(`Version '${newVersion}' is not valid. It should follow the format <major>.<minor>.<?build>.<?revision>.`)
399
    }
400
401
    const originalVersionValues = this.originalVersion.split('.').map(value => Number(value))
402
    const newVersionValues = newVersion.split('.').map(value => Number(value))
403
404
    let currentValueString = ''
405
    let newValueString = ''
406
    for (let i = 0; i < originalVersionValues.length; i++) {
407
      const currentValue = originalVersionValues[i] || 0
408
      const newValue = newVersionValues[i] || 0
409
410
      const currentValueLength = String(currentValue).length
411
      const newValueLength = String(newValue).length
412
413
      const maxLength = Math.max(currentValueLength, newValueLength)
414
415
      currentValueString += '0'.repeat(maxLength - currentValueLength) + String(currentValue)
416
      newValueString += '0'.repeat(maxLength - newValueLength) + String(newValue)
417
    }
418
419
    if (Number(newValueString) < Number(currentValueString) ||
420
    (Number(newValueString) === Number(currentValueString) && newVersion !== this.originalVersion)) throw new Error(`Version '${newVersion}' is smaller than '${this.originalVersion}'`)
421
  }
422
  /* #endregion */
423
424
  /* #region  GENERAL METHODS */
425
  /**
426
   * Verifies if a specified workflow exists in the ***Solution***
427
   */
428
  #worflowExists (flowGuid: string) {
429
    if (this.#workflows.findIndex(wf => wf.id === flowGuid) < 0) throw new Error(`Workflow file with GUID '${flowGuid}' does not exist in this Solution or the Solution was changed without updating 'solution.xml' or 'customizations.xml'`)
430
  }
431
432
  /**
433
   * Retrieves the version replacing '.' to '_'
434
   * @param version The version to be converted to snake_case
435
   * @returns
436
   */
437
  #snake (version: string) {
438
    return version.replaceAll('.', '_')
439
  }
440
  /* #endregion */
441
442
  /* #region CLASS PROPERTIES */
443
  #file: FileInput
444
  #zip: JSZip
445
  /**
446
   * The ***Solution*** file name. It is update as a new version is set.
447
   */
448
  name: string
449
  /**
450
   * The ***Solution*** version. It is update as a new version is set.
451
   */
452
  version: string
453
  /**
454
   * The ***Solution*** data as Base64. It is updated as new copies are added.
455
   */
456
  data: Base64
457
  /**
458
   * The ***Solution*** version as it was when the file was loaded. It does not change when a new version is set.
459
   */
460
  originalVersion: string
461
  #workflows: PrivateWorkflowT[]
462
  #customisations: Xml
463
  #customisationsData: CustomisationsXml
464
  #solution: Xml
465
  #solutionData: SolutionXml
466
  #wasLoaded = false
467
468
  // TODO UndoStack
469
470
  /* #endregion */
471
}
472