1 package be
.nikiroo
.fanfix
.supported
;
3 import java
.awt
.image
.BufferedImage
;
4 import java
.io
.BufferedReader
;
5 import java
.io
.ByteArrayInputStream
;
7 import java
.io
.IOException
;
8 import java
.io
.InputStream
;
9 import java
.io
.InputStreamReader
;
10 import java
.net
.MalformedURLException
;
12 import java
.util
.ArrayList
;
13 import java
.util
.HashMap
;
14 import java
.util
.List
;
16 import java
.util
.Map
.Entry
;
17 import java
.util
.Scanner
;
19 import be
.nikiroo
.fanfix
.Instance
;
20 import be
.nikiroo
.fanfix
.bundles
.Config
;
21 import be
.nikiroo
.fanfix
.bundles
.StringId
;
22 import be
.nikiroo
.fanfix
.data
.Chapter
;
23 import be
.nikiroo
.fanfix
.data
.MetaData
;
24 import be
.nikiroo
.fanfix
.data
.Paragraph
;
25 import be
.nikiroo
.fanfix
.data
.Paragraph
.ParagraphType
;
26 import be
.nikiroo
.fanfix
.data
.Story
;
27 import be
.nikiroo
.utils
.IOUtils
;
28 import be
.nikiroo
.utils
.Progress
;
29 import be
.nikiroo
.utils
.StringUtils
;
32 * This class is the base class used by the other support classes. It can be
33 * used outside of this package, and have static method that you can use to get
34 * access to the correct support class.
36 * It will be used with 'resources' (usually web pages or files).
40 public abstract class BasicSupport
{
42 * The supported input types for which we can get a {@link BasicSupport}
47 public enum SupportType
{
48 /** EPUB files created with this program */
50 /** Pure text file with some rules */
52 /** TEXT but with associated .info file */
54 /** My Little Pony fanfictions */
56 /** Fanfictions from a lot of different universes */
58 /** Website with lots of Mangas */
60 /** Furry website with comics support */
66 * A description of this support type (more information than the
67 * {@link BasicSupport#getSourceName()}).
69 * @return the description
71 public String
getDesc() {
72 String desc
= Instance
.getTrans().getStringX(StringId
.INPUT_DESC
,
76 desc
= Instance
.getTrans().getString(StringId
.INPUT_DESC
, this);
83 * The name of this support type (a short version).
87 public String
getSourceName() {
88 BasicSupport support
= BasicSupport
.getSupport(this);
89 if (support
!= null) {
90 return support
.getSourceName();
97 public String
toString() {
98 return super.toString().toLowerCase();
102 * Call {@link SupportType#valueOf(String.toUpperCase())}.
105 * the possible type name
107 * @return NULL or the type
109 public static SupportType
valueOfUC(String typeName
) {
110 return SupportType
.valueOf(typeName
== null ?
null : typeName
115 * Call {@link SupportType#valueOf(String.toUpperCase())} but return
116 * NULL for NULL instead of raising exception.
119 * the possible type name
121 * @return NULL or the type
123 public static SupportType
valueOfNullOkUC(String typeName
) {
124 if (typeName
== null) {
128 return SupportType
.valueOfUC(typeName
);
132 * Call {@link SupportType#valueOf(String.toUpperCase())} but return
133 * NULL in case of error instead of raising an exception.
136 * the possible type name
138 * @return NULL or the type
140 public static SupportType
valueOfAllOkUC(String typeName
) {
142 return SupportType
.valueOfUC(typeName
);
143 } catch (Exception e
) {
149 private InputStream in
;
150 private SupportType type
;
151 private URL currentReferer
; // with on 'r', as in 'HTTP'...
154 private char openQuote
= Instance
.getTrans().getChar(
155 StringId
.OPEN_SINGLE_QUOTE
);
156 private char closeQuote
= Instance
.getTrans().getChar(
157 StringId
.CLOSE_SINGLE_QUOTE
);
158 private char openDoubleQuote
= Instance
.getTrans().getChar(
159 StringId
.OPEN_DOUBLE_QUOTE
);
160 private char closeDoubleQuote
= Instance
.getTrans().getChar(
161 StringId
.CLOSE_DOUBLE_QUOTE
);
164 * The name of this support class.
168 protected abstract String
getSourceName();
171 * Check if the given resource is supported by this {@link BasicSupport}.
174 * the resource to check for
176 * @return TRUE if it is
178 protected abstract boolean supports(URL url
);
181 * Return TRUE if the support will return HTML encoded content values for
182 * the chapters content.
184 * @return TRUE for HTML
186 protected abstract boolean isHtml();
188 protected abstract MetaData
getMeta(URL source
, InputStream in
)
192 * Return the story description.
195 * the source of the story
197 * the input (the main resource)
199 * @return the description
201 * @throws IOException
202 * in case of I/O error
204 protected abstract String
getDesc(URL source
, InputStream in
)
208 * Return the list of chapters (name and resource).
211 * the source of the story
213 * the input (the main resource)
215 * @return the chapters
217 * @throws IOException
218 * in case of I/O error
220 protected abstract List
<Entry
<String
, URL
>> getChapters(URL source
,
221 InputStream in
) throws IOException
;
224 * Return the content of the chapter (possibly HTML encoded, if
225 * {@link BasicSupport#isHtml()} is TRUE).
228 * the source of the story
230 * the input (the main resource)
234 * @return the content
236 * @throws IOException
237 * in case of I/O error
239 protected abstract String
getChapterContent(URL source
, InputStream in
,
240 int number
) throws IOException
;
243 * Return the list of cookies (values included) that must be used to
244 * correctly fetch the resources.
246 * You are expected to call the super method implementation if you override
249 * @return the cookies
251 public Map
<String
, String
> getCookies() {
252 return new HashMap
<String
, String
>();
256 * Process the given story resource into a partially filled {@link Story}
257 * object containing the name and metadata, except for the description.
262 * @return the {@link Story}
264 * @throws IOException
265 * in case of I/O error
267 public Story
processMeta(URL url
) throws IOException
{
268 return processMeta(url
, true, false);
272 * Process the given story resource into a partially filled {@link Story}
273 * object containing the name and metadata.
279 * close "this" and "in" when done
281 * @return the {@link Story}
283 * @throws IOException
284 * in case of I/O error
286 protected Story
processMeta(URL url
, boolean close
, boolean getDesc
)
288 in
= Instance
.getCache().open(url
, this, false);
294 preprocess(url
, getInput());
296 Story story
= new Story();
297 MetaData meta
= getMeta(url
, getInput());
300 if (meta
!= null && meta
.getCover() == null) {
301 meta
.setCover(getDefaultCover(meta
.getSubject()));
305 String descChapterName
= Instance
.getTrans().getString(
306 StringId
.DESCRIPTION
);
307 story
.getMeta().setResume(
308 makeChapter(url
, 0, descChapterName
,
309 getDesc(url
, getInput())));
317 } catch (IOException e
) {
329 * Process the given story resource into a fully filled {@link Story}
335 * the optional progress reporter
337 * @return the {@link Story}
339 * @throws IOException
340 * in case of I/O error
342 public Story
process(URL url
, Progress pg
) throws IOException
{
346 pg
.setMinMax(0, 100);
349 setCurrentReferer(url
);
353 Story story
= processMeta(url
, false, true);
360 story
.setChapters(new ArrayList
<Chapter
>());
362 List
<Entry
<String
, URL
>> chapters
= getChapters(url
, getInput());
366 if (chapters
!= null) {
367 Progress pgChaps
= new Progress(0, chapters
.size());
368 pg
.addProgress(pgChaps
, 80);
370 for (Entry
<String
, URL
> chap
: chapters
) {
371 setCurrentReferer(chap
.getValue());
372 InputStream chapIn
= Instance
.getCache().open(
373 chap
.getValue(), this, true);
375 story
.getChapters().add(
376 makeChapter(url
, i
, chap
.getKey(),
377 getChapterContent(url
, chapIn
, i
)));
382 pgChaps
.setProgress(i
++);
393 } catch (IOException e
) {
401 currentReferer
= null;
410 public SupportType
getType() {
415 * The current referer {@link URL} (only one 'r', as in 'HTML'...), i.e.,
416 * the current {@link URL} we work on.
418 * @return the referer
420 public URL
getCurrentReferer() {
421 return currentReferer
;
425 * The current referer {@link URL} (only one 'r', as in 'HTML'...), i.e.,
426 * the current {@link URL} we work on.
428 * @param currentReferer
431 protected void setCurrentReferer(URL currentReferer
) {
432 this.currentReferer
= currentReferer
;
443 protected BasicSupport
setType(SupportType type
) {
449 * Prepare the support if needed before processing.
452 * the source of the story
454 * the input (the main resource)
456 * @throws IOException
459 protected void preprocess(URL source
, InputStream in
) throws IOException
{
463 * Now that we have processed the {@link Story}, close the resources if any.
465 * @throws IOException
468 protected void close() throws IOException
{
472 * Create a {@link Chapter} object from the given information, formatting
473 * the content as it should be.
480 * the chapter content
482 * @return the {@link Chapter}
484 * @throws IOException
485 * in case of I/O error
487 protected Chapter
makeChapter(URL source
, int number
, String name
,
488 String content
) throws IOException
{
489 // Chapter name: process it correctly, then remove the possible
490 // redundant "Chapter x: " in front of it
491 String chapterName
= processPara(name
).getContent().trim();
492 for (String lang
: Instance
.getConfig().getString(Config
.CHAPTER
)
494 String chapterWord
= Instance
.getConfig().getStringX(
495 Config
.CHAPTER
, lang
);
496 if (chapterName
.startsWith(chapterWord
)) {
497 chapterName
= chapterName
.substring(chapterWord
.length())
503 if (chapterName
.startsWith(Integer
.toString(number
))) {
504 chapterName
= chapterName
.substring(
505 Integer
.toString(number
).length()).trim();
508 if (chapterName
.startsWith(":")) {
509 chapterName
= chapterName
.substring(1).trim();
513 Chapter chap
= new Chapter(number
, chapterName
);
515 if (content
!= null) {
516 chap
.setParagraphs(makeParagraphs(source
, content
));
524 * Convert the given content into {@link Paragraph}s.
527 * the source URL of the story
529 * the textual content
531 * @return the {@link Paragraph}s
533 * @throws IOException
534 * in case of I/O error
536 protected List
<Paragraph
> makeParagraphs(URL source
, String content
)
539 // Special <HR> processing:
540 content
= content
.replaceAll("(<hr [^>]*>)|(<hr/>)|(<hr>)",
544 List
<Paragraph
> paras
= new ArrayList
<Paragraph
>();
545 InputStream in
= new ByteArrayInputStream(content
.getBytes("UTF-8"));
547 BufferedReader buff
= new BufferedReader(new InputStreamReader(in
,
550 for (String encodedLine
= buff
.readLine(); encodedLine
!= null; encodedLine
= buff
554 lines
= encodedLine
.split("(<p>|</p>|<br>|<br/>|\\n)");
556 lines
= new String
[] { encodedLine
};
559 for (String aline
: lines
) {
560 String line
= aline
.trim();
563 if (line
.startsWith("[") && line
.endsWith("]")) {
564 image
= getImageUrl(this, source
,
565 line
.substring(1, line
.length() - 1).trim());
569 paras
.add(new Paragraph(image
));
571 paras
.add(processPara(line
));
579 // Check quotes for "bad" format
580 List
<Paragraph
> newParas
= new ArrayList
<Paragraph
>();
581 for (Paragraph para
: paras
) {
582 newParas
.addAll(requotify(para
));
586 // Remove double blanks/brks
587 fixBlanksBreaks(paras
);
593 * Fix the {@link ParagraphType#BLANK}s and {@link ParagraphType#BREAK}s of
594 * those {@link Paragraph}s.
596 * The resulting list will not contain a starting or trailing blank/break
597 * nor 2 blanks or breaks following each other.
600 * the list of {@link Paragraph}s to fix
602 protected void fixBlanksBreaks(List
<Paragraph
> paras
) {
603 boolean space
= false;
605 for (int i
= 0; i
< paras
.size(); i
++) {
606 Paragraph para
= paras
.get(i
);
607 boolean thisSpace
= para
.getType() == ParagraphType
.BLANK
;
608 boolean thisBrk
= para
.getType() == ParagraphType
.BREAK
;
610 if (i
> 0 && space
&& thisBrk
) {
613 } else if ((space
|| brk
) && (thisSpace
|| thisBrk
)) {
622 // Remove blank/brk at start
624 && (paras
.get(0).getType() == ParagraphType
.BLANK
|| paras
.get(
625 0).getType() == ParagraphType
.BREAK
)) {
629 // Remove blank/brk at end
630 int last
= paras
.size() - 1;
632 && (paras
.get(last
).getType() == ParagraphType
.BLANK
|| paras
633 .get(last
).getType() == ParagraphType
.BREAK
)) {
639 * Get the default cover related to this subject (see <tt>.info</tt> files).
644 * @return the cover if any, or NULL
646 static BufferedImage
getDefaultCover(String subject
) {
647 if (subject
!= null && !subject
.isEmpty()
648 && Instance
.getCoverDir() != null) {
650 File fileCover
= new File(Instance
.getCoverDir(), subject
);
651 return getImage(null, fileCover
.toURI().toURL(), subject
);
652 } catch (MalformedURLException e
) {
660 * Return the list of supported image extensions.
662 * @return the extensions
664 static String
[] getImageExt(boolean emptyAllowed
) {
666 return new String
[] { "", ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
668 return new String
[] { ".png", ".jpg", ".jpeg", ".gif", ".bmp" };
672 static BufferedImage
getImage(BasicSupport support
, URL source
, String line
) {
673 URL url
= getImageUrl(support
, source
, line
);
675 InputStream in
= null;
677 in
= Instance
.getCache().open(url
, getSupport(url
), true);
678 return IOUtils
.toImage(in
);
679 } catch (IOException e
) {
684 } catch (IOException e
) {
694 * Check if the given resource can be a local image or a remote image, then
695 * refresh the cache with it if it is.
700 * the resource to check
702 * @return the image URL if found, or NULL
705 static URL
getImageUrl(BasicSupport support
, URL source
, String line
) {
711 if (source
!= null) {
712 path
= new File(source
.getFile()).getParent();
714 String basePath
= new File(new File(path
), line
.trim())
716 for (String ext
: getImageExt(true)) {
717 if (new File(basePath
+ ext
).exists()) {
718 url
= new File(basePath
+ ext
).toURI().toURL();
721 } catch (Exception e
) {
722 // Nothing to do here
729 for (String ext
: getImageExt(true)) {
730 if (Instance
.getCache().check(new URL(line
+ ext
))) {
731 url
= new URL(line
+ ext
);
738 for (String ext
: getImageExt(true)) {
740 url
= new URL(line
+ ext
);
741 Instance
.getCache().refresh(url
, support
, true);
743 } catch (IOException e
) {
744 // no image with this ext
749 } catch (MalformedURLException e
) {
754 // refresh the cached file
757 Instance
.getCache().refresh(url
, support
, true);
758 } catch (IOException e
) {
759 // woops, broken image
768 protected InputStream
reset(InputStream in
) {
771 } catch (IOException e
) {
777 * Reset then return {@link BasicSupport#in}.
779 * @return {@link BasicSupport#in}
781 protected InputStream
getInput() {
786 * Fix the author name if it is prefixed with some "by" {@link String}.
789 * the author with a possible prefix
791 * @return the author without prefixes
793 protected String
fixAuthor(String author
) {
794 if (author
!= null) {
795 for (String suffix
: new String
[] { " ", ":" }) {
796 for (String byString
: Instance
.getConfig()
797 .getString(Config
.BYS
).split(",")) {
799 if (author
.toUpperCase().startsWith(byString
.toUpperCase())) {
800 author
= author
.substring(byString
.length()).trim();
805 // Special case (without suffix):
806 if (author
.startsWith("©")) {
807 author
= author
.substring(1);
815 * Check quotes for bad format (i.e., quotes with normal paragraphs inside)
816 * and requotify them (i.e., separate them into QUOTE paragraphs and other
817 * paragraphs (quotes or not)).
820 * the paragraph to requotify (not necessaraly a quote)
822 * @return the correctly (or so we hope) quotified paragraphs
824 protected List
<Paragraph
> requotify(Paragraph para
) {
825 List
<Paragraph
> newParas
= new ArrayList
<Paragraph
>();
827 if (para
.getType() == ParagraphType
.QUOTE
828 && para
.getContent().length() > 2) {
829 String line
= para
.getContent();
830 boolean singleQ
= line
.startsWith("" + openQuote
);
831 boolean doubleQ
= line
.startsWith("" + openDoubleQuote
);
833 // Do not try when more than one quote at a time
834 // (some stories are not easily readable if we do)
836 && line
.indexOf(closeQuote
, 1) < line
837 .lastIndexOf(closeQuote
)) {
842 && line
.indexOf(closeDoubleQuote
, 1) < line
843 .lastIndexOf(closeDoubleQuote
)) {
849 if (!singleQ
&& !doubleQ
) {
850 line
= openDoubleQuote
+ line
+ closeDoubleQuote
;
851 newParas
.add(new Paragraph(ParagraphType
.QUOTE
, line
));
853 char open
= singleQ ? openQuote
: openDoubleQuote
;
854 char close
= singleQ ? closeQuote
: closeDoubleQuote
;
857 boolean inQuote
= false;
859 for (char car
: line
.toCharArray()) {
862 } else if (car
== close
) {
864 } else if (car
== '.' && !inQuote
) {
872 String rest
= line
.substring(posDot
+ 1).trim();
873 line
= line
.substring(0, posDot
+ 1).trim();
874 newParas
.add(new Paragraph(ParagraphType
.QUOTE
, line
));
875 if (!rest
.isEmpty()) {
876 newParas
.addAll(requotify(processPara(rest
)));
890 * Process a {@link Paragraph} from a raw line of text.
892 * Will also fix quotes and HTML encoding if needed.
897 * @return the processed {@link Paragraph}
899 private Paragraph
processPara(String line
) {
900 line
= ifUnhtml(line
).trim();
902 boolean space
= true;
904 boolean quote
= false;
905 boolean tentativeCloseQuote
= false;
909 StringBuilder builder
= new StringBuilder();
910 for (char car
: line
.toCharArray()) {
913 // dash, ndash and mdash: - – —
914 // currently: always use mdash
915 builder
.append(dashCount
== 1 ?
'-' : '—');
920 if (tentativeCloseQuote
) {
921 tentativeCloseQuote
= false;
922 if ((car
>= 'a' && car
<= 'z') || (car
>= 'A' && car
<= 'Z')
923 || (car
>= '0' && car
<= '9')) {
926 builder
.append(closeQuote
);
931 case ' ': // note: unbreakable space
934 case '\n': // just in case
935 case '\r': // just in case
940 if (space
|| (brk
&& quote
)) {
942 builder
.append(openQuote
);
943 } else if (prev
== ' ') {
944 builder
.append(openQuote
);
946 // it is a quote ("I'm off") or a 'quote' ("This
947 // 'good' restaurant"...)
948 tentativeCloseQuote
= true;
953 if (space
|| (brk
&& quote
)) {
955 builder
.append(openDoubleQuote
);
956 } else if (prev
== ' ') {
957 builder
.append(openDoubleQuote
);
959 builder
.append(closeDoubleQuote
);
993 if (space
|| (brk
&& quote
)) {
995 builder
.append(openQuote
);
997 builder
.append(openQuote
);
1010 builder
.append(closeQuote
);
1018 if (space
|| (brk
&& quote
)) {
1020 builder
.append(openDoubleQuote
);
1022 builder
.append(openDoubleQuote
);
1035 builder
.append(closeDoubleQuote
);
1041 builder
.append(car
);
1048 if (tentativeCloseQuote
) {
1049 tentativeCloseQuote
= false;
1050 builder
.append(closeQuote
);
1053 line
= builder
.toString().trim();
1055 ParagraphType type
= ParagraphType
.NORMAL
;
1057 type
= ParagraphType
.BLANK
;
1059 type
= ParagraphType
.BREAK
;
1061 type
= ParagraphType
.QUOTE
;
1064 return new Paragraph(type
, line
);
1068 * Remove the HTML from the inpit <b>if</b> {@link BasicSupport#isHtml()} is
1074 * @return the no html version if needed
1076 private String
ifUnhtml(String input
) {
1077 if (isHtml() && input
!= null) {
1078 return StringUtils
.unhtml(input
);
1085 * Return a {@link BasicSupport} implementation supporting the given
1086 * resource if possible.
1089 * the story resource
1091 * @return an implementation that supports it, or NULL
1093 public static BasicSupport
getSupport(URL url
) {
1098 // TEXT and INFO_TEXT always support files (not URLs though)
1099 for (SupportType type
: SupportType
.values()) {
1100 if (type
!= SupportType
.TEXT
&& type
!= SupportType
.INFO_TEXT
) {
1101 BasicSupport support
= getSupport(type
);
1102 if (support
!= null && support
.supports(url
)) {
1108 for (SupportType type
: new SupportType
[] { SupportType
.TEXT
,
1109 SupportType
.INFO_TEXT
}) {
1110 BasicSupport support
= getSupport(type
);
1111 if (support
!= null && support
.supports(url
)) {
1120 * Return a {@link BasicSupport} implementation supporting the given type.
1125 * @return an implementation that supports it, or NULL
1127 public static BasicSupport
getSupport(SupportType type
) {
1130 return new Epub().setType(type
);
1132 return new InfoText().setType(type
);
1134 return new Fimfiction().setType(type
);
1136 return new Fanfiction().setType(type
);
1138 return new Text().setType(type
);
1140 return new MangaFox().setType(type
);
1142 return new E621().setType(type
);
1144 return new Cbz().setType(type
);
1151 * Return the first line from the given input which correspond to the given
1157 * a string that must be found inside the target line (also
1158 * supports "^" at start to say "only if it starts with" the
1160 * @param relativeLine
1161 * the line to return based upon the target line position (-1 =
1162 * the line before, 0 = the target line...)
1166 static String
getLine(InputStream in
, String needle
, int relativeLine
) {
1167 return getLine(in
, needle
, relativeLine
, true);
1171 * Return a line from the given input which correspond to the given
1177 * a string that must be found inside the target line (also
1178 * supports "^" at start to say "only if it starts with" the
1180 * @param relativeLine
1181 * the line to return based upon the target line position (-1 =
1182 * the line before, 0 = the target line...)
1184 * takes the first result (as opposed to the last one, which will
1185 * also always spend the input)
1189 static String
getLine(InputStream in
, String needle
, int relativeLine
,
1195 } catch (IOException e
) {
1199 List
<String
> lines
= new ArrayList
<String
>();
1200 @SuppressWarnings("resource")
1201 Scanner scan
= new Scanner(in
, "UTF-8");
1203 scan
.useDelimiter("\\n");
1204 while (scan
.hasNext()) {
1205 lines
.add(scan
.next());
1208 if (needle
.startsWith("^")) {
1209 if (lines
.get(lines
.size() - 1).startsWith(
1210 needle
.substring(1))) {
1211 index
= lines
.size() - 1;
1215 if (lines
.get(lines
.size() - 1).contains(needle
)) {
1216 index
= lines
.size() - 1;
1221 if (index
>= 0 && index
+ relativeLine
< lines
.size()) {
1222 rep
= lines
.get(index
+ relativeLine
);