From a576d9ef54e95550708a73ba551e4c18604eb44d Mon Sep 17 00:00:00 2001 From: Jim Miller Date: Fri, 19 Oct 2012 14:00:22 -0500 Subject: [PATCH] Added tag FanFictionDownLoader-4.4.29a for changeset c7f7f8deb13b --- allrecent.html | 78 + app.yaml | 46 + calibre-plugin/__init__.py | 90 + calibre-plugin/about.txt | 28 + calibre-plugin/common_utils.py | 543 ++ calibre-plugin/config.py | 918 ++++ calibre-plugin/dialogs.py | 714 +++ calibre-plugin/ffdl_plugin.py | 1291 +++++ calibre-plugin/images/icon.png | Bin 0 -> 24649 bytes calibre-plugin/images/icon.xcf | Bin 0 -> 63927 bytes calibre-plugin/jobs.py | 193 + ...mport-name-fanfictiondownloader_plugin.txt | 0 cron.yaml | 10 + css/index.css | 73 + defaults.ini | 1218 +++++ delete_fic.py | 59 + downloader.py | 247 + editconfig.html | 89 + epubmerge.py | 25 + example.ini | 103 + fanficdownloader/BeautifulSoup.py | 2014 ++++++++ fanficdownloader/__init__.py | 16 + fanficdownloader/adapters/__init__.py | 179 + .../adapters/adapter_adastrafanficcom.py | 228 + .../adapters/adapter_archiveofourownorg.py | 344 ++ .../adapters/adapter_archiveskyehawkecom.py | 191 + .../adapter_ashwindersycophanthexcom.py | 252 + .../adapters/adapter_bloodtiesfancom.py | 334 ++ .../adapters/adapter_castlefansorg.py | 307 ++ .../adapters/adapter_chaossycophanthexcom.py | 235 + .../adapters/adapter_checkmatedcom.py | 237 + .../adapters/adapter_darksolaceorg.py | 275 ++ .../adapters/adapter_destinysgatewaycom.py | 241 + .../adapters/adapter_dokugacom.py | 238 + .../adapters/adapter_dracoandginnycom.py | 299 ++ .../adapters/adapter_dramioneorg.py | 297 ++ .../adapter_erosnsapphosycophanthexcom.py | 253 + .../adapters/adapter_fanfictionnet.py | 276 ++ .../adapters/adapter_fanfiktionde.py | 200 + .../adapters/adapter_ficbooknet.py | 225 + .../adapters/adapter_fictionalleyorg.py | 231 + .../adapters/adapter_fictionpresscom.py | 50 + .../adapters/adapter_ficwadcom.py | 218 + .../adapters/adapter_fimfictionnet.py | 184 + .../adapters/adapter_finestoriescom.py | 294 ++ .../adapters/adapter_grangerenchantedcom.py | 298 ++ .../adapter_harrypotterfanfictioncom.py | 201 + .../adapters/adapter_hlfictionnet.py | 229 + .../adapters/adapter_hpfandomnet.py | 231 + .../adapters/adapter_hpfanficarchivecom.py | 217 + .../adapters/adapter_iketernalnet.py | 281 ++ .../adapters/adapter_ksarchivecom.py | 312 ++ .../adapters/adapter_libraryofmoriacom.py | 248 + .../adapters/adapter_lumossycophanthexcom.py | 235 + .../adapters/adapter_mediaminerorg.py | 236 + .../adapters/adapter_merlinficdtwinscouk.py | 291 ++ .../adapters/adapter_midnightwhispersca.py | 287 ++ .../adapters/adapter_mugglenetcom.py | 329 ++ .../adapters/adapter_nationallibrarynet.py | 209 + .../adapters/adapter_ncisficcom.py | 216 + .../adapters/adapter_ncisfictioncom.py | 201 + .../adapters/adapter_nfacommunitycom.py | 287 ++ .../adapters/adapter_nhamagicalworldsus.py | 212 + .../adapter_occlumencysycophanthexcom.py | 261 + .../adapter_onedirectionfanfictioncom.py | 267 + .../adapters/adapter_phoenixsongnet.py | 238 + .../adapters/adapter_ponyfictionarchivenet.py | 248 + .../adapters/adapter_portkeyorg.py | 274 ++ .../adapters/adapter_potionsandsnitchesnet.py | 209 + .../adapters/adapter_pretendercentrecom.py | 251 + .../adapters/adapter_prisonbreakficnet.py | 216 + .../adapters/adapter_qafficcom.py | 262 + .../adapters/adapter_samdeanarchivenu.py | 230 + .../adapters/adapter_scarvesandcoffeenet.py | 245 + .../adapters/adapter_sg1heliopoliscom.py | 256 + .../adapters/adapter_sinfuldesireorg.py | 249 + fanficdownloader/adapters/adapter_siyecouk.py | 243 + .../adapters/adapter_squidgeorgpeja.py | 237 + .../adapters/adapter_stargateatlantisorg.py | 227 + .../adapters/adapter_storiesofardacom.py | 148 + .../adapters/adapter_svufictioncom.py | 272 + .../adapters/adapter_tenhawkpresentscom.py | 247 + fanficdownloader/adapters/adapter_test1.py | 236 + .../adapters/adapter_thealphagatecom.py | 212 + .../adapters/adapter_thehexfilesnet.py | 204 + .../adapters/adapter_thehookupzonenet.py | 306 ++ .../adapters/adapter_themasquenet.py | 272 + .../adapters/adapter_thepetulantpoetesscom.py | 240 + .../adapters/adapter_thequidditchpitchorg.py | 289 ++ .../adapter_thewriterscoffeeshopcom.py | 259 + .../adapters/adapter_tthfanficorg.py | 280 ++ .../adapters/adapter_twilightarchivescom.py | 186 + .../adapters/adapter_twilightednet.py | 250 + .../adapters/adapter_twiwritenet.py | 276 ++ .../adapters/adapter_walkingtheplankorg.py | 229 + .../adapters/adapter_whoficcom.py | 233 + .../adapters/adapter_wizardtalesnet.py | 300 ++ .../adapters/adapter_wolverineandroguecom.py | 217 + .../adapters/adapter_wraithbaitcom.py | 223 + .../adapters/adapter_yourfanfictioncom.py | 284 ++ fanficdownloader/adapters/base_adapter.py | 389 ++ fanficdownloader/bbcodeutils/__init__.py | 0 fanficdownloader/bbcodeutils/bbcode2html.py | 325 ++ fanficdownloader/bbcodeutils/bbcodebuilder.py | 77 + fanficdownloader/bbcodeutils/bbcodeparser.py | 255 + fanficdownloader/bbcodeutils/readme.txt | 81 + fanficdownloader/bbcodeutils/test.py | 420 ++ fanficdownloader/chardet/__init__.py | 26 + fanficdownloader/chardet/big5freq.py | 923 ++++ fanficdownloader/chardet/big5prober.py | 41 + fanficdownloader/chardet/chardistribution.py | 200 + .../chardet/charsetgroupprober.py | 96 + fanficdownloader/chardet/charsetprober.py | 60 + .../chardet/codingstatemachine.py | 56 + fanficdownloader/chardet/constants.py | 47 + fanficdownloader/chardet/escprober.py | 79 + fanficdownloader/chardet/escsm.py | 240 + fanficdownloader/chardet/eucjpprober.py | 85 + fanficdownloader/chardet/euckrfreq.py | 594 +++ fanficdownloader/chardet/euckrprober.py | 41 + fanficdownloader/chardet/euctwfreq.py | 426 ++ fanficdownloader/chardet/euctwprober.py | 41 + fanficdownloader/chardet/gb2312freq.py | 471 ++ fanficdownloader/chardet/gb2312prober.py | 41 + fanficdownloader/chardet/hebrewprober.py | 269 + fanficdownloader/chardet/jisfreq.py | 567 +++ fanficdownloader/chardet/jpcntx.py | 210 + .../chardet/langbulgarianmodel.py | 228 + fanficdownloader/chardet/langcyrillicmodel.py | 329 ++ fanficdownloader/chardet/langgreekmodel.py | 225 + fanficdownloader/chardet/langhebrewmodel.py | 201 + .../chardet/langhungarianmodel.py | 225 + fanficdownloader/chardet/langthaimodel.py | 200 + fanficdownloader/chardet/latin1prober.py | 136 + fanficdownloader/chardet/mbcharsetprober.py | 82 + fanficdownloader/chardet/mbcsgroupprober.py | 50 + fanficdownloader/chardet/mbcssm.py | 514 ++ fanficdownloader/chardet/sbcharsetprober.py | 106 + fanficdownloader/chardet/sbcsgroupprober.py | 64 + fanficdownloader/chardet/sjisprober.py | 85 + fanficdownloader/chardet/test.py | 20 + fanficdownloader/chardet/universaldetector.py | 154 + fanficdownloader/chardet/utf8prober.py | 76 + fanficdownloader/configurable.py | 141 + fanficdownloader/epubutils.py | 188 + fanficdownloader/exceptions.py | 70 + fanficdownloader/geturls.py | 110 + fanficdownloader/gziphttp.py | 38 + fanficdownloader/html.py | 126 + fanficdownloader/html2text.py | 453 ++ fanficdownloader/htmlcleanup.py | 478 ++ fanficdownloader/mobi.py | 384 ++ fanficdownloader/story.py | 575 +++ fanficdownloader/translit.py | 57 + fanficdownloader/writers/__init__.py | 38 + fanficdownloader/writers/base_writer.py | 284 ++ fanficdownloader/writers/writer_epub.py | 684 +++ fanficdownloader/writers/writer_html.py | 144 + fanficdownloader/writers/writer_mobi.py | 192 + fanficdownloader/writers/writer_txt.py | 190 + ffstorage.py | 63 + index-ajax.html | 109 + index.html | 605 +++ index.yaml | 28 + js/fdownloader.js | 116 + js/jquery-1.3.2.js | 4376 +++++++++++++++++ login.html | 110 + main.py | 624 +++ makeplugin.py | 38 + makezip.py | 54 + plugin-defaults.ini | 1206 +++++ plugin-example.ini | 101 + queue.yaml | 7 + readme.txt | 37 + recent.html | 85 + settings.py | 25 + simplejson/__init__.py | 318 ++ simplejson/_speedups.c | 2329 +++++++++ simplejson/decoder.py | 354 ++ simplejson/encoder.py | 440 ++ simplejson/scanner.py | 65 + simplejson/tests/__init__.py | 23 + simplejson/tests/test_check_circular.py | 30 + simplejson/tests/test_decode.py | 22 + simplejson/tests/test_default.py | 9 + simplejson/tests/test_dump.py | 21 + .../tests/test_encode_basestring_ascii.py | 38 + simplejson/tests/test_fail.py | 76 + simplejson/tests/test_float.py | 15 + simplejson/tests/test_indent.py | 41 + simplejson/tests/test_pass1.py | 76 + simplejson/tests/test_pass2.py | 14 + simplejson/tests/test_pass3.py | 20 + simplejson/tests/test_recursion.py | 67 + simplejson/tests/test_scanstring.py | 111 + simplejson/tests/test_separators.py | 42 + simplejson/tests/test_unicode.py | 64 + simplejson/tool.py | 37 + static/ajax-loader.gif | Bin 0 -> 10819 bytes static/favicon.ico | Bin 0 -> 21792 bytes status.html | 94 + utils/__init__.py | 1 + utils/remover.py | 109 + utils/tally.py | 64 + 204 files changed, 51910 insertions(+) create mode 100644 allrecent.html create mode 100644 app.yaml create mode 100644 calibre-plugin/__init__.py create mode 100644 calibre-plugin/about.txt create mode 100644 calibre-plugin/common_utils.py create mode 100644 calibre-plugin/config.py create mode 100644 calibre-plugin/dialogs.py create mode 100644 calibre-plugin/ffdl_plugin.py create mode 100644 calibre-plugin/images/icon.png create mode 100644 calibre-plugin/images/icon.xcf create mode 100644 calibre-plugin/jobs.py create mode 100644 calibre-plugin/plugin-import-name-fanfictiondownloader_plugin.txt create mode 100644 cron.yaml create mode 100644 css/index.css create mode 100644 defaults.ini create mode 100644 delete_fic.py create mode 100644 downloader.py create mode 100644 editconfig.html create mode 100644 epubmerge.py create mode 100644 example.ini create mode 100644 fanficdownloader/BeautifulSoup.py create mode 100644 fanficdownloader/__init__.py create mode 100644 fanficdownloader/adapters/__init__.py create mode 100644 fanficdownloader/adapters/adapter_adastrafanficcom.py create mode 100644 fanficdownloader/adapters/adapter_archiveofourownorg.py create mode 100644 fanficdownloader/adapters/adapter_archiveskyehawkecom.py create mode 100644 fanficdownloader/adapters/adapter_ashwindersycophanthexcom.py create mode 100644 fanficdownloader/adapters/adapter_bloodtiesfancom.py create mode 100644 fanficdownloader/adapters/adapter_castlefansorg.py create mode 100644 fanficdownloader/adapters/adapter_chaossycophanthexcom.py create mode 100644 fanficdownloader/adapters/adapter_checkmatedcom.py create mode 100644 fanficdownloader/adapters/adapter_darksolaceorg.py create mode 100644 fanficdownloader/adapters/adapter_destinysgatewaycom.py create mode 100644 fanficdownloader/adapters/adapter_dokugacom.py create mode 100644 fanficdownloader/adapters/adapter_dracoandginnycom.py create mode 100644 fanficdownloader/adapters/adapter_dramioneorg.py create mode 100644 fanficdownloader/adapters/adapter_erosnsapphosycophanthexcom.py create mode 100644 fanficdownloader/adapters/adapter_fanfictionnet.py create mode 100644 fanficdownloader/adapters/adapter_fanfiktionde.py create mode 100644 fanficdownloader/adapters/adapter_ficbooknet.py create mode 100644 fanficdownloader/adapters/adapter_fictionalleyorg.py create mode 100644 fanficdownloader/adapters/adapter_fictionpresscom.py create mode 100644 fanficdownloader/adapters/adapter_ficwadcom.py create mode 100644 fanficdownloader/adapters/adapter_fimfictionnet.py create mode 100644 fanficdownloader/adapters/adapter_finestoriescom.py create mode 100644 fanficdownloader/adapters/adapter_grangerenchantedcom.py create mode 100644 fanficdownloader/adapters/adapter_harrypotterfanfictioncom.py create mode 100644 fanficdownloader/adapters/adapter_hlfictionnet.py create mode 100644 fanficdownloader/adapters/adapter_hpfandomnet.py create mode 100644 fanficdownloader/adapters/adapter_hpfanficarchivecom.py create mode 100644 fanficdownloader/adapters/adapter_iketernalnet.py create mode 100644 fanficdownloader/adapters/adapter_ksarchivecom.py create mode 100644 fanficdownloader/adapters/adapter_libraryofmoriacom.py create mode 100644 fanficdownloader/adapters/adapter_lumossycophanthexcom.py create mode 100644 fanficdownloader/adapters/adapter_mediaminerorg.py create mode 100644 fanficdownloader/adapters/adapter_merlinficdtwinscouk.py create mode 100644 fanficdownloader/adapters/adapter_midnightwhispersca.py create mode 100644 fanficdownloader/adapters/adapter_mugglenetcom.py create mode 100644 fanficdownloader/adapters/adapter_nationallibrarynet.py create mode 100644 fanficdownloader/adapters/adapter_ncisficcom.py create mode 100644 fanficdownloader/adapters/adapter_ncisfictioncom.py create mode 100644 fanficdownloader/adapters/adapter_nfacommunitycom.py create mode 100644 fanficdownloader/adapters/adapter_nhamagicalworldsus.py create mode 100644 fanficdownloader/adapters/adapter_occlumencysycophanthexcom.py create mode 100644 fanficdownloader/adapters/adapter_onedirectionfanfictioncom.py create mode 100644 fanficdownloader/adapters/adapter_phoenixsongnet.py create mode 100644 fanficdownloader/adapters/adapter_ponyfictionarchivenet.py create mode 100644 fanficdownloader/adapters/adapter_portkeyorg.py create mode 100644 fanficdownloader/adapters/adapter_potionsandsnitchesnet.py create mode 100644 fanficdownloader/adapters/adapter_pretendercentrecom.py create mode 100644 fanficdownloader/adapters/adapter_prisonbreakficnet.py create mode 100644 fanficdownloader/adapters/adapter_qafficcom.py create mode 100644 fanficdownloader/adapters/adapter_samdeanarchivenu.py create mode 100644 fanficdownloader/adapters/adapter_scarvesandcoffeenet.py create mode 100644 fanficdownloader/adapters/adapter_sg1heliopoliscom.py create mode 100644 fanficdownloader/adapters/adapter_sinfuldesireorg.py create mode 100644 fanficdownloader/adapters/adapter_siyecouk.py create mode 100644 fanficdownloader/adapters/adapter_squidgeorgpeja.py create mode 100644 fanficdownloader/adapters/adapter_stargateatlantisorg.py create mode 100644 fanficdownloader/adapters/adapter_storiesofardacom.py create mode 100644 fanficdownloader/adapters/adapter_svufictioncom.py create mode 100644 fanficdownloader/adapters/adapter_tenhawkpresentscom.py create mode 100644 fanficdownloader/adapters/adapter_test1.py create mode 100644 fanficdownloader/adapters/adapter_thealphagatecom.py create mode 100644 fanficdownloader/adapters/adapter_thehexfilesnet.py create mode 100644 fanficdownloader/adapters/adapter_thehookupzonenet.py create mode 100644 fanficdownloader/adapters/adapter_themasquenet.py create mode 100644 fanficdownloader/adapters/adapter_thepetulantpoetesscom.py create mode 100644 fanficdownloader/adapters/adapter_thequidditchpitchorg.py create mode 100644 fanficdownloader/adapters/adapter_thewriterscoffeeshopcom.py create mode 100644 fanficdownloader/adapters/adapter_tthfanficorg.py create mode 100644 fanficdownloader/adapters/adapter_twilightarchivescom.py create mode 100644 fanficdownloader/adapters/adapter_twilightednet.py create mode 100644 fanficdownloader/adapters/adapter_twiwritenet.py create mode 100644 fanficdownloader/adapters/adapter_walkingtheplankorg.py create mode 100644 fanficdownloader/adapters/adapter_whoficcom.py create mode 100644 fanficdownloader/adapters/adapter_wizardtalesnet.py create mode 100644 fanficdownloader/adapters/adapter_wolverineandroguecom.py create mode 100644 fanficdownloader/adapters/adapter_wraithbaitcom.py create mode 100644 fanficdownloader/adapters/adapter_yourfanfictioncom.py create mode 100644 fanficdownloader/adapters/base_adapter.py create mode 100644 fanficdownloader/bbcodeutils/__init__.py create mode 100644 fanficdownloader/bbcodeutils/bbcode2html.py create mode 100644 fanficdownloader/bbcodeutils/bbcodebuilder.py create mode 100644 fanficdownloader/bbcodeutils/bbcodeparser.py create mode 100644 fanficdownloader/bbcodeutils/readme.txt create mode 100644 fanficdownloader/bbcodeutils/test.py create mode 100644 fanficdownloader/chardet/__init__.py create mode 100644 fanficdownloader/chardet/big5freq.py create mode 100644 fanficdownloader/chardet/big5prober.py create mode 100644 fanficdownloader/chardet/chardistribution.py create mode 100644 fanficdownloader/chardet/charsetgroupprober.py create mode 100644 fanficdownloader/chardet/charsetprober.py create mode 100644 fanficdownloader/chardet/codingstatemachine.py create mode 100644 fanficdownloader/chardet/constants.py create mode 100644 fanficdownloader/chardet/escprober.py create mode 100644 fanficdownloader/chardet/escsm.py create mode 100644 fanficdownloader/chardet/eucjpprober.py create mode 100644 fanficdownloader/chardet/euckrfreq.py create mode 100644 fanficdownloader/chardet/euckrprober.py create mode 100644 fanficdownloader/chardet/euctwfreq.py create mode 100644 fanficdownloader/chardet/euctwprober.py create mode 100644 fanficdownloader/chardet/gb2312freq.py create mode 100644 fanficdownloader/chardet/gb2312prober.py create mode 100644 fanficdownloader/chardet/hebrewprober.py create mode 100644 fanficdownloader/chardet/jisfreq.py create mode 100644 fanficdownloader/chardet/jpcntx.py create mode 100644 fanficdownloader/chardet/langbulgarianmodel.py create mode 100644 fanficdownloader/chardet/langcyrillicmodel.py create mode 100644 fanficdownloader/chardet/langgreekmodel.py create mode 100644 fanficdownloader/chardet/langhebrewmodel.py create mode 100644 fanficdownloader/chardet/langhungarianmodel.py create mode 100644 fanficdownloader/chardet/langthaimodel.py create mode 100644 fanficdownloader/chardet/latin1prober.py create mode 100644 fanficdownloader/chardet/mbcharsetprober.py create mode 100644 fanficdownloader/chardet/mbcsgroupprober.py create mode 100644 fanficdownloader/chardet/mbcssm.py create mode 100644 fanficdownloader/chardet/sbcharsetprober.py create mode 100644 fanficdownloader/chardet/sbcsgroupprober.py create mode 100644 fanficdownloader/chardet/sjisprober.py create mode 100644 fanficdownloader/chardet/test.py create mode 100644 fanficdownloader/chardet/universaldetector.py create mode 100644 fanficdownloader/chardet/utf8prober.py create mode 100644 fanficdownloader/configurable.py create mode 100644 fanficdownloader/epubutils.py create mode 100644 fanficdownloader/exceptions.py create mode 100644 fanficdownloader/geturls.py create mode 100644 fanficdownloader/gziphttp.py create mode 100644 fanficdownloader/html.py create mode 100644 fanficdownloader/html2text.py create mode 100644 fanficdownloader/htmlcleanup.py create mode 100644 fanficdownloader/mobi.py create mode 100644 fanficdownloader/story.py create mode 100644 fanficdownloader/translit.py create mode 100644 fanficdownloader/writers/__init__.py create mode 100644 fanficdownloader/writers/base_writer.py create mode 100644 fanficdownloader/writers/writer_epub.py create mode 100644 fanficdownloader/writers/writer_html.py create mode 100644 fanficdownloader/writers/writer_mobi.py create mode 100644 fanficdownloader/writers/writer_txt.py create mode 100644 ffstorage.py create mode 100644 index-ajax.html create mode 100644 index.html create mode 100644 index.yaml create mode 100644 js/fdownloader.js create mode 100644 js/jquery-1.3.2.js create mode 100644 login.html create mode 100644 main.py create mode 100644 makeplugin.py create mode 100644 makezip.py create mode 100644 plugin-defaults.ini create mode 100644 plugin-example.ini create mode 100644 queue.yaml create mode 100644 readme.txt create mode 100644 recent.html create mode 100644 settings.py create mode 100644 simplejson/__init__.py create mode 100644 simplejson/_speedups.c create mode 100644 simplejson/decoder.py create mode 100644 simplejson/encoder.py create mode 100644 simplejson/scanner.py create mode 100644 simplejson/tests/__init__.py create mode 100644 simplejson/tests/test_check_circular.py create mode 100644 simplejson/tests/test_decode.py create mode 100644 simplejson/tests/test_default.py create mode 100644 simplejson/tests/test_dump.py create mode 100644 simplejson/tests/test_encode_basestring_ascii.py create mode 100644 simplejson/tests/test_fail.py create mode 100644 simplejson/tests/test_float.py create mode 100644 simplejson/tests/test_indent.py create mode 100644 simplejson/tests/test_pass1.py create mode 100644 simplejson/tests/test_pass2.py create mode 100644 simplejson/tests/test_pass3.py create mode 100644 simplejson/tests/test_recursion.py create mode 100644 simplejson/tests/test_scanstring.py create mode 100644 simplejson/tests/test_separators.py create mode 100644 simplejson/tests/test_unicode.py create mode 100644 simplejson/tool.py create mode 100644 static/ajax-loader.gif create mode 100644 static/favicon.ico create mode 100644 status.html create mode 100644 utils/__init__.py create mode 100644 utils/remover.py create mode 100644 utils/tally.py diff --git a/allrecent.html b/allrecent.html new file mode 100644 index 00000000..477b17b7 --- /dev/null +++ b/allrecent.html @@ -0,0 +1,78 @@ + + + + + FanFictionDownLoader (fanfiction.net, fanficauthors, fictionalley, ficwad to epub and HTML) + + + + +
+

+ FanFictionDownLoader +

+ + + + + {{yourfile}} + + +
+ {% for fic in fics %} +

+ {{ fic.title }} + by {{ fic.author }} Download Count: {{ fic.count }}
+ Word Count: {{ fic.numWords }} Chapter Count: {{ fic.numChapters }}
+ {% if fic.category %} Categories: {{ fic.category }}
{% endif %} + {% if fic.genre %} Genres: {{ fic.genre }}
{% endif %} + {% if fic.language %} Language: {{ fic.language }}
{% endif %} + {% if fic.series %} Series: {{ fic.series }}
{% endif %} + {% if fic.characters %} Characters: {{ fic.characters }}
{% endif %} + {% if fic.status %} Status: {{ fic.status }}
{% endif %} + {% if fic.datePublished %} Published: {{ fic.datePublished }}
{% endif %} + {% if fic.dateUpdated %} Last Updated: {{ fic.dateUpdated }}
{% endif %} + {% if fic.dateCreated %} Last Downloaded: {{ fic.dateCreated }}
{% endif %} + {% if fic.rating %} Rating: {{ fic.rating }}
{% endif %} + {% if fic.warnings %} Warnings: {{ fic.warnings }}
{% endif %} + {% if fic.description %} Summary: {{ fic.description }}
{% endif %} +

+ {% endfor %} +
+ + + + +
+ + diff --git a/app.yaml b/app.yaml new file mode 100644 index 00000000..c38a2609 --- /dev/null +++ b/app.yaml @@ -0,0 +1,46 @@ +# ffd-retief-hrd fanfictiondownloader +application: fanfictiondownloader +version: 4-4-29 +runtime: python27 +api_version: 1 +threadsafe: true + +handlers: + +- url: /r3m0v3r.* + script: utils.remover.app + login: admin + +- url: /tally.* + script: utils.tally.app + login: admin + +- url: /fdownloadtask + script: main.app + login: admin + +- url: /css + static_dir: css + +- url: /js + static_dir: js + +- url: /static + static_dir: static + +- url: /favicon\.ico + static_files: static/favicon.ico + upload: static/favicon\.ico + +- url: /.* + script: main.app + +#builtins: +#- datastore_admin: on + +libraries: +- name: django + version: "1.2" + +- name: PIL + version: "1.1.7" diff --git a/calibre-plugin/__init__.py b/calibre-plugin/__init__.py new file mode 100644 index 00000000..ea81d698 --- /dev/null +++ b/calibre-plugin/__init__.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +# -*- coding: utf-8 -*- +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Jim Miller' +__docformat__ = 'restructuredtext en' + +# The class that all Interface Action plugin wrappers must inherit from +from calibre.customize import InterfaceActionBase + +## Apparently the name for this class doesn't matter--it was still +## 'demo' for the first few versions. +class FanFictionDownLoaderBase(InterfaceActionBase): + ''' + This class is a simple wrapper that provides information about the + actual plugin class. The actual interface plugin class is called + InterfacePlugin and is defined in the ffdl_plugin.py file, as + specified in the actual_plugin field below. + + The reason for having two classes is that it allows the command line + calibre utilities to run without needing to load the GUI libraries. + ''' + name = 'FanFictionDownLoader' + description = 'UI plugin to download FanFiction stories from various sites.' + supported_platforms = ['windows', 'osx', 'linux'] + author = 'Jim Miller' + version = (1, 6, 14) + minimum_calibre_version = (0, 8, 57) + + #: This field defines the GUI plugin class that contains all the code + #: that actually does something. Its format is module_path:class_name + #: The specified class must be defined in the specified module. + actual_plugin = 'calibre_plugins.fanfictiondownloader_plugin.ffdl_plugin:FanFictionDownLoaderPlugin' + + def is_customizable(self): + ''' + This method must return True to enable customization via + Preferences->Plugins + ''' + return True + + def config_widget(self): + ''' + Implement this method and :meth:`save_settings` in your plugin to + use a custom configuration dialog. + + This method, if implemented, must return a QWidget. The widget can have + an optional method validate() that takes no arguments and is called + immediately after the user clicks OK. Changes are applied if and only + if the method returns True. + + If for some reason you cannot perform the configuration at this time, + return a tuple of two strings (message, details), these will be + displayed as a warning dialog to the user and the process will be + aborted. + + The base class implementation of this method raises NotImplementedError + so by default no user configuration is possible. + ''' + # It is important to put this import statement here rather than at the + # top of the module as importing the config class will also cause the + # GUI libraries to be loaded, which we do not want when using calibre + # from the command line + from calibre_plugins.fanfictiondownloader_plugin.config import ConfigWidget + return ConfigWidget(self.actual_plugin_) + + def save_settings(self, config_widget): + ''' + Save the settings specified by the user with config_widget. + + :param config_widget: The widget returned by :meth:`config_widget`. + ''' + config_widget.save_settings() + + # Apply the changes + ac = self.actual_plugin_ + if ac is not None: + ac.apply_settings() + +# For testing, run from command line with this: +# calibre-debug -e __init__.py +# +if __name__ == '__main__': + from PyQt4.Qt import QApplication + from calibre.gui2.preferences import test_widget + app = QApplication([]) + test_widget('Advanced', 'Plugins') diff --git a/calibre-plugin/about.txt b/calibre-plugin/about.txt new file mode 100644 index 00000000..6fca52ef --- /dev/null +++ b/calibre-plugin/about.txt @@ -0,0 +1,28 @@ +
+ +

Plugin created by Jim Miller, borrowing heavily from Grant Drake's +'Reading List', +'Extract ISBN' and +'Count Pages' +plugins. bbcodeutils code contributed by Pau Sanchez.

+ +

+Calibre officially distributes plugins from the mobileread.com forum site. +The official distro channel for this plugin is there: FanFictionDownLoader +

+ +

I also monitor the +general users +group for the downloader. That covers the web application and CLI, too. +

+ +The source for this plugin is available at it's +project home. +
+ +

+See the list of supported sites. +

+

+Read the FAQs. +

diff --git a/calibre-plugin/common_utils.py b/calibre-plugin/common_utils.py new file mode 100644 index 00000000..0ce98474 --- /dev/null +++ b/calibre-plugin/common_utils.py @@ -0,0 +1,543 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Grant Drake ' +__docformat__ = 'restructuredtext en' + +import os +from PyQt4 import QtGui +from PyQt4.Qt import (Qt, QIcon, QPixmap, QLabel, QDialog, QHBoxLayout, + QTableWidgetItem, QFont, QLineEdit, QComboBox, + QVBoxLayout, QDialogButtonBox, QStyledItemDelegate, QDateTime, + QTextEdit, + QListWidget, QAbstractItemView) +from calibre.constants import iswindows +from calibre.gui2 import gprefs, error_dialog, UNDEFINED_QDATETIME, info_dialog +from calibre.gui2.actions import menu_action_unique_name +from calibre.gui2.keyboard import ShortcutConfig +from calibre.utils.config import config_dir +from calibre.utils.date import now, format_date, qt_to_dt, UNDEFINED_DATE + +# Global definition of our plugin name. Used for common functions that require this. +plugin_name = None +# Global definition of our plugin resources. Used to share between the xxxAction and xxxBase +# classes if you need any zip images to be displayed on the configuration dialog. +plugin_icon_resources = {} + + +def set_plugin_icon_resources(name, resources): + ''' + Set our global store of plugin name and icon resources for sharing between + the InterfaceAction class which reads them and the ConfigWidget + if needed for use on the customization dialog for this plugin. + ''' + global plugin_icon_resources, plugin_name + plugin_name = name + plugin_icon_resources = resources + + +def get_icon(icon_name): + ''' + Retrieve a QIcon for the named image from the zip file if it exists, + or if not then from Calibre's image cache. + ''' + if icon_name: + pixmap = get_pixmap(icon_name) + if pixmap is None: + # Look in Calibre's cache for the icon + return QIcon(I(icon_name)) + else: + return QIcon(pixmap) + return QIcon() + + +def get_pixmap(icon_name): + ''' + Retrieve a QPixmap for the named image + Any icons belonging to the plugin must be prefixed with 'images/' + ''' + global plugin_icon_resources, plugin_name + + if not icon_name.startswith('images/'): + # We know this is definitely not an icon belonging to this plugin + pixmap = QPixmap() + pixmap.load(I(icon_name)) + return pixmap + + # Check to see whether the icon exists as a Calibre resource + # This will enable skinning if the user stores icons within a folder like: + # ...\AppData\Roaming\calibre\resources\images\Plugin Name\ + if plugin_name: + local_images_dir = get_local_images_dir(plugin_name) + local_image_path = os.path.join(local_images_dir, icon_name.replace('images/', '')) + if os.path.exists(local_image_path): + pixmap = QPixmap() + pixmap.load(local_image_path) + return pixmap + + # As we did not find an icon elsewhere, look within our zip resources + if icon_name in plugin_icon_resources: + pixmap = QPixmap() + pixmap.loadFromData(plugin_icon_resources[icon_name]) + return pixmap + return None + + +def get_local_images_dir(subfolder=None): + ''' + Returns a path to the user's local resources/images folder + If a subfolder name parameter is specified, appends this to the path + ''' + images_dir = os.path.join(config_dir, 'resources/images') + if subfolder: + images_dir = os.path.join(images_dir, subfolder) + if iswindows: + images_dir = os.path.normpath(images_dir) + return images_dir + + +def create_menu_item(ia, parent_menu, menu_text, image=None, tooltip=None, + shortcut=(), triggered=None, is_checked=None): + ''' + Create a menu action with the specified criteria and action + Note that if no shortcut is specified, will not appear in Preferences->Keyboard + This method should only be used for actions which either have no shortcuts, + or register their menus only once. Use create_menu_action_unique for all else. + ''' + if shortcut is not None: + if len(shortcut) == 0: + shortcut = () + else: + shortcut = _(shortcut) + ac = ia.create_action(spec=(menu_text, None, tooltip, shortcut), + attr=menu_text) + if image: + ac.setIcon(get_icon(image)) + if triggered is not None: + ac.triggered.connect(triggered) + if is_checked is not None: + ac.setCheckable(True) + if is_checked: + ac.setChecked(True) + + parent_menu.addAction(ac) + return ac + + +def create_menu_action_unique(ia, parent_menu, menu_text, image=None, tooltip=None, + shortcut=None, triggered=None, is_checked=None, shortcut_name=None, + unique_name=None): + ''' + Create a menu action with the specified criteria and action, using the new + InterfaceAction.create_menu_action() function which ensures that regardless of + whether a shortcut is specified it will appear in Preferences->Keyboard + ''' + orig_shortcut = shortcut + kb = ia.gui.keyboard + if unique_name is None: + unique_name = menu_text + if not shortcut == False: + full_unique_name = menu_action_unique_name(ia, unique_name) + if full_unique_name in kb.shortcuts: + shortcut = False + else: + if shortcut is not None and not shortcut == False: + if len(shortcut) == 0: + shortcut = None + else: + shortcut = _(shortcut) + + if shortcut_name is None: + shortcut_name = menu_text.replace('&','') + + ac = ia.create_menu_action(parent_menu, unique_name, menu_text, icon=None, shortcut=shortcut, + description=tooltip, triggered=triggered, shortcut_name=shortcut_name) + if shortcut == False and not orig_shortcut == False: + if ac.calibre_shortcut_unique_name in ia.gui.keyboard.shortcuts: + kb.replace_action(ac.calibre_shortcut_unique_name, ac) + if image: + ac.setIcon(get_icon(image)) + if is_checked is not None: + ac.setCheckable(True) + if is_checked: + ac.setChecked(True) + return ac + + +def swap_author_names(author): + if author.find(',') == -1: + return author + name_parts = author.strip().partition(',') + return name_parts[2].strip() + ' ' + name_parts[0] + + +def get_library_uuid(db): + try: + library_uuid = db.library_id + except: + library_uuid = '' + return library_uuid + + +class ImageLabel(QLabel): + + def __init__(self, parent, icon_name, size=16): + QLabel.__init__(self, parent) + pixmap = get_pixmap(icon_name) + self.setPixmap(pixmap) + self.setMaximumSize(size, size) + self.setScaledContents(True) + + +class ImageTitleLayout(QHBoxLayout): + ''' + A reusable layout widget displaying an image followed by a title + ''' + def __init__(self, parent, icon_name, title): + QHBoxLayout.__init__(self) + title_image_label = QLabel(parent) + pixmap = get_pixmap(icon_name) + if pixmap is None: + pixmap = get_pixmap('library.png') + # error_dialog(parent, _('Restart required'), + # _('You must restart Calibre before using this plugin!'), show=True) + else: + title_image_label.setPixmap(pixmap) + title_image_label.setMaximumSize(32, 32) + title_image_label.setScaledContents(True) + self.addWidget(title_image_label) + + title_font = QFont() + title_font.setPointSize(16) + shelf_label = QLabel(title, parent) + shelf_label.setFont(title_font) + self.addWidget(shelf_label) + self.insertStretch(-1) + + +class SizePersistedDialog(QDialog): + ''' + This dialog is a base class for any dialogs that want their size/position + restored when they are next opened. + ''' + def __init__(self, parent, unique_pref_name): + QDialog.__init__(self, parent) + self.unique_pref_name = unique_pref_name + self.geom = gprefs.get(unique_pref_name, None) + self.finished.connect(self.dialog_closing) + + def resize_dialog(self): + if self.geom is None: + self.resize(self.sizeHint()) + else: + self.restoreGeometry(self.geom) + + def dialog_closing(self, result): + geom = bytearray(self.saveGeometry()) + gprefs[self.unique_pref_name] = geom + + +class ReadOnlyTableWidgetItem(QTableWidgetItem): + + def __init__(self, text): + if text is None: + text = '' + QTableWidgetItem.__init__(self, text, QtGui.QTableWidgetItem.UserType) + self.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled) + + +class RatingTableWidgetItem(QTableWidgetItem): + + def __init__(self, rating, is_read_only=False): + QTableWidgetItem.__init__(self, '', QtGui.QTableWidgetItem.UserType) + self.setData(Qt.DisplayRole, rating) + if is_read_only: + self.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled) + + +class DateTableWidgetItem(QTableWidgetItem): + + def __init__(self, date_read, is_read_only=False, default_to_today=False): + if date_read == UNDEFINED_DATE and default_to_today: + date_read = now() + if is_read_only: + QTableWidgetItem.__init__(self, format_date(date_read, None), QtGui.QTableWidgetItem.UserType) + self.setFlags(Qt.ItemIsSelectable|Qt.ItemIsEnabled) + else: + QTableWidgetItem.__init__(self, '', QtGui.QTableWidgetItem.UserType) + self.setData(Qt.DisplayRole, QDateTime(date_read)) + + +class NoWheelComboBox(QComboBox): + + def wheelEvent (self, event): + # Disable the mouse wheel on top of the combo box changing selection as plays havoc in a grid + event.ignore() + + +class CheckableTableWidgetItem(QTableWidgetItem): + + def __init__(self, checked=False, is_tristate=False): + QTableWidgetItem.__init__(self, '') + self.setFlags(Qt.ItemFlags(Qt.ItemIsSelectable | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled )) + if is_tristate: + self.setFlags(self.flags() | Qt.ItemIsTristate) + if checked: + self.setCheckState(Qt.Checked) + else: + if is_tristate and checked is None: + self.setCheckState(Qt.PartiallyChecked) + else: + self.setCheckState(Qt.Unchecked) + + def get_boolean_value(self): + ''' + Return a boolean value indicating whether checkbox is checked + If this is a tristate checkbox, a partially checked value is returned as None + ''' + if self.checkState() == Qt.PartiallyChecked: + return None + else: + return self.checkState() == Qt.Checked + + +class TextIconWidgetItem(QTableWidgetItem): + + def __init__(self, text, icon): + QTableWidgetItem.__init__(self, text) + if icon: + self.setIcon(icon) + + +class ReadOnlyTextIconWidgetItem(ReadOnlyTableWidgetItem): + + def __init__(self, text, icon): + ReadOnlyTableWidgetItem.__init__(self, text) + if icon: + self.setIcon(icon) + + +class ReadOnlyLineEdit(QLineEdit): + + def __init__(self, text, parent): + if text is None: + text = '' + QLineEdit.__init__(self, text, parent) + self.setEnabled(False) + + +class KeyValueComboBox(QComboBox): + + def __init__(self, parent, values, selected_key): + QComboBox.__init__(self, parent) + self.values = values + self.populate_combo(selected_key) + + def populate_combo(self, selected_key): + self.clear() + selected_idx = idx = -1 + for key, value in self.values.iteritems(): + idx = idx + 1 + self.addItem(value) + if key == selected_key: + selected_idx = idx + self.setCurrentIndex(selected_idx) + + def selected_key(self): + for key, value in self.values.iteritems(): + if value == unicode(self.currentText()).strip(): + return key + + +class CustomColumnComboBox(QComboBox): + + def __init__(self, parent, custom_columns, selected_column, initial_items=['']): + QComboBox.__init__(self, parent) + self.populate_combo(custom_columns, selected_column, initial_items) + + def populate_combo(self, custom_columns, selected_column, initial_items=['']): + self.clear() + self.column_names = initial_items + if len(initial_items) > 0: + self.addItems(initial_items) + selected_idx = 0 + for idx, value in enumerate(initial_items): + if value == selected_column: + selected_idx = idx + for key in sorted(custom_columns.keys()): + self.column_names.append(key) + self.addItem('%s (%s)'%(key, custom_columns[key]['name'])) + if key == selected_column: + selected_idx = len(self.column_names) - 1 + self.setCurrentIndex(selected_idx) + + def get_selected_column(self): + return self.column_names[self.currentIndex()] + + +class KeyboardConfigDialog(SizePersistedDialog): + ''' + This dialog is used to allow editing of keyboard shortcuts. + ''' + def __init__(self, gui, group_name): + SizePersistedDialog.__init__(self, gui, 'Keyboard shortcut dialog') + self.gui = gui + self.setWindowTitle('Keyboard shortcuts') + layout = QVBoxLayout(self) + self.setLayout(layout) + + self.keyboard_widget = ShortcutConfig(self) + layout.addWidget(self.keyboard_widget) + self.group_name = group_name + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self.commit) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + # Cause our dialog size to be restored from prefs or created on first usage + self.resize_dialog() + self.initialize() + + def initialize(self): + self.keyboard_widget.initialize(self.gui.keyboard) + self.keyboard_widget.highlight_group(self.group_name) + + def commit(self): + self.keyboard_widget.commit() + self.accept() + + +class DateDelegate(QStyledItemDelegate): + ''' + Delegate for dates. Because this delegate stores the + format as an instance variable, a new instance must be created for each + column. This differs from all the other delegates. + ''' + def __init__(self, parent): + QStyledItemDelegate.__init__(self, parent) + self.format = 'dd MMM yyyy' + + def displayText(self, val, locale): + d = val.toDateTime() + if d <= UNDEFINED_QDATETIME: + return '' + return format_date(qt_to_dt(d, as_utc=False), self.format) + + def createEditor(self, parent, option, index): + qde = QStyledItemDelegate.createEditor(self, parent, option, index) + qde.setDisplayFormat(self.format) + qde.setMinimumDateTime(UNDEFINED_QDATETIME) + qde.setSpecialValueText(_('Undefined')) + qde.setCalendarPopup(True) + return qde + + def setEditorData(self, editor, index): + val = index.model().data(index, Qt.DisplayRole).toDateTime() + if val is None or val == UNDEFINED_QDATETIME: + val = now() + editor.setDateTime(val) + + def setModelData(self, editor, model, index): + val = editor.dateTime() + if val <= UNDEFINED_QDATETIME: + model.setData(index, UNDEFINED_QDATETIME, Qt.EditRole) + else: + model.setData(index, QDateTime(val), Qt.EditRole) + +class PrefsViewerDialog(SizePersistedDialog): + + def __init__(self, gui, namespace): + SizePersistedDialog.__init__(self, gui, 'Prefs Viewer dialog') + self.setWindowTitle('Preferences for: '+namespace) + + self.gui = gui + self.db = gui.current_db + self.namespace = namespace + self._init_controls() + self.resize_dialog() + + self._populate_settings() + + if self.keys_list.count(): + self.keys_list.setCurrentRow(0) + + def _init_controls(self): + layout = QVBoxLayout(self) + self.setLayout(layout) + + ml = QHBoxLayout() + layout.addLayout(ml, 1) + + self.keys_list = QListWidget(self) + self.keys_list.setSelectionMode(QAbstractItemView.SingleSelection) + self.keys_list.setFixedWidth(150) + self.keys_list.setAlternatingRowColors(True) + ml.addWidget(self.keys_list) + self.value_text = QTextEdit(self) + self.value_text.setTabStopWidth(24) + self.value_text.setReadOnly(True) + ml.addWidget(self.value_text, 1) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok) + button_box.accepted.connect(self.accept) + self.clear_button = button_box.addButton('Clear', QDialogButtonBox.ResetRole) + self.clear_button.setIcon(get_icon('trash.png')) + self.clear_button.setToolTip('Clear all settings for this plugin') + self.clear_button.clicked.connect(self._clear_settings) + layout.addWidget(button_box) + + def _populate_settings(self): + self.keys_list.clear() + ns_prefix = self._get_ns_prefix() + keys = sorted([k[len(ns_prefix):] for k in self.db.prefs.iterkeys() + if k.startswith(ns_prefix)]) + for key in keys: + self.keys_list.addItem(key) + self.keys_list.setMinimumWidth(self.keys_list.sizeHintForColumn(0)) + self.keys_list.currentRowChanged[int].connect(self._current_row_changed) + + def _current_row_changed(self, new_row): + if new_row < 0: + self.value_text.clear() + return + key = unicode(self.keys_list.currentItem().text()) + val = self.db.prefs.get_namespaced(self.namespace, key, '') + self.value_text.setPlainText(self.db.prefs.to_raw(val)) + + def _get_ns_prefix(self): + return 'namespaced:%s:'% self.namespace + + def _clear_settings(self): + from calibre.gui2.dialogs.confirm_delete import confirm + message = '

Are you sure you want to clear your settings in this library for this plugin?

' \ + '

Any settings in other libraries or stored in a JSON file in your calibre plugins ' \ + 'folder will not be touched.

' \ + '

You must restart calibre afterwards.

' + if not confirm(message, self.namespace+'_clear_settings', self): + return + ns_prefix = self._get_ns_prefix() + keys = [k for k in self.db.prefs.iterkeys() if k.startswith(ns_prefix)] + for k in keys: + del self.db.prefs[k] + self._populate_settings() + d = info_dialog(self, 'Settings deleted', + '

All settings for this plugin in this library have been cleared.

' + '

Please restart calibre now.

', + show_copy_button=False) + b = d.bb.addButton(_('Restart calibre now'), d.bb.AcceptRole) + b.setIcon(QIcon(I('lt.png'))) + d.do_restart = False + def rf(): + d.do_restart = True + b.clicked.connect(rf) + d.set_details('') + d.exec_() + b.clicked.disconnect() + self.close() + if d.do_restart: + self.gui.quit(restart=True) + diff --git a/calibre-plugin/config.py b/calibre-plugin/config.py new file mode 100644 index 00000000..5bdb6caa --- /dev/null +++ b/calibre-plugin/config.py @@ -0,0 +1,918 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2012, Jim Miller' +__docformat__ = 'restructuredtext en' + +import traceback, copy +from collections import OrderedDict + +from PyQt4.Qt import (QDialog, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QFont, QWidget, + QTextEdit, QComboBox, QCheckBox, QPushButton, QTabWidget, QVariant, QScrollArea) + +from calibre.gui2 import dynamic, info_dialog +from calibre.utils.config import JSONConfig +from calibre.gui2.ui import get_gui + +from calibre_plugins.fanfictiondownloader_plugin.dialogs \ + import (UPDATE, UPDATEALWAYS, OVERWRITE, collision_order) + +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.adapters import getConfigSections + +from calibre_plugins.fanfictiondownloader_plugin.common_utils \ + import ( get_library_uuid, KeyboardConfigDialog, PrefsViewerDialog ) + +from calibre.gui2.complete import MultiCompleteLineEdit + +PREFS_NAMESPACE = 'FanFictionDownLoaderPlugin' +PREFS_KEY_SETTINGS = 'settings' + +# Set defaults used by all. Library specific settings continue to +# take from here. +default_prefs = {} +default_prefs['personal.ini'] = get_resources('plugin-example.ini') + +default_prefs['updatemeta'] = True +default_prefs['updatecover'] = False +default_prefs['updateepubcover'] = False +default_prefs['keeptags'] = False +default_prefs['urlsfromclip'] = True +default_prefs['updatedefault'] = True +default_prefs['fileform'] = 'epub' +default_prefs['collision'] = OVERWRITE +default_prefs['deleteotherforms'] = False +default_prefs['adddialogstaysontop'] = False +default_prefs['includeimages'] = False +default_prefs['lookforurlinhtml'] = False +default_prefs['injectseries'] = False + +default_prefs['send_lists'] = '' +default_prefs['read_lists'] = '' +default_prefs['addtolists'] = False +default_prefs['addtoreadlists'] = False +default_prefs['addtolistsonread'] = False + +default_prefs['gcnewonly'] = False +default_prefs['gc_site_settings'] = {} +default_prefs['allow_gc_from_ini'] = True + +default_prefs['countpagesstats'] = [] + +default_prefs['errorcol'] = '' +default_prefs['custom_cols'] = {} +default_prefs['custom_cols_newonly'] = {} +default_prefs['allow_custcol_from_ini'] = True + +default_prefs['std_cols_newonly'] = {} + +def set_library_config(library_config): + get_gui().current_db.prefs.set_namespaced(PREFS_NAMESPACE, + PREFS_KEY_SETTINGS, + library_config) + +def get_library_config(): + db = get_gui().current_db + library_id = get_library_uuid(db) + library_config = None + # Check whether this is a configuration needing to be migrated + # from json into database. If so: get it, set it, rename it in json. + if library_id in old_prefs: + #print("get prefs from old_prefs") + library_config = old_prefs[library_id] + set_library_config(library_config) + old_prefs["migrated to library db %s"%library_id] = old_prefs[library_id] + del old_prefs[library_id] + + if library_config is None: + #print("get prefs from db") + library_config = db.prefs.get_namespaced(PREFS_NAMESPACE, PREFS_KEY_SETTINGS, + copy.deepcopy(default_prefs)) + return library_config + +# This is where all preferences for this plugin *were* stored +# Remember that this name (i.e. plugins/fanfictiondownloader_plugin) is also +# in a global namespace, so make it as unique as possible. +# You should always prefix your config file name with plugins/, +# so as to ensure you dont accidentally clobber a calibre config file +old_prefs = JSONConfig('plugins/fanfictiondownloader_plugin') + +# fake out so I don't have to change the prefs calls anywhere. The +# Java programmer in me is offended by op-overloading, but it's very +# tidy. +class PrefsFacade(): + def __init__(self,default_prefs): + self.default_prefs = default_prefs + self.libraryid = None + self.current_prefs = None + + def _get_prefs(self): + libraryid = get_library_uuid(get_gui().current_db) + if self.current_prefs == None or self.libraryid != libraryid: + #print("self.current_prefs == None(%s) or self.libraryid != libraryid(%s)"%(self.current_prefs == None,self.libraryid != libraryid)) + self.libraryid = libraryid + self.current_prefs = get_library_config() + return self.current_prefs + + def __getitem__(self,k): + prefs = self._get_prefs() + if k not in prefs: + # pulls from default_prefs.defaults automatically if not set + # in default_prefs + return self.default_prefs[k] + return prefs[k] + + def __setitem__(self,k,v): + prefs = self._get_prefs() + prefs[k]=v + # self._save_prefs(prefs) + + def __delitem__(self,k): + prefs = self._get_prefs() + if k in prefs: + del prefs[k] + + def save_to_db(self): + set_library_config(self._get_prefs()) + + +prefs = PrefsFacade(default_prefs) + +class ConfigWidget(QWidget): + + def __init__(self, plugin_action): + QWidget.__init__(self) + self.plugin_action = plugin_action + + self.l = QVBoxLayout() + self.setLayout(self.l) + + label = QLabel('List of Supported Sites -- FAQs') + label.setOpenExternalLinks(True) + self.l.addWidget(label) + + tab_widget = QTabWidget(self) + self.l.addWidget(tab_widget) + + self.basic_tab = BasicTab(self, plugin_action) + tab_widget.addTab(self.basic_tab, 'Basic') + + self.personalini_tab = PersonalIniTab(self, plugin_action) + tab_widget.addTab(self.personalini_tab, 'personal.ini') + + self.readinglist_tab = ReadingListTab(self, plugin_action) + tab_widget.addTab(self.readinglist_tab, 'Reading Lists') + if 'Reading List' not in plugin_action.gui.iactions: + self.readinglist_tab.setEnabled(False) + + self.generatecover_tab = GenerateCoverTab(self, plugin_action) + tab_widget.addTab(self.generatecover_tab, 'Generate Cover') + if 'Generate Cover' not in plugin_action.gui.iactions: + self.generatecover_tab.setEnabled(False) + + self.countpages_tab = CountPagesTab(self, plugin_action) + tab_widget.addTab(self.countpages_tab, 'Count Pages') + if 'Count Pages' not in plugin_action.gui.iactions: + self.countpages_tab.setEnabled(False) + + self.std_columns_tab = StandardColumnsTab(self, plugin_action) + tab_widget.addTab(self.std_columns_tab, 'Standard Columns') + + self.cust_columns_tab = CustomColumnsTab(self, plugin_action) + tab_widget.addTab(self.cust_columns_tab, 'Custom Columns') + + self.other_tab = OtherTab(self, plugin_action) + tab_widget.addTab(self.other_tab, 'Other') + + + def save_settings(self): + + # basic + prefs['fileform'] = unicode(self.basic_tab.fileform.currentText()) + prefs['collision'] = unicode(self.basic_tab.collision.currentText()) + prefs['updatemeta'] = self.basic_tab.updatemeta.isChecked() + prefs['updatecover'] = self.basic_tab.updatecover.isChecked() + prefs['updateepubcover'] = self.basic_tab.updateepubcover.isChecked() + prefs['keeptags'] = self.basic_tab.keeptags.isChecked() + prefs['urlsfromclip'] = self.basic_tab.urlsfromclip.isChecked() + prefs['updatedefault'] = self.basic_tab.updatedefault.isChecked() + prefs['deleteotherforms'] = self.basic_tab.deleteotherforms.isChecked() + prefs['adddialogstaysontop'] = self.basic_tab.adddialogstaysontop.isChecked() + prefs['includeimages'] = self.basic_tab.includeimages.isChecked() + prefs['lookforurlinhtml'] = self.basic_tab.lookforurlinhtml.isChecked() + prefs['injectseries'] = self.basic_tab.injectseries.isChecked() + + if self.readinglist_tab: + # lists + prefs['send_lists'] = ', '.join(map( lambda x : x.strip(), filter( lambda x : x.strip() != '', unicode(self.readinglist_tab.send_lists_box.text()).split(',')))) + prefs['read_lists'] = ', '.join(map( lambda x : x.strip(), filter( lambda x : x.strip() != '', unicode(self.readinglist_tab.read_lists_box.text()).split(',')))) + # print("send_lists: %s"%prefs['send_lists']) + # print("read_lists: %s"%prefs['read_lists']) + prefs['addtolists'] = self.readinglist_tab.addtolists.isChecked() + prefs['addtoreadlists'] = self.readinglist_tab.addtoreadlists.isChecked() + prefs['addtolistsonread'] = self.readinglist_tab.addtolistsonread.isChecked() + + # personal.ini + ini = unicode(self.personalini_tab.ini.toPlainText()) + if ini: + prefs['personal.ini'] = ini + else: + # if they've removed everything, reset to default. + prefs['personal.ini'] = get_resources('plugin-example.ini') + + # Generate Covers tab + prefs['gcnewonly'] = self.generatecover_tab.gcnewonly.isChecked() + gc_site_settings = {} + for (site,combo) in self.generatecover_tab.gc_dropdowns.iteritems(): + val = unicode(combo.itemData(combo.currentIndex()).toString()) + if val != 'none': + gc_site_settings[site] = val + #print("gc_site_settings[%s]:%s"%(site,gc_site_settings[site])) + prefs['gc_site_settings'] = gc_site_settings + prefs['allow_gc_from_ini'] = self.generatecover_tab.allow_gc_from_ini.isChecked() + + # Count Pages tab + countpagesstats = [] + + if self.countpages_tab.pagecount.isChecked(): + countpagesstats.append('PageCount') + if self.countpages_tab.wordcount.isChecked(): + countpagesstats.append('WordCount') + if self.countpages_tab.fleschreading.isChecked(): + countpagesstats.append('FleschReading') + if self.countpages_tab.fleschgrade.isChecked(): + countpagesstats.append('FleschGrade') + if self.countpages_tab.gunningfog.isChecked(): + countpagesstats.append('GunningFog') + + prefs['countpagesstats'] = countpagesstats + + # Standard Columns tab + colsnewonly = {} + for (col,checkbox) in self.std_columns_tab.stdcol_newonlycheck.iteritems(): + colsnewonly[col] = checkbox.isChecked() + prefs['std_cols_newonly'] = colsnewonly + + # Custom Columns tab + # error column + prefs['errorcol'] = unicode(self.cust_columns_tab.errorcol.itemData(self.cust_columns_tab.errorcol.currentIndex()).toString()) + + # cust cols tab + colsmap = {} + for (col,combo) in self.cust_columns_tab.custcol_dropdowns.iteritems(): + val = unicode(combo.itemData(combo.currentIndex()).toString()) + if val != 'none': + colsmap[col] = val + #print("colsmap[%s]:%s"%(col,colsmap[col])) + prefs['custom_cols'] = colsmap + + colsnewonly = {} + for (col,checkbox) in self.cust_columns_tab.custcol_newonlycheck.iteritems(): + colsnewonly[col] = checkbox.isChecked() + prefs['custom_cols_newonly'] = colsnewonly + + prefs['allow_custcol_from_ini'] = self.cust_columns_tab.allow_custcol_from_ini.isChecked() + + prefs.save_to_db() + + def edit_shortcuts(self): + self.save_settings() + # Force the menus to be rebuilt immediately, so we have all our actions registered + self.plugin_action.rebuild_menus() + d = KeyboardConfigDialog(self.plugin_action.gui, self.plugin_action.action_spec[0]) + if d.exec_() == d.Accepted: + self.plugin_action.gui.keyboard.finalize() + +class BasicTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + self.l = QVBoxLayout() + self.setLayout(self.l) + + label = QLabel('These settings control the basic features of the plugin--downloading FanFiction.') + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + tooltip = "On each download, FFDL offers an option to select the output format.
This sets what that option will default to." + horz = QHBoxLayout() + label = QLabel('Default Output &Format:') + label.setToolTip(tooltip) + horz.addWidget(label) + self.fileform = QComboBox(self) + self.fileform.addItem('epub') + self.fileform.addItem('mobi') + self.fileform.addItem('html') + self.fileform.addItem('txt') + self.fileform.setCurrentIndex(self.fileform.findText(prefs['fileform'])) + self.fileform.setToolTip(tooltip) + self.fileform.activated.connect(self.set_collisions) + label.setBuddy(self.fileform) + horz.addWidget(self.fileform) + self.l.addLayout(horz) + + tooltip = "On each download, FFDL offers an option of what happens if that story already exists.
This sets what that option will default to." + horz = QHBoxLayout() + label = QLabel('Default If Story Already Exists?') + label.setToolTip(tooltip) + horz.addWidget(label) + self.collision = QComboBox(self) + # add collision options + self.set_collisions() + i = self.collision.findText(prefs['collision']) + if i > -1: + self.collision.setCurrentIndex(i) + self.collision.setToolTip(tooltip) + label.setBuddy(self.collision) + horz.addWidget(self.collision) + self.l.addLayout(horz) + + self.updatemeta = QCheckBox('Default Update Calibre &Metadata?',self) + self.updatemeta.setToolTip("On each download, FFDL offers an option to update Calibre's metadata (title, author, URL, tags, custom columns, etc) from the web site.
This sets whether that will default to on or off.
Columns set to 'New Only' in the column tabs will only be set for new books.") + self.updatemeta.setChecked(prefs['updatemeta']) + self.l.addWidget(self.updatemeta) + + self.updateepubcover = QCheckBox('Default Update EPUB Cover when Updating EPUB?',self) + self.updateepubcover.setToolTip("On each download, FFDL offers an option to update the book cover image inside the EPUB from the web site when the EPUB is updated.
This sets whether that will default to on or off.") + self.updateepubcover.setChecked(prefs['updateepubcover']) + self.l.addWidget(self.updateepubcover) + + self.l.addSpacing(10) + + self.deleteotherforms = QCheckBox('Delete other existing formats?',self) + self.deleteotherforms.setToolTip('Check this to automatically delete all other ebook formats when updating an existing book.\nHandy if you have both a Nook(epub) and Kindle(mobi), for example.') + self.deleteotherforms.setChecked(prefs['deleteotherforms']) + self.l.addWidget(self.deleteotherforms) + + self.updatecover = QCheckBox('Update Calibre Cover when Updating Metadata?',self) + self.updatecover.setToolTip("Update calibre book cover image from EPUB when metadata is updated. (EPUB only.)\nDoesn't go looking for new images on 'Update Calibre Metadata Only'.") + self.updatecover.setChecked(prefs['updatecover']) + self.l.addWidget(self.updatecover) + + self.keeptags = QCheckBox('Keep Existing Tags when Updating Metadata?',self) + self.keeptags.setToolTip("Existing tags will be kept and any new tags added.\nCompleted and In-Progress tags will be still be updated, if known.\nLast Updated tags will be updated if lastupdate in include_subject_tags.\n(If Tags is set to 'New Only' in the Standard Columns tab, this has no effect.)") + self.keeptags.setChecked(prefs['keeptags']) + self.l.addWidget(self.keeptags) + + self.l.addSpacing(10) + + self.urlsfromclip = QCheckBox('Take URLs from Clipboard?',self) + self.urlsfromclip.setToolTip('Prefill URLs from valid URLs in Clipboard when Adding New.') + self.urlsfromclip.setChecked(prefs['urlsfromclip']) + self.l.addWidget(self.urlsfromclip) + + self.updatedefault = QCheckBox('Default to Update when books selected?',self) + self.updatedefault.setToolTip('The top FanFictionDownLoader plugin button will start Update if\n'+ + 'books are selected. If unchecked, it will always bring up \'Add New\'.') + self.updatedefault.setChecked(prefs['updatedefault']) + self.l.addWidget(self.updatedefault) + + self.adddialogstaysontop = QCheckBox("Keep 'Add New from URL(s)' dialog on top?",self) + self.adddialogstaysontop.setToolTip("Instructs the OS and Window Manager to keep the 'Add New from URL(s)'\ndialog on top of all other windows. Useful for dragging URLs onto it.") + self.adddialogstaysontop.setChecked(prefs['adddialogstaysontop']) + self.l.addWidget(self.adddialogstaysontop) + + self.l.addSpacing(10) + + # this is a cheat to make it easier for users to realize there's a new include_images features. + self.includeimages = QCheckBox("Include images in EPUBs?",self) + self.includeimages.setToolTip("Download and include images in EPUB stories. This is equivalent to adding:\n\n[epub]\ninclude_images:true\nkeep_summary_html:true\nmake_firstimage_cover:true\n\n ...to the top of personal.ini. Your settings in personal.ini will override this.") + self.includeimages.setChecked(prefs['includeimages']) + self.l.addWidget(self.includeimages) + + self.lookforurlinhtml = QCheckBox("Search EPUB text for Story URL?",self) + self.lookforurlinhtml.setToolTip("Look for first valid story URL inside EPUB text if not found in metadata.\nSomewhat risky, could find wrong URL depending on EPUB content.\nAlso finds and corrects bad ffnet URLs from ficsaver.com files.") + self.lookforurlinhtml.setChecked(prefs['lookforurlinhtml']) + self.l.addWidget(self.lookforurlinhtml) + + self.injectseries = QCheckBox("Inject calibre Series when none found?",self) + self.injectseries.setToolTip("If no series is found, inject the calibre series (if there is one) so it appears on the FFDL title page(not cover).") + self.injectseries.setChecked(prefs['injectseries']) + self.l.addWidget(self.injectseries) + + self.l.insertStretch(-1) + + def set_collisions(self): + prev=self.collision.currentText() + self.collision.clear() + for o in collision_order: + if self.fileform.currentText() == 'epub' or o not in [UPDATE,UPDATEALWAYS]: + self.collision.addItem(o) + i = self.collision.findText(prev) + if i > -1: + self.collision.setCurrentIndex(i) + + def show_defaults(self): + text = get_resources('plugin-defaults.ini') + ShowDefaultsIniDialog(self.windowIcon(),text,self).exec_() + +class PersonalIniTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + self.l = QVBoxLayout() + self.setLayout(self.l) + + label = QLabel('These settings provide more detailed control over what metadata will be displayed inside the ebook as well as let you set is_adult and user/password for different sites.') + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + self.label = QLabel('personal.ini:') + self.l.addWidget(self.label) + + self.ini = QTextEdit(self) + try: + self.ini.setFont(QFont("Courier", + self.plugin_action.gui.font().pointSize()+1)); + except Exception as e: + print("Couldn't get font: %s"%e) + self.ini.setLineWrapMode(QTextEdit.NoWrap) + self.ini.setText(prefs['personal.ini']) + self.l.addWidget(self.ini) + + self.defaults = QPushButton('View Defaults (plugin-defaults.ini)', self) + self.defaults.setToolTip("View all of the plugin's configurable settings\nand their default settings.") + self.defaults.clicked.connect(self.show_defaults) + self.l.addWidget(self.defaults) + + # self.l.insertStretch(-1) + # let edit box fill the space. + + def show_defaults(self): + text = get_resources('plugin-defaults.ini') + ShowDefaultsIniDialog(self.windowIcon(),text,self).exec_() + +class ShowDefaultsIniDialog(QDialog): + + def __init__(self, icon, text, parent=None): + QDialog.__init__(self, parent) + self.resize(600, 500) + self.l = QVBoxLayout() + self.setLayout(self.l) + self.label = QLabel("Plugin Defaults (plugin-defaults.ini) (Read-Only)") + self.label.setToolTip("These are all of the plugin's configurable options\nand their default settings.") + self.setWindowTitle(_('Plugin Defaults')) + self.setWindowIcon(icon) + self.l.addWidget(self.label) + + self.ini = QTextEdit(self) + self.ini.setToolTip("These are all of the plugin's configurable options\nand their default settings.") + try: + self.ini.setFont(QFont("Courier", + get_gui().font().pointSize()+1)); + except Exception as e: + print("Couldn't get font: %s"%e) + self.ini.setLineWrapMode(QTextEdit.NoWrap) + self.ini.setText(text) + self.ini.setReadOnly(True) + self.l.addWidget(self.ini) + + self.ok_button = QPushButton('OK', self) + self.ok_button.clicked.connect(self.hide) + self.l.addWidget(self.ok_button) + +class ReadingListTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + self.l = QVBoxLayout() + self.setLayout(self.l) + + try: + rl_plugin = plugin_action.gui.iactions['Reading List'] + reading_lists = rl_plugin.get_list_names() + except KeyError: + reading_lists= [] + + label = QLabel('These settings provide integration with the Reading List Plugin. Reading List can automatically send to devices and change custom columns. You have to create and configure the lists in Reading List to be useful.') + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + self.addtolists = QCheckBox('Add new/updated stories to "Send to Device" Reading List(s).',self) + self.addtolists.setToolTip('Automatically add new/updated stories to these lists in the Reading List plugin.') + self.addtolists.setChecked(prefs['addtolists']) + self.l.addWidget(self.addtolists) + + horz = QHBoxLayout() + label = QLabel('"Send to Device" Reading Lists') + label.setToolTip("When enabled, new/updated stories will be automatically added to these lists.") + horz.addWidget(label) + self.send_lists_box = MultiCompleteLineEdit(self) + self.send_lists_box.setToolTip("When enabled, new/updated stories will be automatically added to these lists.") + self.send_lists_box.update_items_cache(reading_lists) + self.send_lists_box.setText(prefs['send_lists']) + horz.addWidget(self.send_lists_box) + self.l.addLayout(horz) + + self.addtoreadlists = QCheckBox('Add new/updated stories to "To Read" Reading List(s).',self) + self.addtoreadlists.setToolTip('Automatically add new/updated stories to these lists in the Reading List plugin.\nAlso offers menu option to remove stories from the "To Read" lists.') + self.addtoreadlists.setChecked(prefs['addtoreadlists']) + self.l.addWidget(self.addtoreadlists) + + horz = QHBoxLayout() + label = QLabel('"To Read" Reading Lists') + label.setToolTip("When enabled, new/updated stories will be automatically added to these lists.") + horz.addWidget(label) + self.read_lists_box = MultiCompleteLineEdit(self) + self.read_lists_box.setToolTip("When enabled, new/updated stories will be automatically added to these lists.") + self.read_lists_box.update_items_cache(reading_lists) + self.read_lists_box.setText(prefs['read_lists']) + horz.addWidget(self.read_lists_box) + self.l.addLayout(horz) + + self.addtolistsonread = QCheckBox('Add stories back to "Send to Device" Reading List(s) when marked "Read".',self) + self.addtolistsonread.setToolTip('Menu option to remove from "To Read" lists will also add stories back to "Send to Device" Reading List(s)') + self.addtolistsonread.setChecked(prefs['addtolistsonread']) + self.l.addWidget(self.addtolistsonread) + + self.l.insertStretch(-1) + +class GenerateCoverTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + self.l = QVBoxLayout() + self.setLayout(self.l) + + try: + gc_plugin = plugin_action.gui.iactions['Generate Cover'] + gc_settings = gc_plugin.get_saved_setting_names() + except KeyError: + gc_settings= [] + + label = QLabel('The Generate Cover plugin can create cover images for books using various metadata and configurations. If you have GC installed, FFDL can run GC on new downloads and metadata updates. Pick a GC setting by site or Default.') + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + scrollable = QScrollArea() + scrollcontent = QWidget() + scrollable.setWidget(scrollcontent) + scrollable.setWidgetResizable(True) + self.l.addWidget(scrollable) + + self.sl = QVBoxLayout() + scrollcontent.setLayout(self.sl) + + self.gc_dropdowns = {} + + sitelist = getConfigSections() + sitelist.sort() + sitelist.insert(0,u"Default") + for site in sitelist: + horz = QHBoxLayout() + label = QLabel(site) + if site == u"Default": + s = "On Metadata update, run Generate Cover with this setting, if not selected for specific site." + else: + s = "On Metadata update, run Generate Cover with this setting for %s stories."%site + + label.setToolTip(s) + horz.addWidget(label) + dropdown = QComboBox(self) + dropdown.setToolTip(s) + dropdown.addItem('',QVariant('none')) + for setting in gc_settings: + dropdown.addItem(setting,QVariant(setting)) + self.gc_dropdowns[site] = dropdown + if site in prefs['gc_site_settings']: + dropdown.setCurrentIndex(dropdown.findData(QVariant(prefs['gc_site_settings'][site]))) + + horz.addWidget(dropdown) + self.sl.addLayout(horz) + + self.sl.insertStretch(-1) + + self.gcnewonly = QCheckBox("Run Generate Cover Only on New Books",self) + self.gcnewonly.setToolTip("Default is to run GC any time the calibre metadata is updated.") + self.gcnewonly.setChecked(prefs['gcnewonly']) + self.l.addWidget(self.gcnewonly) + + self.allow_gc_from_ini = QCheckBox('Allow generate_cover_settings from personal.ini to override',self) + self.allow_gc_from_ini.setToolTip("The personal.ini parameter generate_cover_settings allows you to choose a GC setting based on metadata rather than site, but it's much more complex.
generate_cover_settings is ignored when this is off.") + self.allow_gc_from_ini.setChecked(prefs['allow_gc_from_ini']) + self.l.addWidget(self.allow_gc_from_ini) + +class CountPagesTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + self.l = QVBoxLayout() + self.setLayout(self.l) + + label = QLabel('These settings provide integration with the Count Pages Plugin. Count Pages can automatically update custom columns with page, word and reading level statistics. You have to create and configure the columns in Count Pages first.') + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + label = QLabel('If any of the settings below are checked, when stories are added or updated, the Count Pages Plugin will be called to update the checked statistics.') + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + # 'PageCount', 'WordCount', 'FleschReading', 'FleschGrade', 'GunningFog' + self.pagecount = QCheckBox('Page Count',self) + self.pagecount.setToolTip('Which column and algorithm to use are configured in Count Pages.') + self.pagecount.setChecked('PageCount' in prefs['countpagesstats']) + self.l.addWidget(self.pagecount) + + self.wordcount = QCheckBox('Word Count',self) + self.wordcount.setToolTip('Which column and algorithm to use are configured in Count Words.\nWill overwrite word count from FFDL metadata if set to update the same custom column.') + self.wordcount.setChecked('WordCount' in prefs['countpagesstats']) + self.l.addWidget(self.wordcount) + + self.fleschreading = QCheckBox('Flesch Reading Ease',self) + self.fleschreading.setToolTip('Which column and algorithm to use are configured in Count Pages.') + self.fleschreading.setChecked('FleschReading' in prefs['countpagesstats']) + self.l.addWidget(self.fleschreading) + + self.fleschgrade = QCheckBox('Flesch-Kincaid Grade Level',self) + self.fleschgrade.setToolTip('Which column and algorithm to use are configured in Count Pages.') + self.fleschgrade.setChecked('FleschGrade' in prefs['countpagesstats']) + self.l.addWidget(self.fleschgrade) + + self.gunningfog = QCheckBox('Gunning Fog Index',self) + self.gunningfog.setToolTip('Which column and algorithm to use are configured in Count Pages.') + self.gunningfog.setChecked('GunningFog' in prefs['countpagesstats']) + self.l.addWidget(self.gunningfog) + + self.l.insertStretch(-1) + +class OtherTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + self.l = QVBoxLayout() + self.setLayout(self.l) + + label = QLabel("These controls aren't plugin settings as such, but convenience buttons for setting Keyboard shortcuts and getting all the FanFictionDownLoader confirmation dialogs back again.") + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + keyboard_shortcuts_button = QPushButton('Keyboard shortcuts...', self) + keyboard_shortcuts_button.setToolTip(_( + 'Edit the keyboard shortcuts associated with this plugin')) + keyboard_shortcuts_button.clicked.connect(parent_dialog.edit_shortcuts) + self.l.addWidget(keyboard_shortcuts_button) + + reset_confirmation_button = QPushButton(_('Reset disabled &confirmation dialogs'), self) + reset_confirmation_button.setToolTip(_( + 'Reset all show me again dialogs for the FanFictionDownLoader plugin')) + reset_confirmation_button.clicked.connect(self.reset_dialogs) + self.l.addWidget(reset_confirmation_button) + + view_prefs_button = QPushButton('&View library preferences...', self) + view_prefs_button.setToolTip(_( + 'View data stored in the library database for this plugin')) + view_prefs_button.clicked.connect(self.view_prefs) + self.l.addWidget(view_prefs_button) + + self.l.insertStretch(-1) + + def reset_dialogs(self): + for key in dynamic.keys(): + if key.startswith('fanfictiondownloader_') and key.endswith('_again') \ + and dynamic[key] is False: + dynamic[key] = True + info_dialog(self, _('Done'), + _('Confirmation dialogs have all been reset'), + show=True, + show_copy_button=False) + + def view_prefs(self): + d = PrefsViewerDialog(self.plugin_action.gui, PREFS_NAMESPACE) + d.exec_() + +permitted_values = { + 'int' : ['numWords','numChapters'], + 'float' : ['numWords','numChapters'], + 'bool' : ['status-C','status-I'], + 'datetime' : ['datePublished', 'dateUpdated', 'dateCreated'], + 'series' : ['series'], + 'enumeration' : ['category', + 'genre', + 'language', + 'series', + 'characters', + 'ships', + 'status', + 'datePublished', + 'dateUpdated', + 'dateCreated', + 'rating', + 'warnings', + 'numChapters', + 'numWords', + 'site', + 'storyId', + 'authorId', + 'extratags', + 'title', + 'storyUrl', + 'description', + 'author', + 'authorUrl', + 'formatname', + 'version' + #,'formatext' # not useful information. + #,'siteabbrev' + ] + } +# no point copying the whole list. +permitted_values['text'] = permitted_values['enumeration'] +permitted_values['comments'] = permitted_values['enumeration'] + +titleLabels = { + 'category':'Category', + 'genre':'Genre', + 'language':'Language', + 'status':'Status', + 'status-C':'Status:Completed', + 'status-I':'Status:In-Progress', + 'series':'Series', + 'characters':'Characters', + 'ships':'Relationships', + 'datePublished':'Published', + 'dateUpdated':'Updated', + 'dateCreated':'Packaged', + 'rating':'Rating', + 'warnings':'Warnings', + 'numChapters':'Chapters', + 'numWords':'Words', + 'site':'Site', + 'storyId':'Story ID', + 'authorId':'Author ID', + 'extratags':'Extra Tags', + 'title':'Title', + 'storyUrl':'Story URL', + 'description':'Summary', + 'author':'Author', + 'authorUrl':'Author URL', + 'formatname':'File Format', + 'formatext':'File Extension', + 'siteabbrev':'Site Abbrev', + 'version':'FFDL Version' + } + +class CustomColumnsTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + custom_columns = self.plugin_action.gui.library_view.model().custom_columns + + self.l = QVBoxLayout() + self.setLayout(self.l) + + label = QLabel("If you have custom columns defined, they will be listed below. Choose a metadata value type to fill your columns automatically.") + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + self.custcol_dropdowns = {} + self.custcol_newonlycheck = {} + + scrollable = QScrollArea() + scrollcontent = QWidget() + scrollable.setWidget(scrollcontent) + scrollable.setWidgetResizable(True) + self.l.addWidget(scrollable) + + self.sl = QVBoxLayout() + scrollcontent.setLayout(self.sl) + + for key, column in custom_columns.iteritems(): + + if column['datatype'] in permitted_values: + # print("\n============== %s ===========\n"%key) + # for (k,v) in column.iteritems(): + # print("column['%s'] => %s"%(k,v)) + horz = QHBoxLayout() + label = QLabel(column['name']) + label.setToolTip("Update this %s column(%s) with..."%(key,column['datatype'])) + horz.addWidget(label) + dropdown = QComboBox(self) + dropdown.addItem('',QVariant('none')) + for md in permitted_values[column['datatype']]: + dropdown.addItem(titleLabels[md],QVariant(md)) + self.custcol_dropdowns[key] = dropdown + if key in prefs['custom_cols']: + dropdown.setCurrentIndex(dropdown.findData(QVariant(prefs['custom_cols'][key]))) + if column['datatype'] == 'enumeration': + dropdown.setToolTip("Metadata values valid for this type of column.\nValues that aren't valid for this enumeration column will be ignored.") + else: + dropdown.setToolTip("Metadata values valid for this type of column.") + horz.addWidget(dropdown) + + newonlycheck = QCheckBox("New Only",self) + newonlycheck.setToolTip("Write to %s(%s) only for new\nbooks, not updates to existing books."%(column['name'],key)) + self.custcol_newonlycheck[key] = newonlycheck + if key in prefs['custom_cols_newonly']: + newonlycheck.setChecked(prefs['custom_cols_newonly'][key]) + horz.addWidget(newonlycheck) + + self.sl.addLayout(horz) + + self.sl.insertStretch(-1) + + self.l.addSpacing(5) + self.allow_custcol_from_ini = QCheckBox('Allow custom_columns_settings from personal.ini to override',self) + self.allow_custcol_from_ini.setToolTip("The personal.ini parameter custom_columns_settings allows you to set custom columns to site specific values that aren't common to all sites.
custom_columns_settings is ignored when this is off.") + self.allow_custcol_from_ini.setChecked(prefs['allow_custcol_from_ini']) + self.l.addWidget(self.allow_custcol_from_ini) + + self.l.addSpacing(5) + label = QLabel("Special column:") + label.setWordWrap(True) + self.l.addWidget(label) + + horz = QHBoxLayout() + label = QLabel("Update/Overwrite Error Column:") + tooltip="When an update or overwrite of an existing story fails, record the reason in this column.\n(Text and Long Text columns only.)" + label.setToolTip(tooltip) + horz.addWidget(label) + self.errorcol = QComboBox(self) + self.errorcol.setToolTip(tooltip) + self.errorcol.addItem('',QVariant('none')) + for key, column in custom_columns.iteritems(): + if column['datatype'] in ('text','comments'): + self.errorcol.addItem(column['name'],QVariant(key)) + self.errorcol.setCurrentIndex(self.errorcol.findData(QVariant(prefs['errorcol']))) + horz.addWidget(self.errorcol) + self.l.addLayout(horz) + + #print("prefs['custom_cols'] %s"%prefs['custom_cols']) + + +class StandardColumnsTab(QWidget): + + def __init__(self, parent_dialog, plugin_action): + self.parent_dialog = parent_dialog + self.plugin_action = plugin_action + QWidget.__init__(self) + + columns=OrderedDict() + + columns["title"]="Title" + columns["authors"]="Author(s)" + columns["publisher"]="Publisher" + columns["tags"]="Tags" + columns["languages"]="Languages" + columns["pubdate"]="Published Date" + columns["timestamp"]="Date" + columns["comments"]="Comments" + columns["series"]="Series" + columns["identifiers"]="Ids(url id only)" + + self.l = QVBoxLayout() + self.setLayout(self.l) + + label = QLabel("The standard calibre metadata columns are listed below. You may choose whether FFDL will fill each column automatically on updates or only for new books.") + label.setWordWrap(True) + self.l.addWidget(label) + self.l.addSpacing(5) + + self.stdcol_newonlycheck = {} + + for key, column in columns.iteritems(): + horz = QHBoxLayout() + label = QLabel(column) + #label.setToolTip("Update this %s column(%s) with..."%(key,column['datatype'])) + horz.addWidget(label) + + newonlycheck = QCheckBox("New Only",self) + newonlycheck.setToolTip("Write to %s only for new\nbooks, not updates to existing books."%column) + self.stdcol_newonlycheck[key] = newonlycheck + if key in prefs['std_cols_newonly']: + newonlycheck.setChecked(prefs['std_cols_newonly'][key]) + horz.addWidget(newonlycheck) + + self.l.addLayout(horz) + + self.l.insertStretch(-1) diff --git a/calibre-plugin/dialogs.py b/calibre-plugin/dialogs.py new file mode 100644 index 00000000..0345bb19 --- /dev/null +++ b/calibre-plugin/dialogs.py @@ -0,0 +1,714 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2011, Jim Miller' +__docformat__ = 'restructuredtext en' + +import traceback + +from PyQt4 import QtGui +from PyQt4.Qt import (QDialog, QTableWidget, QMessageBox, QVBoxLayout, QHBoxLayout, QGridLayout, + QPushButton, QProgressDialog, QString, QLabel, QCheckBox, QIcon, QTextCursor, + QTextEdit, QLineEdit, QInputDialog, QComboBox, QClipboard, QVariant, + QProgressDialog, QTimer, QDialogButtonBox, QPixmap, Qt, QAbstractItemView ) + +from calibre.gui2 import error_dialog, warning_dialog, question_dialog, info_dialog +from calibre.gui2.dialogs.confirm_delete import confirm + +from calibre import confirm_config_name +from calibre.gui2 import dynamic + +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader import adapters,writers,exceptions +from calibre_plugins.fanfictiondownloader_plugin.common_utils \ + import (ReadOnlyTableWidgetItem, ReadOnlyTextIconWidgetItem, SizePersistedDialog, + ImageTitleLayout, get_icon) + +SKIP='Skip' +ADDNEW='Add New Book' +UPDATE='Update EPUB if New Chapters' +UPDATEALWAYS='Update EPUB Always' +OVERWRITE='Overwrite if Newer' +OVERWRITEALWAYS='Overwrite Always' +CALIBREONLY='Update Calibre Metadata Only' +collision_order=[SKIP, + ADDNEW, + UPDATE, + UPDATEALWAYS, + OVERWRITE, + OVERWRITEALWAYS, + CALIBREONLY,] + +class NotGoingToDownload(Exception): + def __init__(self,error,icon='dialog_error.png'): + self.error=error + self.icon=icon + + def __str__(self): + return self.error + +class DroppableQTextEdit(QTextEdit): + def __init__(self,parent): + QTextEdit.__init__(self,parent) + + def canInsertFromMimeData(self, source): + if source.hasUrls(): + return True; + else: + return QTextEdit.canInsertFromMimeData(self,source) + + def insertFromMimeData(self, source): + if source.hasText(): + self.append(source.text()) + else: + return QTextEdit.insertFromMimeData(self, source) + +class AddNewDialog(SizePersistedDialog): + + def __init__(self, gui, prefs, icon, url_list_text): + SizePersistedDialog.__init__(self, gui, 'FanFictionDownLoader plugin:add new dialog') + self.gui = gui + + if prefs['adddialogstaysontop']: + QDialog.setWindowFlags ( self, Qt.Dialog|Qt.WindowStaysOnTopHint ) + + self.setMinimumWidth(300) + self.l = QVBoxLayout() + self.setLayout(self.l) + + self.setWindowTitle('FanFictionDownLoader') + self.setWindowIcon(icon) + + self.l.addWidget(QLabel('Story URL(s), one per line:')) + self.url = DroppableQTextEdit(self) + self.url.setToolTip('URLs for stories, one per line.\nWill take URLs from clipboard, but only valid URLs.\nAdd [1,5] after the URL to limit the download to chapters 1-5.') + self.url.setLineWrapMode(QTextEdit.NoWrap) + self.url.setText(url_list_text) + self.l.addWidget(self.url) + + horz = QHBoxLayout() + label = QLabel('Output &Format:') + horz.addWidget(label) + self.fileform = QComboBox(self) + self.fileform.addItem('epub') + self.fileform.addItem('mobi') + self.fileform.addItem('html') + self.fileform.addItem('txt') + self.fileform.setCurrentIndex(self.fileform.findText(prefs['fileform'])) + self.fileform.setToolTip('Choose output format to create. May set default from plugin configuration.') + self.fileform.activated.connect(self.set_collisions) + + label.setBuddy(self.fileform) + horz.addWidget(self.fileform) + self.l.addLayout(horz) + + horz = QHBoxLayout() + label = QLabel('If Story Already Exists?') + horz.addWidget(label) + self.collision = QComboBox(self) + self.collision.setToolTip("What to do if there's already an existing story with the same URL or title and author.") + # add collision options + self.set_collisions() + i = self.collision.findText(prefs['collision']) + if i > -1: + self.collision.setCurrentIndex(i) + label.setBuddy(self.collision) + horz.addWidget(self.collision) + self.l.addLayout(horz) + + horz = QHBoxLayout() + self.updatemeta = QCheckBox('Update Calibre &Metadata?',self) + self.updatemeta.setToolTip("Update metadata for existing stories in Calibre from web site?\n(Columns set to 'New Only' in the column tabs will only be set for new books.)") + self.updatemeta.setChecked(prefs['updatemeta']) + horz.addWidget(self.updatemeta) + + self.updateepubcover = QCheckBox('Update EPUB Cover?',self) + self.updateepubcover.setToolTip('Update book cover image from site or defaults (if found) inside the EPUB when EPUB is updated.') + self.updateepubcover.setChecked(prefs['updateepubcover']) + horz.addWidget(self.updateepubcover) + + self.l.addLayout(horz) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + self.l.addWidget(button_box) + + if url_list_text: + button_box.button(QDialogButtonBox.Ok).setFocus() + + # restore saved size. + self.resize_dialog() + #self.resize(self.sizeHint()) + + def set_collisions(self): + prev=self.collision.currentText() + self.collision.clear() + for o in collision_order: + if self.fileform.currentText() == 'epub' or o not in [UPDATE,UPDATEALWAYS]: + self.collision.addItem(o) + i = self.collision.findText(prev) + if i > -1: + self.collision.setCurrentIndex(i) + + def get_ffdl_options(self): + return { + 'fileform': unicode(self.fileform.currentText()), + 'collision': unicode(self.collision.currentText()), + 'updatemeta': self.updatemeta.isChecked(), + 'updateepubcover': self.updateepubcover.isChecked(), + } + + def get_urlstext(self): + return unicode(self.url.toPlainText()) + + +class FakeLineEdit(): + def __init__(self): + pass + + def text(self): + pass + +class CollectURLDialog(SizePersistedDialog): + ''' + Collect single url for get urls. + ''' + def __init__(self, gui, title, url_text): + SizePersistedDialog.__init__(self, gui, 'FanFictionDownLoader plugin:get story urls') + self.gui = gui + self.status=False + + self.setMinimumWidth(300) + + self.l = QGridLayout() + self.setLayout(self.l) + + self.setWindowTitle(title) + self.l.addWidget(QLabel(title),0,0,1,2) + + self.l.addWidget(QLabel("URL:"),1,0) + self.url = QLineEdit(self) + self.url.setText(url_text) + self.l.addWidget(self.url,1,1) + + self.ok_button = QPushButton('OK', self) + self.ok_button.clicked.connect(self.ok) + self.l.addWidget(self.ok_button,2,0) + + self.cancel_button = QPushButton('Cancel', self) + self.cancel_button.clicked.connect(self.cancel) + self.l.addWidget(self.cancel_button,2,1) + + # restore saved size. + self.resize_dialog() + + def ok(self): + self.status=True + self.accept() + + def cancel(self): + self.status=False + self.reject() + +class UserPassDialog(QDialog): + ''' + Need to collect User/Pass for some sites. + ''' + def __init__(self, gui, site, exception=None): + QDialog.__init__(self, gui) + self.gui = gui + self.status=False + + self.l = QGridLayout() + self.setLayout(self.l) + + if exception.passwdonly: + self.setWindowTitle('Password') + self.l.addWidget(QLabel("Author requires a password for this story(%s)."%exception.url),0,0,1,2) + # user isn't used, but it's easier to still have it for + # post processing. + self.user = FakeLineEdit() + else: + self.setWindowTitle('User/Password') + self.l.addWidget(QLabel("%s requires you to login to download this story."%site),0,0,1,2) + + self.l.addWidget(QLabel("User:"),1,0) + self.user = QLineEdit(self) + self.l.addWidget(self.user,1,1) + + self.l.addWidget(QLabel("Password:"),2,0) + self.passwd = QLineEdit(self) + self.passwd.setEchoMode(QLineEdit.Password) + self.l.addWidget(self.passwd,2,1) + + self.ok_button = QPushButton('OK', self) + self.ok_button.clicked.connect(self.ok) + self.l.addWidget(self.ok_button,3,0) + + self.cancel_button = QPushButton('Cancel', self) + self.cancel_button.clicked.connect(self.cancel) + self.l.addWidget(self.cancel_button,3,1) + + self.resize(self.sizeHint()) + + def ok(self): + self.status=True + self.hide() + + def cancel(self): + self.status=False + self.hide() + +class LoopProgressDialog(QProgressDialog): + ''' + ProgressDialog displayed while fetching metadata for each story. + ''' + def __init__(self, gui, + book_list, + foreach_function, + finish_function, + init_label="Fetching metadata for stories...", + win_title="Downloading metadata for stories", + status_prefix="Fetched metadata for"): + QProgressDialog.__init__(self, + init_label, + QString(), 0, len(book_list), gui) + self.setWindowTitle(win_title) + self.setMinimumWidth(500) + self.gui = gui + self.book_list = book_list + self.foreach_function = foreach_function + self.finish_function = finish_function + self.status_prefix = status_prefix + self.i = 0 + + ## self.do_loop does QTimer.singleShot on self.do_loop also. + ## A weird way to do a loop, but that was the example I had. + QTimer.singleShot(0, self.do_loop) + self.exec_() + + def updateStatus(self): + self.setLabelText("%s %d of %d"%(self.status_prefix,self.i+1,len(self.book_list))) + self.setValue(self.i+1) + print(self.labelText()) + + def do_loop(self): + + if self.i == 0: + self.setValue(0) + + book = self.book_list[self.i] + try: + ## collision spec passed into getadapter by partial from ffdl_plugin + ## no retval only if it exists, but collision is SKIP + self.foreach_function(book) + + except NotGoingToDownload as d: + book['good']=False + book['comment']=unicode(d) + book['icon'] = d.icon + + except Exception as e: + book['good']=False + book['comment']=unicode(e) + print("Exception: %s:%s"%(book,unicode(e))) + traceback.print_exc() + + self.updateStatus() + self.i += 1 + + if self.i >= len(self.book_list) or self.wasCanceled(): + return self.do_when_finished() + else: + QTimer.singleShot(0, self.do_loop) + + def do_when_finished(self): + self.hide() + self.gui = None + # Queues a job to process these books in the background. + self.finish_function(self.book_list) + +class AboutDialog(QDialog): + + def __init__(self, parent, icon, text): + QDialog.__init__(self, parent) + self.resize(400, 250) + self.l = QGridLayout() + self.setLayout(self.l) + self.logo = QLabel() + self.logo.setMaximumWidth(110) + self.logo.setPixmap(QPixmap(icon.pixmap(100,100))) + self.label = QLabel(text) + self.label.setOpenExternalLinks(True) + self.label.setWordWrap(True) + self.setWindowTitle(_('About FanFictionDownLoader')) + self.setWindowIcon(icon) + self.l.addWidget(self.logo, 0, 0) + self.l.addWidget(self.label, 0, 1) + self.bb = QDialogButtonBox(self) + b = self.bb.addButton(_('OK'), self.bb.AcceptRole) + b.setDefault(True) + self.l.addWidget(self.bb, 2, 0, 1, -1) + self.bb.accepted.connect(self.accept) + +class IconWidgetItem(ReadOnlyTextIconWidgetItem): + def __init__(self, text, icon, sort_key): + ReadOnlyTextIconWidgetItem.__init__(self, text, icon) + self.sort_key = sort_key + + #Qt uses a simple < check for sorting items, override this to use the sortKey + def __lt__(self, other): + return self.sort_key < other.sort_key + +class AuthorTableWidgetItem(ReadOnlyTableWidgetItem): + def __init__(self, text, sort_key): + ReadOnlyTableWidgetItem.__init__(self, text) + self.sort_key = sort_key + + #Qt uses a simple < check for sorting items, override this to use the sortKey + def __lt__(self, other): + return self.sort_key.lower() < other.sort_key.lower() + +class UpdateExistingDialog(SizePersistedDialog): + def __init__(self, gui, header, prefs, icon, books, + save_size_name='fanfictiondownloader_plugin:update list dialog'): + SizePersistedDialog.__init__(self, gui, save_size_name) + self.gui = gui + + self.setWindowTitle(header) + self.setWindowIcon(icon) + + layout = QVBoxLayout(self) + self.setLayout(layout) + title_layout = ImageTitleLayout(self, 'images/icon.png', + header) + layout.addLayout(title_layout) + books_layout = QHBoxLayout() + layout.addLayout(books_layout) + + self.books_table = StoryListTableWidget(self) + books_layout.addWidget(self.books_table) + + button_layout = QVBoxLayout() + books_layout.addLayout(button_layout) + # self.move_up_button = QtGui.QToolButton(self) + # self.move_up_button.setToolTip('Move selected books up the list') + # self.move_up_button.setIcon(QIcon(I('arrow-up.png'))) + # self.move_up_button.clicked.connect(self.books_table.move_rows_up) + # button_layout.addWidget(self.move_up_button) + spacerItem = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) + button_layout.addItem(spacerItem) + self.remove_button = QtGui.QToolButton(self) + self.remove_button.setToolTip('Remove selected books from the list') + self.remove_button.setIcon(get_icon('list_remove.png')) + self.remove_button.clicked.connect(self.remove_from_list) + button_layout.addWidget(self.remove_button) + spacerItem1 = QtGui.QSpacerItem(20, 40, QtGui.QSizePolicy.Minimum, QtGui.QSizePolicy.Expanding) + button_layout.addItem(spacerItem1) + # self.move_down_button = QtGui.QToolButton(self) + # self.move_down_button.setToolTip('Move selected books down the list') + # self.move_down_button.setIcon(QIcon(I('arrow-down.png'))) + # self.move_down_button.clicked.connect(self.books_table.move_rows_down) + # button_layout.addWidget(self.move_down_button) + + options_layout = QHBoxLayout() + + label = QLabel('Output &Format:') + options_layout.addWidget(label) + self.fileform = QComboBox(self) + self.fileform.addItem('epub') + self.fileform.addItem('mobi') + self.fileform.addItem('html') + self.fileform.addItem('txt') + self.fileform.setCurrentIndex(self.fileform.findText(prefs['fileform'])) + self.fileform.setToolTip('Choose output format to create. May set default from plugin configuration.') + self.fileform.activated.connect(self.set_collisions) + label.setBuddy(self.fileform) + options_layout.addWidget(self.fileform) + + label = QLabel('Update Mode:') + options_layout.addWidget(label) + self.collision = QComboBox(self) + self.collision.setToolTip("What sort of update to perform. May set default from plugin configuration.") + # add collision options + self.set_collisions() + i = self.collision.findText(prefs['collision']) + if i > -1: + self.collision.setCurrentIndex(i) + # self.collision.setToolTip('Overwrite will replace the existing story. Add New will create a new story with the same title and author.') + label.setBuddy(self.collision) + options_layout.addWidget(self.collision) + + self.updatemeta = QCheckBox('Update Calibre &Metadata?',self) + self.updatemeta.setToolTip("Update metadata for existing stories in Calibre from web site?\n(Columns set to 'New Only' in the column tabs will only be set for new books.)") + self.updatemeta.setChecked(prefs['updatemeta']) + options_layout.addWidget(self.updatemeta) + + self.updateepubcover = QCheckBox('Update EPUB Cover?',self) + self.updateepubcover.setToolTip('Update book cover image from site or defaults (if found) inside the EPUB when EPUB is updated.') + self.updateepubcover.setChecked(prefs['updateepubcover']) + options_layout.addWidget(self.updateepubcover) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + options_layout.addWidget(button_box) + + layout.addLayout(options_layout) + + # Cause our dialog size to be restored from prefs or created on first usage + self.resize_dialog() + self.books_table.populate_table(books) + + def set_collisions(self): + prev=self.collision.currentText() + self.collision.clear() + for o in collision_order: + if o not in [ADDNEW,SKIP] and \ + (self.fileform.currentText() == 'epub' or o not in [UPDATE,UPDATEALWAYS]): + self.collision.addItem(o) + i = self.collision.findText(prev) + if i > -1: + self.collision.setCurrentIndex(i) + + def remove_from_list(self): + self.books_table.remove_selected_rows() + + def get_books(self): + return self.books_table.get_books() + + def get_ffdl_options(self): + return { + 'fileform': unicode(self.fileform.currentText()), + 'collision': unicode(self.collision.currentText()), + 'updatemeta': self.updatemeta.isChecked(), + 'updateepubcover': self.updateepubcover.isChecked(), + } + +def display_story_list(gui, header, prefs, icon, books, + label_text='', + save_size_name='fanfictiondownloader_plugin:display list dialog', + offer_skip=False): + all_good = True + for b in books: + if not b['good']: + all_good=False + break + + ## + if all_good and not dynamic.get(confirm_config_name(save_size_name), True): + return True + pass + ## fake accept? + d = DisplayStoryListDialog(gui, header, prefs, icon, books, + label_text, + save_size_name, + offer_skip and all_good) + d.exec_() + return d.result() == d.Accepted + +class DisplayStoryListDialog(SizePersistedDialog): + def __init__(self, gui, header, prefs, icon, books, + label_text='', + save_size_name='fanfictiondownloader_plugin:display list dialog', + offer_skip=False): + SizePersistedDialog.__init__(self, gui, save_size_name) + self.name = save_size_name + self.gui = gui + + self.setWindowTitle(header) + self.setWindowIcon(icon) + + layout = QVBoxLayout(self) + self.setLayout(layout) + title_layout = ImageTitleLayout(self, 'images/icon.png', + header) + layout.addLayout(title_layout) + + self.books_table = StoryListTableWidget(self) + layout.addWidget(self.books_table) + + options_layout = QHBoxLayout() + self.label = QLabel(label_text) + #self.label.setOpenExternalLinks(True) + #self.label.setWordWrap(True) + options_layout.addWidget(self.label) + + if offer_skip: + spacerItem1 = QtGui.QSpacerItem(2, 4, QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum) + options_layout.addItem(spacerItem1) + self.again = QCheckBox('Show this again?',self) + self.again.setChecked(True) + self.again.stateChanged.connect(self.toggle) + self.again.setToolTip('Uncheck to skip review and update stories immediately when no problems.') + options_layout.addWidget(self.again) + + button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + button_box.accepted.connect(self.accept) + button_box.rejected.connect(self.reject) + + options_layout.addWidget(button_box) + + layout.addLayout(options_layout) + + # Cause our dialog size to be restored from prefs or created on first usage + self.resize_dialog() + self.books_table.populate_table(books) + + def get_books(self): + return self.books_table.get_books() + + def toggle(self, *args): + dynamic[confirm_config_name(self.name)] = self.again.isChecked() + + + +class StoryListTableWidget(QTableWidget): + + def __init__(self, parent): + QTableWidget.__init__(self, parent) + self.setSelectionBehavior(QAbstractItemView.SelectRows) + + def populate_table(self, books): + self.clear() + self.setAlternatingRowColors(True) + self.setRowCount(len(books)) + header_labels = ['','Title', 'Author', 'URL', 'Comment'] + self.setColumnCount(len(header_labels)) + self.setHorizontalHeaderLabels(header_labels) + self.horizontalHeader().setStretchLastSection(True) + #self.verticalHeader().setDefaultSectionSize(24) + self.verticalHeader().hide() + + self.books={} + for row, book in enumerate(books): + self.populate_table_row(row, book) + self.books[row] = book + + # turning True breaks up/down. Do we need either sorting or up/down? + self.setSortingEnabled(True) + self.resizeColumnsToContents() + self.setMinimumColumnWidth(1, 100) + self.setMinimumColumnWidth(2, 100) + self.setMinimumColumnWidth(3, 100) + self.setMinimumSize(300, 0) + # if len(books) > 0: + # self.selectRow(0) + self.sortItems(1) + self.sortItems(0) + + def setMinimumColumnWidth(self, col, minimum): + if self.columnWidth(col) < minimum: + self.setColumnWidth(col, minimum) + + def populate_table_row(self, row, book): + if book['good']: + icon = get_icon('ok.png') + val = 0 + else: + icon = get_icon('minus.png') + val = 1 + if 'icon' in book: + icon = get_icon(book['icon']) + + status_cell = IconWidgetItem(None,icon,val) + status_cell.setData(Qt.UserRole, QVariant(val)) + self.setItem(row, 0, status_cell) + + title_cell = ReadOnlyTableWidgetItem(book['title']) + title_cell.setData(Qt.UserRole, QVariant(row)) + self.setItem(row, 1, title_cell) + + self.setItem(row, 2, AuthorTableWidgetItem(", ".join(book['author']), ", ".join(book['author_sort']))) + + url_cell = ReadOnlyTableWidgetItem(book['url']) + #url_cell.setData(Qt.UserRole, QVariant(book['url'])) + self.setItem(row, 3, url_cell) + + comment_cell = ReadOnlyTableWidgetItem(book['comment']) + #comment_cell.setData(Qt.UserRole, QVariant(book)) + self.setItem(row, 4, comment_cell) + + def get_books(self): + books = [] + #print("=========================\nbooks:%s"%self.books) + for row in range(self.rowCount()): + rnum = self.item(row, 1).data(Qt.UserRole).toPyObject() + book = self.books[rnum] + books.append(book) + return books + + def remove_selected_rows(self): + self.setFocus() + rows = self.selectionModel().selectedRows() + if len(rows) == 0: + return + message = '

Are you sure you want to remove this book from the list?' + if len(rows) > 1: + message = '

Are you sure you want to remove the selected %d books from the list?'%len(rows) + if not confirm(message,'fanfictiondownloader_delete_item', self): + return + first_sel_row = self.currentRow() + for selrow in reversed(rows): + self.removeRow(selrow.row()) + if first_sel_row < self.rowCount(): + self.select_and_scroll_to_row(first_sel_row) + elif self.rowCount() > 0: + self.select_and_scroll_to_row(first_sel_row - 1) + + def select_and_scroll_to_row(self, row): + self.selectRow(row) + self.scrollToItem(self.currentItem()) + + def move_rows_up(self): + self.setFocus() + rows = self.selectionModel().selectedRows() + if len(rows) == 0: + return + first_sel_row = rows[0].row() + if first_sel_row <= 0: + return + # Workaround for strange selection bug in Qt which "alters" the selection + # in certain circumstances which meant move down only worked properly "once" + selrows = [] + for row in rows: + selrows.append(row.row()) + selrows.sort() + for selrow in selrows: + self.swap_row_widgets(selrow - 1, selrow + 1) + scroll_to_row = first_sel_row - 1 + if scroll_to_row > 0: + scroll_to_row = scroll_to_row - 1 + self.scrollToItem(self.item(scroll_to_row, 0)) + + def move_rows_down(self): + self.setFocus() + rows = self.selectionModel().selectedRows() + if len(rows) == 0: + return + last_sel_row = rows[-1].row() + if last_sel_row == self.rowCount() - 1: + return + # Workaround for strange selection bug in Qt which "alters" the selection + # in certain circumstances which meant move down only worked properly "once" + selrows = [] + for row in rows: + selrows.append(row.row()) + selrows.sort() + for selrow in reversed(selrows): + self.swap_row_widgets(selrow + 2, selrow) + scroll_to_row = last_sel_row + 1 + if scroll_to_row < self.rowCount() - 1: + scroll_to_row = scroll_to_row + 1 + self.scrollToItem(self.item(scroll_to_row, 0)) + + def swap_row_widgets(self, src_row, dest_row): + self.blockSignals(True) + self.insertRow(dest_row) + for col in range(0, self.columnCount()): + self.setItem(dest_row, col, self.takeItem(src_row, col)) + self.removeRow(src_row) + self.blockSignals(False) diff --git a/calibre-plugin/ffdl_plugin.py b/calibre-plugin/ffdl_plugin.py new file mode 100644 index 00000000..1d334712 --- /dev/null +++ b/calibre-plugin/ffdl_plugin.py @@ -0,0 +1,1291 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2012, Jim Miller' +__docformat__ = 'restructuredtext en' + +import time, os, copy, threading, re, platform +from StringIO import StringIO +from functools import partial +from datetime import datetime +from string import Template + +from PyQt4.Qt import (QApplication, QMenu, QToolButton) + +from PyQt4.Qt import QPixmap, Qt +from PyQt4.QtCore import QBuffer + +from calibre.constants import numeric_version as calibre_version + +from calibre.ptempfile import PersistentTemporaryFile, PersistentTemporaryDirectory, remove_dir +from calibre.ebooks.metadata import MetaInformation, authors_to_string +from calibre.ebooks.metadata.meta import get_metadata +from calibre.gui2 import error_dialog, warning_dialog, question_dialog, info_dialog +from calibre.gui2.dialogs.message_box import ViewLog +from calibre.gui2.dialogs.confirm_delete import confirm +from calibre.utils.date import local_tz +from calibre.library.comments import sanitize_comments_html +from calibre.constants import config_dir as calibre_config_dir + +# The class that all interface action plugins must inherit from +from calibre.gui2.actions import InterfaceAction + +from calibre_plugins.fanfictiondownloader_plugin.common_utils import (set_plugin_icon_resources, get_icon, + create_menu_action_unique, get_library_uuid) + +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader import adapters, exceptions +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.configurable import Configuration +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.epubutils import get_dcsource, get_dcsource_chaptercount, get_story_url_from_html +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.geturls import get_urls_from_page + +from calibre_plugins.fanfictiondownloader_plugin.config import (prefs, permitted_values) +from calibre_plugins.fanfictiondownloader_plugin.dialogs import ( + AddNewDialog, UpdateExistingDialog, display_story_list, DisplayStoryListDialog, + LoopProgressDialog, UserPassDialog, AboutDialog, CollectURLDialog, + OVERWRITE, OVERWRITEALWAYS, UPDATE, UPDATEALWAYS, ADDNEW, SKIP, CALIBREONLY, + NotGoingToDownload ) + +# because calibre immediately transforms html into zip and don't want +# to have an 'if html'. db.has_format is cool with the case mismatch, +# but if I'm doing it anyway... +formmapping = { + 'epub':'EPUB', + 'mobi':'MOBI', + 'html':'ZIP', + 'txt':'TXT' + } + +PLUGIN_ICONS = ['images/icon.png'] + +class FanFictionDownLoaderPlugin(InterfaceAction): + + name = 'FanFictionDownLoader' + + # Declare the main action associated with this plugin + # The keyboard shortcut can be None if you dont want to use a keyboard + # shortcut. Remember that currently calibre has no central management for + # keyboard shortcuts, so try to use an unusual/unused shortcut. + # (text, icon_path, tooltip, keyboard shortcut) + # icon_path isn't in the zip--icon loaded below. + action_spec = (name, None, + 'Download FanFiction stories from various web sites', ()) + # None for keyboard shortcut doesn't allow shortcut. () does, there just isn't one yet + + action_type = 'global' + # make button menu drop down only + #popup_type = QToolButton.InstantPopup + + def genesis(self): + + # This method is called once per plugin, do initial setup here + + # Read the plugin icons and store for potential sharing with the config widget + icon_resources = self.load_resources(PLUGIN_ICONS) + set_plugin_icon_resources(self.name, icon_resources) + + base = self.interface_action_base_plugin + self.version = base.name+" v%d.%d.%d"%base.version + + # Set the icon for this interface action + # The get_icons function is a builtin function defined for all your + # plugin code. It loads icons from the plugin zip file. It returns + # QIcon objects, if you want the actual data, use the analogous + # get_resources builtin function. + + # Note that if you are loading more than one icon, for performance, you + # should pass a list of names to get_icons. In this case, get_icons + # will return a dictionary mapping names to QIcons. Names that + # are not found in the zip file will result in null QIcons. + icon = get_icon('images/icon.png') + + #self.qaction.setText('FFDL') + + # The qaction is automatically created from the action_spec defined + # above + self.qaction.setIcon(icon) + + # Call function when plugin triggered. + self.qaction.triggered.connect(self.plugin_button) + + # Assign our menu to this action + self.menu = QMenu(self.gui) + self.old_actions_unique_map = {} + # menu_actions is just to keep a live reference to the menu + # items to prevent GC removing it. + self.menu_actions = [] + self.qaction.setMenu(self.menu) + self.menus_lock = threading.RLock() + self.menu.aboutToShow.connect(self.about_to_show_menu) + + def initialization_complete(self): + # otherwise configured hot keys won't work until the menu's + # been displayed once. + self.rebuild_menus() + + ## Kludgey, yes, but with the real configuration inside the + ## library now, how else would a user be able to change this + ## setting if it's crashing calibre? + def check_macmenuhack(self): + try: + return self.macmenuhack + except: + file_path = os.path.join(calibre_config_dir, + *("plugins/fanfictiondownloader_macmenuhack.txt".split('/'))) + file_path = os.path.abspath(file_path) + print("macmenuhack file_path:%s"%file_path) + self.macmenuhack = os.access(file_path, os.F_OK) + return self.macmenuhack + + def about_to_show_menu(self): + self.rebuild_menus() + + def library_changed(self, db): + # We need to reset our menus after switching libraries + self.rebuild_menus() + + def rebuild_menus(self): + with self.menus_lock: + do_user_config = self.interface_action_base_plugin.do_user_config + self.menu.clear() + self.actions_unique_map = {} + self.menu_actions = [] + self.add_action = self.create_menu_item_ex(self.menu, '&Add New from URL(s)', image='plus.png', + unique_name='Add New FanFiction Book(s) from URL(s)', + shortcut_name='Add New FanFiction Book(s) from URL(s)', + triggered=self.add_dialog ) + + self.update_action = self.create_menu_item_ex(self.menu, '&Update Existing FanFiction Book(s)', image='plusplus.png', + triggered=self.update_existing) + + if 'Reading List' in self.gui.iactions and (prefs['addtolists'] or prefs['addtoreadlists']) : + self.menu.addSeparator() + addmenutxt, rmmenutxt = None, None + if prefs['addtolists'] and prefs['addtoreadlists'] : + addmenutxt = 'Add to "To Read" and "Send to Device" Lists' + if prefs['addtolistsonread']: + rmmenutxt = 'Remove from "To Read" and add to "Send to Device" Lists' + else: + rmmenutxt = 'Remove from "To Read" Lists' + elif prefs['addtolists'] : + addmenutxt = 'Add Selected to "Send to Device" Lists' + elif prefs['addtoreadlists']: + addmenutxt = 'Add to "To Read" Lists' + rmmenutxt = 'Remove from "To Read" Lists' + + if addmenutxt: + self.add_send_action = self.create_menu_item_ex(self.menu, addmenutxt, image='plusplus.png', + triggered=partial(self.update_lists,add=True)) + + if rmmenutxt: + self.add_remove_action = self.create_menu_item_ex(self.menu, rmmenutxt, image='minusminus.png', + triggered=partial(self.update_lists,add=False)) + + self.menu.addSeparator() + self.get_list_action = self.create_menu_item_ex(self.menu, 'Get URLs from Selected Books', image='bookmarks.png', + triggered=self.get_list_urls) + + self.get_list_url_action = self.create_menu_item_ex(self.menu, 'Get Story URLs from Web Page', image='view.png', + triggered=self.get_urls_from_page) + + # print("platform.system():%s"%platform.system()) + # print("platform.mac_ver()[0]:%s"%platform.mac_ver()[0]) + if not self.check_macmenuhack(): # not platform.mac_ver()[0]: # Some macs crash on these menu items for unknown reasons. + self.menu.addSeparator() + self.config_action = self.create_menu_item_ex(self.menu, '&Configure Plugin', + image= 'config.png', + unique_name='Configure FanFictionDownLoader', + shortcut_name='Configure FanFictionDownLoader', + triggered=partial(do_user_config,parent=self.gui)) + + self.about_action = self.create_menu_item_ex(self.menu, 'About Plugin', + image= 'images/icon.png', + unique_name='About FanFictionDownLoader', + shortcut_name='About FanFictionDownLoader', + triggered=self.about) + + # Before we finalize, make sure we delete any actions for menus that are no longer displayed + for menu_id, unique_name in self.old_actions_unique_map.iteritems(): + if menu_id not in self.actions_unique_map: + self.gui.keyboard.unregister_shortcut(unique_name) + self.old_actions_unique_map = self.actions_unique_map + self.gui.keyboard.finalize() + + def about(self): + # Get the about text from a file inside the plugin zip file + # The get_resources function is a builtin function defined for all your + # plugin code. It loads files from the plugin zip file. It returns + # the bytes from the specified file. + # + # Note that if you are loading more than one file, for performance, you + # should pass a list of names to get_resources. In this case, + # get_resources will return a dictionary mapping names to bytes. Names that + # are not found in the zip file will not be in the returned dictionary. + + text = get_resources('about.txt') + AboutDialog(self.gui,self.qaction.icon(),self.version + text).exec_() + + def create_menu_item_ex(self, parent_menu, menu_text, image=None, tooltip=None, + shortcut=None, triggered=None, is_checked=None, shortcut_name=None, + unique_name=None): + #print("create_menu_item_ex before %s"%menu_text) + ac = create_menu_action_unique(self, parent_menu, menu_text, image, tooltip, + shortcut, triggered, is_checked, shortcut_name, unique_name) + self.actions_unique_map[ac.calibre_shortcut_unique_name] = ac.calibre_shortcut_unique_name + self.menu_actions.append(ac) + #print("create_menu_item_ex after %s"%menu_text) + return ac + + def plugin_button(self): + if len(self.gui.library_view.get_selected_ids()) > 0 and prefs['updatedefault']: + self.update_existing() + else: + self.add_dialog() + + def update_lists(self,add=True): + if len(self.gui.library_view.get_selected_ids()) > 0 and \ + (prefs['addtolists'] or prefs['addtoreadlists']) : + self._update_reading_lists(self.gui.library_view.get_selected_ids(),add) + + def get_urls_from_page(self): + + if prefs['urlsfromclip']: + try: + urltxt = self.get_urls_clip(storyurls=False)[0] + except: + urltxt = "" + + d = CollectURLDialog(self.gui,"Get Story URLs from Web Page",urltxt) + d.exec_() + if not d.status: + return + url = u"%s"%d.url.text() + print("get_urls_from_page URL:%s"%url) + + if 'archiveofourown.org' in url: + configuration = Configuration(adapters.getConfigSectionFor(url),"EPUB") + configuration.readfp(StringIO(get_resources("plugin-defaults.ini"))) + configuration.readfp(StringIO(prefs['personal.ini'])) + else: + configuration = None + url_list = get_urls_from_page(url,configuration) + + if url_list: + d = ViewLog(_("List of URLs"),"\n".join(url_list),parent=self.gui) + d.setWindowIcon(get_icon('bookmarks.png')) + d.exec_() + else: + info_dialog(self.gui, _('List of URLs'), + _('No Valid URLs found on given page.'), + show=True, + show_copy_button=False) + + + def get_list_urls(self): + if len(self.gui.library_view.get_selected_ids()) > 0: + book_list = map( partial(self._convert_id_to_book, good=False), self.gui.library_view.get_selected_ids() ) + + LoopProgressDialog(self.gui, + book_list, + partial(self._get_story_url_for_list, db=self.gui.current_db), + self._finish_get_list_urls, + init_label="Collecting URLs for stories...", + win_title="Get URLs for stories", + status_prefix="URL retrieved") + + def _get_story_url_for_list(self,book,db=None): + book['url'] = self._get_story_url(db,book['calibre_id']) + if book['url'] == None: + book['good']=False + else: + book['good']=True + + def _finish_get_list_urls(self, book_list): + url_list = [ x['url'] for x in book_list if x['good'] ] + if url_list: + d = ViewLog(_("List of URLs"),"\n".join(url_list),parent=self.gui) + d.setWindowIcon(get_icon('bookmarks.png')) + d.exec_() + else: + info_dialog(self.gui, _('List of URLs'), + _('No URLs found in selected books.'), + show=True, + show_copy_button=False) + + def add_dialog(self): + + #print("add_dialog()") + + url_list = self.get_urls_clip() + url_list_text = "\n".join(url_list) + + # self.gui is the main calibre GUI. It acts as the gateway to access + # all the elements of the calibre user interface, it should also be the + # parent of the dialog + # AddNewDialog just collects URLs, format and presents buttons. + d = AddNewDialog(self.gui, + prefs, + self.qaction.icon(), + url_list_text, + ) + d.exec_() + if d.result() != d.Accepted: + return + + url_list = get_url_list(d.get_urlstext()) + add_books = self._convert_urls_to_books(url_list) + #print("add_books:%s"%add_books) + #print("options:%s"%d.get_ffdl_options()) + + options = d.get_ffdl_options() + options['version'] = self.version + print(self.version) + + self.start_downloads( options, add_books ) + + def update_existing(self): + if len(self.gui.library_view.get_selected_ids()) == 0: + return + #print("update_existing()") + + db = self.gui.current_db + book_list = map( partial(self._convert_id_to_book, good=False), self.gui.library_view.get_selected_ids() ) + #book_ids = self.gui.library_view.get_selected_ids() + + LoopProgressDialog(self.gui, + book_list, + partial(self._populate_book_from_calibre_id, db=self.gui.current_db), + self._update_existing_2, + init_label="Collecting stories for update...", + win_title="Get stories for updates", + status_prefix="URL retrieved") + + #books = self._convert_calibre_ids_to_books(db, book_ids) + #print("update books:%s"%books) + + def _update_existing_2(self,book_list): + + d = UpdateExistingDialog(self.gui, + 'Update Existing List', + prefs, + self.qaction.icon(), + book_list, + ) + d.exec_() + if d.result() != d.Accepted: + return + + update_books = d.get_books() + + #print("update_books:%s"%update_books) + #print("options:%s"%d.get_ffdl_options()) + # only if there's some good ones. + if 0 < len(filter(lambda x : x['good'], update_books)): + options = d.get_ffdl_options() + options['version'] = self.version + print(self.version) + self.start_downloads( options, update_books ) + + def get_urls_clip(self,storyurls=True): + url_list = [] + if prefs['urlsfromclip']: + for url in unicode(QApplication.instance().clipboard().text()).split(): + if not storyurls or self._is_good_downloader_url(url): + url_list.append(url) + + return url_list + + def apply_settings(self): + # No need to do anything with perfs here, but we could. + prefs + + def start_downloads(self, options, books): + + #print("start_downloads:%s"%books) + + # create and pass temp dir. + tdir = PersistentTemporaryDirectory(prefix='fanfictiondownloader_') + options['tdir']=tdir + + self.gui.status_bar.show_message(_('Started fetching metadata for %s stories.'%len(books)), 3000) + + if 0 < len(filter(lambda x : x['good'], books)): + LoopProgressDialog(self.gui, + books, + partial(self.get_metadata_for_book, options = options), + partial(self.start_download_list, options = options)) + # LoopProgressDialog calls get_metadata_for_book for each 'good' story, + # get_metadata_for_book updates book for each, + # LoopProgressDialog calls start_download_list at the end which goes + # into the BG, or shows list if no 'good' books. + + def get_metadata_for_book(self,book, + options={'fileform':'epub', + 'collision':ADDNEW, + 'updatemeta':True, + 'updateepubcover':True}): + ''' + Update passed in book dict with metadata from website and + necessary data. To be called from LoopProgressDialog + 'loop'. Also pops dialogs for is adult, user/pass. + ''' + + # The current database shown in the GUI + # db is an instance of the class LibraryDatabase2 from database.py + # This class has many, many methods that allow you to do a lot of + # things. + db = self.gui.current_db + + fileform = options['fileform'] + collision = options['collision'] + updatemeta= options['updatemeta'] + updateepubcover= options['updateepubcover'] + + if not book['good']: + # book has already been flagged bad for whatever reason. + return + + url = book['url'] + print("url:%s"%url) + skip_date_update = False + + options['personal.ini'] = prefs['personal.ini'] + if prefs['includeimages']: + # this is a cheat to make it easier for users. + options['personal.ini'] = '''[epub] +include_images:true +keep_summary_html:true +make_firstimage_cover:true +''' + options['personal.ini'] + + configuration = Configuration(adapters.getConfigSectionFor(url),fileform) + configuration.readfp(StringIO(get_resources("plugin-defaults.ini"))) + configuration.readfp(StringIO(options['personal.ini'])) + adapter = adapters.getAdapter(configuration,url) + + ## three tries, that's enough if both user/pass & is_adult needed, + ## or a couple tries of one or the other + for x in range(0,2): + try: + adapter.getStoryMetadataOnly() + except exceptions.FailedToLogin, f: + print("Login Failed, Need Username/Password.") + userpass = UserPassDialog(self.gui,url,f) + userpass.exec_() # exec_ will make it act modal + if userpass.status: + adapter.username = userpass.user.text() + adapter.password = userpass.passwd.text() + + except exceptions.AdultCheckRequired: + if question_dialog(self.gui, 'Are You Adult?', '

'+ + "%s requires that you be an adult. Please confirm you are an adult in your locale:"%url, + show_copy_button=False): + adapter.is_adult=True + + # let other exceptions percolate up. + story = adapter.getStoryMetadataOnly() + + # set PI version instead of default. + if 'version' in options: + story.setMetadata('version',options['version']) + + book['all_metadata'] = story.getAllMetadata(removeallentities=True) + book['title'] = story.getMetadata("title", removeallentities=True) + book['author_sort'] = book['author'] = story.getList("author", removeallentities=True) + book['publisher'] = story.getMetadata("site") + book['tags'] = story.getSubjectTags(removeallentities=True) + if story.getMetadata("description"): + book['comments'] = sanitize_comments_html(story.getMetadata("description")) + else: + book['comments']='' + book['series'] = story.getMetadata("series", removeallentities=True) + + # adapter.opener is the element with a threadlock. But del + # adapter.opener doesn't work--subproc fails when it tries + # to pull in the adapter object that hasn't been imported yet. + # book['adapter'] = adapter + + book['is_adult'] = adapter.is_adult + book['username'] = adapter.username + book['password'] = adapter.password + + book['icon'] = 'plus.png' + book['status'] = 'Add' + if story.getMetadataRaw('datePublished'): + # should only happen when an adapter is broken, but better to + # fail gracefully. + book['pubdate'] = story.getMetadataRaw('datePublished').replace(tzinfo=local_tz) + book['timestamp'] = None # filled below if not skipped. + + if collision in (CALIBREONLY): + book['icon'] = 'metadata.png' + book['status'] = 'Meta' + + # Dialogs should prevent this case now. + if collision in (UPDATE,UPDATEALWAYS) and fileform != 'epub': + raise NotGoingToDownload("Cannot update non-epub format.") + + book_id = None + + if book['calibre_id'] != None: + # updating an existing book. Update mode applies. + print("update existing id:%s"%book['calibre_id']) + book_id = book['calibre_id'] + # No handling needed: OVERWRITEALWAYS,CALIBREONLY + + # only care about collisions when not ADDNEW + elif collision != ADDNEW: + # 'new' book from URL. collision handling applies. + print("from URL(%s)"%url) + + # try to find by identifier url first. + searchstr = 'identifiers:"=url:=%s"'%url.replace(":","|") + identicalbooks = db.search_getting_ids(searchstr, None) + if len(identicalbooks) < 1: + # find dups + authlist = story.getList("author", removeallentities=True) + if len(authlist) > 100 and calibre_version < (0, 8, 61): + ## should be fixed from 0.8.61 on. In the + ## meantime, if it matches the title *and* first + ## 100 authors, I'm prepared to assume it's a + ## match. + print("reduce author list to 100 only when calibre < 0.8.61") + authlist = authlist[:100] + mi = MetaInformation(story.getMetadata("title", removeallentities=True), + authlist) + identicalbooks = db.find_identical_books(mi) + if len(identicalbooks) > 0: + print("existing found by title/author(s)") + + else: + print("existing found by identifier URL") + + if collision == SKIP and identicalbooks: + raise NotGoingToDownload("Skipping duplicate story.","list_remove.png") + + if len(identicalbooks) > 1: + raise NotGoingToDownload("More than one identical book by Identifer URL or title/author(s)--can't tell which book to update/overwrite.","minusminus.png") + + ## changed: add new book when CALIBREONLY if none found. + if collision == CALIBREONLY and not identicalbooks: + collision = ADDNEW + options['collision'] = ADDNEW + + if len(identicalbooks)>0: + book_id = identicalbooks.pop() + book['calibre_id'] = book_id + book['icon'] = 'edit-redo.png' + book['status'] = 'Update' + + if book_id != None and collision != ADDNEW: + if collision in (CALIBREONLY): + book['comment'] = 'Metadata collected.' + # don't need temp file created below. + return + + ## newer/chaptercount checks are the same for both: + # Update epub, but only if more chapters. + if collision in (UPDATE,UPDATEALWAYS): # collision == UPDATE + # 'book' can exist without epub. If there's no existing epub, + # let it go and it will download it. + if db.has_format(book_id,fileform,index_is_id=True): + (epuburl,chaptercount) = \ + get_dcsource_chaptercount(StringIO(db.format(book_id,'EPUB', + index_is_id=True))) + urlchaptercount = int(story.getMetadata('numChapters')) + if chaptercount == urlchaptercount: + if collision == UPDATE: + raise NotGoingToDownload("Already contains %d chapters."%chaptercount,'edit-undo.png') + else: + # UPDATEALWAYS + skip_date_update = True + elif chaptercount > urlchaptercount: + raise NotGoingToDownload("Existing epub contains %d chapters, web site only has %d. Use Overwrite to force update." % (chaptercount,urlchaptercount),'dialog_error.png') + + if collision == OVERWRITE and \ + db.has_format(book_id,formmapping[fileform],index_is_id=True): + # check make sure incoming is newer. + lastupdated=story.getMetadataRaw('dateUpdated').date() + fileupdated=datetime.fromtimestamp(os.stat(db.format_abspath(book_id, formmapping[fileform], index_is_id=True))[8]).date() + if fileupdated > lastupdated: + raise NotGoingToDownload("Not Overwriting, web site is not newer.",'edit-undo.png') + + # For update, provide a tmp file copy of the existing epub so + # it can't change underneath us. + if collision in (UPDATE,UPDATEALWAYS) and \ + db.has_format(book['calibre_id'],'EPUB',index_is_id=True): + tmp = PersistentTemporaryFile(prefix='old-%s-'%book['calibre_id'], + suffix='.epub', + dir=options['tdir']) + db.copy_format_to(book_id,fileform,tmp,index_is_id=True) + print("existing epub tmp:"+tmp.name) + book['epub_for_update'] = tmp.name + + if collision != CALIBREONLY and not skip_date_update: + # I'm half convinced this should be dateUpdated instead, but + # this behavior matches how epubs come out when imported + # dateCreated == packaged--epub/etc created. + book['timestamp'] = story.getMetadataRaw('dateCreated').replace(tzinfo=local_tz) + + if book_id != None and prefs['injectseries']: + mi = db.get_metadata(book_id,index_is_id=True) + if not book['series'] and mi.series != None: + book['calibre_series'] = (mi.series,mi.series_index) + print("calibre_series:%s [%s]"%book['calibre_series']) + + if book['good']: # there shouldn't be any !'good' books at this point. + # if still 'good', make a temp file to write the output to. + # For HTML format users, make the filename inside the zip something reasonable. + # For crazy long titles/authors, limit it to 200chars. + # For weird/OS-unsafe characters, use file safe only. + tmp = PersistentTemporaryFile(prefix=story.formatFileName("${title}-${author}-",allowunsafefilename=False)[:100], + suffix='.'+options['fileform'], + dir=options['tdir']) + print("title:"+book['title']) + print("outfile:"+tmp.name) + book['outfile'] = tmp.name + + return + + def start_download_list(self,book_list, + options={'fileform':'epub', + 'collision':ADDNEW, + 'updatemeta':True, + 'updateepubcover':True}): + ''' + Called by LoopProgressDialog to start story downloads BG processing. + adapter_list is a list of tuples of (url,adapter) + ''' + #print("start_download_list:book_list:%s"%book_list) + + ## No need to BG process when CALIBREONLY! Fake it. + if options['collision'] in (CALIBREONLY): + class NotJob(object): + def __init__(self,result): + self.failed=False + self.result=result + notjob = NotJob(book_list) + self.download_list_completed(notjob,options=options) + return + + for book in book_list: + if book['good']: + break + else: + ## No good stories to try to download, go straight to + ## list. + d = DisplayStoryListDialog(self.gui, + 'Nothing to Download', + prefs, + self.qaction.icon(), + book_list, + label_text='None of the URLs/stories given can be/need to be downloaded.' + ) + d.exec_() + + + custom_columns = self.gui.library_view.model().custom_columns + if prefs['errorcol'] != '' and prefs['errorcol'] in custom_columns: + label = custom_columns[prefs['errorcol']]['label'] + ## if error column and all bad. + self.previous = self.gui.library_view.currentIndex() + LoopProgressDialog(self.gui, + book_list, + partial(self._update_bad_book, label=label, options=options, db=self.gui.current_db), + partial(self._update_books_completed, options=options, showlist=False), + init_label="Updating calibre for BAD FanFiction stories...", + win_title="Update calibre for BAD FanFiction stories", + status_prefix="Updated") + + + return + + func = 'arbitrary_n' + cpus = self.gui.job_manager.server.pool_size + args = ['calibre_plugins.fanfictiondownloader_plugin.jobs', 'do_download_worker', + (book_list, options, cpus)] + desc = 'Download FanFiction Book' + job = self.gui.job_manager.run_job( + self.Dispatcher(partial(self.download_list_completed,options=options)), + func, args=args, + description=desc) + + self.gui.status_bar.show_message('Starting %d FanFictionDownLoads'%len(book_list),3000) + + def _update_book(self,book,db=None, + options={'fileform':'epub', + 'collision':ADDNEW, + 'updatemeta':True, + 'updateepubcover':True}): + print("add/update %s %s"%(book['title'],book['url'])) + mi = self._make_mi_from_book(book) + + if options['collision'] != CALIBREONLY: + self._add_or_update_book(book,options,prefs,mi) + + if options['collision'] == CALIBREONLY or \ + ( (options['updatemeta'] or book['added']) and book['good'] ): + self._update_metadata(db, book['calibre_id'], book, mi, options) + + def _update_bad_book(self,book,db=None,label='errorcol', + options={'fileform':'epub', + 'collision':ADDNEW, + 'updatemeta':True, + 'updateepubcover':True},): + if book['calibre_id']: + print("add/update bad %s %s %s"%(book['title'],book['url'],book['comment'])) + db.set_custom(book['calibre_id'], book['comment'], label=label, commit=True) + + def _update_books_completed(self, book_list, options={}, showlist=True): + + add_list = filter(lambda x : x['good'] and x['added'], book_list) + add_ids = [ x['calibre_id'] for x in add_list ] + update_list = filter(lambda x : x['good'] and not x['added'], book_list) + update_ids = [ x['calibre_id'] for x in update_list ] + + if len(add_list): + ## even shows up added to searchs. Nice. + self.gui.library_view.model().books_added(len(add_list)) + self.gui.library_view.model().refresh_ids(add_ids) + + if len(update_list): + self.gui.library_view.model().refresh_ids(update_ids) + + current = self.gui.library_view.currentIndex() + self.gui.library_view.model().current_changed(current, self.previous) + self.gui.tags_view.recount() + + if self.gui.cover_flow: + self.gui.cover_flow.dataChanged() + + self.gui.status_bar.show_message(_('Finished Adding/Updating %d books.'%(len(update_list) + len(add_list))), 3000) + + if showlist and (len(update_list) + len(add_list) != len(book_list)): + d = DisplayStoryListDialog(self.gui, + 'Updates completed, final status', + prefs, + self.qaction.icon(), + book_list, + label_text='Stories have be added or updated in Calibre, some had additional problems.' + ) + d.exec_() + + print("all done, remove temp dir.") + remove_dir(options['tdir']) + + all_ids = add_ids + all_ids.extend(update_ids) + if 'Count Pages' in self.gui.iactions and len(prefs['countpagesstats']) and len(all_ids): + cp_plugin = self.gui.iactions['Count Pages'] + cp_plugin.count_statistics(all_ids,prefs['countpagesstats']) + + def download_list_completed(self, job, options={}): + if job.failed: + self.gui.job_exception(job, dialog_title='Failed to Download Stories') + return + + self.previous = self.gui.library_view.currentIndex() + db = self.gui.current_db + + book_list = job.result + good_list = filter(lambda x : x['good'], book_list) + bad_list = filter(lambda x : x['calibre_id'] and not x['good'], book_list) + payload = (good_list, bad_list, options) + + msg = ''' +

FFDL found %s good and %s bad updates.

+

See log for details.

+

Proceed with updating your library?

+'''%(len(good_list),len(bad_list)) + + htmllog='' + for book in good_list: + if 'status' in book: + status = book['status'] + else: + status = 'Good' + htmllog = htmllog + '' + + for book in bad_list: + if 'status' in book: + status = book['status'] + else: + status = 'Bad' + htmllog = htmllog + '' + + htmllog = htmllog + '
StatusTitleAuthorCommentURL
' + ''.join([status,book['title'],", ".join(book['author']),book['comment'],book['url']]) + '
' + ''.join([status,book['title'],", ".join(book['author']),book['comment'],book['url']]) + '
' + + self.gui.proceed_question(self._do_download_list_update, + payload, htmllog, + 'FFDL log', 'FFDL download complete', msg, + show_copy_button=False) + + + def _do_download_list_update(self, payload): + + (good_list,bad_list,options) = payload + total_good = len(good_list) + + self.gui.status_bar.show_message(_('Adding/Updating %s books.'%total_good)) + + if total_good > 0: + LoopProgressDialog(self.gui, + good_list, + partial(self._update_book, options=options, db=self.gui.current_db), + partial(self._update_books_completed, options=options), + init_label="Updating calibre for FanFiction stories...", + win_title="Update calibre for FanFiction stories", + status_prefix="Updated") + + total_bad = len(bad_list) + + if total_bad > 0: + custom_columns = self.gui.library_view.model().custom_columns + if prefs['errorcol'] != '' and prefs['errorcol'] in custom_columns: + self.gui.status_bar.show_message(_('Adding/Updating %s BAD books.'%total_bad)) + label = custom_columns[prefs['errorcol']]['label'] + ## if error column and all bad. + LoopProgressDialog(self.gui, + bad_list, + partial(self._update_bad_book, label=label, options=options, db=self.gui.current_db), + partial(self._update_books_completed, options=options, showlist=False), + init_label="Updating calibre for BAD FanFiction stories...", + win_title="Update calibre for BAD FanFiction stories", + status_prefix="Updated") + + + def _add_or_update_book(self,book,options,prefs,mi=None): + db = self.gui.current_db + + if mi == None: + mi = self._make_mi_from_book(book) + + book_id = book['calibre_id'] + if book_id == None: + book_id = db.create_book_entry(mi, + add_duplicates=True) + book['calibre_id'] = book_id + book['added'] = True + else: + book['added'] = False + + if not db.add_format_with_hooks(book_id, + options['fileform'], + book['outfile'], index_is_id=True): + book['comment'] = "Adding format to book failed for some reason..." + book['good']=False + book['icon']='dialog_error.png' + book['status'] = 'Error' + + if prefs['deleteotherforms']: + fmts = db.formats(book['calibre_id'], index_is_id=True).split(',') + for fmt in fmts: + if fmt != formmapping[options['fileform']]: + print("remove f:"+fmt) + db.remove_format(book['calibre_id'], fmt, index_is_id=True)#, notify=False + + if prefs['addtolists'] or prefs['addtoreadlists']: + self._update_reading_lists([book_id],add=True) + + return book_id + + def _update_metadata(self, db, book_id, book, mi, options): + oldmi = db.get_metadata(book_id,index_is_id=True) + if prefs['keeptags']: + old_tags = db.get_tags(book_id) + # remove old Completed/In-Progress only if there's a new one. + if 'Completed' in mi.tags or 'In-Progress' in mi.tags: + old_tags = filter( lambda x : x not in ('Completed', 'In-Progress'), old_tags) + # remove old Last Update tags if there are new ones. + if len(filter( lambda x : not x.startswith("Last Update"), mi.tags)) > 0: + old_tags = filter( lambda x : not x.startswith("Last Update"), old_tags) + # mi.tags needs to be list, but set kills dups. + mi.tags = list(set(list(old_tags)+mi.tags)) + + if 'langcode' in book['all_metadata']: + mi.languages=[book['all_metadata']['langcode']] + else: + # Set language english, but only if not already set. + if not oldmi.languages: + mi.languages=['eng'] + + if options['fileform'] == 'epub' and prefs['updatecover']: + existingepub = db.format(book_id,'EPUB',index_is_id=True, as_file=True) + epubmi = get_metadata(existingepub,'EPUB') + if epubmi.cover_data[1] is not None: + db.set_cover(book_id, epubmi.cover_data[1]) + + # set author link if found. All current adapters have authorUrl, except anonymous on AO3. + if 'authorUrl' in book['all_metadata']: + authurls = book['all_metadata']['authorUrl'].split(", ") + for i, auth in enumerate(book['author']): + autid=db.get_author_id(auth) + db.set_link_field_for_author(autid, unicode(authurls[i]), + commit=False, notify=False) + + # implement 'newonly' flags here by setting to the current + # value again. + if not book['added']: + for (col,newonly) in prefs['std_cols_newonly'].iteritems(): + if newonly: + if col == "identifiers": + mi.set_identifiers(oldmi.get_identifiers()) + else: + try: + mi.__setattr__(col,oldmi.__getattribute__(col)) + except AttributeError: + print("AttributeError? %s"%col) + pass + + db.set_metadata(book_id,mi) + + # do configured column updates here. + #print("all_metadata: %s"%book['all_metadata']) + custom_columns = self.gui.library_view.model().custom_columns + + #print("prefs['custom_cols'] %s"%prefs['custom_cols']) + for col, meta in prefs['custom_cols'].iteritems(): + #print("setting %s to %s"%(col,meta)) + if col not in custom_columns: + print("%s not an existing column, skipping."%col) + continue + coldef = custom_columns[col] + if col in prefs['custom_cols_newonly'] and prefs['custom_cols_newonly'][col] and not book['added']: + print("Skipping custom column(%s) update, set to New Books Only"%coldef['name']) + continue + if not meta.startswith('status-') and meta not in book['all_metadata'] or \ + meta.startswith('status-') and 'status' not in book['all_metadata']: + print("No value for %s, skipping custom column(%s) update."%(meta,coldef['name'])) + continue + if meta not in permitted_values[coldef['datatype']]: + print("%s not a valid column type for %s, skipping."%(col,meta)) + continue + label = coldef['label'] + if coldef['datatype'] in ('enumeration','text','comments','datetime','series'): + db.set_custom(book_id, book['all_metadata'][meta], label=label, commit=False) + elif coldef['datatype'] in ('int','float'): + num = unicode(book['all_metadata'][meta]).replace(",","") + db.set_custom(book_id, num, label=label, commit=False) + elif coldef['datatype'] == 'bool' and meta.startswith('status-'): + if meta == 'status-C': + val = book['all_metadata']['status'] == 'Completed' + if meta == 'status-I': + val = book['all_metadata']['status'] == 'In-Progress' + db.set_custom(book_id, val, label=label, commit=False) + + adapter = None + if prefs['allow_custcol_from_ini']: + configuration = Configuration(adapters.getConfigSectionFor(book['url']),options['fileform']) + configuration.readfp(StringIO(get_resources("plugin-defaults.ini"))) + configuration.readfp(StringIO(options['personal.ini'])) + adapter = adapters.getAdapter(configuration,book['url']) + + # meta => custcol[,a|n|r] + # cliches=>\#acolumn,r + for line in adapter.getConfig('custom_columns_settings').splitlines(): + if "=>" in line: + (meta,custcol) = map( lambda x: x.strip(), line.split("=>") ) + flag='r' + if "," in custcol: + (custcol,flag) = map( lambda x: x.strip(), custcol.split(",") ) + + print("meta:(%s) => custcol:(%s), flag(%s) "%(meta,custcol,flag)) + + if meta not in book['all_metadata']: + print("No value for %s, skipping custom column(%s) update."%(meta,custcol)) + continue + + if custcol not in custom_columns: + print("No custom column(%s), skipping."%(custcol)) + continue + else: + coldef = custom_columns[custcol] + label = coldef['label'] + + if flag == 'r' or book['added']: # flag 'n' isn't actually needed--*always* set if configured and new book. + db.set_custom(book_id, book['all_metadata'][meta], label=label, commit=False) + + if flag == 'a': + vallist = [] + try: + existing=db.get_custom(book_id,label=label,index_is_id=True) + if isinstance(existing,list): + vallist = existing + elif existing: + vallist = [existing] + except: + pass + + if book['all_metadata'][meta]: + vallist = [book['all_metadata'][meta]] + + db.set_custom(book_id, ", ".join(vallist), label=label, commit=False) + + + db.commit() + + if 'Generate Cover' in self.gui.iactions and (book['added'] or not prefs['gcnewonly']): + + # force a refresh if generating cover so complex composite + # custom columns are current and correct + db.refresh_ids([book_id]) + + gc_plugin = self.gui.iactions['Generate Cover'] + setting_name = None + if prefs['allow_gc_from_ini']: + if not adapter: # might already have it from allow_custcol_from_ini + configuration = Configuration(adapters.getConfigSectionFor(book['url']),options['fileform']) + configuration.readfp(StringIO(get_resources("plugin-defaults.ini"))) + configuration.readfp(StringIO(options['personal.ini'])) + adapter = adapters.getAdapter(configuration,book['url']) + + # template => regexp to match => GC Setting to use. + # generate_cover_settings: + # ${category} => Buffy:? the Vampire Slayer => Buffy + for line in adapter.getConfig('generate_cover_settings').splitlines(): + if "=>" in line: + (template,regexp,setting) = map( lambda x: x.strip(), line.split("=>") ) + value = Template(template).safe_substitute(book['all_metadata']).encode('utf8') + # print("%s(%s) => %s => %s"%(template,value,regexp,setting)) + if re.search(regexp,value): + setting_name = setting + break + + if setting_name: + print("Generate Cover Setting from generate_cover_settings(%s)"%line) + if setting_name not in gc_plugin.get_saved_setting_names(): + print("GC Name %s not found, discarding! (check personal.ini for typos)"%setting_name) + setting_name = None + + if not setting_name and book['all_metadata']['site'] in prefs['gc_site_settings']: + setting_name = prefs['gc_site_settings'][book['all_metadata']['site']] + + if not setting_name and 'Default' in prefs['gc_site_settings']: + setting_name = prefs['gc_site_settings']['Default'] + + if setting_name: + print("Running Generate Cover with settings %s."%setting_name) + realmi = db.get_metadata(book_id, index_is_id=True) + gc_plugin.generate_cover_for_book(realmi,saved_setting_name=setting_name) + + ## if error column set. + if prefs['errorcol'] != '' and prefs['errorcol'] in custom_columns: + label = custom_columns[prefs['errorcol']]['label'] + db.set_custom(book['calibre_id'], '', label=label, commit=True) # book['comment'] + + def _get_clean_reading_lists(self,lists): + if lists == None or lists.strip() == "" : + return [] + else: + return filter( lambda x : x, map( lambda x : x.strip(), lists.split(',') ) ) + + def _update_reading_lists(self,book_ids,add=True): + try: + rl_plugin = self.gui.iactions['Reading List'] + except: + if prefs['addtolists'] or prefs['addtoreadlists']: + message="

You configured FanFictionDownLoader to automatically update Reading Lists, but you don't have the Reading List plugin installed anymore?

" + confirm(message,'fanfictiondownloader_no_reading_list_plugin', self.gui) + return + + # XXX check for existence of lists, warning if not. + if prefs['addtoreadlists']: + if add: + addremovefunc = rl_plugin.add_books_to_list + else: + addremovefunc = rl_plugin.remove_books_from_list + + lists = self._get_clean_reading_lists(prefs['read_lists']) + if len(lists) < 1 : + message="

You configured FanFictionDownLoader to automatically update \"To Read\" Reading Lists, but you don't have any lists set?

" + confirm(message,'fanfictiondownloader_no_read_lists', self.gui) + for l in lists: + if l in rl_plugin.get_list_names(): + #print("add good read l:(%s)"%l) + addremovefunc(l, + book_ids, + display_warnings=False) + else: + if l != '': + message="

You configured FanFictionDownLoader to automatically update Reading List '%s', but you don't have a list of that name?

"%l + confirm(message,'fanfictiondownloader_no_reading_list_%s'%l, self.gui) + + if prefs['addtolists'] and (add or (prefs['addtolistsonread'] and prefs['addtoreadlists']) ): + lists = self._get_clean_reading_lists(prefs['send_lists']) + if len(lists) < 1 : + message="

You configured FanFictionDownLoader to automatically update \"Send to Device\" Reading Lists, but you don't have any lists set?

" + confirm(message,'fanfictiondownloader_no_send_lists', self.gui) + + # Quick demo of how an 'allow send' list might work. + # Issues: allow list per send list? Naming convention? "send(allow)" + # allow_list = rl_plugin.get_book_list("Allow Send to Device") + # # intersection of book_ids & allow_list + # add_book_ids = list(set(book_ids) & set(allow_list)) + + for l in lists: + if l in rl_plugin.get_list_names(): + #print("good send l:(%s)"%l) + rl_plugin.add_books_to_list(l, + #add_book_ids, + book_ids, + display_warnings=False) + else: + if l != '': + message="

You configured FanFictionDownLoader to automatically update Reading List '%s', but you don't have a list of that name?

"%l + confirm(message,'fanfictiondownloader_no_reading_list_%s'%l, self.gui) + + # def _find_existing_book_id(self,db,book,matchurl=True): + # mi = MetaInformation(book["title"],book["author"]) # author is a list. + # identicalbooks = db.find_identical_books(mi) + # if matchurl: # only *really* identical if URL matches, too. + # for ib in identicalbooks: + # if self._get_story_url(db,ib) == book['url']: + # return ib + # if identicalbooks: + # return identicalbooks.pop() + # return None + + def _make_mi_from_book(self,book): + mi = MetaInformation(book['title'],book['author']) # author is a list. + mi.set_identifiers({'url':book['url']}) + mi.publisher = book['publisher'] + mi.tags = book['tags'] + #mi.languages = ['en'] # handled in _update_metadata so it can check for existing lang. + mi.pubdate = book['pubdate'] + mi.timestamp = book['timestamp'] + mi.comments = book['comments'] + mi.series = book['series'] + return mi + + + def _convert_urls_to_books(self, urls): + books = [] + uniqueurls = set() + for url in urls: + # look here for [\d,\d] at end of url, and remove? + mc = re.match(r"^(?P.*?)(?:\[(?P\d+)?(?P,)?(?P\d+)?\])?$",url) + print("url:(%s) begin:(%s) end:(%s)"%(mc.group('url'),mc.group('begin'),mc.group('end'))) + url = mc.group('url') + book = self._convert_url_to_book(url) + book['begin'] = mc.group('begin') + book['end'] = mc.group('end') + if book['begin'] and not mc.group('comma'): + book['end'] = book['begin'] + if book['url'] in uniqueurls: + book['good'] = False + book['comment'] = "Same story already included." + uniqueurls.add(book['url']) + books.append(book) + return books + + def _convert_url_to_book(self, url): + book = {} + book['good'] = True + book['calibre_id'] = None + book['title'] = 'Unknown' + book['author_sort'] = book['author'] = ['Unknown'] # list + book['begin'] = None + book['end'] = None + + book['comment'] = '' + book['url'] = '' + book['added'] = False + + self._set_book_url_and_comment(book,url) + return book + + def _convert_id_to_book(self, idval, good=True): + book = {} + book['good'] = good + book['calibre_id'] = idval + book['title'] = 'Unknown' + book['author_sort'] = book['author'] = ['Unknown'] # list + book['begin'] = None + book['end'] = None + + book['comment'] = '' + book['url'] = '' + book['added'] = False + + return book + + def _populate_book_from_calibre_id(self, book, db=None): + mi = db.get_metadata(book['calibre_id'], index_is_id=True) + #book = {} + book['good'] = True + book['calibre_id'] = mi.id + book['title'] = mi.title + book['author'] = mi.authors + book['author_sort'] = mi.author_sort + book['comment'] = '' + book['url'] = "" + book['added'] = False + + url = self._get_story_url(db,book['calibre_id']) + self._set_book_url_and_comment(book,url) + #return book + + def _set_book_url_and_comment(self,book,url): + if not url: + book['comment'] = "No story URL found." + book['good'] = False + book['icon'] = 'search_delete_saved.png' + book['status'] = 'Not Found' + else: + # get normalized url or None. + book['url'] = self._is_good_downloader_url(url) + if book['url'] == None: + book['url'] = url + book['comment'] = "URL is not a valid story URL." + book['good'] = False + book['icon']='dialog_error.png' + book['status'] = 'Bad URL' + + def _get_story_url(self, db, book_id): + identifiers = db.get_identifiers(book_id,index_is_id=True) + if 'url' in identifiers: + # identifiers have :->| in url. + #print("url from book:"+identifiers['url'].replace('|',':')) + return identifiers['url'].replace('|',':') + else: + ## only epub has URL in it--at least where I can easily find it. + if db.has_format(book_id,'EPUB',index_is_id=True): + existingepub = db.format(book_id,'EPUB',index_is_id=True, as_file=True) + mi = get_metadata(existingepub,'EPUB') + identifiers = mi.get_identifiers() + if 'url' in identifiers: + #print("url from epub:"+identifiers['url'].replace('|',':')) + return identifiers['url'].replace('|',':') + # look for dc:source first, then scan HTML if + link = get_dcsource(existingepub) + if link: + return link + elif prefs['lookforurlinhtml']: + return get_story_url_from_html(existingepub,self._is_good_downloader_url) + return None + + def _is_good_downloader_url(self,url): + # this is the accepted way to 'check for existance of a class variable'? really? + try: + self.dummyconfig + except AttributeError: + self.dummyconfig = Configuration("test1.com","EPUB") + # pulling up an adapter is pretty low over-head. If + # it fails, it's a bad url. + try: + adapter = adapters.getAdapter(self.dummyconfig,url) + url = adapter.url + del adapter + return url + except: + return None; + +def get_url_list(urls): + def f(x): + if x.strip(): return True + else: return False + # set removes dups. + return set(filter(f,urls.strip().splitlines())) + diff --git a/calibre-plugin/images/icon.png b/calibre-plugin/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..e9715307dd4fe35c686b222262b796828856ff22 GIT binary patch literal 24649 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 literal 0 HcmV?d00001 diff --git a/calibre-plugin/images/icon.xcf b/calibre-plugin/images/icon.xcf new file mode 100644 index 0000000000000000000000000000000000000000..76d7c0c9aeb81d6cf0b6df17f8bc7e6c60a59e00 GIT binary patch literal 63927 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 literal 0 HcmV?d00001 diff --git a/calibre-plugin/jobs.py b/calibre-plugin/jobs.py new file mode 100644 index 00000000..89d390f4 --- /dev/null +++ b/calibre-plugin/jobs.py @@ -0,0 +1,193 @@ +#!/usr/bin/env python +# vim:fileencoding=UTF-8:ts=4:sw=4:sta:et:sts=4:ai +from __future__ import (unicode_literals, division, absolute_import, + print_function) + +__license__ = 'GPL v3' +__copyright__ = '2012, Jim Miller' +__copyright__ = '2011, Grant Drake ' +__docformat__ = 'restructuredtext en' + +import time, os, traceback + +from StringIO import StringIO + +from calibre.utils.ipc.server import Server +from calibre.utils.ipc.job import ParallelJob + +from calibre_plugins.fanfictiondownloader_plugin.dialogs import (NotGoingToDownload, + OVERWRITE, OVERWRITEALWAYS, UPDATE, UPDATEALWAYS, ADDNEW, SKIP, CALIBREONLY) +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader import adapters, writers, exceptions +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.configurable import Configuration +from calibre_plugins.fanfictiondownloader_plugin.fanficdownloader.epubutils import get_update_data + +# ------------------------------------------------------------------------------ +# +# Functions to perform downloads using worker jobs +# +# ------------------------------------------------------------------------------ + +def do_download_worker(book_list, options, + cpus, notification=lambda x,y:x): + ''' + Master job, to launch child jobs to extract ISBN for a set of books + This is run as a worker job in the background to keep the UI more + responsive and get around the memory leak issues as it will launch + a child job for each book as a worker process + ''' + server = Server(pool_size=cpus) + + print(options['version']) + total = 0 + # Queue all the jobs + print("Adding jobs for URLs:") + for book in book_list: + if book['good']: + print("%s"%book['url']) + total += 1 + args = ['calibre_plugins.fanfictiondownloader_plugin.jobs', + 'do_download_for_worker', + (book,options)] + job = ParallelJob('arbitrary', + "url:(%s) id:(%s)"%(book['url'],book['calibre_id']), + done=None, + args=args) + job._book = book + # job._book_id = book_id + # job._title = title + # job._modified_date = modified_date + # job._existing_isbn = existing_isbn + server.add_job(job) + + # This server is an arbitrary_n job, so there is a notifier available. + # Set the % complete to a small number to avoid the 'unavailable' indicator + notification(0.01, 'Downloading FanFiction Stories') + + # dequeue the job results as they arrive, saving the results + count = 0 + while True: + job = server.changed_jobs_queue.get() + # A job can 'change' when it is not finished, for example if it + # produces a notification. Ignore these. + job.update() + if not job.is_finished: + continue + # A job really finished. Get the information. + output_book = job.result + #print("output_book:%s"%output_book) + book_list.remove(job._book) + book_list.append(job.result) + book_id = job._book['calibre_id'] + #title = job._title + count = count + 1 + notification(float(count)/total, 'Downloaded Story') + # Add this job's output to the current log + print('Logfile for book ID %s (%s)'%(book_id, job._book['title'])) + print(job.details) + + if count >= total: + # All done! Output some lists for convenience of some users. + print("Successfully downloaded:") + for book in book_list: + if book['good']: + print(book['title']) + print("\nUnsuccessful:") + for book in book_list: + if not book['good']: + print(book['title']) + break + + server.close() + + # return the book list as the job result + return book_list + +def do_download_for_worker(book,options): + ''' + Child job, to extract isbn from formats for this specific book, + when run as a worker job + ''' + try: + book['comment'] = 'Download started...' + + configuration = Configuration(adapters.getConfigSectionFor(book['url']),options['fileform']) + configuration.readfp(StringIO(get_resources("plugin-defaults.ini"))) + configuration.readfp(StringIO(options['personal.ini'])) + + if not options['updateepubcover'] and 'epub_for_update' in book and options['collision'] in (UPDATE, UPDATEALWAYS): + configuration.set("overrides","never_make_cover","true") + + # images only for epub, even if the user mistakenly turned it + # on else where. + if options['fileform'] not in ("epub","html"): + configuration.set("overrides","include_images","false") + + adapter = adapters.getAdapter(configuration,book['url']) + adapter.is_adult = book['is_adult'] + adapter.username = book['username'] + adapter.password = book['password'] + + story = adapter.getStoryMetadataOnly() + if 'calibre_series' in book: + adapter.setSeries(book['calibre_series'][0],book['calibre_series'][1]) + + # set PI version instead of default. + if 'version' in options: + story.setMetadata('version',options['version']) + + writer = writers.getWriter(options['fileform'],configuration,adapter) + + outfile = book['outfile'] + + ## No need to download at all. Shouldn't ever get down here. + if options['collision'] in (CALIBREONLY): + print("Skipping CALIBREONLY 'update' down inside worker--this shouldn't be happening...") + book['comment'] = 'Metadata collected.' + + ## checks were done earlier, it's new or not dup or newer--just write it. + elif options['collision'] in (ADDNEW, SKIP, OVERWRITE, OVERWRITEALWAYS) or \ + ('epub_for_update' not in book and options['collision'] in (UPDATE, UPDATEALWAYS)): + + adapter.setChaptersRange(book['begin'],book['end']) + + print("write to %s"%outfile) + writer.writeStory(outfilename=outfile, forceOverwrite=True) + book['comment'] = 'Download %s completed, %s chapters.'%(options['fileform'],story.getMetadata("numChapters")) + + ## checks were done earlier, just update it. + elif 'epub_for_update' in book and options['collision'] in (UPDATE, UPDATEALWAYS): + + # update now handled by pre-populating the old images and + # chapters in the adapter rather than merging epubs. + urlchaptercount = int(story.getMetadata('numChapters')) + (url, + chaptercount, + adapter.oldchapters, + adapter.oldimgs, + adapter.oldcover, + adapter.calibrebookmark, + adapter.logfile) = get_update_data(book['epub_for_update']) + + print("Do update - epub(%d) vs url(%d)" % (chaptercount, urlchaptercount)) + print("write to %s"%outfile) + + writer.writeStory(outfilename=outfile, forceOverwrite=True) + + book['comment'] = 'Update %s completed, added %s chapters for %s total.'%\ + (options['fileform'],(urlchaptercount-chaptercount),urlchaptercount) + + except NotGoingToDownload as d: + book['good']=False + book['comment']=unicode(d) + book['icon'] = d.icon + + except Exception as e: + book['good']=False + book['comment']=unicode(e) + book['icon']='dialog_error.png' + book['status'] = 'Error' + print("Exception: %s:%s"%(book,unicode(e))) + traceback.print_exc() + + #time.sleep(10) + return book diff --git a/calibre-plugin/plugin-import-name-fanfictiondownloader_plugin.txt b/calibre-plugin/plugin-import-name-fanfictiondownloader_plugin.txt new file mode 100644 index 00000000..e69de29b diff --git a/cron.yaml b/cron.yaml new file mode 100644 index 00000000..e72999f4 --- /dev/null +++ b/cron.yaml @@ -0,0 +1,10 @@ +cron: +- description: cleanup job + url: /r3m0v3r + schedule: every 2 hours + +# There's a bug in the Python 2.7 runtime that prevents this from +# working properly. In theory, there should never be orphans anyway. +#- description: orphan cleanup job +# url: /r3m0v3rOrphans +# schedule: every 4 hours diff --git a/css/index.css b/css/index.css new file mode 100644 index 00000000..eae546b7 --- /dev/null +++ b/css/index.css @@ -0,0 +1,73 @@ +body +{ + font: 0.9em "Helvetica Neue", Arial, Helvetica, Geneva, sans-serif; +} + +#main +{ + width: 60%; + margin-left: 20%; + background-color: #dae6ff; + padding: 2em; +} + +#greeting +{ +# margin-bottom: 1em; + border-color: #efefef; +} + + + +#logpassword:hover, #logpasswordtable:hover, #urlbox:hover, #typebox:hover, #helpbox:hover, #yourfile:hover +{ + border: thin solid #fffeff; +} + +h1 +{ + text-decoration: none; +} + +#logpasswordtable +{ + padding: 1em; +} + +#logpassword, #logpasswordtable { +// display: none; +} + +#urlbox, #typebox, #logpasswordtable, #logpassword, #helpbox, #yourfile +{ + margin: 1em; + padding: 1em; + border: thin dotted #fffeff; +} + +div.field +{ + margin-bottom: 0.5em; +} + +#submitbtn +{ + padding: 1em; +} + +#typelabel +{ +} + +#typeoptions +{ + margin-top: 0.5em; +} + +#error +{ + color: #f00; +} +.recent { + font-size: large; +} diff --git a/defaults.ini b/defaults.ini new file mode 100644 index 00000000..7797f347 --- /dev/null +++ b/defaults.ini @@ -0,0 +1,1218 @@ +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +[defaults] + +## [defaults] section applies to all formats and sites but may be +## overridden at several levels + +## Some sites also require the user to confirm they are adult for +## adult content. Uncomment by removing '#' in front of is_adult. +#is_adult:true + +## All available titlepage_entries and the label used for them: +## _label:
+

+ FanFictionDownLoader +

+ +
+ + +
+ +
+ +
+

Edit Config

+
+ Editing configuration for {{ nickname }}. +
+
+ +
+
+ +
+ +
+
+ +
+

Default System configuration

+
+{{ defaultsini }}
+
+
+ +
+ Powered by Google App Engine +

+ This is a web front-end to FanFictionDownLoader
+ Copyright © Fanficdownloader team +
+ +
+ + +
+
+ + diff --git a/epubmerge.py b/epubmerge.py new file mode 100644 index 00000000..f7e76b8c --- /dev/null +++ b/epubmerge.py @@ -0,0 +1,25 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# epubmerge.py 1.0 + +# Copyright 2011, Jim Miller + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +if __name__ == "__main__": + print(''' +The this utility has been split out into it's own project. +See: http://code.google.com/p/epubmerge/ +...for a CLI epubmerge.py program and calibre plugin. +''') diff --git a/example.ini b/example.ini new file mode 100644 index 00000000..13e4a854 --- /dev/null +++ b/example.ini @@ -0,0 +1,103 @@ +## This is an example of what your personal configuration might look +## like. Uncomment options by removing the '#' in front of them. + +[defaults] +## Some sites also require the user to confirm they are adult for +## adult content. Uncomment by removing '#' in front of is_adult. In +## commandline version, this should go in your personal.ini, not +## defaults.ini. +#is_adult:true + +## Don't like the numbers at the start of chapter titles on some +## sites? You can use strip_chapter_numbers to strip them off. Just +## want to make them all look the same? Strip them off, then add them +## back on with add_chapter_numbers. Don't like the way it strips +## numbers or adds them back? See chapter_title_strip_pattern and +## chapter_title_add_pattern. +#strip_chapter_numbers:true +#add_chapter_numbers:true + +[epub] +## include images from img tags in the body and summary of stories. +## Images will be converted to jpg for size if possible. Images work +## in epub format only. To get mobi or other format with images, +## download as epub and use Calibre to convert. +#include_images:true + +## If not set, the summary will have all html stripped for safety. +## Both this and include_images must be true to get images in the +## summary. +#keep_summary_html:true + +## If set, the first image found will be made the cover image. If +## keep_summary_html is true, any images in summary will be before any +## in chapters. +#make_firstimage_cover:true + +## Resize images down to width, height, preserving aspect ratio. +## Nook size, with margin. +#image_max_size: 580, 725 + +## Change image to grayscale, if graphics library allows, to save +## space. +#grayscale_images: false + + +## Most common, I expect will be using this to save username/passwords +## for different sites. Here are a few examples. See defaults.ini +## for the full list. + +[www.twilighted.net] +#username:YourPenname +#password:YourPassword +## default is false +#collect_series: true + +[www.ficwad.com] +#username:YourUsername +#password:YourPassword + +[www.twiwrite.net] +#username:YourName +#password:yourpassword +## default is false +#collect_series: true + +[www.adastrafanfic.com] +## Some sites do not require a login, but do require the user to +## confirm they are adult for adult content. +#is_adult:true + +[www.thewriterscoffeeshop.com] +#username:YourName +#password:yourpassword +#is_adult:true +## default is false +#collect_series: true + +[www.fictionalley.org] +#is_adult:true + +[www.harrypotterfanfiction.com] +#is_adult:true + +[www.fimfiction.net] +#is_adult:true +#fail_on_password: false + +[www.tthfanfic.org] +#is_adult:true +## tth is a little unusual--it doesn't require user/pass, but the site +## keeps track of which chapters you've read and won't send another +## update until it thinks you're up to date. This way, on download, +## it thinks you're up to date. +#username:YourName +#password:yourpassword + + +## This section will override anything in the system defaults or other +## sections here. +[overrides] +## default varies by site. Set true here to force all sites to +## collect series. +#collect_series: true diff --git a/fanficdownloader/BeautifulSoup.py b/fanficdownloader/BeautifulSoup.py new file mode 100644 index 00000000..4b17b853 --- /dev/null +++ b/fanficdownloader/BeautifulSoup.py @@ -0,0 +1,2014 @@ +"""Beautiful Soup +Elixir and Tonic +"The Screen-Scraper's Friend" +http://www.crummy.com/software/BeautifulSoup/ + +Beautiful Soup parses a (possibly invalid) XML or HTML document into a +tree representation. It provides methods and Pythonic idioms that make +it easy to navigate, search, and modify the tree. + +A well-formed XML/HTML document yields a well-formed data +structure. An ill-formed XML/HTML document yields a correspondingly +ill-formed data structure. If your document is only locally +well-formed, you can use this library to find and process the +well-formed part of it. + +Beautiful Soup works with Python 2.2 and up. It has no external +dependencies, but you'll have more success at converting data to UTF-8 +if you also install these three packages: + +* chardet, for auto-detecting character encodings + http://chardet.feedparser.org/ +* cjkcodecs and iconv_codec, which add more encodings to the ones supported + by stock Python. + http://cjkpython.i18n.org/ + +Beautiful Soup defines classes for two main parsing strategies: + + * BeautifulStoneSoup, for parsing XML, SGML, or your domain-specific + language that kind of looks like XML. + + * BeautifulSoup, for parsing run-of-the-mill HTML code, be it valid + or invalid. This class has web browser-like heuristics for + obtaining a sensible parse tree in the face of common HTML errors. + +Beautiful Soup also defines a class (UnicodeDammit) for autodetecting +the encoding of an HTML or XML document, and converting it to +Unicode. Much of this code is taken from Mark Pilgrim's Universal Feed Parser. + +For more than you ever wanted to know about Beautiful Soup, see the +documentation: +http://www.crummy.com/software/BeautifulSoup/documentation.html + +Here, have some legalese: + +Copyright (c) 2004-2010, Leonard Richardson + +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the the Beautiful Soup Consortium and All + Night Kosher Bakery nor the names of its contributors may be + used to endorse or promote products derived from this software + without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR +CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE, DAMMIT. + +""" +from __future__ import generators + +__author__ = "Leonard Richardson (leonardr@segfault.org)" +__version__ = "3.2.0" +__copyright__ = "Copyright (c) 2004-2010 Leonard Richardson" +__license__ = "New-style BSD" + +from sgmllib import SGMLParser, SGMLParseError +import codecs +import markupbase +import types +import re +import sgmllib +try: + from htmlentitydefs import name2codepoint +except ImportError: + name2codepoint = {} +try: + set +except NameError: + from sets import Set as set + +#These hacks make Beautiful Soup able to parse XML with namespaces +sgmllib.tagfind = re.compile('[a-zA-Z][-_.:a-zA-Z0-9]*') +markupbase._declname_match = re.compile(r'[a-zA-Z][-_.:a-zA-Z0-9]*\s*').match + +DEFAULT_OUTPUT_ENCODING = "utf-8" + +def _match_css_class(str): + """Build a RE to match the given CSS class.""" + return re.compile(r"(^|.*\s)%s($|\s)" % str) + +# First, the classes that represent markup elements. + +class PageElement(object): + """Contains the navigational information for some part of the page + (either a tag or a piece of text)""" + + def setup(self, parent=None, previous=None): + """Sets up the initial relations between this element and + other elements.""" + self.parent = parent + self.previous = previous + self.next = None + self.previousSibling = None + self.nextSibling = None + if self.parent and self.parent.contents: + self.previousSibling = self.parent.contents[-1] + self.previousSibling.nextSibling = self + + def replaceWith(self, replaceWith): + oldParent = self.parent + myIndex = self.parent.index(self) + if hasattr(replaceWith, "parent")\ + and replaceWith.parent is self.parent: + # We're replacing this element with one of its siblings. + index = replaceWith.parent.index(replaceWith) + if index and index < myIndex: + # Furthermore, it comes before this element. That + # means that when we extract it, the index of this + # element will change. + myIndex = myIndex - 1 + self.extract() + oldParent.insert(myIndex, replaceWith) + + def replaceWithChildren(self): + myParent = self.parent + myIndex = self.parent.index(self) + self.extract() + reversedChildren = list(self.contents) + reversedChildren.reverse() + for child in reversedChildren: + myParent.insert(myIndex, child) + + def extract(self): + """Destructively rips this element out of the tree.""" + if self.parent: + try: + del self.parent.contents[self.parent.index(self)] + except ValueError: + pass + + #Find the two elements that would be next to each other if + #this element (and any children) hadn't been parsed. Connect + #the two. + lastChild = self._lastRecursiveChild() + nextElement = lastChild.next + + if self.previous: + self.previous.next = nextElement + if nextElement: + nextElement.previous = self.previous + self.previous = None + lastChild.next = None + + self.parent = None + if self.previousSibling: + self.previousSibling.nextSibling = self.nextSibling + if self.nextSibling: + self.nextSibling.previousSibling = self.previousSibling + self.previousSibling = self.nextSibling = None + return self + + def _lastRecursiveChild(self): + "Finds the last element beneath this object to be parsed." + lastChild = self + while hasattr(lastChild, 'contents') and lastChild.contents: + lastChild = lastChild.contents[-1] + return lastChild + + def insert(self, position, newChild): + if isinstance(newChild, basestring) \ + and not isinstance(newChild, NavigableString): + newChild = NavigableString(newChild) + + position = min(position, len(self.contents)) + if hasattr(newChild, 'parent') and newChild.parent is not None: + # We're 'inserting' an element that's already one + # of this object's children. + if newChild.parent is self: + index = self.index(newChild) + if index > position: + # Furthermore we're moving it further down the + # list of this object's children. That means that + # when we extract this element, our target index + # will jump down one. + position = position - 1 + newChild.extract() + + newChild.parent = self + previousChild = None + if position == 0: + newChild.previousSibling = None + newChild.previous = self + else: + previousChild = self.contents[position-1] + newChild.previousSibling = previousChild + newChild.previousSibling.nextSibling = newChild + newChild.previous = previousChild._lastRecursiveChild() + if newChild.previous: + newChild.previous.next = newChild + + newChildsLastElement = newChild._lastRecursiveChild() + + if position >= len(self.contents): + newChild.nextSibling = None + + parent = self + parentsNextSibling = None + while not parentsNextSibling: + parentsNextSibling = parent.nextSibling + parent = parent.parent + if not parent: # This is the last element in the document. + break + if parentsNextSibling: + newChildsLastElement.next = parentsNextSibling + else: + newChildsLastElement.next = None + else: + nextChild = self.contents[position] + newChild.nextSibling = nextChild + if newChild.nextSibling: + newChild.nextSibling.previousSibling = newChild + newChildsLastElement.next = nextChild + + if newChildsLastElement.next: + newChildsLastElement.next.previous = newChildsLastElement + self.contents.insert(position, newChild) + + def append(self, tag): + """Appends the given tag to the contents of this tag.""" + self.insert(len(self.contents), tag) + + def findNext(self, name=None, attrs={}, text=None, **kwargs): + """Returns the first item that matches the given criteria and + appears after this Tag in the document.""" + return self._findOne(self.findAllNext, name, attrs, text, **kwargs) + + def findAllNext(self, name=None, attrs={}, text=None, limit=None, + **kwargs): + """Returns all items that match the given criteria and appear + after this Tag in the document.""" + return self._findAll(name, attrs, text, limit, self.nextGenerator, + **kwargs) + + def findNextSibling(self, name=None, attrs={}, text=None, **kwargs): + """Returns the closest sibling to this Tag that matches the + given criteria and appears after this Tag in the document.""" + return self._findOne(self.findNextSiblings, name, attrs, text, + **kwargs) + + def findNextSiblings(self, name=None, attrs={}, text=None, limit=None, + **kwargs): + """Returns the siblings of this Tag that match the given + criteria and appear after this Tag in the document.""" + return self._findAll(name, attrs, text, limit, + self.nextSiblingGenerator, **kwargs) + fetchNextSiblings = findNextSiblings # Compatibility with pre-3.x + + def findPrevious(self, name=None, attrs={}, text=None, **kwargs): + """Returns the first item that matches the given criteria and + appears before this Tag in the document.""" + return self._findOne(self.findAllPrevious, name, attrs, text, **kwargs) + + def findAllPrevious(self, name=None, attrs={}, text=None, limit=None, + **kwargs): + """Returns all items that match the given criteria and appear + before this Tag in the document.""" + return self._findAll(name, attrs, text, limit, self.previousGenerator, + **kwargs) + fetchPrevious = findAllPrevious # Compatibility with pre-3.x + + def findPreviousSibling(self, name=None, attrs={}, text=None, **kwargs): + """Returns the closest sibling to this Tag that matches the + given criteria and appears before this Tag in the document.""" + return self._findOne(self.findPreviousSiblings, name, attrs, text, + **kwargs) + + def findPreviousSiblings(self, name=None, attrs={}, text=None, + limit=None, **kwargs): + """Returns the siblings of this Tag that match the given + criteria and appear before this Tag in the document.""" + return self._findAll(name, attrs, text, limit, + self.previousSiblingGenerator, **kwargs) + fetchPreviousSiblings = findPreviousSiblings # Compatibility with pre-3.x + + def findParent(self, name=None, attrs={}, **kwargs): + """Returns the closest parent of this Tag that matches the given + criteria.""" + # NOTE: We can't use _findOne because findParents takes a different + # set of arguments. + r = None + l = self.findParents(name, attrs, 1) + if l: + r = l[0] + return r + + def findParents(self, name=None, attrs={}, limit=None, **kwargs): + """Returns the parents of this Tag that match the given + criteria.""" + + return self._findAll(name, attrs, None, limit, self.parentGenerator, + **kwargs) + fetchParents = findParents # Compatibility with pre-3.x + + #These methods do the real heavy lifting. + + def _findOne(self, method, name, attrs, text, **kwargs): + r = None + l = method(name, attrs, text, 1, **kwargs) + if l: + r = l[0] + return r + + def _findAll(self, name, attrs, text, limit, generator, **kwargs): + "Iterates over a generator looking for things that match." + + if isinstance(name, SoupStrainer): + strainer = name + # (Possibly) special case some findAll*(...) searches + elif text is None and not limit and not attrs and not kwargs: + # findAll*(True) + if name is True: + return [element for element in generator() + if isinstance(element, Tag)] + # findAll*('tag-name') + elif isinstance(name, basestring): + return [element for element in generator() + if isinstance(element, Tag) and + element.name == name] + else: + strainer = SoupStrainer(name, attrs, text, **kwargs) + # Build a SoupStrainer + else: + strainer = SoupStrainer(name, attrs, text, **kwargs) + results = ResultSet(strainer) + g = generator() + while True: + try: + i = g.next() + except StopIteration: + break + if i: + found = strainer.search(i) + if found: + results.append(found) + if limit and len(results) >= limit: + break + return results + + #These Generators can be used to navigate starting from both + #NavigableStrings and Tags. + def nextGenerator(self): + i = self + while i is not None: + i = i.next + yield i + + def nextSiblingGenerator(self): + i = self + while i is not None: + i = i.nextSibling + yield i + + def previousGenerator(self): + i = self + while i is not None: + i = i.previous + yield i + + def previousSiblingGenerator(self): + i = self + while i is not None: + i = i.previousSibling + yield i + + def parentGenerator(self): + i = self + while i is not None: + i = i.parent + yield i + + # Utility methods + def substituteEncoding(self, str, encoding=None): + encoding = encoding or "utf-8" + return str.replace("%SOUP-ENCODING%", encoding) + + def toEncoding(self, s, encoding=None): + """Encodes an object to a string in some encoding, or to Unicode. + .""" + if isinstance(s, unicode): + if encoding: + s = s.encode(encoding) + elif isinstance(s, str): + if encoding: + s = s.encode(encoding) + else: + s = unicode(s) + else: + if encoding: + s = self.toEncoding(str(s), encoding) + else: + s = unicode(s) + return s + +class NavigableString(unicode, PageElement): + + def __new__(cls, value): + """Create a new NavigableString. + + When unpickling a NavigableString, this method is called with + the string in DEFAULT_OUTPUT_ENCODING. That encoding needs to be + passed in to the superclass's __new__ or the superclass won't know + how to handle non-ASCII characters. + """ + if isinstance(value, unicode): + return unicode.__new__(cls, value) + return unicode.__new__(cls, value, DEFAULT_OUTPUT_ENCODING) + + def __getnewargs__(self): + return (NavigableString.__str__(self),) + + def __getattr__(self, attr): + """text.string gives you text. This is for backwards + compatibility for Navigable*String, but for CData* it lets you + get the string without the CData wrapper.""" + if attr == 'string': + return self + else: + raise AttributeError, "'%s' object has no attribute '%s'" % (self.__class__.__name__, attr) + + def __unicode__(self): + return str(self).decode(DEFAULT_OUTPUT_ENCODING) + + def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): + if encoding: + return self.encode(encoding) + else: + return self + +class CData(NavigableString): + + def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): + return "" % NavigableString.__str__(self, encoding) + +class ProcessingInstruction(NavigableString): + def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): + output = self + if "%SOUP-ENCODING%" in output: + output = self.substituteEncoding(output, encoding) + return "" % self.toEncoding(output, encoding) + +class Comment(NavigableString): + def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): + return "" % NavigableString.__str__(self, encoding) + +class Declaration(NavigableString): + def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING): + return "" % NavigableString.__str__(self, encoding) + +class Tag(PageElement): + + """Represents a found HTML tag with its attributes and contents.""" + + def _invert(h): + "Cheap function to invert a hash." + i = {} + for k,v in h.items(): + i[v] = k + return i + + XML_ENTITIES_TO_SPECIAL_CHARS = { "apos" : "'", + "quot" : '"', + "amp" : "&", + "lt" : "<", + "gt" : ">" } + + XML_SPECIAL_CHARS_TO_ENTITIES = _invert(XML_ENTITIES_TO_SPECIAL_CHARS) + + def _convertEntities(self, match): + """Used in a call to re.sub to replace HTML, XML, and numeric + entities with the appropriate Unicode characters. If HTML + entities are being converted, any unrecognized entities are + escaped.""" + x = match.group(1) + if self.convertHTMLEntities and x in name2codepoint: + return unichr(name2codepoint[x]) + elif x in self.XML_ENTITIES_TO_SPECIAL_CHARS: + if self.convertXMLEntities: + return self.XML_ENTITIES_TO_SPECIAL_CHARS[x] + else: + return u'&%s;' % x + elif len(x) > 0 and x[0] == '#': + # Handle numeric entities + if len(x) > 1 and x[1] == 'x': + return unichr(int(x[2:], 16)) + else: + return unichr(int(x[1:])) + + elif self.escapeUnrecognizedEntities: + return u'&%s;' % x + else: + return u'&%s;' % x + + def __init__(self, parser, name, attrs=None, parent=None, + previous=None): + "Basic constructor." + + # We don't actually store the parser object: that lets extracted + # chunks be garbage-collected + self.parserClass = parser.__class__ + self.isSelfClosing = parser.isSelfClosingTag(name) + self.name = name + if attrs is None: + attrs = [] + elif isinstance(attrs, dict): + attrs = attrs.items() + self.attrs = attrs + self.contents = [] + self.setup(parent, previous) + self.hidden = False + self.containsSubstitutions = False + self.convertHTMLEntities = parser.convertHTMLEntities + self.convertXMLEntities = parser.convertXMLEntities + self.escapeUnrecognizedEntities = parser.escapeUnrecognizedEntities + + # Convert any HTML, XML, or numeric entities in the attribute values. + convert = lambda(k, val): (k, + re.sub("&(#\d+|#x[0-9a-fA-F]+|\w+);", + self._convertEntities, + val)) + self.attrs = map(convert, self.attrs) + + def getString(self): + if (len(self.contents) == 1 + and isinstance(self.contents[0], NavigableString)): + return self.contents[0] + + def setString(self, string): + """Replace the contents of the tag with a string""" + self.clear() + self.append(string) + + string = property(getString, setString) + + def getText(self, separator=u""): + if not len(self.contents): + return u"" + stopNode = self._lastRecursiveChild().next + strings = [] + current = self.contents[0] + while current is not stopNode: + if isinstance(current, NavigableString): + strings.append(current.strip()) + current = current.next + return separator.join(strings) + + text = property(getText) + + def get(self, key, default=None): + """Returns the value of the 'key' attribute for the tag, or + the value given for 'default' if it doesn't have that + attribute.""" + return self._getAttrMap().get(key, default) + + def clear(self): + """Extract all children.""" + for child in self.contents[:]: + child.extract() + + def index(self, element): + for i, child in enumerate(self.contents): + if child is element: + return i + raise ValueError("Tag.index: element not in tag") + + def has_key(self, key): + return self._getAttrMap().has_key(key) + + def __getitem__(self, key): + """tag[key] returns the value of the 'key' attribute for the tag, + and throws an exception if it's not there.""" + return self._getAttrMap()[key] + + def __iter__(self): + "Iterating over a tag iterates over its contents." + return iter(self.contents) + + def __len__(self): + "The length of a tag is the length of its list of contents." + return len(self.contents) + + def __contains__(self, x): + return x in self.contents + + def __nonzero__(self): + "A tag is non-None even if it has no contents." + return True + + def __setitem__(self, key, value): + """Setting tag[key] sets the value of the 'key' attribute for the + tag.""" + self._getAttrMap() + self.attrMap[key] = value + found = False + for i in range(0, len(self.attrs)): + if self.attrs[i][0] == key: + self.attrs[i] = (key, value) + found = True + if not found: + self.attrs.append((key, value)) + self._getAttrMap()[key] = value + + def __delitem__(self, key): + "Deleting tag[key] deletes all 'key' attributes for the tag." + for item in self.attrs: + if item[0] == key: + self.attrs.remove(item) + #We don't break because bad HTML can define the same + #attribute multiple times. + self._getAttrMap() + if self.attrMap.has_key(key): + del self.attrMap[key] + + def __call__(self, *args, **kwargs): + """Calling a tag like a function is the same as calling its + findAll() method. Eg. tag('a') returns a list of all the A tags + found within this tag.""" + return apply(self.findAll, args, kwargs) + + def __getattr__(self, tag): + #print "Getattr %s.%s" % (self.__class__, tag) + if len(tag) > 3 and tag.rfind('Tag') == len(tag)-3: + return self.find(tag[:-3]) + elif tag.find('__') != 0: + return self.find(tag) + raise AttributeError, "'%s' object has no attribute '%s'" % (self.__class__, tag) + + def __eq__(self, other): + """Returns true iff this tag has the same name, the same attributes, + and the same contents (recursively) as the given tag. + + NOTE: right now this will return false if two tags have the + same attributes in a different order. Should this be fixed?""" + if other is self: + return True + if not hasattr(other, 'name') or not hasattr(other, 'attrs') or not hasattr(other, 'contents') or self.name != other.name or self.attrs != other.attrs or len(self) != len(other): + return False + for i in range(0, len(self.contents)): + if self.contents[i] != other.contents[i]: + return False + return True + + def __ne__(self, other): + """Returns true iff this tag is not identical to the other tag, + as defined in __eq__.""" + return not self == other + + def __repr__(self, encoding=DEFAULT_OUTPUT_ENCODING): + """Renders this tag as a string.""" + return self.__str__(encoding) + + def __unicode__(self): + return self.__str__(None) + + BARE_AMPERSAND_OR_BRACKET = re.compile("([<>]|" + + "&(?!#\d+;|#x[0-9a-fA-F]+;|\w+;)" + + ")") + + def _sub_entity(self, x): + """Used with a regular expression to substitute the + appropriate XML entity for an XML special character.""" + return "&" + self.XML_SPECIAL_CHARS_TO_ENTITIES[x.group(0)[0]] + ";" + + def __str__(self, encoding=DEFAULT_OUTPUT_ENCODING, + prettyPrint=False, indentLevel=0): + """Returns a string or Unicode representation of this tag and + its contents. To get Unicode, pass None for encoding. + + NOTE: since Python's HTML parser consumes whitespace, this + method is not certain to reproduce the whitespace present in + the original string.""" + + encodedName = self.toEncoding(self.name, encoding) + + attrs = [] + if self.attrs: + for key, val in self.attrs: + fmt = '%s="%s"' + if isinstance(val, basestring): + if self.containsSubstitutions and '%SOUP-ENCODING%' in val: + val = self.substituteEncoding(val, encoding) + + # The attribute value either: + # + # * Contains no embedded double quotes or single quotes. + # No problem: we enclose it in double quotes. + # * Contains embedded single quotes. No problem: + # double quotes work here too. + # * Contains embedded double quotes. No problem: + # we enclose it in single quotes. + # * Embeds both single _and_ double quotes. This + # can't happen naturally, but it can happen if + # you modify an attribute value after parsing + # the document. Now we have a bit of a + # problem. We solve it by enclosing the + # attribute in single quotes, and escaping any + # embedded single quotes to XML entities. + if '"' in val: + fmt = "%s='%s'" + if "'" in val: + # TODO: replace with apos when + # appropriate. + val = val.replace("'", "&squot;") + + # Now we're okay w/r/t quotes. But the attribute + # value might also contain angle brackets, or + # ampersands that aren't part of entities. We need + # to escape those to XML entities too. + val = self.BARE_AMPERSAND_OR_BRACKET.sub(self._sub_entity, val) + + attrs.append(fmt % (self.toEncoding(key, encoding), + self.toEncoding(val, encoding))) + close = '' + closeTag = '' + if self.isSelfClosing: + close = ' /' + else: + closeTag = '' % encodedName + + indentTag, indentContents = 0, 0 + if prettyPrint: + indentTag = indentLevel + space = (' ' * (indentTag-1)) + indentContents = indentTag + 1 + contents = self.renderContents(encoding, prettyPrint, indentContents) + if self.hidden: + s = contents + else: + s = [] + attributeString = '' + if attrs: + attributeString = ' ' + ' '.join(attrs) + if prettyPrint: + s.append(space) + s.append('<%s%s%s>' % (encodedName, attributeString, close)) + if prettyPrint: + s.append("\n") + s.append(contents) + if prettyPrint and contents and contents[-1] != "\n": + s.append("\n") + if prettyPrint and closeTag: + s.append(space) + s.append(closeTag) + if prettyPrint and closeTag and self.nextSibling: + s.append("\n") + s = ''.join(s) + return s + + def decompose(self): + """Recursively destroys the contents of this tree.""" + self.extract() + if len(self.contents) == 0: + return + current = self.contents[0] + while current is not None: + next = current.next + if isinstance(current, Tag): + del current.contents[:] + current.parent = None + current.previous = None + current.previousSibling = None + current.next = None + current.nextSibling = None + current = next + + def prettify(self, encoding=DEFAULT_OUTPUT_ENCODING): + return self.__str__(encoding, True) + + def renderContents(self, encoding=DEFAULT_OUTPUT_ENCODING, + prettyPrint=False, indentLevel=0): + """Renders the contents of this tag as a string in the given + encoding. If encoding is None, returns a Unicode string..""" + s=[] + for c in self: + text = None + if isinstance(c, NavigableString): + text = c.__str__(encoding) + elif isinstance(c, Tag): + s.append(c.__str__(encoding, prettyPrint, indentLevel)) + if text and prettyPrint: + text = text.strip() + if text: + if prettyPrint: + s.append(" " * (indentLevel-1)) + s.append(text) + if prettyPrint: + s.append("\n") + return ''.join(s) + + #Soup methods + + def find(self, name=None, attrs={}, recursive=True, text=None, + **kwargs): + """Return only the first child of this Tag matching the given + criteria.""" + r = None + l = self.findAll(name, attrs, recursive, text, 1, **kwargs) + if l: + r = l[0] + return r + findChild = find + + def findAll(self, name=None, attrs={}, recursive=True, text=None, + limit=None, **kwargs): + """Extracts a list of Tag objects that match the given + criteria. You can specify the name of the Tag and any + attributes you want the Tag to have. + + The value of a key-value pair in the 'attrs' map can be a + string, a list of strings, a regular expression object, or a + callable that takes a string and returns whether or not the + string matches for some custom definition of 'matches'. The + same is true of the tag name.""" + generator = self.recursiveChildGenerator + if not recursive: + generator = self.childGenerator + return self._findAll(name, attrs, text, limit, generator, **kwargs) + findChildren = findAll + + # Pre-3.x compatibility methods + first = find + fetch = findAll + + def fetchText(self, text=None, recursive=True, limit=None): + return self.findAll(text=text, recursive=recursive, limit=limit) + + def firstText(self, text=None, recursive=True): + return self.find(text=text, recursive=recursive) + + #Private methods + + def _getAttrMap(self): + """Initializes a map representation of this tag's attributes, + if not already initialized.""" + if not getattr(self, 'attrMap'): + self.attrMap = {} + for (key, value) in self.attrs: + self.attrMap[key] = value + return self.attrMap + + #Generator methods + def childGenerator(self): + # Just use the iterator from the contents + return iter(self.contents) + + def recursiveChildGenerator(self): + if not len(self.contents): + raise StopIteration + stopNode = self._lastRecursiveChild().next + current = self.contents[0] + while current is not stopNode: + yield current + current = current.next + + +# Next, a couple classes to represent queries and their results. +class SoupStrainer: + """Encapsulates a number of ways of matching a markup element (tag or + text).""" + + def __init__(self, name=None, attrs={}, text=None, **kwargs): + self.name = name + if isinstance(attrs, basestring): + kwargs['class'] = _match_css_class(attrs) + attrs = None + if kwargs: + if attrs: + attrs = attrs.copy() + attrs.update(kwargs) + else: + attrs = kwargs + self.attrs = attrs + self.text = text + + def __str__(self): + if self.text: + return self.text + else: + return "%s|%s" % (self.name, self.attrs) + + def searchTag(self, markupName=None, markupAttrs={}): + found = None + markup = None + if isinstance(markupName, Tag): + markup = markupName + markupAttrs = markup + callFunctionWithTagData = callable(self.name) \ + and not isinstance(markupName, Tag) + + if (not self.name) \ + or callFunctionWithTagData \ + or (markup and self._matches(markup, self.name)) \ + or (not markup and self._matches(markupName, self.name)): + if callFunctionWithTagData: + match = self.name(markupName, markupAttrs) + else: + match = True + markupAttrMap = None + for attr, matchAgainst in self.attrs.items(): + if not markupAttrMap: + if hasattr(markupAttrs, 'get'): + markupAttrMap = markupAttrs + else: + markupAttrMap = {} + for k,v in markupAttrs: + markupAttrMap[k] = v + attrValue = markupAttrMap.get(attr) + if not self._matches(attrValue, matchAgainst): + match = False + break + if match: + if markup: + found = markup + else: + found = markupName + return found + + def search(self, markup): + #print 'looking for %s in %s' % (self, markup) + found = None + # If given a list of items, scan it for a text element that + # matches. + if hasattr(markup, "__iter__") \ + and not isinstance(markup, Tag): + for element in markup: + if isinstance(element, NavigableString) \ + and self.search(element): + found = element + break + # If it's a Tag, make sure its name or attributes match. + # Don't bother with Tags if we're searching for text. + elif isinstance(markup, Tag): + if not self.text: + found = self.searchTag(markup) + # If it's text, make sure the text matches. + elif isinstance(markup, NavigableString) or \ + isinstance(markup, basestring): + if self._matches(markup, self.text): + found = markup + else: + raise Exception, "I don't know how to match against a %s" \ + % markup.__class__ + return found + + def _matches(self, markup, matchAgainst): + #print "Matching %s against %s" % (markup, matchAgainst) + result = False + if matchAgainst is True: + result = markup is not None + elif callable(matchAgainst): + result = matchAgainst(markup) + else: + #Custom match methods take the tag as an argument, but all + #other ways of matching match the tag name as a string. + if isinstance(markup, Tag): + markup = markup.name + if markup and not isinstance(markup, basestring): + markup = unicode(markup) + #Now we know that chunk is either a string, or None. + if hasattr(matchAgainst, 'match'): + # It's a regexp object. + result = markup and matchAgainst.search(markup) + elif hasattr(matchAgainst, '__iter__'): # list-like + result = markup in matchAgainst + elif hasattr(matchAgainst, 'items'): + result = markup.has_key(matchAgainst) + elif matchAgainst and isinstance(markup, basestring): + if isinstance(markup, unicode): + matchAgainst = unicode(matchAgainst) + else: + matchAgainst = str(matchAgainst) + + if not result: + result = matchAgainst == markup + return result + +class ResultSet(list): + """A ResultSet is just a list that keeps track of the SoupStrainer + that created it.""" + def __init__(self, source): + list.__init__([]) + self.source = source + +# Now, some helper functions. + +def buildTagMap(default, *args): + """Turns a list of maps, lists, or scalars into a single map. + Used to build the SELF_CLOSING_TAGS, NESTABLE_TAGS, and + NESTING_RESET_TAGS maps out of lists and partial maps.""" + built = {} + for portion in args: + if hasattr(portion, 'items'): + #It's a map. Merge it. + for k,v in portion.items(): + built[k] = v + elif hasattr(portion, '__iter__'): # is a list + #It's a list. Map each item to the default. + for k in portion: + built[k] = default + else: + #It's a scalar. Map it to the default. + built[portion] = default + return built + +# Now, the parser classes. + +class BeautifulStoneSoup(Tag, SGMLParser): + + """This class contains the basic parser and search code. It defines + a parser that knows nothing about tag behavior except for the + following: + + You can't close a tag without closing all the tags it encloses. + That is, "" actually means + "". + + [Another possible explanation is "", but since + this class defines no SELF_CLOSING_TAGS, it will never use that + explanation.] + + This class is useful for parsing XML or made-up markup languages, + or when BeautifulSoup makes an assumption counter to what you were + expecting.""" + + SELF_CLOSING_TAGS = {} + NESTABLE_TAGS = {} + RESET_NESTING_TAGS = {} + QUOTE_TAGS = {} + PRESERVE_WHITESPACE_TAGS = [] + + MARKUP_MASSAGE = [(re.compile('(<[^<>]*)/>'), + lambda x: x.group(1) + ' />'), + (re.compile(']*)>'), + lambda x: '') + ] + + ROOT_TAG_NAME = u'[document]' + + HTML_ENTITIES = "html" + XML_ENTITIES = "xml" + XHTML_ENTITIES = "xhtml" + # TODO: This only exists for backwards-compatibility + ALL_ENTITIES = XHTML_ENTITIES + + # Used when determining whether a text node is all whitespace and + # can be replaced with a single space. A text node that contains + # fancy Unicode spaces (usually non-breaking) should be left + # alone. + STRIP_ASCII_SPACES = { 9: None, 10: None, 12: None, 13: None, 32: None, } + + def __init__(self, markup="", parseOnlyThese=None, fromEncoding=None, + markupMassage=True, smartQuotesTo=XML_ENTITIES, + convertEntities=None, selfClosingTags=None, isHTML=False): + """The Soup object is initialized as the 'root tag', and the + provided markup (which can be a string or a file-like object) + is fed into the underlying parser. + + sgmllib will process most bad HTML, and the BeautifulSoup + class has some tricks for dealing with some HTML that kills + sgmllib, but Beautiful Soup can nonetheless choke or lose data + if your data uses self-closing tags or declarations + incorrectly. + + By default, Beautiful Soup uses regexes to sanitize input, + avoiding the vast majority of these problems. If the problems + don't apply to you, pass in False for markupMassage, and + you'll get better performance. + + The default parser massage techniques fix the two most common + instances of invalid HTML that choke sgmllib: + +
(No space between name of closing tag and tag close) + (Extraneous whitespace in declaration) + + You can pass in a custom list of (RE object, replace method) + tuples to get Beautiful Soup to scrub your input the way you + want.""" + + self.parseOnlyThese = parseOnlyThese + self.fromEncoding = fromEncoding + self.smartQuotesTo = smartQuotesTo + self.convertEntities = convertEntities + # Set the rules for how we'll deal with the entities we + # encounter + if self.convertEntities: + # It doesn't make sense to convert encoded characters to + # entities even while you're converting entities to Unicode. + # Just convert it all to Unicode. + self.smartQuotesTo = None + if convertEntities == self.HTML_ENTITIES: + self.convertXMLEntities = False + self.convertHTMLEntities = True + self.escapeUnrecognizedEntities = True + elif convertEntities == self.XHTML_ENTITIES: + self.convertXMLEntities = True + self.convertHTMLEntities = True + self.escapeUnrecognizedEntities = False + elif convertEntities == self.XML_ENTITIES: + self.convertXMLEntities = True + self.convertHTMLEntities = False + self.escapeUnrecognizedEntities = False + else: + self.convertXMLEntities = False + self.convertHTMLEntities = False + self.escapeUnrecognizedEntities = False + + self.instanceSelfClosingTags = buildTagMap(None, selfClosingTags) + SGMLParser.__init__(self) + + if hasattr(markup, 'read'): # It's a file-type object. + markup = markup.read() + self.markup = markup + self.markupMassage = markupMassage + try: + self._feed(isHTML=isHTML) + except StopParsing: + pass + self.markup = None # The markup can now be GCed + + def convert_charref(self, name): + """This method fixes a bug in Python's SGMLParser.""" + try: + n = int(name) + except ValueError: + return + if not 0 <= n <= 127 : # ASCII ends at 127, not 255 + return + return self.convert_codepoint(n) + + def _feed(self, inDocumentEncoding=None, isHTML=False): + # Convert the document to Unicode. + markup = self.markup + if isinstance(markup, unicode): + if not hasattr(self, 'originalEncoding'): + self.originalEncoding = None + else: + dammit = UnicodeDammit\ + (markup, [self.fromEncoding, inDocumentEncoding], + smartQuotesTo=self.smartQuotesTo, isHTML=isHTML) + markup = dammit.unicode + self.originalEncoding = dammit.originalEncoding + self.declaredHTMLEncoding = dammit.declaredHTMLEncoding + if markup: + if self.markupMassage: + if not hasattr(self.markupMassage, "__iter__"): + self.markupMassage = self.MARKUP_MASSAGE + for fix, m in self.markupMassage: + markup = fix.sub(m, markup) + # TODO: We get rid of markupMassage so that the + # soup object can be deepcopied later on. Some + # Python installations can't copy regexes. If anyone + # was relying on the existence of markupMassage, this + # might cause problems. + del(self.markupMassage) + self.reset() + + SGMLParser.feed(self, markup) + # Close out any unfinished strings and close all the open tags. + self.endData() + while self.currentTag.name != self.ROOT_TAG_NAME: + self.popTag() + + def __getattr__(self, methodName): + """This method routes method call requests to either the SGMLParser + superclass or the Tag superclass, depending on the method name.""" + #print "__getattr__ called on %s.%s" % (self.__class__, methodName) + + if methodName.startswith('start_') or methodName.startswith('end_') \ + or methodName.startswith('do_'): + return SGMLParser.__getattr__(self, methodName) + elif not methodName.startswith('__'): + return Tag.__getattr__(self, methodName) + else: + raise AttributeError + + def isSelfClosingTag(self, name): + """Returns true iff the given string is the name of a + self-closing tag according to this parser.""" + return self.SELF_CLOSING_TAGS.has_key(name) \ + or self.instanceSelfClosingTags.has_key(name) + + def reset(self): + Tag.__init__(self, self, self.ROOT_TAG_NAME) + self.hidden = 1 + SGMLParser.reset(self) + self.currentData = [] + self.currentTag = None + self.tagStack = [] + self.quoteStack = [] + self.pushTag(self) + + def popTag(self): + tag = self.tagStack.pop() + + #print "Pop", tag.name + if self.tagStack: + self.currentTag = self.tagStack[-1] + return self.currentTag + + def pushTag(self, tag): + #print "Push", tag.name + if self.currentTag: + self.currentTag.contents.append(tag) + self.tagStack.append(tag) + self.currentTag = self.tagStack[-1] + + def endData(self, containerClass=NavigableString): + if self.currentData: + currentData = u''.join(self.currentData) + if (currentData.translate(self.STRIP_ASCII_SPACES) == '' and + not set([tag.name for tag in self.tagStack]).intersection( + self.PRESERVE_WHITESPACE_TAGS)): + if '\n' in currentData: + currentData = '\n' + else: + currentData = ' ' + self.currentData = [] + if self.parseOnlyThese and len(self.tagStack) <= 1 and \ + (not self.parseOnlyThese.text or \ + not self.parseOnlyThese.search(currentData)): + return + o = containerClass(currentData) + o.setup(self.currentTag, self.previous) + if self.previous: + self.previous.next = o + self.previous = o + self.currentTag.contents.append(o) + + + def _popToTag(self, name, inclusivePop=True): + """Pops the tag stack up to and including the most recent + instance of the given tag. If inclusivePop is false, pops the tag + stack up to but *not* including the most recent instqance of + the given tag.""" + #print "Popping to %s" % name + if name == self.ROOT_TAG_NAME: + return + + numPops = 0 + mostRecentTag = None + for i in range(len(self.tagStack)-1, 0, -1): + if name == self.tagStack[i].name: + numPops = len(self.tagStack)-i + break + if not inclusivePop: + numPops = numPops - 1 + + for i in range(0, numPops): + mostRecentTag = self.popTag() + return mostRecentTag + + def _smartPop(self, name): + + """We need to pop up to the previous tag of this type, unless + one of this tag's nesting reset triggers comes between this + tag and the previous tag of this type, OR unless this tag is a + generic nesting trigger and another generic nesting trigger + comes between this tag and the previous tag of this type. + + Examples: +

FooBar *

* should pop to 'p', not 'b'. +

FooBar *

* should pop to 'table', not 'p'. +

Foo

Bar *

* should pop to 'tr', not 'p'. + +

    • *
    • * should pop to 'ul', not the first 'li'. +
  • ** should pop to 'table', not the first 'tr' + tag should + implicitly close the previous tag within the same
    ** should pop to 'tr', not the first 'td' + """ + + nestingResetTriggers = self.NESTABLE_TAGS.get(name) + isNestable = nestingResetTriggers != None + isResetNesting = self.RESET_NESTING_TAGS.has_key(name) + popTo = None + inclusive = True + for i in range(len(self.tagStack)-1, 0, -1): + p = self.tagStack[i] + if (not p or p.name == name) and not isNestable: + #Non-nestable tags get popped to the top or to their + #last occurance. + popTo = name + break + if (nestingResetTriggers is not None + and p.name in nestingResetTriggers) \ + or (nestingResetTriggers is None and isResetNesting + and self.RESET_NESTING_TAGS.has_key(p.name)): + + #If we encounter one of the nesting reset triggers + #peculiar to this tag, or we encounter another tag + #that causes nesting to reset, pop up to but not + #including that tag. + popTo = p.name + inclusive = False + break + p = p.parent + if popTo: + self._popToTag(popTo, inclusive) + + def unknown_starttag(self, name, attrs, selfClosing=0): + #print "Start tag %s: %s" % (name, attrs) + if self.quoteStack: + #This is not a real tag. + #print "<%s> is not real!" % name + attrs = ''.join([' %s="%s"' % (x, y) for x, y in attrs]) + self.handle_data('<%s%s>' % (name, attrs)) + return + self.endData() + + if not self.isSelfClosingTag(name) and not selfClosing: + self._smartPop(name) + + if self.parseOnlyThese and len(self.tagStack) <= 1 \ + and (self.parseOnlyThese.text or not self.parseOnlyThese.searchTag(name, attrs)): + return + + tag = Tag(self, name, attrs, self.currentTag, self.previous) + if self.previous: + self.previous.next = tag + self.previous = tag + self.pushTag(tag) + if selfClosing or self.isSelfClosingTag(name): + self.popTag() + if name in self.QUOTE_TAGS: + #print "Beginning quote (%s)" % name + self.quoteStack.append(name) + self.literal = 1 + return tag + + def unknown_endtag(self, name): + #print "End tag %s" % name + if self.quoteStack and self.quoteStack[-1] != name: + #This is not a real end tag. + #print " is not real!" % name + self.handle_data('' % name) + return + self.endData() + self._popToTag(name) + if self.quoteStack and self.quoteStack[-1] == name: + self.quoteStack.pop() + self.literal = (len(self.quoteStack) > 0) + + def handle_data(self, data): + self.currentData.append(data) + + def _toStringSubclass(self, text, subclass): + """Adds a certain piece of text to the tree as a NavigableString + subclass.""" + self.endData() + self.handle_data(text) + self.endData(subclass) + + def handle_pi(self, text): + """Handle a processing instruction as a ProcessingInstruction + object, possibly one with a %SOUP-ENCODING% slot into which an + encoding will be plugged later.""" + if text[:3] == "xml": + text = u"xml version='1.0' encoding='%SOUP-ENCODING%'" + self._toStringSubclass(text, ProcessingInstruction) + + def handle_comment(self, text): + "Handle comments as Comment objects." + self._toStringSubclass(text, Comment) + + def handle_charref(self, ref): + "Handle character references as data." + if self.convertEntities: + data = unichr(int(ref)) + else: + data = '&#%s;' % ref + self.handle_data(data) + + def handle_entityref(self, ref): + """Handle entity references as data, possibly converting known + HTML and/or XML entity references to the corresponding Unicode + characters.""" + data = None + if self.convertHTMLEntities: + try: + data = unichr(name2codepoint[ref]) + except KeyError: + pass + + if not data and self.convertXMLEntities: + data = self.XML_ENTITIES_TO_SPECIAL_CHARS.get(ref) + + if not data and self.convertHTMLEntities and \ + not self.XML_ENTITIES_TO_SPECIAL_CHARS.get(ref): + # TODO: We've got a problem here. We're told this is + # an entity reference, but it's not an XML entity + # reference or an HTML entity reference. Nonetheless, + # the logical thing to do is to pass it through as an + # unrecognized entity reference. + # + # Except: when the input is "&carol;" this function + # will be called with input "carol". When the input is + # "AT&T", this function will be called with input + # "T". We have no way of knowing whether a semicolon + # was present originally, so we don't know whether + # this is an unknown entity or just a misplaced + # ampersand. + # + # The more common case is a misplaced ampersand, so I + # escape the ampersand and omit the trailing semicolon. + data = "&%s" % ref + if not data: + # This case is different from the one above, because we + # haven't already gone through a supposedly comprehensive + # mapping of entities to Unicode characters. We might not + # have gone through any mapping at all. So the chances are + # very high that this is a real entity, and not a + # misplaced ampersand. + data = "&%s;" % ref + self.handle_data(data) + + def handle_decl(self, data): + "Handle DOCTYPEs and the like as Declaration objects." + self._toStringSubclass(data, Declaration) + + def parse_declaration(self, i): + """Treat a bogus SGML declaration as raw data. Treat a CDATA + declaration as a CData object.""" + j = None + if self.rawdata[i:i+9] == '', i) + if k == -1: + k = len(self.rawdata) + data = self.rawdata[i+9:k] + j = k+3 + self._toStringSubclass(data, CData) + else: + try: + j = SGMLParser.parse_declaration(self, i) + except SGMLParseError: + toHandle = self.rawdata[i:] + self.handle_data(toHandle) + j = i + len(toHandle) + return j + +class BeautifulSoup(BeautifulStoneSoup): + + """This parser knows the following facts about HTML: + + * Some tags have no closing tag and should be interpreted as being + closed as soon as they are encountered. + + * The text inside some tags (ie. 'script') may contain tags which + are not really part of the document and which should be parsed + as text, not tags. If you want to parse the text as tags, you can + always fetch it and parse it explicitly. + + * Tag nesting rules: + + Most tags can't be nested at all. For instance, the occurance of + a

    tag should implicitly close the previous

    tag. + +

    Para1

    Para2 + should be transformed into: +

    Para1

    Para2 + + Some tags can be nested arbitrarily. For instance, the occurance + of a

    tag should _not_ implicitly close the previous +
    tag. + + Alice said:
    Bob said:
    Blah + should NOT be transformed into: + Alice said:
    Bob said:
    Blah + + Some tags can be nested, but the nesting is reset by the + interposition of other tags. For instance, a
    , + but not close a tag in another table. + +
    BlahBlah + should be transformed into: +
    BlahBlah + but, + Blah
    Blah + should NOT be transformed into + Blah
    Blah + + Differing assumptions about tag nesting rules are a major source + of problems with the BeautifulSoup class. If BeautifulSoup is not + treating as nestable a tag your page author treats as nestable, + try ICantBelieveItsBeautifulSoup, MinimalSoup, or + BeautifulStoneSoup before writing your own subclass.""" + + def __init__(self, *args, **kwargs): + if not kwargs.has_key('smartQuotesTo'): + kwargs['smartQuotesTo'] = self.HTML_ENTITIES + kwargs['isHTML'] = True + BeautifulStoneSoup.__init__(self, *args, **kwargs) + + SELF_CLOSING_TAGS = buildTagMap(None, + ('br' , 'hr', 'input', 'img', 'meta', + 'spacer', 'link', 'frame', 'base', 'col')) + + PRESERVE_WHITESPACE_TAGS = set(['pre', 'textarea']) + + QUOTE_TAGS = {'script' : None, 'textarea' : None} + + #According to the HTML standard, each of these inline tags can + #contain another tag of the same type. Furthermore, it's common + #to actually use these tags this way. + NESTABLE_INLINE_TAGS = ('span', 'font', 'q', 'object', 'bdo', 'sub', 'sup', + 'center') + + #According to the HTML standard, these block tags can contain + #another tag of the same type. Furthermore, it's common + #to actually use these tags this way. + NESTABLE_BLOCK_TAGS = ('blockquote', 'div', 'fieldset', 'ins', 'del') + + #Lists can contain other lists, but there are restrictions. + NESTABLE_LIST_TAGS = { 'ol' : [], + 'ul' : [], + 'li' : ['ul', 'ol'], + 'dl' : [], + 'dd' : ['dl'], + 'dt' : ['dl'] } + + #Tables can contain other tables, but there are restrictions. + NESTABLE_TABLE_TAGS = {'table' : [], + 'tr' : ['table', 'tbody', 'tfoot', 'thead'], + 'td' : ['tr'], + 'th' : ['tr'], + 'thead' : ['table'], + 'tbody' : ['table'], + 'tfoot' : ['table'], + } + + NON_NESTABLE_BLOCK_TAGS = ('address', 'form', 'p', 'pre') + + #If one of these tags is encountered, all tags up to the next tag of + #this type are popped. + RESET_NESTING_TAGS = buildTagMap(None, NESTABLE_BLOCK_TAGS, 'noscript', + NON_NESTABLE_BLOCK_TAGS, + NESTABLE_LIST_TAGS, + NESTABLE_TABLE_TAGS) + + NESTABLE_TAGS = buildTagMap([], NESTABLE_INLINE_TAGS, NESTABLE_BLOCK_TAGS, + NESTABLE_LIST_TAGS, NESTABLE_TABLE_TAGS) + + # Used to detect the charset in a META tag; see start_meta + CHARSET_RE = re.compile("((^|;)\s*charset=)([^;]*)", re.M) + + def start_meta(self, attrs): + """Beautiful Soup can detect a charset included in a META tag, + try to convert the document to that charset, and re-parse the + document from the beginning.""" + httpEquiv = None + contentType = None + contentTypeIndex = None + tagNeedsEncodingSubstitution = False + + for i in range(0, len(attrs)): + key, value = attrs[i] + key = key.lower() + if key == 'http-equiv': + httpEquiv = value + elif key == 'content': + contentType = value + contentTypeIndex = i + + if httpEquiv and contentType: # It's an interesting meta tag. + match = self.CHARSET_RE.search(contentType) + if match: + if (self.declaredHTMLEncoding is not None or + self.originalEncoding == self.fromEncoding): + # An HTML encoding was sniffed while converting + # the document to Unicode, or an HTML encoding was + # sniffed during a previous pass through the + # document, or an encoding was specified + # explicitly and it worked. Rewrite the meta tag. + def rewrite(match): + return match.group(1) + "%SOUP-ENCODING%" + newAttr = self.CHARSET_RE.sub(rewrite, contentType) + attrs[contentTypeIndex] = (attrs[contentTypeIndex][0], + newAttr) + tagNeedsEncodingSubstitution = True + else: + # This is our first pass through the document. + # Go through it again with the encoding information. + newCharset = match.group(3) + if newCharset and newCharset != self.originalEncoding: + self.declaredHTMLEncoding = newCharset + self._feed(self.declaredHTMLEncoding) + raise StopParsing + pass + tag = self.unknown_starttag("meta", attrs) + if tag and tagNeedsEncodingSubstitution: + tag.containsSubstitutions = True + +class StopParsing(Exception): + pass + +class ICantBelieveItsBeautifulSoup(BeautifulSoup): + + """The BeautifulSoup class is oriented towards skipping over + common HTML errors like unclosed tags. However, sometimes it makes + errors of its own. For instance, consider this fragment: + + FooBar + + This is perfectly valid (if bizarre) HTML. However, the + BeautifulSoup class will implicitly close the first b tag when it + encounters the second 'b'. It will think the author wrote + "FooBar", and didn't close the first 'b' tag, because + there's no real-world reason to bold something that's already + bold. When it encounters '' it will close two more 'b' + tags, for a grand total of three tags closed instead of two. This + can throw off the rest of your document structure. The same is + true of a number of other tags, listed below. + + It's much more common for someone to forget to close a 'b' tag + than to actually use nested 'b' tags, and the BeautifulSoup class + handles the common case. This class handles the not-co-common + case: where you can't believe someone wrote what they did, but + it's valid HTML and BeautifulSoup screwed up by assuming it + wouldn't be.""" + + I_CANT_BELIEVE_THEYRE_NESTABLE_INLINE_TAGS = \ + ('em', 'big', 'i', 'small', 'tt', 'abbr', 'acronym', 'strong', + 'cite', 'code', 'dfn', 'kbd', 'samp', 'strong', 'var', 'b', + 'big') + + I_CANT_BELIEVE_THEYRE_NESTABLE_BLOCK_TAGS = ('noscript',) + + NESTABLE_TAGS = buildTagMap([], BeautifulSoup.NESTABLE_TAGS, + I_CANT_BELIEVE_THEYRE_NESTABLE_BLOCK_TAGS, + I_CANT_BELIEVE_THEYRE_NESTABLE_INLINE_TAGS) + +class MinimalSoup(BeautifulSoup): + """The MinimalSoup class is for parsing HTML that contains + pathologically bad markup. It makes no assumptions about tag + nesting, but it does know which tags are self-closing, that + + + + + + + + +
    +

    + FanFictionDownLoader +

    + + +
    +
    + Hi, {{ nickname }}! This is a fan fiction downloader, which makes reading stories from various websites much easier. Please paste a URL of the first chapter in the box to start. Alternatively, see your personal list of previously downloaded fanfics. +
    + +
    + Ebook format   +
    + +
    + +
    + + + +
    + + + +
    +
    + +

    + Login and Password +

    +
    + If the story requires a login and password to download (e.g. marked as Mature on FFA), you may need to provide your credentials to download it, otherwise just leave it empty +
    +
    +
    +
    Login
    +
    +
    + +
    +
    Password
    +
    +
    +
    +
    + + +
    + + +
    + +
    +
    + Few things to know, which will make your life substantially easier: +
      +
    1. Small post written by me — how to read fiction in Stanza or any other ebook reader.
    2. +
    3. Currently we support fanfiction.net, fictionpress.com, fanficauthors.net and ficwad.com
    4. +
    5. Paste a URL of the first chapter of the fanfic, not the index page
    6. +
    7. Fics with a single chapter are not supported (you can just copy and paste it)
    8. +
    9. Stories which are too long may not be downloaded correctly and application will report a time-out error — this is a limitation which is currently imposed by Google AppEngine on a long-running activities
    10. +
    11. FicWad support is somewhat flaky — if you feel it doesn't work for you, send all the details to me
    12. +
    13. You can download fanfics and store them for 'later' by just downloading them and visiting recent downloads section, but in future they will be deleted after 5 days to save the space
    14. +
    15. If Downloader simply opens a download file window rather than saves the fanfic and gives you a link, it means it is too large to save in the database and you need to download it straight away
    16. +
    17. If you think that something that should work in fact doesn't, drop me a mail to sigizmund@gmail.com
    18. +
    + Otherwise, just have fun, and if you want to say thank you — use the email above. +
    +
    + Powered by Google App Engine +

    + This is a web front-end to FanFictionDownLoader
    + Copyright © Fanficdownloader team +
    + +
    + + + + diff --git a/index.html b/index.html new file mode 100644 index 00000000..1da7f746 --- /dev/null +++ b/index.html @@ -0,0 +1,605 @@ + + + + + FanFictionDownLoader - read fanfiction from twilighted.net, fanfiction.net, fictionpress.com, fictionalley.org, ficwad.com, potionsandsnitches.net, harrypotterfanfiction.com, mediaminer.org on Kindle, Nook, Sony Reader, iPad, iPhone, Android, Aldiko, Stanza + + + + + + + + + +
    +

    + FanFictionDownLoader +

    + +
    + + +
    + + {{yourfile}} + + + {% if authorized %} +
    +
    +
    +

    Hi, {{ nickname }}! This is FanFictionDownLoader, which makes reading stories from various websites + much easier.

    +
    + +

    + Questions? Check out our + FAQs. +

    +

    + If you have any problems with this application, please + report them in + the FanFictionDownLoader Google Group. The + Previous Version is also available for you to use if necessary. +

    +
    + {{ error_message }} +
    +
    + +
    +
    URL:
    +
    +
    Ebook format
    +
    + EPub + HTML + Plain Text + Mobi(Kindle) +
    +
    +
    + +

    For most readers, including Sony Reader, Nook and iPad, use EPub.

    +
    +
    +
    +

    + Customize your User Configuration. +

    +

    + Or see your personal list of previously downloaded fanfics. +

    +

    + See a list of downloaded fanfics by all users by most popular or most recent. +

    +
    + + {% else %} +
    +
    +

    + This is a FanFictionDownLoader, which makes reading stories from various websites much easier. Before you + can start downloading fanfics, you need to login, so FanFictionDownLoader can remember your fanfics and store them. +

    +

    Login using Google account

    +
    +
    + {% endif %} + +
    +

    + FanFictionDownLoader calibre Plugin +

    + + There's now a version of this downloader that runs + entirely inside the + popular calibre + ebook management package as a plugin. + +

    + + Once you have calibre installed and running, inside + calibre, you can go to 'Get plugins to enhance calibre' or + 'Get new plugins' and + install FanFictionDownLoader. + +

    +
    +
    +

    Supported sites:

    +

    + There's a + Supported + Sites page in our wiki. If you have a site you'd like + to see supported, please check there first. +

    +
    +
    fictionalley.org
    +
    + Use the URL of the story's chapter list, such as +
    http://www.fictionalley.org/authors/drt/DA.html. +
    Or a chapter URL (or one-shot text), such as +
    http://www.fictionalley.org/authors/drt/JOTP01a.html. +
    Both will work for both chaptered and one-shot stories now. +
    +
    fanfiction.net
    +
    + Use the URL of any story chapter, with or without story title such as +
    http://www.fanfiction.net/s/5192986/1/A_Fox_in_Tokyo or +
    http://www.fanfiction.net/s/2345466/3/. +
    +
    fictionpress.com
    +
    + Use the URL of any story chapter, such as +
    http://www.fictionpress.com/s/2851771/1/Untouchable_Love or +
    http://www.fictionpress.com/s/2847338/6/. +
    +
    twilighted.net
    +
    + Use the URL of the start of the story, such as +
    http://twilighted.net/viewstory.php?sid=8422. +
    +
    twiwrite.net
    +
    + Use the URL of the start of the story, such as +
    http://twiwrite.net/viewstory.php?sid=427. +
    +
    ficwad.com
    +
    + Use the URL of the story's chapter list, such as +
    http://www.ficwad.com/story/74884. +
    Note that this is changed from the previous version. The system will still accept chapter URLs, however. +
    +
    harrypotterfanfiction.com
    +
    + Use the URL of the story's chapter list, such as +
    http://www.harrypotterfanfiction.com/viewstory.php?psid=289208. +
    +
    potionsandsnitches.net
    +
    + Use the URL of the story's chapter list, such as +
    http://potionsandsnitches.net/fanfiction/viewstory.php?sid=2332. +
    +
    mediaminer.org
    +
    + Use the URL of the story's chapter list, such as +
    http://www.mediaminer.org/fanfic/view_st.php/166653. +
    Or the story URL for one-shots, such as +
    http://www.mediaminer.org/fanfic/view_st.php/167618 or +
    http://www.mediaminer.org/fanfic/view_ch.php/1234123/123444#fic_c +
    +
    adastrafanfic.com
    +
    + Use the URL of the story's chapter list, such as +
    http://www.adastrafanfic.com/viewstory.php?sid=854. +
    +
    whofic.com
    +
    + Use the URL of the story's chapter list, such as +
    http://www.whofic.com/viewstory.php?sid=16334. +
    +
    thewriterscoffeeshop.com
    +
    + Use the URL of the story's chapter list, such as +
    http://www.thewriterscoffeeshop.com/library/viewstory.php?sid=2110. +
    +
    fanfiction.tenhawkpresents.com
    +
    + Use the URL of the story's chapter list, such as +
    http://fanfiction.tenhawkpresents.com/viewstory.php?sid=294. +
    +
    castlefans.org
    +
    + Use the URL of the story's chapter list, such as +
    http://castlefans.org/fanfic/viewstory.php?sid=123. +
    +
    fimfiction.net
    +
    + Use the URL of the story's chapter list, such as +
    http://www.fimfiction.com/story/123/ +
    or the URL of any chapter, such as +
    http://www.fimfiction.com/story/123/1/. +
    +
    tthfanfic.org
    +
    + Use the URL of any story, with or without chapter, title and notice, such as +
    http://www.tthfanfic.org/Story-5583 +
    http://www.tthfanfic.org/Story-5583/Greywizard+Marked+By+Kane.htm. +
    http://www.tthfanfic.org/T-99999999/Story-26448-15/batzulger+Willow+Rosenberg+and+the+Mind+Riders.htm. +
    +
    www.siye.co.uk
    +
    + Use the URL of the story's chapter list, such as +
    http://www.siye.co.uk/siye/viewstory.php?sid=123. +
    +
    archiveofourown.org
    +
    + Use the URL of the story, or one of it's chapters, such as +
    http://archiveofourown.org/works/76366. +
    http://archiveofourown.org/works/76366/chapters/101584. +
    +
    ficbook.net(Russian)
    +
    + Use the URL of the story, or one of it's chapters, such as +
    http://ficbook.net/readfic/93626. +
    http://ficbook.net/readfic/93626/246417#part_content. +
    +
    fanfiction.mugglenet.com
    +
    + Use the URL of the story's chapter list, such as +
    http://fanfiction.mugglenet.com/viewstory.php?sid=123. +
    +
    www.hpfandom.net
    +
    + Use the URL of the story's chapter list, such as +
    http://www.hpfandom.net/eff/viewstory.php?sid=123. +
    +
    thequidditchpitch.org
    +
    + Use the URL of the story's chapter list, such as +
    http://thequidditchpitch.org/viewstory.php?sid=123. +
    +
    fanfiction.portkey.org
    +
    + Use the URL of the story's chapter list, such as +
    http://fanfiction.portkey.org/story/123. +
    +
    nfacommunity.com
    +
    + Use the URL of the story's chapter list, such as +
    http://nfacommunity.com/viewstory.php?sid=1654. +
    +
    www.midnightwhispers.ca
    +
    + Use the URL of the story's chapter list, such as +
    http://www.midnightwhispers.ca/viewstory.php?sid=1124. +
    +
    ksarchive.com
    +
    + Use the URL of the story's chapter list, such as +
    http://ksarchive.com/viewstory.php?sid=1124. +
    +
    archive.skyehawke.com
    +
    + Use the URL of the story's summary, such as +
    http://archive.skyehawke.com/story.php?no=17466. +
    +
    www.libraryofmoria.com
    +
    + Use the URL of the story's summary, such as +
    http://www.libraryofmoria.com/a/viewstory.php?sid=434. +
    +
    www.wraithbait.com
    +
    + Use the URL of the story's summary, such as +
    http://www.wraithbait.com/viewstory.php?sid=14305. +
    +
    www.squidge.org/peja (WWOMB)
    +
    + Use the URL of the story's summary, such as +
    http://www.squidge.org/peja/cgi-bin/viewstory.php?sid=1234.
    + This is only for squidge.org/peja, not other parts of squidge.org. +
    +
    www.checkmated.com
    +
    + Use the URL of the story's first chapter, such as +
    http://www.checkmated.com/story.php?story=10898. +
    + +
    ashwinder.sycophanthex.com
    +
    + Use the URL of the story's chapter list, such as +
    http://ashwinder.sycophanthex.com/viewstory.php?sid=1234 +
    +
    chaos.sycophanthex.com
    +
    + Use the URL of the story's chapter list, such as +
    http://chaos.sycophanthex.com/viewstory.php?sid=1234 +
    +
    erosnsappho.sycophanthex.com
    +
    + Use the URL of the story's chapter list, such as +
    http://erosnsappho.sycophanthex.com/viewstory.php?sid=1234 +
    +
    lumos.sycophanthex.com
    +
    + Use the URL of the story's chapter list, such as +
    http://lumos.sycophanthex.com/viewstory.php?sid=1234 +
    +
    occlumency.sycophanthex.com
    +
    + Use the URL of the story's chapter list, such as +
    http://erosnsappho.sycophanthex.com/viewstory.php?sid=1234 +
    +
    dramione.org
    +
    + Use the URL of the story's chapter list, such as +
    http://dramione.org/viewstory.php?sid=1234 +
    +
    www.phoenixsong.net
    +
    + Use the URL of any story chapter, such as +
    http://www.phoenixsong.net/fanfiction/story/1234/ +
    +
    www.walkingtheplank.org
    +
    + Use the URL of the story's first chapter, such as +
    http://www.walkingtheplank.org/archive/viewstory.php?sid=1234 +
    +
    thehexfiles.net
    +
    + Use the URL of the story's chapter list, such as +
    http://thehexfiles.net/viewstory.php?sid=1234 +
    +
    www.dokuga.com
    +
    + Use the URL of any story chapter, such as +
    http://www.dokuga.com/fanfiction/story/1234/1 or +
    http://www.dokuga.com/spark/story/1234/1 +
    +
    www.ik-eternal.net
    +
    + Use the URL of the story's chapter list, such as +
    http://www.ik-eternal.net/viewstory.php?sid=1234 +
    +
    onedirectionfanfiction.com
    +
    + Use the URL of the story's first chapter, such as +
    http://onedirectionfanfiction.com/viewstory.php?sid=1234 +
    +
    www.prisonbreakfic.net
    +
    + Use the URL of the story's first chapter, such as +
    http://www.prisonbreakfic.net/viewstory.php?sid=1234 +
    +
    www.storiesofarda.com
    +
    + Use the URL of the story's chapter list, such as +
    http://www.storiesofarda.com/chapterlistview.asp?SID=7000 +
    +
    samdean.archive.nu
    +
    + Use the URL of the story's first chapter, such as +
    http://samdean.archive.nu/viewstory.php?sid=1234 +
    +
    www.yourfanfiction.com
    +
    + Use the URL of the story's first chapter, such as +
    http://www.yourfanfiction.com/viewstory.php?sid=1234 +
    +
    www.destinysgateway.com
    +
    + Use the URL of the story's first chapter, such as +
    http://www.destinysgateway.com/viewstory.php?sid=1234 +
    +
    www.thealphagate.com
    +
    + Use the URL of the story's first chapter, such as +
    http://www.thealphagate.com/viewstory.php?sid=1234 +
    +
    stargate-atlantis.org
    +
    + Use the URL of the story's first chapter, such as +
    http://stargate-atlantis.org/fanfics/viewstory.php?sid=1234 +
    +
    www.ncisfiction.com
    +
    + Use the URL of the story's first chapter, such as +
    http://www.ncisfiction.com/story.php?stid=1234 + Or use the URL of the story's chapter list, such as +
    http://www.ncisfiction.com/chapters.php?stid=1234 +
    +
    www.fanfiktion.de
    +
    + Use the URL of the story's first chapter, such as +
    http://www.fanfiktion.de/s/46ccbef30000616306614050 +
    +
    ponyfictionarchive.net
    +
    + Use the URL of the story's first chapter, such as +
    http://ponyfictionarchive.net/viewstory.php?sid=1234 +
    Or: +
    http://explicit.ponyfictionarchive.net/viewstory.php?sid=1234 +
    +
    sg1-heliopolis.com
    +
    + Use the URL of the story's first chapter, such as +
    http://sg1-heliopolis.com/archive/viewstory.php?sid=1234 +
    Or: +
    http://sg1-heliopolis.com/adult/viewstory.php?sid=1234 +
    Or: +
    http://sg1-heliopolis.com/atlantis/viewstory.php?sid=1234 +
    +
    ncisfic.com
    +
    + Use the URL of the story's first chapter, such as +
    http://ncisfic.com/viewstory.php?storyid=1234 +
    +
    national-library.net
    +
    + Use the URL of the story's first chapter, such as +
    http://national-library.net/viewstory.php?storyid=1234 +
    +
    dark-solace.org
    +
    + Use the URL of the story's first chapter, such as +
    http://dark-solace.org/elysian/viewstory.php?sid=1234 +
    +
    pretendercentre.com
    +
    + Use the URL of the story's first chapter, such as +
    http://pretendercentre.com/missingpieces/viewstory.php?sid=1234 +
    +
    themasque.net
    +
    + Use the URL of the story's first chapter, such as +
    http://themasque.net/wiktt/efiction/viewstory.php?sid=1234 +
    +
    finestories.com
    +
    + Use the URL of the story's first chapter, such as +
    http://finestories.com/s/1234 +
    +
    www.hpfanficarchive.com
    +
    + Use the URL of the story's first chapter, such as +
    http://www.hpfanficarchive.com/stories/viewstory.php?sid=1234 +
    +
    svufiction.com
    +
    + Use the URL of the story's first chapter, such as +
    http://svufiction.com/viewstory.php?sid=1234 +
    +
    www.twilightarchives.com
    +
    + Use the URL of the story's first chapter, such as +
    http://www.twilightarchives.com/read/1234 +
    +
    www.wizardtales.net
    +
    + Use the URL of the story's first chapter, such as +
    http://www.wizardtales.net/viewstory.php?sid=1234 +
    +
    grangerenchanted.com
    +
    + Use the URL of the story's chapter list, such as +
    http://grangerenchanted.com/enchant/viewstory.php?sid=1234 or +
    http://grangerenchanted.com/enchant/viewstory.php?sid=1234 +
    +
    hlfiction.net
    +
    + Use the URL of the story's first chapter, such as +
    http://hlfiction.net/viewstory.php?sid=1234 +
    +
    nha.magical-worlds.us
    +
    + Use the URL of the story's chapter list, such as +
    http://nha.magical-worlds.us/viewstory.php?sid=1234 +
    +
    www.dracoandginny.com
    +
    + Use the URL of the story's chapter list, such as +
    http://www.dracoandginny.com/viewstory.php?sid=1234 +
    +
    www.scarvesandcoffee.net
    +
    + Use the URL of the story's chapter list, such as +
    http://www.scarvesandcoffee.net/viewstory.php?sid=1234 +
    +
    www.thepetulantpoetess.com
    +
    + Use the URL of the story's chapter list, such as +
    http://www.thepetulantpoetess.com/viewstory.php?sid=1234 +
    +
    www.wolverineandrogue.com
    +
    + Use the URL of the story's chapter list, such as +
    http://www.wolverineandrogue.com/wrfa/viewstory.php?sid=1234 +
    +
    www.sinful-desire.org
    +
    + Use the URL of the story's chapter list, such as +
    http://www.sinful-desire.org/archive/viewstory.php?sid=1234 +
    +
    merlinfic.dtwins.co.uk
    +
    + Use the URL of the story's chapter list, such as +
    http://merlinfic.dtwins.co.uk/viewstory.php?sid=1234 +
    +
    bloodties-fans.com
    +
    + Use the URL of the story's chapter list, such as +
    http://bloodties-fans.com/fiction/viewstory.php?sid=1234 +
    +
    thehookupzone.net
    +
    + Use the URL of the story's chapter list, such as +
    http://thehookupzone.net/CriminalMinds/viewstory.php?sid=1234 +
    +
    www.qaf-fic.com
    +
    + Use the URL of the story's chapter list, such as +
    http://www.qaf-fic.com/atp/viewstory.php?sid=1234 +
    +
    + + +

    + A few additional things to know, which will make your life substantially easier: +

    +
      +
    1. + First thing to know: We do not use your Google login and password. In fact, all we know about it is your ID – password + is being verified by Google and is absolutely, totally unknown to anyone but you. +
    2. +
    3. + + Small post written by Roman + — how to read fiction in Stanza or any other ebook reader. +
    4. +
    5. + You can download fanfiction directly from your iPhone, Kindle or (possibly) other ebook reader. +
    6. +
    7. + Downloaded stories are deleted after some time (which should give you enough of time to download it and will keep + Google happy about the app not going over the storage limit). +
    8. +
    9. + If you see some funny characters in downloaded Plain Text file, make sure you choose text file encoding UTF-8 and + not something else. +
    10. +
    11. + If you think that something that should work in fact doesn't, post a message to + our Google Group. we also encourage you to join it so + you will find out about latest updates and fixes as soon as possible +
    12. +
    + Otherwise, just have fun, and if you want to say thank you — use the contacts above. +
    +
    + Powered by Google App Engine +

    + This is a web front-end to FanFictionDownLoader
    + Copyright © FanFictionDownLoader team +
    + +
    + + +
    +
    + + diff --git a/index.yaml b/index.yaml new file mode 100644 index 00000000..a55512f1 --- /dev/null +++ b/index.yaml @@ -0,0 +1,28 @@ +indexes: + +# notAUTOGENERATED + +# This index.yaml is automatically updated whenever the dev_appserver +# detects that a new type of query is run. If you want to manage the +# index.yaml file manually, remove the above marker line (the line +# saying "# AUTOGENERATED"). If you want to manage some indexes +# manually, move them above the marker line. The index.yaml file is +# automatically uploaded to the admin console when you next deploy +# your application using appcfg.py. + +- kind: DownloadData + properties: + - name: download + - name: index + +- kind: DownloadMeta + properties: + - name: user + - name: date + direction: desc + +- kind: SavedMeta + properties: + - name: count + - name: date + direction: desc diff --git a/js/fdownloader.js b/js/fdownloader.js new file mode 100644 index 00000000..8f6ab0a8 --- /dev/null +++ b/js/fdownloader.js @@ -0,0 +1,116 @@ +var g_CurrentKey = null; +var g_Counter = 0; + +var COUNTER_MAX = 50; + + +function setErrorState(error) +{ + olderr = error; + error = error + "
    " + "Complain about this error"; + $('#error').html(error); +} + +function clearErrorState() +{ + $('#error').html(''); +} + +function showFile(data) +{ + $('#yourfile').html('' + data.name + " by " + data.author + ""); + $('#yourfile').show(); +} + +function hideFile() +{ + $('#yourfile').hide(); +} + +function checkResults() +{ + if ( g_Counter >= COUNTER_MAX ) + { + return; + } + + g_Counter+=1; + + $.getJSON('/progress', { 'key' : g_CurrentKey }, function(data) + { + if ( data.result != "Nope") + { + if ( data.result != "OK" ) + { + leaveLoadingState(); + setErrorState(data.result); + } + else + { + showFile(data); + leaveLoadingState(); + // result = data.split("|"); + // showFile(result[1], result[2], result[3]); + } + + $("#progressbar").progressbar('destroy'); + g_Counter = 101; + } + }); + + if ( g_Counter < COUNTER_MAX ) + setTimeout("checkResults()", 1000); + else + { + leaveLoadingState(); + setErrorState("Operation takes too long - terminating by timeout (story too long?)"); + } +} + +function enterLoadingState() +{ + $('#submit_button').hide(); + $('#ajax_loader').show(); +} + +function leaveLoadingState() +{ + $('#submit_button').show(); + $('#ajax_loader').hide(); +} + +function downloadFanfic() +{ + clearErrorState(); + hideFile(); + + + format = $("#format").val(); + alert(format); + + return; + + var url = $('#url').val(); + var login = $('#login').val(); + var password = $('#password').val(); + + if ( url == '' ) + { + setErrorState('URL shouldn\'t be empty'); + return; + } + + if ( (url.indexOf('fanfiction.net') == -1 && url.indexOf('fanficauthors') == -1 && url.indexOf('ficwad') == -1 && url.indexOf('fictionpress') == -1) || (url.indexOf('adultfanfiction.net') != -1) ) + { + setErrorState("This source is not yet supported. Ping me if you want it!"); + return; + } + + $.post('/submitDownload', {'url' : url, 'login' : login, 'password' : password, 'format' : format}, function(data) + { + g_CurrentKey = data; + g_Counter = 0; + setTimeout("checkResults()", 1000); + enterLoadingState(); + }) +} \ No newline at end of file diff --git a/js/jquery-1.3.2.js b/js/jquery-1.3.2.js new file mode 100644 index 00000000..92635743 --- /dev/null +++ b/js/jquery-1.3.2.js @@ -0,0 +1,4376 @@ +/*! + * jQuery JavaScript Library v1.3.2 + * http://jquery.com/ + * + * Copyright (c) 2009 John Resig + * Dual licensed under the MIT and GPL licenses. + * http://docs.jquery.com/License + * + * Date: 2009-02-19 17:34:21 -0500 (Thu, 19 Feb 2009) + * Revision: 6246 + */ +(function(){ + +var + // Will speed up references to window, and allows munging its name. + window = this, + // Will speed up references to undefined, and allows munging its name. + undefined, + // Map over jQuery in case of overwrite + _jQuery = window.jQuery, + // Map over the $ in case of overwrite + _$ = window.$, + + jQuery = window.jQuery = window.$ = function( selector, context ) { + // The jQuery object is actually just the init constructor 'enhanced' + return new jQuery.fn.init( selector, context ); + }, + + // A simple way to check for HTML strings or ID strings + // (both of which we optimize for) + quickExpr = /^[^<]*(<(.|\s)+>)[^>]*$|^#([\w-]+)$/, + // Is it a simple selector + isSimple = /^.[^:#\[\.,]*$/; + +jQuery.fn = jQuery.prototype = { + init: function( selector, context ) { + // Make sure that a selection was provided + selector = selector || document; + + // Handle $(DOMElement) + if ( selector.nodeType ) { + this[0] = selector; + this.length = 1; + this.context = selector; + return this; + } + // Handle HTML strings + if ( typeof selector === "string" ) { + // Are we dealing with HTML string or an ID? + var match = quickExpr.exec( selector ); + + // Verify a match, and that no context was specified for #id + if ( match && (match[1] || !context) ) { + + // HANDLE: $(html) -> $(array) + if ( match[1] ) + selector = jQuery.clean( [ match[1] ], context ); + + // HANDLE: $("#id") + else { + var elem = document.getElementById( match[3] ); + + // Handle the case where IE and Opera return items + // by name instead of ID + if ( elem && elem.id != match[3] ) + return jQuery().find( selector ); + + // Otherwise, we inject the element directly into the jQuery object + var ret = jQuery( elem || [] ); + ret.context = document; + ret.selector = selector; + return ret; + } + + // HANDLE: $(expr, [context]) + // (which is just equivalent to: $(content).find(expr) + } else + return jQuery( context ).find( selector ); + + // HANDLE: $(function) + // Shortcut for document ready + } else if ( jQuery.isFunction( selector ) ) + return jQuery( document ).ready( selector ); + + // Make sure that old selector state is passed along + if ( selector.selector && selector.context ) { + this.selector = selector.selector; + this.context = selector.context; + } + + return this.setArray(jQuery.isArray( selector ) ? + selector : + jQuery.makeArray(selector)); + }, + + // Start with an empty selector + selector: "", + + // The current version of jQuery being used + jquery: "1.3.2", + + // The number of elements contained in the matched element set + size: function() { + return this.length; + }, + + // Get the Nth element in the matched element set OR + // Get the whole matched element set as a clean array + get: function( num ) { + return num === undefined ? + + // Return a 'clean' array + Array.prototype.slice.call( this ) : + + // Return just the object + this[ num ]; + }, + + // Take an array of elements and push it onto the stack + // (returning the new matched element set) + pushStack: function( elems, name, selector ) { + // Build a new jQuery matched element set + var ret = jQuery( elems ); + + // Add the old object onto the stack (as a reference) + ret.prevObject = this; + + ret.context = this.context; + + if ( name === "find" ) + ret.selector = this.selector + (this.selector ? " " : "") + selector; + else if ( name ) + ret.selector = this.selector + "." + name + "(" + selector + ")"; + + // Return the newly-formed element set + return ret; + }, + + // Force the current matched set of elements to become + // the specified array of elements (destroying the stack in the process) + // You should use pushStack() in order to do this, but maintain the stack + setArray: function( elems ) { + // Resetting the length to 0, then using the native Array push + // is a super-fast way to populate an object with array-like properties + this.length = 0; + Array.prototype.push.apply( this, elems ); + + return this; + }, + + // Execute a callback for every element in the matched set. + // (You can seed the arguments with an array of args, but this is + // only used internally.) + each: function( callback, args ) { + return jQuery.each( this, callback, args ); + }, + + // Determine the position of an element within + // the matched set of elements + index: function( elem ) { + // Locate the position of the desired element + return jQuery.inArray( + // If it receives a jQuery object, the first element is used + elem && elem.jquery ? elem[0] : elem + , this ); + }, + + attr: function( name, value, type ) { + var options = name; + + // Look for the case where we're accessing a style value + if ( typeof name === "string" ) + if ( value === undefined ) + return this[0] && jQuery[ type || "attr" ]( this[0], name ); + + else { + options = {}; + options[ name ] = value; + } + + // Check to see if we're setting style values + return this.each(function(i){ + // Set all the styles + for ( name in options ) + jQuery.attr( + type ? + this.style : + this, + name, jQuery.prop( this, options[ name ], type, i, name ) + ); + }); + }, + + css: function( key, value ) { + // ignore negative width and height values + if ( (key == 'width' || key == 'height') && parseFloat(value) < 0 ) + value = undefined; + return this.attr( key, value, "curCSS" ); + }, + + text: function( text ) { + if ( typeof text !== "object" && text != null ) + return this.empty().append( (this[0] && this[0].ownerDocument || document).createTextNode( text ) ); + + var ret = ""; + + jQuery.each( text || this, function(){ + jQuery.each( this.childNodes, function(){ + if ( this.nodeType != 8 ) + ret += this.nodeType != 1 ? + this.nodeValue : + jQuery.fn.text( [ this ] ); + }); + }); + + return ret; + }, + + wrapAll: function( html ) { + if ( this[0] ) { + // The elements to wrap the target around + var wrap = jQuery( html, this[0].ownerDocument ).clone(); + + if ( this[0].parentNode ) + wrap.insertBefore( this[0] ); + + wrap.map(function(){ + var elem = this; + + while ( elem.firstChild ) + elem = elem.firstChild; + + return elem; + }).append(this); + } + + return this; + }, + + wrapInner: function( html ) { + return this.each(function(){ + jQuery( this ).contents().wrapAll( html ); + }); + }, + + wrap: function( html ) { + return this.each(function(){ + jQuery( this ).wrapAll( html ); + }); + }, + + append: function() { + return this.domManip(arguments, true, function(elem){ + if (this.nodeType == 1) + this.appendChild( elem ); + }); + }, + + prepend: function() { + return this.domManip(arguments, true, function(elem){ + if (this.nodeType == 1) + this.insertBefore( elem, this.firstChild ); + }); + }, + + before: function() { + return this.domManip(arguments, false, function(elem){ + this.parentNode.insertBefore( elem, this ); + }); + }, + + after: function() { + return this.domManip(arguments, false, function(elem){ + this.parentNode.insertBefore( elem, this.nextSibling ); + }); + }, + + end: function() { + return this.prevObject || jQuery( [] ); + }, + + // For internal use only. + // Behaves like an Array's method, not like a jQuery method. + push: [].push, + sort: [].sort, + splice: [].splice, + + find: function( selector ) { + if ( this.length === 1 ) { + var ret = this.pushStack( [], "find", selector ); + ret.length = 0; + jQuery.find( selector, this[0], ret ); + return ret; + } else { + return this.pushStack( jQuery.unique(jQuery.map(this, function(elem){ + return jQuery.find( selector, elem ); + })), "find", selector ); + } + }, + + clone: function( events ) { + // Do the clone + var ret = this.map(function(){ + if ( !jQuery.support.noCloneEvent && !jQuery.isXMLDoc(this) ) { + // IE copies events bound via attachEvent when + // using cloneNode. Calling detachEvent on the + // clone will also remove the events from the orignal + // In order to get around this, we use innerHTML. + // Unfortunately, this means some modifications to + // attributes in IE that are actually only stored + // as properties will not be copied (such as the + // the name attribute on an input). + var html = this.outerHTML; + if ( !html ) { + var div = this.ownerDocument.createElement("div"); + div.appendChild( this.cloneNode(true) ); + html = div.innerHTML; + } + + return jQuery.clean([html.replace(/ jQuery\d+="(?:\d+|null)"/g, "").replace(/^\s*/, "")])[0]; + } else + return this.cloneNode(true); + }); + + // Copy the events from the original to the clone + if ( events === true ) { + var orig = this.find("*").andSelf(), i = 0; + + ret.find("*").andSelf().each(function(){ + if ( this.nodeName !== orig[i].nodeName ) + return; + + var events = jQuery.data( orig[i], "events" ); + + for ( var type in events ) { + for ( var handler in events[ type ] ) { + jQuery.event.add( this, type, events[ type ][ handler ], events[ type ][ handler ].data ); + } + } + + i++; + }); + } + + // Return the cloned set + return ret; + }, + + filter: function( selector ) { + return this.pushStack( + jQuery.isFunction( selector ) && + jQuery.grep(this, function(elem, i){ + return selector.call( elem, i ); + }) || + + jQuery.multiFilter( selector, jQuery.grep(this, function(elem){ + return elem.nodeType === 1; + }) ), "filter", selector ); + }, + + closest: function( selector ) { + var pos = jQuery.expr.match.POS.test( selector ) ? jQuery(selector) : null, + closer = 0; + + return this.map(function(){ + var cur = this; + while ( cur && cur.ownerDocument ) { + if ( pos ? pos.index(cur) > -1 : jQuery(cur).is(selector) ) { + jQuery.data(cur, "closest", closer); + return cur; + } + cur = cur.parentNode; + closer++; + } + }); + }, + + not: function( selector ) { + if ( typeof selector === "string" ) + // test special case where just one selector is passed in + if ( isSimple.test( selector ) ) + return this.pushStack( jQuery.multiFilter( selector, this, true ), "not", selector ); + else + selector = jQuery.multiFilter( selector, this ); + + var isArrayLike = selector.length && selector[selector.length - 1] !== undefined && !selector.nodeType; + return this.filter(function() { + return isArrayLike ? jQuery.inArray( this, selector ) < 0 : this != selector; + }); + }, + + add: function( selector ) { + return this.pushStack( jQuery.unique( jQuery.merge( + this.get(), + typeof selector === "string" ? + jQuery( selector ) : + jQuery.makeArray( selector ) + ))); + }, + + is: function( selector ) { + return !!selector && jQuery.multiFilter( selector, this ).length > 0; + }, + + hasClass: function( selector ) { + return !!selector && this.is( "." + selector ); + }, + + val: function( value ) { + if ( value === undefined ) { + var elem = this[0]; + + if ( elem ) { + if( jQuery.nodeName( elem, 'option' ) ) + return (elem.attributes.value || {}).specified ? elem.value : elem.text; + + // We need to handle select boxes special + if ( jQuery.nodeName( elem, "select" ) ) { + var index = elem.selectedIndex, + values = [], + options = elem.options, + one = elem.type == "select-one"; + + // Nothing was selected + if ( index < 0 ) + return null; + + // Loop through all the selected options + for ( var i = one ? index : 0, max = one ? index + 1 : options.length; i < max; i++ ) { + var option = options[ i ]; + + if ( option.selected ) { + // Get the specifc value for the option + value = jQuery(option).val(); + + // We don't need an array for one selects + if ( one ) + return value; + + // Multi-Selects return an array + values.push( value ); + } + } + + return values; + } + + // Everything else, we just grab the value + return (elem.value || "").replace(/\r/g, ""); + + } + + return undefined; + } + + if ( typeof value === "number" ) + value += ''; + + return this.each(function(){ + if ( this.nodeType != 1 ) + return; + + if ( jQuery.isArray(value) && /radio|checkbox/.test( this.type ) ) + this.checked = (jQuery.inArray(this.value, value) >= 0 || + jQuery.inArray(this.name, value) >= 0); + + else if ( jQuery.nodeName( this, "select" ) ) { + var values = jQuery.makeArray(value); + + jQuery( "option", this ).each(function(){ + this.selected = (jQuery.inArray( this.value, values ) >= 0 || + jQuery.inArray( this.text, values ) >= 0); + }); + + if ( !values.length ) + this.selectedIndex = -1; + + } else + this.value = value; + }); + }, + + html: function( value ) { + return value === undefined ? + (this[0] ? + this[0].innerHTML.replace(/ jQuery\d+="(?:\d+|null)"/g, "") : + null) : + this.empty().append( value ); + }, + + replaceWith: function( value ) { + return this.after( value ).remove(); + }, + + eq: function( i ) { + return this.slice( i, +i + 1 ); + }, + + slice: function() { + return this.pushStack( Array.prototype.slice.apply( this, arguments ), + "slice", Array.prototype.slice.call(arguments).join(",") ); + }, + + map: function( callback ) { + return this.pushStack( jQuery.map(this, function(elem, i){ + return callback.call( elem, i, elem ); + })); + }, + + andSelf: function() { + return this.add( this.prevObject ); + }, + + domManip: function( args, table, callback ) { + if ( this[0] ) { + var fragment = (this[0].ownerDocument || this[0]).createDocumentFragment(), + scripts = jQuery.clean( args, (this[0].ownerDocument || this[0]), fragment ), + first = fragment.firstChild; + + if ( first ) + for ( var i = 0, l = this.length; i < l; i++ ) + callback.call( root(this[i], first), this.length > 1 || i > 0 ? + fragment.cloneNode(true) : fragment ); + + if ( scripts ) + jQuery.each( scripts, evalScript ); + } + + return this; + + function root( elem, cur ) { + return table && jQuery.nodeName(elem, "table") && jQuery.nodeName(cur, "tr") ? + (elem.getElementsByTagName("tbody")[0] || + elem.appendChild(elem.ownerDocument.createElement("tbody"))) : + elem; + } + } +}; + +// Give the init function the jQuery prototype for later instantiation +jQuery.fn.init.prototype = jQuery.fn; + +function evalScript( i, elem ) { + if ( elem.src ) + jQuery.ajax({ + url: elem.src, + async: false, + dataType: "script" + }); + + else + jQuery.globalEval( elem.text || elem.textContent || elem.innerHTML || "" ); + + if ( elem.parentNode ) + elem.parentNode.removeChild( elem ); +} + +function now(){ + return +new Date; +} + +jQuery.extend = jQuery.fn.extend = function() { + // copy reference to target object + var target = arguments[0] || {}, i = 1, length = arguments.length, deep = false, options; + + // Handle a deep copy situation + if ( typeof target === "boolean" ) { + deep = target; + target = arguments[1] || {}; + // skip the boolean and the target + i = 2; + } + + // Handle case when target is a string or something (possible in deep copy) + if ( typeof target !== "object" && !jQuery.isFunction(target) ) + target = {}; + + // extend jQuery itself if only one argument is passed + if ( length == i ) { + target = this; + --i; + } + + for ( ; i < length; i++ ) + // Only deal with non-null/undefined values + if ( (options = arguments[ i ]) != null ) + // Extend the base object + for ( var name in options ) { + var src = target[ name ], copy = options[ name ]; + + // Prevent never-ending loop + if ( target === copy ) + continue; + + // Recurse if we're merging object values + if ( deep && copy && typeof copy === "object" && !copy.nodeType ) + target[ name ] = jQuery.extend( deep, + // Never move original objects, clone them + src || ( copy.length != null ? [ ] : { } ) + , copy ); + + // Don't bring in undefined values + else if ( copy !== undefined ) + target[ name ] = copy; + + } + + // Return the modified object + return target; +}; + +// exclude the following css properties to add px +var exclude = /z-?index|font-?weight|opacity|zoom|line-?height/i, + // cache defaultView + defaultView = document.defaultView || {}, + toString = Object.prototype.toString; + +jQuery.extend({ + noConflict: function( deep ) { + window.$ = _$; + + if ( deep ) + window.jQuery = _jQuery; + + return jQuery; + }, + + // See test/unit/core.js for details concerning isFunction. + // Since version 1.3, DOM methods and functions like alert + // aren't supported. They return false on IE (#2968). + isFunction: function( obj ) { + return toString.call(obj) === "[object Function]"; + }, + + isArray: function( obj ) { + return toString.call(obj) === "[object Array]"; + }, + + // check if an element is in a (or is an) XML document + isXMLDoc: function( elem ) { + return elem.nodeType === 9 && elem.documentElement.nodeName !== "HTML" || + !!elem.ownerDocument && jQuery.isXMLDoc( elem.ownerDocument ); + }, + + // Evalulates a script in a global context + globalEval: function( data ) { + if ( data && /\S/.test(data) ) { + // Inspired by code by Andrea Giammarchi + // http://webreflection.blogspot.com/2007/08/global-scope-evaluation-and-dom.html + var head = document.getElementsByTagName("head")[0] || document.documentElement, + script = document.createElement("script"); + + script.type = "text/javascript"; + if ( jQuery.support.scriptEval ) + script.appendChild( document.createTextNode( data ) ); + else + script.text = data; + + // Use insertBefore instead of appendChild to circumvent an IE6 bug. + // This arises when a base node is used (#2709). + head.insertBefore( script, head.firstChild ); + head.removeChild( script ); + } + }, + + nodeName: function( elem, name ) { + return elem.nodeName && elem.nodeName.toUpperCase() == name.toUpperCase(); + }, + + // args is for internal usage only + each: function( object, callback, args ) { + var name, i = 0, length = object.length; + + if ( args ) { + if ( length === undefined ) { + for ( name in object ) + if ( callback.apply( object[ name ], args ) === false ) + break; + } else + for ( ; i < length; ) + if ( callback.apply( object[ i++ ], args ) === false ) + break; + + // A special, fast, case for the most common use of each + } else { + if ( length === undefined ) { + for ( name in object ) + if ( callback.call( object[ name ], name, object[ name ] ) === false ) + break; + } else + for ( var value = object[0]; + i < length && callback.call( value, i, value ) !== false; value = object[++i] ){} + } + + return object; + }, + + prop: function( elem, value, type, i, name ) { + // Handle executable functions + if ( jQuery.isFunction( value ) ) + value = value.call( elem, i ); + + // Handle passing in a number to a CSS property + return typeof value === "number" && type == "curCSS" && !exclude.test( name ) ? + value + "px" : + value; + }, + + className: { + // internal only, use addClass("class") + add: function( elem, classNames ) { + jQuery.each((classNames || "").split(/\s+/), function(i, className){ + if ( elem.nodeType == 1 && !jQuery.className.has( elem.className, className ) ) + elem.className += (elem.className ? " " : "") + className; + }); + }, + + // internal only, use removeClass("class") + remove: function( elem, classNames ) { + if (elem.nodeType == 1) + elem.className = classNames !== undefined ? + jQuery.grep(elem.className.split(/\s+/), function(className){ + return !jQuery.className.has( classNames, className ); + }).join(" ") : + ""; + }, + + // internal only, use hasClass("class") + has: function( elem, className ) { + return elem && jQuery.inArray( className, (elem.className || elem).toString().split(/\s+/) ) > -1; + } + }, + + // A method for quickly swapping in/out CSS properties to get correct calculations + swap: function( elem, options, callback ) { + var old = {}; + // Remember the old values, and insert the new ones + for ( var name in options ) { + old[ name ] = elem.style[ name ]; + elem.style[ name ] = options[ name ]; + } + + callback.call( elem ); + + // Revert the old values + for ( var name in options ) + elem.style[ name ] = old[ name ]; + }, + + css: function( elem, name, force, extra ) { + if ( name == "width" || name == "height" ) { + var val, props = { position: "absolute", visibility: "hidden", display:"block" }, which = name == "width" ? [ "Left", "Right" ] : [ "Top", "Bottom" ]; + + function getWH() { + val = name == "width" ? elem.offsetWidth : elem.offsetHeight; + + if ( extra === "border" ) + return; + + jQuery.each( which, function() { + if ( !extra ) + val -= parseFloat(jQuery.curCSS( elem, "padding" + this, true)) || 0; + if ( extra === "margin" ) + val += parseFloat(jQuery.curCSS( elem, "margin" + this, true)) || 0; + else + val -= parseFloat(jQuery.curCSS( elem, "border" + this + "Width", true)) || 0; + }); + } + + if ( elem.offsetWidth !== 0 ) + getWH(); + else + jQuery.swap( elem, props, getWH ); + + return Math.max(0, Math.round(val)); + } + + return jQuery.curCSS( elem, name, force ); + }, + + curCSS: function( elem, name, force ) { + var ret, style = elem.style; + + // We need to handle opacity special in IE + if ( name == "opacity" && !jQuery.support.opacity ) { + ret = jQuery.attr( style, "opacity" ); + + return ret == "" ? + "1" : + ret; + } + + // Make sure we're using the right name for getting the float value + if ( name.match( /float/i ) ) + name = styleFloat; + + if ( !force && style && style[ name ] ) + ret = style[ name ]; + + else if ( defaultView.getComputedStyle ) { + + // Only "float" is needed here + if ( name.match( /float/i ) ) + name = "float"; + + name = name.replace( /([A-Z])/g, "-$1" ).toLowerCase(); + + var computedStyle = defaultView.getComputedStyle( elem, null ); + + if ( computedStyle ) + ret = computedStyle.getPropertyValue( name ); + + // We should always get a number back from opacity + if ( name == "opacity" && ret == "" ) + ret = "1"; + + } else if ( elem.currentStyle ) { + var camelCase = name.replace(/\-(\w)/g, function(all, letter){ + return letter.toUpperCase(); + }); + + ret = elem.currentStyle[ name ] || elem.currentStyle[ camelCase ]; + + // From the awesome hack by Dean Edwards + // http://erik.eae.net/archives/2007/07/27/18.54.15/#comment-102291 + + // If we're not dealing with a regular pixel number + // but a number that has a weird ending, we need to convert it to pixels + if ( !/^\d+(px)?$/i.test( ret ) && /^\d/.test( ret ) ) { + // Remember the original values + var left = style.left, rsLeft = elem.runtimeStyle.left; + + // Put in the new values to get a computed value out + elem.runtimeStyle.left = elem.currentStyle.left; + style.left = ret || 0; + ret = style.pixelLeft + "px"; + + // Revert the changed values + style.left = left; + elem.runtimeStyle.left = rsLeft; + } + } + + return ret; + }, + + clean: function( elems, context, fragment ) { + context = context || document; + + // !context.createElement fails in IE with an error but returns typeof 'object' + if ( typeof context.createElement === "undefined" ) + context = context.ownerDocument || context[0] && context[0].ownerDocument || document; + + // If a single string is passed in and it's a single tag + // just do a createElement and skip the rest + if ( !fragment && elems.length === 1 && typeof elems[0] === "string" ) { + var match = /^<(\w+)\s*\/?>$/.exec(elems[0]); + if ( match ) + return [ context.createElement( match[1] ) ]; + } + + var ret = [], scripts = [], div = context.createElement("div"); + + jQuery.each(elems, function(i, elem){ + if ( typeof elem === "number" ) + elem += ''; + + if ( !elem ) + return; + + // Convert html string into DOM nodes + if ( typeof elem === "string" ) { + // Fix "XHTML"-style tags in all browsers + elem = elem.replace(/(<(\w+)[^>]*?)\/>/g, function(all, front, tag){ + return tag.match(/^(abbr|br|col|img|input|link|meta|param|hr|area|embed)$/i) ? + all : + front + ">"; + }); + + // Trim whitespace, otherwise indexOf won't work as expected + var tags = elem.replace(/^\s+/, "").substring(0, 10).toLowerCase(); + + var wrap = + // option or optgroup + !tags.indexOf("", "" ] || + + !tags.indexOf("", "" ] || + + tags.match(/^<(thead|tbody|tfoot|colg|cap)/) && + [ 1, "
    ", "
    " ] || + + !tags.indexOf("", "" ] || + + // matched above + (!tags.indexOf("", "" ] || + + !tags.indexOf("", "" ] || + + // IE can't serialize and + + +

    +

    + FanFictionDownLoader +

    + +
    + + +
    + + {% if fic.failure %} +
    + {{ fic.failure }} +
    + {% endif %} +
    + + +
    + + {% if is_login %} + +

    Login and Password

    +
    + {{ site }} requires a Login/Password for this story. + You need to provide your Login/Password for {{ site }} + to download it. +
    +
    +
    Login
    +
    +
    + +
    +
    Password
    +
    +
    + + {% else %} + + + +
    +
    Are you an Adult?
    +
    + + {% endif %} + +
    + +
    + +
    +
    + +
    + Powered by Google App Engine +

    + This is a web front-end to FanFictionDownLoader
    + Copyright © FanFictionDownLoader team +
    + +
    + + +
    +
    + + diff --git a/main.py b/main.py new file mode 100644 index 00000000..d0108126 --- /dev/null +++ b/main.py @@ -0,0 +1,624 @@ +#!/usr/bin/env python +# +# Copyright 2007 Google Inc. +# Copyright 2011 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +import logging +logging.getLogger().setLevel(logging.DEBUG) + +import os +from os.path import dirname, basename, normpath +import re +import sys +import zlib +import urllib +import datetime + +import traceback +from StringIO import StringIO + +## Just to shut up the appengine warning about "You are using the +## default Django version (0.96). The default Django version will +## change in an App Engine release in the near future. Please call +## use_library() to explicitly select a Django version. For more +## information see +## http://code.google.com/appengine/docs/python/tools/libraries.html#Django" +## Note that if you are using the SDK App Engine Launcher and hit an SDK +## Console page first, you will get a django version mismatch error when you +## to go hit one of the application pages. Just change a file again, and +## make sure to hit an app page before the SDK page to clear it. +#os.environ['DJANGO_SETTINGS_MODULE'] = 'settings' +#from google.appengine.dist import use_library +#use_library('django', '1.2') + +from google.appengine.ext import db +from google.appengine.api import taskqueue +from google.appengine.api import users +#from google.appengine.ext import webapp +import webapp2 +from google.appengine.ext.webapp import template +#from google.appengine.ext.webapp2 import util +from google.appengine.runtime import DeadlineExceededError + +from ffstorage import * + +from fanficdownloader import adapters, writers, exceptions +from fanficdownloader.configurable import Configuration + +class UserConfigServer(webapp2.RequestHandler): + + def getUserConfig(self,user,url,fileformat): + + configuration = Configuration(adapters.getConfigSectionFor(url),fileformat) + + logging.debug('reading defaults.ini config file') + configuration.read('defaults.ini') + + ## Pull user's config record. + l = UserConfig.all().filter('user =', user).fetch(1) + if l and l[0].config: + uconfig=l[0] + #logging.debug('reading config from UserConfig(%s)'%uconfig.config) + configuration.readfp(StringIO(uconfig.config)) + + return configuration + +class MainHandler(webapp2.RequestHandler): + def get(self): + user = users.get_current_user() + if user: + error = self.request.get('error') + template_values = {'nickname' : user.nickname(), 'authorized': True} + url = self.request.get('url') + template_values['url'] = url + + if error: + if error == 'login_required': + template_values['error_message'] = 'This story (or one of the chapters) requires you to be logged in.' + elif error == 'bad_url': + template_values['error_message'] = 'Unsupported URL: ' + url + elif error == 'custom': + template_values['error_message'] = 'Error happened: ' + self.request.get('errtext') + elif error == 'configsaved': + template_values['error_message'] = 'Configuration Saved' + elif error == 'recentcleared': + template_values['error_message'] = 'Your Recent Downloads List has been Cleared' + + filename = self.request.get('file') + if len(filename) > 1: + template_values['yourfile'] = '''''' % (filename, self.request.get('name'), self.request.get('author')) + + self.response.headers['Content-Type'] = 'text/html' + path = os.path.join(os.path.dirname(__file__), 'index.html') + + self.response.out.write(template.render(path, template_values)) + else: + logging.debug(users.create_login_url('/')) + url = users.create_login_url(self.request.uri) + template_values = {'login_url' : url, 'authorized': False} + path = os.path.join(os.path.dirname(__file__), 'index.html') + self.response.out.write(template.render(path, template_values)) + + +class EditConfigServer(UserConfigServer): + def get(self): + self.post() + + def post(self): + user = users.get_current_user() + if not user: + self.redirect(users.create_login_url(self.request.uri)) + return + + template_values = {'nickname' : user.nickname(), 'authorized': True} + + ## Pull user's config record. + l = UserConfig.all().filter('user =', user).fetch(1) + if l: + uconfig=l[0] + else: + uconfig=None + + if self.request.get('update'): + if uconfig is None: + uconfig = UserConfig() + uconfig.user = user + uconfig.config = self.request.get('config').encode('utf8')[:10000] ## just in case. + uconfig.put() + try: + # just getting config for testing purposes. + configuration = self.getUserConfig(user,"test1.com","epub") + self.redirect("/?error=configsaved") + except Exception, e: + logging.info("Saved Config Failed:%s"%e) + self.redirect("/?error=custom&errtext=%s"%urlEscape(str(e))) + else: # not update, assume display for edit + if uconfig is not None and uconfig.config: + config = uconfig.config + else: + configfile = open("example.ini","rb") + config = configfile.read() + configfile.close() + template_values['config'] = config + + configfile = open("defaults.ini","rb") + config = configfile.read() + configfile.close() + template_values['defaultsini'] = config + + path = os.path.join(os.path.dirname(__file__), 'editconfig.html') + self.response.headers['Content-Type'] = 'text/html' + self.response.out.write(template.render(path, template_values)) + + +class FileServer(webapp2.RequestHandler): + + def get(self): + fileId = self.request.get('id') + + if fileId == None or len(fileId) < 3: + self.redirect('/') + return + + try: + download = getDownloadMeta(id=fileId) + + name = download.name.encode('utf-8') + + logging.info("Serving file: %s" % name) + + if name.endswith('.epub'): + self.response.headers['Content-Type'] = 'application/epub+zip' + elif name.endswith('.html'): + self.response.headers['Content-Type'] = 'text/html' + elif name.endswith('.txt'): + self.response.headers['Content-Type'] = 'text/plain' + elif name.endswith('.mobi'): + self.response.headers['Content-Type'] = 'application/x-mobipocket-ebook' + elif name.endswith('.zip'): + self.response.headers['Content-Type'] = 'application/zip' + else: + self.response.headers['Content-Type'] = 'application/octet-stream' + + self.response.headers['Content-disposition'] = 'attachment; filename="%s"' % name + + data = DownloadData.all().filter("download =", download).order("index") + # epubs are all already compressed. + # Each chunk is compress individually to avoid having + # to hold the whole in memory just for the + # compress/uncompress + if download.format != 'epub': + def dc(data): + try: + return zlib.decompress(data) + # if error, assume it's a chunk from before we started compessing. + except zlib.error: + return data + else: + def dc(data): + return data + + for datum in data: + self.response.out.write(dc(datum.blob)) + + except Exception, e: + fic = DownloadMeta() + fic.failure = unicode(e) + + template_values = dict(fic = fic, + #nickname = user.nickname(), + #escaped_url = escaped_url + ) + path = os.path.join(os.path.dirname(__file__), 'status.html') + self.response.out.write(template.render(path, template_values)) + +class FileStatusServer(webapp2.RequestHandler): + def get(self): + user = users.get_current_user() + if not user: + self.redirect(users.create_login_url(self.request.uri)) + return + + fileId = self.request.get('id') + + if fileId == None or len(fileId) < 3: + self.redirect('/') + + escaped_url=False + + try: + download = getDownloadMeta(id=fileId) + + if download: + logging.info("Status url: %s" % download.url) + if download.completed and download.format=='epub': + escaped_url = urlEscape(self.request.host_url+"/file/"+download.name+"."+download.format+"?id="+fileId+"&fake=file."+download.format) + else: + download = DownloadMeta() + download.failure = "Download not found" + + except Exception, e: + download = DownloadMeta() + download.failure = unicode(e) + + template_values = dict(fic = download, + nickname = user.nickname(), + escaped_url = escaped_url + ) + path = os.path.join(os.path.dirname(__file__), 'status.html') + self.response.out.write(template.render(path, template_values)) + +class ClearRecentServer(webapp2.RequestHandler): + def get(self): + user = users.get_current_user() + if not user: + self.redirect(users.create_login_url(self.request.uri)) + return + + logging.info("Clearing Recent List for user: "+user.nickname()) + q = DownloadMeta.all() + q.filter('user =', user) + num=0 + while( True ): + results = q.fetch(100) + if results: + for d in results: + d.delete() + for c in d.data_chunks: + c.delete() + num = num + 1 + logging.debug('Delete '+d.url) + else: + break + logging.info('Deleted %d instances download.' % num) + self.redirect("/?error=recentcleared") + +class RecentFilesServer(webapp2.RequestHandler): + def get(self): + user = users.get_current_user() + if not user: + self.redirect(users.create_login_url(self.request.uri)) + return + + q = DownloadMeta.all() + q.filter('user =', user).order('-date') + fics = q.fetch(100) + logging.info("Recent fetched %d downloads for user %s."%(len(fics),user.nickname())) + + for fic in fics: + if fic.completed and fic.format == 'epub': + fic.escaped_url = urlEscape(self.request.host_url+"/file/"+fic.name+"."+fic.format+"?id="+str(fic.key())+"&fake=file."+fic.format) + + template_values = dict(fics = fics, nickname = user.nickname()) + path = os.path.join(os.path.dirname(__file__), 'recent.html') + self.response.out.write(template.render(path, template_values)) + +class AllRecentFilesServer(webapp2.RequestHandler): + def get(self): + user = users.get_current_user() + if not user: + self.redirect(users.create_login_url(self.request.uri)) + return + + q = SavedMeta.all() + if self.request.get('bydate'): + q.order('-date') + else: + q.order('-count') + + fics = q.fetch(200) + logging.info("Recent fetched %d downloads for user %s."%(len(fics),user.nickname())) + + sendslugs = [] + + for fic in fics: + ficslug = FicSlug(fic) + sendslugs.append(ficslug) + + template_values = dict(fics = sendslugs, nickname = user.nickname()) + path = os.path.join(os.path.dirname(__file__), 'allrecent.html') + self.response.out.write(template.render(path, template_values)) + +class FicSlug(): + def __init__(self,savedmeta): + self.url = savedmeta.url + self.count = savedmeta.count + for k, v in savedmeta.meta.iteritems(): + setattr(self,k,v) + +class FanfictionDownloader(UserConfigServer): + def get(self): + self.post() + + def post(self): + logging.getLogger().setLevel(logging.DEBUG) + user = users.get_current_user() + if not user: + self.redirect(users.create_login_url(self.request.uri)) + return + + format = self.request.get('format') + url = self.request.get('url') + + if not url or url.strip() == "": + self.redirect('/') + return + + logging.info("Queuing Download: %s" % url) + login = self.request.get('login') + password = self.request.get('password') + is_adult = self.request.get('is_adult') == "on" + + # use existing record if available. Fetched/Created before + # the adapter can normalize the URL in case we need to record + # an exception. + download = getDownloadMeta(url=url,user=user,format=format,new=True) + + adapter = None + try: + try: + configuration = self.getUserConfig(user,url,format) + except Exception, e: + self.redirect("/?error=custom&errtext=%s"%urlEscape("There's an error in your User Configuration: "+str(e))) + return + + adapter = adapters.getAdapter(configuration,url) + logging.info('Created an adaper: %s' % adapter) + + if len(login) > 1: + adapter.username=login + adapter.password=password + adapter.is_adult=is_adult + + ## This scrapes the metadata, which will be + ## duplicated in the queue task, but it + ## detects bad URLs, bad login, bad story, etc + ## without waiting for the queue. So I think + ## it's worth the double up. Could maybe save + ## it all in the download object someday. + story = adapter.getStoryMetadataOnly() + + ## Fetch again using normalized story URL. The one + ## fetched/created above, if different, will not be saved. + download = getDownloadMeta(url=story.getMetadata('storyUrl'), + user=user,format=format,new=True) + + download.title = story.getMetadata('title') + download.author = story.getMetadata('author') + download.url = story.getMetadata('storyUrl') + download.put() + + taskqueue.add(url='/fdowntask', + queue_name="download", + params={'id':str(download.key()), + 'format':format, + 'url':download.url, + 'login':login, + 'password':password, + 'user':user.email(), + 'is_adult':is_adult}) + + logging.info("enqueued download key: " + str(download.key())) + + except (exceptions.FailedToLogin,exceptions.AdultCheckRequired), e: + download.failure = unicode(e) + download.put() + logging.info(unicode(e)) + is_login= ( isinstance(e, exceptions.FailedToLogin) ) + template_values = dict(nickname = user.nickname(), + url = url, + format = format, + site = adapter.getConfigSection(), + fic = download, + is_login=is_login, + ) + # thewriterscoffeeshop.com can do adult check *and* user required. + if isinstance(e,exceptions.AdultCheckRequired): + template_values['login']=login + template_values['password']=password + + path = os.path.join(os.path.dirname(__file__), 'login.html') + self.response.out.write(template.render(path, template_values)) + return + except (exceptions.InvalidStoryURL,exceptions.UnknownSite,exceptions.StoryDoesNotExist), e: + logging.warn(unicode(e)) + download.failure = unicode(e) + download.put() + except Exception, e: + logging.error("Failure Queuing Download: url:%s" % url) + logging.exception(e) + download.failure = unicode(e) + download.put() + + self.redirect('/status?id='+str(download.key())) + + return + + +class FanfictionDownloaderTask(UserConfigServer): + + def post(self): + logging.getLogger().setLevel(logging.DEBUG) + fileId = self.request.get('id') + # User object can't pass, just email address + user = users.User(self.request.get('user')) + format = self.request.get('format') + url = self.request.get('url') + login = self.request.get('login') + password = self.request.get('password') + is_adult = self.request.get('is_adult') + + logging.info("Downloading: " + url + " for user: "+user.nickname()) + logging.info("ID: " + fileId) + + adapter = None + writerClass = None + + # use existing record if available. + # fileId should have record from /fdown. + download = getDownloadMeta(id=fileId,url=url,user=user,format=format,new=True) + for c in download.data_chunks: + c.delete() + download.put() + + logging.info('Creating adapter...') + + try: + configuration = self.getUserConfig(user,url,format) + adapter = adapters.getAdapter(configuration,url) + + logging.info('Created an adapter: %s' % adapter) + + if len(login) > 1: + adapter.username=login + adapter.password=password + adapter.is_adult=is_adult + + # adapter.getStory() is what does all the heavy lifting. + # adapter.getStoryMetadataOnly() only fetches enough to + # get metadata. writer.writeStory() will call + # adapter.getStory(), too. + writer = writers.getWriter(format,configuration,adapter) + download.name = writer.getOutputFileName() + #logging.debug('output_filename:'+writer.getConfig('output_filename')) + logging.debug('getOutputFileName:'+writer.getOutputFileName()) + download.title = adapter.getStory().getMetadata('title') + download.author = adapter.getStory().getMetadata('author') + download.url = adapter.getStory().getMetadata('storyUrl') + download.put() + + allmeta = adapter.getStory().getAllMetadata(removeallentities=True,doreplacements=False) + + outbuffer = StringIO() + writer.writeStory(outbuffer) + data = outbuffer.getvalue() + outbuffer.close() + del outbuffer + #del writer.adapter + #del writer.story + del writer + #del adapter.story + del adapter + + # epubs are all already compressed. Each chunk is + # compressed individually to avoid having to hold the + # whole in memory just for the compress/uncompress. + if format != 'epub': + def c(data): + return zlib.compress(data) + else: + def c(data): + return data + + index=0 + while( len(data) > 0 ): + DownloadData(download=download, + index=index, + blob=c(data[:1000000])).put() + index += 1 + data = data[1000000:] + download.completed=True + download.put() + + smetal = SavedMeta.all().filter('url =', allmeta['storyUrl'] ).fetch(1) + if smetal and smetal[0]: + smeta = smetal[0] + smeta.count += 1 + else: + smeta=SavedMeta() + smeta.count = 1 + + smeta.url = allmeta['storyUrl'] + smeta.title = allmeta['title'] + smeta.author = allmeta['author'] + smeta.meta = allmeta + smeta.date = datetime.datetime.now() + smeta.put() + + logging.info("Download finished OK") + del data + + except Exception, e: + logging.exception(e) + download.failure = unicode(e) + download.put() + return + + return + +def getDownloadMeta(id=None,url=None,user=None,format=None,new=False): + ## try to get download rec from passed id first. then fall back + ## to user/url/format + download = None + if id: + try: + download = db.get(db.Key(id)) + logging.info("DownloadMeta found by ID:"+id) + except: + pass + + if not download and url and user and format: + try: + q = DownloadMeta.all().filter('user =', user).filter('url =',url).filter('format =',format).fetch(1) + if( q is not None and len(q) > 0 ): + logging.debug("DownloadMeta found by user:%s url:%s format:%s"%(user,url,format)) + download = q[0] + except: + pass + + if new: + # NOT clearing existing chunks here, because this record may + # never be saved. + if not download: + logging.debug("New DownloadMeta") + download = DownloadMeta() + + download.completed=False + download.failure=None + download.date=datetime.datetime.now() + + download.version = "%s:%s" % (os.environ['APPLICATION_ID'],os.environ['CURRENT_VERSION_ID']) + if user: + download.user = user + if url: + download.url = url + if format: + download.format = format + + return download + +def toPercentDecimal(match): + "Return the %decimal number for the character for url escaping" + s = match.group(1) + return "%%%02x" % ord(s) + +def urlEscape(data): + "Escape text, including unicode, for use in URLs" + p = re.compile(r'([^\w])') + return p.sub(toPercentDecimal, data.encode("utf-8")) + +logging.getLogger().setLevel(logging.DEBUG) +app = webapp2.WSGIApplication([('/', MainHandler), + ('/fdowntask', FanfictionDownloaderTask), + ('/fdown', FanfictionDownloader), + (r'/file.*', FileServer), + ('/status', FileStatusServer), + ('/allrecent', AllRecentFilesServer), + ('/recent', RecentFilesServer), + ('/editconfig', EditConfigServer), + ('/clearrecent', ClearRecentServer), + ], + debug=False) diff --git a/makeplugin.py b/makeplugin.py new file mode 100644 index 00000000..231b5408 --- /dev/null +++ b/makeplugin.py @@ -0,0 +1,38 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# epubmerge.py 1.0 + +# Copyright 2011, Jim Miller + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +from glob import glob + +from makezip import createZipFile + +if __name__=="__main__": + filename="FanFictionDownLoader.zip" + exclude=['*.pyc','*~','*.xcf','*[0-9].png'] + # from top dir. 'w' for overwrite + createZipFile(filename,"w", + ['plugin-defaults.ini','plugin-example.ini','epubmerge.py','fanficdownloader'], + exclude=exclude) + #from calibre-plugin dir. 'a' for append + os.chdir('calibre-plugin') + files=['about.txt','images',] + files.extend(glob('*.py')) + files.extend(glob('plugin-import-name-*.txt')) + createZipFile("../"+filename,"a", + files,exclude=exclude) diff --git a/makezip.py b/makezip.py new file mode 100644 index 00000000..55a10197 --- /dev/null +++ b/makezip.py @@ -0,0 +1,54 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# epubmerge.py 1.0 + +# Copyright 2011, Jim Miller + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at + +# http://www.apache.org/licenses/LICENSE-2.0 + +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os, zipfile, sys +from glob import glob + +def addFolderToZip(myZipFile,folder,exclude=[]): + folder = folder.encode('ascii') #convert path to ascii for ZipFile Method + excludelist=[] + for ex in exclude: + excludelist.extend(glob(folder+"/"+ex)) + for file in glob(folder+"/*"): + if file in excludelist: + continue + if os.path.isfile(file): + #print file + myZipFile.write(file, file, zipfile.ZIP_DEFLATED) + elif os.path.isdir(file): + addFolderToZip(myZipFile,file,exclude=exclude) + +def createZipFile(filename,mode,files,exclude=[]): + myZipFile = zipfile.ZipFile( filename, mode ) # Open the zip file for writing + excludelist=[] + for ex in exclude: + excludelist.extend(glob(ex)) + for file in files: + if file in excludelist: + continue + file = file.encode('ascii') #convert path to ascii for ZipFile Method + if os.path.isfile(file): + (filepath, filename) = os.path.split(file) + #print file + myZipFile.write( file, filename, zipfile.ZIP_DEFLATED ) + if os.path.isdir(file): + addFolderToZip(myZipFile,file,exclude=exclude) + myZipFile.close() + return (1,filename) + diff --git a/plugin-defaults.ini b/plugin-defaults.ini new file mode 100644 index 00000000..15a6ebec --- /dev/null +++ b/plugin-defaults.ini @@ -0,0 +1,1206 @@ +# Copyright 2012 Fanficdownloader team +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +[defaults] + +## [defaults] section applies to all formats and sites but may be +## overridden at several levels + +## Some sites also require the user to confirm they are adult for +## adult content. Uncomment by removing '#' in front of is_adult. +#is_adult:true + +## All available titlepage_entries and the label used for them: +## _label: