001package net.filebot.format;
002
003import static java.nio.charset.StandardCharsets.*;
004import static java.util.Arrays.*;
005import static java.util.Collections.*;
006import static java.util.stream.Collectors.*;
007import static net.filebot.CachedResource.*;
008import static net.filebot.Settings.*;
009import static net.filebot.media.XattrMetaInfo.*;
010import static net.filebot.similarity.Normalization.*;
011import static net.filebot.util.FileUtilities.*;
012import static net.filebot.util.RegularExpressions.*;
013import static net.filebot.util.ZipUtilities.*;
014
015import java.io.File;
016import java.net.URI;
017import java.nio.ByteBuffer;
018import java.util.AbstractList;
019import java.util.AbstractMap;
020import java.util.LinkedHashMap;
021import java.util.List;
022import java.util.Locale;
023import java.util.Map;
024import java.util.RandomAccess;
025import java.util.Set;
026import java.util.function.Function;
027import java.util.regex.Pattern;
028
029import org.jsoup.Jsoup;
030
031import groovy.json.JsonSlurper;
032import groovy.xml.XmlSlurper;
033
034import net.filebot.Cache;
035import net.filebot.CacheType;
036import net.filebot.InvalidInputException;
037import net.filebot.MemoryCache;
038import net.filebot.Resource;
039import net.filebot.platform.mac.MacAppUtilities;
040
041public abstract class DataResource {
042
043        public abstract Object getResource();
044
045        public abstract boolean isStale();
046
047        public abstract byte[] bytes() throws Exception;
048
049        public Map<Object, Object> csv() throws Exception {
050                Map<Object, Object> value = new LinkedHashMap<Object, Object>();
051
052                // read CSV file
053                List<Pattern> delimiter = asList(TAB, EQUALS, SEMICOLON, PIPE, COLON, COMMA);
054
055                for (String line : lines()) {
056                        if (line.startsWith("#")) {
057                                continue;
058                        }
059
060                        for (Pattern d : delimiter) {
061                                String[] field = d.split(line, 2);
062                                if (field.length >= 2) {
063                                        // add key=value match
064                                        value.put(field[0], field[1]);
065                                        // lock delimiter
066                                        delimiter = singletonList(d);
067                                        break;
068                                }
069                        }
070                }
071
072                return value;
073        }
074
075        public String text() throws Exception {
076                ByteBuffer data = ByteBuffer.wrap(bytes());
077
078                // users passing Microsoft Excel spreadsheet files instead of plain/text CSV files is surprisingly common
079                if (isZipFile(data)) {
080                        throw new InvalidInputException(this + " is a ZIP archive and not a plain/text file");
081                }
082
083                return decodeTextContent(data, true, UTF_8);
084        }
085
086        public List<String> lines() throws Exception {
087                return asList(NEWLINE.split(text()));
088        }
089
090        public Object xml() throws Exception {
091                return new XmlSlurper().parseText(text());
092        }
093
094        public Object json() throws Exception {
095                return new JsonSlurper().parseText(text());
096        }
097
098        public Object html() throws Exception {
099                return Jsoup.parse(text());
100        }
101
102        public static DataResource local(File file) throws Exception {
103                return getDataResource(file, f -> {
104                        if (isMacSandbox()) {
105                                MacAppUtilities.askUnlockFolders(null, singleton(file));
106                        }
107                        return new Memoized(new Local(f));
108                });
109        }
110
111        public static DataResource remote(URI uri) throws Exception {
112                return getDataResource(uri, f -> {
113                        return new Memoized(new Remote(f));
114                });
115        }
116
117        private static <T> DataResource getDataResource(T resource, Function<T, DataResource> type) throws Exception {
118                DataResource data = cache.getIfPresent(resource);
119                if (data == null || data.isStale()) {
120                        data = type.apply(resource);
121                        cache.put(resource, data);
122                }
123                return data;
124        }
125
126        private static final MemoryCache<Object, DataResource> cache = MemoryCache.forMinutes();
127
128        private static class Local extends DataResource {
129
130                private final File file;
131                private final boolean directory;
132                private final long lastModified;
133
134                public Local(File file) {
135                        this.file = file;
136                        this.directory = file.isDirectory();
137                        this.lastModified = file.lastModified();
138                }
139
140                @Override
141                public Object getResource() {
142                        return file;
143                }
144
145                @Override
146                public boolean isStale() {
147                        return this.lastModified != file.lastModified();
148                }
149
150                @Override
151                public byte[] bytes() throws Exception {
152                        if (!file.isFile()) {
153                                throw new InvalidInputException("File not found: " + file);
154                        }
155                        if (file.length() > ONE_GIGABYTE) {
156                                throw new InvalidInputException("File is too large and probably not a plain/text file: " + file);
157                        }
158                        return readFile(file);
159                }
160
161                @Override
162                public Map<Object, Object> csv() throws Exception {
163                        if (directory) {
164                                return getMediaIndex();
165                        }
166                        return super.csv();
167                }
168
169                @Override
170                public List<String> lines() throws Exception {
171                        if (directory) {
172                                return getDirectoryIndex();
173                        }
174                        return super.lines();
175                }
176
177                public List<String> getDirectoryIndex() {
178                        return asList(file.list());
179                }
180
181                public Map<Object, Object> getMediaIndex() {
182                        Map<Object, Object> value = new LinkedHashMap<Object, Object>();
183                        // read xattr file structure
184                        for (File f : listFiles(file, NOT_HIDDEN)) {
185                                Object m = xattr.getMetaInfo(f);
186                                if (m != null) {
187                                        value.put(f, m);
188                                }
189                        }
190                        return value;
191                }
192        }
193
194        private static class Remote extends DataResource {
195
196                private final URI url;
197
198                public Remote(URI url) {
199                        this.url = url;
200                }
201
202                @Override
203                public Object getResource() {
204                        return url;
205                }
206
207                @Override
208                public boolean isStale() {
209                        return false;
210                }
211
212                @Override
213                public byte[] bytes() throws Exception {
214                        return Cache.getConcurrentCache(Cache.URL, CacheType.Monthly).url(url.toURL()).get();
215                }
216        }
217
218        public static class Post extends DataResource {
219
220                private final URI url;
221                private final String postData;
222                private final String contentType;
223                private final Map<String, String> requestHeader;
224
225                public Post(URI url, String postData, String contentType, Map<String, String> requestHeader) {
226                        this.url = url;
227                        this.postData = postData;
228                        this.contentType = contentType;
229                        this.requestHeader = requestHeader;
230                }
231
232                @Override
233                public Object getResource() {
234                        return url;
235                }
236
237                @Override
238                public boolean isStale() {
239                        return false;
240                }
241
242                @Override
243                public byte[] bytes() throws Exception {
244                        return Cache.getConcurrentCache(Cache.URL, CacheType.Monthly).bytes(url + " " + postData, k -> url.toURL()).fetch(post(() -> {
245                                return postData.getBytes(UTF_8);
246                        }, () -> {
247                                return contentType;
248                        }, () -> {
249                                return requestHeader;
250                        })).get();
251                }
252        }
253
254        private static class Memoized extends DataResource {
255
256                private final DataResource resource;
257
258                private final Resource<Map<Object, Object>> csv;
259                private final Resource<List<String>> lines;
260
261                private final Resource<String> text;
262                private final Resource<Object> xml;
263                private final Resource<Object> json;
264                private final Resource<Object> html;
265
266                public Memoized(DataResource resource) {
267                        this.resource = resource;
268
269                        this.csv = Resource.lazy(() -> new LookupMap(resource.csv()));
270                        this.lines = Resource.lazy(() -> new LookupList(resource.lines()));
271
272                        this.text = Resource.lazy(resource::text);
273                        this.xml = Resource.lazy(resource::xml);
274                        this.json = Resource.lazy(resource::json);
275                        this.html = Resource.lazy(resource::html);
276                }
277
278                @Override
279                public Object getResource() {
280                        return resource.getResource();
281                }
282
283                @Override
284                public boolean isStale() {
285                        return resource.isStale();
286                }
287
288                @Override
289                public byte[] bytes() throws Exception {
290                        return resource.bytes();
291                }
292
293                @Override
294                public Map<Object, Object> csv() throws Exception {
295                        return csv.get();
296                }
297
298                @Override
299                public List<String> lines() throws Exception {
300                        return lines.get();
301                }
302
303                @Override
304                public String text() throws Exception {
305                        return text.get();
306                }
307
308                @Override
309                public Object xml() throws Exception {
310                        return xml.get();
311                }
312
313                @Override
314                public Object json() throws Exception {
315                        return json.get();
316                }
317
318                @Override
319                public Object html() throws Exception {
320                        return html.get();
321                }
322        }
323
324        protected static class LookupMap extends AbstractMap<Object, Object> {
325
326                private final Map<Object, Object> values;
327
328                public LookupMap(Map<Object, Object> values) {
329                        this.values = values;
330                }
331
332                private String definingKey(Object key) {
333                        // letters and digits are defining, everything else will be ignored
334                        return normalizePunctuation(key.toString()).toLowerCase(Locale.ROOT);
335                }
336
337                private Map<String, Object> lookup;
338
339                private Map<String, Object> getLookup() {
340                        if (lookup == null) {
341                                lookup = values.entrySet().stream().collect(toMap(e -> definingKey(e.getKey()), e -> e.getValue(), (a, b) -> a, LinkedHashMap::new));
342                        }
343                        return lookup;
344                }
345
346                @Override
347                public Object get(Object key) {
348                        return getLookup().get(definingKey(key));
349                }
350
351                @Override
352                public boolean containsKey(Object key) {
353                        return getLookup().containsKey(definingKey(key));
354                }
355
356                public Set<Object> keySet() {
357                        return unmodifiableSet(getLookup().keySet());
358                }
359
360                @Override
361                public Set<Entry<Object, Object>> entrySet() {
362                        return unmodifiableSet(values.entrySet());
363                }
364        }
365
366        protected static class LookupList extends AbstractList<String> implements RandomAccess {
367
368                private final List<String> values;
369
370                public LookupList(List<String> values) {
371                        this.values = values;
372                }
373
374                @Override
375                public String get(int index) {
376                        return values.get(index);
377                }
378
379                @Override
380                public int size() {
381                        return values.size();
382                }
383
384                private String definingKey(Object key) {
385                        // letters and digits are defining, everything else will be ignored
386                        return normalizePunctuation(key.toString()).toLowerCase(Locale.ROOT);
387                }
388
389                private Set<String> lookup;
390
391                private Set<String> getLookup() {
392                        if (lookup == null) {
393                                lookup = values.stream().map(this::definingKey).collect(toSet());
394                        }
395                        return lookup;
396                }
397
398                public Set<String> keySet() {
399                        return unmodifiableSet(getLookup());
400                }
401
402                @Override
403                public boolean contains(Object object) {
404                        if (object == null) {
405                                return false;
406                        }
407                        return getLookup().contains(definingKey(object));
408                }
409        }
410
411}