001/*
002 * (C) Copyright 2014-2015 Nuxeo SA (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     vpasquier <vpasquier@nuxeo.com>
018 */
019package org.nuxeo.binary.metadata.internals;
020
021import java.io.File;
022import java.io.IOException;
023import java.io.InputStream;
024import java.nio.file.Files;
025import java.nio.file.StandardCopyOption;
026import java.text.ParseException;
027import java.text.SimpleDateFormat;
028import java.util.ArrayList;
029import java.util.Arrays;
030import java.util.Calendar;
031import java.util.Collection;
032import java.util.Collections;
033import java.util.Date;
034import java.util.HashMap;
035import java.util.List;
036import java.util.Map;
037import java.util.regex.Pattern;
038import java.util.stream.Collectors;
039
040import org.apache.commons.io.FilenameUtils;
041import org.apache.commons.lang.ObjectUtils;
042import org.apache.commons.logging.Log;
043import org.apache.commons.logging.LogFactory;
044import org.codehaus.jackson.map.ObjectMapper;
045import org.codehaus.jackson.type.TypeReference;
046import org.nuxeo.binary.metadata.api.BinaryMetadataConstants;
047import org.nuxeo.binary.metadata.api.BinaryMetadataException;
048import org.nuxeo.binary.metadata.api.BinaryMetadataProcessor;
049import org.nuxeo.ecm.core.api.Blob;
050import org.nuxeo.ecm.core.api.CloseableFile;
051import org.nuxeo.ecm.core.api.impl.blob.FileBlob;
052import org.nuxeo.ecm.platform.commandline.executor.api.CmdParameters;
053import org.nuxeo.ecm.platform.commandline.executor.api.CommandAvailability;
054import org.nuxeo.ecm.platform.commandline.executor.api.CommandLineExecutorService;
055import org.nuxeo.ecm.platform.commandline.executor.api.CommandNotAvailable;
056import org.nuxeo.ecm.platform.commandline.executor.api.ExecResult;
057import org.nuxeo.runtime.api.Framework;
058
059/**
060 * @since 7.1
061 */
062public class ExifToolProcessor implements BinaryMetadataProcessor {
063
064    private static final Log log = LogFactory.getLog(ExifToolProcessor.class);
065
066    private static final String META_NON_USED_SOURCE_FILE = "SourceFile";
067
068    private static final String DATE_FORMAT_PATTERN = "yyyy:MM:dd HH:mm:ss";
069
070    private static final String EXIF_IMAGE_DATE_TIME = "EXIF:DateTime";
071
072    private static final String EXIF_PHOTO_DATE_TIME_ORIGINAL = "EXIF:DateTimeOriginal";
073
074    private static final String EXIF_PHOTO_DATE_TIME_DIGITIZED = "EXIF:DateTimeDigitized";
075
076    protected final ObjectMapper jacksonMapper;
077
078    protected final CommandLineExecutorService commandLineService;
079
080    public ExifToolProcessor() {
081        jacksonMapper = new ObjectMapper();
082        commandLineService = Framework.getLocalService(CommandLineExecutorService.class);
083    }
084
085    @Override
086    public Blob writeMetadata(Blob blob, Map<String, Object> metadata, boolean ignorePrefix) {
087        String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_WRITE_NOPREFIX
088                : BinaryMetadataConstants.EXIFTOOL_WRITE;
089        CommandAvailability ca = commandLineService.getCommandAvailability(command);
090        if (!ca.isAvailable()) {
091            throw new BinaryMetadataException("Command '" + command + "' is not available.");
092        }
093        if (blob == null) {
094            throw new BinaryMetadataException("The following command " + ca + " cannot be executed with a null blob");
095        }
096        try {
097            Blob newBlob = getTemporaryBlob(blob);
098            CmdParameters params = commandLineService.getDefaultCmdParameters();
099            params.addNamedParameter("inFilePath", newBlob.getFile());
100            params.addNamedParameter("tagList", getCommandTags(metadata));
101            ExecResult er = commandLineService.execCommand(command, params);
102            boolean success = er.isSuccessful();
103            if (!success) {
104                log.error("There was an error executing " + "the following command: " + er.getCommandLine() + ". \n"
105                        + er.getOutput().get(0));
106                return null;
107            }
108            newBlob.setMimeType(blob.getMimeType());
109            newBlob.setEncoding(blob.getEncoding());
110            newBlob.setFilename(blob.getFilename());
111            return newBlob;
112        } catch (CommandNotAvailable commandNotAvailable) {
113            throw new BinaryMetadataException("Command '" + command + "' is not available.", commandNotAvailable);
114        } catch (IOException ioException) {
115            throw new BinaryMetadataException(ioException);
116        }
117    }
118
119    protected Map<String, Object> readMetadata(String command, Blob blob, List<String> metadata, boolean ignorePrefix) {
120        CommandAvailability ca = commandLineService.getCommandAvailability(command);
121        if (!ca.isAvailable()) {
122            throw new BinaryMetadataException("Command '" + command + "' is not available.");
123        }
124        if (blob == null) {
125            throw new BinaryMetadataException("The following command " + ca + " cannot be executed with a null blob");
126        }
127        try {
128            ExecResult er;
129            try (CloseableFile source = getTemporaryFile(blob)) {
130                CmdParameters params = commandLineService.getDefaultCmdParameters();
131                params.addNamedParameter("inFilePath", source.getFile());
132                if (metadata != null) {
133                    params.addNamedParameter("tagList", getCommandTags(metadata));
134                }
135                er = commandLineService.execCommand(command, params);
136            }
137            return returnResultMap(er);
138        } catch (CommandNotAvailable commandNotAvailable) {
139            throw new RuntimeException("Command '" + command + "' is not available.", commandNotAvailable);
140        } catch (IOException ioException) {
141            throw new BinaryMetadataException(ioException);
142        }
143    }
144
145    @Override
146    public Map<String, Object> readMetadata(Blob blob, List<String> metadata, boolean ignorePrefix) {
147        String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_READ_TAGLIST_NOPREFIX
148                : BinaryMetadataConstants.EXIFTOOL_READ_TAGLIST;
149        return readMetadata(command, blob, metadata, ignorePrefix);
150    }
151
152    @Override
153    public Map<String, Object> readMetadata(Blob blob, boolean ignorePrefix) {
154        String command = ignorePrefix ? BinaryMetadataConstants.EXIFTOOL_READ_NOPREFIX
155                : BinaryMetadataConstants.EXIFTOOL_READ;
156        return readMetadata(command, blob, null, ignorePrefix);
157    }
158
159    /*--------------------------- Utils ------------------------*/
160
161    protected Map<String, Object> returnResultMap(ExecResult er) throws IOException {
162        if (!er.isSuccessful()) {
163            throw new BinaryMetadataException("There was an error executing " + "the following command: "
164                    + er.getCommandLine(), er.getError());
165        }
166        StringBuilder sb = new StringBuilder();
167        for (String line : er.getOutput()) {
168            sb.append(line);
169        }
170        String jsonOutput = sb.toString();
171        List<Map<String, Object>> resultList = jacksonMapper.readValue(jsonOutput,
172                new TypeReference<List<HashMap<String, Object>>>() {
173                });
174        Map<String, Object> resultMap = resultList.get(0);
175        // Remove the SourceFile metadata injected automatically by ExifTool.
176        resultMap.remove(META_NON_USED_SOURCE_FILE);
177        parseDates(resultMap);
178        return resultMap;
179    }
180
181    /**
182     * @since 7.4
183     */
184    protected void parseDates(Map<String, Object> resultMap) {
185        for (String prop : new String[] { EXIF_IMAGE_DATE_TIME, EXIF_PHOTO_DATE_TIME_ORIGINAL,
186                EXIF_PHOTO_DATE_TIME_DIGITIZED }) {
187            if (resultMap.containsKey(prop)) {
188                Object dateObject = resultMap.get(prop);
189                if (dateObject instanceof String) {
190                    SimpleDateFormat f = new SimpleDateFormat(DATE_FORMAT_PATTERN);
191                    try {
192                        Date date = f.parse((String) dateObject);
193                        resultMap.put(prop, date);
194                    } catch (ParseException e) {
195                        log.error("Could not parse property " + prop, e);
196                    }
197                }
198            }
199        }
200    }
201
202    protected List<String> getCommandTags(List<String> metadataList) {
203        return metadataList.stream().map(tag -> "-" + tag).collect(Collectors.toList());
204    }
205
206    protected List<String> getCommandTags(Map<String, Object> metadataMap) {
207        List<String> commandTags = new ArrayList<>();
208        for (String tag : metadataMap.keySet()) {
209            Object metadataValue = metadataMap.get(tag);
210            if (metadataValue instanceof Collection) {
211                commandTags.addAll(buildCommandTagsFromCollection(tag, (Collection<Object>) metadataValue));
212            } else if (metadataValue instanceof Object[]) {
213                commandTags.addAll(buildCommandTagsFromCollection(tag, Arrays.asList((Object[]) metadataValue)));
214            } else if (metadataValue instanceof Calendar) {
215                commandTags.add(buildCommandTagFromDate(tag, ((Calendar) metadataValue).getTime()));
216            } else {
217                commandTags.add(buildCommandTag(tag, metadataValue));
218            }
219        }
220        return commandTags;
221    }
222
223    /**
224     * @since 8.3
225     */
226    private String buildCommandTag(String tag, Object value) {
227        return "-" + tag + "=" + ObjectUtils.toString(value);
228    }
229
230    /**
231     * @since 8.3
232     */
233    private List<String> buildCommandTagsFromCollection(String tag, Collection<Object> values) {
234        return values.isEmpty() ? Collections.singletonList("-" + tag + "=") : values.stream().map(
235            val -> buildCommandTag(tag, val)
236        ).collect(Collectors.toList());
237    }
238
239    /**
240     * @since 8.4
241     */
242    private String buildCommandTagFromDate(String tag, Date date) {
243        SimpleDateFormat formatter = new SimpleDateFormat(DATE_FORMAT_PATTERN);
244        return "-" + tag + "=" + formatter.format(date);
245    }
246
247    protected Pattern VALID_EXT = Pattern.compile("[a-zA-Z0-9]*");
248
249    /**
250     * We don't want to rely on {@link Blob#getCloseableFile} because it may return the original and we always want a
251     * temporary one to be sure we have a clean filename to pass.
252     *
253     * @since 7.4
254     */
255    protected CloseableFile getTemporaryFile(Blob blob) throws IOException {
256        String ext = FilenameUtils.getExtension(blob.getFilename());
257        if (!VALID_EXT.matcher(ext).matches()) {
258            ext = "tmp";
259        }
260        File tmp = Framework.createTempFile("nxblob-", '.' + ext);
261        File file = blob.getFile();
262        if (file == null) {
263            // if we don't have an underlying File, use a temporary File
264            try (InputStream in = blob.getStream()) {
265                Files.copy(in, tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
266            }
267        } else {
268            // attempt to create a symbolic link, which would be cheaper than a copy
269            tmp.delete();
270            try {
271                Files.createSymbolicLink(tmp.toPath(), file.toPath().toAbsolutePath());
272            } catch (IOException | UnsupportedOperationException e) {
273                // symbolic link not supported, do a copy instead
274                Files.copy(file.toPath(), tmp.toPath());
275            }
276        }
277        return new CloseableFile(tmp, true);
278    }
279
280    /**
281     * Gets a new blob on a temporary file which is a copy of the blob's.
282     *
283     * @since 7.4
284     */
285    protected Blob getTemporaryBlob(Blob blob) throws IOException {
286        String ext = FilenameUtils.getExtension(blob.getFilename());
287        if (!VALID_EXT.matcher(ext).matches()) {
288            ext = "tmp";
289        }
290        Blob newBlob = new FileBlob('.' + ext);
291        File tmp = newBlob.getFile();
292        File file = blob.getFile();
293        if (file == null) {
294            try (InputStream in = blob.getStream()) {
295                Files.copy(in, tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
296            }
297        } else {
298            // do a copy
299            Files.copy(file.toPath(), tmp.toPath(), StandardCopyOption.REPLACE_EXISTING);
300        }
301        return newBlob;
302    }
303
304}