// Near Infinity - An Infinity Engine Browser and Editor // Copyright (C) 2001 - 2005 Jon Olav Hauglid // See LICENSE.txt for license information package org.infinity.gui; import java.awt.BorderLayout; import java.awt.CardLayout; import java.awt.FlowLayout; import java.awt.GridLayout; import java.awt.Insets; import java.awt.event.ActionEvent; import java.awt.event.ActionListener; import java.awt.event.ItemEvent; import java.awt.event.ItemListener; import java.io.BufferedWriter; import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.ByteBuffer; import java.nio.channels.SeekableByteChannel; import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import javax.swing.BorderFactory; import javax.swing.JButton; import javax.swing.JFileChooser; import javax.swing.JLabel; import javax.swing.JMenuItem; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JSlider; import javax.swing.JTable; import javax.swing.JTextField; import javax.swing.ListSelectionModel; import javax.swing.ProgressMonitor; import javax.swing.UIManager; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import javax.swing.event.ListSelectionEvent; import javax.swing.event.ListSelectionListener; import org.fife.ui.rsyntaxtextarea.RSyntaxTextArea; import org.infinity.NearInfinity; import org.infinity.datatype.DecNumber; import org.infinity.datatype.Editable; import org.infinity.datatype.Flag; import org.infinity.datatype.InlineEditable; import org.infinity.datatype.ResourceRef; import org.infinity.datatype.Unknown; import org.infinity.icon.Icons; import org.infinity.resource.AbstractStruct; import org.infinity.resource.Profile; import org.infinity.search.SearchClient; import org.infinity.search.SearchMaster; import org.infinity.search.StringReferenceSearcher; import org.infinity.util.StringResource; import org.infinity.util.io.FileManager; import org.infinity.util.io.StreamUtils; public final class StringEditor extends ChildFrame implements ActionListener, ListSelectionListener, SearchClient, ChangeListener, ItemListener { private static final String s_flags[] = { "None", "Has text", "Has sound", "Has token" }; private static String signature, version; private static int entry_size = 26; // V1 private final ButtonPopupMenu bfind; private final CardLayout cards = new CardLayout(); private final Path stringPath; private final JButton badd = new JButton("Add", Icons.getIcon(Icons.ICON_ADD_16)); private final JButton bdelete = new JButton("Delete", Icons.getIcon(Icons.ICON_REMOVE_16)); private final JButton breread = new JButton("Revert", Icons.getIcon(Icons.ICON_UNDO_16)); private final JButton bsave = new JButton("Save", Icons.getIcon(Icons.ICON_SAVE_16)); private final JButton bexport = new JButton("Export as TXT...", Icons.getIcon(Icons.ICON_EXPORT_16)); private final JMenuItem ifindattribute = new JMenuItem("selected attribute"); private final JMenuItem ifindstring = new JMenuItem("string"); private final JMenuItem ifindref = new JMenuItem("references to this entry"); private final JPanel editpanel = new JPanel(); private final JPanel editcontent = new JPanel(); private final JSlider slider = new JSlider(0, 100, 0); private final JTable table = new JTable(); private final RSyntaxTextArea tatext = new InfinityTextArea(true); private final JTextField tstrref = new JTextField(5); private final StringEditor editor; private final java.util.List<StringEntry> added_entries = new ArrayList<StringEntry>(); private DecNumber entries_count, entries_offset; private Editable editable; private StringEntry entries[]; private Unknown unknown; private int index_shown = -1, init_show; public StringEditor(Path stringPath, int init_show) { super("Edit: " + stringPath); setIconImage(Icons.getIcon(Icons.ICON_EDIT_16).getImage()); this.stringPath = stringPath; if (init_show >= 0) { this.init_show = init_show; } StringResource.close(); JOptionPane.showMessageDialog(NearInfinity.getInstance(), "Make sure you have a backup of " + stringPath.getFileName(), "Warning", JOptionPane.WARNING_MESSAGE); tstrref.addActionListener(this); table.getSelectionModel().setSelectionMode(ListSelectionModel.SINGLE_SELECTION); table.getSelectionModel().addListSelectionListener(this); table.setFont(BrowserMenuBar.getInstance().getScriptFont()); tatext.setMargin(new Insets(3, 3, 3, 3)); tatext.setLineWrap(true); tatext.setWrapStyleWord(true); ifindattribute.setEnabled(false); breread.setToolTipText("Undo all changes"); bfind = new ButtonPopupMenu("Find...", new JMenuItem[]{ifindattribute, ifindstring, ifindref}); bfind.setIcon(Icons.getIcon(Icons.ICON_FIND_16)); badd.setMnemonic('a'); bdelete.setMnemonic('d'); bsave.setMnemonic('s'); breread.setMnemonic('r'); bexport.setMnemonic('e'); badd.addActionListener(this); bdelete.addActionListener(this); bsave.addActionListener(this); breread.addActionListener(this); bfind.addItemListener(this); bexport.addActionListener(this); slider.setMajorTickSpacing(10000); slider.setMinorTickSpacing(1000); slider.setPaintTicks(true); editpanel.setLayout(cards); editpanel.add(new JPanel(), "Empty"); editpanel.add(editcontent, "Edit"); editpanel.setBorder(BorderFactory.createEmptyBorder(0, 3, 0, 3)); cards.show(editpanel, "Empty"); // Construct GUI JLabel label = new JLabel("StrRef: "); label.setLabelFor(tstrref); label.setFont(label.getFont().deriveFont((float)label.getFont().getSize() + 2.0f)); JPanel topleftPanel = new JPanel(new FlowLayout()); topleftPanel.add(label); topleftPanel.add(tstrref); JPanel topPanel = new JPanel(new BorderLayout()); topPanel.add(topleftPanel, BorderLayout.WEST); topPanel.add(slider, BorderLayout.CENTER); JPanel centerleft = new JPanel(new BorderLayout(0, 6)); centerleft.add(table, BorderLayout.NORTH); centerleft.add(editpanel, BorderLayout.CENTER); centerleft.setBorder(BorderFactory.createLineBorder(UIManager.getColor("controlShadow"))); JPanel attributePanel = new JPanel(new BorderLayout()); attributePanel.add(new JLabel("Attributes:"), BorderLayout.NORTH); attributePanel.add(centerleft, BorderLayout.CENTER); JPanel textPanel = new JPanel(new BorderLayout()); textPanel.add(new JLabel("String:"), BorderLayout.NORTH); textPanel.add(new InfinityScrollPane(tatext, true), BorderLayout.CENTER); JPanel centerPanel = new JPanel(new GridLayout(1, 3, 6, 0)); centerPanel.add(attributePanel); centerPanel.add(textPanel); JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.CENTER)); buttonPanel.add(badd); buttonPanel.add(bdelete); buttonPanel.add(bfind); buttonPanel.add(bexport); buttonPanel.add(breread); buttonPanel.add(bsave); JPanel pane = (JPanel)getContentPane(); pane.setLayout(new BorderLayout(3, 3)); pane.add(topPanel, BorderLayout.NORTH); pane.add(centerPanel, BorderLayout.CENTER); pane.add(buttonPanel, BorderLayout.SOUTH); pane.setBorder(BorderFactory.createEmptyBorder(3, 6, 3, 6)); setSize(750, 500); Center.center(this, NearInfinity.getInstance().getBounds()); editor = this; new Thread(new StrRefReader()).start(); } // --------------------- Begin Interface ActionListener --------------------- @Override public void actionPerformed(ActionEvent event) { if (event.getSource() == tstrref) { try { int i = Integer.parseInt(tstrref.getText().trim()); if (i >= 0 && i < entries_count.getValue()) { showEntry(i); } else { JOptionPane.showMessageDialog(this, "Entry not found", "Error", JOptionPane.ERROR_MESSAGE); } } catch (NumberFormatException e) { JOptionPane.showMessageDialog(this, "Not a number", "Error", JOptionPane.ERROR_MESSAGE); } } else if (event.getSource() == bsave) { if (index_shown != -1) { updateEntry(index_shown); } new Thread(new StrRefWriter()).start(); } else if (event.getSource() == breread) { setVisible(false); new Thread(new StrRefReader()).start(); } else if (event.getActionCommand().equals(StructViewer.UPDATE_VALUE)) { if (!editable.updateValue(null)) { JOptionPane.showMessageDialog(this, "Error updating value", "Error", JOptionPane.ERROR_MESSAGE); } table.repaint(); } else if (event.getSource() == badd) { try { showEntry(addEntry(new StringEntry())); } catch (Exception e) { e.printStackTrace(); } } else if (event.getSource() == bdelete) { if (index_shown == entries_count.getValue() - 1) { deleteLastEntry(); } else { JOptionPane.showMessageDialog(this, "You can only delete the last entry", "Error", JOptionPane.ERROR_MESSAGE); } } else if (event.getSource() == bexport) { new Thread(new StrRefExporter()).start(); } } // --------------------- End Interface ActionListener --------------------- // --------------------- Begin Interface ChangeListener --------------------- @Override public void stateChanged(ChangeEvent event) { if (event.getSource() == slider) { if (!slider.getValueIsAdjusting() && slider.getValue() != index_shown) { showEntry(slider.getValue()); } } } // --------------------- End Interface ChangeListener --------------------- // --------------------- Begin Interface ItemListener --------------------- @Override public void itemStateChanged(ItemEvent event) { if (event.getSource() == bfind) { // JMenuItem item = (JMenuItem)event.getItem(); // Should have worked! JMenuItem item = bfind.getSelectedItem(); if (item == ifindstring) { SearchMaster.createAsFrame(this, "StringRef", this); } else if (item == ifindattribute) { SearchMaster.createAsFrame(new AttributeSearcher(table.getSelectedRow()), entries[0].getValueAt(table.getSelectedRow(), 0).toString(), this); } else if (item == ifindref) { new StringReferenceSearcher(index_shown, this); } } } // --------------------- End Interface ItemListener --------------------- // --------------------- Begin Interface ListSelectionListener --------------------- @Override public void valueChanged(ListSelectionEvent event) { if (event.getValueIsAdjusting()) return; ListSelectionModel lsm = (ListSelectionModel)event.getSource(); ifindattribute.setEnabled(!lsm.isSelectionEmpty()); if (lsm.isSelectionEmpty()) { tatext.setText(""); cards.show(editpanel, "Empty"); } else { Object selected = table.getModel().getValueAt(lsm.getMinSelectionIndex(), 1); if (selected instanceof Editable) { editable = (Editable)selected; editcontent.removeAll(); editcontent.setLayout(new BorderLayout()); editcontent.add(editable.edit(this), BorderLayout.CENTER); editcontent.revalidate(); editcontent.repaint(); cards.show(editpanel, "Edit"); editable.select(); } else if (selected instanceof InlineEditable) { cards.show(editpanel, "Empty"); } } } // --------------------- End Interface ListSelectionListener --------------------- // --------------------- Begin Interface SearchClient --------------------- @Override public String getText(int index) { if (index < 0 || index >= entries_count.getValue()) { return null; } if (index < entries.length) { return entries[index].string; } StringEntry entry = added_entries.get(index - entries.length); return entry.string; } @Override public void hitFound(int index) { showEntry(index); } // --------------------- End Interface SearchClient --------------------- public Path getPath() { return stringPath; } public void showEntry(int index) { if (index < 0) { return; } if (index_shown != -1) { updateEntry(index_shown); } StringEntry entry; if (index < entries.length) { entry = entries[index]; } else { entry = added_entries.get(index - entries.length); } entry.fillList(); tstrref.setText(String.valueOf(index)); index_shown = index; slider.setValue(index); table.setModel(entry); if (table.getColumnCount() == 3) { table.getColumnModel().getColumn(2).setPreferredWidth(6); } tatext.setText(entry.string); tatext.setCaretPosition(0); cards.show(editpanel, "Empty"); table.repaint(); editable = null; init_show = 0; } private int addEntry(StringEntry entry) { if (entries_count.getValue() < entries.length) { entries[entries_count.getValue()] = entry; } else { added_entries.add(entry); } entries_count.incValue(1); slider.setMaximum(entries_count.getValue() - 1); entries_offset.incValue(entry_size); return entries_count.getValue() - 1; } private void deleteLastEntry() { if (added_entries.size() > 0) { added_entries.remove(added_entries.size() - 1); } else { entries[entries_count.getValue() - 1] = null; } entries_count.incValue(-1); index_shown = -1; slider.setMaximum(entries_count.getValue() - 1); entries_offset.incValue(-entry_size); showEntry(entries_count.getValue() - 1); } private void updateEntry(int index) { if (index < entries.length) { entries[index].setString(tatext.getText()); } else { StringEntry entry = added_entries.get(index - entries.length); entry.setString(tatext.getText()); } } // -------------------------- INNER CLASSES -------------------------- // StrRefReader /////////////////////////////////////// private final class StrRefReader implements Runnable { private StrRefReader() { } @Override public void run() { Charset charset = StringResource.getCharset(); ProgressMonitor progress = null; try (InputStream is = StreamUtils.getInputStream(stringPath)) { signature = StreamUtils.readString(is, 4); version = StreamUtils.readString(is, 4); if (version.equals("V1 ")) { unknown = new Unknown(StreamUtils.readBytes(is, 2), 0, 2); } else { JOptionPane.showMessageDialog(NearInfinity.getInstance(), "Unsupported version: " + version, "Error", JOptionPane.ERROR_MESSAGE); throw new IOException(); } entries_count = new DecNumber(StreamUtils.readBytes(is, 4), 0, 4, "# entries"); entries_offset = new DecNumber(StreamUtils.readBytes(is, 4), 0, 4, "Entries offset"); entries = new StringEntry[entries_count.getValue()]; progress = new ProgressMonitor(NearInfinity.getInstance(), "Reading strings...", null, 0, 2 * entries_count.getValue()); progress.setMillisToDecideToPopup(100); for (int i = 0; i < entries_count.getValue(); i++) { entries[i] = new StringEntry(StreamUtils.readBytes(is, entry_size), charset); progress.setProgress(i + 1); if (progress.isCanceled()) { entries = null; return; } } } catch (Throwable e) { entries = null; e.printStackTrace(); JOptionPane.showMessageDialog(editor, "Error reading " + stringPath.getFileName() + '\n' + e.toString(), "Error", JOptionPane.ERROR_MESSAGE); return; } try (SeekableByteChannel ch = Files.newByteChannel(stringPath)) { ByteBuffer buffer = StreamUtils.getByteBuffer((int)ch.size()); if (ch.read(buffer) < ch.size()) { throw new IOException(); } buffer.position(0); for (int i = 0; i < entries.length; i++) { entries[i].readString(buffer, entries_offset.getValue()); progress.setProgress(i + 1 + entries_count.getValue()); if (progress.isCanceled()) { entries = null; return; } } slider.setMaximum(entries_count.getValue() - 1); slider.addChangeListener(editor); showEntry(init_show); setVisible(true); } catch (Throwable t) { progress.close(); entries = null; t.printStackTrace(); JOptionPane.showMessageDialog(editor, "Error reading " + stringPath.getFileName() + '\n' + t.getMessage(), "Error", JOptionPane.ERROR_MESSAGE); } } } // StrRefExporter /////////////////////////////////////// private final class StrRefExporter implements Runnable { private StrRefExporter() { } @Override public void run() { bexport.setEnabled(false); bsave.setEnabled(false); breread.setEnabled(false); badd.setEnabled(false); JFileChooser chooser = new JFileChooser(Profile.getGameRoot().toFile()); chooser.setDialogTitle("Export " + stringPath.getFileName()); chooser.setSelectedFile(new File(chooser.getCurrentDirectory(), "dialog.txt")); int returnval = chooser.showSaveDialog(editor); if (returnval == JFileChooser.APPROVE_OPTION) { Path output = chooser.getSelectedFile().toPath(); if (Files.exists(output)) { String options[] = {"Overwrite", "Cancel"}; int result = JOptionPane.showOptionDialog(editor, output + " exists. Overwrite?", "Save resource", JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE, null, options, options[0]); if (result == 1 || result == JOptionPane.CLOSED_OPTION) { bexport.setEnabled(true); bsave.setEnabled(true); breread.setEnabled(true); badd.setEnabled(true); return; } } try { ProgressMonitor progress = new ProgressMonitor(editor, "Writing file...", null, 0, entries_count.getValue()); progress.setMillisToDecideToPopup(100); try (BufferedWriter writer = Files.newBufferedWriter(output, StringResource.getCharset())) { for (int i = 0; i < entries.length; i++) { if (entries[i] != null) { writer.write(i + ":"); writer.newLine(); writer.write(entries[i].string); writer.newLine(); writer.newLine(); } progress.setProgress(i + 1); } for (int i = 0; i < added_entries.size(); i++) { StringEntry entry = added_entries.get(i); writer.write(i + entries.length + ":"); writer.newLine(); writer.write(entry.string); writer.newLine(); writer.newLine(); progress.setProgress(entries.length + i + 1); } } JOptionPane.showMessageDialog(editor, "File exported to " + output, "Export complete", JOptionPane.INFORMATION_MESSAGE); } catch (IOException e) { JOptionPane.showMessageDialog(editor, "Error writing " + output.getFileName(), "Error", JOptionPane.ERROR_MESSAGE); } } bexport.setEnabled(true); bsave.setEnabled(true); breread.setEnabled(true); badd.setEnabled(true); } } // StrRefWriter /////////////////////////////////////// private final class StrRefWriter implements Runnable { private StrRefWriter() { } @Override public void run() { Path outFile = stringPath; // Saving into DLC is not supported if (!FileManager.isDefaultFileSystem(outFile)) { boolean cancel = true; String msg = "\"" + outFile.toString() + "\" is located within a write-protected archive." + "\nDo you want to export it to another location instead?"; int result = JOptionPane.showConfirmDialog(editor, msg, "Save resource", JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE); if (result == JOptionPane.YES_OPTION) { outFile = Profile.getGameRoot().resolve(outFile.getFileName().toString()); JFileChooser fc = new JFileChooser(outFile.getParent().toFile()); fc.setSelectedFile(outFile.toFile()); int ret = fc.showSaveDialog(editor); if (ret == JFileChooser.APPROVE_OPTION) { outFile = fc.getSelectedFile().toPath(); cancel = false; } } if (cancel) { JOptionPane.showMessageDialog(editor, "Operation cancelled.", "Information", JOptionPane.INFORMATION_MESSAGE); return; } } bexport.setEnabled(false); bsave.setEnabled(false); breread.setEnabled(false); badd.setEnabled(false); if (Files.exists(outFile)) { String options[] = {"Overwrite", "Cancel"}; int result = JOptionPane.showOptionDialog(editor, outFile + " exists. Overwrite?", "Save resource", JOptionPane.YES_NO_OPTION, JOptionPane.WARNING_MESSAGE, null, options, options[0]); if (result == 1 || result == JOptionPane.CLOSED_OPTION) { bexport.setEnabled(true); bsave.setEnabled(true); breread.setEnabled(true); badd.setEnabled(true); return; } } StringResource.close(); ProgressMonitor progress = null; try (OutputStream os = StreamUtils.getOutputStream(outFile, true)) { StreamUtils.writeString(os, signature, 4); StreamUtils.writeString(os, version, 4); unknown.write(os); entries_count.write(os); entries_offset.write(os); int offset = 0; for (final StringEntry entry : entries) { if (entry != null) { offset += entry.update(offset); } } for (int i = 0; i < added_entries.size(); i++) { offset += added_entries.get(i).update(offset); } progress = new ProgressMonitor(editor, "Writing file...", null, 0, 2 * entries_count.getValue()); progress.setMillisToDecideToPopup(100); for (int i = 0; i < entries.length; i++) { if (entries[i] != null) { entries[i].write(os); } progress.setProgress(i + 1); } for (int i = 0; i < added_entries.size(); i++) { added_entries.get(i).write(os); progress.setProgress(entries.length + i + 1); } for (int i = 0; i < entries.length; i++) { if (entries[i] != null) { entries[i].writeString(os); } progress.setProgress(i + 1 + entries_count.getValue()); } for (int i = 0; i < added_entries.size(); i++) { added_entries.get(i).writeString(os); progress.setProgress(entries_count.getValue() + entries.length + i + 1); } } catch (IOException e) { JOptionPane.showMessageDialog(editor, "Error writing " + outFile.getFileName(), "Error", JOptionPane.ERROR_MESSAGE); return; } finally { if (progress != null) { progress.close(); progress = null; } } JOptionPane.showMessageDialog(editor, "File written successfully", "Save complete", JOptionPane.INFORMATION_MESSAGE); bsave.setEnabled(true); breread.setEnabled(true); bexport.setEnabled(true); badd.setEnabled(true); } } // StringEntry /////////////////////////////////////// private static final class StringEntry extends AbstractStruct { private int doffset, dlength; private String string = ""; private ByteBuffer buffer; private Charset charset; private StringEntry() throws Exception { super(null, null, StreamUtils.getByteBuffer(entry_size), 0); this.charset = StringResource.getCharset(); } StringEntry(ByteBuffer buffer, Charset charset) throws Exception { super(null, null, buffer, 0); this.charset = (charset != null) ? charset : StringResource.getCharset(); } @Override public int read(ByteBuffer buffer, int offset) throws Exception { this.buffer = StreamUtils.getByteBuffer(18); StreamUtils.copyBytes(buffer, offset, this.buffer, 0, this.buffer.limit()); doffset = buffer.getInt(offset + 0x12); dlength = buffer.getInt(offset + 0x16); return offset + entry_size; } public void fillList() { try { if (getFieldCount() == 0) { buffer.position(0); addField(new Flag(buffer, 0, 2, "Flags", s_flags)); addField(new ResourceRef(buffer, 2, "Associated sound", "WAV")); addField(new DecNumber(buffer, 10, 4, "Volume variance")); addField(new DecNumber(buffer, 14, 4, "Pitch variance")); buffer = null; } } catch (Exception e) { buffer = null; e.printStackTrace(); } } public void readString(ByteBuffer buffer, int baseoffset) throws IOException { string = StreamUtils.readString(buffer, baseoffset + doffset, dlength, charset); } public int update(int newoffset) { doffset = newoffset; dlength = string.getBytes(charset).length; return dlength; } @Override public void write(OutputStream os) throws IOException { // Update must be called first if (getFieldCount() == 0) { buffer.position(0); StreamUtils.writeBytes(os, buffer); } else { super.write(os); } StreamUtils.writeInt(os, doffset); StreamUtils.writeInt(os, dlength); } public void writeString(OutputStream os) throws IOException { StreamUtils.writeString(os, string, dlength, charset); } private void setString(String newstring) { string = newstring; } } // AttributeSearcher /////////////////////////////////////// private final class AttributeSearcher implements SearchClient { private final int selectedrow; private AttributeSearcher(int selectedrow) { this.selectedrow = selectedrow; } @Override public String getText(int index) { if (index < 0 || index >= entries_count.getValue()) return null; StringEntry entry; if (index < entries.length) entry = entries[index]; else entry = added_entries.get(index - entries.length); entry.fillList(); return entry.getField(selectedrow).toString(); } @Override public void hitFound(int index) { showEntry(index); } } }