OpenSlides/client/src/app/site/motions/services/motion-import.service.ts
2019-05-21 14:58:46 +02:00

424 lines
16 KiB
TypeScript

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/ui-services/base-import.service';
import { Category } from 'app/shared/models/motions/category';
import { CategoryRepositoryService } from 'app/core/repositories/motions/category-repository.service';
import { CreateMotion } from '../models/create-motion';
import { MotionBlock } from 'app/shared/models/motions/motion-block';
import { MotionBlockRepositoryService } from 'app/core/repositories/motions/motion-block-repository.service';
import { motionExportOnly, motionImportExportHeaderOrder } from '../motion-import-export-order';
import { MotionRepositoryService } from 'app/core/repositories/motions/motion-repository.service';
import { UserRepositoryService } from 'app/core/repositories/users/user-repository.service';
import { ViewCsvCreateMotion, CsvMapping } from '../models/view-csv-create-motion';
import { ViewMotion } from '../models/view-motion';
import { TagRepositoryService } from 'app/core/repositories/tags/tag-repository.service';
import { Tag } from 'app/shared/models/core/tag';
/**
* Service for motion imports
*/
@Injectable({
providedIn: 'root'
})
export class MotionImportService extends BaseImportService<ViewMotion> {
/**
* List of possible errors and their verbose explanation
*/
public errorList = {
MotionBlock: 'Could not resolve the motion block',
Category: 'Could not resolve the category',
Submitters: 'Could not resolve the submitters',
Tags: 'Could not resolve the tags',
Title: 'A title is required',
Text: "A content in the 'text' column is required",
Duplicates: 'A motion with this identifier already exists.'
};
/**
* The minimimal number of header entries needed to successfully create an entry
*/
public requiredHeaderLength = 3;
/**
* submitters that need to be created prior to importing
*/
public newSubmitters: CsvMapping[] = [];
/**
* Categories that need to be created prior to importing
*/
public newCategories: CsvMapping[] = [];
/**
* MotionBlocks that need to be created prior to importing
*/
public newMotionBlocks: CsvMapping[] = [];
/**
* Mapping of the new tags for the imported motion.
*/
public newTags: CsvMapping[] = [];
/**
* Constructor. Defines the headers expected and calls the abstract class
* @param repo: The repository for motions.
* @param categoryRepo Repository to fetch pre-existing categories
* @param motionBlockRepo Repository to fetch pre-existing motionBlocks
* @param userRepo Repository to query/ create users
* @param translate Translation service
* @param papa External csv parser (ngx-papaparser)
* @param matSnackBar snackBar to display import errors
*/
public constructor(
private repo: MotionRepositoryService,
private categoryRepo: CategoryRepositoryService,
private motionBlockRepo: MotionBlockRepositoryService,
private userRepo: UserRepositoryService,
private tagRepo: TagRepositoryService,
translate: TranslateService,
papa: Papa,
matSnackbar: MatSnackBar
) {
super(translate, papa, matSnackbar);
this.expectedHeader = motionImportExportHeaderOrder.filter(head => !motionExportOnly.includes(head));
}
/**
* Clears all temporary data specific to this importer.
*/
public clearData(): void {
this.newSubmitters = [];
this.newCategories = [];
this.newMotionBlocks = [];
this.newTags = [];
}
/**
* 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<ViewMotion> {
const newEntry = new ViewCsvCreateMotion(new CreateMotion());
const headerLength = Math.min(this.expectedHeader.length, line.length);
for (let idx = 0; idx < headerLength; idx++) {
switch (this.expectedHeader[idx]) {
case 'submitters':
newEntry.csvSubmitters = this.getSubmitters(line[idx]);
break;
case 'category':
newEntry.csvCategory = this.getCategory(line[idx]);
break;
case 'motion_block':
newEntry.csvMotionblock = this.getMotionBlock(line[idx]);
break;
case 'tags':
newEntry.csvTags = this.getTags(line[idx]);
break;
default:
newEntry.motion[this.expectedHeader[idx]] = line[idx];
}
}
const updateModels = this.repo.getMotionDuplicates(newEntry);
const entry: NewEntry<ViewMotion> = {
newEntry: newEntry,
duplicates: updateModels,
status: updateModels.length ? 'error' : 'new',
errors: updateModels.length ? ['Duplicates'] : []
};
if (!entry.newEntry.title) {
this.setError(entry, 'Title');
}
if (!entry.newEntry.text) {
this.setError(entry, 'Title');
}
return entry;
}
/**
* Executes the import. Creates all secondary data, maps the newly created
* secondary data to the new entries, then creates all entries without errors
* by submitting them to the server. The entries will receive the status
* 'done' on success.
*/
public async doImport(): Promise<void> {
this.newMotionBlocks = await this.createNewMotionBlocks();
this.newCategories = await this.createNewCategories();
this.newSubmitters = await this.createNewUsers();
this.newTags = await this.createNewTags();
for (const entry of this.entries) {
if (entry.status !== 'new') {
continue;
}
const openBlocks = (entry.newEntry as ViewCsvCreateMotion).solveMotionBlocks(this.newMotionBlocks);
if (openBlocks) {
this.setError(entry, 'MotionBlock');
this.updatePreview();
continue;
}
const openCategories = (entry.newEntry as ViewCsvCreateMotion).solveCategory(this.newCategories);
if (openCategories) {
this.setError(entry, 'Category');
this.updatePreview();
continue;
}
const openUsers = (entry.newEntry as ViewCsvCreateMotion).solveSubmitters(this.newSubmitters);
if (openUsers) {
this.setError(entry, 'Submitters');
this.updatePreview();
continue;
}
const openTags = (entry.newEntry as ViewCsvCreateMotion).solveTags(this.newTags);
if (openTags) {
this.setError(entry, 'Tags');
this.updatePreview();
continue;
}
await this.repo.create((entry.newEntry as ViewCsvCreateMotion).motion);
entry.status = 'done';
}
this.updatePreview();
}
/**
* Checks the provided submitter(s) and returns an object with mapping of
* existing users and of users that need to be created
*
* @param submitterlist
* @returns a list of submitters mapped with (if already existing) their id
*/
public getSubmitters(submitterlist: string): CsvMapping[] {
const result: CsvMapping[] = [];
if (!submitterlist) {
return result;
}
const submitterArray = submitterlist.split(','); // TODO fails with 'full name'
for (const submitter of submitterArray) {
const existingSubmitters = this.userRepo.getUsersByName(submitter);
if (!existingSubmitters.length) {
if (!this.newSubmitters.find(listedSubmitter => listedSubmitter.name === submitter)) {
this.newSubmitters.push({ name: submitter });
}
result.push({ name: submitter });
}
if (existingSubmitters.length === 1) {
result.push({
name: existingSubmitters[0].short_name,
id: existingSubmitters[0].id
});
}
if (existingSubmitters.length > 1) {
result.push({
name: submitter,
multiId: existingSubmitters.map(ex => ex.id)
});
this.matSnackbar.open('TODO: multiple possible users found for this string', 'ok');
// TODO How to handle several submitters ? Is this possible?
// should have some kind of choice dialog there
}
}
return result;
}
/**
* Checks the provided category/ies and returns a mapping, expands
* newCategories if needed.
*
* The assumption is that there may or not be a prefix wit up to 5
* characters at the beginning, separated by ' - ' from the name.
* It will also accept a registered translation between the current user's
* language and english
*
* @param categoryString
* @returns categories mapped to existing categories
*/
public getCategory(categoryString: string): CsvMapping | null {
if (!categoryString) {
return null;
}
const category = this.splitCategoryString(categoryString);
const existingCategory = this.categoryRepo.getViewModelList().find(cat => {
if (category.prefix && cat.prefix !== category.prefix) {
return false;
}
if (cat.name === category.name) {
return true;
}
if (this.translate.instant(cat.name) === category.name) {
return true;
}
return false;
});
if (existingCategory) {
return {
name: existingCategory.prefixedName,
id: existingCategory.id
};
} else {
if (!this.newCategories.find(newCat => newCat.name === categoryString)) {
this.newCategories.push({ name: categoryString });
}
return { name: categoryString };
}
}
/**
* Checks the motionBlock provided in the string for existance, expands newMotionBlocks
* if needed. Note that it will also check for translation between the current
* user's language and english
*
* @param blockString
* @returns a CSVMap with the MotionBlock and an id (if the motionBlock is already in the dataStore)
*/
public getMotionBlock(blockString: string): CsvMapping | null {
if (!blockString) {
return null;
}
blockString = blockString.trim();
let existingBlock = this.motionBlockRepo.getMotionBlocksByTitle(blockString);
if (!existingBlock.length) {
existingBlock = this.motionBlockRepo.getMotionBlocksByTitle(this.translate.instant(blockString));
}
if (existingBlock.length) {
return { id: existingBlock[0].id, name: existingBlock[0].title };
} else {
if (!this.newMotionBlocks.find(newBlock => newBlock.name === blockString)) {
this.newMotionBlocks.push({ name: blockString });
}
return { name: blockString };
}
}
/**
* Iterates over the given string separated by ','
* Creates for every found string a tag.
*
* @param tagList The list of tags as string.
*
* @returns {CsvMapping[]} The list of tags as csv-mapping.
*/
public getTags(tagList: string): CsvMapping[] {
const result: CsvMapping[] = [];
if (!tagList) {
return result;
}
const tagArray = tagList.split(',');
for (let tag of tagArray) {
tag = tag.trim();
const existingTag = this.tagRepo.getViewModelList().find(tagInRepo => tagInRepo.name === tag);
if (existingTag) {
result.push({ id: existingTag.id, name: existingTag.name });
} else {
if (!this.newTags.find(entry => entry.name === tag)) {
this.newTags.push({ name: tag });
}
result.push({ name: tag });
}
}
return result;
}
/**
* Creates all new Users needed for the import.
*
* @returns a promise with list of new Submitters, updated with newly created ids
*/
private async createNewUsers(): Promise<CsvMapping[]> {
const promises: Promise<CsvMapping>[] = [];
for (const user of this.newSubmitters) {
promises.push(this.userRepo.createFromString(user.name));
}
return await Promise.all(promises);
}
/**
* Creates all new Motion Blocks needed for the import.
*
* @returns a promise with list of new MotionBlocks, updated with newly created ids
*/
private async createNewMotionBlocks(): Promise<CsvMapping[]> {
const promises: Promise<CsvMapping>[] = [];
for (const block of this.newMotionBlocks) {
promises.push(
this.motionBlockRepo.create(new MotionBlock({ title: block.name })).then(identifiable => {
return { name: block.name, id: identifiable.id };
})
);
}
return await Promise.all(promises);
}
/**
* Creates all new Categories needed for the import.
*
* @returns a promise with list of new Categories, updated with newly created ids
*/
private async createNewCategories(): Promise<CsvMapping[]> {
const promises: Promise<CsvMapping>[] = [];
for (const category of this.newCategories) {
const cat = this.splitCategoryString(category.name);
promises.push(
this.categoryRepo
.create(
new Category({
name: cat.name,
prefix: cat.prefix ? cat.prefix : null
})
)
.then(identifiable => {
return { name: category.name, id: identifiable.id };
})
);
}
return await Promise.all(promises);
}
/**
* Combines all tags which are new created to one promise.
*
* @returns {Promise} One promise containing all promises to create a new tag.
*/
private async createNewTags(): Promise<CsvMapping[]> {
const promises: Promise<CsvMapping>[] = [];
for (const tag of this.newTags) {
promises.push(
this.tagRepo
.create(new Tag({ name: tag.name }))
.then(identifiable => ({ name: tag.name, id: identifiable.id }))
);
}
return await Promise.all(promises);
}
/**
* Helper to separate a category string from its' prefix. Assumes that a prefix is no longer
* than 5 chars and separated by a ' - '
*
* @param categoryString the string to parse
* @returns an object with .prefix and .name strings
*/
private splitCategoryString(categoryString: string): { prefix: string; name: string } {
let prefixSeparator = ' - ';
if (categoryString.startsWith(prefixSeparator)) {
prefixSeparator = prefixSeparator.substring(1);
}
categoryString = categoryString.trim();
let prefix = '';
const separatorIndex = categoryString.indexOf(prefixSeparator);
if (separatorIndex >= 0 && separatorIndex < 6) {
prefix = categoryString.substring(0, separatorIndex);
categoryString = categoryString.substring(separatorIndex + prefixSeparator.length);
}
return { prefix: prefix, name: categoryString };
}
}