gui: separate authors into subgroups
[fanfix.git] / src / be / nikiroo / fanfix / library / BasicLibrary.java
1 package be.nikiroo.fanfix.library;
2
3 import java.io.File;
4 import java.io.IOException;
5 import java.net.URL;
6 import java.net.UnknownHostException;
7 import java.util.AbstractMap.SimpleEntry;
8 import java.util.ArrayList;
9 import java.util.Collections;
10 import java.util.List;
11 import java.util.Map.Entry;
12
13 import be.nikiroo.fanfix.Instance;
14 import be.nikiroo.fanfix.data.MetaData;
15 import be.nikiroo.fanfix.data.Story;
16 import be.nikiroo.fanfix.output.BasicOutput;
17 import be.nikiroo.fanfix.output.BasicOutput.OutputType;
18 import be.nikiroo.fanfix.supported.BasicSupport;
19 import be.nikiroo.fanfix.supported.SupportType;
20 import be.nikiroo.utils.Image;
21 import be.nikiroo.utils.Progress;
22 import be.nikiroo.utils.StringUtils;
23
24 /**
25 * Manage a library of Stories: import, export, list, modify.
26 * <p>
27 * Each {@link Story} object will be associated with a (local to the library)
28 * unique ID, the LUID, which will be used to identify the {@link Story}.
29 * <p>
30 * Most of the {@link BasicLibrary} functions work on a partial (cover
31 * <b>MAY</b> not be included) {@link MetaData} object.
32 *
33 * @author niki
34 */
35 abstract public class BasicLibrary {
36 /**
37 * A {@link BasicLibrary} status.
38 *
39 * @author niki
40 */
41 public enum Status {
42 /** The library is ready. */
43 READY,
44 /** The library is invalid (not correctly set up). */
45 INVALID,
46 /** You are not allowed to access this library. */
47 UNAUTORIZED,
48 /** The library is currently out of commission. */
49 UNAVAILABLE,
50 }
51
52 /**
53 * Return a name for this library (the UI may display this).
54 * <p>
55 * Must not be NULL.
56 *
57 * @return the name, or an empty {@link String} if none
58 */
59 public String getLibraryName() {
60 return "";
61 }
62
63 /**
64 * The library status.
65 *
66 * @return the current status
67 */
68 public Status getStatus() {
69 return Status.READY;
70 }
71
72 /**
73 * Retrieve the main {@link File} corresponding to the given {@link Story},
74 * which can be passed to an external reader or instance.
75 * <p>
76 * Do <b>NOT</b> alter this file.
77 *
78 * @param luid
79 * the Library UID of the story
80 * @param pg
81 * the optional {@link Progress}
82 *
83 * @return the corresponding {@link Story}
84 */
85 public abstract File getFile(String luid, Progress pg);
86
87 /**
88 * Return the cover image associated to this story.
89 *
90 * @param luid
91 * the Library UID of the story
92 *
93 * @return the cover image
94 */
95 public abstract Image getCover(String luid);
96
97 /**
98 * Return the cover image associated to this source.
99 * <p>
100 * By default, return the custom cover if any, and if not, return the cover
101 * of the first story with this source.
102 *
103 * @param source
104 * the source
105 *
106 * @return the cover image or NULL
107 */
108 public Image getSourceCover(String source) {
109 Image custom = getCustomSourceCover(source);
110 if (custom != null) {
111 return custom;
112 }
113
114 List<MetaData> metas = getListBySource(source);
115 if (metas.size() > 0) {
116 return getCover(metas.get(0).getLuid());
117 }
118
119 return null;
120 }
121
122 /**
123 * Return the custom cover image associated to this source.
124 * <p>
125 * By default, return NULL.
126 *
127 * @param source
128 * the source to look for
129 *
130 * @return the custom cover or NULL if none
131 */
132 public Image getCustomSourceCover(@SuppressWarnings("unused") String source) {
133 return null;
134 }
135
136 /**
137 * Fix the source cover to the given story cover.
138 *
139 * @param source
140 * the source to change
141 * @param luid
142 * the story LUID
143 */
144 public abstract void setSourceCover(String source, String luid);
145
146 /**
147 * Return the list of stories (represented by their {@link MetaData}, which
148 * <b>MAY</b> not have the cover included).
149 *
150 * @param pg
151 * the optional {@link Progress}
152 *
153 * @return the list (can be empty but not NULL)
154 */
155 protected abstract List<MetaData> getMetas(Progress pg);
156
157 /**
158 * Invalidate the {@link Story} cache (when the content should be re-read
159 * because it was changed).
160 */
161 protected void deleteInfo() {
162 deleteInfo(null);
163 }
164
165 /**
166 * Invalidate the {@link Story} cache (when the content is removed).
167 * <p>
168 * All the cache can be deleted if NULL is passed as meta.
169 *
170 * @param luid
171 * the LUID of the {@link Story} to clear from the cache, or NULL
172 * for all stories
173 */
174 protected abstract void deleteInfo(String luid);
175
176 /**
177 * Invalidate the {@link Story} cache (when the content has changed, but we
178 * already have it) with the new given meta.
179 *
180 * @param meta
181 * the {@link Story} to clear from the cache
182 */
183 protected abstract void updateInfo(MetaData meta);
184
185 /**
186 * Return the next LUID that can be used.
187 *
188 * @return the next luid
189 */
190 protected abstract int getNextId();
191
192 /**
193 * Delete the target {@link Story}.
194 *
195 * @param luid
196 * the LUID of the {@link Story}
197 *
198 * @throws IOException
199 * in case of I/O error or if the {@link Story} wa not found
200 */
201 protected abstract void doDelete(String luid) throws IOException;
202
203 /**
204 * Actually save the story to the back-end.
205 *
206 * @param story
207 * the {@link Story} to save
208 * @param pg
209 * the optional {@link Progress}
210 *
211 * @return the saved {@link Story} (which may have changed, especially
212 * regarding the {@link MetaData})
213 *
214 * @throws IOException
215 * in case of I/O error
216 */
217 protected abstract Story doSave(Story story, Progress pg)
218 throws IOException;
219
220 /**
221 * Refresh the {@link BasicLibrary}, that is, make sure all metas are
222 * loaded.
223 *
224 * @param pg
225 * the optional progress reporter
226 */
227 public void refresh(Progress pg) {
228 getMetas(pg);
229 }
230
231 /**
232 * List all the known types (sources) of stories.
233 *
234 * @return the sources
235 */
236 public synchronized List<String> getSources() {
237 List<String> list = new ArrayList<String>();
238 for (MetaData meta : getMetas(null)) {
239 String storySource = meta.getSource();
240 if (!list.contains(storySource)) {
241 list.add(storySource);
242 }
243 }
244
245 Collections.sort(list);
246 return list;
247 }
248
249 /**
250 * List all the known authors of stories.
251 *
252 * @return the authors
253 */
254 public synchronized List<String> getAuthors() {
255 List<String> list = new ArrayList<String>();
256 for (MetaData meta : getMetas(null)) {
257 String storyAuthor = meta.getAuthor();
258 if (!list.contains(storyAuthor)) {
259 list.add(storyAuthor);
260 }
261 }
262
263 Collections.sort(list);
264 return list;
265 }
266
267 /**
268 * Return the list of authors, grouped by starting letter(s) if needed.
269 * <p>
270 * If the number of author is not too high, only one group with an empty
271 * name and all the authors will be returned.
272 * <p>
273 * If not, the authors will be separated into groups:
274 * <ul>
275 * <li><tt>*</tt>: any author whose name doesn't contain letters nor numbers
276 * </li>
277 * <li><tt>0-9</tt>: any authors whose name starts with a number</li>
278 * <li><tt>A-C</tt> (for instance): any author whose name starts with
279 * <tt>A</tt>, <tt>B</tt> or <tt>C</tt></li>
280 * </ul>
281 * Note that the letters used in the groups can vary (except <tt>*</tt> and
282 * <tt>0-9</tt>, which may only be present or not).
283 *
284 * @return the authors' names, grouped by letter(s)
285 */
286 public List<Entry<String, List<String>>> getAuthorsGrouped() {
287 int MAX = 20;
288
289 List<Entry<String, List<String>>> groups = new ArrayList<Entry<String, List<String>>>();
290 List<String> authors = getAuthors();
291
292 if (authors.size() <= MAX) {
293 groups.add(new SimpleEntry<String, List<String>>("", authors));
294 return groups;
295 }
296
297 groups.add(new SimpleEntry<String, List<String>>("*", getAuthorsGroup(
298 authors, '*')));
299 groups.add(new SimpleEntry<String, List<String>>("0-9",
300 getAuthorsGroup(authors, '0')));
301
302 for (char car = 'A'; car <= 'Z'; car++) {
303 groups.add(new SimpleEntry<String, List<String>>(Character
304 .toString(car), getAuthorsGroup(authors, car)));
305 }
306
307 // do NOT collapse * and [0-9] with the rest
308 for (int i = 2; i + 1 < groups.size(); i++) {
309 Entry<String, List<String>> now = groups.get(i);
310 Entry<String, List<String>> next = groups.get(i + 1);
311 int currentTotal = now.getValue().size() + next.getValue().size();
312 if (currentTotal <= MAX) {
313 String key = now.getKey().charAt(0) + "-"
314 + next.getKey().charAt(next.getKey().length() - 1);
315 List<String> all = new ArrayList<String>();
316 all.addAll(now.getValue());
317 all.addAll(next.getValue());
318 groups.set(i, new SimpleEntry<String, List<String>>(key, all));
319 groups.remove(i + 1);
320 i--;
321 }
322 }
323
324 for (int i = 0; i < groups.size(); i++) {
325 if (groups.get(i).getValue().size() == 0) {
326 groups.remove(i);
327 i--;
328 }
329 }
330
331 return groups;
332 }
333
334 /**
335 * Get all the authors that start with the given character:
336 * <ul>
337 * <li><tt>*</tt>: any author whose name doesn't contain letters nor numbers
338 * </li>
339 * <li><tt>0</tt>: any authors whose name starts with a number</li>
340 * <li><tt>A</tt> (any capital latin letter): any author whose name starts
341 * with <tt>A</tt></li>
342 * </ul>
343 *
344 * @param authors
345 * the full list of authors
346 * @param car
347 * the starting character, <tt>*</tt>, <tt>0</tt> or a capital
348 * letter
349 * @return the authors that fulfill the starting letter
350 */
351 private List<String> getAuthorsGroup(List<String> authors, char car) {
352 List<String> accepted = new ArrayList<String>();
353 for (String author : authors) {
354 char first = '*';
355 for (int i = 0; first == '*' && i < author.length(); i++) {
356 String san = StringUtils.sanitize(author, true, true);
357 char c = san.charAt(i);
358 if (c >= '0' && c <= '9') {
359 first = '0';
360 } else if (c >= 'a' && c <= 'z') {
361 first = (char) (c - 'a' + 'A');
362 } else if (c >= 'A' && c <= 'Z') {
363 first = c;
364 }
365 }
366
367 if (first == car) {
368 accepted.add(author);
369 }
370 }
371
372 return accepted;
373 }
374
375 /**
376 * List all the stories in the {@link BasicLibrary}.
377 * <p>
378 * Cover images not included.
379 *
380 * @return the stories
381 */
382 public synchronized List<MetaData> getList() {
383 return getMetas(null);
384 }
385
386 /**
387 * List all the stories of the given source type in the {@link BasicLibrary}
388 * , or all the stories if NULL is passed as a type.
389 * <p>
390 * Cover images not included.
391 *
392 * @param type
393 * the type of story to retrieve, or NULL for all
394 *
395 * @return the stories
396 */
397 public synchronized List<MetaData> getListBySource(String type) {
398 List<MetaData> list = new ArrayList<MetaData>();
399 for (MetaData meta : getMetas(null)) {
400 String storyType = meta.getSource();
401 if (type == null || type.equalsIgnoreCase(storyType)) {
402 list.add(meta);
403 }
404 }
405
406 Collections.sort(list);
407 return list;
408 }
409
410 /**
411 * List all the stories of the given author in the {@link BasicLibrary}, or
412 * all the stories if NULL is passed as an author.
413 * <p>
414 * Cover images not included.
415 *
416 * @param author
417 * the author of the stories to retrieve, or NULL for all
418 *
419 * @return the stories
420 */
421 public synchronized List<MetaData> getListByAuthor(String author) {
422 List<MetaData> list = new ArrayList<MetaData>();
423 for (MetaData meta : getMetas(null)) {
424 String storyAuthor = meta.getAuthor();
425 if (author == null || author.equalsIgnoreCase(storyAuthor)) {
426 list.add(meta);
427 }
428 }
429
430 Collections.sort(list);
431 return list;
432 }
433
434 /**
435 * Retrieve a {@link MetaData} corresponding to the given {@link Story},
436 * cover image <b>MAY</b> not be included.
437 *
438 * @param luid
439 * the Library UID of the story
440 *
441 * @return the corresponding {@link Story}
442 */
443 public synchronized MetaData getInfo(String luid) {
444 if (luid != null) {
445 for (MetaData meta : getMetas(null)) {
446 if (luid.equals(meta.getLuid())) {
447 return meta;
448 }
449 }
450 }
451
452 return null;
453 }
454
455 /**
456 * Retrieve a specific {@link Story}.
457 *
458 * @param luid
459 * the Library UID of the story
460 * @param pg
461 * the optional progress reporter
462 *
463 * @return the corresponding {@link Story} or NULL if not found
464 */
465 public synchronized Story getStory(String luid, Progress pg) {
466 if (pg == null) {
467 pg = new Progress();
468 }
469
470 Progress pgGet = new Progress();
471 Progress pgProcess = new Progress();
472
473 pg.setMinMax(0, 2);
474 pg.addProgress(pgGet, 1);
475 pg.addProgress(pgProcess, 1);
476
477 Story story = null;
478 for (MetaData meta : getMetas(null)) {
479 if (meta.getLuid().equals(luid)) {
480 File file = getFile(luid, pgGet);
481 pgGet.done();
482 try {
483 SupportType type = SupportType.valueOfAllOkUC(meta
484 .getType());
485 URL url = file.toURI().toURL();
486 if (type != null) {
487 story = BasicSupport.getSupport(type, url) //
488 .process(pgProcess);
489
490 // Because we do not want to clear the meta cache:
491 meta.setCover(story.getMeta().getCover());
492 meta.setResume(story.getMeta().getResume());
493 story.setMeta(meta);
494 //
495 } else {
496 throw new IOException("Unknown type: " + meta.getType());
497 }
498 } catch (IOException e) {
499 // We should not have not-supported files in the
500 // library
501 Instance.getTraceHandler().error(
502 new IOException("Cannot load file from library: "
503 + file, e));
504 } finally {
505 pgProcess.done();
506 pg.done();
507 }
508
509 break;
510 }
511 }
512
513 return story;
514 }
515
516 /**
517 * Import the {@link Story} at the given {@link URL} into the
518 * {@link BasicLibrary}.
519 *
520 * @param url
521 * the {@link URL} to import
522 * @param pg
523 * the optional progress reporter
524 *
525 * @return the imported {@link Story}
526 *
527 * @throws UnknownHostException
528 * if the host is not supported
529 * @throws IOException
530 * in case of I/O error
531 */
532 public Story imprt(URL url, Progress pg) throws IOException {
533 if (pg == null)
534 pg = new Progress();
535
536 pg.setMinMax(0, 1000);
537 Progress pgProcess = new Progress();
538 Progress pgSave = new Progress();
539 pg.addProgress(pgProcess, 800);
540 pg.addProgress(pgSave, 200);
541
542 BasicSupport support = BasicSupport.getSupport(url);
543 if (support == null) {
544 throw new UnknownHostException("" + url);
545 }
546
547 Story story = save(support.process(pgProcess), pgSave);
548 pg.done();
549
550 return story;
551 }
552
553 /**
554 * Import the story from one library to another, and keep the same LUID.
555 *
556 * @param other
557 * the other library to import from
558 * @param luid
559 * the Library UID
560 * @param pg
561 * the optional progress reporter
562 *
563 * @throws IOException
564 * in case of I/O error
565 */
566 public void imprt(BasicLibrary other, String luid, Progress pg)
567 throws IOException {
568 Progress pgGetStory = new Progress();
569 Progress pgSave = new Progress();
570 if (pg == null) {
571 pg = new Progress();
572 }
573
574 pg.setMinMax(0, 2);
575 pg.addProgress(pgGetStory, 1);
576 pg.addProgress(pgSave, 1);
577
578 Story story = other.getStory(luid, pgGetStory);
579 if (story != null) {
580 story = this.save(story, luid, pgSave);
581 pg.done();
582 } else {
583 pg.done();
584 throw new IOException("Cannot find story in Library: " + luid);
585 }
586 }
587
588 /**
589 * Export the {@link Story} to the given target in the given format.
590 *
591 * @param luid
592 * the {@link Story} ID
593 * @param type
594 * the {@link OutputType} to transform it to
595 * @param target
596 * the target to save to
597 * @param pg
598 * the optional progress reporter
599 *
600 * @return the saved resource (the main saved {@link File})
601 *
602 * @throws IOException
603 * in case of I/O error
604 */
605 public File export(String luid, OutputType type, String target, Progress pg)
606 throws IOException {
607 Progress pgGetStory = new Progress();
608 Progress pgOut = new Progress();
609 if (pg != null) {
610 pg.setMax(2);
611 pg.addProgress(pgGetStory, 1);
612 pg.addProgress(pgOut, 1);
613 }
614
615 BasicOutput out = BasicOutput.getOutput(type, false, false);
616 if (out == null) {
617 throw new IOException("Output type not supported: " + type);
618 }
619
620 Story story = getStory(luid, pgGetStory);
621 if (story == null) {
622 throw new IOException("Cannot find story to export: " + luid);
623 }
624
625 return out.process(story, target, pgOut);
626 }
627
628 /**
629 * Save a {@link Story} to the {@link BasicLibrary}.
630 *
631 * @param story
632 * the {@link Story} to save
633 * @param pg
634 * the optional progress reporter
635 *
636 * @return the same {@link Story}, whose LUID may have changed
637 *
638 * @throws IOException
639 * in case of I/O error
640 */
641 public Story save(Story story, Progress pg) throws IOException {
642 return save(story, null, pg);
643 }
644
645 /**
646 * Save a {@link Story} to the {@link BasicLibrary} -- the LUID <b>must</b>
647 * be correct, or NULL to get the next free one.
648 * <p>
649 * Will override any previous {@link Story} with the same LUID.
650 *
651 * @param story
652 * the {@link Story} to save
653 * @param luid
654 * the <b>correct</b> LUID or NULL to get the next free one
655 * @param pg
656 * the optional progress reporter
657 *
658 * @return the same {@link Story}, whose LUID may have changed
659 *
660 * @throws IOException
661 * in case of I/O error
662 */
663 public synchronized Story save(Story story, String luid, Progress pg)
664 throws IOException {
665
666 Instance.getTraceHandler().trace(
667 this.getClass().getSimpleName() + ": saving story " + luid);
668
669 // Do not change the original metadata, but change the original story
670 MetaData meta = story.getMeta().clone();
671 story.setMeta(meta);
672
673 if (luid == null || luid.isEmpty()) {
674 meta.setLuid(String.format("%03d", getNextId()));
675 } else {
676 meta.setLuid(luid);
677 }
678
679 if (luid != null && getInfo(luid) != null) {
680 delete(luid);
681 }
682
683 story = doSave(story, pg);
684
685 updateInfo(story.getMeta());
686
687 Instance.getTraceHandler().trace(
688 this.getClass().getSimpleName() + ": story saved (" + luid
689 + ")");
690
691 return story;
692 }
693
694 /**
695 * Delete the given {@link Story} from this {@link BasicLibrary}.
696 *
697 * @param luid
698 * the LUID of the target {@link Story}
699 *
700 * @throws IOException
701 * in case of I/O error
702 */
703 public synchronized void delete(String luid) throws IOException {
704 Instance.getTraceHandler().trace(
705 this.getClass().getSimpleName() + ": deleting story " + luid);
706
707 doDelete(luid);
708 deleteInfo(luid);
709
710 Instance.getTraceHandler().trace(
711 this.getClass().getSimpleName() + ": story deleted (" + luid
712 + ")");
713 }
714
715 /**
716 * Change the type (source) of the given {@link Story}.
717 *
718 * @param luid
719 * the {@link Story} LUID
720 * @param newSource
721 * the new source
722 * @param pg
723 * the optional progress reporter
724 *
725 * @throws IOException
726 * in case of I/O error or if the {@link Story} was not found
727 */
728 public synchronized void changeSource(String luid, String newSource,
729 Progress pg) throws IOException {
730 MetaData meta = getInfo(luid);
731 if (meta == null) {
732 throw new IOException("Story not found: " + luid);
733 }
734
735 meta.setSource(newSource);
736 saveMeta(meta, pg);
737 }
738
739 /**
740 * Save back the current state of the {@link MetaData} (LUID <b>MUST NOT</b>
741 * change) for this {@link Story}.
742 * <p>
743 * By default, delete the old {@link Story} then recreate a new
744 * {@link Story}.
745 * <p>
746 * Note that this behaviour can lead to data loss.
747 *
748 * @param meta
749 * the new {@link MetaData} (LUID <b>MUST NOT</b> change)
750 * @param pg
751 * the optional {@link Progress}
752 *
753 * @throws IOException
754 * in case of I/O error or if the {@link Story} was not found
755 */
756 protected synchronized void saveMeta(MetaData meta, Progress pg)
757 throws IOException {
758 if (pg == null) {
759 pg = new Progress();
760 }
761
762 Progress pgGet = new Progress();
763 Progress pgSet = new Progress();
764 pg.addProgress(pgGet, 50);
765 pg.addProgress(pgSet, 50);
766
767 Story story = getStory(meta.getLuid(), pgGet);
768 if (story == null) {
769 throw new IOException("Story not found: " + meta.getLuid());
770 }
771
772 delete(meta.getLuid());
773
774 story.setMeta(meta);
775 save(story, meta.getLuid(), pgSet);
776
777 pg.done();
778 }
779 }