Subversion Repositories SmartDukaan

Rev

Details | Last modification | View Log | RSS feed

Rev Author Line No. Line
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
}