adapt-project 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.
@@ -0,0 +1,301 @@
1
+ import path from 'path'
2
+ import fs from 'fs-extra'
3
+ import globs from 'globs'
4
+ import _ from 'lodash'
5
+ import chalk from 'chalk'
6
+ import LanguageFile from './LanguageFile.js'
7
+
8
+ /**
9
+ * @typedef {import('../Framework')} Framework
10
+ * @typedef {import('../JSONFileItem')} JSONFileItem
11
+ */
12
+
13
+ /**
14
+ * Represents all of the json files and file item in a language directory
15
+ * at course/[lang]/*.[jsonext].
16
+ * It is filename agnostic, such that there are no hard references to file names.
17
+ * It has _id and _parentId structure checking and _trackingId management included.
18
+ */
19
+ class Language {
20
+ /**
21
+ * @param {Object} options
22
+ * @param {Framework} options.framework
23
+ * @param {string} options.languagePath
24
+ * @param {string} options.jsonext
25
+ * @param {string} options.trackingIdType,
26
+ * @param {function} options.log
27
+ */
28
+ constructor ({
29
+ framework = null,
30
+ languagePath = '',
31
+ jsonext = 'json',
32
+ trackingIdType = 'block',
33
+ log = console.log
34
+ } = {}) {
35
+ /** @type {Framework} */
36
+ this.framework = framework
37
+ /** @type {string} */
38
+ this.jsonext = jsonext
39
+ /** @type {string} */
40
+ this.path = path.normalize(languagePath).replace(/\\/g, '/')
41
+ /** @type {string} */
42
+ this.name = this.path.split('/').filter(Boolean).pop()
43
+ /** @type {string} */
44
+ this.rootPath = this.path.split('/').filter(Boolean).slice(0, -1).join('/')
45
+ /** @type {string} */
46
+ this.manifestFileName = 'language_data_manifest.js'
47
+ /** @type {string} */
48
+ this.manifestPath = this.path + this.manifestFileName
49
+ /** @type {string} */
50
+ this.trackingIdType = trackingIdType
51
+ /** @type {[LanguageFile]} */
52
+ this.files = null
53
+ /** @type {Object.<string, JSONFileItem>} */
54
+ this._itemIdIndex = null
55
+ /** @type {[JSONFileItem]} */
56
+ this.courseFileItem = null
57
+ /** @type {function} */
58
+ this.log = log
59
+ }
60
+
61
+ /** @returns {Language} */
62
+ load () {
63
+ this.files = []
64
+ this._itemIdIndex = {}
65
+ this.courseFileItem = null
66
+
67
+ const dataFiles = globs.sync(path.join(this.path, '*.' + this.jsonext)).map((dataFilePath) => {
68
+ const relativePath = dataFilePath.slice(this.path.length)
69
+ return relativePath
70
+ }).filter((dataFilePath) => {
71
+ const isManifest = (dataFilePath === this.manifestPath)
72
+ // Skip file if it is the Authoring Tool import/export asset manifest
73
+ const isAATAssetJSON = (dataFilePath === 'assets.json')
74
+ return !isManifest && !isAATAssetJSON
75
+ })
76
+
77
+ dataFiles.forEach(jsonFileName => {
78
+ const jsonFilePath = (this.path + jsonFileName).replace(/\\/g, '/')
79
+ const file = new LanguageFile({
80
+ framework: this.framework,
81
+ language: this,
82
+ jsonext: this.jsonext,
83
+ path: jsonFilePath,
84
+ data: null,
85
+ hasChanged: false
86
+ })
87
+ file.load()
88
+ this.files.push(file)
89
+ })
90
+
91
+ this._itemIdIndex = this.getAllFileItems().reduce((index, fileItem) => {
92
+ const { file, item } = fileItem
93
+ if (item._id && index[item._id]) {
94
+ const err = new Error(`Duplicate ids ${item._id} in ${index[item._id].file.path} and ${file.path}`)
95
+ err.number = 10006
96
+ throw err
97
+ } else if (item._id) {
98
+ index[item._id] = fileItem
99
+ }
100
+ if (item._type === 'course' && this.courseFileItem) {
101
+ const err = new Error(`Duplicate course items found, in ${index[item._id].file.path} and ${file.path}`)
102
+ err.number = 10007
103
+ throw err
104
+ } else if (item._type === 'course') {
105
+ this.courseFileItem = fileItem
106
+ }
107
+ return index
108
+ }, {})
109
+
110
+ return this
111
+ }
112
+
113
+ /** @type {boolean} */
114
+ get isValid () {
115
+ return Boolean(this.courseFileItem)
116
+ }
117
+
118
+ /** @type {boolean} */
119
+ get hasChanged () {
120
+ return this.files.some(file => file.hasChanged)
121
+ }
122
+
123
+ /**
124
+ * Produces a manifest file for the Framework data layer at course/lang/language_data_manifest.js.
125
+ * @returns {Language}
126
+ */
127
+ saveManifest () {
128
+ const dataFiles = globs.sync(path.join(this.path, '*.' + this.jsonext)).map((dataFilePath) => {
129
+ const relativePath = dataFilePath.slice(this.path.length)
130
+ return relativePath
131
+ }).filter((dataFilePath) => {
132
+ const isManifest = (dataFilePath === this.manifestPath)
133
+ // Skip file if it is the Authoring Tool import/export asset manifest
134
+ const isAATAssetJSON = (dataFilePath === 'assets.json')
135
+ return !isManifest && !isAATAssetJSON
136
+ })
137
+ const hasNoDataFiles = !dataFiles.length
138
+ if (hasNoDataFiles) {
139
+ const err = new Error(`No data files found in ${this.path}`)
140
+ err.number = 10008
141
+ throw err
142
+ }
143
+ fs.writeJSONSync(this.manifestPath, dataFiles, { spaces: 0 })
144
+ return this
145
+ }
146
+
147
+ /** @returns {[JSONFileItem]} */
148
+ getAllFileItems () {
149
+ return this.files.reduce((memo, file) => {
150
+ memo.push(...file.fileItems)
151
+ return memo
152
+ }, [])
153
+ }
154
+
155
+ /** @returns {JSONFileItem} */
156
+ getCourseFileItem () {
157
+ if (!this.courseFileItem) {
158
+ const err = new Error(`Could not find course item for ${this.path}`)
159
+ err.number = 10009
160
+ throw err
161
+ }
162
+ return this.courseFileItem
163
+ }
164
+
165
+ /**
166
+ * @param {string} id
167
+ * @returns {JSONFileItem}
168
+ */
169
+ getFileItemById (id) {
170
+ const fileItem = this._itemIdIndex[id]
171
+ if (!fileItem) {
172
+ const err = new Error(`Could not find item for id ${id} in ${this.path}`)
173
+ err.number = 10010
174
+ throw err
175
+ }
176
+ return fileItem
177
+ }
178
+
179
+ /** @returns {Language} */
180
+ checkIds () {
181
+ const items = this.getAllFileItems().map(({ item }) => item)
182
+ // Index and group
183
+ const idIndex = _.keyBy(items, '_id')
184
+ const idGroups = _.groupBy(items, '_id')
185
+ const parentIdGroups = _.groupBy(items, '_parentId')
186
+ // Setup error collection arrays
187
+ const orphanedIds = {}
188
+ let emptyIds = {}
189
+ let duplicateIds = {}
190
+ const missingIds = {}
191
+ items.forEach((o) => {
192
+ const isCourseType = (o._type === 'course')
193
+ const isComponentType = (o._type === 'component')
194
+ if (idGroups[o._id].length > 1) {
195
+ duplicateIds[o._id] = true // Id has more than one item
196
+ }
197
+ if (!isComponentType && !parentIdGroups[o._id]) {
198
+ emptyIds[o._id] = true // Course has no children
199
+ }
200
+ if (!isCourseType && (!o._parentId || !idIndex[o._parentId])) {
201
+ orphanedIds[o._id] = o._type // Item has no defined parent id or the parent id doesn't exist
202
+ }
203
+ if (!isCourseType && o._parentId && !idIndex[o._parentId]) {
204
+ missingIds[o._parentId] = o._type // Referenced parent item does not exist
205
+ }
206
+ })
207
+ const orphanedIdsArray = Object.keys(orphanedIds)
208
+ const missingIdsArray = Object.keys(missingIds)
209
+ emptyIds = Object.keys(emptyIds)
210
+ duplicateIds = Object.keys(duplicateIds)
211
+ // Output for each type of error
212
+ const hasErrored = orphanedIdsArray.length || emptyIds.length || duplicateIds.length || missingIdsArray.length
213
+ if (orphanedIdsArray.length) {
214
+ const orphanedIdString = orphanedIdsArray.map((id) => `${id} (${orphanedIds[id]})`)
215
+ this.log(chalk.yellow(`Orphaned _ids: ${orphanedIdString.join(', ')}`))
216
+ }
217
+ if (missingIdsArray.length) {
218
+ const missingIdString = missingIdsArray.map((id) => `${id} (${missingIds[id]})`)
219
+ this.log(chalk.yellow(`Missing _ids: ${missingIdString.join(', ')}`))
220
+ }
221
+ if (emptyIds.length) {
222
+ this.log(chalk.yellow(`Empty _ids: ${emptyIds}`))
223
+ }
224
+ if (duplicateIds.length) {
225
+ this.log(chalk.yellow(`Duplicate _ids: ${duplicateIds}`))
226
+ }
227
+ // If any error has occured, stop processing.
228
+ if (hasErrored) {
229
+ const err = new Error('Oops, looks like you have some json errors.')
230
+ err.number = 10011
231
+ throw err
232
+ }
233
+ this.log(`No issues found in course/${this.name}, your JSON is a-ok!`)
234
+
235
+ return this
236
+ }
237
+
238
+ /** @returns {Language} */
239
+ addTrackingIds () {
240
+ const { file, item: course } = this.getCourseFileItem()
241
+ course._latestTrackingId = course._latestTrackingId || -1
242
+ file.changed()
243
+
244
+ let wasAdded = false
245
+ const trackingIdsSeen = []
246
+ const fileItems = this.getAllFileItems().filter(fileItem => fileItem.item._type === this.trackingIdType)
247
+ fileItems.forEach(({ file, item }) => {
248
+ this.log(`${this.trackingIdType}: ${item._id}: ${item._trackingId !== undefined ? item._trackingId : 'not set'}`)
249
+ if (item._trackingId === undefined) {
250
+ item._trackingId = ++course._latestTrackingId
251
+ file.changed()
252
+ wasAdded = true
253
+ this.log(`Adding tracking ID: ${item._trackingId} to ${this.trackingIdType} ${item._id}`)
254
+ } else {
255
+ if (trackingIdsSeen.indexOf(item._trackingId) > -1) {
256
+ item._trackingId = ++course._latestTrackingId
257
+ file.changed()
258
+ wasAdded = true
259
+ this.log(`Warning: ${item._id} has the tracking ID ${item._trackingId} but this is already in use. Changing to ${course._latestTrackingId + 1}.`)
260
+ } else {
261
+ trackingIdsSeen.push(item._trackingId)
262
+ }
263
+ }
264
+ if (course._latestTrackingId < item._trackingId) {
265
+ course._latestTrackingId = item._trackingId
266
+ }
267
+ })
268
+
269
+ this.save()
270
+ this.log(`Tracking IDs ${wasAdded ? 'were added to' : 'are ok for'} course/${this.name}. The latest tracking ID is ${course._latestTrackingId}\n`)
271
+
272
+ return this
273
+ }
274
+
275
+ /** @returns {Language} */
276
+ removeTrackingIds () {
277
+ const { file, item: course } = this.getCourseFileItem()
278
+ course._latestTrackingId = -1
279
+ file.changed()
280
+
281
+ this.getAllFileItems().forEach(({ file, item }) => {
282
+ if (item._type !== this.trackingIdType) return
283
+ delete item._trackingId
284
+ file.changed()
285
+ })
286
+
287
+ this.save()
288
+ this.log(`Tracking IDs removed from course/${this.name}.`)
289
+
290
+ return this
291
+ }
292
+
293
+ /** @returns {Language} */
294
+ save () {
295
+ this.files.forEach(file => file.save())
296
+ this.load()
297
+ return this
298
+ }
299
+ }
300
+
301
+ export default Language
@@ -0,0 +1,35 @@
1
+ import JSONFile from '../JSONFile.js'
2
+
3
+ /**
4
+ * @typedef {import('../Framework')} Framework
5
+ * @typedef {import('./Language')} Language
6
+ */
7
+
8
+ /**
9
+ * Represents any of the files at course/[lang]/*.[jsonext].
10
+ */
11
+ class LanguageFile extends JSONFile {
12
+ /**
13
+ * @param {Object} options
14
+ * @param {Framework} options.framework
15
+ * @param {Language} options.language
16
+ * @param {string} options.path
17
+ * @param {string} options.jsonext
18
+ * @param {Object} options.data
19
+ * @param {boolean} options.hasChanged
20
+ */
21
+ constructor ({
22
+ framework = null,
23
+ language = null,
24
+ path = null,
25
+ jsonext = null,
26
+ data = null,
27
+ hasChanged = false
28
+ } = {}) {
29
+ super({ framework, path, jsonext, data, hasChanged })
30
+ /** @type {Language} */
31
+ this.language = language
32
+ }
33
+ }
34
+
35
+ export default LanguageFile
@@ -0,0 +1,143 @@
1
+ import semver from 'semver'
2
+ import globs from 'globs'
3
+ import JSONFile from '../JSONFile.js'
4
+
5
+ /**
6
+ * Represents a single plugin location, bower.json, version, name and schema
7
+ * locations.
8
+ * @todo Should be able to define multiple schemas for all plugins in the AAT
9
+ * and in the Framework
10
+ * @todo Switch to package.json with the move to npm
11
+ * @todo Plugin type is inferred from the JSON.
12
+ * @todo Component _globals target attribute is inferred from the _component
13
+ */
14
+ class Plugin {
15
+ /**
16
+ * @param {Object} options
17
+ * @param {Framework} options.framework
18
+ * @param {string} options.sourcePath
19
+ * @param {string} options.jsonext
20
+ * @param {function} options.log
21
+ */
22
+ constructor ({
23
+ framework = null,
24
+ sourcePath = '',
25
+ log = console.log,
26
+ warn = console.warn
27
+ } = {}) {
28
+ /** @type {Framework} */
29
+ this.framework = framework
30
+ /** @type {string} */
31
+ this.sourcePath = sourcePath.replace(/\\/g, '/')
32
+ /** @type {function} */
33
+ this.log = log
34
+ /** @type {function} */
35
+ this.warn = warn
36
+ /** @type {JSONFile} */
37
+ this.packageJSONFile = null
38
+ }
39
+
40
+ /** @returns {Plugin} */
41
+ load () {
42
+ const pathDerivedName = this.sourcePath.split('/').filter(Boolean).pop()
43
+ const files = globs.sync(this.packageJSONLocations)
44
+ const firstFile = files[0]
45
+ if (firstFile) {
46
+ // use the first package definition found (this will be bower.json / package.json)
47
+ this.packageJSONFile = new JSONFile({
48
+ path: firstFile
49
+ })
50
+ } else {
51
+ // no json found, make some up (this will usually be the core)
52
+ this.packageJSONFile = new JSONFile({
53
+ path: null,
54
+ data: {
55
+ name: pathDerivedName
56
+ }
57
+ })
58
+ }
59
+ this.packageJSONFile.load()
60
+ const packageName = (this.packageJSONFile.firstFileItem.item.name)
61
+ if (packageName !== pathDerivedName) {
62
+ // assume path name is also plugin name, this shouldn't be necessary
63
+ this.warn(`Plugin folder name ${pathDerivedName} does not match package name ${packageName}.`)
64
+ }
65
+ if (this.requiredFramework && !this.isFrameworkCompatible) {
66
+ this.warn(`Required framework version (${this.requiredFramework}) for plugin ${packageName} not satisfied by current framework version (${this.framework.version}).`)
67
+ }
68
+ return this
69
+ }
70
+
71
+ /**
72
+ * Informs the Schemas API from where to fetch the schemas defined in this
73
+ * plugin.
74
+ * @returns {[string]}
75
+ */
76
+ get schemaLocations () {
77
+ return [
78
+ `${this.sourcePath}properties.schema`,
79
+ `${this.sourcePath}schema/*.schema`
80
+ ]
81
+ }
82
+
83
+ /**
84
+ * @returns {[string]}
85
+ */
86
+ get packageJSONLocations () {
87
+ return [
88
+ `${this.sourcePath}bower.json`
89
+ ]
90
+ }
91
+
92
+ /** @returns {string} */
93
+ get name () {
94
+ return this.packageJSONFile.firstFileItem.item.name
95
+ }
96
+
97
+ /** @returns {string} */
98
+ get version () {
99
+ return this.packageJSONFile.firstFileItem.item.version
100
+ }
101
+
102
+ /** @returns {string} */
103
+ get requiredFramework () {
104
+ return this.packageJSONFile.firstFileItem.item.framework
105
+ }
106
+
107
+ /** @returns {boolean} */
108
+ get isFrameworkCompatible () {
109
+ if (!this.framework || !this.framework.version) return true
110
+
111
+ return semver.satisfies(this.framework.version, this.requiredFramework)
112
+ }
113
+
114
+ /**
115
+ * Returns the plugin type name.
116
+ * @returns {string}
117
+ */
118
+ get type () {
119
+ if (this.name === 'core') {
120
+ return 'component'
121
+ }
122
+ const config = this.packageJSONFile.firstFileItem.item
123
+ const configKeys = Object.keys(config)
124
+ const typeKeyName = ['component', 'extension', 'menu', 'theme']
125
+ const foundType = configKeys.find(key => typeKeyName.includes(key))
126
+ if (!foundType) {
127
+ throw new Error(`Unknown plugin type for ${this.name}`)
128
+ }
129
+ return foundType
130
+ }
131
+
132
+ /**
133
+ * @returns {string}
134
+ */
135
+ get targetAttribute () {
136
+ if (this.type === 'component') {
137
+ return this.packageJSONFile.firstFileItem.item.component
138
+ }
139
+ return this.packageJSONFile.firstFileItem.item.targetAttribute
140
+ }
141
+ }
142
+
143
+ export default Plugin
@@ -0,0 +1,26 @@
1
+ import GlobalsSchema from './GlobalsSchema.js'
2
+
3
+ /**
4
+ * Represents a model extension schema. Currently these schemas are only able to
5
+ * extend config, course, contentobject, article, block and component models.
6
+ *
7
+ * @todo pluginLocations is not a good way of listing model extensions or detecting
8
+ * extension schemas. There should be a schema type (model/extension) and
9
+ * the extensions should be declared at the root of the schema.
10
+ * @todo pluginLocations[modelName] should extend any model (article, block,
11
+ * accordion, narrative)
12
+ */
13
+ class ExtensionSchema extends GlobalsSchema {
14
+ /**
15
+ * Returns the defined model extension sub-schemas listed at pluginLocations.
16
+ * @returns {Object|undefined}
17
+ */
18
+ getModelExtensionParts () {
19
+ if (!this.json.properties.pluginLocations || !this.json.properties.pluginLocations.properties) {
20
+ return
21
+ }
22
+ return this.json.properties.pluginLocations && this.json.properties.pluginLocations.properties
23
+ }
24
+ }
25
+
26
+ export default ExtensionSchema
@@ -0,0 +1,65 @@
1
+ import Schema from './Schema.js'
2
+
3
+ /**
4
+ * @typedef {import('./Framework')} Framework
5
+ * @typedef {import('../plugins/Plugin')} Plugin
6
+ */
7
+
8
+ /**
9
+ * Represents the globals properties listed in a model or extension schema.
10
+ * @todo _globals doesn't need to differentiate by plugin type, plugin name should suffice
11
+ * @todo We should drop all pluralisations, they're unnecessary and complicated.
12
+ */
13
+ class GlobalsSchema extends Schema {
14
+ /**
15
+ * @param {Object} options
16
+ * @param {Framework} options.framework
17
+ * @param {string} options.name
18
+ * @param {Plugin} options.plugin
19
+ * @param {Object} options.json
20
+ * @param {string} options.filePath
21
+ * @param {string} options.globalsType
22
+ * @param {string} options.targetAttribute Attribute where this sub-schema will be injected into the course.json:_globals._[pluginType] object
23
+ */
24
+ constructor ({
25
+ framework = null,
26
+ name = '',
27
+ plugin = null,
28
+ json = {},
29
+ filePath = '',
30
+ globalsType = '',
31
+ targetAttribute = ''
32
+ } = {}) {
33
+ super({ framework, name, plugin, json, filePath, globalsType })
34
+ // Add an underscore to the front of the targetAttribute if necessary
35
+ this.targetAttribute = (targetAttribute && targetAttribute[0] !== '_' ? '_' : '') + targetAttribute
36
+ }
37
+
38
+ /**
39
+ * Returns the sub-schema for the course.json:_globals object.
40
+ * @returns {Object|undefined}
41
+ */
42
+ getCourseGlobalsPart () {
43
+ if (!this.json.globals) {
44
+ return
45
+ }
46
+ /**
47
+ * pluralise location name if necessary (components, extensions) etc
48
+ */
49
+ const shouldPluralise = ['component', 'extension'].includes(this.globalsType)
50
+ const globalsType = shouldPluralise ? this.globalsType + 's' : this.globalsType
51
+ return {
52
+ [`_${globalsType}`]: {
53
+ type: 'object',
54
+ properties: {
55
+ [this.targetAttribute]: {
56
+ type: 'object',
57
+ properties: this.json.globals
58
+ }
59
+ }
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ export default GlobalsSchema
@@ -0,0 +1,45 @@
1
+ import GlobalsSchema from './GlobalsSchema.js'
2
+
3
+ /**
4
+ * Represents an article, block, accordion or other type of model schema
5
+ */
6
+ class ModelSchema extends GlobalsSchema {
7
+ /**
8
+ * Create array of translatable attribute paths
9
+ * @returns {[string]}
10
+ */
11
+ getTranslatablePaths () {
12
+ const paths = {}
13
+ this.traverse('', ({ description, next }, attributePath) => {
14
+ switch (description.type) {
15
+ case 'object':
16
+ next(attributePath + description.name + '/')
17
+ break
18
+ case 'array':
19
+ if (!Object.prototype.hasOwnProperty.call(description, 'items')) {
20
+ // handles 'inputType': 'List' edge-case
21
+ break
22
+ }
23
+ if (description.items.type === 'object') {
24
+ next(attributePath + description.name + '/')
25
+ } else {
26
+ next(attributePath)
27
+ }
28
+ break
29
+ case 'string': {
30
+ // check if attribute should be picked
31
+ const value = Boolean(description.translatable)
32
+ if (value === false) {
33
+ break
34
+ }
35
+ // add value to store
36
+ paths[attributePath + description.name + '/'] = value
37
+ break
38
+ }
39
+ }
40
+ }, '/')
41
+ return Object.keys(paths)
42
+ }
43
+ }
44
+
45
+ export default ModelSchema
@@ -0,0 +1,41 @@
1
+ import _ from 'lodash'
2
+
3
+ /**
4
+ * @typedef {import('./Framework')} Framework
5
+ * @typedef {import('./ModelSchema')} ModelSchema
6
+ * @typedef {import('./Schema')} Schema
7
+ */
8
+
9
+ /**
10
+ * Encapsulates a collection of ModelSchema
11
+ * @todo Validation, maybe, if this set of files is used in the AAT
12
+ */
13
+ class ModelSchemas {
14
+ /**
15
+ * @param {Object} options
16
+ * @param {Framework} options.framework
17
+ * @param {[ModelSchema]} options.schemas
18
+ */
19
+ constructor ({
20
+ framework = null,
21
+ schemas = []
22
+ } = {}) {
23
+ /** @type {Framework} */
24
+ this.framework = framework
25
+ /** @type {[ModelSchema]} */
26
+ this.schemas = schemas
27
+ }
28
+
29
+ /**
30
+ * Returns an array of translatable attribute paths derived from the schemas.
31
+ * @returns {[string]}
32
+ */
33
+ getTranslatablePaths () {
34
+ return _.uniq(this.schemas.reduce((paths, modelSchema) => {
35
+ paths.push(...modelSchema.getTranslatablePaths())
36
+ return paths
37
+ }, []))
38
+ }
39
+ }
40
+
41
+ export default ModelSchemas