From 584b5e6b406aefe0a68a7c599956466264ad927f Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:39:03 +0200 Subject: [PATCH 1/8] Add codebook models entry point --- .../codebook-models/ng-package.json | 6 + .../src/lib/codebook.interfaces.ts | 151 ++++++++++++++++++ .../codebook-models/src/public-api.ts | 1 + .../ngx-coding-components/tsconfig.spec.json | 5 +- tsconfig.json | 3 + 5 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 projects/ngx-coding-components/codebook-models/ng-package.json create mode 100644 projects/ngx-coding-components/codebook-models/src/lib/codebook.interfaces.ts create mode 100644 projects/ngx-coding-components/codebook-models/src/public-api.ts diff --git a/projects/ngx-coding-components/codebook-models/ng-package.json b/projects/ngx-coding-components/codebook-models/ng-package.json new file mode 100644 index 0000000..d0a2dcd --- /dev/null +++ b/projects/ngx-coding-components/codebook-models/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/ngx-coding-components/codebook-models/src/lib/codebook.interfaces.ts b/projects/ngx-coding-components/codebook-models/src/lib/codebook.interfaces.ts new file mode 100644 index 0000000..09974dc --- /dev/null +++ b/projects/ngx-coding-components/codebook-models/src/lib/codebook.interfaces.ts @@ -0,0 +1,151 @@ +import { VariableInfo } from '@iqbspecs/variable-info/variable-info.interface'; + +/** + * Item metadata for codebook + */ +export interface ItemMetadata { + [key: string]: unknown; +} + +/** + * Settings for codebook content generation + */ +export interface CodeBookContentSetting { + /** Export format (docx or json) */ + exportFormat: string; + /** Missings profile name */ + missingsProfile: string; + /** Include only manual coding */ + hasOnlyManualCoding: boolean; + /** Include general instructions */ + hasGeneralInstructions: boolean; + /** Include derived variables */ + hasDerivedVars: boolean; + /** Include only variables with codes */ + hasOnlyVarsWithCodes: boolean; + /** Include closed variables */ + hasClosedVars: boolean; + /** Convert code labels to uppercase */ + codeLabelToUpper: boolean; + /** Show score */ + showScore: boolean; + /** Hide item-variable relation */ + hideItemVarRelation: boolean; +} + +/** + * Missing code definition + */ +export interface Missing { + /** Missing code */ + code: string; + /** Missing label */ + label: string; + /** Missing description */ + description: string; +} + +/** + * Code information for codebook + */ +export interface CodeInfo { + /** Code ID */ + id: string; + /** Code label */ + label: string; + /** Code description */ + description: string; + /** Code score (optional) */ + score?: string; +} + +/** + * Variable information for codebook + */ +export interface BookVariable { + /** Variable ID */ + id: string; + /** Variable label */ + label: string; + /** Variable source type */ + sourceType: string; + /** General instruction */ + generalInstruction: string; + /** Codes */ + codes: CodeInfo[]; +} + +/** + * Unit data for codebook + */ +export interface CodebookUnitDto { + /** Unit key */ + key: string; + /** Unit name */ + name: string; + /** Variables */ + variables: BookVariable[]; + /** Missings */ + missings: Missing[]; + /** Items (optional) */ + items?: ItemMetadata[]; +} + +/** + * Unit properties for codebook generation + */ +export interface UnitPropertiesForCodebook { + /** Unit ID */ + id: number; + /** Unit key */ + key: string; + /** Unit name */ + name: string; + /** Coding scheme */ + scheme?: string; + /** Scheme type */ + schemeType?: string; + /** Metadata */ + metadata?: { + /** Items */ + items?: ItemMetadata[]; + }; + /** Variables */ + variables?: VariableInfo[]; +} + +/** + * Unit selection item for codebook export UI + */ +export interface UnitSelectionItem { + /** Unit ID */ + unitId: number; + /** Unit name */ + unitName: string; + /** Unit alias */ + unitAlias: string | null; +} + +/** + * Missings profile for selection + */ +export interface MissingsProfile { + /** Profile ID */ + id: number; + /** Profile label */ + label: string; + /** Missings data */ + missings?: Missing[] | string; +} + +/** + * Codebook export configuration + */ +export interface CodebookExportConfig { + /** Selected unit IDs */ + selectedUnits: number[]; + /** Content options */ + contentOptions: CodeBookContentSetting; + /** Selected missings profile ID */ + missingsProfileId: number; +} diff --git a/projects/ngx-coding-components/codebook-models/src/public-api.ts b/projects/ngx-coding-components/codebook-models/src/public-api.ts new file mode 100644 index 0000000..ed91da9 --- /dev/null +++ b/projects/ngx-coding-components/codebook-models/src/public-api.ts @@ -0,0 +1 @@ +export * from './lib/codebook.interfaces'; diff --git a/projects/ngx-coding-components/tsconfig.spec.json b/projects/ngx-coding-components/tsconfig.spec.json index f78896e..0eee8e0 100644 --- a/projects/ngx-coding-components/tsconfig.spec.json +++ b/projects/ngx-coding-components/tsconfig.spec.json @@ -9,7 +9,10 @@ "paths": { "@angular/*": ["../../node_modules/@angular/*"], "@ngx-translate/core": ["../../node_modules/@ngx-translate/core"], - "@ngx-translate/core/*": ["../../node_modules/@ngx-translate/core/*"] + "@ngx-translate/core/*": ["../../node_modules/@ngx-translate/core/*"], + "@iqb/ngx-coding-components/codebook-models": [ + "projects/ngx-coding-components/codebook-models/src/public-api.ts" + ] } }, "include": [ diff --git a/tsconfig.json b/tsconfig.json index b2b5c0b..e7d38a6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,9 @@ ], "@ngx-coding-components/*": [ "projects/ngx-coding-components/src/lib/*" + ], + "@iqb/ngx-coding-components/codebook-models": [ + "projects/ngx-coding-components/codebook-models/src/public-api.ts" ] }, "sourceMap": true, From 989618dfb4a7fbe9d5cbe30a9425e8de7dc91a7e Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:39:29 +0200 Subject: [PATCH 2/8] Add codebook export entry point --- .../codebook-export/ng-package.json | 6 + .../src/lib/codebook-export/README.md | 364 +++++++++++ .../codebook-export.component.html | 247 ++++++++ .../codebook-export.component.scss | 286 +++++++++ .../codebook-export.component.ts | 578 ++++++++++++++++++ .../codebook-export.provider.ts | 37 ++ .../codebook-export/src/public-api.ts | 9 + .../codebook-export.component.spec.ts | 204 +++++++ .../src/lib/translations/de.json | 39 ++ 9 files changed, 1770 insertions(+) create mode 100644 projects/ngx-coding-components/codebook-export/ng-package.json create mode 100644 projects/ngx-coding-components/codebook-export/src/lib/codebook-export/README.md create mode 100644 projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.html create mode 100644 projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.scss create mode 100644 projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.ts create mode 100644 projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.provider.ts create mode 100644 projects/ngx-coding-components/codebook-export/src/public-api.ts create mode 100644 projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.spec.ts diff --git a/projects/ngx-coding-components/codebook-export/ng-package.json b/projects/ngx-coding-components/codebook-export/ng-package.json new file mode 100644 index 0000000..d0a2dcd --- /dev/null +++ b/projects/ngx-coding-components/codebook-export/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/README.md b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/README.md new file mode 100644 index 0000000..e7b3900 --- /dev/null +++ b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/README.md @@ -0,0 +1,364 @@ +# Codebook Export Component + +The codebook export component provides a comprehensive UI for generating and exporting codebooks from coding schemes. It supports both JSON and DOCX export formats with extensive configuration options. + +## Features + +- **Unit Selection**: Select specific units to include in the codebook +- **Content Filtering**: Configure which variables and codes to include +- **Missings Profiles**: Select from available missings profiles +- **Export Formats**: Export as JSON or DOCX +- **Search & Filter**: Quickly find units with built-in search +- **Background Jobs**: Optional async export with progress and downloads + +## Installation + +The component is available through the `@iqb/ngx-coding-components/codebook-export` entry point. +If you generate DOCX files in the browser instead of delegating export work to a provider, also install the generator peer dependencies: + +```bash +npm install docx cheerio @iqbspecs/coding-scheme @iqbspecs/variable-info +``` + +## Usage + +### Basic Example + +```typescript +import { Component } from '@angular/core'; +import { + CodebookExportComponent, + CodebookExportConfig, + UnitSelectionItem, + MissingsProfile +} from '@iqb/ngx-coding-components/codebook-export'; +import { CodebookGenerator } from '@iqb/ngx-coding-components/codebook-generator'; + +@Component({ + selector: 'app-my-component', + template: ` + + + `, + standalone: true, + imports: [CodebookExportComponent] +}) +export class MyComponent { + units: UnitSelectionItem[] = [ + { unitId: 1, unitName: 'Unit 1.vocs', unitAlias: null }, + { unitId: 2, unitName: 'Unit 2.vocs', unitAlias: null } + ]; + + profiles: MissingsProfile[] = [ + { id: 0, label: 'None' }, + { id: 1, label: 'Standard Missings' } + ]; + + loading = false; + + handleExport(config: CodebookExportConfig) { + console.log('Export config:', config); + // Use CodebookGenerator to generate the codebook + // Then download the file + } + + handleCancel() { + console.log('Export cancelled'); + } +} +``` + +### Provider-Based Example (recommended) + +Use a provider so the component can load data, manage export jobs, and download files. + +```typescript +import { Injectable, Component } from '@angular/core'; +import { map, Observable } from 'rxjs'; +import { + CodebookExportComponent, + CodebookExportProvider, + CodebookExportExecution, + CodebookExportConfig, + UnitSelectionItem, + MissingsProfile, + CODEBOOK_EXPORT_PROVIDER +} from '@iqb/ngx-coding-components/codebook-export'; + +@Injectable() +export class MyCodebookProvider implements CodebookExportProvider { + loadUnits(): Observable { + return this.backend.loadUnits(); + } + + loadMissingsProfiles(): Observable { + return this.backend.loadMissingsProfiles(); + } + + startExport(config: CodebookExportConfig): Observable { + return this.backend.exportCodebook(config).pipe( + map(blob => ({ + type: 'direct', + blob, + fileName: `codebook_${Date.now()}.${config.contentOptions.exportFormat}` + })) + ); + } +} + +@Component({ + selector: 'app-my-component', + template: ``, + standalone: true, + imports: [CodebookExportComponent], + providers: [{ provide: CODEBOOK_EXPORT_PROVIDER, useClass: MyCodebookProvider }] +}) +export class MyComponent {} +``` + +### Provider-Based Example (job + polling) + +```typescript +class MyCodebookProvider implements CodebookExportProvider { + startExport(config: CodebookExportConfig): Observable { + return this.backend.startCodebookJob(config).pipe( + map(response => ({ type: 'job', jobId: response.jobId })) + ); + } + + getJobStatus(jobId: string): Observable { + return this.backend.getCodebookJobStatus(jobId); + } + + download(jobId: string): Observable { + return this.backend.downloadCodebook(jobId); + } +} +``` + +### With Dialog + +```typescript +import { MatDialog } from '@angular/material/dialog'; +import { CodebookExportComponent } from '@iqb/ngx-coding-components/codebook-export'; + +export class MyComponent { + constructor(private dialog: MatDialog) {} + + openCodebookExport() { + const dialogRef = this.dialog.open(CodebookExportComponent, { + width: '90vw', + maxWidth: '1200px', + height: '90vh', + data: { + availableUnits: this.units, + missingsProfiles: this.profiles + } + }); + + dialogRef.componentInstance.export.subscribe(config => { + this.generateCodebook(config); + dialogRef.close(); + }); + } +} +``` + +### Generating Codebooks + +Use the `CodebookGenerator` class to generate codebooks from the export configuration: + +```typescript +import { + CodebookExportConfig +} from '@iqb/ngx-coding-components/codebook-export'; +import { + CodebookGenerator, + UnitPropertiesForCodebook +} from '@iqb/ngx-coding-components/codebook-generator'; + +class MyComponent { + async generateCodebook(config: CodebookExportConfig) { + // Fetch unit data with schemes + const units: UnitPropertiesForCodebook[] = await this.fetchUnits(config.selectedUnits); + + // Fetch missings + const missings = await this.fetchMissings(config.missingsProfileId); + + // Generate codebook + const blob = await CodebookGenerator.generateCodebook( + units, + config.contentOptions, + missings + ); + + // Download the file + this.downloadFile(blob, config.contentOptions.exportFormat); + } + + private downloadFile(blob: Blob, format: string) { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `codebook_${Date.now()}.${format}`; + a.click(); + window.URL.revokeObjectURL(url); + } +} +``` + +## API + +### Component Inputs + +| Input | Type | Default | Description | +|-------|------|---------|-------------| +| `availableUnits` | `UnitSelectionItem[]` | `[]` | List of units available for selection | +| `missingsProfiles` | `MissingsProfile[]` | `[{ id: 0, label: 'None' }]` | Available missings profiles | +| `isLoading` | `boolean` | `false` | Loading state for units | +| `workspaceChanges` | `boolean` | `false` | Whether workspace has unsaved changes | +| `defaultContentOptions` | `Partial` | - | Default content options | +| `provider` | `CodebookExportProvider` | - | Optional provider for loading data and running exports | + +### Component Outputs + +| Output | Type | Description | +|--------|------|-------------| +| `export` | `EventEmitter` | Emitted when export is triggered and no provider is configured | +| `cancel` | `EventEmitter` | Emitted when component is cancelled | + +### Interfaces + +#### `CodebookExportConfig` + +```typescript +interface CodebookExportConfig { + selectedUnits: number[]; + contentOptions: CodeBookContentSetting; + missingsProfileId: number; +} +``` + +#### `CodebookExportProvider` + +```typescript +interface CodebookExportProvider { + loadUnits?(): Observable; + loadMissingsProfiles?(): Observable; + startExport(config: CodebookExportConfig): Observable; + getJobStatus?(jobId: string): Observable; + download?(jobId: string): Observable; +} +``` + +#### `CodebookExportExecution` + +```typescript +type CodebookExportExecution = + | { type: 'direct'; blob: Blob; fileName?: string; mimeType?: string } + | { type: 'job'; jobId: string }; +``` + +#### `CodebookExportJobStatus` + +```typescript +interface CodebookExportJobStatus { + status: 'pending' | 'processing' | 'completed' | 'failed'; + progress?: number; + error?: string; + fileName?: string; + exportFormat?: string; +} +``` + +#### `CodeBookContentSetting` + +```typescript +interface CodeBookContentSetting { + exportFormat: string; // 'json' or 'docx' + missingsProfile: string; + hasOnlyManualCoding: boolean; // Include only manual coding + hasGeneralInstructions: boolean; // Include general instructions + hasDerivedVars: boolean; // Include derived variables + hasOnlyVarsWithCodes: boolean; // Include only variables with codes + hasClosedVars: boolean; // Include closed variables + codeLabelToUpper: boolean; // Convert code labels to uppercase + showScore: boolean; // Show scores + hideItemVarRelation: boolean; // Hide item-variable relation +} +``` + +#### `UnitSelectionItem` + +```typescript +interface UnitSelectionItem { + unitId: number; + unitName: string; + unitAlias: string | null; +} +``` + +#### `MissingsProfile` + +```typescript +interface MissingsProfile { + id: number; + label: string; + missings?: Missing[] | string; +} +``` + +## Content Options + +The component provides extensive configuration for codebook content: + +- **Only Manual Coding**: Include only manually coded variables +- **General Instructions**: Include general coding instructions +- **Derived Variables**: Include derived (calculated) variables +- **Only Variables with Codes**: Exclude variables without code definitions +- **Closed Variables**: Include closed (auto-coded) variables +- **Code Labels to Upper**: Convert all code labels to uppercase +- **Show Score**: Display score values for codes +- **Hide Item-Variable Relation**: Hide the relationship between items and variables + +## Styling + +The component uses Angular Material theming. You can customize the appearance by overriding the component's CSS classes or by providing custom Material theme colors. + +## Translation + +The component uses `@ngx-translate/core` for internationalization. Make sure to provide translations for the following keys: + +- `workspace.export-coding-book` +- `coding.select-units` +- `coding.select-all-units` +- `search` +- `search-units` +- `loading-units` +- `coding.unit-name` +- `no-units-matching` +- `no-units-available` +- `coding.codebook-content` +- `coding.has-only-vars-with-codes` +- `coding.has-general-instructions` +- `coding.hide-item-var-relation` +- `coding.has-derived-vars` +- `coding.has-only-manual-coding` +- `coding.has-closed-vars` +- `coding.show-score` +- `coding.code-label-to-upper` +- `coding.codebook-generating` +- `coding.codebook-completed` +- `workspace.coding-missing-profiles` +- `workspace.select-missings-profile` +- `coding.export-format` +- `coding.error-save-changes` +- `export` +- `close` + +And their corresponding tooltips (prefix with `coding.tooltip.`). diff --git a/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.html b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.html new file mode 100644 index 0000000..ff7076a --- /dev/null +++ b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.html @@ -0,0 +1,247 @@ +
+

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

+ +
+ +
+
+

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

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

{{'loading-units' | translate}}

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

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

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

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

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

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

+ + JSON + DOCX + +
+ + @if(workspaceChanges) { + +
+ {{'coding.error-save-changes' | translate}} +
+ } +
+
+
+ + + @if (codebookJobStatus !== 'idle') { +
+ @if (codebookJobStatus === 'pending' || codebookJobStatus === 'processing') { +
+
+ hourglass_empty + + {{ 'coding.codebook-generating' | translate }} + + {{ codebookJobProgress }}% +
+ + +
+ } + @if (codebookJobStatus === 'completed') { +
+ check_circle + {{ 'coding.codebook-completed' | translate }} +
+ } + @if (codebookJobStatus === 'failed') { +
+ error + {{ codebookJobError }} + +
+ } +
+ } + + + + + +
diff --git a/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.scss b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.scss new file mode 100644 index 0000000..7544944 --- /dev/null +++ b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.scss @@ -0,0 +1,286 @@ +// Main container styles +.export-codebook-container { + display: flex; + flex-direction: column; + height: 100%; +} + +// Dialog content styles +.dialog-content { + min-height: 500px; + padding: 0; + overflow: hidden; +} + +// Layout for the export panels +.export-layout { + display: flex; + flex-direction: row; + gap: 20px; + height: 100%; + padding: 16px; + + @media (max-width: 960px) { + flex-direction: column; + } +} + +// Shared panel styles +.unit-selection-panel, +.settings-panel { + border-radius: 8px; + padding: 16px; + background-color: white; +} + +// Unit selection panel styles +.unit-selection-panel { + flex: 1; + display: flex; + flex-direction: column; + max-height: 600px; + min-width: 300px; + + @media (max-width: 960px) { + max-height: 400px; + } + + @media (max-height: 768px) { + max-height: 450px; + } + + @media (max-height: 600px) { + max-height: 350px; + } +} + +// Panel header with title and count +.panel-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + + h3 { + margin: 0; + font-weight: 500; + } + + .selection-count { + background-color: #f0f0f0; + padding: 4px 8px; + border-radius: 16px; + font-size: 14px; + color: rgba(0, 0, 0, 0.7); + } +} + +// Select all container +.select-all-container { + margin-bottom: 16px; +} + +// Search container +.search-container { + margin-bottom: 16px; + + .search-field { + width: 100%; + } +} + +// Loading container +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; + + .loading-text { + margin-top: 16px; + color: rgba(0, 0, 0, 0.6); + } +} + +// Units table container +.units-table-container { + overflow-y: auto; // Enable vertical scrolling + flex: 1; + border: 1px solid #e0e0e0; + border-radius: 4px; + display: flex; + flex-direction: column; + max-height: 500px; // Fixed height to enable scrolling when content exceeds this height + + // Ensure smooth scrolling on touch devices + -webkit-overflow-scrolling: touch; + + // Add some bottom padding to ensure last row is fully visible when scrolled to bottom + padding-bottom: 4px; +} + +// Units table styles +.units-table { + width: 100%; + // Ensure table takes up available space in the container + flex: 1; + overflow: auto; + + .mat-mdc-header-cell { + background-color: #f5f5f5; + font-weight: 500; + padding: 12px 16px; + position: sticky; + top: 0; + z-index: 1; + } + + .mat-mdc-cell { + padding: 12px 16px; + height: 48px; // Match row height + vertical-align: middle; // Center content vertically + box-sizing: border-box; // Include padding in height calculation + } + + .mat-mdc-row { + height: 48px; // Fixed height for all rows + min-height: 48px; // Ensure minimum height + + &.selected { + background-color: rgba(33, 150, 243, 0.08); + } + + &:hover { + background-color: #f9f9f9; + } + } + + .mat-column-select { + width: 60px; + text-align: center; + } + + // No data row + .mat-mdc-no-data-row { + height: 48px; // Match regular row height + min-height: 48px; + + .mat-mdc-cell { + text-align: center; + padding: 12px 16px; // Match regular cell padding + height: 48px; // Match regular cell height + color: rgba(0, 0, 0, 0.6); + vertical-align: middle; + } + } +} + +// Settings panel styles +.settings-panel { + flex: 1; + display: flex; + flex-direction: column; + min-width: 300px; + max-width: 500px; + overflow-y: auto; + + @media (max-width: 960px) { + max-width: none; + } +} + +// Settings section styles +.settings-section { + padding: 16px 0; + + h3 { + margin-top: 0; + margin-bottom: 16px; + font-weight: 500; + } + + &:first-child { + padding-top: 0; + } + + &:last-child { + padding-bottom: 0; + } +} + +// Warning section styles +.warning-section { + padding: 16px; + background-color: rgba(244, 67, 54, 0.08); + border-radius: 4px; +} + +// Options grid for checkboxes +.options-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); + gap: 12px; + + mat-checkbox { + margin-bottom: 8px; + } +} + +// Full width form field +.full-width { + width: 100%; +} + +// Export format radio group +.export-format-group { + display: flex; + flex-direction: row; + gap: 16px; +} + +// Codebook generation progress section +.codebook-progress-section { + padding: 12px 24px; + border-top: 1px solid #e0e0e0; +} + +.progress-container { + display: flex; + flex-direction: column; + gap: 8px; + + &.completed, + &.failed { + flex-direction: row; + align-items: center; + gap: 8px; + } +} + +.progress-header { + display: flex; + align-items: center; + gap: 8px; +} + +.progress-label { + font-size: 14px; + color: rgba(0, 0, 0, 0.7); +} + +.progress-percentage { + margin-left: auto; + font-size: 14px; + font-weight: 500; + color: rgba(0, 0, 0, 0.87); +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.spinning { + animation: spin 1.5s linear infinite; +} diff --git a/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.ts b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.ts new file mode 100644 index 0000000..a58210c --- /dev/null +++ b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.ts @@ -0,0 +1,578 @@ +import { + Component, + OnInit, + OnDestroy, + OnChanges, + Input, + Output, + EventEmitter, + SimpleChanges, + Inject, + Optional +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { MatButtonModule } from '@angular/material/button'; +import { MatInputModule } from '@angular/material/input'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatOptionModule } from '@angular/material/core'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatDividerModule } from '@angular/material/divider'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { TranslateModule } from '@ngx-translate/core'; +import { + Subject, + Subscription, + interval +} from 'rxjs'; +import { + debounceTime, + distinctUntilChanged, + switchMap, + takeUntil +} from 'rxjs/operators'; +import { + MAT_DIALOG_DATA, MatDialogActions, MatDialogClose, MatDialogContent, MatDialogTitle +} from '@angular/material/dialog'; +import type { + CodeBookContentSetting, + UnitSelectionItem, + MissingsProfile, + CodebookExportConfig +} from '@iqb/ngx-coding-components/codebook-models'; +import { + CODEBOOK_EXPORT_PROVIDER, + CodebookExportExecution, + CodebookExportJobStatus, + CodebookExportProvider +} from './codebook-export.provider'; + +export interface CodebookExportDialogData { + availableUnits?: UnitSelectionItem[]; + missingsProfiles?: MissingsProfile[]; + isLoading?: boolean; + workspaceChanges?: boolean; + defaultContentOptions?: Partial; + provider?: CodebookExportProvider; +} + +/** + * Standalone component for exporting codebooks + * + * This component provides a UI for: + * - Selecting units to include in the codebook + * - Configuring content options (manual coding, derived vars, etc.) + * - Selecting a missings profile + * - Choosing export format (JSON or DOCX) + * - Running exports via an optional provider (direct download or background job) + * + * @example + * ```html + * + * + * ``` + */ +@Component({ + selector: 'ngx-codebook-export', + templateUrl: './codebook-export.component.html', + styleUrls: ['./codebook-export.component.scss'], + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatCheckboxModule, + MatRadioModule, + MatSelectModule, + MatButtonModule, + MatInputModule, + MatFormFieldModule, + MatOptionModule, + MatIconModule, + MatTooltipModule, + MatDividerModule, + MatTableModule, + MatProgressSpinnerModule, + MatProgressBarModule, + TranslateModule, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatDialogTitle + ] +}) +export class CodebookExportComponent implements OnInit, OnDestroy, OnChanges { + /** List of available units for selection */ + @Input() availableUnits: UnitSelectionItem[] = []; + + /** List of available missings profiles */ + @Input() missingsProfiles: MissingsProfile[] = [{ id: 0, label: 'None' }]; + + /** Loading state for units */ + @Input() isLoading = false; + + /** Whether workspace has unsaved changes */ + @Input() workspaceChanges = false; + + /** Default content options */ + @Input() defaultContentOptions?: Partial; + + /** Optional provider for loading data and running exports */ + @Input() provider?: CodebookExportProvider; + + /** Emitted when export is triggered */ + @Output() export = new EventEmitter(); + + /** Emitted when component is closed/cancelled */ + @Output() cancel = new EventEmitter(); + + unitList: number[] = []; + + dataSource: MatTableDataSource = new MatTableDataSource([]); + + filterValue = ''; + filterTextChanged = new Subject(); + + private isLoadingInternal = false; + + selectedMissingsProfile: number = 0; + + displayedColumns: string[] = ['select', 'unitName']; + + contentOptions: CodeBookContentSetting = { + exportFormat: 'docx', + missingsProfile: '', + hasOnlyManualCoding: true, + hasGeneralInstructions: true, + hasDerivedVars: true, + hasOnlyVarsWithCodes: true, + hasClosedVars: true, + codeLabelToUpper: true, + showScore: true, + hideItemVarRelation: true + }; + + codebookJobId: string | null = null; + codebookJobStatus: 'idle' | 'pending' | 'processing' | 'completed' | 'failed' = 'idle'; + codebookJobProgress = 0; + codebookJobError: string | null = null; + private codebookPollingSubscription: Subscription | null = null; + private lastExportConfig: CodebookExportConfig | null = null; + private lastExportFileName: string | null = null; + private lastExportFormat: string | null = null; + + private destroy$ = new Subject(); + + constructor( + @Optional() @Inject(CODEBOOK_EXPORT_PROVIDER) private injectedProvider?: CodebookExportProvider, + @Optional() @Inject(MAT_DIALOG_DATA) private dialogData?: CodebookExportDialogData | null + ) {} + + private get activeProvider(): CodebookExportProvider | undefined { + return this.provider || this.injectedProvider; + } + + get loading(): boolean { + return this.isLoading || this.isLoadingInternal; + } + + get exportDisabled(): boolean { + return ( + this.unitList.length === 0 || + this.codebookJobStatus === 'pending' || + this.codebookJobStatus === 'processing' + ); + } + + ngOnInit(): void { + this.applyDialogData(); + + // Apply default content options if provided + if (this.defaultContentOptions) { + this.contentOptions = { ...this.contentOptions, ...this.defaultContentOptions }; + } + + this.configureDataSource(); + + // Set up filter debouncing + this.filterTextChanged + .pipe( + debounceTime(300), + distinctUntilChanged(), + takeUntil(this.destroy$) + ) + .subscribe(event => { + this.applyFilter(event); + }); + + this.loadUnitsFromProviderIfNeeded(); + this.loadMissingsProfilesFromProviderIfNeeded(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['availableUnits']) { + this.configureDataSource(); + this.syncUnitSelection(); + } + if (changes['missingsProfiles']) { + this.ensureSelectedMissingsProfile(); + } + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + this.stopCodebookPolling(); + } + + applyFilter(event: Event): void { + const filterValue = (event.target as HTMLInputElement | null)?.value ?? this.filterValue; + this.dataSource.filter = filterValue.trim().toLowerCase(); + } + + clearFilter(): void { + this.filterValue = ''; + this.dataSource.filter = ''; + } + + private configureDataSource(): void { + this.dataSource.data = this.availableUnits; + this.dataSource.filterPredicate = (data, filter: string) => { + const formattedName = this.formatUnitName(data.unitName).toLowerCase(); + return formattedName.includes(filter); + }; + } + + private syncUnitSelection(): void { + if (this.unitList.length === 0) return; + const availableIds = new Set(this.availableUnits.map(unit => unit.unitId)); + this.unitList = this.unitList.filter(id => availableIds.has(id)); + } + + private ensureSelectedMissingsProfile(): void { + if (!this.missingsProfiles || this.missingsProfiles.length === 0) { + this.missingsProfiles = [{ id: 0, label: 'None' }]; + this.selectedMissingsProfile = 0; + return; + } + if (!this.missingsProfiles.some(profile => profile.id === this.selectedMissingsProfile)) { + this.selectedMissingsProfile = this.missingsProfiles[0]?.id ?? 0; + } + } + + private ensureNoneProfile(profiles: MissingsProfile[]): MissingsProfile[] { + const safeProfiles = Array.isArray(profiles) ? profiles : []; + if (safeProfiles.some(profile => profile.id === 0)) return safeProfiles; + const noneLabel = this.missingsProfiles.find(profile => profile.id === 0)?.label ?? 'None'; + return [{ id: 0, label: noneLabel }, ...safeProfiles]; + } + + private applyDialogData(): void { + if (!this.dialogData) return; + + if (this.dialogData.availableUnits) { + this.availableUnits = this.dialogData.availableUnits; + } + if (this.dialogData.missingsProfiles) { + this.missingsProfiles = this.ensureNoneProfile(this.dialogData.missingsProfiles); + this.ensureSelectedMissingsProfile(); + } + if (typeof this.dialogData.isLoading === 'boolean') { + this.isLoading = this.dialogData.isLoading; + } + if (typeof this.dialogData.workspaceChanges === 'boolean') { + this.workspaceChanges = this.dialogData.workspaceChanges; + } + if (this.dialogData.defaultContentOptions) { + this.defaultContentOptions = { + ...this.defaultContentOptions, + ...this.dialogData.defaultContentOptions + }; + } + if (this.dialogData.provider) { + this.provider = this.dialogData.provider; + } + } + + private loadUnitsFromProviderIfNeeded(): void { + const provider = this.activeProvider; + if (!provider?.loadUnits) return; + if (this.availableUnits.length > 0) return; + this.isLoadingInternal = true; + provider.loadUnits() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: units => { + this.availableUnits = units || []; + this.configureDataSource(); + this.syncUnitSelection(); + this.isLoadingInternal = false; + }, + error: () => { + this.isLoadingInternal = false; + } + }); + } + + private loadMissingsProfilesFromProviderIfNeeded(): void { + const provider = this.activeProvider; + if (!provider?.loadMissingsProfiles) return; + if (this.missingsProfiles.length > 1 || (this.missingsProfiles.length === 1 && this.missingsProfiles[0].id !== 0)) { + return; + } + provider.loadMissingsProfiles() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: profiles => { + this.missingsProfiles = this.ensureNoneProfile(profiles || []); + this.ensureSelectedMissingsProfile(); + }, + error: () => { + // Keep current profiles + } + }); + } + + toggleUnitSelection(unitId: number, isSelected: boolean): void { + if (isSelected) { + if (!this.unitList.includes(unitId)) { + this.unitList.push(unitId); + } + } else { + this.unitList = this.unitList.filter(id => id !== unitId); + } + } + + isUnitSelected(unitId: number): boolean { + return this.unitList.includes(unitId); + } + + // Used by the template. + // eslint-disable-next-line class-methods-use-this + formatUnitName(unitName: string): string { + if (unitName && unitName.toLowerCase().endsWith('.vocs')) { + return unitName.substring(0, unitName.length - 5); + } + return unitName; + } + + toggleAllUnits(isSelected: boolean): void { + if (isSelected) { + this.unitList = this.availableUnits.map(unit => unit.unitId); + } else { + this.unitList = []; + } + } + + exportCodingBook(): void { + if (this.unitList.length === 0) { + return; + } + + const contentOptions: CodeBookContentSetting = { + ...this.contentOptions, + missingsProfile: this.selectedMissingsProfile.toString() + }; + + const config: CodebookExportConfig = { + selectedUnits: [...this.unitList], + contentOptions, + missingsProfileId: this.selectedMissingsProfile + }; + + const provider = this.activeProvider; + if (!provider?.startExport) { + this.export.emit(config); + return; + } + + this.startProviderExport(provider, config); + } + + onCancel(): void { + this.stopCodebookPolling(); + this.cancel.emit(); + } + + resetCodebookJob(): void { + this.codebookJobId = null; + this.codebookJobStatus = 'idle'; + this.codebookJobProgress = 0; + this.codebookJobError = null; + this.stopCodebookPolling(); + } + + private startProviderExport(provider: CodebookExportProvider, config: CodebookExportConfig): void { + this.stopCodebookPolling(); + this.codebookJobStatus = 'pending'; + this.codebookJobProgress = 0; + this.codebookJobError = null; + this.codebookJobId = null; + this.lastExportConfig = config; + this.lastExportFileName = null; + this.lastExportFormat = config.contentOptions.exportFormat; + + provider.startExport(config) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (execution: CodebookExportExecution) => { + if (execution.type === 'direct') { + this.codebookJobStatus = 'processing'; + this.codebookJobProgress = 100; + const fileName = this.resolveFileName(execution.fileName); + const blob = execution.mimeType ? new Blob([execution.blob], { type: execution.mimeType }) : execution.blob; + try { + CodebookExportComponent.downloadBlob(blob, fileName); + this.codebookJobStatus = 'completed'; + } catch { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Failed to download codebook file'; + } + return; + } + + if (!execution.jobId) { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Failed to start codebook generation job'; + return; + } + + this.codebookJobId = execution.jobId; + this.startCodebookPolling(execution.jobId); + }, + error: () => { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Failed to start codebook generation job'; + } + }); + } + + private startCodebookPolling(jobId: string): void { + const provider = this.activeProvider; + if (!provider?.getJobStatus) { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Job status handler is not available'; + return; + } + + this.stopCodebookPolling(); + + this.codebookPollingSubscription = interval(1500) + .pipe( + takeUntil(this.destroy$), + switchMap(() => provider.getJobStatus!(jobId)) + ) + .subscribe({ + next: (status: CodebookExportJobStatus) => { + if (!status.status && status.error) { + this.codebookJobStatus = 'failed'; + this.codebookJobError = status.error; + this.stopCodebookPolling(); + return; + } + + this.codebookJobProgress = status.progress ?? 0; + if (status.fileName) { + this.lastExportFileName = status.fileName; + } + if (status.exportFormat) { + this.lastExportFormat = status.exportFormat; + } + + if (status.status === 'completed') { + this.codebookJobStatus = 'completed'; + this.stopCodebookPolling(); + this.downloadCodebookResult(jobId); + } else if (status.status === 'failed') { + this.codebookJobStatus = 'failed'; + this.codebookJobError = status.error || 'Codebook generation failed'; + this.stopCodebookPolling(); + } else if (status.status === 'processing') { + this.codebookJobStatus = 'processing'; + } else { + this.codebookJobStatus = 'pending'; + } + }, + error: () => { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Failed to get job status'; + this.stopCodebookPolling(); + } + }); + } + + private stopCodebookPolling(): void { + if (this.codebookPollingSubscription) { + this.codebookPollingSubscription.unsubscribe(); + this.codebookPollingSubscription = null; + } + } + + private downloadCodebookResult(jobId: string): void { + const provider = this.activeProvider; + if (!provider?.download) { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Download handler is not available'; + return; + } + + provider.download(jobId) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: blob => { + const fileName = this.resolveFileName(); + try { + CodebookExportComponent.downloadBlob(blob, fileName); + } catch { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Failed to download codebook file'; + } + }, + error: () => { + this.codebookJobStatus = 'failed'; + this.codebookJobError = 'Failed to download codebook file'; + } + }); + } + + private resolveFileName(preferredName?: string): string { + if (preferredName) return preferredName; + if (this.lastExportFileName) return this.lastExportFileName; + const format = this.lastExportFormat || + this.lastExportConfig?.contentOptions.exportFormat || + this.contentOptions.exportFormat; + return CodebookExportComponent.buildDefaultFileName(format); + } + + private static buildDefaultFileName(format?: string): string { + const extension = (format || 'docx').toLowerCase(); + const now = new Date(); + const year = now.getFullYear(); + const month = `${now.getMonth() + 1}`.padStart(2, '0'); + const day = `${now.getDate()}`.padStart(2, '0'); + const hours = `${now.getHours()}`.padStart(2, '0'); + const minutes = `${now.getMinutes()}`.padStart(2, '0'); + const seconds = `${now.getSeconds()}`.padStart(2, '0'); + return `codebook_${year}${month}${day}_${hours}${minutes}${seconds}.${extension}`; + } + + private static downloadBlob(blob: Blob, fileName: string): void { + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = fileName; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + } +} diff --git a/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.provider.ts b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.provider.ts new file mode 100644 index 0000000..ad70313 --- /dev/null +++ b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.provider.ts @@ -0,0 +1,37 @@ +import { InjectionToken } from '@angular/core'; +import { Observable } from 'rxjs'; +import type { + CodebookExportConfig, + MissingsProfile, + UnitSelectionItem +} from '@iqb/ngx-coding-components/codebook-models'; + +export type CodebookExportExecution = + | { + type: 'direct'; + blob: Blob; + fileName?: string; + mimeType?: string; + } + | { + type: 'job'; + jobId: string; + }; + +export interface CodebookExportJobStatus { + status: 'pending' | 'processing' | 'completed' | 'failed'; + progress?: number; + error?: string; + fileName?: string; + exportFormat?: string; +} + +export interface CodebookExportProvider { + loadUnits?(): Observable; + loadMissingsProfiles?(): Observable; + startExport(config: CodebookExportConfig): Observable; + getJobStatus?(jobId: string): Observable; + download?(jobId: string): Observable; +} + +export const CODEBOOK_EXPORT_PROVIDER = new InjectionToken('CODEBOOK_EXPORT_PROVIDER'); diff --git a/projects/ngx-coding-components/codebook-export/src/public-api.ts b/projects/ngx-coding-components/codebook-export/src/public-api.ts new file mode 100644 index 0000000..d7054c7 --- /dev/null +++ b/projects/ngx-coding-components/codebook-export/src/public-api.ts @@ -0,0 +1,9 @@ +export * from './lib/codebook-export/codebook-export.component'; +export * from './lib/codebook-export/codebook-export.provider'; +export type { + CodeBookContentSetting, + CodebookExportConfig, + Missing, + MissingsProfile, + UnitSelectionItem +} from '@iqb/ngx-coding-components/codebook-models'; diff --git a/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.spec.ts b/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.spec.ts new file mode 100644 index 0000000..79a3837 --- /dev/null +++ b/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.spec.ts @@ -0,0 +1,204 @@ +import { + ComponentFixture, TestBed, fakeAsync, tick +} from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateFakeLoader, TranslateLoader, TranslateModule } from '@ngx-translate/core'; +import { of } from 'rxjs'; + +import { + CodebookExportComponent +} from '../../../codebook-export/src/lib/codebook-export/codebook-export.component'; +import { + CodebookExportExecution, + CodebookExportJobStatus, + CodebookExportProvider +} from '../../../codebook-export/src/lib/codebook-export/codebook-export.provider'; +import { + CodebookExportConfig, + UnitSelectionItem, + MissingsProfile +} from '../../../codebook-models/src/lib/codebook.interfaces'; + +describe('CodebookExportComponent', () => { + let fixture: ComponentFixture; + let component: CodebookExportComponent; + let lastAnchor: HTMLAnchorElement | null; + + function setupDownloadSpies() { + const urlApi = window.URL as typeof window.URL & { + createObjectURL?: (blob: Blob) => string; + revokeObjectURL?: (url: string) => void; + }; + if (!urlApi.createObjectURL) { + urlApi.createObjectURL = () => 'blob:mock'; + } + if (!urlApi.revokeObjectURL) { + urlApi.revokeObjectURL = () => undefined; + } + spyOn(urlApi, 'createObjectURL').and.returnValue('blob:mock'); + spyOn(urlApi, 'revokeObjectURL').and.callThrough(); + + const realCreateElement = document.createElement.bind(document); + lastAnchor = null; + spyOn(document, 'createElement').and.callFake((tagName: string) => { + const element = realCreateElement(tagName); + if (tagName === 'a') { + lastAnchor = element as HTMLAnchorElement; + spyOn(lastAnchor, 'click'); + } + return element; + }); + } + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CodebookExportComponent, + NoopAnimationsModule, + TranslateModule.forRoot({ + loader: { provide: TranslateLoader, useClass: TranslateFakeLoader } + }) + ] + }).compileComponents(); + + fixture = TestBed.createComponent(CodebookExportComponent); + component = fixture.componentInstance; + setupDownloadSpies(); + }); + + it('emits export config when no provider is set', () => { + component.availableUnits = [{ unitId: 1, unitName: 'Unit 1.vocs', unitAlias: null }]; + component.unitList = [1]; + const emitSpy = spyOn(component.export, 'emit'); + + fixture.detectChanges(); + + component.exportCodingBook(); + + expect(emitSpy).toHaveBeenCalled(); + const config = emitSpy.calls.mostRecent().args[0] as CodebookExportConfig | undefined; + expect(config).toBeTruthy(); + if (!config) { + throw new Error('Expected export config to be emitted.'); + } + expect(config.selectedUnits).toEqual([1]); + expect(config.missingsProfileId).toBe(0); + expect(config.contentOptions.exportFormat).toBe('docx'); + + component.unitList.push(2); + component.contentOptions.exportFormat = 'json'; + + expect(config.selectedUnits).toEqual([1]); + expect(config.contentOptions.exportFormat).toBe('docx'); + }); + + it('initializes inputs from dialog data', () => { + const units: UnitSelectionItem[] = [{ unitId: 3, unitName: 'Unit 3.vocs', unitAlias: null }]; + const profiles: MissingsProfile[] = [{ id: 2, label: 'Custom' }]; + const dialogComponent = new CodebookExportComponent(undefined, { + availableUnits: units, + missingsProfiles: profiles, + workspaceChanges: true, + defaultContentOptions: { exportFormat: 'json' } + }); + + dialogComponent.ngOnInit(); + + expect(dialogComponent.availableUnits).toBe(units); + expect(dialogComponent.missingsProfiles).toEqual([{ id: 0, label: 'None' }, ...profiles]); + expect(dialogComponent.selectedMissingsProfile).toBe(0); + expect(dialogComponent.workspaceChanges).toBeTrue(); + expect(dialogComponent.contentOptions.exportFormat).toBe('json'); + + dialogComponent.ngOnDestroy(); + }); + + it('runs direct export via provider and downloads the file', () => { + const blob = new Blob(['hello'], { type: 'text/plain' }); + const provider: CodebookExportProvider = { + startExport: jasmine.createSpy('startExport').and.returnValue(of({ + type: 'direct', + blob, + fileName: 'codebook.txt' + } as CodebookExportExecution)) + }; + + component.provider = provider; + component.unitList = [1]; + fixture.detectChanges(); + + component.exportCodingBook(); + + expect(provider.startExport).toHaveBeenCalled(); + expect(component.codebookJobStatus).toBe('completed'); + expect(lastAnchor?.download).toBe('codebook.txt'); + expect((lastAnchor as HTMLAnchorElement).click).toHaveBeenCalled(); + }); + + it('polls job status and downloads when completed', fakeAsync(() => { + const blob = new Blob(['job'], { type: 'application/octet-stream' }); + const provider: CodebookExportProvider = { + startExport: jasmine.createSpy('startExport').and.returnValue(of({ + type: 'job', + jobId: 'job-1' + } as CodebookExportExecution)), + getJobStatus: jasmine.createSpy('getJobStatus').and.returnValue(of({ + status: 'completed', + progress: 100, + fileName: 'job.docx' + } as CodebookExportJobStatus)), + download: jasmine.createSpy('download').and.returnValue(of(blob)) + }; + + component.provider = provider; + component.unitList = [1]; + fixture.detectChanges(); + + component.exportCodingBook(); + expect(component.codebookJobStatus).toBe('pending'); + + tick(1500); + + expect(provider.getJobStatus).toHaveBeenCalledWith('job-1'); + expect(provider.download).toHaveBeenCalledWith('job-1'); + expect(component.codebookJobStatus).toBe('completed'); + })); + + it('loads units and missings profiles from provider when none are provided', fakeAsync(() => { + const units: UnitSelectionItem[] = [{ unitId: 7, unitName: 'Unit 7.vocs', unitAlias: null }]; + const missings: MissingsProfile[] = [{ id: 1, label: 'Standard' }]; + const provider: CodebookExportProvider = { + loadUnits: jasmine.createSpy('loadUnits').and.returnValue(of(units)), + loadMissingsProfiles: jasmine.createSpy('loadMissingsProfiles').and.returnValue(of(missings)), + startExport: jasmine.createSpy('startExport').and.returnValue(of({ + type: 'direct', + blob: new Blob(['x']) + } as CodebookExportExecution)) + }; + + component.provider = provider; + fixture.detectChanges(); + tick(); + + expect(provider.loadUnits).toHaveBeenCalled(); + expect(provider.loadMissingsProfiles).toHaveBeenCalled(); + expect(component.availableUnits.length).toBe(1); + expect(component.missingsProfiles.some(profile => profile.id === 0)).toBeTrue(); + })); + + it('clears the filter without requiring an input event', () => { + component.availableUnits = [ + { unitId: 1, unitName: 'Alpha.vocs', unitAlias: null }, + { unitId: 2, unitName: 'Beta.vocs', unitAlias: null } + ]; + fixture.detectChanges(); + + component.filterValue = 'alpha'; + component.dataSource.filter = 'alpha'; + + component.clearFilter(); + + expect(component.filterValue).toBe(''); + expect(component.dataSource.filter).toBe(''); + }); +}); diff --git a/projects/ngx-coding-components/src/lib/translations/de.json b/projects/ngx-coding-components/src/lib/translations/de.json index 55736fb..fb19f43 100644 --- a/projects/ngx-coding-components/src/lib/translations/de.json +++ b/projects/ngx-coding-components/src/lib/translations/de.json @@ -1,6 +1,12 @@ { "close": "Schließen", + "export": "Exportieren", "filter-by": "Filtern nach...", + "search": "Suchen", + "search-units": "Einheiten suchen", + "loading-units": "Einheiten werden geladen...", + "no-units-matching": "Keine passenden Einheiten für", + "no-units-available": "Keine Einheiten verfügbar", "copied-to-clipboard": "In Zwischenablage kopiert", "varList": { "base": "Basisvariablen", @@ -34,6 +40,13 @@ } }, + "workspace": { + "export-coding-book": "Kodierhandbuch exportieren", + "coding-missing-profiles": "Missing-Profile", + "select-missings-profile": "Missing-Profil auswählen", + "no-missings-profile": "Keines" + }, + "schemer": { "info": { "title": "Schemer – Informationen", @@ -140,6 +153,32 @@ "rule": "Regel", "vars-with-codes-only": "Nur Variablen mit Codes", "raw-responses": "Rohantworten anzeigen", + "select-units": "Einheiten auswählen", + "select-all-units": "Alle Einheiten auswählen", + "unit-name": "Einheit", + "codebook-content": "Inhalt des Kodierhandbuchs", + "has-only-vars-with-codes": "Nur Variablen mit Codes", + "has-general-instructions": "Allgemeine Instruktionen", + "hide-item-var-relation": "Item-Variablen-Beziehung ausblenden", + "has-derived-vars": "Abgeleitete Variablen", + "has-only-manual-coding": "Nur manuelle Kodierung", + "has-closed-vars": "Geschlossene Variablen", + "show-score": "Bewertung anzeigen", + "code-label-to-upper": "Code-Labels in Großbuchstaben", + "export-format": "Exportformat", + "error-save-changes": "Bitte speichern Sie Ihre Änderungen vor dem Export.", + "codebook-generating": "Kodierhandbuch wird erzeugt", + "codebook-completed": "Kodierhandbuch wurde erzeugt", + "tooltip": { + "has-only-vars-with-codes": "Variablen ohne Codes werden nicht exportiert.", + "has-general-instructions": "Allgemeine Kodierinstruktionen werden exportiert.", + "hide-item-var-relation": "Die Zuordnung zwischen Items und Variablen wird ausgeblendet.", + "has-derived-vars": "Abgeleitete Variablen werden exportiert.", + "has-only-manual-coding": "Nur Codes mit manueller Kodierinstruktion werden exportiert.", + "has-closed-vars": "Geschlossene automatisch kodierte Variablen werden exportiert.", + "show-score": "Bewertungen der Codes werden exportiert.", + "code-label-to-upper": "Code-Labels werden in Großbuchstaben ausgegeben." + }, "instruction": "Instruktion", "variable": "Variable", "result": "Ergebnis", From 8c55ac2dd081d28da042a991e2ddb8016e808675 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:39:42 +0200 Subject: [PATCH 3/8] Add codebook generator entry point --- package-lock.json | 388 +++++++++- package.json | 3 + .../codebook-generator/ng-package.json | 6 + .../codebook-docx-generator.class.ts | 670 ++++++++++++++++++ .../codebook-generator.class.ts | 187 +++++ .../codebook-generator/src/public-api.ts | 11 + projects/ngx-coding-components/package.json | 10 + .../codebook-generator.class.spec.ts | 176 +++++ 8 files changed, 1450 insertions(+), 1 deletion(-) create mode 100644 projects/ngx-coding-components/codebook-generator/ng-package.json create mode 100644 projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-docx-generator.class.ts create mode 100644 projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts create mode 100644 projects/ngx-coding-components/codebook-generator/src/public-api.ts create mode 100644 projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts diff --git a/package-lock.json b/package-lock.json index 99ad67f..c203830 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,8 +41,11 @@ "@iqb/eslint-config": "^2.2.0", "@types/jasmine": "~4.3.0", "@types/node": "^20.4.4", + "cheerio": "^1.0.0", + "docx": "^8.5.0", "eslint": "^8.57.0", "iqb-dev-components": "^1.4.1", + "jszip": "^3.10.1", "jasmine-core": "~4.6.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", @@ -7815,6 +7818,106 @@ "dev": true, "license": "MIT" }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio-select/node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio-select/node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cheerio/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/cheerio/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/chokidar": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", @@ -8746,6 +8849,42 @@ "node": ">=6.0.0" } }, + "node_modules/docx": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/docx/-/docx-8.5.0.tgz", + "integrity": "sha512-4SbcbedPXTciySXiSnNNLuJXpvxFe5nqivbiEHXyL8P/w0wx2uW7YXNjnYgjW0e2e6vy+L/tMISU/oAiXCl57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^20.3.1", + "jszip": "^3.10.1", + "nanoid": "^5.0.4", + "xml": "^1.0.1", + "xml-js": "^1.6.8" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/docx/node_modules/nanoid": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.1.11.tgz", + "integrity": "sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^18 || >=20" + } + }, "node_modules/dom-serialize": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/dom-serialize/-/dom-serialize-2.2.1.tgz", @@ -8864,6 +9003,33 @@ "node": ">= 0.8" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/engine.io": { "version": "6.6.5", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.5.tgz", @@ -10865,6 +11031,13 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, "node_modules/immutable": { "version": "5.1.5", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", @@ -11825,6 +11998,49 @@ ], "license": "MIT" }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dev": true, + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/karma": { "version": "6.4.4", "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", @@ -12475,6 +12691,16 @@ } } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -14229,6 +14455,13 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "dev": true, + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -14312,6 +14545,85 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parse5-parser-stream/node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parse5-sax-parser": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz", @@ -15597,8 +15909,8 @@ "version": "1.5.0", "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", + "devOptional": true, "license": "BlueOak-1.0.0", - "optional": true, "engines": { "node": ">=11.0.0" } @@ -15898,6 +16210,13 @@ "node": ">= 0.4" } }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true, + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -17131,6 +17450,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -18123,6 +18452,43 @@ "node": ">=0.8.0" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-encoding/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -18334,6 +18700,26 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/xml": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz", + "integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/xml-js": { + "version": "1.6.11", + "resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index b9f689b..261a451 100644 --- a/package.json +++ b/package.json @@ -94,8 +94,11 @@ "@iqb/eslint-config": "^2.2.0", "@types/jasmine": "~4.3.0", "@types/node": "^20.4.4", + "cheerio": "^1.0.0", + "docx": "^8.5.0", "eslint": "^8.57.0", "iqb-dev-components": "^1.4.1", + "jszip": "^3.10.1", "jasmine-core": "~4.6.0", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", diff --git a/projects/ngx-coding-components/codebook-generator/ng-package.json b/projects/ngx-coding-components/codebook-generator/ng-package.json new file mode 100644 index 0000000..d0a2dcd --- /dev/null +++ b/projects/ngx-coding-components/codebook-generator/ng-package.json @@ -0,0 +1,6 @@ +{ + "$schema": "../../../node_modules/ng-packagr/ng-package.schema.json", + "lib": { + "entryFile": "src/public-api.ts" + } +} diff --git a/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-docx-generator.class.ts b/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-docx-generator.class.ts new file mode 100644 index 0000000..04ec014 --- /dev/null +++ b/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-docx-generator.class.ts @@ -0,0 +1,670 @@ +import { + AlignmentType, + Document, + HeadingLevel, + Packer, + Paragraph, + Table, + TableCell, + TableRow, + TextRun, + Footer, + WidthType, + PageNumber, + ITableCellBorders, + Header, + LevelFormat +} from 'docx'; +import * as cheerio from 'cheerio'; +import type { AnyNode, Element } from 'domhandler'; +import type { + BookVariable, CodeBookContentSetting, CodebookUnitDto, ItemMetadata +} from '@iqb/ngx-coding-components/codebook-models'; + +/** + * Class for generating DOCX files for codebooks + */ +export class CodebookDocxGenerator { + /** + * Generate a DOCX file for a codebook + * @param codingBookUnits List of codebook units + * @param contentSetting Codebook content settings + * @returns Blob with DOCX file + */ + static async generateDocx( + codingBookUnits: CodebookUnitDto[], + contentSetting: CodeBookContentSetting + ): Promise { + if (codingBookUnits.length) { + const units: (Paragraph | Table)[] = []; + let missings: Paragraph[] = []; + codingBookUnits.forEach(variableCoding => { + missings = this.getMissings(variableCoding); + if (variableCoding.variables.length || !contentSetting.hasOnlyVarsWithCodes) { + units.push(...(this.createDocXForUnit( + variableCoding.items || [], + variableCoding.variables, + contentSetting, + this.getUnitHeader(variableCoding) + ) as (Paragraph | Table)[])); + } + }); + return Packer.toBlob( + this.setDocXDocument( + units, + missings) + ); + } + return new Blob([], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); + } + + /** + * Get unit header + * @param variableCoding Codebook unit + * @returns Paragraph with unit header + */ + private static getUnitHeader(variableCoding: CodebookUnitDto): Paragraph { + return new Paragraph({ + border: { + bottom: { + color: '#000000', + style: 'single', + size: 10 + }, + top: { + color: '#000000', + style: 'single', + size: 10 + } + }, + spacing: { + before: 400, + after: 200 + }, + text: variableCoding.name, + heading: HeadingLevel.HEADING_1, + alignment: AlignmentType.CENTER + }); + } + + /** + * Get missings paragraphs + * @param variableCoding Codebook unit + * @returns List of paragraphs with missings + */ + private static getMissings(variableCoding: CodebookUnitDto): Paragraph[] { + const missings: Paragraph[] = []; + try { + variableCoding.missings.forEach(missing => { + if (missing.code && missing.label && missing.description) { + missings.push(new Paragraph({ + children: [new TextRun({ text: `${missing.code} ${missing.label}`, bold: true })], + spacing: { + after: 20 + } + })); + missings.push(new Paragraph({ + text: `${missing.description}`, + spacing: { + after: 100 + } + })); + } else { + missings.push(new Paragraph({ + text: 'kein valides Missing ', + spacing: { + after: 200 + } + })); + } + }); + } catch { + missings.push(new Paragraph({ + text: 'kein validen Missings gefunden', + spacing: { + after: 200 + } + })); + } + return missings; + } + + /** + * Get table borders + * @returns Table cell borders + */ + private static get TableBoarders(): ITableCellBorders { + return { + top: { + size: 1, + color: '#000000', + style: 'single' + }, + bottom: { + size: 1, + color: '#000000', + style: 'single' + }, + left: { + size: 1, + color: '#000000', + style: 'single' + }, + right: { + size: 1, + color: '#000000', + style: 'single' + } + }; + } + + /** + * Get code rows for a table + * @param variable Book variable + * @param contentSetting Codebook content settings + * @returns List of table rows + */ + private static getCodeRows(variable: BookVariable, contentSetting: CodeBookContentSetting): TableRow[] { + const rows: TableRow[] = []; + const headerRow = new TableRow({ + tableHeader: true, + children: [ + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[0], + type: WidthType.DXA + }, + children: [new Paragraph({ + children: [ + new TextRun({ + text: 'Code', + bold: true + }) + ] + })] + }), + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[1], + type: WidthType.DXA + }, + children: [new Paragraph({ + children: [ + new TextRun({ + text: 'Label', + bold: true + }) + ] + })] + }), + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[2], + type: WidthType.DXA + }, + children: [new Paragraph({ + children: [ + new TextRun({ + text: 'Beschreibung', + bold: true + }) + ] + })] + }) + ] + }); + rows.push(headerRow); + if (contentSetting.showScore) { + headerRow.addChildElement( + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[3], + type: WidthType.DXA + }, + children: [new Paragraph({ + children: [ + new TextRun({ + text: 'Score', + bold: true + }) + ] + })] + }) + ); + } + variable.codes.forEach(code => { + const row = new TableRow({ + children: [ + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[0], + type: WidthType.DXA + }, + children: [new Paragraph(code.id)] + }), + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[1], + type: WidthType.DXA + }, + children: [new Paragraph(code.label)] + }), + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[2], + type: WidthType.DXA + }, + children: this.htmlToDocx(code.description, contentSetting) + }) + ] + }); + if (contentSetting.showScore) { + row.addChildElement( + new TableCell({ + borders: this.TableBoarders, + width: { + size: this.getColumnWidths(contentSetting)[3], + type: WidthType.DXA + }, + children: [new Paragraph(code.score || '')] + }) + ); + } + rows.push(row); + }); + return rows; + } + + /** + * Get column widths for a table + * @param contentSetting Codebook content settings + * @returns List of column widths + */ + private static getColumnWidths(contentSetting: CodeBookContentSetting): number[] { + return contentSetting.showScore ? [1000, 2000, 5000, 1000] : [1000, 2000, 6000]; + } + + /** + * Get variables for a unit + * @param codeBookVariable List of book variables + * @param contentSetting Codebook content settings + * @param varItems List of item metadata + * @returns List of file children + */ + private static getVariables( + codeBookVariable: BookVariable[], + contentSetting: CodeBookContentSetting, + varItems: ItemMetadata[] + ): (Paragraph | Table)[] { + const children: (Paragraph | Table)[] = []; + codeBookVariable.forEach(variable => { + children.push(this.getVariableHeader(variable)); + if (!contentSetting.hideItemVarRelation) { + children.push(...this.getVariableItems(variable, varItems)); + } + if (variable.generalInstruction) { + children.push(...this.getGeneralInstruction(contentSetting, variable)); + } + if (variable.codes.length) { + children.push(this.getCodeTable(variable, contentSetting)); + } + }); + return children; + } + + /** + * Get variable header + * @param variable Book variable + * @returns Paragraph with variable header + */ + private static getVariableHeader(variable: BookVariable): Paragraph { + return new Paragraph({ + text: variable.label, + heading: HeadingLevel.HEADING_2, + spacing: { + before: 400, + after: 200 + } + }); + } + + /** + * Get variable items + * @param variable Book variable + * @param varItems List of item metadata + * @returns List of paragraphs with variable items + */ + private static getVariableItems(variable: BookVariable, varItems: ItemMetadata[]): Paragraph[] { + const paragraphs: Paragraph[] = []; + const items = varItems.filter(item => { + const variableId = variable.id.replace(/\./g, '_'); + return item[variableId] !== undefined; + }); + if (items.length) { + paragraphs.push(new Paragraph({ + text: 'Items:', + spacing: { + after: 100 + } + })); + items.forEach(item => { + paragraphs.push(new Paragraph({ + text: `${item['key']} ${item['label']}`, + bullet: { + level: 0 + } + })); + }); + } + return paragraphs; + } + + /** + * Get general instruction + * @param contentSetting Codebook content settings + * @param codeBookVariable Book variable + * @returns List of paragraphs with general instruction + */ + private static getGeneralInstruction( + contentSetting: CodeBookContentSetting, + codeBookVariable: BookVariable + ): Paragraph[] { + return codeBookVariable.generalInstruction ? + this.htmlToDocx(codeBookVariable.generalInstruction, contentSetting) : []; + } + + /** + * Get code table + * @param codeBookVariable Book variable + * @param contentSetting Codebook content settings + * @returns Table with codes + */ + private static getCodeTable(codeBookVariable: BookVariable, contentSetting: CodeBookContentSetting): Table { + return new Table({ + rows: this.getCodeRows(codeBookVariable, contentSetting), + width: { + size: 9000, + type: WidthType.DXA + } + }); + } + + /** + * Create DOCX for a unit + * @param items List of item metadata + * @param codeBookVariable List of book variables + * @param contentSetting Codebook content settings + * @param unitHeader Paragraph with unit header + * @returns List of file children + */ + private static createDocXForUnit( + items: ItemMetadata[], + codeBookVariable: BookVariable[], + contentSetting: CodeBookContentSetting, + unitHeader: Paragraph + ): (Paragraph | Table)[] { + return [ + unitHeader, + ...this.getVariables(codeBookVariable, contentSetting, items) + ]; + } + + /** + * Set DOCX document + * @param children List of file children + * @param missings List of paragraphs with missings + * @returns Document + */ + private static setDocXDocument(children: (Paragraph | Table)[], missings: Paragraph[]): Document { + return new Document({ + creator: 'IQB-Kodierbox', + title: 'Codebook', + description: 'Codebook', + styles: { + paragraphStyles: [ + { + id: 'Normal', + name: 'Normal', + basedOn: 'Normal', + next: 'Normal', + quickFormat: true, + run: { + size: 24, + font: 'Calibri' + }, + paragraph: { + spacing: { + after: 120 + } + } + } + ] + }, + numbering: { + config: [ + { + reference: 'codebook-numbering', + levels: [ + { + level: 0, + format: LevelFormat.DECIMAL, + text: '%1.', + alignment: AlignmentType.START, + style: { + paragraph: { + indent: { + left: 720, + hanging: 360 + } + } + } + } + ] + } + ] + }, + sections: [ + { + properties: { + page: { + margin: { + top: 1000, + right: 1000, + bottom: 1000, + left: 1000 + } + } + }, + headers: { + default: new Header({ + children: [ + new Paragraph({ + alignment: AlignmentType.RIGHT, + children: [ + new TextRun('IQB-Kodierbox Codebook '), + new TextRun({ + children: [PageNumber.CURRENT], + font: 'Calibri' + }), + new TextRun({ + children: [' / '], + font: 'Calibri' + }), + new TextRun({ + children: [PageNumber.TOTAL_PAGES], + font: 'Calibri' + }) + ] + }) + ] + }) + }, + + footers: { + default: new Footer({ + children: [ + new Paragraph({ + alignment: AlignmentType.CENTER, + children: [ + new TextRun({ + text: new Date().toLocaleDateString(), + font: 'Calibri' + }) + ] + }) + ] + }) + }, + children: [ + ...(missings.length > 0 ? [ + new Paragraph({ + text: 'Missings', + heading: HeadingLevel.HEADING_1, + spacing: { + after: 200 + } + }), + ...missings + ] : []), + ...children + ] + } + ] + }); + } + + /** + * Convert HTML to DOCX + * @param html HTML string + * @param contentSetting Codebook content settings + * @returns List of paragraphs + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + private static htmlToDocx(html: string, contentSetting: CodeBookContentSetting): Paragraph[] { + const paragraphs: Paragraph[] = []; + if (!html) return paragraphs; + + try { + const $ = cheerio.load(`
${html}
`); + const rootElement = $('div')[0]; + + if (rootElement && rootElement.children) { + this.processChildNodes(rootElement.children, paragraphs); + } + } catch (error) { + paragraphs.push(new Paragraph({ text: html })); + } + + return paragraphs; + } + + /** + * Process child nodes + * @param nodes List of nodes + * @param paragraphs List of paragraphs + */ + private static processChildNodes(nodes: AnyNode[], paragraphs: Paragraph[]): void { + nodes.forEach(node => { + if (node.type === 'text') { + if ('data' in node && node.data && node.data.trim()) { + paragraphs.push(new Paragraph({ text: this.normalizeText(node.data) })); + } + } else if (node.type === 'tag') { + const element = node as Element; + const tagName = element.name.toLowerCase(); + + if (tagName === 'p') { + const textRuns: TextRun[] = []; + this.processInlineElements(element.children, textRuns); + if (textRuns.length > 0) { + paragraphs.push(new Paragraph({ children: textRuns })); + } + } else if (tagName === 'ul' || tagName === 'ol') { + this.processListElements(element.children, paragraphs, tagName === 'ol'); + } else if (element.children && element.children.length > 0) { + this.processChildNodes(element.children, paragraphs); + } + } + }); + } + + /** + * Process inline elements + * @param nodes List of nodes + * @param textRuns List of text runs + */ + private static processInlineElements(nodes: AnyNode[], textRuns: TextRun[]): void { + nodes.forEach(node => { + if (node.type === 'text') { + if ('data' in node && node.data && node.data.trim()) { + textRuns.push(new TextRun({ text: this.normalizeText(node.data) })); + } + } else if (node.type === 'tag') { + const element = node as Element; + const tagName = element.name.toLowerCase(); + + if (tagName === 'strong' || tagName === 'b') { + if (element.children) { + element.children.forEach(child => { + if (child.type === 'text' && child.data) { + textRuns.push(new TextRun({ text: this.normalizeText(child.data), bold: true })); + } + }); + } + } else if (tagName === 'em' || tagName === 'i') { + if (element.children) { + element.children.forEach(child => { + if (child.type === 'text' && child.data) { + textRuns.push(new TextRun({ text: this.normalizeText(child.data), italics: true })); + } + }); + } + } else if (element.children && element.children.length > 0) { + this.processInlineElements(element.children, textRuns); + } + } + }); + } + + /** + * Process list elements + * @param nodes List of nodes + * @param paragraphs List of paragraphs + * @param isOrdered Whether the list is ordered + */ + private static processListElements(nodes: AnyNode[], paragraphs: Paragraph[], isOrdered: boolean): void { + nodes.forEach(node => { + if (node.type === 'tag') { + const element = node as Element; + if (element.name.toLowerCase() === 'li') { + const textRuns: TextRun[] = []; + this.processInlineElements(element.children, textRuns); + if (textRuns.length > 0) { + const numbering = isOrdered ? { + reference: 'codebook-numbering', + level: 0 + } : undefined; + paragraphs.push(new Paragraph({ + children: textRuns, + bullet: isOrdered ? undefined : { + level: 0 + }, + numbering + })); + } + } + } + }); + } + + private static normalizeText(text: string): string { + return text.replace(/\s+/g, ' '); + } +} diff --git a/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts b/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts new file mode 100644 index 0000000..e215972 --- /dev/null +++ b/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts @@ -0,0 +1,187 @@ +import { + ToTextFactory, CodeAsText +} from '@iqb/responses'; +import { VariableCodingData, CodeData, CodingScheme } from '@iqbspecs/coding-scheme'; + +import type { + BookVariable, + CodeBookContentSetting, + CodebookUnitDto, + CodeInfo, + Missing, + UnitPropertiesForCodebook +} from '@iqb/ngx-coding-components/codebook-models'; +import { CodebookDocxGenerator } from './codebook-docx-generator.class'; + +/** + * Class for generating codebooks + */ +export class CodebookGenerator { + static generateCodebook( + units: UnitPropertiesForCodebook[], + contentSetting: CodeBookContentSetting, + missings: Missing[] + ): Promise { + if (units.length === 0) { + return Promise.resolve(new Blob(['[]'], { type: 'application/json' })); + } + const codebook: CodebookUnitDto[] = units.map( + (unit: UnitPropertiesForCodebook) => this.getCodeBookDataForUnit(unit, contentSetting, missings) + ); + + if (contentSetting.exportFormat === 'docx') { + return CodebookDocxGenerator.generateDocx(codebook, contentSetting); + } + + const noItemsCodebook = codebook.map((unit: CodebookUnitDto) => ({ + key: unit.key, + name: unit.name, + variables: unit.variables, + missings: unit.missings + })); + const data = JSON.stringify(noItemsCodebook); + return Promise.resolve(new Blob([data], { type: 'application/json' })); + } + + private static getCodeBookDataForUnit( + unit: UnitPropertiesForCodebook, + contentSetting: CodeBookContentSetting, + missings: Missing[] + ): CodebookUnitDto { + const parsedScheme = unit.scheme ? new CodingScheme(unit.scheme) : null; + const variableCodings = parsedScheme?.variableCodings || []; + const bookVariables = this.getBookVariables(variableCodings, contentSetting); + return { + key: unit.key, + name: unit.name, + variables: this.getSortedBookVariables(bookVariables.filter(v => v.sourceType !== 'BASE_NO_VALUE')), + missings: missings, + items: unit.metadata?.items + }; + } + + private static getBookVariables( + variableCodings: VariableCodingData[], + contentSetting: CodeBookContentSetting + ): BookVariable[] { + return variableCodings.reduce((bookVariables: BookVariable[], variableCoding) => { + const bookVariable = this.getBaseOrDerivedBookVariable(variableCoding, contentSetting); + if (bookVariable) bookVariables.push(bookVariable); + return bookVariables; + }, []); + } + + private static getSortedBookVariables(bookVariables: BookVariable[]): BookVariable[] { + return bookVariables.sort((a, b) => { + if (a.id < b.id) return -1; + if (a.id > b.id) return 1; + return 0; + }); + } + + private static getBaseOrDerivedBookVariable( + variableCoding: VariableCodingData, + contentSetting: CodeBookContentSetting + ): BookVariable | null { + const rawCodes = variableCoding.codes ?? []; + const codes: CodeInfo[] = this.getCodes(rawCodes, contentSetting); + const isDerived: boolean = ( + variableCoding.sourceType !== 'BASE' && variableCoding.sourceType !== 'BASE_NO_VALUE' + ); + if (!isDerived || contentSetting.hasDerivedVars) { + return this.getManualOrClosedCodedBookVariable(contentSetting, codes, variableCoding); + } + return null; + } + + private static getManualOrClosedCodedBookVariable( + contentSetting: CodeBookContentSetting, + codes: CodeInfo[], + variableCoding: VariableCodingData + ): BookVariable | null { + if ( + codes.length === 0 && + ( + contentSetting.hasOnlyVarsWithCodes || + contentSetting.hasOnlyManualCoding || + !contentSetting.hasClosedVars + ) + ) { + return null; + } + return { + id: variableCoding.alias || variableCoding.id, + label: variableCoding.label ?? '', + sourceType: variableCoding.sourceType, + generalInstruction: contentSetting.hasGeneralInstructions ? + (variableCoding.manualInstruction ?? '') : + '', + codes: codes + }; + } + + private static isClosedCode(codeData: CodeData): boolean { + return codeData.type === 'RESIDUAL_AUTO' || codeData.type === 'INTENDED_INCOMPLETE'; + } + + private static shouldIncludeCode(codeData: CodeData, contentSetting: CodeBookContentSetting): boolean { + if (!contentSetting.hasClosedVars && this.isClosedCode(codeData)) { + return false; + } + if (contentSetting.hasOnlyManualCoding && !codeData.manualInstruction) { + return false; + } + return true; + } + + private static getCodes(codes: CodeData[], contentSetting: CodeBookContentSetting): CodeInfo[] { + return codes.reduce((codeInfos: CodeInfo[], code) => { + if (code.id !== undefined && code.id !== null && this.shouldIncludeCode(code, contentSetting)) { + try { + const codeInfo = this.getCodeInfoFromCodeAsText(code, contentSetting); + codeInfos.push(codeInfo); + } catch (error) { + const codeInfo = this.getCodeInfo(code, contentSetting); + codeInfos.push(codeInfo); + } + } + return codeInfos; + }, []); + } + + private static getCodeInfo(code: CodeData, contentSetting: CodeBookContentSetting): CodeInfo { + const codeInfo: CodeInfo = { + id: `${code.id}`, + label: '', + description: + '

Kodierschema mit Schemer Version ab 1.5 erzeugen!

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

${ruleSetDescription}

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

${ruleSetDescription}

`; + } + ); + return rulesDescription; + } +} diff --git a/projects/ngx-coding-components/codebook-generator/src/public-api.ts b/projects/ngx-coding-components/codebook-generator/src/public-api.ts new file mode 100644 index 0000000..37b2655 --- /dev/null +++ b/projects/ngx-coding-components/codebook-generator/src/public-api.ts @@ -0,0 +1,11 @@ +export * from './lib/codebook-generator/codebook-generator.class'; +export * from './lib/codebook-generator/codebook-docx-generator.class'; +export type { + BookVariable, + CodeBookContentSetting, + CodebookUnitDto, + CodeInfo, + ItemMetadata, + Missing, + UnitPropertiesForCodebook +} from '@iqb/ngx-coding-components/codebook-models'; diff --git a/projects/ngx-coding-components/package.json b/projects/ngx-coding-components/package.json index d9b47a1..9396ed9 100644 --- a/projects/ngx-coding-components/package.json +++ b/projects/ngx-coding-components/package.json @@ -62,8 +62,18 @@ "ngx-build-plus": "^20.0.0", "prosemirror-state": "^1.3.4", "rxjs": "~7.8.0", + "docx": "^8.5.0", + "cheerio": "^1.0.0", "zone.js": "~0.15.0" }, + "peerDependenciesMeta": { + "docx": { + "optional": true + }, + "cheerio": { + "optional": true + } + }, "devDependencies": { "@angular-devkit/build-angular": "^20.0.4", "@angular/compiler-cli": "^20.0.5", diff --git a/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts b/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts new file mode 100644 index 0000000..7297ba9 --- /dev/null +++ b/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts @@ -0,0 +1,176 @@ +import JSZip from 'jszip'; + +import { + CodebookGenerator +} from '../../../codebook-generator/src/lib/codebook-generator/codebook-generator.class'; +import { + CodeBookContentSetting +} from '../../../codebook-models/src/lib/codebook.interfaces'; + +describe('CodebookGenerator', () => { + const contentSetting: CodeBookContentSetting = { + exportFormat: 'json', + missingsProfile: '', + hasOnlyManualCoding: false, + hasGeneralInstructions: true, + hasDerivedVars: true, + hasOnlyVarsWithCodes: true, + hasClosedVars: true, + codeLabelToUpper: false, + showScore: true, + hideItemVarRelation: true + }; + + it('keeps numeric code id 0 in JSON exports', async () => { + const scheme = JSON.stringify({ + version: '1.5', + variableCodings: [ + { + id: 'VAR1', + sourceType: 'BASE', + label: 'Variable 1', + codes: [ + { + id: 0, + type: 'RESIDUAL_AUTO', + label: 'Falsch', + score: 0, + ruleSetOperatorAnd: true, + ruleSets: [], + manualInstruction: '' + } + ] + } + ] + }); + + const blob = await CodebookGenerator.generateCodebook( + [ + { + id: 1, + key: 'UNIT1', + name: 'Unit 1', + scheme + } + ], + contentSetting, + [] + ); + const codebook = JSON.parse(await blob.text()); + + expect(blob).toEqual(jasmine.any(Blob)); + expect(codebook[0].variables[0].codes[0].id).toBe('0'); + }); + + it('returns a JSON Blob for empty exports', async () => { + const blob = await CodebookGenerator.generateCodebook([], contentSetting, []); + + expect(blob).toEqual(jasmine.any(Blob)); + expect(blob.type).toBe('application/json'); + expect(await blob.text()).toBe('[]'); + }); + + it('filters closed codes without dropping variables that still have included codes', async () => { + const scheme = JSON.stringify({ + version: '1.5', + variableCodings: [ + { + id: 'VAR1', + sourceType: 'BASE', + label: 'Variable 1', + codes: [ + { + id: 1, + type: 'FULL_CREDIT', + label: 'Manuell', + score: 1, + ruleSetOperatorAnd: true, + ruleSets: [], + manualInstruction: '

Manual instruction

' + }, + { + id: 0, + type: 'RESIDUAL_AUTO', + label: 'Automatisch', + score: 0, + ruleSetOperatorAnd: true, + ruleSets: [], + manualInstruction: '' + } + ] + } + ] + }); + + const blob = await CodebookGenerator.generateCodebook( + [ + { + id: 1, + key: 'UNIT1', + name: 'Unit 1', + scheme + } + ], + { + ...contentSetting, + hasClosedVars: false + }, + [] + ); + const codebook = JSON.parse(await blob.text()); + + expect(codebook[0].variables.length).toBe(1); + expect(codebook[0].variables[0].codes.map((code: { id: string }) => code.id)).toEqual(['1']); + }); + + it('writes ordered lists as numbering and normalizes HTML whitespace in DOCX exports', async () => { + const scheme = JSON.stringify({ + version: '1.5', + variableCodings: [ + { + id: 'VAR1', + sourceType: 'BASE', + label: 'Variable 1', + codes: [ + { + id: 1, + type: 'FULL_CREDIT', + label: 'Manuell', + score: 1, + ruleSetOperatorAnd: true, + ruleSets: [], + manualInstruction: '
  1. Alpha Beta
  2. Gamma\nDelta
' + } + ] + } + ] + }); + + const blob = await CodebookGenerator.generateCodebook( + [ + { + id: 1, + key: 'UNIT1', + name: 'Unit 1', + scheme + } + ], + { + ...contentSetting, + exportFormat: 'docx' + }, + [] + ); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + const documentXml = await zip.file('word/document.xml')?.async('string'); + const numberingXml = await zip.file('word/numbering.xml')?.async('string'); + + expect(blob).toEqual(jasmine.any(Blob)); + expect(documentXml).toContain(''); + expect(documentXml).not.toContain(''); + expect(documentXml).toContain('Alpha Beta'); + expect(documentXml).toContain('Gamma Delta'); + expect(numberingXml).toContain(''); + }); +}); From fc6714244429e2d57c53c94f82da5d7f2e27d135 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:49:06 +0200 Subject: [PATCH 4/8] Add buffer dependency for codebook generator --- package-lock.json | 70 ++++++++++++++++++- package.json | 3 +- .../src/lib/codebook-export/README.md | 2 +- projects/ngx-coding-components/package.json | 4 ++ 4 files changed, 76 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c203830..272a563 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,12 +41,13 @@ "@iqb/eslint-config": "^2.2.0", "@types/jasmine": "~4.3.0", "@types/node": "^20.4.4", + "buffer": "^6.0.3", "cheerio": "^1.0.0", "docx": "^8.5.0", "eslint": "^8.57.0", "iqb-dev-components": "^1.4.1", - "jszip": "^3.10.1", "jasmine-core": "~4.6.0", + "jszip": "^3.10.1", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", @@ -7431,6 +7432,27 @@ "devOptional": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "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" + }, "node_modules/base64id": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", @@ -7596,6 +7618,31 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "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/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -10956,6 +11003,27 @@ "postcss": "^8.1.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "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": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index 261a451..cb4f935 100644 --- a/package.json +++ b/package.json @@ -94,12 +94,13 @@ "@iqb/eslint-config": "^2.2.0", "@types/jasmine": "~4.3.0", "@types/node": "^20.4.4", + "buffer": "^6.0.3", "cheerio": "^1.0.0", "docx": "^8.5.0", "eslint": "^8.57.0", "iqb-dev-components": "^1.4.1", - "jszip": "^3.10.1", "jasmine-core": "~4.6.0", + "jszip": "^3.10.1", "karma": "~6.4.0", "karma-chrome-launcher": "~3.2.0", "karma-coverage": "~2.2.0", diff --git a/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/README.md b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/README.md index e7b3900..971443a 100644 --- a/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/README.md +++ b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/README.md @@ -17,7 +17,7 @@ The component is available through the `@iqb/ngx-coding-components/codebook-expo If you generate DOCX files in the browser instead of delegating export work to a provider, also install the generator peer dependencies: ```bash -npm install docx cheerio @iqbspecs/coding-scheme @iqbspecs/variable-info +npm install docx cheerio buffer @iqbspecs/coding-scheme @iqbspecs/variable-info ``` ## Usage diff --git a/projects/ngx-coding-components/package.json b/projects/ngx-coding-components/package.json index 9396ed9..003a226 100644 --- a/projects/ngx-coding-components/package.json +++ b/projects/ngx-coding-components/package.json @@ -64,6 +64,7 @@ "rxjs": "~7.8.0", "docx": "^8.5.0", "cheerio": "^1.0.0", + "buffer": "^6.0.3", "zone.js": "~0.15.0" }, "peerDependenciesMeta": { @@ -72,6 +73,9 @@ }, "cheerio": { "optional": true + }, + "buffer": { + "optional": true } }, "devDependencies": { From 2ee07c1098fc872b7cd779f7f3d7c24a0b6d468c Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:02:01 +0200 Subject: [PATCH 5/8] Align codebook export with Studio behavior --- .../codebook-export.component.ts | 6 +- .../codebook-generator.class.ts | 46 +++--- .../src/lib/codebook.interfaces.ts | 2 +- .../codebook-export.component.spec.ts | 20 ++- .../codebook-generator.class.spec.ts | 152 +++++++++++++++++- 5 files changed, 199 insertions(+), 27 deletions(-) diff --git a/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.ts b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.ts index a58210c..b39fc88 100644 --- a/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.ts +++ b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.ts @@ -188,6 +188,7 @@ export class CodebookExportComponent implements OnInit, OnDestroy, OnChanges { get exportDisabled(): boolean { return ( this.unitList.length === 0 || + this.workspaceChanges || this.codebookJobStatus === 'pending' || this.codebookJobStatus === 'processing' ); @@ -374,13 +375,14 @@ export class CodebookExportComponent implements OnInit, OnDestroy, OnChanges { } exportCodingBook(): void { - if (this.unitList.length === 0) { + if (this.exportDisabled) { return; } + const selectedProfile = this.missingsProfiles.find(profile => profile.id === this.selectedMissingsProfile); const contentOptions: CodeBookContentSetting = { ...this.contentOptions, - missingsProfile: this.selectedMissingsProfile.toString() + missingsProfile: selectedProfile && selectedProfile.id !== 0 ? selectedProfile.label : '' }; const config: CodebookExportConfig = { diff --git a/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts b/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts index e215972..2b198f3 100644 --- a/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts +++ b/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts @@ -99,16 +99,26 @@ export class CodebookGenerator { codes: CodeInfo[], variableCoding: VariableCodingData ): BookVariable | null { - if ( - codes.length === 0 && - ( - contentSetting.hasOnlyVarsWithCodes || - contentSetting.hasOnlyManualCoding || - !contentSetting.hasClosedVars - ) - ) { - return null; + const isClosed = this.isClosed(variableCoding); + const isManual = this.isManual(variableCoding); + + const filterManual = contentSetting.hasOnlyManualCoding; + const filterClosed = contentSetting.hasClosedVars; + + let manualMatches = isManual; + const closedMatches = isClosed; + + if (filterManual && !filterClosed) { + manualMatches = isManual && !isClosed; + } + + if (filterManual || filterClosed) { + const matches = (filterManual && manualMatches) || (filterClosed && closedMatches); + if (!matches) return null; + } else if (contentSetting.hasOnlyVarsWithCodes) { + if (!isManual && !isClosed) return null; } + return { id: variableCoding.alias || variableCoding.id, label: variableCoding.label ?? '', @@ -120,23 +130,19 @@ export class CodebookGenerator { }; } - private static isClosedCode(codeData: CodeData): boolean { - return codeData.type === 'RESIDUAL_AUTO' || codeData.type === 'INTENDED_INCOMPLETE'; + private static isClosed(variableCodingData: VariableCodingData): boolean { + return (variableCodingData.codes ?? []).some( + codeData => codeData.type === 'RESIDUAL_AUTO' || codeData.type === 'INTENDED_INCOMPLETE' + ); } - private static shouldIncludeCode(codeData: CodeData, contentSetting: CodeBookContentSetting): boolean { - if (!contentSetting.hasClosedVars && this.isClosedCode(codeData)) { - return false; - } - if (contentSetting.hasOnlyManualCoding && !codeData.manualInstruction) { - return false; - } - return true; + private static isManual(variableCodingData: VariableCodingData): boolean { + return (variableCodingData.codes ?? []).some(codeData => !!codeData.manualInstruction); } private static getCodes(codes: CodeData[], contentSetting: CodeBookContentSetting): CodeInfo[] { return codes.reduce((codeInfos: CodeInfo[], code) => { - if (code.id !== undefined && code.id !== null && this.shouldIncludeCode(code, contentSetting)) { + if (code.id !== undefined && code.id !== null) { try { const codeInfo = this.getCodeInfoFromCodeAsText(code, contentSetting); codeInfos.push(codeInfo); diff --git a/projects/ngx-coding-components/codebook-models/src/lib/codebook.interfaces.ts b/projects/ngx-coding-components/codebook-models/src/lib/codebook.interfaces.ts index 09974dc..8eb8d8d 100644 --- a/projects/ngx-coding-components/codebook-models/src/lib/codebook.interfaces.ts +++ b/projects/ngx-coding-components/codebook-models/src/lib/codebook.interfaces.ts @@ -38,7 +38,7 @@ export interface CodeBookContentSetting { */ export interface Missing { /** Missing code */ - code: string; + code: string | number; /** Missing label */ label: string; /** Missing description */ diff --git a/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.spec.ts b/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.spec.ts index 79a3837..be6bcd4 100644 --- a/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.spec.ts +++ b/projects/ngx-coding-components/src/lib/codebook-export/codebook-export.component.spec.ts @@ -68,6 +68,8 @@ describe('CodebookExportComponent', () => { it('emits export config when no provider is set', () => { component.availableUnits = [{ unitId: 1, unitName: 'Unit 1.vocs', unitAlias: null }]; + component.missingsProfiles = [{ id: 0, label: 'None' }, { id: 2, label: 'Standard' }]; + component.selectedMissingsProfile = 2; component.unitList = [1]; const emitSpy = spyOn(component.export, 'emit'); @@ -82,7 +84,8 @@ describe('CodebookExportComponent', () => { throw new Error('Expected export config to be emitted.'); } expect(config.selectedUnits).toEqual([1]); - expect(config.missingsProfileId).toBe(0); + expect(config.missingsProfileId).toBe(2); + expect(config.contentOptions.missingsProfile).toBe('Standard'); expect(config.contentOptions.exportFormat).toBe('docx'); component.unitList.push(2); @@ -113,6 +116,21 @@ describe('CodebookExportComponent', () => { dialogComponent.ngOnDestroy(); }); + it('disables and blocks export while the workspace has unsaved changes', () => { + component.availableUnits = [{ unitId: 1, unitName: 'Unit 1.vocs', unitAlias: null }]; + component.unitList = [1]; + component.workspaceChanges = true; + const emitSpy = spyOn(component.export, 'emit'); + + fixture.detectChanges(); + + expect(component.exportDisabled).toBeTrue(); + + component.exportCodingBook(); + + expect(emitSpy).not.toHaveBeenCalled(); + }); + it('runs direct export via provider and downloads the file', () => { const blob = new Blob(['hello'], { type: 'text/plain' }); const provider: CodebookExportProvider = { diff --git a/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts b/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts index 7297ba9..1e48fc2 100644 --- a/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts +++ b/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts @@ -70,7 +70,7 @@ describe('CodebookGenerator', () => { expect(await blob.text()).toBe('[]'); }); - it('filters closed codes without dropping variables that still have included codes', async () => { + it('keeps all codes of an included variable to match Studio exports', async () => { const scheme = JSON.stringify({ version: '1.5', variableCodings: [ @@ -120,7 +120,152 @@ describe('CodebookGenerator', () => { const codebook = JSON.parse(await blob.text()); expect(codebook[0].variables.length).toBe(1); - expect(codebook[0].variables[0].codes.map((code: { id: string }) => code.id)).toEqual(['1']); + expect(codebook[0].variables[0].codes.map((code: { id: string }) => code.id)).toEqual(['1', '0']); + }); + + describe('Studio-compatible variable filtering', () => { + const makeCode = (id: number, type: string, manualInstruction: string) => ({ + id, + label: `Code ${id}`, + score: 1, + type, + manualInstruction, + ruleSetOperatorAnd: true, + ruleSets: [] + }); + + const scheme = JSON.stringify({ + variableCodings: [ + { + id: 'v_manual', + sourceType: 'BASE', + label: 'Manual var', + manualInstruction: '', + codes: [makeCode(1, 'INTENDED', 'do it')] + }, + { + id: 'v_manual_but_only_closed', + sourceType: 'BASE', + label: 'Manual but residual auto', + manualInstruction: '', + codes: [makeCode(11, 'RESIDUAL_AUTO', 'do it')] + }, + { + id: 'v_mixed', + sourceType: 'BASE', + label: 'Mixed var', + manualInstruction: '', + codes: [ + makeCode(21, 'INTENDED', 'do it'), + makeCode(22, 'RESIDUAL_AUTO', '') + ] + }, + { + id: 'v_closed', + sourceType: 'BASE', + label: 'Closed var', + manualInstruction: '', + codes: [makeCode(2, 'RESIDUAL_AUTO', '')] + }, + { + id: 'v_uncoded', + sourceType: 'BASE', + label: 'Uncoded var', + manualInstruction: '', + codes: [makeCode(3, 'INTENDED', '')] + } + ] + }); + + const baseSettings: CodeBookContentSetting = { + exportFormat: 'json', + missingsProfile: '', + hasClosedVars: false, + hasOnlyManualCoding: false, + hasDerivedVars: false, + hasGeneralInstructions: false, + codeLabelToUpper: false, + showScore: false, + hideItemVarRelation: false, + hasOnlyVarsWithCodes: false + }; + + const getVarIds = async (settings: CodeBookContentSetting): Promise => { + const blob = await CodebookGenerator.generateCodebook( + [ + { + id: 1, + key: 'UNIT1', + name: 'Unit 1', + scheme + } + ], + settings, + [] + ); + const codebook = JSON.parse(await blob.text()); + return codebook[0].variables.map((variable: { id: string }) => variable.id); + }; + + it('includes all variables when no filter is active', async () => { + const ids = await getVarIds({ ...baseSettings }); + + expect(ids).toEqual([ + 'v_closed', + 'v_manual', + 'v_manual_but_only_closed', + 'v_mixed', + 'v_uncoded' + ]); + }); + + it('uses hasOnlyVarsWithCodes as a manual-or-closed variable filter', async () => { + const ids = await getVarIds({ ...baseSettings, hasOnlyVarsWithCodes: true }); + + expect(ids).toEqual([ + 'v_closed', + 'v_manual', + 'v_manual_but_only_closed', + 'v_mixed' + ]); + }); + + it('excludes variables that are both manual and closed when only manual coding is selected', async () => { + const ids = await getVarIds({ + ...baseSettings, + hasOnlyManualCoding: true + }); + + expect(ids).toEqual(['v_manual']); + }); + + it('includes closed and mixed variables when closed variables are selected', async () => { + const ids = await getVarIds({ + ...baseSettings, + hasClosedVars: true + }); + + expect(ids).toEqual([ + 'v_closed', + 'v_manual_but_only_closed', + 'v_mixed' + ]); + }); + + it('uses OR semantics when manual and closed filters are both selected', async () => { + const ids = await getVarIds({ + ...baseSettings, + hasOnlyManualCoding: true, + hasClosedVars: true + }); + + expect(ids).toEqual([ + 'v_closed', + 'v_manual', + 'v_manual_but_only_closed', + 'v_mixed' + ]); + }); }); it('writes ordered lists as numbering and normalizes HTML whitespace in DOCX exports', async () => { @@ -157,7 +302,8 @@ describe('CodebookGenerator', () => { ], { ...contentSetting, - exportFormat: 'docx' + exportFormat: 'docx', + hasClosedVars: false }, [] ); From 319e208997299e7e00d83e71860cc7d7c4a402c0 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:12:46 +0200 Subject: [PATCH 6/8] Align codebook DOCX output with Studio --- .../codebook-docx-generator.class.ts | 190 +++++++----------- .../codebook-generator.class.spec.ts | 72 ++++++- 2 files changed, 140 insertions(+), 122 deletions(-) diff --git a/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-docx-generator.class.ts b/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-docx-generator.class.ts index 04ec014..e0cb190 100644 --- a/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-docx-generator.class.ts +++ b/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-docx-generator.class.ts @@ -81,7 +81,7 @@ export class CodebookDocxGenerator { before: 400, after: 200 }, - text: variableCoding.name, + text: `${variableCoding.key} ${variableCoding.name}`, heading: HeadingLevel.HEADING_1, alignment: AlignmentType.CENTER }); @@ -166,129 +166,74 @@ export class CodebookDocxGenerator { */ private static getCodeRows(variable: BookVariable, contentSetting: CodeBookContentSetting): TableRow[] { const rows: TableRow[] = []; + const headerCells = [ + this.createHeaderCell('Code', this.getColumnWidths(contentSetting)[0]), + this.createHeaderCell('Label', this.getColumnWidths(contentSetting)[1]), + ...(contentSetting.showScore ? + [this.createHeaderCell('Score', this.getColumnWidths(contentSetting)[2])] : + []), + this.createHeaderCell( + 'Beschreibung', + this.getColumnWidths(contentSetting)[this.getColumnWidths(contentSetting).length - 1] + ) + ]; 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 - }) - ] - })] - }) - ] + children: headerCells }); 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 codeCells = [ + this.createCodeCell([new Paragraph(code.id)], this.getColumnWidths(contentSetting)[0]), + this.createCodeCell([new Paragraph(code.label)], this.getColumnWidths(contentSetting)[1]), + ...(contentSetting.showScore ? + [this.createCodeCell([new Paragraph(code.score || '')], this.getColumnWidths(contentSetting)[2])] : + []), + this.createCodeCell( + this.htmlToDocx(code.description, contentSetting), + this.getColumnWidths(contentSetting)[this.getColumnWidths(contentSetting).length - 1] + ) + ]; 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) - }) - ] + children: codeCells }); - 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; } + private static createHeaderCell(text: string, width: number): TableCell { + return this.createCodeCell( + [new Paragraph({ + children: [ + new TextRun({ + text, + bold: true + }) + ] + })], + width + ); + } + + private static createCodeCell(children: Paragraph[], width: number): TableCell { + return new TableCell({ + borders: this.TableBoarders, + width: { + size: width, + type: WidthType.DXA + }, + children + }); + } + /** * 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]; + return contentSetting.showScore ? [1000, 2000, 1000, 5000] : [1000, 2000, 6000]; } /** @@ -326,7 +271,7 @@ export class CodebookDocxGenerator { */ private static getVariableHeader(variable: BookVariable): Paragraph { return new Paragraph({ - text: variable.label, + text: `${variable.id} ${variable.label}`, heading: HeadingLevel.HEADING_2, spacing: { before: 400, @@ -343,29 +288,32 @@ export class CodebookDocxGenerator { */ 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; - }); + const items = varItems.filter(item => this.isItemRelatedToVariable(item, variable.id)); if (items.length) { paragraphs.push(new Paragraph({ - text: 'Items:', + text: `Item(s): ${items.map(item => this.getItemId(item)).join(' ')}`, + heading: HeadingLevel.HEADING_3, spacing: { - after: 100 + before: 200, + after: 200 } })); - items.forEach(item => { - paragraphs.push(new Paragraph({ - text: `${item['key']} ${item['label']}`, - bullet: { - level: 0 - } - })); - }); } return paragraphs; } + private static isItemRelatedToVariable(item: ItemMetadata, variableId: string): boolean { + const normalizedVariableId = variableId.replace(/\./g, '_'); + return item['variableId'] === variableId || + item[variableId] !== undefined || + item[normalizedVariableId] !== undefined; + } + + private static getItemId(item: ItemMetadata): string { + const itemId = item['id'] ?? item['key'] ?? item['label'] ?? ''; + return `${itemId}`; + } + /** * Get general instruction * @param contentSetting Codebook content settings diff --git a/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts b/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts index 1e48fc2..a7b7181 100644 --- a/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts +++ b/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts @@ -4,7 +4,11 @@ import { CodebookGenerator } from '../../../codebook-generator/src/lib/codebook-generator/codebook-generator.class'; import { - CodeBookContentSetting + CodebookDocxGenerator +} from '../../../codebook-generator/src/lib/codebook-generator/codebook-docx-generator.class'; +import { + CodeBookContentSetting, + CodebookUnitDto } from '../../../codebook-models/src/lib/codebook.interfaces'; describe('CodebookGenerator', () => { @@ -319,4 +323,70 @@ describe('CodebookGenerator', () => { expect(numberingXml).toContain(''); }); + + it('writes Studio-compatible DOCX headers, item relations and score column order', async () => { + const unit: CodebookUnitDto = { + key: 'UNIT1', + name: 'Unit 1', + missings: [], + items: [ + { + id: 'ITEM1', + variableId: 'VAR1' + }, + { + id: 'ITEM2', + VAR1: true + }, + { + id: 'ITEM3', + variableId: 'OTHER' + } + ], + variables: [ + { + id: 'VAR1', + label: 'Variable 1', + sourceType: 'BASE', + generalInstruction: '', + codes: [ + { + id: 'C1', + label: 'Code 1', + score: '77', + description: '

Description text

' + } + ] + } + ] + }; + + const blob = await CodebookDocxGenerator.generateDocx( + [unit], + { + ...contentSetting, + exportFormat: 'docx', + hideItemVarRelation: false + } + ); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + const documentXml = await zip.file('word/document.xml')?.async('string'); + + expect(documentXml).toContain('UNIT1 Unit 1'); + expect(documentXml).toContain('VAR1 Variable 1'); + expect(documentXml).toContain('Item(s): ITEM1 ITEM2'); + expect(documentXml).not.toContain('ITEM3'); + + const scoreHeaderIndex = documentXml?.indexOf('Score') ?? -1; + const descriptionHeaderIndex = documentXml?.indexOf('Beschreibung') ?? -1; + const scoreValueIndex = documentXml?.indexOf('77') ?? -1; + const descriptionValueIndex = documentXml?.indexOf('Description text') ?? -1; + + expect(scoreHeaderIndex).toBeGreaterThan(-1); + expect(descriptionHeaderIndex).toBeGreaterThan(-1); + expect(scoreHeaderIndex).toBeLessThan(descriptionHeaderIndex); + expect(scoreValueIndex).toBeGreaterThan(-1); + expect(descriptionValueIndex).toBeGreaterThan(-1); + expect(scoreValueIndex).toBeLessThan(descriptionValueIndex); + }); }); From 2a77005c2dcb46eafa60aeb0469ccab77b13fe21 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:39:18 +0200 Subject: [PATCH 7/8] Handle codebook DOCX edge cases --- .../codebook-docx-generator.class.ts | 65 ++++++++------ .../codebook-generator.class.ts | 3 - .../codebook-generator.class.spec.ts | 87 +++++++++++++++++++ 3 files changed, 127 insertions(+), 28 deletions(-) diff --git a/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-docx-generator.class.ts b/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-docx-generator.class.ts index e0cb190..2417e42 100644 --- a/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-docx-generator.class.ts +++ b/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-docx-generator.class.ts @@ -18,7 +18,7 @@ import { import * as cheerio from 'cheerio'; import type { AnyNode, Element } from 'domhandler'; import type { - BookVariable, CodeBookContentSetting, CodebookUnitDto, ItemMetadata + BookVariable, CodeBookContentSetting, CodebookUnitDto, ItemMetadata, Missing } from '@iqb/ngx-coding-components/codebook-models'; /** @@ -35,27 +35,23 @@ export class CodebookDocxGenerator { codingBookUnits: CodebookUnitDto[], contentSetting: CodeBookContentSetting ): Promise { - if (codingBookUnits.length) { - const units: (Paragraph | Table)[] = []; - let missings: Paragraph[] = []; - codingBookUnits.forEach(variableCoding => { - missings = this.getMissings(variableCoding); - if (variableCoding.variables.length || !contentSetting.hasOnlyVarsWithCodes) { - units.push(...(this.createDocXForUnit( - variableCoding.items || [], - variableCoding.variables, - contentSetting, - this.getUnitHeader(variableCoding) - ) as (Paragraph | Table)[])); - } - }); - return Packer.toBlob( - this.setDocXDocument( - units, - missings) - ); - } - return new Blob([], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); + const units: (Paragraph | Table)[] = []; + const missings = this.getMissings(codingBookUnits); + codingBookUnits.forEach(variableCoding => { + if (variableCoding.variables.length || !contentSetting.hasOnlyVarsWithCodes) { + units.push(...(this.createDocXForUnit( + variableCoding.items || [], + variableCoding.variables, + contentSetting, + this.getUnitHeader(variableCoding) + ) as (Paragraph | Table)[])); + } + }); + return Packer.toBlob( + this.setDocXDocument( + units, + missings) + ); } /** @@ -92,11 +88,11 @@ export class CodebookDocxGenerator { * @param variableCoding Codebook unit * @returns List of paragraphs with missings */ - private static getMissings(variableCoding: CodebookUnitDto): Paragraph[] { + private static getMissings(codingBookUnits: CodebookUnitDto[]): Paragraph[] { const missings: Paragraph[] = []; try { - variableCoding.missings.forEach(missing => { - if (missing.code && missing.label && missing.description) { + this.getUniqueMissingDefinitions(codingBookUnits).forEach(missing => { + if (this.isValidMissing(missing)) { missings.push(new Paragraph({ children: [new TextRun({ text: `${missing.code} ${missing.label}`, bold: true })], spacing: { @@ -129,6 +125,25 @@ export class CodebookDocxGenerator { return missings; } + private static getUniqueMissingDefinitions(codingBookUnits: CodebookUnitDto[]): Missing[] { + const uniqueMissings = new Map(); + codingBookUnits.flatMap(unit => unit.missings || []).forEach(missing => { + uniqueMissings.set( + `${missing.code}\u0000${missing.label}\u0000${missing.description}`, + missing + ); + }); + return [...uniqueMissings.values()]; + } + + private static isValidMissing(missing: Missing): boolean { + return missing.code !== undefined && + missing.code !== null && + `${missing.code}` !== '' && + !!missing.label && + !!missing.description; + } + /** * Get table borders * @returns Table cell borders diff --git a/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts b/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts index 2b198f3..17ff579 100644 --- a/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts +++ b/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts @@ -22,9 +22,6 @@ export class CodebookGenerator { contentSetting: CodeBookContentSetting, missings: Missing[] ): Promise { - if (units.length === 0) { - return Promise.resolve(new Blob(['[]'], { type: 'application/json' })); - } const codebook: CodebookUnitDto[] = units.map( (unit: UnitPropertiesForCodebook) => this.getCodeBookDataForUnit(unit, contentSetting, missings) ); diff --git a/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts b/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts index a7b7181..4543c56 100644 --- a/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts +++ b/projects/ngx-coding-components/src/lib/codebook-generator/codebook-generator.class.spec.ts @@ -74,6 +74,93 @@ describe('CodebookGenerator', () => { expect(await blob.text()).toBe('[]'); }); + it('returns a valid DOCX Blob for empty DOCX exports', async () => { + const blob = await CodebookGenerator.generateCodebook( + [], + { + ...contentSetting, + exportFormat: 'docx' + }, + [] + ); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + const documentXml = await zip.file('word/document.xml')?.async('string'); + + expect(blob).toEqual(jasmine.any(Blob)); + expect(blob.size).toBeGreaterThan(0); + expect(documentXml).toContain(' { + const unit: CodebookUnitDto = { + key: 'UNIT1', + name: 'Unit 1', + variables: [], + missings: [ + { + code: 0, + label: 'Nicht bearbeitet', + description: 'Keine Eingabe' + } + ] + }; + + const blob = await CodebookDocxGenerator.generateDocx([unit], { + ...contentSetting, + exportFormat: 'docx' + }); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + const documentXml = await zip.file('word/document.xml')?.async('string'); + + expect(documentXml).toContain('0 Nicht bearbeitet'); + expect(documentXml).toContain('Keine Eingabe'); + expect(documentXml).not.toContain('kein valides Missing'); + }); + + it('keeps unique missings from all units in DOCX exports', async () => { + const unitA: CodebookUnitDto = { + key: 'UNIT1', + name: 'Unit 1', + variables: [], + missings: [ + { + code: 9, + label: 'Missing A', + description: 'Description A' + } + ] + }; + const unitB: CodebookUnitDto = { + key: 'UNIT2', + name: 'Unit 2', + variables: [], + missings: [ + { + code: 9, + label: 'Missing A', + description: 'Description A' + }, + { + code: 99, + label: 'Missing B', + description: 'Description B' + } + ] + }; + + const blob = await CodebookDocxGenerator.generateDocx([unitA, unitB], { + ...contentSetting, + exportFormat: 'docx' + }); + const zip = await JSZip.loadAsync(await blob.arrayBuffer()); + const documentXml = await zip.file('word/document.xml')?.async('string') || ''; + + expect(documentXml.match(/9 Missing A/g)?.length).toBe(1); + expect(documentXml).toContain('Description A'); + expect(documentXml).toContain('99 Missing B'); + expect(documentXml).toContain('Description B'); + }); + it('keeps all codes of an included variable to match Studio exports', async () => { const scheme = JSON.stringify({ version: '1.5', From c99cb991426fd6f05e59a45b99e5510918c742f1 Mon Sep 17 00:00:00 2001 From: jurei733 <67505990+jurei733@users.noreply.github.com> Date: Wed, 17 Jun 2026 11:07:40 +0200 Subject: [PATCH 8/8] Fix codebook generator optional DOCX loading --- .../src/lib/codebook-export/codebook-export.component.ts | 5 +++-- .../src/lib/codebook-generator/codebook-generator.class.ts | 6 +++--- .../codebook-generator/src/public-api.ts | 1 - 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.ts b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.ts index b39fc88..96380cb 100644 --- a/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.ts +++ b/projects/ngx-coding-components/codebook-export/src/lib/codebook-export/codebook-export.component.ts @@ -34,7 +34,8 @@ import { import { debounceTime, distinctUntilChanged, - switchMap, + exhaustMap, + take, takeUntil } from 'rxjs/operators'; import { @@ -471,7 +472,7 @@ export class CodebookExportComponent implements OnInit, OnDestroy, OnChanges { this.codebookPollingSubscription = interval(1500) .pipe( takeUntil(this.destroy$), - switchMap(() => provider.getJobStatus!(jobId)) + exhaustMap(() => provider.getJobStatus!(jobId).pipe(take(1))) ) .subscribe({ next: (status: CodebookExportJobStatus) => { diff --git a/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts b/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts index 17ff579..ff96469 100644 --- a/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts +++ b/projects/ngx-coding-components/codebook-generator/src/lib/codebook-generator/codebook-generator.class.ts @@ -11,13 +11,12 @@ import type { Missing, UnitPropertiesForCodebook } from '@iqb/ngx-coding-components/codebook-models'; -import { CodebookDocxGenerator } from './codebook-docx-generator.class'; /** * Class for generating codebooks */ export class CodebookGenerator { - static generateCodebook( + static async generateCodebook( units: UnitPropertiesForCodebook[], contentSetting: CodeBookContentSetting, missings: Missing[] @@ -27,6 +26,7 @@ export class CodebookGenerator { ); if (contentSetting.exportFormat === 'docx') { + const { CodebookDocxGenerator } = await import('./codebook-docx-generator.class'); return CodebookDocxGenerator.generateDocx(codebook, contentSetting); } @@ -37,7 +37,7 @@ export class CodebookGenerator { missings: unit.missings })); const data = JSON.stringify(noItemsCodebook); - return Promise.resolve(new Blob([data], { type: 'application/json' })); + return new Blob([data], { type: 'application/json' }); } private static getCodeBookDataForUnit( diff --git a/projects/ngx-coding-components/codebook-generator/src/public-api.ts b/projects/ngx-coding-components/codebook-generator/src/public-api.ts index 37b2655..511d0a8 100644 --- a/projects/ngx-coding-components/codebook-generator/src/public-api.ts +++ b/projects/ngx-coding-components/codebook-generator/src/public-api.ts @@ -1,5 +1,4 @@ export * from './lib/codebook-generator/codebook-generator.class'; -export * from './lib/codebook-generator/codebook-docx-generator.class'; export type { BookVariable, CodeBookContentSetting,