From babfeba87ac7027758c9d4ec8e8c3ae46cbb4075 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Thu, 7 Apr 2022 12:30:48 +0200 Subject: [PATCH] Improved UI layout and navigation. --- .../browser/EmailDatasetBrowser.java | 20 ++- .../browser/control/DirectoryFileFilter.java | 19 +++ .../browser/control/DownloadEmailsAction.java | 23 +--- .../control/GenerateDatasetAction.java | 96 +++++++------- .../browser/control/PathSelectField.java | 86 +++++++++++++ .../browser/control/SwingUtils.java | 41 ++++++ .../browser/control/email/EmailAction.java | 24 ++++ .../browser/control/email/HideAction.java | 24 ++++ .../control/email/HideAllByAuthorAction.java | 36 ++++++ .../control/email/HideAllByBodyAction.java | 31 +++++ .../browser/control/email/ShowAction.java | 24 ++++ .../browser/email/EmailBodyPanel.java | 36 ++++++ .../browser/email/EmailInfoPanel.java | 19 ++- .../browser/email/EmailNavigationPanel.java | 97 ++++++++++++++ .../browser/email/EmailViewListener.java | 10 ++ .../browser/email/EmailViewPanel.java | 120 +++++------------- .../browser/email/RepliesPanel.java | 22 ++-- .../browser/email/TagListCellRenderer.java | 10 +- .../email_indexer/browser/email/TagPanel.java | 20 ++- .../email_indexer/data/EmailDataset.java | 31 +++-- .../email_indexer/data/EmailRepository.java | 112 ++++++++-------- .../email_indexer/data/util/DbUtils.java | 67 +++++++++- .../email_indexer/data/util/FileUtils.java | 21 +++ .../gen/EmailIndexGenerator.java | 49 ++++--- .../gen/FilterTransformEmailHandler.java | 31 ----- .../gen/SanitizingEmailHandler.java | 54 ++++++++ src/main/resources/sql/schema.sql | 22 +++- 27 files changed, 838 insertions(+), 307 deletions(-) create mode 100644 src/main/java/nl/andrewl/email_indexer/browser/control/DirectoryFileFilter.java create mode 100644 src/main/java/nl/andrewl/email_indexer/browser/control/PathSelectField.java create mode 100644 src/main/java/nl/andrewl/email_indexer/browser/control/SwingUtils.java create mode 100644 src/main/java/nl/andrewl/email_indexer/browser/control/email/EmailAction.java create mode 100644 src/main/java/nl/andrewl/email_indexer/browser/control/email/HideAction.java create mode 100644 src/main/java/nl/andrewl/email_indexer/browser/control/email/HideAllByAuthorAction.java create mode 100644 src/main/java/nl/andrewl/email_indexer/browser/control/email/HideAllByBodyAction.java create mode 100644 src/main/java/nl/andrewl/email_indexer/browser/control/email/ShowAction.java create mode 100644 src/main/java/nl/andrewl/email_indexer/browser/email/EmailBodyPanel.java create mode 100644 src/main/java/nl/andrewl/email_indexer/browser/email/EmailNavigationPanel.java create mode 100644 src/main/java/nl/andrewl/email_indexer/browser/email/EmailViewListener.java create mode 100644 src/main/java/nl/andrewl/email_indexer/data/util/FileUtils.java delete mode 100644 src/main/java/nl/andrewl/email_indexer/gen/FilterTransformEmailHandler.java create mode 100644 src/main/java/nl/andrewl/email_indexer/gen/SanitizingEmailHandler.java diff --git a/src/main/java/nl/andrewl/email_indexer/browser/EmailDatasetBrowser.java b/src/main/java/nl/andrewl/email_indexer/browser/EmailDatasetBrowser.java index e4432f5..f66b4c9 100644 --- a/src/main/java/nl/andrewl/email_indexer/browser/EmailDatasetBrowser.java +++ b/src/main/java/nl/andrewl/email_indexer/browser/EmailDatasetBrowser.java @@ -1,8 +1,10 @@ package nl.andrewl.email_indexer.browser; -import nl.andrewl.email_indexer.browser.control.DatasetOpenAction; -import nl.andrewl.email_indexer.browser.control.ExportDatasetAction; -import nl.andrewl.email_indexer.browser.control.GenerateDatasetAction; +import nl.andrewl.email_indexer.browser.control.*; +import nl.andrewl.email_indexer.browser.control.email.HideAction; +import nl.andrewl.email_indexer.browser.control.email.HideAllByAuthorAction; +import nl.andrewl.email_indexer.browser.control.email.HideAllByBodyAction; +import nl.andrewl.email_indexer.browser.control.email.ShowAction; import nl.andrewl.email_indexer.browser.email.EmailViewPanel; import nl.andrewl.email_indexer.data.EmailDataset; @@ -19,11 +21,10 @@ public class EmailDatasetBrowser extends JFrame { public EmailDatasetBrowser () { super("Email Dataset Browser"); this.setDefaultCloseOperation(EXIT_ON_CLOSE); - this.setJMenuBar(buildMenu()); - this.emailViewPanel = new EmailViewPanel(); this.searchPanel = new SearchPanel(emailViewPanel); this.setContentPane(buildUi()); + this.setJMenuBar(buildMenu()); this.setPreferredSize(new Dimension(1000, 600)); this.pack(); this.setLocationRelativeTo(null); @@ -66,9 +67,16 @@ private JMenuBar buildMenu() { fileMenu.add(new JMenuItem(new DatasetOpenAction(this))); fileMenu.add(new JMenuItem(new GenerateDatasetAction(this))); fileMenu.add(new JMenuItem(new ExportDatasetAction(this))); - menuBar.add(fileMenu); + JMenu filterMenu = new JMenu("Filter"); + filterMenu.add(new JMenuItem(new HideAction(emailViewPanel))); + filterMenu.add(new JMenuItem(new ShowAction(emailViewPanel))); + filterMenu.add(new JMenuItem(new HideAllByAuthorAction(emailViewPanel))); + filterMenu.add(new JMenuItem(new HideAllByBodyAction(emailViewPanel))); + + menuBar.add(filterMenu); + return menuBar; } diff --git a/src/main/java/nl/andrewl/email_indexer/browser/control/DirectoryFileFilter.java b/src/main/java/nl/andrewl/email_indexer/browser/control/DirectoryFileFilter.java new file mode 100644 index 0000000..e593712 --- /dev/null +++ b/src/main/java/nl/andrewl/email_indexer/browser/control/DirectoryFileFilter.java @@ -0,0 +1,19 @@ +package nl.andrewl.email_indexer.browser.control; + +import javax.swing.filechooser.FileFilter; +import java.io.File; + +/** + * A file filter that only allows users to select directories. + */ +public class DirectoryFileFilter extends FileFilter { + @Override + public boolean accept(File f) { + return f.isDirectory(); + } + + @Override + public String getDescription() { + return "Directories"; + } +} diff --git a/src/main/java/nl/andrewl/email_indexer/browser/control/DownloadEmailsAction.java b/src/main/java/nl/andrewl/email_indexer/browser/control/DownloadEmailsAction.java index d8771eb..440d6a4 100644 --- a/src/main/java/nl/andrewl/email_indexer/browser/control/DownloadEmailsAction.java +++ b/src/main/java/nl/andrewl/email_indexer/browser/control/DownloadEmailsAction.java @@ -26,19 +26,6 @@ public void actionPerformed(ActionEvent e) { inputPanel.setLayout(new BoxLayout(inputPanel, BoxLayout.PAGE_AXIS)); JTextField domainField = new JTextField(0); JTextField listField = new JTextField(0); - JTextField dirField = new JTextField(0); - dirField.setEditable(false); - JButton setDirButton = new JButton("Download to"); - setDirButton.addActionListener(event -> { - JFileChooser fc = new JFileChooser(Path.of(".").toFile()); - fc.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); - int result = fc.showOpenDialog(dialog); - if (result == JFileChooser.APPROVE_OPTION) { - Path dir = fc.getSelectedFile().toPath(); - dirField.setText(dir.toAbsolutePath().toString()); - } - }); - JPanel domainPanel = new JPanel(new BorderLayout()); domainPanel.add(new JLabel("Domain"), BorderLayout.WEST); domainPanel.add(domainField, BorderLayout.CENTER); @@ -47,10 +34,8 @@ public void actionPerformed(ActionEvent e) { listPanel.add(new JLabel("List"), BorderLayout.WEST); listPanel.add(listField, BorderLayout.CENTER); inputPanel.add(listPanel); - JPanel dirPanel = new JPanel(new BorderLayout()); - dirPanel.add(dirField, BorderLayout.CENTER); - dirPanel.add(setDirButton, BorderLayout.EAST); - inputPanel.add(dirPanel); + PathSelectField dirField = PathSelectField.directorySelectField(); + inputPanel.add(dirField); p.add(inputPanel, BorderLayout.CENTER); @@ -60,10 +45,10 @@ public void actionPerformed(ActionEvent e) { JButton downloadButton = new JButton("Download"); downloadButton.addActionListener(event -> { ApacheMailingListDownloader downloader = new ApacheMailingListDownloader(domainField.getText(), listField.getText()); - Path outputDir = Path.of(dirField.getText()); + Path outputDir = dirField.getSelectedPath(); domainField.setEditable(false); listField.setEditable(false); - setDirButton.setEnabled(false); + dirField.setEnabled(false); downloadButton.setEnabled(false); cancelButton.setEnabled(false); dialog.setTitle("Downloading..."); diff --git a/src/main/java/nl/andrewl/email_indexer/browser/control/GenerateDatasetAction.java b/src/main/java/nl/andrewl/email_indexer/browser/control/GenerateDatasetAction.java index 362299d..4a97173 100644 --- a/src/main/java/nl/andrewl/email_indexer/browser/control/GenerateDatasetAction.java +++ b/src/main/java/nl/andrewl/email_indexer/browser/control/GenerateDatasetAction.java @@ -1,6 +1,7 @@ package nl.andrewl.email_indexer.browser.control; import nl.andrewl.email_indexer.browser.EmailDatasetBrowser; +import nl.andrewl.email_indexer.data.util.FileUtils; import nl.andrewl.email_indexer.gen.EmailDatasetGenerator; import javax.swing.*; @@ -33,23 +34,8 @@ public void actionPerformed(ActionEvent e) { inputPanel.add(items.getKey()); JList mboxDirsList = items.getValue(); - JPanel datasetDirPanel = new JPanel(new BorderLayout()); - JTextField datasetDirField = new JTextField(0); - datasetDirField.setEditable(false); - JButton datasetDirButton = new JButton("Generate to"); - datasetDirButton.addActionListener(event -> { - JFileChooser fc = new JFileChooser(Path.of(".").toFile()); - fc.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); - int result = fc.showSaveDialog(browser); - if (result == JFileChooser.APPROVE_OPTION) { - Path dsDir = fc.getSelectedFile().toPath(); - datasetDirField.setText(dsDir.toAbsolutePath().toString()); - } - }); - datasetDirPanel.add(datasetDirButton, BorderLayout.EAST); - datasetDirPanel.add(datasetDirField, BorderLayout.CENTER); - inputPanel.add(datasetDirPanel); - + PathSelectField datasetDirField = PathSelectField.directorySelectField(); + inputPanel.add(datasetDirField); p.add(inputPanel, BorderLayout.CENTER); JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.RIGHT)); @@ -57,39 +43,30 @@ public void actionPerformed(ActionEvent e) { cancelButton.addActionListener(event -> dialog.dispose()); JButton generateButton = new JButton("Generate"); generateButton.addActionListener(event -> { - int size = mboxDirsList.getModel().getSize(); - if (size < 1) { + if (!validateDialog(dialog, mboxDirsList, datasetDirField)) return; + Collection paths = new ArrayList<>(); + for (int i = 0; i < mboxDirsList.getModel().getSize(); i++) { + paths.add(mboxDirsList.getModel().getElementAt(i)); + } + Path dsDir = datasetDirField.getSelectedPath(); + SwingUtils.setAllButtonsEnabled(dialog, false); + datasetDirField.setEnabled(false); + dialog.setTitle("Generating..."); + try { + new EmailDatasetGenerator().generate(paths, dsDir) + .thenRun(() -> { + JOptionPane.showMessageDialog(dialog, "Dataset generated!"); + dialog.dispose(); + }); + } catch (Exception ex) { + ex.printStackTrace(); JOptionPane.showMessageDialog( dialog, - "No MBox directories have been added.", - "No MBox Directories", - JOptionPane.WARNING_MESSAGE + "An error occurred while generating the dataset:\n" + ex.getMessage(), + "Error", + JOptionPane.ERROR_MESSAGE ); - } else { - Collection paths = new ArrayList<>(); - for (int i = 0; i < size; i++) { - paths.add(mboxDirsList.getModel().getElementAt(i)); - } - Path dsDir = Path.of(datasetDirField.getText()); - generateButton.setEnabled(false); - cancelButton.setEnabled(false); - dialog.setTitle("Generating..."); - try { - new EmailDatasetGenerator().generate(paths, dsDir) - .thenRun(() -> { - JOptionPane.showMessageDialog(dialog, "Dataset generated!"); - dialog.dispose(); - }); - } catch (Exception ex) { - ex.printStackTrace(); - JOptionPane.showMessageDialog( - dialog, - "An error occurred while generating the dataset:\n" + ex.getMessage(), - "Error", - JOptionPane.ERROR_MESSAGE - ); - dialog.dispose(); - } + dialog.dispose(); } }); buttonPanel.add(generateButton); @@ -112,7 +89,9 @@ private Map.Entry> buildMBoxDirsPanel(JDialog owner) { JButton addMboxDirButton = new JButton("Add Mbox Directory"); addMboxDirButton.addActionListener(event -> { JFileChooser fc = new JFileChooser(Path.of(".").toFile()); + fc.setFileFilter(new DirectoryFileFilter()); fc.setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY); + fc.setAcceptAllFileFilterUsed(false); int result = fc.showOpenDialog(browser); if (result == JFileChooser.APPROVE_OPTION) { Path mboxDir = fc.getSelectedFile().toPath(); @@ -149,4 +128,27 @@ private Map.Entry> buildMBoxDirsPanel(JDialog owner) { return new AbstractMap.SimpleEntry<>(mboxDirsPanel, mboxDirsList); } + + private boolean validateDialog(JDialog dialog, JList mboxDirsList, PathSelectField datasetDirField) { + if (mboxDirsList.getModel().getSize() < 1) { + JOptionPane.showMessageDialog( + dialog, + "No MBox directories have been added.", + "No MBox Directories", + JOptionPane.WARNING_MESSAGE + ); + return false; + } + Path datasetDir = datasetDirField.getSelectedPath(); + if (datasetDir == null || !Files.isDirectory(datasetDir) || !FileUtils.isDirEmpty(datasetDir)) { + JOptionPane.showMessageDialog( + dialog, + "You must select an empty directory to generate the dataset in.", + "Invalid Dataset Directory", + JOptionPane.WARNING_MESSAGE + ); + return false; + } + return true; + } } diff --git a/src/main/java/nl/andrewl/email_indexer/browser/control/PathSelectField.java b/src/main/java/nl/andrewl/email_indexer/browser/control/PathSelectField.java new file mode 100644 index 0000000..5617033 --- /dev/null +++ b/src/main/java/nl/andrewl/email_indexer/browser/control/PathSelectField.java @@ -0,0 +1,86 @@ +package nl.andrewl.email_indexer.browser.control; + +import javax.swing.*; +import javax.swing.event.MouseInputAdapter; +import javax.swing.filechooser.FileFilter; +import javax.swing.filechooser.FileNameExtensionFilter; +import java.awt.*; +import java.awt.event.MouseEvent; +import java.awt.event.MouseListener; +import java.nio.file.Path; + +public class PathSelectField extends JPanel { + private final JTextField pathField; + private final JButton selectPathButton; + private final MouseListener pathFieldMouseListener; + + private final int fileSelectionMode; + private final boolean acceptAll; + private final FileFilter fileFilter; + private Path selectedPath = null; + + public PathSelectField(int fileSelectionMode, boolean acceptAll, FileFilter filter) { + super(new BorderLayout()); + this.fileSelectionMode = fileSelectionMode; + this.acceptAll = acceptAll; + this.fileFilter = filter; + + pathField = new JTextField(0); + pathField.setMinimumSize(new Dimension(100, 30)); + pathField.setEditable(false); + add(pathField, BorderLayout.CENTER); + + selectPathButton = new JButton("Select file..."); + selectPathButton.setMinimumSize(new Dimension(50, 30)); + add(selectPathButton, BorderLayout.EAST); + + selectPathButton.addActionListener(e -> selectFile()); + pathFieldMouseListener = new MouseInputAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2 && e.getButton() == 1) { + selectFile(); + } + } + }; + pathField.addMouseListener(pathFieldMouseListener); + } + + public static PathSelectField directorySelectField() { + return new PathSelectField(JFileChooser.DIRECTORIES_ONLY, false, new DirectoryFileFilter()); + } + + public static PathSelectField fileTypeSelectField(String extension, String name) { + return new PathSelectField(JFileChooser.FILES_ONLY, false, new FileNameExtensionFilter(name, extension)); + } + + public void setEnabled(boolean enabled) { + selectPathButton.setEnabled(enabled); + if (enabled) { + pathField.addMouseListener(pathFieldMouseListener); + } else { + pathField.removeMouseListener(pathFieldMouseListener); + } + } + + private void selectFile() { + JFileChooser fc = new JFileChooser(selectedPath == null ? Path.of(".").toFile() : selectedPath.toFile()); + fc.setFileSelectionMode(fileSelectionMode); + fc.setFileFilter(fileFilter); + fc.setAcceptAllFileFilterUsed(acceptAll); + int result = fc.showDialog(this, "Select"); + if (result == JFileChooser.APPROVE_OPTION) { + setSelectPath(fc.getSelectedFile().toPath()); + } + } + + public void setSelectPath(Path p) { + selectedPath = p; + String text = p == null ? null : p.toAbsolutePath().toString(); + pathField.setText(text); + } + + public Path getSelectedPath() { + return selectedPath; + } +} diff --git a/src/main/java/nl/andrewl/email_indexer/browser/control/SwingUtils.java b/src/main/java/nl/andrewl/email_indexer/browser/control/SwingUtils.java new file mode 100644 index 0000000..000ac13 --- /dev/null +++ b/src/main/java/nl/andrewl/email_indexer/browser/control/SwingUtils.java @@ -0,0 +1,41 @@ +package nl.andrewl.email_indexer.browser.control; + +import javax.swing.*; +import java.awt.*; +import java.util.Random; + +public final class SwingUtils { + public static final Random random = new Random(); + private SwingUtils() {} + + public static void setAllButtonsEnabled(Container c, boolean enabled) { + for (var component : c.getComponents()) { + if (component instanceof JButton button) { + button.setEnabled(enabled); + } else if (component instanceof Container nested) { + setAllButtonsEnabled(nested, enabled); + } + } + } + + @SuppressWarnings("unchecked") + public static T findFirstInstance(Container c, Class type) { + for (var component: c.getComponents()) { + if (component.getClass().equals(type)) { + return (T) component; + } else if (component instanceof Container nested) { + T result = findFirstInstance(nested, type); + if (result != null) return result; + } + } + return null; + } + + public static Color getColor(String text) { + random.setSeed(text.hashCode()); + float hue = random.nextFloat(); + float saturation = random.nextFloat() / 4f + 0.75f; + float luminance = 0.9f; + return Color.getHSBColor(hue, saturation, luminance); + } +} diff --git a/src/main/java/nl/andrewl/email_indexer/browser/control/email/EmailAction.java b/src/main/java/nl/andrewl/email_indexer/browser/control/email/EmailAction.java new file mode 100644 index 0000000..9e6ed74 --- /dev/null +++ b/src/main/java/nl/andrewl/email_indexer/browser/control/email/EmailAction.java @@ -0,0 +1,24 @@ +package nl.andrewl.email_indexer.browser.control.email; + +import nl.andrewl.email_indexer.browser.email.EmailViewListener; +import nl.andrewl.email_indexer.browser.email.EmailViewPanel; +import nl.andrewl.email_indexer.data.EmailEntry; + +import javax.swing.*; + +public abstract class EmailAction extends AbstractAction implements EmailViewListener { + protected final EmailViewPanel emailViewPanel; + + protected EmailAction(String name, EmailViewPanel emailViewPanel) { + super(name); + this.emailViewPanel = emailViewPanel; + emailViewPanel.addListener(this); + } + + @Override + public void emailUpdated(EmailEntry email) { + setEnabled(email != null && shouldBeEnabled(email)); + } + + protected abstract boolean shouldBeEnabled(EmailEntry email); +} diff --git a/src/main/java/nl/andrewl/email_indexer/browser/control/email/HideAction.java b/src/main/java/nl/andrewl/email_indexer/browser/control/email/HideAction.java new file mode 100644 index 0000000..5aaecf8 --- /dev/null +++ b/src/main/java/nl/andrewl/email_indexer/browser/control/email/HideAction.java @@ -0,0 +1,24 @@ +package nl.andrewl.email_indexer.browser.control.email; + +import nl.andrewl.email_indexer.browser.email.EmailViewPanel; +import nl.andrewl.email_indexer.data.EmailEntry; +import nl.andrewl.email_indexer.data.EmailRepository; + +import java.awt.event.ActionEvent; + +public class HideAction extends EmailAction { + public HideAction(EmailViewPanel emailViewPanel) { + super("Hide", emailViewPanel); + } + + @Override + public void actionPerformed(ActionEvent e) { + new EmailRepository(emailViewPanel.getCurrentDataset()).hideEmail(emailViewPanel.getEmail().messageId()); + emailViewPanel.refresh(); + } + + @Override + protected boolean shouldBeEnabled(EmailEntry email) { + return !email.hidden(); + } +} diff --git a/src/main/java/nl/andrewl/email_indexer/browser/control/email/HideAllByAuthorAction.java b/src/main/java/nl/andrewl/email_indexer/browser/control/email/HideAllByAuthorAction.java new file mode 100644 index 0000000..893ebd8 --- /dev/null +++ b/src/main/java/nl/andrewl/email_indexer/browser/control/email/HideAllByAuthorAction.java @@ -0,0 +1,36 @@ +package nl.andrewl.email_indexer.browser.control.email; + +import nl.andrewl.email_indexer.browser.email.EmailViewPanel; +import nl.andrewl.email_indexer.data.EmailEntry; +import nl.andrewl.email_indexer.data.EmailRepository; + +import javax.swing.*; +import java.awt.event.ActionEvent; + +/** + * An action which hides the current author by their name. + */ +public class HideAllByAuthorAction extends EmailAction { + public HideAllByAuthorAction(EmailViewPanel emailViewPanel) { + super("Hide by Author", emailViewPanel); + } + + @Override + public void actionPerformed(ActionEvent e) { + var email = emailViewPanel.getEmail(); + String emailAddress = email.sentFrom().substring(email.sentFrom().lastIndexOf('<') + 1, email.sentFrom().length() - 1); + long count = new EmailRepository(emailViewPanel.getCurrentDataset()) + .hideAllEmailsBySentFrom('%' + emailAddress + '%'); + JOptionPane.showMessageDialog( + emailViewPanel, + "Hid %d emails.".formatted(count), + "Hid Emails", + JOptionPane.INFORMATION_MESSAGE + ); + } + + @Override + protected boolean shouldBeEnabled(EmailEntry email) { + return true; + } +} diff --git a/src/main/java/nl/andrewl/email_indexer/browser/control/email/HideAllByBodyAction.java b/src/main/java/nl/andrewl/email_indexer/browser/control/email/HideAllByBodyAction.java new file mode 100644 index 0000000..6675099 --- /dev/null +++ b/src/main/java/nl/andrewl/email_indexer/browser/control/email/HideAllByBodyAction.java @@ -0,0 +1,31 @@ +package nl.andrewl.email_indexer.browser.control.email; + +import nl.andrewl.email_indexer.browser.email.EmailViewPanel; +import nl.andrewl.email_indexer.data.EmailEntry; +import nl.andrewl.email_indexer.data.EmailRepository; + +import javax.swing.*; +import java.awt.event.ActionEvent; + +public class HideAllByBodyAction extends EmailAction { + public HideAllByBodyAction(EmailViewPanel emailViewPanel) { + super("Hide all by body", emailViewPanel); + } + + @Override + protected boolean shouldBeEnabled(EmailEntry email) { + return true; + } + + @Override + public void actionPerformed(ActionEvent e) { + long count = new EmailRepository(emailViewPanel.getCurrentDataset()) + .hideAllEmailsByBody(emailViewPanel.getEmail().body()); + JOptionPane.showMessageDialog( + emailViewPanel, + "Hid %d emails.".formatted(count), + "Hid Emails", + JOptionPane.INFORMATION_MESSAGE + ); + } +} diff --git a/src/main/java/nl/andrewl/email_indexer/browser/control/email/ShowAction.java b/src/main/java/nl/andrewl/email_indexer/browser/control/email/ShowAction.java new file mode 100644 index 0000000..3869e21 --- /dev/null +++ b/src/main/java/nl/andrewl/email_indexer/browser/control/email/ShowAction.java @@ -0,0 +1,24 @@ +package nl.andrewl.email_indexer.browser.control.email; + +import nl.andrewl.email_indexer.browser.email.EmailViewPanel; +import nl.andrewl.email_indexer.data.EmailEntry; +import nl.andrewl.email_indexer.data.EmailRepository; + +import java.awt.event.ActionEvent; + +public class ShowAction extends EmailAction { + public ShowAction(EmailViewPanel emailViewPanel) { + super("Show", emailViewPanel); + } + + @Override + protected boolean shouldBeEnabled(EmailEntry email) { + return email.hidden(); + } + + @Override + public void actionPerformed(ActionEvent e) { + new EmailRepository(emailViewPanel.getCurrentDataset()).showEmail(emailViewPanel.getEmail().messageId()); + emailViewPanel.refresh(); + } +} diff --git a/src/main/java/nl/andrewl/email_indexer/browser/email/EmailBodyPanel.java b/src/main/java/nl/andrewl/email_indexer/browser/email/EmailBodyPanel.java new file mode 100644 index 0000000..baa4512 --- /dev/null +++ b/src/main/java/nl/andrewl/email_indexer/browser/email/EmailBodyPanel.java @@ -0,0 +1,36 @@ +package nl.andrewl.email_indexer.browser.email; + +import nl.andrewl.email_indexer.data.EmailEntry; + +import javax.swing.*; +import java.awt.*; + +/** + * A panel containing some basic components for viewing the body of an email. + */ +public class EmailBodyPanel extends JPanel implements EmailViewListener { + private final JTextPane textPane = new JTextPane(); + + public EmailBodyPanel() { + super(new BorderLayout()); + textPane.setEditable(false); + textPane.setFont(new Font("monospaced", textPane.getFont().getStyle(), 16)); + textPane.setBackground(textPane.getBackground().darker()); + JScrollPane scrollPane = new JScrollPane(textPane); + add(scrollPane, BorderLayout.CENTER); + } + + private void setEmail(EmailEntry email) { + if (email != null) { + textPane.setText(email.body()); + textPane.setCaretPosition(0); + } else { + textPane.setText(null); + } + } + + @Override + public void emailUpdated(EmailEntry email) { + setEmail(email); + } +} diff --git a/src/main/java/nl/andrewl/email_indexer/browser/email/EmailInfoPanel.java b/src/main/java/nl/andrewl/email_indexer/browser/email/EmailInfoPanel.java index 51d7283..47143de 100644 --- a/src/main/java/nl/andrewl/email_indexer/browser/email/EmailInfoPanel.java +++ b/src/main/java/nl/andrewl/email_indexer/browser/email/EmailInfoPanel.java @@ -9,7 +9,11 @@ import java.time.format.DateTimeFormatter; import java.util.Optional; -public class EmailInfoPanel extends JPanel { +/** + * A panel that shows some auxiliary details about an email, aside from the main + * body of the email. + */ +public class EmailInfoPanel extends JPanel implements EmailViewListener { private final EmailViewPanel parent; private final JLabel messageIdLabel = new JLabel(); @@ -26,9 +30,14 @@ public EmailInfoPanel(EmailViewPanel parent) { this.parent = parent; this.tagPanel = new TagPanel(parent); this.tagPanel.setPreferredSize(new Dimension(-1, 200)); + parent.addListener(tagPanel); this.repliesPanel = new RepliesPanel(parent); this.repliesPanel.setPreferredSize(new Dimension(-1, 200)); + parent.addListener(repliesPanel); + buildUI(); + } + private void buildUI() { GridBagConstraints labelConstraint = new GridBagConstraints(); labelConstraint.anchor = GridBagConstraints.FIRST_LINE_START; labelConstraint.weightx = 0.01; @@ -100,7 +109,11 @@ public void setEmail(EmailEntry email) { } else { this.inReplyToButton.setEnabled(false); } - this.tagPanel.setEmail(email); - this.repliesPanel.setEmail(email); + } + + @Override + public void emailUpdated(EmailEntry email) { + setEmail(email); + setVisible(email != null); } } diff --git a/src/main/java/nl/andrewl/email_indexer/browser/email/EmailNavigationPanel.java b/src/main/java/nl/andrewl/email_indexer/browser/email/EmailNavigationPanel.java new file mode 100644 index 0000000..ec99108 --- /dev/null +++ b/src/main/java/nl/andrewl/email_indexer/browser/email/EmailNavigationPanel.java @@ -0,0 +1,97 @@ +package nl.andrewl.email_indexer.browser.email; + +import nl.andrewl.email_indexer.browser.control.SwingUtils; +import nl.andrewl.email_indexer.data.EmailEntry; + +import javax.swing.*; +import java.awt.*; +import java.util.Stack; + +/** + * A panel that provides navigation components so that users can traverse back + * through the path of emails they've read. + */ +public class EmailNavigationPanel extends JPanel { + private final EmailViewPanel emailViewPanel; + private final Stack navigationStack = new Stack<>(); + private final JPanel breadcrumbPanel; + private final JScrollPane breadcrumbScrollPane; + private final JButton backButton = new JButton("Back"); + + public EmailNavigationPanel(EmailViewPanel emailViewPanel) { + super(new BorderLayout()); + this.emailViewPanel = emailViewPanel; + this.breadcrumbPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + breadcrumbScrollPane = new JScrollPane(breadcrumbPanel, ScrollPaneConstants.VERTICAL_SCROLLBAR_NEVER, ScrollPaneConstants.HORIZONTAL_SCROLLBAR_ALWAYS); + add(breadcrumbScrollPane, BorderLayout.CENTER); + + JPanel buttonPanel = new JPanel(new FlowLayout(FlowLayout.LEFT)); + backButton.addActionListener(e -> { + navigateBack(); + emailViewPanel.fetchAndSetEmail(navigationStack.peek().messageId()); + }); + buttonPanel.add(backButton); + add(buttonPanel, BorderLayout.WEST); + } + + private void update() { + SwingUtilities.invokeLater(() -> { + updateButtons(); + updateBreadcrumbPanel(); + }); + } + + public void navigateTo(EmailEntry email) { + int idx = -1; + for (int i = 0; i < navigationStack.size(); i++) { + var e = navigationStack.get(i); + if (e.equals(email)) { + idx = i; + } + } + if (idx != -1) { + for (int i = navigationStack.size() - 1; i >= idx; i--) { + navigationStack.pop(); + } + } + + navigationStack.push(email); + update(); + } + + public void navigateBack() { + navigationStack.pop(); + update(); + } + + public void clear() { + navigationStack.clear(); + update(); + } + + private void updateButtons() { + backButton.setEnabled(navigationStack.size() > 1); + } + + private void updateBreadcrumbPanel() { + breadcrumbPanel.removeAll(); + for (int i = 0; i < navigationStack.size(); i++) { + var email = navigationStack.get(i); + JButton button = new JButton(email.subject().substring(0, Math.min(email.subject().length(), 24))); + button.setToolTipText("Subject: %s\nFrom: %s\nId: %s".formatted(email.subject(), email.sentFrom(), email.messageId())); + button.setForeground(SwingUtils.getColor(email.messageId())); + final int idx = i; + button.addActionListener(e -> { + for (int k = navigationStack.size() - 1; k > idx; k--) navigationStack.pop(); + emailViewPanel.fetchAndSetEmail(email.messageId()); + update(); + }); + breadcrumbPanel.add(button); + if (i < navigationStack.size() - 1) { + breadcrumbPanel.add(new JLabel(" > ")); + } + } + breadcrumbPanel.repaint(); + breadcrumbScrollPane.revalidate(); + } +} diff --git a/src/main/java/nl/andrewl/email_indexer/browser/email/EmailViewListener.java b/src/main/java/nl/andrewl/email_indexer/browser/email/EmailViewListener.java new file mode 100644 index 0000000..a96f319 --- /dev/null +++ b/src/main/java/nl/andrewl/email_indexer/browser/email/EmailViewListener.java @@ -0,0 +1,10 @@ +package nl.andrewl.email_indexer.browser.email; + +import nl.andrewl.email_indexer.data.EmailEntry; + +/** + * Listener for when the email a user is viewing is updated. + */ +public interface EmailViewListener { + void emailUpdated(EmailEntry email); +} diff --git a/src/main/java/nl/andrewl/email_indexer/browser/email/EmailViewPanel.java b/src/main/java/nl/andrewl/email_indexer/browser/email/EmailViewPanel.java index 46feb38..2794037 100644 --- a/src/main/java/nl/andrewl/email_indexer/browser/email/EmailViewPanel.java +++ b/src/main/java/nl/andrewl/email_indexer/browser/email/EmailViewPanel.java @@ -6,91 +6,60 @@ import javax.swing.*; import java.awt.*; -import java.util.Stack; +import java.util.HashSet; +import java.util.Set; +/** + * A panel that displays all information about an email. This is the main user + * interface for interacting with a specific email. + */ public class EmailViewPanel extends JPanel { private EmailDataset currentDataset = null; private EmailEntry email; - private final Stack navigationStack = new Stack<>(); - - private final JTextPane emailTextPane; - private final EmailInfoPanel infoPanel; - private final JButton backButton; - private final JButton removeSimilarButton; - private final JButton removeAuthorButton; + private final EmailNavigationPanel navigationPanel; + private final Set listeners = new HashSet<>(); public EmailViewPanel() { this.setLayout(new BorderLayout()); - this.emailTextPane = new JTextPane(); - this.emailTextPane.setEditable(false); - this.emailTextPane.setFont(new Font("monospaced", emailTextPane.getFont().getStyle(), 16)); - this.emailTextPane.setBackground(this.emailTextPane.getBackground().darker()); - JScrollPane emailScrollPane = new JScrollPane(this.emailTextPane); - this.add(emailScrollPane, BorderLayout.CENTER); - - this.infoPanel = new EmailInfoPanel(this); - this.infoPanel.setPreferredSize(new Dimension(400, -1)); - this.add(this.infoPanel, BorderLayout.EAST); - - JPanel navbar = new JPanel(new FlowLayout(FlowLayout.RIGHT)); - backButton = new JButton("Back"); - backButton.addActionListener(e -> { - navigateBack(); - }); - removeSimilarButton = new JButton("Hide all with same body"); - removeSimilarButton.addActionListener(e -> { - long removed = new EmailRepository(this.currentDataset).hideAllEmailsByBody(this.email.body()); - JOptionPane.showMessageDialog( - this, - "Removed %d emails.".formatted(removed), - "Removed Emails", - JOptionPane.INFORMATION_MESSAGE - ); - }); - removeAuthorButton = new JButton("Hide all sent by this author"); - removeAuthorButton.addActionListener(e -> { - String emailAddress = email.sentFrom().substring(email.sentFrom().lastIndexOf('<') + 1, email.sentFrom().length() - 1); - long removed = new EmailRepository(this.currentDataset).hideAllEmailsBySentFrom('%' + emailAddress + '%'); - JOptionPane.showMessageDialog( - this, - "Removed %d emails.".formatted(removed), - "Removed Emails", - JOptionPane.INFORMATION_MESSAGE - ); - }); - navbar.add(removeSimilarButton); - navbar.add(removeAuthorButton); - navbar.add(backButton); - this.add(navbar, BorderLayout.NORTH); + EmailBodyPanel bodyPanel = new EmailBodyPanel(); + this.add(bodyPanel, BorderLayout.CENTER); + addListener(bodyPanel); + + EmailInfoPanel infoPanel = new EmailInfoPanel(this); + infoPanel.setPreferredSize(new Dimension(400, -1)); + this.add(infoPanel, BorderLayout.EAST); + addListener(infoPanel); + + this.navigationPanel = new EmailNavigationPanel(this); + this.add(navigationPanel, BorderLayout.NORTH); setEmail(null); } + public void addListener(EmailViewListener listener) { + this.listeners.add(listener); + } + public void setDataset(EmailDataset dataset) { this.currentDataset = dataset; setEmail(null); - clearNavigation(); + navigationPanel.clear(); } public EmailDataset getCurrentDataset() { return this.currentDataset; } - private void setEmail(EmailEntry email) { + public EmailEntry getEmail() { + return email; + } + + public void setEmail(EmailEntry email) { this.email = email; - if (email != null) { - this.emailTextPane.setText(email.body()); - this.emailTextPane.setCaretPosition(0); - } else { - this.emailTextPane.setText(null); - } - this.infoPanel.setEmail(email); - this.infoPanel.setVisible(email != null); - removeSimilarButton.setEnabled(email != null); - removeAuthorButton.setEnabled(email != null); + listeners.forEach(l -> l.emailUpdated(email)); } - private void fetchAndSetEmail(String messageId) { + public void fetchAndSetEmail(String messageId) { if (this.currentDataset != null) { new EmailRepository(currentDataset).findEmailById(messageId) .ifPresentOrElse(this::setEmail, () -> setEmail(null)); @@ -101,34 +70,15 @@ private void fetchAndSetEmail(String messageId) { public void navigateTo(String messageId) { fetchAndSetEmail(messageId); - navigationStack.push(messageId); - backButton.setEnabled(true); - } - - public void navigateTo(EmailEntry email) { - setEmail(email); - navigationStack.push(email.messageId()); - backButton.setEnabled(true); - } - - public void navigateBack() { - if (navigationStack.size() > 1) { - navigationStack.pop(); - fetchAndSetEmail(navigationStack.peek()); + if (email != null) { + navigationPanel.navigateTo(email); } - backButton.setEnabled(navigationStack.size() > 1); } public void startNavigate(EmailEntry email) { + navigationPanel.clear(); setEmail(email); - navigationStack.clear(); - navigationStack.push(email.messageId()); - backButton.setEnabled(false); - } - - public void clearNavigation() { - navigationStack.clear(); - backButton.setEnabled(false); + navigationPanel.navigateTo(email); } public void refresh() { diff --git a/src/main/java/nl/andrewl/email_indexer/browser/email/RepliesPanel.java b/src/main/java/nl/andrewl/email_indexer/browser/email/RepliesPanel.java index 4dce7b8..7af3c79 100644 --- a/src/main/java/nl/andrewl/email_indexer/browser/email/RepliesPanel.java +++ b/src/main/java/nl/andrewl/email_indexer/browser/email/RepliesPanel.java @@ -8,7 +8,7 @@ import java.util.ArrayList; import java.util.List; -public class RepliesPanel extends JPanel { +public class RepliesPanel extends JPanel implements EmailViewListener { private final EmailViewPanel parent; private final JPanel buttonPanel; @@ -16,11 +16,11 @@ public class RepliesPanel extends JPanel { public RepliesPanel(EmailViewPanel parent) { super(new BorderLayout()); this.parent = parent; + this.setBorder(BorderFactory.createTitledBorder("Replies")); this.buttonPanel = new JPanel(); this.buttonPanel.setLayout(new BoxLayout(this.buttonPanel, BoxLayout.PAGE_AXIS)); JScrollPane scrollPane = new JScrollPane(buttonPanel); this.add(scrollPane, BorderLayout.CENTER); - this.add(new JLabel("Replies"), BorderLayout.NORTH); } public void setEmail(EmailEntry email) { @@ -30,14 +30,20 @@ public void setEmail(EmailEntry email) { var replies = repo.findAllReplies(email.messageId()); for (var reply : replies) { JButton button = new JButton("%s
by %s".formatted(reply.subject(), reply.sentFrom())); - button.addActionListener(e -> { - SwingUtilities.invokeLater(() -> parent.navigateTo(reply.messageId())); - }); + button.addActionListener(e -> SwingUtilities.invokeLater(() -> parent.navigateTo(reply.messageId()))); buttonsToAdd.add(button); } } - buttonPanel.removeAll(); - buttonsToAdd.forEach(buttonPanel::add); - buttonPanel.repaint(); + SwingUtilities.invokeLater(() -> { + buttonPanel.removeAll(); + buttonsToAdd.forEach(buttonPanel::add); + System.out.println(buttonPanel.getComponents().length); + buttonPanel.repaint(); + }); + } + + @Override + public void emailUpdated(EmailEntry email) { + setEmail(email); } } diff --git a/src/main/java/nl/andrewl/email_indexer/browser/email/TagListCellRenderer.java b/src/main/java/nl/andrewl/email_indexer/browser/email/TagListCellRenderer.java index 5e3b01d..543b4f1 100644 --- a/src/main/java/nl/andrewl/email_indexer/browser/email/TagListCellRenderer.java +++ b/src/main/java/nl/andrewl/email_indexer/browser/email/TagListCellRenderer.java @@ -1,5 +1,7 @@ package nl.andrewl.email_indexer.browser.email; +import nl.andrewl.email_indexer.browser.control.SwingUtils; + import javax.swing.*; import java.awt.*; import java.util.Random; @@ -9,7 +11,6 @@ * a random color determined by the hashcode of its name. */ public class TagListCellRenderer implements ListCellRenderer { - private final Random random = new Random(); private final JLabel label = new JLabel(); public TagListCellRenderer() { @@ -19,12 +20,7 @@ public TagListCellRenderer() { @Override public Component getListCellRendererComponent(JList list, String value, int index, boolean isSelected, boolean cellHasFocus) { - // Generate color based on hash of name. - random.setSeed(value.hashCode()); - float hue = random.nextFloat(); - float saturation = random.nextFloat() / 4f + 0.75f; - float luminance = 0.9f; - Color foregroundColor = Color.getHSBColor(hue, saturation, luminance); + Color foregroundColor = SwingUtils.getColor(value); label.setText(value); label.setForeground(foregroundColor); diff --git a/src/main/java/nl/andrewl/email_indexer/browser/email/TagPanel.java b/src/main/java/nl/andrewl/email_indexer/browser/email/TagPanel.java index c718833..073eabd 100644 --- a/src/main/java/nl/andrewl/email_indexer/browser/email/TagPanel.java +++ b/src/main/java/nl/andrewl/email_indexer/browser/email/TagPanel.java @@ -4,14 +4,13 @@ import nl.andrewl.email_indexer.data.EmailRepository; import javax.swing.*; -import javax.swing.border.Border; import java.awt.*; /** * Panel that's used to manage the tags belonging to a single email entry. It * shows the list of tags, and provides facilities to modify that list. */ -public class TagPanel extends JPanel { +public class TagPanel extends JPanel implements EmailViewListener { private final EmailViewPanel parent; private final DefaultListModel tagListModel = new DefaultListModel<>(); private final DefaultListModel parentTagListModel = new DefaultListModel<>(); @@ -23,7 +22,7 @@ public class TagPanel extends JPanel { public TagPanel(EmailViewPanel parent) { super(new BorderLayout()); this.parent = parent; - this.add(new JLabel("Tags"), BorderLayout.NORTH); + this.setBorder(BorderFactory.createTitledBorder("Tags")); this.removeButton.setEnabled(false); JPanel centerPanel = new JPanel(new GridLayout(0, 2)); @@ -53,10 +52,11 @@ public TagPanel(EmailViewPanel parent) { this.add(centerPanel, BorderLayout.CENTER); - JPanel buttonPanel = new JPanel(); + JPanel buttonPanel = new JPanel(new BorderLayout()); JComboBox tagComboBox = new JComboBox<>(this.tagComboBoxModel); tagComboBox.setEditable(true); - buttonPanel.add(tagComboBox); + buttonPanel.add(tagComboBox, BorderLayout.CENTER); + JPanel buttonCtlPanel = new JPanel(); JButton addButton = new JButton("Add"); addButton.addActionListener(e -> { String tag = (String) tagComboBox.getSelectedItem(); @@ -65,7 +65,7 @@ public TagPanel(EmailViewPanel parent) { parent.refresh(); } }); - buttonPanel.add(addButton); + buttonCtlPanel.add(addButton); removeButton.addActionListener(e -> { var repo = new EmailRepository(parent.getCurrentDataset()); for (var tag : tagList.getSelectedValuesList()) { @@ -73,7 +73,8 @@ public TagPanel(EmailViewPanel parent) { } parent.refresh(); }); - buttonPanel.add(removeButton); + buttonCtlPanel.add(removeButton); + buttonPanel.add(buttonCtlPanel, BorderLayout.EAST); this.add(buttonPanel, BorderLayout.SOUTH); } @@ -91,4 +92,9 @@ public void setEmail(EmailEntry email) { this.childTagListModel.addAll(repo.getAllChildTags(email.messageId())); } } + + @Override + public void emailUpdated(EmailEntry email) { + setEmail(email); + } } diff --git a/src/main/java/nl/andrewl/email_indexer/data/EmailDataset.java b/src/main/java/nl/andrewl/email_indexer/data/EmailDataset.java index 24b823e..792cba2 100644 --- a/src/main/java/nl/andrewl/email_indexer/data/EmailDataset.java +++ b/src/main/java/nl/andrewl/email_indexer/data/EmailDataset.java @@ -20,13 +20,18 @@ * disk as a ZIP file. */ public class EmailDataset { + /** + * The directory that this dataset resides in. + */ private final Path openDir; - private final Path dsFile; + + /** + * The database connection used by this dataset, while it's open. + */ private Connection dbConn; - public EmailDataset(Path openDir, Path dsFile) throws SQLException { + public EmailDataset(Path openDir) throws SQLException { this.openDir = openDir; - this.dsFile = dsFile; establishConnection(); } @@ -66,15 +71,25 @@ public static CompletionStage open(Path dsFile) { try { if (Files.isDirectory(dsFile)) { if (!Files.exists(dsFile.resolve("index")) || !Files.exists(dsFile.resolve("database.mv.db"))) { - throw new IOException("Invalid dataset directory."); + throw new IOException("Invalid dataset directory. A dataset must contain an \"index\" directory, and a \"database.mv.db\" file."); } - cf.complete(new EmailDataset(dsFile, dsFile.getParent().resolve(dsFile.getFileName().toString() + ".zip"))); - } else { - Path openDir = Files.createTempDirectory(Path.of("."), "email-dataset"); + cf.complete(new EmailDataset(dsFile)); + } else if (dsFile.getFileName().toString().toLowerCase().endsWith(".zip")) { + String filename = dsFile.getFileName().toString(); + String dirName = filename.substring(0, filename.lastIndexOf('.')); + // Add '_' prefix until we have a new directory that doesn't exist yet. + Path openDir = dsFile.resolveSibling(dirName); + while (Files.exists(openDir)) { + dirName = "_" + dirName; + openDir = dsFile.resolveSibling(dirName); + } + Files.createDirectory(openDir); var zip = new ZipFile(dsFile.toFile()); zip.extractAll(openDir.toAbsolutePath().toString()); zip.close(); - cf.complete(new EmailDataset(openDir, dsFile)); + cf.complete(new EmailDataset(openDir)); + } else { + throw new IOException("Invalid file."); } } catch (Exception e) { cf.completeExceptionally(e); diff --git a/src/main/java/nl/andrewl/email_indexer/data/EmailRepository.java b/src/main/java/nl/andrewl/email_indexer/data/EmailRepository.java index b281be5..07908b7 100644 --- a/src/main/java/nl/andrewl/email_indexer/data/EmailRepository.java +++ b/src/main/java/nl/andrewl/email_indexer/data/EmailRepository.java @@ -2,7 +2,6 @@ import nl.andrewl.email_indexer.EmailIndexSearcher; import nl.andrewl.email_indexer.data.util.ConditionBuilder; -import nl.andrewl.email_indexer.data.util.DbUtils; import org.apache.lucene.queryparser.classic.ParseException; import java.io.IOException; @@ -12,6 +11,8 @@ import java.time.ZonedDateTime; import java.util.*; +import static nl.andrewl.email_indexer.data.util.DbUtils.*; + public class EmailRepository { private final Connection conn; private final Path indexDir; @@ -26,15 +27,15 @@ public EmailRepository(EmailDataset ds) { } public long countEmails() { - return DbUtils.count(conn, "SELECT COUNT(MESSAGE_ID) FROM EMAIL"); + return count(conn, "SELECT COUNT(MESSAGE_ID) FROM EMAIL"); } public long countTags() { - return DbUtils.count(conn, "SELECT COUNT(TAG) FROM EMAIL_TAG"); + return count(conn, "SELECT COUNT(TAG) FROM EMAIL_TAG"); } public long countTaggedEmails() { - return DbUtils.count(conn, "SELECT COUNT(DISTINCT MESSAGE_ID) FROM EMAIL_TAG"); + return count(conn, "SELECT COUNT(DISTINCT MESSAGE_ID) FROM EMAIL_TAG"); } public Optional findEmailById(String messageId) { @@ -67,15 +68,13 @@ public Optional findEmailById(String messageId) { } public List findAllReplies(String messageId) { - List entries = new ArrayList<>(); - try (var stmt = conn.prepareStatement(QueryCache.load("/sql/fetch_email_preview_by_in_reply_to.sql"))) { - stmt.setString(1, messageId); - var rs = stmt.executeQuery(); - while (rs.next()) entries.add(new EmailEntryPreview(rs)); - } catch (SQLException e) { - e.printStackTrace(); - } - return entries; + return fetch( + conn, + QueryCache.load("/sql/fetch_email_preview_by_in_reply_to.sql"), + EmailEntryPreview::new, + messageId + ); + } public Optional findRootEmailByChildId(String messageId) { @@ -127,7 +126,7 @@ public EmailSearchResult findAll(int page, int size, Boolean hidden, Boolean tag try (var stmt = conn.prepareStatement(q)) { var rs = stmt.executeQuery(); while (rs.next()) entries.add(new EmailEntryPreview(rs)); - long totalResultCount = DbUtils.count(conn, getSearchCountQuery(hidden, tagged)); + long totalResultCount = count(conn, getSearchCountQuery(hidden, tagged)); return EmailSearchResult.of(entries, page, size, totalResultCount); } catch (SQLException e) { e.printStackTrace(); @@ -166,37 +165,17 @@ SELECT COUNT(EMAIL.MESSAGE_ID) } public boolean hasTag(String messageId, String tag) { - try (var stmt = conn.prepareStatement("SELECT COUNT(TAG) FROM EMAIL_TAG WHERE MESSAGE_ID = ? AND TAG = ?")) { - stmt.setString(1, messageId); - stmt.setString(2, tag); - var rs = stmt.executeQuery(); - if (rs.next()) return rs.getLong(1) > 0; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return count(conn, "SELECT COUNT(TAG) FROM EMAIL_TAG WHERE MESSAGE_ID = ? AND TAG = ?", messageId, tag) > 0; } public void addTag(String messageId, String tag) { if (!hasTag(messageId, tag)) { - try (var stmt = conn.prepareStatement("INSERT INTO EMAIL_TAG (MESSAGE_ID, TAG) VALUES (?, ?)")) { - stmt.setString(1, messageId); - stmt.setString(2, tag); - stmt.executeUpdate(); - } catch (SQLException e) { - e.printStackTrace(); - } + update(conn, "INSERT INTO EMAIL_TAG (MESSAGE_ID, TAG) VALUES (?, ?)", messageId, tag); } } public void removeTag(String messageId, String tag) { - try (var stmt = conn.prepareStatement("DELETE FROM EMAIL_TAG WHERE MESSAGE_ID = ? AND TAG = ?")) { - stmt.setString(1, messageId); - stmt.setString(2, tag); - stmt.executeUpdate(); - } catch (SQLException e) { - e.printStackTrace(); - } + update(conn, "DELETE FROM EMAIL_TAG WHERE MESSAGE_ID = ? AND TAG = ?", messageId, tag); } /** @@ -204,14 +183,7 @@ public void removeTag(String messageId, String tag) { * @return The list of all tags. */ public List getAllTags() { - List tags = new ArrayList<>(); - try (var stmt = conn.prepareStatement("SELECT DISTINCT TAG FROM EMAIL_TAG ORDER BY TAG")) { - var rs = stmt.executeQuery(); - while (rs.next()) tags.add(rs.getString(1)); - } catch (SQLException e) { - e.printStackTrace(); - } - return tags; + return fetch(conn, "SELECT DISTINCT TAG FROM EMAIL_TAG ORDER BY TAG", rs -> rs.getString(1)); } /** @@ -282,23 +254,49 @@ public List getAllChildTags(String messageId) { return tagList; } - public long hideAllEmailsByBody(String body) { - try (var stmt = conn.prepareStatement("UPDATE EMAIL SET HIDDEN = TRUE WHERE BODY LIKE ?")) { - stmt.setString(1, body); - return stmt.executeUpdate(); - } catch (SQLException e) { - e.printStackTrace(); - return 0; - } + public void hideEmail(String messageId) { + update(conn, "UPDATE EMAIL SET HIDDEN = TRUE WHERE MESSAGE_ID = ?", messageId); } - public long hideAllEmailsBySentFrom(String sentFrom) { - try (var stmt = conn.prepareStatement("UPDATE EMAIL SET HIDDEN = TRUE WHERE SENT_FROM LIKE ?")) { - stmt.setString(1, sentFrom); - return stmt.executeUpdate(); + public void showEmail(String messageId) { + update(conn, "UPDATE EMAIL SET HIDDEN = FALSE WHERE MESSAGE_ID = ?", messageId); + } + + private int hideEmailsByQuery(String msg, String conditions, Object... args) { + try { + conn.setAutoCommit(false); + List ids = fetch(conn, "SELECT MESSAGE_ID FROM EMAIL WHERE " + conditions, rs -> rs.getString(1), args); + long mId = insertWithId(conn, "INSERT INTO MUTATION (DESCRIPTION) VALUES (?)", msg); + try (var stmt = conn.prepareStatement("INSERT INTO MUTATION_EMAIL(MUTATION_ID, MESSAGE_ID) VALUES (?, ?)")) { + stmt.setLong(1, mId); + for (var id : ids) { + stmt.setString(2, id); + stmt.executeUpdate(); + } + } + int count = update(conn, "UPDATE EMAIL SET HIDDEN = TRUE WHERE " + conditions, args); + conn.commit(); + conn.setAutoCommit(true); + return count; } catch (SQLException e) { e.printStackTrace(); return 0; } } + + public int hideAllEmailsByBody(String body) { + return hideEmailsByQuery( + "Hiding all emails with a body like: \n\n" + body, + "HIDDEN = FALSE AND BODY LIKE ?", + body + ); + } + + public int hideAllEmailsBySentFrom(String sentFrom) { + return hideEmailsByQuery( + "Hiding all emails sent by email addresses like: " + sentFrom, + "HIDDEN = FALSE AND SENT_FROM LIKE ?", + sentFrom + ); + } } diff --git a/src/main/java/nl/andrewl/email_indexer/data/util/DbUtils.java b/src/main/java/nl/andrewl/email_indexer/data/util/DbUtils.java index 5ceb340..615f8f5 100644 --- a/src/main/java/nl/andrewl/email_indexer/data/util/DbUtils.java +++ b/src/main/java/nl/andrewl/email_indexer/data/util/DbUtils.java @@ -1,11 +1,27 @@ package nl.andrewl.email_indexer.data.util; import java.sql.Connection; +import java.sql.ResultSet; import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; -public class DbUtils { - public static long count(Connection c, String query) { +public final class DbUtils { + private DbUtils() {} + + /** + * Performs a count query. + * @param c The connection to use. + * @param query The query to use for counting. It should select a single + * integer/long value as the first item in the result set. + * @param args The arguments to the query. + * @return The count that was obtained. + */ + public static long count(Connection c, String query, Object... args) { try (var stmt = c.prepareStatement(query)) { + int idx = 1; + for (var arg : args) stmt.setObject(idx++, arg); var rs = stmt.executeQuery(); if (rs.next()) { return rs.getLong(1); @@ -15,4 +31,51 @@ public static long count(Connection c, String query) { } return 0; } + + public static int update(Connection c, String query, Object... args) { + try (var stmt = c.prepareStatement(query)) { + int idx = 1; + for (var arg : args) stmt.setObject(idx++, arg); + return stmt.executeUpdate(); + } catch (SQLException e) { + e.printStackTrace(); + return 0; + } + } + + public static long insertWithId(Connection c, String query, Object... args) { + try (var stmt = c.prepareStatement(query, Statement.RETURN_GENERATED_KEYS)) { + int idx = 1; + for (var arg : args) stmt.setObject(idx++, arg); + int count = stmt.executeUpdate(); + if (count != 1) throw new SQLException("Only one row should be inserted."); + var rs = stmt.getGeneratedKeys(); + if (rs.next()) { + return rs.getLong(1); + } else { + throw new SQLException("No keys were returned."); + } + } catch (SQLException e) { + e.printStackTrace(); + return -1; + } + } + + @FunctionalInterface + public interface ResultSetMapper { + T map(ResultSet rs) throws SQLException; + } + + public static List fetch(Connection c, String query, ResultSetMapper mapper, Object... args) { + List items = new ArrayList<>(); + try (var stmt = c.prepareStatement(query)) { + int idx = 1; + for (var arg : args) stmt.setObject(idx++, arg); + var rs = stmt.executeQuery(); + while (rs.next()) items.add(mapper.map(rs)); + } catch (SQLException e) { + e.printStackTrace(); + } + return items; + } } diff --git a/src/main/java/nl/andrewl/email_indexer/data/util/FileUtils.java b/src/main/java/nl/andrewl/email_indexer/data/util/FileUtils.java new file mode 100644 index 0000000..2473ea4 --- /dev/null +++ b/src/main/java/nl/andrewl/email_indexer/data/util/FileUtils.java @@ -0,0 +1,21 @@ +package nl.andrewl.email_indexer.data.util; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +public class FileUtils { + public static int getFileCount(Path dir) { + if (!Files.isDirectory(dir)) return 0; + try (var s = Files.list(dir)) { + return (int) s.count(); + } catch (IOException e) { + e.printStackTrace(); + return 0; + } + } + + public static boolean isDirEmpty(Path dir) { + return getFileCount(dir) == 0; + } +} diff --git a/src/main/java/nl/andrewl/email_indexer/gen/EmailIndexGenerator.java b/src/main/java/nl/andrewl/email_indexer/gen/EmailIndexGenerator.java index 5f6cfda..154fc53 100644 --- a/src/main/java/nl/andrewl/email_indexer/gen/EmailIndexGenerator.java +++ b/src/main/java/nl/andrewl/email_indexer/gen/EmailIndexGenerator.java @@ -1,7 +1,6 @@ package nl.andrewl.email_indexer.gen; import nl.andrewl.mbox_parser.CompositeEmailHandler; -import nl.andrewl.mbox_parser.Email; import nl.andrewl.mbox_parser.EmailHandler; import nl.andrewl.mbox_parser.MBoxParser; import org.apache.lucene.analysis.Analyzer; @@ -12,44 +11,42 @@ import org.apache.lucene.store.FSDirectory; import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.LinkOption; import java.nio.file.Path; -import java.util.ArrayList; import java.util.Collection; -import java.util.List; -import java.util.function.Consumer; -import java.util.function.Function; +/** + * Component which parses a set of mbox files to produce a Lucene index. + */ public class EmailIndexGenerator { public void generateIndex(Collection inputDirs, Path outputDir, EmailHandler... emailHandlers) throws IOException { Files.createDirectories(outputDir); Directory emailDirectory = FSDirectory.open(outputDir); Analyzer analyzer = new StandardAnalyzer(); IndexWriterConfig config = new IndexWriterConfig(analyzer); - List> filters = new ArrayList<>(); - filters.add(email -> email.charset != null); - List> transformers = new ArrayList<>(); - transformers.add(email -> { - String[] lines = email.readBodyAsText().split("\n"); - StringBuilder sb = new StringBuilder(email.body.length); - for (var line : lines) { - if (!line.trim().startsWith(">")) { - sb.append(line).append("\n"); - } - } - email.body = sb.toString().getBytes(StandardCharsets.UTF_8); - email.charset = StandardCharsets.UTF_8.name(); - email.transferEncoding = "8bit"; - }); try (IndexWriter emailIndexWriter = new IndexWriter(emailDirectory, config)) { - var handler = new CompositeEmailHandler(new IndexingEmailHandler(emailIndexWriter)); - for (var h : emailHandlers) handler.withHandler(h); - MBoxParser parser = new MBoxParser(new FilterTransformEmailHandler(handler, filters, transformers)); + // Create a composite handler that has a collection of handlers that are called for each email parsed. + var compositeHandler = new CompositeEmailHandler(new IndexingEmailHandler(emailIndexWriter)); + for (var h : emailHandlers) compositeHandler.withHandler(h); + // Wrap the composite handler in a sanitizing handler to filter out plain junk. + MBoxParser parser = new MBoxParser(new SanitizingEmailHandler(compositeHandler)); for (var dir : inputDirs) { - try (var s = Files.list(dir)) { - for (var p : s.toList()) parser.parse(p); + parseRecursive(dir, parser); + } + } + } + + private void parseRecursive(Path dir, MBoxParser parser) throws IOException { + System.out.println("Parsing directory: " + dir); + try (var s = Files.list(dir)) { + for (var p : s.toList()) { + if (Files.isDirectory(p, LinkOption.NOFOLLOW_LINKS)) { + parseRecursive(p, parser); + } else if (Files.isRegularFile(p, LinkOption.NOFOLLOW_LINKS) && Files.isReadable(p)) { + System.out.println("Parsing file: " + p); + parser.parse(p); } } } diff --git a/src/main/java/nl/andrewl/email_indexer/gen/FilterTransformEmailHandler.java b/src/main/java/nl/andrewl/email_indexer/gen/FilterTransformEmailHandler.java deleted file mode 100644 index 9f90838..0000000 --- a/src/main/java/nl/andrewl/email_indexer/gen/FilterTransformEmailHandler.java +++ /dev/null @@ -1,31 +0,0 @@ -package nl.andrewl.email_indexer.gen; - -import nl.andrewl.mbox_parser.Email; -import nl.andrewl.mbox_parser.EmailHandler; - -import java.util.List; -import java.util.function.Consumer; -import java.util.function.Function; - -public class FilterTransformEmailHandler implements EmailHandler { - private final EmailHandler handler; - private final List> filterFunctions; - private final List> transformers; - - public FilterTransformEmailHandler(EmailHandler handler, List> filterFunctions, List> transformers) { - this.handler = handler; - this.filterFunctions = filterFunctions; - this.transformers = transformers; - } - - @Override - public void emailReceived(Email email) { - for (var f : filterFunctions) { - if (!f.apply(email)) return; - } - for (var t : transformers) { - t.accept(email); - } - handler.emailReceived(email); - } -} diff --git a/src/main/java/nl/andrewl/email_indexer/gen/SanitizingEmailHandler.java b/src/main/java/nl/andrewl/email_indexer/gen/SanitizingEmailHandler.java new file mode 100644 index 0000000..108ec72 --- /dev/null +++ b/src/main/java/nl/andrewl/email_indexer/gen/SanitizingEmailHandler.java @@ -0,0 +1,54 @@ +package nl.andrewl.email_indexer.gen; + +import nl.andrewl.mbox_parser.Email; +import nl.andrewl.mbox_parser.EmailHandler; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +/** + * A simple handler that helps to sanitize emails by removing those without a + * charset, and doing a best-effort attempt to remove annoying indented reply + * content. + */ +public class SanitizingEmailHandler implements EmailHandler { + private final EmailHandler handler; + private final List> filterFunctions; + private final List> transformers; + + public SanitizingEmailHandler(EmailHandler handler) { + this.handler = handler; + + this.filterFunctions = new ArrayList<>(); + filterFunctions.add(email -> email.charset != null); + filterFunctions.add(email -> email.body.length > 0); + + this.transformers = new ArrayList<>(); + transformers.add(email -> { + String[] lines = email.readBodyAsText().split("\n"); + StringBuilder sb = new StringBuilder(email.body.length); + for (var line : lines) { + if (!line.trim().startsWith(">")) { + sb.append(line).append("\n"); + } + } + email.body = sb.toString().getBytes(StandardCharsets.UTF_8); + email.charset = StandardCharsets.UTF_8.name(); + email.transferEncoding = "8bit"; + }); + } + + @Override + public void emailReceived(Email email) { + for (var f : filterFunctions) { + if (!f.apply(email)) return; + } + for (var t : transformers) { + t.accept(email); + } + handler.emailReceived(email); + } +} diff --git a/src/main/resources/sql/schema.sql b/src/main/resources/sql/schema.sql index f543d3f..2db26b9 100644 --- a/src/main/resources/sql/schema.sql +++ b/src/main/resources/sql/schema.sql @@ -7,8 +7,28 @@ CREATE TABLE EMAIL ( BODY LONGTEXT, HIDDEN BOOL NOT NULL DEFAULT FALSE ); +CREATE INDEX IDX_EMAIL_REPLY ON EMAIL(IN_REPLY_TO); +CREATE INDEX IDX_EMAIL_DATE ON EMAIL(DATE); + +/* Tags are simply defined as some short text attached to an email. */ CREATE TABLE EMAIL_TAG ( - MESSAGE_ID VARCHAR(255) NOT NULL REFERENCES EMAIL(MESSAGE_ID), + MESSAGE_ID VARCHAR(255) NOT NULL REFERENCES EMAIL(MESSAGE_ID) + ON UPDATE CASCADE ON DELETE CASCADE, TAG VARCHAR(255) NOT NULL, PRIMARY KEY (MESSAGE_ID, TAG) +); +CREATE INDEX IDX_EMAIL_TAG ON EMAIL_TAG(TAG); + +/* Utility tables to record all changes made to the dataset. Each mutation should affect one or more emails. */ +CREATE TABLE MUTATION ( + ID BIGINT PRIMARY KEY AUTO_INCREMENT, + DESCRIPTION LONGTEXT NOT NULL, + PERFORMED_AT TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP(0) +); +CREATE TABLE MUTATION_EMAIL ( + MUTATION_ID BIGINT NOT NULL REFERENCES MUTATION (ID) + ON UPDATE CASCADE ON DELETE CASCADE, + MESSAGE_ID VARCHAR(255) NOT NULL REFERENCES EMAIL(MESSAGE_ID) + ON UPDATE CASCADE ON DELETE CASCADE, + PRIMARY KEY (MUTATION_ID, MESSAGE_ID) ); \ No newline at end of file