@openstax/ts-utils 1.38.0-beta → 1.38.1

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.
@@ -3,5 +3,5 @@ import type { JWK } from 'node-jose';
3
3
  export type JwksFetcher = (uri: string) => Promise<{
4
4
  keys: JWK.RawKey[];
5
5
  }>;
6
- export declare const getJwksClient: (iss: string, fetcher?: JwksFetcher) => JwksClient;
6
+ export declare const getJwksClient: (jwksUri: string, fetcher?: JwksFetcher) => JwksClient;
7
7
  export declare const getJwksKey: (iss: string, kid?: string, fetcher?: JwksFetcher) => Promise<import("jwks-rsa").SigningKey>;
@@ -1,10 +1,10 @@
1
1
  import { JwksClient } from 'jwks-rsa';
2
2
  import { memoize } from './helpers';
3
- export const getJwksClient = memoize((iss, fetcher) => {
4
- const jwksUri = new URL('/.well-known/jwks.json', iss).toString();
3
+ export const getJwksClient = memoize((jwksUri, fetcher) => {
5
4
  return new JwksClient({ jwksUri, fetcher });
6
5
  });
7
6
  export const getJwksKey = memoize(async (iss, kid, fetcher) => {
8
- const client = getJwksClient(iss, fetcher);
7
+ const jwksUri = new URL('/.well-known/jwks.json', iss).toString();
8
+ const client = getJwksClient(jwksUri, fetcher);
9
9
  return client.getSigningKey(kid);
10
10
  });
@@ -14,16 +14,21 @@ export type ActivityState = {
14
14
  mostRecentWithCompleted?: AttemptEntry;
15
15
  };
16
16
  export declare const matchAttempt: (statement: UXapiStatement) => boolean;
17
- export declare const matchAttemptCompleted: (attempt: UXapiStatement) => (statement: UXapiStatement) => boolean;
18
- export declare const matchAttemptScored: (attempt: UXapiStatement) => (statement: UXapiStatement) => boolean;
17
+ export declare const matchAttemptCompleted: (attempt: UXapiStatement) => (statement: UXapiStatement) => boolean | "";
18
+ export declare const matchAttemptScored: (attempt: UXapiStatement) => (statement: UXapiStatement) => boolean | "";
19
19
  export declare const resolveAttempts: (statements: UXapiStatement[], options?: {
20
20
  activityIRI?: string;
21
21
  parentActivityAttempt?: string;
22
22
  }) => UXapiStatement[];
23
23
  export declare const resolveCompletedForAttempt: (statements: UXapiStatement[], attempt: UXapiStatement, activityIRI?: string) => UXapiStatement | undefined;
24
24
  export declare const resolveScoredForAttempt: (statements: UXapiStatement[], attempt: UXapiStatement, activityIRI?: string) => UXapiStatement | undefined;
25
- export declare const oldestStatement: (statements: UXapiStatement[]) => UXapiStatement | undefined;
26
- export declare const mostRecentStatement: (statements: UXapiStatement[]) => UXapiStatement | undefined;
25
+ export declare const getStatementTimeString: (statement: UXapiStatement) => string;
26
+ export declare const getStatementTime: (statement: UXapiStatement) => Date;
27
+ export declare const oldestStatement: (items: UXapiStatement[]) => UXapiStatement | undefined;
28
+ export declare const mostRecentStatement: (items: UXapiStatement[]) => UXapiStatement | undefined;
29
+ export declare const oldestEntry: (items: AttemptEntry[]) => AttemptEntry | undefined;
30
+ export declare const mostRecentEntry: (items: AttemptEntry[]) => AttemptEntry | undefined;
31
+ export declare const bestEntry: (items: AttemptEntry[]) => AttemptEntry | undefined;
27
32
  export declare const resolveAttemptInfo: (statements: UXapiStatement[], options?: {
28
33
  activityIRI?: string;
29
34
  currentAttempt?: string;
@@ -21,14 +21,14 @@ export const EXT_PENDING_SCORING = 'https://openstax.org/xapi/extensions/pending
21
21
  export const matchAttempt = (statement) => statement.verb.id === Verb.Attempted;
22
22
  export const matchAttemptCompleted = (attempt) => (statement) => {
23
23
  var _a, _b;
24
- return statement.verb.id === Verb.Completed
24
+ return statement.verb.id === Verb.Completed && attempt.id
25
25
  && statement.context !== undefined
26
26
  && ((_a = statement.context.statement) === null || _a === void 0 ? void 0 : _a.id) === attempt.id
27
27
  && statement.context.registration === ((_b = attempt.context) === null || _b === void 0 ? void 0 : _b.registration);
28
28
  };
29
29
  export const matchAttemptScored = (attempt) => (statement) => {
30
30
  var _a, _b;
31
- return statement.verb.id === Verb.Scored
31
+ return statement.verb.id === Verb.Scored && attempt.id
32
32
  && statement.context !== undefined
33
33
  && ((_a = statement.context.statement) === null || _a === void 0 ? void 0 : _a.id) === attempt.id
34
34
  && statement.context.registration === ((_b = attempt.context) === null || _b === void 0 ? void 0 : _b.registration);
@@ -42,101 +42,86 @@ export const resolveAttempts = (statements, options) => statements.filter(statem
42
42
  export const resolveCompletedForAttempt = (statements, attempt, activityIRI) => statements.find(statement => matchAttemptCompleted(attempt)(statement)
43
43
  && (!activityIRI || statement.object.id === activityIRI));
44
44
  export const resolveScoredForAttempt = (statements, attempt, activityIRI) => {
45
- // Prefer the most recent Scored
46
- const scored = mostRecentStatement(statements.filter(s => matchAttemptScored(attempt)(s) && (!activityIRI || s.object.id === activityIRI)));
47
- if (scored)
48
- return scored;
49
- // if no scored found, return most recent Completed with a score for the attempt.
50
- const completedWithScore = mostRecentStatement(statements.filter(s => matchAttemptCompleted(attempt)(s) &&
51
- (!activityIRI || s.object.id === activityIRI) && s.result &&
52
- s.result.score &&
53
- (s.result.score.scaled !== undefined)));
54
- return completedWithScore;
45
+ // scored or completed statements that contain a score in their
46
+ // result are 'scoring statements' for our purposes.
47
+ const scoringStatements = statements.filter(s => {
48
+ var _a;
49
+ return (matchAttemptScored(attempt)(s) || matchAttemptCompleted(attempt)(s))
50
+ && ((_a = s.result) === null || _a === void 0 ? void 0 : _a.score)
51
+ && (!activityIRI || s.object.id === activityIRI);
52
+ });
53
+ return mostRecentStatement(scoringStatements);
54
+ };
55
+ const resolveAttemptEntries = (statements, attempts, activityIRI) => attempts.reduce((acc, attempt) => {
56
+ const completed = resolveCompletedForAttempt(statements, attempt, activityIRI);
57
+ const scored = resolveScoredForAttempt(statements, attempt, activityIRI);
58
+ acc.push({ attempt, completed, scored });
59
+ return acc;
60
+ }, []);
61
+ export const getStatementTimeString = (statement) => 'stored' in statement && statement.stored ? statement.stored : statement.timestamp;
62
+ export const getStatementTime = (statement) => parseISO(getStatementTimeString(statement));
63
+ const findByComparator = (comparator, extractor) => (items) => {
64
+ return items.reduce((best, item) => {
65
+ if (!best)
66
+ return item;
67
+ return comparator(extractor(best), extractor(item)) ? best : item;
68
+ }, undefined);
69
+ };
70
+ export const oldestStatement = findByComparator(isBefore, getStatementTime);
71
+ export const mostRecentStatement = findByComparator(isAfter, getStatementTime);
72
+ export const oldestEntry = findByComparator(isBefore, (entry) => getStatementTime(entry.attempt));
73
+ export const mostRecentEntry = findByComparator(isAfter, (entry) => getStatementTime(entry.attempt));
74
+ const scaledScoreForEntry = (entry) => {
75
+ var _a;
76
+ const scoringStatement = entry.scored;
77
+ const score = (_a = scoringStatement === null || scoringStatement === void 0 ? void 0 : scoringStatement.result) === null || _a === void 0 ? void 0 : _a.score;
78
+ if (!score)
79
+ return { score: undefined, entry };
80
+ // Prefer scaled, otherwise normalize raw/max if available
81
+ if (score.scaled !== undefined)
82
+ return { score: score.scaled, entry };
83
+ if (score.raw !== undefined && score.max !== undefined && score.max !== 0) {
84
+ return { score: score.raw / score.max, entry };
85
+ }
86
+ return { score: undefined, entry };
55
87
  };
56
- export const oldestStatement = (statements) => statements.reduce((result, statement) => result && isBefore(parseISO('stored' in result && result.stored ? result.stored : result.timestamp), parseISO(statement.timestamp)) ? result : statement, statements[0]);
57
- export const mostRecentStatement = (statements) => statements.reduce((result, statement) => result && isAfter(parseISO('stored' in result && result.stored ? result.stored : result.timestamp), parseISO(statement.timestamp)) ? result : statement, statements[0]);
88
+ /*
89
+ * the 'best' entry is the one with the highest scaled score.
90
+ * if two entries have no score, the most recent is considered best.
91
+ */
92
+ export const bestEntry = findByComparator((a, b) => {
93
+ // if neither has a score, use the most recent
94
+ if (a.score === undefined && b.score === undefined) {
95
+ return isAfter(getStatementTime(a.entry.attempt), getStatementTime(b.entry.attempt));
96
+ }
97
+ if (a.score === undefined)
98
+ return false;
99
+ if (b.score === undefined)
100
+ return true;
101
+ return a.score > b.score;
102
+ }, scaledScoreForEntry);
58
103
  export const resolveAttemptInfo = (statements, options) => {
59
104
  // TODO optimize. i'm 100% that this could all be done in one iteration but i'm not messing around with that for now.
60
105
  const attempts = resolveAttempts(statements, options);
61
- /* attempts that have a completed statement */
62
- const resolveAttemptsWithCompleted = (statements, attempts, activityIRI) => attempts.reduce((acc, attempt) => {
63
- const completed = resolveCompletedForAttempt(statements, attempt, activityIRI);
64
- if (completed)
65
- acc.push({ attempt, completed });
66
- return acc;
67
- }, []);
68
- const completedPairs = resolveAttemptsWithCompleted(statements, attempts, options === null || options === void 0 ? void 0 : options.activityIRI);
69
- const completedAttempts = completedPairs.length;
70
- /* the last attempt sorted by timestamp */
71
- const currentAttempt = (options === null || options === void 0 ? void 0 : options.currentAttempt)
72
- ? attempts.find(attempt => attempt.id === options.currentAttempt)
73
- : (options === null || options === void 0 ? void 0 : options.currentPreference) === 'oldest'
74
- ? oldestStatement(attempts)
75
- : mostRecentStatement(attempts);
76
- const current = currentAttempt
77
- ? {
78
- attempt: currentAttempt,
79
- completed: resolveCompletedForAttempt(statements, currentAttempt, options === null || options === void 0 ? void 0 : options.activityIRI),
80
- scored: resolveScoredForAttempt(statements, currentAttempt, options === null || options === void 0 ? void 0 : options.activityIRI),
81
- }
82
- : undefined;
83
- const currentAttemptStatements = currentAttempt
106
+ const attemptEntries = resolveAttemptEntries(statements, attempts, options === null || options === void 0 ? void 0 : options.activityIRI);
107
+ const completedEntries = attemptEntries.filter((entry) => !!entry.completed);
108
+ const current = (options === null || options === void 0 ? void 0 : options.currentAttempt)
109
+ ? attemptEntries.find(({ attempt }) => attempt.id === options.currentAttempt)
110
+ : (options === null || options === void 0 ? void 0 : options.currentPreference) === 'oldest' /* the attempt sorted by timestamp */
111
+ ? oldestEntry(attemptEntries)
112
+ : mostRecentEntry(attemptEntries);
113
+ const currentAttemptStatements = current
84
114
  ? statements.filter(statement => {
85
115
  var _a;
86
116
  return (!(options === null || options === void 0 ? void 0 : options.activityIRI) || statement.object.id === options.activityIRI) &&
87
- ((_a = statement.context) === null || _a === void 0 ? void 0 : _a.registration) === currentAttempt.id;
117
+ ((_a = statement.context) === null || _a === void 0 ? void 0 : _a.registration) === current.attempt.id;
88
118
  })
89
119
  : [];
90
- const mostRecentPair = completedPairs.reduce((cur, pair) => {
91
- if (!cur)
92
- return pair;
93
- return isAfter(parseISO(cur.attempt.timestamp), parseISO(pair.attempt.timestamp)) ? cur : pair;
94
- }, undefined);
95
- const mostRecentWithCompleted = mostRecentPair
96
- ? {
97
- attempt: mostRecentPair.attempt,
98
- completed: resolveCompletedForAttempt(statements, mostRecentPair.attempt, options === null || options === void 0 ? void 0 : options.activityIRI),
99
- scored: resolveScoredForAttempt(statements, mostRecentPair.attempt, options === null || options === void 0 ? void 0 : options.activityIRI),
100
- }
101
- : undefined;
102
- const scaledForAttempt = (attempt) => {
103
- var _a;
104
- // resolveScoredForAttempt may return a Scored, or fallback to a Completed that has a score
105
- const picked = resolveScoredForAttempt(statements, attempt, options === null || options === void 0 ? void 0 : options.activityIRI);
106
- const score = (_a = picked === null || picked === void 0 ? void 0 : picked.result) === null || _a === void 0 ? void 0 : _a.score;
107
- if (!score)
108
- return undefined;
109
- // Prefer scaled, otherwise normalize raw/max if available
110
- if (score.scaled !== undefined)
111
- return score.scaled;
112
- if (score.raw !== undefined && score.max !== undefined && score.max !== 0) {
113
- return score.raw / score.max;
114
- }
115
- return undefined;
116
- };
117
- let bestAttempt = completedPairs.reduce((best, { attempt }) => {
118
- const bScaled = best !== undefined ? scaledForAttempt(best) : undefined;
119
- const aScaled = scaledForAttempt(attempt);
120
- if (bScaled === undefined)
121
- return aScaled === undefined ? best : attempt;
122
- if (aScaled === undefined)
123
- return best;
124
- return aScaled > bScaled ? attempt : best;
125
- }, undefined);
126
- // Fallback if no attempt had a score, use newest completed as best
127
- if (!bestAttempt && mostRecentPair) {
128
- bestAttempt = mostRecentPair.attempt;
129
- }
130
- const best = bestAttempt
131
- ? {
132
- attempt: bestAttempt,
133
- completed: resolveCompletedForAttempt(statements, bestAttempt, options && options.activityIRI),
134
- scored: resolveScoredForAttempt(statements, bestAttempt, options && options.activityIRI),
135
- }
136
- : undefined;
120
+ const mostRecentWithCompleted = mostRecentEntry(completedEntries);
121
+ const best = bestEntry(completedEntries);
137
122
  return {
138
123
  attempts: attempts.length,
139
- completedAttempts,
124
+ completedAttempts: completedEntries.length,
140
125
  currentAttemptStatements,
141
126
  current,
142
127
  best,
@@ -1,6 +1,6 @@
1
1
  import { AccountsGateway, MappedUserInfo } from '../accountsGateway';
2
2
  import { AuthProvider } from '../authProvider';
3
- import { ActivityState } from './attempt-utils';
3
+ import { ActivityState, AttemptEntry } from './attempt-utils';
4
4
  import { LrsGateway } from '.';
5
5
  export interface Grade {
6
6
  activityProgress: 'Initialized' | 'Started' | 'inProgress' | 'Submitted' | 'Completed';
@@ -23,15 +23,8 @@ export declare const getRegistrationAttemptInfo: (lrs: LrsGateway, registration:
23
23
  }) => Promise<{
24
24
  [key: string]: ActivityState;
25
25
  }>;
26
- export declare const getScoreGrade: (score: {
27
- scaled?: number;
28
- raw?: number;
29
- min?: number;
30
- max?: number;
31
- }, options: {
26
+ export declare const getScoreGrade: (entry: Partial<AttemptEntry>, options: {
32
27
  maxScore?: number;
33
- startedAt?: string;
34
- submittedAt?: string;
35
28
  userId?: string;
36
29
  }) => Grade;
37
30
  export type Progress = {
@@ -1,6 +1,6 @@
1
1
  import partition from 'lodash/fp/partition';
2
2
  import { roundToPrecision } from '../..';
3
- import { EXT_PENDING_SCORING, resolveAttemptInfo } from './attempt-utils';
3
+ import { EXT_PENDING_SCORING, getStatementTimeString, resolveAttemptInfo } from './attempt-utils';
4
4
  export const getRegistrationAttemptInfo = async (lrs, registration, options) => {
5
5
  const { currentPreference, ...xapiOptions } = options !== null && options !== void 0 ? options : {};
6
6
  const allStatements = await lrs.getAllXapiStatements({ ...xapiOptions, registration });
@@ -23,60 +23,47 @@ export const getRegistrationAttemptInfo = async (lrs, registration, options) =>
23
23
  // lti: http://www.imsglobal.org/spec/lti-ags/v2p0#score-publish-service
24
24
  // ltijs: https://cvmcosta.me/ltijs/#/grading
25
25
  // Note: "min" is currently completely ignored
26
- export const getScoreGrade = (score, options) => {
27
- const { raw, scaled, max } = score;
28
- const { maxScore, startedAt, submittedAt, userId } = options;
26
+ export const getScoreGrade = (entry, options) => {
27
+ var _a, _b, _c, _d, _e;
28
+ const { raw, scaled, max } = ((_b = (_a = entry.scored) === null || _a === void 0 ? void 0 : _a.result) === null || _b === void 0 ? void 0 : _b.score) || {};
29
+ const { maxScore, userId } = options;
29
30
  const scoreMaximum = maxScore !== null && maxScore !== void 0 ? maxScore : 100;
30
31
  const scoreGiven = raw && max
31
32
  ? scoreMaximum / max * raw
32
33
  : scaled
33
34
  ? scaled * scoreMaximum
34
35
  : 0;
35
- const submission = {};
36
- if (startedAt) {
37
- submission.startedAt = startedAt;
38
- }
39
- if (submittedAt) {
40
- submission.submittedAt = submittedAt;
41
- }
36
+ const submission = {
37
+ startedAt: entry.attempt ? getStatementTimeString(entry.attempt) : undefined,
38
+ submittedAt: entry.completed ? getStatementTimeString(entry.completed) : undefined,
39
+ };
40
+ const pendingManual = entry.scored === undefined
41
+ && ((_e = (_d = (_c = entry.completed) === null || _c === void 0 ? void 0 : _c.result) === null || _d === void 0 ? void 0 : _d.extensions) === null || _e === void 0 ? void 0 : _e[EXT_PENDING_SCORING]) === 'true';
42
42
  return {
43
43
  userId,
44
- activityProgress: submittedAt ? 'Completed' : 'Started',
44
+ activityProgress: entry.completed ? 'Completed' : 'Started',
45
45
  // canvas assumes that anything that isn't 'FullyGraded' requires manual grading and displays a "needs grading" icon.
46
46
  // if you warp your mind you can consider the portion of the assignment which is completed to be fully graded.
47
- gradingProgress: 'FullyGraded',
47
+ gradingProgress: pendingManual ? 'PendingManual' : 'FullyGraded',
48
48
  scoreMaximum,
49
49
  scoreGiven: roundToPrecision(scoreGiven, -2),
50
50
  submission,
51
51
  };
52
52
  };
53
53
  // These methods assign 0's to incomplete activities
54
- const pickAttemptAndCompletedOrScored = (state, gradePreference) => {
55
- var _a;
56
- const entry = (gradePreference === 'best' ? state.best : state.current);
57
- return {
58
- attempt: entry === null || entry === void 0 ? void 0 : entry.attempt,
59
- completedOrScored: (_a = entry === null || entry === void 0 ? void 0 : entry.scored) !== null && _a !== void 0 ? _a : entry === null || entry === void 0 ? void 0 : entry.completed,
60
- completed: entry === null || entry === void 0 ? void 0 : entry.completed,
61
- };
54
+ const getGradingAttemptEntry = (state, gradePreference) => {
55
+ return (gradePreference === 'best' ? state.best : state.current) || {};
62
56
  };
63
57
  const getCompletedActivityStateGradeAndProgress = ({ name, scoreMaximum, state, userId, gradePreference = 'current' }) => {
64
- var _a, _b, _c;
65
- const { attempt, completedOrScored, completed } = pickAttemptAndCompletedOrScored(state, gradePreference);
66
- const grade = getScoreGrade(((_a = completedOrScored === null || completedOrScored === void 0 ? void 0 : completedOrScored.result) === null || _a === void 0 ? void 0 : _a.score) || {}, {
58
+ const entry = getGradingAttemptEntry(state, gradePreference);
59
+ const grade = getScoreGrade(entry, {
67
60
  maxScore: scoreMaximum,
68
- startedAt: attempt === null || attempt === void 0 ? void 0 : attempt.timestamp,
69
- submittedAt: completed === null || completed === void 0 ? void 0 : completed.timestamp,
70
61
  userId,
71
62
  });
72
- // If this Completed is explicitly "pending scoring", surface that in the grade.
73
- if (((_c = (_b = completedOrScored === null || completedOrScored === void 0 ? void 0 : completedOrScored.result) === null || _b === void 0 ? void 0 : _b.extensions) === null || _c === void 0 ? void 0 : _c[EXT_PENDING_SCORING]) === 'true') {
74
- grade.gradingProgress = 'PendingManual';
75
- }
76
63
  return {
77
64
  grade,
78
65
  progress: {
79
- scaled: completedOrScored ? 1 : 0,
66
+ scaled: entry.completed ? 1 : 0,
80
67
  },
81
68
  name,
82
69
  };
@@ -89,7 +76,7 @@ export const getCompletedUserInfosGradeAndProgress = (infos, scoreMaximum, grade
89
76
  gradePreference,
90
77
  }));
91
78
  export const getCurrentGrade = async (services, registration, options) => {
92
- var _a;
79
+ var _a, _b;
93
80
  const user = await services.ltiAuthProvider.getUser();
94
81
  if (!user) {
95
82
  return null;
@@ -107,7 +94,7 @@ export const getCurrentGrade = async (services, registration, options) => {
107
94
  gradePreference,
108
95
  });
109
96
  }
110
- if (userInfo.current && userInfo.current.completed || !incompleteAttemptCallback) {
97
+ if (((_b = userInfo.current) === null || _b === void 0 ? void 0 : _b.completed) || !incompleteAttemptCallback) {
111
98
  return getCompletedActivityStateGradeAndProgress({
112
99
  name,
113
100
  scoreMaximum,
@@ -128,8 +115,8 @@ export const getAssignmentGrades = async (services, assignmentIRI, registration,
128
115
  }
129
116
  // Partition based on whether the chosen preference has a completed attempt
130
117
  const [incompleteInfo, completedInfo] = partition((info) => {
131
- const { completedOrScored } = pickAttemptAndCompletedOrScored(info.data, gradePreference);
132
- return completedOrScored === undefined;
118
+ const { completed } = getGradingAttemptEntry(info.data, gradePreference);
119
+ return completed === undefined;
133
120
  })(mappedInfo);
134
121
  return getCompletedUserInfosGradeAndProgress(completedInfo, scoreMaximum, gradePreference).concat(await incompleteAttemptsCallback(incompleteInfo));
135
122
  };