Fix cover not deleted, add new UI option "Move to"
[fanfix.git] / src / be / nikiroo / fanfix / Library.java
1 package be.nikiroo.fanfix;
2
3 import java.awt.image.BufferedImage;
4 import java.io.File;
5 import java.io.FileFilter;
6 import java.io.IOException;
7 import java.net.URL;
8 import java.util.ArrayList;
9 import java.util.Collections;
10 import java.util.HashMap;
11 import java.util.List;
12 import java.util.Map;
13 import java.util.Map.Entry;
14
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.BasicSupport;
22 import be.nikiroo.fanfix.supported.BasicSupport.SupportType;
23 import be.nikiroo.fanfix.supported.InfoReader;
24 import be.nikiroo.utils.IOUtils;
25 import be.nikiroo.utils.Progress;
26
27 /**
28 * Manage a library of Stories: import, export, list.
29 * <p>
30 * Each {@link Story} object will be associated with a (local to the library)
31 * unique ID, the LUID, which will be used to identify the {@link Story}.
32 * <p>
33 * Most of the {@link Library} functions work on either the LUID or a partial
34 * (cover not included) {@link MetaData} object.
35 *
36 * @author niki
37 */
38 public class Library {
39 private File baseDir;
40 private Map<MetaData, File> stories;
41 private int lastId;
42 private OutputType text;
43 private OutputType image;
44
45 /**
46 * Create a new {@link Library} with the given backend directory.
47 *
48 * @param dir
49 * the directory where to find the {@link Story} objects
50 * @param text
51 * the {@link OutputType} to save the text-focused stories into
52 * @param image
53 * the {@link OutputType} to save the images-focused stories into
54 */
55 public Library(File dir, OutputType text, OutputType image) {
56 this.baseDir = dir;
57 this.stories = new HashMap<MetaData, File>();
58 this.lastId = 0;
59 this.text = text;
60 this.image = image;
61
62 dir.mkdirs();
63 }
64
65 /**
66 * Refresh the {@link Library}, that is, make sure all stories are loaded.
67 *
68 * @param pg
69 * the optional progress reporter
70 */
71 public void refresh(Progress pg) {
72 getStories(pg);
73 }
74
75 /**
76 * List all the known types (sources) of stories.
77 *
78 * @return the types
79 */
80 public synchronized List<String> getTypes() {
81 List<String> list = new ArrayList<String>();
82 for (Entry<MetaData, File> entry : getStories(null).entrySet()) {
83 String storyType = entry.getKey().getSource();
84 if (!list.contains(storyType)) {
85 list.add(storyType);
86 }
87 }
88
89 Collections.sort(list);
90 return list;
91 }
92
93 /**
94 * List all the known authors of stories.
95 *
96 * @return the authors
97 */
98 public synchronized List<String> getAuthors() {
99 List<String> list = new ArrayList<String>();
100 for (Entry<MetaData, File> entry : getStories(null).entrySet()) {
101 String storyAuthor = entry.getKey().getAuthor();
102 if (!list.contains(storyAuthor)) {
103 list.add(storyAuthor);
104 }
105 }
106
107 Collections.sort(list);
108 return list;
109 }
110
111 /**
112 * List all the stories of the given author in the {@link Library}, or all
113 * the stories if NULL is passed as an author.
114 * <p>
115 * Cover images not included.
116 *
117 * @param author
118 * the author of the stories to retrieve, or NULL for all
119 *
120 * @return the stories
121 */
122 public synchronized List<MetaData> getListByAuthor(String author) {
123 List<MetaData> list = new ArrayList<MetaData>();
124 for (Entry<MetaData, File> entry : getStories(null).entrySet()) {
125 String storyAuthor = entry.getKey().getAuthor();
126 if (author == null || author.equalsIgnoreCase(storyAuthor)) {
127 list.add(entry.getKey());
128 }
129 }
130
131 Collections.sort(list);
132 return list;
133 }
134
135 /**
136 * List all the stories of the given source type in the {@link Library}, or
137 * all the stories if NULL is passed as a type.
138 * <p>
139 * Cover images not included.
140 *
141 * @param type
142 * the type of story to retrieve, or NULL for all
143 *
144 * @return the stories
145 */
146 public synchronized List<MetaData> getListByType(String type) {
147 if (type != null) {
148 // convert the type to dir name
149 type = getDir(type).getName();
150 }
151
152 List<MetaData> list = new ArrayList<MetaData>();
153 for (Entry<MetaData, File> entry : getStories(null).entrySet()) {
154 String storyType = entry.getValue().getParentFile().getName();
155 if (type == null || type.equalsIgnoreCase(storyType)) {
156 list.add(entry.getKey());
157 }
158 }
159
160 Collections.sort(list);
161 return list;
162 }
163
164 /**
165 * Retrieve a {@link File} corresponding to the given {@link Story}, cover
166 * image not included.
167 *
168 * @param luid
169 * the Library UID of the story
170 *
171 * @return the corresponding {@link Story}
172 */
173 public synchronized MetaData getInfo(String luid) {
174 if (luid != null) {
175 for (Entry<MetaData, File> entry : getStories(null).entrySet()) {
176 if (luid.equals(entry.getKey().getLuid())) {
177 return entry.getKey();
178 }
179 }
180 }
181
182 return null;
183 }
184
185 /**
186 * Retrieve a {@link File} corresponding to the given {@link Story}.
187 *
188 * @param luid
189 * the Library UID of the story
190 *
191 * @return the corresponding {@link Story}
192 */
193 public synchronized File getFile(String luid) {
194 if (luid != null) {
195 for (Entry<MetaData, File> entry : getStories(null).entrySet()) {
196 if (luid.equals(entry.getKey().getLuid())) {
197 return entry.getValue();
198 }
199 }
200 }
201
202 return null;
203 }
204
205 /**
206 * Return the cover image associated to this story.
207 *
208 * @param luid
209 * the Library UID of the story
210 *
211 * @return the cover image
212 */
213 public synchronized BufferedImage getCover(String luid) {
214 MetaData meta = getInfo(luid);
215 if (meta != null) {
216 try {
217 File infoFile = new File(getFile(meta).getPath() + ".info");
218 meta = readMeta(infoFile, true).getKey();
219 return meta.getCover();
220 } catch (IOException e) {
221 Instance.syserr(e);
222 }
223 }
224
225 return null;
226 }
227
228 /**
229 * Retrieve a specific {@link Story}.
230 *
231 * @param luid
232 * the Library UID of the story
233 * @param pg
234 * the optional progress reporter
235 *
236 * @return the corresponding {@link Story} or NULL if not found
237 */
238 public synchronized Story getStory(String luid, Progress pg) {
239 if (luid != null) {
240 for (Entry<MetaData, File> entry : getStories(null).entrySet()) {
241 if (luid.equals(entry.getKey().getLuid())) {
242 try {
243 SupportType type = SupportType.valueOfAllOkUC(entry
244 .getKey().getType());
245 URL url = entry.getValue().toURI().toURL();
246 if (type != null) {
247 return BasicSupport.getSupport(type).process(url,
248 pg);
249 } else {
250 throw new IOException("Unknown type: "
251 + entry.getKey().getType());
252 }
253 } catch (IOException e) {
254 // We should not have not-supported files in the
255 // library
256 Instance.syserr(new IOException(
257 "Cannot load file from library: "
258 + entry.getValue().getPath(), e));
259 }
260 }
261 }
262 }
263
264 if (pg != null) {
265 pg.setMinMax(0, 1);
266 pg.setProgress(1);
267 }
268
269 return null;
270 }
271
272 /**
273 * Import the {@link Story} at the given {@link URL} into the
274 * {@link Library}.
275 *
276 * @param url
277 * the {@link URL} to import
278 * @param pg
279 * the optional progress reporter
280 *
281 * @return the imported {@link Story}
282 *
283 * @throws IOException
284 * in case of I/O error
285 */
286 public Story imprt(URL url, Progress pg) throws IOException {
287 BasicSupport support = BasicSupport.getSupport(url);
288 if (support == null) {
289 throw new IOException("URL not supported: " + url.toString());
290 }
291
292 return save(support.process(url, pg), null);
293 }
294
295 /**
296 * Export the {@link Story} to the given target in the given format.
297 *
298 * @param luid
299 * the {@link Story} ID
300 * @param type
301 * the {@link OutputType} to transform it to
302 * @param target
303 * the target to save to
304 * @param pg
305 * the optional progress reporter
306 *
307 * @return the saved resource (the main saved {@link File})
308 *
309 * @throws IOException
310 * in case of I/O error
311 */
312 public File export(String luid, OutputType type, String target, Progress pg)
313 throws IOException {
314 Progress pgGetStory = new Progress();
315 Progress pgOut = new Progress();
316 if (pg != null) {
317 pg.setMax(2);
318 pg.addProgress(pgGetStory, 1);
319 pg.addProgress(pgOut, 1);
320 }
321
322 BasicOutput out = BasicOutput.getOutput(type, true);
323 if (out == null) {
324 throw new IOException("Output type not supported: " + type);
325 }
326
327 Story story = getStory(luid, pgGetStory);
328 if (story == null) {
329 throw new IOException("Cannot find story to export: " + luid);
330 }
331
332 return out.process(story, target, pgOut);
333 }
334
335 /**
336 * Save a {@link Story} to the {@link Library}.
337 *
338 * @param story
339 * the {@link Story} to save
340 * @param pg
341 * the optional progress reporter
342 *
343 * @return the same {@link Story}, whose LUID may have changed
344 *
345 * @throws IOException
346 * in case of I/O error
347 */
348 public Story save(Story story, Progress pg) throws IOException {
349 return save(story, null, pg);
350 }
351
352 /**
353 * Save a {@link Story} to the {@link Library} -- the LUID <b>must</b> be
354 * correct, or NULL to get the next free one.
355 *
356 * @param story
357 * the {@link Story} to save
358 * @param luid
359 * the <b>correct</b> LUID or NULL to get the next free one
360 * @param pg
361 * the optional progress reporter
362 *
363 * @return the same {@link Story}, whose LUID may have changed
364 *
365 * @throws IOException
366 * in case of I/O error
367 */
368 public synchronized Story save(Story story, String luid, Progress pg)
369 throws IOException {
370 // Do not change the original metadata, but change the original story
371 MetaData key = story.getMeta().clone();
372 story.setMeta(key);
373
374 if (luid == null || luid.isEmpty()) {
375 getStories(null); // refresh lastId if needed
376 key.setLuid(String.format("%03d", (++lastId)));
377 } else {
378 key.setLuid(luid);
379 }
380
381 getDir(key.getSource()).mkdirs();
382 if (!getDir(key.getSource()).exists()) {
383 throw new IOException("Cannot create library dir");
384 }
385
386 OutputType out;
387 if (key != null && key.isImageDocument()) {
388 out = image;
389 } else {
390 out = text;
391 }
392
393 BasicOutput it = BasicOutput.getOutput(out, true);
394 it.process(story, getFile(key).getPath(), pg);
395
396 // empty cache
397 stories.clear();
398
399 return story;
400 }
401
402 /**
403 * Delete the given {@link Story} from this {@link Library}.
404 *
405 * @param luid
406 * the LUID of the target {@link Story}
407 *
408 * @return TRUE if it was deleted
409 */
410 public synchronized boolean delete(String luid) {
411 boolean ok = false;
412
413 List<File> files = getFiles(luid);
414 if (!files.isEmpty()) {
415 for (File file : files) {
416 IOUtils.deltree(file);
417 }
418
419 ok = true;
420
421 // clear cache
422 stories.clear();
423 }
424
425 return ok;
426 }
427
428 /**
429 * Change the type (source) of the given {@link Story}.
430 *
431 * @param luid
432 * the {@link Story} LUID
433 * @param newSourcethe
434 * new source
435 *
436 * @return TRUE if the {@link Story} was found
437 */
438 public synchronized boolean changeType(String luid, String newType) {
439 MetaData meta = getInfo(luid);
440 if (meta != null) {
441 meta.setSource(newType);
442 File newDir = getDir(meta.getSource());
443 if (!newDir.exists()) {
444 newDir.mkdir();
445 }
446
447 List<File> files = getFiles(luid);
448 for (File file : files) {
449 if (file.getName().endsWith(".info")) {
450 try {
451 String name = file.getName().replaceFirst("\\.info$",
452 "");
453 InfoCover.writeInfo(newDir, name, meta);
454 file.delete();
455 } catch (IOException e) {
456 Instance.syserr(e);
457 }
458 } else {
459 file.renameTo(new File(newDir, file.getName()));
460 }
461 }
462
463 // clear cache
464 stories.clear();
465
466 return true;
467 }
468
469 return false;
470 }
471
472 /**
473 * Return the list of files/dirs on disk for this {@link Story}.
474 * <p>
475 * If the {@link Story} is not found, and empty list is returned.
476 *
477 * @param luid
478 * the {@link Story} LUID
479 *
480 * @return the list of {@link File}s
481 */
482 private List<File> getFiles(String luid) {
483 List<File> files = new ArrayList<File>();
484
485 MetaData meta = getInfo(luid);
486 File file = getStories(null).get(meta);
487
488 if (file != null) {
489 files.add(file);
490
491 String readerExt = getOutputType(meta).getDefaultExtension(true);
492 String fileExt = getOutputType(meta).getDefaultExtension(false);
493
494 String path = file.getAbsolutePath();
495 if (readerExt != null && !readerExt.equals(fileExt)) {
496 path = path.substring(0, path.length() - readerExt.length())
497 + fileExt;
498 file = new File(path);
499
500 if (file.exists()) {
501 files.add(file);
502 }
503 }
504
505 File infoFile = new File(path + ".info");
506 if (!infoFile.exists()) {
507 infoFile = new File(path.substring(0,
508 path.length() - fileExt.length())
509 + ".info");
510 }
511
512 if (infoFile.exists()) {
513 files.add(infoFile);
514 }
515
516 String coverExt = "."
517 + Instance.getConfig().getString(Config.IMAGE_FORMAT_COVER);
518 File coverFile = new File(path + coverExt);
519 if (!coverFile.exists()) {
520 coverFile = new File(path.substring(0,
521 path.length() - fileExt.length())
522 + coverExt);
523 }
524
525 if (coverFile.exists()) {
526 files.add(coverFile);
527 }
528 }
529
530 return files;
531 }
532
533 /**
534 * The directory (full path) where the {@link Story} related to this
535 * {@link MetaData} should be located on disk.
536 *
537 * @param type
538 * the type (source)
539 *
540 * @return the target directory
541 */
542 private File getDir(String type) {
543 String source = type.replaceAll("[^a-zA-Z0-9._+-]", "_");
544 return new File(baseDir, source);
545 }
546
547 /**
548 * The target (full path) where the {@link Story} related to this
549 * {@link MetaData} should be located on disk.
550 *
551 * @param key
552 * the {@link Story} {@link MetaData}
553 *
554 * @return the target
555 */
556 private File getFile(MetaData key) {
557 String title = key.getTitle();
558 if (title == null) {
559 title = "";
560 }
561 title = title.replaceAll("[^a-zA-Z0-9._+-]", "_");
562 return new File(getDir(key.getSource()), key.getLuid() + "_" + title);
563 }
564
565 /**
566 * Return all the known stories in this {@link Library} object.
567 *
568 * @param pg
569 * the optional progress reporter
570 *
571 * @return the stories
572 */
573 private synchronized Map<MetaData, File> getStories(Progress pg) {
574 if (pg == null) {
575 pg = new Progress();
576 } else {
577 pg.setMinMax(0, 100);
578 }
579
580 if (stories.isEmpty()) {
581 lastId = 0;
582
583 File[] dirs = baseDir.listFiles(new FileFilter() {
584 public boolean accept(File file) {
585 return file != null && file.isDirectory();
586 }
587 });
588
589 Progress pgDirs = new Progress(0, 100 * dirs.length);
590 pg.addProgress(pgDirs, 100);
591
592 for (File dir : dirs) {
593 File[] files = dir.listFiles(new FileFilter() {
594 public boolean accept(File file) {
595 return file != null
596 && file.getPath().toLowerCase()
597 .endsWith(".info");
598 }
599 });
600
601 Progress pgFiles = new Progress(0, files.length);
602 pgDirs.addProgress(pgFiles, 100);
603 pgDirs.setName("Loading from: " + dir.getName());
604
605 for (File file : files) {
606 pgFiles.setName(file.getName());
607 try {
608 Entry<MetaData, File> entry = readMeta(file, false);
609 try {
610 int id = Integer.parseInt(entry.getKey().getLuid());
611 if (id > lastId) {
612 lastId = id;
613 }
614
615 stories.put(entry.getKey(), entry.getValue());
616 } catch (Exception e) {
617 // not normal!!
618 throw new IOException(
619 "Cannot understand the LUID of "
620 + file.getPath() + ": "
621 + entry.getKey().getLuid(), e);
622 }
623 } catch (IOException e) {
624 // We should not have not-supported files in the
625 // library
626 Instance.syserr(new IOException(
627 "Cannot load file from library: "
628 + file.getPath(), e));
629 }
630 pgFiles.add(1);
631 }
632
633 pgFiles.setName(null);
634 }
635
636 pgDirs.setName("Loading directories");
637 }
638
639 return stories;
640 }
641
642 private Entry<MetaData, File> readMeta(File infoFile, boolean withCover)
643 throws IOException {
644
645 final MetaData meta = InfoReader.readMeta(infoFile, withCover);
646
647 // Replace .info with whatever is needed:
648 String path = infoFile.getPath();
649 path = path.substring(0, path.length() - ".info".length());
650
651 String newExt = getOutputType(meta).getDefaultExtension(true);
652
653 File targetFile = new File(path + newExt);
654
655 final File ffile = targetFile;
656 return new Entry<MetaData, File>() {
657 public File setValue(File value) {
658 return null;
659 }
660
661 public File getValue() {
662 return ffile;
663 }
664
665 public MetaData getKey() {
666 return meta;
667 }
668 };
669 }
670
671 /**
672 * Return the {@link OutputType} for this {@link Story}.
673 *
674 * @param meta
675 * the {@link Story} {@link MetaData}
676 *
677 * @return the type
678 */
679 private OutputType getOutputType(MetaData meta) {
680 if (meta != null && meta.isImageDocument()) {
681 return image;
682 } else {
683 return text;
684 }
685 }
686 }