@silexlabs/silex 3.0.5 → 3.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,6 +65,7 @@ import { storagePlugin } from './storage'
65
65
  import { API_PATH, API_WEBSITE_ASSETS_WRITE, API_WEBSITE_PATH } from '../../constants'
66
66
  import { ClientConfig } from '../config'
67
67
  import { titleCase } from '../utils'
68
+ import uploadProgress from './upload-progress'
68
69
 
69
70
  const plugins = [
70
71
  {name: './project-bar', value: projectBarPlugin}, // has to be before panels and dialogs
@@ -100,6 +101,7 @@ const plugins = [
100
101
  {name: './resize-panel', value: resizePanelPlugin},
101
102
  {name: '@silexlabs/grapesjs-notifications', value: notificationsPlugin},
102
103
  {name: '@silexlabs/grapesjs-keymaps-dialog', value: keymapsDialogPlugin},
104
+ {name: './upload-progress', value: uploadProgress},
103
105
  ]
104
106
  // Check that all plugins are loaded correctly
105
107
  plugins
@@ -77,9 +77,6 @@ export const settingsDialog = (editor, opts) => {
77
77
  const model = editor.getModel()
78
78
  model.set('settings', data.settings || {})
79
79
  model.set('name', data.name)
80
- editor.once('canvas:frame:load', () => {
81
- updateDom(editor)
82
- })
83
80
  })
84
81
  // Normal way to apply the head content to the DOM
85
82
  // fix #1559 Custom <head> code must be reapplied
@@ -61,8 +61,14 @@ export const storagePlugin = (editor: PublishableEditor) => {
61
61
  if (data.assets) data.assets = addTempDataToAssetUrl(data.assets, options.id, user.storage.connectorId)
62
62
  if (data.styles) data.styles = addTempDataToStyles(data.styles, options.id, user.storage.connectorId)
63
63
  //setTimeout(() => progressiveLoadPages(editor, data))
64
- await progressiveLoadPages(editor, data)
65
- return {}
64
+ if (!data.pages) {
65
+ // This happens when the website was just created
66
+ // Let grapesjs create the pages in the frontend
67
+ return data
68
+ } else {
69
+ await progressiveLoadPages(editor, data)
70
+ return {}
71
+ }
66
72
  } catch (err) {
67
73
  editor.UndoManager.clear()
68
74
  if (err.httpStatusCode === 401) {
@@ -207,6 +213,13 @@ async function progressiveLoadPages(editor: PublishableEditor, data: ProjectData
207
213
  if (data.styles) editor.setStyle(data.styles)
208
214
  if (data.assets) editor.AssetManager.add(data.assets)
209
215
 
216
+ await nextFrame()
217
+ setTimeout(() => {
218
+ // FIXME: Why is this setTimeout needed?
219
+ editor.trigger('storage:end:load', data)
220
+ editor.trigger('canvas:frame:load', editor)
221
+ })
222
+
210
223
  // Sélectionner la première page
211
224
  const firstPage = editor.Pages.getAll()[0]
212
225
  if (firstPage) editor.Pages.select(firstPage)
@@ -0,0 +1,48 @@
1
+ import { Editor } from 'grapesjs'
2
+
3
+ export default function pluginUploadProgress(editor: Editor) {
4
+ const assetManager = editor.AssetManager
5
+ const barIntervals = new WeakMap<HTMLDivElement, number>()
6
+
7
+ editor.on('asset:upload:start', () => {
8
+ const header = document.querySelector('.gjs-am-assets-header') as HTMLDivElement | null
9
+ if (!header || header.querySelector('.gjs-upload-bar')) return
10
+
11
+ const bar = document.createElement('div')
12
+ bar.className = 'gjs-upload-bar'
13
+ bar.style.cssText = `
14
+ height: 4px;
15
+ background: #28a745;
16
+ width: 0%;
17
+ margin-top: 4px;
18
+ transition: width 0.2s ease;
19
+ `
20
+ header.appendChild(bar)
21
+
22
+ let percent = 0
23
+ const interval = setInterval(() => {
24
+ percent = Math.min(percent + Math.random() * 10, 95)
25
+ bar.style.width = `${percent}%`
26
+ }, 200)
27
+
28
+ barIntervals.set(bar, interval as unknown as number)
29
+ })
30
+
31
+ const cleanup = (success = true) => {
32
+ const bar = document.querySelector('.gjs-upload-bar') as HTMLDivElement | null
33
+ if (!bar) return
34
+
35
+ const interval = barIntervals.get(bar)
36
+ if (interval) {
37
+ clearInterval(interval)
38
+ barIntervals.delete(bar)
39
+ }
40
+
41
+ if (!success) bar.style.background = '#dc3545'
42
+ bar.style.width = '100%'
43
+ setTimeout(() => bar.remove(), success ? 1000 : 2000)
44
+ }
45
+
46
+ editor.on('asset:upload:end', () => cleanup(true))
47
+ editor.on('asset:upload:error', () => cleanup(false))
48
+ }
@@ -21,6 +21,7 @@
21
21
 
22
22
  export const WEBSITE_DATA_FILE = 'website.json'
23
23
  export const WEBSITE_META_DATA_FILE = 'meta.json'
24
+ export const WEBSITE_PAGES_FOLDER = 'src'
24
25
 
25
26
  export const DEFAULT_WEBSITE_ID = 'default'
26
27
  export const DEFAULT_LANGUAGE = 'en'
@@ -15,14 +15,15 @@
15
15
  * along with this program. If not, see <https://www.gnu.org/licenses/>.
16
16
  */
17
17
 
18
- import { API_CONNECTOR_LOGIN_CALLBACK, API_CONNECTOR_PATH, API_PATH, WEBSITE_DATA_FILE } from '../../constants'
18
+ import { API_CONNECTOR_LOGIN_CALLBACK, API_CONNECTOR_PATH, API_PATH, WEBSITE_DATA_FILE, WEBSITE_PAGES_FOLDER } from '../../constants'
19
19
  import { ServerConfig } from '../../server/config'
20
20
  import { ConnectorFile, ConnectorFileContent, StatusCallback, StorageConnector, contentToBuffer, contentToString, toConnectorData } from '../../server/connectors/connectors'
21
- import { ApiError, ConnectorType, ConnectorUser, WebsiteData, WebsiteId, WebsiteMeta, WebsiteMetaFileContent, JobStatus } from '../../types'
21
+ import { ApiError, ConnectorType, ConnectorUser, WebsiteData, WebsiteId, WebsiteMeta, WebsiteMetaFileContent, JobStatus, Page } from '../../types'
22
22
  import fetch from 'node-fetch'
23
23
  import crypto, { createHash } from 'crypto'
24
24
  import { join } from 'path'
25
25
  import { Agent } from 'https'
26
+ import { getPageSlug } from '../../page'
26
27
 
27
28
  /**
28
29
  * Gitlab connector
@@ -35,12 +36,12 @@ export interface GitlabOptions {
35
36
  clientSecret: string
36
37
  branch: string
37
38
  assetsFolder: string
38
- //metaRepo: string
39
- //metaRepoFile: string
40
39
  repoPrefix: string
41
40
  scope: string
42
41
  domain: string
43
42
  timeOut: number
43
+ //metaRepo: string
44
+ //metaRepoFile: string
44
45
  }
45
46
 
46
47
  export interface GitlabToken {
@@ -112,16 +113,26 @@ interface GitlabFetchCommits {
112
113
  since: string
113
114
  }
114
115
 
115
- interface MetaRepoFileContent {
116
- websites: {
117
- [websiteId: string]: {
118
- meta: WebsiteMetaFileContent,
119
- createdAt: string,
120
- updatedAt: string,
121
- }
122
- }
116
+ interface GitlabPage {
117
+ id: string
118
+ name: string
119
+ isFile: true
123
120
  }
124
121
 
122
+ interface GitlabWebsiteData {
123
+ pages: GitlabPage[]
124
+ }
125
+
126
+ // interface MetaRepoFileContent {
127
+ // websites: {
128
+ // [websiteId: string]: {
129
+ // meta: WebsiteMetaFileContent,
130
+ // createdAt: string,
131
+ // updatedAt: string,
132
+ // }
133
+ // }
134
+ // }
135
+
125
136
  const svg = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
126
137
  <svg
127
138
  xmlns:svg="http://www.w3.org/2000/svg"
@@ -200,16 +211,18 @@ export default class GitlabConnector implements StorageConnector {
200
211
  scope: 'api', // 'api+read_api+read_user+read_repository+write_repository+email+sudo+profile+openid'
201
212
  ...opts,
202
213
  } as GitlabOptions
203
- if(!this.options.clientId) throw new Error('Missing Gitlab client ID')
204
- if(!this.options.clientSecret) throw new Error('Missing Gitlab client secret')
205
- if(!this.options.domain) throw new Error('Missing Gitlab domain')
206
- if(!this.options.timeOut) this.options.timeOut = 15000 /* default value */
214
+ if (!this.options.clientId) throw new Error('Missing Gitlab client ID')
215
+ if (!this.options.clientSecret) throw new Error('Missing Gitlab client secret')
216
+ if (!this.options.domain) throw new Error('Missing Gitlab domain')
217
+ if (!this.options.timeOut) this.options.timeOut = 15000 /* default value */
207
218
  }
208
219
 
209
220
  // **
210
221
  // Convenience methods for the Gitlab API
211
- private getAssetPath(path: string): string {
212
- return encodeURIComponent(join(this.options.assetsFolder, path))
222
+ private getAssetPath(path: string, encode = true): string {
223
+ const resolvedPath = join(this.options.assetsFolder, path)
224
+ if (encode) return encodeURIComponent(resolvedPath)
225
+ return resolvedPath
213
226
  }
214
227
 
215
228
  isUsingOfficialInstance(): boolean {
@@ -244,51 +257,11 @@ export default class GitlabConnector implements StorageConnector {
244
257
  }
245
258
 
246
259
  async readFile(session: GitlabSession, websiteId: string, fileName: string): Promise<Buffer> {
260
+ // Remove leading slash
247
261
  const safePath = fileName.replace(/^\//, '')
248
- // Call the API
249
- const url = `${this.options.domain}/api/v4/projects/${websiteId}/repository/files/${safePath}?ref=${this.options.branch}&access_token=${this.getSessionToken(session).token?.access_token}`
250
- const response = await fetch(url, {
251
- agent: this.getAgent(),
252
- method: 'GET',
253
- headers: {
254
- 'Content-Type': 'application/json',
255
- },
256
- })
257
- const json = await response.json()
258
- if(!response.ok) throw new ApiError(`Error reading file "${fileName}" from Gitlab: ${json?.message ?? json?.error ?? response.statusText}`, response.status)
259
- // From base64 string to buffer
260
- const buf = Buffer.from(json.content, 'base64')
261
- // Return the image bytes
262
- return buf
262
+ return this.downloadRawFile(session, websiteId, safePath)
263
263
  }
264
264
 
265
- /*
266
- * Get the meta repo path for the current user
267
- * The meta repo contains a JSON file which contains the list of websites
268
- */
269
- //private getMetaRepoPath(session: GitlabSession): string {
270
- // if(!this.getSessionToken(session).username) throw new ApiError('Missing Gitlab user ID. User not logged in?', 401)
271
- // return encodeURIComponent(`${this.getSessionToken(session).username}/${this.options.metaRepo}`)
272
- //}
273
-
274
- ///**
275
- // * Initialize the storage with a meta repo
276
- // */
277
- //private async initStorage(session: GitlabSession): Promise<void> {
278
- // // Create the meta repo
279
- // try {
280
- // const project = await this.callApi(session, 'api/v4/projects/', 'POST', {
281
- // name: this.options.metaRepo,
282
- // }) as any
283
- // return this.createFile(session, this.getMetaRepoPath(session), this.options.metaRepoFile, JSON.stringify({
284
- // websites: {}
285
- // } as MetaRepoFileContent))
286
- // } catch (e) {
287
- // console.error('Could not init storage', e.statusCode, e.httpStatusCode, e)
288
- // throw e
289
- // }
290
- //}
291
-
292
265
  /**
293
266
  * Call the Gitlab API with the user's token and handle errors
294
267
  */
@@ -301,7 +274,7 @@ export default class GitlabConnector implements StorageConnector {
301
274
  'Content-Type': 'application/json',
302
275
  }
303
276
  if (method === 'GET' && body) {
304
- console.error('Gitlab API error (4) - GET request with body', {url, method, body, params})
277
+ console.error('Gitlab API error (4) - GET request with body', { url, method, body, params })
305
278
  }
306
279
  // With or without body
307
280
  let response
@@ -318,11 +291,17 @@ export default class GitlabConnector implements StorageConnector {
318
291
  })
319
292
  } catch (e) {
320
293
  console.error('Gitlab API error (0)', e)
321
- throw new ApiError(`Gitlab API error (0): ${e.message} ${e.code} ${e.name} ${e.type}`, 500)
294
+ throw new ApiError(`Gitlab API error (0): ${e.message} ${e.text} ${e.code} ${e.name} ${e.type}`, 500)
322
295
  }
323
- let json: { message: string, error: string } | any
324
296
  // Handle the case when the server returns a non-JSON response (e.g. 400 Bad Request)
325
- const text = await response.text()
297
+ const text = await async function () {
298
+ try {
299
+ return await response.text()
300
+ } catch (e) {
301
+ console.error('Gitlab API error (6) - could not parse response', response.status, response.statusText, { url, method, body, params }, e)
302
+ throw new ApiError(`Gitlab API error (6): response body not available. ${e.message}`, 500)
303
+ }
304
+ }()
326
305
  if (!response.ok) {
327
306
  if (text.includes('A file with this name doesn\'t exist')) {
328
307
  throw new ApiError('Gitlab API error (5): Not Found', 404)
@@ -354,7 +333,7 @@ export default class GitlabConnector implements StorageConnector {
354
333
  return await this.callApi(session, path, method, body as any, params)
355
334
  } else {
356
335
  const message = typeof refreshJson?.message === 'object' ? Object.entries(refreshJson.message).map(entry => entry.join(' ')).join(' ') : refreshJson?.message ?? refreshJson?.error ?? response.statusText
357
- console.error('Gitlab API error (2) - could not refresh token', response.status, response.statusText, {message}, 'refresh_token:', token?.refresh_token)
336
+ console.error('Gitlab API error (2) - could not refresh token', response.status, response.statusText, { message }, 'refresh_token:', token?.refresh_token)
358
337
  // Workaround for when the token is invalid
359
338
  // It happens often which is not normal (refresh token should last 6 months)
360
339
  this.logout(session)
@@ -362,26 +341,104 @@ export default class GitlabConnector implements StorageConnector {
362
341
  throw new ApiError(`Gitlab API error (2): ${message}`, response.status)
363
342
  }
364
343
  } else {
365
- const message = typeof json?.message === 'object' ? Object.entries(json.message).map(entry => entry.join(' ')).join(' ') : json?.message ?? json?.error ?? response.statusText
366
- console.error('Gitlab API error (1)', response.status, response.statusText, {url, method, body, params, text, message})
367
- throw new ApiError(`Gitlab API error (1): ${message}`, response.status)
344
+ const message = response.statusText
345
+ console.error('Gitlab API error (1)', response.status, response.statusText, { url, method, body, params, text: text, message })
346
+ throw new ApiError(`Gitlab API error (1): ${message} (${text})`, response.status)
368
347
  }
369
348
  }
349
+ let json: { message: string, error: string } | any
370
350
  try {
371
351
  json = JSON.parse(text)
372
352
  } catch (e) {
373
- if(!response.ok) {
353
+ if (!response.ok) {
374
354
  // A real error
375
355
  throw e
376
356
  } else {
377
357
  // Useless error linked to the fact that the response is not JSON
378
- console.error('Gitlab API error (3) - could not parse response', response.status, response.statusText, {url, method, body, params, text})
358
+ console.error('Gitlab API error (3) - could not parse response', response.status, response.statusText, { url, method, body, params, text: text })
379
359
  return text
380
360
  }
381
361
  }
382
362
  return json
383
363
  }
384
364
 
365
+ // Cache for projects path
366
+ private projectPathCache = new Map<string, string>()
367
+
368
+ async downloadRawFile(session: GitlabSession, projectId: string, filePath: string): Promise<Buffer> {
369
+ const token = this.getSessionToken(session).token?.access_token
370
+ const domain = this.options.domain
371
+ const branch = this.options.branch
372
+
373
+ // Get the project path with namespace
374
+ let pathWithNamespace = this.projectPathCache.get(projectId)
375
+ if (!pathWithNamespace) {
376
+ const metaUrl = `${domain}/api/v4/projects/${projectId}`
377
+ const metaRes = await fetch(metaUrl, {
378
+ agent: this.getAgent(),
379
+ headers: {
380
+ 'Authorization': `Bearer ${token}`,
381
+ },
382
+ })
383
+ console.log('metaRes', metaRes.status, metaRes.statusText, { metaUrl })
384
+
385
+ const metaText = await metaRes.text()
386
+ if (!metaRes.ok) {
387
+ console.error('GitLab raw error (meta)', metaRes.status, metaRes.statusText, { metaUrl, metaText })
388
+ throw new ApiError(`GitLab raw error (meta): ${metaRes.statusText}`, metaRes.status)
389
+ }
390
+ console.log('metaText', metaText)
391
+
392
+ try {
393
+ const meta = JSON.parse(metaText)
394
+ pathWithNamespace = meta.path_with_namespace
395
+ } catch (e) {
396
+ throw new ApiError('GitLab raw error (meta): could not parse project info', 500)
397
+ }
398
+ console.log('pathWithNamespace', pathWithNamespace)
399
+
400
+ // Limit cache size, remove least recently used
401
+ this.projectPathCache.set(projectId, pathWithNamespace!)
402
+ if (this.projectPathCache.size > 500) {
403
+ this.projectPathCache.delete(this.projectPathCache.keys().next().value)
404
+ }
405
+ }
406
+
407
+ // Construct the raw URL
408
+ // GET /projects/:id/repository/files/:file_path/raw
409
+ const rawUrl = `${domain}/api/v4/projects/${encodeURIComponent(pathWithNamespace!)}/repository/files/${encodeURIComponent(filePath)}/raw?ref=${branch}&access_token=${token}`
410
+ const fileRes = await fetch(rawUrl, {
411
+ agent: this.getAgent(),
412
+ })
413
+ console.log('fileRes', fileRes.status, fileRes.statusText, { rawUrl, token })
414
+
415
+ const contentType = fileRes.headers.get('content-type')
416
+ if (contentType?.includes('text/html')) {
417
+ const html = await fileRes.text()
418
+ throw new ApiError('GitLab returned HTML instead of file (unauthorized or not found).', 401)
419
+ }
420
+
421
+ if (!fileRes.ok) {
422
+ const errText = await fileRes.text()
423
+ if (errText.includes('not found') || fileRes.status === 404) {
424
+ throw new ApiError('GitLab raw error (5): Not Found', 404)
425
+ }
426
+ console.error('GitLab raw error (1)', fileRes.status, fileRes.statusText, { rawUrl, errText })
427
+ throw new ApiError(`GitLab raw error (1): ${fileRes.statusText} (${errText})`, fileRes.status)
428
+ }
429
+ console.log('fileRes ok', fileRes.status, fileRes.statusText)
430
+
431
+ try {
432
+ console.log('fileRes buffer', fileRes.headers.get('content-type'), fileRes.headers.get('content-length'))
433
+ const buffer = await fileRes.buffer()
434
+ console.log('fileRes buffer', buffer.length)
435
+ return buffer
436
+ } catch (e) {
437
+ console.error('GitLab raw error (3): could not read buffer', e)
438
+ throw new ApiError('GitLab raw error (3): failed to read binary content', 500)
439
+ }
440
+ }
441
+
385
442
  private generateCodeVerifier() {
386
443
  return crypto.randomBytes(64).toString('base64')
387
444
  .replace(/\+/g, '-')
@@ -472,15 +529,15 @@ export default class GitlabConnector implements StorageConnector {
472
529
  */
473
530
  async setToken(session: GitlabSession, loginResult: any): Promise<void> {
474
531
  const sessionToken = this.getSessionToken(session)
475
- if(!loginResult.state || loginResult.state !== sessionToken?.state) {
532
+ if (!loginResult.state || loginResult.state !== sessionToken?.state) {
476
533
  this.logout(session)
477
534
  throw new ApiError('Invalid state', 401)
478
535
  }
479
- if(!sessionToken?.codeVerifier) {
536
+ if (!sessionToken?.codeVerifier) {
480
537
  this.logout(session)
481
538
  throw new ApiError('Missing code verifier', 401)
482
539
  }
483
- if(!sessionToken?.codeChallenge) {
540
+ if (!sessionToken?.codeChallenge) {
484
541
  this.logout(session)
485
542
  throw new ApiError('Missing code challenge', 401)
486
543
  }
@@ -558,7 +615,7 @@ export default class GitlabConnector implements StorageConnector {
558
615
  // }
559
616
  //}
560
617
  const userId = this.getSessionToken(session).userId
561
- if(!userId) {
618
+ if (!userId) {
562
619
  this.logout(session)
563
620
  throw new ApiError('Missing Gitlab user ID. User not logged in?', 401)
564
621
  }
@@ -574,16 +631,45 @@ export default class GitlabConnector implements StorageConnector {
574
631
  }))
575
632
  }
576
633
 
634
+ /**
635
+ * Read the website data
636
+ * The website data file is named `website.json` and the pages are named `page-{id}.json`
637
+ * The pages are stored in the `src` folder by default
638
+ */
577
639
  async readWebsite(session: GitlabSession, websiteId: string): Promise<WebsiteData> {
578
- const result = await this.callApi(session, `api/v4/projects/${websiteId}/repository/files/${WEBSITE_DATA_FILE}`, 'GET', null, {
579
- ref: this.options.branch,
580
- }) as any
581
- const { content } = result
582
- const contentDecoded = Buffer.from(content, 'base64').toString('utf8')
583
- const websiteData = JSON.parse(contentDecoded) as WebsiteData
584
- return websiteData
640
+ const websiteDataBuf = await this.downloadRawFile(session, websiteId, WEBSITE_DATA_FILE)
641
+ const websiteData = JSON.parse(websiteDataBuf.toString('utf8')) as GitlabWebsiteData | WebsiteData
642
+
643
+ // If the website pages are not in the main file, we need to read them
644
+ // This happens when the website was just created
645
+ // Let grapesjs create the pages in the frontend
646
+ if (!websiteData.pages) {
647
+ return websiteData as WebsiteData
648
+ }
649
+
650
+ // Load each page in parallel
651
+ const pages = await Promise.all(websiteData.pages.map(async (page: GitlabPage | Page) => {
652
+ if ((page as GitlabPage).isFile) {
653
+ const name = getPageSlug(page.name)
654
+ const fileName = (`${(getPageSlug(page.name))}-${page.id}`)
655
+ const filePath = `${WEBSITE_PAGES_FOLDER}/${fileName}.json`
656
+ const pageContent = await this.downloadRawFile(session, websiteId, filePath)
657
+ const res = JSON.parse(pageContent.toString('utf8')) as Page
658
+ return res
659
+ }
660
+ return page as Page
661
+ }))
662
+
663
+ // Read each page file if needed
664
+ return {
665
+ ...websiteData,
666
+ pages,
667
+ } as WebsiteData
585
668
  }
586
669
 
670
+ /**
671
+ * Create a new website, i.e. a new Gitlab repository with an empty website data file
672
+ */
587
673
  async createWebsite(session: GitlabSession, websiteMeta: WebsiteMetaFileContent): Promise<WebsiteId> {
588
674
  const project = await this.callApi(session, 'api/v4/projects/', 'POST', {
589
675
  name: this.options.repoPrefix + websiteMeta.name,
@@ -595,13 +681,76 @@ export default class GitlabConnector implements StorageConnector {
595
681
  return project.id
596
682
  }
597
683
 
684
+ /**
685
+ * Update the website data
686
+ * Split the website data into 1 file per page + 1 file for the website data itself
687
+ * Use gitlab batch API to create/update the files
688
+ */
598
689
  async updateWebsite(session: GitlabSession, websiteId: WebsiteId, websiteData: WebsiteData): Promise<void> {
599
- const project = await this.callApi(session, `api/v4/projects/${websiteId}/repository/files/${WEBSITE_DATA_FILE}`, 'PUT', {
690
+ const batchActions: GitlabAction[] = []
691
+
692
+ // List existing files in the pages folder
693
+ let existingFiles: string[] = []
694
+ try {
695
+ const files = await this.callApi(session, `api/v4/projects/${websiteId}/repository/tree`, 'GET', null, {
696
+ path: WEBSITE_PAGES_FOLDER,
697
+ recursive: false,
698
+ }) as any[]
699
+ existingFiles = files.map(file => file.path)
700
+ } catch (e) {
701
+ if (e.statusCode !== 404 && e.httpStatusCode !== 404) {
702
+ throw e
703
+ }
704
+ }
705
+
706
+ // Prepare actions for each page
707
+ const pages = websiteData.pages.map((page: Page) => {
708
+ const file_name = encodeURIComponent(`${(getPageSlug(page.name))}-${page.id}`)
709
+ const file_path = (`${WEBSITE_PAGES_FOLDER}/${file_name}.json`)
710
+ const content = JSON.stringify(page)
711
+
712
+ // Determine whether to create or update the file
713
+ if (existingFiles.includes(file_path)) {
714
+ batchActions.push({
715
+ action: 'update',
716
+ file_path,
717
+ content,
718
+ })
719
+ } else {
720
+ batchActions.push({
721
+ action: 'create',
722
+ file_path,
723
+ content,
724
+ })
725
+ }
726
+
727
+ return {
728
+ name: page.name,
729
+ id: page.id,
730
+ isFile: true,
731
+ }
732
+ })
733
+
734
+ // Prepare the main website data file
735
+ const websiteDataWithGitlabPages = {
736
+ ...websiteData,
737
+ pages,
738
+ } as GitlabWebsiteData
739
+
740
+ batchActions.push({
741
+ action: 'update',
742
+ file_path: WEBSITE_DATA_FILE,
743
+ content: JSON.stringify(websiteDataWithGitlabPages),
744
+ })
745
+
746
+ // Perform a single batch commit
747
+ const batch = {
600
748
  branch: this.options.branch,
601
749
  commit_message: 'Update website data from Silex',
602
- content: JSON.stringify(websiteData),
603
- id: websiteId,
604
- })
750
+ actions: batchActions,
751
+ }
752
+
753
+ await this.callApi(session, `api/v4/projects/${websiteId}/repository/commits`, 'POST', batch)
605
754
  }
606
755
 
607
756
  async deleteWebsite(session: GitlabSession, websiteId: WebsiteId): Promise<void> {
@@ -621,7 +770,6 @@ export default class GitlabConnector implements StorageConnector {
621
770
  // branch: this.options.branch,
622
771
  // commit_message: `Delete meta data of ${data.meta.name} (${websiteId}) from Silex`,
623
772
  // content: JSON.stringify(metaRepo),
624
- // file_path: this.options.metaRepoFile,
625
773
  //})
626
774
  }
627
775
 
@@ -641,12 +789,12 @@ export default class GitlabConnector implements StorageConnector {
641
789
  name: meta.name + ' Copy ' + new Date().toISOString().replace(/T.*/, '') + ' ' + Math.random().toString(36).substring(2, 4),
642
790
  })
643
791
  // Upload all files
644
- for(const file of files) {
645
- const path = encodeURIComponent(file)
646
- const content = await this.readFile(session, websiteId, path)
792
+ for (const file of files) {
793
+ const content = await this.readFile(session, websiteId, file)
647
794
  // From buffer to string
648
795
  const contentStr = content.toString('base64')
649
- switch(file) {
796
+ const path = encodeURIComponent(file)
797
+ switch (file) {
650
798
  case WEBSITE_DATA_FILE:
651
799
  await this.updateFile(session, newId, path, contentStr, true)
652
800
  break
@@ -686,40 +834,13 @@ export default class GitlabConnector implements StorageConnector {
686
834
  }
687
835
 
688
836
  async setWebsiteMeta(session: GitlabSession, websiteId: WebsiteId, websiteMeta: WebsiteMetaFileContent): Promise<void> {
689
- //// Load the meta repo data
690
- //const file = await this.callApi(session, `api/v4/projects/${this.getMetaRepoPath(session)}/repository/files/${this.options.metaRepoFile}`, 'GET', null, {
691
- // ref: this.options.branch,
692
- //})
693
- //const metaRepo = JSON.parse(Buffer.from(file.content, 'base64').toString('utf8')) as MetaRepoFileContent
694
- //// Update or create the website meta data
695
- //metaRepo.websites[websiteId] = {
696
- // updatedAt: new Date().toISOString(),
697
- // createdAt: metaRepo.websites[websiteId]?.createdAt ?? new Date().toISOString(),
698
- // meta: websiteMeta,
699
- //}
700
- //// Save the meta repo data
701
- //const project = await this.callApi(session, `api/v4/projects/${this.getMetaRepoPath(session)}/repository/files/${this.options.metaRepoFile}`, 'PUT', {
702
- // branch: this.options.branch,
703
- // commit_message: `Update website meta data of ${websiteMeta.name} (${websiteId}) from Silex`,
704
- // content: JSON.stringify(metaRepo),
705
- // file_path: this.options.metaRepoFile,
706
- //})
707
-
708
837
  // Rename the repo if needed
709
838
  const oldMeta = await this.getWebsiteMeta(session, websiteId)
710
- if(websiteMeta.name !== oldMeta.name) {
839
+ if (websiteMeta.name !== oldMeta.name) {
711
840
  await this.callApi(session, `api/v4/projects/${websiteId}`, 'PUT', {
712
841
  name: this.options.repoPrefix + websiteMeta.name,
713
842
  })
714
843
  }
715
- //// Update the metadata file
716
- //await this.callApi(session, `api/v4/projects/${websiteId}/repository/files/${WEBSITE_META_DATA_FILE}`, 'PUT', {
717
- // branch: this.options.branch,
718
- // commit_message: 'Update website meta data from Silex',
719
- // content: JSON.stringify(websiteMeta),
720
- // file_path: WEBSITE_META_DATA_FILE,
721
- // id: websiteId,
722
- //})
723
844
  }
724
845
 
725
846
  async writeAssets(session: GitlabSession, websiteId: string, files: ConnectorFile[], status?: StatusCallback | undefined): Promise<void> {
@@ -746,8 +867,7 @@ export default class GitlabConnector implements StorageConnector {
746
867
  }
747
868
 
748
869
  async readAsset(session: GitlabSession, websiteId: string, fileName: string): Promise<ConnectorFileContent> {
749
- // Remove leading slash
750
- const finalPath = this.getAssetPath(fileName)
870
+ const finalPath = this.getAssetPath(fileName, false)
751
871
  return this.readFile(session, websiteId, finalPath)
752
872
  }
753
873
 
@@ -762,4 +882,32 @@ export default class GitlabConnector implements StorageConnector {
762
882
  })),
763
883
  })
764
884
  }
885
+
886
+ /*
887
+ * Get the meta repo path for the current user
888
+ * The meta repo contains a JSON file which contains the list of websites
889
+ */
890
+ //private getMetaRepoPath(session: GitlabSession): string {
891
+ // if(!this.getSessionToken(session).username) throw new ApiError('Missing Gitlab user ID. User not logged in?', 401)
892
+ // return encodeURIComponent(`${this.getSessionToken(session).username}/${this.options.metaRepo}`)
893
+ //}
894
+
895
+ ///**
896
+ // * Initialize the storage with a meta repo
897
+ // */
898
+ //private async initStorage(session: GitlabSession): Promise<void> {
899
+ // // Create the meta repo
900
+ // try {
901
+ // const project = await this.callApi(session, 'api/v4/projects/', 'POST', {
902
+ // name: this.options.metaRepo,
903
+ // }) as any
904
+ // return this.createFile(session, this.getMetaRepoPath(session), this.options.metaRepoFile, JSON.stringify({
905
+ // websites: {}
906
+ // } as MetaRepoFileContent))
907
+ // } catch (e) {
908
+ // console.error('Could not init storage', e.statusCode, e.httpStatusCode, e)
909
+ // throw e
910
+ // }
911
+ //}
912
+
765
913
  }