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