1 package be
.nikiroo
.fanfix
.library
;
4 import java
.io
.IOException
;
6 import java
.net
.UnknownHostException
;
7 import java
.util
.ArrayList
;
8 import java
.util
.Collections
;
11 import java
.util
.TreeMap
;
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
;
25 * Manage a library of Stories: import, export, list, modify.
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}.
30 * Most of the {@link BasicLibrary} functions work on a partial (cover
31 * <b>MAY</b> not be included) {@link MetaData} object.
35 abstract public class BasicLibrary
{
37 * A {@link BasicLibrary} status.
42 /** The library is ready and r/w. */
44 /** The library is ready, but read-only. */
46 /** The library is invalid (not correctly set up). */
48 /** You are not allowed to access this library. */
50 /** The library is currently out of commission. */
54 * The library is available (you can query it).
56 * It does <b>not</b> specify if it is read-only or not.
58 * @return TRUE if it is
60 public boolean isReady() {
61 return (this == READ_WRITE
|| this == READ_ONLY
);
65 * This library can be modified (= you are allowed to modify it).
67 * @return TRUE if it is
69 public boolean isWritable() {
70 return (this == READ_WRITE
);
75 * Return a name for this library (the UI may display this).
79 * @return the name, or an empty {@link String} if none
81 public String
getLibraryName() {
88 * @return the current status
90 public Status
getStatus() {
91 return Status
.READ_WRITE
;
95 * Retrieve the main {@link File} corresponding to the given {@link Story},
96 * which can be passed to an external reader or instance.
98 * Do <b>NOT</b> alter this file.
101 * the Library UID of the story
103 * the optional {@link Progress}
105 * @return the corresponding {@link Story}
107 * @throws IOException
108 * in case of IOException
110 public abstract File
getFile(String luid
, Progress pg
) throws IOException
;
113 * Return the cover image associated to this story.
116 * the Library UID of the story
118 * @return the cover image
120 * @throws IOException
121 * in case of IOException
123 public abstract Image
getCover(String luid
) throws IOException
;
126 * Return the cover image associated to this source.
128 * By default, return the custom cover if any, and if not, return the cover
129 * of the first story with this source.
134 * @return the cover image or NULL
136 * @throws IOException
137 * in case of IOException
139 public Image
getSourceCover(String source
) throws IOException
{
140 Image custom
= getCustomSourceCover(source
);
141 if (custom
!= null) {
145 List
<MetaData
> metas
= getListBySource(source
);
146 if (metas
.size() > 0) {
147 return getCover(metas
.get(0).getLuid());
154 * Return the cover image associated to this author.
156 * By default, return the custom cover if any, and if not, return the cover
157 * of the first story with this author.
162 * @return the cover image or NULL
164 * @throws IOException
165 * in case of IOException
167 public Image
getAuthorCover(String author
) throws IOException
{
168 Image custom
= getCustomAuthorCover(author
);
169 if (custom
!= null) {
173 List
<MetaData
> metas
= getListByAuthor(author
);
174 if (metas
.size() > 0) {
175 return getCover(metas
.get(0).getLuid());
182 * Return the custom cover image associated to this source.
184 * By default, return NULL.
187 * the source to look for
189 * @return the custom cover or NULL if none
191 * @throws IOException
192 * in case of IOException
194 public Image
getCustomSourceCover(@SuppressWarnings("unused") String source
)
200 * Return the custom cover image associated to this author.
202 * By default, return NULL.
205 * the author to look for
207 * @return the custom cover or NULL if none
209 * @throws IOException
210 * in case of IOException
212 public Image
getCustomAuthorCover(@SuppressWarnings("unused") String author
)
218 * Set the source cover to the given story cover.
221 * the source to change
225 * @throws IOException
226 * in case of IOException
228 public abstract void setSourceCover(String source
, String luid
)
232 * Set the author cover to the given story cover.
235 * the author to change
239 * @throws IOException
240 * in case of IOException
242 public abstract void setAuthorCover(String author
, String luid
)
246 * Return the list of stories (represented by their {@link MetaData}, which
247 * <b>MAY</b> not have the cover included).
250 * the optional {@link Progress}
252 * @return the list (can be empty but not NULL)
254 * @throws IOException
255 * in case of IOException
257 protected abstract List
<MetaData
> getMetas(Progress pg
) throws IOException
;
260 * Invalidate the {@link Story} cache (when the content should be re-read
261 * because it was changed).
263 protected void invalidateInfo() {
264 invalidateInfo(null);
268 * Invalidate the {@link Story} cache (when the content is removed).
270 * All the cache can be deleted if NULL is passed as meta.
273 * the LUID of the {@link Story} to clear from the cache, or NULL
276 protected abstract void invalidateInfo(String luid
);
279 * Invalidate the {@link Story} cache (when the content has changed, but we
280 * already have it) with the new given meta.
283 * the {@link Story} to clear from the cache
285 * @throws IOException
286 * in case of IOException
288 protected abstract void updateInfo(MetaData meta
) throws IOException
;
291 * Return the next LUID that can be used.
293 * @return the next luid
295 protected abstract int getNextId();
298 * Delete the target {@link Story}.
301 * the LUID of the {@link Story}
303 * @throws IOException
304 * in case of I/O error or if the {@link Story} wa not found
306 protected abstract void doDelete(String luid
) throws IOException
;
309 * Actually save the story to the back-end.
312 * the {@link Story} to save
314 * the optional {@link Progress}
316 * @return the saved {@link Story} (which may have changed, especially
317 * regarding the {@link MetaData})
319 * @throws IOException
320 * in case of I/O error
322 protected abstract Story
doSave(Story story
, Progress pg
)
326 * Refresh the {@link BasicLibrary}, that is, make sure all metas are
330 * the optional progress reporter
332 * @throws IOException
333 * in case of IOException
335 public void refresh(Progress pg
) {
338 } catch (IOException e
) {
339 // We will let it fail later
344 * List all the known types (sources) of stories.
346 * @return the sources
348 * @throws IOException
349 * in case of IOException
351 public synchronized List
<String
> getSources() throws IOException
{
352 List
<String
> list
= new ArrayList
<String
>();
353 for (MetaData meta
: getMetas(null)) {
354 String storySource
= meta
.getSource();
355 if (!list
.contains(storySource
)) {
356 list
.add(storySource
);
360 Collections
.sort(list
);
365 * List all the known types (sources) of stories, grouped by directory
366 * ("Source_1/a" and "Source_1/b" will be grouped into "Source_1").
368 * Note that an empty item in the list means a non-grouped source (type) --
369 * e.g., you could have for Source_1:
371 * <li><tt></tt>: empty, so source is "Source_1"</li>
372 * <li><tt>a</tt>: empty, so source is "Source_1/a"</li>
373 * <li><tt>b</tt>: empty, so source is "Source_1/b"</li>
376 * @return the grouped list
378 * @throws IOException
379 * in case of IOException
381 public synchronized Map
<String
, List
<String
>> getSourcesGrouped()
383 Map
<String
, List
<String
>> map
= new TreeMap
<String
, List
<String
>>();
384 for (String source
: getSources()) {
388 int pos
= source
.indexOf('/');
389 if (pos
> 0 && pos
< source
.length() - 1) {
390 name
= source
.substring(0, pos
);
391 subname
= source
.substring(pos
+ 1);
398 List
<String
> list
= map
.get(name
);
400 list
= new ArrayList
<String
>();
410 * List all the known authors of stories.
412 * @return the authors
414 * @throws IOException
415 * in case of IOException
417 public synchronized List
<String
> getAuthors() throws IOException
{
418 List
<String
> list
= new ArrayList
<String
>();
419 for (MetaData meta
: getMetas(null)) {
420 String storyAuthor
= meta
.getAuthor();
421 if (!list
.contains(storyAuthor
)) {
422 list
.add(storyAuthor
);
426 Collections
.sort(list
);
431 * Return the list of authors, grouped by starting letter(s) if needed.
433 * If the number of author is not too high, only one group with an empty
434 * name and all the authors will be returned.
436 * If not, the authors will be separated into groups:
438 * <li><tt>*</tt>: any author whose name doesn't contain letters nor numbers
440 * <li><tt>0-9</tt>: any authors whose name starts with a number</li>
441 * <li><tt>A-C</tt> (for instance): any author whose name starts with
442 * <tt>A</tt>, <tt>B</tt> or <tt>C</tt></li>
444 * Note that the letters used in the groups can vary (except <tt>*</tt> and
445 * <tt>0-9</tt>, which may only be present or not).
447 * @return the authors' names, grouped by letter(s)
449 * @throws IOException
450 * in case of IOException
452 public Map
<String
, List
<String
>> getAuthorsGrouped() throws IOException
{
455 Map
<String
, List
<String
>> groups
= new TreeMap
<String
, List
<String
>>();
456 List
<String
> authors
= getAuthors();
458 // If all authors fit the max, just report them as is
459 if (authors
.size() <= MAX
) {
460 groups
.put("", authors
);
464 // Create groups A to Z, which can be empty here
465 for (char car
= 'A'; car
<= 'Z'; car
++) {
466 groups
.put(Character
.toString(car
), getAuthorsGroup(authors
, car
));
470 List
<String
> keys
= new ArrayList
<String
>(groups
.keySet());
471 for (int i
= 0; i
+ 1 < keys
.size(); i
++) {
472 String keyNow
= keys
.get(i
);
473 String keyNext
= keys
.get(i
+ 1);
475 List
<String
> now
= groups
.get(keyNow
);
476 List
<String
> next
= groups
.get(keyNext
);
478 int currentTotal
= now
.size() + next
.size();
479 if (currentTotal
<= MAX
) {
480 String key
= keyNow
.charAt(0) + "-"
481 + keyNext
.charAt(keyNext
.length() - 1);
483 List
<String
> all
= new ArrayList
<String
>();
487 groups
.remove(keyNow
);
488 groups
.remove(keyNext
);
489 groups
.put(key
, all
);
491 keys
.set(i
, key
); // set the new key instead of key(i)
492 keys
.remove(i
+ 1); // remove the next, consumed key
493 i
--; // restart at key(i)
497 // Add "special" groups
498 groups
.put("*", getAuthorsGroup(authors
, '*'));
499 groups
.put("0-9", getAuthorsGroup(authors
, '0'));
501 // Prune empty groups
502 keys
= new ArrayList
<String
>(groups
.keySet());
503 for (String key
: keys
) {
504 if (groups
.get(key
).isEmpty()) {
513 * Get all the authors that start with the given character:
515 * <li><tt>*</tt>: any author whose name doesn't contain letters nor numbers
517 * <li><tt>0</tt>: any authors whose name starts with a number</li>
518 * <li><tt>A</tt> (any capital latin letter): any author whose name starts
519 * with <tt>A</tt></li>
523 * the full list of authors
525 * the starting character, <tt>*</tt>, <tt>0</tt> or a capital
528 * @return the authors that fulfill the starting letter
530 * @throws IOException
531 * in case of IOException
533 private List
<String
> getAuthorsGroup(List
<String
> authors
, char car
)
535 List
<String
> accepted
= new ArrayList
<String
>();
536 for (String author
: authors
) {
538 for (int i
= 0; first
== '*' && i
< author
.length(); i
++) {
539 String san
= StringUtils
.sanitize(author
, true, true);
540 char c
= san
.charAt(i
);
541 if (c
>= '0' && c
<= '9') {
543 } else if (c
>= 'a' && c
<= 'z') {
544 first
= (char) (c
- 'a' + 'A');
545 } else if (c
>= 'A' && c
<= 'Z') {
551 accepted
.add(author
);
559 * List all the stories in the {@link BasicLibrary}.
561 * Cover images <b>MAYBE</b> not included.
563 * @return the stories
565 * @throws IOException
566 * in case of IOException
568 public synchronized List
<MetaData
> getList() throws IOException
{
569 return getMetas(null);
573 * List all the stories of the given source type in the {@link BasicLibrary}
574 * , or all the stories if NULL is passed as a type.
576 * Cover images not included.
579 * the type of story to retrieve, or NULL for all
581 * @return the stories
583 * @throws IOException
584 * in case of IOException
586 public synchronized List
<MetaData
> getListBySource(String type
)
588 List
<MetaData
> list
= new ArrayList
<MetaData
>();
589 for (MetaData meta
: getMetas(null)) {
590 String storyType
= meta
.getSource();
591 if (type
== null || type
.equalsIgnoreCase(storyType
)) {
596 Collections
.sort(list
);
601 * List all the stories of the given author in the {@link BasicLibrary}, or
602 * all the stories if NULL is passed as an author.
604 * Cover images not included.
607 * the author of the stories to retrieve, or NULL for all
609 * @return the stories
611 * @throws IOException
612 * in case of IOException
614 public synchronized List
<MetaData
> getListByAuthor(String author
)
616 List
<MetaData
> list
= new ArrayList
<MetaData
>();
617 for (MetaData meta
: getMetas(null)) {
618 String storyAuthor
= meta
.getAuthor();
619 if (author
== null || author
.equalsIgnoreCase(storyAuthor
)) {
624 Collections
.sort(list
);
629 * Retrieve a {@link MetaData} corresponding to the given {@link Story},
630 * cover image <b>MAY</b> not be included.
633 * the Library UID of the story
635 * @return the corresponding {@link Story}
637 * @throws IOException
638 * in case of IOException
640 public synchronized MetaData
getInfo(String luid
) throws IOException
{
642 for (MetaData meta
: getMetas(null)) {
643 if (luid
.equals(meta
.getLuid())) {
653 * Retrieve a specific {@link Story}.
656 * the Library UID of the story
658 * the optional progress reporter
660 * @return the corresponding {@link Story} or NULL if not found
662 * @throws IOException
663 * in case of IOException
665 public synchronized Story
getStory(String luid
, Progress pg
)
667 Progress pgMetas
= new Progress();
668 Progress pgStory
= new Progress();
670 pg
.setMinMax(0, 100);
671 pg
.addProgress(pgMetas
, 10);
672 pg
.addProgress(pgStory
, 90);
675 MetaData meta
= null;
676 for (MetaData oneMeta
: getMetas(pgMetas
)) {
677 if (oneMeta
.getLuid().equals(luid
)) {
685 Story story
= getStory(luid
, meta
, pgStory
);
692 * Retrieve a specific {@link Story}.
695 * the meta of the story
697 * the optional progress reporter
699 * @return the corresponding {@link Story} or NULL if not found
701 * @throws IOException
702 * in case of IOException
704 public synchronized Story
getStory(String luid
,
705 @SuppressWarnings("javadoc") MetaData meta
, Progress pg
)
712 Progress pgGet
= new Progress();
713 Progress pgProcess
= new Progress();
716 pg
.addProgress(pgGet
, 1);
717 pg
.addProgress(pgProcess
, 1);
720 File file
= getFile(luid
, pgGet
);
723 SupportType type
= SupportType
.valueOfAllOkUC(meta
.getType());
724 URL url
= file
.toURI().toURL();
726 story
= BasicSupport
.getSupport(type
, url
) //
729 // Because we do not want to clear the meta cache:
730 meta
.setCover(story
.getMeta().getCover());
731 meta
.setResume(story
.getMeta().getResume());
735 throw new IOException("Unknown type: " + meta
.getType());
737 } catch (IOException e
) {
738 // We should not have not-supported files in the
740 Instance
.getTraceHandler().error(
741 new IOException(String
.format(
742 "Cannot load file of type '%s' from library: %s",
743 meta
.getType(), file
), e
));
753 * Import the {@link Story} at the given {@link URL} into the
754 * {@link BasicLibrary}.
757 * the {@link URL} to import
759 * the optional progress reporter
761 * @return the imported {@link Story}
763 * @throws UnknownHostException
764 * if the host is not supported
765 * @throws IOException
766 * in case of I/O error
768 public Story
imprt(URL url
, Progress pg
) throws IOException
{
772 pg
.setMinMax(0, 1000);
773 Progress pgProcess
= new Progress();
774 Progress pgSave
= new Progress();
775 pg
.addProgress(pgProcess
, 800);
776 pg
.addProgress(pgSave
, 200);
778 BasicSupport support
= BasicSupport
.getSupport(url
);
779 if (support
== null) {
780 throw new UnknownHostException("" + url
);
783 Story story
= save(support
.process(pgProcess
), pgSave
);
790 * Import the story from one library to another, and keep the same LUID.
793 * the other library to import from
797 * the optional progress reporter
799 * @throws IOException
800 * in case of I/O error
802 public void imprt(BasicLibrary other
, String luid
, Progress pg
)
804 Progress pgGetStory
= new Progress();
805 Progress pgSave
= new Progress();
811 pg
.addProgress(pgGetStory
, 1);
812 pg
.addProgress(pgSave
, 1);
814 Story story
= other
.getStory(luid
, pgGetStory
);
816 story
= this.save(story
, luid
, pgSave
);
820 throw new IOException("Cannot find story in Library: " + luid
);
825 * Export the {@link Story} to the given target in the given format.
828 * the {@link Story} ID
830 * the {@link OutputType} to transform it to
832 * the target to save to
834 * the optional progress reporter
836 * @return the saved resource (the main saved {@link File})
838 * @throws IOException
839 * in case of I/O error
841 public File
export(String luid
, OutputType type
, String target
, Progress pg
)
843 Progress pgGetStory
= new Progress();
844 Progress pgOut
= new Progress();
847 pg
.addProgress(pgGetStory
, 1);
848 pg
.addProgress(pgOut
, 1);
851 BasicOutput out
= BasicOutput
.getOutput(type
, false, false);
853 throw new IOException("Output type not supported: " + type
);
856 Story story
= getStory(luid
, pgGetStory
);
858 throw new IOException("Cannot find story to export: " + luid
);
861 return out
.process(story
, target
, pgOut
);
865 * Save a {@link Story} to the {@link BasicLibrary}.
868 * the {@link Story} to save
870 * the optional progress reporter
872 * @return the same {@link Story}, whose LUID may have changed
874 * @throws IOException
875 * in case of I/O error
877 public Story
save(Story story
, Progress pg
) throws IOException
{
878 return save(story
, null, pg
);
882 * Save a {@link Story} to the {@link BasicLibrary} -- the LUID <b>must</b>
883 * be correct, or NULL to get the next free one.
885 * Will override any previous {@link Story} with the same LUID.
888 * the {@link Story} to save
890 * the <b>correct</b> LUID or NULL to get the next free one
892 * the optional progress reporter
894 * @return the same {@link Story}, whose LUID may have changed
896 * @throws IOException
897 * in case of I/O error
899 public synchronized Story
save(Story story
, String luid
, Progress pg
)
902 Instance
.getTraceHandler().trace(
903 this.getClass().getSimpleName() + ": saving story " + luid
);
905 // Do not change the original metadata, but change the original story
906 MetaData meta
= story
.getMeta().clone();
909 if (luid
== null || luid
.isEmpty()) {
910 meta
.setLuid(String
.format("%03d", getNextId()));
915 if (luid
!= null && getInfo(luid
) != null) {
919 story
= doSave(story
, pg
);
921 updateInfo(story
.getMeta());
923 Instance
.getTraceHandler().trace(
924 this.getClass().getSimpleName() + ": story saved (" + luid
931 * Delete the given {@link Story} from this {@link BasicLibrary}.
934 * the LUID of the target {@link Story}
936 * @throws IOException
937 * in case of I/O error
939 public synchronized void delete(String luid
) throws IOException
{
940 Instance
.getTraceHandler().trace(
941 this.getClass().getSimpleName() + ": deleting story " + luid
);
944 invalidateInfo(luid
);
946 Instance
.getTraceHandler().trace(
947 this.getClass().getSimpleName() + ": story deleted (" + luid
952 * Change the type (source) of the given {@link Story}.
955 * the {@link Story} LUID
959 * the optional progress reporter
961 * @throws IOException
962 * in case of I/O error or if the {@link Story} was not found
964 public synchronized void changeSource(String luid
, String newSource
,
965 Progress pg
) throws IOException
{
966 MetaData meta
= getInfo(luid
);
968 throw new IOException("Story not found: " + luid
);
971 changeSTA(luid
, newSource
, meta
.getTitle(), meta
.getAuthor(), pg
);
975 * Change the title (name) of the given {@link Story}.
978 * the {@link Story} LUID
982 * the optional progress reporter
984 * @throws IOException
985 * in case of I/O error or if the {@link Story} was not found
987 public synchronized void changeTitle(String luid
, String newTitle
,
988 Progress pg
) throws IOException
{
989 MetaData meta
= getInfo(luid
);
991 throw new IOException("Story not found: " + luid
);
994 changeSTA(luid
, meta
.getSource(), newTitle
, meta
.getAuthor(), pg
);
998 * Change the author of the given {@link Story}.
1001 * the {@link Story} LUID
1005 * the optional progress reporter
1007 * @throws IOException
1008 * in case of I/O error or if the {@link Story} was not found
1010 public synchronized void changeAuthor(String luid
, String newAuthor
,
1011 Progress pg
) throws IOException
{
1012 MetaData meta
= getInfo(luid
);
1014 throw new IOException("Story not found: " + luid
);
1017 changeSTA(luid
, meta
.getSource(), meta
.getTitle(), newAuthor
, pg
);
1021 * Change the Source, Title and Author of the {@link Story} in one single
1025 * the {@link Story} LUID
1033 * the optional progress reporter
1035 * @throws IOException
1036 * in case of I/O error or if the {@link Story} was not found
1038 protected synchronized void changeSTA(String luid
, String newSource
,
1039 String newTitle
, String newAuthor
, Progress pg
) throws IOException
{
1040 MetaData meta
= getInfo(luid
);
1042 throw new IOException("Story not found: " + luid
);
1045 meta
.setSource(newSource
);
1046 meta
.setTitle(newTitle
);
1047 meta
.setAuthor(newAuthor
);
1050 invalidateInfo(luid
);
1054 * Save back the current state of the {@link MetaData} (LUID <b>MUST NOT</b>
1055 * change) for this {@link Story}.
1057 * By default, delete the old {@link Story} then recreate a new
1060 * Note that this behaviour can lead to data loss in case of problems!
1063 * the new {@link MetaData} (LUID <b>MUST NOT</b> change)
1065 * the optional {@link Progress}
1067 * @throws IOException
1068 * in case of I/O error or if the {@link Story} was not found
1070 protected synchronized void saveMeta(MetaData meta
, Progress pg
)
1071 throws IOException
{
1073 pg
= new Progress();
1076 Progress pgGet
= new Progress();
1077 Progress pgSet
= new Progress();
1078 pg
.addProgress(pgGet
, 50);
1079 pg
.addProgress(pgSet
, 50);
1081 Story story
= getStory(meta
.getLuid(), pgGet
);
1082 if (story
== null) {
1083 throw new IOException("Story not found: " + meta
.getLuid());
1086 // TODO: this is not safe!
1087 delete(meta
.getLuid());
1088 story
.setMeta(meta
);
1089 save(story
, meta
.getLuid(), pgSet
);