@01-edu/shared 1.0.5 → 1.0.9

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/README.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # 01-Edu shared library
2
2
 
3
- ## `check-definitions` command
3
+ ## `check-defs` command
4
4
 
5
+ Script used to validate content correctness.
5
6
 
7
+ It check the overall structure, attributes and other properties in definitions
8
+
9
+ It also check the existance of needed `README.md` files
10
+
11
+ ### Usage:
12
+
13
+ ```sh
14
+ # Ensure your current working directory is the repository you want to check
15
+ cd piscine-test
16
+
17
+ # Normal usage
18
+ npx -y -p '@01-edu/shared' check-defs
19
+
20
+ # Pinned version
21
+ npx -y -p '@01-edu/shared@v1.0.6' check-defs
22
+ ```
23
+
24
+ #### `-w`, `--watch` param
25
+
26
+ Will re-check on system events (new, updated, removed files)
27
+ Never exit, just keep waiting for changes to update the report
28
+
29
+ ```sh
30
+ npx -y -p '@01-edu/shared' check-defs --watch
31
+ ```
package/attrs-defs.js CHANGED
@@ -9,7 +9,6 @@ import {
9
9
  } from './event-utils.js'
10
10
  import { onboardingTypes } from './onboarding.js'
11
11
  import { getObjectFromRelativePath } from './path.js'
12
- import { supportedLang } from './programming-languages.js'
13
12
  import { gamesScoring } from './score.js'
14
13
  import { hasRequiredSkills, skillsSet } from './skill-definitions.js'
15
14
  import {
@@ -49,14 +48,14 @@ const numOrOne = x => (typeof x === 'number' && !Number.isNaN(x) ? x : 1)
49
48
  const _difficultyXpCoef = o =>
50
49
  o.attrs.difficulty * numOrOne(o.attrs.difficultyMod)
51
50
  const add = (a, b) => a + b
52
- const byValueIdx = ([ak, av], [bk, bv]) => av.index - bv.index
51
+ const byValueIdx = ([_ak, av], [_bk, bv]) => av.index - bv.index
53
52
 
54
53
  const translate = (object, user, key) =>
55
54
  object.attrs[`${key}-${user?.attrs?.language}`] ||
56
55
  object.attrs[`${key}-en`] ||
57
56
  key
58
57
 
59
- const firstOfGroup = (a, i, children) => {
58
+ const firstOfGroup = (a, _i, children) => {
60
59
  const { group } = a.attrs
61
60
  if (!group) return true
62
61
  return children.find(b => b.attrs.group === group) === a
@@ -65,7 +64,9 @@ const firstOfGroup = (a, i, children) => {
65
64
  const getCoreName = object => {
66
65
  const corePath = object.attrs.requirements?.core
67
66
  const core = _children(object.parent).find(
68
- child => child.id === getObjectFromRelativePath(corePath, object)?.id,
67
+ child =>
68
+ child.id ===
69
+ getObjectFromRelativePath(corePath, object, { throwError: false })?.id,
69
70
  )
70
71
  return core?.name || ''
71
72
  }
@@ -122,6 +123,7 @@ const TypeObject = def => ({
122
123
  // value*: // defaultValue ONLY IF the attribute is required, or when it is added,
123
124
  // functions: // if the attribute is associated to function(s) (if just one - it's this by default; if several, by default the user can choose in between them),
124
125
  // label: // text displayed in front end, instead of attribute name (client friendly),
126
+ // restrictive: // if true, the attribute will be defined only on the object itself and cannot be overridden in the relation to the parent object
125
127
  // instruction: // mention displayed under the label in the front end - to give precision that need to be seen,
126
128
  // description: complete explanation of what the attributes does - description is displayed only when the used hover the ℹ️ icon in the front end,
127
129
  // required: // if the attribute is always there by default,
@@ -212,15 +214,13 @@ attrs.allowedFunctions = {
212
214
  const autoValidateLabel = 'Automatic success'
213
215
  const autoValidateWhereLabel = `${autoValidateLabel} if`
214
216
  const autoRejectWhereLabel = `Automatic reject if`
215
- const sharedAutoValidationOperator = (action, autoValidationLabel) => ({
217
+ const sharedAutoValidationOperator = action => ({
216
218
  type: 'string',
217
219
  restrictive: true,
218
220
  label: `${action} - number of conditions to fulfil`,
219
221
  ...Functions({ all: () => 'and', one: () => 'or' }),
220
222
  })
221
- attrs.autoRejectOperator = {
222
- games: sharedAutoValidationOperator('Reject', autoRejectWhereLabel),
223
- }
223
+ attrs.autoRejectOperator = { games: sharedAutoValidationOperator('Reject') }
224
224
 
225
225
  const games = Object.entries(gamesScoring)
226
226
  const gamesParameters = games.flatMap(([game, params]) => [
@@ -244,10 +244,6 @@ const allParameters = [
244
244
  ['level', { shouldFailUnder: 10, shouldSucceedFrom: 25 }],
245
245
  ...gamesParameters,
246
246
  ]
247
- const allParametersStr = allParameters
248
- .map(([parameter, _]) => breath(parameter))
249
- .join(', ')
250
- .slice(0, -2)
251
247
 
252
248
  // TODO: mv to a attrs-utils file
253
249
  const isntObjectOrIsEmpty = elem =>
@@ -286,7 +282,7 @@ const checkTextLength = (str, length) => {
286
282
  const checkValidURL = value => {
287
283
  try {
288
284
  return Boolean(new URL(value))
289
- } catch (err) {
285
+ } catch {
290
286
  throw Error('Invalid URL.')
291
287
  }
292
288
  }
@@ -350,9 +346,7 @@ attrs.autoValidate = {
350
346
  }),
351
347
  }
352
348
 
353
- attrs.autoValidateOperator = {
354
- games: sharedAutoValidationOperator('Success', autoValidateWhereLabel),
355
- }
349
+ attrs.autoValidateOperator = { games: sharedAutoValidationOperator('Success') }
356
350
 
357
351
  // none of these conditions are required
358
352
  types.autoValidateWhereConditions = Object.fromEntries(
@@ -554,20 +548,6 @@ attrs.capacity = {
554
548
  }
555
549
 
556
550
  const exerciseCategories = new Set(['required', 'optional', 'bonus'])
557
- const projectCategories = new Set(['synchronous', 'enrichment', 'asynchronous'])
558
- // to check: should we set dynamically the enrichment project category relying
559
- // on the fact that the object has a valid core project set in its requirements?
560
- // if so, in the admin interface we have to enforce to select a core project for the enrichment category to be applied
561
- const projectCategory = Literal('synchronous', {
562
- label: 'Category of the project',
563
- required: true,
564
- options: ['synchronous', 'asynchronous', 'enrichment'],
565
- check: value => {
566
- if (!projectCategories.has(value)) {
567
- throw Error('Must be "synchronous", "asynchronous" or "enrichment"')
568
- }
569
- },
570
- })
571
551
  relationAttrs.category = {
572
552
  // exercises in exams need the category for expected xp calculation
573
553
  exam: {
@@ -596,11 +576,7 @@ relationAttrs.category = {
596
576
  },
597
577
  }),
598
578
  },
599
- module: { project: { ...projectCategory, editable: true } },
600
- piscine: {
601
- project: { ...projectCategory, private: true },
602
- raid: Literal('required', { required: true, private: true }),
603
- },
579
+ piscine: { raid: Literal('required', { required: true, private: true }) },
604
580
  }
605
581
 
606
582
  // checkbox is not required - sign step can be used without, just to display a text
@@ -956,7 +932,10 @@ const getProjectName = object => {
956
932
  // only for projects with a core requirement set, and which name starts by the core name:
957
933
  // remove the core name from the name to avoid repetition
958
934
  const coreName = getCoreName(object)
959
- return name.startsWith(coreName) ? name.slice(coreName.length + 1) : name
935
+ // TODO: couldn't we just display the key?
936
+ return coreName && name.startsWith(coreName)
937
+ ? name.slice(coreName.length + 1)
938
+ : name
960
939
  }
961
940
  const sharedDisplayedName = Literal('', {
962
941
  editable: true,
@@ -981,7 +960,7 @@ attrs.displayedName = {
981
960
  }
982
961
 
983
962
  const isHackathon = object => object.attrs.special
984
- const getExerciseDuration = ({ parent, attrs }) =>
963
+ const getExerciseDuration = ({ parent }) =>
985
964
  isHackathon(parent)
986
965
  ? parent.attrs.duration / Object.keys(parent.children).length
987
966
  : 0
@@ -1878,7 +1857,7 @@ attrs.input = {
1878
1857
  }
1879
1858
  try {
1880
1859
  return new RegExp(value)
1881
- } catch (err) {
1860
+ } catch {
1882
1861
  throw Error('Invalid Regular expression.')
1883
1862
  }
1884
1863
  }
@@ -1892,7 +1871,7 @@ const getInScope = ({ attrs }) => {
1892
1871
  }
1893
1872
  const getProjectInScope = ({ attrs }) => attrs.hasStarted
1894
1873
  const getExamExerciseInScope = ({ parent }) => parent.attrs.inScope
1895
- const getExerciseInScope = ({ parent, attrs, index }) => {
1874
+ const getExerciseInScope = ({ parent, attrs }) => {
1896
1875
  if (parent.attrs.inScope) {
1897
1876
  if (isHackathon(parent)) {
1898
1877
  const now = Date.now()
@@ -2024,31 +2003,55 @@ types.objectRootRelativePath = Literal('../', {
2024
2003
  label: 'Content relative path',
2025
2004
  instruction: 'In same parent',
2026
2005
  editable: true,
2027
- check: getObjectFromRelativePath,
2006
+ check: (path, object) => {
2007
+ try {
2008
+ getObjectFromRelativePath(path, object)
2009
+ } catch (err) {
2010
+ err.userFeedback = `This content relative path (${path}) is invalid, please select a valid content in the list below or remove it.`
2011
+ throw err
2012
+ }
2013
+ },
2028
2014
  options: object => {
2029
2015
  if (!object?.parent?.children) return []
2030
2016
  const sorted = Object.entries(object.parent.children).sort(byValueIdx)
2031
- const index = sorted.findIndex(([k, v]) => k === object.key)
2017
+ const index = sorted.findIndex(([k]) => k === object.key)
2032
2018
  if (index < 1) return sorted.map(([key, _]) => `../${key}`)
2033
2019
  const before = sorted.slice(0, index)
2034
- // TODO: add a filter on category and let possibility to select the async after?
2035
- // const after = sorted.slice(index + 1)
2036
- // .filter(([k, v]) => v.attrs?.category === 'asynchronous')
2037
- // return [...before, ...after].map(([key, _]) => `../${key}`)
2038
2020
  return before.map(([key, _]) => `../${key}`).reverse()
2039
2021
  },
2040
2022
  })
2041
2023
  types.sharedObjectList = {
2042
2024
  label: 'Contents', // synonyms: item, material
2043
2025
  editable: true,
2044
- check: objects => {
2026
+ check: (objects, object) => {
2045
2027
  if (!Array.isArray(objects) || !objects.length) {
2046
- throw Error('Must be a non empty array')
2028
+ const error = new Error('Must be a non empty array')
2029
+ error.userFeedback =
2030
+ 'This list cannot be empty! Please add an item or remove the setting.'
2031
+ throw error
2047
2032
  }
2048
2033
  const uniques = [...new Set(objects)]
2049
2034
  if (objects.length !== uniques.length) {
2050
2035
  throw Error('Duplicates are not allowed.')
2051
2036
  }
2037
+
2038
+ const invalidObjectsRequirements = objects.filter(path => {
2039
+ const objectFromPath = getObjectFromRelativePath(path, object, {
2040
+ throwError: false,
2041
+ })
2042
+ return !objectFromPath
2043
+ })
2044
+
2045
+ if (invalidObjectsRequirements.length) {
2046
+ const paths = invalidObjectsRequirements.map(p => `'${p}'`).join(', ')
2047
+ const error = new Error(
2048
+ `Invalid objects requirements - no object found for the following relative paths: ${paths}`,
2049
+ )
2050
+ error.userFeedback =
2051
+ 'Some Contents are misconfigured, please update them!'
2052
+ console.error(error.message)
2053
+ throw error
2054
+ }
2052
2055
  },
2053
2056
  }
2054
2057
 
@@ -2067,6 +2070,25 @@ const contentRequirements = {
2067
2070
  if (!skills && !objects && !core) {
2068
2071
  throw Error('Empty requirements, should be removed')
2069
2072
  }
2073
+
2074
+ const allObjects = core ? [...(objects || []), core] : objects || []
2075
+ const invalidObjectsRequirements = allObjects.filter(path => {
2076
+ const objectFromPath = getObjectFromRelativePath(path, object, {
2077
+ throwError: false,
2078
+ })
2079
+ return !objectFromPath
2080
+ })
2081
+
2082
+ if (invalidObjectsRequirements.length) {
2083
+ const paths = invalidObjectsRequirements.map(p => `'${p}'`).join(', ')
2084
+ const error = new Error(
2085
+ `Invalid objects requirements - no object found for the following relative paths: ${paths}`,
2086
+ )
2087
+ error.userFeedback =
2088
+ 'You have some misconfigured Access conditions, please update them!'
2089
+ console.error(error.message)
2090
+ throw error
2091
+ }
2070
2092
  },
2071
2093
  }
2072
2094
 
@@ -2075,7 +2097,7 @@ const levelRequirements = {
2075
2097
  instruction: 'Conditions to unlock and earn this level',
2076
2098
  description:
2077
2099
  'Sets the requirements that have to be met for a level to be unlocked and earned by a student.',
2078
- check: (requirements, object) => {
2100
+ check: requirements => {
2079
2101
  const { skills, objects, ...rest } = requirements
2080
2102
  if (Object.keys(rest).length) {
2081
2103
  throw Error(`Unsupported attribute "${Object.keys(rest)[0]}"`)
@@ -2187,6 +2209,17 @@ attrs.link = {
2187
2209
  }),
2188
2210
  }
2189
2211
 
2212
+ relationAttrs.mandatory = {
2213
+ module: {
2214
+ project: {
2215
+ label: 'Mandatory content to validate the curriculum',
2216
+ type: 'boolean',
2217
+ required: false,
2218
+ editable: true,
2219
+ },
2220
+ },
2221
+ }
2222
+
2190
2223
  // TODO: should be required for exam exercises.
2191
2224
  // But it is about to be refactored to define group base on exercise level?
2192
2225
  const maxGroupReducer = (t, c) => Math.max(c.attrs.group || 1, t) // added '|| 1' as group is not required and could be undefined
@@ -2351,7 +2384,7 @@ const getCaptainLogin = ({ group, parent }) => {
2351
2384
  }
2352
2385
  const getRepositoryPath = ({ name, group, parent }, user) =>
2353
2386
  `${getCaptainLogin({ group, parent }) || user.login}/${name}`
2354
- const getExerciseRepositoryPath = ({ attrs, parent, path, group }, user) => {
2387
+ const getExerciseRepositoryPath = ({ attrs, parent, group }, user) => {
2355
2388
  const captainLogin = getCaptainLogin({ parent, group })
2356
2389
  return captainLogin
2357
2390
  ? `${captainLogin}/${parent.path.replace(/\/+/g, '-')}`
@@ -2775,7 +2808,7 @@ const getProgressStatus = progress => {
2775
2808
  }
2776
2809
 
2777
2810
  const examExerciseStatus = object => {
2778
- const { prev, attrs, progress, parent, index, event } = object
2811
+ const { attrs, progress, parent } = object
2779
2812
  const progressStatus = getProgressStatus(progress)
2780
2813
  if (progressStatus) return progressStatus
2781
2814
  if (!parent) return 'available'
@@ -2795,7 +2828,7 @@ const examExerciseStatus = object => {
2795
2828
  return prevValidated ? 'available' : 'blocked'
2796
2829
  }
2797
2830
  const questExerciseStatus = object => {
2798
- const { prev, attrs, progress, parent, index, event } = object
2831
+ const { prev, attrs, progress, parent } = object
2799
2832
  const progressStatus = getProgressStatus(progress)
2800
2833
  if (progressStatus) return progressStatus
2801
2834
  if (!parent) return 'available'
@@ -2847,8 +2880,10 @@ const hasSucceededRequiredObjects = (requirements, object) => {
2847
2880
  try {
2848
2881
  objectFromPath = getObjectFromRelativePath(relativePath, object)
2849
2882
  } catch {
2850
- // consider the requirement locked if an error is thrown by getObjectFromRelativePath
2851
- return false
2883
+ // consider the requirement unlocked if an error is thrown by getObjectFromRelativePath,
2884
+ // because it would mean an admin wrongly set the requirement, probably manually in the db
2885
+ // (it is not possible to set an invalid requirement from the admin configuration interface)
2886
+ return true
2852
2887
  }
2853
2888
  return objectFromPath?.attrs.status === 'succeeded'
2854
2889
  })
@@ -3099,6 +3134,74 @@ const sharedText = Literal('', {
3099
3134
  editable: true,
3100
3135
  ...translatable,
3101
3136
  })
3137
+
3138
+ types.teamworkRankName = Literal('', {
3139
+ label: 'Rank name',
3140
+ type: 'string',
3141
+ editable: true,
3142
+ required: true,
3143
+ primary: true,
3144
+ check: (name, object) => {
3145
+ const definitionsWithSameName = object.attrs.teamworkRanks?.filter(
3146
+ rankDefinition => rankDefinition.name === name,
3147
+ )
3148
+ if (definitionsWithSameName?.length > 1) {
3149
+ throw Error(
3150
+ `Name "${name}" is already set for a teamwork rank definition! A given name can only be attributed once to a rank.`,
3151
+ )
3152
+ }
3153
+ },
3154
+ })
3155
+
3156
+ types.teamworkRankParticipations = Literal(0, {
3157
+ label: 'Group Participations',
3158
+ instruction:
3159
+ 'The number of users the student has to work with, to unlock this rank.',
3160
+ editable: true,
3161
+ required: true,
3162
+ options: arrayOf(150, 1),
3163
+ type: 'number',
3164
+ check: (groups, object) => {
3165
+ if (!Number.isInteger(groups)) throw Error('Must be a whole number')
3166
+
3167
+ const definitionsWithSameLevel = object.attrs.teamworkRanks.filter(
3168
+ rankDefinition => rankDefinition.groups === groups,
3169
+ )
3170
+ if (definitionsWithSameLevel?.length > 1) {
3171
+ throw Error(
3172
+ `There is already a rank with ${groups} users required for the rank`,
3173
+ )
3174
+ }
3175
+ },
3176
+ })
3177
+
3178
+ types.teamworkRanks = TypeObject({
3179
+ type: {
3180
+ name: types.teamworkRankName,
3181
+ groups: types.teamworkRankParticipations,
3182
+ },
3183
+ })
3184
+
3185
+ attrs.teamworkRanks = {
3186
+ campus: {
3187
+ label: 'Teamwork ranks',
3188
+ instruction: 'List of teamwork ranks',
3189
+ type: [types.teamworkRanks],
3190
+ required: true,
3191
+ editable: true,
3192
+ value: (...args) => [
3193
+ mapValues(types.teamworkRanks.type, subDef =>
3194
+ getDefaultValue(subDef, ...args),
3195
+ ),
3196
+ ],
3197
+ check: teamworkRanks => {
3198
+ if (!teamworkRanks?.length || !Array.isArray(teamworkRanks)) {
3199
+ throw Error('Must be a non empty array')
3200
+ }
3201
+ },
3202
+ },
3203
+ }
3204
+
3102
3205
  // TODO: rename it documentToSign, or something more specific than 'text'
3103
3206
  attrs.text = {
3104
3207
  'upload-step': {
@@ -3242,6 +3345,7 @@ types.adminSelectionValidation = TypeObject({
3242
3345
  private: true,
3243
3346
  primary: true,
3244
3347
  label: 'Type',
3348
+ options: ['admin_selection'],
3245
3349
  }),
3246
3350
  },
3247
3351
  label: 'Admin selection evaluation',
@@ -3252,7 +3356,12 @@ const getTesterImage = ({ attrs }) =>
3252
3356
  types.testerValidation = TypeObject({
3253
3357
  label: 'Tester evaluation',
3254
3358
  type: {
3255
- type: Literal('tester', { required: true, private: true, label: 'Type' }),
3359
+ type: Literal('tester', {
3360
+ required: true,
3361
+ private: true,
3362
+ label: 'Type',
3363
+ options: ['tester'],
3364
+ }),
3256
3365
  testImage: {
3257
3366
  type: 'string',
3258
3367
  label: 'Docker image',
@@ -3281,12 +3390,18 @@ const sharedMatchWhere = {
3281
3390
  }
3282
3391
  // matchWhere for user_audit validations
3283
3392
  // NB: user 1 should always be excluded because he has admin role that can't be removed
3393
+ const usersOfCampusWithAdmins = ({ attrs }) => ({
3394
+ campus: { _eq: attrs.campus },
3395
+ })
3284
3396
  const userInCampus = ({ attrs }) => ({
3285
3397
  campus: { _eq: attrs.campus },
3286
3398
  _not: { roles: { slug: { _in: ['admin', `campus_admin_${attrs.campus}`] } } },
3287
3399
  })
3400
+ const usersInEventWithAdmins = ({ attrs }) => ({
3401
+ events: { eventId: { _eq: attrs.eventId } },
3402
+ })
3288
3403
  const userInEvent = ({ attrs }) => ({
3289
- ...userInCampus({ attrs }),
3404
+ _not: { roles: { slug: { _in: ['admin', `campus_admin_${attrs.campus}`] } } },
3290
3405
  events: { eventId: { _eq: attrs.eventId } },
3291
3406
  })
3292
3407
 
@@ -3355,6 +3470,7 @@ types.adminAuditValidation = TypeObject({
3355
3470
  private: true,
3356
3471
  primary: true,
3357
3472
  label: 'Type',
3473
+ options: ['admin_audit'],
3358
3474
  }),
3359
3475
  delay: types.adminAuditValidationDelay,
3360
3476
  required: types.adminAuditValidationRequired,
@@ -3395,6 +3511,35 @@ types.userAuditValidationRatio = Literal(2, {
3395
3511
  },
3396
3512
  })
3397
3513
 
3514
+ types.matchInfluence = TypeObject({
3515
+ label: 'Match Algorithm Influence',
3516
+ editable: true,
3517
+ required: true,
3518
+ instruction: 'Adjust weights in the Match algorithm',
3519
+ type: {
3520
+ auditsRatio: Literal(2.0, {
3521
+ label: 'Current Audit Ratio',
3522
+ editable: true,
3523
+ required: true,
3524
+ }),
3525
+ auditsAssigned: Literal(2.0, {
3526
+ editable: true,
3527
+ required: true,
3528
+ label: 'Fewest Pending Audits',
3529
+ }),
3530
+ levelProximity: Literal(2.0, {
3531
+ editable: true,
3532
+ required: true,
3533
+ label: 'Level Proximity',
3534
+ }),
3535
+ lastAuditAttributed: Literal(2.0, {
3536
+ editable: true,
3537
+ required: true,
3538
+ label: 'Last Audit Attributed',
3539
+ }),
3540
+ },
3541
+ })
3542
+
3398
3543
  types.userAuditValidation = TypeObject({
3399
3544
  label: 'User audit evaluation',
3400
3545
  type: {
@@ -3403,15 +3548,19 @@ types.userAuditValidation = TypeObject({
3403
3548
  private: true,
3404
3549
  primary: true,
3405
3550
  label: 'Type',
3551
+ options: ['user_audit'],
3406
3552
  }),
3407
3553
  delay: types.userAuditValidationDelay,
3408
3554
  required: types.userAuditValidationRequired,
3409
3555
  ratio: types.userAuditValidationRatio,
3556
+ matchInfluence: types.matchInfluence,
3410
3557
  matchWhere: {
3411
3558
  ...sharedMatchWhere,
3412
3559
  ...Functions({
3413
3560
  'any user in same campus': userInCampus,
3414
3561
  'any user in same event': userInEvent,
3562
+ 'any user in same campus (with admins)': usersOfCampusWithAdmins,
3563
+ 'any user in same event (with admins)': usersInEventWithAdmins,
3415
3564
  }),
3416
3565
  },
3417
3566
  form: sharedTypesForm,
@@ -3438,6 +3587,7 @@ types.raidAuditorValidation = TypeObject({
3438
3587
  private: true,
3439
3588
  primary: true,
3440
3589
  label: 'Type',
3590
+ options: ['dedicated_auditors_for_event'],
3441
3591
  }),
3442
3592
  delay: types.adminAuditValidationDelay, // no limit to audit?
3443
3593
  required: types.adminAuditValidationRequired,
@@ -3556,7 +3706,7 @@ const sharedZeroXpIndex = Literal(0, {
3556
3706
  private: true,
3557
3707
  })
3558
3708
  const parentXpIndex = ({ parent }) => parent.attrs.xpIndex
3559
- const getXpIndex = ({ id, name, prev, parent, attrs, type }) => {
3709
+ const getXpIndex = ({ prev, parent }) => {
3560
3710
  if (prev) return (prev.attrs.xpIndex || 0) + (_difficultyXpCoef(prev) || 1)
3561
3711
  return parent?.attrs.xpIndex || 0
3562
3712
  }
package/attrs.js CHANGED
@@ -16,8 +16,8 @@ const typeChecker = (defs, value, object, key) => {
16
16
  const { type, check, options } = defs
17
17
 
18
18
  if (value == null) {
19
- if (!defs.required) return true
20
- // if no value for required attribute, reject
19
+ if (!defs.required || defs.value !== undefined) return true
20
+ // if no value for required attribute without a default value, reject
21
21
  throw Error(`missing value for required attribute ${key}`)
22
22
  }
23
23
 
@@ -33,11 +33,17 @@ const typeChecker = (defs, value, object, key) => {
33
33
 
34
34
  if (options) {
35
35
  const opts = typeof options === 'function' ? options(object) : options
36
- const isAnOption = opts.includes(value)
37
- if (!isAnOption) {
38
- throw Error(
39
- `invalid option for ${key}: should be included in ${opts.join(', ')}`,
40
- )
36
+ if (opts.length === 1) {
37
+ if (opts[0] !== value) {
38
+ throw Error(`${key} must be ${opts[0]} but was ${value}`)
39
+ }
40
+ } else {
41
+ const isAnOption = opts.includes(value)
42
+ if (!isAnOption) {
43
+ throw Error(
44
+ `invalid option for ${key}: should be included in ${opts.join(', ')}`,
45
+ )
46
+ }
41
47
  }
42
48
  }
43
49
 
@@ -111,7 +117,7 @@ const typeChecker = (defs, value, object, key) => {
111
117
  // same as attrs with the check & function by name generated and descriptions form markdown files
112
118
  export const attributes = mapEntries(attrs, ([attrKey, matches]) => [
113
119
  attrKey,
114
- mapValues(matches, (defs, type) => ({
120
+ mapValues(matches, (defs /* type */) => ({
115
121
  ...defs,
116
122
  check: (value, object) => typeChecker(defs, value, object, attrKey),
117
123
  })),
@@ -121,8 +127,8 @@ export const relationAttributes = mapEntries(
121
127
  relationAttrs,
122
128
  ([attrKey, byParent]) => [
123
129
  attrKey,
124
- mapValues(byParent, (matches, parentType) =>
125
- mapValues(matches, (defs, childType) => ({
130
+ mapValues(byParent, (matches /* parentType */) =>
131
+ mapValues(matches, (defs /* childType */) => ({
126
132
  ...defs,
127
133
  check: (value, object) => typeChecker(defs, value, object, attrKey),
128
134
  })),
@@ -1,31 +1,71 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { readFile, stat } from 'node:fs/promises'
3
+ import { readFile, stat, watch } from 'node:fs/promises'
4
4
 
5
5
  import { checkAndBuildDefinitions } from '../definitions-checker.js'
6
6
 
7
- const auditValidation = validation => validation.type.endsWith('_audit')
8
- try {
9
- const result = await checkAndBuildDefinitions(async key => {
10
- const referencePath = `content${key ? `/${key}` : ''}/def.json`
11
- const def = JSON.parse(await readFile(referencePath, 'utf8'))
12
- def.attrs || (def.attrs = {})
13
- def.referencePath = referencePath
14
- switch (def.type) {
15
- case 'project':
16
- // biome-ignore lint/suspicious/noFallthroughSwitchClause: We want to fallthrough
17
- case 'raid': {
18
- await stat(`content/${key}/audit/README.md`)
7
+ const rootTypes = ['module', 'piscine', 'sign-up', 'onboarding']
8
+ const isAudit = validation => validation.type.endsWith('_audit')
9
+ const readDef = async key => {
10
+ const path = key == null ? 'content/def.json' : `content/${key}/def.json`
11
+ const def = JSON.parse(await readFile(path, 'utf8'))
12
+ def.attrs || (def.attrs = {})
13
+ def.referencePath = path
14
+
15
+ if (key == null && !rootTypes.includes(def.type)) {
16
+ throw Error(
17
+ `Root definition must be one of ${rootTypes.join(', ')}, found: ${def.type}`,
18
+ )
19
+ }
20
+
21
+ switch (def.type) {
22
+ case 'project':
23
+ // biome-ignore lint/suspicious/noFallthroughSwitchClause: We want to fallthrough
24
+ case 'raid': {
25
+ const audit = (def.attrs.validations || []).find(isAudit)
26
+ if (!audit) {
27
+ throw Error('project and raid must have an audit validation specified')
19
28
  }
20
- case 'exercise': {
21
- await stat(`content/${key}/README.md`)
29
+ if (audit.form) {
30
+ throw Error(
31
+ 'audit form attribute is automatically set to be ./audit/README.md, do not specify it',
32
+ )
33
+ }
34
+ audit.form = `content/${key}/audit/README.md`
35
+ await stat(audit.form)
36
+ }
37
+ case 'exercise': {
38
+ if (def.attrs.subject) {
39
+ throw Error(
40
+ 'subject attribute is automatically set to be ./README.md, do not specify it',
41
+ )
22
42
  }
43
+ def.attrs.subject = `content/${key}/README.md`
44
+ await stat(def.attrs.subject)
23
45
  }
46
+ }
47
+
48
+ return def
49
+ }
50
+
51
+ const runChecks = async () => {
52
+ try {
53
+ return await checkAndBuildDefinitions(readDef)
54
+ } catch ({ message, stack, ...props }) {
55
+ console.error(message)
56
+ console.error(props)
57
+ }
58
+ }
24
59
 
25
- return def
26
- })
27
- } catch ({ message, ...props }) {
28
- console.error(message, props)
60
+ if (process.argv.includes('--watch') || process.argv.includes('-w')) {
61
+ await runChecks()
62
+ for await (const event of watch('.', { recursive: true })) {
63
+ console.clear()
64
+ if (!event.filename.endsWith('def.json')) continue
65
+ console.log(event.eventType, 'on', event.filename, '\n')
66
+ await runChecks()
67
+ }
68
+ } else if (!(await runChecks())) {
29
69
  process.exit(1)
30
70
  }
31
71
 
@@ -1,5 +1,5 @@
1
- import { childTypes, objectTypes } from './toolbox.js'
2
1
  import { attributes, relationAttributes } from './attrs.js'
2
+ import { childTypes, objectTypes } from './toolbox.js'
3
3
 
4
4
  const normalize = str =>
5
5
  str
@@ -9,16 +9,8 @@ const normalize = str =>
9
9
  .replaceAll(' ', '-')
10
10
 
11
11
  const assertDef = def => {
12
- const {
13
- type,
14
- name,
15
- attrs,
16
- children,
17
- childrenAttrs,
18
- refId,
19
- referencePath,
20
- ...rest
21
- } = def
12
+ const { type, name, attrs, children, childrenAttrs, referencePath, ...rest } =
13
+ def
22
14
  const [extra] = Object.keys(rest)
23
15
  if (extra) {
24
16
  throw Error(
@@ -27,11 +19,12 @@ const assertDef = def => {
27
19
  }
28
20
  if (!objectTypes.has(type)) throw Error(`Invalid type property`)
29
21
  if (!name || typeof name !== 'string') throw Error(`Invalid name property`)
30
- if (attrs && typeof attrs !== 'object') throw Error(`Invalid attrs property`)
31
- if (refId && typeof refId !== 'number') throw Error(`Invalid refId property`)
22
+ if (!attrs || typeof attrs !== 'object' || Array.isArray(attrs)) {
23
+ throw Error(`Invalid attrs property`)
24
+ }
32
25
  if (childrenAttrs) throw Error(`childrenAttrs is no longer supported`)
33
26
 
34
- for (const [key, value] of Object.entries(attrs)) {
27
+ for (const key of Object.keys(attrs || {})) {
35
28
  if (relationAttributes[key]) {
36
29
  throw Error(
37
30
  `Attr ${key} should be defined in the relation with its parent, not on the object itself.`,
@@ -52,16 +45,19 @@ const assertDef = def => {
52
45
  }
53
46
  }
54
47
 
55
- // TODO: maybe check for circularity here
56
- // I skipped this check for simplicity and performance
57
- // because since we already have check that we have a correct parent / child type
58
- // structure, a circular relation should not be possible
59
- const buildTree = ({ name, type, attrs, children, referencePath }, parent) => {
48
+ const buildTree = (
49
+ { name, type, attrs, children, referencePath },
50
+ parent,
51
+ depth,
52
+ ) => {
53
+ // As we check for the parent / child type structure, this should never happen
54
+ // so we did not bother to make a more descriptive error for this case
55
+ if (depth > 100) throw Error('Unexpected very deep tree, maybe circular ?')
60
56
  const object = { name, type, attrs, children: {}, referencePath, parent }
61
57
  if (!children) return object
62
58
  let prev
63
59
  for (const [key, { ref, ...rest }] of Object.entries(children)) {
64
- const child = buildTree(ref, object)
60
+ const child = buildTree(ref, object, (depth || 0) + 1)
65
61
  child.attrs = { ...child.attrs, ...rest }
66
62
  prev && (prev.next = child)
67
63
  child.prev = prev
@@ -73,7 +69,9 @@ const buildTree = ({ name, type, attrs, children, referencePath }, parent) => {
73
69
  const assertRelation = (parent, key, relation) => {
74
70
  if (!/^[a-z0-9-]*$/.test(key)) {
75
71
  throw Error(
76
- `Invalid key for child ${key} Kebab-case key suggestion: ${normalize(key)}`,
72
+ `Invalid key for child ${key} Kebab-case key suggestion: ${normalize(
73
+ key,
74
+ )}`,
77
75
  )
78
76
  }
79
77
 
@@ -89,7 +87,7 @@ const assertRelation = (parent, key, relation) => {
89
87
  throw Error(`Self reference in child ${key}`)
90
88
  }
91
89
 
92
- for (const [key, value] of Object.entries(relation)) {
90
+ for (const key of Object.keys(relation)) {
93
91
  if (key === 'ref') continue
94
92
  const matches = attributes[key]
95
93
  if (matches?.[type]?.restrictive) {
@@ -138,29 +136,6 @@ const checkAttrs = object => {
138
136
  }
139
137
  }
140
138
 
141
- // TODO: replace by Set.intersection once available in node
142
- const intersection = (a, b) => {
143
- const result = new Set()
144
- for (const element of a) {
145
- b.has(element) && result.add(element)
146
- }
147
- return result
148
- }
149
-
150
- const generatePairs = arr => {
151
- const result = []
152
- let i = -1
153
- // Only go up to the second to last element
154
- while (++i < arr.length - 1) {
155
- let j = i
156
- // Start from the next element to avoid duplicates
157
- while (++j < arr.length) {
158
- result.push([arr[i], arr[j]])
159
- }
160
- }
161
- return result
162
- }
163
-
164
139
  const isExam = def => def.type === 'exam'
165
140
  const assertExams = definitions => {
166
141
  const groups = {}
@@ -197,7 +172,16 @@ const assertExams = definitions => {
197
172
  export const checkAndBuildDefinitions = async readDef => {
198
173
  const cache = {}
199
174
  const getDefs = async ([key, relation]) => {
200
- const def = cache[key] || (cache[key] = await readDef(key))
175
+ let def
176
+ try {
177
+ const cached = cache[key]
178
+ def = await (cached || (cache[key] = readDef(key)))
179
+ relation && (relation.ref = def)
180
+ if (cached) return [] // skip assert and children checks if already cached
181
+ } catch (err) {
182
+ err.name = key
183
+ throw err
184
+ }
201
185
  try {
202
186
  assertDef(def)
203
187
  } catch (err) {
@@ -205,9 +189,15 @@ export const checkAndBuildDefinitions = async readDef => {
205
189
  err.referencePath = def.referencePath
206
190
  throw err
207
191
  }
208
- relation && (relation.ref = def)
209
192
  const relations = Object.entries(def.children || {}).map(getDefs)
210
- return [def, ...(await Promise.all(relations)).flat()]
193
+ try {
194
+ return [def, ...(await Promise.all(relations)).flat()]
195
+ } catch (err) {
196
+ // Some content may be used at multiple place (ex: exam exercises)
197
+ // so we may have multiple parents
198
+ key && (err.parents || (err.parents = [])).push(key)
199
+ throw err
200
+ }
211
201
  }
212
202
 
213
203
  const definitions = await getDefs([])
@@ -215,7 +205,6 @@ export const checkAndBuildDefinitions = async readDef => {
215
205
  // Assert all relations looks ok, possible child / parent, allowed attributes
216
206
  for (const def of definitions) {
217
207
  if (!def.children) continue
218
- const allowedTypes = childTypes[def.type]
219
208
  try {
220
209
  for (const entry of Object.entries(def.children)) {
221
210
  assertRelation(def, entry[0], entry[1])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@01-edu/shared",
3
- "version": "1.0.5",
3
+ "version": "1.0.9",
4
4
  "type": "module",
5
5
  "description": "",
6
6
  "scripts": {
@@ -21,7 +21,7 @@
21
21
  "./skill-definitions.js",
22
22
  "./toolbox.js"
23
23
  ],
24
- "license": "MIT",
24
+ "license": "Fair",
25
25
  "bin": {
26
26
  "check-defs": "./bin/check-definitions.js"
27
27
  },
package/path.js CHANGED
@@ -39,15 +39,23 @@ const walk = (object, segment) => {
39
39
  }
40
40
 
41
41
  /**
42
- * @returns {Object} the object in the relative path
43
- * @argument relativePath {string} a relative path
44
- * @argument currentObj {Object} the object from where the relative path starts
42
+ * @param {string} relativePath - a relative path
43
+ * @param {Object} currentObj - the object from where the relative path starts
44
+ * @param {Object} [opts = { throwError: true } ] - options object with boolean property `throwError` to throw an error in case the path is invalid or no object is found
45
+ * @returns {Object} - the object in the relative path
45
46
  */
46
- export const getObjectFromRelativePath = (relativePath, currentObj) => {
47
+ export const getObjectFromRelativePath = (
48
+ relativePath,
49
+ currentObj,
50
+ opts = { throwError: true },
51
+ ) => {
47
52
  const currentPath = currentObj.path
48
53
  if (isAbsolutePath(relativePath)) {
49
- throw Error("'relativePath' must be a relative path")
54
+ if (opts.throwError) throw Error("'relativePath' must be a relative path")
55
+ console.error("'relativePath' must be a relative path")
56
+ return undefined
50
57
  }
58
+
51
59
  // Ignore trailing `/` in the relative path
52
60
  if (relativePath[relativePath.length - 1] === '/') {
53
61
  relativePath = relativePath.slice(0, -1)
@@ -55,9 +63,11 @@ export const getObjectFromRelativePath = (relativePath, currentObj) => {
55
63
 
56
64
  const matchingObject = relativePath.split('/').reduce(walk, currentObj)
57
65
  if (matchingObject) return matchingObject
58
- throw Error(
59
- `Incorrect relative path '${relativePath}': no object found — current path = '${currentPath}'`,
60
- )
66
+
67
+ const errorMessage = `Incorrect relative path '${relativePath}': no object found — current path = '${currentPath}'`
68
+ if (opts.throwError) throw Error(errorMessage)
69
+ console.error(errorMessage)
70
+ return undefined
61
71
  }
62
72
 
63
73
  const isAbsolutePath = path => path.startsWith('/')
package/toolbox.js CHANGED
@@ -182,15 +182,17 @@ export const formatedDuration = (seconds, { noSeconds } = {}) => {
182
182
  }
183
183
 
184
184
  const toDate = date => (date instanceof Date ? date : new Date(date))
185
- export const toDateFormat = d => {
185
+ export const toDateFormat = (d, isISO = true) => {
186
186
  const date = toDate(d)
187
187
  const month = (date.getMonth() + 1).toString().padStart(2, '0')
188
188
  const day = date.getDate().toString().padStart(2, '0')
189
189
 
190
- return `${date.getFullYear()}-${month}-${day}`
190
+ return isISO
191
+ ? `${date.getFullYear()}-${month}-${day}`
192
+ : `${day}/${month}/${date.getFullYear()}`
191
193
  }
192
194
 
193
- export const toISOStringWithTimeZone = d => toDate(d).toISOString().slice(0, -1)
195
+ export const toISOStringWithTimeZone = d => toDate(d).toISOString()
194
196
 
195
197
  export const toDateFormatWithTime = (d, separator = 'T') => {
196
198
  const date = toDate(d)
@@ -220,7 +222,7 @@ export const monthNames = [
220
222
  'Dec',
221
223
  ]
222
224
 
223
- const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
225
+ export const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
224
226
 
225
227
  const hour = hour => {
226
228
  if (hour === 0 || hour === 12) return 12
@@ -256,12 +258,12 @@ const getDateElems = date => {
256
258
  return { day, jj, mm, yyyy, hh, min }
257
259
  }
258
260
 
259
- export const formatedDateTime = date => {
261
+ export const formatedDateTime = (date, splitter = 'at') => {
260
262
  if (!date) return '-'
261
263
  if (dateIsPermanent(date)) return '∞'
262
264
  const { jj, mm, yyyy, hh, min } = getDateElems(date)
263
265
 
264
- return `${monthNames[mm]} ${jj}, ${yyyy} at ${hour(hh)}:${minPad(
266
+ return `${monthNames[mm]} ${jj}, ${yyyy} ${splitter} ${hour(hh)}:${minPad(
265
267
  min,
266
268
  )} ${suffix(hh)}`
267
269
  }
@@ -337,13 +339,12 @@ const toSnakeCase = str =>
337
339
  // id card > id-card
338
340
  // should be enough for unique usage we have of it in check-refs
339
341
  // checked the perfs and it is faster + more secure than using replace(s) + toLowerCase
340
- const toLowerCase = x => x.toLowerCase()
341
342
  export const toKebabCase = str =>
342
343
  str
343
344
  .match(
344
345
  /[A-Z](?=[A-Z][a-z]+[0-9]*[a-z]|\b)|[A-Z]?[a-z]+[0-9]+[a-z]?[A-Z]*|[A-Z]?[a-z]+[0-9]*|[A-Z]|[0-9]+/g,
345
346
  )
346
- .map(toLowerCase)
347
+ .map(x => x.toLowerCase())
347
348
  .join('-')
348
349
 
349
350
  const spaceJoin = (_, a, b) => `${a} ${b}`
@@ -481,3 +482,18 @@ export const base64Encode = str => {
481
482
 
482
483
  export const timePlusDelay = (time, delay) =>
483
484
  new Date(new Date(time).getTime() + delay).toISOString()
485
+
486
+ export const getRecordStatus = record => {
487
+ if (!isFinished(record.startAt)) return 'starting soon'
488
+ if (record.endAt && isFinished(record.endAt)) {
489
+ return 'finished'
490
+ }
491
+ if (record.type.isPermanent) return 'permanent'
492
+ return record.endAt ? 'in progress' : 'unblock required'
493
+ }
494
+
495
+ export const createFrequencyMap = arr =>
496
+ arr.reduce((map, item) => {
497
+ map[item] = (map[item] || 0) + 1
498
+ return map
499
+ }, {})