| 37651 |
vikas |
1 |
package com.spice.profitmandi.service.lms.meta;
|
|
|
2 |
|
|
|
3 |
import com.spice.profitmandi.common.util.ExcelUtils;
|
|
|
4 |
import org.apache.commons.csv.CSVFormat;
|
|
|
5 |
import org.apache.commons.csv.CSVParser;
|
|
|
6 |
import org.apache.commons.csv.CSVRecord;
|
|
|
7 |
import org.apache.logging.log4j.LogManager;
|
|
|
8 |
import org.apache.logging.log4j.Logger;
|
|
|
9 |
import org.apache.poi.ss.usermodel.Cell;
|
|
|
10 |
import org.apache.poi.ss.usermodel.Row;
|
|
|
11 |
import org.apache.poi.ss.usermodel.Sheet;
|
|
|
12 |
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
|
|
|
13 |
import org.springframework.stereotype.Component;
|
|
|
14 |
import org.w3c.dom.Document;
|
|
|
15 |
import org.w3c.dom.Element;
|
|
|
16 |
import org.w3c.dom.Node;
|
|
|
17 |
import org.w3c.dom.NodeList;
|
|
|
18 |
|
|
|
19 |
import javax.xml.XMLConstants;
|
|
|
20 |
import javax.xml.parsers.DocumentBuilderFactory;
|
|
|
21 |
import java.io.ByteArrayInputStream;
|
|
|
22 |
import java.io.InputStreamReader;
|
|
|
23 |
import java.nio.charset.StandardCharsets;
|
|
|
24 |
import java.util.ArrayList;
|
|
|
25 |
import java.util.LinkedHashMap;
|
|
|
26 |
import java.util.List;
|
|
|
27 |
import java.util.Map;
|
|
|
28 |
|
|
|
29 |
/**
|
|
|
30 |
* Reads a Meta lead-ads export into header-keyed rows.
|
|
|
31 |
*
|
|
|
32 |
* <p><b>Why this exists rather than a call to POI.</b> Meta's export is named {@code .xls} but is
|
|
|
33 |
* actually <b>SpreadsheetML 2003</b> — plain XML under
|
|
|
34 |
* {@code urn:schemas-microsoft-com:office:spreadsheet}. Neither HSSF nor XSSF can open it, and every
|
|
|
35 |
* other spreadsheet reader in this codebase hard-codes {@code new XSSFWorkbook(...)}, which throws on
|
|
|
36 |
* it. So the format is detected from the <em>content</em>, never the file extension — this file is
|
|
|
37 |
* the proof that the extension lies.
|
|
|
38 |
*
|
|
|
39 |
* <p><b>Rows are keyed by header name, not column index.</b> A Meta form's custom questions become
|
|
|
40 |
* columns, so two forms produce different layouts; positional access would silently read the wrong
|
|
|
41 |
* field the first time someone exports a different form.
|
|
|
42 |
*/
|
|
|
43 |
@Component
|
|
|
44 |
public class MetaLeadFileParser {
|
|
|
45 |
|
|
|
46 |
private static final Logger LOGGER = LogManager.getLogger(MetaLeadFileParser.class);
|
|
|
47 |
|
|
|
48 |
private static final String SPREADSHEETML_NS = "urn:schemas-microsoft-com:office:spreadsheet";
|
|
|
49 |
|
|
|
50 |
/** Enough bytes to recognise a format without buffering an entire upload twice. */
|
|
|
51 |
private static final int SNIFF_BYTES = 512;
|
|
|
52 |
|
|
|
53 |
/** What the parser produced, plus anything the caller should be told about the file itself. */
|
|
|
54 |
public static class ParseResult {
|
|
|
55 |
public final List<String> headers;
|
|
|
56 |
/** One map per data row, keyed by header. Missing cells are present as empty strings. */
|
|
|
57 |
public final List<Map<String, String>> rows;
|
|
|
58 |
public final String detectedFormat;
|
|
|
59 |
|
|
|
60 |
ParseResult(List<String> headers, List<Map<String, String>> rows, String detectedFormat) {
|
|
|
61 |
this.headers = headers;
|
|
|
62 |
this.rows = rows;
|
|
|
63 |
this.detectedFormat = detectedFormat;
|
|
|
64 |
}
|
|
|
65 |
}
|
|
|
66 |
|
|
|
67 |
/**
|
|
|
68 |
* @param content the whole uploaded file
|
|
|
69 |
* @param fileName used only for error messages — never for format detection
|
|
|
70 |
* @throws IllegalArgumentException with a message fit to show a user
|
|
|
71 |
*/
|
|
|
72 |
public ParseResult parse(byte[] content, String fileName) {
|
|
|
73 |
if (content == null || content.length == 0) {
|
|
|
74 |
throw new IllegalArgumentException("The uploaded file is empty.");
|
|
|
75 |
}
|
|
|
76 |
String head = new String(content, 0, Math.min(SNIFF_BYTES, content.length), StandardCharsets.UTF_8);
|
|
|
77 |
|
|
|
78 |
try {
|
|
|
79 |
if (head.contains(SPREADSHEETML_NS)) {
|
|
|
80 |
return parseSpreadsheetMl(content);
|
|
|
81 |
}
|
|
|
82 |
if (content.length > 1 && content[0] == 'P' && content[1] == 'K') {
|
|
|
83 |
return parseXlsx(content);
|
|
|
84 |
}
|
|
|
85 |
if (content.length > 7 && (content[0] & 0xFF) == 0xD0 && (content[1] & 0xFF) == 0xCF) {
|
|
|
86 |
// Genuine legacy BIFF .xls. Meta does not produce these; say so plainly rather than
|
|
|
87 |
// letting POI throw something unreadable.
|
|
|
88 |
throw new IllegalArgumentException("This is a legacy binary .xls file. "
|
|
|
89 |
+ "Re-export from Meta, or save it as .xlsx or .csv, and upload again.");
|
|
|
90 |
}
|
|
|
91 |
return parseCsv(content);
|
|
|
92 |
} catch (IllegalArgumentException e) {
|
|
|
93 |
throw e;
|
|
|
94 |
} catch (Exception e) {
|
|
|
95 |
LOGGER.error("Could not parse the uploaded Meta lead file {}", fileName, e);
|
|
|
96 |
throw new IllegalArgumentException("Could not read the file: " + e.getMessage());
|
|
|
97 |
}
|
|
|
98 |
}
|
|
|
99 |
|
|
|
100 |
// ---- SpreadsheetML 2003 (what Meta actually exports) --------------------------------------
|
|
|
101 |
|
|
|
102 |
private ParseResult parseSpreadsheetMl(byte[] content) throws Exception {
|
|
|
103 |
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
|
|
104 |
factory.setNamespaceAware(true);
|
|
|
105 |
// The file is third-party input, so external entities stay off (XXE).
|
|
|
106 |
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
|
|
|
107 |
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
|
|
108 |
factory.setExpandEntityReferences(false);
|
|
|
109 |
|
|
|
110 |
Document doc = factory.newDocumentBuilder().parse(new ByteArrayInputStream(content));
|
|
|
111 |
NodeList rowNodes = doc.getElementsByTagNameNS(SPREADSHEETML_NS, "Row");
|
|
|
112 |
if (rowNodes.getLength() == 0) {
|
|
|
113 |
throw new IllegalArgumentException("The spreadsheet has no rows.");
|
|
|
114 |
}
|
|
|
115 |
|
|
|
116 |
List<List<String>> table = new ArrayList<>();
|
|
|
117 |
for (int i = 0; i < rowNodes.getLength(); i++) {
|
|
|
118 |
table.add(spreadsheetMlCells((Element) rowNodes.item(i)));
|
|
|
119 |
}
|
|
|
120 |
return toResult(table, "SpreadsheetML 2003");
|
|
|
121 |
}
|
|
|
122 |
|
|
|
123 |
/**
|
|
|
124 |
* Excel omits empty cells and signals the jump with {@code ss:Index}, so cells must be placed at
|
|
|
125 |
* their declared position. Reading them in document order would shift every later column left —
|
|
|
126 |
* which is exactly how a phone number ends up in the city field.
|
|
|
127 |
*/
|
|
|
128 |
private List<String> spreadsheetMlCells(Element row) {
|
|
|
129 |
List<String> values = new ArrayList<>();
|
|
|
130 |
NodeList children = row.getChildNodes();
|
|
|
131 |
for (int i = 0; i < children.getLength(); i++) {
|
|
|
132 |
Node node = children.item(i);
|
|
|
133 |
if (node.getNodeType() != Node.ELEMENT_NODE
|
|
|
134 |
|| !"Cell".equals(node.getLocalName())
|
|
|
135 |
|| !SPREADSHEETML_NS.equals(node.getNamespaceURI())) {
|
|
|
136 |
continue;
|
|
|
137 |
}
|
|
|
138 |
Element cell = (Element) node;
|
|
|
139 |
String index = cell.getAttributeNS(SPREADSHEETML_NS, "Index");
|
|
|
140 |
if (index != null && !index.isEmpty()) {
|
|
|
141 |
int target = Integer.parseInt(index) - 1;
|
|
|
142 |
while (values.size() < target) {
|
|
|
143 |
values.add("");
|
|
|
144 |
}
|
|
|
145 |
}
|
|
|
146 |
values.add(firstDataText(cell));
|
|
|
147 |
}
|
|
|
148 |
return values;
|
|
|
149 |
}
|
|
|
150 |
|
|
|
151 |
private String firstDataText(Element cell) {
|
|
|
152 |
NodeList children = cell.getChildNodes();
|
|
|
153 |
for (int i = 0; i < children.getLength(); i++) {
|
|
|
154 |
Node node = children.item(i);
|
|
|
155 |
if (node.getNodeType() == Node.ELEMENT_NODE
|
|
|
156 |
&& "Data".equals(node.getLocalName())
|
|
|
157 |
&& SPREADSHEETML_NS.equals(node.getNamespaceURI())) {
|
|
|
158 |
String text = node.getTextContent();
|
|
|
159 |
return text == null ? "" : text.trim();
|
|
|
160 |
}
|
|
|
161 |
}
|
|
|
162 |
return "";
|
|
|
163 |
}
|
|
|
164 |
|
|
|
165 |
// ---- xlsx ---------------------------------------------------------------------------------
|
|
|
166 |
|
|
|
167 |
private ParseResult parseXlsx(byte[] content) throws Exception {
|
|
|
168 |
List<List<String>> table = new ArrayList<>();
|
|
|
169 |
try (XSSFWorkbook workbook = new XSSFWorkbook(new ByteArrayInputStream(content))) {
|
|
|
170 |
Sheet sheet = workbook.getSheetAt(0);
|
|
|
171 |
if (sheet == null) {
|
|
|
172 |
throw new IllegalArgumentException("The workbook has no sheets.");
|
|
|
173 |
}
|
|
|
174 |
for (Row row : sheet) {
|
|
|
175 |
List<String> values = new ArrayList<>();
|
|
|
176 |
// getLastCellNum, not the cell iterator: the iterator skips undefined cells, which
|
|
|
177 |
// would shift columns exactly as the ss:Index case above.
|
|
|
178 |
short last = row.getLastCellNum();
|
|
|
179 |
for (int c = 0; c < last; c++) {
|
|
|
180 |
Cell cell = row.getCell(c);
|
|
|
181 |
values.add(cell == null ? "" : ExcelUtils.getCellValue(cell).trim());
|
|
|
182 |
}
|
|
|
183 |
table.add(values);
|
|
|
184 |
}
|
|
|
185 |
}
|
|
|
186 |
return toResult(table, "xlsx");
|
|
|
187 |
}
|
|
|
188 |
|
|
|
189 |
// ---- csv ----------------------------------------------------------------------------------
|
|
|
190 |
|
|
|
191 |
private ParseResult parseCsv(byte[] content) throws Exception {
|
|
|
192 |
List<List<String>> table = new ArrayList<>();
|
|
|
193 |
// Explicit UTF-8 rather than the platform default, and the BOM is stripped — Excel writes
|
|
|
194 |
// one when saving CSV, and it would otherwise corrupt the first header name.
|
|
|
195 |
int offset = (content.length > 2 && (content[0] & 0xFF) == 0xEF
|
|
|
196 |
&& (content[1] & 0xFF) == 0xBB && (content[2] & 0xFF) == 0xBF) ? 3 : 0;
|
|
|
197 |
try (CSVParser parser = new CSVParser(
|
|
|
198 |
new InputStreamReader(
|
|
|
199 |
new ByteArrayInputStream(content, offset, content.length - offset),
|
|
|
200 |
StandardCharsets.UTF_8),
|
|
|
201 |
CSVFormat.DEFAULT)) {
|
|
|
202 |
for (CSVRecord record : parser) {
|
|
|
203 |
List<String> values = new ArrayList<>();
|
|
|
204 |
for (int i = 0; i < record.size(); i++) {
|
|
|
205 |
String v = record.get(i);
|
|
|
206 |
values.add(v == null ? "" : v.trim());
|
|
|
207 |
}
|
|
|
208 |
table.add(values);
|
|
|
209 |
}
|
|
|
210 |
}
|
|
|
211 |
return toResult(table, "csv");
|
|
|
212 |
}
|
|
|
213 |
|
|
|
214 |
// ---- shared -------------------------------------------------------------------------------
|
|
|
215 |
|
|
|
216 |
private ParseResult toResult(List<List<String>> table, String format) {
|
|
|
217 |
if (table.isEmpty()) {
|
|
|
218 |
throw new IllegalArgumentException("The file has no rows.");
|
|
|
219 |
}
|
|
|
220 |
List<String> headers = new ArrayList<>();
|
|
|
221 |
for (String raw : table.get(0)) {
|
|
|
222 |
headers.add(raw == null ? "" : raw.trim());
|
|
|
223 |
}
|
|
|
224 |
if (headers.isEmpty()) {
|
|
|
225 |
throw new IllegalArgumentException("The first row has no column headings.");
|
|
|
226 |
}
|
|
|
227 |
if (table.size() < 2) {
|
|
|
228 |
throw new IllegalArgumentException("The file has column headings but no data rows.");
|
|
|
229 |
}
|
|
|
230 |
|
|
|
231 |
List<Map<String, String>> rows = new ArrayList<>();
|
|
|
232 |
for (int i = 1; i < table.size(); i++) {
|
|
|
233 |
List<String> cells = table.get(i);
|
|
|
234 |
if (isBlank(cells)) {
|
|
|
235 |
// Trailing blank rows are normal in exports; they are not data and not errors.
|
|
|
236 |
continue;
|
|
|
237 |
}
|
|
|
238 |
Map<String, String> row = new LinkedHashMap<>();
|
|
|
239 |
for (int c = 0; c < headers.size(); c++) {
|
|
|
240 |
String header = headers.get(c);
|
|
|
241 |
if (header.isEmpty()) {
|
|
|
242 |
continue;
|
|
|
243 |
}
|
|
|
244 |
row.put(header, c < cells.size() && cells.get(c) != null ? cells.get(c) : "");
|
|
|
245 |
}
|
|
|
246 |
rows.add(row);
|
|
|
247 |
}
|
|
|
248 |
LOGGER.info("Parsed a {} Meta lead file: {} columns, {} data rows", format, headers.size(), rows.size());
|
|
|
249 |
return new ParseResult(headers, rows, format);
|
|
|
250 |
}
|
|
|
251 |
|
|
|
252 |
private boolean isBlank(List<String> cells) {
|
|
|
253 |
for (String c : cells) {
|
|
|
254 |
if (c != null && !c.trim().isEmpty()) {
|
|
|
255 |
return false;
|
|
|
256 |
}
|
|
|
257 |
}
|
|
|
258 |
return true;
|
|
|
259 |
}
|
|
|
260 |
}
|