TwoDa.java

// <editor-fold defaultstate="collapsed" desc="license">
/*
 * Copyright (c) 2018, Karl H. Beckers
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * * Redistributions of source code must retain the above copyright notice,
 *   this list of conditions and the following disclaimer.
 * * Redistributions in binary form must reproduce the above copyright notice,
 *   this list of conditions and the following disclaimer in the documentation
 *   and/or other materials provided with the distribution.
 * * Neither the name of the <ORGANIZATION> nor the names of its contributors
 *   may be used to endorse or promote products derived from this software
 *   without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
 * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
 * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
 * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
 * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
 * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
 * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
 * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 * POSSIBILITY OF SUCH DAMAGE.
 **/
// </editor-fold>
package net.jarre_de_the.griffin.file;

import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import net.jarre_de_the.griffin.Util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 *
 * @author charly4711
 */
public class TwoDa extends PersistableNwnFile implements EmbeddableNwnFile {

    private static final Logger LOGGER = LoggerFactory.getLogger(TwoDa.class);

    public static final String EMPTY_FIELD = "****";
    public static final String DEFAULT_FILE_TYPE = "2DA ";
    public static final String DEFAULT_FILE_VERSION = "V2.0";

    private long embeddedOffset = 0L;
    private long embeddedLength = 0L;
    private String defaultValue = null;
    private List<String> headers = new ArrayList<String>();
    private String[][] data;

    @Override
    public void parse(RandomAccessFile in) throws IOException {
        long bytesToRead = embeddedLength;

        // it is not exactly optimal to first read the whole 2DA into a
        // string and then parse that, again, but let's revisit that, later.
        StringBuilder stringResult = new StringBuilder();
        int bufLen = (int) Math.min(bytesToRead, Integer.MAX_VALUE);
        byte[] buf = new byte[bufLen];

        do {
            if (buf.length != bufLen) {
                buf = new byte[bufLen];
            }

            in.readFully(buf);
            stringResult.append(new String(buf, Util.CHARSET_US_ASCII));
            bytesToRead -= bufLen;
            bufLen = (int) Math.min(bytesToRead, Integer.MAX_VALUE);

        } while (bytesToRead > 0);

        String contents = stringResult.toString();
//        System.out.println("ALL: " + contents);

        // phase 2 parse string
        String[] lines = ((String) contents).split("\r\n|\r|\n");
        int firstTableLine = 2;

        if (lines[0].length() >= 4) {
            setFileType(lines[0].substring(0, 4));
        }
        if (lines[0].length() >= 8) {
            setFileVersion(lines[0].substring(4, 8));
        }

        // It would have been too easy, if Bioware would have
        // adhered to their own specifications
        if (lines[1].startsWith("DEFAULT")) {
            if (lines[1].length() >= 8) {
                defaultValue = lines[1].substring(8);
            }
            if (defaultValue != null) {
                defaultValue = defaultValue.trim();
            }
            if (defaultValue != null && !defaultValue.isEmpty()) {
                if (defaultValue.charAt(0) == '"') {
                    defaultValue = defaultValue.substring(1);
                }
                if (defaultValue.charAt(defaultValue.length() - 1) == '"') {
                    defaultValue = defaultValue.substring(0, defaultValue.length() - 1);
                }
            }
        } else if (!lines[1].trim().isEmpty()) {
            firstTableLine--;
        }

        int skippedLines = 0;
        for (int i = firstTableLine; i < lines.length; i++) {
            String line = lines[i].trim();
            LOGGER.debug("line: " + line);

            if (null == line || line.isEmpty()) {
                if (i <= firstTableLine) {
                    firstTableLine++;
                } else {
                    skippedLines++;
                }
                continue;
            }
            List<String> tokens = new ArrayList<String>();

            boolean quoted = false;
            for (int lstart = 0, lend = 0; lend < line.length(); lend++) {
                // this is what you get if you insist that data files
                // should be human readable: tabs where the specs say only
                // spaces are supported. Let's be happy the devs didn't
                // copy past special chars or fonts from Excel
                if (line.charAt(lstart) == ' ' || line.charAt(lstart) == '\t') {
                    lstart++;
                    continue;
                }
                if (line.charAt(lend) == '"') {
                    quoted = (!(quoted));
                }
                if (((!quoted) && (line.charAt(lend) == ' ' || line.charAt(lend) == '\t'))
                        || lend >= line.length() - 1) {
                    String token = line.substring(lstart, lend + 1).trim();
                    // remove quotes so the String is unquoted to work with
                    // and won't be quoted twice when persisted
                    if (token.charAt(0) == '"') {
                        token = token.substring(1);
                    }
                    if (token.charAt(token.length() - 1) == '"') {
                        token = token.substring(0, token.length() - 1);
                    }

                    tokens.add(token);
                    lstart = lend + 1;
                }
            }
            if (i == firstTableLine) {
                data = new String[lines.length - (firstTableLine + 1)][tokens.size()];
                headers.addAll(tokens);
            } else {
                for (int n = 1; n < (headers.size() + 1); n++) {
                    // some files are bugged and have unquoted whitespace
                    // we could try to work around it, but I don't know how
                    // the game itself treats this, so we stick to the specification
                    if (n < tokens.size()) {
                        data[i - (firstTableLine + skippedLines + 1)][n - 1] = tokens.get(n);
                    }
                }
            }
        }
        // if we skipped empty lines, we cut them off the end off the array
        if (skippedLines > 0) {
            String[][] linesMinusSkipped = new String[data.length - skippedLines][];
            System.arraycopy(data, 0, linesMinusSkipped, 0, linesMinusSkipped.length);
            data = linesMinusSkipped;
        }

        if (LOGGER.isDebugEnabled()) {
            for (String[] line : data) {
                StringBuilder sb = new StringBuilder();
                for (String cell : line) {
                    sb.append(cell).append("|");
                }
                LOGGER.debug(sb.toString());
            }
        }
    }

    @Override
    public long getEmbeddedOffset() {
        return embeddedOffset;
    }

    @Override
    public void setEmbeddedOffset(long offset) {
        this.embeddedOffset = offset;
    }

    @Override
    public long getEmbeddedLength() {
        return embeddedLength;
    }

    @Override
    public void setEmbeddedLength(long length) {
        this.embeddedLength = length;
    }

    private static String quote(String string) {
        String result = string;
        if (null == string || string.isEmpty()) {
            result = EMPTY_FIELD;
        } else if (string.contains(" ")) {
            result = "\"" + string + "\"";
        }
        return result;
    }

    private static String pad(String string, int len) {
        StringBuilder sb = new StringBuilder().append(string);
        for (int i = 0; i < len - string.length(); i++) {
            sb.append(" ");
        }
        return sb.toString();
    }

    private int maxWidthInColumn(int col) {
        int result = 0;

        for (int row = 0; row < data.length; row++) {
            if (col >= 0 && data[row].length > col) {
                String cell = data[row][col];
                int cellLentgh = 0;

                if (null == cell || cell.isEmpty()) {
                    cellLentgh = quote(EMPTY_FIELD).length();
                } else {
                    cellLentgh = quote(cell).length();
                }
                if (cellLentgh > result) {
                    result = cellLentgh;
                }
            } else if (col >= 0) {
                int cellLentgh = quote(EMPTY_FIELD).length();

                if (cellLentgh > result) {
                    result = cellLentgh;
                }
            } else {
                result = -1;
            }
        }

        if (col >= 0 && null != headers && col < headers.size()
                && null != headers.get(col)
                && headers.get(col).length() > result) {
            result = headers.get(col).length();
        }
        result++;
        return result;
    }

    @Override
    public byte[] persist() throws IOException {
        StringBuilder output = new StringBuilder();
        String eol = "\r\n";

        // file header
        output.append(getFileType()).append(getFileVersion()).append(eol);
        if (defaultValue != null && !defaultValue.isEmpty()) {
            output.append("DEFAULT: ").append(quote(defaultValue)).append(eol);
        } else {
            output.append(eol);
        }

        // determin column widths
        int[] columnWidths = new int[headers.size()];
        for (int col = 0; col < headers.size(); col++) {
            columnWidths[col] = maxWidthInColumn(col);
        }

        // table header
        int startFirstColumn = ("" + data.length).length() + 1;
        output.append(pad(" ", startFirstColumn));
        for (int col = 0; col < headers.size(); col++) {
            output.append(pad(quote(headers.get(col)), columnWidths[col]));
        }
        output.append(eol);

        // table data
        for (int row = 0; row < data.length; row++) {
            output.append(pad("" + row, startFirstColumn));

            for (int col = 0; col < headers.size(); col++) {
                output.append(pad(quote(data[row][col]), columnWidths[col]));
            }
            output.append(eol);
        }

        return output.toString().getBytes(Util.CHARSET_US_ASCII);
    }

    public String getDefaultValue() {
        return defaultValue;
    }

    public void setDefaultValue(String value) {
        this.defaultValue = value;
    }

    public String[][] getData() {
        return data;
    }

    public void setData(String[][] data) {
        if (null != data) {
            this.data = new String[data.length][];

            for (int line = 0; line < data.length; line++) {
                if (null != data[line]) {
                    this.data[line] = new String[data[line].length];
                    System.arraycopy(data[line], 0, this.data[line], 0, data[line].length);
                }
            }
        } else {
            this.data = null;
        }
    }

    public List<String> getHeaders() {
        return headers;
    }

    public void setHeaders(List<String> headers) {
        this.headers = headers;
    }

    /**
     * Return the column number with the requested header.
     *
     * @param header The header of the column
     * @return the index of the column or -1 if the header was not found.
     */
    public int getColumn(String header) {
        int result = -1;

        if (null != header) {
            for (int col = 0; col < headers.size(); col++) {
                if (header.equals(headers.get(col))) {
                    result = col;
                    break;
                }
            }
        }
        return result;
    }

    /**
     * Return the index of the row that has the requested value in the column
     * specified.
     *
     * @param column the column to look at
     * @param value the value to look for
     * @return the index of the row or -1 if the value was not found
     */
    public int getRowByCellValue(int column, String value) {
        int result = -1;
        if (column >= 0 && column < headers.size() && null != value) {
            for (int row = 0; row < data.length; row++) {
                if (data[row].length > column && value.equals(data[row][column])) {
                    result = row;
                    break;
                }
            }
        }
        return result;
    }

    /**
     * Returns a copy of the column specified. To manipulate the data, you have
     * to use getData.
     *
     * @param column
     * @return
     */
    public String[] getColumnValues(int column) {
        String[] result = null;
        if (column >= 0 && column < headers.size()) {
            result = new String[data.length];
            for (int row = 0; row < data.length; row++) {
                if (data[row].length > column) {
                    result[row] = data[row][column];
                }
            }
        }
        return result;
    }

    @Override
    public TwoDa clone() throws CloneNotSupportedException {
        TwoDa clone = (TwoDa) super.clone();

        clone.setEmbeddedOffset(getEmbeddedOffset());
        clone.setEmbeddedLength(getEmbeddedLength());
        // type not mutable
        // version not mutable
        clone.setDefaultValue(getDefaultValue());

        List<String> headers = new ArrayList<String>();
        headers.addAll(getHeaders());
        clone.setHeaders(headers);

        String[][] dataClone = new String[getData().length][];
        for (int i = 0; i < getData().length; i++) {
            dataClone[i] = getData()[i].clone();
        }
        clone.setData(dataClone);

        return clone;
    }

    @Override
    public boolean equals(Object compare) {
        if (compare == this) {
            return true;
        }

        if (!super.equals(compare)) {
            return false;
        }

        if (!(compare instanceof TwoDa)) {
            return false;
        }

        TwoDa file = (TwoDa) compare;

        // default value
        if (!((null != this.getDefaultValue() && this.getDefaultValue().equals(file.getDefaultValue()))
                || (null == this.getDefaultValue() && null == file.getDefaultValue())
                || (null == this.getDefaultValue() && null != file.getDefaultValue() && file.getDefaultValue().isEmpty())
                || (null != this.getDefaultValue() && this.getDefaultValue().isEmpty() && null == file.getDefaultValue()))) {
            return false;
        }

        return areNullablePropertiesEqual(this.getHeaders(), file.getHeaders())
                && areNullablePropertiesEqual(this.getData(), file.getData());
        // because we normalize files, both the offset and the resource
        // length may differ, though files are semantically the same
        //
//                && this.getEmbeddedLength() == file.getEmbeddedLength()
//                && this.getEmbeddedOffset() == file.getEmbeddedOffset();
    }

    @Override
    public int hashCode() {
        int hash = super.hashCode();
        hash = 97 * hash + (int) (this.embeddedOffset ^ (this.embeddedOffset >>> 32));
        hash = 97 * hash + (int) (this.embeddedLength ^ (this.embeddedLength >>> 32));
        hash = 97 * hash + Objects.hashCode(this.headers);
        hash = 97 * hash + Arrays.deepHashCode(this.data);
        return hash;
    }
}