public class CatalogItem {
	private String title = "";
	private String url = "";
	private String name = "";
	private Date date = null;
	private String compressedSize = "";
	private String fullSize = "";

	private boolean stateLocal = false;
	private boolean stateNewer = false;


	public CatalogItem() {
	}


	public void setTitle( String s ) {
		title = (( s != null ) ? s : "");
	}

	public String getTitle() {
		return title;
	}

	public void setUrl( String s ) {
		url = (( s != null ) ? s : "");
	}

	public String getUrl() {
		return url;
	}

	public void setName( String s ) {
		name = (( s != null ) ? s : "");
	}

	public String getName() {
		return name;
	}

	public void setDate( Date d ) {
		date = d;
	}

	public Date getDate() {
		return date;
	}

	/**
	 * Sets the reported size of the zip file that wraps the UHS file.
	 */
	public void setCompressedSize( String s ) {
		compressedSize = (( s != null ) ? s : "");
	}

	public String getCompressedSize() {
		return compressedSize;
	}

	/**
	 * Sets the reported size of the UHS file, once extracted from the zip file.
	 */
	public void setFullSize( String s ) {
		fullSize = (( s != null ) ? s : "");
	}

	public String getFullSize() {
		return fullSize;
	}


	/**
	 * Sets whether there is a local file with this catalog entry's name.
	 *
	 * @see #resetState()
	 */
	public void setLocal( boolean b ) {
		stateLocal = b;
	}

	public boolean isLocal() {
		return stateLocal;
	}

	/**
	 * Sets whether the catalog entry is newer than the local file.
	 *
	 * @see #resetState()
	 */
	public void setNewer( boolean b ) {
		stateNewer = b;
	}

	public boolean isNewer() {
		return stateNewer;
	}

	/**
	 * Resets the catalog-vs-local state flage.
	 *
	 * @see #setLocal(boolean)
	 * @see #setNewer(boolean)
	 */
	public void resetState() {
		setLocal( false );
		setNewer( false );
	}


	@Override
	public String toString() {
		return getTitle();
	}
} public class CatalogItemComparator implements Comparator<CatalogItem> {
	public static final int SORT_TITLE = 0;
	public static final int SORT_NAME = 1;
	public static final int SORT_DATE = 2;
	public static final int SORT_FULLSIZE = 3;

	protected int sortBy;


	/**
	 * Constructs a comparator that sorts by a given property.
	 *
	 * @param sortBy  one of: SORT_TITLE, SORT_NAME, SORT_DATE, or SORT_FULLSIZE
	 */
	public CatalogItemComparator( int sortBy ) {
		this.sortBy = sortBy;
	}

	/**
	 * Constructs a comparator that sorts by title.
	 */
	public CatalogItemComparator() {
		this( SORT_TITLE );
	}


	@Override
	public int compare( CatalogItem a, CatalogItem b ) {
		if ( a != null && b != null ) {
			if ( sortBy == SORT_TITLE ) {
				return a.getTitle().compareTo( b.getTitle() );
			}
			if ( sortBy == SORT_DATE ) {
				return a.getDate().compareTo( b.getDate() );
			}
			if ( sortBy == SORT_FULLSIZE ) {
				if ( a.getFullSize().matches( "^[0-9]+$" ) && b.getFullSize().matches( "^[0-9]+$" ) ) {
					if ( a.getFullSize().length() == b.getFullSize().length() ) {
						return a.getFullSize().compareTo( b.getFullSize() );
					}
					else {
						return (( a.getFullSize().length() > b.getFullSize().length() ) ? 1 : -1);
					}
				}
				return a.getFullSize().compareTo( b.getFullSize() );
			}
			if ( sortBy == SORT_NAME ) {
				return a.getName().compareTo( b.getName() );
			}
		}
		return 1;
	}
} public class CatalogParser {
	public static final String DEFAULT_CATALOG_URL = "http://www.uhs-hints.com:80/cgi-bin/update.cgi";

	// According to the server's Content-Type HTTP response header.
	public static final String DEFAULT_CATALOG_ENCODING = "ISO-8859-1";

	public static final String DEFAULT_USER_AGENT = "UHSWIN/5.2";


	private final Logger logger = LoggerFactory.getLogger( CatalogParser.class );

	/*
	 * SimpleDateFormat stops reading strings longer than the pattern.
	 * So just parse date. The time segment, if present, will be ignored.
	 */
	private DateFormat goofyDateFormat = new SimpleDateFormat( "dd-MMM-yy" );


	public CatalogParser() {
	}


	/**
	 *
	 * Parses the catalog of available hint files.
	 *
	 * @param catalogString  the xml-like string downloaded from the server
	 * @return a List of CatalogItem objects
	 */
	public List<CatalogItem> parseCatalog( String catalogString ) {
		logger.debug( "Catalog parse started" );

		List<CatalogItem> catalog = new ArrayList<CatalogItem>();

		if (catalogString == null || catalogString.length() == 0) return catalog;


		Pattern msgPtn = Pattern.compile( "<MESSAGE>(.*?)</MESSAGE>" );

		Pattern fileChunkPtn = Pattern.compile( "(?s)<FILE>(.*?)</FILE>\\s*" );
		Pattern titlePtn = Pattern.compile( "<FTITLE>(.*?)</FTITLE>" );
		Pattern urlPtn = Pattern.compile( "<FURL>(.*?)</FURL>" );
		Pattern namePtn = Pattern.compile( "<FNAME>(.*?)</FNAME>" );
		Pattern datePtn = Pattern.compile( "<FDATE>(.*?)</FDATE>" );
		Pattern compressedSizePtn = Pattern.compile( "<FSIZE>(.*?)</FSIZE>" );
		Pattern fullSizePtn = Pattern.compile( "<FFULLSIZE>(.*?)</FFULLSIZE>" );

		// Let chunk's find() skip the <MESSAGE> tag, if present.
		// Could've done find(index) from the end of the message.
		//   and used "\\G" (previous match) for strict back-to-back <FILE> parsing.

		Matcher fileChunkMatcher = fileChunkPtn.matcher( catalogString );
		Matcher m = null;
		while (fileChunkMatcher.find()) {
			String fileChunk = fileChunkMatcher.group( 1 );
			CatalogItem catItem = new CatalogItem();

			m = titlePtn.matcher( fileChunk );
			if ( m.find() ) catItem.setTitle( m.group( 1 ) );

			m = urlPtn.matcher( fileChunk );
			if ( m.find() ) catItem.setUrl( m.group( 1 ) );

			m = namePtn.matcher( fileChunk );
			if ( m.find() ) catItem.setName( m.group( 1 ) );

			m = datePtn.matcher( fileChunk );
			if ( m.find() ) {
				try {
					catItem.setDate( goofyDateFormat.parse( m.group( 1 ) ) );
				}
				catch ( ParseException e ) {
					logger.warn( "Unexpected catalog date format: '{}'", m.group( 1 ) );
				}
			}

			m = compressedSizePtn.matcher( fileChunk );
			if ( m.find() ) catItem.setCompressedSize( m.group( 1 ) );

			m = fullSizePtn.matcher( fileChunk );
			if ( m.find() ) catItem.setFullSize( m.group(1) );

			catalog.add( catItem );
		}

		logger.debug( "Catalog parse finished (count: {})", catalog.size() );

		return catalog;
	}
} public interface Nerfable {

	/*
	 * Either nerf or restore user interaction.
	 *
	 * @param b  the nerfed state
	 */
	public void setNerfed( boolean b );
} public class CatalogTableCellRenderer extends DefaultTableCellRenderer {
	private Color localColor = new Color( 225, 225, 225 );
	private Color newerColor = new Color( 255, 255, 200 );

	Color normalUnselColor = null;


	public CatalogTableCellRenderer() {
		//Color changes don't reset on their own, so cache the initial value
		normalUnselColor = this.getBackground();
	}


	@Override
	public Component getTableCellRendererComponent( JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column ) {
		Component c = super.getTableCellRendererComponent( table, value, isSelected, hasFocus, row, column );

		if ( table != null && table.getModel() instanceof CatalogTableModel ) {
			if ( !isSelected ) {
				CatalogTableModel model = (CatalogTableModel)table.getModel();
				CatalogItem catItem = model.getUHS( row );
				Color rowColor = normalUnselColor;

				if ( catItem != null ) {
					if ( catItem.isNewer() ) {
						rowColor = newerColor;
					}
					else if ( catItem.isLocal() ) {
						rowColor = localColor;
					}
				}
				c.setBackground( rowColor );
			}
		}

		return c;
	}
} public class CatalogTableModel extends AbstractTableModel {
	private Comparator<CatalogItem> comparator = new CatalogItemComparator();
	private DateFormat dateFormat = new SimpleDateFormat( "yyyy-MM-dd" );

	List<CatalogItem> dataVector = new Vector<CatalogItem>();
	List<String> colVector = new Vector<String>();


	public CatalogTableModel( String[] columnNames ) {
		for ( int i=0; i < columnNames.length; i++ ) {
			colVector.add( columnNames[i] );
		}
	}

	@Override
	public int getRowCount() {
		return dataVector.size();
	}

	@Override
	public int getColumnCount() {
		return colVector.size();
	}

	@Override
	public String getColumnName( int column ) {
		if ( column < 0 || column >= colVector.size() ) return null;

		return colVector.get( column );
	}

	@Override
	public Object getValueAt( int row, int column ) {
		if ( column < 0 || column >= colVector.size() || row < 0 || row >= dataVector.size() ) return null;

		Object value = null;
		if ( getColumnName( column ).equals( "Title" ) ) {
			value = dataVector.get( row ).getTitle();
		}
		else if ( getColumnName( column ).equals( "Name" ) ) {
			value = dataVector.get( row ).getName();
		}
		else if (getColumnName( column ).equals( "Date" ) ) {
			Date uhsDate = dataVector.get( row ).getDate();
			value = (( uhsDate != null ) ? dateFormat.format( dataVector.get( row ).getDate() ) : "");
		}
		else if ( getColumnName( column ).equals( "Size" ) ) {
			value = dataVector.get( row ).getCompressedSize();
		}
		else if ( getColumnName( column ).equals( "FullSize" ) ) {
			value = dataVector.get( row ).getFullSize();
		}
		return value;
	}

	@Override
	public boolean isCellEditable( int x, int y ) {
		return false;
	}


	public void addUHS( CatalogItem catItem ) {
		dataVector.add( catItem );
		this.fireTableDataChanged();
	}

	public void addUHSs( CatalogItem[] catItems ) {
		for ( int i=0; i < catItems.length; i++ ) {
			dataVector.add( catItems[i] );
		}
		this.fireTableDataChanged();
	}


	public void removeUHSs( int[] indeces ) {
		Vector<Integer> indexVector = new Vector<Integer>();
		for ( int i=0; i < indeces.length; i++ ) {
			indexVector.add( new Integer( indeces[i] ) );
		}
		Collections.sort( indexVector );

		for ( int i=indexVector.size()-1; i >= 0; i-- ) {
			dataVector.remove( indexVector.get( i ).intValue() );
		}
		this.fireTableDataChanged();
	}


	public CatalogItem getUHS( int row ) {
		if ( row < 0 || row >= dataVector.size() ) return null;
		return dataVector.get( row );
	}


	public void clear() {
		dataVector.clear();
		this.fireTableDataChanged();
	}


	public void sort() {
		Collections.sort( dataVector, comparator );
		this.fireTableDataChanged();
	}

	public void sort( Comparator<CatalogItem> c ) {
		comparator = c;
		sort();
	}
} public class StringFetchTask extends SwingWorker<StringFetchTask.StringFetchResult, Object> {

	// First generic is the result, returned by doInBackground().
	// Second generic is for returning intermediate results while running. (Unused)

	private volatile boolean aborting = false;
	private String userAgent = System.getProperty( "http.agent" );
	private String encoding = "utf-8";

	private String urlString;


	public StringFetchTask( String urlString ) {
		this.urlString = urlString;
	}


	public void setUserAgent( String s ) {
		this.userAgent = s;
	}

	public String getUserAgent() {
		return userAgent;
	}

	public void setEncoding( String s ) {
		this.encoding = s;
	}

	public String getEncoding() {
		return encoding;
	}


	/**
	 * Signals that the task should end gracefully.
	 * <p>
	 * SwingWorker's cancel() will cause get() to throw a CancellationException.
	 * Use this method instead.
	 * <p>
	 * This method is thread-safe.
	 */
	public void abortTask() {
		aborting = true;
	}


	@Override
	public StringFetchResult doInBackground() {
		HttpURLConnection con = null;
		InputStream downloadStream = null;
		BufferedReader r = null;
		StringBuilder contentString = null;

		StringFetchResult fetchResult = new StringFetchResult( urlString );
		Exception ex = null;

		try {
			con = (HttpURLConnection)(new URL( urlString ).openConnection());
			con.setRequestProperty( "User-Agent", userAgent );
			con.connect();

			if ( con.getResponseCode() != HttpURLConnection.HTTP_OK ) {
				throw new FetchUnitException( "Server returned HTTP "+ con.getResponseCode() +" "+ con.getResponseMessage() );
			}

			// Get the file's length, if the server reports it. (possibly -1).
			int contentLength = con.getContentLength();

			downloadStream = con.getInputStream();
			r = new BufferedReader( new InputStreamReader( downloadStream, encoding ) );

			contentString = new StringBuilder();
			char data[] = new char[1024];
			long total = 0;
			int count;
			while ( (count=r.read( data, 0, data.length )) != -1 ) {
				if ( this.isCancelled() || aborting ) {
					r.close();

					fetchResult.status = StringFetchResult.STATUS_CANCELLED;
					return fetchResult;
				}
				total += count;
				if ( contentLength > 0 ) {
					this.setProgress( (int)(total * 100 / contentLength) );
				}
				contentString.append( data, 0, count );
			}

			r.close();

			fetchResult.status = StringFetchResult.STATUS_COMPLETED;
			fetchResult.content = contentString.toString();
		}
		catch ( IOException e ) {
			ex = e;
		}
		catch ( FetchUnitException e ) {
			ex = e;
		}
		finally {
			try {if ( r != null ) r.close();} catch ( IOException e ) {}
			try {if ( downloadStream != null ) downloadStream.close();} catch ( IOException e ) {}
			if ( con != null ) con.disconnect();
		}
		if ( ex != null ) {
			fetchResult.status = StringFetchResult.STATUS_ERROR;
			fetchResult.errorCause = ex;
		}

		return fetchResult;
	}

	@Override
	protected void done() {
		// Could trugger callbacks here, or let the listener react to a status change.
	}



	public static class StringFetchResult {
		public static final int STATUS_DOWNLOADING = 0;
		public static final int STATUS_COMPLETED = 1;
		public static final int STATUS_CANCELLED = 2;
		public static final int STATUS_ERROR = 3;

		public String urlString;
		public int status = STATUS_DOWNLOADING;
		public Throwable errorCause = null;
		public String content = null;

		public StringFetchResult( String urlString ) {
			this.urlString = urlString;
		}
	}
} public static class StringFetchResult {
	public static final int STATUS_DOWNLOADING = 0;
	public static final int STATUS_COMPLETED = 1;
	public static final int STATUS_CANCELLED = 2;
	public static final int STATUS_ERROR = 3;

	public String urlString;
	public int status = STATUS_DOWNLOADING;
	public Throwable errorCause = null;
	public String content = null;

	public StringFetchResult( String urlString ) {
		this.urlString = urlString;
	}
} public static class UHSFetchResult {
	public static final int STATUS_DOWNLOADING = 0;
	public static final int STATUS_COMPLETED = 1;
	public static final int STATUS_CANCELLED = 2;
	public static final int STATUS_ERROR = 3;

	public CatalogItem catItem;
	public int status = STATUS_DOWNLOADING;
	public Throwable errorCause = null;
	public File file = null;

	public UHSFetchResult( CatalogItem catItem ) {
		this.catItem = catItem;
	}
} public class UHSReaderPanel extends JPanel implements UHSReaderNavCtrl, ActionListener {
	public static final int SCROLL_TO_TOP = 0;
	public static final int SCROLL_TO_BOTTOM = 1;
	public static final int SCROLL_IF_INCOMPLETE = 2;


	private final Logger logger = LoggerFactory.getLogger( UHSReaderPanel.class );

	private String readerTitle = "";

	private UHSRootNode rootNode = null;
	private UHSNode currentNode = null;

	private List<NodePanel> nodePanelRegistry = new ArrayList<NodePanel>();

	private NodePanel currentNodePanel = null;

	private JScrollPane centerScroll = null;

	private List<UHSNode> historyArray = new ArrayList<UHSNode>();
	private List<UHSNode> futureArray = new ArrayList<UHSNode>();
	private JButton openBtn = null;
	private JButton backBtn = null;
	private JButton forwardBtn = null;
	private JButton findBtn = null;

	private JLabel nodeTitleLbl = null;
	private JLabel revealedLbl = null;
	private JButton revealNextBtn = null;
	private JCheckBox showAllBox = null;

	private File hintsDir = new File( "./" );


	public UHSReaderPanel() {
		super( new BorderLayout() );

		GridBagConstraints gridC = new GridBagConstraints();

		JPanel topPanel = new JPanel( new BorderLayout() );
			JPanel ctrlPanel = new JPanel( new GridLayout( 0, 3 ) );

				JPanel ctrlLeftPanel = new JPanel( new GridBagLayout() );
					gridC.gridx = 0;
					gridC.weightx = 1.0;
					gridC.fill = GridBagConstraints.NONE;
					gridC.anchor = GridBagConstraints.WEST;
					openBtn = new JButton( "Open..." );
						ctrlLeftPanel.add( openBtn, gridC );
				ctrlPanel.add( ctrlLeftPanel );

				JPanel ctrlCenterPanel = new JPanel( new GridBagLayout() );
					gridC.gridx = 0;
					gridC.weightx = 1.0;
					gridC.fill = GridBagConstraints.NONE;
					gridC.anchor = GridBagConstraints.CENTER;
					backBtn = new JButton( "<" );
						backBtn.setEnabled( false );
						ctrlCenterPanel.add( backBtn, gridC );
					gridC.gridx++;
					forwardBtn = new JButton( ">" );
						forwardBtn.setEnabled( false );
						ctrlCenterPanel.add( forwardBtn, gridC );
					ctrlPanel.add( ctrlCenterPanel );

				JPanel ctrlRightPanel = new JPanel( new GridBagLayout() );
					gridC.gridx = 0;
					gridC.weightx = 1.0;
					gridC.fill = GridBagConstraints.NONE;
					gridC.anchor = GridBagConstraints.EAST;
					findBtn = new JButton( "Find..." );
						ctrlRightPanel.add( findBtn, gridC );
					ctrlPanel.add( ctrlRightPanel );

				topPanel.add( ctrlPanel, BorderLayout.CENTER );

			JPanel nodeTitlePanel = new JPanel();
				nodeTitlePanel.setLayout( new BoxLayout( nodeTitlePanel, BoxLayout.X_AXIS ) );
				nodeTitleLbl = new JLabel( "" );
					nodeTitlePanel.add( nodeTitleLbl );
				topPanel.add( nodeTitlePanel, BorderLayout.SOUTH );

		JPanel centerPanel = new JPanel( new BorderLayout() );
			centerScroll = new JScrollPane();
				centerScroll.setVerticalScrollBarPolicy( JScrollPane.VERTICAL_SCROLLBAR_ALWAYS );
				centerScroll.setHorizontalScrollBarPolicy( JScrollPane.HORIZONTAL_SCROLLBAR_AS_NEEDED );
				centerPanel.add( centerScroll );

		JPanel revealPanel = new JPanel( new GridLayout( 0, 3 ) );
			revealedLbl = new JLabel( "" );
				revealedLbl.setHorizontalAlignment( SwingConstants.CENTER );
				revealPanel.add( revealedLbl );

			JPanel revealNextHolderPanel = new JPanel( new GridBagLayout() );
				revealNextBtn = new JButton( "V" );
					revealNextBtn.setEnabled( false );
					revealNextBtn.setPreferredSize( new Dimension( revealNextBtn.getPreferredSize().width*2, revealNextBtn.getPreferredSize().height ) );
					revealNextHolderPanel.add( revealNextBtn );
				revealPanel.add( revealNextHolderPanel );

			showAllBox = new JCheckBox( "Show All" );
				showAllBox.setEnabled( true );
				showAllBox.setHorizontalAlignment( SwingConstants.CENTER );
				//showAllBox.setMaximumSize( showAllBox.getPreferredSize() );
				revealPanel.add( showAllBox );

		openBtn.addActionListener( this );
		backBtn.addActionListener( this );
		forwardBtn.addActionListener( this );
		findBtn.addActionListener( this );
		revealNextBtn.addActionListener( this );
		showAllBox.addActionListener( this );


		this.add( topPanel, BorderLayout.NORTH );
		this.add( centerPanel, BorderLayout.CENTER );
		this.add( revealPanel, BorderLayout.SOUTH );

		reset();
	}


	@Override
	public void actionPerformed( ActionEvent e ) {
		Object source = e.getSource();

		if ( source == openBtn ) {
			FileFilter uhsFilter = new FileFilter() {
				@Override
				public String getDescription() {return "UHS Files (*.uhs)";}

				@Override
				public boolean accept( File f ) {
					return (f.isDirectory() || f.getName().toLowerCase().endsWith( ".uhs" ));
				}
			};
			FileFilter puhsFilter = new FileFilter() {
				@Override
				public String getDescription() {return "Proto UHS Files (*.puhs)";}

				@Override
				public boolean accept( File f ) {
					return (f.isDirectory() || f.getName().toLowerCase().endsWith( ".puhs" ));
				}
			};

			JFileChooser chooser = new JFileChooser( hintsDir );
				chooser.addChoosableFileFilter( uhsFilter );
				chooser.addChoosableFileFilter( puhsFilter );
				chooser.setFileFilter( uhsFilter );

			int status = chooser.showOpenDialog( this );
			if ( status == 0 ) {
				openFile( chooser.getSelectedFile() );
			}
		}
		else if ( source == backBtn ) {
			setReaderNode( historyArray.get( historyArray.size()-1 ) );
		}
		else if ( source == forwardBtn ) {
			setReaderNode( futureArray.get( futureArray.size()-1 ) );
		}
		else if ( source == findBtn ) {
			String tmpString = JOptionPane.showInputDialog( this, "Find what?", "Find text", JOptionPane.QUESTION_MESSAGE );
			if ( tmpString == null || tmpString.length() == 0 ) return;

			UHSNode newNode = new UHSNode( "Result" );
			newNode.setRawStringContent( "Search Results for \""+ tmpString +"\"" );
			searchNode( newNode, "", 0, rootNode, tmpString.toLowerCase() );
			setReaderNode( newNode );
		}
		else if ( source == revealNextBtn ) {
			revealNext();
			scrollTo( SCROLL_TO_BOTTOM );
			scrollTo( SCROLL_TO_BOTTOM );
		}
		else if ( source == showAllBox ) {
			if( !showAllBox.isSelected() ) return;

			while ( revealNext() != false );
		}
	}


	/**
	 * Sets the dir in which to look for UHS files.
	 *
	 * The default path is "."
	 */
	public void setHintsDir( File d ) {
		hintsDir = d;
	}

	/**
	 * Returns the dir in which to look for UHS files.
	 */
	public File getHintsDir() {
		return hintsDir;
	}


	/**
	 * Registers a reusable NodePanel to handle a UHSNode class (and its subclasses).
	 * <p>
	 * When needed, panels will be searched (in reverse order of
	 * registration) for the first one whose accept() method returns true.
	 * <p>
	 * Registered panels determine the result of isNodeVisitable().
	 *
	 * @see isNodeVisitable(UHSNode)
	 * @see net.vhati.openuhs.desktopreader.reader.NodePanel.accept(UHSNode)
	 */
	public void registerNodePanel( NodePanel nodePanel ) {
		nodePanelRegistry.add( nodePanel );
	}

	/**
	 * Returns a previously registered NodePanel capable of representing a UHSNode, or null.
	 *
	 * @see #registerNodePanel(NodePanel)
	 */
	protected NodePanel getPanelForNode( UHSNode node ) {
		for ( int i=nodePanelRegistry.size()-1; i >= 0 ; i-- ) {
			NodePanel p = nodePanelRegistry.get( i );
			if ( p.accept( node ) ) return p;
		}
		return null;
	}


	/**
	 * Resets this panel.
	 */
	public void reset() {
		historyArray.clear();
		futureArray.clear();
		backBtn.setEnabled( false );
		forwardBtn.setEnabled( false );
		findBtn.setEnabled( false );

		revealedLbl.setText( "" );
		revealNextBtn.setEnabled( false );

		rootNode = null;
		currentNode = null;

		centerScroll.setViewportView( null );
		if ( currentNodePanel != null ) currentNodePanel.reset();
		currentNodePanel = null;

		setReaderTitle( null );
	}


	/**
	 * Opens a UHS file.
	 *
	 * @param f  the location of the file
	 */
	public void openFile( final File f ) {
		ancestorSetNerfed( true );

		logger.info( "Opened {}", f.getName() );

		Thread parseWorker = new Thread() {
			public void run() {
				UHSRootNode rootNode = null;
				try {
					if ( f.getName().matches( "(?i).*[.]uhs$" ) ) {
						UHSParser uhsParser = new UHSParser();
						rootNode = uhsParser.parseFile( f );
					}
					else if ( f.getName().matches( "(?i).*[.]puhs" ) ) {
						Proto4xUHSParser protoParser = new Proto4xUHSParser();
						rootNode = protoParser.parseFile( f );
					}
				}
				catch ( IOException e ) {
					logger.error( "Unreadable file or parsing error", e );
				}

				final UHSRootNode finalRootNode = rootNode;
				// Back to the event thread...
				Runnable r = new Runnable() {
					@Override
					public void run() {
						if ( finalRootNode != null ) {
							setReaderRootNode( finalRootNode );
						} else {
							JOptionPane.showMessageDialog( UHSReaderPanel.this, "Unreadable file or parsing error", "OpenUHS Cannot Continue", JOptionPane.ERROR_MESSAGE );
						}
						ancestorSetNerfed( false );
					}
				};
				SwingUtilities.invokeLater( r );
			}
		};

		parseWorker.start();
	}


	/**
	 * Displays a new UHS tree.
	 *
	 * @param newRootNode  the new root node
	 */
	public void setReaderRootNode( UHSRootNode newRootNode ) {
		reset();
		rootNode = newRootNode;
		findBtn.setEnabled( true );
		setReaderNode( rootNode );

		String title = rootNode.getUHSTitle();
		setReaderTitle( (( title != null ) ? title : "") );

		System.gc();
	}


	/**
	 * Displays a new node within the current tree.
	 * <p>
	 * If the new node is the same as the next/previous one, breadcrumbs
	 * will be traversed.
	 *
	 * @param newNode  the new node
	 */
	@Override
	public void setReaderNode( UHSNode newNode ) {
		if ( newNode == null ) return;

		NodePanel newNodePanel = getPanelForNode( newNode );
		if ( newNodePanel == null ) {
			JOptionPane.showMessageDialog( this, "That node is not supported by this reader.", "OpenUHS Cannot Continue", JOptionPane.ERROR_MESSAGE );
			logger.error( "The reader has no registered NodePanels for node ({}) with content: {}", newNode.getClass().getCanonicalName(), newNode.getPrintableContent() );
			return;
		}
		else {
			logger.debug( "Setting reader's node panel to: {}", newNodePanel.getClass().getCanonicalName() );
		}

		if ( !historyArray.isEmpty() && historyArray.get( historyArray.size()-1 ).equals( newNode ) ) {
			// Move one node into the past.
			historyArray.remove( historyArray.size()-1 );
			if ( currentNode != null ) {
				// Leave a breadcrumb before changing to the new node.
				futureArray.add( currentNode );
			}
		}
		else if ( !futureArray.isEmpty() && futureArray.get( futureArray.size()-1 ).equals( newNode ) ) {
			// Move one node into the future.
			futureArray.remove( futureArray.size()-1 );
			if ( currentNode != null ) {
				// Leave a breadcrumb before changing to the new node.
				historyArray.add( currentNode );
			}
		}
		else {
			if ( currentNode != null ) {
				// Leave a breadcrumb before changing to the new node.
				historyArray.add( currentNode );
			}
			// Wipe the future.
			futureArray.clear();
		}
		backBtn.setEnabled( !historyArray.isEmpty() );
		forwardBtn.setEnabled( !futureArray.isEmpty() );

		currentNode = newNode;
		boolean showAll = showAllBox.isSelected();

		if ( currentNodePanel != null ) currentNodePanel.reset();

		if ( currentNodePanel != newNodePanel ) {
			currentNodePanel = newNodePanel;
			currentNodePanel.setNavCtrl( this );
			centerScroll.setViewportView( currentNodePanel );
		}
		currentNodePanel.setNode( currentNode, showAll );

		nodeTitleLbl.setText( currentNodePanel.getTitle() );

		scrollTo( SCROLL_IF_INCOMPLETE );

		if ( currentNodePanel.isRevealSupported() ) {
			revealedLbl.setText( currentNode.getCurrentReveal() +"/"+ currentNode.getMaximumReveal() );
		} else {
			revealedLbl.setText( "" );
		}
		revealNextBtn.setEnabled( !currentNodePanel.isComplete() );

		this.validate();
		this.repaint();
	}


	/**
	 * Displays a new node within the current tree.
	 * <p>
	 * Nothing will happen if the ID isn't among the root node's list of link targets.
	 *
	 * @param id  the id of the new node
	 */
	@Override
	public void setReaderNode( int id ) {
		UHSNode tmpNode = rootNode.getNodeByLinkId( id );
		if ( tmpNode != null ) {
			setReaderNode( tmpNode );
		} else {
			JOptionPane.showMessageDialog( this, "Could not find link target: "+ id, "OpenUHS Cannot Continue", JOptionPane.ERROR_MESSAGE );
		}
	}


	/**
	 * Sets the reader's title.
	 *
	 * @param s  a title (null is treated as "")
	 */
	@Override
	public void setReaderTitle( String s ) {
		readerTitle = (( s != null ) ? s : "");

		Object ancestor = getTopLevelAncestor();
		if ( ancestor != null && ancestor instanceof Frame ) {
			((Frame)ancestor).setTitle( readerTitle );
		}
	}

	@Override
	public String getReaderTitle() {
		return readerTitle;
	}

	@Override
	public boolean isNodeVisitable( UHSNode node ) {
		return ( getPanelForNode( node ) != null );
	}


	/**
	 * Recursively searches for a phrase within children of a node.
	 * <p>
	 * This'll go into an infinite loop if two nodes have each other as children.
	 * Luckily real UHS files aren't structured that way.
	 * Link targets don't count as children.
	 *
	 * @param resultsNode  an existing temporary node to add results to
	 * @param prefix  phrase to prepend to result titles (use "")
	 * @param depth  recursion level reminder (use 0)
	 * @param input  the phrase to search for (must be lowercase)
	 */
	public void searchNode( UHSNode resultsNode, String prefix, int depth, UHSNode currentNode, String input ) {
		if ( input == null || input.length() == 0 ) return;
		// Assuming input is lower case because toLowering it here would be wasteful.

		prefix = (( depth > 1 ) ? prefix+" : " : "") + currentNode.getDecoratedStringContent();

		depth++;
		boolean beenListed = false;
		for ( int i=0; i < currentNode.getChildCount(); i++ ) {
			UHSNode tmpNode = currentNode.getChild( i );

			if ( beenListed == false ) {
				if ( tmpNode.getDecoratedStringContent().toLowerCase().indexOf( input ) != -1 ) {
					UHSNode newNode = new UHSNode( "Result" );
					newNode.setRawStringContent( prefix );
					newNode.setChildren( currentNode.getChildren() );
					resultsNode.addChild( newNode );
					beenListed = true;
				}
			}
			searchNode( resultsNode, prefix, depth, tmpNode, input );
		}
	}


	/**
	 * Reveals the next hint of the current node panel.
	 *
	 * @return true if successful, false otherwise
	 */
	public boolean revealNext() {
		if ( currentNodePanel == null ) return false;
		if ( currentNodePanel.isComplete() ) return false;

		currentNodePanel.revealNext();

		revealedLbl.setText( currentNodePanel.getCurrentReveal() +"/"+ currentNodePanel.getMaximumReveal() );
		revealNextBtn.setEnabled( !currentNodePanel.isComplete() );

		return true;
	}


	/**
	 * Scrolls to the top/bottom of the visible hints.
	 * <p>
	 * This schedules a Runnable to do the scrolling at the next opportunity.
	 * <p>
	 * SCROLL_IF_INCOMPLETE option defers completeness checking until the thread runs: top if true, bottom otherwise.
	 * <p>
	 * The thread yielding is a workaround for JScrollPane goofiness when the content grows.
	 *
	 * @param position  one of: SCROLL_TO_TOP, SCROLL_TO_BOTTOM, or SCROLL_IF_INCOMPLETE
	 * @see SwingUtilities#invokeLater(Runnable)
	 * @see Thread#yield()
	 */
	public void scrollTo( int position ) {
		// Wait a bit longer.
		Thread.yield();

		if ( position == SCROLL_IF_INCOMPLETE ) {
			if ( currentNodePanel.isComplete() ) position = SCROLL_TO_TOP;
			else position = SCROLL_TO_BOTTOM;
		}
		if ( position == SCROLL_TO_TOP ) {
			SwingUtilities.invokeLater( new Runnable() {
				@Override
				public void run() {
					centerScroll.getViewport().setViewPosition( new Point( 0, 0 ) );
				}
			});
		}
		else if ( position == SCROLL_TO_BOTTOM ) {
			SwingUtilities.invokeLater(new Runnable() {
				@Override
				public void run() {
					// Wait some more.
					Thread.yield();
					int viewHeight = centerScroll.getViewport().getViewSize().height;
					int visibleHeight = centerScroll.getViewport().getExtentSize().height;
					if ( viewHeight > visibleHeight ) {
						centerScroll.getViewport().setViewPosition( new Point( 0, viewHeight-visibleHeight ) );
					}
				}
			});
		}
	}


	/**
	 * Sets the font size for all UHSTextAreas.
	 */
	public void setFontSize( int newSize ) {
		if ( newSize <= 0 ) throw new IllegalArgumentException( String.format( "Invalid font size: %d", newSize ) );

		Style baseStyle = UHSTextArea.DEFAULT_STYLES.getStyle( "base" );
		StyleConstants.setFontSize( baseStyle, newSize );
	}


	/**
	 * Calls setNerfed on the top-level ancestor, if nerfable.
	 *
	 * A dedicated method was easier than passing the ancestor to runnables.
	 */
	private void ancestorSetNerfed( boolean b ) {
		boolean nerfable = false;
		Component parentComponent = null;
		Object ancestor = this.getTopLevelAncestor();
		if ( ancestor != null ) {
			if ( ancestor instanceof Nerfable ) nerfable = true;
			if ( ancestor instanceof Component ) parentComponent = (Component)ancestor;
		}

		if (nerfable) ((Nerfable)ancestor).setNerfed( b );
	}
}
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Component;
import java.awt.EventQueue;
import java.awt.GridBagConstraints;
import java.awt.GridBagLayout;
import java.awt.GridLayout;
import java.awt.Insets;
import java.awt.Rectangle;
import java.awt.event.ActionEvent;
import java.awt.event.ActionListener;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
import java.beans.PropertyChangeEvent;
import java.beans.PropertyChangeListener;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.Comparator;
import java.util.Date;
import java.util.List;
import javax.swing.AbstractAction;
import javax.swing.Action;
import javax.swing.BorderFactory;
import javax.swing.Box;
import javax.swing.BoxLayout;
import javax.swing.JButton;
import javax.swing.JCheckBox;
import javax.swing.JComponent;
import javax.swing.JLabel;
import javax.swing.JOptionPane;
import javax.swing.JPanel;
import javax.swing.JScrollPane;
import javax.swing.JSeparator;
import javax.swing.JTable;
import javax.swing.JTextField;
import javax.swing.KeyStroke;
import javax.swing.ListSelectionModel;
import javax.swing.ProgressMonitor;
import javax.swing.SwingWorker;
import javax.swing.event.ListSelectionEvent;
import javax.swing.event.ListSelectionListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.vhati.openuhs.core.downloader.CatalogItem;
import net.vhati.openuhs.core.downloader.CatalogItemComparator;
import net.vhati.openuhs.core.downloader.CatalogParser;
import net.vhati.openuhs.desktopreader.Nerfable;
import net.vhati.openuhs.desktopreader.downloader.CatalogTableCellRenderer;
import net.vhati.openuhs.desktopreader.downloader.CatalogTableModel;
import net.vhati.openuhs.desktopreader.downloader.StringFetchTask;
import net.vhati.openuhs.desktopreader.downloader.StringFetchTask.StringFetchResult;
import net.vhati.openuhs.desktopreader.downloader.UHSFetchTask.UHSFetchResult;
import net.vhati.openuhs.desktopreader.reader.UHSReaderPanel;
							if ( fetchResult.status == StringFetchResult.STATUS_COMPLETED ) {

								CatalogParser catalogParser = new CatalogParser();
								List<CatalogItem> catalog = catalogParser.parseCatalog( fetchResult.content );
								if ( catalog.size() > 0 ) {
									catalogTableModel.clear();

									for ( CatalogItem catItem : catalog ) {
										catalogTableModel.addUHS( catItem );
									}
									catalogTableModel.sort();
									colorizeTable();
								}
								else {
									// TODO: ...
								}
							}
							else {
								if ( fetchResult.status != StringFetchResult.STATUS_CANCELLED ) {
									// TODO: ...
								}
							}
						}
						catch ( Exception ex ) {
							// InterruptedException, while get() was blocking.
							// java.util.concurrent.ExecutionException, if SwingWorker threw something.

							logger.error( "Could not fetch/parse catalog", ex );
						}

						if ( progressDlg != null ) progressDlg.close();
						ancestorSetNerfed( false );
					}
				}
			}
		});

		catalogFetchTask.execute();
	}


	private void colorizeTable() {
		catalogTable.clearSelection();

		String[] hintNames = hintsDir.list();
		Arrays.sort( hintNames );

		for ( int i=0; i < catalogTableModel.getRowCount(); i++ ) {
			CatalogItem catItem = catalogTableModel.getUHS( i );
			catItem.resetState();

			if ( Arrays.binarySearch( hintNames, catItem.getName()) >= 0 ) {
				catItem.setLocal( true );
				File uhsFile = new File( hintsDir, catItem.getName() );
				Date localDate = new Date( uhsFile.lastModified() );

				if ( catItem.getDate() != null && catItem.getDate().after( localDate ) ) {
					catItem.setNewer( true );
				}
			}
		}
		catalogTable.repaint();
	}


	private void find( String s ) {
		if ( s.length() == 0 ) return;
		String findString = s.toLowerCase();

		Rectangle viewRect = catalogTableScroll.getViewport().getViewRect();
		int selRow = catalogTable.getSelectedRow();
		int rowCount = catalogTableModel.getRowCount();
		int foundRow = -1;

		if ( selRow >= 0 ) {
			for ( int i=selRow+1; i < rowCount; i++ ) {
				CatalogItem catItem = catalogTableModel.getUHS( i );
				if ( catItem.getTitle().toLowerCase().indexOf( findString ) != -1 ) {
					foundRow = i;
					break;
				}
			}
		}
		if ( foundRow == -1 ) {
			for ( int i=0; i < (selRow >= 0 ? selRow : rowCount); i++ ) {
				CatalogItem catItem = catalogTableModel.getUHS( i );
				if ( catItem.getTitle().toLowerCase().indexOf( findString ) != -1 ) {
					foundRow = i;
					break;
				}
			}
		}
		if ( foundRow != -1 ) {
			catalogTable.scrollRectToVisible( new Rectangle( catalogTable.getCellRect( foundRow, 0, true ) ) );
			catalogTable.setRowSelectionInterval( foundRow, foundRow );
		}
	}


	/**
	 * Returns the table displaying the catalog.
	 * <p>
	 * This is so parent containers can add listeners to the downloader's GUI.
	 *
	 * @return the table
	 */
	public JTable getCatalogTable() {
		return catalogTable;
	}


	/**
	 * Calls setNerfed on the top-level ancestor, if nerfable.
	 * <p>
	 * A dedicated method was easier than passing the ancestor to runnables.
	 */
	private void ancestorSetNerfed( boolean b ) {
		boolean nerfable = false;
		Component ancestorComponent = getAncestorComponent();
		if ( ancestorComponent != null ) {
			if ( ancestorComponent instanceof Nerfable ) nerfable = true;