From 70343a4e446eefd332a1e379f1359399e7aa2799 Mon Sep 17 00:00:00 2001 From: Robin Duda Date: Sun, 28 Oct 2018 15:07:32 +0100 Subject: [PATCH] Add support for parsing CSV files. --- README.md | 9 +- excelastic.png | Bin 26108 -> 27997 bytes pom.xml | 2 +- .../com/codingchili/ApplicationLauncher.java | 2 +- .../codingchili/Controller/CommandLine.java | 6 +- .../com/codingchili/Controller/Website.java | 8 +- .../java/com/codingchili/Model/CSVParser.java | 220 +++++++++++++++ .../ColumnsExceededHeadersException.java | 19 ++ .../com/codingchili/Model/ExcelParser.java | 259 ++++++++++++++++++ .../com/codingchili/Model/FileParser.java | 250 ++--------------- .../com/codingchili/Model/ImportEvent.java | 4 +- .../codingchili/Model/ImportEventCodec.java | 2 +- .../Model/InvalidFileNameException.java | 16 ++ .../com/codingchili/Model/ParserFactory.java | 60 ++++ .../Model/UnsupportedFileTypeException.java | 16 ++ src/main/resources/templates/index.jade | 12 +- src/test/java/TestParser.java | 69 ----- .../codingchili}/TestConfiguration.java | 44 +-- src/test/java/com/codingchili/TestParser.java | 111 ++++++++ .../{ => com/codingchili}/TestWebsite.java | 164 +++++------ .../{ => com/codingchili}/TestWriter.java | 130 +++++---- src/test/{java => resources}/invalid.xlsx | 0 src/test/resources/test.csv | 3 + src/test/{java => resources}/test.xls | Bin src/test/{java => resources}/test.xlsx | Bin 25 files changed, 913 insertions(+), 493 deletions(-) create mode 100644 src/main/java/com/codingchili/Model/CSVParser.java create mode 100644 src/main/java/com/codingchili/Model/ColumnsExceededHeadersException.java create mode 100644 src/main/java/com/codingchili/Model/ExcelParser.java create mode 100644 src/main/java/com/codingchili/Model/InvalidFileNameException.java create mode 100644 src/main/java/com/codingchili/Model/ParserFactory.java create mode 100644 src/main/java/com/codingchili/Model/UnsupportedFileTypeException.java delete mode 100644 src/test/java/TestParser.java rename src/test/java/{ => com/codingchili}/TestConfiguration.java (95%) create mode 100644 src/test/java/com/codingchili/TestParser.java rename src/test/java/{ => com/codingchili}/TestWebsite.java (96%) rename src/test/java/{ => com/codingchili}/TestWriter.java (83%) rename src/test/{java => resources}/invalid.xlsx (100%) create mode 100644 src/test/resources/test.csv rename src/test/{java => resources}/test.xls (100%) rename src/test/{java => resources}/test.xlsx (100%) diff --git a/README.md b/README.md index 40b9a51..61414a5 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,13 @@ Tested with ElasticSearch 5.6.2 and 6.2.3. Running the application, filename and index is required, to import from the terminal run: ``` -java -Xmx2g -jar excelastic-1.2.7.jar --mapping mappingName --clear +java -Xmx2g -jar excelastic-1.3.0.jar --mapping mappingName --clear ``` If running with --clear, then the existing index will be cleared before the import starts. To run with the web interface, run the following in your terminal: ``` -java -Xmx2g -jar excelastic-1.2.7.jar +java -Xmx2g -jar excelastic-1.3.0.jar ``` When the application successfully connects to the ElasticSearch server, the browser will automatically open a new tab. @@ -67,10 +67,7 @@ If no configuration file is present a new configuration file will be created usi ## Contributing -If you want to contribute to this project, open an issue or pull request. :: - -In the 1.2.7 release we have cleaned up the code and added even more javadoc -in order to promote contributions! :astonished: +If you want to contribute to this project, open an issue or pull request. :heart_eyes_cat: :metal: --- diff --git a/excelastic.png b/excelastic.png index 6100d72bc80a34a7728fb331fe23d8e0037bdcad..dfdf5066030643c74cdf4ef4910540de9af8c77e 100644 GIT binary patch literal 27997 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 literal 26108 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 V`keAozvcom.codingchili excelastic - 1.2.7 + 1.3.0 diff --git a/src/main/java/com/codingchili/ApplicationLauncher.java b/src/main/java/com/codingchili/ApplicationLauncher.java index c5d346b..11d275f 100644 --- a/src/main/java/com/codingchili/ApplicationLauncher.java +++ b/src/main/java/com/codingchili/ApplicationLauncher.java @@ -21,7 +21,7 @@ */ public class ApplicationLauncher { private final ApplicationLogger logger = new ApplicationLogger(getClass()); - public static String VERSION = "1.2.7"; + public static String VERSION = "1.3.0"; private Vertx vertx; public static void main(String[] args) { diff --git a/src/main/java/com/codingchili/Controller/CommandLine.java b/src/main/java/com/codingchili/Controller/CommandLine.java index 3523313..a6cc298 100644 --- a/src/main/java/com/codingchili/Controller/CommandLine.java +++ b/src/main/java/com/codingchili/Controller/CommandLine.java @@ -46,9 +46,11 @@ private void importFile(ImportEvent event, String fileName) { logger.loadingFromFilesystem(fileName); logger.parsingStarted(); try { - FileParser parser = new FileParser(new File(fileName), 1, fileName); + FileParser parser = ParserFactory.getByFilename(fileName); + parser.setFileData(fileName, 1, fileName); + event.setParser(parser); - parser.assertFileParsable(); + parser.initialize(); logger.importStarted(event.getIndex()); vertx.eventBus().send(Configuration.INDEXING_ELASTICSEARCH, event, getDeliveryOpts(), diff --git a/src/main/java/com/codingchili/Controller/Website.java b/src/main/java/com/codingchili/Controller/Website.java index f17cb9f..a77c9db 100644 --- a/src/main/java/com/codingchili/Controller/Website.java +++ b/src/main/java/com/codingchili/Controller/Website.java @@ -20,7 +20,7 @@ import static com.codingchili.ApplicationLauncher.VERSION; import static com.codingchili.Model.Configuration.INDEXING_ELASTICSEARCH; import static com.codingchili.Model.ElasticWriter.*; -import static com.codingchili.Model.FileParser.INDEX; +import static com.codingchili.Model.ExcelParser.INDEX; /** * @author Robin Duda @@ -172,8 +172,10 @@ private void parse(String uploadedFileName, MultiMap params, String fileName, Fu vertx.executeBlocking(blocking -> { try { ImportEvent event = ImportEvent.fromParams(params); - FileParser parser = new FileParser(new File(uploadedFileName), event.getOffset(), fileName); - parser.assertFileParsable(); + FileParser parser = ParserFactory.getByFilename(fileName); + parser.setFileData(uploadedFileName, event.getOffset(), fileName); + + parser.initialize(); event.setParser(parser); // submit an import event. diff --git a/src/main/java/com/codingchili/Model/CSVParser.java b/src/main/java/com/codingchili/Model/CSVParser.java new file mode 100644 index 0000000..17882bc --- /dev/null +++ b/src/main/java/com/codingchili/Model/CSVParser.java @@ -0,0 +1,220 @@ +package com.codingchili.Model; + +import io.vertx.core.json.JsonObject; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import java.io.*; +import java.nio.ByteBuffer; +import java.nio.MappedByteBuffer; +import java.nio.channels.FileChannel; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * @author Robin Duda + *

+ * Parses CSV files. + */ +public class CSVParser implements FileParser { + private static final int MAX_LINE_LENGTH = 16384; + private static final int PAGE_16MB = 16777216; + + private static final char TOKEN_NULL = '\0'; + private static final char TOKEN_CR = '\r'; + private static final char TOKEN_LF = '\n'; + private static final char TOKEN_QUOTE = '\"'; + private static final char TOKEN_SEPARATOR = ','; + + private ByteBuffer buffer = ByteBuffer.allocate(MAX_LINE_LENGTH); + private JsonObject headers = new JsonObject(); + private Iterator header; + private RandomAccessFile file; + private MappedByteBuffer map; + private long fileSize; + private int index = 0; + private int rows = 0; + + @Override + public void setFileData(String localFileName, int offset, String fileName) throws FileNotFoundException { + file = new RandomAccessFile(localFileName, "rw"); + try { + map = file.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, PAGE_16MB); + fileSize = file.length(); + readRowCount(); + readHeaders(); + } catch (IOException e) { + throw new ParserException(e); + } + } + + @Override + public Set getSupportedFileExtensions() { + return new HashSet<>(Collections.singletonList(".csv")); + } + + @Override + public void initialize() { + index = 0; + map.position(0); + readRow(); // skip headers row. + for (int i = 0; i < rows; i++) { + readRow(); + } + } + + private int readRowCount() { + for (int i = map.position(); i < fileSize; i++) { + if (map.get(i) == '\n') { + rows++; + } + } + return rows; + } + + private void readHeaders() throws IOException { + map.position(0); + + for (int i = map.position(); i < file.length(); i++) { + if (map.get(i) == '\n') { + Arrays.stream(new String(buffer.array()).split(",")) + .map(header -> header.replaceAll("\"", "")) + .map(String::trim).forEach(header -> { + headers.put(header, ""); + }); + break; + } else { + buffer.put(map.get(i)); + } + } + buffer.clear(); + } + + private void process(AtomicInteger columnsRead, ByteBuffer buffer, JsonObject json) { + columnsRead.incrementAndGet(); + + if (columnsRead.get() > headers.size()) { + throw new ColumnsExceededHeadersException(columnsRead.get(), headers.size(), index + 1); + } else { + int read = buffer.position(); + byte[] line = new byte[read + 1]; + + buffer.position(0); + buffer.get(line, 0, read); + line[line.length - 1] = '\0'; + + json.put(header.next(), parseDatatype(line)); + buffer.clear(); + } + } + + private JsonObject readRow() { + // reset current header. + header = headers.fieldNames().iterator(); + + AtomicInteger columnsRead = new AtomicInteger(0); + JsonObject json = headers.copy(); + boolean quoted = false; + boolean done = false; + + while (!done) { + byte current = map.get(); + + switch (current) { + case TOKEN_NULL: + // EOF call process. + process(columnsRead, buffer, json); + done = true; + break; + case TOKEN_CR: + case TOKEN_LF: + // final header is being read and EOL appears. + if (columnsRead.get() == headers.size() - 1) { + process(columnsRead, buffer, json); + done = true; + break; + } else { + // skip token if not all headers read. + continue; + } + case TOKEN_QUOTE: + // toggle quoted to support commas within quotes. + quoted = !quoted; + break; + case TOKEN_SEPARATOR: + if (!quoted) { + process(columnsRead, buffer, json); + break; + } + default: + // store the current token in the buffer until the column ends. + buffer.put(current); + } + } + + if (!(columnsRead.get() == headers.size())) { + throw new ParserException( + String.format("Error at line %d, values (%d) does not match headers (%d).", + index, columnsRead.get(), headers.size())); + } else { + index++; + } + + // parse json object. + return json; + } + + private Object parseDatatype(byte[] data) { + String line = new String(data).trim(); + + if (line.matches("[0-9]*")) { + return Integer.parseInt(line); + } else if (line.matches("true|false")) { + return Boolean.parseBoolean(line); + } else { + return line; + } + } + + @Override + public int getNumberOfElements() { + return rows; + } + + @Override + public void subscribe(Subscriber subscriber) { + map.position(0); + readRow(); + index = 0; + + subscriber.onSubscribe(new Subscription() { + private boolean complete = false; + private int index = 0; + + @Override + public void request(long count) { + for (int i = 0; i < count && i < rows; i++) { + JsonObject result = readRow(); + + if (result != null) { + subscriber.onNext(result); + } else { + complete = true; + subscriber.onComplete(); + } + } + + index += count; + + if (index >= rows && !complete) { + subscriber.onComplete(); + } + } + + @Override + public void cancel() { + // send no more items! + } + }); + } +} diff --git a/src/main/java/com/codingchili/Model/ColumnsExceededHeadersException.java b/src/main/java/com/codingchili/Model/ColumnsExceededHeadersException.java new file mode 100644 index 0000000..6e6a778 --- /dev/null +++ b/src/main/java/com/codingchili/Model/ColumnsExceededHeadersException.java @@ -0,0 +1,19 @@ +package com.codingchili.Model; + +/** + * @author Robin Duda + * + * Thrown when more columns are encountered than there is headers. + */ +public class ColumnsExceededHeadersException extends ParserException { + + /** + * @param values number of values encountered + * @param headers the number of headers on the first row. + * @param index the line in the file. + */ + public ColumnsExceededHeadersException(int values, int headers, int index) { + super(String.format("Encountered too many values (%d) on row %d, expected to match headers (%d).", + values, index, headers)); + } +} diff --git a/src/main/java/com/codingchili/Model/ExcelParser.java b/src/main/java/com/codingchili/Model/ExcelParser.java new file mode 100644 index 0000000..cac3b20 --- /dev/null +++ b/src/main/java/com/codingchili/Model/ExcelParser.java @@ -0,0 +1,259 @@ +package com.codingchili.Model; + +import com.codingchili.logging.ApplicationLogger; +import io.vertx.core.json.JsonObject; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; +import org.apache.poi.openxml4j.exceptions.InvalidFormatException; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.reactivestreams.*; + +import java.io.*; +import java.util.*; +import java.util.function.Consumer; + +/** + * @author Robin Duda + *

+ * Parses xlsx files into json objects. + */ +public class ExcelParser implements FileParser { + public static final String INDEX = "index"; + private static final String OOXML = ".xlsx"; + private static final String XML97 = ".xls"; + private ApplicationLogger logger = new ApplicationLogger(getClass()); + private String fileName; + private Sheet sheet; + private int columns; + private int offset; + private int rows; + + @Override + public void setFileData(String localFileName, int offset, String fileName) + throws ParserException, FileNotFoundException { + + File file = new File(localFileName); + offset -= 1; // convert excel row number to 0-based index. + + if (file.exists()) { + try { + Workbook workbook = getWorkbook(file, fileName); + this.sheet = workbook.getSheetAt(0); + this.offset = offset; + this.fileName = fileName; + this.columns = getColumnCount(sheet.getRow(offset)); + this.rows = getItemCount(sheet, offset); + } catch (Exception e) { + if (e instanceof ParserException) { + throw (ParserException) e; + } else { + throw new ParserException(e); + } + } + } else { + throw new FileNotFoundException(file.getAbsolutePath()); + } + } + + @Override + public Set getSupportedFileExtensions() { + return new HashSet<>(Arrays.asList(OOXML, XML97)); + } + + /** + * Returns a workbook implementation based on the extension of the filname. + * + * @param file stream representing a workbook + * @param fileName the filename to determine a specific workbook implementation + * @return a workbook implentation that supports the given file format + * @throws ParserException when the file extension is unsupported + * @throws IOException when the given data is not a valid workbook + */ + private Workbook getWorkbook(File file, String fileName) throws ParserException, IOException { + if (fileName.endsWith(OOXML)) { + try { + return new XSSFWorkbook(file); + } catch (InvalidFormatException e) { + throw new ParserException(e); + } + } else if (fileName.endsWith(XML97)) { + return new HSSFWorkbook(new FileInputStream(file)); + } else { + throw new ParserException( + String.format("Unrecognized file extension for file %s, expected %s or %s.", + fileName, OOXML, XML97)); + } + } + + @Override + public void initialize() { + logger.parsingFile(fileName, offset); + + // parse all rows. + readRows((json) -> { + // skip storing the results of the parse. + }, offset, rows, true); + + logger.parsedFile(rows - 1, fileName); + } + + @Override + public void subscribe(Subscriber subscriber) { + subscriber.onSubscribe(new Subscription() { + private int index = 0; + + @Override + public void request(long count) { + readRows(subscriber::onNext, index, count, false); + index += count; + + if (index >= rows) { + subscriber.onComplete(); + } + } + + @Override + public void cancel() { + // send no more items! + } + }); + } + + /** + * Parses the given portion of the excel file, this saves memory as the whole file + * does not need to be stored in memory as JSON at once. + * + * @param begin the offset from the starting row. + * @param count the number of lines to parse. + * @param consumer processor of json items. + */ + public void parseRowRange(int begin, int count, Consumer consumer) { + readRows(consumer, begin, begin + count, false); + } + + + @Override + public int getNumberOfElements() { + return rows; + } + + /** + * Reads the given range of rows and converts it to json. + * + * @param start the starting element, 0 represents the first row after the row with the column titles. + * @param count the number of elements to read - can never read past the max number of rows. + * @param consumer called with the produced JSON object for each parsed row. + */ + private void readRows(Consumer consumer, int start, long count, boolean dryRun) { + String[] columns = getColumns(sheet.getRow(offset)); + + for (int i = start; i < (count + start) && i < rows; i++) { + consumer.accept(getRow(columns, sheet.getRow(i + offset + 1), dryRun)); + } + } + + /** + * retrieves the values of the column titles. + * + * @param row that points to the column titles. + * @return an array of the titles + */ + private String[] getColumns(Row row) { + String[] titles = new String[columns]; + + for (int i = 0; i < titles.length; i++) { + titles[i] = row.getCell(i).getStringCellValue(); + } + return titles; + } + + /** + * Returns the number of columns present on the given row. + * + * @param row the row to read column count from. + * @return the number of columns on the given row + */ + private int getColumnCount(Row row) { + DataFormatter formatter = new DataFormatter(); + Iterator iterator = row.iterator(); + int count = 0; + + while (iterator.hasNext()) { + Cell cell = iterator.next(); + String value = formatter.formatCellValue(cell); + + if (value.length() > 0) { + count++; + } else { + break; + } + } + return count; + } + + /** + * counts the number of rows to be imported taking into account the offset + * of the title columns. + * + * @param sheet the sheet to read items from + * @param offset the offset of the title columns + * @return the number of rows minus the column title offset. + */ + private int getItemCount(Sheet sheet, int offset) { + int count = 0; + Row row = sheet.getRow(offset + 1); + + while (row != null) { + count++; + row = sheet.getRow(offset + 1 + count); + } + + return count; + } + + /** + * retrieves a row as a json object. + * + * @param titles the titles of the row. + * @param row the row to read values from. + * @param dryRun if true no results will be generated and this method returns null. + * @return a jsonobject that maps titles to the column values. + */ + private JsonObject getRow(String[] titles, Row row, boolean dryRun) { + DataFormatter formatter = new DataFormatter(); + JsonObject json = null; + int index = 0; + + if (!dryRun) { + json = new JsonObject(); + } + + for (int i = 0; i < row.getLastCellNum(); i++) { + Cell cell = row.getCell(i); + Object value = null; + + if (cell != null) { + switch (cell.getCellTypeEnum()) { + case STRING: + value = formatter.formatCellValue(cell); + break; + case NUMERIC: + if (DateUtil.isCellDateFormatted(cell)) { + value = cell.getDateCellValue().toInstant().toString(); + } else { + value = cell.getNumericCellValue(); + } + break; + } + // avoid indexing null or empty string, fails to index rows + // when date fields are empty and can lead to mappings being + // set up incorrectly if leading rows has missing data. + if (!dryRun && value != null && !(value.toString().length() == 0)) { + json.put(titles[index], value); + } + } + index++; + } + return json; + } +} diff --git a/src/main/java/com/codingchili/Model/FileParser.java b/src/main/java/com/codingchili/Model/FileParser.java index f120cdb..99b8396 100644 --- a/src/main/java/com/codingchili/Model/FileParser.java +++ b/src/main/java/com/codingchili/Model/FileParser.java @@ -1,260 +1,42 @@ package com.codingchili.Model; -import com.codingchili.logging.ApplicationLogger; import io.vertx.core.json.JsonObject; -import org.apache.poi.hssf.usermodel.HSSFWorkbook; -import org.apache.poi.openxml4j.exceptions.InvalidFormatException; -import org.apache.poi.ss.usermodel.*; -import org.apache.poi.xssf.usermodel.XSSFWorkbook; -import org.reactivestreams.*; +import org.reactivestreams.Publisher; -import java.io.*; -import java.util.Iterator; -import java.util.function.Consumer; +import java.io.FileNotFoundException; +import java.util.Set; /** * @author Robin Duda *

- * Parses xlsx files into json objects. + * Interface used to support different input file formats. + * The parser is subscribable and emits json objects for importing. */ -public class FileParser implements Publisher { - public static final String INDEX = "index"; - private static final String OOXML = ".xlsx"; - private static final String XML97 = ".xls"; - private ApplicationLogger logger = new ApplicationLogger(getClass()); - private String fileName; - private Sheet sheet; - private int columns; - private int offset; - private int rows; +public interface FileParser extends Publisher { /** - * Parses the contents of an excel into JSON. - * - * @param file file of an excel file. - * @param offset row number containing column titles. + * @param localFileName a file on disk to be parsed, do not read this into memory + * as it could be potentially very large. + * @param offset indicates how many empty rows to skip before finding the titles. + * @param fileName the original name of the file to be imported. */ - public FileParser(File file, int offset, String fileName) throws ParserException, FileNotFoundException { - offset -= 1; // convert excel row number to 0-based index. - - if (file.exists()) { - try { - Workbook workbook = getWorkbook(file, fileName); - this.sheet = workbook.getSheetAt(0); - this.offset = offset; - this.fileName = fileName; - this.columns = getColumnCount(sheet.getRow(offset)); - this.rows = getItemCount(sheet, offset); - } catch (Exception e) { - if (e instanceof ParserException) { - throw (ParserException) e; - } else { - throw new ParserException(e); - } - } - } else { - throw new FileNotFoundException(file.getAbsolutePath()); - } - } + void setFileData(String localFileName, int offset, String fileName) throws FileNotFoundException; /** - * Returns a workbook implementation based on the extension of the filname. - * - * @param file stream representing a workbook - * @param fileName the filename to determine a specific workbook implementation - * @return a workbook implentation that supports the given file format - * @throws ParserException when the file extension is unsupported - * @throws IOException when the given data is not a valid workbook + * @return a set of file extensions that this fileparser supports. */ - private Workbook getWorkbook(File file, String fileName) throws ParserException, IOException { - if (fileName.endsWith(OOXML)) { - try { - return new XSSFWorkbook(file); - } catch (InvalidFormatException e) { - throw new ParserException(e); - } - } else if (fileName.endsWith(XML97)) { - return new HSSFWorkbook(new FileInputStream(file)); - } else { - throw new ParserException( - String.format("Unrecognized file extension for file %s, expected %s or %s.", - fileName, OOXML, XML97)); - } - } - - @Override - public void subscribe(Subscriber subscriber) { - subscriber.onSubscribe(new Subscription() { - private int index = 0; - - @Override - public void request(long count) { - readRows(index, count, subscriber::onNext, false); - index += count; - - if (index >= rows) { - subscriber.onComplete(); - } - } - - @Override - public void cancel() { - // send no more items! - } - }); - } + Set getSupportedFileExtensions(); /** * Parses the excel file to make sure that it is parseable without allocating memory - * for the result. This should be called before{@link #parseRowRange(int, int, Consumer)} to make + * for the result. This should be called before importing to make * sure any imports does not fail halfway through. */ - public void assertFileParsable() { - logger.parsingFile(fileName, offset); - - // parse all rows. - readRows(offset, rows, (json) -> { - // skip storing the results of the parse. - }, true); - logger.parsedFile(rows - 1, fileName); - } - - /** - * Parses the given portion of the excel file, this saves memory as the whole file - * does not need to be stored in memory as JSON at once. - * - * @param begin the offset from the starting row. - * @param count the number of lines to parse. - * @param consumer processor of json items. - */ - public void parseRowRange(int begin, int count, Consumer consumer) { - readRows(begin, begin + count, consumer, false); - } + void initialize(); /** * @return the number of elements that was parsed. */ - public int getNumberOfElements() { - return rows; - } - - /** - * Reads the given range of rows and converts it to json. - * - * @param start the starting element, 0 represents the first row after the row with the column titles. - * @param count the number of elements to read - can never read past the max number of rows. - * @param consumer called with the produced JSON object for each parsed row. - */ - private void readRows(int start, long count, Consumer consumer, boolean dryRun) { - String[] columns = getColumns(sheet.getRow(offset)); - - for (int i = start; i < (count + start) && i < rows; i++) { - consumer.accept(getRow(columns, sheet.getRow(i + offset + 1), dryRun)); - } - } - - /** - * retrieves the values of the column titles. - * - * @param row that points to the column titles. - * @return an array of the titles - */ - private String[] getColumns(Row row) { - String[] titles = new String[columns]; - - for (int i = 0; i < titles.length; i++) { - titles[i] = row.getCell(i).getStringCellValue(); - } - return titles; - } - - /** - * Returns the number of columns present on the given row. - * - * @param row the row to read column count from. - * @return the number of columns on the given row - */ - private int getColumnCount(Row row) { - DataFormatter formatter = new DataFormatter(); - Iterator iterator = row.iterator(); - int count = 0; - - while (iterator.hasNext()) { - Cell cell = iterator.next(); - String value = formatter.formatCellValue(cell); - - if (value.length() > 0) { - count++; - } else { - break; - } - } - return count; - } - - /** - * counts the number of rows to be imported taking into account the offset - * of the title columns. - * - * @param sheet the sheet to read items from - * @param offset the offset of the title columns - * @return the number of rows minus the column title offset. - */ - private int getItemCount(Sheet sheet, int offset) { - int count = 0; - Row row = sheet.getRow(offset + 1); - - while (row != null) { - count++; - row = sheet.getRow(offset + 1 + count); - } - - return count; - } - - /** - * retrieves a row as a json object. - * - * @param titles the titles of the row. - * @param row the row to read values from. - * @param dryRun if true no results will be generated and this method returns null. - * @return a jsonobject that maps titles to the column values. - */ - private JsonObject getRow(String[] titles, Row row, boolean dryRun) { - DataFormatter formatter = new DataFormatter(); - JsonObject json = null; - int index = 0; - - if (!dryRun) { - json = new JsonObject(); - } - - for (int i = 0; i < row.getLastCellNum(); i++) { - Cell cell = row.getCell(i); - Object value = null; + int getNumberOfElements(); - if (cell != null) { - switch (cell.getCellTypeEnum()) { - case STRING: - value = formatter.formatCellValue(cell); - break; - case NUMERIC: - if (DateUtil.isCellDateFormatted(cell)) { - value = cell.getDateCellValue().toInstant().toString(); - } else { - value = cell.getNumericCellValue(); - } - break; - } - // avoid indexing null or empty string, fails to index rows - // when date fields are empty and can lead to mappings being - // set up incorrectly if leading rows has missing data. - if (!dryRun && value != null && !(value.toString().length() == 0)) { - json.put(titles[index], value); - } - } - index++; - } - return json; - } } diff --git a/src/main/java/com/codingchili/Model/ImportEvent.java b/src/main/java/com/codingchili/Model/ImportEvent.java index 56e7a3c..b952f09 100644 --- a/src/main/java/com/codingchili/Model/ImportEvent.java +++ b/src/main/java/com/codingchili/Model/ImportEvent.java @@ -6,7 +6,7 @@ import java.util.Optional; import static com.codingchili.Controller.Website.UPLOAD_ID; -import static com.codingchili.Model.FileParser.INDEX; +import static com.codingchili.Model.ExcelParser.INDEX; /** * @author Robin Duda @@ -53,7 +53,7 @@ public static ImportEvent fromCommandLineArgs(String[] args) { return new ImportEvent() .setIndex(args[1]) .setOffset(getArgParamValue(args, ARG_OFFSET).map(Integer::parseInt).orElse(1)) - .setClearExisting(Arrays.stream(args).anyMatch(param -> param.equals(ARG_CLEAR))) + .setClearExisting(Arrays.asList(args).contains(ARG_CLEAR)) .setMapping(getArgParamValue(args, ARG_MAPPING).orElse("default")); } diff --git a/src/main/java/com/codingchili/Model/ImportEventCodec.java b/src/main/java/com/codingchili/Model/ImportEventCodec.java index c05a7ce..7bcfac5 100644 --- a/src/main/java/com/codingchili/Model/ImportEventCodec.java +++ b/src/main/java/com/codingchili/Model/ImportEventCodec.java @@ -7,7 +7,7 @@ /** * @author Robin Duda *

- * This codec is used to transfer a {@link FileParser} reference over the local event bus. + * This codec is used to transfer a {@link ExcelParser} reference over the local event bus. */ public class ImportEventCodec implements MessageCodec { diff --git a/src/main/java/com/codingchili/Model/InvalidFileNameException.java b/src/main/java/com/codingchili/Model/InvalidFileNameException.java new file mode 100644 index 0000000..e0b78e2 --- /dev/null +++ b/src/main/java/com/codingchili/Model/InvalidFileNameException.java @@ -0,0 +1,16 @@ +package com.codingchili.Model; + +/** + * @author Robin Duda + * + * Thrown when an invalid filename has been specified. + */ +public class InvalidFileNameException extends RuntimeException { + + /** + * @param fileName the full filename. + */ + public InvalidFileNameException(String fileName) { + super(String.format("File with name '%s' is missing extension.", fileName)); + } +} diff --git a/src/main/java/com/codingchili/Model/ParserFactory.java b/src/main/java/com/codingchili/Model/ParserFactory.java new file mode 100644 index 0000000..e90a45d --- /dev/null +++ b/src/main/java/com/codingchili/Model/ParserFactory.java @@ -0,0 +1,60 @@ +package com.codingchili.Model; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +/** + * @author Robin Duda + *

+ * Handles support for multiple file formats. + */ +public class ParserFactory { + private static final Map> parsers = new ConcurrentHashMap<>(); + + static { + register(ExcelParser::new); + register(CSVParser::new); + } + + /** + * @param parser the given parser is instantiated and registered for use with its + * supported file extensions. + */ + public static void register(Supplier parser) { + for (String ext : parser.get().getSupportedFileExtensions()) { + parsers.put(ext, parser); + } + } + + /** + * Retrieves a file parser that is registered for the file extension in the given filename. + * + * @param fileName a filename that contains an extension. + * @return a parser that is registered for use with the given extension, throws an + * exception if no parser exists or if the file does not have an extension. + */ + public static FileParser getByFilename(String fileName) { + int extensionAt = fileName.lastIndexOf("."); + + if (extensionAt > 0) { + // include the dot separator in the extension. + String extension = fileName.substring(extensionAt); + + if (parsers.containsKey(extension)) { + return parsers.get(extension).get(); + } else { + throw new UnsupportedFileTypeException(extension); + } + } else { + throw new InvalidFileNameException(fileName); + } + } + + /** + * @return a list of file extensions that is registered in the parser factory. + */ + public static Set getSupportedExtensions() { + return parsers.keySet(); + } +} diff --git a/src/main/java/com/codingchili/Model/UnsupportedFileTypeException.java b/src/main/java/com/codingchili/Model/UnsupportedFileTypeException.java new file mode 100644 index 0000000..8edf9ea --- /dev/null +++ b/src/main/java/com/codingchili/Model/UnsupportedFileTypeException.java @@ -0,0 +1,16 @@ +package com.codingchili.Model; + +/** + * @author Robin Duda + * + * Thrown when a parser has not been registered for the given file extension. + */ +public class UnsupportedFileTypeException extends RuntimeException { + + /** + * @param extension the file extension that was unsupported. + */ + public UnsupportedFileTypeException(String extension) { + super(String.format("Missing parser for file extension '%s'.", extension)); + } +} diff --git a/src/main/resources/templates/index.jade b/src/main/resources/templates/index.jade index fcae9e1..5276b06 100644 --- a/src/main/resources/templates/index.jade +++ b/src/main/resources/templates/index.jade @@ -25,16 +25,16 @@ html(lang='en') input#uploadId(hidden='true', value='', name='uploadId') fieldset .form-group - label.col-lg-2.control-label(for='index') Index - .col-lg-10 + label.col-lg-3.control-label(for='index') Index + .col-lg-9 input#index.form-control(type='text', name='index', placeholder='generate date') .form-group - label.col-lg-2.control-label(for='mapping') Mapping - .col-lg-10 + label.col-lg-3.control-label(for='mapping') Mapping + .col-lg-9 input#index.form-control(type='text', name='mapping', placeholder='default') .form-group - label.col-lg-2.control-label(for='offset') Title-row - .col-lg-10 + label.col-lg-3.control-label(for='offset') Title-row (excel) + .col-lg-9 input#offset.form-control(type='text', name='offset', value='1') .form-group label.col-lg-2.control-label(for='clear') diff --git a/src/test/java/TestParser.java b/src/test/java/TestParser.java deleted file mode 100644 index f2654b0..0000000 --- a/src/test/java/TestParser.java +++ /dev/null @@ -1,69 +0,0 @@ -import com.codingchili.Model.FileParser; -import com.codingchili.Model.ParserException; -import io.vertx.core.json.JsonArray; -import io.vertx.core.json.JsonObject; -import io.vertx.ext.unit.TestContext; -import io.vertx.ext.unit.junit.VertxUnitRunner; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.*; -import java.nio.file.Files; -import java.nio.file.Paths; - -/** - * @author Robin Duda - */ -@RunWith(VertxUnitRunner.class) -public class TestParser { - public static final String TEST_XLSX_FILE = "src/test/java/test.xlsx"; - public static final String TEST_XLS_FILE = "src/test/java/test.xls"; - public static final String TEST_INVALID_FILE = "src/test/java/invalid.xlsx"; - public static final int ROW_OFFSET = 5; - private static final String XLSX = ".xlsx"; - - @Test - public void failParseInvalid() throws Exception { - try { - new FileParser(new File(TEST_INVALID_FILE), 5, XLSX); - throw new Exception("Should fail for invalid bytes."); - } catch (ParserException ignored) { - } - } - - @Test - public void testParseOOXML(TestContext context) throws IOException, ParserException { - testParseFile(context, TEST_XLSX_FILE); - } - - @Test - public void testParse2007(TestContext context) throws IOException, ParserException { - testParseFile(context, TEST_XLS_FILE); - } - - private void testParseFile(TestContext context, String fileName) throws IOException, ParserException { - FileParser parser = new FileParser( - Paths.get(fileName).toFile(), - ROW_OFFSET, - fileName - ); - - parser.assertFileParsable(); - - JsonArray list = new JsonArray(); - parser.parseRowRange(0, parser.getNumberOfElements(), list::add); - - context.assertEquals(2, list.size()); - - for (int i = 0; i < list.size(); i++) { - JsonObject json = list.getJsonObject(i); - context.assertTrue(json.containsKey("Column 1")); - context.assertTrue(json.containsKey("Column 2")); - context.assertTrue(json.containsKey("Column 3")); - - context.assertEquals("cell " + (ROW_OFFSET + 1 + i) + "." + 1, json.getString("Column 1")); - context.assertEquals("cell " + (ROW_OFFSET + 1 + i) + "." + 2, json.getString("Column 2")); - context.assertEquals("cell " + (ROW_OFFSET + 1 + i) + "." + 3, json.getString("Column 3")); - } - } -} diff --git a/src/test/java/TestConfiguration.java b/src/test/java/com/codingchili/TestConfiguration.java similarity index 95% rename from src/test/java/TestConfiguration.java rename to src/test/java/com/codingchili/TestConfiguration.java index 3be557c..88a411d 100644 --- a/src/test/java/TestConfiguration.java +++ b/src/test/java/com/codingchili/TestConfiguration.java @@ -1,21 +1,23 @@ -import com.codingchili.Model.Configuration; -import io.vertx.ext.unit.TestContext; -import io.vertx.ext.unit.junit.VertxUnitRunner; -import org.junit.Test; -import org.junit.runner.RunWith; - -/** - * @author Robin Duda - */ - -@RunWith(VertxUnitRunner.class) -public class TestConfiguration { - - @Test - public void shouldLoadConfiguration(TestContext context) { - context.assertNotNull(Configuration.getWebPort()); - context.assertNotNull(Configuration.getElasticPort()); - context.assertNotNull(Configuration.getElasticHost()); - } - -} +package com.codingchili; + +import com.codingchili.Model.Configuration; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import org.junit.Test; +import org.junit.runner.RunWith; + +/** + * @author Robin Duda + */ + +@RunWith(VertxUnitRunner.class) +public class TestConfiguration { + + @Test + public void shouldLoadConfiguration(TestContext context) { + context.assertNotNull(Configuration.getWebPort()); + context.assertNotNull(Configuration.getElasticPort()); + context.assertNotNull(Configuration.getElasticHost()); + } + +} diff --git a/src/test/java/com/codingchili/TestParser.java b/src/test/java/com/codingchili/TestParser.java new file mode 100644 index 0000000..8592847 --- /dev/null +++ b/src/test/java/com/codingchili/TestParser.java @@ -0,0 +1,111 @@ +package com.codingchili; + +import com.codingchili.Model.*; +import io.vertx.core.json.JsonArray; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.reactivestreams.Subscriber; +import org.reactivestreams.Subscription; + +import java.io.IOException; + +/** + * @author Robin Duda + */ +@RunWith(VertxUnitRunner.class) +public class TestParser { + private static final String TEST_XLSX_FILE = "/test.xlsx"; + static final String TEST_XLS_FILE = "/test.xls"; + private static final String TEST_INVALID_FILE = "/invalid.xlsx"; + static final int ROW_OFFSET = 5; + private static final String XLSX = ".xlsx"; + private static final String TEST_CSV = "/test.csv"; + + @Test + public void failParseInvalid() throws Exception { + try { + new ExcelParser().setFileData(toPath(TEST_INVALID_FILE), 5, XLSX); + throw new Exception("Should fail for invalid bytes."); + } catch (ParserException ignored) { + } + } + + @Test(expected = InvalidFileNameException.class) + public void testParseMissingExt() { + ParserFactory.getByFilename("file"); + } + + @Test(expected = UnsupportedFileTypeException.class) + public void testParseMissingParser() { + ParserFactory.getByFilename("file.xxx"); + } + + @Test + public void testParseOOXML(TestContext context) throws IOException { + testParseFile(context, TEST_XLSX_FILE); + } + + @Test + public void testParse2007(TestContext context) throws IOException { + testParseFile(context, TEST_XLS_FILE); + } + + @Test + public void testParseCSV(TestContext context) throws IOException { + testParseFile(context, TEST_CSV); + } + + private void testParseFile(TestContext context, String fileName) throws IOException, ParserException { + FileParser parser = ParserFactory.getByFilename(fileName); + parser.setFileData( + toPath(fileName), + ROW_OFFSET, + fileName + ); + + parser.initialize(); + + parser.subscribe(new Subscriber() { + JsonArray list = new JsonArray(); + + + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(3); + } + + @Override + public void onNext(JsonObject entry) { + list.add(entry); + } + + @Override + public void onError(Throwable throwable) { + + } + + @Override + public void onComplete() { + context.assertEquals(2, list.size()); + + for (int i = 0; i < list.size(); i++) { + JsonObject json = list.getJsonObject(i); + context.assertTrue(json.containsKey("Column 1")); + context.assertTrue(json.containsKey("Column 2")); + context.assertTrue(json.containsKey("Column 3")); + + context.assertEquals("cell " + (ROW_OFFSET + 1 + i) + "." + 1, json.getString("Column 1")); + context.assertEquals("cell " + (ROW_OFFSET + 1 + i) + "." + 2, json.getString("Column 2")); + context.assertEquals("cell " + (ROW_OFFSET + 1 + i) + "." + 3, json.getString("Column 3")); + } + } + }); + } + + private static String toPath(String resource) { + return TestParser.class.getResource(resource).getPath(); + } +} diff --git a/src/test/java/TestWebsite.java b/src/test/java/com/codingchili/TestWebsite.java similarity index 96% rename from src/test/java/TestWebsite.java rename to src/test/java/com/codingchili/TestWebsite.java index 8886904..e5bc3ca 100644 --- a/src/test/java/TestWebsite.java +++ b/src/test/java/com/codingchili/TestWebsite.java @@ -1,81 +1,83 @@ -import com.codingchili.Controller.Website; -import com.codingchili.Model.Configuration; -import io.vertx.core.Vertx; -import io.vertx.core.json.JsonObject; -import io.vertx.ext.unit.Async; -import io.vertx.ext.unit.TestContext; -import io.vertx.ext.unit.junit.Timeout; -import io.vertx.ext.unit.junit.VertxUnitRunner; -import org.junit.*; -import org.junit.runner.RunWith; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; - -/** - * @author Robin Duda - */ -@RunWith(VertxUnitRunner.class) -public class TestWebsite { - private Vertx vertx; - - @Before - public void setUp(TestContext context) { - vertx = Vertx.vertx(); - vertx.deployVerticle(new Website(), context.asyncAssertSuccess()); - } - - @After - public void tearDown(TestContext context) { - vertx.close(context.asyncAssertSuccess()); - } - - @Rule - public Timeout timeout = Timeout.seconds(5); - - @Test - public void shouldGetStartPage(TestContext context) { - Async async = context.async(); - - vertx.createHttpClient().getNow(Configuration.getWebPort(), "localhost", "/", response -> { - context.assertEquals(200, response.statusCode()); - async.complete(); - }); - } - - @Ignore("The file must be recognized as a file on the server side, test broken.") - public void shouldSucceedUpload(TestContext context) throws IOException { - Async async = context.async(); - - vertx.createHttpClient().post(Configuration.getWebPort(), "localhost", "/api/upload", response -> { - response.bodyHandler(body -> { - context.assertTrue(body.toString().contains("Done")); - context.assertEquals(200, response.statusCode()); - async.complete(); - }); - }).putHeader("content-type", "multipart/form-data").end(new JsonObject() - .put("index", "test") - .put("offset", 5) - .put("file", getFileBytes()) - .encode()); - } - - private byte[] getFileBytes() throws IOException { - return Files.readAllBytes(Paths.get("src/test/java/test.xlsx")); - } - - @Test - public void shouldFailUpload(TestContext context) { - Async async = context.async(); - - vertx.createHttpClient().post(Configuration.getWebPort(), "localhost", "/api/upload", response -> { - response.bodyHandler(body -> { - context.assertTrue(body.toString().contains("error")); - context.assertEquals(200, response.statusCode()); - async.complete(); - }); - }).end(); - } - -} +package com.codingchili; + +import com.codingchili.Controller.Website; +import com.codingchili.Model.Configuration; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.Timeout; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import org.junit.*; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Paths; + +/** + * @author Robin Duda + */ +@RunWith(VertxUnitRunner.class) +public class TestWebsite { + private Vertx vertx; + + @Before + public void setUp(TestContext context) { + vertx = Vertx.vertx(); + vertx.deployVerticle(new Website(), context.asyncAssertSuccess()); + } + + @After + public void tearDown(TestContext context) { + vertx.close(context.asyncAssertSuccess()); + } + + @Rule + public Timeout timeout = Timeout.seconds(5); + + @Test + public void shouldGetStartPage(TestContext context) { + Async async = context.async(); + + vertx.createHttpClient().getNow(Configuration.getWebPort(), "localhost", "/", response -> { + context.assertEquals(200, response.statusCode()); + async.complete(); + }); + } + + @Ignore("The file must be recognized as a file on the server side, test broken.") + public void shouldSucceedUpload(TestContext context) throws IOException { + Async async = context.async(); + + vertx.createHttpClient().post(Configuration.getWebPort(), "localhost", "/api/upload", response -> { + response.bodyHandler(body -> { + context.assertTrue(body.toString().contains("Done")); + context.assertEquals(200, response.statusCode()); + async.complete(); + }); + }).putHeader("content-type", "multipart/form-data").end(new JsonObject() + .put("index", "test") + .put("offset", 5) + .put("file", getFileBytes()) + .encode()); + } + + private byte[] getFileBytes() throws IOException { + return Files.readAllBytes(Paths.get("src/test/java/test.xlsx")); + } + + @Test + public void shouldFailUpload(TestContext context) { + Async async = context.async(); + + vertx.createHttpClient().post(Configuration.getWebPort(), "localhost", "/api/upload", response -> { + response.bodyHandler(body -> { + context.assertTrue(body.toString().contains("error")); + context.assertEquals(200, response.statusCode()); + async.complete(); + }); + }).end(); + } + +} diff --git a/src/test/java/TestWriter.java b/src/test/java/com/codingchili/TestWriter.java similarity index 83% rename from src/test/java/TestWriter.java rename to src/test/java/com/codingchili/TestWriter.java index dd75d03..065b5cf 100644 --- a/src/test/java/TestWriter.java +++ b/src/test/java/com/codingchili/TestWriter.java @@ -1,66 +1,64 @@ -import com.codingchili.Model.*; -import io.vertx.core.Vertx; -import io.vertx.core.json.JsonArray; -import io.vertx.core.json.JsonObject; -import io.vertx.ext.unit.Async; -import io.vertx.ext.unit.TestContext; -import io.vertx.ext.unit.junit.Timeout; -import io.vertx.ext.unit.junit.VertxUnitRunner; -import org.junit.After; -import org.junit.Before; -import org.junit.Rule; -import org.junit.Test; -import org.junit.runner.RunWith; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; - -/** - * @author Robin Duda - */ -@RunWith(VertxUnitRunner.class) -public class TestWriter { - private Vertx vertx; - - @Before - public void setUp(TestContext context) { - vertx = Vertx.vertx(); - ImportEventCodec.registerOn(vertx); - vertx.deployVerticle(new ElasticWriter(), context.asyncAssertSuccess()); - } - - @Rule - public Timeout timeout = Timeout.seconds(5); - - @After - public void tearDown(TestContext context) { - vertx.close(context.asyncAssertSuccess()); - } - - @Test - public void shouldWriteToElasticPort(TestContext context) throws IOException { - Async async = context.async(); - - vertx.createHttpServer().requestHandler(request -> { - - request.bodyHandler(body -> { - context.assertTrue(body.toString() != null); - async.complete(); - }); - }).listen(Configuration.getElasticPort()); - - FileParser fileParser = new FileParser( - Paths.get(TestParser.TEST_XLS_FILE).toFile(), - TestParser.ROW_OFFSET, - "testFileName.xls"); - - vertx.eventBus().send(Configuration.INDEXING_ELASTICSEARCH, new ImportEvent() - .setParser(fileParser) - .setIndex("text-index") - .setClearExisting(false) - .setMapping("test-mapping")); - } - -} +package com.codingchili; + +import com.codingchili.Model.*; +import io.vertx.core.Vertx; +import io.vertx.ext.unit.Async; +import io.vertx.ext.unit.TestContext; +import io.vertx.ext.unit.junit.Timeout; +import io.vertx.ext.unit.junit.VertxUnitRunner; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.nio.file.Paths; + +/** + * @author Robin Duda + */ +@RunWith(VertxUnitRunner.class) +public class TestWriter { + private Vertx vertx; + + @Before + public void setUp(TestContext context) { + vertx = Vertx.vertx(); + ImportEventCodec.registerOn(vertx); + vertx.deployVerticle(new ElasticWriter(), context.asyncAssertSuccess()); + } + + @Rule + public Timeout timeout = Timeout.seconds(5); + + @After + public void tearDown(TestContext context) { + vertx.close(context.asyncAssertSuccess()); + } + + @Test + public void shouldWriteToElasticPort(TestContext context) throws IOException { + Async async = context.async(); + + vertx.createHttpServer().requestHandler(request -> { + + request.bodyHandler(body -> { + context.assertTrue(body.toString() != null); + async.complete(); + }); + }).listen(Configuration.getElasticPort()); + + ExcelParser fileParser = new ExcelParser(); + fileParser.setFileData(getClass().getResource(TestParser.TEST_XLS_FILE).getPath(), + TestParser.ROW_OFFSET, + "testFileName.xls"); + + vertx.eventBus().send(Configuration.INDEXING_ELASTICSEARCH, new ImportEvent() + .setParser(fileParser) + .setIndex("text-index") + .setClearExisting(false) + .setMapping("test-mapping")); + } + +} diff --git a/src/test/java/invalid.xlsx b/src/test/resources/invalid.xlsx similarity index 100% rename from src/test/java/invalid.xlsx rename to src/test/resources/invalid.xlsx diff --git a/src/test/resources/test.csv b/src/test/resources/test.csv new file mode 100644 index 0000000..6306f62 --- /dev/null +++ b/src/test/resources/test.csv @@ -0,0 +1,3 @@ +Column 1, Column 2, Column 3 +cell 6.1, cell 6.2, cell 6.3 +cell 7.1, cell 7.2, cell 7.3 \ No newline at end of file diff --git a/src/test/java/test.xls b/src/test/resources/test.xls similarity index 100% rename from src/test/java/test.xls rename to src/test/resources/test.xls diff --git a/src/test/java/test.xlsx b/src/test/resources/test.xlsx similarity index 100% rename from src/test/java/test.xlsx rename to src/test/resources/test.xlsx