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;
}
}