diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0144a3f0c..4a56361a3 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -41,7 +41,8 @@ variables: REGISTRY_PASSWORD: "${CI_REGISTRY_PASSWORD}" REGISTRY_PROJECT: "/${CI_PROJECT_PATH}/" DOCKER_HUB_PROXY: "${CI_DEPENDENCY_PROXY_GROUP_IMAGE_PREFIX}/" - BASE_IMAGE: node:22-bookworm-slim + # Match package.json/.nvmrc and retain Python/compiler tools for native modules. + BASE_IMAGE: node:22-bookworm CICD_BASE_IMAGE: "${BASE_IMAGE}-git" CYPRESS_IMAGE: cypress/browsers:node-22.14.0-chrome-134.0.6998.165-1-ff-136.0.3-edge-134.0.3124.85-1 CYPRESS_CACHE_FOLDER: "${CI_PROJECT_DIR}/cache/Cypress" diff --git a/api-dto/coding/codebook-content-setting.ts b/api-dto/coding/codebook-content-setting.ts index 319547843..8c3010d23 100644 --- a/api-dto/coding/codebook-content-setting.ts +++ b/api-dto/coding/codebook-content-setting.ts @@ -1,32 +1,11 @@ +import type { CodeBookContentSetting as SharedCodebookOptions } from '@iqb/ngx-coding-components/codebook-models'; /** * Settings for codebook content generation */ export type CodebookExportFormat = 'docx' | 'json'; -export type CodebookTrainingRequirementFilter = 'all' | 'required' | 'not-required'; +export type { CodebookTrainingRequirementFilter } from '@iqb/ngx-coding-components/codebook-models'; -export interface CodeBookContentSetting { - /** Export format (docx or json) */ - exportFormat: CodebookExportFormat; - /** 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; - /** Filter variables by increased coder training requirement */ - trainingRequirement?: CodebookTrainingRequirementFilter; +export interface CodeBookContentSetting extends SharedCodebookOptions { /** Restrict variables to a job definition */ jobDefinitionId?: number | null; /** Restrict variables to one or more variable bundles */ diff --git a/apps/backend/Dockerfile b/apps/backend/Dockerfile index c3e28fc70..e825b564a 100644 --- a/apps/backend/Dockerfile +++ b/apps/backend/Dockerfile @@ -27,9 +27,11 @@ USER node WORKDIR /usr/src/coding-box-${PROJECT} RUN mkdir packages -# Install dependencies +# Install dependencies from the same commit-pinned Codebook build as the frontend. +COPY --chown=node:node --from=base /usr/src/coding-box-base/vendor/iqb-ngx-coding-components-4.1.0-dev.ff15d6d.tgz ./vendor/iqb-ngx-coding-components-4.1.0-dev.ff15d6d.tgz COPY --chown=node:node --from=base /usr/src/coding-box-base/dist/apps/${PROJECT}/package.json . RUN --mount=type=cache,target=~/.npm \ + npm pkg set dependencies.@iqb/ngx-coding-components=file:vendor/iqb-ngx-coding-components-4.1.0-dev.ff15d6d.tgz overrides.image-size=2.0.3 && \ npm install --omit=dev --no-fund # Install PostgreSQL client (missing in dist/apps/backend/package.json - nx bug?) diff --git a/apps/backend/src/app/admin/code-book/codebook-docx-generator.class.spec.ts b/apps/backend/src/app/admin/code-book/codebook-docx-generator.class.spec.ts index 446001937..5cc6b0e6c 100644 --- a/apps/backend/src/app/admin/code-book/codebook-docx-generator.class.spec.ts +++ b/apps/backend/src/app/admin/code-book/codebook-docx-generator.class.spec.ts @@ -1,66 +1,22 @@ +import type { CodeBookContentSetting } from '@iqb/ngx-coding-components/codebook-models'; import { CodebookDocxGenerator } from './codebook-docx-generator.class'; -jest.mock('docx', () => ({ - ImportedXmlComponent: { - fromXmlString: jest.fn().mockReturnValue({}) - } -})); - -jest.mock('katex', () => ({ - renderToString: jest.fn().mockReturnValue('mocked-mathml') -})); - -jest.mock('mathml2omml', () => ({ - mml2omml: jest.fn().mockReturnValue('a') -})); - -describe('CodebookDocxGenerator', () => { - describe('OMML sanitizing', () => { - it('should sanitize invalid XML chars inside OMML text nodes', () => { - const generator = CodebookDocxGenerator as unknown as { - sanitizeOmmlXml: (omml: string) => string; - }; - const rawOmml = 'a'; - const sanitizedOmml = generator.sanitizeOmmlXml(rawOmml); - - expect(sanitizedOmml).toContain('a<b & c'); - expect(sanitizedOmml).not.toContain('a { - const fromXmlString = ( - jest.requireMock('docx') as { - ImportedXmlComponent: { fromXmlString: jest.Mock }; - } - ).ImportedXmlComponent.fromXmlString; - fromXmlString.mockClear(); - - const generator = CodebookDocxGenerator as unknown as { - latexToOmml: (latex: string) => unknown; - }; - const result = generator.latexToOmml('aa<b & c' - ); - }); - - it('should unwrap the ImportedXmlComponent wrapper before returning OMML', () => { - const fromXmlString = ( - jest.requireMock('docx') as { - ImportedXmlComponent: { fromXmlString: jest.Mock }; - } - ).ImportedXmlComponent.fromXmlString; - const ommlRoot = { rootKey: 'm:oMath' }; - fromXmlString.mockReturnValueOnce({ root: [ommlRoot] }); - - const generator = CodebookDocxGenerator as unknown as { - latexToOmml: (latex: string) => unknown; - }; - const result = generator.latexToOmml('a { + it('returns a valid DOCX Buffer even for an empty export', async () => { + const options: CodeBookContentSetting = { + exportFormat: 'docx', + missingsProfile: '', + hasOnlyManualCoding: false, + hasClosedVars: false, + hasOnlyVarsWithCodes: false, + hasDerivedVars: true, + hasGeneralInstructions: true, + codeLabelToUpper: false, + showScore: true, + hideItemVarRelation: true + }; + const buffer = await CodebookDocxGenerator.generateDocx([], options); + expect(Buffer.isBuffer(buffer)).toBe(true); + expect(buffer.subarray(0, 2).toString()).toBe('PK'); }); }); diff --git a/apps/backend/src/app/admin/code-book/codebook-docx-generator.class.ts b/apps/backend/src/app/admin/code-book/codebook-docx-generator.class.ts index 2d1cb1d80..a646b48cf 100644 --- a/apps/backend/src/app/admin/code-book/codebook-docx-generator.class.ts +++ b/apps/backend/src/app/admin/code-book/codebook-docx-generator.class.ts @@ -1,781 +1,10 @@ -import { - AlignmentType, - Document, - HeadingLevel, - Packer, - Paragraph, - Table, - TableCell, - TableRow, - TextRun, - Footer, - WidthType, - PageNumber, - ITableCellBorders, - Header, - FileChild, - ParagraphChild, - ImportedXmlComponent -} from 'docx'; -import * as cheerio from 'cheerio'; -// Using type-only import to avoid dependency warning -import type { AnyNode, Element } from 'domhandler'; -import * as katex from 'katex'; -import { mml2omml } from 'mathml2omml'; -import { - BookVariable, CodeBookContentSetting, CodebookUnitDto, ItemMetadata -} from './codebook.interfaces'; +import { CodebookDocxGenerator as SharedDocxGenerator } from '@iqb/ngx-coding-components/codebook-generator'; +import type { CodeBookContentSetting, CodebookUnitDto } from '@iqb/ngx-coding-components/codebook-models'; -/** - * Class for generating DOCX files for codebooks - */ +/** Compatibility adapter for direct callers. */ export class CodebookDocxGenerator { - private static decodeLatex(latex: string): string { - if (!latex) return ''; - try { - return decodeURIComponent(latex); - } catch { - return latex; - } - } - - private static latexToOmml(latex: string): ImportedXmlComponent | null { - const trimmed = latex.trim(); - if (!trimmed) return null; - try { - const mathml = katex.renderToString(trimmed, { - output: 'mathml', - throwOnError: false, - strict: 'ignore' - }); - const omml = mml2omml(mathml); - return this.unwrapImportedXmlRoot( - ImportedXmlComponent.fromXmlString(this.sanitizeOmmlXml(omml)) - ); - } catch { - return null; - } - } - - private static unwrapImportedXmlRoot( - component: ImportedXmlComponent - ): ImportedXmlComponent { - const importedComponent = component as ImportedXmlComponent & { - root?: Array; - rootKey?: string; - }; - if (importedComponent.rootKey !== undefined) { - return component; - } - - const firstChild = importedComponent.root?.[0]; - return typeof firstChild === 'object' && firstChild !== null ? - firstChild as ImportedXmlComponent : - component; - } - - private static isMathComponent( - child: ParagraphChild - ): child is ImportedXmlComponent { - if (typeof child !== 'object' || child === null) { - return false; - } - const importedChild = child as ImportedXmlComponent & { - rootKey?: string; - }; - return importedChild.rootKey === 'm:oMath' || - importedChild.rootKey === 'm:oMathPara'; - } - - private static createLeftAlignedMathParagraph( - mathChildren: ImportedXmlComponent[] - ): ImportedXmlComponent { - const mathParagraph = new ImportedXmlComponent('m:oMathPara'); - const mathParagraphProperties = new ImportedXmlComponent('m:oMathParaPr'); - mathParagraphProperties.push( - new ImportedXmlComponent('m:jc', { 'm:val': 'left' }) - ); - mathParagraph.push(mathParagraphProperties); - mathChildren.forEach(mathChild => mathParagraph.push(mathChild)); - return mathParagraph; - } - - private static normalizeParagraphChildren( - children: ParagraphChild[] - ): ParagraphChild[] { - if (!children.length) { - return children; - } - - const mathChildren = children.filter( - this.isMathComponent - ) as ImportedXmlComponent[]; - if (mathChildren.length === children.length) { - return [this.createLeftAlignedMathParagraph(mathChildren)]; - } - - return children; - } - - private static sanitizeOmmlXml(omml: string): string { - if (!omml) return omml; - return omml.replace( - /(]*>)([\s\S]*?)(<\/m:t>)/g, - (_, prefix: string, rawText: string, suffix: string) => `${prefix}${this.escapeXmlText(rawText)}${suffix}` - ); - } - - private static escapeXmlText(text: string): string { - return text - .replace( - /&(?!(?:amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);)/g, - '&' - ) - .replace(//g, '>'); - } - - private static normalizeMathTokens(html: string): string { - return html.replace(/\[\[iqb-math:([\s\S]*?)]]/g, (_, encodedFormula: string) => { - const latex = this.decodeLatex(encodedFormula); - const escapedLatex = latex.replace(/"/g, '"'); - return ``; - }); - } - - /** - * 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: FileChild[] = []; - 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 FileChild[])); - } - }); - 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[] - ): FileChild[] { - const children: FileChild[] = []; - 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 - ): FileChild[] { - 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: FileChild[], missings: Paragraph[]): Document { - const doc = 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 - ] - } - ] - }); - return doc; - } - - /** - * 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 normalizedHtml = this.normalizeMathTokens(html); - const $ = cheerio.load(`
${normalizedHtml}
`); - 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 { - for (const node of nodes) { - 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 children: ParagraphChild[] = []; - this.processInlineElements(element.children, children); - if (children.length > 0) { - paragraphs.push(new Paragraph({ - children: this.normalizeParagraphChildren(children) - })); - } - } 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 children List of paragraph children - */ - private static processInlineElements(nodes: AnyNode[], children: ParagraphChild[]): void { - for (const node of nodes) { - if (node.type === 'text') { - if ('data' in node && node.data && node.data.trim()) { - children.push(new TextRun({ text: node.data.trim() })); - } - } else if (node.type === 'tag') { - const element = node as Element; - const tagName = element.name.toLowerCase(); - - if (tagName === 'span' && element.attribs?.class?.includes('iqb-math-formula')) { - const rawLatex = element.attribs?.['data-latex'] || ''; - const latex = this.decodeLatex(rawLatex).trim(); - if (latex) { - const ommlComponent = this.latexToOmml(latex); - if (ommlComponent) { - children.push(ommlComponent); - } else { - children.push(new TextRun({ text: latex })); - } - } - continue; - } - - if (tagName === 'strong' || tagName === 'b') { - if (element.children) { - for (const child of element.children) { - if (child.type === 'text' && child.data) { - children.push(new TextRun({ text: child.data.trim(), bold: true })); - } - } - } - } else if (tagName === 'em' || tagName === 'i') { - if (element.children) { - for (const child of element.children) { - if (child.type === 'text' && child.data) { - children.push(new TextRun({ text: child.data.trim(), italics: true })); - } - } - } - } else if (element.children && element.children.length > 0) { - this.processInlineElements(element.children, children); - } - } - } - } - - /** - * 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; - for (const node of nodes) { - if (node.type === 'tag') { - const element = node as Element; - if (element.name.toLowerCase() === 'li') { - const children: ParagraphChild[] = []; - this.processInlineElements(element.children, children); - if (children.length > 0) { - paragraphs.push(new Paragraph({ - children: this.normalizeParagraphChildren(children), - bullet: { - level: 0 - }, - numbering: isOrdered ? { - reference: 'default-numbering', - level: 0, - instance: index += 1 - } : undefined - })); - } - } - } - } + static async generateDocx(units: CodebookUnitDto[], options: CodeBookContentSetting): Promise { + const blob = await SharedDocxGenerator.generateDocx(units, options); + return Buffer.from(await blob.arrayBuffer()); } } diff --git a/apps/backend/src/app/admin/code-book/codebook-docx-generator.integration.spec.ts b/apps/backend/src/app/admin/code-book/codebook-docx-generator.integration.spec.ts index 8d6807a0e..aa601ddc5 100644 --- a/apps/backend/src/app/admin/code-book/codebook-docx-generator.integration.spec.ts +++ b/apps/backend/src/app/admin/code-book/codebook-docx-generator.integration.spec.ts @@ -2,7 +2,7 @@ import AdmZip = require('adm-zip'); import { CodeBookContentSetting, CodebookUnitDto -} from './codebook.interfaces'; +} from '@iqb/ngx-coding-components/codebook-models'; import { CodebookDocxGenerator } from './codebook-docx-generator.class'; const defaultSettings: CodeBookContentSetting = { @@ -63,6 +63,48 @@ describe('CodebookDocxGenerator integration', () => { consoleWarnSpy.mockRestore(); }); + it.each([false, true])('retains code and general text around failed inline images, score=%p', async showScore => { + const html = '

VOR' + + 'NACH

WEITER

'; + const units = buildUnits(html); + units[0].variables[0].generalInstruction = html; + const xml = new AdmZip(await CodebookDocxGenerator.generateDocx( + units, { ...defaultSettings, hasGeneralInstructions: true, showScore } + )).readAsText('word/document.xml'); + const paragraphs = xml.match(/]*)?>[\s\S]*?<\/w:p>/g) || []; + const instructions = paragraphs.filter(paragraph => paragraph.includes('>VOR')); + expect(instructions).toHaveLength(2); + instructions.forEach(paragraph => { + expect(paragraph).toContain('>HTML konnte nicht verarbeitet werden.'); + expect(paragraph).toContain('>NACH'); + expect(paragraph).toContain(''); + expect(paragraph).toContain(''); + }); + expect(paragraphs.filter(paragraph => paragraph.includes('>WEITER'))).toHaveLength(2); + }); + + it.each([ + ['

10 kg

', '10\u00a0kg'], + ['

20 cm

', '20\u202fcm'], + ['

10 kg

', '10\u00a0kg'], + ['

Abstand bleibt

', 'Abstand bleibt'] + ])('retains exact code and general instruction spacing for %p', async (html, text) => { + const units = buildUnits(html); + units[0].variables[0].generalInstruction = html; + const settings = { ...defaultSettings, hasGeneralInstructions: true }; + const xml = new AdmZip(await CodebookDocxGenerator.generateDocx(units, settings)).readAsText('word/document.xml'); + expect(xml.split(`>${text}`).length - 1).toBe(2); + }); + + it.each([' ', '\t\r\n', '\u00a0'])('does not add a paragraph for general instruction %p', async generalInstruction => { + const units = buildUnits('

Bewerten

'); + const settings = { ...defaultSettings, hasGeneralInstructions: true }; + const baseline = new AdmZip(await CodebookDocxGenerator.generateDocx(units, settings)).readAsText('word/document.xml'); + units[0].variables[0].generalInstruction = generalInstruction; + const actual = new AdmZip(await CodebookDocxGenerator.generateDocx(units, settings)).readAsText('word/document.xml'); + expect(actual).toBe(baseline); + }); + it('creates DOCX XML without undefined wrappers for formula spans', async () => { const description = '

Formel: ' + diff --git a/apps/backend/src/app/admin/code-book/codebook-generator.class.spec.ts b/apps/backend/src/app/admin/code-book/codebook-generator.class.spec.ts index 2489a6a56..1974d1a84 100644 --- a/apps/backend/src/app/admin/code-book/codebook-generator.class.spec.ts +++ b/apps/backend/src/app/admin/code-book/codebook-generator.class.spec.ts @@ -1,249 +1,87 @@ +import AdmZip = require('adm-zip'); +import { load } from 'cheerio'; import { CodebookGenerator } from './codebook-generator.class'; +import type { CodeBookContentSetting } from './codebook.interfaces'; -const contentSetting = { +const options: CodeBookContentSetting = { exportFormat: 'json', - hasDerivedVars: false, - hasOnlyVarsWithCodes: false, - hasOnlyManualCoding: false, - hasClosedVars: true, + missingsProfile: '', + hasOnlyManualCoding: true, + hasClosedVars: false, + hasOnlyVarsWithCodes: true, + hasDerivedVars: true, hasGeneralInstructions: true, codeLabelToUpper: false, showScore: true, - hideItemVarRelation: false, - trainingRequirement: 'all', - jobDefinitionId: null + hideItemVarRelation: false }; - -const manualCode = { - id: 1, - label: 'Code A', - score: 1, - type: 'FULL_CREDIT', - manualInstruction: '

manual

', - ruleSet: [] -}; - -const closedCode = { - id: 2, - label: 'Closed', - score: 0, - type: 'RESIDUAL_AUTO', - manualInstruction: '', - ruleSet: [] -}; - -const variableCoding = { - id: 'VAR', - alias: 'ALIAS', - label: 'Variable', +const coding = (id: string, training = false) => ({ + id, + alias: `${id}_ALIAS`, + label: id, sourceType: 'BASE', - manualInstruction: '

general

', - codes: [manualCode, closedCode] + processing: training ? ['CODER_TRAINING_REQUIRED'] : [], + codes: [ + { + id: 0, label: 'Zero', type: 'FULL_CREDIT', score: 0, ruleSets: [], manualInstruction: '

Bewerten

' + }, + { + id: 1, label: 'Rest', type: 'RESIDUAL_AUTO', score: 0, ruleSets: [], manualInstruction: '' + } + ] +}); +const unit = { + id: 1, key: 'U.VOCS', name: 'Unit', scheme: JSON.stringify({ version: '3.0', variableCodings: [coding('A', true), coding('B')] }), metadata: { items: [] } }; -describe('CodebookGenerator', () => { - it('returns an empty json buffer for empty codebooks', async () => { - await expect(CodebookGenerator.generateCodebook([], contentSetting as never, [])) - .resolves.toEqual(Buffer.from('[]', 'utf-8')); - }); - - it('formats and filters book variables and codes', () => { - const generator = CodebookGenerator as unknown as Record unknown>; - - expect(generator.getSortedBookVariables([ - { id: 'B', sourceType: 'BASE', codes: [] }, - { id: 'A', sourceType: 'BASE', codes: [] } - ])).toEqual([ - { id: 'A', sourceType: 'BASE', codes: [] }, - { id: 'B', sourceType: 'BASE', codes: [] } - ]); - - expect(generator.isClosed(variableCoding)).toBe(true); - expect(generator.isManual(variableCoding)).toBe(true); - expect(generator.isManualWithoutClosed(variableCoding)).toBe(true); - expect(generator.isClosedWithoutManual(variableCoding)).toBe(true); - - const baseVariable = generator.getBaseOrDerivedBookVariable(variableCoding, contentSetting); - expect(baseVariable).toMatchObject({ id: 'ALIAS', label: 'Variable' }); - - const derivedVariable = generator.getBaseOrDerivedBookVariable( - { ...variableCoding, sourceType: 'DERIVE' }, - { ...contentSetting, hasDerivedVars: false } - ); - expect(derivedVariable).toBeNull(); - - expect(generator.getManualOrClosedCodedBookVariable( - { ...contentSetting, hasOnlyVarsWithCodes: true }, - [], - variableCoding - )).toBeNull(); - - expect(generator.getManualOrClosedCodedBookVariable( - { ...contentSetting, hasOnlyManualCoding: true, hasClosedVars: false }, - [], - { ...variableCoding, codes: [closedCode] } - )).toBeNull(); - - expect(generator.getManualOrClosedCodedBookVariable( - { ...contentSetting, hasOnlyManualCoding: true, hasClosedVars: true }, - [], - { ...variableCoding, codes: [{ ...closedCode, manualInstruction: '' }] } - )).toBeNull(); - - expect(generator.getManualOrClosedCodedBookVariable( - { ...contentSetting, hasClosedVars: false }, - [], - { ...variableCoding, codes: [closedCode] } - )).toBeNull(); - }); - - it('builds code information fallbacks and rule descriptions', () => { - const generator = CodebookGenerator as unknown as Record unknown>; - - expect(generator.getCodeInfo({ id: 5 }, { ...contentSetting, showScore: true })) - .toMatchObject({ id: '5', score: '' }); - - expect(generator.getRulesDescription( - { ruleSetDescriptions: ['Keine Regeln definiert.', 'Regel A'] }, - { manualInstruction: '' } - )).toContain('Regel A'); - - const codeInfos = generator.getCodes( - [ - { ...manualCode, id: 1 }, - { id: 0 }, - { ...manualCode, id: 3, ruleSet: undefined } - ], - { ...contentSetting, codeLabelToUpper: true } - ) as { id: string }[]; - expect(Array.isArray(codeInfos)).toBe(true); - expect(codeInfos.length).toBeGreaterThan(0); - expect(codeInfos.map(codeInfo => codeInfo.id)).toContain('0'); - }); - - it('includes code 0 in generated codebook output', async () => { - const codebookBuffer = await CodebookGenerator.generateCodebook( - [ - { - id: 1, - key: 'UNIT.VOCS', - name: 'Unit', - scheme: JSON.stringify({ - version: '3.0', - variableCodings: [ - { - ...variableCoding, - codes: [ - { - ...manualCode, id: 0, label: 'Code 0', score: 0, type: 'NO_CREDIT' - }, - { - ...manualCode, id: 1, label: 'Code 1', score: 1, type: 'FULL_CREDIT' - } - ] - } - ] - }) - } - ], - contentSetting as never, - [] - ); - - const codebook = JSON.parse(codebookBuffer.toString('utf-8')) as Array<{ - variables: Array<{ codes: Array<{ id: string }> }>; - }>; - - expect(codebook[0].variables[0].codes.map(code => code.id)).toEqual(['0', '1']); - }); - - it('filters variables by increased coder training requirement', async () => { - const codebookBuffer = await CodebookGenerator.generateCodebook( - [ - { - id: 1, - key: 'UNIT.VOCS', - name: 'Unit', - scheme: JSON.stringify({ - version: '3.0', - variableCodings: [ - { - ...variableCoding, - id: 'VAR_REQUIRED', - alias: 'VAR_REQUIRED', - processing: ['CODER_TRAINING_REQUIRED'] - }, - { - ...variableCoding, - id: 'VAR_NORMAL', - alias: 'VAR_NORMAL', - processing: [] - } - ] - }) - } - ], - { ...contentSetting, trainingRequirement: 'required' } as never, - [] - ); - - const codebook = JSON.parse(codebookBuffer.toString('utf-8')) as Array<{ - variables: Array<{ id: string }>; - }>; - - expect(codebook[0].variables.map(variable => variable.id)).toEqual([ - 'VAR_REQUIRED' - ]); +describe('Kodierbox adapter to shared CodebookGenerator', () => { + it.each([ + { + name: 'direct list instructions', + instruction: '
  • Erstes Kriterium
  • Zweites Kriterium
', + ruleSets: [], + expected: ['Erstes Kriterium', 'Zweites Kriterium'] + }, + { + name: 'plain instructions after generated rule paragraphs', + instruction: 'Manuelle Instruktion', + ruleSets: [{ rules: [{ method: 'MATCH', parameters: ['ABC'] }], ruleOperatorAnd: true }], + expected: ['ABC', 'Manuelle Instruktion'] + } + ])('retains $name in complete DOCX exports', async ({ instruction, ruleSets, expected }) => { + const variable = { + ...coding('V'), + codes: [{ ...coding('V').codes[0], manualInstruction: instruction, ruleSets }] + }; + const buffer = await CodebookGenerator.generateCodebook([{ + ...unit, + scheme: JSON.stringify({ version: '3.0', variableCodings: [variable] }) + }], { ...options, exportFormat: 'docx', hasClosedVars: true }, []); + const $ = load(new AdmZip(buffer).readAsText('word/document.xml'), { xml: true }); + const paragraphs = $('w\\:tr').first().children('w\\:tc').last() + .find('w\\:p') + .toArray() + .map(paragraph => $(paragraph).find('w\\:t').text()); + expect(paragraphs).toEqual(expected); }); - it('filters codes without manual instructions in manual codebooks without closed variables', () => { - const generator = CodebookGenerator as unknown as Record unknown>; - - const codeInfos = generator.getCodes( - [ - manualCode, - { ...manualCode, id: 2, manualInstruction: '' }, - { ...manualCode, id: 3, manualInstruction: ' ' }, - { ...manualCode, id: 4, manualInstruction: '

' } - ], - { ...contentSetting, hasOnlyManualCoding: true, hasClosedVars: false } - ) as { id: string; description: string }[]; - - expect(codeInfos).toHaveLength(1); - expect(codeInfos[0]).toMatchObject({ - id: '1', - description: '

manual

' - }); + it('keeps the training scope before applying shared manual code filtering', async () => { + const buffer = await CodebookGenerator.generateCodebook([unit], { ...options, trainingRequirement: 'required' }, []); + const [result] = JSON.parse(buffer.toString()); + expect(result.key).toBe('U'); + expect(result.variables.map(v => v.id)).toEqual(['A_ALIAS']); + expect(result.variables[0].codes.map(c => c.id)).toEqual(['0']); }); - it('keeps codes without manual instructions when closed variables are included', () => { - const generator = CodebookGenerator as unknown as Record unknown>; - - const codeInfos = generator.getCodes( - [ - manualCode, - { ...manualCode, id: 2, manualInstruction: '' }, - { ...manualCode, id: 3, manualInstruction: ' ' } - ], - { ...contentSetting, hasOnlyManualCoding: true, hasClosedVars: true } - ) as { id: string; description: string }[]; - - expect(codeInfos.map(codeInfo => codeInfo.id)).toEqual(['1', '2', '3']); + it('retains all / not-required training choices', async () => { + for (const [trainingRequirement, count] of [['all', 2], ['not-required', 1]] as const) { + const buffer = await CodebookGenerator.generateCodebook([unit], { ...options, trainingRequirement }, []); + expect(JSON.parse(buffer.toString())[0].variables).toHaveLength(count); + } }); - it('treats visually empty HTML manual instructions as missing for variable filtering', () => { - const generator = CodebookGenerator as unknown as Record unknown>; - const whitespaceManualVariable = { - ...variableCoding, - codes: [{ ...manualCode, manualInstruction: '

' }] - }; - - expect(generator.isManual(whitespaceManualVariable)).toBe(false); - expect(generator.isManualWithoutClosed(whitespaceManualVariable)).toBe(false); - expect(generator.getManualOrClosedCodedBookVariable( - { ...contentSetting, hasOnlyManualCoding: true, hasClosedVars: false }, - [], - whitespaceManualVariable - )).toBeNull(); + it('returns an empty JSON array and rejects malformed schemes', async () => { + expect((await CodebookGenerator.generateCodebook([], options, [])).toString()).toBe('[]'); + await expect(CodebookGenerator.generateCodebook([{ ...unit, scheme: '{' }], options, [])).rejects.toThrow(); }); }); diff --git a/apps/backend/src/app/admin/code-book/codebook-generator.class.ts b/apps/backend/src/app/admin/code-book/codebook-generator.class.ts index b667dd38d..8f2b5c0ed 100644 --- a/apps/backend/src/app/admin/code-book/codebook-generator.class.ts +++ b/apps/backend/src/app/admin/code-book/codebook-generator.class.ts @@ -1,231 +1,45 @@ -import { - ToTextFactory, CodeAsText -} from '@iqb/responses'; -import { VariableCodingData, CodeData, CodingScheme } from '@iqbspecs/coding-scheme'; +import { BadRequestException } from '@nestjs/common'; +import { CodebookGenerator as SharedCodebookGenerator, CodebookGenerationError } from '@iqb/ngx-coding-components/codebook-generator'; +import type { UnitPropertiesForCodebook as SharedUnit } from '@iqb/ngx-coding-components/codebook-models'; +import { CodeBookContentSetting, Missing, UnitPropertiesForCodebook } from './codebook.interfaces'; -import { - BookVariable, - CodeBookContentSetting, - CodebookUnitDto, - CodeInfo, - Missing, - UnitPropertiesForCodebook -} from './codebook.interfaces'; -import { CodebookDocxGenerator } from './codebook-docx-generator.class'; -import { hasVisibleManualInstruction } from '../../utils/manual-instruction.util'; - -/** - * Class for generating codebooks - */ +/** Host adapter for imported metadata; shared filters and rendering belong to the library. */ 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 { - if (!this.matchesTrainingRequirement(variableCoding, contentSetting)) { - return null; - } - - const codes: CodeInfo[] = this.getCodes(variableCoding.codes, 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 matchesTrainingRequirement( - variableCoding: VariableCodingData, - contentSetting: CodeBookContentSetting - ): boolean { - const filter = contentSetting.trainingRequirement || 'all'; - if (filter === 'all') { - return true; - } - - const trainingRequired = - variableCoding.processing?.includes('CODER_TRAINING_REQUIRED') ?? false; - - return filter === 'required' ? trainingRequired : !trainingRequired; - } - - 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; + static async generateCodebook(units: UnitPropertiesForCodebook[], options: CodeBookContentSetting, missings: Missing[]): Promise { + const normalized: SharedUnit[] = units.map(unit => { + let scheme; + try { scheme = unit.scheme ? JSON.parse(unit.scheme) : null; } catch { + throw new BadRequestException(`Ungültiges Kodierschema für Aufgabe ${unit.key}.`); } - } else if (contentSetting.hasOnlyManualCoding) { - if (!this.isManual(variableCoding)) { - return null; + if (scheme && !Array.isArray(scheme.variableCodings)) { + throw new BadRequestException(`Ungültiges Kodierschema für Aufgabe ${unit.key}.`); } - } else if (!contentSetting.hasClosedVars) { - if (this.isClosedWithoutManual(variableCoding)) { - return null; + const codings = Array.isArray(scheme?.variableCodings) ? scheme.variableCodings : []; + if (codings.some((variable: { id?: unknown }) => !variable || typeof variable.id !== 'string')) { + throw new BadRequestException(`Ungültiges Kodierschema für Aufgabe ${unit.key}.`); } + const items = (unit.metadata?.items || []).flatMap(item => { + const id = item.id ?? item.key; + if (id === undefined || id === null) return []; + return codings.filter((variable: { id: string; alias?: string }) => { + const variableId = variable.alias || variable.id; + return item.variableId === variable.id || item.variableId === variableId || + item[variableId] !== undefined || item[variableId.replace(/\./g, '_')] !== undefined; + }).map((variable: { id: string; alias?: string }) => ({ id: String(id), variableId: variable.alias || variable.id })); + }); + return { + ...unit, + key: unit.key.replace(/\.vocs$/i, ''), + scheme: scheme ? JSON.stringify(scheme) : unit.scheme, + metadata: { items } + }; + }); + try { + const blob = await SharedCodebookGenerator.generateCodebook(normalized, options, missings); + return Buffer.from(await blob.arrayBuffer()); + } catch (error) { + if (error instanceof CodebookGenerationError) throw new BadRequestException(error.message); + throw error; } - 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 { - return variableCoding.codes.some(codeData => codeData.type === 'RESIDUAL_AUTO' || codeData.type === 'INTENDED_INCOMPLETE'); - } - - private static isManual(variableCoding: VariableCodingData): boolean { - return variableCoding.codes.some(codeData => this.hasManualInstruction(codeData)); - } - - private static isManualWithoutClosed(variableCoding: VariableCodingData): boolean { - return variableCoding.codes.some(codeData => this.hasManualInstruction(codeData) && - (codeData.type !== 'RESIDUAL_AUTO' && codeData.type !== 'INTENDED_INCOMPLETE')); - } - - private static isClosedWithoutManual(variableCoding: VariableCodingData): boolean { - return variableCoding.codes - .some(codeData => (codeData.type === 'RESIDUAL_AUTO' || codeData.type === 'INTENDED_INCOMPLETE') && - !this.hasManualInstruction(codeData)); - } - - private static hasManualInstruction(codeData: CodeData): boolean { - return hasVisibleManualInstruction(codeData); - } - - private static shouldHideCodeInManualCodebook( - code: CodeData, - contentSetting: CodeBookContentSetting - ): boolean { - return contentSetting.hasOnlyManualCoding && - !contentSetting.hasClosedVars && - !this.hasManualInstruction(code); - } - - private static getCodes(codes: CodeData[], contentSetting: CodeBookContentSetting): CodeInfo[] { - return codes.reduce((codeInfos: CodeInfo[], code) => { - if (this.shouldHideCodeInManualCodebook(code, contentSetting)) { - return codeInfos; - } - if (code.id !== undefined && code.id !== null) { - 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/apps/backend/src/app/admin/code-book/codebook.interfaces.ts b/apps/backend/src/app/admin/code-book/codebook.interfaces.ts index 2565f2f22..907bc53e2 100644 --- a/apps/backend/src/app/admin/code-book/codebook.interfaces.ts +++ b/apps/backend/src/app/admin/code-book/codebook.interfaces.ts @@ -1,3 +1,4 @@ +import type { CodeBookContentSetting as SharedCodebookOptions } from '@iqb/ngx-coding-components/codebook-models'; import { VariableInfo } from '@iqbspecs/variable-info/variable-info.interface'; /** @@ -13,27 +14,7 @@ export interface ItemMetadata { export type CodebookExportFormat = 'docx' | 'json'; export type CodebookTrainingRequirementFilter = 'all' | 'required' | 'not-required'; -export interface CodeBookContentSetting { - /** Export format (docx or json) */ - exportFormat: CodebookExportFormat; - /** 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; +export interface CodeBookContentSetting extends SharedCodebookOptions { /** Filter variables by increased coder training requirement */ trainingRequirement?: CodebookTrainingRequirementFilter; /** Restrict variables to a job definition */ @@ -47,7 +28,7 @@ export interface CodeBookContentSetting { */ export interface Missing { /** Missing code */ - code: string; + code: string | number; /** Missing label */ label: string; /** Missing description */ diff --git a/apps/backend/src/app/codebook-image-security.spec.ts b/apps/backend/src/app/codebook-image-security.spec.ts new file mode 100644 index 000000000..6412783a0 --- /dev/null +++ b/apps/backend/src/app/codebook-image-security.spec.ts @@ -0,0 +1,25 @@ +import { spawnSync } from 'node:child_process'; + +/** Exercise the parser actually resolved by the installed CommonJS generator. */ +describe('Codebook image parser security', () => { + it('rejects an ICNS entry that cannot advance instead of looping forever', () => { + const result = spawnSync(process.execPath, ['-e', ` + const assert = require('node:assert/strict'); + const path = require('node:path'); + const entry = require.resolve('@iqb/ngx-coding-components/codebook-generator'); + const parser = require(require.resolve('image-size', { paths: [path.dirname(entry)] })); + const invalid = Buffer.alloc(16); + invalid.write('icns', 0); + invalid.writeUInt32BE(16, 4); + invalid.write('ic07', 8); + invalid.writeUInt32BE(0, 12); + assert.throws(() => parser.imageSize(invalid), /Invalid ICNS/); + const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aX1sAAAAASUVORK5CYII=', 'base64'); + const dimensions = parser.imageSize(png); + assert.equal(dimensions.width, 1); + assert.equal(dimensions.height, 1); + `], { cwd: process.cwd(), timeout: 5000, encoding: 'utf8' }); + expect(result.error).toBeUndefined(); + expect(result.status).toBe(0); + }); +}); diff --git a/apps/backend/src/app/database/services/coding/autocoder-compatibility.spec.ts b/apps/backend/src/app/database/services/coding/autocoder-compatibility.spec.ts new file mode 100644 index 000000000..a319590d0 --- /dev/null +++ b/apps/backend/src/app/database/services/coding/autocoder-compatibility.spec.ts @@ -0,0 +1,197 @@ +import { execFileSync } from 'node:child_process'; +import { CodingSchemeFactory as Reference } from '@iqb/responses-legacy-reference'; +import { CodingSchemeFactory as Current } from '@iqb/responses'; +import { VariableCodingData } from '@iqbspecs/coding-scheme'; +import type { ConfigOptions } from 'mathjs'; +import { CodingSchemeFactory as Compatible } from './generated/autocoder-compatibility-engine'; + +const statuses = [ + 'UNSET', 'NOT_REACHED', 'DISPLAYED', 'VALUE_CHANGED', 'DERIVE_ERROR', 'CODING_COMPLETE', + 'NO_CODING', 'INVALID', 'CODING_INCOMPLETE', 'CODING_ERROR', 'PARTLY_DISPLAYED', + 'DERIVE_PENDING', 'INTENDED_INCOMPLETE', 'CODE_SELECTION_PENDING' +]; +const values = [null, '', 0, 1, -1, 2.5, '0', '2.5', 'a', false, true, [1, 2], [null, 1], ['a', 'b']]; +const sourceTypes = ['MANUAL', 'COPY_VALUE', 'CONCAT_CODE', 'SUM_CODE', 'SUM_SCORE', 'UNIQUE_VALUES', 'SOLVER'] as const; +const solverReference = (variable: string): string => `\${${variable}}`; +const expressions = [ + `${solverReference('A')}+${solverReference('B')}`, `${solverReference('A:0:0')}+${solverReference('B:INC:0')}`, `${solverReference('A')} > 0 ? null : ${solverReference('B')}`, `${solverReference('A')} > 0 ? ${solverReference('B')} : null`, + `${solverReference('A')} / ${solverReference('B')}`, `${solverReference('A')} == ${solverReference('B')} ? 1 : 0`, `round(${solverReference('A')}/${solverReference('B')},2)`, 'null', 'null + 0' +]; +const codes: VariableCodingData['codes'] = [ + { + id: 9, type: 'FULL_CREDIT', score: 9, ruleSets: [{ rules: [{ method: 'IS_NULL' }] }] + }, + ...[0, 1].map(id => ({ + id, + type: 'FULL_CREDIT' as const, + score: id, + ruleSets: [{ rules: [{ method: 'NUMERIC_MATCH' as const, parameters: [String(id)] }] }] + })), + { + id: 3, type: 'RESIDUAL_AUTO', score: 3, ruleSets: [] + } +]; +const serializedInput = (value: unknown) => JSON.parse(JSON.stringify(value)); + +describe('Responses 5.3.1 with the original Kodierbox solver contract', () => { + it('keeps the generated engine reproducible from the installed current package', () => { + execFileSync(process.execPath, ['scripts/build-autocoder-compat.mjs', '--check'], { + cwd: process.cwd(), stdio: 'pipe' + }); + }); + + it.each(sourceTypes)('matches the independent 5.2.0 reference for %s', sourceType => { + let comparisons = 0; + for (const status of statuses) { + for (const value of values) { + for (const aliases of [false, true]) { + for (const subform of [undefined, 'S']) { + for (const expression of sourceType === 'SOLVER' ? expressions : ['']) { + const scheme: VariableCodingData[] = [ + { + id: 'A', alias: aliases ? 'OUT_A' : undefined, sourceType: 'BASE', codes + }, + { + id: 'B', alias: aliases ? 'OUT_B' : undefined, sourceType: 'BASE', codes + }, + { + id: 'D', + alias: aliases ? 'OUT_D' : undefined, + sourceType, + deriveSources: sourceType === 'COPY_VALUE' ? ['A'] : ['A', 'B'], + sourceParameters: { processing: [], solverExpression: expression }, + codes + } + ]; + const input = [ + { + id: aliases ? 'OUT_A' : 'A', status, value, subform, code: 0, score: 0, codes: [{ id: 0 }] + }, + { + id: aliases ? 'OUT_B' : 'B', status: 'CODING_COMPLETE', value: 2, code: 2, score: 2, subform + } + ]; + expect(Compatible.code(serializedInput(input), scheme)).toEqual(Reference.code(serializedInput(input), scheme)); + comparisons += 1; + } + } + } + } + } + expect(comparisons).toBe(sourceType === 'SOLVER' ? 7056 : 784); + }); + + it('preserves existing derived targets, pending states and structural no-value variables', () => { + for (const status of statuses) { + for (const expression of [`ceil(${solverReference('A')})`, `${solverReference('A')} > 0 ? null : ${solverReference('A')}`]) { + const scheme: VariableCodingData[] = [ + { id: 'A', sourceType: 'BASE', codes }, + { id: 'N', sourceType: 'BASE_NO_VALUE', codes: [] }, + { + id: 'D', + sourceType: 'SOLVER', + deriveSources: ['A'], + sourceParameters: { + processing: [], solverExpression: expression + }, + codes + } + ]; + const input = [ + { id: 'A', value: 1e-15, status: 'VALUE_CHANGED' }, + { id: 'N', value: null, status: 'UNSET' }, + { + id: 'D', value: 222, status, code: 7, score: 7 + } + ]; + expect(Compatible.code(serializedInput(input), scheme)).toEqual(Reference.code(serializedInput(input), scheme)); + } + } + }); + + it.each([ + [`ceil(${solverReference('A')})`, 1e-15, 1], [`${solverReference('A')} == 0 ? 1 : 0`, 1e-15, 0], + [`floor(${solverReference('A')})`, -1e-15, -1], [`${solverReference('A')} > 0 ? null : ${solverReference('A')}`, 1, null], + ['min(null)', 1, null], ['max(null)', 1, null], ['1 / 0', 1, null], ['sqrt(-1)', 1, null] + ])('preserves %s including a dependent score sum and separate subforms', (expression, value, expected) => { + const scheme: VariableCodingData[] = [ + { id: 'A', sourceType: 'BASE', codes: [{ id: 1, type: 'RESIDUAL_AUTO', score: 1 }] }, + { + id: 'D', + sourceType: 'SOLVER', + deriveSources: ['A'], + sourceParameters: { processing: [], solverExpression: String(expression) }, + codes + }, + { + id: 'S', sourceType: 'SUM_SCORE', deriveSources: ['D', 'A'], codes + } + ]; + const input = ['first', 'second'].map(subform => ({ + id: 'A', value, subform, status: 'VALUE_CHANGED' + })); + const actual = Compatible.code(serializedInput(input), scheme); + expect(actual).toEqual(Reference.code(serializedInput(input), scheme)); + for (const subform of ['first', 'second']) { + const derived = actual.find(r => r.id === 'D' && r.subform === subform); + expect(derived?.value).toBe(expected); + expect(derived?.status).toBe(expected === null ? 'DERIVE_ERROR' : 'CODING_COMPLETE'); + expect(actual.find(r => r.id === 'S' && r.subform === subform)?.status) + .toBe(expected === null ? 'DERIVE_ERROR' : 'CODING_COMPLETE'); + } + }); + + it('does not modify global math configuration or the normal Responses factory', () => { + // The default mathjs 12 entry has a read-only config() getter. Its .d.ts + // incorrectly reuses the configurable instance's required argument. + const math = jest.requireActual<{ config: () => ConfigOptions }>('mathjs'); + const configuration = math.config(); + const factory = Current.code; + const scheme: VariableCodingData[] = [ + { id: 'A', sourceType: 'BASE', codes: [{ id: 1, type: 'RESIDUAL_AUTO', score: 1 }] }, + { + id: 'D', + sourceType: 'SOLVER', + deriveSources: ['A'], + sourceParameters: { + processing: [], solverExpression: `ceil(${solverReference('A')})` + }, + codes + } + ]; + const input = [{ id: 'A', value: 1e-15, status: 'VALUE_CHANGED' }]; + const before = Current.code(serializedInput(input), scheme); + expect(Compatible.code(serializedInput(input), scheme).find(r => r.id === 'D')?.value).toBe(1); + expect(Current.code(serializedInput(input), scheme)).toEqual(before); + expect(before.find(r => r.id === 'D')?.value).toBe(0); + expect(Current.code).toBe(factory); + expect(math.config()).toEqual(configuration); + }); + + it.each([ + 'config({epsilon:0.1}).epsilon', + 'evaluate("config({epsilon:0.1}).epsilon")', + 'parser().evaluate("config({epsilon:0.1}).epsilon")' + ])('keeps the original read-only solver configuration for %s', expression => { + const scheme: VariableCodingData[] = [ + { id: 'A', sourceType: 'BASE', codes }, + { + id: 'D', + sourceType: 'SOLVER', + deriveSources: ['A'], + sourceParameters: { + processing: [], solverExpression: expression + }, + codes + } + ]; + const input = [{ + id: 'A', value: 1, status: 'CODING_COMPLETE', code: 1, score: 1 + }]; + expect(Compatible.code(serializedInput(input), scheme)).toEqual(Reference.code(serializedInput(input), scheme)); + expect(Compatible.code(serializedInput(input), scheme).find(r => r.id === 'D')?.status).toBe('DERIVE_ERROR'); + scheme[1].sourceParameters!.solverExpression = `ceil(${solverReference('A')})`; + input[0].value = 1e-15; + expect(Compatible.code(serializedInput(input), scheme).find(r => r.id === 'D')?.value).toBe(1); + }); +}); diff --git a/apps/backend/src/app/database/services/coding/autocoder-schema-validation.util.spec.ts b/apps/backend/src/app/database/services/coding/autocoder-schema-validation.util.spec.ts index dca722499..be08b233f 100644 --- a/apps/backend/src/app/database/services/coding/autocoder-schema-validation.util.spec.ts +++ b/apps/backend/src/app/database/services/coding/autocoder-schema-validation.util.spec.ts @@ -165,32 +165,48 @@ describe('applyAutocoderSchemaValidationMode', () => { }); }); + it('does not tolerate an INVALID_SOURCE for a matching uppercase NO_VALUE variable', () => { + const invalidSource = problem('INVALID_SOURCE'); + const variable: VariableInfo = JSON.parse(JSON.stringify({ + ...baseVariable('variable-1', 'no-value'), type: 'NO_VALUE' + })); + expect(applyAutocoderSchemaValidationMode( + [invalidSource], 'compatible', [variable], [coding('variable-1', 'BASE_NO_VALUE')] + )).toEqual({ blockingProblems: [invalidSource], toleratedProblems: [] }); + }); + it.each([ - ['BASE', 'no-value'], - ['BASE_NO_VALUE', 'string'] + ['BASE', 'no-value', false], + ['BASE', 'NO_VALUE', false], + ['BASE_NO_VALUE', 'string', true], + ['BASE_NO_VALUE', 'STRING', true] ] as const)( - 'tolerates the actual validator result for %s against %s', - (sourceType, variableType) => { - const baseVariables = [baseVariable('variable-1', variableType)]; + 'preserves the current validator contract for %s against %s', + (sourceType, variableType, hasSourceMismatch) => { + const baseVariables: VariableInfo[] = [JSON.parse(JSON.stringify({ + ...baseVariable('variable-1', 'string'), type: variableType + }))]; const variableCodings = [coding('variable-1', sourceType)]; const problems = Autocoder.CodingSchemeFactory.validate( baseVariables, variableCodings ); - expect(problems).toEqual(expect.arrayContaining([ - expect.objectContaining({ - type: 'INVALID_SOURCE', - breaking: true, - variableId: 'variable-1' - }) - ])); + const breakingProblems = problems.filter(entry => entry.breaking); + expect(breakingProblems).toEqual(hasSourceMismatch ? [expect.objectContaining({ + type: 'INVALID_SOURCE', + breaking: true, + variableId: 'variable-1' + })] : []); expect(applyAutocoderSchemaValidationMode( problems, 'compatible', baseVariables, variableCodings - ).blockingProblems).toEqual([]); + )).toEqual({ blockingProblems: [], toleratedProblems: breakingProblems }); + expect(applyAutocoderSchemaValidationMode( + problems, 'strict', baseVariables, variableCodings + ).blockingProblems).toEqual(breakingProblems); } ); diff --git a/apps/backend/src/app/database/services/coding/autocoder-schema-validation.util.ts b/apps/backend/src/app/database/services/coding/autocoder-schema-validation.util.ts index 81adea1cf..7035e13fe 100644 --- a/apps/backend/src/app/database/services/coding/autocoder-schema-validation.util.ts +++ b/apps/backend/src/app/database/services/coding/autocoder-schema-validation.util.ts @@ -1,4 +1,5 @@ import type { CodingSchemeProblem } from '@iqb/responses/coding-interfaces'; +import { normalizeVariableInfoType } from '@iqb/responses'; import type { VariableCodingData } from '@iqbspecs/coding-scheme'; import type { VariableInfo } from '@iqbspecs/variable-info/variable-info.interface'; import { AutocoderSchemaValidationMode } from '../../../config/runtime-config.service'; @@ -41,9 +42,8 @@ function isCompatibleLegacyBaseProblem( return false; } - return coding.sourceType === 'BASE' ? - matchingBaseVariables[0].type === 'no-value' : - matchingBaseVariables[0].type !== 'no-value'; + const variableType = normalizeVariableInfoType(matchingBaseVariables[0].type); + return coding.sourceType === 'BASE' ? variableType === 'NO_VALUE' : variableType !== 'NO_VALUE'; } export function applyAutocoderSchemaValidationMode( diff --git a/apps/backend/src/app/database/services/coding/autocoder-solver-math.ts b/apps/backend/src/app/database/services/coding/autocoder-solver-math.ts new file mode 100644 index 000000000..17ecf6535 --- /dev/null +++ b/apps/backend/src/app/database/services/coding/autocoder-solver-math.ts @@ -0,0 +1,26 @@ +import { all, create } from 'mathjs'; + +// A private instance of the original solver's mathjs 12.4.3. Do not configure +// the shared mathjs instance or change the math used by the Responses peer. +const solverMath = create(all, { epsilon: 1e-12 }); +// The old default mathjs entry exposed a read-only config function. Preserve +// that contract inside the private instance, including nested evaluate/parser +// calls. Keep the function's option properties used by mathjs factories. +const originalConfig = solverMath.config; +const configuration = Object.freeze(originalConfig({})); +const readOnlyConfig = Object.assign((options?: unknown) => { + if (options) throw new Error('The solver configuration is read-only.'); + return configuration; +}, originalConfig); +solverMath.import({ config: readOnlyConfig }, { override: true }); + +export function evaluate(expression: string): unknown { + const value: unknown = solverMath.evaluate(expression); + // Responses 5.2.0 rejected non-numeric solver results, including null. Throw + // inside evaluation so 5.3.1's existing error path retains DERIVE_ERROR before + // code matching or dependent derivations can turn it into a completed result. + if (value === null) { + throw new Error('SOLVER must produce a finite number.'); + } + return value; +} diff --git a/apps/backend/src/app/database/services/coding/codebook-generation.service.spec.ts b/apps/backend/src/app/database/services/coding/codebook-generation.service.spec.ts index 80634132d..65a39f731 100644 --- a/apps/backend/src/app/database/services/coding/codebook-generation.service.spec.ts +++ b/apps/backend/src/app/database/services/coding/codebook-generation.service.spec.ts @@ -50,6 +50,19 @@ describe('CodebookGenerationService', () => { ); }); + it.each([0, '0', 99])('preserves missing code %p and its JSON type like Studio', async code => { + repository.find.mockResolvedValue([{ + id: 1, file_id: 'A.VOCS', filename: 'Aufgabe.vocs', data: '{"version":"3.4","variableCodings":[]}', structured_data: null + }]); + missingsProfilesService.getMissingsProfileDetails.mockResolvedValue({ + missings: JSON.stringify([{ code, label: 'Missing', description: 'Keine Antwort' }]) + }); + await service.generateCodebook(7, 8, contentOptions, [1]); + expect(CodebookGenerator.generateCodebook).toHaveBeenCalledWith( + expect.any(Array), contentOptions, [{ code, label: 'Missing', description: 'Keine Antwort' }] + ); + }); + it('loads codebook units only from the requested workspace and preserves request order', async () => { repository.find.mockResolvedValue([ { diff --git a/apps/backend/src/app/database/services/coding/codebook-generation.service.ts b/apps/backend/src/app/database/services/coding/codebook-generation.service.ts index d4b46534a..1bcfe0a53 100644 --- a/apps/backend/src/app/database/services/coding/codebook-generation.service.ts +++ b/apps/backend/src/app/database/services/coding/codebook-generation.service.ts @@ -107,7 +107,7 @@ export class CodebookGenerationService { profile.missings; if (Array.isArray(profileMissings) && profileMissings.length > 0) { missings = profileMissings.map(m => ({ - code: m.code.toString(), + code: m.code, label: m.label, description: m.description })); diff --git a/apps/backend/src/app/database/services/coding/coding-process.service.spec.ts b/apps/backend/src/app/database/services/coding/coding-process.service.spec.ts index da17b0719..50bba98bf 100644 --- a/apps/backend/src/app/database/services/coding/coding-process.service.spec.ts +++ b/apps/backend/src/app/database/services/coding/coding-process.service.spec.ts @@ -24,13 +24,17 @@ jest.mock('@iqb/responses', () => ({ code: jest.fn().mockReturnValue([]), validate: jest.fn().mockReturnValue([]), getVariableDependencyTree: jest.fn().mockImplementation( - variableCodings => jest.requireActual( - '@iqb/responses' + variableCodings => jest.requireActual( + './generated/autocoder-compatibility-engine' ).CodingSchemeFactory.getVariableDependencyTree(variableCodings) ) } })); +jest.mock('./generated/autocoder-compatibility-engine', () => ({ + CodingSchemeFactory: jest.requireMock('@iqb/responses').CodingSchemeFactory +})); + jest.mock('cheerio', () => ({ load: jest.fn().mockImplementation(() => (selector: string) => ({ text: jest.fn().mockReturnValue( @@ -494,8 +498,8 @@ describe('CodingProcessService', () => { ] ) => { const actualAutocoder = jest.requireActual< - typeof import('@iqb/responses') - >('@iqb/responses'); + typeof import('./generated/autocoder-compatibility-engine') + >('./generated/autocoder-compatibility-engine'); (Autocoder.CodingSchemeFactory.code as jest.Mock).mockImplementation( actualAutocoder.CodingSchemeFactory.code ); @@ -3026,6 +3030,99 @@ describe('CodingProcessService', () => { ])).toThrow('Autocoder produced multiple updates for generated:1:03:'); }); + it('preserves legacy pending selection and Code 0 through the real current engine', () => { + const actualAutocoder = jest.requireActual( + './generated/autocoder-compatibility-engine' + ); + (Autocoder.CodingSchemeFactory.code as jest.Mock) + .mockImplementationOnce(actualAutocoder.CodingSchemeFactory.code); + const codeResponses = ( + service as unknown as { + codeAutocoderResponses: ( + responses: Array>, + variableCodings: VariableCodingData[] + ) => Array>; + } + ).codeAutocoderResponses.bind(service); + const input = { + id: 'public-alias', + subform: 'part-a', + status: 'CODE_SELECTION_PENDING', + value: 'A', + code: 0, + score: 0, + codes: [{ id: 0, parameter: 'legacy selection' }] + }; + const result = codeResponses([input], [{ + id: 'technical-id', + alias: 'public-alias', + sourceType: 'BASE', + codes: [{ + id: 1, + type: 'FULL_CREDIT', + score: 1, + ruleSets: [{ rules: [{ method: 'MATCH', parameters: ['A'] }] }] + }] + }]); + expect(result).toEqual([input]); + expect(input.status).toBe('CODE_SELECTION_PENDING'); + expect(input.codes).toEqual([{ id: 0, parameter: 'legacy selection' }]); + }); + + it.each([ + ['ceil near zero', 1e-15, `ceil(${solverReference('A')})`, 'CODING_COMPLETE', 1, 1], + ['equality near zero', 1e-15, `${solverReference('A')} == 0 ? 1 : 0`, 'CODING_COMPLETE', 0, 0], + ['null branch', 1, `${solverReference('A')} > 0 ? null : ${solverReference('A')}`, 'DERIVE_ERROR', undefined, undefined] + ])('preserves legacy SOLVER %s through alias routing and coding', ( + _name, value, expression, status, code, score + ) => { + const actualAutocoder = jest.requireActual( + './generated/autocoder-compatibility-engine' + ); + (Autocoder.CodingSchemeFactory.code as jest.Mock) + .mockImplementationOnce(actualAutocoder.CodingSchemeFactory.code); + const codeResponses = ( + service as unknown as { + codeAutocoderResponses: ( + responses: Array>, + variableCodings: VariableCodingData[] + ) => Array>; + } + ).codeAutocoderResponses.bind(service); + const result = codeResponses([{ + id: 'A', value, status: 'VALUE_CHANGED', subform: 'part-a' + }], [ + { + id: 'source', alias: 'A', sourceType: 'BASE', codes: [] + }, + { + id: 'derived', + alias: 'D', + sourceType: 'SOLVER', + deriveSources: ['source'], + sourceParameters: { processing: [], solverExpression: String(expression) }, + codes: [ + { + id: 9, type: 'FULL_CREDIT', score: 9, ruleSets: [{ rules: [{ method: 'IS_NULL' }] }] + }, + ...[0, 1].map(id => ({ + id, + type: 'FULL_CREDIT' as const, + score: id, + ruleSets: [{ rules: [{ method: 'NUMERIC_MATCH' as const, parameters: [String(id)] }] }] + })) + ] + } + ]); + expect(result.find(response => response.id === 'D')).toEqual({ + id: 'D', + value: status === 'DERIVE_ERROR' ? null : code, + status, + subform: 'part-a', + ...(code === undefined ? {} : { code, score }) + }); + }); + it('rejects genuinely duplicate output aliases before coding', () => { const createNamespace = ( service as unknown as { @@ -3087,8 +3184,8 @@ describe('CodingProcessService', () => { }); it('codes solver expressions after canonical namespace routing', () => { - const actualAutocoder = jest.requireActual( - '@iqb/responses' + const actualAutocoder = jest.requireActual( + './generated/autocoder-compatibility-engine' ); (Autocoder.CodingSchemeFactory.code as jest.Mock) .mockImplementationOnce(actualAutocoder.CodingSchemeFactory.code); @@ -3182,8 +3279,8 @@ describe('CodingProcessService', () => { } } ])('$caseName for SUM_SCORE', ({ sourceTuples, expected }) => { - const actualAutocoder = jest.requireActual( - '@iqb/responses' + const actualAutocoder = jest.requireActual( + './generated/autocoder-compatibility-engine' ); (Autocoder.CodingSchemeFactory.code as jest.Mock) .mockImplementationOnce(actualAutocoder.CodingSchemeFactory.code); @@ -3534,8 +3631,8 @@ describe('CodingProcessService', () => { }); it('keeps a real response status instead of an alias-chain placeholder', () => { - const actualAutocoder = jest.requireActual( - '@iqb/responses' + const actualAutocoder = jest.requireActual( + './generated/autocoder-compatibility-engine' ); (Autocoder.CodingSchemeFactory.code as jest.Mock) .mockImplementationOnce(actualAutocoder.CodingSchemeFactory.code); @@ -3606,8 +3703,8 @@ describe('CodingProcessService', () => { }); it('accepts generated output aliases from a previous run', async () => { - const actualAutocoder = jest.requireActual( - '@iqb/responses' + const actualAutocoder = jest.requireActual( + './generated/autocoder-compatibility-engine' ); (Autocoder.CodingSchemeFactory.validate as jest.Mock) .mockImplementationOnce(actualAutocoder.CodingSchemeFactory.validate); @@ -3738,8 +3835,8 @@ describe('CodingProcessService', () => { }); it('resolves the supported base-to-derived shadow without first-wins behavior', async () => { - const actualAutocoder = jest.requireActual( - '@iqb/responses' + const actualAutocoder = jest.requireActual( + './generated/autocoder-compatibility-engine' ); (Autocoder.CodingSchemeFactory.validate as jest.Mock) .mockImplementationOnce(actualAutocoder.CodingSchemeFactory.validate); @@ -3827,8 +3924,8 @@ describe('CodingProcessService', () => { }); it('updates the existing MMB102 target through its BASE_NO_VALUE shadow', async () => { - const actualAutocoder = jest.requireActual( - '@iqb/responses' + const actualAutocoder = jest.requireActual( + './generated/autocoder-compatibility-engine' ); (Autocoder.CodingSchemeFactory.code as jest.Mock) .mockImplementation(actualAutocoder.CodingSchemeFactory.code); @@ -3938,8 +4035,8 @@ describe('CodingProcessService', () => { }); it('accepts a run-1-generated MMB102 target through its BASE_NO_VALUE shadow', () => { - const actualAutocoder = jest.requireActual( - '@iqb/responses' + const actualAutocoder = jest.requireActual( + './generated/autocoder-compatibility-engine' ); (Autocoder.CodingSchemeFactory.code as jest.Mock) .mockImplementation(actualAutocoder.CodingSchemeFactory.code); @@ -4020,8 +4117,8 @@ describe('CodingProcessService', () => { }); it('keeps partial MMB102 sources invalid without leaking the structural target', () => { - const actualAutocoder = jest.requireActual( - '@iqb/responses' + const actualAutocoder = jest.requireActual( + './generated/autocoder-compatibility-engine' ); (Autocoder.CodingSchemeFactory.code as jest.Mock) .mockImplementation(actualAutocoder.CodingSchemeFactory.code); @@ -4091,8 +4188,8 @@ describe('CodingProcessService', () => { }); it('derives MZV005 item 02 only from its own source pair', () => { - const actualAutocoder = jest.requireActual( - '@iqb/responses' + const actualAutocoder = jest.requireActual( + './generated/autocoder-compatibility-engine' ); (Autocoder.CodingSchemeFactory.code as jest.Mock) .mockImplementation(actualAutocoder.CodingSchemeFactory.code); diff --git a/apps/backend/src/app/database/services/coding/coding-process.service.ts b/apps/backend/src/app/database/services/coding/coding-process.service.ts index bbb69d271..d49776f72 100644 --- a/apps/backend/src/app/database/services/coding/coding-process.service.ts +++ b/apps/backend/src/app/database/services/coding/coding-process.service.ts @@ -4,7 +4,6 @@ import { Brackets, EntityManager, In, Repository, QueryRunner } from 'typeorm'; import { VariableCodingData, CodingScheme } from '@iqbspecs/coding-scheme'; -import type { Response as AutocoderResponse } from '@iqbspecs/response/response.interface'; import * as Autocoder from '@iqb/responses'; import * as cheerio from 'cheerio'; @@ -49,6 +48,11 @@ import { } from '../shared/workspace-files-lock.util'; import { RuntimeConfigService } from '../../../config/runtime-config.service'; import { applyAutocoderSchemaValidationMode } from './autocoder-schema-validation.util'; +import { CodingSchemeFactory as CompatibleCodingSchemeFactory } from './generated/autocoder-compatibility-engine'; + +// Use the installed engine's contract here. The application's persistence and +// manual-coding contract remains response 1.5.1, including legacy status 13. +type AutocoderResponse = Parameters[0][number]; type UnitCodingJobMetadata = { source?: 'manual-selection' | 'coding-freshness'; @@ -1434,7 +1438,7 @@ export class CodingProcessService { return { id: String(response.variableid), value: responseValue, - status: statusNumberToString(inputStatus) as import('@iqbspecs/response/response.interface').ResponseStatusType, + status: statusNumberToString(inputStatus) as AutocoderResponse['status'], subform: response.subform, code: inputCode, score: inputScore @@ -2066,7 +2070,7 @@ export class CodingProcessService { canonicalInputIndexes.set(key, index); }); - const canonicalResults = Autocoder.CodingSchemeFactory.code( + const canonicalResults = CompatibleCodingSchemeFactory.code( canonicalResponses, namespace.variableCodings ); diff --git a/apps/backend/src/app/database/services/coding/generated/autocoder-compatibility-engine.d.ts b/apps/backend/src/app/database/services/coding/generated/autocoder-compatibility-engine.d.ts new file mode 100644 index 000000000..e5b5edfe6 --- /dev/null +++ b/apps/backend/src/app/database/services/coding/generated/autocoder-compatibility-engine.d.ts @@ -0,0 +1,2 @@ +// The generated bundle retains the public factory contract from Responses. +export { CodingSchemeFactory } from '@iqb/responses'; diff --git a/apps/backend/src/app/database/services/coding/generated/autocoder-compatibility-engine.js b/apps/backend/src/app/database/services/coding/generated/autocoder-compatibility-engine.js new file mode 100644 index 000000000..0d3645c1b --- /dev/null +++ b/apps/backend/src/app/database/services/coding/generated/autocoder-compatibility-engine.js @@ -0,0 +1,2034 @@ +// Generated by node scripts/build-autocoder-compat.mjs; do not edit. +// Engine: @iqb/responses 5.3.1, IQB, CC0-1.0. Only solver math is scoped to +// autocoder-solver-math.ts / mathjs 12.4.3 (Apache-2.0), preserving 5.2.0 behaviour. +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __esm = (fn, res, err) => function __init() { + if (err) throw err[0]; + try { + return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res; + } catch (e) { + throw err = [e], e; + } +}; +var __commonJS = (cb, mod) => function __require() { + try { + return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; + } catch (e) { + throw mod = 0, e; + } +}; +var __export = (target, all2) => { + for (var name in all2) + __defProp(target, name, { get: all2[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// node_modules/@iqb/responses/utils/deep-clone.js +var require_deep_clone = __commonJS({ + "node_modules/@iqb/responses/utils/deep-clone.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.deepClone = void 0; + function deepClone(obj) { + if (typeof structuredClone === "function") { + return structuredClone(obj); + } + return JSON.parse(JSON.stringify(obj)); + } + exports2.deepClone = deepClone; + } +}); + +// node_modules/@iqb/responses/graph/dependency-tree.js +var require_dependency_tree = __commonJS({ + "node_modules/@iqb/responses/graph/dependency-tree.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.getVariableDependencyTree = void 0; + var resolveCommonPage = (currentPage, newPage) => { + if (currentPage === null) { + return newPage; + } + return currentPage === newPage ? currentPage : ""; + }; + var getVariableDependencyTree = (variableCodings) => { + const graph = variableCodings.filter((c) => c.sourceType === "BASE").map((c) => ({ + id: c.id, + level: 0, + sources: [], + page: c.page || "" + })); + const baseNoValueCount = variableCodings.filter((c) => c.sourceType === "BASE_NO_VALUE").length; + const maxGraphLength = variableCodings.length - baseNoValueCount; + let dependenciesResolved = true; + while (dependenciesResolved && graph.length < maxGraphLength) { + dependenciesResolved = false; + const processVariableCoding = (vc) => { + var _a; + const existingNode = graph.find((node) => node.id === vc.id); + if (vc.sourceType !== "BASE_NO_VALUE" && !existingNode) { + const deriveSources = (_a = vc.deriveSources) !== null && _a !== void 0 ? _a : []; + let maxLevel = 0; + let commonPage = null; + const allSourcesResolved = deriveSources.every((sourceId) => { + const sourceNode = graph.find((node) => node.id === sourceId); + if (sourceNode) { + maxLevel = Math.max(maxLevel, sourceNode.level); + commonPage = resolveCommonPage(commonPage, sourceNode.page); + return true; + } + return false; + }); + if (allSourcesResolved) { + graph.push({ + id: vc.id, + level: maxLevel + 1, + sources: [...deriveSources], + page: commonPage || "" + }); + return true; + } + } + return false; + }; + let anyDependenciesResolved = false; + variableCodings.forEach((vc) => { + if (processVariableCoding(vc)) { + anyDependenciesResolved = true; + } + }); + dependenciesResolved = anyDependenciesResolved; + } + if (dependenciesResolved) { + return graph; + } + throw new Error("Circular dependency detected in the coding scheme"); + }; + exports2.getVariableDependencyTree = getVariableDependencyTree; + } +}); + +// apps/backend/src/app/database/services/coding/autocoder-solver-math.ts +var autocoder_solver_math_exports = {}; +__export(autocoder_solver_math_exports, { + evaluate: () => evaluate +}); +function evaluate(expression) { + const value = solverMath.evaluate(expression); + if (value === null) { + throw new Error("SOLVER must produce a finite number."); + } + return value; +} +var import_mathjs, solverMath, originalConfig, configuration, readOnlyConfig; +var init_autocoder_solver_math = __esm({ + "apps/backend/src/app/database/services/coding/autocoder-solver-math.ts"() { + import_mathjs = require("mathjs"); + solverMath = (0, import_mathjs.create)(import_mathjs.all, { epsilon: 1e-12 }); + originalConfig = solverMath.config; + configuration = Object.freeze(originalConfig({})); + readOnlyConfig = Object.assign((options) => { + if (options) throw new Error("The solver configuration is read-only."); + return configuration; + }, originalConfig); + solverMath.import({ config: readOnlyConfig }, { override: true }); + } +}); + +// node_modules/@iqb/responses/constants.js +var require_constants = __commonJS({ + "node_modules/@iqb/responses/constants.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.VALID_STATES_TO_START_DERIVE_PENDING_CHECK = exports2.DERIVE_PENDING_STATUSES = exports2.PARTLY_DISPLAYED_STATUSES = exports2.CONCAT_SUM_VALID_STATES = exports2.COPY_SOLVER_VALID_STATES = exports2.MANUAL_VALID_STATES = exports2.CODING_SCHEME_STATUS = void 0; + exports2.CODING_SCHEME_STATUS = { + CODING_INCOMPLETE: "CODING_INCOMPLETE", + CODING_COMPLETE: "CODING_COMPLETE", + CODING_ERROR: "CODING_ERROR", + DERIVE_PENDING: "DERIVE_PENDING", + DERIVE_ERROR: "DERIVE_ERROR", + DISPLAYED: "DISPLAYED", + PARTLY_DISPLAYED: "PARTLY_DISPLAYED", + NOT_REACHED: "NOT_REACHED", + INVALID: "INVALID", + INTENDED_INCOMPLETE: "INTENDED_INCOMPLETE", + NO_CODING: "NO_CODING", + UNSET: "UNSET", + VALUE_CHANGED: "VALUE_CHANGED" + }; + exports2.MANUAL_VALID_STATES = [ + exports2.CODING_SCHEME_STATUS.INVALID, + exports2.CODING_SCHEME_STATUS.VALUE_CHANGED, + exports2.CODING_SCHEME_STATUS.NO_CODING, + exports2.CODING_SCHEME_STATUS.CODING_ERROR, + exports2.CODING_SCHEME_STATUS.CODING_COMPLETE, + exports2.CODING_SCHEME_STATUS.INTENDED_INCOMPLETE + ]; + exports2.COPY_SOLVER_VALID_STATES = [ + exports2.CODING_SCHEME_STATUS.VALUE_CHANGED, + exports2.CODING_SCHEME_STATUS.NO_CODING, + exports2.CODING_SCHEME_STATUS.CODING_INCOMPLETE, + exports2.CODING_SCHEME_STATUS.CODING_ERROR, + exports2.CODING_SCHEME_STATUS.CODING_COMPLETE, + exports2.CODING_SCHEME_STATUS.INTENDED_INCOMPLETE + ]; + exports2.CONCAT_SUM_VALID_STATES = [ + exports2.CODING_SCHEME_STATUS.CODING_COMPLETE, + exports2.CODING_SCHEME_STATUS.INTENDED_INCOMPLETE + ]; + exports2.PARTLY_DISPLAYED_STATUSES = [ + exports2.CODING_SCHEME_STATUS.NOT_REACHED, + exports2.CODING_SCHEME_STATUS.DISPLAYED, + exports2.CODING_SCHEME_STATUS.PARTLY_DISPLAYED + ]; + exports2.DERIVE_PENDING_STATUSES = [ + exports2.CODING_SCHEME_STATUS.CODING_INCOMPLETE, + exports2.CODING_SCHEME_STATUS.DERIVE_PENDING + ]; + exports2.VALID_STATES_TO_START_DERIVE_PENDING_CHECK = [ + exports2.CODING_SCHEME_STATUS.CODING_INCOMPLETE, + exports2.CODING_SCHEME_STATUS.CODING_COMPLETE, + exports2.CODING_SCHEME_STATUS.DERIVE_PENDING, + exports2.CODING_SCHEME_STATUS.INTENDED_INCOMPLETE + ]; + } +}); + +// node_modules/@iqb/responses/value-transform.js +var require_value_transform = __commonJS({ + "node_modules/@iqb/responses/value-transform.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.isEmptyValue = exports2.transformValue = exports2.getValueAsString = exports2.getValueAsNumber = exports2.transformString = exports2.compileFragmentRegExp = void 0; + function compileFragmentRegExp(fragmenting) { + return fragmenting ? new RegExp(fragmenting, "g") : void 0; + } + exports2.compileFragmentRegExp = compileFragmentRegExp; + function transformString(value, processing, fragmentExp) { + if (fragmentExp) { + const matchResult = [...value.matchAll(fragmentExp)]; + return matchResult.length > 0 ? matchResult[0].slice(1) : []; + } + const shouldRemoveAllSpaces = processing.includes("REMOVE_ALL_SPACES") || processing.includes("IGNORE_ALL_SPACES"); + const shouldTrimAndNormalizeSpaces = processing.includes("REMOVE_DISPENSABLE_SPACES") || processing.includes("IGNORE_DISPENSABLE_SPACES"); + const shouldConvertToLowerCase = processing.includes("IGNORE_CASE") || processing.includes("TO_LOWER_CASE"); + let transformedString = value; + if (shouldRemoveAllSpaces && transformedString) { + transformedString = transformedString.replace(/\s+/g, ""); + } + if (shouldTrimAndNormalizeSpaces && transformedString) { + transformedString = transformedString.trim().replace(/\s+/g, " "); + } + if (shouldConvertToLowerCase && transformedString) { + transformedString = transformedString.toLowerCase(); + } + return transformedString; + } + exports2.transformString = transformString; + function getValueAsNumber(value) { + if (value === null || value === "") { + return 0; + } + if (typeof value === "number") { + return value; + } + if (typeof value === "boolean") { + return value ? 1 : 0; + } + let normalizedString = value.trim(); + normalizedString = normalizedString.replace(/\s+/g, "").replace(",", "."); + const isInvalidNumber = !/^[-+]?\d+(\.\d+)?$/.test(normalizedString); + if (isInvalidNumber) { + return null; + } + const parsedValue = Number.parseFloat(normalizedString); + return Number.isNaN(parsedValue) ? null : parsedValue; + } + exports2.getValueAsNumber = getValueAsNumber; + function getValueAsString(value, processing = []) { + if (typeof value === "number") { + return value.toString(10); + } + if (typeof value === "boolean") { + return value ? "true" : "false"; + } + if (typeof value === "string") { + let processedString = value; + if (processing.includes("REMOVE_ALL_SPACES") || processing.includes("IGNORE_ALL_SPACES")) { + processedString = processedString.replace(/\s/g, ""); + } else if (processing.includes("REMOVE_DISPENSABLE_SPACES") || processing.includes("IGNORE_DISPENSABLE_SPACES")) { + processedString = processedString.trim().replace(/\s+/g, " "); + } + if (processing.includes("TO_LOWER_CASE")) { + processedString = processedString.toLowerCase(); + } + return processedString; + } + return null; + } + exports2.getValueAsString = getValueAsString; + function transformValue(value, fragmenting, sortArray) { + const fragmentRegEx = compileFragmentRegExp(fragmenting); + const transformIfString = (v) => { + if (typeof v === "string") { + return transformString(v, [], fragmentRegEx); + } + return v; + }; + if (Array.isArray(value)) { + const sorted = sortArray ? [...value] : value; + if (sortArray) { + sorted.sort((a, b) => { + const aAsString = getValueAsString(a) || ""; + const bAsString = getValueAsString(b) || ""; + return aAsString.localeCompare(bAsString); + }); + } + return sorted.map(transformIfString); + } + if (typeof value === "string") { + return transformString(value, [], fragmentRegEx); + } + return value; + } + exports2.transformValue = transformValue; + function isEmptyValue(value) { + return value === "" || Array.isArray(value) && value.length === 0; + } + exports2.isEmptyValue = isEmptyValue; + } +}); + +// node_modules/@iqb/responses/rule-engine/scalar-evaluator.js +var require_scalar_evaluator = __commonJS({ + "node_modules/@iqb/responses/rule-engine/scalar-evaluator.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.checkOneValue = void 0; + var value_transform_1 = require_value_transform(); + function findString(value, codingProcessing, parameters = []) { + const allStrings = parameters.flatMap((p) => p.split(/\r?\n/)).map((s) => { + const transformed = (0, value_transform_1.transformString)(s, codingProcessing); + return Array.isArray(transformed) ? transformed[0] || "" : transformed; + }); + let stringToCompare = (0, value_transform_1.transformString)(value, codingProcessing); + if (Array.isArray(stringToCompare)) { + stringToCompare = stringToCompare[0] || ""; + } + return allStrings.includes(stringToCompare); + } + function findStringRegEx(value, parameters, addCaseIgnoreFlag) { + const allStrings = parameters.flatMap((p) => p.split(/\r?\n/)); + return allStrings.some((s) => { + try { + const regEx = new RegExp(s, addCaseIgnoreFlag ? "i" : void 0); + return regEx.test(value); + } catch (e) { + return false; + } + }); + } + function findNumericValue(value, parameters = []) { + if (value === null || value === "") { + return false; + } + const allCompareValues = parameters.flatMap((p) => p.split(/\r?\n/)).map((s) => (0, value_transform_1.getValueAsNumber)(s)); + const valueAsNumber = (0, value_transform_1.getValueAsNumber)(value); + return valueAsNumber !== null && allCompareValues.includes(valueAsNumber); + } + function checkOneValue(valueToCheck, rule, codingProcessing) { + let returnValue = false; + let valueAsNumber = null; + switch (rule.method) { + case "IS_NULL": + if (valueToCheck === null) + returnValue = true; + break; + case "IS_EMPTY": + if ((0, value_transform_1.isEmptyValue)(valueToCheck)) + returnValue = true; + break; + case "MATCH": + if (valueToCheck !== null && valueToCheck !== "") { + let valueAsString = valueToCheck; + if (typeof valueToCheck === "number") { + valueAsString = valueToCheck.toString(10); + } else if (typeof valueToCheck === "boolean") { + valueAsString = valueToCheck.toString(); + } + returnValue = findString(valueAsString, codingProcessing, rule.parameters); + } + break; + case "MATCH_REGEX": + if (valueToCheck !== null && valueToCheck !== "") { + let valueAsString = valueToCheck; + if (typeof valueToCheck === "number") { + valueAsString = valueToCheck.toString(10); + } else if (typeof valueToCheck === "boolean") { + valueAsString = valueToCheck.toString(); + } + returnValue = findStringRegEx(valueAsString, rule.parameters || [], codingProcessing.includes("IGNORE_CASE")); + } + break; + case "NUMERIC_MATCH": + returnValue = findNumericValue(valueToCheck, rule.parameters); + break; + case "NUMERIC_LESS_THAN": + if (valueToCheck !== null && valueToCheck !== "") { + valueAsNumber = (0, value_transform_1.getValueAsNumber)(valueToCheck); + if (typeof valueAsNumber === "number" && rule.parameters) { + const compareValue = Number.parseFloat(rule.parameters[0]); + returnValue = !Number.isNaN(compareValue) && valueAsNumber < compareValue; + } + } + break; + case "NUMERIC_MAX": + if (valueToCheck !== null && valueToCheck !== "") { + valueAsNumber = (0, value_transform_1.getValueAsNumber)(valueToCheck); + if (typeof valueAsNumber === "number" && rule.parameters) { + const compareValue = Number.parseFloat(rule.parameters[0]); + returnValue = !Number.isNaN(compareValue) && valueAsNumber <= compareValue; + } + } + break; + case "NUMERIC_MORE_THAN": + if (valueToCheck !== null && valueToCheck !== "") { + valueAsNumber = (0, value_transform_1.getValueAsNumber)(valueToCheck); + if (typeof valueAsNumber === "number" && rule.parameters) { + const compareValue = Number.parseFloat(rule.parameters[0]); + returnValue = !Number.isNaN(compareValue) && valueAsNumber > compareValue; + } + } + break; + case "NUMERIC_MIN": + if (valueToCheck !== null && valueToCheck !== "") { + valueAsNumber = (0, value_transform_1.getValueAsNumber)(valueToCheck); + if (typeof valueAsNumber === "number" && rule.parameters) { + const compareValue = Number.parseFloat(rule.parameters[0]); + returnValue = !Number.isNaN(compareValue) && valueAsNumber >= compareValue; + } + } + break; + case "NUMERIC_RANGE": + if (valueToCheck !== null && valueToCheck !== "") { + valueAsNumber = (0, value_transform_1.getValueAsNumber)(valueToCheck); + if (typeof valueAsNumber === "number" && rule.parameters) { + const compareValueLL = (0, value_transform_1.getValueAsNumber)(rule.parameters[0]); + const compareValueUL = (0, value_transform_1.getValueAsNumber)(rule.parameters[1]); + if (typeof compareValueLL === "number" && typeof compareValueUL === "number") { + returnValue = valueAsNumber > compareValueLL && valueAsNumber <= compareValueUL; + } + } + } + break; + case "NUMERIC_FULL_RANGE": + if (valueToCheck !== null && valueToCheck !== "") { + valueAsNumber = (0, value_transform_1.getValueAsNumber)(valueToCheck); + if (typeof valueAsNumber === "number" && rule.parameters) { + const compareValueLL = (0, value_transform_1.getValueAsNumber)(rule.parameters[0]); + const compareValueUL = (0, value_transform_1.getValueAsNumber)(rule.parameters[1]); + if (typeof compareValueLL === "number" && typeof compareValueUL === "number") { + returnValue = valueAsNumber >= compareValueLL && valueAsNumber <= compareValueUL; + } + } + } + break; + case "IS_TRUE": + returnValue = valueToCheck === 1 || valueToCheck === "1" || valueToCheck === true || valueToCheck === "true"; + break; + case "IS_FALSE": + returnValue = valueToCheck === 0 || valueToCheck === "0" || valueToCheck === false || valueToCheck === "false"; + break; + } + return returnValue; + } + exports2.checkOneValue = checkOneValue; + } +}); + +// node_modules/@iqb/responses/rule-engine/rule-matcher.js +var require_rule_matcher = __commonJS({ + "node_modules/@iqb/responses/rule-engine/rule-matcher.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.isMatchRule = void 0; + var scalar_evaluator_1 = require_scalar_evaluator(); + function isMatchRule(valueToCheck, rule, isValueArray, codingProcessing) { + if (Array.isArray(valueToCheck) && isValueArray) { + if (valueToCheck.length === 0) { + return (0, scalar_evaluator_1.checkOneValue)("", rule, codingProcessing); + } + return valueToCheck.some((valueMember) => { + if (Array.isArray(valueMember)) { + if (typeof rule.fragment === "undefined" || rule.fragment < 0) { + return valueMember.some((fragment) => (0, scalar_evaluator_1.checkOneValue)(fragment, rule, codingProcessing)); + } + return (0, scalar_evaluator_1.checkOneValue)(valueMember[rule.fragment], rule, codingProcessing); + } + return (0, scalar_evaluator_1.checkOneValue)(valueMember, rule, codingProcessing); + }); + } + if (Array.isArray(valueToCheck)) { + const { fragment } = rule; + if (fragment == null || fragment < 0) { + return valueToCheck.some((value) => (0, scalar_evaluator_1.checkOneValue)(value, rule, codingProcessing)); + } + return (0, scalar_evaluator_1.checkOneValue)(valueToCheck[fragment], rule, codingProcessing); + } + return (0, scalar_evaluator_1.checkOneValue)(valueToCheck, rule, codingProcessing); + } + exports2.isMatchRule = isMatchRule; + } +}); + +// node_modules/@iqb/responses/rule-engine/array-position.js +var require_array_position = __commonJS({ + "node_modules/@iqb/responses/rule-engine/array-position.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.resolveValueArrayPosMember = void 0; + var value_transform_1 = require_value_transform(); + function resolveValueArrayPosMember(valueToCheck, ruleSet, isValueArray) { + if (!isValueArray || !Array.isArray(valueToCheck)) { + return void 0; + } + if (typeof ruleSet.valueArrayPos === "number") { + if (ruleSet.valueArrayPos >= 0 && ruleSet.valueArrayPos < valueToCheck.length) { + return valueToCheck[ruleSet.valueArrayPos]; + } + return void 0; + } + if (ruleSet.valueArrayPos === "SUM") { + return valueToCheck.map((v) => { + if (Array.isArray(v)) { + return v.map((s) => (0, value_transform_1.getValueAsNumber)(s) || 0).reduce((a, b) => a + b, 0); + } + return (0, value_transform_1.getValueAsNumber)(v) || 0; + }).reduce((pv, cv) => pv + cv, 0); + } + if (ruleSet.valueArrayPos === "LENGTH") { + return valueToCheck.length; + } + return void 0; + } + exports2.resolveValueArrayPosMember = resolveValueArrayPosMember; + } +}); + +// node_modules/@iqb/responses/rule-engine/rule-set-evaluator.js +var require_rule_set_evaluator = __commonJS({ + "node_modules/@iqb/responses/rule-engine/rule-set-evaluator.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.isMatchRuleSet = void 0; + var rule_matcher_1 = require_rule_matcher(); + var array_position_1 = require_array_position(); + function evaluateRuleSetRules(valueToEvaluate, ruleSet, isValueArray, codingProcessing) { + var _a, _b; + if (!ruleSet.ruleOperatorAnd) { + return ((_a = ruleSet.rules) !== null && _a !== void 0 ? _a : []).some((rule) => (0, rule_matcher_1.isMatchRule)(valueToEvaluate, rule, isValueArray, codingProcessing)); + } + return ((_b = ruleSet.rules) !== null && _b !== void 0 ? _b : []).every((rule) => (0, rule_matcher_1.isMatchRule)(valueToEvaluate, rule, isValueArray, codingProcessing)); + } + function evaluateAnyOpen(valueToCheck, ruleSet, codingProcessing) { + if (!Array.isArray(valueToCheck)) + return false; + if (valueToCheck.length === 0) + return false; + if (ruleSet.valueArrayPos !== "ANY_OPEN") + return false; + return valueToCheck.some((value) => { + var _a; + return ((_a = ruleSet.rules) !== null && _a !== void 0 ? _a : []).every((rule) => (0, rule_matcher_1.isMatchRule)(value, rule, false, codingProcessing)); + }); + } + function evaluateAllMembers(valueToCheck, ruleSet, codingProcessing) { + if (!Array.isArray(valueToCheck)) + return false; + if (valueToCheck.length <= 1) + return false; + if (ruleSet.valueArrayPos !== "ANY") + return false; + return valueToCheck.every((value) => { + var _a; + return ((_a = ruleSet.rules) !== null && _a !== void 0 ? _a : []).every((rule) => (0, rule_matcher_1.isMatchRule)(value, rule, false, codingProcessing)); + }); + } + function isMatchRuleSet(valueToCheck, ruleSet, isValueArray, codingProcessing) { + if (!ruleSet.rules || ruleSet.rules.length === 0) { + return false; + } + const valueMemberToCheck = (0, array_position_1.resolveValueArrayPosMember)(valueToCheck, ruleSet, isValueArray); + const valueToEvaluate = typeof valueMemberToCheck !== "undefined" ? valueMemberToCheck : valueToCheck; + const isValueArrayForRules = typeof valueMemberToCheck === "undefined" && isValueArray; + const rulesMatch = evaluateRuleSetRules(valueToEvaluate, ruleSet, isValueArrayForRules, codingProcessing); + if (rulesMatch && isValueArray && Array.isArray(valueToCheck)) { + if (ruleSet.valueArrayPos === "ANY_OPEN" && valueToCheck.length > 0) { + return evaluateAnyOpen(valueToCheck, ruleSet, codingProcessing); + } + if (ruleSet.valueArrayPos === "ANY" && valueToCheck.length > 1) { + return evaluateAllMembers(valueToCheck, ruleSet, codingProcessing); + } + } + return rulesMatch; + } + exports2.isMatchRuleSet = isMatchRuleSet; + } +}); + +// node_modules/@iqb/responses/rule-engine.js +var require_rule_engine = __commonJS({ + "node_modules/@iqb/responses/rule-engine.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.isMatchRuleSet = exports2.checkOneValue = void 0; + var scalar_evaluator_1 = require_scalar_evaluator(); + Object.defineProperty(exports2, "checkOneValue", { enumerable: true, get: function() { + return scalar_evaluator_1.checkOneValue; + } }); + var rule_set_evaluator_1 = require_rule_set_evaluator(); + Object.defineProperty(exports2, "isMatchRuleSet", { enumerable: true, get: function() { + return rule_set_evaluator_1.isMatchRuleSet; + } }); + } +}); + +// node_modules/@iqb/responses/coding-factory.js +var require_coding_factory = __commonJS({ + "node_modules/@iqb/responses/coding-factory.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.CodingFactory = void 0; + var value_transform_1 = require_value_transform(); + var rule_engine_1 = require_rule_engine(); + var deep_clone_12 = require_deep_clone(); + var constants_1 = require_constants(); + var CodingFactory = class _CodingFactory { + static createBaseCodingVariable(varId, sourceType) { + return { + id: varId, + alias: varId, + label: "", + sourceType, + sourceParameters: { + solverExpression: "", + processing: [] + }, + deriveSources: [], + processing: [], + fragmenting: "", + manualInstruction: "", + codeModel: "RULES_ONLY", + codes: [] + }; + } + static createCodingVariable(varId) { + return this.createBaseCodingVariable(varId, "BASE"); + } + static getValueAsNumber(value) { + return (0, value_transform_1.getValueAsNumber)(value); + } + static getValueAsString(value, processing = []) { + return (0, value_transform_1.getValueAsString)(value, processing); + } + static isEmptyValue(value) { + return (0, value_transform_1.isEmptyValue)(value); + } + static isMatchRuleSet(valueToCheck, ruleSet, isValueArray, codingProcessing) { + return (0, rule_engine_1.isMatchRuleSet)(valueToCheck, ruleSet, isValueArray, codingProcessing); + } + static code(response, coding, options) { + var _a, _b, _c, _d, _e, _f; + const newResponse = (0, deep_clone_12.deepClone)(response); + if (!coding || ((_b = (_a = coding.codes) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) === 0) { + newResponse.status = constants_1.CODING_SCHEME_STATUS.NO_CODING; + return newResponse; + } + let valueToCheck = null; + try { + const shouldSortArray = ((_c = coding.processing) === null || _c === void 0 ? void 0 : _c.includes("SORT_ARRAY")) || false; + const fragmentingPattern = coding.fragmenting || ""; + valueToCheck = (0, value_transform_1.transformValue)(newResponse.value, fragmentingPattern, shouldSortArray); + } catch (error) { + (_d = options === null || options === void 0 ? void 0 : options.onError) === null || _d === void 0 ? void 0 : _d.call(options, error); + newResponse.status = constants_1.CODING_SCHEME_STATUS.CODING_ERROR; + return newResponse; + } + let hasElse = false; + let elseCode = 0; + let elseScore = 0; + let elseType; + let changed = false; + try { + const codes = (_e = coding.codes) !== null && _e !== void 0 ? _e : []; + const isValueArray = Array.isArray(newResponse.value); + const processing = coding.processing || []; + codes.some((code) => { + var _a2; + const codeType = code.type; + if (typeof codeType === "string" && ["RESIDUAL_AUTO", "INTENDED_INCOMPLETE"].includes(codeType)) { + hasElse = true; + elseCode = code.id; + elseType = codeType; + elseScore = code.score || 0; + return false; + } + const ruleSets = (_a2 = code.ruleSets) !== null && _a2 !== void 0 ? _a2 : []; + const isMatchingRuleSet = (ruleSet) => _CodingFactory.isMatchRuleSet(valueToCheck, ruleSet, isValueArray, processing); + const hasMatch = code.ruleSetOperatorAnd ? ruleSets.length > 0 && ruleSets.every(isMatchingRuleSet) : ruleSets.some(isMatchingRuleSet); + if (!hasMatch) + return false; + const { id, score } = code; + const idAsString = String(id); + const isInvalidOrIncomplete = idAsString === constants_1.CODING_SCHEME_STATUS.INVALID || idAsString === constants_1.CODING_SCHEME_STATUS.INTENDED_INCOMPLETE; + newResponse.status = isInvalidOrIncomplete ? idAsString : constants_1.CODING_SCHEME_STATUS.CODING_COMPLETE; + newResponse.code = isInvalidOrIncomplete ? 0 : id; + newResponse.score = isInvalidOrIncomplete ? 0 : score || 0; + changed = true; + return true; + }); + } catch (error) { + (_f = options === null || options === void 0 ? void 0 : options.onError) === null || _f === void 0 ? void 0 : _f.call(options, error); + newResponse.status = constants_1.CODING_SCHEME_STATUS.CODING_ERROR; + return newResponse; + } + if (!changed) { + if (hasElse) { + if (elseType === constants_1.CODING_SCHEME_STATUS.INTENDED_INCOMPLETE) { + newResponse.status = constants_1.CODING_SCHEME_STATUS.INTENDED_INCOMPLETE; + newResponse.code = elseCode; + newResponse.score = elseScore; + } else if (String(elseCode) === constants_1.CODING_SCHEME_STATUS.INVALID) { + newResponse.status = constants_1.CODING_SCHEME_STATUS.INVALID; + newResponse.code = 0; + newResponse.score = 0; + } else { + newResponse.status = constants_1.CODING_SCHEME_STATUS.CODING_COMPLETE; + newResponse.code = elseCode; + newResponse.score = elseScore; + } + } else { + newResponse.status = constants_1.CODING_SCHEME_STATUS.CODING_INCOMPLETE; + } + } + return newResponse; + } + }; + exports2.CodingFactory = CodingFactory; + } +}); + +// node_modules/@iqb/responses/status-helpers.js +var require_status_helpers = __commonJS({ + "node_modules/@iqb/responses/status-helpers.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.isUnsetLikeResponse = exports2.isErrorStatus = exports2.isPendingStatus = void 0; + var constants_1 = require_constants(); + var isPendingStatus = (status) => status === constants_1.CODING_SCHEME_STATUS.CODING_INCOMPLETE || status === constants_1.CODING_SCHEME_STATUS.DERIVE_PENDING; + exports2.isPendingStatus = isPendingStatus; + var isErrorStatus = (status) => status === constants_1.CODING_SCHEME_STATUS.CODING_ERROR || status === constants_1.CODING_SCHEME_STATUS.DERIVE_ERROR || status === constants_1.CODING_SCHEME_STATUS.NO_CODING; + exports2.isErrorStatus = isErrorStatus; + var isUnsetLikeResponse = (response) => response.status === constants_1.CODING_SCHEME_STATUS.UNSET || response.status !== constants_1.CODING_SCHEME_STATUS.CODING_COMPLETE && response.code == null && response.score == null; + exports2.isUnsetLikeResponse = isUnsetLikeResponse; + } +}); + +// node_modules/@iqb/responses/derive/derive-value.js +var require_derive_value = __commonJS({ + "node_modules/@iqb/responses/derive/derive-value.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.deriveValue = exports2.amountFalseStates = void 0; + var mathjs_1 = (init_autocoder_solver_math(), __toCommonJS(autocoder_solver_math_exports)); + var coding_scheme_1 = require("@iqbspecs/coding-scheme"); + var constants_1 = require_constants(); + var coding_factory_1 = require_coding_factory(); + var status_helpers_1 = require_status_helpers(); + var value_transform_1 = require_value_transform(); + var deriveErrorResponse = (coding, subform) => ({ + id: coding.id, + value: null, + status: constants_1.CODING_SCHEME_STATUS.DERIVE_ERROR, + subform + }); + var amountFalseStates = (coding, sourceResponses, variableCodings = []) => { + switch (coding.sourceType) { + case "MANUAL": { + const isInvalid = (r) => { + var _a, _b, _c, _d; + return !constants_1.MANUAL_VALID_STATES.includes(r.status) && !(r.status === constants_1.CODING_SCHEME_STATUS.DISPLAYED && ((_b = (_a = coding.sourceParameters) === null || _a === void 0 ? void 0 : _a.processing) === null || _b === void 0 ? void 0 : _b.includes("TAKE_DISPLAYED_AS_VALUE_CHANGED")) || r.status === constants_1.CODING_SCHEME_STATUS.NOT_REACHED && ((_d = (_c = coding.sourceParameters) === null || _c === void 0 ? void 0 : _c.processing) === null || _d === void 0 ? void 0 : _d.includes("TAKE_NOT_REACHED_AS_VALUE_CHANGED"))); + }; + return sourceResponses.filter(isInvalid).length; + } + case "COPY_VALUE": + case "UNIQUE_VALUES": + case "SOLVER": { + const codingById = new Map(variableCodings.map((c) => [c.id, c])); + const isInvalid = (r) => { + var _a; + return !constants_1.COPY_SOLVER_VALID_STATES.includes(r.status) && !(coding.sourceType === "SOLVER" && r.status === constants_1.CODING_SCHEME_STATUS.INVALID && ((_a = codingById.get(r.id)) === null || _a === void 0 ? void 0 : _a.sourceType) === "BASE" && isSolverEmptyValue(r.value)); + }; + return sourceResponses.filter(isInvalid).length; + } + case "CONCAT_CODE": + case "SUM_CODE": + case "SUM_SCORE": { + const isInvalid = (r) => !constants_1.CONCAT_SUM_VALID_STATES.includes(r.status); + return sourceResponses.filter(isInvalid).length; + } + default: + return 0; + } + }; + exports2.amountFalseStates = amountFalseStates; + var SOLVER_ERROR_POLICY = { kind: "ERROR" }; + var isSolverEmptyValue = (value) => value === null || typeof value === "string" && value.trim() === ""; + var handleManual = ({ coding, sourceResponses, subformSource }) => { + if (sourceResponses.every((r) => r.status === constants_1.CODING_SCHEME_STATUS.INTENDED_INCOMPLETE)) { + return { + id: coding.id, + value: null, + status: constants_1.CODING_SCHEME_STATUS.CODING_INCOMPLETE, + subform: subformSource + }; + } + return { + id: coding.id, + value: null, + status: constants_1.CODING_SCHEME_STATUS.CODING_COMPLETE, + subform: subformSource + }; + }; + var handleCopyValue = ({ coding, sourceResponses, subformSource }) => { + if (sourceResponses.some((response) => response.status === constants_1.CODING_SCHEME_STATUS.DERIVE_PENDING)) { + return { + id: coding.id, + value: null, + status: constants_1.CODING_SCHEME_STATUS.DERIVE_PENDING, + subform: subformSource + }; + } + const derivedValue = sourceResponses[0].value; + return { + id: coding.id, + value: derivedValue ? JSON.parse(JSON.stringify(derivedValue)) : null, + status: constants_1.CODING_SCHEME_STATUS.VALUE_CHANGED, + subform: subformSource + }; + }; + var handleConcatCode = ({ coding, sourceResponseById, subformSource }) => { + var _a, _b, _c; + const deriveSources = (_a = coding.deriveSources) !== null && _a !== void 0 ? _a : []; + const extractCode = (sourceId) => { + const response = sourceResponseById.get(sourceId); + return response && (response.code || response.code === 0) ? response.code.toString(10) : "?"; + }; + const codes = deriveSources.map(extractCode); + if ((_c = (_b = coding.sourceParameters) === null || _b === void 0 ? void 0 : _b.processing) === null || _c === void 0 ? void 0 : _c.includes("SORT")) { + codes.sort(); + } + return { + id: coding.id, + value: codes.join(coding_scheme_1.DeriveConcatDelimiter), + status: constants_1.CODING_SCHEME_STATUS.VALUE_CHANGED, + subform: subformSource + }; + }; + var handleSumCode = ({ coding, sourceResponseById, subformSource }) => { + var _a; + const deriveSources = (_a = coding.deriveSources) !== null && _a !== void 0 ? _a : []; + const allSourcesPresent = deriveSources.every((sourceId) => sourceResponseById.has(sourceId)); + if (!allSourcesPresent) { + return deriveErrorResponse(coding, subformSource); + } + return { + id: coding.id, + value: deriveSources.reduce((sum, sourceId) => { + var _a2; + const myResponse = sourceResponseById.get(sourceId); + return sum + ((_a2 = myResponse === null || myResponse === void 0 ? void 0 : myResponse.code) !== null && _a2 !== void 0 ? _a2 : 0); + }, 0), + status: constants_1.CODING_SCHEME_STATUS.VALUE_CHANGED, + subform: subformSource + }; + }; + var handleSumScore = ({ coding, sourceResponseById, subformSource }) => { + var _a; + const deriveSources = (_a = coding.deriveSources) !== null && _a !== void 0 ? _a : []; + const allSourcesPresent = deriveSources.every((sourceId) => sourceResponseById.has(sourceId)); + if (!allSourcesPresent) { + return deriveErrorResponse(coding, subformSource); + } + return { + id: coding.id, + value: deriveSources.map((sourceId) => { + var _a2; + const response = sourceResponseById.get(sourceId); + return (_a2 = response === null || response === void 0 ? void 0 : response.score) !== null && _a2 !== void 0 ? _a2 : 0; + }).reduce((total, score) => total + score, 0), + status: constants_1.CODING_SCHEME_STATUS.VALUE_CHANGED, + subform: subformSource + }; + }; + var handleUniqueValues = ({ coding, sourceResponses, subformSource }) => { + const valuesToCompare = sourceResponses.filter((r) => coding_scheme_1.validStatesForDerivingValue.includes(r.status)).map((r) => { + var _a; + const processing = (_a = coding.sourceParameters) === null || _a === void 0 ? void 0 : _a.processing; + const isToNumberProcessing = processing === null || processing === void 0 ? void 0 : processing.includes("TO_NUMBER"); + if (Array.isArray(r.value)) { + return r.value.map((value) => isToNumberProcessing ? (coding_factory_1.CodingFactory.getValueAsNumber(value) || 0).toString(10) : coding_factory_1.CodingFactory.getValueAsString(value, processing) || "").join("##"); + } + return isToNumberProcessing ? (coding_factory_1.CodingFactory.getValueAsNumber(r.value) || 0).toString(10) : coding_factory_1.CodingFactory.getValueAsString(r.value, processing) || ""; + }); + const duplicates = valuesToCompare.filter((value, index, array) => array.indexOf(value) < index); + return { + id: coding.id, + value: duplicates.length === 0, + status: constants_1.CODING_SCHEME_STATUS.VALUE_CHANGED, + subform: subformSource + }; + }; + var isSolverAliasChar = (char) => { + const code = char.charCodeAt(0); + return code >= 48 && code <= 57 || code >= 65 && code <= 90 || code >= 97 && code <= 122 || char === "_" || char === "," || char === "-"; + }; + var isDigit = (char) => { + const code = char.charCodeAt(0); + return code >= 48 && code <= 57; + }; + var parseSolverValuePolicy = (text) => { + const trimmed = text.trim(); + const normalized = trimmed.toUpperCase(); + if (normalized === "ERROR") { + return SOLVER_ERROR_POLICY; + } + if (normalized === "INC") { + return { kind: "INC" }; + } + if (trimmed.length === 0) { + return null; + } + const defaultValue = coding_factory_1.CodingFactory.getValueAsNumber(trimmed); + return defaultValue === null ? null : { kind: "DEFAULT", value: defaultValue }; + }; + var parseSolverToken = (text, content) => { + const contentParts = content.split(":"); + if (contentParts.length > 3) { + return null; + } + let variableAlias = contentParts[0].trim(); + let fragmentIndex; + const emptyPolicy = contentParts.length > 1 ? parseSolverValuePolicy(contentParts[1]) : SOLVER_ERROR_POLICY; + const nonNumericPolicy = contentParts.length > 2 ? parseSolverValuePolicy(contentParts[2]) : SOLVER_ERROR_POLICY; + if (!emptyPolicy || !nonNumericPolicy) { + return null; + } + if (variableAlias.endsWith("]")) { + const fragmentStart = variableAlias.lastIndexOf("["); + if (fragmentStart < 0) { + return null; + } + const fragmentText = variableAlias.slice(fragmentStart + 1, -1).trim(); + if (fragmentText.length === 0 || !Array.from(fragmentText).every(isDigit)) { + return null; + } + fragmentIndex = Number.parseInt(fragmentText, 10); + variableAlias = variableAlias.slice(0, fragmentStart).trim(); + } + if (variableAlias.length === 0 || !Array.from(variableAlias).every(isSolverAliasChar)) { + return null; + } + return { + text, + variableAlias, + fragmentIndex, + emptyPolicy, + nonNumericPolicy + }; + }; + var getSolverTokens = (solverExpression) => { + const tokens = []; + let searchStart = 0; + while (searchStart < solverExpression.length) { + const tokenStart = solverExpression.indexOf("${", searchStart); + if (tokenStart < 0) { + break; + } + const tokenEnd = solverExpression.indexOf("}", tokenStart + 2); + if (tokenEnd < 0) { + break; + } + const text = solverExpression.slice(tokenStart, tokenEnd + 1); + const content = solverExpression.slice(tokenStart + 2, tokenEnd); + const token = parseSolverToken(text, content); + if (!token) { + return null; + } + tokens.push(token); + searchStart = tokenEnd + 1; + } + return tokens; + }; + var getFragmentValueAsNumber = (response, sourceCoding, fragmentIndex) => { + if (!sourceCoding || Array.isArray(response.value)) { + return { kind: "deriveError" }; + } + let transformedValue; + try { + transformedValue = (0, value_transform_1.transformValue)(response.value, sourceCoding.fragmenting || "", false); + } catch (error) { + return { kind: "deriveError" }; + } + if (!Array.isArray(transformedValue)) { + return { kind: "empty" }; + } + const fragment = transformedValue[fragmentIndex]; + if (typeof fragment === "undefined") { + return { kind: "empty" }; + } + if (typeof fragment === "string" && fragment.trim() === "") { + return { kind: "empty" }; + } + if (Array.isArray(fragment)) { + return { kind: "deriveError" }; + } + const value = coding_factory_1.CodingFactory.getValueAsNumber(fragment); + return value === null ? { kind: "nonNumeric" } : { kind: "value", value }; + }; + var getSolverReplacementValue = (replacement, sourceResponseById, sourceCodingById) => { + const responseToReplace = sourceResponseById.get(replacement.sourceId); + if (!responseToReplace) { + return { kind: "empty" }; + } + if (typeof replacement.fragmentIndex === "number") { + return getFragmentValueAsNumber(responseToReplace, sourceCodingById.get(replacement.sourceId), replacement.fragmentIndex); + } + if (Array.isArray(responseToReplace.value)) { + return { kind: "deriveError" }; + } + if (isSolverEmptyValue(responseToReplace.value)) { + return { kind: "empty" }; + } + const value = coding_factory_1.CodingFactory.getValueAsNumber(responseToReplace.value); + return value === null ? { kind: "nonNumeric" } : { kind: "value", value }; + }; + var handleSolverPolicy = (coding, subformSource, policy) => { + if (policy.kind === "DEFAULT") { + return policy.value; + } + if (policy.kind === "INC") { + return { + id: coding.id, + value: null, + status: constants_1.CODING_SCHEME_STATUS.CODING_INCOMPLETE, + subform: subformSource + }; + } + return deriveErrorResponse(coding, subformSource); + }; + var handleSolver = ({ variableCodings, coding, sourceResponseById, subformSource }) => { + var _a, _b; + if (!(coding.sourceParameters && coding.sourceParameters.processing && coding.sourceParameters.solverExpression)) { + return deriveErrorResponse(coding, subformSource); + } + const deriveSources = (_a = coding.deriveSources) !== null && _a !== void 0 ? _a : []; + const sourceCodingById = new Map(variableCodings.map((c) => [c.id, c])); + const sources = []; + const replacements = /* @__PURE__ */ new Map(); + const tokens = getSolverTokens(coding.sourceParameters.solverExpression); + if (!tokens) { + return deriveErrorResponse(coding, subformSource); + } + for (const token of tokens) { + const variableAlias = token.variableAlias; + const matchId = ((_b = variableCodings.find((c) => c.alias === variableAlias)) === null || _b === void 0 ? void 0 : _b.id) || variableAlias; + if (!sources.includes(matchId)) { + sources.push(matchId); + } + if (!replacements.has(token.text)) { + replacements.set(token.text, { + sourceId: matchId, + fragmentIndex: token.fragmentIndex, + emptyPolicy: token.emptyPolicy, + nonNumericPolicy: token.nonNumericPolicy + }); + } + } + const missingDeriveVars = sources.filter((source) => !deriveSources.includes(source)); + if (missingDeriveVars.length > 0) { + return { + id: coding.id, + value: null, + status: constants_1.CODING_SCHEME_STATUS.DERIVE_ERROR, + subform: subformSource + }; + } + let newExpression = coding.sourceParameters.solverExpression; + for (const [toReplace, replacement] of replacements.entries()) { + const resolvedValue = getSolverReplacementValue(replacement, sourceResponseById, sourceCodingById); + if (resolvedValue.kind === "deriveError") { + return deriveErrorResponse(coding, subformSource); + } + let valueToReplace; + if (resolvedValue.kind === "empty") { + valueToReplace = handleSolverPolicy(coding, subformSource, replacement.emptyPolicy); + } else if (resolvedValue.kind === "nonNumeric") { + valueToReplace = handleSolverPolicy(coding, subformSource, replacement.nonNumericPolicy); + } else { + valueToReplace = resolvedValue.value; + } + if (typeof valueToReplace !== "number") { + return valueToReplace; + } + newExpression = newExpression.split(toReplace).join(valueToReplace.toString(10)); + } + let newValue; + try { + newValue = (0, mathjs_1.evaluate)(newExpression); + } catch (error) { + return deriveErrorResponse(coding, subformSource); + } + if (newValue === null) { + return { + id: coding.id, + value: null, + status: constants_1.CODING_SCHEME_STATUS.VALUE_CHANGED, + subform: subformSource + }; + } + if (typeof newValue !== "number" || !Number.isFinite(newValue)) { + return deriveErrorResponse(coding, subformSource); + } + return { + id: coding.id, + value: newValue, + status: constants_1.CODING_SCHEME_STATUS.VALUE_CHANGED, + subform: subformSource + }; + }; + var deriveValue = (variableCodings, coding, sourceResponses) => { + var _a; + const subformSource = (_a = sourceResponses.find((r) => r.subform !== void 0)) === null || _a === void 0 ? void 0 : _a.subform; + const sourceCodingById = new Map(variableCodings.map((c) => [c.id, c])); + const statusPrecedence = [ + { from: constants_1.CODING_SCHEME_STATUS.UNSET, to: constants_1.CODING_SCHEME_STATUS.UNSET }, + { + from: constants_1.CODING_SCHEME_STATUS.DERIVE_ERROR, + to: constants_1.CODING_SCHEME_STATUS.DERIVE_ERROR + }, + { + from: constants_1.CODING_SCHEME_STATUS.NO_CODING, + to: constants_1.CODING_SCHEME_STATUS.DERIVE_ERROR + }, + { + from: constants_1.CODING_SCHEME_STATUS.CODING_ERROR, + to: constants_1.CODING_SCHEME_STATUS.CODING_ERROR + }, + { from: constants_1.CODING_SCHEME_STATUS.INVALID, to: constants_1.CODING_SCHEME_STATUS.INVALID } + ]; + const shouldApplyStatusPrecedence = (sourceResponse, mapping) => { + var _a2, _b; + if (coding.sourceType !== "SOLVER") { + return sourceResponse.status === mapping.from; + } + if (mapping.from === constants_1.CODING_SCHEME_STATUS.NO_CODING) { + return sourceResponse.status === mapping.from && ((_a2 = sourceCodingById.get(sourceResponse.id)) === null || _a2 === void 0 ? void 0 : _a2.sourceType) !== "BASE"; + } + if (mapping.from === constants_1.CODING_SCHEME_STATUS.INVALID && sourceResponse.status === mapping.from && ((_b = sourceCodingById.get(sourceResponse.id)) === null || _b === void 0 ? void 0 : _b.sourceType) === "BASE" && isSolverEmptyValue(sourceResponse.value)) { + return false; + } + return sourceResponse.status === mapping.from; + }; + for (const mapping of statusPrecedence) { + if (sourceResponses.some((r) => shouldApplyStatusPrecedence(r, mapping))) { + return { + id: coding.id, + value: null, + status: mapping.to, + subform: subformSource + }; + } + } + const hasPending = sourceResponses.some((r) => (0, status_helpers_1.isPendingStatus)(r.status)); + if (hasPending && sourceResponses.every((r) => constants_1.VALID_STATES_TO_START_DERIVE_PENDING_CHECK.includes(r.status)) && !["MANUAL", "COPY_VALUE", "UNIQUE_VALUES", "SOLVER"].includes(coding.sourceType)) { + return { + id: coding.id, + value: null, + status: constants_1.CODING_SCHEME_STATUS.DERIVE_PENDING, + subform: subformSource + }; + } + const falseStates = (0, exports2.amountFalseStates)(coding, sourceResponses, variableCodings); + if (sourceResponses.length >= falseStates && falseStates > 0) { + const allHaveSameStatus = sourceResponses.every((r) => r.status === sourceResponses[0].status); + const allArePartlyDisplayedStatuses = sourceResponses.every((r) => constants_1.PARTLY_DISPLAYED_STATUSES.includes(r.status)); + if (allHaveSameStatus) { + return { + id: coding.id, + value: null, + status: sourceResponses[0].status, + subform: subformSource + }; + } + if (allArePartlyDisplayedStatuses) { + return { + id: coding.id, + value: null, + status: constants_1.CODING_SCHEME_STATUS.PARTLY_DISPLAYED, + subform: subformSource + }; + } + return { + id: coding.id, + value: null, + status: constants_1.CODING_SCHEME_STATUS.INVALID, + subform: subformSource + }; + } + const ctx = { + variableCodings, + coding, + sourceResponses, + sourceResponseById: new Map(sourceResponses.map((r) => [r.id, r])), + subformSource + }; + const handlers = { + MANUAL: handleManual, + COPY_VALUE: handleCopyValue, + CONCAT_CODE: handleConcatCode, + SUM_CODE: handleSumCode, + SUM_SCORE: handleSumScore, + UNIQUE_VALUES: handleUniqueValues, + SOLVER: handleSolver + }; + const handler = Object.prototype.hasOwnProperty.call(handlers, coding.sourceType) ? handlers[coding.sourceType] : void 0; + return handler ? handler(ctx) : deriveErrorResponse(coding, subformSource); + }; + exports2.deriveValue = deriveValue; + } +}); + +// node_modules/@iqb/responses/derive/dependency-plan.js +var require_dependency_plan = __commonJS({ + "node_modules/@iqb/responses/derive/dependency-plan.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.executeDependencyPlan = exports2.ensureResponsesExist = exports2.buildDependencyPlan = void 0; + var constants_1 = require_constants(); + function buildDependencyPlan(variableCodings, getVariableDependencyTree, options) { + var _a; + try { + return { + varDependencies: getVariableDependencyTree(variableCodings), + globalDeriveError: false + }; + } catch (error) { + (_a = options === null || options === void 0 ? void 0 : options.onError) === null || _a === void 0 ? void 0 : _a.call(options, error); + return { varDependencies: [], globalDeriveError: true }; + } + } + exports2.buildDependencyPlan = buildDependencyPlan; + function ensureResponsesExist(responses, variableCodings, plan) { + const updatedPlan = Object.assign(Object.assign({}, plan), { varDependencies: [...plan.varDependencies] }); + const updatedResponses = [...responses]; + variableCodings.forEach((coding) => { + if (updatedPlan.globalDeriveError && coding.sourceType === "BASE") { + updatedPlan.varDependencies.push({ + id: coding.id, + level: 0, + sources: [], + page: coding.page || "" + }); + } + if (updatedResponses.some((response) => response.id === coding.id)) { + return; + } + if (coding.sourceType !== "BASE_NO_VALUE") { + const status = updatedPlan.globalDeriveError && coding.sourceType !== "BASE" ? constants_1.CODING_SCHEME_STATUS.DERIVE_ERROR : constants_1.CODING_SCHEME_STATUS.UNSET; + updatedResponses.push({ + id: coding.id, + value: null, + status + }); + } + }); + return { responses: updatedResponses, plan: updatedPlan }; + } + exports2.ensureResponsesExist = ensureResponsesExist; + function executeDependencyPlan(responses, variableCodings, plan, applyDerivationsAndCoding, options) { + const updatedResponses = [...responses]; + applyDerivationsAndCoding(updatedResponses, variableCodings, plan.varDependencies, options); + return updatedResponses; + } + exports2.executeDependencyPlan = executeDependencyPlan; + } +}); + +// node_modules/@iqb/responses/derive/derivation-traversal.js +var require_derivation_traversal = __commonJS({ + "node_modules/@iqb/responses/derive/derivation-traversal.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.applyDerivationsAndCoding = void 0; + var coding_scheme_1 = require("@iqbspecs/coding-scheme"); + var constants_1 = require_constants(); + var coding_factory_1 = require_coding_factory(); + var derive_value_12 = require_derive_value(); + function applyDerivationsAndCoding(responses, variableCodings, varDependencies, options) { + const responseById = new Map(responses.map((r) => [r.id, r])); + const codingById = new Map(variableCodings.map((vc) => [vc.id, vc])); + const maxVarLevel = Math.max(0, ...varDependencies.map((n) => n.level)); + for (let level = 0; level <= maxVarLevel; level++) { + const currentLevelNodes = varDependencies.filter((n) => n.level === level); + for (const varNode of currentLevelNodes) { + const targetResponse = responseById.get(varNode.id); + const varCoding = codingById.get(varNode.id); + if (targetResponse && varCoding) { + if (varNode.sources.length > 0 && coding_scheme_1.validStatesToStartDeriving.includes(targetResponse.status)) { + deriveResponse(variableCodings, targetResponse, varCoding, varNode.sources, responses, options); + responseById.set(targetResponse.id, targetResponse); + } + if (targetResponse.status === constants_1.CODING_SCHEME_STATUS.VALUE_CHANGED) { + processCoding(targetResponse, varCoding, options); + } + } + } + } + } + exports2.applyDerivationsAndCoding = applyDerivationsAndCoding; + function deriveResponse(variableCodings, targetResponse, varCoding, sourceIds, responsesList, options) { + var _a; + if (targetResponse.status === constants_1.CODING_SCHEME_STATUS.CODING_ERROR) { + return; + } + try { + const sourceResponses = responsesList.filter((r) => sourceIds.includes(r.id)); + const derivedResponse = (0, derive_value_12.deriveValue)(variableCodings, varCoding, sourceResponses); + targetResponse.status = derivedResponse.status; + targetResponse.subform = derivedResponse.subform; + if (derivedResponse.status === constants_1.CODING_SCHEME_STATUS.VALUE_CHANGED) { + targetResponse.value = derivedResponse.value; + } + } catch (error) { + (_a = options === null || options === void 0 ? void 0 : options.onError) === null || _a === void 0 ? void 0 : _a.call(options, error); + targetResponse.status = constants_1.CODING_SCHEME_STATUS.DERIVE_ERROR; + targetResponse.value = null; + } + } + function processCoding(targetResponse, varCoding, options) { + var _a, _b, _c, _d; + if (((_b = (_a = varCoding.codes) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) > 0) { + const codedResponse = coding_factory_1.CodingFactory.code(targetResponse, varCoding, options); + if (codedResponse.status !== targetResponse.status) { + targetResponse.status = codedResponse.status; + targetResponse.code = codedResponse.code; + targetResponse.score = codedResponse.score; + } + } else if (varCoding.sourceType === "BASE") { + const takeEmptyAsValid = ((_d = (_c = varCoding.sourceParameters) === null || _c === void 0 ? void 0 : _c.processing) === null || _d === void 0 ? void 0 : _d.includes("TAKE_EMPTY_AS_VALID")) || false; + if (!takeEmptyAsValid) { + targetResponse.status = constants_1.CODING_SCHEME_STATUS.NO_CODING; + } + } else { + targetResponse.status = constants_1.CODING_SCHEME_STATUS.NO_CODING; + } + } + } +}); + +// node_modules/@iqb/responses/normalize/variable-info-spelling.js +var require_variable_info_spelling = __commonJS({ + "node_modules/@iqb/responses/normalize/variable-info-spelling.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.normalizeVariableInfoFormat = exports2.normalizeVariableInfoType = void 0; + var TYPE_V1_TO_V2 = { + string: "STRING", + integer: "INTEGER", + number: "NUMBER", + boolean: "BOOLEAN", + attachment: "ATTACHMENT", + json: "JSON", + "no-value": "NO_VALUE", + coded: "CODED" + }; + var FORMAT_V1_TO_V2 = { + "text-selection": "TEXT_SELECTION", + image: "IMAGE", + "capture-image": "CAPTURE_IMAGE", + audio: "AUDIO", + "ggb-file": "GGB_FILE", + "non-negative": "NON_NEGATIVE", + latex: "LATEX", + "math-ml": "MATH_ML", + "math-table": "MATH_TABLE", + "math-text-mix": "MATH_TEXT_MIX", + "ggb-variable": "GGB_VARIABLE", + "chem-element-symbols": "CHEM_ELEMENT_SYMBOLS", + "chem-molecule": "CHEM_MOLECULE" + }; + var lookUp = (table, value) => Object.prototype.hasOwnProperty.call(table, value) ? table[value] : value; + var normalizeVariableInfoType = (type) => lookUp(TYPE_V1_TO_V2, type); + exports2.normalizeVariableInfoType = normalizeVariableInfoType; + var normalizeVariableInfoFormat = (format) => lookUp(FORMAT_V1_TO_V2, format); + exports2.normalizeVariableInfoFormat = normalizeVariableInfoFormat; + } +}); + +// node_modules/@iqb/responses/validation/validate-coding-scheme.js +var require_validate_coding_scheme = __commonJS({ + "node_modules/@iqb/responses/validation/validate-coding-scheme.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.validateCodingScheme = void 0; + var coding_scheme_1 = require("@iqbspecs/coding-scheme"); + var variable_info_spelling_1 = require_variable_info_spelling(); + var validateCodingScheme = (baseVariables, variableCodings) => { + const baseVarById = /* @__PURE__ */ new Map(); + const problems = []; + const numericRuleMethods = [ + "NUMERIC_MATCH", + "NUMERIC_RANGE", + "NUMERIC_FULL_RANGE", + "NUMERIC_MIN", + "NUMERIC_MORE_THAN", + "NUMERIC_LESS_THAN", + "NUMERIC_MAX" + ]; + const booleanRuleMethods = ["IS_TRUE", "IS_FALSE"]; + const pushRuleParameterMismatch = (variableId, variableLabel, code) => { + problems.push({ + type: "RULE_PARAMETER_COUNT_MISMATCH", + breaking: true, + variableId, + variableLabel, + code + }); + }; + const pushRuleParameterInvalid = (variableId, variableLabel, code) => { + problems.push({ + type: "RULE_PARAMETER_INVALID", + breaking: true, + variableId, + variableLabel, + code + }); + }; + const pushRuleRegexInvalid = (variableId, variableLabel, code) => { + problems.push({ + type: "RULE_REGEX_INVALID", + breaking: true, + variableId, + variableLabel, + code + }); + }; + const pushRuleNumericRangeInvalid = (variableId, variableLabel, code) => { + problems.push({ + type: "RULE_NUMERIC_RANGE_INVALID", + breaking: true, + variableId, + variableLabel, + code + }); + }; + const pushRulesetValueArrayPosInvalid = (variableId, variableLabel, code) => { + problems.push({ + type: "RULESET_VALUE_ARRAY_POS_INVALID", + breaking: true, + variableId, + variableLabel, + code + }); + }; + const isFiniteNumberString = (s) => { + if (typeof s !== "string") + return false; + const n = Number.parseFloat(s); + return Number.isFinite(n); + }; + const getCodeRef = (code) => typeof code.id !== "undefined" && code.id !== null ? String(code.id) : "null"; + const getBaseVariableValueShape = (varInfo) => { + const type = (varInfo === null || varInfo === void 0 ? void 0 : varInfo.type) ? (0, variable_info_spelling_1.normalizeVariableInfoType)(varInfo.type) : void 0; + if (type === "NO_VALUE") { + return { valueType: "string", multiple: true }; + } + const multiple = varInfo === null || varInfo === void 0 ? void 0 : varInfo.multiple; + const valuePositionLabels = varInfo === null || varInfo === void 0 ? void 0 : varInfo.valuePositionLabels; + if (!type) { + return { valueType: "unknown", multiple, valuePositionLabels }; + } + if (["INTEGER", "NUMBER"].includes(type)) { + return { valueType: "numeric", multiple, valuePositionLabels }; + } + if (type === "BOOLEAN") { + return { valueType: "boolean", multiple, valuePositionLabels }; + } + if (type === "STRING") { + return { valueType: "string", multiple, valuePositionLabels }; + } + return { valueType: "other", multiple, valuePositionLabels }; + }; + const isKnownIncompatibleNumericRuleType = (valueType) => valueType !== "unknown" && !["numeric", "boolean", "string"].includes(valueType); + const isKnownIncompatibleBooleanRuleType = (valueType) => valueType !== "unknown" && !["numeric", "boolean", "string"].includes(valueType); + const pushInvalidSourceProblem = (variableId, variableLabel) => { + problems.push({ + type: "INVALID_SOURCE", + breaking: true, + variableId, + variableLabel + }); + }; + const baseVarIdCounts = /* @__PURE__ */ new Map(); + baseVariables.forEach((bv) => { + var _a; + if (bv === null || bv === void 0 ? void 0 : bv.id) { + baseVarById.set(bv.id, bv); + baseVarIdCounts.set(bv.id, ((_a = baseVarIdCounts.get(bv.id)) !== null && _a !== void 0 ? _a : 0) + 1); + } + }); + [...baseVarIdCounts.entries()].filter(([, count]) => count > 1).forEach(([id]) => { + pushInvalidSourceProblem(id, ""); + }); + const codingIdCounts = /* @__PURE__ */ new Map(); + const codingAliasCounts = /* @__PURE__ */ new Map(); + const codingById = /* @__PURE__ */ new Map(); + const codingsByAlias = /* @__PURE__ */ new Map(); + variableCodings.forEach((vc) => { + var _a, _b, _c; + codingIdCounts.set(vc.id, ((_a = codingIdCounts.get(vc.id)) !== null && _a !== void 0 ? _a : 0) + 1); + codingById.set(vc.id, vc); + if (vc.alias) { + codingAliasCounts.set(vc.alias, ((_b = codingAliasCounts.get(vc.alias)) !== null && _b !== void 0 ? _b : 0) + 1); + codingsByAlias.set(vc.alias, [ + ...(_c = codingsByAlias.get(vc.alias)) !== null && _c !== void 0 ? _c : [], + vc + ]); + } + }); + const getSourceValueShape = (sourceId, visitedCodingIds) => { + if (!sourceId) { + return { valueType: "unknown" }; + } + const sourceCoding = codingById.get(sourceId); + if (sourceCoding) { + return getCodingValueShape(sourceCoding, visitedCodingIds); + } + return getBaseVariableValueShape(baseVarById.get(sourceId)); + }; + const getCodingValueShape = (coding, visitedCodingIds = /* @__PURE__ */ new Set()) => { + var _a; + if (visitedCodingIds.has(coding.id)) { + return { valueType: "unknown" }; + } + const nextVisitedCodingIds = new Set(visitedCodingIds); + nextVisitedCodingIds.add(coding.id); + switch (coding.sourceType) { + case "BASE": + return getBaseVariableValueShape(baseVarById.get(coding.id)); + case "COPY_VALUE": + return getSourceValueShape((_a = coding.deriveSources) === null || _a === void 0 ? void 0 : _a[0], nextVisitedCodingIds); + case "CONCAT_CODE": + return { valueType: "string", multiple: false }; + case "SUM_CODE": + case "SUM_SCORE": + case "SOLVER": + return { valueType: "numeric", multiple: false }; + case "UNIQUE_VALUES": + return { valueType: "boolean", multiple: false }; + case "BASE_NO_VALUE": + return { valueType: "other", multiple: false }; + case "MANUAL": + default: + return { valueType: "unknown" }; + } + }; + const isDerivedVariable = (vc) => vc.sourceType !== "BASE" && vc.sourceType !== "BASE_NO_VALUE"; + const isDerivedShadowingItsBaseSource = (vc) => { + var _a; + if (!vc.alias || vc.alias === vc.id || !isDerivedVariable(vc)) { + return false; + } + const shadowedCoding = codingById.get(vc.alias); + return Boolean(shadowedCoding && shadowedCoding.sourceType === "BASE" && ((_a = vc.deriveSources) !== null && _a !== void 0 ? _a : []).includes(shadowedCoding.id)); + }; + const isAllowedAliasShadowingGroup = (alias) => { + var _a; + const codingsWithAlias = (_a = codingsByAlias.get(alias)) !== null && _a !== void 0 ? _a : []; + const shadowedCoding = codingById.get(alias); + if (!shadowedCoding || shadowedCoding.sourceType !== "BASE") { + return false; + } + const shadowingCodings = codingsWithAlias.filter((vc) => vc.id !== shadowedCoding.id); + return shadowingCodings.length === 1 && isDerivedShadowingItsBaseSource(shadowingCodings[0]); + }; + const hasAliasCollisionWithPublicIdentifier = (vc) => { + if (!vc.alias || vc.alias === vc.id) { + return false; + } + const codingWithMatchingId = codingById.get(vc.alias); + if ((codingWithMatchingId === null || codingWithMatchingId === void 0 ? void 0 : codingWithMatchingId.sourceType) === "BASE_NO_VALUE") { + return false; + } + return Boolean(codingWithMatchingId && (codingWithMatchingId.alias || codingWithMatchingId.id) === vc.alias); + }; + variableCodings.forEach((vc) => { + var _a, _b; + if (((_a = codingIdCounts.get(vc.id)) !== null && _a !== void 0 ? _a : 0) > 1) { + pushInvalidSourceProblem(vc.alias || vc.id, vc.label || ""); + } + if (vc.alias && ((_b = codingAliasCounts.get(vc.alias)) !== null && _b !== void 0 ? _b : 0) > 1 && !isAllowedAliasShadowingGroup(vc.alias)) { + pushInvalidSourceProblem(vc.alias || vc.id, vc.label || ""); + } + if (hasAliasCollisionWithPublicIdentifier(vc) && !isDerivedShadowingItsBaseSource(vc)) { + pushInvalidSourceProblem(vc.alias || vc.id, vc.label || ""); + } + }); + const allDerivedVariableIds = variableCodings.filter((vc) => vc.sourceType !== "BASE" && vc.sourceType !== "BASE_NO_VALUE").map((vc) => vc.id); + const allBaseVariableInfoIds = baseVariables.map((bv) => bv.id); + const allPossibleSourceIds = [ + ...allBaseVariableInfoIds, + ...allDerivedVariableIds + ]; + const variableValuesCopied = []; + variableCodings.filter((vc) => vc.sourceType === "COPY_VALUE").forEach((vc) => { + var _a; + variableValuesCopied.push(...(_a = vc.deriveSources) !== null && _a !== void 0 ? _a : []); + }); + variableCodings.forEach((c) => { + var _a, _b, _c; + const codingValueShape = getCodingValueShape(c); + if (c.sourceType === "BASE") { + if (allBaseVariableInfoIds.indexOf(c.id) < 0) { + problems.push({ + type: "SOURCE_MISSING", + breaking: true, + variableId: c.alias || c.id, + variableLabel: c.label || "" + }); + } + } else if (c.sourceType === "BASE_NO_VALUE") { + const noValueVarInfo = baseVarById.get(c.id); + if (noValueVarInfo && (0, variable_info_spelling_1.normalizeVariableInfoType)(noValueVarInfo.type) !== "NO_VALUE") { + problems.push({ + type: "INVALID_SOURCE", + breaking: true, + variableId: c.alias || c.id, + variableLabel: c.label || "" + }); + } + if (allBaseVariableInfoIds.indexOf(c.id) < 0) { + problems.push({ + type: "SOURCE_MISSING", + breaking: true, + variableId: c.alias || c.id, + variableLabel: c.label || "" + }); + } + } else if (c.deriveSources && c.deriveSources.length > 0) { + if (c.sourceType === "COPY_VALUE") { + if (c.deriveSources.length > 1) { + problems.push({ + type: "MORE_THAN_ONE_SOURCE", + breaking: false, + variableId: c.alias || c.id, + variableLabel: c.label || "" + }); + } + if (allPossibleSourceIds.indexOf(c.deriveSources[0]) >= 0 && allBaseVariableInfoIds.indexOf(c.deriveSources[0]) < 0) { + problems.push({ + type: "VALUE_COPY_NOT_FROM_BASE", + breaking: false, + variableId: c.alias || c.id, + variableLabel: c.label || "" + }); + } + } else if (c.deriveSources.length === 1) { + problems.push({ + type: "ONLY_ONE_SOURCE", + breaking: false, + variableId: c.alias || c.id, + variableLabel: c.label || "" + }); + } + c.deriveSources.forEach((s) => { + if (allPossibleSourceIds.indexOf(s) < 0) { + problems.push({ + type: "SOURCE_MISSING", + breaking: true, + variableId: c.alias || c.id, + variableLabel: c.label || "" + }); + } + }); + } else { + problems.push({ + type: "SOURCE_MISSING", + breaking: true, + variableId: c.alias || c.id, + variableLabel: c.label || "" + }); + } + if (((_b = (_a = c.codes) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0) > 0) { + (_c = c.codes) === null || _c === void 0 ? void 0 : _c.forEach((code) => { + var _a2; + (_a2 = code.ruleSets) === null || _a2 === void 0 ? void 0 : _a2.forEach((rs) => { + var _a3, _b2, _c2, _d; + const valueArrayPos = rs.valueArrayPos; + const isAllowedValueArrayPos = typeof valueArrayPos === "undefined" || typeof valueArrayPos === "number" || ["ANY", "ANY_OPEN", "SUM", "LENGTH"].includes(String(valueArrayPos)); + if (!isAllowedValueArrayPos) { + pushRulesetValueArrayPosInvalid(c.alias || c.id, c.label || "", getCodeRef(code)); + } + if (typeof valueArrayPos === "number" && valueArrayPos < 0) { + pushRulesetValueArrayPosInvalid(c.alias || c.id, c.label || "", getCodeRef(code)); + } + if (typeof valueArrayPos !== "undefined" && codingValueShape.multiple === false) { + pushRulesetValueArrayPosInvalid(c.alias || c.id, c.label || "", getCodeRef(code)); + } + if (typeof valueArrayPos === "number" && codingValueShape.multiple === true && ((_b2 = (_a3 = codingValueShape.valuePositionLabels) === null || _a3 === void 0 ? void 0 : _a3.length) !== null && _b2 !== void 0 ? _b2 : 0) > 0 && valueArrayPos >= ((_d = (_c2 = codingValueShape.valuePositionLabels) === null || _c2 === void 0 ? void 0 : _c2.length) !== null && _d !== void 0 ? _d : 0)) { + pushRulesetValueArrayPosInvalid(c.alias || c.id, c.label || "", getCodeRef(code)); + } + rs.rules.forEach((r) => { + var _a4; + const parameterCount = r.parameters ? r.parameters.length : 0; + const expectedParameterCount = coding_scheme_1.RuleMethodParameterCount[r.method]; + const isMismatch = expectedParameterCount < 0 ? parameterCount < 1 : parameterCount !== expectedParameterCount; + if (isMismatch) { + pushRuleParameterMismatch(c.alias || c.id, c.label || "", getCodeRef(code)); + return; + } + const params = (_a4 = r.parameters) !== null && _a4 !== void 0 ? _a4 : []; + if (typeof r.fragment !== "undefined" && (!c.fragmenting || r.fragment < -1 || !Number.isInteger(r.fragment))) { + pushRuleParameterInvalid(c.alias || c.id, c.label || "", getCodeRef(code)); + } + if (numericRuleMethods.includes(r.method) && isKnownIncompatibleNumericRuleType(codingValueShape.valueType)) { + pushRuleParameterInvalid(c.alias || c.id, c.label || "", getCodeRef(code)); + } + if (booleanRuleMethods.includes(r.method) && isKnownIncompatibleBooleanRuleType(codingValueShape.valueType)) { + pushRuleParameterInvalid(c.alias || c.id, c.label || "", getCodeRef(code)); + } + if (r.method === "MATCH_REGEX") { + const patterns = params.flatMap((p) => p.split(/\r?\n/)); + patterns.forEach((p) => { + try { + new RegExp(p); + } catch (e) { + pushRuleRegexInvalid(c.alias || c.id, c.label || "", getCodeRef(code)); + } + }); + } + if ([ + "NUMERIC_MATCH", + "NUMERIC_LESS_THAN", + "NUMERIC_MORE_THAN", + "NUMERIC_MAX", + "NUMERIC_MIN" + ].includes(r.method)) { + const values = params.flatMap((p) => p.split(/\r?\n/)); + if (values.some((v) => !isFiniteNumberString(v))) { + pushRuleParameterInvalid(c.alias || c.id, c.label || "", getCodeRef(code)); + } + } + if (r.method === "NUMERIC_RANGE" || r.method === "NUMERIC_FULL_RANGE") { + const ll = params[0]; + const ul = params[1]; + if (!isFiniteNumberString(ll) || !isFiniteNumberString(ul)) { + pushRuleNumericRangeInvalid(c.alias || c.id, c.label || "", getCodeRef(code)); + } else { + const llNum = Number.parseFloat(ll); + const ulNum = Number.parseFloat(ul); + if (llNum > ulNum) { + pushRuleNumericRangeInvalid(c.alias || c.id, c.label || "", getCodeRef(code)); + } + } + } + }); + }); + }); + } else if (variableValuesCopied.indexOf(c.id) < 0) { + if (c.sourceType !== "BASE_NO_VALUE") { + problems.push({ + type: "VACANT", + breaking: false, + variableId: c.alias || c.id, + variableLabel: c.label || "" + }); + } + } + }); + return problems; + }; + exports2.validateCodingScheme = validateCodingScheme; + } +}); + +// node_modules/@iqb/responses/subform/grouping.js +var require_grouping = __commonJS({ + "node_modules/@iqb/responses/subform/grouping.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.groupResponsesBySubform = void 0; + var groupResponsesBySubform = (responses) => { + const notSubformResponses = []; + const subformGroups = responses.reduce((acc, r) => { + if (r.subform) { + acc[r.subform] = acc[r.subform] || []; + acc[r.subform].push(r); + } else { + notSubformResponses.push(r); + } + return acc; + }, {}); + return { subformGroups, notSubformResponses }; + }; + exports2.groupResponsesBySubform = groupResponsesBySubform; + } +}); + +// node_modules/@iqb/responses/mapping/alias-mapper.js +var require_alias_mapper = __commonJS({ + "node_modules/@iqb/responses/mapping/alias-mapper.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.mapResponseIdToAlias = exports2.mapResponseAliasToId = void 0; + var isDerivedVariable = (coding) => coding.sourceType !== "BASE" && coding.sourceType !== "BASE_NO_VALUE"; + var shadowsBaseSourceAlias = (coding, shadowedCoding) => { + var _a; + return Boolean(coding.alias && shadowedCoding && shadowedCoding.id === coding.alias && shadowedCoding.sourceType === "BASE" && isDerivedVariable(coding) && ((_a = coding.deriveSources) !== null && _a !== void 0 ? _a : []).includes(shadowedCoding.id)); + }; + var getAliasMap = (variableCodings) => { + const codingById = new Map(variableCodings.map((coding) => [coding.id, coding])); + const aliasMap = /* @__PURE__ */ new Map(); + variableCodings.filter((coding) => Boolean(coding.alias)).forEach((coding) => { + const alias = coding.alias; + const shadowedCoding = codingById.get(alias); + if (shadowsBaseSourceAlias(coding, shadowedCoding)) { + return; + } + aliasMap.set(alias, coding.id); + }); + return aliasMap; + }; + var mapResponseAliasToId = (responses, variableCodings) => { + const aliasMap = getAliasMap(variableCodings); + return responses.map((response) => Object.assign(Object.assign({}, response), { id: aliasMap.get(response.id) || response.id })); + }; + exports2.mapResponseAliasToId = mapResponseAliasToId; + var mapResponseIdToAlias = (responses, variableCodings) => { + const codingMap = new Map(variableCodings.map((coding) => [coding.id, coding.alias || coding.id])); + return responses.map((response) => Object.assign(Object.assign({}, response), { id: codingMap.get(response.id) || response.id })); + }; + exports2.mapResponseIdToAlias = mapResponseIdToAlias; + } +}); + +// node_modules/@iqb/responses/normalize/response-status.js +var require_response_status = __commonJS({ + "node_modules/@iqb/responses/normalize/response-status.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.markEmptyValuesInvalidForBaseUnlessAllowed = exports2.normalizeNotReachedToValueChanged = exports2.normalizeDisplayedToValueChanged = void 0; + var coding_factory_1 = require_coding_factory(); + var constants_1 = require_constants(); + var normalizeDisplayedToValueChanged = (responses, variableCodings) => { + const codingById = new Map(variableCodings.map((c) => [c.id, c])); + responses.filter((r) => r.status === constants_1.CODING_SCHEME_STATUS.DISPLAYED).forEach((r) => { + var _a; + const myCoding = codingById.get(r.id); + if (myCoding && myCoding.sourceType === "BASE" && ((_a = myCoding.sourceParameters) === null || _a === void 0 ? void 0 : _a.processing) && myCoding.sourceParameters.processing.includes("TAKE_DISPLAYED_AS_VALUE_CHANGED")) { + r.status = constants_1.CODING_SCHEME_STATUS.VALUE_CHANGED; + } + }); + }; + exports2.normalizeDisplayedToValueChanged = normalizeDisplayedToValueChanged; + var normalizeNotReachedToValueChanged = (responses, variableCodings) => { + const codingById = new Map(variableCodings.map((c) => [c.id, c])); + responses.filter((r) => r.status === constants_1.CODING_SCHEME_STATUS.NOT_REACHED).forEach((r) => { + var _a, _b; + const myCoding = codingById.get(r.id); + if ((myCoding === null || myCoding === void 0 ? void 0 : myCoding.sourceType) === "BASE" && ((_b = (_a = myCoding.sourceParameters) === null || _a === void 0 ? void 0 : _a.processing) === null || _b === void 0 ? void 0 : _b.includes("TAKE_NOT_REACHED_AS_VALUE_CHANGED"))) { + r.status = constants_1.CODING_SCHEME_STATUS.VALUE_CHANGED; + } + }); + }; + exports2.normalizeNotReachedToValueChanged = normalizeNotReachedToValueChanged; + var markEmptyValuesInvalidForBaseUnlessAllowed = (responses, variableCodings) => { + const codingById = new Map(variableCodings.map((c) => [c.id, c])); + responses.filter((r) => r.status === constants_1.CODING_SCHEME_STATUS.VALUE_CHANGED && coding_factory_1.CodingFactory.isEmptyValue(r.value)).forEach((r) => { + var _a, _b; + const myCoding = codingById.get(r.id); + if (!myCoding) { + return; + } + const isBaseType = myCoding.sourceType === "BASE"; + const takeEmptyAsValid = ((_b = (_a = myCoding.sourceParameters) === null || _a === void 0 ? void 0 : _a.processing) === null || _b === void 0 ? void 0 : _b.includes("TAKE_EMPTY_AS_VALID")) || false; + if (isBaseType && !takeEmptyAsValid) { + r.status = constants_1.CODING_SCHEME_STATUS.INVALID; + } + }); + }; + exports2.markEmptyValuesInvalidForBaseUnlessAllowed = markEmptyValuesInvalidForBaseUnlessAllowed; + } +}); + +// node_modules/@iqb/responses/merge/resolve-derived-conflicts.js +var require_resolve_derived_conflicts = __commonJS({ + "node_modules/@iqb/responses/merge/resolve-derived-conflicts.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.removeBaseResponsesShadowedByDerivedAlias = exports2.removeBaseResponsesShadowedByDerived = void 0; + var constants_1 = require_constants(); + var status_helpers_1 = require_status_helpers(); + var removeBaseResponsesShadowedByDerived = (responses, variableCodings) => { + const nonBaseCodings = variableCodings.filter((vc) => vc.sourceType !== "BASE"); + return responses.filter((response) => { + const hasDerivedConflict = nonBaseCodings.some((vc) => response.id === vc.id && (0, status_helpers_1.isUnsetLikeResponse)(Object.assign(Object.assign({}, response), { status: response.status })) && response.status !== constants_1.CODING_SCHEME_STATUS.CODING_COMPLETE); + return !hasDerivedConflict; + }); + }; + exports2.removeBaseResponsesShadowedByDerived = removeBaseResponsesShadowedByDerived; + var removeBaseResponsesShadowedByDerivedAlias = (responses, variableCodings) => { + const baseCodingIds = new Set(variableCodings.filter((vc) => vc.sourceType === "BASE").map((vc) => vc.id)); + const shadowingPairs = variableCodings.filter((vc) => { + var _a; + return vc.sourceType !== "BASE" && vc.sourceType !== "BASE_NO_VALUE" && Boolean(vc.alias) && vc.alias !== vc.id && baseCodingIds.has(vc.alias) && ((_a = vc.deriveSources) !== null && _a !== void 0 ? _a : []).includes(vc.alias); + }).map((vc) => ({ + baseId: vc.alias, + derivedId: vc.id + })); + return responses.filter((response) => { + const isShadowedBaseResponse = shadowingPairs.some((pair) => response.id === pair.baseId && responses.some((candidate) => candidate.id === pair.derivedId && candidate.subform === response.subform)); + return !isShadowedBaseResponse; + }); + }; + exports2.removeBaseResponsesShadowedByDerivedAlias = removeBaseResponsesShadowedByDerivedAlias; + } +}); + +// node_modules/@iqb/responses/finalize/finalize-and-deduplicate.js +var require_finalize_and_deduplicate = __commonJS({ + "node_modules/@iqb/responses/finalize/finalize-and-deduplicate.js"(exports2) { + "use strict"; + Object.defineProperty(exports2, "__esModule", { value: true }); + exports2.finalizeAndDeduplicateResponses = void 0; + var constants_1 = require_constants(); + function finalizeAndDeduplicateResponses(allCodedResponses, subformGroups, variableCodings) { + const isDuplicateResponse = (item, index, self) => index !== self.findIndex((t) => t.id === item.id && t.subform === item.subform); + const derivedAliases = new Set(variableCodings.filter((vc) => vc.sourceType !== "BASE" && vc.sourceType !== "BASE_NO_VALUE").map((vc) => vc.alias || vc.id)); + function shouldDropUnsetBecauseShadowed(response) { + if (Object.keys(subformGroups).length === 0) + return false; + const foundInSubformGroups = Object.values(subformGroups)[0].find((sr) => sr.id === response.id); + const foundInDerived = derivedAliases.has(response.id); + return Boolean(foundInSubformGroups || foundInDerived) && response.status === constants_1.CODING_SCHEME_STATUS.UNSET; + } + const uniqueResponses = allCodedResponses.filter((item, index, self) => !isDuplicateResponse(item, index, self)).filter((r) => !shouldDropUnsetBecauseShadowed(r)); + return [...uniqueResponses]; + } + exports2.finalizeAndDeduplicateResponses = finalizeAndDeduplicateResponses; + } +}); + +// node_modules/@iqb/responses/coding-scheme-factory.js +Object.defineProperty(exports, "__esModule", { value: true }); +exports.CodingSchemeFactory = void 0; +var deep_clone_1 = require_deep_clone(); +var dependency_tree_1 = require_dependency_tree(); +var derive_value_1 = require_derive_value(); +var dependency_plan_1 = require_dependency_plan(); +var derivation_traversal_1 = require_derivation_traversal(); +var validate_coding_scheme_1 = require_validate_coding_scheme(); +var grouping_1 = require_grouping(); +var alias_mapper_1 = require_alias_mapper(); +var response_status_1 = require_response_status(); +var resolve_derived_conflicts_1 = require_resolve_derived_conflicts(); +var finalize_and_deduplicate_1 = require_finalize_and_deduplicate(); +var CodingSchemeFactory = class _CodingSchemeFactory { + constructor() { + this.variableCodings = []; + } + static buildCodingPipeline(options) { + return [ + (ctx) => Object.assign(Object.assign({}, ctx), { responses: _CodingSchemeFactory.prepareResponsesForGroup(ctx.allResponses, ctx.variableCodings, ctx.notSubformResponses) }), + (ctx) => Object.assign(Object.assign({}, ctx), { responses: _CodingSchemeFactory.normalizeStatuses(ctx.responses, ctx.variableCodings) }), + (ctx) => Object.assign(Object.assign({}, ctx), { responses: (0, resolve_derived_conflicts_1.removeBaseResponsesShadowedByDerived)(ctx.responses, ctx.variableCodings) }), + (ctx) => { + const plan = (0, dependency_plan_1.buildDependencyPlan)(ctx.variableCodings, _CodingSchemeFactory.getVariableDependencyTree, options); + const prepared = (0, dependency_plan_1.ensureResponsesExist)(ctx.responses, ctx.variableCodings, plan); + const executed = (0, dependency_plan_1.executeDependencyPlan)(prepared.responses, ctx.variableCodings, prepared.plan, derivation_traversal_1.applyDerivationsAndCoding, options); + const resolved = (0, resolve_derived_conflicts_1.removeBaseResponsesShadowedByDerivedAlias)(executed, ctx.variableCodings); + return Object.assign(Object.assign({}, ctx), { responses: resolved }); + }, + (ctx) => Object.assign(Object.assign({}, ctx), { responses: (0, alias_mapper_1.mapResponseIdToAlias)(ctx.responses, ctx.variableCodings) }) + ]; + } + static prepareResponsesForGroup(groupResponses, variableCodings, notSubformResponses) { + const updatedResponses = (0, alias_mapper_1.mapResponseAliasToId)(groupResponses, variableCodings); + const isSubformGroup = groupResponses.every((r) => r.subform !== void 0); + return isSubformGroup ? [...updatedResponses, ...notSubformResponses] : [...updatedResponses]; + } + static normalizeStatuses(responses, variableCodings) { + const normalized = [...responses]; + (0, response_status_1.normalizeDisplayedToValueChanged)(normalized, variableCodings); + (0, response_status_1.normalizeNotReachedToValueChanged)(normalized, variableCodings); + (0, response_status_1.markEmptyValuesInvalidForBaseUnlessAllowed)(normalized, variableCodings); + return normalized; + } + static getVariableDependencyTree(variableCodings) { + return (0, dependency_tree_1.getVariableDependencyTree)(variableCodings); + } + static deriveValue(variableCodings, coding, sourceResponses) { + return (0, derive_value_1.deriveValue)(variableCodings, coding, sourceResponses); + } + static code(unitResponses, variableCodings, options) { + let newResponses = (0, deep_clone_1.deepClone)(unitResponses); + let allCodedResponses = []; + const { subformGroups, notSubformResponses } = (0, grouping_1.groupResponsesBySubform)(newResponses); + const pipeline = _CodingSchemeFactory.buildCodingPipeline(options); + [...Object.values(subformGroups), notSubformResponses].forEach((allResponses) => { + const initialCtx = { + responses: newResponses, + variableCodings, + notSubformResponses, + allResponses + }; + const finalCtx = pipeline.reduce((ctx, step) => step(ctx), initialCtx); + newResponses = finalCtx.responses; + allCodedResponses = [...allCodedResponses, ...finalCtx.responses]; + }); + return (0, finalize_and_deduplicate_1.finalizeAndDeduplicateResponses)(allCodedResponses, subformGroups, variableCodings); + } + static validate(baseVariables, variableCodings) { + return (0, validate_coding_scheme_1.validateCodingScheme)(baseVariables, variableCodings); + } + static getBaseVarsList(varAliases, variableCodings) { + const codingById = new Map(variableCodings.map((vc) => [vc.id, vc])); + const codingByAlias = new Map(variableCodings.filter((vc) => Boolean(vc.alias)).map((vc) => [vc.alias, vc])); + const getByAlias = (alias) => codingByAlias.get(alias); + const getById = (id) => codingById.get(id); + const getSourceVarAliases = (sourceVar) => { + var _a; + if (!sourceVar) + return []; + if (sourceVar.sourceType === "BASE") + return [sourceVar.alias || sourceVar.id]; + return ((_a = sourceVar.deriveSources) !== null && _a !== void 0 ? _a : []).map(getById).flatMap(getSourceVarAliases); + }; + const baseVarAliases = varAliases.map(getByAlias).flatMap(getSourceVarAliases); + return [...new Set(baseVarAliases)]; + } +}; +exports.CodingSchemeFactory = CodingSchemeFactory; diff --git a/apps/backend/src/app/database/services/coding/responses-compatibility.spec.ts b/apps/backend/src/app/database/services/coding/responses-compatibility.spec.ts new file mode 100644 index 000000000..68302e601 --- /dev/null +++ b/apps/backend/src/app/database/services/coding/responses-compatibility.spec.ts @@ -0,0 +1,39 @@ +import { CodingSchemeFactory } from '@iqb/responses'; +import { VariableCodingData } from '@iqbspecs/coding-scheme'; +import { VariableInfo } from '@iqbspecs/variable-info/variable-info.interface'; + +describe('Installed Responses engine compatibility', () => { + const variable = (type: string): VariableInfo => JSON.parse(JSON.stringify({ + id: 'V', type, format: '', multiple: false, nullable: false, values: [], valuePositionLabels: [] + })); + const noValueCoding: VariableCodingData = { id: 'V', sourceType: 'BASE_NO_VALUE', codes: [] }; + + it.each(['NO_VALUE', 'no-value'])('accepts %s without a false blocking validation error', type => { + expect(CodingSchemeFactory.validate([variable(type)], [noValueCoding])).toEqual([]); + }); + + it('retains validation of truly incompatible sources', () => { + expect(CodingSchemeFactory.validate([variable('STRING')], [noValueCoding])).toContainEqual( + expect.objectContaining({ type: 'INVALID_SOURCE', breaking: true, variableId: 'V' }) + ); + }); + + it('keeps Code 0 and Score 0 in real automatic coding', () => { + const actual = CodingSchemeFactory.code([{ id: 'V', value: 'A', status: 'VALUE_CHANGED' }], [{ + id: 'V', + sourceType: 'BASE', + codes: [{ + id: 0, + type: 'FULL_CREDIT', + score: 0, + ruleSets: [{ rules: [{ method: 'MATCH', parameters: ['A'] }] }] + }] + }]); + expect(actual).toContainEqual(expect.objectContaining({ + id: 'V', + code: 0, + score: 0, + status: 'CODING_COMPLETE' + })); + }); +}); diff --git a/apps/frontend/src/app/app.config.ts b/apps/frontend/src/app/app.config.ts index 03d19d9dc..63b48ffd6 100755 --- a/apps/frontend/src/app/app.config.ts +++ b/apps/frontend/src/app/app.config.ts @@ -18,25 +18,15 @@ import { UserActivityService, withAutoRefreshToken } from 'keycloak-angular'; -import { Observable } from 'rxjs'; import { routes } from './app.routes'; import { environment } from '../environments/environment'; import { authInterceptor } from './core/interceptors/auth.interceptor'; import { journalInterceptor } from './core/interceptors/journal-interceptor'; import { SERVER_URL } from './injection-tokens'; import { AUTH_SESSION_AUTO_REFRESH_TIMEOUT_MS } from './core/services/auth-session.config'; +import { CacheBustingTranslateLoader } from './core/services/translation-loader'; -const translationCacheBust = Date.now().toString(); - -export class CacheBustingTranslateLoader implements TranslateLoader { - constructor(private http: HttpClient) {} - - getTranslation(lang: string): Observable> { - return this.http.get>( - `./assets/i18n/${lang}.json?v=${translationCacheBust}` - ); - } -} +export { CacheBustingTranslateLoader } from './core/services/translation-loader'; export function createTranslateLoader(http: HttpClient): CacheBustingTranslateLoader { return new CacheBustingTranslateLoader(http); diff --git a/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.html b/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.html index 86d01093a..56fa17c00 100644 --- a/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.html +++ b/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.html @@ -1,103 +1,10 @@ -
-

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

- -
- -
-
-

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

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

{{'loading-units' | translate}}

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

{{'coding.codebook-quick-filters' | translate}}

- - {{'coding.training-requirement-filter' | translate}} - - {{'coding.training-requirement-all' | translate}} - {{'coding.training-requirement-required' | translate}} - {{'coding.training-requirement-not-required' | translate}} - - -
{{'coding.job-definition-filter' | translate}}
@@ -142,136 +49,10 @@

{{'coding.codebook-quick-filters' | translate}}

- - {{'coding.variable-bundle-filter' | translate}} - - @for (variableBundle of availableVariableBundles(); track variableBundle.id) { - - {{ variableBundle.name }} - - } - @if (!isLoadingVariableBundles() && availableVariableBundles().length === 0) { - {{'coding.variable-bundle-none' | 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') {
@@ -309,18 +90,5 @@

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

} - - - -
+ diff --git a/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.spec.ts b/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.spec.ts index 26a72acfc..e8f1d24dc 100644 --- a/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.spec.ts +++ b/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.spec.ts @@ -1,6 +1,10 @@ -import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { + ComponentFixture, TestBed +} from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { CodebookExportComponent } from '@iqb/ngx-coding-components/codebook-export'; import { DatePipe } from '@angular/common'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { of, throwError, BehaviorSubject, Subject } from 'rxjs'; @@ -110,6 +114,7 @@ describe('ExportCodingBookComponent', () => { const mockValidationResults$ = new BehaviorSubject(null); beforeEach(async () => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask', 'requestAnimationFrame'] }); const exportServiceMock = { getCodingBook: jest.fn(), startCodebookJob: jest.fn(), @@ -165,6 +170,7 @@ describe('ExportCodingBookComponent', () => { await TestBed.configureTestingModule({ imports: [ExportCodingBookComponent], providers: [ + { provide: MatDialogRef, useValue: { close: jest.fn() } }, { provide: CodingExportService, useValue: exportServiceMock }, { provide: CodingJobBackendService, useValue: codingJobBackendServiceMock }, { provide: MissingsProfileService, useValue: missingsProfileServiceMock }, @@ -214,7 +220,9 @@ describe('ExportCodingBookComponent', () => { afterEach(() => { mockValidationProgress$.next({ status: 'idle', progress: 0, message: '' }); mockValidationResults$.next(null); + fixture.destroy(); jest.restoreAllMocks(); + jest.useRealTimers(); }); it('should create', () => { @@ -695,6 +703,72 @@ describe('ExportCodingBookComponent', () => { }); }); + it('keeps host scope filters when the shared form requests an export', () => { + component.selectedJobDefinitionId.set(10); + component.selectedVariableBundleIds.set([5]); + component.contentOptions.update(options => ({ ...options, trainingRequirement: 'required' })); + exportService.startCodebookJob.mockReturnValue(new Subject()); + component.onSharedExport({ + selectedUnits: [2], + missingsProfileId: 1, + contentOptions: { ...component.contentOptions(), exportFormat: 'json' } + }); + component.onSharedExport({ + selectedUnits: [1], + missingsProfileId: 2, + contentOptions: { ...component.contentOptions(), exportFormat: 'docx' } + }); + expect(exportService.startCodebookJob).toHaveBeenCalledTimes(1); + expect(exportService.startCodebookJob).toHaveBeenCalledWith(1, '1', expect.objectContaining({ + exportFormat: 'json', trainingRequirement: 'required', jobDefinitionId: 10, variableBundleIds: [5] + }), [2]); + }); + + it.each(['required', 'not-required'] as const)('preserves %s from filter change through the rendered shared form to job start', trainingRequirement => { + fixture.detectChanges(); + const shared = fixture.debugElement.query(By.directive(CodebookExportComponent)).componentInstance as CodebookExportComponent; + shared.contentOptions.trainingRequirement = trainingRequirement; + expect(shared.showGroupColumn).toBe(false); + expect(fixture.nativeElement.textContent).not.toContain('coding.variable-bundle-filter'); + expect(fixture.nativeElement.querySelector('mat-select[multiple]')).toBeNull(); + fixture.detectChanges(); + shared.toggleAll(); + exportService.startCodebookJob.mockReturnValue(new Subject()); + shared.exportCodingBook(); + expect(exportService.startCodebookJob).toHaveBeenCalledWith( + 1, + expect.any(String), + expect.objectContaining({ trainingRequirement }), + expect.arrayContaining([1]) + ); + }); + + it('does not overlap slow status requests and cancels them when the dialog closes', () => { + const status = new Subject<{ status: string; progress: number }>(); + exportService.startCodebookJob.mockReturnValue(of({ jobId: 'job', message: 'ok' })); + exportService.getCodebookJobStatus.mockReturnValue(status); + component.unitList.set([1]); component.exportCodingBook(); + jest.advanceTimersByTime(4500); + expect(exportService.getCodebookJobStatus).toHaveBeenCalledTimes(1); + expect(status.observed).toBe(true); + component.ngOnDestroy(); + expect(status.observed).toBe(false); + jest.advanceTimersByTime(4500); + expect(exportService.getCodebookJobStatus).toHaveBeenCalledTimes(1); + }); + + it('keeps export locked until the completed job has been downloaded', () => { + exportService.startCodebookJob.mockReturnValue(of({ jobId: 'job', message: 'ok' })); + exportService.getCodebookJobStatus.mockReturnValue(of({ status: 'completed', progress: 100 })); + const download = new Subject(); + exportService.downloadCodebookFile.mockReturnValue(download); + component.unitList.set([1]); component.exportCodingBook(); jest.advanceTimersByTime(1500); + component.exportCodingBook(); + expect(component.isExportBusy).toBe(true); + expect(exportService.startCodebookJob).toHaveBeenCalledTimes(1); + component.ngOnDestroy(); expect(download.observed).toBe(false); + }); + describe('resetCodebookJob', () => { it('should reset all codebook job state', () => { component.codebookJobId.set('123'); diff --git a/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.ts b/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.ts index 148cb7994..1c3ebaa68 100644 --- a/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.ts +++ b/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.ts @@ -1,39 +1,36 @@ import { - Component, OnInit, OnDestroy, computed, signal, ChangeDetectionStrategy, DestroyRef, inject + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + OnInit, + OnDestroy, + signal, + inject } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { CodebookExportComponent } from '@iqb/ngx-coding-components/codebook-export'; +import type { CodebookExportConfig, UnitSelectionItem } from '@iqb/ngx-coding-components/codebook-models'; import { DatePipe } from '@angular/common'; import { FormsModule } from '@angular/forms'; -import { MatDialog, MatDialogModule } from '@angular/material/dialog'; -import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatRadioModule } from '@angular/material/radio'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; 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 { MatTableDataSource } from '@angular/material/table'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { Subject, Subscription, interval } from 'rxjs'; import { - Subject, Subscription, interval -} from 'rxjs'; -import { - debounceTime, distinctUntilChanged, switchMap, takeUntil + debounceTime, distinctUntilChanged, exhaustMap, takeUntil } from 'rxjs/operators'; import { CodeBookContentSetting, CodebookTrainingRequirementFilter } from '../../../../../../../api-dto/coding/codebook-content-setting'; import { CodingExportService } from '../../services/coding-export.service'; -import { - CodingJobBackendService, - JobDefinition -} from '../../services/coding-job-backend.service'; +import { CodingJobBackendService, JobDefinition } from '../../services/coding-job-backend.service'; import { VariableBundle } from '../../models/coding-job.model'; import { MissingsProfileService } from '../../services/missings-profile.service'; import { FileService } from '../../../shared/services/file/file.service'; @@ -54,72 +51,53 @@ interface CodebookUnitOption { unitAlias: string | null; unitData: string; } - @Component({ - changeDetection: ChangeDetectionStrategy.OnPush, selector: 'coding-box-export-coding-book', + changeDetection: ChangeDetectionStrategy.OnPush, templateUrl: './export-coding-book.component.html', styleUrls: ['./export-coding-book.component.scss'], standalone: true, imports: [ + CodebookExportComponent, FormsModule, MatDialogModule, - MatCheckboxModule, - MatRadioModule, MatSelectModule, MatButtonModule, - MatInputModule, MatFormFieldModule, MatOptionModule, MatIconModule, MatTooltipModule, - MatDividerModule, - MatTableModule, - MatProgressSpinnerModule, MatProgressBarModule, TranslateModule ], - providers: [ - DatePipe - ] + providers: [DatePipe] }) export class ExportCodingBookComponent implements OnInit, OnDestroy { - private exportService = inject(CodingExportService); - private codingJobBackendService = inject(CodingJobBackendService); - private missingsProfileService = inject(MissingsProfileService); - private fileService = inject(FileService); - private appService = inject(AppService); - private datePipe = inject(DatePipe); - private validationStateService = inject(ValidationStateService); - private translateService = inject(TranslateService); - private dialog = inject(MatDialog); - - private readonly destroyRef = inject(DestroyRef); - readonly unitList = signal([]); readonly availableUnits = signal([]); - dataSource = new MatTableDataSource([]); - - protected readonly filterValue = signal(''); - protected filterTextChanged = new Subject(); + readonly filterValue = signal(''); + filterTextChanged = new Subject(); readonly isLoading = signal(false); - readonly selectedMissingsProfile = signal(0); - readonly missingsProfiles = signal<{ id: number, label: string }[]>([{ id: 0, label: '' }]); + readonly missingsProfiles = signal< + { + id: number; + label: string; + }[] + >([{ id: 0, label: '' }]); + readonly selectedJobDefinitionId = signal(null); readonly availableJobDefinitions = signal([]); readonly jobDefinitionOptions = signal([]); - protected readonly selectedJobDefinitionLabel = signal(''); - protected readonly selectedJobDefinitionSummary = signal(''); + readonly selectedJobDefinitionLabel = signal(''); + readonly selectedJobDefinitionSummary = signal(''); readonly isLoadingJobDefinitions = signal(false); - protected readonly selectedVariableBundleIds = signal([]); + readonly selectedVariableBundleIds = signal([]); readonly availableVariableBundles = signal([]); - protected readonly isLoadingVariableBundles = signal(false); - protected readonly workspaceChanges = signal(false); - - protected displayedColumns: string[] = ['select', 'unitName']; - + readonly isLoadingVariableBundles = signal(false); + readonly workspaceChanges = signal(false); + displayedColumns: string[] = ['select', 'unitName']; readonly contentOptions = signal({ exportFormat: 'docx', missingsProfile: '', @@ -139,18 +117,77 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); readonly validationResults = signal(null); readonly validationProgress = signal(null); - readonly isValidating = computed(() => { - const status = this.validationProgress()?.status; - return status === 'loading' || status === 'processing'; - }); - + readonly isValidating = signal(false); readonly validationCacheKey = signal(null); - readonly codebookJobId = signal(null); readonly codebookJobStatus = signal<'idle' | 'pending' | 'processing' | 'completed' | 'failed'>('idle'); readonly codebookJobProgress = signal(0); readonly codebookJobError = signal(null); private codebookPollingSubscription: Subscription | null = null; + private readonly exportService = inject(CodingExportService); + private readonly codingJobBackendService = inject(CodingJobBackendService); + private readonly missingsProfileService = inject(MissingsProfileService); + private readonly fileService = inject(FileService); + private readonly appService = inject(AppService); + private readonly datePipe = inject(DatePipe); + private readonly validationStateService = inject(ValidationStateService); + private readonly translateService = inject(TranslateService); + private readonly dialog = inject(MatDialog); + private readonly dialogRef = inject>(MatDialogRef); + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private sharedUnitsSource: CodebookUnitOption[] | null = null; + private sharedUnitsCache: UnitSelectionItem[] = []; + private profilesSource: + | { + id: number; + label: string; + }[] + | null = null; + + private profilesCache: { + id: number; + label: string; + }[] = []; + + get sharedUnits(): UnitSelectionItem[] { + if (this.sharedUnitsSource !== this.availableUnits()) { + this.sharedUnitsSource = this.availableUnits(); + this.sharedUnitsCache = this.availableUnits().map(unit => ({ + unitId: unit.unitId, + key: unit.unitKey.replace(/\.vocs$/i, ''), + unitName: this.formatUnitName(unit.unitName), + unitAlias: unit.unitAlias + })); + } + return this.sharedUnitsCache; + } + + get sharedMissingsProfiles(): { + id: number; + label: string; + }[] { + if (this.profilesSource !== this.missingsProfiles()) { + this.profilesSource = this.missingsProfiles(); + this.profilesCache = this.missingsProfiles().filter(profile => profile.id !== 0); + } + return this.profilesCache; + } + + get isExportBusy(): boolean { + return this.codebookJobStatus() === 'pending' || this.codebookJobStatus() === 'processing'; + } + + onSharedExport(config: CodebookExportConfig): void { + if (this.isExportBusy) return; + this.unitList.set([...config.selectedUnits]); + this.selectedMissingsProfile.set(config.missingsProfileId); + this.contentOptions.set({ ...this.contentOptions(), ...config.contentOptions }); + this.exportCodingBook(); + } + + closeDialog(): void { + this.dialogRef.close(false); + } ngOnInit(): void { this.updateSelectedJobDefinitionDisplay(); @@ -159,26 +196,18 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { this.loadJobDefinitions(); this.loadVariableBundles(); this.loadUnitsWithFileIds(); - this.filterTextChanged - .pipe( - debounceTime(300), - distinctUntilChanged() - ).pipe(takeUntilDestroyed(this.destroyRef)) + .pipe(debounceTime(300), distinctUntilChanged(), takeUntil(this.destroy$)) .subscribe(event => { this.applyFilter(event); }); - this.validationStateService.validationProgress$ .pipe(takeUntil(this.destroy$)) .subscribe(progress => this.handleValidationProgress(progress)); - this.handleValidationProgress(this.validationStateService.getValidationProgress()); - this.validationStateService.validationResults$ .pipe(takeUntil(this.destroy$)) .subscribe(results => this.handleValidationResults(results)); - this.handleValidationResults(this.validationStateService.getValidationResults()); } @@ -197,39 +226,44 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { const workspaceId = this.appService.selectedWorkspaceId; if (workspaceId) { this.isLoading.set(true); - - this.fileService.getUnitsWithFileIds(workspaceId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ - next: units => { - if (units && units.length > 0) { - this.availableUnits.set(units.map((unit: { id: number; unitId: string; fileName: string; data: string }) => ({ - unitId: unit.id, - unitKey: unit.unitId, - unitName: unit.fileName, - unitAlias: null, - unitData: unit.data - }))); - this.dataSource.data = this.availableUnits(); - this.dataSource.filterPredicate = (data, filter: string) => { - const formattedName = this.formatUnitName(data.unitName).toLowerCase(); - return formattedName.includes(filter); - }; - - this.unitList.set([]); - this.applyQuickFilterUnitSelection(); + this.fileService + .getUnitsWithFileIds(workspaceId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: units => { + this.changeDetectorRef.markForCheck(); + if (units && units.length > 0) { + this.availableUnits.set( + units.map((unit: { id: number; unitId: string; fileName: string; data: string }) => ({ + unitId: unit.id, + unitKey: unit.unitId, + unitName: unit.fileName, + unitAlias: null, + unitData: unit.data + })) + ); + this.dataSource.data = this.availableUnits(); + this.dataSource.filterPredicate = (data, filter: string) => { + const formattedName = this.formatUnitName(data.unitName).toLowerCase(); + return formattedName.includes(filter); + }; + this.unitList.set([]); + this.applyQuickFilterUnitSelection(); + } + this.isLoading.set(false); + }, + error: () => { + this.changeDetectorRef.markForCheck(); + this.isLoading.set(false); } - this.isLoading.set(false); - }, - error: () => { - this.isLoading.set(false); - } - }); + }); } } toggleUnitSelection(unitId: number, isSelected: boolean): void { if (isSelected) { if (!this.unitList().includes(unitId)) { - this.unitList.update(unitIds => [...unitIds, unitId]); + this.unitList.set([...this.unitList(), unitId]); } } else { this.unitList.set(this.unitList().filter(id => id !== unitId)); @@ -259,31 +293,20 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { return false; } - protected updateContentOption( - key: K, - value: CodeBookContentSetting[K] - ): void { - this.contentOptions.update(options => ({ ...options, [key]: value })); - } - - onTrainingRequirementChange( - trainingRequirement: CodebookTrainingRequirementFilter - ): void { - this.updateContentOption('trainingRequirement', trainingRequirement); + onTrainingRequirementChange(trainingRequirement: CodebookTrainingRequirementFilter): void { + this.contentOptions.update(current => ({ ...current, trainingRequirement: trainingRequirement })); } onJobDefinitionFilterChange(jobDefinitionId: number | null): void { this.selectedJobDefinitionId.set(jobDefinitionId); - this.updateContentOption('jobDefinitionId', jobDefinitionId); + this.contentOptions.update(current => ({ ...current, jobDefinitionId: jobDefinitionId })); this.updateSelectedJobDefinitionDisplay(); this.applyQuickFilterUnitSelection(); } onVariableBundleFilterChange(variableBundleIds: number[]): void { - this.selectedVariableBundleIds.set(Array.isArray(variableBundleIds) ? - variableBundleIds : - []); - this.updateContentOption('variableBundleIds', this.selectedVariableBundleIds()); + this.selectedVariableBundleIds.set(Array.isArray(variableBundleIds) ? variableBundleIds : []); + this.contentOptions.update(current => ({ ...current, variableBundleIds: this.selectedVariableBundleIds() })); this.applyQuickFilterUnitSelection(); } @@ -291,23 +314,26 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { if (this.isLoadingJobDefinitions()) { return; } - const dialogData: CodebookJobDefinitionPickerDialogData = { options: this.jobDefinitionOptions(), selectedJobDefinitionId: this.selectedJobDefinitionId() }; - - this.dialog.open(CodebookJobDefinitionPickerDialogComponent, { - width: '960px', - maxWidth: '94vw', - maxHeight: '88vh', - autoFocus: false, - data: dialogData - }).afterClosed().pipe(takeUntilDestroyed(this.destroyRef)).subscribe(jobDefinitionId => { - if (jobDefinitionId !== undefined) { - this.onJobDefinitionFilterChange(jobDefinitionId); - } - }); + this.dialog + .open(CodebookJobDefinitionPickerDialogComponent, { + width: '960px', + maxWidth: '94vw', + maxHeight: '88vh', + autoFocus: false, + data: dialogData + }) + .afterClosed() + .pipe(takeUntil(this.destroy$)) + .subscribe(jobDefinitionId => { + this.changeDetectorRef.markForCheck(); + if (jobDefinitionId !== undefined) { + this.onJobDefinitionFilterChange(jobDefinitionId); + } + }); } private getJobDefinitionLabel(jobDefinition: JobDefinition): string { @@ -321,16 +347,24 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { const coderCount = jobDefinition.assignedCoders?.length || 0; const caseOrderingMode = this.getJobDefinitionCaseOrderingLabel(jobDefinition); const maxCases = jobDefinition.maxCodingCases ? - ` · ${this.translateService.instant('coding.job-definition-max-cases', { count: jobDefinition.maxCodingCases })}` : + ` · ${this.translateService.instant('coding.job-definition-max-cases', { + count: jobDefinition.maxCodingCases + })}` : ''; - const scopeSummary = [ - this.formatCount(variableCount, 'coding.job-definition-count-variable-one', 'coding.job-definition-count-variable-many'), + this.formatCount( + variableCount, + 'coding.job-definition-count-variable-one', + 'coding.job-definition-count-variable-many' + ), this.formatCount(unitCount, 'coding.job-definition-count-unit-one', 'coding.job-definition-count-unit-many'), - this.formatCount(bundleCount, 'coding.job-definition-count-bundle-one', 'coding.job-definition-count-bundle-many'), + this.formatCount( + bundleCount, + 'coding.job-definition-count-bundle-one', + 'coding.job-definition-count-bundle-many' + ), this.formatCount(coderCount, 'coding.job-definition-count-coder-one', 'coding.job-definition-count-coder-many') ].join(' · '); - return `${scopeSummary} · ${caseOrderingMode}${maxCases}`; } @@ -338,11 +372,9 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { const bundleNames = (jobDefinition.assignedVariableBundles || []) .map(bundle => this.getHydratedVariableBundle(bundle.id)?.name || bundle.name) .filter((name): name is string => Boolean(name)); - if (bundleNames.length === 0) { return ''; } - const visibleNames = bundleNames.slice(0, 2).join(', '); const hiddenCount = bundleNames.length - 2; return hiddenCount > 0 ? @@ -355,23 +387,29 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { if (!workspaceId) { return; } - this.isLoadingJobDefinitions.set(true); - this.codingJobBackendService.getJobDefinitions(workspaceId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ - next: jobDefinitions => { - this.availableJobDefinitions.set(jobDefinitions - .filter(jobDefinition => jobDefinition.id !== undefined) - .sort((a, b) => (a.id ?? 0) - (b.id ?? 0))); - this.refreshJobDefinitionOptions(); - this.isLoadingJobDefinitions.set(false); - this.applyQuickFilterUnitSelection(); - }, - error: () => { - this.availableJobDefinitions.set([]); - this.refreshJobDefinitionOptions(); - this.isLoadingJobDefinitions.set(false); - } - }); + this.codingJobBackendService + .getJobDefinitions(workspaceId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: jobDefinitions => { + this.changeDetectorRef.markForCheck(); + this.availableJobDefinitions.set( + jobDefinitions + .filter(jobDefinition => jobDefinition.id !== undefined) + .sort((a, b) => (a.id ?? 0) - (b.id ?? 0)) + ); + this.refreshJobDefinitionOptions(); + this.isLoadingJobDefinitions.set(false); + this.applyQuickFilterUnitSelection(); + }, + error: () => { + this.changeDetectorRef.markForCheck(); + this.availableJobDefinitions.set([]); + this.refreshJobDefinitionOptions(); + this.isLoadingJobDefinitions.set(false); + } + }); } private loadVariableBundles(): void { @@ -379,73 +417,72 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { if (!workspaceId) { return; } - this.isLoadingVariableBundles.set(true); - this.codingJobBackendService.getVariableBundles(workspaceId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ - next: variableBundles => { - this.availableVariableBundles.set(variableBundles - .filter(variableBundle => variableBundle.id !== undefined) - .sort((a, b) => a.name.localeCompare(b.name))); - this.refreshJobDefinitionOptions(); - this.isLoadingVariableBundles.set(false); - this.applyQuickFilterUnitSelection(); - }, - error: () => { - this.availableVariableBundles.set([]); - this.refreshJobDefinitionOptions(); - this.isLoadingVariableBundles.set(false); - } - }); + this.codingJobBackendService + .getVariableBundles(workspaceId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: variableBundles => { + this.changeDetectorRef.markForCheck(); + this.availableVariableBundles.set( + variableBundles + .filter(variableBundle => variableBundle.id !== undefined) + .sort((a, b) => a.name.localeCompare(b.name)) + ); + this.refreshJobDefinitionOptions(); + this.isLoadingVariableBundles.set(false); + this.applyQuickFilterUnitSelection(); + }, + error: () => { + this.changeDetectorRef.markForCheck(); + this.availableVariableBundles.set([]); + this.refreshJobDefinitionOptions(); + this.isLoadingVariableBundles.set(false); + } + }); } private applyQuickFilterUnitSelection(): void { if (this.availableUnits().length === 0) { return; } - const filterVariableSelections: Map>[] = []; const selectedJobDefinition = this.getSelectedJobDefinition(); if (selectedJobDefinition) { filterVariableSelections.push(this.getJobDefinitionVariableSelections(selectedJobDefinition)); } - if (this.selectedVariableBundleIds().length > 0) { filterVariableSelections.push(this.getSelectedVariableBundleVariableSelections()); } - if (filterVariableSelections.length === 0) { this.unitList.set([]); return; } - const variableSelections = this.intersectVariableSelectionMaps(filterVariableSelections); if (!this.hasVariableSelections(variableSelections)) { this.unitList.set([]); return; } - const unitKeys = new Set(variableSelections.keys()); - this.unitList.set(this.availableUnits() - .filter(unit => ( - unitKeys.has(this.normalizeUnitKey(unit.unitKey)) || - unitKeys.has(this.normalizeUnitKey(unit.unitName)) - )) - .map(unit => unit.unitId)); + this.unitList.set( + this.availableUnits() + .filter( + unit => unitKeys.has(this.normalizeUnitKey(unit.unitKey)) || unitKeys.has(this.normalizeUnitKey(unit.unitName)) + ) + .map(unit => unit.unitId) + ); } private getSelectedJobDefinition(): JobDefinition | undefined { if (this.selectedJobDefinitionId() === null) { return undefined; } - - return this.availableJobDefinitions().find( - jobDefinition => jobDefinition.id === this.selectedJobDefinitionId() - ); + return this.availableJobDefinitions().find(jobDefinition => jobDefinition.id === this.selectedJobDefinitionId()); } private refreshJobDefinitionOptions(): void { - this.jobDefinitionOptions.set(this.availableJobDefinitions() - .map(jobDefinition => { + this.jobDefinitionOptions.set( + this.availableJobDefinitions().map(jobDefinition => { const label = this.getJobDefinitionLabel(jobDefinition); const meta = this.getJobDefinitionMeta(jobDefinition); const bundleSummary = this.getJobDefinitionBundleSummary(jobDefinition); @@ -458,16 +495,16 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { bundleSummary, summary }; - })); + }) + ); this.updateSelectedJobDefinitionDisplay(); } private updateSelectedJobDefinitionDisplay(): void { - const selectedOption = this.jobDefinitionOptions().find( - option => option.id === this.selectedJobDefinitionId() + const selectedOption = this.jobDefinitionOptions().find(option => option.id === this.selectedJobDefinitionId()); + this.selectedJobDefinitionLabel.set( + selectedOption?.label || this.translateService.instant('coding.job-definition-all') ); - this.selectedJobDefinitionLabel.set(selectedOption?.label || - this.translateService.instant('coding.job-definition-all')); this.selectedJobDefinitionSummary.set(selectedOption?.summary || ''); } @@ -475,35 +512,25 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { if (this.selectedVariableBundleIds().length === 0) { return []; } - - return this.availableVariableBundles().filter(variableBundle => ( - this.selectedVariableBundleIds().includes(variableBundle.id) - )); + return this.availableVariableBundles().filter(variableBundle => this.selectedVariableBundleIds().includes(variableBundle.id) + ); } private getSelectedVariableBundleVariableSelections(): Map> { const variableSelections = new Map>(); - this.getSelectedVariableBundles() - .forEach(variableBundle => this.addVariableSelections( - variableSelections, - variableBundle.variables - )); + this.getSelectedVariableBundles().forEach(variableBundle => this.addVariableSelections(variableSelections, variableBundle.variables) + ); return variableSelections; } - private intersectVariableSelectionMaps( - selectionMaps: Map>[] - ): Map> { + private intersectVariableSelectionMaps(selectionMaps: Map>[]): Map> { const [firstSelectionMap, ...otherSelectionMaps] = selectionMaps; if (!firstSelectionMap) { return new Map>(); } - const intersection = new Map( - [...firstSelectionMap.entries()] - .map(([unitKey, variableIds]) => [unitKey, new Set(variableIds)]) + [...firstSelectionMap.entries()].map(([unitKey, variableIds]) => [unitKey, new Set(variableIds)]) ); - otherSelectionMaps.forEach(selectionMap => { [...intersection.entries()].forEach(([unitKey, variableIds]) => { const nextVariableIds = selectionMap.get(unitKey); @@ -511,7 +538,6 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { intersection.delete(unitKey); return; } - const intersectedVariableIds = new Set( [...variableIds].filter(variableId => nextVariableIds.has(variableId)) ); @@ -519,28 +545,19 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { intersection.delete(unitKey); return; } - intersection.set(unitKey, intersectedVariableIds); }); }); - return intersection; } - private getJobDefinitionVariableSelections( - jobDefinition: JobDefinition - ): Map> { + private getJobDefinitionVariableSelections(jobDefinition: JobDefinition): Map> { const variableSelections = new Map>(); this.addVariableSelections(variableSelections, jobDefinition.assignedVariables || []); - (jobDefinition.assignedVariableBundles || []) - .forEach(bundle => { - const hydratedBundle = this.getHydratedVariableBundle(bundle.id); - this.addVariableSelections( - variableSelections, - hydratedBundle?.variables || [] - ); - }); - + (jobDefinition.assignedVariableBundles || []).forEach(bundle => { + const hydratedBundle = this.getHydratedVariableBundle(bundle.id); + this.addVariableSelections(variableSelections, hydratedBundle?.variables || []); + }); return variableSelections; } @@ -548,17 +565,17 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { if (bundleId === undefined) { return undefined; } - - return this.availableVariableBundles().find( - availableBundle => availableBundle.id === bundleId - ); + return this.availableVariableBundles().find(availableBundle => availableBundle.id === bundleId); } - private getJobDefinitionVariables(jobDefinition: JobDefinition): Array<{ unitName?: string; variableId?: string }> { + private getJobDefinitionVariables(jobDefinition: JobDefinition): Array<{ + unitName?: string; + variableId?: string; + }> { const directVariables = jobDefinition.assignedVariables || []; - const bundleVariables = (jobDefinition.assignedVariableBundles || []) - .flatMap(bundle => this.getHydratedVariableBundle(bundle.id)?.variables || bundle.variables || []); - + const bundleVariables = (jobDefinition.assignedVariableBundles || []).flatMap( + bundle => this.getHydratedVariableBundle(bundle.id)?.variables || bundle.variables || [] + ); return [...directVariables, ...bundleVariables]; } @@ -568,7 +585,6 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { .map(variable => `${this.normalizeUnitKey(variable.unitName)}:${variable.variableId || ''}`) .filter(variableKey => variableKey !== ':') ); - return variableKeys.size; } @@ -578,7 +594,6 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { .map(variable => this.normalizeUnitKey(variable.unitName)) .filter(Boolean) ); - return unitKeys.size; } @@ -598,27 +613,24 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { private addVariableSelections( variableSelections: Map>, - variables: Array<{ unitName?: string; variableId?: string }> + variables: Array<{ + unitName?: string; + variableId?: string; + }> ): void { variables.forEach(variable => { const unitKey = this.normalizeUnitKey(variable.unitName); - const variableId = this.normalizeVariableIdForUnit( - unitKey, - variable.variableId - ); + const variableId = this.normalizeVariableIdForUnit(unitKey, variable.variableId); if (!unitKey || !variableId) { return; } - const variableIds = variableSelections.get(unitKey) ?? new Set(); variableIds.add(variableId); variableSelections.set(unitKey, variableIds); }); } - private hasVariableSelections( - variableSelections: Map> - ): boolean { + private hasVariableSelections(variableSelections: Map>): boolean { return [...variableSelections.values()].some(variableIds => variableIds.size > 0); } @@ -629,42 +641,37 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { .toUpperCase(); } - private normalizeVariableIdForUnit( - unitKey: string, - value: string | undefined - ): string { + private normalizeVariableIdForUnit(unitKey: string, value: string | undefined): string { const variableId = (value || '').trim(); if (!unitKey || !variableId) { return ''; } - - const unit = this.availableUnits().find(availableUnit => ( - this.normalizeUnitKey(availableUnit.unitKey) === unitKey || - this.normalizeUnitKey(availableUnit.unitName) === unitKey - )); + const unit = this.availableUnits().find( + availableUnit => this.normalizeUnitKey(availableUnit.unitKey) === unitKey || + this.normalizeUnitKey(availableUnit.unitName) === unitKey + ); if (!unit?.unitData) { return variableId; } - try { const scheme = JSON.parse(unit.unitData) as { - variableCodings?: Array<{ id?: string; alias?: string }>; + variableCodings?: Array<{ + id?: string; + alias?: string; + }>; }; const variableCodings = scheme.variableCodings || []; - const matchingVariableCoding = variableCodings - .find(variableCoding => variableCoding.alias?.trim() === variableId) || - variableCodings - .find(variableCoding => variableCoding.id?.trim() === variableId); - - return matchingVariableCoding?.alias?.trim() || - matchingVariableCoding?.id?.trim() || - variableId; + const matchingVariableCoding = + variableCodings.find(variableCoding => variableCoding.alias?.trim() === variableId) || + variableCodings.find(variableCoding => variableCoding.id?.trim() === variableId); + return matchingVariableCoding?.alias?.trim() || matchingVariableCoding?.id?.trim() || variableId; } catch { return variableId; } } private handleValidationResults(results: ValidateCodingCompletenessResponseDto | null): void { + this.changeDetectorRef.markForCheck(); this.validationResults.set(results); if (results) { this.validationCacheKey.set(results.cacheKey || null); @@ -672,26 +679,43 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { } private handleValidationProgress(progress: ValidationProgress): void { + this.changeDetectorRef.markForCheck(); this.validationProgress.set(progress); + this.isValidating.set(progress.status === 'loading' || progress.status === 'processing'); } private loadMissingsProfiles(): void { const workspaceId = this.appService.selectedWorkspaceId; if (workspaceId) { - this.missingsProfileService.getMissingsProfiles(workspaceId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ - next: profiles => { - this.missingsProfiles.set([{ id: 0, label: '' }, ...profiles.map((profile: { label: string; id: number }) => ({ id: profile.id ?? 0, label: profile.label }))]); - this.selectedMissingsProfile.set(0); - this.updateContentOption('missingsProfile', this.selectedMissingsProfile().toString()); - }, - error: () => { - // Error occurred while loading missings profiles - } - }); + this.missingsProfileService + .getMissingsProfiles(workspaceId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: profiles => { + this.changeDetectorRef.markForCheck(); + this.missingsProfiles.set([ + { id: 0, label: '' }, + ...profiles.map((profile: { label: string; id: number }) => ({ + id: profile.id ?? 0, + label: profile.label + })) + ]); + this.selectedMissingsProfile.set(0); + this.contentOptions.update(current => ({ + ...current, + missingsProfile: this.selectedMissingsProfile().toString() + })); + }, + error: () => { + this.changeDetectorRef.markForCheck(); + // Error occurred while loading missings profiles + } + }); } } exportCodingBook(): void { + if (this.isExportBusy) return; const workspaceId = this.appService.selectedWorkspaceId; if (!workspaceId) { return; @@ -699,53 +723,52 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { if (this.unitList().length === 0) { return; } - - this.updateContentOption('missingsProfile', this.selectedMissingsProfile().toString()); - this.updateContentOption('jobDefinitionId', this.selectedJobDefinitionId()); - this.updateContentOption('variableBundleIds', this.selectedVariableBundleIds()); + this.contentOptions.update(current => ({ + ...current, + missingsProfile: this.selectedMissingsProfile().toString() + })); + this.contentOptions.update(current => ({ ...current, jobDefinitionId: this.selectedJobDefinitionId() })); + this.contentOptions.update(current => ({ ...current, variableBundleIds: this.selectedVariableBundleIds() })); this.codebookJobStatus.set('pending'); this.codebookJobProgress.set(0); this.codebookJobError.set(null); this.codebookJobId.set(null); - - this.exportService.startCodebookJob( - workspaceId, - this.contentOptions().missingsProfile, - this.contentOptions(), - this.unitList() - ).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ - next: response => { - this.codebookJobId.set(response.jobId); - this.startCodebookPolling(workspaceId, response.jobId); - }, - error: () => { - this.codebookJobStatus.set('failed'); - this.codebookJobError.set('Failed to start codebook generation job'); - } - }); + this.exportService + .startCodebookJob(workspaceId, this.contentOptions().missingsProfile, this.contentOptions(), this.unitList()) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: response => { + this.changeDetectorRef.markForCheck(); + this.codebookJobId.set(response.jobId); + this.startCodebookPolling(workspaceId, response.jobId); + }, + error: () => { + this.changeDetectorRef.markForCheck(); + this.codebookJobStatus.set('failed'); + this.codebookJobError.set('Failed to start codebook generation job'); + } + }); } private startCodebookPolling(workspaceId: number, jobId: string): void { this.stopCodebookPolling(); - this.codebookPollingSubscription = interval(1500) .pipe( - takeUntil(this.destroy$), - switchMap(() => this.exportService.getCodebookJobStatus(workspaceId, jobId)) + exhaustMap(() => this.exportService.getCodebookJobStatus(workspaceId, jobId)), + takeUntil(this.destroy$) ) .subscribe({ next: status => { + this.changeDetectorRef.markForCheck(); if (!status.status && status.error) { this.codebookJobStatus.set('failed'); this.codebookJobError.set(status.error); this.stopCodebookPolling(); return; } - this.codebookJobProgress.set(status.progress || 0); - if (status.status === 'completed') { - this.codebookJobStatus.set('completed'); + this.codebookJobStatus.set('processing'); this.stopCodebookPolling(); this.downloadCodebookResult(workspaceId, jobId); } else if (status.status === 'failed') { @@ -759,6 +782,7 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { } }, error: () => { + this.changeDetectorRef.markForCheck(); this.codebookJobStatus.set('failed'); this.codebookJobError.set('Failed to get job status'); this.stopCodebookPolling(); @@ -774,24 +798,30 @@ export class ExportCodingBookComponent implements OnInit, OnDestroy { } private downloadCodebookResult(workspaceId: number, jobId: string): void { - this.exportService.downloadCodebookFile(workspaceId, jobId).pipe(takeUntilDestroyed(this.destroyRef)).subscribe({ - next: blob => { - const url = window.URL.createObjectURL(blob); - const a = document.createElement('a'); - const timestamp = this.datePipe.transform(new Date(), 'yyyyMMdd_HHmmss'); - const fileExtension = this.contentOptions().exportFormat.toLowerCase(); - a.href = url; - a.download = `codebook_${timestamp}.${fileExtension}`; - document.body.appendChild(a); - a.click(); - window.URL.revokeObjectURL(url); - document.body.removeChild(a); - }, - error: () => { - this.codebookJobStatus.set('failed'); - this.codebookJobError.set('Failed to download codebook file'); - } - }); + this.exportService + .downloadCodebookFile(workspaceId, jobId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: blob => { + this.changeDetectorRef.markForCheck(); + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + const timestamp = this.datePipe.transform(new Date(), 'yyyyMMdd_HHmmss'); + const fileExtension = this.contentOptions().exportFormat.toLowerCase(); + a.href = url; + a.download = `codebook_${timestamp}.${fileExtension}`; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + this.codebookJobStatus.set('completed'); + }, + error: () => { + this.changeDetectorRef.markForCheck(); + this.codebookJobStatus.set('failed'); + this.codebookJobError.set('Failed to download codebook file'); + } + }); } resetCodebookJob(): void { diff --git a/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.zoneless.spec.ts b/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.zoneless.spec.ts index 3c8149cf9..21e7a2e79 100644 --- a/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.zoneless.spec.ts +++ b/apps/frontend/src/app/coding/components/export-coding-book/export-coding-book.component.zoneless.spec.ts @@ -1,14 +1,130 @@ import { provideZonelessChangeDetection } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { MatDialog, MatDialogRef } from '@angular/material/dialog'; import { TranslateModule } from '@ngx-translate/core'; +import { CodebookExportComponent } from '@iqb/ngx-coding-components/codebook-export'; import { BehaviorSubject, of, Subject } from 'rxjs'; import { ExportCodingBookComponent } from './export-coding-book.component'; import { CodingExportService } from '../../services/coding-export.service'; import { CodingJobBackendService } from '../../services/coding-job-backend.service'; import { MissingsProfileService } from '../../services/missings-profile.service'; -import { ValidationStateService } from '../../services/validation-state.service'; import { FileService } from '../../../shared/services/file/file.service'; import { AppService } from '../../../core/services/app.service'; +import { ValidationStateService } from '../../services/validation-state.service'; + +describe('shared codebook with zoneless change detection', () => { + let fixture: ComponentFixture; + let units: Subject<{ id: number; unitId: string; fileName: string; data: string }[]>; + let status: Subject<{ status: string; progress: number }>; + let download: Subject; + const start = jest.fn(); + const getStatus = jest.fn(); + const downloadFile = jest.fn(); + + beforeEach(async () => { + jest.useFakeTimers({ doNotFake: ['queueMicrotask', 'requestAnimationFrame'] }); + units = new Subject(); + status = new Subject(); + download = new Subject(); + start.mockReset().mockReturnValue(of({ jobId: 'job-1' })); + getStatus.mockReset().mockReturnValue(status); + downloadFile.mockReset().mockReturnValue(download); + await TestBed.configureTestingModule({ + imports: [ExportCodingBookComponent, TranslateModule.forRoot()], + providers: [ + provideZonelessChangeDetection(), + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { provide: MatDialog, useValue: { open: jest.fn() } }, + { provide: AppService, useValue: { selectedWorkspaceId: 1 } }, + { provide: FileService, useValue: { getUnitsWithFileIds: () => units } }, + { + provide: CodingJobBackendService, + useValue: { getJobDefinitions: () => of([]), getVariableBundles: () => of([]) } + }, + { provide: MissingsProfileService, useValue: { getMissingsProfiles: () => of([]) } }, + { + provide: CodingExportService, + useValue: { startCodebookJob: start, getCodebookJobStatus: getStatus, downloadCodebookFile: downloadFile } + }, + { + provide: ValidationStateService, + useValue: { + validationProgress$: of({ status: 'idle', progress: 0, message: '' }), + validationResults$: of(null), + getValidationProgress: () => ({ status: 'idle', progress: 0, message: '' }), + getValidationResults: () => null + } + } + ] + }).compileComponents(); + fixture = TestBed.createComponent(ExportCodingBookComponent); + }); + + afterEach(() => { + fixture?.destroy(); + jest.useRealTimers(); + }); + + async function loadUnits(): Promise { + fixture.detectChanges(); + await fixture.whenStable(); + units.next([{ + id: 1, unitId: 'UNIT', fileName: 'Aufgabe.vocs', data: '{}' + }]); + await fixture.whenStable(); + return fixture.debugElement.query(By.directive(CodebookExportComponent)).componentInstance as CodebookExportComponent; + } + + it('renders asynchronously loaded tasks and the common training filter without a manual refresh', async () => { + const shared = await loadUnits(); + expect(fixture.nativeElement.textContent).toContain('Aufgabe'); + expect(shared.loading).toBe(false); + expect(shared.showGroupColumn).toBe(false); + expect(fixture.nativeElement.querySelector('.codebook-training-filter')).not.toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain('coding.variable-bundle-filter'); + expect(fixture.nativeElement.querySelector('mat-select[multiple]')).toBeNull(); + expect(fixture.nativeElement.querySelector('.mat-column-group')).toBeNull(); + }); + + it('renders delayed progress and download errors and keeps exports locked until download ends', async () => { + const shared = await loadUnits(); + shared.toggleAll(); + shared.exportCodingBook(); + await fixture.whenStable(); + expect(shared.busy).toBe(true); + jest.advanceTimersByTime(1500); + status.next({ status: 'processing', progress: 37 }); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('.progress-percentage').textContent).toContain('37%'); + status.next({ status: 'completed', progress: 100 }); + await fixture.whenStable(); + expect(shared.busy).toBe(true); + shared.exportCodingBook(); + expect(start).toHaveBeenCalledTimes(1); + download.error(new Error('download failed')); + await fixture.whenStable(); + expect(fixture.nativeElement.querySelector('.failed').textContent).toContain('Failed to download'); + expect(shared.busy).toBe(false); + }); + + it('renders loading errors and stops outstanding status requests when destroyed', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + units.error(new Error('load failed')); + await fixture.whenStable(); + const shared = fixture.debugElement.query(By.directive(CodebookExportComponent)).componentInstance as CodebookExportComponent; + expect(shared.loading).toBe(false); + fixture.componentInstance.unitList.set([1]); + fixture.componentInstance.exportCodingBook(); + jest.advanceTimersByTime(1500); + expect(status.observed).toBe(true); + fixture.destroy(); + expect(status.observed).toBe(false); + jest.advanceTimersByTime(3000); + expect(getStatus).toHaveBeenCalledTimes(1); + }); +}); type ObservableValue = T extends import('rxjs').Observable ? V : never; type JobStatus = ObservableValue>; @@ -35,6 +151,8 @@ describe('Codebook export with delayed responses without Zone', () => { imports: [ExportCodingBookComponent, TranslateModule.forRoot()], providers: [ provideZonelessChangeDetection(), + { provide: MatDialogRef, useValue: { close: jest.fn() } }, + { provide: MatDialog, useValue: { open: jest.fn() } }, { provide: CodingExportService, useValue: { @@ -77,8 +195,8 @@ describe('Codebook export with delayed responses without Zone', () => { id: 1, unitId: 'UNIT', fileName: 'UNIT.vocs', data: '{}' }]); await fixture.whenStable(); - const selectAll: HTMLInputElement = fixture.nativeElement.querySelector('.select-all-container input'); - selectAll.click(); + const shared = fixture.debugElement.query(By.directive(CodebookExportComponent)).componentInstance as CodebookExportComponent; + shared.toggleAll(); await fixture.whenStable(); expect(exportButton().disabled).toBe(false); exportButton().click(); @@ -89,20 +207,21 @@ describe('Codebook export with delayed responses without Zone', () => { } it('ends loading and displays selectable units after a delayed response', async () => { - expect(fixture.nativeElement.querySelector('mat-spinner')).not.toBeNull(); + expect(fixture.nativeElement.querySelector('mat-header-cell input').disabled).toBe(true); units.next([{ id: 1, unitId: 'UNIT', fileName: 'UNIT.vocs', data: '{}' }]); await fixture.whenStable(); - expect(fixture.nativeElement.querySelector('mat-spinner')).toBeNull(); - expect(fixture.nativeElement.querySelector('table').textContent).toContain('UNIT'); - expect(fixture.nativeElement.querySelector('.selection-count').textContent).toContain('0 / 1'); + expect(fixture.debugElement.query(By.directive(CodebookExportComponent)).componentInstance.loading).toBe(false); + expect(fixture.nativeElement.querySelector('mat-table').textContent).toContain('UNIT'); + expect(fixture.nativeElement.querySelectorAll('mat-row')).toHaveLength(1); + expect(fixture.nativeElement.querySelector('mat-header-cell input').disabled).toBe(false); }); it('ends loading after a delayed unit-list failure', async () => { units.error(new Error('Units failed')); await fixture.whenStable(); - expect(fixture.nativeElement.querySelector('mat-spinner')).toBeNull(); + expect(fixture.debugElement.query(By.directive(CodebookExportComponent)).componentInstance.loading).toBe(false); expect(exportButton().disabled).toBe(true); }); @@ -117,9 +236,15 @@ describe('Codebook export with delayed responses without Zone', () => { jest.advanceTimersByTime(1500); status.next({ status: 'completed', progress: 100 }); await fixture.whenStable(); - expect(fixture.nativeElement.querySelector('.progress-container.completed')).not.toBeNull(); - expect(exportButton().disabled).toBe(false); + expect(fixture.nativeElement.querySelector('.progress-container.completed')).toBeNull(); + expect(exportButton().disabled).toBe(true); expect(downloadCodebookFile).toHaveBeenCalledWith(1, 'job-1'); + window.URL.createObjectURL = jest.fn(() => 'blob:codebook'); + window.URL.revokeObjectURL = jest.fn(); + jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + download.next(new Blob(['codebook'])); + await fixture.whenStable(); + expect(exportButton().disabled).toBe(false); }); it('renders delayed polling errors and resets through the bound button', async () => { @@ -139,8 +264,8 @@ describe('Codebook export with delayed responses without Zone', () => { id: 1, unitId: 'UNIT', fileName: 'UNIT.vocs', data: '{}' }]); await fixture.whenStable(); - const selectAll: HTMLInputElement = fixture.nativeElement.querySelector('.select-all-container input'); - selectAll.click(); + const shared = fixture.debugElement.query(By.directive(CodebookExportComponent)).componentInstance as CodebookExportComponent; + shared.toggleAll(); await fixture.whenStable(); exportButton().click(); await fixture.whenStable(); diff --git a/apps/frontend/src/app/core/services/translation-loader.spec.ts b/apps/frontend/src/app/core/services/translation-loader.spec.ts new file mode 100644 index 000000000..b20fd867c --- /dev/null +++ b/apps/frontend/src/app/core/services/translation-loader.spec.ts @@ -0,0 +1,57 @@ +import { TestBed } from '@angular/core/testing'; +import { HttpClient, provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core'; +import { firstValueFrom } from 'rxjs'; +import applicationTranslations from '../../../assets/i18n/de.json'; +import { CacheBustingTranslateLoader } from './translation-loader'; + +describe('shared component translations in Kodierbox', () => { + let translate: TranslateService; + let http: HttpTestingController; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [TranslateModule.forRoot({ + loader: { + provide: TranslateLoader, + useFactory: (client: HttpClient) => new CacheBustingTranslateLoader(client), + deps: [HttpClient] + } + })], + providers: [provideHttpClient(), provideHttpClientTesting()] + }); + translate = TestBed.inject(TranslateService); + http = TestBed.inject(HttpTestingController); + }); + + afterEach(() => http.verify()); + + it('uses component defaults without copying Codebook labels into the application file', async () => { + expect(applicationTranslations).not.toHaveProperty('codebook'); + const loaded = firstValueFrom(translate.use('de')); + http.expectOne(request => /^\.\/assets\/i18n\/de\.json\?v=\d+$/.test(request.url)) + .flush(applicationTranslations); + await loaded; + expect(translate.instant('codebook.export')).toBe('Exportieren'); + expect(translate.instant('codebook.dialogs.cancel')).toBe('Abbrechen'); + expect(translate.instant('codebook.coding.selected-units', { count: 2 })).toBe('2 Aufgaben ausgewählt'); + }); + + it('preserves application-only labels and deliberate overrides', async () => { + const loaded = firstValueFrom(translate.use('de')); + http.expectOne(request => request.url.startsWith('./assets/i18n/de.json?')) + .flush({ applicationOnly: 'Eigener Text', codebook: { export: 'Herunterladen' } }); + await loaded; + expect(translate.instant('applicationOnly')).toBe('Eigener Text'); + expect(translate.instant('codebook.export')).toBe('Herunterladen'); + expect(translate.instant('codebook.dialogs.cancel')).toBe('Abbrechen'); + }); + + it('selects the German component defaults for a regional locale', async () => { + const loaded = firstValueFrom(translate.use('de-AT')); + http.expectOne(request => request.url.startsWith('./assets/i18n/de-AT.json?')).flush({}); + await loaded; + expect(translate.instant('codebook.export')).toBe('Exportieren'); + }); +}); diff --git a/apps/frontend/src/app/core/services/translation-loader.ts b/apps/frontend/src/app/core/services/translation-loader.ts new file mode 100644 index 000000000..783554844 --- /dev/null +++ b/apps/frontend/src/app/core/services/translation-loader.ts @@ -0,0 +1,24 @@ +import { HttpClient } from '@angular/common/http'; +import { TranslateLoader } from '@ngx-translate/core'; +import { Observable, map } from 'rxjs'; +import { + NGX_CODING_COMPONENTS_DE_TRANSLATIONS, + mergeTranslations, + selectTranslationsForLanguage, + TranslationMap +} from '@iqb/ngx-coding-components/translations'; + +const translationCacheBust = Date.now().toString(); +const componentTranslations = { de: NGX_CODING_COMPONENTS_DE_TRANSLATIONS }; + +export class CacheBustingTranslateLoader implements TranslateLoader { + constructor(private http: HttpClient) {} + + getTranslation(lang: string): Observable { + return this.http.get(`./assets/i18n/${lang}.json?v=${translationCacheBust}`) + .pipe(map(applicationTranslations => mergeTranslations( + selectTranslationsForLanguage(lang, componentTranslations), + applicationTranslations + ))); + } +} diff --git a/cypress/zoneless/statistics-codebook.cy.ts b/cypress/zoneless/statistics-codebook.cy.ts index 7316f04d0..a8bc5bbb5 100644 --- a/cypress/zoneless/statistics-codebook.cy.ts +++ b/cypress/zoneless/statistics-codebook.cy.ts @@ -213,12 +213,16 @@ describe('Zoneless replay statistics and codebook export', () => { cy.window().then(win => { win.location.hash = '/workspace-admin/5/coding/management'; }); cy.get('coding-box-coding-management .action-buttons-toolbar button').contains('Codebook').click(); cy.get('coding-box-export-coding-book').as('dialog'); - cy.get('@dialog').find('mat-spinner').should('be.visible'); + cy.get('@dialog').should('not.contain.text', 'codebook.'); + cy.get('@dialog').find('button[type="submit"]').should('contain.text', 'Exportieren'); + cy.get('@dialog').find('ngx-codebook-export mat-header-cell input').should('be.disabled'); cy.then(() => { releaseUnits(); }); cy.wait('@units'); - cy.get('@dialog').find('mat-spinner').should('not.exist'); - cy.get('@dialog').find('.select-all-container input').check(); - cy.get('@dialog').find('.options-grid mat-checkbox').eq(1).find('input').uncheck(); + cy.get('@dialog').contains('button', 'Abbrechen').scrollIntoView().should('be.visible'); + cy.get('@dialog').find('ngx-codebook-export mat-header-cell input').should('not.be.disabled'); + cy.get('@dialog').find('ngx-codebook-export mat-header-cell input').check(); + cy.get('@dialog').should('contain.text', '1 Aufgaben ausgewählt'); + cy.get('@dialog').contains('mat-checkbox', 'Allgemeine Hinweise für jede Variable').find('input').uncheck(); cy.get('@dialog').find('button[type="submit"]').click(); cy.wait('@startCodebook'); cy.get('@dialog').find('button[type="submit"]').should('be.disabled'); diff --git a/docs/autocoder-solver-compatibility.md b/docs/autocoder-solver-compatibility.md new file mode 100644 index 000000000..c785dddc6 --- /dev/null +++ b/docs/autocoder-solver-compatibility.md @@ -0,0 +1,62 @@ +# SOLVER-Kompatibilität der Kodierbox + +Kodierbox verwendet `@iqb/responses` 5.3.1. Validierung, Abhängigkeitsgraph und +Codebook-Regeltexte verwenden weiterhin das unveränderte installierte Paket. +Für automatische Kodierung erhält dessen Engine die bisherige SOLVER-Mathematik. + +## Produktionsweg + +`CodingProcessService.codeAutocoderResponses` übergibt seine kanonisierten Daten +an `generated/autocoder-compatibility-engine.js`. Diese Datei wird aus dem +installierten öffentlichen `coding-scheme-factory`-Einstieg von Responses 5.3.1 +mit esbuild erzeugt. Der Engine-Quellcode wird nicht manuell bearbeitet. Nur der +mathjs-Import wird auf `autocoder-solver-math.ts` umgeleitet. + +Dieser Adapter verwendet eine private mathjs-12.4.3-Instanz mit der bisherigen +relativen Toleranz 1e-12. Die exakte bisherige Math-Version erhält auch Funktionen +jenseits von Vergleich und Rundung; nur die neue absolute Toleranz auf null zu +setzen würde nicht alle Unterschiede zwischen mathjs 12 und 15 ausschließen. +Die Konfiguration ist wie beim bisherigen Default-Einstieg schreibgeschützt, +auch in verschachtelten evaluate/parser-Aufrufen. Globale mathjs-Instanzen und +die normale Responses-Factory werden nicht verändert. + +Ein NULL-Ergebnis löst vor der Rückgabe der Auswertung einen Fehler aus. Der +bestehende Fehlerpfad der 5.3.1-Engine ergibt dadurch wie bisher DERIVE_ERROR. +Die Korrektur wirkt innerhalb des ursprünglichen Ableitungsablaufs: Codeauswahl +und abhängige SUM_SCORE-/weitere Ableitungen sehen bereits den richtigen Status. +Es werden keine fertigen Ergebnisse nachträglich umgeschrieben. + +## Nachweise + +Drei Service-Regressionen sichern ceil nahe null, Gleichheit nahe null und einen +NULL-Zweig mit echten Regeln, Alias und Subform. Sie scheiterten vor dem Fix. +Die unabhängige Vergleichsmatrix prüft 11.760 Paare gegen das veröffentlichte +Responses 5.2.0: sieben Ableitungsarten, 14 Statuswerte, 14 Wertformen, Aliase, +Subforms und neun SOLVER-Ausdrücke. Weitere Fälle prüfen bestehende Zielzustände, +BASE_NO_VALUE, Ableitungsketten, nicht endliche Ergebnisse und Konfigurationsschutz. + +`@iqb/responses-legacy-reference` ist ausschließlich eine Entwicklungsabhängigkeit +für diese unabhängige Referenz. Das erzeugte Produktionsbundle enthält Engine-Code +aus Responses 5.3.1, keine 5.2.0-Engine. Die Runtime benötigt das Referenzpaket nicht. + +## Wartung + +Nach einem bewusst geprüften Responses- oder Adapter-Update: + +```sh +node scripts/build-autocoder-compat.mjs +node scripts/build-autocoder-compat.mjs --check +npx nx test backend --runInBand +npx nx build backend +``` + +Der Builder prüft die Responses-Version 5.3.1, mathjs 12.4.3 und genau einen +umgeleiteten Math-Import. Der Backend-Test baut das Artefakt erneut und vergleicht +es bytegenau mit der eingecheckten Datei. Änderungen an Paket, Adapter oder +Generator können somit nicht unbemerkt ein veraltetes Bundle freigeben. +Der generierte Header dokumentiert Herkunft und Lizenzen. Die Deklaration +übernimmt den öffentlichen Factory-Vertrag direkt aus Responses. + +Frontend und Backend auf dem Entwicklungsrechner getrennt prüfen. Die gemeinsame +Bibliothek, Studios Schemer/Preview und beide Codebook-Generatoren erhalten diese +Kodierbox-spezifische Kompatibilität nicht; deren bisheriges Verhalten bleibt bestehen. diff --git a/docs/codebook-acceptance-2026-10-13.md b/docs/codebook-acceptance-2026-10-13.md new file mode 100644 index 000000000..347b0a92f --- /dev/null +++ b/docs/codebook-acceptance-2026-10-13.md @@ -0,0 +1,11 @@ +# Kodierbox: Codebook-Abnahme vom 13.10.2026 + +Dieser Bericht beschreibt den früheren Paketstand `dcee6cc…`. Die aktuelle Formatkorrektur und Aspect-Abnahme steht im [ergänzenden Bericht](codebook-aspect-acceptance-2026-10-13.md). + +58 Backend- und 60 Frontendtests; echte Redis-Jobs/Downloads, Jobdefinitionspicker, Missingprofil, Score-Abwahl, Doppelauslösung, Fehleranzeige, Retry, Polling-Ende und Workspace-Grenzen bestanden. + +Beide Host-Builds und Backend-Lint erfolgreich. Identischer gepackter 4.1.0-Tarball in beiden Hosts; SHA256: `dcee6cc39436baf91cf2558afedfbc202e044ca14dbf72c7bfdf4b381e5c0ccd`. Der Installationsvergleich über die echten Adapter besteht für 4.654 JSON-/DOCX-Exportpaare. + +Microsoft Office Web Viewer / Word hat die tatsächlichen Browser-Downloads gerendert: alle drei vollständigen Seiten sind zwischen Studio und Kodierbox pixelgleich. Sechs Seiteninhalte mit/ohne Scores sind außerdem mit dem ursprünglichen Studio pixelgleich, abgesehen von der gesondert geprüften Fußzeile „IQB Codebook“. Die zusätzliche Editor-Matrix prüft 384 Formatkombinationen über ESM und CommonJS; alle 48 gültigen Markierungskombinationen wurden auch in Word gerendert. Insgesamt 1.206 Generator-Tests und 20 Office-Seiten. Desktop-Word und ein vollständiger Aspect-Editor-/Player-Lauf wurden nicht ausgeführt. + +Vollständige Bedingungen, Belege, CI-Grenzen und bestehende Layoutgrenzen: [gemeinsames Abnahmeprotokoll](https://github.com/iqb-berlin/coding-components/blob/shared-codebook/docs/codebook-acceptance-2026-10-13.md). Die vollständigen Host-Suiten wurden für diesen Fix nicht erneut ausgeführt. Lokale Abnahme ist kein Merge-/Release-/Deployment-Nachweis. diff --git a/docs/codebook-alpha-2026-10-15.md b/docs/codebook-alpha-2026-10-15.md new file mode 100644 index 000000000..b9cda313a --- /dev/null +++ b/docs/codebook-alpha-2026-10-15.md @@ -0,0 +1,66 @@ +# Codebook-Alpha nach dem aktuellen develop-Abgleich + +> Historischer Bericht vom 15.10.2026. Der Branch verwendet inzwischen den +> [korrigierten Entwicklungsbuild vom 17.10.2026](codebook-integration-2026-10-17.md). +> Die folgenden Paketangaben bleiben als Nachweis des damaligen Stands erhalten. + +Dieser Stand verwendet den veröffentlichten Tarball +`vendor/iqb-ngx-coding-components-4.1.0-alpha.1.tgz` aus dem +[Alpha-Release](https://github.com/iqb-berlin/coding-components/releases/tag/Schemer_v2.9.0-alpha.1). +SHA-256: `cb85078662e8fe4490aa90001e369db059571da73ec73a1457a7ffb89d49f296`. +Studio und Kodierbox verwenden dieselbe Paketdatei; alle 20 installierten +Paketdateien stimmen mit dem Release-Build überein. Responses bleibt 5.3.1. +Die bestehende lokale SOLVER-Kompatibilität und der Antwortvertrag bleiben erhalten. + +## develop und Zoneless + +`develop` auf `31ad79f3d` wird übernommen, einschließlich Zoneless-Umstellung +und verbindlicher Quality Gates. Die gemeinsame Codebook-Oberfläche bleibt +bestehen; der Wrapper verwendet Signals und `inject()` wie die aktuelle Anwendung. +Die manuellen Angular-Zone-Timer der ergänzten Tests sind durch Jest-Timer ersetzt. +Die verzögerten Antwortfälle aus develop und die Prüfungen der gemeinsamen +Oberfläche werden gemeinsam ausgeführt. + +Die Aufgaben-/Missingprofil-Auswahl und der Schulungsfilter bleiben gemeinsam. +Jobdefinitionen und Jobstatus bleiben im Kodierbox-Wrapper; keine Gruppenspalte +oder Variablenbündel-Auswahl im Dialog. Der serverseitige Scope-Vertrag bleibt +bestehen. `exhaustMap` verhindert überlappende Statusabfragen; Schließen beendet +laufende Abfragen und Downloads. Der Export bleibt bis zum Ende des Downloads +gesperrt und wird bei Fehlern wieder freigegeben. + +Die von der gemeinsamen Bibliothek deklarierte Angular-Animations-Peerabhängigkeit +wird ausdrücklich auf denselben Patchstand 21.2.24 wie Angular Core gesetzt. +Das verhindert eine unpassende automatische Peerauflösung auf 21.2.25; es werden +keine Zone- oder Animationsprovider in die Anwendung zurückgebracht. + +## Behobener CI-Audit-Befund + +Pipeline [104049](https://scm.cms.hu-berlin.de/iqb/coding-box/-/pipelines/104049) +scheiterte im Job [audit-app / 474890](https://scm.cms.hu-berlin.de/iqb/coding-box/-/jobs/474890). +Die 19 Paketkorrekturtests waren dort grün. Die beiden hohen npm-Befunde +betrafen die verschachtelte `image-size@1.2.1`-Installation und die davon +betroffene gemeinsame Bibliothek. Der Host erzwingt nun `image-size@2.0.3`. + +Der neue Backend-Test verwendet den tatsächlich vom installierten Generator +aufgelösten Parser und einen begrenzten Kindprozess. Ein ICNS-Eintrag mit +Eintragslänge 0 muss abgelehnt werden; ein gültiges PNG liefert weiter 1×1 Pixel. +Der vollständige lokale npm-Audit meldet danach 0 hohe und 0 kritische Befunde; +57 moderate und ein niedriger Befund bleiben sichtbar. Die Auditgrenze wird nicht +abgeschwächt. + +Das Backend-Dockerfile kopiert denselben Alpha-Tarball und setzt die +`image-size@2.0.3`-Override auch für seine separate Produktionsinstallation. + +## Installations- und Exportvergleich + +[Maschinenlesbarer Vergleich](codebook-alpha-installation-comparison-2026-10-15.json): +4.694 Exportpaare durch die echten Studio-/Kodierbox-Adapter sind identisch, +einschließlich JSON und aller verglichenen DOCX-Bestandteile. Unveränderte alte +Studio-Referenzen, Leerzeichenfälle, Inlinebilder, Code/Missing 0 und gespeicherte +Chemieformate sind enthalten. Beide Hosts verwenden dasselbe Alpha-Paket und +dieselbe Responses-Auflösung im Host und in der Bibliothek. + +Persistenzgrenzen dieses Vergleichs liegen im Speicher. Der Vergleich ersetzt +keinen aktuellen HTTP-/Browser-/Redis-/Word-Lauf. Historische Word-Abnahmen +behalten ihre ursprünglichen Artefaktbezüge. Exakte lokale Tests und entfernte CI +stehen im PR; ein Deployment ist damit nicht bestätigt. diff --git a/docs/codebook-alpha-installation-comparison-2026-10-15.json b/docs/codebook-alpha-installation-comparison-2026-10-15.json new file mode 100644 index 000000000..b27067534 --- /dev/null +++ b/docs/codebook-alpha-installation-comparison-2026-10-15.json @@ -0,0 +1,23 @@ +{ + "hostExportPairs": 4694, + "optionCombinations": 768, + "missingCases": 3, + "formats": 2, + "originalStudioWhitespaceCases": 20, + "inlineImageCases": 5, + "inlineImageExportPairs": 40, + "identicalJson": true, + "identicalAllDocxParts": true, + "artifactIntegrity": "sha512-v54DIARPSU9sO4prpUWWC4e4eUPafraIw31RCXuS1kU7e6QIHqXr4n51JgQSpa2SVUEM8lkmFoPWSZwcjrY+dQ==", + "identicalInstalledPackageFiles": true, + "installedPackageFiles": 20, + "responsesVersion": "5.3.1", + "responsesPeerRange": "^5.3.1", + "responsesSharedWithHost": true, + "currentVariableInfoValidated": true, + "storedChemistryFormatsRecognized": true, + "realHostAdapters": true, + "inMemoryRepositories": true, + "httpBrowserRedis": false, + "windowsWord": false +} \ No newline at end of file diff --git a/docs/codebook-aspect-acceptance-2026-10-13.md b/docs/codebook-aspect-acceptance-2026-10-13.md new file mode 100644 index 000000000..3b6ff651e --- /dev/null +++ b/docs/codebook-aspect-acceptance-2026-10-13.md @@ -0,0 +1,82 @@ +# Browser- und Word-Abnahme der Codebooks + +Die bestätigten Formatierungsfehler sind behoben. Der echte Aspect Editor und Player, der gemeinsame Instruktionseditor sowie Studio und Kodierbox wurden mit synthetischen Daten geprüft. Microsoft Office Web Viewer / Word hat 262 Seiten gerendert; die Aufnahmen jeder Seite sind mit deren tatsächlicher Fußzeile abgeglichen. + +## Umfang und Ergebnisse + +| Prüfung | Ergebnis | +| --- | --- | +| Aspect: HTML laden, Unit über die Oberfläche speichern, echter Verona-Player | 2.740 Formatfälle, 40 native Formeln | +| Gemeinsamer Editor: HTML laden, speichern und wiederöffnen | 2.738 unterstützte Formatfälle | +| Vollständige gültige Textmarkierungsmatrix je Editor | 2.496 = 16 Kombinationen aus fett/kursiv/unterstrichen/durchgestrichen × normal/tief/hoch × vier Ausrichtungen × 13 Schriftgrößen | +| Weitere Matrixfälle | 16 Farbgrenzen, neun Listenstile auf fünf Ebenen, 120 Absatzkombinationen, 16 Überschriften, Bildpositionen, Formeln, Sonderzeichen, Umbrüche und Zitate; Aspect zusätzlich Inlinebild und Trennlinien | +| Tatsächliche Toolbar-Bedienung | 57 Aspect- und 52 gemeinsame Editorfälle; jeweils alle 38 Sonderzeichen-Schaltflächen; gemeinsamer Formeldialog zusätzlich separat geprüft | +| HTML-Austausch in beiden Richtungen | Je 2.738 Fälle und 40 Formeln erhalten; Speichern/Wiederöffnen und echter Player erfolgreich | +| Austausch bis Word | In beiden Richtungen **alle normalisierten DOCX-Bestandteile identisch** zur tatsächlich in Word gerenderten Matrix | +| Generator | 1.245 Tests bestanden, keine übersprungen; ESM und CommonJS, mit und ohne Scores | +| Editor-Regressionsprüfungen | Gemeinsamer Editor 10 Tests; Aspect 16 Tests bestanden | +| Installationsvergleich | 4.654 Studio-/Kodierbox-Exportpaare: JSON und alle normalisierten DOCX-Bestandteile identisch | +| Host-Tests | Studio 49 API + 66 Frontend; Kodierbox 58 Backend + 60 Frontend bestanden | +| Builds | Bibliothek, frischer Aspect Editor/Player und alle vier Host-Builds erfolgreich | +| Installation | Identischer 4.1.0-Tarball in beiden Hosts; zusätzlich saubere Studio-Runtime-Installation mit 515 Paketen; alle 20 Paketdateien in allen drei Installationen bytegleich | + +Die große Matrix läuft über die tatsächlichen Tiptap-Parser und Serialisierungen. Die Bedienfälle prüfen zusätzlich echte Schaltflächen, Auswahllisten, Bilddateiauswahl und Speicherdialoge. Der gemeinsame MathLive-Dialog wird über seinen öffentlichen Eingabewert und das reale Input-Ereignis bedient. Die erfolgreichen Editor-, Player-, Authentifizierungs-, API- und Queue-Abläufe verwenden ihre echten Implementierungen. + +## Behobene Fehler + +- Normale Mehrfachabstände wurden beim Laden und Wiederöffnen reduziert. Normale, geschützte und schmale geschützte Leerzeichen bleiben erhalten; JSON-Inhalte und ausdrücklich gesetzte Parseroptionen funktionieren weiter. +- Farben, Schriftgrößen und Hervorhebungen wurden auf den ganzen Absatz ausgedehnt. Sie gelten jetzt nur für die jeweilige markierte Textstelle. CSS-Pixel und Punktangaben werden korrekt in Word-Halbpunkte umgerechnet. +- Absatzabstände, Einzüge, hängende Einzüge und formatierte Listen gingen im Word-Export verloren. Die gesetzten Attribute und Listenmarker bleiben erhalten. +- Einrückung und hängender Einzug funktionierten nach „Alles auswählen“ nicht; im gemeinsamen Editor scheiterte Absatzabstand an Textknoten. Beide Bedienpfade sind repariert. +- Inlinebilder wurden aus dem Text herausgezogen oder doppelt ausgegeben; gesetzte linke/rechte Bildpositionen gingen verloren. Word erhält die Platzierung einschließlich Textumlauf-Ankern. +- Aspect-Formeln wurden aus veraltetem MathLive-Anzeige-HTML gelesen; reine Formelinstruktionen konnten herausgefiltert werden. Beide Formel-HTML-Formate werden aus ihrem LaTeX verarbeitet, und der Instruktionsfilter erkennt reine Aspect-Formeln. +- Die Editoren erkannten beim HTML-Austausch jeweils nur ihren eigenen Formelknoten. Beide lesen nun beide Formate und speichern im eigenen kanonischen Format weiter. +- Über- und Unterstriche wurden als kurzer Akzent und teils an der falschen Position dargestellt. Native Word-Balken umfassen jetzt den ganzen Ausdruck. Bruchstriche, Wurzelzeichen und große Operatoren erhalten dieselbe Farbe und Größe wie die Formelzeichen; der Vorher-/Nachher-Nachweis wurde in Word tatsächlich geprüft. +- Schriftgröße, Farbe und Textmarkierungen um Formeln gingen im nativen Word-Mathematikformat verloren. Die umgebenden Eigenschaften werden auf native Mathematikläufe übertragen; der Formelcache bleibt unverändert und überträgt keine fremde Formatierung. +- Formelabsätze wurden immer links ausgerichtet; die gewählte Ausrichtung bleibt erhalten. Zeilenumbrüche innerhalb von LaTeX-Attributen werden nicht mehr in HTML-Break-Tags umgewandelt. +- Aspect erlaubte keine erneute Auswahl derselben Bilddatei. Das Dateifeld wird nach dem Auslesen geleert; die vier Bildpositionen wurden mit derselben Datei tatsächlich eingefügt. + +## Erhalt der bisherigen Studio-Ausgabe + +Die unabhängigen Referenzen stammen weiterhin vom ursprünglichen Studio-Commit `2504265721a59c7c65425072951ce82ab16f0fb8`. Die 32 bestehenden Rohschema-Referenzen und 20 Leerzeichenfälle wurden nicht aus der neuen Implementierung neu erzeugt. + +Grundlayout, Auswahlregeln, Scores, Missing-Codes, Beziehungen, Bilder und unveränderte Formatfälle bleiben gegen die alten vollständigen JSON-/DOCX-Referenzen abgesichert. Eine **bewusste Korrektur** betrifft den alten `Farbe`-Referenzabsatz: explizite `18pt` wurden früher als 18 Word-Halbpunkte, also 9pt, behandelt; nun werden korrekt 36 Halbpunkte ausgegeben. Der Vergleich normalisiert ausschließlich diesen streng geprüften Unterschied und die gleichwertige Hex-Schreibweise der Hervorhebung zurück. Alle anderen Dokumentbestandteile bleiben Gegenstand des ursprünglichen Hashvergleichs. Neue Tests prüfen die korrigierten Werte unmittelbar. + +Die frühere pixelgleiche Abnahme des Pakets `dcee6cc…` ist ein historischer Nachweis. Für das jetzige Paket gelten die oben beschriebenen gezielten Darstellungsfixes und die neue Word-Abnahme; eine pauschale Pixelgleichheit zu allen alten Fehlformatierungen wird nicht behauptet. + +## Microsoft Word + +| Dokument | Seiten | +| --- | ---: | +| Aspect: vollständige Formatmatrix | 114 | +| Gemeinsamer Editor: vollständige Formatmatrix | 114 | +| Aspect: Übersicht aller unterschiedlichen Formatmerkmale | 12 | +| Gemeinsamer Editor: Formatübersicht | 12 | +| Tatsächlich bediente Toolbarfälle und 13 kombinierte Formelschriftgrößen | 6 | +| Aktuelle Studio-Referenz mit korrigierter expliziter Schriftgröße | 3 | +| Farbige zusammengesetzte Formeln, breite Über-/Unterstriche | 1 | +| **Gesamt** | **262** | + +Alle Seiten wurden aus dem echten Microsoft-Viewer erfasst. Dessen wiederverwendete Bildkacheln wurden anhand ihrer Seitenposition erfasst; ein zusätzlicher OCR-Abgleich bestätigt die erwartete Seitennummer jeder Aufnahme. Die Formatübersichten und repräsentative Matrixseiten wurden visuell geprüft. Die vollständigen Matrixinhalte, Markierungen, Größen und Ausrichtungen sind zusätzlich im DOCX geprüft. Beide Richtungen des Editor-Austauschs ergeben exakt dieselben Word-Dokumentbestandteile wie die gerenderte gemeinsame Matrix. + +Die Galerie enthält sämtliche Seiten in Originalauflösung. Verwendet wurden ausschließlich erfundene, versionierte Abnahmedokumente; kein Office-Konto und keine Nutzerdaten waren nötig. Dies ist eine Microsoft-Word-Web-Abnahme. Desktop-Word unter Windows oder macOS wurde nicht ausgeführt. + +## Echte Host-Downloads + +Studio und Kodierbox exportieren drei synthetische Aufgaben mit insgesamt 5.479 Code-Instruktionen: beide vollständigen Editor-Matrizen und eine kombinierte formatierte Aspect-Formel. Beide gebauten Frontends laufen mit den produktiven NestJS-Bundles. Studio verwendet eine frisch migrierte PostgreSQL-Datenbank; Kodierbox zusätzlich echtes Redis/Bull und Keycloak. + +Studio prüft JSON/DOCX, leere Auswahl, Auswahl während der Suche, Dialogabschluss, 401/403 sowie sichtbare Fehler und erneuten Versuch. Kodierbox prüft echte Jobanlage, Status und Download, doppelte Auslösung, Anlage-/Status-/Downloadfehler, erneuten Versuch und das Ende des Pollings beim Schließen. Ausschließlich Fehlerzweige werden gezielt im Browsernetzwerk injiziert. Erfolgreiche Downloads sind in JSON und jedem normalisierten DOCX-Bestandteil identisch; geschützte und wiederholte Leerzeichen bleiben erhalten. + +Testdienste: PostgreSQL 16.11 mit Repository-Migrationen, Studio Liquibase 4.33 und Kodierbox 4.28, Redis 7.4 und Keycloak 26.7.3. Die vorhandene Keycloak-JS-Version 23.0.7 benötigt im isolierten Testrealm den Nonce-Kompatibilitätsmapper. Die Prüfung ist lokal; sie bestätigt keine Produktion oder Bereitstellung. Die vollständigen Host-Testsuiten wurden nicht erneut ausgeführt. + +Der Dokumentvergleich normalisiert ausschließlich die Datumsausgabe in der Fußzeile, unabhängig vom Ausführungstag. Ein eigener Test stellt sicher, dass Datumsangaben und Markenbezeichnungen im eigentlichen Nutzerinhalt erhalten bleiben. Zusätzlich sind explizite Listenstarts bei 0 und 7 abgesichert. + +## Paket und Abgrenzung + +Paket-SHA256: `0e355c3896799ae765cb6e90b00147da0359c56f5fdc90d36b7caf40fbe59ab8`. + +Die Abnahme betrifft Rich Text in Textinhalten und manuellen Kodieranweisungen. Interaktive Antwortfelder sind eigenständige Player-Elemente. Farben wurden über repräsentative Hex-/RGB-/Namenswerte, Bildpositionen mit synthetischen PNG-Dateien geprüft. Formatierte Listen erhalten im statischen Word-Codebook explizite Marker; sie werden dort nicht automatisch neu nummeriert. + +Lokale Tests, Browser-/Word-Abnahme, Push und entfernte CI sind getrennte Nachweise. Der aktuelle CI-Stand wird bei den Pull Requests dokumentiert; aus lokalen Erfolgen wird keine grüne GitLab-Pipeline abgeleitet. + +Versionierte synthetische Word-Dateien: `scripts/fixtures/word-acceptance/` im coding-components-Repository. Reproduzierbare Generator- und Formatprüfungen: `npm run build_cc && npm run test:codebook`; Host-Vergleich: `scripts/compare-codebook-installations.mjs`. Die vollständige lokale Galerie und maschinenlesbaren Ausführungsnachweise ergänzen diese Zusammenfassung. diff --git a/docs/codebook-inline-images-2026-10-15.md b/docs/codebook-inline-images-2026-10-15.md new file mode 100644 index 000000000..1fbb01b43 --- /dev/null +++ b/docs/codebook-inline-images-2026-10-15.md @@ -0,0 +1,69 @@ +# Inlinebild-Korrektur und Installationsvergleich vom 15.10.2026 + +## Korrektur + +Ein defektes Inlinebild konnte den gesamten umgebenden Instruktionsabsatz durch +„HTML konnte nicht verarbeitet werden.“ ersetzen. Der Fehler wird jetzt direkt +am Bild behandelt: Der Hinweis steht an seiner Position, Text und Formatierung +vor und nach dem Bild sowie folgende Absätze bleiben erhalten. Spätere gültige +Bilder und Formeln werden weiterhin genau einmal ausgegeben. Gültige Dokumente, +Filter, JSON-Inhalte, UI und Jobverarbeitung werden nicht geändert. + +## Regressionstests + +40 neue Tests prüfen fünf erfundene Fälle (defekte Bildbytes, leeres src, +fehlendes src, nicht unterstützte Bildbytes und mehrere defekte Bilder vor +einem gültigen Bild und einer Formel). Alle Fälle werden als allgemeine und +Code-Instruktion, mit und ohne Score-Spalte, über ESM und CommonJS exportiert. +Geprüft werden vollständiger Absatztext, Quellreihenfolge, Fettmarkierung, Farbe, +Schriftgröße, Folgeabsatz, Bildmedien und native Formeln. Alle 40 Tests schlugen +vor der Korrektur wegen des fehlenden Absatzes fehl und bestehen danach. + +Studio und Kodierbox ergänzen außerdem jeweils zwei Tests gegen das tatsächlich +installierte Paket: allgemeine und Code-Instruktionen in beiden Score-Layouts. + +- Bibliothek frisch gebaut; 1.285 Generator-Tests ohne übersprungene Tests bestanden. +- 526 Angular-Tests bestanden; Generator-Lint ohne Fehler, sechs bestehende Warnungen. +- Studio: 15 API-Tests und 66 betroffene Frontend-Tests bestanden; API-Lint ohne Fehler. +- Kodierbox: gesamte Backend-Suite mit 2.635 bestandenen und 17 übersprungenen Tests; + 60 betroffene Frontend-Tests bestanden; Backend-Lint ohne Fehler. +- Bibliotheksbuild sowie beide Frontend- und beide Backend/API-Builds bestanden; + Frontend und Backend wurden getrennt und nacheinander geprüft. +- Die 32 ursprünglichen Studio-Rohschema-Referenzen und 20 Leerzeichenreferenzen + wurden nicht neu erzeugt und bestehen weiterhin. Die bereits dokumentierte, + gezielt normalisierte 18pt-Korrektur bleibt unverändert. + +## Gleiches installiertes Artefakt + +Beide Anwendungen verwenden denselben neuen 4.1.0-Tarball. SHA256: +`1340db41e6493cca3d4395426c14f156319c3bbbf0b81d01f84c233a56a5091a`. +Root-Locks und Studios API-Runtime-Lock enthalten dieselbe neue Paketprüfsumme; +sonstige Abhängigkeitsversionen bleiben unverändert. + +Der Vergleich prüft vor dem Export die Tarball-Bytes gegen die Lock-Prüfsumme +und alle 20 ausgelieferten Paketdateien gegen den frischen Build. Das verhindert, +dass eine alte npm-Installation trotz aktualisiertem Lock unbemerkt verglichen wird. + +4.694 Exportpaare bestehen: die bisherigen 4.654 plus 40 zusätzliche Bildfehler- +Paare (fünf Fälle, zwei Instruktionsorte, zwei Score-Layouts, JSON und DOCX). +JSON wird strukturell, DOCX über alle normalisierten Dokumentbestandteile inklusive +Bildern, Beziehungen, Styles und Fußzeile verglichen. Die neuen Fälle prüfen +zusätzlich erwartete Inhalte, Code 0 und optionale Scores ausdrücklich. + +Der maschinenlesbare Nachweis liegt in der Bibliothek unter +`docs/codebook-inline-image-installation-comparison-2026-10-15.json`. + +## Nachweisgrenzen + +Die echten Anwendungsadapter laufen in diesem Vergleich mit Datenhaltung im Speicher. +HTTP, Browser, PostgreSQL und Redis wurden für diesen Fix nicht erneut geprüft. +Die frühere Word-Web-Abnahme gilt für den damaligen Paketstand; für den neuen +Tarball wurde kein neuer Microsoft-Renderinglauf ausgeführt. Desktop-Word unter +Windows bleibt offen. Es wurde kein Paket veröffentlicht, gemergt oder ausgerollt. + +## Wiederholung + +In coding-components: `npm run build_cc`, danach `npm run test:codebook` und +`npm run test:cc`. Den gepackten Tarball in beide Anwendungen installieren und +`node scripts/compare-codebook-installations.mjs STUDIO_ROOT BOX_ROOT OUTPUT_DIR` +ausführen. Frontend- und Backend-Prüfungen getrennt und nacheinander ausführen. diff --git a/docs/codebook-installation-comparison-2026-10-17.json b/docs/codebook-installation-comparison-2026-10-17.json new file mode 100644 index 000000000..d492f9b30 --- /dev/null +++ b/docs/codebook-installation-comparison-2026-10-17.json @@ -0,0 +1,23 @@ +{ + "hostExportPairs": 4694, + "optionCombinations": 768, + "missingCases": 3, + "formats": 2, + "originalStudioWhitespaceCases": 20, + "inlineImageCases": 5, + "inlineImageExportPairs": 40, + "identicalJson": true, + "identicalAllDocxParts": true, + "artifactIntegrity": "sha512-RjXrwSua+T89/XvhyqjmeOYnc/wUXpFMVr5aYGWeluDnHSnP8pTIJDme+mqDFgAJ1MQAIUbuQu7+rlPGhjkrrQ==", + "identicalInstalledPackageFiles": true, + "installedPackageFiles": 20, + "responsesVersion": "5.3.1", + "responsesPeerRange": "^5.3.1", + "responsesSharedWithHost": true, + "currentVariableInfoValidated": true, + "storedChemistryFormatsRecognized": true, + "realHostAdapters": true, + "inMemoryRepositories": true, + "httpBrowserRedis": false, + "windowsWord": false +} \ No newline at end of file diff --git a/docs/codebook-integration-2026-10-17.md b/docs/codebook-integration-2026-10-17.md new file mode 100644 index 000000000..275c8eadf --- /dev/null +++ b/docs/codebook-integration-2026-10-17.md @@ -0,0 +1,74 @@ +# Codebook-Integration vom 17.10.2026 + +## Gemeinsames Paket + +Studio und Kodierbox installieren denselben Entwicklungsbuild: + +- Datei: `vendor/iqb-ngx-coding-components-4.1.0-dev.ff15d6d.tgz` +- Quellcommit: `ff15d6d882417db26bc81fc88a09074eab74d57d` aus coding-components PR #251 +- SHA-256: `e67c04e07762350cb4ee3b1a70a36bcde0f2ba1c24df1f5a40000346d34fd112` +- Integrität: `sha512-RjXrwSua+T89/XvhyqjmeOYnc/wUXpFMVr5aYGWeluDnHSnP8pTIJDme+mqDFgAJ1MQAIUbuQu7+rlPGhjkrrQ==` + +Der Build enthält die Checkbox-Korrektur, die DOCX-Korrektur für explizite +Null-Einzüge sowie die gemeinsamen deutschen Codebook-Texte und Übersetzungshilfen. +Die Quelle wurde mit `npm run build_cc` gebaut. Nur das erzeugte Paketmanifest +bekam die Entwicklungsversionsnummer und `gitHead`, anschließend wurde genau +einmal `npm pack` ausgeführt. Beide Hosts enthalten denselben Tarball. +Versionsnummern im Bibliotheksquellcode und vorhandene Releases bleiben erhalten. +Es wurde kein neuer Alpha-Kandidat, Git-Tag oder Registry-Release erstellt. + +Nach `git pull` installiert `npm ci` das eingecheckte Paket. Eine gesonderte +manuelle Installation der Bibliothek ist nicht erforderlich. Das im Studio +gewählte Schemer-HTML bleibt eine separat konfigurierte Anwendung im iframe; +die Bibliotheksaktualisierung ersetzt dieses HTML nicht. + +## Übersetzungen + +Der Root-Loader führt die deutschen Bibliotheksvorgaben rekursiv mit der eigenen +Sprachdatei zusammen. Eigene Begriffe überschreiben bei Bedarf einzelne Schlüssel. +Die 42 kopierten `codebook.*`-Standardtexte sind aus der Anwendungsdatei entfernt; +anwendungsspezifische Kodier-/Jobtexte bleiben erhalten. Alle Komponenten nutzen +den bestehenden gemeinsamen TranslateService. Es wird kein zweiter Dienst erzeugt. +`mergeTranslations` und `selectTranslationsForLanguage` kommen aus dem +Bibliotheks-Einstieg `@iqb/ngx-coding-components/translations`. +Cache-Busting für die Anwendungsdatei bleibt erhalten. + +## Paket- und Exportvergleich + +Alle 20 installierten Paketdateien stimmen mit dem gebauten Paket und miteinander +überein. Die Tarball-Integrität stimmt mit beiden Root-Locks überein. Responses +bleibt der gemeinsame Host-Peer 5.3.1. 4.694 Exportpaare über die echten +Studio-/Kodierbox-Adapter ergeben identisches JSON und identische normalisierte +DOCX-Bestandteile. Unveränderte alte Studio-Referenzen werden weiter verglichen; +die Persistenzgrenzen dieses Vergleichs liegen im Speicher. +Der maschinenlesbare Nachweis steht in +[codebook-installation-comparison-2026-10-17.json](codebook-installation-comparison-2026-10-17.json). + +Kodierboxs bisheriger HTTP-Loader wird um die Bibliotheksvorgaben ergänzt. +Das Backend-Dockerfile installiert denselben Tarball. Der bestehende +`image-size@2.0.3`-Override und der SOLVER-Adapter bleiben erhalten. + +## Lokale Prüfung dieses Integrationsstands + +- Frontend: 2.865 Tests in 274 Suites bestanden, einschließlich neuer Tests mit + echter Anwendungs-Sprachdatei, Überschreibungen, Regionsfallback und Interpolation. +- Backend: 2.764 Tests in 178 Suites bestanden; 17 Tests in drei vorhandenen Suites + bleiben übersprungen. Frontend und Backend wurden nacheinander validiert. +- Frontend-/Backend-Lint und beide Produktionsbuilds bestanden. +- Drei Chrome-Cypress-Tests am Produktionsfrontend bestanden, einschließlich + Codebook-Beschriftungen, interpolierter Auswahlanzahl, Fortschritt, erfolgreichem + Download und Fehleranzeige. Authentifizierung und HTTP-Antworten sind simuliert. +- Separate Produktionsinstallation aus dem generierten Backend-Manifest, demselben + Tarball, allen generierten Overrides und dem gepinnten PostgreSQL-Treiber erfolgreich + mit npm 11.18.0 und `--omit=dev --ignore-scripts` (657 Pakete). +- Alle 20 installierten Bibliotheksdateien stimmen auch in der Produktionsinstallation + mit dem gebauten Paket überein. JSON-/DOCX-Einstieg, Null-Einzug mit 100 Twips, + native Formel sowie ICNS-/PNG-Regression mit `image-size@2.0.3` bestanden. +- Vollständiger Root-Audit: 0 hoch, 0 kritisch, 57 moderat, 1 niedrig. + +Der [Produktionspaket-Nachweis](codebook-production-check-2026-10-17.json) +bezieht sich auf diese separate Installation. Native Installationsskripte und +Docker-Start wurden dabei nicht ausgeführt. Ein Deployment und eine erneute +Microsoft-Word-Abnahme des Originalfalls sind nicht bestätigt. Die entfernte CI +ist am aktuellen PR-Head separat zu prüfen. Frühere Word-Abnahmen gelten für +ihre dort dokumentierten Artefakte. diff --git a/docs/codebook-production-check-2026-10-17.json b/docs/codebook-production-check-2026-10-17.json new file mode 100644 index 000000000..9a55daa49 --- /dev/null +++ b/docs/codebook-production-check-2026-10-17.json @@ -0,0 +1,13 @@ +{ + "package": "4.1.0-dev.ff15d6d", + "source": "ff15d6d882417db26bc81fc88a09074eab74d57d", + "responses": "5.3.1", + "imageSize": "2.0.3", + "icnsAndPng": true, + "json": true, + "docx": true, + "zeroIndentTwips": 100, + "nativeFormula": true, + "scriptsExecuted": false, + "dockerStarted": false +} diff --git a/docs/codebook-responses-2026-10-15.md b/docs/codebook-responses-2026-10-15.md new file mode 100644 index 000000000..95349e466 --- /dev/null +++ b/docs/codebook-responses-2026-10-15.md @@ -0,0 +1,120 @@ +# Responses-Aktualisierung und Codebook-Prüfung vom 15.10.2026 + +## Versions- und Laufzeitvertrag + +`npm view @iqb/responses version` lieferte beim Abgleich 5.3.1. Diese aktuelle +Patchversion der gewünschten 5.3-Reihe ist in allen drei Root-Manifests exakt +festgelegt. Studios API-Runtime-Manifest und -Lock verwenden ebenfalls 5.3.1. +Die gemeinsame Bibliothek bezieht Responses als Peer mit `^5.3.0`, statt eine +private 5.2.2-Version für alle Bibliotheksfunktionen mitzuliefern. + +Damit lösen Schemer, Validierung und Codebook-Regeltextkonvertierung in beiden +Anwendungen dieselbe Responses-Installation wie ihr Host auf. Dies wird am +installierten Paket geprüft, nicht aus den Manifests angenommen. Der Generator +bleibt Angular-frei; Jobverarbeitung und Polling bleiben in der Kodierbox. + +Studio wird von 5.3.0 auf 5.3.1 aktualisiert, die Kodierbox-Kodierengine von 5.2.0 +auf 5.3.1. Kodierboxs Antwort- und Persistenzvertrag bleibt bei +`@iqbspecs/response` 1.5.1, einschließlich CODE_SELECTION_PENDING und alter +`codes`-Listen. Responses selbst benötigt intern Antwortformat 2.0. Der private +Typ für die Verarbeitung in CodingProcessService wird daher aus dem echten +Engine-Parameter abgeleitet. Die bestehende numerische Datenbank-Statusübergabe +wird nicht umgedeutet: Ein Regressionstest durch den realen Kodierbox-Adapter +und die echte 5.3.1-Engine erhält Status 13, Code/Score 0, alte Codeauswahl, +Alias und Subform. Keine Datenmigration und keine Status-Neuzuordnung. + +## Ergänzte Regressionen + +- 24 Bibliothekstests verwenden die echte Responses-Implementierung über + Validierung und SchemerService. Sie prüfen beide NO_VALUE-Schreibweisen, + sieben alte/neue Typbezeichnungen, elf alte/neue Formatbezeichnungen, + zwei Chemieformate und eine tatsächlich unzulässige STRING/BASE_NO_VALUE- + Kombination. Mit dem früheren privaten 5.2.2 scheiterten 22 dieser Tests; + mit 5.3.1 bestehen alle 24. Die anderen beiden Tests bestanden bereits zuvor. +- Drei Studio-Tests erzeugen den tatsächlich installierten öffentlichen + SchemerComponent, ohne Responses-Factory-Mock. NO_VALUE und no-value erhalten + Status OK; STRING/BASE_NO_VALUE bleibt ein blockierender INVALID_SOURCE-Fehler. + Der Großschreibungsfall scheiterte vor dem Paketwechsel. +- Vier Kodierbox-Tests prüfen die echte installierte Engine: beide NO_VALUE- + Schreibweisen, echte Quellenfehler und automatische Kodierung mit Code/Score 0. +- Der bestehende Kodierbox-Kompatibilitätsfilter berücksichtigt beide + NO_VALUE-Schreibweisen. Ein zusätzlicher Test verhindert, dass ein Fehler + für eine eigentlich passende uppercase-NO_VALUE-Quelle fälschlich toleriert wird. + Bestehende und ergänzte Tests prüfen den echten Validator in strict/compatible. + BASE/no-value wird vom aktuellen Validator nativ akzeptiert; BASE_NO_VALUE/STRING + bleibt tatsächlich unzulässig und wird nur nach der bestehenden ausdrücklich + gewählten Legacy-Kompatibilität toleriert. Strict lässt diesen Fehler blockierend. +- Ein zusätzlicher Service-Test prüft die oben beschriebene alte Codeauswahl + durch den echten aktuellen Autocoder einschließlich Alias-Routing. + +Die bewussten Validator-Korrekturen der 5.3-Reihe werden somit ausdrücklich +übernommen; die Validierung wird nicht pauschal abgeschaltet. Der Runtime-Code +für JSON-/DOCX-Erstellung wurde bei diesem Paketwechsel nicht geändert. + +## Lokale Prüfungen + +| Bereich | Ergebnis | +| --- | --- | +| coding-components Angular | 550 Tests bestanden | +| Generator, ESM und CommonJS | 1.285 Tests bestanden, keine übersprungen | +| Eigenständiger Schemer | 6 Tests und Build bestanden | +| Studio-Frontend, betroffener Bereich | 178 Tests in 9 Suites bestanden | +| Studio-API, betroffener Bereich | 145 Tests in 4 Suites bestanden | +| Kodierbox-Frontend, vollständige Suite | 2.102 Tests in 207 Suites bestanden | +| Kodierbox-Backend, vollständige Suite | 2.643 Tests bestanden, 17 bestehende Tests übersprungen | + +Bibliothek und alle vier Anwendungsbuilds bestanden. Frontend- und Backend- +Prüfungen liefen in getrennten, aufeinanderfolgenden Phasen. Host-Lint bestand; +Studio meldet bestehende Warnungen. Die neue Bibliotheks-Testdatei besteht ESLint. +Studios Docker-Versionsprüfung stimmt mit der frisch gebauten API überein; +Runtime-Lock, Root-Locks und Tarball-Prüfsummen sind abgeglichen. Eine neue +vollständige Docker-Runtime-Installation wurde für diesen Stand nicht ausgeführt. + +## Erneuter Installationsvergleich + +Beide Anwendungen enthalten dasselbe frisch gebaute und installierte 4.1.0-Artefakt: + +- SHA256: `9a0e75158dcc70e821c6a481d431ec6a0429e248afb85bd27991b1ae1af22d7a`. +- Alle 20 installierten Paketdateien entsprechen dem frischen Bibliotheksbuild. +- Beide Hosts und die Bibliothek lösen Responses auf denselben lokalen Pfad auf; + die installierte Version ist jeweils 5.3.1. Eine private Responses-Abhängigkeit + wird vom Vergleich explizit abgelehnt. +- Der installierte Validator akzeptiert uppercase NO_VALUE; die installierte + Regeltextdarstellung erkennt das neue Chemieformat. + +**4.694 Exportpaare bestehen:** 4.608 synthetische Matrixpaare, 6 ergänzende +Vertragsfälle, 40 Exporte gegen die 20 unabhängigen Studio-Leerzeichenreferenzen +und 40 Inlinebildfehler-Paare. Die Matrix umfasst alle 256 Kombinationen der acht +Ja/Nein-Optionen, drei Schulungsfilter, drei Missingkonstellationen und beide Formate. +Der Vergleich verwendet Studios DownloadWorkspacesClass und Kodierboxs echten +Generation-Service mit ihren Anwendungsadaptern. Datenquellen liegen im Speicher. +JSON wird strukturell verglichen, DOCX in allen normalisierten Paketbestandteilen: +Text, Tabellen, Styles, Fußzeilen, Seitenfelder, Medien, Beziehungen und Formeln. +Die neuen Versions-/Auflösungsprüfungen laufen vor diesen Exporten. + +Die 32 ursprünglichen Studio-Rohschema-Referenzen und 20 Leerzeichenreferenzen +wurden nicht neu erzeugt. Die Generator-Suite prüft sie weiterhin; gezielt +vereinbarte Normalisierungen wie die frühere 18pt-Korrektur bleiben unverändert. +Die Tests belegen die abgedeckten Fälle, keine allgemeine Fehlerfreiheit beliebiger +Daten. Die künstlichen Aufgaben sind weiterhin von den Beispiel-ZIPs unabhängig. + +Maschinenlesbarer aktueller Nachweis in coding-components: +`docs/codebook-responses-installation-comparison-2026-10-15.json`. + +## Grenzen und Einführung + +Für diesen Paketwechsel wurden kein neuer HTTP-/Browser-/Redis-Durchlauf und +kein neuer Microsoft-Word-Renderinglauf ausgeführt. Die frühere Word-Web-Abnahme +bezieht sich auf das damalige Artefakt. Desktop-Word unter Windows bleibt offen; +DOCX-XML-Gleichheit allein ist kein Nachweis identischer Windows-Word-Darstellung. +Die verpflichtende visuelle Abnahme und Prüfung beider realer Anwendungsabläufe +bleiben vor dem Ausrollen bestehen. + +Der eigenständige Schemer wurde mit 5.3.1 gebaut und getestet. Studios produktiv +über Verona geladene Schemer-Module erhalten diese Version erst durch ihre +separate Veröffentlichung und Einbindung; der Anwendungspaketwechsel ersetzt +keine bereits hochgeladenen Module. + +Dies ist eine lokale Prüfung des Release-Kandidaten. CI auf den gepushten +PR-Ständen ist ein separater Nachweis. Keine npm-Veröffentlichung, kein Merge, +kein Deployment und keine Produktionsabnahme wurden durchgeführt. diff --git a/docs/codebook-solver-compatibility-2026-10-15.md b/docs/codebook-solver-compatibility-2026-10-15.md new file mode 100644 index 000000000..0e7a0145a --- /dev/null +++ b/docs/codebook-solver-compatibility-2026-10-15.md @@ -0,0 +1,145 @@ +# SOLVER-Kompatibilität und Paket-Mindestversion vom 15.10.2026 + +Dieser Nachtrag ersetzt den Paketstand aus `codebook-responses-2026-10-15.md`. +Responses bleibt in allen Anwendungen und der Bibliotheksentwicklung bei 5.3.1. +Der öffentliche Peer der Bibliothek verlangt jetzt `^5.3.1`: 5.3.0 erkennt die +kleingeschriebenen Chemieformate gespeicherter Aufgaben noch nicht. +Zwei neue Bibliothekstests sichern zusätzlich `chem-molecule` und +`chem-element-symbols`; der Installationsvergleich prüft die Mindestversion und +beide Formen direkt über die tatsächlich installierten Pakete. + +## Erhaltenes Kodierverhalten der Kodierbox + +Das unveränderte npm-Responses 5.3.1 ändert gegenüber Kodierboxs bisherigem 5.2.0 +SOLVER-Ergebnisse: neue absolute mathjs-Toleranz und Kodierung von NULL-Ergebnissen. +Die Kodierbox erhält deshalb eine lokale, reproduzierbar erzeugte Engine-Fassung +**aus dem installierten Responses 5.3.1**, deren einziger umgeleiteter Import die +SOLVER-Mathematik ist. Der gesamte Ableitungsablauf bleibt aus 5.3.1; Codes und +abhängige Variablen sehen das richtige Ergebnis bereits vor der Codeauswahl. + +Der Adapter verwendet eine private Instanz der bisherigen mathjs-Version 12.4.3 +mit relativer Toleranz 1e-12 und schreibgeschützter Konfiguration. Dies erhält auch +weitere Math-Funktionen; nur die absolute Toleranz von mathjs 15 zu ändern wäre +keine vollständige Absicherung. NULL liefert wie bisher DERIVE_ERROR. +Auch verschachtelte evaluate/parser-Aufrufe können die Konfiguration nicht ändern. +Globale Math-Instanzen und die normale Responses-Factory werden nicht verändert. + +Validierung, Abhängigkeitsgraph, Schemer und Codebook-Regeltexte bleiben auf dem +normalen Responses-5.3.1-Peer. Studio behält seine bisherige eigene Engine-Semantik. +Die gemeinsame Bibliothek enthält keine Kodierbox-Kompatibilität und weiterhin +keine Jobsteuerung. Es gibt keine Datenmigration oder neue Statuszuordnung. + +## Regressionen und unabhängiger Vergleich + +Die drei neuen Service-Regressionen schlugen vor der Korrektur fehl und bestehen +nun über den echten Kodierbox-Namespace-/Aliasweg: + +| Fall | Bisheriges und nun wiederhergestelltes Ergebnis | +| --- | --- | +| ceil(A), A=1e-15, passende 0/1-Regeln | Wert, Code und Score 1 | +| A==0 ? 1:0, A=1e-15, passende 0/1-Regeln | Wert, Code und Score 0 | +| NULL-Zweig, auch mit IS_NULL-Code | DERIVE_ERROR; keine abgeschlossene Kodierung | + +Eine unabhängige Matrix vergleicht **11.760 Engine-Paare** mit dem unveränderten, +veröffentlichten Responses 5.2.0: sieben Ableitungsarten, 14 Statuswerte, 14 +Wertformen, Aliasvarianten, Subforms und neun SOLVER-Ausdrücke. Weitere Fälle +prüfen bestehende Zielzustände, BASE_NO_VALUE, SUM_SCORE-Ketten, nicht endliche +Ergebnisse, Konfigurationsschutz und ausbleibende globale Nebenwirkungen. +Das alte Referenzpaket ist ausschließlich eine Entwicklungsabhängigkeit und +wird nicht für Produktionskodierung verwendet. + +Der Builder `coding-box/scripts/build-autocoder-compat.mjs` bündelt die +installierten 5.3.1-Quellen, ohne sie manuell zu bearbeiten, und leitet genau einen +Math-Import um. Der Backend-Test baut das eingecheckte Artefakt erneut und +vergleicht es bytegenau; Version und Importanzahl sind geprüft. Details stehen +in `coding-box/docs/autocoder-solver-compatibility.md`. + +## Gemeinsames installiertes Paket + +Beide Hosts verwenden denselben neu gebauten 4.1.0-Tarball, SHA256: +`2df79a7b18196d7c25d7e02e15d5b148200d75ba2a315214ccfb8386536dd0a6`. +Root-Locks und Studios API-Runtime-Lock enthalten dessen passende Prüfsumme und +Peer-Mindestversion. Der Generator- und UI-Runtime-Code wurde dabei nicht geändert. + +Die unveränderten unabhängigen Studio-Referenzen bleiben verbindlich; sie werden +nicht aus dem neuen Generator neu erzeugt. Der erneute Installationsvergleich +prüft JSON strukturell und alle normalisierten DOCX-Bestandteile einschließlich +Bildern, Formeln, Styles, Tabellen, Beziehungen, Fußzeilen und Seitenfeldern. +Die echten Anwendungsadapter verwenden Datenquellen im Speicher. + +## Abschließende lokale Prüfung + +| Bereich | Ergebnis | +| --- | --- | +| Bibliotheks-Angular | 552 Tests bestanden | +| Eigenständiger Schemer | 6 Tests und Build bestanden | +| Generator ESM/CommonJS | 1.285 Tests bestanden, keine übersprungen | +| Studio-Frontend | 178 Tests in 9 betroffenen Suites bestanden | +| Studio-API | 145 Tests in 4 betroffenen Suites bestanden | +| Kodierbox-Frontend vollständig | 2.516 Tests bestanden | +| Kodierbox-Zoneless | 750 Tests bestanden | +| Kodierbox-Paketkorrekturen | 19 Tests bestanden | +| Kodierbox-Backend vollständig | 2.682 bestanden, 17 bestehende Tests übersprungen | + +Alle Hosttests liefen ohne Jest-/Nx-Cache. Bibliothek und alle vier Host-Builds +bestehen. Host-Lint und Lint der geänderten Bibliothekstestdatei bestehen; +Studio meldet bestehende Warnungen. Frontend- und Backend-Prüfungen wurden +getrennt und nacheinander ausgeführt. Die Studio-Testfixture verwendet einen +indexierten Statuszugriff, der auch bei frischer strikter Kompilierung gültig ist. + +**4.694 installierte Exportpaare bestehen erneut.** Vor dem Export werden alle +20 Paketdateien gegen den frischen Bibliotheksbuild geprüft. Die 32 unabhängigen +Studio-Rohschema- und 20 Abstandsreferenzen wurden nicht neu erzeugt und bestehen +weiterhin. Die bereits dokumentierten gezielten Formatkorrekturen bleiben erhalten. +Der aktuelle maschinenlesbare Nachweis liegt in coding-components unter +`docs/codebook-solver-installation-comparison-2026-10-15.json`; er enthält nun +zusätzlich Peer-Mindestversion und den Nachweis gespeicherter Chemieformate. + +Der gebaute Kodierbox-Backend-Code enthält die lokale SOLVER-Kompatibilität. +Sein Runtime-Manifest verlangt Responses 5.3.1 und mathjs 12.4.3 und enthält das +alte Referenzpaket nicht. Auch im Backend-Bundle kommt kein Referenzpaket-Import +vor. Studios Docker-Driftprüfung stimmt mit der frisch gebauten API überein; +Runtime-Lock und Tarball-Prüfsumme sind abgeglichen. Eine neue vollständige +Docker-Runtime-Installation wurde nicht durchgeführt. + + +## Erneute Prüfung nach Kodierbox-develop-Abgleich + +Die Kodierbox wurde mit `develop` auf `ab8d6f250` (Angular-Zoneless-Grundlage, +PR #1048) zusammengeführt. Die vier Konflikte betrafen die beiden CI-Dateien, +`package.json` und `package-lock.json`. Die eingegangenen Sicherheitsupdates, +Paketkorrekturen und Zoneless-Änderungen bleiben erhalten; Responses 5.3.1, +der gemeinsame Tarball, mathjs 12.4.3 und die reine Entwicklungsreferenz wurden +beibehalten. Das vollständige Node-22-Bookworm-Image und die getrennten +Frontend-/Backend-Prüfungen bleiben in der CI erhalten. + +Das aktualisierte esbuild 0.28.1 erfordert eine neue Erzeugung der eingecheckten +Kompatibilitätsengine. Die Änderung betrifft dessen Modulinitialisierungshelfer; +der bytegenaue Reproduzierbarkeitstest und die unabhängigen Engine-Vergleiche +bestehen mit dem neuen Artefakt. Der Codebook-Zoneless-Test verwendet jetzt +`whenStable()` und Jest-Timer anstelle des Zone.js-abhängigen `fakeAsync`. +Seine drei Fälle bestehen auch im normalen Frontend-Testlauf. Sie prüfen weiterhin +asynchrones Laden, sichtbaren Fortschritt, Downloadsperre und Polling-Abbruch. + +Die Tabelle oben enthält die nach diesem Abgleich wiederholten Kodierbox-Prüfungen. +Die 19 Paketkorrekturtests sichern außerdem die aus `develop` übernommenen +Abhängigkeiten ab. Der Installationsvergleich wurde anschließend mit den +unveränderten installierten Studio-/Bibliothekspaketen erneut ausgeführt; +Bibliotheks- und Studio-Builds/Tests wurden durch diesen Kodierbox-Abgleich nicht +verändert. Die unabhängigen Studio-Referenzdateien bleiben unverändert. + +## Nachweisgrenzen + +Die ergänzte [Windows-Desktop-Word-Abnahme](codebook-word-windows-2026-10-15.md) +prüft den oben bezeichneten aktuellen Tarball: 16 synthetische Dokumente, +32 vollständig gesichtete Seiten, sieben pixelgleiche aktuelle Host-Paare und +zwei unveränderte alte Studio-Referenzen. Word LTSC 2024, Version 2408, +Build 16.0.17932.21000 (64 Bit), besteht auch den Wiederholungslauf der endgültigen +Prüfskriptfassung. Die vereinbarte Kennzeichnung und die dokumentierte +18pt-Korrektur bleiben die sichtbaren Unterschiede zur alten Rich-Text-Referenz. + +Ein aktueller HTTP-/Browser-/Redis-Lauf wurde für diese Korrektur nicht erneut +ausgeführt; die Word-Abnahme ersetzt diese Anwendungsprüfungen nicht. +Bereits veröffentlichte Verona-Schemer-Module benötigen weiterhin eine separate Aktualisierung. +Es wurden kein npm-Release, kein PR-Merge, kein Deployment und keine Datenmigration ausgeführt. +Die CI der neuen PR-Heads ist ein separater Nachweis. diff --git a/docs/codebook-word-windows-2026-10-15.md b/docs/codebook-word-windows-2026-10-15.md new file mode 100644 index 000000000..6b0096c9b --- /dev/null +++ b/docs/codebook-word-windows-2026-10-15.md @@ -0,0 +1,22 @@ +# Windows-Desktop-Word-Abnahme am 15.10.2026 + +Der aktuelle gemeinsame Tarball mit SHA256 +`2df79a7b18196d7c25d7e02e15d5b148200d75ba2a315214ccfb8386536dd0a6` +wurde in Microsoft Word LTSC 2024, Version 2408, Build 16.0.17932.21000 (64 Bit) +auf Windows geprüft: **16 synthetische Dokumente mit 32 vollständig gesichteten +Seiten bestehen**. Die sieben aktuellen Studio-/Kodierbox-Paare sind auf allen +Seiten pixelgleich. Zwei unveränderte alte Studio-Rich-Text-Referenzen bleiben +abgesehen von vereinbarter Kennzeichnung/Erstellungsdatum und der dokumentierten +18pt-Schriftgrößenkorrektur erhalten. Alle DOCX-Quellprüfsummen sind unverändert. + +Die endgültige PowerShell-Prüfung wurde auf Windows erneut ausgeführt; alle +32 Seiten stimmen pixelgenau mit dem visuell gesichteten Lauf überein. +Es wurden in diesem Nachtrag ausschließlich Dokumentation und die gemeinsam +verwalteten Prüfscripte geändert. Anwendungscode, installiertes Paket und +unabhängige Studio-Referenzen bleiben unverändert. + +[Ausführlicher Bericht und Reproduktionsanleitung](https://github.com/iqb-berlin/coding-components/blob/shared-codebook/docs/codebook-word-windows-2026-10-15.md) +und [maschinenlesbarer Nachweis mit Datei- und Seitenbild-Prüfsummen](https://github.com/iqb-berlin/coding-components/blob/shared-codebook/docs/codebook-word-windows-2026-10-15.json) +liegen in coding-components. Der Nachweis gilt für die beschriebenen Fälle und +diese Word-Version. Browser-/HTTP-/Redis-/Berechtigungsprüfungen und entfernte CI +bleiben eigene Nachweise. Kein PR-Merge, Release oder Deployment. diff --git a/docs/shared-codebook.md b/docs/shared-codebook.md new file mode 100644 index 000000000..cae485a78 --- /dev/null +++ b/docs/shared-codebook.md @@ -0,0 +1,86 @@ +# Integration des gemeinsamen Codebooks + +## Aktueller Entwicklungsstand vom 17.10.2026 + +Beide Anwendungen verwenden jetzt denselben korrigierten Entwicklungsbuild +`4.1.0-dev.ff15d6d` aus Bibliothekscommit +`ff15d6d882417db26bc81fc88a09074eab74d57d`. +Der [Integrationsbericht](codebook-integration-2026-10-17.md) dokumentiert Paket, +Übersetzungseinbindung und aktuelle Prüfungen. Es wurde kein neuer Alpha-Kandidat +oder Registry-Release erstellt. Die folgenden Abnahmeberichte beziehen sich auf +ihre jeweils genannten früheren Artefakte. + +## Windows-Desktop-Word-Abnahme des aktuellen Paketstands + +Die [Abnahme vom 15.10.2026](codebook-word-windows-2026-10-15.md) bestätigt +16 synthetische Dokumente mit 32 vollständig gesichteten Seiten in Word LTSC 2024 +(Build 16.0.17932.21000, 64 Bit). Alle sieben aktuellen Studio-/Kodierbox-Paare +sind pixelgleich; die unveränderten alten Studio-Referenzen bleiben abgesehen +von vereinbarter Kennzeichnung und dokumentierter 18pt-Korrektur erhalten. +Der geprüfte Tarball ist der nachfolgend bezeichnete aktuelle SOLVER-Paketstand. + +## Aktuelle SOLVER-Kompatibilität und Mindestversion + +Der [Nachtrag vom 15.10.2026](codebook-solver-compatibility-2026-10-15.md) erhält +Kodierboxs bisherige SOLVER-Ergebnisse mit Engine-Code aus Responses 5.3.1 und +isolierter bisheriger Mathematik. Die Bibliothek verlangt nun den Peer `^5.3.1` +und prüft Chemieformate auch in gespeicherter Kleinschreibung. Aktueller Tarball- +SHA256: `2df79a7b18196d7c25d7e02e15d5b148200d75ba2a315214ccfb8386536dd0a6`. +Frühere Abschnitte und Berichte unten beziehen sich auf damalige Paketstände. + +## Vorheriger Responses-Abgleich + +Die [Prüfung vom 15.10.2026](codebook-responses-2026-10-15.md) verwendet überall +`@iqb/responses` 5.3.1, die beim Abgleich aktuelle Patchversion der 5.3-Reihe. +Die Bibliothek bezieht Responses als Peer (`^5.3.0`); Schemer, Validierung und +Generator verwenden dieselbe Installation wie der Host. Es gibt keine private +5.2.2-Installation mehr. 1.285 Generator-Tests und 4.694 installierte Exportpaare +bestehen einschließlich der unveränderten Studio-Referenzen. Aktueller Tarball- +SHA256: `9a0e75158dcc70e821c6a481d431ec6a0429e248afb85bd27991b1ae1af22d7a`. +Frühere Berichte unten beziehen sich auf ihre damaligen Paketstände. + +## Vorherige Inlinebild-Korrektur + +Die [Prüfung vom 15.10.2026](codebook-inline-images-2026-10-15.md) behebt Textverlust bei defekten Inlinebildern. 1.285 Generator-Tests und 4.694 installierte Exportpaare bestehen. Dabei verwendeten beide Anwendungen den Tarball mit SHA256 `1340db41e6493cca3d4395426c14f156319c3bbbf0b81d01f84c233a56a5091a`. Eine neue Word-Abnahme wurde für diesen Paketstand nicht ausgeführt. + +## Format- und Aspect-Abnahme vom 13.10.2026 + +Die [Browser- und Word-Abnahme vom 13.10.2026](codebook-aspect-acceptance-2026-10-13.md) ergänzt die frühere Leerzeichen-Abnahme um bestätigte Formatierungsfixes und den bidirektionalen Formel-Austausch. Damals geprüftes Paket-SHA256: `0e355c3896799ae765cb6e90b00147da0359c56f5fdc90d36b7caf40fbe59ab8`. 1.245 Generator-Tests, 4.654 installierte Exportpaare und 262 tatsächliche Microsoft-Word-Web-Seiten sind geprüft. Die explizite 18pt-Schriftgröße des alten `Farbe`-Referenzfalls wird bewusst korrigiert; alle übrigen alten Dokumentbestandteile bleiben streng verglichen. Angaben zu früheren Paketständen weiter unten sind historische Nachweise. + +Die Codebook-UI und der JSON-/DOCX-Generator kommen aus +`@iqb/ngx-coding-components` 4.1.0. Studio bildet die Bedien- und Ausgabereferenz. +Die vollständigen Regeln und Referenztests stehen in +`coding-components/docs/shared-codebook.md`. + +Schulungsbedarf wird in beiden Anwendungen im gemeinsamen Formular ausgewählt +und im gemeinsamen Generator anhand von CODER_TRAINING_REQUIRED gefiltert. +Der Kodierbox-Wrapper ergänzt den Jobdefinitionsfilter. Die Gruppenspalte und +der zusätzliche Variablenbündel-Filter werden hier nicht angezeigt (Issue #176). +Der bestehende serverseitige Variablenbündel-Vertrag bleibt erhalten; Bündeldaten +werden weiterhin zur korrekten Auflösung von Jobdefinitionen geladen. +Workspace-Prüfung und serverseitige Schnittmengenbildung bleiben bestehen. +Sowohl synchroner Export als auch Queue-Prozessor verwenden über den vorhandenen +Generation-Service denselben gemeinsamen Generator. Jobstart, Statusabfrage, +Fortschritt und Download bleiben ausschließlich in der Kodierbox. Schließen +beendet die Statusabfrage, nicht den Serverjob. +Der Wrapper meldet asynchrone Daten- und Statusänderungen an Angular und ist mit +OnPush und zoneless Change Detection getestet. + +## Release-Kandidat + +Die Abhängigkeit zeigt vor Veröffentlichung auf +`vendor/iqb-ngx-coding-components-4.1.0.tgz`. Das Backend-Dockerfile übernimmt +dasselbe Artefakt ausdrücklich in die Runtime-Installation. Ohne diese Zuordnung +würde Nx die Versionsnummer 4.1.0 im generierten Runtime-Manifest ausgeben und npm +zu früh die noch unveröffentlichte Registry-Version installieren wollen. + +Nach gemeinsamer Abnahme: Bibliothek veröffentlichen, die Root-Abhängigkeit auf +4.1.0 umstellen und Lock aktualisieren. Anschließend die Tarball-COPY- und +`npm pkg set`-Schritte im Backend-Dockerfile sowie den Tarball entfernen und die +Runtime-Installation erneut prüfen. Nicht nur die Frontend-Abhängigkeit ändern. + +Die Kodierengine verwendet wie Schemer, Validierung und Codebook-Regeltexte +`@iqb/responses` 5.3.1. Die Bibliothek verwendet den Host-Peer. Der Anwendungs- +und Persistenzvertrag bleibt bei response-Spezifikation 1.5.1; ein echter +Engine-Regressionstest erhält auch den alten Status CODE_SELECTION_PENDING. +Rollback erfolgt durch die vorherige Anwendungsversion, ohne Datenmigration. diff --git a/package-lock.json b/package-lock.json index a8030204a..c5105fc5d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.17.5", "license": "MIT", "dependencies": { + "@angular/animations": "21.2.24", "@angular/cdk": "21.2.14", "@angular/common": "21.2.24", "@angular/compiler": "21.2.24", @@ -21,7 +22,8 @@ "@angular/router": "21.2.24", "@iqb/metadata-components": "1.0.0", "@iqb/metadata-resolver": "0.2.0", - "@iqb/responses": "^5.2.0", + "@iqb/ngx-coding-components": "file:vendor/iqb-ngx-coding-components-4.1.0-dev.ff15d6d.tgz", + "@iqb/responses": "5.3.1", "@iqbspecs/coding-scheme": "^3.4.1", "@iqbspecs/metadata-profile": "^0.11.1", "@iqbspecs/metadata-values": "^3.0.1", @@ -69,6 +71,7 @@ "keycloak-angular": "21.0.0", "keycloak-js": "^26.2.4", "libxmljs2": "^0.37.0", + "mathjs": "12.4.3", "mathml2omml": "^0.5.0", "multer": "2.4.0", "passport-jwt": "^4.0.1", @@ -96,6 +99,7 @@ "@angular/compiler-cli": "21.2.24", "@golevelup/ts-jest": "^0.5.0", "@iqb/eslint-config": "^2.1.1", + "@iqb/responses-legacy-reference": "npm:@iqb/responses@5.2.0", "@nx/angular": "22.7.12", "@nx/cypress": "22.7.12", "@nx/esbuild": "22.7.12", @@ -756,6 +760,22 @@ "typescript": "*" } }, + "node_modules/@angular/animations": { + "version": "21.2.24", + "resolved": "https://registry.npmjs.org/@angular/animations/-/animations-21.2.24.tgz", + "integrity": "sha512-l83bg0i6tmBSmbf2GhpccnJp827RDq7uDbZf3vjWPPCzrQUeFNbUjYgzINzoLAbjhZMxEz6Z2ALgouRCXYJ83w==", + "deprecated": "@angular/animations is deprecated. Use `animate.enter` and `animate.leave` instead. For more information see: https://v22.angular.dev/guide/animations.", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@angular/core": "21.2.24" + } + }, "node_modules/@angular/build": { "version": "21.2.24", "resolved": "https://registry.npmjs.org/@angular/build/-/build-21.2.24.tgz", @@ -3253,6 +3273,20 @@ "dev": true, "license": "MIT" }, + "node_modules/@cortex-js/compute-engine": { + "version": "0.24.1", + "resolved": "https://registry.npmjs.org/@cortex-js/compute-engine/-/compute-engine-0.24.1.tgz", + "integrity": "sha512-B9pLaZFC1vBrL64yR2LQWy2sj1E/+wtwIVokPuPqX8YWXwj9W2AMVyS9pSMN8gEdjpAC50nSmtL6110Rgi7dKA==", + "license": "MIT", + "dependencies": { + "complex.js": "^2.1.1", + "decimal.js": "^10.4.3" + }, + "engines": { + "node": ">=16.14.2", + "npm": ">=8.5.0" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -4534,6 +4568,19 @@ "eslint-config-airbnb-typescript": "^18.0.0" } }, + "node_modules/@iqb/mathlive": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@iqb/mathlive/-/mathlive-0.4.1.tgz", + "integrity": "sha512-SjMXUBazoLvhDqpJVw6JvqD185B/XMKsINtcOIQSh8EU22ciMBpJbhFcDjOiD6DjPU65cFuOjmLMavp5jbp6iw==", + "license": "MIT", + "dependencies": { + "@cortex-js/compute-engine": "0.24.1" + }, + "engines": { + "node": ">=16.14.2", + "npm": ">=8.5.0" + } + }, "node_modules/@iqb/metadata-components": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@iqb/metadata-components/-/metadata-components-1.0.0.tgz", @@ -4566,15 +4613,223 @@ "@iqbspecs/metadata-profile": "^0.11.1" } }, + "node_modules/@iqb/ngx-coding-components": { + "version": "4.1.0-dev.ff15d6d", + "resolved": "file:vendor/iqb-ngx-coding-components-4.1.0-dev.ff15d6d.tgz", + "integrity": "sha512-RjXrwSua+T89/XvhyqjmeOYnc/wUXpFMVr5aYGWeluDnHSnP8pTIJDme+mqDFgAJ1MQAIUbuQu7+rlPGhjkrrQ==", + "license": "MIT", + "dependencies": { + "@iqb/mathlive": "^0.4.1", + "buffer": "6.0.3", + "cheerio": "1.1.2", + "docx": "8.5.0", + "image-size": "2.0.3", + "katex": "^0.16.11", + "mathml2omml": "0.5.0", + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/animations": "^21.0.0", + "@angular/cdk": "^21.0.0", + "@angular/common": "^21.0.0", + "@angular/compiler": "^21.0.0", + "@angular/core": "^21.0.0", + "@angular/elements": "^21.0.0", + "@angular/forms": "^21.0.0", + "@angular/material": "^21.0.0", + "@angular/platform-browser": "^21.0.0", + "@angular/platform-browser-dynamic": "^21.0.0", + "@angular/router": "^21.0.0", + "@iqb/responses": "^5.3.1", + "@iqbspecs/coding-scheme": "^3.4.1", + "@iqbspecs/response": "^1.5.1 || ^2.0.0", + "@iqbspecs/variable-info": "^1.3.0", + "@ngx-translate/core": "^15.0.0", + "@tiptap/core": "^2.23.1", + "@tiptap/extension-blockquote": "^2.23.1", + "@tiptap/extension-bold": "^2.23.1", + "@tiptap/extension-bubble-menu": "^2.23.1", + "@tiptap/extension-bullet-list": "^2.23.1", + "@tiptap/extension-color": "^2.23.1", + "@tiptap/extension-document": "^2.23.1", + "@tiptap/extension-floating-menu": "^2.23.1", + "@tiptap/extension-font-family": "^2.23.1", + "@tiptap/extension-heading": "^2.23.1", + "@tiptap/extension-highlight": "^2.23.1", + "@tiptap/extension-history": "^2.23.1", + "@tiptap/extension-image": "^2.23.1", + "@tiptap/extension-italic": "^2.23.1", + "@tiptap/extension-list-item": "^2.23.1", + "@tiptap/extension-ordered-list": "^2.23.1", + "@tiptap/extension-paragraph": "^2.23.1", + "@tiptap/extension-strike": "^2.23.1", + "@tiptap/extension-subscript": "^2.23.1", + "@tiptap/extension-superscript": "^2.23.1", + "@tiptap/extension-text": "^2.23.1", + "@tiptap/extension-text-align": "^2.23.1", + "@tiptap/extension-text-style": "^2.23.1", + "@tiptap/extension-underline": "^2.23.1", + "ngx-tiptap": "^12.0.0", + "prosemirror-state": "^1.3.4", + "rxjs": "~7.8.0" + } + }, + "node_modules/@iqb/ngx-coding-components/node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@iqb/ngx-coding-components/node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/@iqb/ngx-coding-components/node_modules/cheerio": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.1.2.tgz", + "integrity": "sha512-IkxPpb5rS/d1IiLbHMgfPuS0FgiWTtFIm/Nj+2woXDLTZ7fOT2eqzgYbdMlLweqlHbsZjxEChoVK+7iph7jyQg==", + "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.0.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.12.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/@iqb/ngx-coding-components/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==", + "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/@iqb/ngx-coding-components/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@iqb/ngx-coding-components/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/@iqb/ngx-coding-components/node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, "node_modules/@iqb/responses": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@iqb/responses/-/responses-5.3.1.tgz", + "integrity": "sha512-pP5NL1OC2NupC07sg/IYFYFtOjbAVusKL0XT9yt78LB13pfZTq0EuxZBMRe6ITctBlVw9sf3alqN0W2w01/lVg==", + "license": "CC0 1.0", + "dependencies": { + "@iqbspecs/coding-scheme": "^3.4.1", + "@iqbspecs/response": "^2.0.0", + "@iqbspecs/variable-info": "^1.3.0", + "mathjs": "^15.1.0" + } + }, + "node_modules/@iqb/responses-legacy-reference": { + "name": "@iqb/responses", "version": "5.2.0", "resolved": "https://registry.npmjs.org/@iqb/responses/-/responses-5.2.0.tgz", "integrity": "sha512-Pk1s0CFA/SeAdns/RdHEKMSt/WhHYRzM7jkU7p5n/Nt+1Hpwj4zkvS8uXfofNplYhV7lkeMes1kcceqqQ2fekw==", + "dev": true, "license": "CC0 1.0", "dependencies": { "mathjs": "^12.4.2" } }, + "node_modules/@iqb/responses/node_modules/@iqbspecs/response": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@iqbspecs/response/-/response-2.0.0.tgz", + "integrity": "sha512-PSEqXhFTGodpmkxkDD8aBWbNDi0R+308k2lvZwMO8lrDvsKA5CXQCmrEzYfNQoTlTaR9wwO+PHWpuHtNmTL6gw==", + "license": "CC0 1.0" + }, + "node_modules/@iqb/responses/node_modules/mathjs": { + "version": "15.2.0", + "resolved": "https://registry.npmjs.org/mathjs/-/mathjs-15.2.0.tgz", + "integrity": "sha512-UAQzSVob9rNLdGpqcFMYmSu9dkuLYy7Lr2hBEQS5SHQdknA9VppJz3cy2KkpMzTODunad6V6cNv+5kOLsePLow==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.26.10", + "complex.js": "^2.2.5", + "decimal.js": "^10.4.3", + "escape-latex": "^1.2.0", + "fraction.js": "^5.2.1", + "javascript-natural-sort": "^0.7.1", + "seedrandom": "^3.0.5", + "tiny-emitter": "^2.1.0", + "typed-function": "^4.2.1" + }, + "bin": { + "mathjs": "bin/cli.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/@iqbspecs/coding-scheme": { "version": "3.4.1", "resolved": "https://registry.npmjs.org/@iqbspecs/coding-scheme/-/coding-scheme-3.4.1.tgz", @@ -10065,6 +10320,17 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@prisma/debug": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", @@ -10115,6 +10381,13 @@ "@prisma/debug": "5.22.0" } }, + "node_modules/@remirror/core-constants": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz", + "integrity": "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==", + "license": "MIT", + "peer": true + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.4", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.4.tgz", @@ -11240,45 +11513,423 @@ "node": "^20.17.0 || >=22.9.0" } }, - "node_modules/@sinclair/typebox": { - "version": "0.34.48", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", - "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", - "dev": true, - "license": "MIT" + "node_modules/@sinclair/typebox": { + "version": "0.34.48", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", + "integrity": "sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "type-detect": "4.0.8" + } + }, + "node_modules/@sinonjs/fake-timers": { + "version": "15.4.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", + "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@sqltools/formatter": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", + "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@tiptap/core": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.3.tgz", + "integrity": "sha512-a5LfRbLpfaGI3hbL/LPHUYHI0I+FQHdSHsy8L4YnVIuu3hXcm3QZgkWbpEGf8hCz8krk6zEiu0+iFOjTySU2FA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.3.tgz", + "integrity": "sha512-NwK7FUFF0CKGJt/qNDR7plL/9vkJQBy8ziiyn8lk3j/j6tAFlCpgBMj420A1T26ItDpwOCyyaq+PMWHML+E4VQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.3.tgz", + "integrity": "sha512-d5tQLAl5nHNrHNkBEgHJ0GYQ52iAsq83fUiKnDxPWuF6leKMOtkUBt9rw918p/L6MPg/MHROZ3Qt4Q+lmVYbfQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bubble-menu": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.3.tgz", + "integrity": "sha512-08dt5pG9j3NTID1BnKT+FlfVgf16BPXEYtDCmPMLQq/jKHlOP49SRBAGhvYHZt8K7xkj0+JYhcgcJfytukTcKQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.3.tgz", + "integrity": "sha512-LEYkcuCCHYDm6NHWZRIl3Lpac1jXFhABZZpEj+V/ypPgPwKQSIzUMCySHXXNCUQOK3ipF0Z+O18kFnekag8ZLQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-color": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-color/-/extension-color-2.27.3.tgz", + "integrity": "sha512-+xHveJ8YfneusZIp87/8UW7VfdtCVgE5iN3JkTudC1TdYCnMqxbaIwQaGtsBH+UxinXhJSU9tcsD1P6QgKJSqg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/extension-text-style": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.3.tgz", + "integrity": "sha512-U10TnvBa6WTjBu64U4gn/HaxkBs/96q0U6mKH0PO9Ab0n3Bf/iN4HMtix2TEa3qWlJ8peVk9/RBwjyxNVNub4w==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-floating-menu": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.3.tgz", + "integrity": "sha512-4Be2efRPxLqZT0QB/IStVdVR1wP+FVPQxw/7qJ+xRk+m7EX45LVO0hvRdMK9aMO8Yd8JK2Wh9V3DbCN6cZ89vw==", + "license": "MIT", + "peer": true, + "dependencies": { + "tippy.js": "^6.3.7" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-font-family": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-font-family/-/extension-font-family-2.27.3.tgz", + "integrity": "sha512-TwTUCxv2uFfYItW8xMrzVjJaH8KNZXwY9KF1a9PtxtJmFCylJBkYst+V0QYZlgseQfes/tZjZyiGd1QQcgTrgQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/extension-text-style": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.3.tgz", + "integrity": "sha512-VWcj9b5VAhMJSAeG8xZyzCaVCcwmBGJL2k77lE2ZQaCS/jSvKUTN5ntd/1vwLrmaduciB7FmdVgOmWFcODyX1Q==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-highlight": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-highlight/-/extension-highlight-2.27.3.tgz", + "integrity": "sha512-NalKhoLGfXelD1P+2wThcpiorINDqeNz05brko28Tg+Ue9skOKO54Uh0XCoe93Mig/MvP/KQbi578LCYz8mqmw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-history": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.3.tgz", + "integrity": "sha512-btT7Teg9xtWbv9q6uc38aIQbZsbMhgNu1NGLf7nLf7Vqzm+GRCpRF5EzlF9RpwEPtC2j1bxrDzpy427Su1qF7w==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-image": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-image/-/extension-image-2.27.3.tgz", + "integrity": "sha512-YCOxC+UOFisHVpowPc3UmUtJDV9tjKJLeHKef3DZ6h1ixOnp2LStuhy2ohcSjISg1mZlYrx+/GoYottjwj7pww==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.3.tgz", + "integrity": "sha512-ycgP6h7QQ4WXojOlH7gQEWwzNEzkIkVzdfH6jAZtZ2nw71L5KO1t5kt6Iu382CmQa60skDTuErbfiBUx7a3e2A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.3.tgz", + "integrity": "sha512-jWh5tZdNiZDx8X3jKV80EM6zMfUeRD3HKNVGcj5izqNncgH+/jJtF3hDmpP0nbFmhren8BQFo6W5f9L/GqtAVA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.3.tgz", + "integrity": "sha512-RvxSnE8rpiMSosD7ANCsyA+7XPEz0R6mDOFlORMqAf+NnPfCnH8dguVuKBGOdodPj69NcTSlA/6VBCS+3J0CLw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.3.tgz", + "integrity": "sha512-Nbevu3wZk212NDpp1FaN05UbTHA4NHP2B5bAmkP8z3I+/ITvEgp7p9vFqJxZhe7fmqCSFlzhKj8ibmBc+Iu4CQ==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.3.tgz", + "integrity": "sha512-9Ax1UIRDOdPk/bGnHbKM39Bw+Zb0PV8IInlT6hr8K1or0wWZuiyE4zlKP3bhAB6IcDr4mJLJQ6TEmxKihivC5A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-subscript": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-subscript/-/extension-subscript-2.27.3.tgz", + "integrity": "sha512-x5Q1UVHLVP24wteh0xMSnwQ59WDKwNDOH0HzgbJ/UrDnN4f9H/haflQ4yZL0xdT2cYpvzJGPBugi2iKLcpeZjg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-superscript": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-superscript/-/extension-superscript-2.27.3.tgz", + "integrity": "sha512-dLZ9FBG6w5xlEeEsDR8T0nmI3ZDAYgLdkb/+bj8yOkzIUAbf9eDsE3HgqUrHPIm/7h9arJydnrdgOaxVDVlldg==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.3.tgz", + "integrity": "sha512-9VnSK7qXUuZevNzE0FIymbc8BbY5X+0k4NxxO2dtPBOX4IoNHcWzmpLcuCggd3uN6XYLAXhDi/w2hkmBtig8Mw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-align": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.3.tgz", + "integrity": "sha512-1gUN+rdCkuYDePfY8/AixiftkXZDqDN1czZS+e5QIZCNcYnFPIIVu4qurE9kaE0NYo46/RPhPERFBQAbK+dWFw==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } + }, + "node_modules/@tiptap/extension-text-style": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.3.tgz", + "integrity": "sha512-Z4ZKju7vA2vUCeWVgEWERHvnln6XtgEMcb29xW8i25dSrw5Qefn/b+ym7JPkaMvjWbbQ+5VtECiaEYdL3rRH6Q==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" + } }, - "node_modules/@sinonjs/commons": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", - "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "type-detect": "4.0.8" + "node_modules/@tiptap/extension-underline": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-2.27.3.tgz", + "integrity": "sha512-9qc6WtfF7j+gwlbz7ChmwjPqDRF8T2vbHMrZLoWERAez2pOi9VfRG7lj437sc1v9LPT31FlWyIkaTDTGqYMPWA==", + "license": "MIT", + "peer": true, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "^2.7.0" } }, - "node_modules/@sinonjs/fake-timers": { - "version": "15.4.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.4.0.tgz", - "integrity": "sha512-DsG+8/LscQIQg68J6Ef3dv10u6nVyetYn923s3/sus5eaGfTo1of5WMZSLf0UJc9KDuKPilPH0UDJCjvNbDNCA==", - "dev": true, - "license": "BSD-3-Clause", + "node_modules/@tiptap/pm": { + "version": "2.27.3", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.3.tgz", + "integrity": "sha512-E9mBCSwe8YdWXvpjRMqddx4Yd5lEj+EprNM+4J+MtBXPGFZCUAa90rkOKU4m+Pn7rd1N5s7wRsExqFco79twbg==", + "license": "MIT", + "peer": true, "dependencies": { - "@sinonjs/commons": "^3.0.1" + "prosemirror-changeset": "^2.3.0", + "prosemirror-collab": "^1.3.1", + "prosemirror-commands": "^1.6.2", + "prosemirror-dropcursor": "^1.8.1", + "prosemirror-gapcursor": "^1.3.2", + "prosemirror-history": "^1.4.1", + "prosemirror-inputrules": "^1.4.0", + "prosemirror-keymap": "^1.2.2", + "prosemirror-markdown": "^1.13.1", + "prosemirror-menu": "^1.2.4", + "prosemirror-model": "^1.23.0", + "prosemirror-schema-basic": "^1.2.3", + "prosemirror-schema-list": "^1.4.1", + "prosemirror-state": "^1.4.3", + "prosemirror-tables": "^1.6.4", + "prosemirror-trailing-node": "^3.0.0", + "prosemirror-transform": "^1.10.2", + "prosemirror-view": "^1.42.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" } }, - "node_modules/@sqltools/formatter": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz", - "integrity": "sha512-Uy0+khmZqUrUGm5dmMqVlnvufZRSK0FbYzVgp0UMstm+F5+W2/jnEEQyc9vo1ZR/E5ZI/B1WjjoTqBqwJL6Krw==", - "license": "MIT" - }, - "node_modules/@standard-schema/spec": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", - "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "license": "MIT" - }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -11665,12 +12316,37 @@ "@types/node": "*" } }, + "node_modules/@types/linkify-it": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", + "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", + "license": "MIT", + "peer": true + }, "node_modules/@types/luxon": { "version": "3.7.1", "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz", "integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==", "license": "MIT" }, + "node_modules/@types/markdown-it": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.2.0.tgz", + "integrity": "sha512-NoQ2yGlLWj4wpxMs+TYmRKk3thDrQ97agr7sFqfLsAlvoS8SNQuTrlObhFqG9iugdTtgOE9jpJ6FNM4ZGsa5xQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/linkify-it": "^5", + "@types/mdurl": "^2" + } + }, + "node_modules/@types/mdurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", + "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", + "license": "MIT", + "peer": true + }, "node_modules/@types/mime": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", @@ -15629,6 +16305,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT", + "peer": true + }, "node_modules/cron": { "version": "4.3.5", "resolved": "https://registry.npmjs.org/cron/-/cron-4.3.5.tgz", @@ -17084,7 +17767,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -18551,7 +19233,6 @@ "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" @@ -19663,17 +20344,15 @@ } }, "node_modules/image-size": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz", - "integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==", - "dev": true, + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.3.tgz", + "integrity": "sha512-9BJAtVCBth+76NwqnEfWr3PiRnYhbquk6K/USuQ1GQajz5NgLzO7tNsnoNjHfO7LuftgkBf+CCH6j28hjMbIYQ==", "license": "MIT", - "optional": true, "bin": { "image-size": "bin/image-size.js" }, "engines": { - "node": ">=0.10.0" + "node": ">=18" } }, "node_modules/immediate": { @@ -22154,6 +22833,26 @@ "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, + "node_modules/linkify-it": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz", + "integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "uc.micro": "^2.0.0" + } + }, "node_modules/listenercount": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", @@ -22787,6 +23486,34 @@ "node": ">= 0.6" } }, + "node_modules/markdown-it": { + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.2.tgz", + "integrity": "sha512-sHHjZ5fJKlgrG4qns2YwVcdNep35h5fERrfkD2YNsb9UFk0UIHarbiTaHKVMlPuWAoiilyK8Fv/jAm11slsY7Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/markdown-it" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "argparse": "^2.0.1", + "entities": "^4.5.0", + "linkify-it": "^5.0.2", + "mdurl": "^2.0.0", + "punycode.js": "^2.3.1", + "uc.micro": "^2.1.0" + }, + "bin": { + "markdown-it": "bin/markdown-it.mjs" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -22845,6 +23572,13 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/mdurl": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.1.0.tgz", + "integrity": "sha512-1+HBaOx0zi/dQWht8rNv9MYf9qqpqL/kxI0hXImU6Y547zM6Sni8BQibt7ifgMcYtQg41ao3Ivd6cnSM86inpg==", + "license": "MIT", + "peer": true + }, "node_modules/media-typer": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", @@ -23430,6 +24164,31 @@ "dev": true, "license": "MIT" }, + "node_modules/ngx-tiptap": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ngx-tiptap/-/ngx-tiptap-12.0.0.tgz", + "integrity": "sha512-m8jngTbQZWCMDSogwMcXaNpIpNvCx47D6hWM6lgmOLR2UC5vvvphQmDuC1t66Cn4brdol/vUVVFBbgqo56Jsmw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@angular/common": ">=19.0.0", + "@angular/core": ">=19.0.0", + "@angular/forms": ">=19.0.0", + "@tiptap/core": ">=2.7.0", + "@tiptap/extension-bubble-menu": "^2.7.0", + "@tiptap/extension-floating-menu": "^2.7.0", + "@tiptap/pm": "^2.7.0" + } + }, "node_modules/node-abi": { "version": "3.87.0", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", @@ -24843,6 +25602,13 @@ "license": "MIT", "optional": true }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT", + "peer": true + }, "node_modules/ospath": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/ospath/-/ospath-1.2.2.tgz", @@ -26666,6 +27432,219 @@ "node": ">= 4" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz", + "integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-collab": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/prosemirror-collab/-/prosemirror-collab-1.3.1.tgz", + "integrity": "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz", + "integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "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": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz", + "integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-markdown": { + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.8.tgz", + "integrity": "sha512-Zi43BXX28jmxDJKvk8TDS2gMkBY/mgP8ynd6l/Vxrv8rgNFs5hsV1G0VscxTID1UCzVPcidADP9fUOBY3xxUeA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/markdown-it": "^14.0.0", + "markdown-it": "^14.0.0", + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-menu": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/prosemirror-menu/-/prosemirror-menu-1.3.2.tgz", + "integrity": "sha512-6VgUJTYod0nMBlCaYJGhXGLu7Gt4AvcwcOq0YfJCY/6Uh+3S7UsWhpy6rJFCBFOmonq1hD8KyWOtZhkppd4YPg==", + "license": "MIT", + "peer": true, + "dependencies": { + "crelt": "^1.0.0", + "prosemirror-commands": "^1.0.0", + "prosemirror-history": "^1.0.0", + "prosemirror-state": "^1.0.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.12", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz", + "integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==", + "license": "MIT", + "peer": true, + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-basic": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/prosemirror-schema-basic/-/prosemirror-schema-basic-1.2.5.tgz", + "integrity": "sha512-McBWhfgOfThyu3Nltbs8SOUbM4t/h0pxteibaaHd1QR4S+F1t0u7M1ETaCE6/cveFoF+2fowTIBNkJOyeZQjNg==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-model": "^1.25.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-trailing-node": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/prosemirror-trailing-node/-/prosemirror-trailing-node-3.0.0.tgz", + "integrity": "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@remirror/core-constants": "3.0.0", + "escape-string-regexp": "^4.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.22.1", + "prosemirror-state": "^1.4.2", + "prosemirror-view": "^1.33.8" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz", + "integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.6", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz", + "integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "prosemirror-model": "^1.25.12", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/proxy-addr": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", @@ -26729,6 +27708,16 @@ "node": ">=6" } }, + "node_modules/punycode.js": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", + "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -27390,6 +28379,13 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT", + "peer": true + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -29701,6 +30697,16 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tippy.js": { + "version": "6.3.7", + "resolved": "https://registry.npmjs.org/tippy.js/-/tippy.js-6.3.7.tgz", + "integrity": "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@popperjs/core": "^2.9.0" + } + }, "node_modules/tldts": { "version": "6.1.86", "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", @@ -31045,6 +32051,13 @@ "node": ">=14.17" } }, + "node_modules/uc.micro": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "license": "MIT", + "peer": true + }, "node_modules/uglify-js": { "version": "3.19.3", "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", @@ -31561,6 +32574,13 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT", + "peer": true + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", diff --git a/package.json b/package.json index 7ee247a53..4f9858bc8 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "node": ">=22.12.0 <23" }, "dependencies": { + "@angular/animations": "21.2.24", "@angular/cdk": "21.2.14", "@angular/common": "21.2.24", "@angular/compiler": "21.2.24", @@ -40,7 +41,8 @@ "@angular/router": "21.2.24", "@iqb/metadata-components": "1.0.0", "@iqb/metadata-resolver": "0.2.0", - "@iqb/responses": "^5.2.0", + "@iqb/ngx-coding-components": "file:vendor/iqb-ngx-coding-components-4.1.0-dev.ff15d6d.tgz", + "@iqb/responses": "5.3.1", "@iqbspecs/coding-scheme": "^3.4.1", "@iqbspecs/metadata-profile": "^0.11.1", "@iqbspecs/metadata-values": "^3.0.1", @@ -88,6 +90,7 @@ "keycloak-angular": "21.0.0", "keycloak-js": "^26.2.4", "libxmljs2": "^0.37.0", + "mathjs": "12.4.3", "mathml2omml": "^0.5.0", "multer": "2.4.0", "passport-jwt": "^4.0.1", @@ -115,6 +118,7 @@ "@angular/compiler-cli": "21.2.24", "@golevelup/ts-jest": "^0.5.0", "@iqb/eslint-config": "^2.1.1", + "@iqb/responses-legacy-reference": "npm:@iqb/responses@5.2.0", "@nx/angular": "22.7.12", "@nx/cypress": "22.7.12", "@nx/esbuild": "22.7.12", @@ -203,7 +207,8 @@ "compression": "1.8.2", "proxy-addr": "2.0.8", "source-map-js": "1.2.2", - "handlebars": "4.7.10" + "handlebars": "4.7.10", + "image-size": "2.0.3" }, "eslintConfig": { "extends": "@iqb/eslint-config", diff --git a/scripts/build-autocoder-compat.mjs b/scripts/build-autocoder-compat.mjs new file mode 100644 index 000000000..40a140915 --- /dev/null +++ b/scripts/build-autocoder-compat.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { dirname, join, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +// Rebuild after reviewing an installed Responses upgrade. esbuild bundles its +// sources unchanged and redirects only the mathjs import to our solver adapter. +const root = fileURLToPath(new URL('../', import.meta.url)); +const require = createRequire(join(root, 'package.json')); +const responsesManifest = require.resolve('@iqb/responses/package.json'); +const responsesRoot = dirname(responsesManifest); +assert.equal(require('@iqb/responses/package.json').version, '5.3.1'); +assert.equal(require('mathjs/package.json').version, '12.4.3'); +const output = join(root, 'apps/backend/src/app/database/services/coding/generated/autocoder-compatibility-engine.js'); +const mathAdapter = join(root, 'apps/backend/src/app/database/services/coding/autocoder-solver-math.ts'); +let redirectedImports = 0; +const result = await build({ + absWorkingDir: root, + entryPoints: [require.resolve('@iqb/responses/coding-scheme-factory')], + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + write: false, + external: ['@iqbspecs/coding-scheme'], + banner: { js: '// Generated by node scripts/build-autocoder-compat.mjs; do not edit.\n' + + '// Engine: @iqb/responses 5.3.1, IQB, CC0-1.0. Only solver math is scoped to\n' + + '// autocoder-solver-math.ts / mathjs 12.4.3 (Apache-2.0), preserving 5.2.0 behaviour.' }, + plugins: [{ + name: 'isolated-legacy-solver-math', + setup(builder) { + builder.onResolve({ filter: /^mathjs$/ }, args => { + if (args.importer.startsWith(responsesRoot + sep)) { + redirectedImports += 1; + return { path: mathAdapter }; + } + return { path: 'mathjs', external: true }; + }); + } + }] +}); +assert.equal(redirectedImports, 1, 'Review the Responses dependency graph before changing this adapter'); +const content = result.outputFiles[0].text; +if (process.argv.includes('--check')) { + assert.equal(await readFile(output, 'utf8'), content, + 'Stale compatibility bundle: run node scripts/build-autocoder-compat.mjs'); + console.log('PASS: generated engine matches installed Responses 5.3.1 and isolated solver adapter'); +} else { + await mkdir(dirname(output), { recursive: true }); + await writeFile(output, content); +} diff --git a/vendor/iqb-ngx-coding-components-4.1.0-dev.ff15d6d.tgz b/vendor/iqb-ngx-coding-components-4.1.0-dev.ff15d6d.tgz new file mode 100644 index 000000000..5b00dccf6 Binary files /dev/null and b/vendor/iqb-ngx-coding-components-4.1.0-dev.ff15d6d.tgz differ