cover: allow custom author covers
[nikiroo-utils.git] / src / be / nikiroo / fanfix / library / LocalLibrary.java
CommitLineData
e42573a0 1package be.nikiroo.fanfix.library;
68e2c6d2 2
68e2c6d2
NR
3import java.io.File;
4import java.io.FileFilter;
14b57448 5import java.io.FileInputStream;
e1de8087 6import java.io.FileNotFoundException;
68e2c6d2 7import java.io.IOException;
14b57448 8import java.io.InputStream;
68e2c6d2
NR
9import java.util.ArrayList;
10import java.util.HashMap;
11import java.util.List;
12import java.util.Map;
13
e42573a0 14import be.nikiroo.fanfix.Instance;
68e2c6d2
NR
15import be.nikiroo.fanfix.bundles.Config;
16import be.nikiroo.fanfix.data.MetaData;
17import be.nikiroo.fanfix.data.Story;
18import be.nikiroo.fanfix.output.BasicOutput;
19import be.nikiroo.fanfix.output.BasicOutput.OutputType;
20import be.nikiroo.fanfix.output.InfoCover;
21import be.nikiroo.fanfix.supported.InfoReader;
22import be.nikiroo.utils.IOUtils;
16a81ef7 23import be.nikiroo.utils.Image;
68e2c6d2 24import be.nikiroo.utils.Progress;
3989dfc5 25import be.nikiroo.utils.StringUtils;
68e2c6d2
NR
26
27/**
28 * This {@link BasicLibrary} will store the stories locally on disk.
29 *
30 * @author niki
31 */
32public class LocalLibrary extends BasicLibrary {
33 private int lastId;
34 private Map<MetaData, File[]> stories; // Files: [ infoFile, TargetFile ]
16a81ef7 35 private Map<String, Image> sourceCovers;
3989dfc5 36 private Map<String, Image> authorCovers;
68e2c6d2
NR
37
38 private File baseDir;
39 private OutputType text;
40 private OutputType image;
41
e604986c
NR
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
ff05b828
NR
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)
e604986c
NR
66 */
67 public LocalLibrary(File baseDir, String text, String image,
68 boolean defaultIsHtml) {
ff05b828 69 this(baseDir, OutputType.valueOfAllOkUC(text,
e604986c 70 defaultIsHtml ? OutputType.HTML : OutputType.INFO_TEXT),
ff05b828 71 OutputType.valueOfAllOkUC(image,
e604986c
NR
72 defaultIsHtml ? OutputType.HTML : OutputType.CBZ));
73 }
74
68e2c6d2
NR
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
e604986c 81 * the {@link OutputType} to use for non-image documents
68e2c6d2 82 * @param image
e604986c 83 * the {@link OutputType} to use for image documents
68e2c6d2
NR
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;
b56c9d60 92 this.sourceCovers = null;
68e2c6d2
NR
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
ff05b828 103 public File getFile(String luid, Progress pg) {
9e2fad36
NR
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);
9e2fad36 111 File[] files = getStories(pg).get(meta);
68e2c6d2 112 if (files != null) {
9e2fad36
NR
113 mess = "file retrieved for ";
114 file = files[1];
68e2c6d2
NR
115 }
116
9e2fad36 117 Instance.getTraceHandler().trace(
9b863b20
NR
118 this.getClass().getSimpleName() + ": " + mess + luid + " ("
119 + meta.getTitle() + ")");
9e2fad36
NR
120
121 return file;
68e2c6d2
NR
122 }
123
124 @Override
16a81ef7 125 public Image getCover(String luid) {
68e2c6d2
NR
126 MetaData meta = getInfo(luid);
127 if (meta != null) {
3ab45a6e
NR
128 if (meta.getCover() != null) {
129 return meta.getCover();
130 }
131
68e2c6d2
NR
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) {
62c63b07 140 Instance.getTraceHandler().error(e);
68e2c6d2
NR
141 }
142 }
143 }
144
145 return null;
146 }
147
148 @Override
efa3c511 149 protected synchronized void updateInfo(MetaData meta) {
c8d48938 150 invalidateInfo();
efa3c511
NR
151 }
152
153 @Override
c8d48938 154 protected void invalidateInfo(String luid) {
68e2c6d2 155 stories = null;
b56c9d60 156 sourceCovers = null;
68e2c6d2
NR
157 }
158
159 @Override
160 protected synchronized int getNextId() {
14b57448 161 getStories(null); // make sure lastId is set
68e2c6d2
NR
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);
e272f05f 170 file.getParentFile().delete();
68e2c6d2
NR
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
925298fd 181 BasicOutput it = BasicOutput.getOutput(getOutputType(meta), true, true);
68e2c6d2
NR
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()) {
3a0605e6 192 newDir.mkdirs();
68e2c6d2
NR
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$", "");
68e2c6d2 204 relatedFile.delete();
c8d48938 205 InfoCover.writeInfo(newDir, name, meta);
e272f05f 206 relatedFile.getParentFile().delete();
68e2c6d2 207 } catch (IOException e) {
62c63b07 208 Instance.getTraceHandler().error(e);
68e2c6d2
NR
209 }
210 } else {
211 relatedFile.renameTo(new File(newDir, relatedFile.getName()));
e272f05f 212 relatedFile.getParentFile().delete();
68e2c6d2
NR
213 }
214 }
215
c8d48938 216 invalidateInfo();
68e2c6d2
NR
217 }
218
14b57448 219 @Override
e1de8087 220 public synchronized Image getCustomSourceCover(String source) {
b56c9d60 221 if (sourceCovers == null) {
e1de8087
NR
222 sourceCovers = new HashMap<String, Image>();
223 }
224
225 Image img = sourceCovers.get(source);
226 if (img != null) {
227 return img;
b56c9d60
NR
228 }
229
3a0605e6 230 File coverDir = getExpectedDir(source);
e1de8087
NR
231 if (coverDir.isDirectory()) {
232 File cover = new File(coverDir, ".cover.png");
a09ef2bb
NR
233 if (cover.exists()) {
234 InputStream in;
e1de8087 235 try {
a09ef2bb
NR
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));
e1de8087 249 }
e1de8087 250 }
14b57448
NR
251 }
252
253 return sourceCovers.get(source);
254 }
255
3989dfc5
NR
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
14b57448
NR
281 @Override
282 public void setSourceCover(String source, String luid) {
e1de8087
NR
283 setSourceCover(source, getCover(luid));
284 }
b56c9d60 285
3989dfc5
NR
286 @Override
287 public void setAuthorCover(String author, String luid) {
288 setAuthorCover(author, getCover(luid));
289 }
290
e1de8087 291 /**
3989dfc5 292 * Set the source cover to the given story cover.
e1de8087
NR
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) {
cf45a4c4
NR
300 File dir = getExpectedDir(source);
301 dir.mkdirs();
302 File cover = new File(dir, ".cover");
14b57448 303 try {
e1de8087
NR
304 Instance.getCache().saveAsImage(coverImage, cover, true);
305 if (sourceCovers != null) {
306 sourceCovers.put(source, coverImage);
307 }
14b57448 308 } catch (IOException e) {
62c63b07 309 Instance.getTraceHandler().error(e);
14b57448
NR
310 }
311 }
312
3989dfc5
NR
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
b89dfb6e
NR
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
ff05b828 341 // Check if we can simply copy the files instead of the whole process
b89dfb6e 342 if (other instanceof LocalLibrary) {
ff05b828 343 LocalLibrary otherLocalLibrary = (LocalLibrary) other;
e604986c 344
e604986c
NR
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());
b89dfb6e 350 File to = this.getExpectedDir(meta.getSource());
3a0605e6
NR
351 List<File> relatedFiles = otherLocalLibrary
352 .getRelatedFiles(luid);
353 if (!relatedFiles.isEmpty()) {
354 pg.setMinMax(0, relatedFiles.size());
b89dfb6e
NR
355 }
356
3a0605e6
NR
357 for (File relatedFile : relatedFiles) {
358 File target = new File(relatedFile.getAbsolutePath()
359 .replace(from.getAbsolutePath(),
360 to.getAbsolutePath()));
361 if (!relatedFile.equals(target)) {
e604986c 362 target.getParentFile().mkdirs();
b89dfb6e
NR
363 InputStream in = null;
364 try {
3a0605e6 365 in = new FileInputStream(relatedFile);
b89dfb6e
NR
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
c8d48938 383 invalidateInfo();
b89dfb6e
NR
384 pg.done();
385 return;
386 }
387 }
388
389 super.imprt(other, luid, pg);
390 }
391
68e2c6d2
NR
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;
68e2c6d2 403 }
211f7ddb
NR
404
405 return text;
68e2c6d2
NR
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._+-]", "_");
3989dfc5
NR
443 if (title.length() > 40) {
444 title = title.substring(0, 40);
445 }
68e2c6d2
NR
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 *
085a2f9a 454 * @param source
68e2c6d2
NR
455 * the type (source)
456 *
457 * @return the target directory
458 */
085a2f9a 459 private File getExpectedDir(String source) {
3a0605e6
NR
460 String sanitizedSource = source.replaceAll("[^a-zA-Z0-9._+/-]", "_");
461
3989dfc5
NR
462 while (sanitizedSource.startsWith("/")
463 || sanitizedSource.startsWith("_")) {
3a0605e6
NR
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()) {
3989dfc5 474 sanitizedSource = "_EMPTY";
3a0605e6
NR
475 }
476
085a2f9a 477 return new File(baseDir, sanitizedSource);
68e2c6d2
NR
478 }
479
3989dfc5
NR
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
68e2c6d2
NR
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);
211f7ddb 517 }
68e2c6d2 518
211f7ddb
NR
519 File infoFile = getStories(null).get(meta)[0];
520 File targetFile = getStories(null).get(meta)[1];
68e2c6d2 521
211f7ddb
NR
522 files.add(infoFile);
523 files.add(targetFile);
68e2c6d2 524
211f7ddb
NR
525 String readerExt = getOutputType(meta).getDefaultExtension(true);
526 String fileExt = getOutputType(meta).getDefaultExtension(false);
68e2c6d2 527
211f7ddb
NR
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);
68e2c6d2 533
211f7ddb
NR
534 if (relatedFile.exists()) {
535 files.add(relatedFile);
68e2c6d2 536 }
211f7ddb 537 }
68e2c6d2 538
211f7ddb 539 String coverExt = "."
2a25f781
NR
540 + Instance.getConfig().getString(Config.IMAGE_FORMAT_COVER)
541 .toLowerCase();
211f7ddb
NR
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);
68e2c6d2
NR
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
c8d48938 561 * {@link BasicLibrary#invalidateInfo()}).
68e2c6d2
NR
562 *
563 * @param pg
564 * the optional {@link Progress}
565 *
9b863b20
NR
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})
68e2c6d2
NR
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() {
211f7ddb 582 @Override
68e2c6d2
NR
583 public boolean accept(File file) {
584 return file != null && file.isDirectory();
585 }
586 });
587
b4f9071c
NR
588 if (dirs != null) {
589 Progress pgDirs = new Progress(0, 100 * dirs.length);
590 pg.addProgress(pgDirs, 100);
591
592 for (File dir : dirs) {
3a0605e6 593 Progress pgFiles = new Progress();
b4f9071c
NR
594 pgDirs.addProgress(pgFiles, 100);
595 pgDirs.setName("Loading from: " + dir.getName());
596
3a0605e6 597 addToStories(pgFiles, dir);
68e2c6d2 598
b4f9071c 599 pgFiles.setName(null);
14b57448
NR
600 }
601
b4f9071c 602 pgDirs.setName("Loading directories");
68e2c6d2 603 }
68e2c6d2
NR
604 }
605
b4f9071c 606 pg.done();
68e2c6d2
NR
607 return stories;
608 }
3a0605e6
NR
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 }
68e2c6d2 663}