diff --git a/source/net/filebot/mac/DropToUnlock.java b/source/net/filebot/mac/DropToUnlock.java new file mode 100644 index 00000000..5f7a8a89 --- /dev/null +++ b/source/net/filebot/mac/DropToUnlock.java @@ -0,0 +1,222 @@ +package net.filebot.mac; + +import static javax.swing.BorderFactory.*; +import static net.filebot.UserFiles.*; +import static net.filebot.mac.MacAppUtilities.*; +import static net.filebot.ui.NotificationLogging.*; +import static net.filebot.util.FileUtilities.*; +import static net.filebot.util.ui.SwingUI.*; + +import java.awt.BasicStroke; +import java.awt.Color; +import java.awt.Component; +import java.awt.Cursor; +import java.awt.Desktop; +import java.awt.Dialog.ModalExclusionType; +import java.awt.Dimension; +import java.awt.Font; +import java.awt.Graphics; +import java.awt.Graphics2D; +import java.awt.RenderingHints; +import java.awt.Window; +import java.awt.datatransfer.Transferable; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.awt.geom.RoundRectangle2D; +import java.io.File; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; + +import javax.swing.DefaultListCellRenderer; +import javax.swing.JComponent; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JList; + +import net.filebot.ResourceManager; +import net.filebot.ui.HeaderPanel; +import net.filebot.ui.transfer.DefaultTransferHandler; +import net.filebot.ui.transfer.FileTransferable; +import net.filebot.ui.transfer.TransferablePolicy; +import net.miginfocom.swing.MigLayout; + +public class DropToUnlock extends JList { + + public static boolean showUnlockDialog(Window owner, Collection folders) { + final List model = folders.stream().map(f -> new File(f.getAbsolutePath())).filter(f -> f.isDirectory()).sorted().distinct().collect(Collectors.toList()); + + // TODO store secure bookmarks and auto-unlock folders if possible + + // check if we even need to unlock anything + if (model.stream().allMatch(f -> !isFolderLocked(f))) + return true; + + final JDialog dialog = new JDialog(owner); + final AtomicBoolean dialogCancelled = new AtomicBoolean(true); + DropToUnlock d = new DropToUnlock(model) { + + @Override + public void updateLockStatus(File... folders) { + super.updateLockStatus(folders); + + // UI feedback for unlocked folders + for (File it : folders) { + if (!isFolderLocked(it)) { + UILogger.log(Level.INFO, "Folder " + it.getName() + " has been unlocked"); + } + } + + // if all folders have been unlocked auto-close dialog + if (model.stream().allMatch(f -> !isFolderLocked(f))) { + dialogCancelled.set(false); + dialog.setVisible(false); + } + }; + }; + d.setBorder(createEmptyBorder(5, 15, 120, 15)); + + JComponent c = (JComponent) dialog.getContentPane(); + c.setLayout(new MigLayout("insets 0, fill")); + + HeaderPanel h = new HeaderPanel(); + h.getTitleLabel().setText("Folder Permissions Required"); + h.getTitleLabel().setIcon(ResourceManager.getIcon("file.lock")); + h.getTitleLabel().setBorder(createEmptyBorder(0, 0, 0, 64)); + c.add(h, "wmin 150px, hmin 75px, growx, dock north"); + c.add(d, "wmin 150px, hmin 150px, grow"); + + dialog.setModal(true); + dialog.setModalExclusionType(ModalExclusionType.TOOLKIT_EXCLUDE); + dialog.setSize(new Dimension(540, 420)); + dialog.setResizable(false); + dialog.setLocationByPlatform(true); + dialog.setAlwaysOnTop(true); + + // open required folders for easy drag and drop + invokeLater(750, new Runnable() { + + @Override + public void run() { + model.stream().map(f -> f.getParentFile()).distinct().forEach(f -> { + try { + Desktop.getDesktop().open(f); + } catch (Exception e) { + Logger.getLogger(DropToUnlock.class.getName()).log(Level.WARNING, e.toString()); + } + }); + } + }); + + // show and wait + dialog.setVisible(true); + + return !dialogCancelled.get(); + } + + public DropToUnlock(Collection model) { + super(model.toArray(new File[0])); + + setLayoutOrientation(JList.HORIZONTAL_WRAP); + setVisibleRowCount(-1); + + setCellRenderer(new FolderLockCellRenderer()); + + setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); + addMouseListener(new FileChooserAction()); + + setTransferHandler(new DefaultTransferHandler(new FolderDropPolicy(), null)); + } + + public void updateLockStatus(File... folder) { + repaint(); + } + + private final RoundRectangle2D dropArea = new RoundRectangle2D.Double(0, 0, 0, 0, 20, 20); + private final BasicStroke dashedStroke = new BasicStroke(1.0f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND, 10.0f, new float[] { 5.0f }, 0.0f); + + @Override + protected void paintComponent(Graphics g) { + super.paintComponent(g); + + Graphics2D g2d = (Graphics2D) g; + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); + + // draw dashed bounding box + int w = 300; + int h = 70; + int pad = 20; + + g2d.setColor(Color.lightGray); + dropArea.setFrameFromCenter(getWidth() / 2, getHeight() - (h / 2) - pad - 10, (getWidth() - w) / 2, getHeight() - h - 2 * pad); + g2d.setStroke(dashedStroke); + g2d.draw(dropArea); + + // draw text + g2d.setColor(Color.gray); + g2d.setFont(g2d.getFont().deriveFont(Font.ITALIC, 36)); + g2d.drawString("Drop 'em", (int) dropArea.getMinX() + 15, (int) dropArea.getMinY() + 40); + g2d.drawString("to Unlock 'em", (int) dropArea.getMinX() + 45, (int) dropArea.getMinY() + 40 + 35); + } + + protected class FolderDropPolicy extends TransferablePolicy { + + @Override + public boolean accept(Transferable tr) throws Exception { + return true; + } + + public void handleTransferable(Transferable tr, TransferAction action) throws Exception { + List files = FileTransferable.getFilesFromTransferable(tr); + if (files != null) { + List folders = filter(files, FOLDERS); + if (folders.size() > 0) { + updateLockStatus(folders.toArray(new File[0])); + } + } + } + } + + protected static class FolderLockCellRenderer extends DefaultListCellRenderer { + + @Override + public Dimension getPreferredSize() { + return new Dimension(100, 100); + } + + @Override + public Component getListCellRendererComponent(JList list, Object value, int index, boolean isSelected, boolean cellHasFocus) { + File folder = (File) value; + JLabel c = (JLabel) super.getListCellRendererComponent(list, folder.getName(), index, false, false); + + c.setIcon(ResourceManager.getIcon(isFolderLocked(folder) ? "folder.locked" : "folder.open")); + c.setHorizontalTextPosition(JLabel.CENTER); + c.setVerticalTextPosition(JLabel.BOTTOM); + + return c; + } + } + + protected static class FileChooserAction extends MouseAdapter { + + public void mouseClicked(MouseEvent evt) { + DropToUnlock list = (DropToUnlock) evt.getSource(); + if (evt.getClickCount() > 0) { + int index = list.locationToIndex(evt.getPoint()); + if (index >= 0 && list.getCellBounds(index, index).contains(evt.getPoint())) { + File folder = list.getModel().getElementAt(index); + if (isFolderLocked(folder)) { + if (null != showOpenDialogSelectFolder(folder, "Grant Permission", getWindow(list))) { + list.updateLockStatus(folder); + } + } + } + } + } + } + +} diff --git a/source/net/filebot/mac/MacAppUtilities.java b/source/net/filebot/mac/MacAppUtilities.java index 353d306d..f37cb473 100644 --- a/source/net/filebot/mac/MacAppUtilities.java +++ b/source/net/filebot/mac/MacAppUtilities.java @@ -1,6 +1,7 @@ package net.filebot.mac; import java.awt.Window; +import java.io.File; import java.lang.reflect.Method; import java.util.logging.Level; import java.util.logging.Logger; @@ -50,4 +51,8 @@ public class MacAppUtilities { Logger.getLogger(MacAppUtilities.class.getName()).log(Level.WARNING, "requestForeground not supported: " + t); } } + + public static boolean isFolderLocked(File folder) { + return folder.isDirectory() && !folder.canRead() && !folder.canWrite(); + } } diff --git a/source/net/filebot/media/MediaDetection.java b/source/net/filebot/media/MediaDetection.java index 511bac6d..5b379943 100644 --- a/source/net/filebot/media/MediaDetection.java +++ b/source/net/filebot/media/MediaDetection.java @@ -989,6 +989,19 @@ public class MediaDetection { return releaseInfo.getStructureRootPattern().matcher(folder.getName()).matches(); } + public static File getStructureRoot(File file) throws IOException { + boolean structureRoot = false; + for (File it : listPathTail(file, Integer.MAX_VALUE, true)) { + if (structureRoot || isStructureRoot(it)) { + if (it.isDirectory()) { + return it; + } + structureRoot = true; // find first existing folder at or after the structure root folder (which may not exist yet) + } + } + return null; + } + public static File getStructurePathTail(File file) throws IOException { LinkedList relativePath = new LinkedList(); diff --git a/source/net/filebot/resources/file.lock.png b/source/net/filebot/resources/file.lock.png new file mode 100644 index 00000000..5b24f192 Binary files /dev/null and b/source/net/filebot/resources/file.lock.png differ diff --git a/source/net/filebot/resources/folder.locked.png b/source/net/filebot/resources/folder.locked.png new file mode 100644 index 00000000..eb6376bb Binary files /dev/null and b/source/net/filebot/resources/folder.locked.png differ diff --git a/source/net/filebot/resources/folder.open.png b/source/net/filebot/resources/folder.open.png new file mode 100644 index 00000000..c05ea6c5 Binary files /dev/null and b/source/net/filebot/resources/folder.open.png differ diff --git a/source/net/filebot/ui/HeaderPanel.java b/source/net/filebot/ui/HeaderPanel.java index e8dc1e5e..75c5559a 100644 --- a/source/net/filebot/ui/HeaderPanel.java +++ b/source/net/filebot/ui/HeaderPanel.java @@ -17,7 +17,7 @@ import net.filebot.util.ui.GradientStyle; import net.filebot.util.ui.notification.SeparatorBorder; import net.filebot.util.ui.notification.SeparatorBorder.Position; -class HeaderPanel extends JComponent { +public class HeaderPanel extends JComponent { private JLabel titleLabel = new JLabel(); diff --git a/source/net/filebot/ui/rename/RenameAction.java b/source/net/filebot/ui/rename/RenameAction.java index ef95c579..1d64d4fd 100644 --- a/source/net/filebot/ui/rename/RenameAction.java +++ b/source/net/filebot/ui/rename/RenameAction.java @@ -14,6 +14,7 @@ import java.awt.Window; import java.awt.event.ActionEvent; import java.beans.PropertyChangeEvent; import java.io.File; +import java.io.IOException; import java.util.AbstractList; import java.util.AbstractMap.SimpleEntry; import java.util.ArrayList; @@ -24,6 +25,7 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; +import java.util.TreeSet; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Semaphore; @@ -44,6 +46,7 @@ import net.filebot.HistorySpooler; import net.filebot.NativeRenameAction; import net.filebot.ResourceManager; import net.filebot.StandardRenameAction; +import net.filebot.mac.DropToUnlock; import net.filebot.media.MediaDetection; import net.filebot.similarity.Match; import net.filebot.util.ui.ProgressDialog; @@ -148,7 +151,22 @@ class RenameAction extends AbstractAction { } } - private Map checkRenamePlan(List> renamePlan, Window parent) { + private Map checkRenamePlan(List> renamePlan, Window parent) throws IOException { + // ask for user permissions to output paths + if (isMacSandbox()) { + Set folders = new TreeSet(); + for (Entry it : renamePlan) { + File structureRoot = MediaDetection.getStructureRoot(it.getValue()); + if (structureRoot != null) { + folders.add(structureRoot); + } + } + + if (!DropToUnlock.showUnlockDialog(parent, folders)) { + return emptyMap(); + } + } + // build rename map and perform some sanity checks Map renameMap = new HashMap(); Set destinationSet = new HashSet();