do not allow empty cover images
[fanfix.git] / src / be / nikiroo / fanfix / library / LocalLibrary.java
1 package be.nikiroo.fanfix.library;
2
3 import java.io.File;
4 import java.io.FileFilter;
5 import java.io.FileInputStream;
6 import java.io.FileNotFoundException;
7 import java.io.IOException;
8 import java.io.InputStream;
9 import java.util.ArrayList;
10 import java.util.HashMap;
11 import java.util.List;
12 import java.util.Map;
13
14 import be.nikiroo.fanfix.Instance;
15 import be.nikiroo.fanfix.bundles.Config;
16 import be.nikiroo.fanfix.bundles.ConfigBundle;
17 import be.nikiroo.fanfix.data.MetaData;
18 import be.nikiroo.fanfix.data.Story;
19 import be.nikiroo.fanfix.output.BasicOutput;
20 import be.nikiroo.fanfix.output.BasicOutput.OutputType;
21 import be.nikiroo.fanfix.output.InfoCover;
22 import be.nikiroo.fanfix.supported.InfoReader;
23 import be.nikiroo.utils.HashUtils;
24 import be.nikiroo.utils.IOUtils;
25 import be.nikiroo.utils.Image;
26 import be.nikiroo.utils.Progress;
27
28 /**
29 * This {@link BasicLibrary} will store the stories locally on disk.
30 *
31 * @author niki
32 */
33 public class LocalLibrary extends BasicLibrary {
34 private int lastId;
35 private Object lock = new Object();
36 private Map<MetaData, File[]> stories; // Files: [ infoFile, TargetFile ]
37 private Map<String, Image> sourceCovers;
38 private Map<String, Image> authorCovers;
39
40 private File baseDir;
41 private OutputType text;
42 private OutputType image;
43
44 /**
45 * Create a new {@link LocalLibrary} with the given back-end directory.
46 *
47 * @param baseDir
48 * the directory where to find the {@link Story} objects
49 * @param config
50 * the configuration used to know which kind of default
51 * {@link OutputType} to use for images and non-images stories
52 */
53 public LocalLibrary(File baseDir, ConfigBundle config) {
54 this(baseDir, //
55 config.getString(Config.FILE_FORMAT_NON_IMAGES_DOCUMENT_TYPE),
56 config.getString(Config.FILE_FORMAT_IMAGES_DOCUMENT_TYPE),
57 false);
58 }
59
60 /**
61 * Create a new {@link LocalLibrary} with the given back-end directory.
62 *
63 * @param baseDir
64 * the directory where to find the {@link Story} objects
65 * @param text
66 * the {@link OutputType} to use for non-image documents
67 * @param image
68 * the {@link OutputType} to use for image documents
69 * @param defaultIsHtml
70 * if the given text or image is invalid, use HTML by default (if
71 * not, it will be INFO_TEXT/CBZ by default)
72 */
73 public LocalLibrary(File baseDir, String text, String image,
74 boolean defaultIsHtml) {
75 this(baseDir,
76 OutputType.valueOfAllOkUC(text,
77 defaultIsHtml ? OutputType.HTML : OutputType.INFO_TEXT),
78 OutputType.valueOfAllOkUC(image,
79 defaultIsHtml ? OutputType.HTML : OutputType.CBZ));
80 }
81
82 /**
83 * Create a new {@link LocalLibrary} with the given back-end directory.
84 *
85 * @param baseDir
86 * the directory where to find the {@link Story} objects
87 * @param text
88 * the {@link OutputType} to use for non-image documents
89 * @param image
90 * the {@link OutputType} to use for image documents
91 */
92 public LocalLibrary(File baseDir, OutputType text, OutputType image) {
93 this.baseDir = baseDir;
94 this.text = text;
95 this.image = image;
96
97 this.lastId = 0;
98 this.stories = null;
99 this.sourceCovers = null;
100
101 baseDir.mkdirs();
102 }
103
104 @Override
105 protected List<MetaData> getMetas(Progress pg) {
106 return new ArrayList<MetaData>(getStories(pg).keySet());
107 }
108
109 @Override
110 public File getFile(String luid, Progress pg) throws IOException {
111 Instance.getInstance().getTraceHandler().trace(
112 this.getClass().getSimpleName() + ": get file for " + luid);
113
114 File file = null;
115 String mess = "no file found for ";
116
117 MetaData meta = getInfo(luid);
118 if (meta != null) {
119 File[] files = getStories(pg).get(meta);
120 if (files != null) {
121 mess = "file retrieved for ";
122 file = files[1];
123 }
124 }
125
126 Instance.getInstance().getTraceHandler()
127 .trace(this.getClass().getSimpleName() + ": " + mess + luid
128 + " (" + meta.getTitle() + ")");
129
130 return file;
131 }
132
133 @Override
134 public Image getCover(String luid) throws IOException {
135 MetaData meta = getInfo(luid);
136 if (meta != null) {
137 if (meta.getCover() != null) {
138 return meta.getCover();
139 }
140
141 File[] files = getStories(null).get(meta);
142 if (files != null) {
143 File infoFile = files[0];
144
145 try {
146 meta = InfoReader.readMeta(infoFile, true);
147 return meta.getCover();
148 } catch (IOException e) {
149 Instance.getInstance().getTraceHandler().error(e);
150 }
151 }
152 }
153
154 return null;
155 }
156
157 @Override
158 protected void updateInfo(MetaData meta) {
159 invalidateInfo();
160 }
161
162 @Override
163 protected void invalidateInfo(String luid) {
164 synchronized (lock) {
165 stories = null;
166 sourceCovers = null;
167 }
168 }
169
170 @Override
171 protected String getNextId() {
172 getStories(null); // make sure lastId is set
173
174 synchronized (lock) {
175 return String.format("%03d", ++lastId);
176 }
177 }
178
179 @Override
180 protected void doDelete(String luid) throws IOException {
181 for (File file : getRelatedFiles(luid)) {
182 // TODO: throw an IOException if we cannot delete the files?
183 IOUtils.deltree(file);
184 file.getParentFile().delete();
185 }
186 }
187
188 @Override
189 protected Story doSave(Story story, Progress pg) throws IOException {
190 MetaData meta = story.getMeta();
191
192 File expectedTarget = getExpectedFile(meta);
193 expectedTarget.getParentFile().mkdirs();
194
195 BasicOutput it = BasicOutput.getOutput(getOutputType(meta), true, true);
196 it.process(story, expectedTarget.getPath(), pg);
197
198 return story;
199 }
200
201 @Override
202 protected synchronized void saveMeta(MetaData meta, Progress pg)
203 throws IOException {
204 File newDir = getExpectedDir(meta.getSource());
205 if (!newDir.exists()) {
206 newDir.mkdirs();
207 }
208
209 List<File> relatedFiles = getRelatedFiles(meta.getLuid());
210 for (File relatedFile : relatedFiles) {
211 // TODO: this is not safe at all.
212 // We should copy all the files THEN delete them
213 // Maybe also adding some rollback cleanup if possible
214 if (relatedFile.getName().endsWith(".info")) {
215 try {
216 String name = relatedFile.getName().replaceFirst("\\.info$",
217 "");
218 relatedFile.delete();
219 InfoCover.writeInfo(newDir, name, meta);
220 relatedFile.getParentFile().delete();
221 } catch (IOException e) {
222 Instance.getInstance().getTraceHandler().error(e);
223 }
224 } else {
225 relatedFile.renameTo(new File(newDir, relatedFile.getName()));
226 relatedFile.getParentFile().delete();
227 }
228 }
229
230 updateInfo(meta);
231 }
232
233 @Override
234 public Image getCustomSourceCover(String source) {
235 synchronized (lock) {
236 if (sourceCovers == null) {
237 sourceCovers = new HashMap<String, Image>();
238 }
239 }
240
241 synchronized (lock) {
242 Image img = sourceCovers.get(source);
243 if (img != null) {
244 return img;
245 }
246 }
247
248 File coverDir = getExpectedDir(source);
249 if (coverDir.isDirectory()) {
250 File cover = new File(coverDir, ".cover.png");
251 if (cover.exists()) {
252 InputStream in;
253 try {
254 in = new FileInputStream(cover);
255 try {
256 synchronized (lock) {
257 Image img = new Image(in);
258 if (img.getSize() == 0) {
259 img.close();
260 throw new IOException(
261 "Empty image not accepted");
262 }
263 sourceCovers.put(source, img);
264 }
265 } finally {
266 in.close();
267 }
268 } catch (FileNotFoundException e) {
269 e.printStackTrace();
270 } catch (IOException e) {
271 Instance.getInstance().getTraceHandler()
272 .error(new IOException(
273 "Cannot load the existing custom source cover: "
274 + cover,
275 e));
276 }
277 }
278 }
279
280 synchronized (lock) {
281 return sourceCovers.get(source);
282 }
283 }
284
285 @Override
286 public Image getCustomAuthorCover(String author) {
287 synchronized (lock) {
288 if (authorCovers == null) {
289 authorCovers = new HashMap<String, Image>();
290 }
291 }
292
293 synchronized (lock) {
294 Image img = authorCovers.get(author);
295 if (img != null) {
296 return img;
297 }
298 }
299
300 File cover = getAuthorCoverFile(author);
301 if (cover.exists()) {
302 InputStream in;
303 try {
304 in = new FileInputStream(cover);
305 try {
306 synchronized (lock) {
307 Image img = new Image(in);
308 if (img.getSize() == 0) {
309 img.close();
310 throw new IOException(
311 "Empty image not accepted");
312 }
313 authorCovers.put(author, img);
314 }
315 } finally {
316 in.close();
317 }
318 } catch (FileNotFoundException e) {
319 e.printStackTrace();
320 } catch (IOException e) {
321 Instance.getInstance().getTraceHandler()
322 .error(new IOException(
323 "Cannot load the existing custom author cover: "
324 + cover,
325 e));
326 }
327 }
328
329 synchronized (lock) {
330 return authorCovers.get(author);
331 }
332 }
333
334 @Override
335 public void setSourceCover(String source, String luid) throws IOException {
336 setSourceCover(source, getCover(luid));
337 }
338
339 @Override
340 public void setAuthorCover(String author, String luid) throws IOException {
341 setAuthorCover(author, getCover(luid));
342 }
343
344 /**
345 * Set the source cover to the given story cover.
346 *
347 * @param source
348 * the source to change
349 * @param coverImage
350 * the cover image
351 */
352 void setSourceCover(String source, Image coverImage) {
353 File dir = getExpectedDir(source);
354 dir.mkdirs();
355 File cover = new File(dir, ".cover");
356 try {
357 Instance.getInstance().getCache().saveAsImage(coverImage, cover,
358 true);
359 synchronized (lock) {
360 if (sourceCovers != null) {
361 sourceCovers.put(source, coverImage);
362 }
363 }
364 } catch (IOException e) {
365 Instance.getInstance().getTraceHandler().error(e);
366 }
367 }
368
369 /**
370 * Set the author cover to the given story cover.
371 *
372 * @param author
373 * the author to change
374 * @param coverImage
375 * the cover image
376 */
377 void setAuthorCover(String author, Image coverImage) {
378 File cover = getAuthorCoverFile(author);
379 cover.getParentFile().mkdirs();
380 try {
381 Instance.getInstance().getCache().saveAsImage(coverImage, cover,
382 true);
383 synchronized (lock) {
384 if (authorCovers != null) {
385 authorCovers.put(author, coverImage);
386 }
387 }
388 } catch (IOException e) {
389 Instance.getInstance().getTraceHandler().error(e);
390 }
391 }
392
393 @Override
394 public void imprt(BasicLibrary other, String luid, Progress pg)
395 throws IOException {
396 if (pg == null) {
397 pg = new Progress();
398 }
399
400 // Check if we can simply copy the files instead of the whole process
401 if (other instanceof LocalLibrary) {
402 LocalLibrary otherLocalLibrary = (LocalLibrary) other;
403
404 MetaData meta = otherLocalLibrary.getInfo(luid);
405 String expectedType = ""
406 + (meta != null && meta.isImageDocument() ? image : text);
407 if (meta != null && meta.getType().equals(expectedType)) {
408 File from = otherLocalLibrary.getExpectedDir(meta.getSource());
409 File to = this.getExpectedDir(meta.getSource());
410 List<File> relatedFiles = otherLocalLibrary
411 .getRelatedFiles(luid);
412 if (!relatedFiles.isEmpty()) {
413 pg.setMinMax(0, relatedFiles.size());
414 }
415
416 for (File relatedFile : relatedFiles) {
417 File target = new File(relatedFile.getAbsolutePath()
418 .replace(from.getAbsolutePath(),
419 to.getAbsolutePath()));
420 if (!relatedFile.equals(target)) {
421 target.getParentFile().mkdirs();
422 InputStream in = null;
423 try {
424 in = new FileInputStream(relatedFile);
425 IOUtils.write(in, target);
426 } catch (IOException e) {
427 if (in != null) {
428 try {
429 in.close();
430 } catch (Exception ee) {
431 }
432 }
433
434 pg.done();
435 throw e;
436 }
437 }
438
439 pg.add(1);
440 }
441
442 invalidateInfo();
443 pg.done();
444 return;
445 }
446 }
447
448 super.imprt(other, luid, pg);
449 }
450
451 /**
452 * Return the {@link OutputType} for this {@link Story}.
453 *
454 * @param meta
455 * the {@link Story} {@link MetaData}
456 *
457 * @return the type
458 */
459 private OutputType getOutputType(MetaData meta) {
460 if (meta != null && meta.isImageDocument()) {
461 return image;
462 }
463
464 return text;
465 }
466
467 /**
468 * Return the default {@link OutputType} for this kind of {@link Story}.
469 *
470 * @param imageDocument
471 * TRUE for images document, FALSE for text documents
472 *
473 * @return the type
474 */
475 public String getOutputType(boolean imageDocument) {
476 if (imageDocument) {
477 return image.toString();
478 }
479
480 return text.toString();
481 }
482
483 /**
484 * Get the target {@link File} related to the given <tt>.info</tt>
485 * {@link File} and {@link MetaData}.
486 *
487 * @param meta
488 * the meta
489 * @param infoFile
490 * the <tt>.info</tt> {@link File}
491 *
492 * @return the target {@link File}
493 */
494 private File getTargetFile(MetaData meta, File infoFile) {
495 // Replace .info with whatever is needed:
496 String path = infoFile.getPath();
497 path = path.substring(0, path.length() - ".info".length());
498 String newExt = getOutputType(meta).getDefaultExtension(true);
499
500 return new File(path + newExt);
501 }
502
503 /**
504 * The target (full path) where the {@link Story} related to this
505 * {@link MetaData} should be located on disk for a new {@link Story}.
506 *
507 * @param key
508 * the {@link Story} {@link MetaData}
509 *
510 * @return the target
511 */
512 private File getExpectedFile(MetaData key) {
513 String title = key.getTitle();
514 if (title == null) {
515 title = "";
516 }
517 title = title.replaceAll("[^a-zA-Z0-9._+-]", "_");
518 if (title.length() > 40) {
519 title = title.substring(0, 40);
520 }
521 return new File(getExpectedDir(key.getSource()),
522 key.getLuid() + "_" + title);
523 }
524
525 /**
526 * The directory (full path) where the new {@link Story} related to this
527 * {@link MetaData} should be located on disk.
528 *
529 * @param source
530 * the type (source)
531 *
532 * @return the target directory
533 */
534 private File getExpectedDir(String source) {
535 String sanitizedSource = source.replaceAll("[^a-zA-Z0-9._+/-]", "_");
536
537 while (sanitizedSource.startsWith("/")
538 || sanitizedSource.startsWith("_")) {
539 if (sanitizedSource.length() > 1) {
540 sanitizedSource = sanitizedSource.substring(1);
541 } else {
542 sanitizedSource = "";
543 }
544 }
545
546 sanitizedSource = sanitizedSource.replace("/", File.separator);
547
548 if (sanitizedSource.isEmpty()) {
549 sanitizedSource = "_EMPTY";
550 }
551
552 return new File(baseDir, sanitizedSource);
553 }
554
555 /**
556 * Return the full path to the file to use for the custom cover of this
557 * author.
558 * <p>
559 * One or more of the parent directories <b>MAY</b> not exist.
560 *
561 * @param author
562 * the author
563 *
564 * @return the custom cover file
565 */
566 private File getAuthorCoverFile(String author) {
567 File aDir = new File(baseDir, "_AUTHORS");
568 String hash = HashUtils.md5(author);
569 String ext = Instance.getInstance().getConfig()
570 .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER);
571 return new File(aDir, hash + "." + ext.toLowerCase());
572 }
573
574 /**
575 * Return the list of files/directories on disk for this {@link Story}.
576 * <p>
577 * If the {@link Story} is not found, and empty list is returned.
578 *
579 * @param luid
580 * the {@link Story} LUID
581 *
582 * @return the list of {@link File}s
583 *
584 * @throws IOException
585 * if the {@link Story} was not found
586 */
587 private List<File> getRelatedFiles(String luid) throws IOException {
588 List<File> files = new ArrayList<File>();
589
590 MetaData meta = getInfo(luid);
591 if (meta == null) {
592 throw new IOException("Story not found: " + luid);
593 }
594
595 File infoFile = getStories(null).get(meta)[0];
596 File targetFile = getStories(null).get(meta)[1];
597
598 files.add(infoFile);
599 files.add(targetFile);
600
601 String readerExt = getOutputType(meta).getDefaultExtension(true);
602 String fileExt = getOutputType(meta).getDefaultExtension(false);
603
604 String path = targetFile.getAbsolutePath();
605 if (readerExt != null && !readerExt.equals(fileExt)) {
606 path = path.substring(0, path.length() - readerExt.length())
607 + fileExt;
608 File relatedFile = new File(path);
609
610 if (relatedFile.exists()) {
611 files.add(relatedFile);
612 }
613 }
614
615 String coverExt = "." + Instance.getInstance().getConfig()
616 .getString(Config.FILE_FORMAT_IMAGE_FORMAT_COVER).toLowerCase();
617 File coverFile = new File(path + coverExt);
618 if (!coverFile.exists()) {
619 coverFile = new File(
620 path.substring(0, path.length() - fileExt.length())
621 + coverExt);
622 }
623
624 if (coverFile.exists()) {
625 files.add(coverFile);
626 }
627
628 return files;
629 }
630
631 /**
632 * Fill the list of stories by reading the content of the local directory
633 * {@link LocalLibrary#baseDir}.
634 * <p>
635 * Will use a cached list when possible (see
636 * {@link BasicLibrary#invalidateInfo()}).
637 *
638 * @param pg
639 * the optional {@link Progress}
640 *
641 * @return the list of stories (for each item, the first {@link File} is the
642 * info file, the second file is the target {@link File})
643 */
644 private Map<MetaData, File[]> getStories(Progress pg) {
645 if (pg == null) {
646 pg = new Progress();
647 } else {
648 pg.setMinMax(0, 100);
649 }
650
651 Map<MetaData, File[]> stories = this.stories;
652 if (stories == null) {
653 stories = getStoriesDo(pg);
654 synchronized (lock) {
655 if (this.stories == null)
656 this.stories = stories;
657 else
658 stories = this.stories;
659 }
660 }
661
662 pg.done();
663 return stories;
664
665 }
666
667 /**
668 * Actually do the work of {@link LocalLibrary#getStories(Progress)} (i.e.,
669 * do not retrieve the cache).
670 *
671 * @param pg
672 * the optional {@link Progress}
673 *
674 * @return the list of stories (for each item, the first {@link File} is the
675 * info file, the second file is the target {@link File})
676 */
677 private synchronized Map<MetaData, File[]> getStoriesDo(Progress pg) {
678 if (pg == null) {
679 pg = new Progress();
680 } else {
681 pg.setMinMax(0, 100);
682 }
683
684 Map<MetaData, File[]> stories = new HashMap<MetaData, File[]>();
685
686 File[] dirs = baseDir.listFiles(new FileFilter() {
687 @Override
688 public boolean accept(File file) {
689 return file != null && file.isDirectory();
690 }
691 });
692
693 if (dirs != null) {
694 Progress pgDirs = new Progress(0, 100 * dirs.length);
695 pg.addProgress(pgDirs, 100);
696
697 for (File dir : dirs) {
698 Progress pgFiles = new Progress();
699 pgDirs.addProgress(pgFiles, 100);
700 pgDirs.setName("Loading from: " + dir.getName());
701
702 addToStories(stories, pgFiles, dir);
703
704 pgFiles.setName(null);
705 }
706
707 pgDirs.setName("Loading directories");
708 }
709
710 pg.done();
711
712 return stories;
713 }
714
715 private void addToStories(Map<MetaData, File[]> stories, Progress pgFiles,
716 File dir) {
717 File[] infoFilesAndSubdirs = dir.listFiles(new FileFilter() {
718 @Override
719 public boolean accept(File file) {
720 boolean info = file != null && file.isFile()
721 && file.getPath().toLowerCase().endsWith(".info");
722 boolean dir = file != null && file.isDirectory();
723 boolean isExpandedHtml = new File(file, "index.html").isFile();
724 return info || (dir && !isExpandedHtml);
725 }
726 });
727
728 if (pgFiles != null) {
729 pgFiles.setMinMax(0, infoFilesAndSubdirs.length);
730 }
731
732 for (File infoFileOrSubdir : infoFilesAndSubdirs) {
733 if (infoFileOrSubdir.isDirectory()) {
734 addToStories(stories, null, infoFileOrSubdir);
735 } else {
736 try {
737 MetaData meta = InfoReader.readMeta(infoFileOrSubdir,
738 false);
739 try {
740 int id = Integer.parseInt(meta.getLuid());
741 if (id > lastId) {
742 lastId = id;
743 }
744
745 stories.put(meta, new File[] { infoFileOrSubdir,
746 getTargetFile(meta, infoFileOrSubdir) });
747 } catch (Exception e) {
748 // not normal!!
749 throw new IOException("Cannot understand the LUID of "
750 + infoFileOrSubdir + ": " + meta.getLuid(), e);
751 }
752 } catch (IOException e) {
753 // We should not have not-supported files in the
754 // library
755 Instance.getInstance().getTraceHandler().error(
756 new IOException("Cannot load file from library: "
757 + infoFileOrSubdir, e));
758 }
759 }
760
761 if (pgFiles != null) {
762 pgFiles.add(1);
763 }
764 }
765 }
766 }