@quran-lab/tajweedformer 2.0.0 → 2.0.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.
@@ -0,0 +1,126 @@
1
+ // An app's own wording for the sharing copy (SDK 2.0.1; the platform client doc, "Your own wording"). When an
2
+ // app replaces deck strings, the learner agrees to that text, so the grant is recorded under a version derived
3
+ // from it and every session of that grant sends the text's hash for Quran Lab to review:
4
+ //
5
+ // canonical = "quranlab.consent-text/1\n" + "base " + <deck consent_version> + "\n"
6
+ // + for each replaced key, sorted by its UTF-8 bytes: key + "\t" + escaped text + "\n"
7
+ // escaped = \ -> \\, newline -> \n, carriage return -> \r, tab -> \t (nothing else, no normalisation)
8
+ // consent_text_sha256 = SHA-256 of the canonical UTF-8 bytes, lower-case hex
9
+ // consent_version = "custom-" + its first 24 hex digits
10
+ //
11
+ // The same derivation in every SDK, pinned by the shared vector consent_text.json. Synchronous (a small
12
+ // SHA-256 here) so the consent state can compare versions at any moment.
13
+ // Tried and rejected: WebCrypto's digest (async: the version would not be known when a sheet answer or a
14
+ // settings switch has to be recorded); JSON canonicalisation (encoders escape differently across platforms).
15
+
16
+ import { DECKS } from './decks.js';
17
+
18
+ export const CONSENT_TEXT_FORMAT = 'quranlab.consent-text/1';
19
+ /** CLDR plural categories a plural line may be given for (`impact.minutes.one`, ...). */
20
+ export const PLURAL_CATEGORIES = Object.freeze(['zero', 'one', 'two', 'few', 'many', 'other']);
21
+
22
+ const utf8 = (s) => new TextEncoder().encode(s);
23
+
24
+ // ------------------------------------------------------------------------------------------------
25
+ // SHA-256 (FIPS 180-4), synchronous
26
+
27
+ const K = new Uint32Array([
28
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be,
29
+ 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa,
30
+ 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85,
31
+ 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
32
+ 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f,
33
+ 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
34
+ ]);
35
+
36
+ /** SHA-256 of bytes, lower-case hex. */
37
+ export function sha256HexSync(bytes) {
38
+ const len = bytes.length;
39
+ const blocks = Math.ceil((len + 9) / 64);
40
+ const m = new Uint8Array(blocks * 64);
41
+ m.set(bytes);
42
+ m[len] = 0x80;
43
+ const dv = new DataView(m.buffer);
44
+ dv.setUint32(m.length - 8, Math.floor(len / 0x20000000), false);
45
+ dv.setUint32(m.length - 4, (len * 8) >>> 0, false);
46
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
47
+ const w = new Uint32Array(64);
48
+ const rotr = (x, n) => (x >>> n) | (x << (32 - n));
49
+ for (let b = 0; b < blocks; b++) {
50
+ for (let i = 0; i < 16; i++) w[i] = dv.getUint32(b * 64 + i * 4, false);
51
+ for (let i = 16; i < 64; i++) {
52
+ const s0 = rotr(w[i - 15], 7) ^ rotr(w[i - 15], 18) ^ (w[i - 15] >>> 3);
53
+ const s1 = rotr(w[i - 2], 17) ^ rotr(w[i - 2], 19) ^ (w[i - 2] >>> 10);
54
+ w[i] = (w[i - 16] + s0 + w[i - 7] + s1) >>> 0;
55
+ }
56
+ let [a, bb, c, d, e, f, g, hh] = h;
57
+ for (let i = 0; i < 64; i++) {
58
+ const t1 = (hh + (rotr(e, 6) ^ rotr(e, 11) ^ rotr(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
59
+ const t2 = ((rotr(a, 2) ^ rotr(a, 13) ^ rotr(a, 22)) + ((a & bb) ^ (a & c) ^ (bb & c))) >>> 0;
60
+ hh = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = bb; bb = a; a = (t1 + t2) >>> 0;
61
+ }
62
+ h[0] += a; h[1] += bb; h[2] += c; h[3] += d; h[4] += e; h[5] += f; h[6] += g; h[7] += hh;
63
+ }
64
+ return [...h].map((x) => x.toString(16).padStart(8, '0')).join('');
65
+ }
66
+
67
+ // ------------------------------------------------------------------------------------------------
68
+
69
+ /** The keys an app may replace: every string key of the decks, and each plural line by category. */
70
+ export function wordingKeys() {
71
+ const keys = new Set();
72
+ for (const d of Object.values(DECKS)) {
73
+ for (const [k, v] of Object.entries(d.strings)) {
74
+ if (typeof v === 'string') keys.add(k);
75
+ else if (v && typeof v === 'object') for (const c of PLURAL_CATEGORIES) keys.add(`${k}.${c}`);
76
+ }
77
+ }
78
+ return keys;
79
+ }
80
+
81
+ const compareUtf8 = (a, b) => {
82
+ const x = utf8(a), y = utf8(b);
83
+ for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] - y[i];
84
+ return x.length - y.length;
85
+ };
86
+ const escape = (t) => t.replace(/\\/g, '\\\\').replace(/\n/g, '\\n').replace(/\r/g, '\\r').replace(/\t/g, '\\t');
87
+
88
+ /**
89
+ * Checks an app's overrides: an object of deck keys to non-blank strings. Throws a TypeError naming the
90
+ * first problem (an unknown key, a text that is not a string, empty or blank); returns a frozen copy.
91
+ * null, undefined or {} mean no overrides.
92
+ */
93
+ export function validateWording(overrides) {
94
+ if (overrides === null || overrides === undefined) return Object.freeze({});
95
+ if (typeof overrides !== 'object' || Array.isArray(overrides)) throw new TypeError('wording: an object of {key: text}');
96
+ const keys = wordingKeys();
97
+ const out = {};
98
+ for (const [k, v] of Object.entries(overrides)) {
99
+ if (!keys.has(k)) throw new TypeError(`wording: unknown key ${JSON.stringify(k)}`);
100
+ if (typeof v !== 'string') throw new TypeError(`wording: ${k} is not a string`);
101
+ if (!v.length) throw new TypeError(`wording: ${k} is empty`);
102
+ if (/^\s*$/u.test(v)) throw new TypeError(`wording: ${k} is blank`);
103
+ out[k] = v;
104
+ }
105
+ return Object.freeze(out);
106
+ }
107
+
108
+ /** The canonical text of a wording (a string; its UTF-8 bytes are what is hashed), or null without overrides. */
109
+ export function canonicalConsentText(base, overrides) {
110
+ const keys = Object.keys(overrides || {});
111
+ if (!keys.length) return null;
112
+ let out = `${CONSENT_TEXT_FORMAT}\nbase ${base}\n`;
113
+ for (const k of keys.sort(compareUtf8)) out += `${k}\t${escape(overrides[k])}\n`;
114
+ return out;
115
+ }
116
+
117
+ /**
118
+ * The consent a learner gives under a deck and an app's overrides:
119
+ * { version, textSha256, canonicalText }; without overrides the deck's version and nulls.
120
+ */
121
+ export function deriveConsent(base, overrides) {
122
+ const canonicalText = canonicalConsentText(base, overrides);
123
+ if (canonicalText === null) return Object.freeze({ version: base, textSha256: null, canonicalText: null });
124
+ const textSha256 = sha256HexSync(utf8(canonicalText));
125
+ return Object.freeze({ version: `custom-${textSha256.slice(0, 24)}`, textSha256, canonicalText });
126
+ }
package/web/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- // Type definitions for @quran-lab/tajweedformer: the V4 TajweedFormer API (SDK 2.0.0).
1
+ // Type definitions for @quran-lab/tajweedformer: the V4 TajweedFormer API (SDK 2.0.1).
2
2
  export * from './tajweed/index.js';
3
3
  import { TajweedFormer } from './tajweed/index.js';
4
4
  export default TajweedFormer;