diff --git a/.classpath b/.classpath index 6a484a24..cec9ef29 100644 --- a/.classpath +++ b/.classpath @@ -34,5 +34,6 @@ + diff --git a/build.xml b/build.xml index e0e6c04b..0bcbaf0b 100644 --- a/build.xml +++ b/build.xml @@ -253,6 +253,11 @@ + + + + + diff --git a/ivy.xml b/ivy.xml index 2885600b..f97f35ed 100644 --- a/ivy.xml +++ b/ivy.xml @@ -25,6 +25,7 @@ + diff --git a/source/net/filebot/RenameAction.java b/source/net/filebot/RenameAction.java index 2b56978b..959dd922 100644 --- a/source/net/filebot/RenameAction.java +++ b/source/net/filebot/RenameAction.java @@ -1,12 +1,14 @@ package net.filebot; - import java.io.File; - public interface RenameAction { File rename(File from, File to) throws Exception; + default boolean canRevert() { + return true; + } + } diff --git a/source/net/filebot/StandardRenameAction.java b/source/net/filebot/StandardRenameAction.java index f149abc0..8e4e72ac 100644 --- a/source/net/filebot/StandardRenameAction.java +++ b/source/net/filebot/StandardRenameAction.java @@ -129,6 +129,11 @@ public enum StandardRenameAction implements RenameAction { public File rename(File from, File to) throws IOException { return FileUtilities.resolve(from, to); } + + @Override + public boolean canRevert() { + return false; + } }; public String getDisplayName() { diff --git a/source/net/filebot/cli/ArgumentBean.java b/source/net/filebot/cli/ArgumentBean.java index 9719fd5f..17ffbd53 100644 --- a/source/net/filebot/cli/ArgumentBean.java +++ b/source/net/filebot/cli/ArgumentBean.java @@ -124,6 +124,10 @@ public class ArgumentBean { return rename || getSubtitles || check || list || mediaInfo || revert || extract || script != null; } + public boolean isInteractive() { + return "interactive".equalsIgnoreCase(mode) && System.console() != null; + } + public boolean printVersion() { return version; } diff --git a/source/net/filebot/cli/ArgumentProcessor.java b/source/net/filebot/cli/ArgumentProcessor.java index ca999858..a85d36df 100644 --- a/source/net/filebot/cli/ArgumentProcessor.java +++ b/source/net/filebot/cli/ArgumentProcessor.java @@ -20,12 +20,15 @@ public class ArgumentProcessor { public int run(ArgumentBean args) { try { + // interactive mode enables basic selection and confirmation dialogs in the CLI + CmdlineInterface cli = args.isInteractive() ? new CmdlineOperationsTextUI() : new CmdlineOperations(); + if (args.script == null) { // execute command - return runCommand(args); + return runCommand(cli, args); } else { // execute user script - runScript(args); + runScript(cli, args); // script finished successfully log.finest("Done ヾ(@⌒ー⌒@)ノ"); @@ -46,9 +49,7 @@ public class ArgumentProcessor { return 1; } - public int runCommand(ArgumentBean args) throws Exception { - CmdlineInterface cli = new CmdlineOperations(); - + public int runCommand(CmdlineInterface cli, ArgumentBean args) throws Exception { // sanity checks if (args.getSubtitles && args.recursive) { throw new CmdlineException("`filebot -get-subtitles -r` has been disabled due to abuse. Please see http://bit.ly/suball for details."); @@ -103,13 +104,13 @@ public class ArgumentProcessor { return 0; } - public void runScript(ArgumentBean args) throws Throwable { + public void runScript(CmdlineInterface cli, ArgumentBean args) throws Throwable { Bindings bindings = new SimpleBindings(); - bindings.put(ScriptShell.SHELL_ARGV_BINDING_NAME, args.getArgumentArray()); + bindings.put(ScriptShell.SHELL_ARGS_BINDING_NAME, args); bindings.put(ScriptShell.ARGV_BINDING_NAME, args.getFiles(false)); ScriptSource source = ScriptSource.findScriptProvider(args.script); - ScriptShell shell = new ScriptShell(source.getScriptProvider(args.script), args.defines); + ScriptShell shell = new ScriptShell(source.getScriptProvider(args.script), cli, args.defines); shell.runScript(source.accept(args.script), bindings); } diff --git a/source/net/filebot/cli/CmdlineOperations.java b/source/net/filebot/cli/CmdlineOperations.java index 35313742..6836306b 100644 --- a/source/net/filebot/cli/CmdlineOperations.java +++ b/source/net/filebot/cli/CmdlineOperations.java @@ -601,7 +601,7 @@ public class CmdlineOperations implements CmdlineInterface { destination = resolve(source, destination); } - if (!destination.equals(source) && destination.exists() && renameAction != StandardRenameAction.TEST) { + if (!destination.equals(source) && destination.exists() && renameAction.canRevert()) { if (conflictAction == ConflictAction.FAIL) { throw new CmdlineException("File already exists: " + destination); } @@ -632,14 +632,16 @@ public class CmdlineOperations implements CmdlineInterface { } } finally { // update rename history - HistorySpooler.getInstance().append(renameLog.entrySet()); + if (renameAction.canRevert()) { + HistorySpooler.getInstance().append(renameLog.entrySet()); + } // printer number of renamed files if any log.fine(format("Processed %d files", renameLog.size())); } // write metadata into xattr if xattr is enabled - if (matches != null && renameLog.size() > 0 && renameAction != StandardRenameAction.TEST) { + if (matches != null && renameLog.size() > 0 && renameAction.canRevert()) { for (Match match : matches) { File source = match.getValue(); Object infoObject = match.getCandidate(); @@ -868,7 +870,7 @@ public class CmdlineOperations implements CmdlineInterface { return output; } - private List selectSearchResult(String query, Collection options, boolean alias, boolean strict) throws Exception { + protected List selectSearchResult(String query, Collection options, boolean alias, boolean strict) throws Exception { List probableMatches = getProbableMatches(query, options, alias, strict); if (probableMatches.isEmpty() || (strict && probableMatches.size() != 1)) { diff --git a/source/net/filebot/cli/CmdlineOperationsTextUI.java b/source/net/filebot/cli/CmdlineOperationsTextUI.java new file mode 100644 index 00000000..bf2bce4f --- /dev/null +++ b/source/net/filebot/cli/CmdlineOperationsTextUI.java @@ -0,0 +1,162 @@ +package net.filebot.cli; + +import static java.util.Arrays.*; +import static java.util.Collections.*; +import static net.filebot.media.MediaDetection.*; + +import java.io.File; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import com.googlecode.lanterna.TextColor; +import com.googlecode.lanterna.bundle.LanternaThemes; +import com.googlecode.lanterna.gui2.BasicWindow; +import com.googlecode.lanterna.gui2.Button; +import com.googlecode.lanterna.gui2.CheckBoxList; +import com.googlecode.lanterna.gui2.DefaultWindowManager; +import com.googlecode.lanterna.gui2.Direction; +import com.googlecode.lanterna.gui2.EmptySpace; +import com.googlecode.lanterna.gui2.GridLayout; +import com.googlecode.lanterna.gui2.LocalizedString; +import com.googlecode.lanterna.gui2.MultiWindowTextGUI; +import com.googlecode.lanterna.gui2.Panel; +import com.googlecode.lanterna.gui2.Panels; +import com.googlecode.lanterna.gui2.Separator; +import com.googlecode.lanterna.gui2.Window.Hint; +import com.googlecode.lanterna.gui2.dialogs.ListSelectDialogBuilder; +import com.googlecode.lanterna.screen.Screen; +import com.googlecode.lanterna.screen.TerminalScreen; +import com.googlecode.lanterna.terminal.DefaultTerminalFactory; +import com.googlecode.lanterna.terminal.Terminal; + +import net.filebot.RenameAction; +import net.filebot.similarity.Match; +import net.filebot.web.SearchResult; + +public class CmdlineOperationsTextUI extends CmdlineOperations { + + public static final String DEFAULT_THEME = "businessmachine"; + + private Terminal terminal; + private Screen screen; + private MultiWindowTextGUI ui; + + public CmdlineOperationsTextUI() throws Exception { + terminal = new DefaultTerminalFactory().createTerminal(); + screen = new TerminalScreen(terminal); + ui = new MultiWindowTextGUI(screen, new DefaultWindowManager(), new EmptySpace(TextColor.ANSI.DEFAULT)); + + // use green matrix-style theme + ui.setTheme(LanternaThemes.getRegisteredTheme(DEFAULT_THEME)); + } + + public T onScreen(Supplier dialog) throws Exception { + try { + screen.startScreen(); + return dialog.get(); + } finally { + screen.stopScreen(); + } + } + + @Override + public List renameAll(Map renameMap, RenameAction renameAction, ConflictAction conflictAction, List> matches) throws Exception { + // manually confirm each file mapping + Map selection = onScreen(() -> confirmRenameMap(renameMap, renameAction, conflictAction)); + + return super.renameAll(selection, renameAction, conflictAction, matches); + } + + @Override + protected List selectSearchResult(String query, Collection options, boolean alias, boolean strict) throws Exception { + List matches = getProbableMatches(query, options, alias, false); + + // manually select option if there is more than one + if (matches.size() > 1) { + return onScreen(() -> confirmSearchResult(query, matches)); + } + + return matches; + } + + protected List confirmSearchResult(String query, List options) { + ListSelectDialogBuilder dialog = new ListSelectDialogBuilder(); + dialog.setTitle("Multiple Options"); + dialog.setDescription(String.format("Select best match for \"%s\"", query)); + dialog.setExtraWindowHints(singleton(Hint.CENTERED)); + + options.forEach(dialog::addListItem); + + // show UI + SearchResult selection = dialog.build().showDialog(ui); + if (selection == null) { + return emptyList(); + } + + return singletonList(selection); + } + + protected Map confirmRenameMap(Map renameMap, RenameAction renameAction, ConflictAction conflictAction) { + Map selection = new LinkedHashMap(); + + BasicWindow dialog = new BasicWindow(); + dialog.setTitle(String.format("%s / %s", renameAction, conflictAction)); + dialog.setHints(asList(Hint.MODAL, Hint.CENTERED)); + + CheckBoxList checkBoxList = new CheckBoxList(); + + int columnSize = renameMap.keySet().stream().mapToInt(f -> f.getName().length()).max().orElse(0); + String labelFormat = "%-" + columnSize + "s\t=>\t%s"; + + renameMap.forEach((k, v) -> { + checkBoxList.addItem(new CheckBoxListItem(String.format(labelFormat, k.getName(), v.getName()), k, v), true); + }); + + Button continueButton = new Button(LocalizedString.OK.toString(), () -> { + checkBoxList.getCheckedItems().forEach(it -> selection.put(it.key, it.value)); + dialog.close(); + }); + + Button cancelButton = new Button(LocalizedString.Cancel.toString(), () -> { + selection.clear(); + dialog.close(); + }); + + Panel contentPane = new Panel(); + contentPane.setLayoutManager(new GridLayout(1)); + + contentPane.addComponent(checkBoxList.setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.BEGINNING, GridLayout.Alignment.BEGINNING, true, true, 1, 1))); + contentPane.addComponent(new Separator(Direction.HORIZONTAL).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.FILL, GridLayout.Alignment.CENTER, true, false, 1, 1))); + contentPane.addComponent(Panels.grid(2, continueButton, cancelButton).setLayoutData(GridLayout.createLayoutData(GridLayout.Alignment.END, GridLayout.Alignment.CENTER, false, false, 1, 1))); + + dialog.setComponent(contentPane); + + ui.addWindowAndWait(dialog); + + return selection; + } + + protected static class CheckBoxListItem { + + public final String label; + + public final File key; + public final File value; + + public CheckBoxListItem(String label, File key, File value) { + this.label = label; + this.key = key; + this.value = value; + } + + @Override + public String toString() { + return label; + } + + } + +} diff --git a/source/net/filebot/cli/GroovyPad.java b/source/net/filebot/cli/GroovyPad.java index c39eac4b..2e3b1df6 100644 --- a/source/net/filebot/cli/GroovyPad.java +++ b/source/net/filebot/cli/GroovyPad.java @@ -160,7 +160,7 @@ public class GroovyPad extends JFrame { protected ScriptShell createScriptShell() { try { - return new ScriptShell(s -> ScriptSource.GITHUB_STABLE.getScriptProvider(s).getScript(s), new HashMap()); + return new ScriptShell(s -> ScriptSource.GITHUB_STABLE.getScriptProvider(s).getScript(s), new CmdlineOperations(), new HashMap()); } catch (Exception e) { throw new RuntimeException(e); } @@ -226,7 +226,7 @@ public class GroovyPad extends JFrame { public void run() { try { Bindings bindings = new SimpleBindings(); - bindings.put(ScriptShell.SHELL_ARGV_BINDING_NAME, Settings.getApplicationArguments().getArgumentArray()); + bindings.put(ScriptShell.SHELL_ARGS_BINDING_NAME, Settings.getApplicationArguments()); bindings.put(ScriptShell.ARGV_BINDING_NAME, Settings.getApplicationArguments().getFiles(false)); result = shell.evaluate(script, bindings); diff --git a/source/net/filebot/cli/ScriptShell.java b/source/net/filebot/cli/ScriptShell.java index 5e27e310..ac5fadc5 100644 --- a/source/net/filebot/cli/ScriptShell.java +++ b/source/net/filebot/cli/ScriptShell.java @@ -21,12 +21,13 @@ public class ScriptShell { public static final String ARGV_BINDING_NAME = "args"; public static final String SHELL_BINDING_NAME = "__shell"; - public static final String SHELL_ARGV_BINDING_NAME = "__args"; + public static final String SHELL_CLI_BINDING_NAME = "__cli"; + public static final String SHELL_ARGS_BINDING_NAME = "__args"; private final ScriptEngine engine; private final ScriptProvider scriptProvider; - public ScriptShell(ScriptProvider scriptProvider, Map globals) throws ScriptException { + public ScriptShell(ScriptProvider scriptProvider, CmdlineInterface cli, Map globals) throws ScriptException { this.engine = createScriptEngine(); this.scriptProvider = scriptProvider; @@ -36,6 +37,7 @@ public class ScriptShell { // bind API objects bindings.put(SHELL_BINDING_NAME, this); + bindings.put(SHELL_CLI_BINDING_NAME, cli); // setup script context engine.getContext().setBindings(bindings, ScriptContext.GLOBAL_SCOPE); diff --git a/source/net/filebot/cli/ScriptShellBaseClass.java b/source/net/filebot/cli/ScriptShellBaseClass.java index 232ea5f5..94759b3a 100644 --- a/source/net/filebot/cli/ScriptShellBaseClass.java +++ b/source/net/filebot/cli/ScriptShellBaseClass.java @@ -78,6 +78,18 @@ public abstract class ScriptShellBaseClass extends Script { } } + private ArgumentBean getArgumentBean() { + return (ArgumentBean) getBinding().getVariable(ScriptShell.SHELL_ARGS_BINDING_NAME); + } + + private ScriptShell getShell() { + return (ScriptShell) getBinding().getVariable(ScriptShell.SHELL_BINDING_NAME); + } + + private CmdlineInterface getCLI() { + return (CmdlineInterface) getBinding().getVariable(ScriptShell.SHELL_CLI_BINDING_NAME); + } + public void include(String input) throws Throwable { try { executeScript(input, null, null, null); @@ -109,12 +121,11 @@ public abstract class ScriptShellBaseClass extends Script { parameters.putAll(bindings); } - parameters.put(ScriptShell.SHELL_ARGV_BINDING_NAME, argv != null ? argv.toArray(new String[0]) : new String[0]); + parameters.put(ScriptShell.SHELL_ARGS_BINDING_NAME, new ArgumentBean(argv != null ? argv.toArray(new String[0]) : new String[0])); parameters.put(ScriptShell.ARGV_BINDING_NAME, args != null ? new ArrayList(args) : new ArrayList()); // run given script - ScriptShell shell = (ScriptShell) getBinding().getVariable(ScriptShell.SHELL_BINDING_NAME); - return shell.runScript(input, parameters); + return getShell().runScript(input, parameters); } public Object tryQuietly(Closure c) { @@ -317,8 +328,6 @@ public abstract class ScriptShellBaseClass extends Script { action, conflict, query, filter, format, db, order, lang, output, encoding, strict, forceExtractAll } - private static final CmdlineInterface cli = new CmdlineOperations(); - public List rename(Map parameters) throws Exception { List input = getInputFileList(parameters); Map option = getDefaultOptions(parameters); @@ -327,9 +336,9 @@ public abstract class ScriptShellBaseClass extends Script { try { if (input.isEmpty() && !getInputFileMap(parameters).isEmpty()) { - return cli.rename(getInputFileMap(parameters), action, asString(option.get(Option.conflict))); + return getCLI().rename(getInputFileMap(parameters), action, asString(option.get(Option.conflict))); } else { - return cli.rename(input, action, asString(option.get(Option.conflict)), asString(option.get(Option.output)), asString(option.get(Option.format)), asString(option.get(Option.db)), asString(option.get(Option.query)), asString(option.get(Option.order)), asString(option.get(Option.filter)), asString(option.get(Option.lang)), strict); + return getCLI().rename(input, action, asString(option.get(Option.conflict)), asString(option.get(Option.output)), asString(option.get(Option.format)), asString(option.get(Option.db)), asString(option.get(Option.query)), asString(option.get(Option.order)), asString(option.get(Option.filter)), asString(option.get(Option.lang)), strict); } } catch (Exception e) { printException(e); @@ -344,7 +353,7 @@ public abstract class ScriptShellBaseClass extends Script { boolean strict = DefaultTypeTransformation.castToBoolean(option.get(Option.strict)); try { - return cli.getSubtitles(input, asString(option.get(Option.db)), asString(option.get(Option.query)), asString(option.get(Option.lang)), asString(option.get(Option.output)), asString(option.get(Option.encoding)), asString(option.get(Option.format)), strict); + return getCLI().getSubtitles(input, asString(option.get(Option.db)), asString(option.get(Option.query)), asString(option.get(Option.lang)), asString(option.get(Option.output)), asString(option.get(Option.encoding)), asString(option.get(Option.format)), strict); } catch (Exception e) { printException(e); } @@ -358,7 +367,7 @@ public abstract class ScriptShellBaseClass extends Script { boolean strict = DefaultTypeTransformation.castToBoolean(option.get(Option.strict)); try { - return cli.getMissingSubtitles(input, asString(option.get(Option.db)), asString(option.get(Option.query)), asString(option.get(Option.lang)), asString(option.get(Option.output)), asString(option.get(Option.encoding)), asString(option.get(Option.format)), strict); + return getCLI().getMissingSubtitles(input, asString(option.get(Option.db)), asString(option.get(Option.query)), asString(option.get(Option.lang)), asString(option.get(Option.output)), asString(option.get(Option.encoding)), asString(option.get(Option.format)), strict); } catch (Exception e) { printException(e); } @@ -370,7 +379,7 @@ public abstract class ScriptShellBaseClass extends Script { List input = getInputFileList(parameters); try { - return cli.check(input); + return getCLI().check(input); } catch (Exception e) { printException(e); } @@ -383,7 +392,7 @@ public abstract class ScriptShellBaseClass extends Script { Map option = getDefaultOptions(parameters); try { - return cli.compute(input, asString(option.get(Option.output)), asString(option.get(Option.encoding))); + return getCLI().compute(input, asString(option.get(Option.output)), asString(option.get(Option.encoding))); } catch (Exception e) { printException(e); } @@ -398,7 +407,7 @@ public abstract class ScriptShellBaseClass extends Script { boolean forceExtractAll = DefaultTypeTransformation.castToBoolean(option.get(Option.forceExtractAll)); try { - return cli.extract(input, asString(option.get(Option.output)), asString(option.get(Option.conflict)), filter, forceExtractAll); + return getCLI().extract(input, asString(option.get(Option.output)), asString(option.get(Option.conflict)), filter, forceExtractAll); } catch (Exception e) { printException(e); } @@ -410,7 +419,7 @@ public abstract class ScriptShellBaseClass extends Script { Map option = getDefaultOptions(parameters); try { - return cli.fetchEpisodeList(asString(option.get(Option.query)), asString(option.get(Option.format)), asString(option.get(Option.db)), asString(option.get(Option.order)), asString(option.get(Option.filter)), asString(option.get(Option.lang))); + return getCLI().fetchEpisodeList(asString(option.get(Option.query)), asString(option.get(Option.format)), asString(option.get(Option.db)), asString(option.get(Option.order)), asString(option.get(Option.filter)), asString(option.get(Option.lang))); } catch (Exception e) { printException(e); } @@ -426,7 +435,7 @@ public abstract class ScriptShellBaseClass extends Script { Map option = getDefaultOptions(parameters); try { - return cli.getMediaInfo(input, asString(option.get(Option.format)), asString(option.get(Option.filter))); + return getCLI().getMediaInfo(input, asString(option.get(Option.format)), asString(option.get(Option.filter))); } catch (Exception e) { printException(e); } @@ -484,14 +493,6 @@ public abstract class ScriptShellBaseClass extends Script { return files; } - private ArgumentBean getArgumentBean() { - try { - return new ArgumentBean((String[]) getBinding().getVariable(ScriptShell.SHELL_ARGV_BINDING_NAME)); - } catch (Exception e) { - throw new IllegalStateException(e.getMessage(), e); - } - } - private Map getDefaultOptions(Map parameters) throws Exception { Map options = new EnumMap(Option.class);