diff --git a/package-lock.json b/package-lock.json index 6c19fd6..3868e50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,17 +6,17 @@ "": { "name": "coding-components", "dependencies": { - "@angular/animations": "^20.3.17", + "@angular/animations": "^20.3.18", "@angular/cdk": "^20.2.14", - "@angular/common": "^20.3.17", - "@angular/compiler": "^20.3.17", - "@angular/core": "^20.3.17", - "@angular/elements": "^20.3.17", - "@angular/forms": "^20.3.17", + "@angular/common": "^20.3.18", + "@angular/compiler": "^20.3.18", + "@angular/core": "^20.3.18", + "@angular/elements": "^20.3.18", + "@angular/forms": "^20.3.18", "@angular/material": "^20.2.14", - "@angular/platform-browser": "^20.3.17", - "@angular/platform-browser-dynamic": "^20.3.17", - "@angular/router": "^20.3.17", + "@angular/platform-browser": "^20.3.18", + "@angular/platform-browser-dynamic": "^20.3.18", + "@angular/router": "^20.3.18", "@iqb/mathlive": "^0.4.1", "@iqb/responses": "^5.0.0", "@iqbspecs/coding-scheme": "^3.3.1", @@ -37,11 +37,14 @@ "@angular-devkit/core": "^20.3.19", "@angular/build": "^20.3.19", "@angular/cli": "^20.3.19", - "@angular/compiler-cli": "^20.3.17", + "@angular/compiler-cli": "^20.3.18", "@iqb/eslint-config": "^2.2.0", + "@schematics/angular": "20.3.19", "@types/jasmine": "~4.3.0", "@types/node": "^20.4.4", - "eslint": "^8.57.0", + "cheerio": "^1.0.0", + "docx": "^8.5.0", + "eslint": "^8.57.1", "iqb-dev-components": "^1.4.1", "jasmine-core": "~4.6.0", "karma": "~6.4.0", @@ -501,7 +504,6 @@ "version": "20.3.19", "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-20.3.19.tgz", "integrity": "sha512-7ZwThNeCcdKNuFpmrpQvm049v2Y+7CCCoFOzGg0UnH7F+wmaTSwEwLr+NmGJO0shYCUGl1Q/pcF9y58xs2njiQ==", - "dev": true, "license": "MIT", "dependencies": { "@angular-devkit/core": "20.3.19", @@ -517,9 +519,9 @@ } }, "node_modules/@angular/animations": { - "version": "20.3.17", - "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.17.tgz", - "integrity": "sha512-KvdgFjCTkOD3WVt4gzmJOoX914eey/Efu2Pb/KUM0Bqp1ZoXiFpI48GCd1b6Ks8JlDBeAfgjtpdSUB2aLnMRZQ==", + "version": "20.3.18", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-20.3.18.tgz", + "integrity": "sha512-XFxgSyjfs0SRD2vQVFJljmM4z9nTvUoI8TRqSre/+l8D2FgzD5pG67Aj2BgDgpSFAUkIcI37G48ijK7a3ZZ3WA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -528,7 +530,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.17" + "@angular/core": "20.3.18" } }, "node_modules/@angular/build": { @@ -679,27 +681,10 @@ "yarn": ">= 1.13.0" } }, - "node_modules/@angular/cli/node_modules/@schematics/angular": { - "version": "20.3.19", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-20.3.19.tgz", - "integrity": "sha512-LG8w+NhrrjWw14r1Xgw/pwPT/WlFgVo5vH8qQMpkMvLhPHGAt6nsaQEUYKot8mlpatMQK0yCrrsNPvLquBvD2A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@angular-devkit/core": "20.3.19", - "@angular-devkit/schematics": "20.3.19", - "jsonc-parser": "3.3.1" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, "node_modules/@angular/common": { - "version": "20.3.17", - "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.17.tgz", - "integrity": "sha512-Dqd8f8o9MehszTZIB7o7jrERlwLOSK64gNngK14DCQazz5lpIhAF6hBjx7zjHpa7L9eAYPK1TaxQUXypjzj18Q==", + "version": "20.3.18", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-20.3.18.tgz", + "integrity": "sha512-M62oQbSTRmnGavIVCwimoadg/PDWadgNhactMm9fgH0eM9rx+iWBAYJk4VufO0bwOhysFpRZpJgXlFjOifz/Jw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -708,14 +693,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.17", + "@angular/core": "20.3.18", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/compiler": { - "version": "20.3.17", - "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.17.tgz", - "integrity": "sha512-cj3x6aFk9xOOxX+qEdeN8T5YbnBNWJ4UMHB/LQoDr7/xCJJGa40IhcOAuJeuF2kGqTwx6MCXnvjO8XOQfHhe9g==", + "version": "20.3.18", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-20.3.18.tgz", + "integrity": "sha512-AaP/LCiDNcYmF135EEozjyR04NRBT38ZfBHQwjhgwiBBTejmvcpHwJaHSkraLpZqZzE4BQqqmgiQ1EJqxEwLVA==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -725,9 +710,9 @@ } }, "node_modules/@angular/compiler-cli": { - "version": "20.3.17", - "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.17.tgz", - "integrity": "sha512-w5pmO1pXO9tUMgUMWstpDmAWh5s1lJWo+2GI/ByaUEgBZkXd2S92sWoDL+bhy+JSvFzdLGdua6BncHBOX7hEjA==", + "version": "20.3.18", + "resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-20.3.18.tgz", + "integrity": "sha512-zsoEgLgnblmRbi47YwMghKirJ8IBKJ3+I8TxLBRIBrhx+KHFp+6oeDeLyu9H+djdyk88zexVd09wzR/YK73F0g==", "license": "MIT", "dependencies": { "@babel/core": "7.28.3", @@ -747,7 +732,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.17", + "@angular/compiler": "20.3.18", "typescript": ">=5.8 <6.0" }, "peerDependenciesMeta": { @@ -757,9 +742,9 @@ } }, "node_modules/@angular/core": { - "version": "20.3.17", - "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.17.tgz", - "integrity": "sha512-YlQqxMeHI9XJw7I7oM3hYFQd4lQbK37IdlD9ztROIw5FjX6i6lmLU7+X1MQGSRi2r+X9l3IZtl33hRTNvkoUBw==", + "version": "20.3.18", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-20.3.18.tgz", + "integrity": "sha512-B+NQQngd/aDbcfW0zGLis3wTLDeHTeTYMl/mGKQH+HwdPaRCKI1wEtaXaOYVJXkP2FeThocPevB8gLwNlPQUUw==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -768,7 +753,7 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/compiler": "20.3.17", + "@angular/compiler": "20.3.18", "rxjs": "^6.5.3 || ^7.4.0", "zone.js": "~0.15.0" }, @@ -782,9 +767,9 @@ } }, "node_modules/@angular/elements": { - "version": "20.3.17", - "resolved": "https://registry.npmjs.org/@angular/elements/-/elements-20.3.17.tgz", - "integrity": "sha512-6PwdtKlHEJbCK1dCniRBIuvAw5au6kuwiLCSDLwF0OYAeNegdX+tiGnncaIn1FncuyQvAKmwE0lJlMTLx2oIxw==", + "version": "20.3.18", + "resolved": "https://registry.npmjs.org/@angular/elements/-/elements-20.3.18.tgz", + "integrity": "sha512-5UMS+2pbUMfxw9PG60bFBLNx9nSE8agRxxdynvUEbKqBwX9YmILT5F5/8OvmHjORd33GMOIaSRWWEAoi5VtJpg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -793,14 +778,14 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/core": "20.3.17", + "@angular/core": "20.3.18", "rxjs": "^6.5.3 || ^7.4.0" } }, "node_modules/@angular/forms": { - "version": "20.3.17", - "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.17.tgz", - "integrity": "sha512-iGS6NwzcyJzinbPMapsQtcN0ZJ62vr6hcul+FNa40CaK2ePC04S+C5n+DIphzwnwsFHDBIWuTQRfk/lNYdN1JA==", + "version": "20.3.18", + "resolved": "https://registry.npmjs.org/@angular/forms/-/forms-20.3.18.tgz", + "integrity": "sha512-x6/99LfxolyZIFUL3Wr0OrtuXHEDwEz/rwx+WzE7NL+n35yO40t3kp0Sn5uMFwI94i91QZJmXHltMpZhrVLuYg==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -809,9 +794,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.17", - "@angular/core": "20.3.17", - "@angular/platform-browser": "20.3.17", + "@angular/common": "20.3.18", + "@angular/core": "20.3.18", + "@angular/platform-browser": "20.3.18", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -833,9 +818,9 @@ } }, "node_modules/@angular/platform-browser": { - "version": "20.3.17", - "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.17.tgz", - "integrity": "sha512-GA8pK+0F2/KGdYn5LMpLBrPTkQUwGjQE8Q+qsivOa150cK3OuD0po5PvYK58l+niGIVvm0wB1xGKTHTOiX/+4A==", + "version": "20.3.18", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-20.3.18.tgz", + "integrity": "sha512-q6s5rEN1yYazpHYp+k4pboXRzMsRB9auzTRBEhyXSGYxqzrnn3qHN0DqgsLC9WAdyhCgnIEMFA8kRT+W277DqQ==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -844,9 +829,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/animations": "20.3.17", - "@angular/common": "20.3.17", - "@angular/core": "20.3.17" + "@angular/animations": "20.3.18", + "@angular/common": "20.3.18", + "@angular/core": "20.3.18" }, "peerDependenciesMeta": { "@angular/animations": { @@ -855,9 +840,9 @@ } }, "node_modules/@angular/platform-browser-dynamic": { - "version": "20.3.17", - "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.17.tgz", - "integrity": "sha512-yTxFuGQ+z0J9khNIhfFZ+kkT7TOFb8kFZKyUz0DxHOmE0q/TEvNZoy3jXOs8xCBFf1+6BY0NqFNlPna+uw36FQ==", + "version": "20.3.18", + "resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.18.tgz", + "integrity": "sha512-NyTobOGYVzGmPmtI+3lxMzxi0TbLq4SRNQ2ENEJAt6k2JnMmHBm483ppLRAM47nGlDdiraW0IX93EtYYNkiK3g==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -866,16 +851,16 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.17", - "@angular/compiler": "20.3.17", - "@angular/core": "20.3.17", - "@angular/platform-browser": "20.3.17" + "@angular/common": "20.3.18", + "@angular/compiler": "20.3.18", + "@angular/core": "20.3.18", + "@angular/platform-browser": "20.3.18" } }, "node_modules/@angular/router": { - "version": "20.3.17", - "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.17.tgz", - "integrity": "sha512-p0r0IOJhUcn8WHx4gkSlfwifkkYO5mSDtq4iM5OunZTlSaeSxLb1vTRg2VBgwdzpgAM+eZSMBTTVF/M3pdoELQ==", + "version": "20.3.18", + "resolved": "https://registry.npmjs.org/@angular/router/-/router-20.3.18.tgz", + "integrity": "sha512-3CWejsEYr+ze+ktvWN/qHdyq5WLrj96QZpGYJyxh1pchIcpMPE9MmLpdjf0CUrWYB7g/85u0Geq/xsz72JrGng==", "license": "MIT", "dependencies": { "tslib": "^2.3.0" @@ -884,9 +869,9 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "peerDependencies": { - "@angular/common": "20.3.17", - "@angular/core": "20.3.17", - "@angular/platform-browser": "20.3.17", + "@angular/common": "20.3.18", + "@angular/core": "20.3.18", + "@angular/platform-browser": "20.3.18", "rxjs": "^6.5.3 || ^7.4.0" } }, @@ -5674,14 +5659,13 @@ "peer": true }, "node_modules/@schematics/angular": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-21.2.1.tgz", - "integrity": "sha512-DjrHRMoILhbZ6tc7aNZWuHA1wCm1iU/JN1TxAwNEyIBgyU3Fx8Z5baK4w0TCpOIPt0RLWVgP2L7kka9aXWCUFA==", + "version": "20.3.19", + "resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-20.3.19.tgz", + "integrity": "sha512-LG8w+NhrrjWw14r1Xgw/pwPT/WlFgVo5vH8qQMpkMvLhPHGAt6nsaQEUYKot8mlpatMQK0yCrrsNPvLquBvD2A==", "license": "MIT", - "peer": true, "dependencies": { - "@angular-devkit/core": "21.2.1", - "@angular-devkit/schematics": "21.2.1", + "@angular-devkit/core": "20.3.19", + "@angular-devkit/schematics": "20.3.19", "jsonc-parser": "3.3.1" }, "engines": { @@ -5690,218 +5674,6 @@ "yarn": ">= 1.13.0" } }, - "node_modules/@schematics/angular/node_modules/@angular-devkit/core": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-21.2.1.tgz", - "integrity": "sha512-TpXGjERqVPN8EPt7LdmWAwh0oNQ/6uWFutzGZiXhJy81n1zb1O1XrqhRAmvP1cAo5O+na6IV2JkkCmxL6F8GUg==", - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "8.18.0", - "ajv-formats": "3.0.1", - "jsonc-parser": "3.3.1", - "picomatch": "4.0.3", - "rxjs": "7.8.2", - "source-map": "0.7.6" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - }, - "peerDependencies": { - "chokidar": "^5.0.0" - }, - "peerDependenciesMeta": { - "chokidar": { - "optional": true - } - } - }, - "node_modules/@schematics/angular/node_modules/@angular-devkit/schematics": { - "version": "21.2.1", - "resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-21.2.1.tgz", - "integrity": "sha512-CWoamHaasAHMjHcYqxbj0tMnoXxdGotcAz2SpiuWtH28Lnf5xfbTaJn/lwdMP8Wdh4tgA+uYh2l45A5auCwmkw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@angular-devkit/core": "21.2.1", - "jsonc-parser": "3.3.1", - "magic-string": "0.30.21", - "ora": "9.3.0", - "rxjs": "7.8.2" - }, - "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0", - "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", - "yarn": ">= 1.13.0" - } - }, - "node_modules/@schematics/angular/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@schematics/angular/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@schematics/angular/node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "readdirp": "^5.0.0" - }, - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@schematics/angular/node_modules/cli-spinners": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz", - "integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@schematics/angular/node_modules/log-symbols": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", - "integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==", - "license": "MIT", - "peer": true, - "dependencies": { - "is-unicode-supported": "^2.0.0", - "yoctocolors": "^2.1.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@schematics/angular/node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/@schematics/angular/node_modules/ora": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/ora/-/ora-9.3.0.tgz", - "integrity": "sha512-lBX72MWFduWEf7v7uWf5DHp9Jn5BI8bNPGuFgtXMmr2uDz2Gz2749y3am3agSDdkhHPHYmmxEGSKH85ZLGzgXw==", - "license": "MIT", - "peer": true, - "dependencies": { - "chalk": "^5.6.2", - "cli-cursor": "^5.0.0", - "cli-spinners": "^3.2.0", - "is-interactive": "^2.0.0", - "is-unicode-supported": "^2.1.0", - "log-symbols": "^7.0.1", - "stdin-discarder": "^0.3.1", - "string-width": "^8.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@schematics/angular/node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", - "extraneous": true, - "license": "MIT", - "engines": { - "node": ">= 20.19.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/@schematics/angular/node_modules/stdin-discarder": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.1.tgz", - "integrity": "sha512-reExS1kSGoElkextOcPkel4NE99S0BWxjUHQeDFnR8S993JxpPX7KU4MNmO19NXhlJp+8dmdCbKQVNgLJh2teA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@schematics/angular/node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", - "license": "MIT", - "peer": true, - "dependencies": { - "get-east-asian-width": "^1.5.0", - "strip-ansi": "^7.1.2" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@schematics/angular/node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, "node_modules/@sigstore/bundle": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-4.0.0.tgz", @@ -8055,6 +7827,106 @@ "dev": true, "license": "MIT" }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio-select/node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio-select/node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -8986,6 +8858,42 @@ "node": ">=6.0.0" } }, + "node_modules/docx": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/docx/-/docx-8.5.0.tgz", + "integrity": "sha512-4SbcbedPXTciySXiSnNNLuJXpvxFe5nqivbiEHXyL8P/w0wx2uW7YXNjnYgjW0e2e6vy+L/tMISU/oAiXCl57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.3.1", + "jszip": "^3.10.1", + "nanoid": "^5.0.4", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.6.tgz", + "integrity": "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/dom-serialize": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", @@ -9104,6 +9012,33 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/engine.io": { "version": "6.6.5", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", @@ -11114,6 +11049,13 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/immutable": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", @@ -12074,6 +12016,59 @@ ], "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/karma": { "version": "6.4.4", "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", @@ -12740,6 +12735,16 @@ } } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -14494,6 +14499,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -14577,6 +14589,85 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-parser-stream/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parse5-sax-parser": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", @@ -15009,9 +15100,9 @@ } }, "node_modules/prosemirror-gapcursor": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.0.tgz", - "integrity": "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ==", + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", "license": "MIT", "peer": true, "dependencies": { @@ -15863,8 +15954,8 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "devOptional": true, "license": "BlueOak-1.0.0", - "optional": true, "engines": { "node": ">=11.0.0" } @@ -16164,6 +16255,13 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -17391,6 +17489,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.1.tgz", + "integrity": "sha512-5xoBibbmnjlcR3jdqtY2Lnx7WbrD/tHlT01TmvqZUFVc9Q1w4+j5hbnapTqbcXITMH1ovjq/W7BkqBilHiVAaA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -18398,6 +18506,43 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -18609,6 +18754,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -18672,19 +18837,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/yoctocolors": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", - "integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/yoctocolors-cjs": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", diff --git a/package.json b/package.json index 12c8607..4bde696 100644 --- a/package.json +++ b/package.json @@ -31,17 +31,17 @@ "test:coverage": "npm run test:cc:coverage && npm run test:schemer:coverage" }, "dependencies": { - "@angular/animations": "^20.3.17", + "@angular/animations": "^20.3.18", "@angular/cdk": "^20.2.14", - "@angular/common": "^20.3.17", - "@angular/compiler": "^20.3.17", - "@angular/core": "^20.3.17", - "@angular/elements": "^20.3.17", - "@angular/forms": "^20.3.17", + "@angular/common": "^20.3.18", + "@angular/compiler": "^20.3.18", + "@angular/core": "^20.3.18", + "@angular/elements": "^20.3.18", + "@angular/forms": "^20.3.18", "@angular/material": "^20.2.14", - "@angular/platform-browser": "^20.3.17", - "@angular/platform-browser-dynamic": "^20.3.17", - "@angular/router": "^20.3.17", + "@angular/platform-browser": "^20.3.18", + "@angular/platform-browser-dynamic": "^20.3.18", + "@angular/router": "^20.3.18", "@iqb/mathlive": "^0.4.1", "@iqb/responses": "^5.0.0", "@iqbspecs/coding-scheme": "^3.3.1", @@ -88,13 +88,16 @@ "devDependencies": { "@angular-devkit/build-angular": "^20.3.19", "@angular-devkit/core": "^20.3.19", + "@schematics/angular": "20.3.19", "@angular/build": "^20.3.19", "@angular/cli": "^20.3.19", - "@angular/compiler-cli": "^20.3.17", + "@angular/compiler-cli": "^20.3.18", "@iqb/eslint-config": "^2.2.0", "@types/jasmine": "~4.3.0", "@types/node": "^20.4.4", - "eslint": "^8.57.0", + "cheerio": "^1.0.0", + "docx": "^8.5.0", + "eslint": "^8.57.1", "iqb-dev-components": "^1.4.1", "jasmine-core": "~4.6.0", "karma": "~6.4.0", diff --git a/projects/ngx-coding-components/package.json b/projects/ngx-coding-components/package.json index c13e6b7..34becb2 100644 --- a/projects/ngx-coding-components/package.json +++ b/projects/ngx-coding-components/package.json @@ -18,17 +18,17 @@ "homepage": "https://github.com/iqb-berlin/coding-components#readme", "peerDependencies": { "mathjs": "^12.4.2", - "@angular/animations": "^20.0.5", - "@angular/cdk": "^20.0.4", - "@angular/common": "^20.0.5", - "@angular/compiler": "^20.0.5", - "@angular/core": "^20.0.5", - "@angular/elements": "^20.0.5", - "@angular/forms": "^20.0.5", - "@angular/material": "^20.0.4", - "@angular/platform-browser": "^20.0.5", - "@angular/platform-browser-dynamic": "^20.0.5", - "@angular/router": "^20.0.5", + "@angular/animations": ">=20.0.0", + "@angular/cdk": ">=20.0.0", + "@angular/common": ">=20.0.0", + "@angular/compiler": ">=20.0.0", + "@angular/core": ">=20.0.0", + "@angular/elements": ">=20.0.0", + "@angular/forms": ">=20.0.0", + "@angular/material": ">=20.0.0", + "@angular/platform-browser": ">=20.0.0", + "@angular/platform-browser-dynamic": ">=20.0.0", + "@angular/router": ">=20.0.0", "@iqb/responses": "^5.0.0", "@iqbspecs/coding-scheme": "^3.3.1", "@iqbspecs/response": "^1.4.0", @@ -62,6 +62,8 @@ "ngx-build-plus": "^20.0.0", "prosemirror-state": "^1.3.4", "rxjs": "~7.8.0", + "docx": "^8.5.0", + "cheerio": "^1.0.0", "zone.js": "~0.15.0" }, "devDependencies": { diff --git a/projects/ngx-coding-components/src/lib/codebook-export/README.md b/projects/ngx-coding-components/src/lib/codebook-export/README.md new file mode 100644 index 0000000..7cef9a3 --- /dev/null +++ b/projects/ngx-coding-components/src/lib/codebook-export/README.md @@ -0,0 +1,357 @@ +# Codebook Export Component + +The codebook export component provides a comprehensive UI for generating and exporting codebooks from coding schemes. It supports both JSON and DOCX export formats with extensive configuration options. + +## Features + +- **Unit Selection**: Select specific units to include in the codebook +- **Content Filtering**: Configure which variables and codes to include +- **Missings Profiles**: Select from available missings profiles +- **Export Formats**: Export as JSON or DOCX +- **Search & Filter**: Quickly find units with built-in search +- **Background Jobs**: Optional async export with progress and downloads + +## Installation + +The component is part of `@iqb/ngx-coding-components`. Make sure you have the required peer dependencies installed: + +```bash +npm install docx cheerio @iqbspecs/coding-scheme @iqbspecs/variable-info +``` + +## Usage + +### Basic Example + +```typescript +import { Component } from '@angular/core'; +import { CodebookExportComponent, CodebookExportConfig, UnitSelectionItem, MissingsProfile } from '@iqb/ngx-coding-components'; + +@Component({ + selector: 'app-my-component', + template: ` + + + `, + standalone: true, + imports: [CodebookExportComponent] +}) +export class MyComponent { + units: UnitSelectionItem[] = [ + { unitId: 1, unitName: 'Unit 1.vocs', unitAlias: null }, + { unitId: 2, unitName: 'Unit 2.vocs', unitAlias: null } + ]; + + profiles: MissingsProfile[] = [ + { id: 0, label: 'None' }, + { id: 1, label: 'Standard Missings' } + ]; + + loading = false; + + handleExport(config: CodebookExportConfig) { + console.log('Export config:', config); + // Use CodebookGenerator to generate the codebook + // Then download the file + } + + handleCancel() { + console.log('Export cancelled'); + } +} +``` + +### Provider-Based Example (recommended) + +Use a provider so the component can load data, manage export jobs, and download files. + +```typescript +import { Injectable, Component } from '@angular/core'; +import { map, Observable } from 'rxjs'; +import { + CodebookExportComponent, + CodebookExportProvider, + CodebookExportExecution, + CodebookExportConfig, + UnitSelectionItem, + MissingsProfile, + CODEBOOK_EXPORT_PROVIDER +} from '@iqb/ngx-coding-components'; + +@Injectable() +export class MyCodebookProvider implements CodebookExportProvider { + loadUnits(): Observable { + return this.backend.loadUnits(); + } + + loadMissingsProfiles(): Observable { + return this.backend.loadMissingsProfiles(); + } + + startExport(config: CodebookExportConfig): Observable { + return this.backend.exportCodebook(config).pipe( + map(blob => ({ + type: 'direct', + blob, + fileName: `codebook_${Date.now()}.${config.contentOptions.exportFormat}` + })) + ); + } +} + +@Component({ + selector: 'app-my-component', + template: ``, + standalone: true, + imports: [CodebookExportComponent], + providers: [{ provide: CODEBOOK_EXPORT_PROVIDER, useClass: MyCodebookProvider }] +}) +export class MyComponent {} +``` + +### Provider-Based Example (job + polling) + +```typescript +class MyCodebookProvider implements CodebookExportProvider { + startExport(config: CodebookExportConfig): Observable { + return this.backend.startCodebookJob(config).pipe( + map(response => ({ type: 'job', jobId: response.jobId })) + ); + } + + getJobStatus(jobId: string): Observable { + return this.backend.getCodebookJobStatus(jobId); + } + + download(jobId: string): Observable { + return this.backend.downloadCodebook(jobId); + } +} +``` + +### With Dialog + +```typescript +import { MatDialog } from '@angular/material/dialog'; +import { CodebookExportComponent } from '@iqb/ngx-coding-components'; + +export class MyComponent { + constructor(private dialog: MatDialog) {} + + openCodebookExport() { + const dialogRef = this.dialog.open(CodebookExportComponent, { + width: '90vw', + maxWidth: '1200px', + height: '90vh', + data: { + availableUnits: this.units, + missingsProfiles: this.profiles + } + }); + + dialogRef.componentInstance.export.subscribe(config => { + this.generateCodebook(config); + dialogRef.close(); + }); + } +} +``` + +### Generating Codebooks + +Use the `CodebookGenerator` class to generate codebooks from the export configuration: + +```typescript +import { CodebookGenerator, CodebookExportConfig, UnitPropertiesForCodebook } from '@iqb/ngx-coding-components'; + +class MyComponent { + async generateCodebook(config: CodebookExportConfig) { + // Fetch unit data with schemes + const units: UnitPropertiesForCodebook[] = await this.fetchUnits(config.selectedUnits); + + // Fetch missings + const missings = await this.fetchMissings(config.missingsProfileId); + + // Generate codebook + const buffer = await CodebookGenerator.generateCodebook( + units, + config.contentOptions, + missings + ); + + // Download the file + this.downloadFile(buffer, config.contentOptions.exportFormat); + } + + private downloadFile(buffer: Buffer, format: string) { + const blob = new Blob([buffer], { + type: format === 'docx' + ? 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' + : 'application/json' + }); + + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `codebook_${Date.now()}.${format}`; + a.click(); + window.URL.revokeObjectURL(url); + } +} +``` + +## API + +### Component Inputs + +| Input | Type | Default | Description | +|-------|------|---------|-------------| +| `availableUnits` | `UnitSelectionItem[]` | `[]` | List of units available for selection | +| `missingsProfiles` | `MissingsProfile[]` | `[{ id: 0, label: 'None' }]` | Available missings profiles | +| `isLoading` | `boolean` | `false` | Loading state for units | +| `workspaceChanges` | `boolean` | `false` | Whether workspace has unsaved changes | +| `defaultContentOptions` | `Partial` | - | Default content options | +| `provider` | `CodebookExportProvider` | - | Optional provider for loading data and running exports | + +### Component Outputs + +| Output | Type | Description | +|--------|------|-------------| +| `export` | `EventEmitter` | Emitted when export is triggered and no provider is configured | +| `cancel` | `EventEmitter` | Emitted when component is cancelled | + +### Interfaces + +#### `CodebookExportConfig` + +```typescript +interface CodebookExportConfig { + selectedUnits: number[]; + contentOptions: CodeBookContentSetting; + missingsProfileId: number; +} +``` + +#### `CodebookExportProvider` + +```typescript +interface CodebookExportProvider { + loadUnits?(): Observable; + loadMissingsProfiles?(): Observable; + startExport(config: CodebookExportConfig): Observable; + getJobStatus?(jobId: string): Observable; + download?(jobId: string): Observable; +} +``` + +#### `CodebookExportExecution` + +```typescript +type CodebookExportExecution = + | { type: 'direct'; blob: Blob; fileName?: string; mimeType?: string } + | { type: 'job'; jobId: string }; +``` + +#### `CodebookExportJobStatus` + +```typescript +interface CodebookExportJobStatus { + status: 'pending' | 'processing' | 'completed' | 'failed'; + progress?: number; + error?: string; + fileName?: string; + exportFormat?: string; +} +``` + +#### `CodeBookContentSetting` + +```typescript +interface CodeBookContentSetting { + exportFormat: string; // 'json' or 'docx' + missingsProfile: string; + hasOnlyManualCoding: boolean; // Include only manual coding + hasGeneralInstructions: boolean; // Include general instructions + hasDerivedVars: boolean; // Include derived variables + hasOnlyVarsWithCodes: boolean; // Include only variables with codes + hasClosedVars: boolean; // Include closed variables + codeLabelToUpper: boolean; // Convert code labels to uppercase + showScore: boolean; // Show scores + hideItemVarRelation: boolean; // Hide item-variable relation +} +``` + +#### `UnitSelectionItem` + +```typescript +interface UnitSelectionItem { + unitId: number; + unitName: string; + unitAlias: string | null; +} +``` + +#### `MissingsProfile` + +```typescript +interface MissingsProfile { + id: number; + label: string; + missings?: Missing[] | string; +} +``` + +## Content Options + +The component provides extensive configuration for codebook content: + +- **Only Manual Coding**: Include only manually coded variables +- **General Instructions**: Include general coding instructions +- **Derived Variables**: Include derived (calculated) variables +- **Only Variables with Codes**: Exclude variables without code definitions +- **Closed Variables**: Include closed (auto-coded) variables +- **Code Labels to Upper**: Convert all code labels to uppercase +- **Show Score**: Display score values for codes +- **Hide Item-Variable Relation**: Hide the relationship between items and variables + +## Styling + +The component uses Angular Material theming. You can customize the appearance by overriding the component's CSS classes or by providing custom Material theme colors. + +## Translation + +The component uses `@ngx-translate/core` for internationalization. Make sure to provide translations for the following keys: + +- `workspace.export-coding-book` +- `coding.select-units` +- `coding.select-all-units` +- `search` +- `search-units` +- `loading-units` +- `coding.unit-name` +- `no-units-matching` +- `no-units-available` +- `coding.codebook-content` +- `coding.has-only-vars-with-codes` +- `coding.has-general-instructions` +- `coding.hide-item-var-relation` +- `coding.has-derived-vars` +- `coding.has-only-manual-coding` +- `coding.has-closed-vars` +- `coding.show-score` +- `coding.code-label-to-upper` +- `coding.codebook-generating` +- `coding.codebook-completed` +- `workspace.coding-missing-profiles` +- `workspace.select-missings-profile` +- `coding.export-format` +- `coding.error-save-changes` +- `export` +- `close` + +And their corresponding tooltips (prefix with `coding.tooltip.`). diff --git a/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.html b/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.html new file mode 100644 index 0000000..438990c --- /dev/null +++ b/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.html @@ -0,0 +1,247 @@ +
+

{{ 'workspace.export-coding-book' | translate }}

+ +
+ +
+
+

{{'coding.select-units' | translate}}

+ {{ unitList.length }} / {{ availableUnits.length }} +
+ +
+ + {{'coding.select-all-units' | translate}} + +
+ + +
+ + {{'search' | translate}} + + search + @if (filterValue) { + + } + +
+ + + @if (loading) { +
+ +

{{'loading-units' | translate}}

+
+ } + + + @if (!loading) { +
+ + + + + + + + + + + + + + + + + + + + +
+ + + {{'coding.unit-name' | translate}}{{formatUnitName(unit.unitName)}}
+ @if (filterValue) { + {{'no-units-matching' | translate}} "{{filterValue}}" + } @else { + {{'no-units-available' | translate}} + } +
+
+ } +
+ + +
+ +
+

{{'coding.codebook-content' | translate}}

+
+ + {{'coding.has-only-vars-with-codes' | translate}} + + + + {{'coding.has-general-instructions' | translate}} + + + + {{'coding.hide-item-var-relation' | translate}} + + + + {{'coding.has-derived-vars' | translate}} + + + + {{'coding.has-only-manual-coding' | translate}} + + + + {{'coding.has-closed-vars' | translate}} + + + + {{'coding.show-score' | translate}} + + + + {{'coding.code-label-to-upper' | translate}} + +
+
+ + + + +
+

{{ 'workspace.coding-missing-profiles' | translate }}

+ + {{'workspace.select-missings-profile' | translate }} + + @for (missingsProfile of missingsProfiles; track missingsProfile) { + + @if (missingsProfile.id === 0) { + {{ 'Keines' }} + } @else { + {{missingsProfile.label}} + } + + } + + +
+ + + + +
+

{{'coding.export-format' | translate}}

+ + JSON + DOCX + +
+ + @if(workspaceChanges) { + +
+ {{'coding.error-save-changes' | translate}} +
+ } +
+
+
+ + + @if (codebookJobStatus !== 'idle') { +
+ @if (codebookJobStatus === 'pending' || codebookJobStatus === 'processing') { +
+
+ hourglass_empty + + {{ 'coding.codebook-generating' | translate }} + + {{ codebookJobProgress }}% +
+ + +
+ } + @if (codebookJobStatus === 'completed') { +
+ check_circle + {{ 'coding.codebook-completed' | translate }} +
+ } + @if (codebookJobStatus === 'failed') { +
+ error + {{ codebookJobError }} + +
+ } +
+ } + + + + + +
diff --git a/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.scss b/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.scss new file mode 100644 index 0000000..7544944 --- /dev/null +++ b/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.scss @@ -0,0 +1,286 @@ +// Main container styles +.export-codebook-container { + display: flex; + flex-direction: column; + height: 100%; +} + +// Dialog content styles +.dialog-content { + min-height: 500px; + padding: 0; + overflow: hidden; +} + +// Layout for the export panels +.export-layout { + display: flex; + flex-direction: row; + gap: 20px; + height: 100%; + padding: 16px; + + @media (max-width: 960px) { + flex-direction: column; + } +} + +// Shared panel styles +.unit-selection-panel, +.settings-panel { + border-radius: 8px; + padding: 16px; + background-color: white; +} + +// Unit selection panel styles +.unit-selection-panel { + flex: 1; + display: flex; + flex-direction: column; + max-height: 600px; + min-width: 300px; + + @media (max-width: 960px) { + max-height: 400px; + } + + @media (max-height: 768px) { + max-height: 450px; + } + + @media (max-height: 600px) { + max-height: 350px; + } +} + +// Panel header with title and count +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + + h3 { + margin: 0; + font-weight: 500; + } + + .selection-count { + background-color: #f0f0f0; + padding: 4px 8px; + border-radius: 16px; + font-size: 14px; + color: rgba(0, 0, 0, 0.7); + } +} + +// Select all container +.select-all-container { + margin-bottom: 16px; +} + +// Search container +.search-container { + margin-bottom: 16px; + + .search-field { + width: 100%; + } +} + +// Loading container +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + + .loading-text { + margin-top: 16px; + color: rgba(0, 0, 0, 0.6); + } +} + +// Units table container +.units-table-container { + overflow-y: auto; // Enable vertical scrolling + flex: 1; + border: 1px solid #e0e0e0; + border-radius: 4px; + display: flex; + flex-direction: column; + max-height: 500px; // Fixed height to enable scrolling when content exceeds this height + + // Ensure smooth scrolling on touch devices + -webkit-overflow-scrolling: touch; + + // Add some bottom padding to ensure last row is fully visible when scrolled to bottom + padding-bottom: 4px; +} + +// Units table styles +.units-table { + width: 100%; + // Ensure table takes up available space in the container + flex: 1; + overflow: auto; + + .mat-mdc-header-cell { + background-color: #f5f5f5; + font-weight: 500; + padding: 12px 16px; + position: sticky; + top: 0; + z-index: 1; + } + + .mat-mdc-cell { + padding: 12px 16px; + height: 48px; // Match row height + vertical-align: middle; // Center content vertically + box-sizing: border-box; // Include padding in height calculation + } + + .mat-mdc-row { + height: 48px; // Fixed height for all rows + min-height: 48px; // Ensure minimum height + + &.selected { + background-color: rgba(33, 150, 243, 0.08); + } + + &:hover { + background-color: #f9f9f9; + } + } + + .mat-column-select { + width: 60px; + text-align: center; + } + + // No data row + .mat-mdc-no-data-row { + height: 48px; // Match regular row height + min-height: 48px; + + .mat-mdc-cell { + text-align: center; + padding: 12px 16px; // Match regular cell padding + height: 48px; // Match regular cell height + color: rgba(0, 0, 0, 0.6); + vertical-align: middle; + } + } +} + +// Settings panel styles +.settings-panel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 300px; + max-width: 500px; + overflow-y: auto; + + @media (max-width: 960px) { + max-width: none; + } +} + +// Settings section styles +.settings-section { + padding: 16px 0; + + h3 { + margin-top: 0; + margin-bottom: 16px; + font-weight: 500; + } + + &:first-child { + padding-top: 0; + } + + &:last-child { + padding-bottom: 0; + } +} + +// Warning section styles +.warning-section { + padding: 16px; + background-color: rgba(244, 67, 54, 0.08); + border-radius: 4px; +} + +// Options grid for checkboxes +.options-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 12px; + + mat-checkbox { + margin-bottom: 8px; + } +} + +// Full width form field +.full-width { + width: 100%; +} + +// Export format radio group +.export-format-group { + display: flex; + flex-direction: row; + gap: 16px; +} + +// Codebook generation progress section +.codebook-progress-section { + padding: 12px 24px; + border-top: 1px solid #e0e0e0; +} + +.progress-container { + display: flex; + flex-direction: column; + gap: 8px; + + &.completed, + &.failed { + flex-direction: row; + align-items: center; + gap: 8px; + } +} + +.progress-header { + display: flex; + align-items: center; + gap: 8px; +} + +.progress-label { + font-size: 14px; + color: rgba(0, 0, 0, 0.7); +} + +.progress-percentage { + margin-left: auto; + font-size: 14px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.spinning { + animation: spin 1.5s linear infinite; +} diff --git a/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.spec.ts b/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.spec.ts new file mode 100644 index 0000000..3b5a70e --- /dev/null +++ b/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.spec.ts @@ -0,0 +1,155 @@ +import { + ComponentFixture, TestBed, fakeAsync, tick +} from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { CodebookExportComponent } from './codebook-export.component'; +import { + CodebookExportExecution, + CodebookExportJobStatus, + CodebookExportProvider +} from './codebook-export.provider'; +import { CodebookExportConfig, UnitSelectionItem, MissingsProfile } from '../models/codebook.interfaces'; + +describe('CodebookExportComponent', () => { + let fixture: ComponentFixture; + let component: CodebookExportComponent; + let lastAnchor: HTMLAnchorElement | null; + + function setupDownloadSpies() { + const urlApi = window.URL as typeof window.URL & { + createObjectURL?: (blob: Blob) => string; + revokeObjectURL?: (url: string) => void; + }; + if (!urlApi.createObjectURL) { + urlApi.createObjectURL = () => 'blob:mock'; + } + if (!urlApi.revokeObjectURL) { + urlApi.revokeObjectURL = () => undefined; + } + spyOn(urlApi, 'createObjectURL').and.returnValue('blob:mock'); + spyOn(urlApi, 'revokeObjectURL').and.callThrough(); + + const realCreateElement = document.createElement.bind(document); + lastAnchor = null; + spyOn(document, 'createElement').and.callFake((tagName: string) => { + const element = realCreateElement(tagName); + if (tagName === 'a') { + lastAnchor = element as HTMLAnchorElement; + spyOn(lastAnchor, 'click'); + } + return element; + }); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CodebookExportComponent, + NoopAnimationsModule, + TranslateModule.forRoot({ + loader: { provide: TranslateLoader, useClass: TranslateFakeLoader } + }) + ] + }).compileComponents(); + + fixture = TestBed.createComponent(CodebookExportComponent); + component = fixture.componentInstance; + setupDownloadSpies(); + }); + + it('emits export config when no provider is set', () => { + component.availableUnits = [{ unitId: 1, unitName: 'Unit 1.vocs', unitAlias: null }]; + component.unitList = [1]; + const emitSpy = spyOn(component.export, 'emit'); + + fixture.detectChanges(); + + component.exportCodingBook(); + + expect(emitSpy).toHaveBeenCalled(); + const config = emitSpy.calls.mostRecent().args[0] as CodebookExportConfig | undefined; + expect(config).toBeTruthy(); + if (!config) { + throw new Error('Expected export config to be emitted.'); + } + expect(config.selectedUnits).toEqual([1]); + expect(config.missingsProfileId).toBe(0); + expect(config.contentOptions.exportFormat).toBe('docx'); + }); + + it('runs direct export via provider and downloads the file', () => { + const blob = new Blob(['hello'], { type: 'text/plain' }); + const provider: CodebookExportProvider = { + startExport: jasmine.createSpy('startExport').and.returnValue(of({ + type: 'direct', + blob, + fileName: 'codebook.txt' + } as CodebookExportExecution)) + }; + + component.provider = provider; + component.unitList = [1]; + fixture.detectChanges(); + + component.exportCodingBook(); + + expect(provider.startExport).toHaveBeenCalled(); + expect(component.codebookJobStatus).toBe('completed'); + expect(lastAnchor?.download).toBe('codebook.txt'); + expect((lastAnchor as HTMLAnchorElement).click).toHaveBeenCalled(); + }); + + it('polls job status and downloads when completed', fakeAsync(() => { + const blob = new Blob(['job'], { type: 'application/octet-stream' }); + const provider: CodebookExportProvider = { + startExport: jasmine.createSpy('startExport').and.returnValue(of({ + type: 'job', + jobId: 'job-1' + } as CodebookExportExecution)), + getJobStatus: jasmine.createSpy('getJobStatus').and.returnValue(of({ + status: 'completed', + progress: 100, + fileName: 'job.docx' + } as CodebookExportJobStatus)), + download: jasmine.createSpy('download').and.returnValue(of(blob)) + }; + + component.provider = provider; + component.unitList = [1]; + fixture.detectChanges(); + + component.exportCodingBook(); + expect(component.codebookJobStatus).toBe('pending'); + + tick(1500); + + expect(provider.getJobStatus).toHaveBeenCalledWith('job-1'); + expect(provider.download).toHaveBeenCalledWith('job-1'); + expect(component.codebookJobStatus).toBe('completed'); + })); + + it('loads units and missings profiles from provider when none are provided', fakeAsync(() => { + const units: UnitSelectionItem[] = [{ unitId: 7, unitName: 'Unit 7.vocs', unitAlias: null }]; + const missings: MissingsProfile[] = [{ id: 1, label: 'Standard' }]; + const provider: CodebookExportProvider = { + loadUnits: jasmine.createSpy('loadUnits').and.returnValue(of(units)), + loadMissingsProfiles: jasmine.createSpy('loadMissingsProfiles').and.returnValue(of(missings)), + startExport: jasmine.createSpy('startExport').and.returnValue(of({ + type: 'direct', + blob: new Blob(['x']) + } as CodebookExportExecution)) + }; + + component.provider = provider; + fixture.detectChanges(); + tick(); + + expect(provider.loadUnits).toHaveBeenCalled(); + expect(provider.loadMissingsProfiles).toHaveBeenCalled(); + expect(component.availableUnits.length).toBe(1); + expect(component.missingsProfiles.some(profile => profile.id === 0)).toBeTrue(); + })); +}); diff --git a/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.ts b/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.ts new file mode 100644 index 0000000..b25e9aa --- /dev/null +++ b/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.ts @@ -0,0 +1,531 @@ +import { + Component, + OnInit, + OnDestroy, + OnChanges, + Input, + Output, + EventEmitter, + SimpleChanges, + Inject, + Optional +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatOptionModule } from '@angular/material/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { TranslateModule } from '@ngx-translate/core'; +import { + Subject, + Subscription, + interval +} from 'rxjs'; +import { + debounceTime, + distinctUntilChanged, + switchMap, + takeUntil +} from 'rxjs/operators'; +import { + MatDialogActions, MatDialogClose, MatDialogContent, MatDialogTitle +} from '@angular/material/dialog'; +import { + CodeBookContentSetting, + UnitSelectionItem, + MissingsProfile, + CodebookExportConfig +} from '../models/codebook.interfaces'; +import { + CODEBOOK_EXPORT_PROVIDER, + CodebookExportExecution, + CodebookExportJobStatus, + CodebookExportProvider +} from './codebook-export.provider'; + +/** + * Standalone component for exporting codebooks + * + * This component provides a UI for: + * - Selecting units to include in the codebook + * - Configuring content options (manual coding, derived vars, etc.) + * - Selecting a missings profile + * - Choosing export format (JSON or DOCX) + * - Running exports via an optional provider (direct download or background job) + * + * @example + * ```html + * + * + * ``` + */ +@Component({ + selector: 'ngx-codebook-export', + templateUrl: './codebook-export.component.html', + styleUrls: ['./codebook-export.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatCheckboxModule, + MatRadioModule, + MatSelectModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatOptionModule, + MatIconModule, + MatTooltipModule, + MatDividerModule, + MatTableModule, + MatProgressSpinnerModule, + MatProgressBarModule, + TranslateModule, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatDialogTitle + ] +}) +export class CodebookExportComponent implements OnInit, OnDestroy, OnChanges { + /** List of available units for selection */ + @Input() availableUnits: UnitSelectionItem[] = []; + + /** List of available missings profiles */ + @Input() missingsProfiles: MissingsProfile[] = [{ id: 0, label: 'None' }]; + + /** Loading state for units */ + @Input() isLoading = false; + + /** Whether workspace has unsaved changes */ + @Input() workspaceChanges = false; + + /** Default content options */ + @Input() defaultContentOptions?: Partial; + + /** Optional provider for loading data and running exports */ + @Input() provider?: CodebookExportProvider; + + /** Emitted when export is triggered */ + @Output() export = new EventEmitter(); + + /** Emitted when component is closed/cancelled */ + @Output() cancel = new EventEmitter(); + + unitList: number[] = []; + + dataSource: MatTableDataSource = new MatTableDataSource([]); + + filterValue = ''; + filterTextChanged = new Subject(); + + private isLoadingInternal = false; + + selectedMissingsProfile: number = 0; + + displayedColumns: string[] = ['select', 'unitName']; + + contentOptions: CodeBookContentSetting = { + exportFormat: 'docx', + missingsProfile: '', + hasOnlyManualCoding: true, + hasGeneralInstructions: true, + hasDerivedVars: true, + hasOnlyVarsWithCodes: true, + hasClosedVars: true, + codeLabelToUpper: true, + showScore: true, + hideItemVarRelation: true + }; + + codebookJobId: string | null = null; + codebookJobStatus: 'idle' | 'pending' | 'processing' | 'completed' | 'failed' = 'idle'; + codebookJobProgress = 0; + codebookJobError: string | null = null; + private codebookPollingSubscription: Subscription | null = null; + private lastExportConfig: CodebookExportConfig | null = null; + private lastExportFileName: string | null = null; + private lastExportFormat: string | null = null; + + private destroy$ = new Subject(); + + constructor( + @Optional() @Inject(CODEBOOK_EXPORT_PROVIDER) private injectedProvider?: CodebookExportProvider + ) {} + + private get activeProvider(): CodebookExportProvider | undefined { + return this.provider || this.injectedProvider; + } + + get loading(): boolean { + return this.isLoading || this.isLoadingInternal; + } + + get exportDisabled(): boolean { + return ( + this.unitList.length === 0 || + this.codebookJobStatus === 'pending' || + this.codebookJobStatus === 'processing' + ); + } + + ngOnInit(): void { + // Apply default content options if provided + if (this.defaultContentOptions) { + this.contentOptions = { ...this.contentOptions, ...this.defaultContentOptions }; + } + + this.configureDataSource(); + + // Set up filter debouncing + this.filterTextChanged + .pipe( + debounceTime(300), + distinctUntilChanged(), + takeUntil(this.destroy$) + ) + .subscribe(event => { + this.applyFilter(event); + }); + + this.loadUnitsFromProviderIfNeeded(); + this.loadMissingsProfilesFromProviderIfNeeded(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['availableUnits']) { + this.configureDataSource(); + this.syncUnitSelection(); + } + if (changes['missingsProfiles']) { + this.ensureSelectedMissingsProfile(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.stopCodebookPolling(); + } + + applyFilter(event: Event): void { + const filterValue = (event.target as HTMLInputElement).value; + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + private configureDataSource(): void { + this.dataSource.data = this.availableUnits; + this.dataSource.filterPredicate = (data, filter: string) => { + const formattedName = this.formatUnitName(data.unitName).toLowerCase(); + return formattedName.includes(filter); + }; + } + + private syncUnitSelection(): void { + if (this.unitList.length === 0) return; + const availableIds = new Set(this.availableUnits.map(unit => unit.unitId)); + this.unitList = this.unitList.filter(id => availableIds.has(id)); + } + + private ensureSelectedMissingsProfile(): void { + if (!this.missingsProfiles || this.missingsProfiles.length === 0) { + this.missingsProfiles = [{ id: 0, label: 'None' }]; + this.selectedMissingsProfile = 0; + return; + } + if (!this.missingsProfiles.some(profile => profile.id === this.selectedMissingsProfile)) { + this.selectedMissingsProfile = this.missingsProfiles[0]?.id ?? 0; + } + } + + private ensureNoneProfile(profiles: MissingsProfile[]): MissingsProfile[] { + const safeProfiles = Array.isArray(profiles) ? profiles : []; + if (safeProfiles.some(profile => profile.id === 0)) return safeProfiles; + const noneLabel = this.missingsProfiles.find(profile => profile.id === 0)?.label ?? 'None'; + return [{ id: 0, label: noneLabel }, ...safeProfiles]; + } + + private loadUnitsFromProviderIfNeeded(): void { + const provider = this.activeProvider; + if (!provider?.loadUnits) return; + if (this.availableUnits.length > 0) return; + this.isLoadingInternal = true; + provider.loadUnits() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: units => { + this.availableUnits = units || []; + this.configureDataSource(); + this.syncUnitSelection(); + this.isLoadingInternal = false; + }, + error: () => { + this.isLoadingInternal = false; + } + }); + } + + private loadMissingsProfilesFromProviderIfNeeded(): void { + const provider = this.activeProvider; + if (!provider?.loadMissingsProfiles) return; + if (this.missingsProfiles.length > 1 || (this.missingsProfiles.length === 1 && this.missingsProfiles[0].id !== 0)) { + return; + } + provider.loadMissingsProfiles() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: profiles => { + this.missingsProfiles = this.ensureNoneProfile(profiles || []); + this.ensureSelectedMissingsProfile(); + }, + error: () => { + // Keep current profiles + } + }); + } + + toggleUnitSelection(unitId: number, isSelected: boolean): void { + if (isSelected) { + if (!this.unitList.includes(unitId)) { + this.unitList.push(unitId); + } + } else { + this.unitList = this.unitList.filter(id => id !== unitId); + } + } + + isUnitSelected(unitId: number): boolean { + return this.unitList.includes(unitId); + } + + // Used by the template. + // eslint-disable-next-line class-methods-use-this + formatUnitName(unitName: string): string { + if (unitName && unitName.toLowerCase().endsWith('.vocs')) { + return unitName.substring(0, unitName.length - 5); + } + return unitName; + } + + toggleAllUnits(isSelected: boolean): void { + if (isSelected) { + this.unitList = this.availableUnits.map(unit => unit.unitId); + } else { + this.unitList = []; + } + } + + exportCodingBook(): void { + if (this.unitList.length === 0) { + return; + } + + this.contentOptions.missingsProfile = this.selectedMissingsProfile.toString(); + + const config: CodebookExportConfig = { + selectedUnits: this.unitList, + contentOptions: this.contentOptions, + missingsProfileId: this.selectedMissingsProfile + }; + + const provider = this.activeProvider; + if (!provider?.startExport) { + this.export.emit(config); + return; + } + + this.startProviderExport(provider, config); + } + + onCancel(): void { + this.stopCodebookPolling(); + this.cancel.emit(); + } + + resetCodebookJob(): void { + this.codebookJobId = null; + this.codebookJobStatus = 'idle'; + this.codebookJobProgress = 0; + this.codebookJobError = null; + this.stopCodebookPolling(); + } + + private startProviderExport(provider: CodebookExportProvider, config: CodebookExportConfig): void { + this.stopCodebookPolling(); + this.codebookJobStatus = 'pending'; + this.codebookJobProgress = 0; + this.codebookJobError = null; + this.codebookJobId = null; + this.lastExportConfig = config; + this.lastExportFileName = null; + this.lastExportFormat = config.contentOptions.exportFormat; + + provider.startExport(config) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (execution: CodebookExportExecution) => { + if (execution.type === 'direct') { + this.codebookJobStatus = 'processing'; + this.codebookJobProgress = 100; + const fileName = this.resolveFileName(execution.fileName); + const blob = execution.mimeType ? new Blob([execution.blob], { type: execution.mimeType }) : execution.blob; + try { + CodebookExportComponent.downloadBlob(blob, fileName); + this.codebookJobStatus = 'completed'; + } catch { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Failed to download codebook file'; + } + return; + } + + if (!execution.jobId) { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Failed to start codebook generation job'; + return; + } + + this.codebookJobId = execution.jobId; + this.startCodebookPolling(execution.jobId); + }, + error: () => { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Failed to start codebook generation job'; + } + }); + } + + private startCodebookPolling(jobId: string): void { + const provider = this.activeProvider; + if (!provider?.getJobStatus) { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Job status handler is not available'; + return; + } + + this.stopCodebookPolling(); + + this.codebookPollingSubscription = interval(1500) + .pipe( + takeUntil(this.destroy$), + switchMap(() => provider.getJobStatus!(jobId)) + ) + .subscribe({ + next: (status: CodebookExportJobStatus) => { + if (!status.status && status.error) { + this.codebookJobStatus = 'failed'; + this.codebookJobError = status.error; + this.stopCodebookPolling(); + return; + } + + this.codebookJobProgress = status.progress ?? 0; + if (status.fileName) { + this.lastExportFileName = status.fileName; + } + if (status.exportFormat) { + this.lastExportFormat = status.exportFormat; + } + + if (status.status === 'completed') { + this.codebookJobStatus = 'completed'; + this.stopCodebookPolling(); + this.downloadCodebookResult(jobId); + } else if (status.status === 'failed') { + this.codebookJobStatus = 'failed'; + this.codebookJobError = status.error || 'Codebook generation failed'; + this.stopCodebookPolling(); + } else if (status.status === 'processing') { + this.codebookJobStatus = 'processing'; + } else { + this.codebookJobStatus = 'pending'; + } + }, + error: () => { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Failed to get job status'; + this.stopCodebookPolling(); + } + }); + } + + private stopCodebookPolling(): void { + if (this.codebookPollingSubscription) { + this.codebookPollingSubscription.unsubscribe(); + this.codebookPollingSubscription = null; + } + } + + private downloadCodebookResult(jobId: string): void { + const provider = this.activeProvider; + if (!provider?.download) { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Download handler is not available'; + return; + } + + provider.download(jobId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: blob => { + const fileName = this.resolveFileName(); + try { + CodebookExportComponent.downloadBlob(blob, fileName); + } catch { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Failed to download codebook file'; + } + }, + error: () => { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Failed to download codebook file'; + } + }); + } + + private resolveFileName(preferredName?: string): string { + if (preferredName) return preferredName; + if (this.lastExportFileName) return this.lastExportFileName; + const format = this.lastExportFormat || + this.lastExportConfig?.contentOptions.exportFormat || + this.contentOptions.exportFormat; + return CodebookExportComponent.buildDefaultFileName(format); + } + + private static buildDefaultFileName(format?: string): string { + const extension = (format || 'docx').toLowerCase(); + const now = new Date(); + const year = now.getFullYear(); + const month = `${now.getMonth() + 1}`.padStart(2, '0'); + const day = `${now.getDate()}`.padStart(2, '0'); + const hours = `${now.getHours()}`.padStart(2, '0'); + const minutes = `${now.getMinutes()}`.padStart(2, '0'); + const seconds = `${now.getSeconds()}`.padStart(2, '0'); + return `codebook_${year}${month}${day}_${hours}${minutes}${seconds}.${extension}`; + } + + private static downloadBlob(blob: Blob, fileName: string): void { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } +} diff --git a/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.provider.ts b/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.provider.ts new file mode 100644 index 0000000..72eb85d --- /dev/null +++ b/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.provider.ts @@ -0,0 +1,37 @@ +import { InjectionToken } from '@angular/core'; +import { Observable } from 'rxjs'; +import { + CodebookExportConfig, + MissingsProfile, + UnitSelectionItem +} from '../models/codebook.interfaces'; + +export type CodebookExportExecution = + | { + type: 'direct'; + blob: Blob; + fileName?: string; + mimeType?: string; + } + | { + type: 'job'; + jobId: string; + }; + +export interface CodebookExportJobStatus { + status: 'pending' | 'processing' | 'completed' | 'failed'; + progress?: number; + error?: string; + fileName?: string; + exportFormat?: string; +} + +export interface CodebookExportProvider { + loadUnits?(): Observable; + loadMissingsProfiles?(): Observable; + startExport(config: CodebookExportConfig): Observable; + getJobStatus?(jobId: string): Observable; + download?(jobId: string): Observable; +} + +export const CODEBOOK_EXPORT_PROVIDER = new InjectionToken('CODEBOOK_EXPORT_PROVIDER'); diff --git a/projects/ngx-coding-components/src/lib/codebook-generator/codebook-docx-generator.class.ts b/projects/ngx-coding-components/src/lib/codebook-generator/codebook-docx-generator.class.ts new file mode 100644 index 0000000..f7f602b --- /dev/null +++ b/projects/ngx-coding-components/src/lib/codebook-generator/codebook-docx-generator.class.ts @@ -0,0 +1,646 @@ +import { + AlignmentType, + Document, + HeadingLevel, + Packer, + Paragraph, + Table, + TableCell, + TableRow, + TextRun, + Footer, + WidthType, + PageNumber, + ITableCellBorders, + Header +} from 'docx'; +import * as cheerio from 'cheerio'; +import type { AnyNode, Element } from 'domhandler'; +import { + BookVariable, CodeBookContentSetting, CodebookUnitDto, ItemMetadata +} from '../models/codebook.interfaces'; + +/** + * Class for generating DOCX files for codebooks + */ +export class CodebookDocxGenerator { + /** + * Generate a DOCX file for a codebook + * @param codingBookUnits List of codebook units + * @param contentSetting Codebook content settings + * @returns Buffer with DOCX file + */ + static async generateDocx( + codingBookUnits: CodebookUnitDto[], + contentSetting: CodeBookContentSetting + ): Promise { + if (codingBookUnits.length) { + const units: (Paragraph | Table)[] = []; + let missings: Paragraph[] = []; + codingBookUnits.forEach(variableCoding => { + missings = this.getMissings(variableCoding); + if (variableCoding.variables.length || !contentSetting.hasOnlyVarsWithCodes) { + units.push(...(this.createDocXForUnit( + variableCoding.items || [], + variableCoding.variables, + contentSetting, + this.getUnitHeader(variableCoding) + ) as (Paragraph | Table)[])); + } + }); + const b64string = await Packer.toBase64String( + this.setDocXDocument( + units, + missings) + ); + return Buffer.from(b64string, 'base64'); + } + return Buffer.from('', 'utf-8'); + } + + /** + * Get unit header + * @param variableCoding Codebook unit + * @returns Paragraph with unit header + */ + private static getUnitHeader(variableCoding: CodebookUnitDto): Paragraph { + return new Paragraph({ + border: { + bottom: { + color: '#000000', + style: 'single', + size: 10 + }, + top: { + color: '#000000', + style: 'single', + size: 10 + } + }, + spacing: { + before: 400, + after: 200 + }, + text: variableCoding.name, + heading: HeadingLevel.HEADING_1, + alignment: AlignmentType.CENTER + }); + } + + /** + * Get missings paragraphs + * @param variableCoding Codebook unit + * @returns List of paragraphs with missings + */ + private static getMissings(variableCoding: CodebookUnitDto): Paragraph[] { + const missings: Paragraph[] = []; + try { + variableCoding.missings.forEach(missing => { + if (missing.code && missing.label && missing.description) { + missings.push(new Paragraph({ + children: [new TextRun({ text: `${missing.code} ${missing.label}`, bold: true })], + spacing: { + after: 20 + } + })); + missings.push(new Paragraph({ + text: `${missing.description}`, + spacing: { + after: 100 + } + })); + } else { + missings.push(new Paragraph({ + text: 'kein valides Missing ', + spacing: { + after: 200 + } + })); + } + }); + } catch { + missings.push(new Paragraph({ + text: 'kein validen Missings gefunden', + spacing: { + after: 200 + } + })); + } + return missings; + } + + /** + * Get table borders + * @returns Table cell borders + */ + private static get TableBoarders(): ITableCellBorders { + return { + top: { + size: 1, + color: '#000000', + style: 'single' + }, + bottom: { + size: 1, + color: '#000000', + style: 'single' + }, + left: { + size: 1, + color: '#000000', + style: 'single' + }, + right: { + size: 1, + color: '#000000', + style: 'single' + } + }; + } + + /** + * Get code rows for a table + * @param variable Book variable + * @param contentSetting Codebook content settings + * @returns List of table rows + */ + private static getCodeRows(variable: BookVariable, contentSetting: CodeBookContentSetting): TableRow[] { + const rows: TableRow[] = []; + const headerRow = new TableRow({ + tableHeader: true, + children: [ + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[0], + type: WidthType.DXA + }, + children: [new Paragraph({ + children: [ + new TextRun({ + text: 'Code', + bold: true + }) + ] + })] + }), + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[1], + type: WidthType.DXA + }, + children: [new Paragraph({ + children: [ + new TextRun({ + text: 'Label', + bold: true + }) + ] + })] + }), + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[2], + type: WidthType.DXA + }, + children: [new Paragraph({ + children: [ + new TextRun({ + text: 'Beschreibung', + bold: true + }) + ] + })] + }) + ] + }); + rows.push(headerRow); + if (contentSetting.showScore) { + headerRow.addChildElement( + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[3], + type: WidthType.DXA + }, + children: [new Paragraph({ + children: [ + new TextRun({ + text: 'Score', + bold: true + }) + ] + })] + }) + ); + } + variable.codes.forEach(code => { + const row = new TableRow({ + children: [ + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[0], + type: WidthType.DXA + }, + children: [new Paragraph(code.id)] + }), + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[1], + type: WidthType.DXA + }, + children: [new Paragraph(code.label)] + }), + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[2], + type: WidthType.DXA + }, + children: this.htmlToDocx(code.description, contentSetting) + }) + ] + }); + if (contentSetting.showScore) { + row.addChildElement( + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[3], + type: WidthType.DXA + }, + children: [new Paragraph(code.score || '')] + }) + ); + } + rows.push(row); + }); + return rows; + } + + /** + * Get column widths for a table + * @param contentSetting Codebook content settings + * @returns List of column widths + */ + private static getColumnWidths(contentSetting: CodeBookContentSetting): number[] { + return contentSetting.showScore ? [1000, 2000, 5000, 1000] : [1000, 2000, 6000]; + } + + /** + * Get variables for a unit + * @param codeBookVariable List of book variables + * @param contentSetting Codebook content settings + * @param varItems List of item metadata + * @returns List of file children + */ + private static getVariables( + codeBookVariable: BookVariable[], + contentSetting: CodeBookContentSetting, + varItems: ItemMetadata[] + ): (Paragraph | Table)[] { + const children: (Paragraph | Table)[] = []; + codeBookVariable.forEach(variable => { + children.push(this.getVariableHeader(variable)); + if (!contentSetting.hideItemVarRelation) { + children.push(...this.getVariableItems(variable, varItems)); + } + if (variable.generalInstruction) { + children.push(...this.getGeneralInstruction(contentSetting, variable)); + } + if (variable.codes.length) { + children.push(this.getCodeTable(variable, contentSetting)); + } + }); + return children; + } + + /** + * Get variable header + * @param variable Book variable + * @returns Paragraph with variable header + */ + private static getVariableHeader(variable: BookVariable): Paragraph { + return new Paragraph({ + text: variable.label, + heading: HeadingLevel.HEADING_2, + spacing: { + before: 400, + after: 200 + } + }); + } + + /** + * Get variable items + * @param variable Book variable + * @param varItems List of item metadata + * @returns List of paragraphs with variable items + */ + private static getVariableItems(variable: BookVariable, varItems: ItemMetadata[]): Paragraph[] { + const paragraphs: Paragraph[] = []; + const items = varItems.filter(item => { + const variableId = variable.id.replace(/\./g, '_'); + return item[variableId] !== undefined; + }); + if (items.length) { + paragraphs.push(new Paragraph({ + text: 'Items:', + spacing: { + after: 100 + } + })); + items.forEach(item => { + paragraphs.push(new Paragraph({ + text: `${item['key']} ${item['label']}`, + bullet: { + level: 0 + } + })); + }); + } + return paragraphs; + } + + /** + * Get general instruction + * @param contentSetting Codebook content settings + * @param codeBookVariable Book variable + * @returns List of paragraphs with general instruction + */ + private static getGeneralInstruction( + contentSetting: CodeBookContentSetting, + codeBookVariable: BookVariable + ): Paragraph[] { + return codeBookVariable.generalInstruction ? + this.htmlToDocx(codeBookVariable.generalInstruction, contentSetting) : []; + } + + /** + * Get code table + * @param codeBookVariable Book variable + * @param contentSetting Codebook content settings + * @returns Table with codes + */ + private static getCodeTable(codeBookVariable: BookVariable, contentSetting: CodeBookContentSetting): Table { + return new Table({ + rows: this.getCodeRows(codeBookVariable, contentSetting), + width: { + size: 9000, + type: WidthType.DXA + } + }); + } + + /** + * Create DOCX for a unit + * @param items List of item metadata + * @param codeBookVariable List of book variables + * @param contentSetting Codebook content settings + * @param unitHeader Paragraph with unit header + * @returns List of file children + */ + private static createDocXForUnit( + items: ItemMetadata[], + codeBookVariable: BookVariable[], + contentSetting: CodeBookContentSetting, + unitHeader: Paragraph + ): (Paragraph | Table)[] { + return [ + unitHeader, + ...this.getVariables(codeBookVariable, contentSetting, items) + ]; + } + + /** + * Set DOCX document + * @param children List of file children + * @param missings List of paragraphs with missings + * @returns Document + */ + private static setDocXDocument(children: (Paragraph | Table)[], missings: Paragraph[]): Document { + return new Document({ + creator: 'IQB-Kodierbox', + title: 'Codebook', + description: 'Codebook', + styles: { + paragraphStyles: [ + { + id: 'Normal', + name: 'Normal', + basedOn: 'Normal', + next: 'Normal', + quickFormat: true, + run: { + size: 24, + font: 'Calibri' + }, + paragraph: { + spacing: { + after: 120 + } + } + } + ] + }, + sections: [ + { + properties: { + page: { + margin: { + top: 1000, + right: 1000, + bottom: 1000, + left: 1000 + } + } + }, + headers: { + default: new Header({ + children: [ + new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [ + new TextRun('IQB-Kodierbox Codebook '), + new TextRun({ + children: [PageNumber.CURRENT], + font: 'Calibri' + }), + new TextRun({ + children: [' / '], + font: 'Calibri' + }), + new TextRun({ + children: [PageNumber.TOTAL_PAGES], + font: 'Calibri' + }) + ] + }) + ] + }) + }, + + footers: { + default: new Footer({ + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [ + new TextRun({ + text: new Date().toLocaleDateString(), + font: 'Calibri' + }) + ] + }) + ] + }) + }, + children: [ + ...(missings.length > 0 ? [ + new Paragraph({ + text: 'Missings', + heading: HeadingLevel.HEADING_1, + spacing: { + after: 200 + } + }), + ...missings + ] : []), + ...children + ] + } + ] + }); + } + + /** + * Convert HTML to DOCX + * @param html HTML string + * @param contentSetting Codebook content settings + * @returns List of paragraphs + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private static htmlToDocx(html: string, contentSetting: CodeBookContentSetting): Paragraph[] { + const paragraphs: Paragraph[] = []; + if (!html) return paragraphs; + + try { + const $ = cheerio.load(`
${html}
`); + const rootElement = $('div')[0]; + + if (rootElement && rootElement.children) { + this.processChildNodes(rootElement.children, paragraphs); + } + } catch (error) { + paragraphs.push(new Paragraph({ text: html })); + } + + return paragraphs; + } + + /** + * Process child nodes + * @param nodes List of nodes + * @param paragraphs List of paragraphs + */ + private static processChildNodes(nodes: AnyNode[], paragraphs: Paragraph[]): void { + nodes.forEach(node => { + if (node.type === 'text') { + if ('data' in node && node.data && node.data.trim()) { + paragraphs.push(new Paragraph({ text: node.data.trim() })); + } + } else if (node.type === 'tag') { + const element = node as Element; + const tagName = element.name.toLowerCase(); + + if (tagName === 'p') { + const textRuns: TextRun[] = []; + this.processInlineElements(element.children, textRuns); + if (textRuns.length > 0) { + paragraphs.push(new Paragraph({ children: textRuns })); + } + } else if (tagName === 'ul' || tagName === 'ol') { + this.processListElements(element.children, paragraphs, tagName === 'ol'); + } else if (element.children && element.children.length > 0) { + this.processChildNodes(element.children, paragraphs); + } + } + }); + } + + /** + * Process inline elements + * @param nodes List of nodes + * @param textRuns List of text runs + */ + private static processInlineElements(nodes: AnyNode[], textRuns: TextRun[]): void { + nodes.forEach(node => { + if (node.type === 'text') { + if ('data' in node && node.data && node.data.trim()) { + textRuns.push(new TextRun({ text: node.data.trim() })); + } + } else if (node.type === 'tag') { + const element = node as Element; + const tagName = element.name.toLowerCase(); + + if (tagName === 'strong' || tagName === 'b') { + if (element.children) { + element.children.forEach(child => { + if (child.type === 'text' && child.data) { + textRuns.push(new TextRun({ text: child.data.trim(), bold: true })); + } + }); + } + } else if (tagName === 'em' || tagName === 'i') { + if (element.children) { + element.children.forEach(child => { + if (child.type === 'text' && child.data) { + textRuns.push(new TextRun({ text: child.data.trim(), italics: true })); + } + }); + } + } else if (element.children && element.children.length > 0) { + this.processInlineElements(element.children, textRuns); + } + } + }); + } + + /** + * Process list elements + * @param nodes List of nodes + * @param paragraphs List of paragraphs + * @param isOrdered Whether the list is ordered + */ + private static processListElements(nodes: AnyNode[], paragraphs: Paragraph[], isOrdered: boolean): void { + let index = 1; + nodes.forEach(node => { + if (node.type === 'tag') { + const element = node as Element; + if (element.name.toLowerCase() === 'li') { + const textRuns: TextRun[] = []; + this.processInlineElements(element.children, textRuns); + if (textRuns.length > 0) { + const numbering = isOrdered ? { + reference: 'default-numbering', + level: 0, + instance: index + } : undefined; + if (isOrdered) index += 1; + paragraphs.push(new Paragraph({ + children: textRuns, + bullet: { + level: 0 + }, + numbering + })); + } + } + } + }); + } +} diff --git a/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.ts b/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.ts new file mode 100644 index 0000000..9a5636d --- /dev/null +++ b/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.ts @@ -0,0 +1,209 @@ +import { + ToTextFactory, CodeAsText +} from '@iqb/responses'; +import { VariableCodingData, CodeData, CodingScheme } from '@iqbspecs/coding-scheme'; + +import { + BookVariable, + CodeBookContentSetting, + CodebookUnitDto, + CodeInfo, + Missing, + UnitPropertiesForCodebook +} from '../models/codebook.interfaces'; +import { CodebookDocxGenerator } from './codebook-docx-generator.class'; + +/** + * Class for generating codebooks + */ +export class CodebookGenerator { + static generateCodebook( + units: UnitPropertiesForCodebook[], + contentSetting: CodeBookContentSetting, + missings: Missing[] + ): Promise { + if (units.length === 0) { + return Promise.resolve(Buffer.from('[]', 'utf-8')); + } + const codebook: CodebookUnitDto[] = units.map( + (unit: UnitPropertiesForCodebook) => this.getCodeBookDataForUnit(unit, contentSetting, missings) + ); + + if (contentSetting.exportFormat === 'docx') { + return CodebookDocxGenerator.generateDocx(codebook, contentSetting); + } + + return new Promise(resolve => { + const noItemsCodebook = codebook.map((unit: CodebookUnitDto) => ({ + key: unit.key, + name: unit.name, + variables: unit.variables, + missings: unit.missings + })); + const data = JSON.stringify(noItemsCodebook); + resolve(Buffer.from(data, 'utf-8')); + }); + } + + private static getCodeBookDataForUnit( + unit: UnitPropertiesForCodebook, + contentSetting: CodeBookContentSetting, + missings: Missing[] + ): CodebookUnitDto { + const parsedScheme = unit.scheme ? new CodingScheme(unit.scheme) : null; + const variableCodings = parsedScheme?.variableCodings || []; + const bookVariables = this.getBookVariables(variableCodings, contentSetting); + return { + key: unit.key, + name: unit.name, + variables: this.getSortedBookVariables(bookVariables.filter(v => v.sourceType !== 'BASE_NO_VALUE')), + missings: missings, + items: unit.metadata?.items + }; + } + + private static getBookVariables( + variableCodings: VariableCodingData[], + contentSetting: CodeBookContentSetting + ): BookVariable[] { + return variableCodings.reduce((bookVariables: BookVariable[], variableCoding) => { + const bookVariable = this.getBaseOrDerivedBookVariable(variableCoding, contentSetting); + if (bookVariable) bookVariables.push(bookVariable); + return bookVariables; + }, []); + } + + private static getSortedBookVariables(bookVariables: BookVariable[]): BookVariable[] { + return bookVariables.sort((a, b) => { + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; + return 0; + }); + } + + private static getBaseOrDerivedBookVariable( + variableCoding: VariableCodingData, + contentSetting: CodeBookContentSetting + ): BookVariable | null { + const rawCodes = variableCoding.codes ?? []; + const codes: CodeInfo[] = this.getCodes(rawCodes, contentSetting); + const isDerived: boolean = ( + variableCoding.sourceType !== 'BASE' && variableCoding.sourceType !== 'BASE_NO_VALUE' + ); + if (!isDerived || contentSetting.hasDerivedVars) { + return this.getManualOrClosedCodedBookVariable(contentSetting, codes, variableCoding); + } + return null; + } + + private static getManualOrClosedCodedBookVariable( + contentSetting: CodeBookContentSetting, + codes: CodeInfo[], + variableCoding: VariableCodingData + ): BookVariable | null { + if (contentSetting.hasOnlyVarsWithCodes && codes.length === 0) { + return null; + } + if (contentSetting.hasOnlyManualCoding && !contentSetting.hasClosedVars) { + if (!this.isManualWithoutClosed(variableCoding)) { + return null; + } + } else if (contentSetting.hasOnlyManualCoding) { + if (!this.isManual(variableCoding)) { + return null; + } + } else if (!contentSetting.hasClosedVars) { + if (this.isClosedWithoutManual(variableCoding)) { + return null; + } + } + return { + id: variableCoding.alias || variableCoding.id, + label: variableCoding.label ?? '', + sourceType: variableCoding.sourceType, + generalInstruction: contentSetting.hasGeneralInstructions ? + (variableCoding.manualInstruction ?? '') : + '', + codes: codes + }; + } + + private static isClosed(variableCoding: VariableCodingData): boolean { + const codes = variableCoding.codes ?? []; + return codes.some( + (codeData: CodeData) => codeData.type === 'RESIDUAL_AUTO' || codeData.type === 'INTENDED_INCOMPLETE' + ); + } + + private static isManual(variableCoding: VariableCodingData): boolean { + const codes = variableCoding.codes ?? []; + return codes.some((codeData: CodeData) => codeData.manualInstruction); + } + + private static isManualWithoutClosed(variableCoding: VariableCodingData): boolean { + const codes = variableCoding.codes ?? []; + return codes.some((codeData: CodeData) => codeData.manualInstruction && + (codeData.type !== 'RESIDUAL_AUTO' && codeData.type !== 'INTENDED_INCOMPLETE') + ); + } + + private static isClosedWithoutManual(variableCoding: VariableCodingData): boolean { + const codes = variableCoding.codes ?? []; + return codes + .some( + (codeData: CodeData) => (codeData.type === 'RESIDUAL_AUTO' || codeData.type === 'INTENDED_INCOMPLETE') && + !codeData.manualInstruction + ); + } + + private static getCodes(codes: CodeData[], contentSetting: CodeBookContentSetting): CodeInfo[] { + return codes.reduce((codeInfos: CodeInfo[], code) => { + if (code.id) { + try { + const codeInfo = this.getCodeInfoFromCodeAsText(code, contentSetting); + codeInfos.push(codeInfo); + } catch (error) { + const codeInfo = this.getCodeInfo(code, contentSetting); + codeInfos.push(codeInfo); + } + } + return codeInfos; + }, []); + } + + private static getCodeInfo(code: CodeData, contentSetting: CodeBookContentSetting): CodeInfo { + const codeInfo: CodeInfo = { + id: `${code.id}`, + label: '', + description: + '

Kodierschema mit Schemer Version ab 1.5 erzeugen!

' + }; + if (contentSetting.showScore) codeInfo.score = ''; + return codeInfo; + } + + private static getCodeInfoFromCodeAsText(code: CodeData, contentSetting: CodeBookContentSetting): CodeInfo { + const codeAsText = ToTextFactory.codeAsText(code, 'SIMPLE'); + const rulesDescription = contentSetting.hasOnlyManualCoding && !contentSetting.hasClosedVars ? '' : + this.getRulesDescription(codeAsText, code); + const codeInfo: CodeInfo = { + id: `${code.id}`, + label: contentSetting.codeLabelToUpper ? codeAsText.label.toUpperCase() : codeAsText.label, + description: `${rulesDescription}${code.manualInstruction ?? ''}` + }; + if (contentSetting.showScore) codeInfo.score = codeAsText.score.toString(); + return codeInfo; + } + + private static getRulesDescription(codeAsText: CodeAsText, code: CodeData): string { + let rulesDescription = ''; + codeAsText.ruleSetDescriptions.forEach( + (ruleSetDescription: string) => { + if (ruleSetDescription !== 'Keine Regeln definiert.') { + rulesDescription += `

${ruleSetDescription}

`; + } else if ((code.manualInstruction ?? '') === '') rulesDescription += `

${ruleSetDescription}

`; + } + ); + return rulesDescription; + } +} diff --git a/projects/ngx-coding-components/src/lib/models/codebook.interfaces.ts b/projects/ngx-coding-components/src/lib/models/codebook.interfaces.ts new file mode 100644 index 0000000..09974dc --- /dev/null +++ b/projects/ngx-coding-components/src/lib/models/codebook.interfaces.ts @@ -0,0 +1,151 @@ +import { VariableInfo } from '@iqbspecs/variable-info/variable-info.interface'; + +/** + * Item metadata for codebook + */ +export interface ItemMetadata { + [key: string]: unknown; +} + +/** + * Settings for codebook content generation + */ +export interface CodeBookContentSetting { + /** Export format (docx or json) */ + exportFormat: string; + /** Missings profile name */ + missingsProfile: string; + /** Include only manual coding */ + hasOnlyManualCoding: boolean; + /** Include general instructions */ + hasGeneralInstructions: boolean; + /** Include derived variables */ + hasDerivedVars: boolean; + /** Include only variables with codes */ + hasOnlyVarsWithCodes: boolean; + /** Include closed variables */ + hasClosedVars: boolean; + /** Convert code labels to uppercase */ + codeLabelToUpper: boolean; + /** Show score */ + showScore: boolean; + /** Hide item-variable relation */ + hideItemVarRelation: boolean; +} + +/** + * Missing code definition + */ +export interface Missing { + /** Missing code */ + code: string; + /** Missing label */ + label: string; + /** Missing description */ + description: string; +} + +/** + * Code information for codebook + */ +export interface CodeInfo { + /** Code ID */ + id: string; + /** Code label */ + label: string; + /** Code description */ + description: string; + /** Code score (optional) */ + score?: string; +} + +/** + * Variable information for codebook + */ +export interface BookVariable { + /** Variable ID */ + id: string; + /** Variable label */ + label: string; + /** Variable source type */ + sourceType: string; + /** General instruction */ + generalInstruction: string; + /** Codes */ + codes: CodeInfo[]; +} + +/** + * Unit data for codebook + */ +export interface CodebookUnitDto { + /** Unit key */ + key: string; + /** Unit name */ + name: string; + /** Variables */ + variables: BookVariable[]; + /** Missings */ + missings: Missing[]; + /** Items (optional) */ + items?: ItemMetadata[]; +} + +/** + * Unit properties for codebook generation + */ +export interface UnitPropertiesForCodebook { + /** Unit ID */ + id: number; + /** Unit key */ + key: string; + /** Unit name */ + name: string; + /** Coding scheme */ + scheme?: string; + /** Scheme type */ + schemeType?: string; + /** Metadata */ + metadata?: { + /** Items */ + items?: ItemMetadata[]; + }; + /** Variables */ + variables?: VariableInfo[]; +} + +/** + * Unit selection item for codebook export UI + */ +export interface UnitSelectionItem { + /** Unit ID */ + unitId: number; + /** Unit name */ + unitName: string; + /** Unit alias */ + unitAlias: string | null; +} + +/** + * Missings profile for selection + */ +export interface MissingsProfile { + /** Profile ID */ + id: number; + /** Profile label */ + label: string; + /** Missings data */ + missings?: Missing[] | string; +} + +/** + * Codebook export configuration + */ +export interface CodebookExportConfig { + /** Selected unit IDs */ + selectedUnits: number[]; + /** Content options */ + contentOptions: CodeBookContentSetting; + /** Selected missings profile ID */ + missingsProfileId: number; +} diff --git a/projects/ngx-coding-components/src/public-api.ts b/projects/ngx-coding-components/src/public-api.ts index 193bd2b..5799cff 100644 --- a/projects/ngx-coding-components/src/public-api.ts +++ b/projects/ngx-coding-components/src/public-api.ts @@ -12,3 +12,8 @@ export * from './lib/dialogs/show-coding-dialog.component'; export * from './lib/dialogs/simple-input-dialog.component'; export * from './lib/dialogs/coding-scheme-dialog.component'; export * from './lib/elements/register-elements'; +export * from './lib/codebook-export/codebook-export.component'; +export * from './lib/codebook-export/codebook-export.provider'; +export * from './lib/models/codebook.interfaces'; +export * from './lib/codebook-generator/codebook-generator.class'; +export * from './lib/codebook-generator/codebook-docx-generator.class';