adapt-authoring-adaptframework 2.5.6 → 3.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/.github/workflows/releases.yml +2 -1
- package/conf/config.schema.json +5 -0
- package/index.js +1 -1
- package/lib/AdaptFrameworkBuild.js +97 -47
- package/lib/AdaptFrameworkImport.js +87 -80
- package/lib/AdaptFrameworkModule.js +87 -4
- package/lib/BuildCache.js +105 -0
- package/lib/handlers.js +3 -2
- package/lib/migrations/vanilla-background-styles.js +17 -0
- package/lib/utils/applyBuildReplacements.js +23 -0
- package/lib/utils/collectMigrationScripts.js +15 -0
- package/lib/utils/computePluginHash.js +14 -0
- package/lib/utils/copyFrameworkSource.js +2 -2
- package/lib/utils/generateLanguageManifest.js +9 -0
- package/lib/utils/log.js +5 -7
- package/lib/utils/migrateExistingCourses.js +87 -0
- package/lib/utils/prebuildCache.js +110 -0
- package/lib/utils/readFrameworkPluginVersions.js +21 -0
- package/lib/utils/runContentMigration.js +72 -0
- package/lib/utils.js +8 -0
- package/package.json +4 -5
- package/tests/AdaptFrameworkImport.spec.js +7 -13
- package/tests/BuildCache.spec.js +107 -0
- package/tests/utils-applyBuildReplacements.spec.js +57 -0
- package/tests/utils-collectMigrationScripts.spec.js +45 -0
- package/tests/utils-computePluginHash.spec.js +40 -0
- package/tests/utils-generateLanguageManifest.spec.js +27 -0
- package/tests/utils-migrateExistingCourses.spec.js +163 -0
- package/tests/utils-readFrameworkPluginVersions.spec.js +48 -0
- package/tests/utils-runContentMigration.spec.js +101 -0
|
@@ -4,7 +4,8 @@ import AdaptFrameworkImport from './AdaptFrameworkImport.js'
|
|
|
4
4
|
import fs from 'node:fs/promises'
|
|
5
5
|
import { getHandler, postHandler, importHandler, postUpdateHandler, getUpdateHandler } from './handlers.js'
|
|
6
6
|
import { loadRouteConfig, registerRoutes } from 'adapt-authoring-server'
|
|
7
|
-
import { runCliCommand } from './utils.js'
|
|
7
|
+
import { runCliCommand, readFrameworkPluginVersions, migrateExistingCourses, computePluginHash, prebuildCache } from './utils.js'
|
|
8
|
+
import BuildCache from './BuildCache.js'
|
|
8
9
|
import path from 'node:path'
|
|
9
10
|
import semver from 'semver'
|
|
10
11
|
|
|
@@ -83,6 +84,17 @@ class AdaptFrameworkModule extends AbstractModule {
|
|
|
83
84
|
|
|
84
85
|
await this.installFramework()
|
|
85
86
|
|
|
87
|
+
this.app.waitForModule('contentplugin').then(contentplugin => {
|
|
88
|
+
contentplugin.postInsertHook.tap(() => this.invalidatePrebuiltCache())
|
|
89
|
+
contentplugin.postUpdateHook.tap(() => this.invalidatePrebuiltCache())
|
|
90
|
+
contentplugin.postDeleteHook.tap(() => this.invalidatePrebuiltCache())
|
|
91
|
+
})
|
|
92
|
+
this.postUpdateHook.tap(() => this.invalidatePrebuiltCache())
|
|
93
|
+
|
|
94
|
+
if (this.getConfig('prebuildCache')) {
|
|
95
|
+
this.prebuildCache()
|
|
96
|
+
}
|
|
97
|
+
|
|
86
98
|
process.env.BROWSERSLIST_IGNORE_OLD_DATA = '1'
|
|
87
99
|
|
|
88
100
|
if (this.app.args['update-framework'] === true) {
|
|
@@ -209,6 +221,7 @@ class AdaptFrameworkModule extends AbstractModule {
|
|
|
209
221
|
* @return {Promise}
|
|
210
222
|
*/
|
|
211
223
|
async updateFramework (version) {
|
|
224
|
+
let migrationResult
|
|
212
225
|
try {
|
|
213
226
|
if (version) {
|
|
214
227
|
this.checkVersionCompatibility(version)
|
|
@@ -216,13 +229,71 @@ class AdaptFrameworkModule extends AbstractModule {
|
|
|
216
229
|
if (!version && this.targetVersionRange) {
|
|
217
230
|
version = await this.getLatestVersion()
|
|
218
231
|
}
|
|
232
|
+
const fromPlugins = await readFrameworkPluginVersions(this.path)
|
|
219
233
|
await this.runCliCommand('updateFramework', { version })
|
|
220
234
|
this._version = await this.runCliCommand('getCurrentFrameworkVersion')
|
|
235
|
+
const toPlugins = await readFrameworkPluginVersions(this.path)
|
|
236
|
+
migrationResult = await migrateExistingCourses({ fromPlugins, toPlugins, frameworkDir: this.path })
|
|
221
237
|
} catch (e) {
|
|
222
238
|
this.log('error', `failed to update framework, ${e.message}`)
|
|
223
239
|
throw e.statusCode ? e : this.app.errors.FW_UPDATE_FAILED.setData({ reason: e.message })
|
|
224
240
|
}
|
|
225
|
-
this.postUpdateHook.invoke()
|
|
241
|
+
await this.postUpdateHook.invoke()
|
|
242
|
+
return migrationResult
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Returns a cached plugin hash, computing it on first call
|
|
247
|
+
* @return {Promise<String>}
|
|
248
|
+
*/
|
|
249
|
+
async getPluginHash () {
|
|
250
|
+
if (!this._pluginHash) {
|
|
251
|
+
this._pluginHash = await computePluginHash(this.path)
|
|
252
|
+
}
|
|
253
|
+
return this._pluginHash
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Invalidates the prebuilt compilation cache and optionally
|
|
258
|
+
* triggers an eager rebuild of the shared cache in the background
|
|
259
|
+
*/
|
|
260
|
+
async invalidatePrebuiltCache () {
|
|
261
|
+
this._pluginHash = null
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
await new BuildCache(path.join(this.getConfig('buildDir'), 'prebuilt-cache')).invalidate()
|
|
265
|
+
} catch (e) {
|
|
266
|
+
this.log('warn', `failed to invalidate prebuilt cache: ${e.message}`)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (this.getConfig('prebuildCache')) {
|
|
270
|
+
this.prebuildCache()
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Eagerly rebuilds the prebuilt cache in the background, iterating every
|
|
276
|
+
* (theme, menu) combination of installed plugins. Safe to call multiple
|
|
277
|
+
* times — if a build is already in progress, it will be reused.
|
|
278
|
+
* Idempotent — already-cached combos are skipped.
|
|
279
|
+
* @return {Promise<void>}
|
|
280
|
+
*/
|
|
281
|
+
prebuildCache () {
|
|
282
|
+
if (this._eagerBuildPromise) {
|
|
283
|
+
return this._eagerBuildPromise
|
|
284
|
+
}
|
|
285
|
+
this._eagerBuildPromise = prebuildCache({
|
|
286
|
+
buildDir: this.getConfig('buildDir'),
|
|
287
|
+
frameworkDir: this.path
|
|
288
|
+
}).catch(e => {
|
|
289
|
+
this.log('warn', `eager prebuild failed: ${e.message}`)
|
|
290
|
+
if (e.cmd) this.log('warn', `cmd: ${e.cmd}`)
|
|
291
|
+
if (e.raw) this.log('warn', `output: ${e.raw}`)
|
|
292
|
+
if (e.stderr) this.log('warn', `stderr: ${e.stderr}`)
|
|
293
|
+
}).finally(() => {
|
|
294
|
+
this._eagerBuildPromise = null
|
|
295
|
+
})
|
|
296
|
+
return this._eagerBuildPromise
|
|
226
297
|
}
|
|
227
298
|
|
|
228
299
|
/**
|
|
@@ -245,7 +316,7 @@ class AdaptFrameworkModule extends AbstractModule {
|
|
|
245
316
|
async loadSchemas () {
|
|
246
317
|
const jsonschema = await this.app.waitForModule('jsonschema')
|
|
247
318
|
const schemas = (await this.runCliCommand('getSchemaPaths')).filter(s => s.includes('/core/'))
|
|
248
|
-
|
|
319
|
+
schemas.forEach(s => jsonschema.registerSchema(s))
|
|
249
320
|
}
|
|
250
321
|
|
|
251
322
|
/**
|
|
@@ -265,7 +336,7 @@ class AdaptFrameworkModule extends AbstractModule {
|
|
|
265
336
|
if (!course) {
|
|
266
337
|
return
|
|
267
338
|
}
|
|
268
|
-
const shareWithUsers = course?.
|
|
339
|
+
const shareWithUsers = course._shareWithUsers?.map(id => id.toString()) ?? []
|
|
269
340
|
const userId = req.auth.user._id.toString()
|
|
270
341
|
return course.createdBy.toString() === userId || course._isShared || shareWithUsers.includes(userId)
|
|
271
342
|
}
|
|
@@ -307,6 +378,18 @@ class AdaptFrameworkModule extends AbstractModule {
|
|
|
307
378
|
this.contentMigrations.push(migration)
|
|
308
379
|
}
|
|
309
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Migrates content for specific courses. Called by contentplugin on plugin update.
|
|
383
|
+
* @param {Object} options
|
|
384
|
+
* @param {Array<{name: String, version: String}>} options.fromPlugins Plugin versions before the update
|
|
385
|
+
* @param {Array<{name: String, version: String}>} options.toPlugins Plugin versions after the update
|
|
386
|
+
* @param {String[]} options.courseIds Course IDs to migrate
|
|
387
|
+
* @returns {Promise<{migrated: Number, failed: Number, errors: Array}>}
|
|
388
|
+
*/
|
|
389
|
+
async migrateCourses ({ fromPlugins, toPlugins, courseIds }) {
|
|
390
|
+
return migrateExistingCourses({ fromPlugins, toPlugins, frameworkDir: this.path, courseIds })
|
|
391
|
+
}
|
|
392
|
+
|
|
310
393
|
/**
|
|
311
394
|
* Builds a single Adapt framework course
|
|
312
395
|
* @param {AdaptFrameworkBuildOptions} options
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'upath'
|
|
3
|
+
import { log } from './utils/log.js'
|
|
4
|
+
|
|
5
|
+
/** Build output entries that aren't cached (rebuilt per-build from course data) */
|
|
6
|
+
const SKIP_ENTRIES = new Set(['course'])
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Filesystem-level cache of grunt build output, keyed by (pluginHash, theme, menu).
|
|
10
|
+
* One instance per cache root; methods are stateless beyond the root path.
|
|
11
|
+
*/
|
|
12
|
+
class BuildCache {
|
|
13
|
+
/**
|
|
14
|
+
* @param {String} cacheRoot Root cache directory
|
|
15
|
+
*/
|
|
16
|
+
constructor (cacheRoot) {
|
|
17
|
+
this.cacheRoot = cacheRoot
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @returns {String} The cache directory path for the given combo
|
|
22
|
+
*/
|
|
23
|
+
getPath (pluginHash, theme, menu) {
|
|
24
|
+
return path.join(this.cacheRoot, `${pluginHash}_${theme}_${menu}`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* @returns {Promise<Boolean>} Whether a cached build exists for the given combo
|
|
29
|
+
*/
|
|
30
|
+
async has (pluginHash, theme, menu) {
|
|
31
|
+
try {
|
|
32
|
+
await fs.access(this.getPath(pluginHash, theme, menu))
|
|
33
|
+
return true
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Copies the build output (minus per-course content) into the cache for the given combo.
|
|
41
|
+
* Uses a temp dir + atomic rename for parallel safety.
|
|
42
|
+
* @param {String} buildOutputDir The build output directory
|
|
43
|
+
*/
|
|
44
|
+
async populate (buildOutputDir, pluginHash, theme, menu) {
|
|
45
|
+
const cacheDir = this.getPath(pluginHash, theme, menu)
|
|
46
|
+
await fs.mkdir(this.cacheRoot, { recursive: true })
|
|
47
|
+
|
|
48
|
+
const tmpDir = `${cacheDir}_tmp_${Date.now()}`
|
|
49
|
+
try {
|
|
50
|
+
await fs.mkdir(tmpDir, { recursive: true })
|
|
51
|
+
const entries = await fs.readdir(buildOutputDir)
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (SKIP_ENTRIES.has(entry)) continue
|
|
54
|
+
await copyEntry(path.join(buildOutputDir, entry), path.join(tmpDir, entry))
|
|
55
|
+
}
|
|
56
|
+
await safeRename(tmpDir, cacheDir)
|
|
57
|
+
log('info', 'CACHE', `populated cache for ${pluginHash} (theme=${theme}, menu=${menu})`)
|
|
58
|
+
} catch (e) {
|
|
59
|
+
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
60
|
+
throw e
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Copies cached artifacts to a build directory.
|
|
66
|
+
* @param {String} destDir Destination build directory
|
|
67
|
+
*/
|
|
68
|
+
async restore (pluginHash, theme, menu, destDir) {
|
|
69
|
+
await fs.mkdir(destDir, { recursive: true })
|
|
70
|
+
await fs.cp(this.getPath(pluginHash, theme, menu), destDir, { recursive: true })
|
|
71
|
+
log('info', 'CACHE', `restored from cache for ${pluginHash} (theme=${theme}, menu=${menu})`)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Removes the entire cache root.
|
|
76
|
+
*/
|
|
77
|
+
async invalidate () {
|
|
78
|
+
await fs.rm(this.cacheRoot, { recursive: true, force: true })
|
|
79
|
+
log('info', 'CACHE', 'invalidated prebuilt cache')
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function copyEntry (src, dest) {
|
|
84
|
+
const stat = await fs.stat(src)
|
|
85
|
+
if (stat.isDirectory()) {
|
|
86
|
+
await fs.cp(src, dest, { recursive: true })
|
|
87
|
+
} else {
|
|
88
|
+
await fs.mkdir(path.dirname(dest), { recursive: true })
|
|
89
|
+
await fs.copyFile(src, dest)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function safeRename (src, dest) {
|
|
94
|
+
try {
|
|
95
|
+
await fs.rename(src, dest)
|
|
96
|
+
} catch (e) {
|
|
97
|
+
if (e.code === 'ENOTEMPTY' || e.code === 'EEXIST') {
|
|
98
|
+
await fs.rm(src, { recursive: true, force: true })
|
|
99
|
+
} else {
|
|
100
|
+
throw e
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export default BuildCache
|
package/lib/handlers.js
CHANGED
|
@@ -133,11 +133,12 @@ export async function postUpdateHandler (req, res, next) {
|
|
|
133
133
|
}
|
|
134
134
|
log('info', 'running framework update')
|
|
135
135
|
const previousVersion = framework.version
|
|
136
|
-
await framework.updateFramework(req.body.version)
|
|
136
|
+
const migrationResult = await framework.updateFramework(req.body.version)
|
|
137
137
|
const currentVersion = framework.version !== previousVersion ? framework.version : undefined
|
|
138
138
|
res.json({
|
|
139
139
|
from: previousVersion,
|
|
140
|
-
to: currentVersion
|
|
140
|
+
to: currentVersion,
|
|
141
|
+
migration: migrationResult
|
|
141
142
|
})
|
|
142
143
|
} catch (e) {
|
|
143
144
|
return next(e)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
const FIELDS = ['_backgroundRepeat', '_backgroundSize', '_backgroundPosition']
|
|
2
|
+
|
|
3
|
+
function clean (styles) {
|
|
4
|
+
if (!styles || typeof styles !== 'object') return
|
|
5
|
+
for (const f of FIELDS) {
|
|
6
|
+
if (styles[f] === '' || styles[f] === null) delete styles[f]
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function VanillaBackgroundStyles (data) {
|
|
11
|
+
const v = data._vanilla
|
|
12
|
+
if (!v || typeof v !== 'object') return
|
|
13
|
+
clean(v._backgroundStyles)
|
|
14
|
+
clean(v._pageHeader?._backgroundStyles)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default VanillaBackgroundStyles
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'upath'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Applies @@placeholder substitutions in index.html
|
|
6
|
+
* @param {String} buildDir The build output directory
|
|
7
|
+
* @param {Object} data Replacement values
|
|
8
|
+
* @param {String} data.defaultLanguage The default language code
|
|
9
|
+
* @param {String} data.defaultDirection The default text direction
|
|
10
|
+
* @param {String} data.buildType The build type (e.g. 'development')
|
|
11
|
+
* @param {Number} data.timestamp The build timestamp
|
|
12
|
+
* @return {Promise}
|
|
13
|
+
*/
|
|
14
|
+
export async function applyBuildReplacements (buildDir, { defaultLanguage, defaultDirection, buildType, timestamp }) {
|
|
15
|
+
const indexPath = path.join(buildDir, 'index.html')
|
|
16
|
+
let html = await fs.readFile(indexPath, 'utf8')
|
|
17
|
+
html = html
|
|
18
|
+
.replace(/@@config\._defaultLanguage/g, defaultLanguage)
|
|
19
|
+
.replace(/@@config\._defaultDirection/g, defaultDirection)
|
|
20
|
+
.replace(/@@build\.type/g, buildType)
|
|
21
|
+
.replace(/@@build\.timestamp/g, String(timestamp))
|
|
22
|
+
await fs.writeFile(indexPath, html)
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { glob } from 'glob'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Collects all migration script paths from the framework's src directory
|
|
6
|
+
* @param {String} frameworkDir Absolute path to the framework directory
|
|
7
|
+
* @returns {Promise<String[]>} Absolute paths to migration scripts
|
|
8
|
+
*/
|
|
9
|
+
export async function collectMigrationScripts (frameworkDir) {
|
|
10
|
+
const srcDir = path.join(frameworkDir, 'src')
|
|
11
|
+
return glob([
|
|
12
|
+
'core/migrations/**/*.js',
|
|
13
|
+
'*/*/migrations/**/*.js'
|
|
14
|
+
], { cwd: srcDir, absolute: true })
|
|
15
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import Project from 'adapt-cli/lib/integration/Project.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Computes a deterministic hash from the installed plugin set
|
|
6
|
+
* @param {String} frameworkDir Path to the local framework installation
|
|
7
|
+
* @return {Promise<String>} 16-char hex hash
|
|
8
|
+
*/
|
|
9
|
+
export async function computePluginHash (frameworkDir) {
|
|
10
|
+
const project = new Project({ cwd: frameworkDir })
|
|
11
|
+
const deps = await project.getInstalledDependencies()
|
|
12
|
+
const sorted = Object.entries(deps).sort(([a], [b]) => a.localeCompare(b))
|
|
13
|
+
return createHash('sha256').update(JSON.stringify(sorted)).digest('hex').slice(0, 16)
|
|
14
|
+
}
|
|
@@ -17,7 +17,7 @@ export async function copyFrameworkSource (options) {
|
|
|
17
17
|
if (options.copyNodeModules !== true) BLACKLIST.push('node_modules')
|
|
18
18
|
|
|
19
19
|
const srcDir = path.join(fwPath, 'src')
|
|
20
|
-
const enabledPlugins = options.enabledPlugins
|
|
20
|
+
const enabledPlugins = options.enabledPlugins
|
|
21
21
|
await fs.cp(fwPath, options.destDir, {
|
|
22
22
|
recursive: true,
|
|
23
23
|
filter: f => {
|
|
@@ -25,7 +25,7 @@ export async function copyFrameworkSource (options) {
|
|
|
25
25
|
const [type, name] = path.relative(srcDir, f).split('/')
|
|
26
26
|
const isPlugin = f.startsWith(srcDir) && type && type !== 'core' && !!name
|
|
27
27
|
|
|
28
|
-
if (isPlugin && !enabledPlugins.includes(name)) {
|
|
28
|
+
if (isPlugin && enabledPlugins && !enabledPlugins.includes(name)) {
|
|
29
29
|
return false
|
|
30
30
|
}
|
|
31
31
|
return !BLACKLIST.includes(path.basename(f))
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Returns the list of JSON filenames that belong in a language manifest.
|
|
3
|
+
* The framework runtime reads this to know which data files to fetch.
|
|
4
|
+
* @param {Array<String>} jsonFileNames All JSON filenames written to the language dir
|
|
5
|
+
* @return {Array<String>} Filtered list excluding the manifest itself and assets.json
|
|
6
|
+
*/
|
|
7
|
+
export function generateLanguageManifest (jsonFileNames) {
|
|
8
|
+
return jsonFileNames.filter(f => f !== 'language_data_manifest.js' && f !== 'assets.json')
|
|
9
|
+
}
|
package/lib/utils/log.js
CHANGED
|
@@ -3,15 +3,13 @@ import bytes from 'bytes'
|
|
|
3
3
|
import fsSync from 'node:fs'
|
|
4
4
|
import path from 'upath'
|
|
5
5
|
|
|
6
|
-
let fw
|
|
7
|
-
|
|
8
6
|
/**
|
|
9
|
-
* Logs a message using the framework module
|
|
10
|
-
* @param {
|
|
7
|
+
* Logs a message using the framework module's namespace
|
|
8
|
+
* @param {String} level Log level
|
|
9
|
+
* @param {...*} rest Arguments to be logged
|
|
11
10
|
*/
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
return fw.log(...args)
|
|
11
|
+
export function log (level, ...rest) {
|
|
12
|
+
App.instance?.logger?.log(level, 'adaptframework', ...rest)
|
|
15
13
|
}
|
|
16
14
|
|
|
17
15
|
/**
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { App } from 'adapt-authoring-core'
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util'
|
|
3
|
+
import { collectMigrationScripts } from './collectMigrationScripts.js'
|
|
4
|
+
import { runContentMigration } from './runContentMigration.js'
|
|
5
|
+
import { log } from './log.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Migrates content for a set of courses by courseId
|
|
9
|
+
* @param {Object} options
|
|
10
|
+
* @param {Array<{name: String, version: String}>} options.fromPlugins Plugin versions before update
|
|
11
|
+
* @param {Array<{name: String, version: String}>} options.toPlugins Plugin versions after update
|
|
12
|
+
* @param {String} options.frameworkDir Absolute path to the framework directory
|
|
13
|
+
* @param {String[]} [options.courseIds] Specific course IDs to migrate (if omitted, migrates all)
|
|
14
|
+
* @returns {Promise<{migrated: Number, failed: Number, errors: Array}>}
|
|
15
|
+
*/
|
|
16
|
+
export async function migrateExistingCourses ({ fromPlugins, toPlugins, frameworkDir, courseIds }) {
|
|
17
|
+
const content = await App.instance.waitForModule('content')
|
|
18
|
+
const scripts = await collectMigrationScripts(frameworkDir)
|
|
19
|
+
|
|
20
|
+
if (!scripts.length) {
|
|
21
|
+
log('debug', 'no migration scripts found, skipping')
|
|
22
|
+
return { migrated: 0, failed: 0, errors: [] }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const foundCourses = courseIds
|
|
26
|
+
? await Promise.all(courseIds.map(async _id => content.findOne({ _id, _type: 'course' }, { strict: false })))
|
|
27
|
+
: await content.find({ _type: 'course' })
|
|
28
|
+
|
|
29
|
+
let migrated = 0
|
|
30
|
+
let failed = 0
|
|
31
|
+
const errors = []
|
|
32
|
+
|
|
33
|
+
for (let ci = 0; ci < foundCourses.length; ci++) {
|
|
34
|
+
const course = foundCourses[ci]
|
|
35
|
+
if (!course) {
|
|
36
|
+
const courseId = courseIds?.[ci] ?? 'unknown'
|
|
37
|
+
log('warn', `course ${courseId} not found, skipping`)
|
|
38
|
+
errors.push({ courseId, error: 'course not found' })
|
|
39
|
+
failed++
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const courseId = course._id.toString()
|
|
44
|
+
log('debug', `migrating course ${courseId}`)
|
|
45
|
+
|
|
46
|
+
const courseContent = await fetchCourseContent(content, course)
|
|
47
|
+
const originals = courseContent.map(item => JSON.parse(JSON.stringify(item)))
|
|
48
|
+
|
|
49
|
+
const migratedContent = await runContentMigration({
|
|
50
|
+
content: courseContent,
|
|
51
|
+
fromPlugins: JSON.parse(JSON.stringify(fromPlugins)),
|
|
52
|
+
toPlugins,
|
|
53
|
+
scripts
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
let updatedCount = 0
|
|
57
|
+
for (let i = 0; i < migratedContent.length; i++) {
|
|
58
|
+
const normalized = JSON.parse(JSON.stringify(migratedContent[i]))
|
|
59
|
+
if (!isDeepStrictEqual(originals[i], normalized)) {
|
|
60
|
+
await content.update({ _id: migratedContent[i]._id }, normalized)
|
|
61
|
+
updatedCount++
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (updatedCount > 0) {
|
|
65
|
+
log('info', `migrated ${updatedCount} items in course ${courseId}`)
|
|
66
|
+
}
|
|
67
|
+
migrated++
|
|
68
|
+
} catch (e) {
|
|
69
|
+
const courseId = course?._id?.toString() ?? 'unknown'
|
|
70
|
+
log('error', `migration failed for course ${courseId}`, e.message)
|
|
71
|
+
errors.push({ courseId, error: e.message })
|
|
72
|
+
failed++
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
log('info', `migration complete: ${migrated} succeeded, ${failed} failed`)
|
|
77
|
+
return { migrated, failed, errors }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function fetchCourseContent (content, course) {
|
|
81
|
+
const config = await content.findOne({ _courseId: course._id, _type: 'config' }, { strict: false })
|
|
82
|
+
const items = await content.find({ _courseId: course._id, _type: { $nin: ['course', 'config'] } })
|
|
83
|
+
const result = [course]
|
|
84
|
+
if (config) result.push(config)
|
|
85
|
+
result.push(...items)
|
|
86
|
+
return result
|
|
87
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { App, ensureDir, writeJson } from 'adapt-authoring-core'
|
|
2
|
+
import AdaptCli from 'adapt-cli'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import path from 'upath'
|
|
5
|
+
import { copyFrameworkSource } from './copyFrameworkSource.js'
|
|
6
|
+
import BuildCache from '../BuildCache.js'
|
|
7
|
+
import { computePluginHash } from './computePluginHash.js'
|
|
8
|
+
import { log } from './log.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Eagerly populates the prebuilt cache for every (theme, menu) combination
|
|
12
|
+
* of installed plugins. Iterates serially: each iteration runs a full grunt
|
|
13
|
+
* build with the chosen theme/menu and caches the output.
|
|
14
|
+
*
|
|
15
|
+
* Idempotent — combos that already have a cache entry are skipped, so
|
|
16
|
+
* re-runs only build what's missing. Per-iteration failures are logged
|
|
17
|
+
* but don't abort the whole prebuild.
|
|
18
|
+
* @param {Object} options
|
|
19
|
+
* @param {String} options.buildDir Root build directory
|
|
20
|
+
* @param {String} options.frameworkDir Path to the adapt_framework source
|
|
21
|
+
* @return {Promise}
|
|
22
|
+
*/
|
|
23
|
+
export async function prebuildCache ({ buildDir, frameworkDir }) {
|
|
24
|
+
const app = App.instance
|
|
25
|
+
const cache = new BuildCache(path.join(buildDir, 'prebuilt-cache'))
|
|
26
|
+
const pluginHash = await computePluginHash(frameworkDir)
|
|
27
|
+
|
|
28
|
+
const contentplugin = await app.waitForModule('contentplugin')
|
|
29
|
+
const allPlugins = await contentplugin.find({})
|
|
30
|
+
const themes = allPlugins.filter(p => p.type === 'theme')
|
|
31
|
+
const menus = allPlugins.filter(p => p.type === 'menu')
|
|
32
|
+
|
|
33
|
+
if (!themes.length || !menus.length) {
|
|
34
|
+
throw new Error('Cannot prebuild cache: no theme or menu plugin installed')
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
log('info', 'CACHE', `starting eager prebuild for ${themes.length * menus.length} (theme,menu) combinations`)
|
|
38
|
+
|
|
39
|
+
for (const theme of themes) {
|
|
40
|
+
for (const menu of menus) {
|
|
41
|
+
try {
|
|
42
|
+
await prebuildOne({ buildDir, cache, pluginHash, theme, menu, allPlugins })
|
|
43
|
+
} catch (e) {
|
|
44
|
+
log('warn', 'CACHE', `eager prebuild failed for theme=${theme.name} menu=${menu.name}: ${e.message}`)
|
|
45
|
+
if (e.cmd) log('warn', 'CACHE', `cmd: ${e.cmd}`)
|
|
46
|
+
if (e.stderr) log('warn', 'CACHE', `stderr: ${e.stderr}`)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
log('info', 'CACHE', 'eager prebuild complete')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function prebuildOne ({ buildDir, cache, pluginHash, theme, menu, allPlugins }) {
|
|
55
|
+
const app = App.instance
|
|
56
|
+
|
|
57
|
+
if (await cache.has(pluginHash, theme.name, menu.name)) {
|
|
58
|
+
log('info', 'CACHE', `skipping cached combo theme=${theme.name} menu=${menu.name}`)
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Only one theme/menu can be active per build — drop the others so
|
|
63
|
+
// the framework's less:dev task doesn't glob multiple themes' LESS
|
|
64
|
+
// into a single adapt.css (see adapt_framework#3802).
|
|
65
|
+
const includedPlugins = allPlugins.filter(p =>
|
|
66
|
+
(p.type !== 'theme' && p.type !== 'menu') || p.name === theme.name || p.name === menu.name
|
|
67
|
+
)
|
|
68
|
+
const pluginNames = includedPlugins.map(p => p.name)
|
|
69
|
+
|
|
70
|
+
const tmpDir = path.join(buildDir, `_eager_cache_${Date.now()}_${theme.name}_${menu.name}`)
|
|
71
|
+
try {
|
|
72
|
+
log('info', 'CACHE', `building combo theme=${theme.name} menu=${menu.name}`)
|
|
73
|
+
|
|
74
|
+
await copyFrameworkSource({
|
|
75
|
+
destDir: tmpDir,
|
|
76
|
+
enabledPlugins: pluginNames,
|
|
77
|
+
linkNodeModules: true
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const outputDir = path.join(tmpDir, 'build')
|
|
81
|
+
const buildCourseDir = path.join(outputDir, 'course', 'en')
|
|
82
|
+
await ensureDir(buildCourseDir)
|
|
83
|
+
await writeJson(path.join(outputDir, 'course', 'config.json'), {
|
|
84
|
+
_defaultLanguage: 'en',
|
|
85
|
+
_theme: theme.name,
|
|
86
|
+
_menu: menu.name,
|
|
87
|
+
_enabledPlugins: pluginNames
|
|
88
|
+
})
|
|
89
|
+
await writeJson(path.join(buildCourseDir, 'course.json'), {
|
|
90
|
+
title: '_eager_cache_build',
|
|
91
|
+
_latestTrackingId: 0
|
|
92
|
+
})
|
|
93
|
+
const cacheDir = path.join(buildDir, 'cache')
|
|
94
|
+
await ensureDir(cacheDir)
|
|
95
|
+
|
|
96
|
+
await AdaptCli.buildCourse({
|
|
97
|
+
cwd: tmpDir,
|
|
98
|
+
sourceMaps: true,
|
|
99
|
+
outputDir,
|
|
100
|
+
cachePath: path.join(cacheDir, '_eager_cache'),
|
|
101
|
+
logger: { log: (...args) => app.logger.log('debug', 'adapt-cli', ...args) }
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
if (!await cache.has(pluginHash, theme.name, menu.name)) {
|
|
105
|
+
await cache.populate(outputDir, pluginHash, theme.name, menu.name)
|
|
106
|
+
}
|
|
107
|
+
} finally {
|
|
108
|
+
await fs.rm(tmpDir, { recursive: true, force: true })
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readJson } from 'adapt-authoring-core'
|
|
2
|
+
import { glob } from 'glob'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reads bower.json files from the framework's src directory to build a list of plugin names and versions
|
|
7
|
+
* @param {String} frameworkDir Absolute path to the framework directory
|
|
8
|
+
* @returns {Promise<Array<{name: String, version: String}>>}
|
|
9
|
+
*/
|
|
10
|
+
export async function readFrameworkPluginVersions (frameworkDir) {
|
|
11
|
+
const srcDir = path.join(frameworkDir, 'src')
|
|
12
|
+
const bowerPaths = await glob([
|
|
13
|
+
'core/bower.json',
|
|
14
|
+
'{components,extensions,menu,theme}/*/bower.json'
|
|
15
|
+
], { cwd: srcDir, absolute: true })
|
|
16
|
+
const plugins = await Promise.all(bowerPaths.map(async p => {
|
|
17
|
+
const { name, version } = await readJson(p)
|
|
18
|
+
return { name, version }
|
|
19
|
+
}))
|
|
20
|
+
return plugins
|
|
21
|
+
}
|