Passed
Push — main ( 19d65e...4d6bcb )
by Eduardo
03:04
created

SoFloC.load   A

Complexity

Conditions 4

Size

Total Lines 28
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 4

Importance

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