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