Added `-exec` option that works similar to `find -exec` and the `--def exec` option.

e.g.
```
filebot -rename $OPTS -exec echo {f}
filebot -rename $OPTS -exec echo {f} +
```
This commit is contained in:
Reinhard Pointner 2017-04-18 15:25:34 +08:00
parent 789c472876
commit aa10510e87
7 changed files with 171 additions and 51 deletions

View File

@ -27,6 +27,7 @@ import org.kohsuke.args4j.CmdLineParser;
import org.kohsuke.args4j.Option; import org.kohsuke.args4j.Option;
import org.kohsuke.args4j.ParserProperties; import org.kohsuke.args4j.ParserProperties;
import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler; import org.kohsuke.args4j.spi.ExplicitBooleanOptionHandler;
import org.kohsuke.args4j.spi.RestOfArgumentsHandler;
import net.filebot.ApplicationFolder; import net.filebot.ApplicationFolder;
import net.filebot.Language; import net.filebot.Language;
@ -140,6 +141,9 @@ public class ArgumentBean {
@Option(name = "--def", usage = "Define script variables", handler = BindingsHandler.class) @Option(name = "--def", usage = "Define script variables", handler = BindingsHandler.class)
public Map<String, String> defines = new LinkedHashMap<String, String>(); public Map<String, String> defines = new LinkedHashMap<String, String>();
@Option(name = "-exec", usage = "Execute command", handler = RestOfArgumentsHandler.class)
public List<String> exec = new ArrayList<String>();
@Argument @Argument
public List<String> arguments = new ArrayList<String>(); public List<String> arguments = new ArrayList<String>();
@ -307,6 +311,14 @@ public class ArgumentBean {
return Level.parse(log.toUpperCase()); return Level.parse(log.toUpperCase());
} }
public ExecCommand getExecCommand() {
try {
return exec == null || exec.isEmpty() ? null : ExecCommand.parse(exec, getOutputPath());
} catch (Exception e) {
throw new CmdlineException("Illegal exec expression: " + exec);
}
}
public PanelBuilder[] getPanelBuilders() { public PanelBuilder[] getPanelBuilders() {
// default multi panel mode // default multi panel mode
if (mode == null) { if (mode == null) {

View File

@ -55,7 +55,7 @@ public class ArgumentProcessor {
// rename files in linear order // rename files in linear order
if (args.list && args.rename) { if (args.list && args.rename) {
return cli.rename(args.getEpisodeListProvider(), args.getSearchQuery(), args.getExpressionFileFormat(), args.getExpressionFilter(), args.getSortOrder(), args.getLanguage().getLocale(), args.isStrict(), args.getFiles(true), args.getRenameAction(), args.getConflictAction(), args.getOutputPath()).isEmpty() ? 1 : 0; return cli.rename(args.getEpisodeListProvider(), args.getSearchQuery(), args.getExpressionFileFormat(), args.getExpressionFilter(), args.getSortOrder(), args.getLanguage().getLocale(), args.isStrict(), args.getFiles(true), args.getRenameAction(), args.getConflictAction(), args.getOutputPath(), args.getExecCommand()).isEmpty() ? 1 : 0;
} }
// print episode info // print episode info
@ -85,7 +85,7 @@ public class ArgumentProcessor {
} }
if (args.rename) { if (args.rename) {
cli.rename(files, args.getRenameAction(), args.getConflictAction(), args.getAbsoluteOutputFolder(), args.getExpressionFileFormat(), args.getDatasource(), args.getSearchQuery(), args.getSortOrder(), args.getExpressionFilter(), args.getLanguage().getLocale(), args.isStrict()); cli.rename(files, args.getRenameAction(), args.getConflictAction(), args.getAbsoluteOutputFolder(), args.getExpressionFileFormat(), args.getDatasource(), args.getSearchQuery(), args.getSortOrder(), args.getExpressionFilter(), args.getLanguage().getLocale(), args.isStrict(), args.getExecCommand());
} }
if (args.check) { if (args.check) {

View File

@ -23,9 +23,9 @@ import net.filebot.web.SortOrder;
public interface CmdlineInterface { public interface CmdlineInterface {
List<File> rename(Collection<File> files, RenameAction action, ConflictAction conflict, File output, ExpressionFileFormat format, Datasource db, String query, SortOrder order, ExpressionFilter filter, Locale locale, boolean strict) throws Exception; List<File> rename(Collection<File> files, RenameAction action, ConflictAction conflict, File output, ExpressionFileFormat format, Datasource db, String query, SortOrder order, ExpressionFilter filter, Locale locale, boolean strict, ExecCommand exec) throws Exception;
List<File> rename(EpisodeListProvider db, String query, ExpressionFileFormat format, ExpressionFilter filter, SortOrder order, Locale locale, boolean strict, List<File> files, RenameAction action, ConflictAction conflict, File output) throws Exception; List<File> rename(EpisodeListProvider db, String query, ExpressionFileFormat format, ExpressionFilter filter, SortOrder order, Locale locale, boolean strict, List<File> files, RenameAction action, ConflictAction conflict, File output, ExecCommand exec) throws Exception;
List<File> rename(Map<File, File> rename, RenameAction action, ConflictAction conflict) throws Exception; List<File> rename(Map<File, File> rename, RenameAction action, ConflictAction conflict) throws Exception;

View File

@ -1,6 +1,7 @@
package net.filebot.cli; package net.filebot.cli;
import static java.nio.charset.StandardCharsets.*; import static java.nio.charset.StandardCharsets.*;
import static java.util.Arrays.*;
import static java.util.Collections.*; import static java.util.Collections.*;
import static java.util.stream.Collectors.*; import static java.util.stream.Collectors.*;
import static net.filebot.Logging.*; import static net.filebot.Logging.*;
@ -84,25 +85,25 @@ import net.filebot.web.VideoHashSubtitleService;
public class CmdlineOperations implements CmdlineInterface { public class CmdlineOperations implements CmdlineInterface {
@Override @Override
public List<File> rename(Collection<File> files, RenameAction action, ConflictAction conflict, File output, ExpressionFileFormat format, Datasource db, String query, SortOrder order, ExpressionFilter filter, Locale locale, boolean strict) throws Exception { public List<File> rename(Collection<File> files, RenameAction action, ConflictAction conflict, File output, ExpressionFileFormat format, Datasource db, String query, SortOrder order, ExpressionFilter filter, Locale locale, boolean strict, ExecCommand exec) throws Exception {
// movie mode // movie mode
if (db instanceof MovieIdentificationService) { if (db instanceof MovieIdentificationService) {
return renameMovie(files, action, conflict, output, format, (MovieIdentificationService) db, query, filter, locale, strict); return renameMovie(files, action, conflict, output, format, (MovieIdentificationService) db, query, filter, locale, strict, exec);
} }
// series mode // series mode
if (db instanceof EpisodeListProvider) { if (db instanceof EpisodeListProvider) {
return renameSeries(files, action, conflict, output, format, (EpisodeListProvider) db, query, order, filter, locale, strict); return renameSeries(files, action, conflict, output, format, (EpisodeListProvider) db, query, order, filter, locale, strict, exec);
} }
// music mode // music mode
if (db instanceof MusicIdentificationService) { if (db instanceof MusicIdentificationService) {
return renameMusic(files, action, conflict, output, format, (MusicIdentificationService) db); return renameMusic(files, action, conflict, output, format, singletonList((MusicIdentificationService) db), exec);
} }
// generic file / xattr mode // generic file / xattr mode
if (db instanceof XattrMetaInfoProvider) { if (db instanceof XattrMetaInfoProvider) {
return renameFiles(files, action, conflict, output, format, (XattrMetaInfoProvider) db, filter, strict); return renameFiles(files, action, conflict, output, format, (XattrMetaInfoProvider) db, filter, strict, exec);
} }
// auto-detect mode for each fileset // auto-detect mode for each fileset
@ -114,16 +115,16 @@ public class CmdlineOperations implements CmdlineInterface {
for (Type key : it.getKey().types()) { for (Type key : it.getKey().types()) {
switch (key) { switch (key) {
case Movie: case Movie:
results.addAll(renameMovie(it.getValue(), action, conflict, output, format, TheMovieDB, query, filter, locale, strict)); results.addAll(renameMovie(it.getValue(), action, conflict, output, format, TheMovieDB, query, filter, locale, strict, exec));
break; break;
case Series: case Series:
results.addAll(renameSeries(it.getValue(), action, conflict, output, format, TheTVDB, query, order, filter, locale, strict)); results.addAll(renameSeries(it.getValue(), action, conflict, output, format, TheTVDB, query, order, filter, locale, strict, exec));
break; break;
case Anime: case Anime:
results.addAll(renameSeries(it.getValue(), action, conflict, output, format, AniDB, query, order, filter, locale, strict)); results.addAll(renameSeries(it.getValue(), action, conflict, output, format, AniDB, query, order, filter, locale, strict, exec));
break; break;
case Music: case Music:
results.addAll(renameMusic(it.getValue(), action, conflict, output, format, MediaInfoID3, AcoustID)); results.addAll(renameMusic(it.getValue(), action, conflict, output, format, asList(MediaInfoID3, AcoustID), exec)); // prefer existing ID3 tags and use acoustid only when necessary
break; break;
} }
} }
@ -140,7 +141,7 @@ public class CmdlineOperations implements CmdlineInterface {
} }
@Override @Override
public List<File> rename(EpisodeListProvider db, String query, ExpressionFileFormat format, ExpressionFilter filter, SortOrder order, Locale locale, boolean strict, List<File> files, RenameAction action, ConflictAction conflict, File outputDir) throws Exception { public List<File> rename(EpisodeListProvider db, String query, ExpressionFileFormat format, ExpressionFilter filter, SortOrder order, Locale locale, boolean strict, List<File> files, RenameAction action, ConflictAction conflict, File outputDir, ExecCommand exec) throws Exception {
// match files and episodes in linear order // match files and episodes in linear order
List<Episode> episodes = fetchEpisodeList(db, query, filter, order, locale, strict); List<Episode> episodes = fetchEpisodeList(db, query, filter, order, locale, strict);
@ -150,16 +151,16 @@ public class CmdlineOperations implements CmdlineInterface {
} }
// rename episodes // rename episodes
return renameAll(formatMatches(matches, format, outputDir), action, conflict, matches); return renameAll(formatMatches(matches, format, outputDir), action, conflict, matches, exec);
} }
@Override @Override
public List<File> rename(Map<File, File> renameMap, RenameAction renameAction, ConflictAction conflict) throws Exception { public List<File> rename(Map<File, File> renameMap, RenameAction renameAction, ConflictAction conflict) throws Exception {
// generic rename function that can be passed any set of files // generic rename function that can be passed any set of files
return renameAll(renameMap, renameAction, conflict, null); return renameAll(renameMap, renameAction, conflict, null, null);
} }
public List<File> renameSeries(Collection<File> files, RenameAction renameAction, ConflictAction conflictAction, File outputDir, ExpressionFileFormat format, EpisodeListProvider db, String query, SortOrder sortOrder, ExpressionFilter filter, Locale locale, boolean strict) throws Exception { public List<File> renameSeries(Collection<File> files, RenameAction renameAction, ConflictAction conflictAction, File outputDir, ExpressionFileFormat format, EpisodeListProvider db, String query, SortOrder sortOrder, ExpressionFilter filter, Locale locale, boolean strict, ExecCommand exec) throws Exception {
log.config(format("Rename episodes using [%s]", db.getName())); log.config(format("Rename episodes using [%s]", db.getName()));
// ignore sample files // ignore sample files
@ -242,7 +243,7 @@ public class CmdlineOperations implements CmdlineInterface {
matches.addAll(derivateMatches); matches.addAll(derivateMatches);
// rename episodes // rename episodes
return renameAll(formatMatches(matches, format, outputDir), renameAction, conflictAction, matches); return renameAll(formatMatches(matches, format, outputDir), renameAction, conflictAction, matches, exec);
} }
private List<Match<File, Object>> matchEpisodes(Collection<File> files, Collection<Episode> episodes, boolean strict) throws Exception { private List<Match<File, Object>> matchEpisodes(Collection<File> files, Collection<Episode> episodes, boolean strict) throws Exception {
@ -303,7 +304,7 @@ public class CmdlineOperations implements CmdlineInterface {
return new ArrayList<Episode>(episodes); return new ArrayList<Episode>(episodes);
} }
public List<File> renameMovie(Collection<File> files, RenameAction renameAction, ConflictAction conflictAction, File outputDir, ExpressionFileFormat format, MovieIdentificationService service, String query, ExpressionFilter filter, Locale locale, boolean strict) throws Exception { public List<File> renameMovie(Collection<File> files, RenameAction renameAction, ConflictAction conflictAction, File outputDir, ExpressionFileFormat format, MovieIdentificationService service, String query, ExpressionFilter filter, Locale locale, boolean strict, ExecCommand exec) throws Exception {
log.config(format("Rename movies using [%s]", service.getName())); log.config(format("Rename movies using [%s]", service.getName()));
// ignore sample files // ignore sample files
@ -480,10 +481,10 @@ public class CmdlineOperations implements CmdlineInterface {
}); });
// rename movies // rename movies
return renameAll(formatMatches(matches, format, outputDir), renameAction, conflictAction, matches); return renameAll(formatMatches(matches, format, outputDir), renameAction, conflictAction, matches, exec);
} }
public List<File> renameMusic(Collection<File> files, RenameAction renameAction, ConflictAction conflictAction, File outputDir, ExpressionFileFormat format, MusicIdentificationService... services) throws Exception { public List<File> renameMusic(Collection<File> files, RenameAction renameAction, ConflictAction conflictAction, File outputDir, ExpressionFileFormat format, List<MusicIdentificationService> services, ExecCommand exec) throws Exception {
List<File> audioFiles = sortByUniquePath(filter(files, AUDIO_FILES, VIDEO_FILES)); List<File> audioFiles = sortByUniquePath(filter(files, AUDIO_FILES, VIDEO_FILES));
// check audio files against all services if necessary // check audio files against all services if necessary
@ -491,24 +492,26 @@ public class CmdlineOperations implements CmdlineInterface {
LinkedHashSet<File> remaining = new LinkedHashSet<File>(audioFiles); LinkedHashSet<File> remaining = new LinkedHashSet<File>(audioFiles);
// check audio files against all services // check audio files against all services
for (int i = 0; i < services.length && remaining.size() > 0; i++) { for (MusicIdentificationService service : services) {
log.config(format("Rename music using %s", services[i].getIdentifier())); if (remaining.size() > 0) {
services[i].lookup(remaining).forEach((file, music) -> { log.config(format("Rename music using %s", service.getIdentifier()));
if (music != null) { service.lookup(remaining).forEach((file, music) -> {
matches.add(new Match<File, AudioTrack>(file, music.clone())); if (music != null) {
remaining.remove(file); matches.add(new Match<File, AudioTrack>(file, music.clone()));
} remaining.remove(file);
}); }
});
}
} }
// error logging // error logging
remaining.forEach(f -> log.warning(format("Failed to process music file: %s", f))); remaining.forEach(f -> log.warning(format("Failed to process music file: %s", f)));
// rename movies // rename movies
return renameAll(formatMatches(matches, format, outputDir), renameAction, conflictAction, null); return renameAll(formatMatches(matches, format, outputDir), renameAction, conflictAction, null, exec);
} }
public List<File> renameFiles(Collection<File> files, RenameAction renameAction, ConflictAction conflictAction, File outputDir, ExpressionFileFormat format, XattrMetaInfoProvider service, ExpressionFilter filter, boolean strict) throws Exception { public List<File> renameFiles(Collection<File> files, RenameAction renameAction, ConflictAction conflictAction, File outputDir, ExpressionFileFormat format, XattrMetaInfoProvider service, ExpressionFilter filter, boolean strict, ExecCommand exec) throws Exception {
log.config(format("Rename files using [%s]", service.getName())); log.config(format("Rename files using [%s]", service.getName()));
Map<File, File> renameMap = new LinkedHashMap<File, File>(); Map<File, File> renameMap = new LinkedHashMap<File, File>();
@ -525,7 +528,7 @@ public class CmdlineOperations implements CmdlineInterface {
} }
}); });
return renameAll(renameMap, renameAction, conflictAction, null); return renameAll(renameMap, renameAction, conflictAction, null, exec);
} }
private Map<File, Object> getContext(List<Match<File, ?>> matches) { private Map<File, Object> getContext(List<Match<File, ?>> matches) {
@ -533,7 +536,7 @@ public class CmdlineOperations implements CmdlineInterface {
@Override @Override
public Set<Entry<File, Object>> entrySet() { public Set<Entry<File, Object>> entrySet() {
return matches.stream().collect(toMap(it -> it.getValue(), it -> (Object) it.getCandidate())).entrySet(); return matches.stream().collect(toMap(it -> it.getValue(), it -> (Object) it.getCandidate(), (a, b) -> a, LinkedHashMap::new)).entrySet();
} }
}; };
} }
@ -570,7 +573,7 @@ public class CmdlineOperations implements CmdlineInterface {
return renameMap; return renameMap;
} }
protected List<File> renameAll(Map<File, File> renameMap, RenameAction renameAction, ConflictAction conflictAction, List<Match<File, ?>> matches) throws Exception { protected List<File> renameAll(Map<File, File> renameMap, RenameAction renameAction, ConflictAction conflictAction, List<Match<File, ?>> matches, ExecCommand exec) throws Exception {
if (renameMap.isEmpty()) { if (renameMap.isEmpty()) {
throw new CmdlineException("Failed to identify or process any files"); throw new CmdlineException("Failed to identify or process any files");
} }
@ -596,7 +599,7 @@ public class CmdlineOperations implements CmdlineInterface {
} }
// do not allow abuse of online databases by repeatedly processing the same files // do not allow abuse of online databases by repeatedly processing the same files
if (matches != null && equalsFileContent(source, destination)) { if (matches != null && renameAction.canRevert() && source.length() > 0 && equalsFileContent(source, destination)) {
throw new CmdlineException(String.format("Failed to process [%s] because [%s] is an exact copy and already exists", source, destination)); throw new CmdlineException(String.format("Failed to process [%s] because [%s] is an exact copy and already exists", source, destination));
} }
@ -636,33 +639,39 @@ public class CmdlineOperations implements CmdlineInterface {
} }
} finally { } finally {
// update history and xattr metadata // update history and xattr metadata
writeHistory(renameAction, renameLog, matches); if (renameLog.size() > 0) {
writeHistory(renameAction, renameLog, matches);
}
// print number of processed files // print number of processed files
log.fine(format("Processed %d files", renameLog.size())); log.fine(format("Processed %d files", renameLog.size()));
} }
// new file names // execute command
if (exec != null) {
Map<File, Object> context = renameLog.values().stream().filter(Objects::nonNull).collect(toMap(f -> f, f -> xattr.getMetaInfo(f), (a, b) -> a, LinkedHashMap::new));
if (context.size() > 0) {
exec.execute(context.entrySet().stream().map(m -> new MediaBindingBean(m.getValue(), m.getKey(), context)).toArray(MediaBindingBean[]::new));
}
}
// destination files may include null values
return new ArrayList<File>(renameLog.values()); return new ArrayList<File>(renameLog.values());
} }
protected void writeHistory(RenameAction action, Map<File, File> log, List<Match<File, ?>> matches) { protected void writeHistory(RenameAction action, Map<File, File> log, List<Match<File, ?>> matches) {
if (log.isEmpty() || !action.canRevert()) {
return;
}
// write rename history // write rename history
HistorySpooler.getInstance().append(log.entrySet()); if (action.canRevert()) {
HistorySpooler.getInstance().append(log.entrySet());
}
// write xattr metadata // write xattr metadata
if (matches != null) { if (matches != null) {
for (Match<File, ?> match : matches) { for (Match<File, ?> match : matches) {
File source = match.getValue(); if (match.getCandidate() != null) {
Object infoObject = match.getCandidate(); File destination = log.get(match.getValue());
if (infoObject != null) {
File destination = log.get(source);
if (destination != null && destination.isFile()) { if (destination != null && destination.isFile()) {
xattr.setMetaInfo(destination, infoObject, source.getName()); xattr.setMetaInfo(destination, match.getCandidate(), match.getValue().getName());
} }
} }
} }

View File

@ -69,10 +69,10 @@ public class CmdlineOperationsTextUI extends CmdlineOperations {
} }
@Override @Override
public List<File> renameAll(Map<File, File> renameMap, RenameAction renameAction, ConflictAction conflictAction, List<Match<File, ?>> matches) throws Exception { public List<File> renameAll(Map<File, File> renameMap, RenameAction renameAction, ConflictAction conflictAction, List<Match<File, ?>> matches, ExecCommand exec) throws Exception {
// default behavior if rename map is empty // default behavior if rename map is empty
if (renameMap.isEmpty()) { if (renameMap.isEmpty()) {
return super.renameAll(renameMap, renameAction, conflictAction, matches); return super.renameAll(renameMap, renameAction, conflictAction, matches, exec);
} }
// manually confirm each file mapping // manually confirm each file mapping
@ -91,7 +91,7 @@ public class CmdlineOperationsTextUI extends CmdlineOperations {
return emptyList(); return emptyList();
} }
return super.renameAll(selection.stream().collect(toMap(Entry::getKey, Entry::getValue, (a, b) -> a, LinkedHashMap::new)), renameAction, conflictAction, matches); return super.renameAll(selection.stream().collect(toMap(Entry::getKey, Entry::getValue, (a, b) -> a, LinkedHashMap::new)), renameAction, conflictAction, matches, exec);
} }
@Override @Override

View File

@ -0,0 +1,99 @@
package net.filebot.cli;
import static java.util.stream.Collectors.*;
import static net.filebot.Logging.*;
import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.stream.Stream;
import javax.script.ScriptException;
import net.filebot.format.ExpressionFormat;
import net.filebot.format.MediaBindingBean;
public class ExecCommand {
private List<ExpressionFormat> template;
private boolean parallel;
private File directory;
public ExecCommand(List<ExpressionFormat> template, boolean parallel, File directory) {
this.template = template;
this.parallel = parallel;
this.directory = directory;
}
public void execute(MediaBindingBean... group) throws IOException, InterruptedException {
if (parallel) {
executeParallel(group);
} else {
executeSequence(group);
}
}
private void executeSequence(MediaBindingBean... group) throws IOException, InterruptedException {
// collect unique commands
List<List<String>> commands = Stream.of(group).map(v -> {
return template.stream().map(t -> getArgumentValue(t, v)).filter(Objects::nonNull).collect(toList());
}).distinct().collect(toList());
// execute unique commands
for (List<String> command : commands) {
execute(command);
}
}
private void executeParallel(MediaBindingBean... group) throws IOException, InterruptedException {
// collect single command
List<String> command = template.stream().flatMap(t -> {
return Stream.of(group).map(v -> getArgumentValue(t, v)).filter(Objects::nonNull).distinct();
}).collect(toList());
// execute single command
execute(command);
}
private String getArgumentValue(ExpressionFormat template, MediaBindingBean variables) {
try {
return template.format(variables);
} catch (Exception e) {
debug.warning(cause(template.getExpression(), e));
}
return null;
}
private void execute(List<String> command) throws IOException, InterruptedException {
ProcessBuilder process = new ProcessBuilder(command);
process.directory(directory);
process.inheritIO();
debug.finest(message("Execute", command));
int exitCode = process.start().waitFor();
if (exitCode != 0) {
throw new IOException(String.format("%s failed with exit code %d", command, exitCode));
}
}
public static ExecCommand parse(List<String> args, File directory) throws ScriptException {
// execute one command per file or one command with many file arguments
boolean parallel = args.lastIndexOf("+") == args.size() - 1;
if (parallel) {
args = args.subList(0, args.size() - 1);
}
List<ExpressionFormat> template = new ArrayList<ExpressionFormat>();
for (String argument : args) {
template.add(new ExpressionFormat(argument));
}
return new ExecCommand(template, parallel, directory);
}
}

View File

@ -339,7 +339,7 @@ public abstract class ScriptShellBaseClass extends Script {
try { try {
if (files.size() > 0) { if (files.size() > 0) {
return getCLI().rename(files, action, args.getConflictAction(), args.getAbsoluteOutputFolder(), args.getExpressionFileFormat(), args.getDatasource(), args.getSearchQuery(), args.getSortOrder(), args.getExpressionFilter(), args.getLanguage().getLocale(), args.isStrict()); return getCLI().rename(files, action, args.getConflictAction(), args.getAbsoluteOutputFolder(), args.getExpressionFileFormat(), args.getDatasource(), args.getSearchQuery(), args.getSortOrder(), args.getExpressionFilter(), args.getLanguage().getLocale(), args.isStrict(), args.getExecCommand());
} }
if (map.size() > 0) { if (map.size() > 0) {