diff --git a/client/src/app/site/motions/components/statute-paragraph-list/statute-import-list/statute-import-list.component.html b/client/src/app/site/motions/components/statute-paragraph-list/statute-import-list/statute-import-list.component.html new file mode 100644 index 000000000..4cd90dd7b --- /dev/null +++ b/client/src/app/site/motions/components/statute-paragraph-list/statute-import-list/statute-import-list.component.html @@ -0,0 +1,159 @@ + + +

Import Statutes

+ + +
+ + + Required comma or semicolon separated values with these column header names in the first row: +
+
Title, Text
+ + +
+ + Encoding of the file + + + {{ option.label | translate }} + + + + + Column separator + + + {{ option.label | translate }} + + + + + Text separator + + + {{ option.label | translate }} + + + +
+
+ + +
+
+ + + +

Preview

+
+ +
+   + playlist_add +  {{ newCount }}  Statute paragraphs(s) will be imported. +
+ +
+   + warning +  {{ nonImportableCount }}  entries will be ommitted. +
+ +
+   + done +  {{ doneCount }}  Statute paragraphs have been imported. +
+
+
+ After verifiy the preview click on 'import' please (see top right). +
+
+ + Show all + Show errors only + Show correct entries + +
+
+ + + + + +
+ + {{ getActionIcon(entry) }} + +
+
+ + {{ getActionIcon(entry) }} + +
+
+ + {{ getActionIcon(entry) }} + +
+
+
+ + + + Title + + + warning + + {{ entry.newEntry.title }} + + + + + + Text + + + warning + + {{ getShortPreview(entry.newEntry.text) }} + + + + +
+
+
diff --git a/client/src/app/site/motions/components/statute-paragraph-list/statute-import-list/statute-import-list.component.spec.ts b/client/src/app/site/motions/components/statute-paragraph-list/statute-import-list/statute-import-list.component.spec.ts new file mode 100644 index 000000000..4100f2536 --- /dev/null +++ b/client/src/app/site/motions/components/statute-paragraph-list/statute-import-list/statute-import-list.component.spec.ts @@ -0,0 +1,26 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { StatuteImportListComponent } from './statute-import-list.component'; +import { E2EImportsModule } from 'e2e-imports.module'; + +describe('StatuteImportListComponent', () => { + let component: StatuteImportListComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [StatuteImportListComponent], + imports: [E2EImportsModule] + }).compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(StatuteImportListComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/client/src/app/site/motions/components/statute-paragraph-list/statute-import-list/statute-import-list.component.ts b/client/src/app/site/motions/components/statute-paragraph-list/statute-import-list/statute-import-list.component.ts new file mode 100644 index 000000000..cf06e9f07 --- /dev/null +++ b/client/src/app/site/motions/components/statute-paragraph-list/statute-import-list/statute-import-list.component.ts @@ -0,0 +1,84 @@ +import { Component } from '@angular/core'; +import { MatSnackBar } from '@angular/material'; +import { Title } from '@angular/platform-browser'; +import { TranslateService } from '@ngx-translate/core'; + +import { BaseImportListComponent } from 'app/site/base/base-import-list'; +import { ViewStatuteParagraph } from 'app/site/motions/models/view-statute-paragraph'; +import { StatuteImportService } from 'app/site/motions/services/statute-import.service'; +import { StatuteCsvExportService } from 'app/site/motions/services/statute-csv-export.service'; + +/** + * Component for the statute paragraphs import list view. + */ +@Component({ + selector: 'os-statute-import-list', + templateUrl: './statute-import-list.component.html' +}) +export class StatuteImportListComponent extends BaseImportListComponent { + /** + * Constructor for list view bases + * + * @param titleService the title serivce + * @param matSnackBar snackbar for displaying errors + * @param translate the translate service + * @param importer: The statute csv import service + * @param statuteCSVExport: service for exporting example data + */ + public constructor( + titleService: Title, + matSnackBar: MatSnackBar, + translate: TranslateService, + importer: StatuteImportService, + private statuteCSVExport: StatuteCsvExportService + ) { + super(importer, titleService, translate, matSnackBar); + } + + /** + * Returns the first characters of a string, for preview purposes + * + * @param input + */ + public getShortPreview(input: string): string { + if (input.length > 50) { + return this.stripHtmlTags(input.substring(0, 47)) + '...'; + } + return this.stripHtmlTags(input); + } + + /** + * Returns the first and last 150 characters of a string; used within + * tooltips for the preview + * + * @param input + */ + public getLongPreview(input: string): string { + if (input.length < 300) { + return this.stripHtmlTags(input); + } + return ( + this.stripHtmlTags(input.substring(0, 147)) + + ' [...] ' + + this.stripHtmlTags(input.substring(input.length - 150, input.length)) + ); + } + + /** + * Helper to remove html tags from a string. + * CAUTION: It is just a basic "don't show distracting html tags in a + * preview", not an actual tested sanitizer! + * @param inputString + */ + private stripHtmlTags(inputString: string): string { + const regexp = new RegExp(/<[^ ][^<>]*(>|$)/g); + return inputString.replace(regexp, '').trim(); + } + + /** + * Triggers an example csv download + */ + public downloadCsvExample(): void { + this.statuteCSVExport.exportDummyCSV(); + } +} diff --git a/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.html b/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.html index 56e103ff2..f54c4ba73 100644 --- a/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.html +++ b/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.html @@ -40,7 +40,7 @@ - @@ -110,8 +110,12 @@ - + diff --git a/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.ts b/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.ts index 04aefada6..13465c69c 100644 --- a/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.ts +++ b/client/src/app/site/motions/components/statute-paragraph-list/statute-paragraph-list.component.ts @@ -10,6 +10,7 @@ import { ViewStatuteParagraph } from '../../models/view-statute-paragraph'; import { StatuteParagraphRepositoryService } from '../../services/statute-paragraph-repository.service'; import { BaseViewComponent } from '../../../base/base-view'; import { MatSnackBar } from '@angular/material'; +import { StatuteCsvExportService } from '../../services/statute-csv-export.service'; /** * List view for the statute paragraphs. @@ -38,13 +39,15 @@ export class StatuteParagraphListComponent extends BaseViewComponent implements public editId: number | null; /** - * The usual component constructor + * The usual component constructor. Initializes the forms + * * @param titleService * @param translate * @param matSnackBar * @param repo * @param formBuilder * @param promptService + * @param csvExportService */ public constructor( titleService: Title, @@ -52,7 +55,8 @@ export class StatuteParagraphListComponent extends BaseViewComponent implements matSnackBar: MatSnackBar, private repo: StatuteParagraphRepositoryService, private formBuilder: FormBuilder, - private promptService: PromptService + private promptService: PromptService, + private csvExportService: StatuteCsvExportService ) { super(titleService, translate, matSnackBar); @@ -200,4 +204,11 @@ export class StatuteParagraphListComponent extends BaseViewComponent implements public onCancelUpdate(): void { this.editId = null; } + + /** + * Triggers a csv export of the statute paragraphs + */ + public onCsvExport(): void { + this.csvExportService.exportStatutes(this.statuteParagraphs); + } } diff --git a/client/src/app/site/motions/motions-routing.module.ts b/client/src/app/site/motions/motions-routing.module.ts index 13c7fd3d4..f53f731d3 100644 --- a/client/src/app/site/motions/motions-routing.module.ts +++ b/client/src/app/site/motions/motions-routing.module.ts @@ -11,6 +11,7 @@ import { MotionDetailComponent } from './components/motion-detail/motion-detail. import { MotionImportListComponent } from './components/motion-import-list/motion-import-list.component'; import { MotionListComponent } from './components/motion-list/motion-list.component'; import { SpeakerListComponent } from '../agenda/components/speaker-list/speaker-list.component'; +import { StatuteImportListComponent } from './components/statute-paragraph-list/statute-import-list/statute-import-list.component'; import { StatuteParagraphListComponent } from './components/statute-paragraph-list/statute-paragraph-list.component'; const routes: Routes = [ @@ -18,6 +19,7 @@ const routes: Routes = [ { path: 'category', component: CategoryListComponent }, { path: 'comment-section', component: MotionCommentSectionListComponent }, { path: 'statute-paragraphs', component: StatuteParagraphListComponent }, + { path: 'statute-paragraphs/import', component: StatuteImportListComponent }, { path: 'call-list', component: CallListComponent }, { path: 'blocks', component: MotionBlockListComponent }, { path: 'blocks/:id', component: MotionBlockDetailComponent }, diff --git a/client/src/app/site/motions/motions.module.ts b/client/src/app/site/motions/motions.module.ts index 3d295f0f2..96ff274ed 100644 --- a/client/src/app/site/motions/motions.module.ts +++ b/client/src/app/site/motions/motions.module.ts @@ -22,6 +22,7 @@ import { ManageSubmittersComponent } from './components/manage-submitters/manage import { MotionPollComponent } from './components/motion-poll/motion-poll.component'; import { MotionPollDialogComponent } from './components/motion-poll/motion-poll-dialog.component'; import { MotionExportDialogComponent } from './components/motion-export-dialog/motion-export-dialog.component'; +import { StatuteImportListComponent } from './components/statute-paragraph-list/statute-import-list/statute-import-list.component'; @NgModule({ imports: [CommonModule, MotionsRoutingModule, SharedModule], @@ -44,7 +45,8 @@ import { MotionExportDialogComponent } from './components/motion-export-dialog/m ManageSubmittersComponent, MotionPollComponent, MotionPollDialogComponent, - MotionExportDialogComponent + MotionExportDialogComponent, + StatuteImportListComponent ], entryComponents: [ MotionChangeRecommendationComponent, diff --git a/client/src/app/site/motions/services/statute-csv-export.service.spec.ts b/client/src/app/site/motions/services/statute-csv-export.service.spec.ts new file mode 100644 index 000000000..0da963dea --- /dev/null +++ b/client/src/app/site/motions/services/statute-csv-export.service.spec.ts @@ -0,0 +1,17 @@ +import { TestBed, inject } from '@angular/core/testing'; + +import { E2EImportsModule } from 'e2e-imports.module'; +import { StatuteCsvExportService } from './statute-csv-export.service'; + +describe('StatuteCsvExportService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [E2EImportsModule], + providers: [StatuteCsvExportService] + }); + }); + + it('should be created', inject([StatuteCsvExportService], (service: StatuteCsvExportService) => { + expect(service).toBeTruthy(); + })); +}); diff --git a/client/src/app/site/motions/services/statute-csv-export.service.ts b/client/src/app/site/motions/services/statute-csv-export.service.ts new file mode 100644 index 000000000..7d9647b4e --- /dev/null +++ b/client/src/app/site/motions/services/statute-csv-export.service.ts @@ -0,0 +1,58 @@ +import { Injectable } from '@angular/core'; + +import { TranslateService } from '@ngx-translate/core'; + +import { CsvExportService, CsvColumnDefinitionProperty } from 'app/core/services/csv-export.service'; +import { ViewStatuteParagraph } from '../models/view-statute-paragraph'; +import { FileExportService } from 'app/core/services/file-export.service'; + +/** + * Exports CSVs for statute paragraphs. + */ +@Injectable({ + providedIn: 'root' +}) +export class StatuteCsvExportService { + /** + * Does nothing. + * + * @param csvExport CsvExportService + * @param translate TranslateService + * @param fileExport FileExportService + */ + public constructor( + private csvExport: CsvExportService, + private translate: TranslateService, + private fileExport: FileExportService + ) {} + + /** + * Export all statute paragraphs as CSV + * + * @param statute statute PParagraphs to export + */ + public exportStatutes(statutes: ViewStatuteParagraph[]): void { + const exportProperties: CsvColumnDefinitionProperty[] = [ + { property: 'title' }, + { property: 'text' } + ]; + this.csvExport.export(statutes, exportProperties, this.translate.instant('Statutes') + '.csv'); + } + + /** + * Exports a short example file + */ + public exportDummyCSV(): void { + const headerRow = ['Title', 'Text'].map(item => this.translate.instant(item)).join(','); + const rows = [ + headerRow, + '§1,"This is the first section"', + '"§1, A 3", "This is another important aspect"', + '§2,Yet another' + ]; + this.fileExport.saveFile( + rows.join('\n'), + `${this.translate.instant('Statutes')} - ${this.translate.instant('example')}.csv` + ); + } +} diff --git a/client/src/app/site/motions/services/statute-import.service.ts b/client/src/app/site/motions/services/statute-import.service.ts new file mode 100644 index 000000000..a05b4c994 --- /dev/null +++ b/client/src/app/site/motions/services/statute-import.service.ts @@ -0,0 +1,98 @@ +import { Injectable } from '@angular/core'; +import { MatSnackBar } from '@angular/material'; +import { Papa } from 'ngx-papaparse'; +import { TranslateService } from '@ngx-translate/core'; + +import { BaseImportService, NewEntry } from 'app/core/services/base-import.service'; +import { StatuteParagraph } from 'app/shared/models/motions/statute-paragraph'; +import { StatuteParagraphRepositoryService } from './statute-paragraph-repository.service'; +import { ViewStatuteParagraph } from '../models/view-statute-paragraph'; + +/** + * Service for motion imports + */ +@Injectable({ + providedIn: 'root' +}) +export class StatuteImportService extends BaseImportService { + /** + * List of possible errors and their verbose explanation + */ + public errorList = { + Duplicates: 'A statute with this title already exists.' + }; + + /** + * The minimimal number of header entries needed to successfully create an entry + */ + public requiredHeaderLength = 2; + + /** + * Constructor. Defines the headers expected and calls the abstract class + * @param repo: The repository for statuteparagraphs. + * @param translate Translation service + * @param papa External csv parser (ngx-papaparser) + * @param matSnackBar snackBar to display import errors + */ + public constructor( + private repo: StatuteParagraphRepositoryService, + translate: TranslateService, + papa: Papa, + matSnackbar: MatSnackBar + ) { + super(translate, papa, matSnackbar); + + this.expectedHeader = ['title', 'text']; + } + + /** + * Clears all temporary data specific to this importer. + */ + public clearData(): void { + // does nothing + } + + /** + * Parses a string representing an entry, extracting secondary data, appending + * the array of secondary imports as needed + * + * @param line + * @returns a new Entry representing a Motion + */ + public mapData(line: string): NewEntry { + const newEntry = new ViewStatuteParagraph(new StatuteParagraph()); + const headerLength = Math.min(this.expectedHeader.length, line.length); + for (let idx = 0; idx < headerLength; idx++) { + switch (this.expectedHeader[idx]) { + case 'title': + newEntry.statuteParagraph.title = line[idx]; + break; + case 'text': + newEntry.statuteParagraph.text = line[idx]; + break; + } + } + const updateModels = this.repo.getViewModelList().filter(paragraph => paragraph.title === newEntry.title); + return { + newEntry: newEntry, + duplicates: updateModels, + status: updateModels.length ? 'error' : 'new', + errors: updateModels.length ? ['Duplicates'] : [] + }; + } + + /** + * Executes the import. Creates all entries without errors by submitting + * them to the server. The entries will receive the status 'done' on success. + */ + public async doImport(): Promise { + for (const entry of this.entries) { + if (entry.status !== 'new') { + continue; + } + await this.repo.create(entry.newEntry.statuteParagraph); + entry.status = 'done'; + } + this.updatePreview(); + } +}