first commit
This commit is contained in:
parent
352e1653f8
commit
16a197e856
44 changed files with 5942 additions and 3 deletions
914
src/main/java/net/vhati/ftldat/FTLDat.java
Normal file
914
src/main/java/net/vhati/ftldat/FTLDat.java
Normal file
|
@ -0,0 +1,914 @@
|
|||
package net.vhati.ftldat;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.ByteOrder;
|
||||
import java.nio.MappedByteBuffer;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.CharsetEncoder;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Stack;
|
||||
|
||||
import net.vhati.ftldat.FileChannelRegionInputStream;
|
||||
import net.vhati.ftldat.SizeLimitInputStream;
|
||||
|
||||
|
||||
public class FTLDat {
|
||||
|
||||
/**
|
||||
* Splits a path on "/" the way FTL expects them in .dat files.
|
||||
*/
|
||||
public static String[] ftlPathSplit( String path ) {
|
||||
return path.split( "/" );
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates an array of Strings with "/" between them,
|
||||
* as seen in .dat files.
|
||||
* Backslashes will become forward-slashes.
|
||||
*/
|
||||
public static String ftlPathJoin( String[] chunks ) {
|
||||
StringBuilder buf = new StringBuilder();
|
||||
boolean first = true;
|
||||
for ( String chunk : chunks ) {
|
||||
if ( chunk.length() == 0 ) continue;
|
||||
if (first) {
|
||||
buf.append( "/" );
|
||||
first = false;
|
||||
}
|
||||
buf.append( chunk );
|
||||
}
|
||||
return buf.toString().replace("\\", "/");
|
||||
}
|
||||
|
||||
public static String ftlPathJoin( String a, String b ) {
|
||||
StringBuilder buf = new StringBuilder();
|
||||
if ( a.length() > 0 ) buf.append( a );
|
||||
if ( a.length() * b.length() > 0 ) buf.append( "/" );
|
||||
if ( b.length() > 0 ) buf.append( b );
|
||||
return buf.toString().replace("\\", "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies all bytes from one file to another.
|
||||
*/
|
||||
public static void copyFile( File srcFile, File dstFile ) throws IOException {
|
||||
FileInputStream is = null;
|
||||
FileOutputStream os = null;
|
||||
try {
|
||||
is = new FileInputStream( srcFile );
|
||||
os = new FileOutputStream( dstFile );
|
||||
|
||||
byte[] buf = new byte[4096];
|
||||
int len;
|
||||
while ( (len = is.read(buf)) >= 0 ) {
|
||||
os.write( buf, 0, len );
|
||||
}
|
||||
}
|
||||
finally {
|
||||
try {if (is != null) is.close();}
|
||||
catch (IOException e) {}
|
||||
|
||||
try {if (os != null) os.close();}
|
||||
catch (IOException e) {}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates an MD5 hash of data from an InputStream.
|
||||
*
|
||||
* The returned string will be lowercase hexadecimal.
|
||||
*/
|
||||
public static String calcStreamMD5( InputStream is ) throws NoSuchAlgorithmException, IOException {
|
||||
MessageDigest md = MessageDigest.getInstance( "MD5" );
|
||||
byte[] buf = new byte[4096];
|
||||
int len;
|
||||
while ( (len = is.read(buf)) >= 0 ) {
|
||||
md.update( buf, 0, len );
|
||||
}
|
||||
|
||||
byte[] hashBytes = md.digest();
|
||||
StringBuilder hashStringBuf = new StringBuilder();
|
||||
for ( byte b : hashBytes ) {
|
||||
hashStringBuf.append( Integer.toString( (b & 0xff) + 0x100, 16 ).substring(1) );
|
||||
}
|
||||
return hashStringBuf.toString();
|
||||
}
|
||||
|
||||
public static String calcFileMD5( File f ) throws NoSuchAlgorithmException, IOException {
|
||||
String result = null;
|
||||
FileInputStream is = null;
|
||||
try {
|
||||
is = new FileInputStream(f);
|
||||
result = FTLDat.calcStreamMD5( is );
|
||||
}
|
||||
finally {
|
||||
try {if (is != null) is.close();}
|
||||
catch (Exception e) {}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an approximate byte count for humans.
|
||||
*/
|
||||
private String humanReadableByteCount( long bytes, boolean si ) {
|
||||
int unit = si ? 1000 : 1024;
|
||||
if ( bytes < unit ) return bytes +" B";
|
||||
int exp = (int)( Math.log(bytes) / Math.log(unit) );
|
||||
String pre = (si ? "kMGTPE" : "KMGTPE").charAt(exp-1) + (si ? "" : "i");
|
||||
return String.format( "%.1f %sB", (bytes / Math.pow(unit, exp)), pre );
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static class FTLDatException extends RuntimeException {
|
||||
public FTLDatException() {
|
||||
super();
|
||||
}
|
||||
|
||||
public FTLDatException( String message ) {
|
||||
super( message );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Information about an innerFile within a dat.
|
||||
*
|
||||
* entryOffset - Offset (written in header) to
|
||||
* the dataSize + innerPath + data.
|
||||
* innerPath - A virtual location ("dir/dir/filename").
|
||||
* dataOffset - Offset to the innerFile.
|
||||
* dataSize - Size of the innerFile.
|
||||
*/
|
||||
public static class DatEntry {
|
||||
public long entryOffset = 0;
|
||||
public String innerPath = null;
|
||||
public long dataOffset = 0;
|
||||
public long dataSize = 0;
|
||||
|
||||
public DatEntry() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* A holder for (innerPath + size) results from listSizes().
|
||||
*/
|
||||
public static class PathAndSize {
|
||||
public String path = null;
|
||||
public long size = 0;
|
||||
|
||||
public PathAndSize( String path, long size ) {
|
||||
this.path = path;
|
||||
this.size = size;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* A holder for results after repacking a dat.
|
||||
*/
|
||||
public static class RepackResult {
|
||||
public long oldDatLength = 0;
|
||||
public long newDatLength = 0;
|
||||
public long bytesChanged = 0;
|
||||
|
||||
public RepackResult( long oldDatLength, long newDatLength, long bytesChanged ) {
|
||||
this.oldDatLength = oldDatLength;
|
||||
this.newDatLength = newDatLength;
|
||||
this.bytesChanged = bytesChanged;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* An InputStream wrapping a ByteBuffer.
|
||||
*
|
||||
* A memory-mapped region of a file is a ByteBuffer.
|
||||
*/
|
||||
public static class ByteBufferBackedInputStream extends InputStream {
|
||||
ByteBuffer buf;
|
||||
|
||||
public ByteBufferBackedInputStream( ByteBuffer buf ) {
|
||||
this.buf = buf;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized int available() throws IOException {
|
||||
if ( !buf.hasRemaining() ) return 0;
|
||||
return buf.remaining();
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized int read() throws IOException {
|
||||
if ( !buf.hasRemaining() ) return -1;
|
||||
return buf.get() & 0xFF;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized int read( byte[] bytes, int off, int len ) throws IOException {
|
||||
if ( !buf.hasRemaining() ) return -1;
|
||||
len = Math.min( len, buf.remaining() );
|
||||
buf.get(bytes, off, len);
|
||||
return len;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public abstract static class AbstractPack {
|
||||
|
||||
/**
|
||||
* Returns a descriptive name for this dat.
|
||||
*/
|
||||
public String getName() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an list of all innerPaths.
|
||||
*/
|
||||
public List<String> list() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of pairs of (innerPath, filesize).
|
||||
*/
|
||||
public List<PathAndSize> listSizes() {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds bytes read from srcFile to the pack, as innerPath.
|
||||
*/
|
||||
public void add( String innerPath, InputStream is ) throws IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the contents of the file with innerPath to dstFile.
|
||||
*/
|
||||
public void extractTo( String innerPath, OutputStream os ) throws FileNotFoundException, IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the file with innerPath from the pack.
|
||||
*/
|
||||
public void remove( String innerPath ) throws FileNotFoundException, IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether innerPath is in the pack.
|
||||
*/
|
||||
public boolean contains( String innerPath ) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an InputStream get bytes from an innerFile.
|
||||
*
|
||||
* Close all input streams before calling methods to
|
||||
* modify this dat. Do not pass an input stream from
|
||||
* this dat instance into another of its own methods.
|
||||
*/
|
||||
public InputStream getInputStream( String innerPath ) throws FileNotFoundException, IOException {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes this dat and releases any system resources associated with the stream.
|
||||
*/
|
||||
public void close() throws IOException {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* A pseudo-dat backed by a real filesystem.
|
||||
*
|
||||
* Files can be independently added/removed/altered
|
||||
* directly, so long as this class is not busy
|
||||
* at the time.
|
||||
*/
|
||||
public static class FolderPack extends AbstractPack {
|
||||
private File rootDir;
|
||||
|
||||
public FolderPack( File rootDir ) {
|
||||
this.rootDir = rootDir;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return rootDir.getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> list() {
|
||||
List<String> result = new ArrayList<String>();
|
||||
|
||||
Stack<String> pendingPaths = new Stack<String>();
|
||||
pendingPaths.push( "" );
|
||||
|
||||
while ( !pendingPaths.isEmpty() ) {
|
||||
String current = pendingPaths.pop();
|
||||
File tmpFile = new File( rootDir, current );
|
||||
if ( tmpFile.isFile() ) {
|
||||
result.add( current );
|
||||
}
|
||||
else if ( tmpFile.isDirectory() ) {
|
||||
for ( String childName : tmpFile.list() ) {
|
||||
pendingPaths.push( FTLDat.ftlPathJoin(current, childName) );
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PathAndSize> listSizes() {
|
||||
List<PathAndSize> result = new ArrayList<PathAndSize>();
|
||||
List<String> innerPaths = list();
|
||||
for ( String innerPath : innerPaths ) {
|
||||
File tmpFile = getFile( innerPath );
|
||||
result.add( new PathAndSize( innerPath, tmpFile.length() ) );
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void add( String innerPath, InputStream is ) throws IOException {
|
||||
File dstFile = getFile( innerPath );
|
||||
if ( dstFile.exists() ) throw new FTLDatException( "File already exists" );
|
||||
|
||||
dstFile.getParentFile().mkdirs();
|
||||
|
||||
FileOutputStream os = null;
|
||||
try {
|
||||
os = new FileOutputStream( dstFile );
|
||||
|
||||
byte[] buf = new byte[4096];
|
||||
int len;
|
||||
while ( (len = is.read(buf)) >= 0 ) {
|
||||
os.write( buf, 0, len );
|
||||
}
|
||||
}
|
||||
finally {
|
||||
try {if (os != null) os.close();}
|
||||
catch (IOException e) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extractTo( String innerPath, OutputStream os ) throws IOException {
|
||||
File srcFile = getFile( innerPath );
|
||||
|
||||
FileInputStream is = null;
|
||||
try {
|
||||
is = new FileInputStream( srcFile );
|
||||
|
||||
byte[] buf = new byte[4096];
|
||||
int len;
|
||||
while ( (len = is.read(buf)) >= 0 ) {
|
||||
os.write( buf, 0, len );
|
||||
}
|
||||
}
|
||||
finally {
|
||||
try {if (is != null) is.close();}
|
||||
catch (IOException e) {}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove( String innerPath ) {
|
||||
File tmpFile = getFile( innerPath );
|
||||
if ( tmpFile.exists() && tmpFile.isFile() ) {
|
||||
tmpFile.delete();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains( String innerPath ) {
|
||||
File tmpFile = getFile( innerPath );
|
||||
return tmpFile.exists();
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream( String innerPath ) throws FileNotFoundException, IOException {
|
||||
return new FileInputStream( getFile( innerPath ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a File object for an innerPath.
|
||||
* The location it represents is not guaranteed to exist.
|
||||
*/
|
||||
public File getFile( String innerPath ) {
|
||||
return new File( rootDir, innerPath );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static class FTLPack extends AbstractPack {
|
||||
private CharsetEncoder asciiEncoder = Charset.forName("US-ASCII").newEncoder();
|
||||
|
||||
private File datFile = null;
|
||||
private RandomAccessFile raf = null;
|
||||
private ArrayList<DatEntry> entryList = null;
|
||||
private Map<String,Integer> pathToIndexMap = null;
|
||||
private ByteBuffer byteBuffer = null;
|
||||
|
||||
public FTLPack( File datFile, boolean create ) throws IOException {
|
||||
this( datFile, create, 2048 );
|
||||
}
|
||||
|
||||
public FTLPack( File datFile, boolean create, int indexSize ) throws IOException {
|
||||
this.datFile = datFile;
|
||||
raf = new RandomAccessFile( datFile, "rw" );
|
||||
|
||||
if ( create ) {
|
||||
createIndex( indexSize );
|
||||
} else {
|
||||
readIndex();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a little-endian unsigned int.
|
||||
* Java doesn't have an unsigned int primitive,
|
||||
* so a long holds the value instead.
|
||||
*/
|
||||
private long readLittleUInt() throws IOException {
|
||||
if ( byteBuffer == null || !byteBuffer.hasArray() || byteBuffer.array().length < 4 ) {
|
||||
byteBuffer = ByteBuffer.wrap( new byte[4] );
|
||||
byteBuffer.order( ByteOrder.LITTLE_ENDIAN );
|
||||
}
|
||||
raf.readFully( byteBuffer.array(), 0, 4 );
|
||||
|
||||
// Read a signed int, then discard sign
|
||||
// by casting to long and hacking off bits.
|
||||
long result = byteBuffer.getInt( 0 );
|
||||
result &= 0x00000000FFFFFFFFL;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void writeLittleUInt( long n ) throws IOException {
|
||||
if ( byteBuffer == null || !byteBuffer.hasArray() || byteBuffer.array().length < 4 ) {
|
||||
byteBuffer = ByteBuffer.wrap( new byte[4] );
|
||||
byteBuffer.order( ByteOrder.LITTLE_ENDIAN );
|
||||
}
|
||||
|
||||
// Write a signed int, after discarding sign
|
||||
// by casting from long and hacking off bits.
|
||||
byteBuffer.putInt( 0, (int)(n & 0x00000000FFFFFFFFL) );
|
||||
|
||||
raf.write( byteBuffer.array(), 0, 4 );
|
||||
}
|
||||
|
||||
private String readLittleUString() throws IOException {
|
||||
long strLen = readLittleUInt();
|
||||
byte[] strBytes = new byte[ (int)strLen ];
|
||||
raf.readFully( strBytes );
|
||||
|
||||
return new String( strBytes, asciiEncoder.charset().name() );
|
||||
}
|
||||
|
||||
private void writeLittleUString( String s ) throws IOException {
|
||||
writeLittleUInt( s.length() );
|
||||
byte[] strBytes = s.getBytes( asciiEncoder.charset().name() );
|
||||
raf.write( strBytes );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the offset to seek within the header,
|
||||
* in order to read the offset of an innerFile entry.
|
||||
*
|
||||
* @param n the nth index.
|
||||
*/
|
||||
private long getHeaderIndexPosition( int n ) {
|
||||
return ( 4 + n*4 ); // 4-byte indexSize + 4-byte indeces.
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new index.
|
||||
* WARNING: This will erase the file.
|
||||
*/
|
||||
private void createIndex( int indexSize ) throws IOException {
|
||||
entryList = new ArrayList<DatEntry>( indexSize );
|
||||
for ( int i=0; i < indexSize; i++ )
|
||||
entryList.add( null );
|
||||
|
||||
pathToIndexMap = new HashMap<String,Integer>( indexSize );
|
||||
|
||||
raf.seek( 0 );
|
||||
raf.setLength( 0 );
|
||||
writeLittleUInt( indexSize );
|
||||
for ( int i=0; i < indexSize; i++ )
|
||||
writeLittleUInt( 0 );
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads (or re-reads) the index from the file.
|
||||
*/
|
||||
private void readIndex() throws IOException {
|
||||
raf.seek( 0 );
|
||||
int indexSize = (int)readLittleUInt();
|
||||
if ( indexSize * 4 > raf.length() ) {
|
||||
throw new IOException( String.format( "Corrupt dat file (%s): Its header claims to be larger than the entire file.", getName() ) );
|
||||
}
|
||||
|
||||
entryList = new ArrayList<DatEntry>( indexSize );
|
||||
for ( int i=0; i < indexSize; i++ )
|
||||
entryList.add( null );
|
||||
|
||||
pathToIndexMap = new HashMap<String,Integer>( indexSize );
|
||||
|
||||
// Store partial DatEntry objects in entryList (leaving nulls where absent).
|
||||
for ( int i=0; i < indexSize; i++ ) {
|
||||
long entryOffset = readLittleUInt();
|
||||
|
||||
if ( entryOffset != 0 ) {
|
||||
DatEntry entry = new DatEntry();
|
||||
entry.entryOffset = entryOffset;
|
||||
entryList.set( i, entry );
|
||||
}
|
||||
}
|
||||
|
||||
for ( int i=0; i < indexSize; i++ ) {
|
||||
DatEntry entry = entryList.get( i );
|
||||
if ( entry == null ) continue;
|
||||
|
||||
raf.seek( entry.entryOffset );
|
||||
entry.dataSize = readLittleUInt();
|
||||
entry.innerPath = readLittleUString();
|
||||
entry.dataOffset = raf.getChannel().position();
|
||||
|
||||
if ( pathToIndexMap.containsKey( entry.innerPath ) ) {
|
||||
throw new IOException( "InnerPath occurs more than once: "+ entry.innerPath );
|
||||
}
|
||||
pathToIndexMap.put( entry.innerPath, new Integer(i) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the nth index's entry to the end of the file.
|
||||
* It will still be nth in the header, however.
|
||||
* Used by growIndex().
|
||||
*/
|
||||
private void moveEntryToEOF( int n ) throws IOException {
|
||||
DatEntry entry = entryList.get( n );
|
||||
long oldOffset = entry.entryOffset;
|
||||
long newOffset = raf.length();
|
||||
|
||||
long totalBytes = (entry.dataOffset-entry.entryOffset) + entry.dataSize;
|
||||
long bytesRemaining = totalBytes;
|
||||
byte[] buf = new byte[4096];
|
||||
int len;
|
||||
while ( bytesRemaining > 0 ) {
|
||||
raf.seek( oldOffset + totalBytes - bytesRemaining );
|
||||
len = raf.read( buf, 0, (int)Math.min(buf.length, bytesRemaining) );
|
||||
if ( len == -1 ) {
|
||||
throw new IOException( "EOF prematurely reached reading innerPath: "+ entry.innerPath );
|
||||
}
|
||||
|
||||
raf.seek( newOffset + totalBytes - bytesRemaining );
|
||||
raf.write( buf, 0, len );
|
||||
bytesRemaining -= len;
|
||||
}
|
||||
// Update the index.
|
||||
raf.seek( getHeaderIndexPosition( n ) );
|
||||
writeLittleUInt( newOffset );
|
||||
entry.dataOffset = ( newOffset + (entry.dataOffset-entry.entryOffset) );
|
||||
entry.entryOffset = newOffset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the index has room for at least <amount> entries.
|
||||
* This is done by moving the first innerFile after the index
|
||||
* to the end of the file. The region it used to occupy can then
|
||||
* be filled with additional indeces.
|
||||
*/
|
||||
private void growIndex( int amount ) throws IOException {
|
||||
int freeRoom = -1;
|
||||
|
||||
while ( true ) {
|
||||
int vacancyCount = Collections.frequency( entryList, null );
|
||||
|
||||
if ( entryList.size() - vacancyCount == 0 ) {
|
||||
// There is no innerFile after the index. We can grow
|
||||
// as much as we like. Limit ourselves to amount.
|
||||
freeRoom = amount;
|
||||
break;
|
||||
}
|
||||
else {
|
||||
// Find the used index with the lowest entryOffset.
|
||||
int earliestUsedIndex = -1;
|
||||
long minEntryOffset = Long.MAX_VALUE;
|
||||
for ( int i=0; i < entryList.size(); i++ ) {
|
||||
DatEntry entry = entryList.get( i );
|
||||
if ( entry.entryOffset < minEntryOffset ) {
|
||||
earliestUsedIndex = i;
|
||||
minEntryOffset = entry.entryOffset;
|
||||
}
|
||||
}
|
||||
// (region between header and first innerFile entry) / (possible 4-byte ints).
|
||||
freeRoom = (int)( ( minEntryOffset - getHeaderIndexPosition( entryList.size() ) ) / 4 );
|
||||
|
||||
if ( freeRoom >= amount ) {
|
||||
freeRoom = amount; // We don't need hundreds of thousands more.
|
||||
break;
|
||||
}
|
||||
|
||||
// If it's not enough, move the first file and check again.
|
||||
moveEntryToEOF( earliestUsedIndex );
|
||||
}
|
||||
}
|
||||
// Expand the header to claim the vacated region.
|
||||
for ( int i=0; i < freeRoom; i++ ) {
|
||||
entryList.add( null );
|
||||
}
|
||||
raf.seek( 0 );
|
||||
writeLittleUInt( entryList.size() );
|
||||
raf.seek( getHeaderIndexPosition(entryList.size() - freeRoom) );
|
||||
for ( int i=0; i < freeRoom; i++ ) {
|
||||
writeLittleUInt( 0 );
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return datFile.getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<String> list() {
|
||||
List<String> result = new ArrayList<String>();
|
||||
result.addAll( pathToIndexMap.keySet() );
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PathAndSize> listSizes() {
|
||||
List<PathAndSize> result = new ArrayList<PathAndSize>();
|
||||
for ( DatEntry entry : entryList ) {
|
||||
if ( entry == null ) continue;
|
||||
PathAndSize pas = new PathAndSize( entry.innerPath, entry.dataSize );
|
||||
result.add( pas );
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void add( String innerPath, InputStream is ) throws IOException {
|
||||
if ( pathToIndexMap.containsKey( innerPath ) ) {
|
||||
throw new FTLDatException( "InnerPath already exists: "+ innerPath );
|
||||
}
|
||||
if ( !asciiEncoder.canEncode( innerPath ) ) {
|
||||
throw new FTLDatException( "InnerPath contains non-ascii characters: "+ innerPath );
|
||||
}
|
||||
|
||||
// Find a vacancy in the header, or create one.
|
||||
int entryIndex = entryList.indexOf( null );
|
||||
if ( entryIndex == -1 ) {
|
||||
growIndex( 50 ); // Save effort for 49 future adds.
|
||||
entryIndex = entryList.indexOf( null );
|
||||
}
|
||||
|
||||
DatEntry entry = new DatEntry();
|
||||
entry.entryOffset = raf.length();
|
||||
entry.innerPath = innerPath;
|
||||
entry.dataSize = 0; // Write this later.
|
||||
|
||||
raf.seek( getHeaderIndexPosition( entryIndex ) );
|
||||
writeLittleUInt( entry.entryOffset );
|
||||
|
||||
raf.seek( entry.entryOffset );
|
||||
writeLittleUInt( entry.dataSize );
|
||||
writeLittleUString( entry.innerPath );
|
||||
entry.dataOffset = raf.getChannel().position();
|
||||
|
||||
byte[] buf = new byte[4096];
|
||||
int len;
|
||||
while ( (len = is.read(buf)) >= 0 ) {
|
||||
raf.write( buf, 0, len );
|
||||
}
|
||||
|
||||
// Go back and fill in the dataSize.
|
||||
entry.dataSize = raf.getChannel().position() - entry.dataOffset;
|
||||
raf.seek( entry.entryOffset );
|
||||
writeLittleUInt( entry.dataSize );
|
||||
|
||||
entryList.set( entryIndex, entry );
|
||||
pathToIndexMap.put( innerPath, entryIndex );
|
||||
}
|
||||
|
||||
@Override
|
||||
public void extractTo( String innerPath, OutputStream os ) throws FileNotFoundException, IOException {
|
||||
if ( !pathToIndexMap.containsKey( innerPath ) ) {
|
||||
throw new FileNotFoundException( "InnerPath does not exist: "+ innerPath );
|
||||
}
|
||||
|
||||
int entryIndex = pathToIndexMap.get( innerPath ).intValue();
|
||||
DatEntry entry = entryList.get( entryIndex );
|
||||
|
||||
raf.seek( entry.dataOffset );
|
||||
|
||||
long bytesRemaining = entry.dataSize;
|
||||
byte[] buf = new byte[4096];
|
||||
int len;
|
||||
while ( bytesRemaining > 0 ) {
|
||||
raf.seek( entry.dataOffset + entry.dataSize - bytesRemaining );
|
||||
len = raf.read( buf, 0, (int)Math.min(buf.length, bytesRemaining) );
|
||||
if ( len == -1 ) {
|
||||
throw new IOException( "EOF prematurely reached reading innerPath: "+ entry.innerPath );
|
||||
}
|
||||
|
||||
os.write( buf, 0, len );
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void remove( String innerPath ) throws FileNotFoundException, IOException {
|
||||
if ( !pathToIndexMap.containsKey( innerPath ) ) {
|
||||
throw new FileNotFoundException( "InnerPath does not exist: "+ innerPath );
|
||||
}
|
||||
|
||||
int entryIndex = pathToIndexMap.get( innerPath ).intValue();
|
||||
pathToIndexMap.remove( innerPath );
|
||||
DatEntry removedEntry = entryList.set( entryIndex, null );
|
||||
|
||||
raf.seek( getHeaderIndexPosition( entryIndex ) );
|
||||
writeLittleUInt( 0 );
|
||||
|
||||
if ( removedEntry.dataOffset + removedEntry.dataSize == raf.length() ) {
|
||||
// Data appeared at the end. Truncate.
|
||||
raf.setLength( removedEntry.entryOffset );
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains( String innerPath ) {
|
||||
return pathToIndexMap.containsKey( innerPath );
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream( String innerPath ) throws FileNotFoundException, IOException {
|
||||
if ( !pathToIndexMap.containsKey( innerPath ) ) {
|
||||
throw new FileNotFoundException( "InnerPath does not exist: "+ innerPath );
|
||||
}
|
||||
|
||||
int entryIndex = pathToIndexMap.get( innerPath ).intValue();
|
||||
DatEntry entry = entryList.get( entryIndex );
|
||||
|
||||
// Create a stream that can only see this region.
|
||||
// Multiple read-only streams can coexist (each has its own position).
|
||||
InputStream stream = new FileChannelRegionInputStream( raf.getChannel(), entry.dataOffset, entry.dataSize );
|
||||
|
||||
// Create a stream that can only see this region.
|
||||
// There must be no other reads/seeks until it is closed.
|
||||
//InputStream tmpStream = Channels.newInputStream( raf.getChannel().position( entry.dataOffset ) );
|
||||
//InputStream stream = new SizeLimitInputStream( tmpStream, entry.dataSize );
|
||||
|
||||
// Mapped regions may not garbage collect promptly.
|
||||
// That would keep the file in use: bad.
|
||||
// Closing raf doesn't affect them. :/
|
||||
// This method has best I/O performance though.
|
||||
//MappedByteBuffer buf = raf.getChannel().map( FileChannel.MapMode.READ_ONLY, entry.dataOffset, entry.dataSize );
|
||||
//buf.load();
|
||||
//InputStream stream = new ByteBufferBackedInputStream( buf );
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() throws IOException {
|
||||
raf.close();
|
||||
}
|
||||
|
||||
public List<DatEntry> listMetadata() {
|
||||
return new ArrayList<DatEntry>( entryList );
|
||||
}
|
||||
|
||||
/**
|
||||
* Repacks the dat file. This will remove gaps, which could
|
||||
* be created when adding, removing or replacing files.
|
||||
*/
|
||||
public RepackResult repack() throws IOException {
|
||||
// Build a list of non-null entries, sorted in the order their data appears.
|
||||
|
||||
ArrayList<DatEntry> tmpEntries = new ArrayList<DatEntry>( pathToIndexMap.size() );
|
||||
for ( Map.Entry<String,Integer> mapping : pathToIndexMap.entrySet() ) {
|
||||
Integer iObj = mapping.getValue();
|
||||
DatEntry entry = entryList.get( iObj.intValue() );
|
||||
if ( entry != null ) {
|
||||
tmpEntries.add( entry );
|
||||
} else { // The following should never happen!
|
||||
throw new IOException ( "Bad entryIndex for innerPath: "+ mapping.getKey() );
|
||||
}
|
||||
}
|
||||
Collections.sort( tmpEntries, new Comparator<DatEntry>() {
|
||||
public int compare( DatEntry a, DatEntry b ) {
|
||||
if ( b == null ) return -1;
|
||||
if ( a == null ) return 1;
|
||||
DatEntry dA = (DatEntry)a;
|
||||
DatEntry dB = (DatEntry)b;
|
||||
if ( dA.entryOffset < dB.entryOffset ) return -1;
|
||||
if ( dA.entryOffset > dB.entryOffset ) return 1;
|
||||
return 0;
|
||||
}
|
||||
@Override
|
||||
public boolean equals( Object o ) {
|
||||
return ( o != null ? o == this : false );
|
||||
}
|
||||
} );
|
||||
|
||||
for ( int i=0; i < tmpEntries.size()-1; i++ ) {
|
||||
DatEntry a = tmpEntries.get( i );
|
||||
DatEntry b = tmpEntries.get( i+1 );
|
||||
if ( a.dataOffset+a.dataSize > b.entryOffset ) {
|
||||
throw new IOException( String.format( "Cannot repack datfile with overlapping entries (\"%s\" and \"%s\").", a.innerPath, b.innerPath ) );
|
||||
}
|
||||
}
|
||||
|
||||
pathToIndexMap.clear(); // entryList gets replaced later.
|
||||
long bytesChanged = 0;
|
||||
|
||||
// Write the header size.
|
||||
if ( tmpEntries.size() != entryList.size() ) {
|
||||
raf.seek( 0 );
|
||||
writeLittleUInt( tmpEntries.size() );
|
||||
bytesChanged += 4;
|
||||
}
|
||||
|
||||
long pendingEntryOffset = getHeaderIndexPosition( tmpEntries.size() );
|
||||
|
||||
for ( int i=0; i < tmpEntries.size(); i++ ) {
|
||||
DatEntry entry = tmpEntries.get ( i );
|
||||
pathToIndexMap.put( entry.innerPath, new Integer(i) );
|
||||
|
||||
// Write the header index.
|
||||
raf.seek( getHeaderIndexPosition( i ) );
|
||||
writeLittleUInt( pendingEntryOffset );
|
||||
bytesChanged += 4;
|
||||
|
||||
// Shift the entry toward the start of the dat.
|
||||
if ( pendingEntryOffset != entry.entryOffset ) {
|
||||
long totalBytes = (entry.dataOffset-entry.entryOffset) + entry.dataSize;
|
||||
long bytesRemaining = totalBytes;
|
||||
byte[] buf = new byte[4096];
|
||||
int len;
|
||||
while ( bytesRemaining > 0 ) {
|
||||
raf.seek( entry.entryOffset + totalBytes - bytesRemaining );
|
||||
len = raf.read( buf, 0, (int)Math.min(buf.length, bytesRemaining) );
|
||||
if ( len == -1 ) {
|
||||
throw new IOException( "EOF prematurely reached reading innerPath: "+ entry.innerPath );
|
||||
}
|
||||
|
||||
raf.seek( pendingEntryOffset + totalBytes - bytesRemaining );
|
||||
raf.write( buf, 0, len );
|
||||
bytesRemaining -= len;
|
||||
}
|
||||
|
||||
entry.dataOffset = pendingEntryOffset + (entry.dataOffset-entry.entryOffset);
|
||||
entry.entryOffset = pendingEntryOffset;
|
||||
bytesChanged += totalBytes;
|
||||
}
|
||||
|
||||
pendingEntryOffset += (entry.dataOffset-entry.entryOffset) + entry.dataSize;
|
||||
}
|
||||
|
||||
entryList = tmpEntries;
|
||||
|
||||
long oldDatLength = raf.length();
|
||||
long newDatLength = pendingEntryOffset;
|
||||
raf.setLength( newDatLength );
|
||||
return new RepackResult( oldDatLength, newDatLength, bytesChanged );
|
||||
}
|
||||
}
|
||||
}
|
115
src/main/java/net/vhati/ftldat/FileChannelRegionInputStream.java
Normal file
115
src/main/java/net/vhati/ftldat/FileChannelRegionInputStream.java
Normal file
|
@ -0,0 +1,115 @@
|
|||
package net.vhati.ftldat;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.BufferUnderflowException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.channels.ClosedChannelException;
|
||||
import java.nio.channels.FileChannel;
|
||||
|
||||
|
||||
public class FileChannelRegionInputStream extends InputStream {
|
||||
|
||||
private FileChannel channel;
|
||||
private long regionOffset;
|
||||
private long regionLength;
|
||||
|
||||
// A buffer holds an even narrower region of the file.
|
||||
// When possible read() calls will reuse this,
|
||||
// rather than pester the channel.
|
||||
private ByteBuffer buf = null;
|
||||
private long bufOffset = 0; // Relative to regionOffset.
|
||||
private int bufLength = 0;
|
||||
|
||||
private long intraPos = 0;
|
||||
|
||||
|
||||
public FileChannelRegionInputStream( FileChannel channel, long offset, long length ) {
|
||||
this( channel, offset, length, 4096 );
|
||||
}
|
||||
|
||||
public FileChannelRegionInputStream( FileChannel channel, long offset, long length, int bufferSize ) {
|
||||
this.channel = channel;
|
||||
this.regionOffset = offset;
|
||||
this.regionLength = length;
|
||||
buf = ByteBuffer.allocate( bufferSize );
|
||||
}
|
||||
|
||||
@Override
|
||||
public int available() throws IOException {
|
||||
if ( !channel.isOpen() ) throw new ClosedChannelException();
|
||||
return bufLength;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
if ( !channel.isOpen() ) throw new ClosedChannelException();
|
||||
if ( intraPos >= regionLength ) return -1;
|
||||
|
||||
if ( intraPos < bufOffset || intraPos >= bufOffset+bufLength ) {
|
||||
// The requested byte isn't currently buffered.
|
||||
bufOffset = intraPos;
|
||||
int len = 0; // Get *something*.
|
||||
buf.position( 0 );
|
||||
while ( len == 0 ) {
|
||||
len = channel.read( buf, regionOffset + bufOffset );
|
||||
}
|
||||
if ( len == -1 ) {
|
||||
bufLength = 0;
|
||||
return -1;
|
||||
} else {
|
||||
bufLength = len;
|
||||
}
|
||||
}
|
||||
|
||||
// Do an absolute get() from the buffer.
|
||||
int result = buf.get( (int)(intraPos - bufOffset) );
|
||||
intraPos++;
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read( byte[] b, int bOff, int bLen ) throws IOException {
|
||||
if ( bLen == 0 ) return 0;
|
||||
if ( bOff < 0 ) throw new IndexOutOfBoundsException( String.format( "Index: %d, Size: %d", bOff, bLen ) );
|
||||
if ( bOff + bLen > b.length ) throw new IndexOutOfBoundsException( String.format( "Index: %d, Size: %d", (bOff+bLen), bLen ) );
|
||||
if ( !channel.isOpen() ) throw new ClosedChannelException();
|
||||
if ( intraPos >= regionLength ) return -1;
|
||||
|
||||
int bytesTotal = Math.min( bLen, (int)(regionLength - intraPos) );
|
||||
int bytesRemaining = bytesTotal;
|
||||
|
||||
if ( intraPos >= bufOffset || intraPos < bufOffset+bufLength ) {
|
||||
// Read part of the current buffer, possibly until the end.
|
||||
|
||||
buf.position( (int)(intraPos - bufOffset) );
|
||||
int bufTodo = Math.min( bytesRemaining, bufLength - (int)(intraPos - bufOffset) );
|
||||
buf.get( b, bOff, bufTodo );
|
||||
bytesRemaining -= bufTodo;
|
||||
intraPos += bufTodo;
|
||||
}
|
||||
|
||||
if ( bytesRemaining > 0 ) {
|
||||
// Refill the buffer at the current intraPos.
|
||||
|
||||
bufOffset = intraPos;
|
||||
int len = 0;
|
||||
buf.position( 0 );
|
||||
len = channel.read( buf, regionOffset + bufOffset );
|
||||
if ( len == -1 ) {
|
||||
bufLength = 0;
|
||||
throw new BufferUnderflowException();
|
||||
} else {
|
||||
bufLength = len;
|
||||
}
|
||||
|
||||
buf.position( 0 );
|
||||
int bufTodo = Math.min( bytesRemaining, bufLength );
|
||||
buf.get( b, bOff, bufTodo );
|
||||
bytesRemaining -= bufTodo;
|
||||
intraPos += bufTodo;
|
||||
}
|
||||
|
||||
return ( bytesTotal - bytesRemaining ); // Return number of bytes read.
|
||||
}
|
||||
}
|
247
src/main/java/net/vhati/ftldat/SizeLimitInputStream.java
Normal file
247
src/main/java/net/vhati/ftldat/SizeLimitInputStream.java
Normal file
|
@ -0,0 +1,247 @@
|
|||
/*
|
||||
* Input stream wrapper with a byte limit.
|
||||
* Copyright (C) 2004 Stephen Ostermiller
|
||||
* http://ostermiller.org/contact.pl?regarding=Java+Utilities
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation; either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* See COPYING.TXT for details.
|
||||
*/
|
||||
|
||||
package net.vhati.ftldat;
|
||||
|
||||
import java.io.*;
|
||||
|
||||
|
||||
/**
|
||||
* An input stream wrapper that will read only a set number of bytes from the
|
||||
* underlying stream.
|
||||
*
|
||||
* @author Stephen Ostermiller http://ostermiller.org/contact.pl?regarding=Java+Utilities
|
||||
* @since ostermillerutils 1.04.00
|
||||
*/
|
||||
public class SizeLimitInputStream extends InputStream {
|
||||
|
||||
/**
|
||||
* The input stream that is being protected.
|
||||
* All methods should be forwarded to it,
|
||||
* after checking the size that has been read.
|
||||
*
|
||||
* @since ostermillerutils 1.04.00
|
||||
*/
|
||||
protected InputStream in;
|
||||
|
||||
/**
|
||||
* The number of bytes to read at most from this
|
||||
* Stream. Read methods should
|
||||
* check to ensure that bytesRead never
|
||||
* exceeds maxBytesToRead.
|
||||
*
|
||||
* @since ostermillerutils 1.04.00
|
||||
*/
|
||||
protected long maxBytesToRead = 0;
|
||||
|
||||
/**
|
||||
* The number of bytes that have been read
|
||||
* from this stream. Read methods should
|
||||
* check to ensure that bytesRead never
|
||||
* exceeds maxBytesToRead.
|
||||
*
|
||||
* @since ostermillerutils 1.04.00
|
||||
*/
|
||||
protected long bytesRead = 0;
|
||||
|
||||
/**
|
||||
* The number of bytes that have been read
|
||||
* from this stream since mark() was called.
|
||||
*
|
||||
* @since ostermillerutils 1.04.00
|
||||
*/
|
||||
protected long bytesReadSinceMark = 0;
|
||||
|
||||
/**
|
||||
* The number of bytes the user has request
|
||||
* to have been marked for reset.
|
||||
*
|
||||
* @since ostermillerutils 1.04.00
|
||||
*/
|
||||
protected long markReadLimitBytes = -1;
|
||||
|
||||
/**
|
||||
* Get the number of bytes actually read
|
||||
* from this stream.
|
||||
*
|
||||
* @return number of bytes that have already been taken from this stream.
|
||||
*
|
||||
* @since ostermillerutils 1.04.00
|
||||
*/
|
||||
public long getBytesRead(){
|
||||
return bytesRead;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maximum number of bytes left to read
|
||||
* before the limit (set in the constructor) is reached.
|
||||
*
|
||||
* @return The number of bytes that (at a maximum) are left to be taken from this stream.
|
||||
*
|
||||
* @since ostermillerutils 1.04.00
|
||||
*/
|
||||
public long getBytesLeft(){
|
||||
return maxBytesToRead - bytesRead;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell whether the number of bytes specified
|
||||
* in the constructor have been read yet.
|
||||
*
|
||||
* @return true iff the specified number of bytes have all been read.
|
||||
*
|
||||
* @since ostermillerutils 1.04.00
|
||||
*/
|
||||
public boolean allBytesRead(){
|
||||
return getBytesLeft() == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of total bytes (including bytes already read)
|
||||
* that can be read from this stream (as set in the constructor).
|
||||
* @return Maximum bytes that can be read until the size limit runs out
|
||||
*
|
||||
* @since ostermillerutils 1.04.00
|
||||
*/
|
||||
public long getMaxBytesToRead(){
|
||||
return maxBytesToRead;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new size limit input stream from
|
||||
* another stream given a size limit.
|
||||
*
|
||||
* @param in The input stream.
|
||||
* @param maxBytesToRead the max number of bytes to allow to be read from the underlying stream.
|
||||
*
|
||||
* @since ostermillerutils 1.04.00
|
||||
*/
|
||||
public SizeLimitInputStream( InputStream in, long maxBytesToRead ){
|
||||
this.in = in;
|
||||
this.maxBytesToRead = maxBytesToRead;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override public int read() throws IOException {
|
||||
if ( bytesRead >= maxBytesToRead ){
|
||||
return -1;
|
||||
}
|
||||
int b = in.read();
|
||||
if ( b != -1 ){
|
||||
bytesRead++;
|
||||
bytesReadSinceMark++;
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override public int read( byte[] b ) throws IOException {
|
||||
return this.read( b, 0, b.length );
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override public int read( byte[] b, int off, int len ) throws IOException {
|
||||
if ( bytesRead >= maxBytesToRead ){
|
||||
return -1;
|
||||
}
|
||||
long bytesLeft = getBytesLeft();
|
||||
if ( len > bytesLeft ){
|
||||
len = (int)bytesLeft;
|
||||
}
|
||||
int bytesJustRead = in.read( b, off, len );
|
||||
bytesRead += bytesJustRead;
|
||||
bytesReadSinceMark += bytesJustRead;
|
||||
return bytesJustRead;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override public long skip(long n) throws IOException {
|
||||
if ( bytesRead >= maxBytesToRead ){
|
||||
return -1;
|
||||
}
|
||||
long bytesLeft = getBytesLeft();
|
||||
if ( n > bytesLeft ){
|
||||
n = bytesLeft;
|
||||
}
|
||||
return in.skip(n);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override public int available() throws IOException {
|
||||
int available = in.available();
|
||||
long bytesLeft = getBytesLeft();
|
||||
if ( available > bytesLeft ){
|
||||
available = (int)bytesLeft;
|
||||
}
|
||||
return available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close this stream and underlying streams.
|
||||
* Calling this method may make data on the
|
||||
* underlying stream unavailable.
|
||||
* <p>
|
||||
* Consider wrapping this stream in a NoCloseStream
|
||||
* so that clients can
|
||||
* call close() with no effect.
|
||||
*
|
||||
* @since ostermillerutils 1.04.00
|
||||
*/
|
||||
@Override public void close() throws IOException {
|
||||
in.close();
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override public void mark(int readlimit){
|
||||
if ( in.markSupported() ){
|
||||
markReadLimitBytes = readlimit;
|
||||
bytesReadSinceMark = 0;
|
||||
in.mark(readlimit);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override public void reset() throws IOException {
|
||||
if ( in.markSupported() && bytesReadSinceMark <= markReadLimitBytes ){
|
||||
bytesRead -= bytesReadSinceMark;
|
||||
in.reset();
|
||||
bytesReadSinceMark = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
@Override public boolean markSupported(){
|
||||
return in.markSupported();
|
||||
}
|
||||
}
|
190
src/main/java/net/vhati/modmanager/FTLModManager.java
Normal file
190
src/main/java/net/vhati/modmanager/FTLModManager.java
Normal file
|
@ -0,0 +1,190 @@
|
|||
package net.vhati.modmanager;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.util.Properties;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.UIManager;
|
||||
|
||||
import net.vhati.modmanager.core.ComparableVersion;
|
||||
import net.vhati.modmanager.core.FTLUtilities;
|
||||
import net.vhati.modmanager.ui.ManagerFrame;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
|
||||
public class FTLModManager {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(FTLModManager.class);
|
||||
|
||||
private static final String APP_NAME = "Slipstream Mod Manager";
|
||||
private static final ComparableVersion APP_VERSION = new ComparableVersion( "1.0" );
|
||||
|
||||
|
||||
public static void main( String[] args ) {
|
||||
|
||||
log.debug( String.format( "%s v%s", APP_NAME, APP_VERSION ) );
|
||||
log.debug( System.getProperty("os.name") +" "+ System.getProperty("os.version") +" "+ System.getProperty("os.arch") );
|
||||
log.debug( System.getProperty("java.vm.name") +", "+ System.getProperty("java.version") );
|
||||
|
||||
|
||||
File configFile = new File( "modman.cfg" );
|
||||
|
||||
boolean writeConfig = false;
|
||||
Properties config = new Properties();
|
||||
config.setProperty( "allow_zip", "false" );
|
||||
config.setProperty( "ftl_dats_path", "" );
|
||||
config.setProperty( "never_run_ftl", "false" );
|
||||
config.setProperty( "use_default_ui", "false" );
|
||||
// "update_catalog" doesn't have a default.
|
||||
|
||||
// Read the config file.
|
||||
InputStream in = null;
|
||||
try {
|
||||
if ( configFile.exists() ) {
|
||||
log.trace( "Loading properties from config file." );
|
||||
in = new FileInputStream( configFile );
|
||||
config.load( new InputStreamReader( in, "UTF-8" ) );
|
||||
} else {
|
||||
writeConfig = true; // Create a new cfg, but only if necessary.
|
||||
}
|
||||
}
|
||||
catch (IOException e) {
|
||||
log.error( "Error loading config.", e );
|
||||
showErrorDialog( "Error loading config from "+ configFile.getPath() );
|
||||
}
|
||||
finally {
|
||||
try {if ( in != null ) in.close();}
|
||||
catch ( IOException e ) {}
|
||||
}
|
||||
|
||||
// Look-and-Feel.
|
||||
String useDefaultUI = config.getProperty( "use_default_ui", "false" );
|
||||
|
||||
if ( !useDefaultUI.equals("true") ) {
|
||||
try {
|
||||
log.trace( "Using system Look and Feel" );
|
||||
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
|
||||
}
|
||||
catch (Exception e) {
|
||||
log.error( "Error setting system Look and Feel.", e );
|
||||
log.info( "Setting 'useDefaultUI=true' in the config file will prevent this error." );
|
||||
}
|
||||
} else {
|
||||
log.debug( "Using default Look and Feel." );
|
||||
}
|
||||
|
||||
// FTL Resources Path.
|
||||
File datsDir = null;
|
||||
String datsPath = config.getProperty( "ftl_dats_path", "" );
|
||||
|
||||
if ( datsPath.length() > 0 ) {
|
||||
log.info( "Using FTL dats path from config: "+ datsPath );
|
||||
datsDir = new File( datsPath );
|
||||
if ( FTLUtilities.isDatsDirValid( datsDir ) == false ) {
|
||||
log.error( "The config's ftl_dats_path does not exist, or it lacks data.dat." );
|
||||
datsDir = null;
|
||||
}
|
||||
} else {
|
||||
log.trace( "No FTL dats path previously set." );
|
||||
}
|
||||
|
||||
// Find/prompt for the path to set in the config.
|
||||
if ( datsDir == null ) {
|
||||
datsDir = FTLUtilities.findDatsDir();
|
||||
if ( datsDir != null ) {
|
||||
int response = JOptionPane.showConfirmDialog(null, "FTL resources were found in:\n"+ datsDir.getPath() +"\nIs this correct?", "Confirm", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
|
||||
if ( response == JOptionPane.NO_OPTION ) datsDir = null;
|
||||
}
|
||||
|
||||
if ( datsDir == null ) {
|
||||
log.debug( "FTL dats path was not located automatically. Prompting user for location." );
|
||||
datsDir = FTLUtilities.promptForDatsDir( null );
|
||||
}
|
||||
|
||||
if ( datsDir != null ) {
|
||||
config.setProperty( "ftl_dats_path", datsDir.getAbsolutePath() );
|
||||
writeConfig = true;
|
||||
log.info( "FTL dats located at: "+ datsDir.getAbsolutePath() );
|
||||
}
|
||||
}
|
||||
|
||||
if ( datsDir == null ) {
|
||||
showErrorDialog( "FTL resources were not found.\nThe Mod Manager will now exit." );
|
||||
log.debug( "No FTL dats path found, exiting." );
|
||||
System.exit( 1 );
|
||||
}
|
||||
|
||||
// Prompt if update_catalog is invalid or hasn't been set.
|
||||
|
||||
String updateCatalog = config.getProperty( "update_catalog" );
|
||||
if ( updateCatalog == null || !updateCatalog.matches("^true|false$") ) {
|
||||
String message = "";
|
||||
message += "Would you like Slipstream to periodically\n";
|
||||
message += "download descriptions for the latest mods?\n\n";
|
||||
message += "You can change this later in modman.cfg.";
|
||||
|
||||
int response = JOptionPane.showConfirmDialog(null, message, "Catalog Updates", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE);
|
||||
if ( response == JOptionPane.YES_OPTION )
|
||||
config.setProperty( "update_catalog", "true" );
|
||||
else
|
||||
config.setProperty( "update_catalog", "false" );
|
||||
}
|
||||
|
||||
|
||||
if ( writeConfig ) {
|
||||
OutputStream out = null;
|
||||
try {
|
||||
out = new FileOutputStream( configFile );
|
||||
String configComments = "";
|
||||
configComments += "\n";
|
||||
configComments += " allow_zip - Sets whether to treat .zip files as .ftl files. Default: false.\n";
|
||||
configComments += " ftl_dats_path - The path to FTL's resources folder. If invalid, you'll be prompted.\n";
|
||||
configComments += " never_run_ftl - If true, there will be no offer to run FTL after patching. Default: false.\n";
|
||||
configComments += " update_catalog - If true, periodically download descriptions for the latest mods. If invalid, you'll be prompted.\n";
|
||||
configComments += " use_default_ui - If true, no attempt will be made to resemble a native GUI. Default: false.\n";
|
||||
|
||||
config.store( new OutputStreamWriter( out, "UTF-8" ), configComments );
|
||||
}
|
||||
catch ( IOException e ) {
|
||||
log.error( "Error saving config to "+ configFile.getPath(), e );
|
||||
showErrorDialog( "Error saving config to "+ configFile.getPath() );
|
||||
}
|
||||
finally {
|
||||
try {if ( out != null ) out.close();}
|
||||
catch ( IOException e ) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Create the GUI.
|
||||
try {
|
||||
final ManagerFrame frame = new ManagerFrame( config, APP_NAME, APP_VERSION );
|
||||
frame.setDefaultCloseOperation( frame.EXIT_ON_CLOSE );
|
||||
frame.setVisible(true);
|
||||
|
||||
SwingUtilities.invokeLater(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
frame.init();
|
||||
}
|
||||
});
|
||||
}
|
||||
catch ( Exception e ) {
|
||||
log.error( "Exception while creating ManagerFrame.", e );
|
||||
System.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private static void showErrorDialog( String message ) {
|
||||
JOptionPane.showMessageDialog(null, message, "Error", JOptionPane.ERROR_MESSAGE);
|
||||
}
|
||||
}
|
340
src/main/java/net/vhati/modmanager/core/ComparableVersion.java
Normal file
340
src/main/java/net/vhati/modmanager/core/ComparableVersion.java
Normal file
|
@ -0,0 +1,340 @@
|
|||
package net.vhati.modmanager.core;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
/**
|
||||
* A version string (eg, 10.4.2_17 or 2.7.5rc1 ).
|
||||
*
|
||||
* It is composed of three parts:
|
||||
* - A series of period-separated positive ints.
|
||||
*
|
||||
* - The numbers may be immediately followed by a short
|
||||
* suffix string.
|
||||
*
|
||||
* - Finally, a string comment, separated from the rest
|
||||
* by a space.
|
||||
*
|
||||
* The (numbers + suffix) or comment may appear alone.
|
||||
*
|
||||
* For details, see the string constructor and compareTo().
|
||||
*/
|
||||
public class ComparableVersion implements Comparable<ComparableVersion> {
|
||||
|
||||
private Pattern numbersPtn = Pattern.compile( "^((?:\\d+[.])*\\d+)" );
|
||||
private Pattern suffixPtn = Pattern.compile( "([-_]|(?:[-_]?(?:[ab]|r|rc)))(\\d+)|([A-Za-z](?= |$))" );
|
||||
private Pattern commentPtn = Pattern.compile( "(.+)$" );
|
||||
|
||||
private int[] numbers;
|
||||
private String suffix;
|
||||
private String comment;
|
||||
|
||||
private String suffixDivider; // Suffix prior to a number, if there was a number.
|
||||
private int suffixNum;
|
||||
|
||||
|
||||
public ComparableVersion( int[] numbers, String suffix, String comment ) {
|
||||
this.numbers = numbers;
|
||||
setSuffix( suffix );
|
||||
setComment( comment );
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs an AppVersion by parsing a string.
|
||||
*
|
||||
* The suffix can be:
|
||||
* - A divider string followed by a number.
|
||||
* - Optional Hyphen/underscore, then a|b|r|rc, then 0-9+.
|
||||
* - Hyphen/underscore, then 0-9+.
|
||||
* Or the suffix can be a single letter without a number.
|
||||
*
|
||||
* Examples:
|
||||
* 1
|
||||
* 1 Blah
|
||||
* 1.2 Blah
|
||||
* 1.2.3 Blah
|
||||
* 1.2.3-8 Blah
|
||||
* 1.2.3_b9 Blah
|
||||
* 1.2.3a1 Blah
|
||||
* 1.2.3b1 Blah
|
||||
* 1.2.3rc2 Blah
|
||||
* 1.2.3z Blah
|
||||
* 1.2.3D
|
||||
* Alpha
|
||||
*
|
||||
* @throws IllegalArgumentException if the string is unsuitable
|
||||
*/
|
||||
public ComparableVersion( String s ) {
|
||||
boolean noNumbers = true;
|
||||
boolean noComment = true;
|
||||
|
||||
Matcher numbersMatcher = numbersPtn.matcher( s );
|
||||
Matcher suffixMatcher = suffixPtn.matcher( s );
|
||||
Matcher commentMatcher = commentPtn.matcher( s );
|
||||
|
||||
if ( numbersMatcher.lookingAt() ) {
|
||||
noNumbers = false;
|
||||
setNumbers( numbersMatcher.group( 0 ) );
|
||||
|
||||
commentMatcher.region( numbersMatcher.end(), s.length() );
|
||||
|
||||
// We have numbers; do we have a suffix?
|
||||
suffixMatcher.region( numbersMatcher.end(), s.length() );
|
||||
if ( suffixMatcher.lookingAt() ) {
|
||||
setSuffix( suffixMatcher.group( 0 ) );
|
||||
|
||||
commentMatcher.region( suffixMatcher.end(), s.length() );
|
||||
}
|
||||
else {
|
||||
setSuffix( null );
|
||||
}
|
||||
|
||||
// If a space occurs after (numbers +suffix?), skip it.
|
||||
// Thus the comment matcher will start on the first comment char.
|
||||
//
|
||||
if ( commentMatcher.regionStart()+1 < s.length() ) {
|
||||
if ( s.charAt( commentMatcher.regionStart() ) == ' ' ) {
|
||||
commentMatcher.region( commentMatcher.regionStart()+1, s.length() );
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
numbers = new int[0];
|
||||
setSuffix( null );
|
||||
}
|
||||
|
||||
// Check for a comment (at the start, elsewhere if region was set).
|
||||
if ( commentMatcher.lookingAt() ) {
|
||||
noComment = false;
|
||||
setComment( commentMatcher.group( 1 ) );
|
||||
}
|
||||
|
||||
if ( noNumbers && noComment ) {
|
||||
throw new IllegalArgumentException( "Could not parse version string: "+ s );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void setNumbers( String s ) {
|
||||
if ( s == null || s.length() == 0 ) {
|
||||
numbers = new int[0];
|
||||
return;
|
||||
}
|
||||
|
||||
Matcher m = numbersPtn.matcher( s );
|
||||
if ( m.matches() ) {
|
||||
String numString = m.group( 1 );
|
||||
String[] numChunks = numString.split("[.]");
|
||||
|
||||
numbers = new int[ numChunks.length ];
|
||||
for ( int i=0; i < numChunks.length; i++ ) {
|
||||
numbers[i] = Integer.parseInt( numChunks[i] );
|
||||
}
|
||||
}
|
||||
else {
|
||||
throw new IllegalArgumentException( "Could not parse version numbers string: "+ s );
|
||||
}
|
||||
}
|
||||
|
||||
private void setSuffix( String s ) {
|
||||
if ( s == null || s.length() == 0 ) {
|
||||
suffix = null;
|
||||
suffixNum = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
Matcher m = suffixPtn.matcher( s );
|
||||
if ( m.matches() ) {
|
||||
suffix = s;
|
||||
|
||||
// Matched groups 1 and 2... or 3.
|
||||
|
||||
if ( m.group(1) != null ) {
|
||||
suffixDivider = m.group(1);
|
||||
}
|
||||
|
||||
if ( m.group(2) != null ) {
|
||||
suffixNum = Integer.parseInt( m.group(2) );
|
||||
} else {
|
||||
suffixNum = -1;
|
||||
}
|
||||
|
||||
suffix = m.group(0);
|
||||
}
|
||||
else {
|
||||
throw new IllegalArgumentException( "Could not parse version suffix string: "+ s );
|
||||
}
|
||||
}
|
||||
|
||||
private void setComment( String s ) {
|
||||
if ( s == null || s.length() == 0 ) {
|
||||
comment = null;
|
||||
return;
|
||||
}
|
||||
|
||||
Matcher m = commentPtn.matcher( s );
|
||||
if ( m.matches() ) {
|
||||
comment = m.group(1);
|
||||
}
|
||||
else {
|
||||
throw new IllegalArgumentException( "Could not parse version comment string: "+ s );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the array of major/minor/etc version numbers.
|
||||
*/
|
||||
public int[] getNumbers() {
|
||||
return numbers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the pre-number portion of the suffix, or null if there was no number.
|
||||
*/
|
||||
public String getSuffixDivider() {
|
||||
return suffixDivider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number in the suffix, or -1 if there was no number.
|
||||
*/
|
||||
public int getSuffixNumber() {
|
||||
return suffixNum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the entire suffix, or null.
|
||||
*/
|
||||
public String getSuffix() {
|
||||
return suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the human-readable comment, or null.
|
||||
*/
|
||||
public String getComment() {
|
||||
return comment;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
StringBuilder buf = new StringBuilder();
|
||||
for ( int number : numbers ) {
|
||||
if ( buf.length() > 0 ) buf.append( "." );
|
||||
buf.append( number );
|
||||
}
|
||||
if ( suffix != null ) {
|
||||
buf.append( suffix );
|
||||
}
|
||||
if ( comment != null ) {
|
||||
if ( buf.length() > 0 ) buf.append( " " );
|
||||
buf.append( comment );
|
||||
}
|
||||
|
||||
return buf.toString();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Compares this object with the specified object for order.
|
||||
*
|
||||
* - The ints are compared arithmetically. In case of ties,
|
||||
* the version with the most numbers wins.
|
||||
* - If both versions' suffixes have a number, and the same
|
||||
* characters appear before that number, then the suffix number
|
||||
* is compared arithmetically.
|
||||
* - Then the entire suffix is compared alphabetically.
|
||||
* - Then the comment is compared alphabetically.
|
||||
*/
|
||||
@Override
|
||||
public int compareTo( ComparableVersion other ) {
|
||||
if ( other == null ) return -1;
|
||||
if ( other == this ) return 0;
|
||||
|
||||
int[] oNumbers = other.getNumbers();
|
||||
for ( int i=0; i < numbers.length && i < oNumbers.length; i++ ) {
|
||||
if ( numbers[i] < oNumbers[i] ) return -1;
|
||||
if ( numbers[i] > oNumbers[i] ) return 1;
|
||||
}
|
||||
if ( numbers.length < oNumbers.length ) return -1;
|
||||
if ( numbers.length > oNumbers.length ) return 1;
|
||||
|
||||
if ( suffixDivider != null && other.getSuffixDivider() != null ) {
|
||||
if ( suffixDivider.equals( other.getSuffixDivider() ) ) {
|
||||
if ( suffixNum < other.getSuffixNumber() ) return -1;
|
||||
if ( suffixNum > other.getSuffixNumber() ) return 1;
|
||||
}
|
||||
}
|
||||
|
||||
if ( suffix == null && other.getSuffix() != null ) return -1;
|
||||
if ( suffix != null && other.getSuffix() == null ) return 1;
|
||||
if ( suffix != null && other.getSuffix() != null ) {
|
||||
int cmp = suffix.compareTo( other.getSuffix() );
|
||||
if ( cmp != 0 ) return cmp;
|
||||
}
|
||||
|
||||
if ( comment == null && other.getComment() != null ) return -1;
|
||||
if ( comment != null && other.getComment() == null ) return 1;
|
||||
if ( comment != null && other.getComment() != null ) {
|
||||
int cmp = comment.compareTo( other.getComment() );
|
||||
if ( cmp != 0 ) return cmp;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals( Object o ) {
|
||||
if ( o == null ) return false;
|
||||
if ( o == this ) return true;
|
||||
if ( o instanceof ComparableVersion == false ) return false;
|
||||
ComparableVersion other = (ComparableVersion)o;
|
||||
|
||||
int[] oNumbers = other.getNumbers();
|
||||
for ( int i=0; i < numbers.length && i < oNumbers.length; i++ ) {
|
||||
if ( numbers[i] != oNumbers[i] ) return false;
|
||||
}
|
||||
if ( numbers.length != oNumbers.length ) return false;
|
||||
|
||||
if ( suffix == null && other.getSuffix() != null ) return false;
|
||||
if ( suffix != null && other.getSuffix() == null ) return false;
|
||||
if ( !suffix.equals( other.getSuffix() ) ) return false;
|
||||
|
||||
if ( comment == null && other.getComment() != null ) return false;
|
||||
if ( comment != null && other.getComment() == null ) return false;
|
||||
if ( !comment.equals( other.getComment() ) ) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = 79;
|
||||
int salt = 35;
|
||||
int nullCode = 13;
|
||||
|
||||
List<Integer> tmpNumbers = new ArrayList<Integer>( getNumbers().length );
|
||||
for ( int n : getNumbers() )
|
||||
tmpNumbers.add( new Integer(n) );
|
||||
result = salt * result + tmpNumbers.hashCode();
|
||||
|
||||
String tmpSuffix = getSuffix();
|
||||
if ( tmpSuffix == null )
|
||||
result = salt * result + nullCode;
|
||||
else
|
||||
result = salt * result + tmpSuffix.hashCode();
|
||||
|
||||
String tmpComment = getComment();
|
||||
if ( tmpComment == null )
|
||||
result = salt * result + nullCode;
|
||||
else
|
||||
result = salt * result + tmpComment.hashCode();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
163
src/main/java/net/vhati/modmanager/core/FTLUtilities.java
Normal file
163
src/main/java/net/vhati/modmanager/core/FTLUtilities.java
Normal file
|
@ -0,0 +1,163 @@
|
|||
package net.vhati.modmanager.core;
|
||||
|
||||
import java.awt.Component;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import javax.swing.JFileChooser;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.filechooser.FileFilter;
|
||||
|
||||
|
||||
public class FTLUtilities {
|
||||
|
||||
/**
|
||||
* Confirms the FTL resources dir exists and contains the dat files.
|
||||
*/
|
||||
public static boolean isDatsDirValid( File d ) {
|
||||
if ( !d.exists() || !d.isDirectory() ) return false;
|
||||
if ( !new File(d, "data.dat").exists() ) return false;
|
||||
if ( !new File(d, "resource.dat").exists() ) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the FTL resources dir, or null.
|
||||
*/
|
||||
public static File findDatsDir() {
|
||||
String steamPath = "Steam/steamapps/common/FTL Faster Than Light/resources";
|
||||
String gogPath = "GOG.com/Faster Than Light/resources";
|
||||
|
||||
String xdgDataHome = System.getenv("XDG_DATA_HOME");
|
||||
if (xdgDataHome == null)
|
||||
xdgDataHome = System.getProperty("user.home") +"/.local/share";
|
||||
|
||||
File[] candidates = new File[] {
|
||||
// Windows - Steam
|
||||
new File( new File(""+System.getenv("ProgramFiles(x86)")), steamPath ),
|
||||
new File( new File(""+System.getenv("ProgramFiles")), steamPath ),
|
||||
// Windows - GOG
|
||||
new File( new File(""+System.getenv("ProgramFiles(x86)")), gogPath ),
|
||||
new File( new File(""+System.getenv("ProgramFiles")), gogPath ),
|
||||
// Linux - Steam
|
||||
new File( xdgDataHome +"/Steam/SteamApps/common/FTL Faster Than Light/data/resources" ),
|
||||
// OSX - Steam
|
||||
new File( System.getProperty("user.home") +"/Library/Application Support/Steam/SteamApps/common/FTL Faster Than Light/FTL.app/Contents/Resources" ),
|
||||
// OSX
|
||||
new File( "/Applications/FTL.app/Contents/Resources" )
|
||||
};
|
||||
|
||||
File result = null;
|
||||
|
||||
for ( File candidate : candidates ) {
|
||||
if ( isDatsDirValid( candidate ) ) {
|
||||
result = candidate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Modally prompts the user for the FTL resources dir.
|
||||
*
|
||||
* @param parentComponent a parent for Swing popups, or null
|
||||
*/
|
||||
public static File promptForDatsDir( Component parentComponent ) {
|
||||
File result = null;
|
||||
|
||||
String message = "";
|
||||
message += "You will now be prompted to locate FTL manually.\n";
|
||||
message += "Select '(FTL dir)/resources/data.dat'.\n";
|
||||
message += "Or 'FTL.app', if you're on OSX.";
|
||||
JOptionPane.showMessageDialog(null, message, "Find FTL", JOptionPane.INFORMATION_MESSAGE);
|
||||
|
||||
final JFileChooser fc = new JFileChooser();
|
||||
fc.setDialogTitle( "Find data.dat or FTL.app" );
|
||||
fc.addChoosableFileFilter(new FileFilter() {
|
||||
@Override
|
||||
public String getDescription() {
|
||||
return "FTL Data File - (FTL dir)/resources/data.dat";
|
||||
}
|
||||
@Override
|
||||
public boolean accept(File f) {
|
||||
return f.isDirectory() || f.getName().equals("data.dat") || f.getName().equals("FTL.app");
|
||||
}
|
||||
});
|
||||
fc.setMultiSelectionEnabled(false);
|
||||
|
||||
if ( fc.showOpenDialog(null) == JFileChooser.APPROVE_OPTION ) {
|
||||
File f = fc.getSelectedFile();
|
||||
if ( f.getName().equals("data.dat") ) {
|
||||
result = f.getParentFile();
|
||||
}
|
||||
else if ( f.getName().endsWith(".app") && f.isDirectory() ) {
|
||||
File contentsPath = new File(f, "Contents");
|
||||
if( contentsPath.exists() && contentsPath.isDirectory() && new File(contentsPath, "Resources").exists() )
|
||||
result = new File(contentsPath, "Resources");
|
||||
}
|
||||
}
|
||||
|
||||
if ( result != null && isDatsDirValid( result ) ) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the executable that will launch FTL, or null.
|
||||
*
|
||||
* On Windows, FTLGame.exe is one dir above "resources/".
|
||||
* On OSX, FTL.app is the grandparent dir itself (a bundle).
|
||||
*/
|
||||
public static File findGameExe( File datsDir ) {
|
||||
File result = null;
|
||||
|
||||
if ( System.getProperty("os.name").startsWith("Windows") ) {
|
||||
File ftlDir = datsDir.getParentFile();
|
||||
if ( ftlDir != null ) {
|
||||
File exeFile = new File( ftlDir, "FTLGame.exe" );
|
||||
if ( exeFile.exists() ) result = exeFile;
|
||||
}
|
||||
}
|
||||
else if ( System.getProperty("os.name").contains("OS X") ) {
|
||||
// FTL.app/Contents/Resources/
|
||||
File contentsDir = datsDir.getParentFile();
|
||||
if ( contentsDir != null ) {
|
||||
File bundleDir = contentsDir.getParentFile();
|
||||
if ( bundleDir != null ) {
|
||||
if ( new File( bundleDir, "Contents/Info.plist" ).exists() ) {
|
||||
result = bundleDir;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Spawns the game (FTLGame.exe or FTL.app).
|
||||
*
|
||||
* @param exeFile see findGameExe()
|
||||
* @return a Process object, or null
|
||||
*/
|
||||
public static Process launchGame( File exeFile ) throws IOException {
|
||||
if ( exeFile == null ) return null;
|
||||
|
||||
Process result = null;
|
||||
ProcessBuilder pb = null;
|
||||
if ( System.getProperty("os.name").contains("OS X") ) {
|
||||
pb = new ProcessBuilder( "open", "-a", exeFile.getAbsolutePath() );
|
||||
} else {
|
||||
pb = new ProcessBuilder( exeFile.getAbsolutePath() );
|
||||
}
|
||||
if ( pb != null ) {
|
||||
pb.directory( exeFile.getParentFile() );
|
||||
result = pb.start();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package net.vhati.modmanager.core;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
|
||||
public interface HashObserver {
|
||||
public void hashCalculated( File f, String hash );
|
||||
}
|
58
src/main/java/net/vhati/modmanager/core/HashThread.java
Normal file
58
src/main/java/net/vhati/modmanager/core/HashThread.java
Normal file
|
@ -0,0 +1,58 @@
|
|||
package net.vhati.modmanager.core;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import net.vhati.ftldat.FTLDat;
|
||||
import net.vhati.modmanager.core.HashObserver;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
|
||||
/**
|
||||
* A thread to calculate MD5 hashes of files in the background.
|
||||
*
|
||||
* As each file is hashed, a class implementing HashObserver is notified.
|
||||
* Note: The callback on that class needs to be thread-safe.
|
||||
*/
|
||||
public class HashThread extends Thread {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(HashThread.class);
|
||||
|
||||
private List<File> fileList = new ArrayList<File>();
|
||||
private HashObserver hashObserver = null;
|
||||
|
||||
|
||||
public HashThread( File[] files, HashObserver hashObserver ) {
|
||||
this.fileList.addAll( Arrays.asList(files) );
|
||||
this.hashObserver = hashObserver;
|
||||
}
|
||||
|
||||
|
||||
public void run() {
|
||||
for ( File f : fileList ) {
|
||||
String hash = calcFileMD5( f );
|
||||
if ( hash != null ) {
|
||||
hashObserver.hashCalculated( f, hash );
|
||||
}
|
||||
}
|
||||
|
||||
log.info( "Background hashing finished." );
|
||||
}
|
||||
|
||||
|
||||
private String calcFileMD5( File f ) {
|
||||
String result = null;
|
||||
try {
|
||||
result = FTLDat.calcFileMD5( f );
|
||||
}
|
||||
catch ( Exception e ) {
|
||||
log.error( "Error while calculating hash for file: "+ f.getPath(), e );
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
61
src/main/java/net/vhati/modmanager/core/ModDB.java
Normal file
61
src/main/java/net/vhati/modmanager/core/ModDB.java
Normal file
|
@ -0,0 +1,61 @@
|
|||
package net.vhati.modmanager.core;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
|
||||
import net.vhati.modmanager.core.ModInfo;
|
||||
|
||||
|
||||
public class ModDB {
|
||||
|
||||
// Accociates Forum thread urls with hashes of their forst post's content.
|
||||
private HashMap<String,String> threadHashMap = new HashMap<String,String>();
|
||||
|
||||
private ArrayList<ModInfo> catalog = new ArrayList<ModInfo>();
|
||||
|
||||
|
||||
/**
|
||||
* Returns mod info for a given file hash.
|
||||
*/
|
||||
public ModInfo getModInfo( String hash ) {
|
||||
if ( hash == null ) return null;
|
||||
|
||||
for ( ModInfo modInfo : catalog ) {
|
||||
if ( modInfo.getFileHash().equals(hash) ) {
|
||||
return modInfo;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void addMod( ModInfo modInfo ) {
|
||||
catalog.add( modInfo );
|
||||
}
|
||||
|
||||
public void removeMod( ModInfo modInfo ) {
|
||||
catalog.remove( modInfo );
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores the first-post content hash of a forum thread.
|
||||
*/
|
||||
public void putThreadHash( String url, String threadHash ) {
|
||||
threadHashMap.put( url, threadHash );
|
||||
}
|
||||
|
||||
public String getThreadHash( String url ) {
|
||||
return threadHashMap.get( url );
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
threadHashMap.clear();
|
||||
catalog.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the internal ArrayList of mod info.
|
||||
*/
|
||||
public ArrayList<ModInfo> getCatalog() {
|
||||
return catalog;
|
||||
}
|
||||
}
|
54
src/main/java/net/vhati/modmanager/core/ModFileInfo.java
Normal file
54
src/main/java/net/vhati/modmanager/core/ModFileInfo.java
Normal file
|
@ -0,0 +1,54 @@
|
|||
package net.vhati.modmanager.core;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
|
||||
public class ModFileInfo implements Comparable<ModFileInfo> {
|
||||
private File file;
|
||||
private String name;
|
||||
|
||||
|
||||
public ModFileInfo( File f ) {
|
||||
this.file = f;
|
||||
this.name = f.getName().replaceAll( "[.][^.]+$", "" );
|
||||
}
|
||||
|
||||
public File getFile() { return this.file; }
|
||||
public String getName() { return this.name; }
|
||||
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return getName();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo( ModFileInfo other ) {
|
||||
if ( other == null ) return -1;
|
||||
if ( other == this ) return 0;
|
||||
|
||||
return getName().compareTo( other.getName() );
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals( Object o ) {
|
||||
if ( o == null ) return false;
|
||||
if ( o == this ) return true;
|
||||
if ( o instanceof ModFileInfo == false ) return false;
|
||||
|
||||
ModFileInfo other = (ModFileInfo)o;
|
||||
if ( !getFile().equals( other.getFile() ) ) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = 89;
|
||||
int salt = 36;
|
||||
|
||||
result = salt * result + getFile().hashCode();
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
62
src/main/java/net/vhati/modmanager/core/ModInfo.java
Normal file
62
src/main/java/net/vhati/modmanager/core/ModInfo.java
Normal file
|
@ -0,0 +1,62 @@
|
|||
package net.vhati.modmanager.core;
|
||||
|
||||
|
||||
public class ModInfo {
|
||||
private String title = "???";
|
||||
private String author = "???";
|
||||
private String url = "???";
|
||||
private String description = "";
|
||||
private String fileHash = "???";
|
||||
private String version = "???";
|
||||
|
||||
|
||||
public void setTitle( String s ) { this.title = s; }
|
||||
public void setAuthor( String s ) { this.author = s; }
|
||||
public void setURL( String s ) { this.url = s; }
|
||||
public void setDescription( String s ) { this.description = s; }
|
||||
public void setFileHash( String s ) { this.fileHash = s; }
|
||||
public void setVersion( String s ) { this.version = s; }
|
||||
|
||||
public String getTitle() { return this.title; }
|
||||
public String getAuthor() { return this.author; }
|
||||
public String getURL() { return this.url; }
|
||||
public String getDescription() { return this.description; }
|
||||
public String getFileHash() { return this.fileHash; }
|
||||
public String getVersion() { return this.version; }
|
||||
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return getTitle();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals( Object o ) {
|
||||
if ( o == null ) return false;
|
||||
if ( o == this ) return true;
|
||||
if ( o instanceof ModInfo == false ) return false;
|
||||
|
||||
ModInfo other = (ModInfo)o;
|
||||
if ( !getTitle().equals( other.getTitle() ) ) return false;
|
||||
if ( !getAuthor().equals( other.getAuthor() ) ) return false;
|
||||
if ( !getURL().equals( other.getURL() ) ) return false;
|
||||
if ( !getDescription().equals( other.getDescription() ) ) return false;
|
||||
if ( !getFileHash().equals( other.getFileHash() ) ) return false;
|
||||
if ( !getVersion().equals( other.getVersion() ) ) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = 79;
|
||||
int salt = 35;
|
||||
|
||||
result = salt * result + getTitle().hashCode();
|
||||
result = salt * result + getAuthor().hashCode();
|
||||
result = salt * result + getURL().hashCode();
|
||||
result = salt * result + getDescription().hashCode();
|
||||
result = salt * result + getFileHash().hashCode();
|
||||
result = salt * result + getVersion().hashCode();
|
||||
return result;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
package net.vhati.modmanager.core;
|
||||
|
||||
import java.io.File;
|
||||
|
||||
|
||||
public interface ModPatchObserver {
|
||||
|
||||
/**
|
||||
* Updates a progress bar.
|
||||
*
|
||||
* If either arg is -1, the bar will become indeterminate.
|
||||
*
|
||||
* @param value the new value
|
||||
* @param max the new maximum
|
||||
*/
|
||||
public void patchingProgress( final int value, final int max );
|
||||
|
||||
/**
|
||||
* Non-specific activity.
|
||||
*
|
||||
* @param message a string, or null
|
||||
*/
|
||||
public void patchingStatus( String message );
|
||||
|
||||
/**
|
||||
* A mod is about to be processed.
|
||||
*/
|
||||
public void patchingMod( File modFile );
|
||||
|
||||
/**
|
||||
* Patching ended.
|
||||
* If anything went wrong, e may be non-null.
|
||||
*/
|
||||
public void patchingEnded( boolean success, Exception e );
|
||||
}
|
325
src/main/java/net/vhati/modmanager/core/ModPatchThread.java
Normal file
325
src/main/java/net/vhati/modmanager/core/ModPatchThread.java
Normal file
|
@ -0,0 +1,325 @@
|
|||
package net.vhati.modmanager.core;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import net.vhati.ftldat.FTLDat;
|
||||
import net.vhati.ftldat.FTLDat.AbstractPack;
|
||||
import net.vhati.ftldat.FTLDat.FTLPack;
|
||||
import net.vhati.modmanager.core.ModPatchObserver;
|
||||
import net.vhati.modmanager.core.ModUtilities;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
|
||||
public class ModPatchThread extends Thread {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(ModPatchThread.class);
|
||||
|
||||
// Other threads can check or set this.
|
||||
public volatile boolean keepRunning = true;
|
||||
|
||||
private Thread shutdownHook = null;
|
||||
|
||||
private List<File> modFiles = new ArrayList<File>();
|
||||
private BackedUpDat dataDat = null;
|
||||
private BackedUpDat resDat = null;
|
||||
private ModPatchObserver observer = null;
|
||||
|
||||
private final int progMax = 100;
|
||||
private final int progBackupMax = 25;
|
||||
private final int progClobberMax = 25;
|
||||
private final int progModsMax = 40;
|
||||
private final int progRepackMax = 5;
|
||||
private int progMilestone = 0;
|
||||
|
||||
public ModPatchThread( List<File> modFiles, BackedUpDat dataDat, BackedUpDat resDat, ModPatchObserver observer ) {
|
||||
this.modFiles.addAll( modFiles );
|
||||
this.dataDat = dataDat;
|
||||
this.resDat = resDat;
|
||||
this.observer = observer;
|
||||
}
|
||||
|
||||
|
||||
public void run() {
|
||||
boolean result;
|
||||
Exception exception = null;
|
||||
|
||||
// When JVM tries to exit, stall until this thread ends on its own.
|
||||
shutdownHook = new Thread() {
|
||||
@Override
|
||||
public void run() {
|
||||
keepRunning = false;
|
||||
boolean interrupted = false;
|
||||
try {
|
||||
while ( true ) {
|
||||
try {
|
||||
ModPatchThread.this.join();
|
||||
break;
|
||||
}
|
||||
catch ( InterruptedException e ) {
|
||||
interrupted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ( interrupted ) Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
};
|
||||
Runtime.getRuntime().addShutdownHook( shutdownHook );
|
||||
|
||||
try {
|
||||
result = patch();
|
||||
}
|
||||
catch ( Exception e ) {
|
||||
log.error( "Patching failed. See log for details.", e );
|
||||
exception = e;
|
||||
result = false;
|
||||
}
|
||||
|
||||
observer.patchingEnded( result, exception );
|
||||
|
||||
Runtime.getRuntime().removeShutdownHook( shutdownHook );
|
||||
}
|
||||
|
||||
|
||||
private boolean patch() throws IOException {
|
||||
|
||||
observer.patchingProgress( 0, progMax );
|
||||
|
||||
BackedUpDat[] allDats = new BackedUpDat[] {dataDat, resDat};
|
||||
|
||||
FTLPack dataP = null;
|
||||
FTLPack resP = null;
|
||||
|
||||
try {
|
||||
int backupsCreated = 0;
|
||||
int datsClobbered = 0;
|
||||
int modsInstalled = 0;
|
||||
int datsRepacked = 0;
|
||||
|
||||
// Create backup dats, if necessary.
|
||||
for ( BackedUpDat dat : allDats ) {
|
||||
if ( !dat.bakFile.exists() ) {
|
||||
log.info( String.format( "Backing up \"%s\".", dat.datFile.getName() ) );
|
||||
observer.patchingStatus( String.format( "Backing up \"%s\".", dat.datFile.getName() ) );
|
||||
|
||||
FTLDat.copyFile( dat.datFile, dat.bakFile );
|
||||
backupsCreated++;
|
||||
observer.patchingProgress( progMilestone + progBackupMax/allDats.length*backupsCreated, progMax );
|
||||
|
||||
if ( !keepRunning ) return false;
|
||||
}
|
||||
}
|
||||
progMilestone += progBackupMax;
|
||||
observer.patchingProgress( progMilestone, progMax );
|
||||
observer.patchingStatus( null );
|
||||
|
||||
if ( backupsCreated != allDats.length ) {
|
||||
// Clobber current dat files with their respective backups.
|
||||
// But don't bother if we made those backups just now.
|
||||
|
||||
for ( BackedUpDat dat : allDats ) {
|
||||
log.info( String.format( "Restoring vanilla \"%s\"...", dat.datFile.getName() ) );
|
||||
observer.patchingStatus( String.format( "Restoring vanilla \"%s\"...", dat.datFile.getName() ) );
|
||||
|
||||
FTLDat.copyFile( dat.bakFile, dat.datFile );
|
||||
datsClobbered++;
|
||||
observer.patchingProgress( progMilestone + progClobberMax/allDats.length*datsClobbered, progMax );
|
||||
|
||||
if ( !keepRunning ) return false;
|
||||
}
|
||||
observer.patchingStatus( null );
|
||||
}
|
||||
progMilestone += progClobberMax;
|
||||
observer.patchingProgress( progMilestone, progMax );
|
||||
|
||||
if ( modFiles.isEmpty() ) {
|
||||
// No mods. Nothing else to do.
|
||||
observer.patchingProgress( progMax, progMax );
|
||||
return true;
|
||||
}
|
||||
|
||||
dataP = new FTLPack( dataDat.datFile, false );
|
||||
resP = new FTLPack( resDat.datFile, false );
|
||||
|
||||
Map<String,AbstractPack> topFolderMap = new HashMap<String,AbstractPack>();
|
||||
topFolderMap.put( "data", dataP );
|
||||
topFolderMap.put( "audio", resP );
|
||||
topFolderMap.put( "fonts", resP );
|
||||
topFolderMap.put( "img", resP );
|
||||
|
||||
// Track modified innerPaths in case they're clobbered.
|
||||
List<String> moddedItems = new ArrayList<String>();
|
||||
|
||||
List<String> knownPaths = new ArrayList<String>();
|
||||
knownPaths.addAll( dataP.list() );
|
||||
knownPaths.addAll( resP.list() );
|
||||
|
||||
List<String> knownPathsLower = new ArrayList<String>( knownPaths.size() );
|
||||
for ( String innerPath : knownPaths ) {
|
||||
knownPathsLower.add( innerPath.toLowerCase() );
|
||||
}
|
||||
|
||||
// Group1: parentPath, Group2: topFolder, Group3: fileName
|
||||
Pattern pathPtn = Pattern.compile( "^(([^/]+)/(?:.*/)?)([^/]+)$" );
|
||||
|
||||
for ( File modFile : modFiles ) {
|
||||
if ( !keepRunning ) return false;
|
||||
|
||||
ZipInputStream zis = null;
|
||||
try {
|
||||
log.info( "" );
|
||||
log.info( String.format( "Installing mod: %s", modFile.getName() ) );
|
||||
observer.patchingMod( modFile );
|
||||
|
||||
zis = new ZipInputStream( new FileInputStream( modFile ) );
|
||||
ZipEntry item;
|
||||
while ( (item = zis.getNextEntry()) != null ) {
|
||||
if ( item.isDirectory() ) continue;
|
||||
|
||||
Matcher m = pathPtn.matcher( item.getName() );
|
||||
if ( !m.matches() ) {
|
||||
log.warn( String.format( "Unexpected innerPath: %s", item.getName() ) );
|
||||
zis.closeEntry();
|
||||
continue;
|
||||
}
|
||||
|
||||
String parentPath = m.group(1);
|
||||
String topFolder = m.group(2);
|
||||
String fileName = m.group(3);
|
||||
|
||||
AbstractPack ftlP = topFolderMap.get( topFolder );
|
||||
if ( ftlP == null ) {
|
||||
log.warn( String.format( "Unexpected innerPath: %s", item.getName() ) );
|
||||
zis.closeEntry();
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( fileName.endsWith( ".xml.append" ) || fileName.endsWith( ".append.xml" ) ) {
|
||||
String innerPath = parentPath + fileName.replaceAll( "[.](?:xml[.]append|append[.]xml)$", ".xml" );
|
||||
innerPath = checkCase( innerPath, knownPaths, knownPathsLower );
|
||||
|
||||
if ( !ftlP.contains( innerPath ) ) {
|
||||
log.warn( String.format( "Non-existent innerPath wasn't appended: %s", innerPath ) );
|
||||
}
|
||||
else {
|
||||
InputStream dstStream = null;
|
||||
try {
|
||||
dstStream = ftlP.getInputStream(innerPath);
|
||||
InputStream mergedStream = ModUtilities.appendXMLFile( zis, dstStream, ftlP.getName()+":"+innerPath, modFile.getName()+":"+parentPath+fileName );
|
||||
dstStream.close();
|
||||
ftlP.remove( innerPath );
|
||||
ftlP.add( innerPath, mergedStream );
|
||||
}
|
||||
finally {
|
||||
try {if ( dstStream != null ) dstStream.close();}
|
||||
catch ( IOException e ) {}
|
||||
}
|
||||
|
||||
if ( !moddedItems.contains(innerPath) )
|
||||
moddedItems.add( innerPath );
|
||||
}
|
||||
}
|
||||
else {
|
||||
String innerPath = checkCase( item.getName(), knownPaths, knownPathsLower );
|
||||
|
||||
if ( !moddedItems.contains(innerPath) )
|
||||
moddedItems.add( innerPath );
|
||||
else
|
||||
log.warn( String.format( "Clobbering earlier mods: %s", innerPath ) );
|
||||
|
||||
if ( ftlP.contains( innerPath ) )
|
||||
ftlP.remove( innerPath );
|
||||
ftlP.add( innerPath, zis );
|
||||
}
|
||||
|
||||
zis.closeEntry();
|
||||
}
|
||||
}
|
||||
finally {
|
||||
try {if (zis != null) zis.close();}
|
||||
catch ( Exception e ) {}
|
||||
|
||||
System.gc();
|
||||
}
|
||||
|
||||
modsInstalled++;
|
||||
observer.patchingProgress( progMilestone + progModsMax/modFiles.size()*modsInstalled, progMax );
|
||||
}
|
||||
progMilestone += progModsMax;
|
||||
observer.patchingProgress( progMilestone, progMax );
|
||||
|
||||
// Prune 'removed' files from dats.
|
||||
for ( AbstractPack ftlP : new AbstractPack[]{dataP,resP} ) {
|
||||
if ( ftlP instanceof FTLPack ) {
|
||||
observer.patchingStatus( String.format( "Repacking \"%s\"...", ftlP.getName() ) );
|
||||
|
||||
long bytesChanged = ((FTLPack)ftlP).repack().bytesChanged;
|
||||
log.info( String.format( "Repacked \"%s\" (%d bytes affected)", ftlP.getName(), bytesChanged ) );
|
||||
|
||||
datsRepacked++;
|
||||
observer.patchingProgress( progMilestone + progRepackMax/allDats.length*datsRepacked, progMax );
|
||||
}
|
||||
}
|
||||
progMilestone += progRepackMax;
|
||||
observer.patchingProgress( progMilestone, progMax );
|
||||
|
||||
observer.patchingProgress( 100, progMax );
|
||||
return true;
|
||||
}
|
||||
finally {
|
||||
try {if (dataP != null) dataP.close();}
|
||||
catch( Exception e ) {}
|
||||
|
||||
try {if (resP != null) resP.close();}
|
||||
catch( Exception e ) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks if an innerPath exists, ignoring letter case.
|
||||
*
|
||||
* If there is no collision, the innerPath is added to the known lists.
|
||||
* A warning will be logged if a path with differing case exists.
|
||||
*
|
||||
* @param knownPaths a list of innerPaths seen so far
|
||||
* @param knownPathsLower a copy of knownPaths, lower-cased
|
||||
* @return the existing path (if different), or innerPath
|
||||
*/
|
||||
private String checkCase( String innerPath, List<String> knownPaths, List<String> knownPathsLower ) {
|
||||
if ( knownPaths.contains( innerPath ) ) return innerPath;
|
||||
|
||||
String lowerPath = innerPath.toLowerCase();
|
||||
int lowerIndex = knownPathsLower.indexOf( lowerPath );
|
||||
if ( lowerIndex != -1 ) {
|
||||
String knownPath = knownPaths.get( lowerIndex );
|
||||
log.warn( String.format( "Modded file's case doesn't match existing path: \"%s\" vs \"%s\"", innerPath, knownPath ) );
|
||||
return knownPath;
|
||||
}
|
||||
|
||||
knownPaths.add( innerPath );
|
||||
knownPathsLower.add( lowerPath );
|
||||
return innerPath;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public static class BackedUpDat {
|
||||
public File datFile = null;
|
||||
public File bakFile = null;
|
||||
}
|
||||
}
|
660
src/main/java/net/vhati/modmanager/core/ModUtilities.java
Normal file
660
src/main/java/net/vhati/modmanager/core/ModUtilities.java
Normal file
|
@ -0,0 +1,660 @@
|
|||
package net.vhati.modmanager.core;
|
||||
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.io.StringReader;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.nio.charset.CharacterCodingException;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.CharsetDecoder;
|
||||
import java.nio.charset.CharsetEncoder;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.util.zip.ZipInputStream;
|
||||
|
||||
import net.vhati.modmanager.core.Report;
|
||||
import net.vhati.modmanager.core.Report.ReportFormatter;
|
||||
import net.vhati.modmanager.core.Report.ReportMessage;
|
||||
|
||||
import ar.com.hjg.pngj.PngReader;
|
||||
|
||||
import org.jdom2.input.JDOMParseException;
|
||||
import org.jdom2.input.SAXBuilder;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
|
||||
public class ModUtilities {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(ModUtilities.class);
|
||||
|
||||
/**
|
||||
* Determines text encoding for an InputStream and decodes its bytes as a string.
|
||||
*
|
||||
* CR and CR-LF line endings will be normalized to LF.
|
||||
*
|
||||
* @param is a stream to read
|
||||
* @param description how error messages should refer to the stream, or null
|
||||
*/
|
||||
public static DecodeResult decodeText( InputStream is, String description ) throws IOException {
|
||||
String result = null;
|
||||
|
||||
byte[] buf = new byte[4096];
|
||||
int len;
|
||||
ByteArrayOutputStream tmpData = new ByteArrayOutputStream();
|
||||
while ( (len = is.read(buf)) >= 0 ) {
|
||||
tmpData.write( buf, 0, len );
|
||||
}
|
||||
byte[] allBytes = tmpData.toByteArray();
|
||||
tmpData.reset();
|
||||
|
||||
Map<byte[],String> boms = new LinkedHashMap<byte[],String>();
|
||||
boms.put( new byte[] {(byte)0xEF,(byte)0xBB,(byte)0xBF}, "UTF-8" );
|
||||
boms.put( new byte[] {(byte)0xFF,(byte)0xFE}, "UTF-16LE" );
|
||||
boms.put( new byte[] {(byte)0xFE,(byte)0xFF}, "UTF-16BE" );
|
||||
|
||||
String encoding = null;
|
||||
byte[] bom = null;
|
||||
|
||||
for ( Map.Entry<byte[],String> entry : boms.entrySet() ) {
|
||||
byte[] tmpBom = entry.getKey();
|
||||
byte[] firstBytes = Arrays.copyOfRange( allBytes, 0, tmpBom.length );
|
||||
if ( Arrays.equals( tmpBom, firstBytes ) ) {
|
||||
encoding = entry.getValue();
|
||||
bom = tmpBom;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( encoding != null ) {
|
||||
// This may throw CharacterCodingException.
|
||||
CharsetDecoder decoder = Charset.forName( encoding ).newDecoder();
|
||||
ByteBuffer byteBuffer = ByteBuffer.wrap( allBytes, bom.length, allBytes.length-bom.length );
|
||||
result = decoder.decode( byteBuffer ).toString();
|
||||
}
|
||||
else {
|
||||
ByteBuffer byteBuffer = ByteBuffer.wrap( allBytes );
|
||||
|
||||
Map<String,Exception> errorMap = new LinkedHashMap<String,Exception>();
|
||||
for ( String guess : new String[] {"UTF-8", "windows-1252"} ) {
|
||||
try {
|
||||
CharsetDecoder decoder = Charset.forName( guess ).newDecoder();
|
||||
result = decoder.decode( byteBuffer ).toString();
|
||||
encoding = guess;
|
||||
break;
|
||||
}
|
||||
catch ( CharacterCodingException e ) {
|
||||
errorMap.put( guess, e );
|
||||
}
|
||||
}
|
||||
if ( encoding == null ) {
|
||||
// All guesses failed!?
|
||||
String msg = String.format( "Could not guess encoding for %s.", (description!=null ? "\""+description+"\"" : "a file") );
|
||||
for ( Map.Entry<String,Exception> entry : errorMap.entrySet() ) {
|
||||
msg += String.format( "\nFailed to decode as %s: %s", entry.getKey(), entry.getValue() );
|
||||
}
|
||||
throw new IOException( msg );
|
||||
}
|
||||
}
|
||||
|
||||
result = result.replaceAll( "\r(?!\n)|\r\n", "\n" );
|
||||
return new DecodeResult( result, encoding, bom );
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Semi-intelligently appends XML from one file (src) onto another (dst).
|
||||
*
|
||||
* The two InputStreams are read, and the combined result
|
||||
* is returned as a new third InputStream.
|
||||
*
|
||||
* The returned stream is a ByteArrayInputStream
|
||||
* which doesn't need closing.
|
||||
*
|
||||
* The description arguments identify the streams for log messages.
|
||||
*/
|
||||
public static InputStream appendXMLFile( InputStream srcStream, InputStream dstStream, String srcDescription, String dstDescription ) throws IOException {
|
||||
Pattern xmlDeclPtn = Pattern.compile( "<[?]xml version=\"1.0\" encoding=\"[^\"]+?\"[?]>\n*" );
|
||||
|
||||
String srcText = decodeText( srcStream, srcDescription ).text;
|
||||
srcText = xmlDeclPtn.matcher(srcText).replaceFirst( "" );
|
||||
|
||||
String dstText = decodeText( dstStream, dstDescription ).text;
|
||||
dstText = xmlDeclPtn.matcher(dstText).replaceFirst( "" );
|
||||
|
||||
ByteArrayOutputStream tmpData = new ByteArrayOutputStream();
|
||||
BufferedWriter bw = new BufferedWriter( new OutputStreamWriter( tmpData, "UTF-8" ) );
|
||||
|
||||
bw.write( "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" );
|
||||
bw.write( dstText );
|
||||
bw.write( "\n\n<!-- Appended by GMM -->\n\n");
|
||||
bw.write( srcText );
|
||||
bw.write( "\n" );
|
||||
bw.flush();
|
||||
|
||||
InputStream result = new ByteArrayInputStream( tmpData.toByteArray() );
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a mod file for common problems.
|
||||
*
|
||||
* @param modFile an *.ftl file to check
|
||||
* @param formatter custom message decoration/indention, or null
|
||||
*/
|
||||
public static Report validateModFile( File modFile, ReportFormatter formatter ) {
|
||||
if ( formatter == null ) formatter = new ReportFormatter();
|
||||
|
||||
List<ReportMessage> messages = new ArrayList<ReportMessage>();
|
||||
List<ReportMessage> pendingMsgs = new ArrayList<ReportMessage>();
|
||||
boolean modValid = true;
|
||||
boolean seenAppend = false;
|
||||
|
||||
Pattern junkFilePtn = Pattern.compile( "[.]DS_Store$|^thumbs[.]db$" );
|
||||
|
||||
Pattern validRootDirPtn = Pattern.compile( "^(?:audio|data|fonts|img)/" );
|
||||
List<String> seenJunkDirs = new ArrayList<String>();
|
||||
|
||||
CharsetEncoder asciiEncoder = Charset.forName("US-ASCII").newEncoder();
|
||||
|
||||
ZipInputStream zis = null;
|
||||
try {
|
||||
zis = new ZipInputStream( new FileInputStream( modFile ) );
|
||||
ZipEntry item;
|
||||
while ( (item = zis.getNextEntry()) != null ) {
|
||||
String innerPath = item.getName();
|
||||
pendingMsgs.clear();
|
||||
|
||||
if ( !asciiEncoder.canEncode( innerPath ) ) {
|
||||
pendingMsgs.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
String.format( "Non-ASCII characters in path." )
|
||||
) );
|
||||
modValid = false;
|
||||
}
|
||||
|
||||
if ( innerPath.indexOf("/") == -1 ) {
|
||||
pendingMsgs.add( new ReportMessage(
|
||||
ReportMessage.WARNING,
|
||||
String.format( "Extraneous top-level file." )
|
||||
) );
|
||||
modValid = false;
|
||||
}
|
||||
else if ( !validRootDirPtn.matcher(innerPath).find() ) {
|
||||
String junkDir = innerPath.replaceFirst( "/.*", "/" );
|
||||
if ( !seenJunkDirs.contains( junkDir ) ) {
|
||||
seenJunkDirs.add( junkDir );
|
||||
pendingMsgs.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
String.format( "Unsupported top-level folder: %s", junkDir )
|
||||
) );
|
||||
}
|
||||
modValid = false;
|
||||
}
|
||||
else if ( item.isDirectory() ) {
|
||||
}
|
||||
else if ( junkFilePtn.matcher(innerPath).find() ) {
|
||||
pendingMsgs.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
String.format( "Junk file" )
|
||||
) );
|
||||
modValid = false;
|
||||
}
|
||||
else if ( innerPath.endsWith( ".png" ) ) {
|
||||
try {
|
||||
PngReader pngr = new PngReader( zis );
|
||||
|
||||
// Check for Truecolor+Alpha (32bit RGBA).
|
||||
if ( pngr.imgInfo.channels != 4 || pngr.imgInfo.bitDepth != 8 ) {
|
||||
|
||||
String colorTypeString = "???";
|
||||
if ( pngr.imgInfo.channels == 4 )
|
||||
colorTypeString = "RGB+Alpha";
|
||||
else if ( pngr.imgInfo.channels == 3 )
|
||||
colorTypeString = "RGB";
|
||||
else if ( pngr.imgInfo.channels == 2 )
|
||||
colorTypeString = "Gray+Alpha";
|
||||
else if ( pngr.imgInfo.channels == 1 && !pngr.imgInfo.greyscale )
|
||||
colorTypeString = "Indexed Color";
|
||||
else if ( pngr.imgInfo.channels == 1 && pngr.imgInfo.greyscale )
|
||||
colorTypeString = "Gray";
|
||||
|
||||
pendingMsgs.add( new ReportMessage(
|
||||
ReportMessage.WARNING,
|
||||
String.format( "ColorType: %s (Usually 32bit Truecolor+Alpha)", colorTypeString )
|
||||
) );
|
||||
}
|
||||
}
|
||||
catch ( Exception e ) {
|
||||
log.error( String.format( "Error while validating \"%s:%s\".", modFile.getName(), innerPath ), e );
|
||||
pendingMsgs.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
"An error occurred. See log for details."
|
||||
) );
|
||||
modValid = false;
|
||||
}
|
||||
}
|
||||
else if ( innerPath.matches( "^.*(?:.xml.append|.append.xml|.xml)$" ) ) {
|
||||
if ( innerPath.matches( "^.*(?:.xml.append|.append.xml)$" ) )
|
||||
seenAppend = true;
|
||||
|
||||
DecodeResult decodeResult = ModUtilities.decodeText( zis, modFile.getName()+":"+innerPath );
|
||||
|
||||
if ( decodeResult.bom != null ) {
|
||||
pendingMsgs.add( new ReportMessage(
|
||||
ReportMessage.WARNING,
|
||||
String.format( "%s BOM detected. (ascii is safest)", decodeResult.encoding )
|
||||
) );
|
||||
modValid = false;
|
||||
}
|
||||
|
||||
List<Pattern> oddCharPtns = new ArrayList<Pattern>();
|
||||
Map<Pattern,String> oddCharSuggestions = new HashMap<Pattern,String>();
|
||||
Map<Pattern,List<Character>> oddCharLists = new HashMap<Pattern,List<Character>>();
|
||||
|
||||
oddCharPtns.add( Pattern.compile( "\\u0060|\\u201A|\\u2018|\\u2019" ) );
|
||||
oddCharSuggestions.put( oddCharPtns.get(oddCharPtns.size()-1), "'" );
|
||||
|
||||
oddCharPtns.add( Pattern.compile( "\\u201E|\\u201C|\\u201D" ) );
|
||||
oddCharSuggestions.put( oddCharPtns.get(oddCharPtns.size()-1), "\"" );
|
||||
|
||||
oddCharPtns.add( Pattern.compile( "\\u2013|\\u2014" ) );
|
||||
oddCharSuggestions.put( oddCharPtns.get(oddCharPtns.size()-1), "-" );
|
||||
|
||||
oddCharPtns.add( Pattern.compile( "\\u2026" ) );
|
||||
oddCharSuggestions.put( oddCharPtns.get(oddCharPtns.size()-1), "..." );
|
||||
|
||||
for ( Pattern ptn : oddCharPtns ) {
|
||||
Matcher m = ptn.matcher( decodeResult.text );
|
||||
List<Character> chars = null;
|
||||
while ( m.find() ) {
|
||||
if ( chars == null )
|
||||
chars = new ArrayList<Character>();
|
||||
|
||||
Character cObj = new Character( m.group(0).charAt(0) );
|
||||
if ( !chars.contains( cObj ) )
|
||||
chars.add( cObj );
|
||||
}
|
||||
if ( chars != null )
|
||||
oddCharLists.put( ptn, chars );
|
||||
}
|
||||
for ( Pattern ptn : oddCharPtns ) {
|
||||
List<Character> chars = oddCharLists.get( ptn );
|
||||
if ( chars != null ) {
|
||||
String suggestion = oddCharSuggestions.get( ptn );
|
||||
StringBuilder charBuf = new StringBuilder( chars.size() );
|
||||
for ( Character cObj : chars )
|
||||
charBuf.append( cObj.charValue() );
|
||||
|
||||
pendingMsgs.add( new ReportMessage(
|
||||
ReportMessage.WARNING,
|
||||
String.format( "Odd characters resembling %s : %s", suggestion, charBuf.toString() )
|
||||
) );
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Nag if there are chars FTL can't show.
|
||||
|
||||
Report xmlReport = validateModXML( decodeResult.text, formatter );
|
||||
|
||||
if ( xmlReport.text.length() > 0 ) {
|
||||
pendingMsgs.add( new ReportMessage(
|
||||
ReportMessage.NESTED_BLOCK,
|
||||
xmlReport.text
|
||||
) );
|
||||
}
|
||||
|
||||
if ( xmlReport.outcome == false )
|
||||
modValid = false;
|
||||
}
|
||||
|
||||
if ( !pendingMsgs.isEmpty() ) {
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.SUBSECTION,
|
||||
innerPath
|
||||
) );
|
||||
messages.addAll( pendingMsgs );
|
||||
}
|
||||
|
||||
zis.closeEntry();
|
||||
}
|
||||
|
||||
if ( !seenAppend ) {
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.WARNING_SUBSECTION,
|
||||
"This mod doesn't append. It clobbers."
|
||||
) );
|
||||
modValid = false;
|
||||
}
|
||||
}
|
||||
catch ( Exception e ) {
|
||||
log.error( String.format( "Error while validating mod: %s", modFile.getName() ), e );
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
"An error occurred. See log for details."
|
||||
) );
|
||||
modValid = false;
|
||||
}
|
||||
finally {
|
||||
try {if ( zis != null ) zis.close();}
|
||||
catch ( IOException e ) {}
|
||||
}
|
||||
|
||||
if ( modValid ) {
|
||||
//messages.clear(); // Nothing bad enough to mention.
|
||||
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.INFO,
|
||||
String.format( "No Problems", modFile.getName() )
|
||||
) );
|
||||
}
|
||||
|
||||
// Insert the mod's filename at the top.
|
||||
messages.add( 0, new ReportMessage(
|
||||
ReportMessage.SECTION,
|
||||
String.format( "%s:", modFile.getName() )
|
||||
) );
|
||||
|
||||
StringBuilder resultBuf = new StringBuilder();
|
||||
formatter.format( messages, resultBuf );
|
||||
return new Report( resultBuf, modValid );
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks a mod's xml for problems.
|
||||
*
|
||||
* It first tries to preemptively fix and report
|
||||
* common typos all at once.
|
||||
* Then a real XML parser runs, which stops at the
|
||||
* first typo it sees. :/
|
||||
*
|
||||
* @param text unparsed xml
|
||||
* @param formatter custom message decoration/indention, or null
|
||||
*/
|
||||
public static Report validateModXML( String text, ReportFormatter formatter ) {
|
||||
if ( formatter == null ) formatter = new ReportFormatter();
|
||||
|
||||
List<ReportMessage> messages = new ArrayList<ReportMessage>();
|
||||
boolean xmlValid = true;
|
||||
|
||||
StringBuffer srcBuf = new StringBuffer( text );
|
||||
StringBuffer dstBuf = new StringBuffer( text.length() );
|
||||
StringBuffer tmpBuf; // For swapping;
|
||||
String ptn;
|
||||
Matcher m;
|
||||
|
||||
// Wrap everything in a root tag, while mindful of the xml declaration.
|
||||
Pattern xmlDeclPtn = Pattern.compile( "<[?]xml version=\"1.0\" encoding=\"[^\"]+?\"[?]>" );
|
||||
m = xmlDeclPtn.matcher( srcBuf );
|
||||
if ( m.find() ) {
|
||||
if ( m.start() == 0 ) {
|
||||
dstBuf.append( srcBuf.subSequence( 0, m.end() ) );
|
||||
dstBuf.append( "\n<wrapper>\n" );
|
||||
dstBuf.append( srcBuf.subSequence( m.end(), srcBuf.length() ) );
|
||||
}
|
||||
else {
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
"<?xml... ?> should only occur on the first line."
|
||||
) );
|
||||
dstBuf.append( "<wrapper>\n" );
|
||||
dstBuf.append( srcBuf.subSequence( 0, m.start() ) );
|
||||
dstBuf.append( srcBuf.subSequence( m.end(), srcBuf.length() ) );
|
||||
}
|
||||
dstBuf.append( "\n</wrapper>" );
|
||||
}
|
||||
else {
|
||||
dstBuf.insert( 0, "<wrapper>\n" );
|
||||
dstBuf.append( srcBuf );
|
||||
dstBuf.append( "\n</wrapper>" );
|
||||
}
|
||||
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
|
||||
|
||||
// Comments with long tails or double-dashes.
|
||||
m = Pattern.compile( "(?s)<!--(-*)(.*?)(-*)-->" ).matcher( srcBuf );
|
||||
while ( m.find() ) {
|
||||
if ( m.group(1).length() > 0 || m.group(3).length() > 0 || m.group(2).indexOf("--") != -1 ) {
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
"<!-- No other dashes should touch. -->"
|
||||
) );
|
||||
}
|
||||
m.appendReplacement( dstBuf, m.quoteReplacement(m.group(2).replaceAll("[^\n]", "")) ); // Strip comments, but preserve line count.
|
||||
}
|
||||
m.appendTail( dstBuf );
|
||||
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
|
||||
|
||||
// Mismatched single-line tags.
|
||||
// Example: blueprints.xml: <title>...</type>
|
||||
m = Pattern.compile( "<([^/!][^> ]+?)((?: [^>]+?)?)(?<!/)>([^<]+?)</([^>]+?)>" ).matcher( srcBuf );
|
||||
while ( m.find() ) {
|
||||
if ( m.group(1).equals( m.group(4) ) == false ) {
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
"<"+ m.group(1) +"...>...</"+ m.group(4) +">"
|
||||
) );
|
||||
m.appendReplacement( dstBuf, m.quoteReplacement("<"+ m.group(1) + m.group(2) +">"+ m.group(3) +"</"+ m.group(1) +">") );
|
||||
}
|
||||
}
|
||||
m.appendTail( dstBuf );
|
||||
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
|
||||
|
||||
// <pilot power="1"max="3" room="0"/>
|
||||
// Groan, \t separates attribs sometimes.
|
||||
m = Pattern.compile( "<([^> ]+?)( [^>]+?\")([^\"= \t>]+?=\"[^\"]+?\")((?:[^>]+?)?)>" ).matcher( srcBuf );
|
||||
while ( m.find() ) {
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
"<"+ m.group(1) +"...\""+ m.group(3) +"...>"
|
||||
) );
|
||||
m.appendReplacement( dstBuf, m.quoteReplacement("<"+ m.group(1) + m.group(2) +" "+ m.group(3) + m.group(4) +">") );
|
||||
}
|
||||
m.appendTail( dstBuf );
|
||||
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
|
||||
|
||||
// sector_data.xml closing tag.
|
||||
m = Pattern.compile( "((?s)<sectorDescription[^>]*>.*?)</sectorDescrption>" ).matcher( srcBuf );
|
||||
while ( m.find() ) {
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
"<sectorDescription>...</sectorDescrption>"
|
||||
) );
|
||||
m.appendReplacement( dstBuf, m.quoteReplacement(m.group(1) +"</sectorDescription>") );
|
||||
}
|
||||
m.appendTail( dstBuf );
|
||||
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
|
||||
|
||||
// {anyship}.xml: <gib1>...</gib2>
|
||||
m = Pattern.compile( "(?s)<(gib[0-9]+)>(.*?)</(gib[0-9]+)>" ).matcher( srcBuf );
|
||||
while ( m.find() ) {
|
||||
if ( m.group(1).equals( m.group(3) ) == false ) {
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
"<"+ m.group(1) +">...</"+ m.group(3) +">"
|
||||
) );
|
||||
m.appendReplacement( dstBuf, m.quoteReplacement("<"+ m.group(1) +">"+ m.group(2) +"</"+ m.group(1) +">") );
|
||||
}
|
||||
else {
|
||||
m.appendReplacement( dstBuf, m.quoteReplacement(m.group(0)) );
|
||||
}
|
||||
}
|
||||
m.appendTail( dstBuf );
|
||||
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
|
||||
|
||||
// event*.xml: <choice... hidden="true" hidden="true">
|
||||
m = Pattern.compile( "<([a-zA-Z0-9_-]+?)((?: [^>]+?)?) ([^>]+?)(=\"[^\">]+?\") \\3(?:=\"[^\">]+?\")([^>]*)>" ).matcher( srcBuf );
|
||||
while ( m.find() ) {
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
"<"+ m.group(1) +"... "+ m.group(3) +"=... "+ m.group(3) +"=...>"
|
||||
) );
|
||||
m.appendReplacement( dstBuf, m.quoteReplacement("<"+ m.group(1) + m.group(2) +" "+ m.group(3) + m.group(4) +" "+ m.group(5) +">") );
|
||||
}
|
||||
m.appendTail( dstBuf );
|
||||
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
|
||||
|
||||
// <shields>...</slot>
|
||||
ptn = "";
|
||||
ptn += "(<shields *(?: [^>]*)?>\\s*";
|
||||
ptn += "<slot *(?: [^>]*)?>\\s*";
|
||||
ptn += "(?:<direction>[^<]*</direction>\\s*)?";
|
||||
ptn += "(?:<number>[^<]*</number>\\s*)?";
|
||||
ptn += "</slot>\\s*)";
|
||||
ptn += "</slot>"; // Wrong closing tag.
|
||||
m = Pattern.compile( ptn ).matcher( srcBuf );
|
||||
while ( m.find() ) {
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
"<shields>...</slot>"
|
||||
) );
|
||||
m.appendReplacement( dstBuf, m.quoteReplacement(m.group(1) +"</shields>") );
|
||||
}
|
||||
m.appendTail( dstBuf );
|
||||
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
|
||||
|
||||
// <shipBlueprint>...</ship>
|
||||
ptn = "";
|
||||
ptn += "(<shipBlueprint *(?: [^>]*)?>\\s*";
|
||||
ptn += "<class>[^<]*</class>\\s*";
|
||||
ptn += "<systemList *(?: [^>]*)?>\\s*";
|
||||
ptn += "(?:<[a-zA-Z]+ *(?: [^>]*)?/>\\s*)*";
|
||||
ptn += "</systemList>\\s*";
|
||||
ptn += "(?:<droneList *(?: [^>]*)?>\\s*";
|
||||
ptn += "(?:<[a-zA-Z]+ *(?: [^>]*)?/>\\s*)*";
|
||||
ptn += "</droneList>\\s*)?";
|
||||
ptn += "(?:<weaponList *(?: [^>]*)?>\\s*";
|
||||
ptn += "(?:<[a-zA-Z]+ *(?: [^>]*)?/>\\s*)*";
|
||||
ptn += "</weaponList>\\s*)?";
|
||||
ptn += "(?:<[a-zA-Z]+ *(?: [^>]*)?/>\\s*)*)";
|
||||
ptn += "</ship>"; // Wrong closing tag.
|
||||
m = Pattern.compile( ptn ).matcher( srcBuf );
|
||||
while ( m.find() ) {
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
"<shipBlueprint>...</ship>"
|
||||
) );
|
||||
m.appendReplacement( dstBuf, m.quoteReplacement(m.group(1) +"</shipBlueprint>") );
|
||||
}
|
||||
m.appendTail( dstBuf );
|
||||
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
|
||||
|
||||
// <textList>...</text>
|
||||
ptn = "";
|
||||
ptn += "(?u)(<textList *(?: [^>]*)?>\\s*";
|
||||
ptn += "(?:<text *(?: [^>]*)?>[^<]*</text>\\s*)*)";
|
||||
ptn += "</text>"; // Wrong closing tag.
|
||||
m = Pattern.compile( ptn ).matcher( srcBuf );
|
||||
while ( m.find() ) {
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.ERROR,
|
||||
"<textList>...</text>"
|
||||
) );
|
||||
m.appendReplacement( dstBuf, m.quoteReplacement(m.group(1) +"</textList>") );
|
||||
}
|
||||
m.appendTail( dstBuf );
|
||||
tmpBuf = srcBuf; srcBuf = dstBuf; dstBuf = tmpBuf; dstBuf.setLength(0);
|
||||
|
||||
try {
|
||||
SAXBuilder saxBuilder = new SAXBuilder();
|
||||
saxBuilder.build( new StringReader(srcBuf.toString()) );
|
||||
|
||||
xmlValid = true;
|
||||
for ( ReportMessage message : messages ) {
|
||||
if ( message.type == ReportMessage.ERROR ) {
|
||||
xmlValid = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch ( JDOMParseException e ) {
|
||||
int lineNum = e.getLineNumber();
|
||||
if ( lineNum != -1 ) {
|
||||
int badStart = -1;
|
||||
int badEnd = -1;
|
||||
String badLine = "???";
|
||||
m = Pattern.compile( "\n" ).matcher( srcBuf );
|
||||
for ( int i=1; i <= lineNum && m.find(); i++) {
|
||||
if ( i == lineNum-1 ) {
|
||||
badStart = m.end();
|
||||
} else if ( i == lineNum ) {
|
||||
badEnd = m.start();
|
||||
badLine = srcBuf.substring( badStart, badEnd );
|
||||
}
|
||||
}
|
||||
String msg = String.format( "Fix this and try again:\n%s", e );
|
||||
msg += "\n";
|
||||
msg += "~ ~ ~ ~ ~\n";
|
||||
msg += badLine +"\n";
|
||||
msg += "~ ~ ~ ~ ~";
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.EXCEPTION,
|
||||
msg
|
||||
) );
|
||||
}
|
||||
else {
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.EXCEPTION,
|
||||
"An error occurred. See log for details."
|
||||
) );
|
||||
}
|
||||
xmlValid = false;
|
||||
}
|
||||
catch ( Exception e ) {
|
||||
log.error( "Error while validating mod xml.", e );
|
||||
messages.add( new ReportMessage(
|
||||
ReportMessage.EXCEPTION,
|
||||
"An error occurred. See log for details."
|
||||
) );
|
||||
xmlValid = false;
|
||||
}
|
||||
|
||||
StringBuilder resultBuf = new StringBuilder();
|
||||
|
||||
ReportMessage prevMessage = null;
|
||||
for ( ReportMessage message : messages ) {
|
||||
if ( message.equals(prevMessage) ) continue;
|
||||
|
||||
formatter.format( message, resultBuf );
|
||||
prevMessage = message;
|
||||
}
|
||||
|
||||
return new Report( resultBuf, xmlValid );
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* A holder for results from decodeText().
|
||||
*
|
||||
* text - The decoded string.
|
||||
* encoding - The encoding used.
|
||||
* bom - The BOM bytes found, or null.
|
||||
*/
|
||||
public static class DecodeResult {
|
||||
public final String text;
|
||||
public final String encoding;
|
||||
public final byte[] bom;
|
||||
|
||||
public DecodeResult( String text, String encoding, byte[] bom ) {
|
||||
this.text = text;
|
||||
this.encoding = encoding;
|
||||
this.bom = bom;
|
||||
}
|
||||
}
|
||||
}
|
198
src/main/java/net/vhati/modmanager/core/Report.java
Normal file
198
src/main/java/net/vhati/modmanager/core/Report.java
Normal file
|
@ -0,0 +1,198 @@
|
|||
package net.vhati.modmanager.core;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
|
||||
/**
|
||||
* A human-readable block of text, with a boolean outcome.
|
||||
*/
|
||||
public class Report {
|
||||
public final CharSequence text;
|
||||
public final boolean outcome;
|
||||
|
||||
public Report( CharSequence text, boolean outcome ) {
|
||||
this.text = text;
|
||||
this.outcome = outcome;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Formats ReportMessages to include in buffered reports.
|
||||
*
|
||||
* Symbols are prepended to indicate type.
|
||||
*
|
||||
* Methods can accept a formatter as an argument,
|
||||
* internally accumulate messages, format them,
|
||||
* and return an Appendable CharSequence.
|
||||
*
|
||||
* To nest reports, that buffer can be intented
|
||||
* and appended to another buffer; or it can be
|
||||
* wrapped in a NESTED_BLOCK message of its own.
|
||||
*
|
||||
* The Appendable interface claims to throw
|
||||
* IOException, but StringBuffer and StringBuilder
|
||||
* never do. So extra methods specifically accept
|
||||
* those classes and swallow the exception.
|
||||
*
|
||||
* If exceptions are desired, cast args to the
|
||||
* more general type.
|
||||
*/
|
||||
public static class ReportFormatter {
|
||||
protected Pattern breakPtn = Pattern.compile( "(^|\n)(?=[^\n])" );
|
||||
|
||||
public String getIndent() { return " "; }
|
||||
|
||||
public String getPrefix( int messageType ) {
|
||||
switch ( messageType ) {
|
||||
case ReportMessage.WARNING: return "~ ";
|
||||
case ReportMessage.ERROR: return "! ";
|
||||
case ReportMessage.EXCEPTION: return "! ";
|
||||
case ReportMessage.SECTION : return "@ ";
|
||||
case ReportMessage.SUBSECTION: return "> ";
|
||||
case ReportMessage.WARNING_SUBSECTION: return "~ ";
|
||||
case ReportMessage.ERROR_SUBSECTION: return "! ";
|
||||
case ReportMessage.NESTED_BLOCK: return "";
|
||||
default: return getIndent();
|
||||
}
|
||||
}
|
||||
|
||||
public void format( List<ReportMessage> messages, Appendable buf ) throws IOException {
|
||||
for ( ReportMessage message : messages )
|
||||
format( message, buf );
|
||||
}
|
||||
|
||||
public void format( ReportMessage message, Appendable buf ) throws IOException {
|
||||
if ( message.type == ReportMessage.NESTED_BLOCK ) {
|
||||
// Already formatted this once, indent it instead.
|
||||
indent( message.text, buf );
|
||||
return;
|
||||
}
|
||||
|
||||
// Subsections get an extra linebreak above them.
|
||||
switch ( message.type ) {
|
||||
case ReportMessage.SUBSECTION:
|
||||
case ReportMessage.WARNING_SUBSECTION:
|
||||
case ReportMessage.ERROR_SUBSECTION:
|
||||
buf.append( "\n" );
|
||||
default:
|
||||
// Not a subsection.
|
||||
}
|
||||
|
||||
buf.append( getPrefix( message.type ) );
|
||||
buf.append( message.text );
|
||||
buf.append( "\n" );
|
||||
|
||||
// Sections get underlined.
|
||||
if ( message.type == ReportMessage.SECTION ) {
|
||||
buf.append( getIndent() );
|
||||
for ( int i=0; i < message.text.length(); i++ )
|
||||
buf.append( "-" );
|
||||
buf.append( "\n" );
|
||||
}
|
||||
}
|
||||
|
||||
public void indent( CharSequence src, Appendable dst ) throws IOException {
|
||||
Matcher m = breakPtn.matcher( src );
|
||||
int lastEnd = 0;
|
||||
while ( m.find() ) {
|
||||
if ( m.start() - lastEnd > 0 )
|
||||
dst.append( src.subSequence( lastEnd, m.start() ) );
|
||||
|
||||
if ( m.group(1).length() > 0 ) // Didn't match beginning (^).
|
||||
dst.append( "\n" );
|
||||
dst.append( getIndent() );
|
||||
lastEnd = m.end();
|
||||
}
|
||||
int srcLen = src.length();
|
||||
if ( lastEnd < srcLen )
|
||||
dst.append( src.subSequence( lastEnd, srcLen ) );
|
||||
}
|
||||
|
||||
/** Exception-swallowing wrapper. */
|
||||
public void format( List<ReportMessage> messages, StringBuffer buf ) {
|
||||
try { format( messages, (Appendable)buf ); }
|
||||
catch( IOException e ) {}
|
||||
}
|
||||
|
||||
/** Exception-swallowing wrapper. */
|
||||
public void format( List<ReportMessage> messages, StringBuilder buf ) {
|
||||
try { format( messages, (Appendable)buf ); }
|
||||
catch( IOException e ) {}
|
||||
}
|
||||
|
||||
/** Exception-swallowing wrapper. */
|
||||
public void format( ReportMessage message, StringBuffer buf ) {
|
||||
try { format( message, (Appendable)buf ); }
|
||||
catch( IOException e ) {}
|
||||
}
|
||||
|
||||
/** Exception-swallowing wrapper. */
|
||||
public void format( ReportMessage message, StringBuilder buf ) {
|
||||
try { format( message, (Appendable)buf ); }
|
||||
catch( IOException e ) {}
|
||||
}
|
||||
|
||||
/** Exception-swallowing wrapper. */
|
||||
public void indent( CharSequence src, StringBuffer dst ) {
|
||||
try { indent( src, (Appendable)dst ); }
|
||||
catch( IOException e ) {}
|
||||
}
|
||||
|
||||
/** Exception-swallowing wrapper. */
|
||||
public void indent( CharSequence src, StringBuilder dst ) {
|
||||
try { indent( src, (Appendable)dst ); }
|
||||
catch( IOException e ) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Notice text, with a formatting hint.
|
||||
*
|
||||
* Messages can be compared for equality
|
||||
* to ignore repeats.
|
||||
*/
|
||||
public static class ReportMessage {
|
||||
public static final int INFO = 0;
|
||||
public static final int WARNING = 1;
|
||||
public static final int ERROR = 2;
|
||||
public static final int EXCEPTION = 3;
|
||||
public static final int SECTION = 4;
|
||||
public static final int SUBSECTION = 5;
|
||||
public static final int WARNING_SUBSECTION = 6;
|
||||
public static final int ERROR_SUBSECTION = 7;
|
||||
public static final int NESTED_BLOCK = 8;
|
||||
|
||||
public final int type;
|
||||
public final CharSequence text;
|
||||
|
||||
public ReportMessage( int type, CharSequence text ) {
|
||||
this.type = type;
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals( Object o ) {
|
||||
if ( o == null ) return false;
|
||||
if ( o == this ) return true;
|
||||
if ( o instanceof ReportMessage == false ) return false;
|
||||
ReportMessage other = (ReportMessage)o;
|
||||
return ( this.type == other.type && this.text.equals(other.text) );
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = 236;
|
||||
int salt = 778;
|
||||
|
||||
result = salt * result + this.type;
|
||||
result = salt * result + text.hashCode();
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,142 @@
|
|||
package net.vhati.modmanager.json;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
|
||||
public class GrognakCatalogFetcher {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(GrognakCatalogFetcher.class);
|
||||
|
||||
public static final String CATALOG_URL = "https://raw.github.com/Grognak/Grognaks-Mod-Manager/master/backup/current_catalog.json";
|
||||
|
||||
|
||||
/**
|
||||
* Downloads the latest mod catalog.
|
||||
*
|
||||
* @return true if the catalog successfully downloaded, false otherwise
|
||||
*/
|
||||
public static boolean fetchCatalog( String catalogURL, File catalogFile, File eTagFile ) {
|
||||
String localETag = null;
|
||||
|
||||
log.debug( "Attempting to download a newer catalog..." );
|
||||
if ( eTagFile.exists() ) {
|
||||
// Load the old eTag.
|
||||
InputStream etagIn = null;
|
||||
try {
|
||||
etagIn = new FileInputStream( eTagFile );
|
||||
BufferedReader br = new BufferedReader( new InputStreamReader( etagIn, "UTF-8" ) );
|
||||
String line = br.readLine();
|
||||
if ( line.length() > 0 )
|
||||
localETag = line;
|
||||
}
|
||||
catch ( IOException e ) {
|
||||
// Not serious enough to be a real error.
|
||||
log.debug( String.format( "Error reading catalog eTag from \"%s\".", eTagFile.getName() ), e );
|
||||
}
|
||||
finally {
|
||||
try {if ( etagIn != null ) etagIn.close();}
|
||||
catch ( IOException e ) {}
|
||||
}
|
||||
}
|
||||
|
||||
String remoteETag = null;
|
||||
InputStream urlIn = null;
|
||||
OutputStream catalogOut = null;
|
||||
try {
|
||||
URL url = new URL( catalogURL );
|
||||
URLConnection conn = url.openConnection();
|
||||
|
||||
if ( conn instanceof HttpURLConnection == false ) {
|
||||
log.error( String.format( "Non-Http(s) URL given for catalog fetching: %s", catalogURL ) );
|
||||
return false;
|
||||
}
|
||||
HttpURLConnection httpConn = (HttpURLConnection)conn;
|
||||
|
||||
httpConn.setReadTimeout( 10000 );
|
||||
if ( localETag != null )
|
||||
httpConn.setRequestProperty( "If-None-Match", localETag );
|
||||
httpConn.connect();
|
||||
|
||||
int responseCode = httpConn.getResponseCode();
|
||||
|
||||
if ( responseCode == HttpURLConnection.HTTP_NOT_MODIFIED ) {
|
||||
log.debug( "The server's catalog has not been modified since the previous check." );
|
||||
|
||||
// Update the catalog file's timestamp as if it had downloaded.
|
||||
catalogFile.setLastModified( new Date().getTime() );
|
||||
|
||||
return false;
|
||||
}
|
||||
else if ( responseCode == HttpURLConnection.HTTP_OK ) {
|
||||
Map<String, List<String>> headerMap = httpConn.getHeaderFields();
|
||||
List<String> eTagValues = headerMap.get( "ETag" );
|
||||
if ( eTagValues != null && eTagValues.size() > 0 )
|
||||
remoteETag = eTagValues.get( 0 );
|
||||
|
||||
urlIn = httpConn.getInputStream();
|
||||
catalogOut = new FileOutputStream( catalogFile );
|
||||
byte[] buf = new byte[4096];
|
||||
int len;
|
||||
while ( (len = urlIn.read(buf)) >= 0 ) {
|
||||
catalogOut.write( buf, 0, len );
|
||||
}
|
||||
}
|
||||
else {
|
||||
log.error( String.format( "Catalog download request failed: HTTP Code %d (%s).", responseCode, httpConn.getResponseMessage() ) );
|
||||
return false;
|
||||
}
|
||||
}
|
||||
catch ( MalformedURLException e ) {
|
||||
log.error( "Error fetching latest catalog.", e );
|
||||
}
|
||||
catch ( IOException e ) {
|
||||
log.error( "Error fetching latest catalog.", e );
|
||||
}
|
||||
finally {
|
||||
try {if ( urlIn != null ) urlIn.close();}
|
||||
catch ( IOException e ) {}
|
||||
|
||||
try {if ( catalogOut != null ) catalogOut.close();}
|
||||
catch ( IOException e ) {}
|
||||
}
|
||||
|
||||
if ( remoteETag != null ) {
|
||||
// Save the new eTag.
|
||||
OutputStream etagOut = null;
|
||||
try {
|
||||
etagOut = new FileOutputStream( eTagFile );
|
||||
BufferedWriter bw = new BufferedWriter( new OutputStreamWriter( etagOut, "UTF-8" ) );
|
||||
bw.append( remoteETag );
|
||||
bw.flush();
|
||||
}
|
||||
catch ( IOException e ) {
|
||||
log.error( String.format( "Error writing catalog eTag to \"%s\".", eTagFile.getName() ), e );
|
||||
}
|
||||
finally {
|
||||
try {if ( etagOut != null ) etagOut.close();}
|
||||
catch ( IOException e ) {}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
package net.vhati.modmanager.json;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
|
||||
import net.vhati.modmanager.core.ModDB;
|
||||
import net.vhati.modmanager.core.ModInfo;
|
||||
|
||||
import com.fasterxml.jackson.annotation.PropertyAccessor;
|
||||
import com.fasterxml.jackson.annotation.JsonAutoDetect.Visibility;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
|
||||
public class JacksonGrognakCatalogReader {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(JacksonGrognakCatalogReader.class);
|
||||
|
||||
|
||||
public static ModDB parse( File jsonFile ) {
|
||||
ModDB modDB = new ModDB();
|
||||
|
||||
Exception exception = null;
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
mapper.configure(JsonParser.Feature.ALLOW_SINGLE_QUOTES, true);
|
||||
mapper.setVisibility(PropertyAccessor.FIELD, Visibility.ANY);
|
||||
|
||||
JsonNode rootNode = mapper.readTree(jsonFile);
|
||||
JsonNode catalogsNode = rootNode.get("catalog_versions");
|
||||
JsonNode catalogNode = catalogsNode.get("1");
|
||||
|
||||
for ( JsonNode infoNode : catalogNode ) {
|
||||
String threadURL = infoNode.get("url").textValue();
|
||||
String threadHash = infoNode.get("thread_hash").textValue();
|
||||
if ( !threadURL.equals("???") && !threadHash.equals("???") )
|
||||
modDB.putThreadHash( threadURL, threadHash );
|
||||
|
||||
JsonNode versionsNode = infoNode.get("versions");
|
||||
for ( JsonNode versionNode : versionsNode ) {
|
||||
ModInfo modInfo = new ModInfo();
|
||||
modInfo.setTitle( infoNode.get("title").textValue() );
|
||||
modInfo.setAuthor( infoNode.get("author").textValue() );
|
||||
modInfo.setURL( infoNode.get("url").textValue() );
|
||||
modInfo.setDescription( infoNode.get("desc").textValue() );
|
||||
modInfo.setFileHash( versionNode.get("hash").textValue() );
|
||||
modInfo.setVersion( versionNode.get("version").textValue() );
|
||||
modDB.addMod( modInfo );
|
||||
}
|
||||
}
|
||||
}
|
||||
catch ( JsonProcessingException e ) {
|
||||
exception = e;
|
||||
}
|
||||
catch ( IOException e ) {
|
||||
exception = e;
|
||||
}
|
||||
if ( exception != null ) {
|
||||
log.error( exception );
|
||||
return null;
|
||||
}
|
||||
|
||||
return modDB;
|
||||
}
|
||||
}
|
111
src/main/java/net/vhati/modmanager/ui/ChecklistTableModel.java
Normal file
111
src/main/java/net/vhati/modmanager/ui/ChecklistTableModel.java
Normal file
|
@ -0,0 +1,111 @@
|
|||
package net.vhati.modmanager.ui;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import javax.swing.table.AbstractTableModel;
|
||||
|
||||
import net.vhati.modmanager.core.ModInfo;
|
||||
|
||||
|
||||
public class ChecklistTableModel<T> extends AbstractTableModel implements Reorderable {
|
||||
|
||||
private static final int COLUMN_CHECK = 0;
|
||||
private static final int COLUMN_PAYLOAD = 1;
|
||||
|
||||
private static final int DATA_CHECK = 0;
|
||||
private static final int DATA_PAYLOAD = 1;
|
||||
|
||||
private String[] columnNames = new String[] {"?", "Name"};
|
||||
private Class[] columnTypes = new Class[] {Boolean.class, String.class};
|
||||
|
||||
private List<List<Object>> rowsList = new ArrayList<List<Object>>();
|
||||
|
||||
|
||||
public void addItem( T o ) {
|
||||
insertItem( rowsList.size(), false, o );
|
||||
}
|
||||
|
||||
public void insertItem( int row, boolean selected, T o ) {
|
||||
int newRowIndex = rowsList.size();
|
||||
|
||||
List<Object> rowData = new ArrayList<Object>();
|
||||
rowData.add( new Boolean(selected) );
|
||||
rowData.add( o );
|
||||
rowsList.add( row, rowData );
|
||||
|
||||
fireTableRowsInserted( row, row );
|
||||
}
|
||||
|
||||
public void removeItem( int row ) {
|
||||
rowsList.remove( row );
|
||||
fireTableRowsDeleted( row, row );
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public T getItem( int row ) {
|
||||
return (T)rowsList.get(row).get(DATA_PAYLOAD);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reorder( int fromRow, int toRow ) {
|
||||
if ( toRow > fromRow ) toRow--;
|
||||
List<Object> rowData = rowsList.get( fromRow );
|
||||
rowsList.remove( fromRow );
|
||||
fireTableRowsDeleted( fromRow, fromRow );
|
||||
rowsList.add( toRow, rowData );
|
||||
fireTableRowsInserted( toRow, toRow );
|
||||
}
|
||||
|
||||
public void setSelected( int row, boolean b ) {
|
||||
rowsList.get(row).set( DATA_CHECK, new Boolean(b) );
|
||||
fireTableRowsUpdated( row, row );
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public boolean isSelected( int row ) {
|
||||
return ((Boolean)rowsList.get(row).get(DATA_CHECK)).booleanValue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getColumnCount() {
|
||||
return columnNames.length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getRowCount() {
|
||||
return rowsList.size();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getValueAt( int row, int column ) {
|
||||
if ( column == COLUMN_CHECK ) {
|
||||
return rowsList.get(row).get(DATA_CHECK);
|
||||
}
|
||||
else if ( column == COLUMN_PAYLOAD ) {
|
||||
Object o = rowsList.get(row).get(DATA_PAYLOAD);
|
||||
return o.toString();
|
||||
}
|
||||
throw new ArrayIndexOutOfBoundsException();
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public void setValueAt( Object o, int row, int column ) {
|
||||
if ( column == COLUMN_CHECK ) {
|
||||
Boolean bool = (Boolean)o;
|
||||
rowsList.get(row).set( DATA_CHECK, bool );
|
||||
fireTableRowsUpdated( row, row );
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isCellEditable( int row, int column ) {
|
||||
if ( column == COLUMN_CHECK ) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class getColumnClass( int column ) {
|
||||
return columnTypes[column];
|
||||
}
|
||||
}
|
|
@ -0,0 +1,97 @@
|
|||
package net.vhati.modmanager.ui;
|
||||
|
||||
import java.awt.Toolkit;
|
||||
import java.awt.datatransfer.DataFlavor;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import javax.swing.AbstractAction;
|
||||
import javax.swing.Action;
|
||||
import javax.swing.JMenuItem;
|
||||
import javax.swing.JPopupMenu;
|
||||
import javax.swing.text.JTextComponent;
|
||||
|
||||
|
||||
/**
|
||||
* A Cut/Copy/Paste/SelectAll context menu for JTextComponents.
|
||||
*/
|
||||
public class ClipboardMenuMouseListener extends MouseAdapter {
|
||||
|
||||
private JPopupMenu popup = new JPopupMenu();
|
||||
|
||||
private Action cutAction;
|
||||
private Action copyAction;
|
||||
private Action pasteAction;
|
||||
private Action selectAllAction;
|
||||
|
||||
private JTextComponent textComponent = null;
|
||||
|
||||
|
||||
public ClipboardMenuMouseListener() {
|
||||
cutAction = new AbstractAction( "Cut" ) {
|
||||
@Override
|
||||
public void actionPerformed( ActionEvent ae ) {
|
||||
textComponent.cut();
|
||||
}
|
||||
};
|
||||
copyAction = new AbstractAction( "Copy" ) {
|
||||
@Override
|
||||
public void actionPerformed( ActionEvent ae ) {
|
||||
textComponent.copy();
|
||||
}
|
||||
};
|
||||
pasteAction = new AbstractAction( "Paste" ) {
|
||||
@Override
|
||||
public void actionPerformed( ActionEvent ae ) {
|
||||
textComponent.paste();
|
||||
}
|
||||
};
|
||||
selectAllAction = new AbstractAction( "Select All" ) {
|
||||
@Override
|
||||
public void actionPerformed( ActionEvent ae ) {
|
||||
textComponent.selectAll();
|
||||
}
|
||||
};
|
||||
|
||||
popup.add( cutAction );
|
||||
popup.add( copyAction );
|
||||
popup.add( pasteAction );
|
||||
popup.addSeparator();
|
||||
popup.add( selectAllAction );
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void mousePressed( MouseEvent e ) {
|
||||
if ( e.isPopupTrigger() ) showMenu( e );
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseReleased( MouseEvent e ) {
|
||||
if ( e.isPopupTrigger() ) showMenu( e );
|
||||
}
|
||||
|
||||
public void showMenu( MouseEvent e ) {
|
||||
if ( e.getSource() instanceof JTextComponent == false ) return;
|
||||
|
||||
textComponent = (JTextComponent)e.getSource();
|
||||
textComponent.requestFocus();
|
||||
|
||||
boolean enabled = textComponent.isEnabled();
|
||||
boolean editable = textComponent.isEditable();
|
||||
boolean nonempty = !(textComponent.getText() == null || textComponent.getText().equals(""));
|
||||
boolean marked = textComponent.getSelectedText() != null;
|
||||
|
||||
boolean pasteAvailable = Toolkit.getDefaultToolkit().getSystemClipboard().getContents(null).isDataFlavorSupported(DataFlavor.stringFlavor);
|
||||
|
||||
cutAction.setEnabled( enabled && editable && marked );
|
||||
copyAction.setEnabled( enabled && marked );
|
||||
pasteAction.setEnabled( enabled && editable && pasteAvailable );
|
||||
selectAllAction.setEnabled( enabled && nonempty );
|
||||
|
||||
int nx = e.getX();
|
||||
if ( nx > 500 ) nx = nx - popup.getSize().width;
|
||||
|
||||
popup.show( e.getComponent(), nx, e.getY() - popup.getSize().height );
|
||||
}
|
||||
}
|
532
src/main/java/net/vhati/modmanager/ui/ManagerFrame.java
Normal file
532
src/main/java/net/vhati/modmanager/ui/ManagerFrame.java
Normal file
|
@ -0,0 +1,532 @@
|
|||
// http://docs.oracle.com/javase/tutorial/uiswing/dnd/emptytable.html
|
||||
// http://stackoverflow.com/questions/638807/how-do-i-drag-and-drop-a-row-in-a-jtable
|
||||
// http://www.java2s.com/Tutorial/Java/0240__Swing/UsingdefaultBooleanvaluecelleditorandrenderer.htm
|
||||
|
||||
package net.vhati.modmanager.ui;
|
||||
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Component;
|
||||
import java.awt.Dimension;
|
||||
import java.awt.Insets;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.ActionListener;
|
||||
import java.awt.event.WindowAdapter;
|
||||
import java.awt.event.WindowEvent;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.BufferedWriter;
|
||||
import java.io.File;
|
||||
import java.io.FileFilter;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.Calendar;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.HashMap;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Iterator;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.DropMode;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JFrame;
|
||||
import javax.swing.JLabel;
|
||||
import javax.swing.JOptionPane;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTable;
|
||||
import javax.swing.JTextArea;
|
||||
import javax.swing.ListSelectionModel;
|
||||
import javax.swing.SwingUtilities;
|
||||
import javax.swing.event.ListSelectionEvent;
|
||||
import javax.swing.event.ListSelectionListener;
|
||||
import javax.swing.table.DefaultTableModel;
|
||||
|
||||
import net.vhati.modmanager.core.ComparableVersion;
|
||||
import net.vhati.modmanager.core.FTLUtilities;
|
||||
import net.vhati.modmanager.core.HashObserver;
|
||||
import net.vhati.modmanager.core.HashThread;
|
||||
import net.vhati.modmanager.core.ModDB;
|
||||
import net.vhati.modmanager.core.ModFileInfo;
|
||||
import net.vhati.modmanager.core.ModInfo;
|
||||
import net.vhati.modmanager.core.ModPatchThread;
|
||||
import net.vhati.modmanager.core.ModPatchThread.BackedUpDat;
|
||||
import net.vhati.modmanager.core.ModUtilities;
|
||||
import net.vhati.modmanager.core.Report;
|
||||
import net.vhati.modmanager.json.GrognakCatalogFetcher;
|
||||
import net.vhati.modmanager.json.JacksonGrognakCatalogReader;
|
||||
import net.vhati.modmanager.ui.ChecklistTableModel;
|
||||
import net.vhati.modmanager.ui.ClipboardMenuMouseListener;
|
||||
import net.vhati.modmanager.ui.ModInfoArea;
|
||||
import net.vhati.modmanager.ui.ModPatchDialog;
|
||||
import net.vhati.modmanager.ui.Statusbar;
|
||||
import net.vhati.modmanager.ui.StatusbarMouseListener;
|
||||
import net.vhati.modmanager.ui.TableRowTransferHandler;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
|
||||
public class ManagerFrame extends JFrame implements ActionListener, HashObserver, Statusbar {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(ManagerFrame.class);
|
||||
|
||||
private File backupDir = new File( "./backup/" );
|
||||
private File modsDir = new File( "./mods/" );
|
||||
|
||||
private int catalogFetchInterval = 7; // Days.
|
||||
private File catalogFile = new File( backupDir, "current_catalog.json" );
|
||||
private File catalogETagFile = new File( backupDir, "current_catalog_etag.txt" );
|
||||
|
||||
private Properties config;
|
||||
private String appName;
|
||||
private ComparableVersion appVersion;
|
||||
|
||||
private HashMap<File,String> modFileHashes = new HashMap<File,String>();
|
||||
private ModDB modDB = new ModDB();
|
||||
|
||||
private ChecklistTableModel<ModFileInfo> localModsTableModel;
|
||||
private JTable localModsTable;
|
||||
|
||||
private JButton patchBtn;
|
||||
private JButton toggleAllBtn;
|
||||
private JButton validateBtn;
|
||||
private JButton aboutBtn;
|
||||
|
||||
private ModInfoArea infoArea;
|
||||
|
||||
private JLabel statusLbl;
|
||||
|
||||
|
||||
public ManagerFrame( Properties config, String appName, ComparableVersion appVersion ) {
|
||||
super();
|
||||
this.config = config;
|
||||
this.appName = appName;
|
||||
this.appVersion = appVersion;
|
||||
|
||||
this.setTitle( String.format( "%s v%s", appName, appVersion ) );
|
||||
|
||||
JPanel contentPane = new JPanel( new BorderLayout() );
|
||||
|
||||
JPanel mainPane = new JPanel( new BorderLayout() );
|
||||
contentPane.add( mainPane, BorderLayout.CENTER );
|
||||
|
||||
JPanel topPanel = new JPanel( new BorderLayout() );
|
||||
|
||||
localModsTableModel = new ChecklistTableModel<ModFileInfo>();
|
||||
|
||||
localModsTable = new JTable( localModsTableModel );
|
||||
localModsTable.setFillsViewportHeight( true );
|
||||
localModsTable.setSelectionMode( ListSelectionModel.SINGLE_SELECTION );
|
||||
localModsTable.setTableHeader( null );
|
||||
localModsTable.getColumnModel().getColumn(0).setMinWidth(30);
|
||||
localModsTable.getColumnModel().getColumn(0).setMaxWidth(30);
|
||||
localModsTable.getColumnModel().getColumn(0).setPreferredWidth(30);
|
||||
|
||||
JScrollPane localModsScroll = new JScrollPane( null, JScrollPane.VERTICAL_SCROLLBAR_ALWAYS, JScrollPane.HORIZONTAL_SCROLLBAR_NEVER );
|
||||
localModsScroll.setViewportView( localModsTable );
|
||||
//localModsScroll.setColumnHeaderView( null ); // Counterpart to setTableHeader().
|
||||
localModsScroll.setPreferredSize( new Dimension(Integer.MIN_VALUE, Integer.MIN_VALUE) );
|
||||
topPanel.add( localModsScroll, BorderLayout.CENTER );
|
||||
|
||||
JPanel modActionsPanel = new JPanel();
|
||||
modActionsPanel.setLayout( new BoxLayout(modActionsPanel, BoxLayout.Y_AXIS) );
|
||||
modActionsPanel.setBorder( BorderFactory.createEmptyBorder(0,5,5,0) );
|
||||
Insets actionInsets = new Insets(5,10,5,10);
|
||||
|
||||
patchBtn = new JButton("Patch");
|
||||
patchBtn.setMargin( actionInsets );
|
||||
patchBtn.addMouseListener( new StatusbarMouseListener( this, "Incorporate all selected mods into the game." ) );
|
||||
patchBtn.addActionListener(this);
|
||||
modActionsPanel.add( patchBtn );
|
||||
|
||||
toggleAllBtn = new JButton("Toggle All");
|
||||
toggleAllBtn.setMargin( actionInsets );
|
||||
toggleAllBtn.addMouseListener( new StatusbarMouseListener( this, "Select all mods, or none." ) );
|
||||
toggleAllBtn.addActionListener(this);
|
||||
modActionsPanel.add( toggleAllBtn );
|
||||
|
||||
validateBtn = new JButton("Validate");
|
||||
validateBtn.setMargin( actionInsets );
|
||||
validateBtn.addMouseListener( new StatusbarMouseListener( this, "Check selected mods for problems." ) );
|
||||
validateBtn.addActionListener(this);
|
||||
modActionsPanel.add( validateBtn );
|
||||
|
||||
aboutBtn = new JButton("About");
|
||||
aboutBtn.setMargin( actionInsets );
|
||||
aboutBtn.addMouseListener( new StatusbarMouseListener( this, "Show info about this program." ) );
|
||||
aboutBtn.addActionListener(this);
|
||||
modActionsPanel.add( aboutBtn );
|
||||
|
||||
topPanel.add( modActionsPanel, BorderLayout.EAST );
|
||||
|
||||
JButton[] actionBtns = new JButton[] {patchBtn, toggleAllBtn, validateBtn, aboutBtn};
|
||||
int actionBtnWidth = Integer.MIN_VALUE;
|
||||
int actionBtnHeight = Integer.MIN_VALUE;
|
||||
for ( JButton btn : actionBtns ) {
|
||||
actionBtnWidth = Math.max( actionBtnWidth, btn.getPreferredSize().width );
|
||||
actionBtnHeight = Math.max( actionBtnHeight, btn.getPreferredSize().height );
|
||||
}
|
||||
for ( JButton btn : actionBtns ) {
|
||||
Dimension size = new Dimension( actionBtnWidth, actionBtnHeight );
|
||||
btn.setPreferredSize( size );
|
||||
btn.setMinimumSize( size );
|
||||
btn.setMaximumSize( size );
|
||||
}
|
||||
|
||||
mainPane.add( topPanel, BorderLayout.NORTH );
|
||||
|
||||
infoArea = new ModInfoArea();
|
||||
infoArea.setPreferredSize( new Dimension(504, 220) );
|
||||
mainPane.add( infoArea, BorderLayout.CENTER );
|
||||
|
||||
JPanel statusPanel = new JPanel();
|
||||
statusPanel.setLayout( new BoxLayout(statusPanel, BoxLayout.Y_AXIS) );
|
||||
statusPanel.setBorder( BorderFactory.createLoweredBevelBorder() );
|
||||
statusLbl = new JLabel(" ");
|
||||
statusLbl.setBorder( BorderFactory.createEmptyBorder(2, 4, 2, 4) );
|
||||
statusLbl.setAlignmentX( Component.LEFT_ALIGNMENT );
|
||||
statusPanel.add( statusLbl );
|
||||
contentPane.add( statusPanel, BorderLayout.SOUTH );
|
||||
|
||||
|
||||
localModsTable.getSelectionModel().addListSelectionListener(new ListSelectionListener() {
|
||||
@Override
|
||||
public void valueChanged( ListSelectionEvent e ) {
|
||||
if ( e.getValueIsAdjusting() ) return;
|
||||
|
||||
int row = localModsTable.getSelectedRow();
|
||||
if ( row == -1 ) return;
|
||||
|
||||
ModFileInfo modFileInfo = localModsTableModel.getItem( row );
|
||||
showLocalModInfo( modFileInfo );
|
||||
}
|
||||
});
|
||||
|
||||
localModsTable.setTransferHandler( new TableRowTransferHandler( localModsTable ) );
|
||||
localModsTable.setDropMode( DropMode.INSERT ); // Drop between rows, not on them.
|
||||
localModsTable.setDragEnabled( true );
|
||||
|
||||
this.setContentPane( contentPane );
|
||||
this.pack();
|
||||
this.setLocationRelativeTo(null);
|
||||
|
||||
showAboutInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra initialization that must be called after the constructor.
|
||||
* This must be called on the Swing event thread (use invokeLater()).
|
||||
*/
|
||||
public void init() {
|
||||
File[] modFiles = modsDir.listFiles(new FileFilter() {
|
||||
@Override
|
||||
public boolean accept( File f ) {
|
||||
if ( f.isFile() ) {
|
||||
if ( f.getName().endsWith(".ftl") ) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
List<ModFileInfo> unsortedMods = new ArrayList<ModFileInfo>();
|
||||
for ( File f : modFiles ) {
|
||||
ModFileInfo modFileInfo = new ModFileInfo( f );
|
||||
unsortedMods.add( modFileInfo );
|
||||
}
|
||||
|
||||
List<ModFileInfo> sortedMods = loadModOrder( unsortedMods );
|
||||
for ( ModFileInfo modFileInfo : sortedMods ) {
|
||||
localModsTableModel.addItem( modFileInfo );
|
||||
}
|
||||
|
||||
HashThread hashThread = new HashThread( modFiles, this );
|
||||
hashThread.setDaemon( true );
|
||||
hashThread.start();
|
||||
|
||||
boolean needNewCatalog = false;
|
||||
|
||||
if ( catalogFile.exists() ) {
|
||||
// Load the catalog first, before updating.
|
||||
ModDB currentDB = JacksonGrognakCatalogReader.parse( catalogFile );
|
||||
if ( currentDB != null ) modDB = currentDB;
|
||||
|
||||
// Check if the downloaded catalog is stale.
|
||||
Date catalogDate = new Date( catalogFile.lastModified() );
|
||||
Calendar cal = Calendar.getInstance();
|
||||
cal.add( Calendar.DATE, catalogFetchInterval * -1 );
|
||||
if ( catalogDate.before( cal.getTime() ) ) {
|
||||
log.debug( String.format( "Catalog is older than %d days.", catalogFetchInterval ) );
|
||||
needNewCatalog = true;
|
||||
} else {
|
||||
log.debug( "Catalog isn't stale yet." );
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Catalog file doesn't exist.
|
||||
needNewCatalog = true;
|
||||
}
|
||||
|
||||
// Don't update if the user doesn't want to.
|
||||
String updatesAllowed = config.getProperty( "update_catalog", "false" );
|
||||
if ( !updatesAllowed.equals("true") ) needNewCatalog = false;
|
||||
|
||||
if ( needNewCatalog ) {
|
||||
Runnable fetchTask = new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
String catalogURL = GrognakCatalogFetcher.CATALOG_URL;
|
||||
boolean fetched = GrognakCatalogFetcher.fetchCatalog( catalogURL, catalogFile, catalogETagFile );
|
||||
|
||||
if ( fetched ) reloadCatalog();
|
||||
}
|
||||
};
|
||||
Thread fetchThread = new Thread( fetchTask );
|
||||
fetchThread.start();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reparses and replace the downloaded ModDB catalog. (thread-safe)
|
||||
*/
|
||||
public void reloadCatalog() {
|
||||
SwingUtilities.invokeLater(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if ( catalogFile.exists() ) {
|
||||
ModDB currentDB = JacksonGrognakCatalogReader.parse( catalogFile );
|
||||
if ( currentDB != null ) modDB = currentDB;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Reads modorder.txt and returns a mod list in that order.
|
||||
*
|
||||
* Mods not mentioned in the text appear at the end, alphabetically.
|
||||
* If an error occurs, an alphabetized list is returned.
|
||||
*/
|
||||
private List<ModFileInfo> loadModOrder( List<ModFileInfo> unsortedMods ) {
|
||||
List<ModFileInfo> sortedMods = new ArrayList<ModFileInfo>();
|
||||
List<ModFileInfo> availableMods = new ArrayList<ModFileInfo>( unsortedMods );
|
||||
Collections.sort( availableMods );
|
||||
|
||||
FileInputStream is = null;
|
||||
try {
|
||||
is = new FileInputStream( new File( modsDir, "modorder.txt" ) );
|
||||
BufferedReader br = new BufferedReader(new InputStreamReader( is, Charset.forName("UTF-8") ));
|
||||
String line;
|
||||
while ( (line = br.readLine()) != null ) {
|
||||
Iterator<ModFileInfo> it = availableMods.iterator();
|
||||
while ( it.hasNext() ) {
|
||||
ModFileInfo modFileInfo = it.next();
|
||||
if ( modFileInfo.getName().equals(line) ) {
|
||||
it.remove();
|
||||
sortedMods.add( modFileInfo );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch ( FileNotFoundException e ) {
|
||||
}
|
||||
catch ( IOException e ) {
|
||||
log.error( "Error reading modorder.txt.", e );
|
||||
}
|
||||
finally {
|
||||
try {if (is != null) is.close();}
|
||||
catch (Exception e) {}
|
||||
}
|
||||
sortedMods.addAll( availableMods );
|
||||
|
||||
return sortedMods;
|
||||
}
|
||||
|
||||
private void saveModOrder( List<ModFileInfo> sortedMods ) {
|
||||
FileOutputStream os = null;
|
||||
try {
|
||||
os = new FileOutputStream( new File( modsDir, "modorder.txt" ) );
|
||||
BufferedWriter bw = new BufferedWriter(new OutputStreamWriter( os, Charset.forName("UTF-8") ));
|
||||
|
||||
for ( ModFileInfo modFileInfo : sortedMods ) {
|
||||
bw.write( modFileInfo.getName() );
|
||||
bw.write( "\r\n" );
|
||||
}
|
||||
bw.flush();
|
||||
}
|
||||
catch ( IOException e ) {
|
||||
log.error( "Error writing modorder.txt.", e );
|
||||
}
|
||||
finally {
|
||||
try {if (os != null) os.close();}
|
||||
catch (Exception e) {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public void showAboutInfo() {
|
||||
String body = "";
|
||||
body += "- Drag to reorder mods.\n";
|
||||
body += "- Click the checkboxes to select.\n";
|
||||
body += "- Click 'Patch' to apply mods ( select none for vanilla ).\n";
|
||||
body += "\n";
|
||||
body += "Thanks for using this mod manager.\n";
|
||||
body += "Make sure to visit the forum for updates!";
|
||||
|
||||
infoArea.setDescription( appName, "Vhati", appVersion.toString(), "http://abc.net", body );
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows info about a local mod in the text area.
|
||||
*/
|
||||
public void showLocalModInfo( ModFileInfo modFileInfo ) {
|
||||
String modHash = modFileHashes.get( modFileInfo.getFile() );
|
||||
|
||||
ModInfo modInfo = modDB.getModInfo( modHash );
|
||||
if ( modInfo != null ) {
|
||||
infoArea.setDescription( modInfo.getTitle(), modInfo.getAuthor(), modInfo.getVersion(), modInfo.getURL(), modInfo.getDescription() );
|
||||
}
|
||||
else {
|
||||
String body = "";
|
||||
body += "No info is available for the selected mod.\n\n";
|
||||
body += "If it's stable, please let the Slipstream devs know ";
|
||||
body += "where you found it and include this md5 hash:\n";
|
||||
body += modHash +"\n";
|
||||
infoArea.setDescription( modFileInfo.getName(), body );
|
||||
log.info( String.format("No info for selected mod: %s (%s).", modFileInfo.getName(), modHash) );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void setStatusText( String text ) {
|
||||
if (text.length() > 0)
|
||||
statusLbl.setText(text);
|
||||
else
|
||||
statusLbl.setText(" ");
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void actionPerformed( ActionEvent e ) {
|
||||
Object source = e.getSource();
|
||||
|
||||
if ( source == patchBtn ) {
|
||||
List<ModFileInfo> sortedMods = new ArrayList<ModFileInfo>();
|
||||
List<File> modFiles = new ArrayList<File>();
|
||||
|
||||
for ( int i=0; i < localModsTableModel.getRowCount(); i++ ) {
|
||||
if ( localModsTableModel.isSelected(i) ) {
|
||||
sortedMods.add( localModsTableModel.getItem(i) );
|
||||
modFiles.add( localModsTableModel.getItem(i).getFile() );
|
||||
}
|
||||
}
|
||||
saveModOrder( sortedMods );
|
||||
|
||||
File datsDir = new File( config.getProperty( "ftl_dats_path" ) );
|
||||
|
||||
BackedUpDat dataDat = new BackedUpDat();
|
||||
dataDat.datFile = new File( datsDir, "data.dat" );
|
||||
dataDat.bakFile = new File( backupDir, "data.dat.bak" );
|
||||
BackedUpDat resDat = new BackedUpDat();
|
||||
resDat.datFile = new File( datsDir, "resource.dat" );
|
||||
resDat.bakFile = new File( backupDir, "resource.dat.bak" );
|
||||
|
||||
ModPatchDialog patchDlg = new ModPatchDialog( this );
|
||||
patchDlg.setSuccessTask( new SpawnGameTask() );
|
||||
|
||||
log.info( "" );
|
||||
log.info( "Patching..." );
|
||||
log.info( "" );
|
||||
ModPatchThread patchThread = new ModPatchThread( modFiles, dataDat, resDat, patchDlg );
|
||||
patchThread.start();
|
||||
|
||||
patchDlg.setVisible( true );
|
||||
}
|
||||
else if ( source == toggleAllBtn ) {
|
||||
int selectedCount = 0;
|
||||
for ( int i = localModsTableModel.getRowCount()-1; i >= 0; i-- ) {
|
||||
if ( localModsTableModel.isSelected(i) ) selectedCount++;
|
||||
}
|
||||
boolean b = ( selectedCount != localModsTableModel.getRowCount() );
|
||||
|
||||
for ( int i = localModsTableModel.getRowCount()-1; i >= 0; i-- ) {
|
||||
localModsTableModel.setSelected( i, b );
|
||||
}
|
||||
}
|
||||
else if ( source == validateBtn ) {
|
||||
StringBuilder resultBuf = new StringBuilder();
|
||||
boolean anyInvalid = false;
|
||||
|
||||
for ( int i = localModsTableModel.getRowCount()-1; i >= 0; i-- ) {
|
||||
if ( !localModsTableModel.isSelected(i) ) continue;
|
||||
|
||||
ModFileInfo modFileInfo = localModsTableModel.getItem( i );
|
||||
Report validateReport = ModUtilities.validateModFile( modFileInfo.getFile(), null );
|
||||
resultBuf.append( validateReport.text );
|
||||
resultBuf.append( "\n" );
|
||||
|
||||
if ( validateReport.outcome == false ) anyInvalid = true;
|
||||
}
|
||||
|
||||
if ( resultBuf.length() == 0 ) {
|
||||
resultBuf.append( "No mods were checked." );
|
||||
}
|
||||
else if ( anyInvalid ) {
|
||||
resultBuf.append( "FTL itself can tolerate lots of errors and still run. " );
|
||||
resultBuf.append( "But invalid XML may break tools that do proper parsing, " );
|
||||
resultBuf.append( "and it hinders the development of new tools.\n" );
|
||||
}
|
||||
infoArea.setDescription( "Results", resultBuf.toString() );
|
||||
}
|
||||
else if ( source == aboutBtn ) {
|
||||
showAboutInfo();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void hashCalculated( final File f, final String hash ) {
|
||||
SwingUtilities.invokeLater( new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
modFileHashes.put( f, hash );
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
private class SpawnGameTask implements Runnable {
|
||||
@Override
|
||||
public void run() {
|
||||
String neverRunFtl = config.getProperty( "never_run_ftl", "false" );
|
||||
if ( !neverRunFtl.equals("true") ) {
|
||||
File datsDir = new File( config.getProperty( "ftl_dats_path" ) );
|
||||
|
||||
File exeFile = FTLUtilities.findGameExe( datsDir );
|
||||
if ( exeFile != null ) {
|
||||
int response = JOptionPane.showConfirmDialog( ManagerFrame.this, "Do you want to run the game now?", "Ready to Play", JOptionPane.YES_NO_OPTION, JOptionPane.QUESTION_MESSAGE );
|
||||
if ( response == JOptionPane.YES_OPTION ) {
|
||||
log.info( "Launching FTL..." );
|
||||
try {
|
||||
FTLUtilities.launchGame( exeFile );
|
||||
} catch ( Exception e ) {
|
||||
log.error( "Error launching FTL.", e );
|
||||
}
|
||||
System.exit( 0 );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
152
src/main/java/net/vhati/modmanager/ui/ModInfoArea.java
Normal file
152
src/main/java/net/vhati/modmanager/ui/ModInfoArea.java
Normal file
|
@ -0,0 +1,152 @@
|
|||
package net.vhati.modmanager.ui;
|
||||
|
||||
import java.awt.Color;
|
||||
import java.awt.Cursor;
|
||||
import java.awt.Desktop;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.net.URI;
|
||||
import java.util.HashMap;
|
||||
import javax.swing.JScrollPane;
|
||||
import javax.swing.JTextPane;
|
||||
import javax.swing.event.MouseInputAdapter;
|
||||
import javax.swing.text.AttributeSet;
|
||||
import javax.swing.text.BadLocationException;
|
||||
import javax.swing.text.SimpleAttributeSet;
|
||||
import javax.swing.text.StyleConstants;
|
||||
import javax.swing.text.StyledDocument;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
|
||||
public class ModInfoArea extends JScrollPane {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(ModInfoArea.class);
|
||||
|
||||
private static final String HYPERLINK_TARGET = "hyperlink-target";
|
||||
|
||||
private JTextPane textPane;
|
||||
private StyledDocument doc;
|
||||
private HashMap<String,SimpleAttributeSet> attrMap = new HashMap<String,SimpleAttributeSet>();
|
||||
|
||||
|
||||
public ModInfoArea() {
|
||||
super();
|
||||
|
||||
textPane = new JTextPane();
|
||||
textPane.setEditable( false );
|
||||
|
||||
doc = textPane.getStyledDocument();
|
||||
|
||||
SimpleAttributeSet tmpAttr = new SimpleAttributeSet();
|
||||
StyleConstants.setFontFamily( tmpAttr, "Monospaced" );
|
||||
StyleConstants.setFontSize( tmpAttr, 12 );
|
||||
attrMap.put( "regular", tmpAttr );
|
||||
|
||||
tmpAttr = new SimpleAttributeSet();
|
||||
StyleConstants.setFontFamily( tmpAttr, "SansSerif" );
|
||||
StyleConstants.setFontSize( tmpAttr, 24 );
|
||||
StyleConstants.setBold( tmpAttr, true );
|
||||
attrMap.put( "title", tmpAttr );
|
||||
|
||||
tmpAttr = new SimpleAttributeSet();
|
||||
StyleConstants.setFontFamily( tmpAttr, "Monospaced" );
|
||||
StyleConstants.setFontSize( tmpAttr, 12 );
|
||||
StyleConstants.setForeground( tmpAttr, Color.BLUE );
|
||||
StyleConstants.setUnderline( tmpAttr, true );
|
||||
attrMap.put( "hyperlink", tmpAttr );
|
||||
|
||||
MouseInputAdapter hyperlinkListener = new MouseInputAdapter() {
|
||||
@Override
|
||||
public void mouseClicked( MouseEvent e ) {
|
||||
AttributeSet tmpAttr = doc.getCharacterElement( textPane.viewToModel(e.getPoint()) ).getAttributes();
|
||||
Object targetObj = tmpAttr.getAttribute( HYPERLINK_TARGET );
|
||||
if ( targetObj != null ) {
|
||||
Desktop desktop = Desktop.isDesktopSupported() ? Desktop.getDesktop() : null;
|
||||
if ( desktop != null && desktop.isSupported(Desktop.Action.BROWSE) ) {
|
||||
try {
|
||||
desktop.browse( new URI(targetObj.toString()) );
|
||||
}
|
||||
catch ( Exception f ) {
|
||||
log.error( "Error browsing clicked url: "+ targetObj.toString(), f );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseMoved( MouseEvent e ) {
|
||||
AttributeSet tmpAttr = doc.getCharacterElement( textPane.viewToModel(e.getPoint()) ).getAttributes();
|
||||
if ( tmpAttr.getAttribute( HYPERLINK_TARGET ) != null ) {
|
||||
textPane.setCursor( new Cursor(Cursor.HAND_CURSOR) );
|
||||
} else {
|
||||
textPane.setCursor( new Cursor(Cursor.DEFAULT_CURSOR) );
|
||||
}
|
||||
}
|
||||
};
|
||||
textPane.addMouseListener( hyperlinkListener );
|
||||
textPane.addMouseMotionListener( hyperlinkListener );
|
||||
|
||||
textPane.addMouseListener( new ClipboardMenuMouseListener() );
|
||||
|
||||
this.setViewportView( textPane );
|
||||
}
|
||||
|
||||
|
||||
public void setDescription( String title, String body ) {
|
||||
setDescription( title, null, null, null, body );
|
||||
}
|
||||
|
||||
public void setDescription( String title, String author, String version, String url, String body ) {
|
||||
try {
|
||||
doc.remove( 0, doc.getLength() );
|
||||
doc.insertString( doc.getLength(), title +"\n", attrMap.get("title") );
|
||||
|
||||
boolean first = true;
|
||||
if ( author != null ) {
|
||||
doc.insertString( doc.getLength(), String.format("%sby %s", (first ? "" : " "), author), attrMap.get("regular") );
|
||||
first = false;
|
||||
}
|
||||
if ( version != null ) {
|
||||
doc.insertString( doc.getLength(), String.format("%s(version %s)", (first ? "" : " "), version), attrMap.get("regular") );
|
||||
first = false;
|
||||
}
|
||||
if ( !first ) {
|
||||
doc.insertString( doc.getLength(), "\n", attrMap.get("regular") );
|
||||
}
|
||||
|
||||
if ( url != null ) {
|
||||
SimpleAttributeSet tmpAttr;
|
||||
doc.insertString( doc.getLength(), "Website: ", attrMap.get("regular") );
|
||||
|
||||
boolean browseWorks = false;
|
||||
Desktop desktop = Desktop.isDesktopSupported() ? Desktop.getDesktop() : null;
|
||||
if ( desktop != null && desktop.isSupported(Desktop.Action.BROWSE) ) {
|
||||
browseWorks = true;
|
||||
}
|
||||
|
||||
if ( browseWorks && url.matches("^(?:https?|ftp)://.*") ) {
|
||||
tmpAttr = new SimpleAttributeSet( attrMap.get("hyperlink") );
|
||||
tmpAttr.addAttribute( HYPERLINK_TARGET, url );
|
||||
doc.insertString( doc.getLength(), "Link", tmpAttr );
|
||||
} else {
|
||||
tmpAttr = new SimpleAttributeSet( attrMap.get("regular") );
|
||||
doc.insertString( doc.getLength(), url, tmpAttr );
|
||||
}
|
||||
|
||||
doc.insertString( doc.getLength(), "\n", attrMap.get("regular") );
|
||||
}
|
||||
|
||||
doc.insertString( doc.getLength(), "\n", attrMap.get("regular") );
|
||||
|
||||
if ( body != null ) {
|
||||
doc.insertString( doc.getLength(), body, attrMap.get("regular") );
|
||||
}
|
||||
}
|
||||
catch ( BadLocationException e) {
|
||||
log.error( e );
|
||||
}
|
||||
|
||||
textPane.setCaretPosition(0);
|
||||
}
|
||||
}
|
182
src/main/java/net/vhati/modmanager/ui/ModPatchDialog.java
Normal file
182
src/main/java/net/vhati/modmanager/ui/ModPatchDialog.java
Normal file
|
@ -0,0 +1,182 @@
|
|||
package net.vhati.modmanager.ui;
|
||||
|
||||
import java.awt.BorderLayout;
|
||||
import java.awt.Frame;
|
||||
import java.awt.event.ActionEvent;
|
||||
import java.awt.event.ActionListener;
|
||||
import java.io.File;
|
||||
import javax.swing.BorderFactory;
|
||||
import javax.swing.Box;
|
||||
import javax.swing.BoxLayout;
|
||||
import javax.swing.JButton;
|
||||
import javax.swing.JDialog;
|
||||
import javax.swing.JPanel;
|
||||
import javax.swing.JProgressBar;
|
||||
import javax.swing.JTextArea;
|
||||
import javax.swing.SwingUtilities;
|
||||
|
||||
import net.vhati.modmanager.core.ModPatchObserver;
|
||||
|
||||
|
||||
public class ModPatchDialog extends JDialog implements ActionListener, ModPatchObserver {
|
||||
|
||||
private JProgressBar progressBar;
|
||||
private JTextArea statusArea;
|
||||
private JButton continueBtn;
|
||||
|
||||
private boolean done = false;
|
||||
private boolean patchingSucceeded = false;
|
||||
private Runnable successTask = null;
|
||||
|
||||
|
||||
public ModPatchDialog( Frame owner ) {
|
||||
super( owner, "Patching...", true );
|
||||
this.setDefaultCloseOperation( JDialog.DO_NOTHING_ON_CLOSE );
|
||||
|
||||
progressBar = new JProgressBar();
|
||||
progressBar.setBorderPainted( true );
|
||||
|
||||
JPanel progressHolder = new JPanel( new BorderLayout() );
|
||||
progressHolder.setBorder( BorderFactory.createEmptyBorder( 10, 15, 0, 15 ) );
|
||||
progressHolder.add( progressBar );
|
||||
getContentPane().add( progressHolder, BorderLayout.NORTH );
|
||||
|
||||
statusArea = new JTextArea();
|
||||
statusArea.setBorder( BorderFactory.createEtchedBorder() );
|
||||
statusArea.setLineWrap( true );
|
||||
statusArea.setWrapStyleWord( true );
|
||||
statusArea.setEditable( false );
|
||||
|
||||
JPanel statusHolder = new JPanel( new BorderLayout() );
|
||||
statusHolder.setBorder( BorderFactory.createEmptyBorder( 15, 15, 15, 15 ) );
|
||||
statusHolder.add( statusArea );
|
||||
getContentPane().add( statusHolder, BorderLayout.CENTER );
|
||||
|
||||
continueBtn = new JButton( "Continue" );
|
||||
continueBtn.setEnabled( false );
|
||||
continueBtn.addActionListener( this );
|
||||
|
||||
JPanel continueHolder = new JPanel();
|
||||
continueHolder.setLayout( new BoxLayout( continueHolder, BoxLayout.X_AXIS ) );
|
||||
continueHolder.setBorder( BorderFactory.createEmptyBorder( 0, 0, 10, 0 ) );
|
||||
continueHolder.add( Box.createHorizontalGlue() );
|
||||
continueHolder.add( continueBtn );
|
||||
continueHolder.add( Box.createHorizontalGlue() );
|
||||
getContentPane().add( continueHolder, BorderLayout.SOUTH );
|
||||
|
||||
this.setSize( 400, 160 );
|
||||
this.setLocationRelativeTo( owner );
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void actionPerformed( ActionEvent e ) {
|
||||
Object source = e.getSource();
|
||||
|
||||
if ( source == continueBtn ) {
|
||||
this.setVisible( false );
|
||||
this.dispose();
|
||||
|
||||
if ( done && patchingSucceeded && successTask != null ) {
|
||||
successTask.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private void setStatusText( String message ) {
|
||||
statusArea.setText( message != null ? message : "..." );
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Updates the progress bar.
|
||||
*
|
||||
* If either arg is -1, the bar will become indeterminate.
|
||||
*
|
||||
* @param value the new value
|
||||
* @param max the new maximum
|
||||
*/
|
||||
@Override
|
||||
public void patchingProgress( final int value, final int max ) {
|
||||
SwingUtilities.invokeLater(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if ( value >= 0 && max >= 0 ) {
|
||||
if ( progressBar.isIndeterminate() )
|
||||
progressBar.setIndeterminate( false );
|
||||
|
||||
if ( progressBar.getMaximum() != max ) {
|
||||
progressBar.setValue( 0 );
|
||||
progressBar.setMaximum( max );
|
||||
}
|
||||
progressBar.setValue( value );
|
||||
}
|
||||
else {
|
||||
if ( !progressBar.isIndeterminate() )
|
||||
progressBar.setIndeterminate( true );
|
||||
progressBar.setValue( 0 );
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-specific activity.
|
||||
*
|
||||
* @param message a string, or null
|
||||
*/
|
||||
@Override
|
||||
public void patchingStatus( final String message ) {
|
||||
SwingUtilities.invokeLater(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
setStatusText( message != null ? message : "..." );
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A mod is about to be processed.
|
||||
*/
|
||||
@Override
|
||||
public void patchingMod( final File modFile ) {
|
||||
SwingUtilities.invokeLater(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
setStatusText( String.format( "Installing mod \"%s\"...", modFile.getName() ) );
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Patching ended.
|
||||
*
|
||||
* If anything went wrong, e may be non-null.
|
||||
*/
|
||||
@Override
|
||||
public void patchingEnded( final boolean success, final Exception e ) {
|
||||
SwingUtilities.invokeLater(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
if ( success )
|
||||
setStatusText( "Patching completed." );
|
||||
else
|
||||
setStatusText( String.format( "Patching failed: %s", e ) );
|
||||
|
||||
done = true;
|
||||
patchingSucceeded = success;
|
||||
|
||||
continueBtn.setEnabled( true );
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sets a runnable to trigger after patching successfully.
|
||||
*/
|
||||
public void setSuccessTask( Runnable r ) {
|
||||
successTask = r;
|
||||
}
|
||||
}
|
9
src/main/java/net/vhati/modmanager/ui/Reorderable.java
Normal file
9
src/main/java/net/vhati/modmanager/ui/Reorderable.java
Normal file
|
@ -0,0 +1,9 @@
|
|||
package net.vhati.modmanager.ui;
|
||||
|
||||
|
||||
public interface Reorderable {
|
||||
/**
|
||||
* Moves an element at fromIndex to toIndex.
|
||||
*/
|
||||
public void reorder( int fromIndex, int toIndex );
|
||||
}
|
6
src/main/java/net/vhati/modmanager/ui/Statusbar.java
Normal file
6
src/main/java/net/vhati/modmanager/ui/Statusbar.java
Normal file
|
@ -0,0 +1,6 @@
|
|||
package net.vhati.modmanager.ui;
|
||||
|
||||
|
||||
public interface Statusbar {
|
||||
public void setStatusText( String text );
|
||||
}
|
|
@ -0,0 +1,36 @@
|
|||
package net.vhati.modmanager.ui;
|
||||
|
||||
import java.awt.event.MouseAdapter;
|
||||
import java.awt.event.MouseEvent;
|
||||
import java.awt.event.MouseListener;
|
||||
|
||||
import net.vhati.modmanager.ui.Statusbar;
|
||||
|
||||
|
||||
/**
|
||||
* A MouseListener to show rollover help text in a status bar.
|
||||
*
|
||||
* Construct this with the help text, and a class
|
||||
* implementing the Statusbar interface.
|
||||
*
|
||||
* Then add this mouseListener to a component.
|
||||
*/
|
||||
public class StatusbarMouseListener extends MouseAdapter {
|
||||
private Statusbar bar = null;
|
||||
private String text = null;
|
||||
|
||||
public StatusbarMouseListener( Statusbar bar, String text ) {
|
||||
this.bar = bar;
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseEntered( MouseEvent e ) {
|
||||
bar.setStatusText( text );
|
||||
}
|
||||
|
||||
@Override
|
||||
public void mouseExited( MouseEvent e ) {
|
||||
bar.setStatusText( "" );
|
||||
}
|
||||
}
|
|
@ -0,0 +1,127 @@
|
|||
package net.vhati.modmanager.ui;
|
||||
|
||||
import java.awt.Cursor;
|
||||
import java.awt.datatransfer.DataFlavor;
|
||||
import java.awt.datatransfer.Transferable;
|
||||
import java.awt.dnd.DragSource;
|
||||
import javax.swing.JComponent;
|
||||
import javax.swing.JTable;
|
||||
import javax.swing.TransferHandler;
|
||||
|
||||
import org.apache.logging.log4j.LogManager;
|
||||
import org.apache.logging.log4j.Logger;
|
||||
|
||||
|
||||
/**
|
||||
* Allows drag and drop reordering of JTable rows.
|
||||
*
|
||||
* Its TableModel must implement the Reorderable interface.
|
||||
*/
|
||||
public class TableRowTransferHandler extends TransferHandler {
|
||||
|
||||
private static final Logger log = LogManager.getLogger(TableRowTransferHandler.class);
|
||||
|
||||
private DataFlavor localIntegerFlavor = null;
|
||||
|
||||
private JTable table = null;
|
||||
|
||||
|
||||
public TableRowTransferHandler( JTable table ) {
|
||||
if ( table.getModel() instanceof Reorderable == false ) {
|
||||
throw new IllegalArgumentException( "The tableModel doesn't implement Reorderable." );
|
||||
}
|
||||
this.table = table;
|
||||
|
||||
try {
|
||||
localIntegerFlavor = new DataFlavor( DataFlavor.javaJVMLocalObjectMimeType + ";class=\""+ Integer.class.getName() +"\"" );
|
||||
}
|
||||
catch ( ClassNotFoundException e ) {
|
||||
log.error( e );
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Transferable createTransferable( JComponent c ) {
|
||||
assert ( c == table );
|
||||
int row = table.getSelectedRow();
|
||||
return new IntegerTransferrable( new Integer(row) );
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean canImport( TransferHandler.TransferSupport ts ) {
|
||||
boolean b = ( ts.getComponent() == table && ts.isDrop() && ts.isDataFlavorSupported(localIntegerFlavor) );
|
||||
table.setCursor( b ? DragSource.DefaultMoveDrop : DragSource.DefaultMoveNoDrop );
|
||||
return b;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getSourceActions( JComponent comp ) {
|
||||
return TransferHandler.MOVE;
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("Unchecked")
|
||||
public boolean importData( TransferHandler.TransferSupport ts ) {
|
||||
if ( !canImport(ts) ) return false;
|
||||
|
||||
JTable target = (JTable)ts.getComponent();
|
||||
JTable.DropLocation dl = (JTable.DropLocation)ts.getDropLocation();
|
||||
int dropRow = dl.getRow();
|
||||
int rowCount = table.getModel().getRowCount();
|
||||
if ( dropRow < 0 || dropRow > rowCount ) dropRow = rowCount;
|
||||
|
||||
target.setCursor( Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR) );
|
||||
try {
|
||||
Integer draggedRow = (Integer)ts.getTransferable().getTransferData(localIntegerFlavor);
|
||||
if ( draggedRow != -1 && draggedRow != dropRow ) {
|
||||
((Reorderable)table.getModel()).reorder( draggedRow, dropRow );
|
||||
if ( dropRow > draggedRow ) dropRow--;
|
||||
target.getSelectionModel().addSelectionInterval( dropRow, dropRow );
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error( e );
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void exportDone( JComponent source, Transferable data, int action ) {
|
||||
if ( action == TransferHandler.MOVE || action == TransferHandler.NONE ) {
|
||||
table.setCursor( Cursor.getPredefinedCursor(Cursor.DEFAULT_CURSOR) );
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Drag and drop Integer data, constructed with a raw object
|
||||
* from a drag source, to be transformed into a flavor
|
||||
* suitable for the drop target.
|
||||
*/
|
||||
private class IntegerTransferrable implements Transferable{
|
||||
private Integer data;
|
||||
|
||||
public IntegerTransferrable( Integer data ) {
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object getTransferData( DataFlavor flavor ) {
|
||||
if ( flavor.equals( localIntegerFlavor ) ) {
|
||||
return data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DataFlavor[] getTransferDataFlavors() {
|
||||
return new DataFlavor[] {localIntegerFlavor};
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isDataFlavorSupported( DataFlavor flavor ) {
|
||||
return flavor.equals( localIntegerFlavor );
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue