Repository: Pewpews/happypanda Branch: master Commit: 6acf2560404c Files: 35 Total size: 989.3 KB Directory structure: gitextract_sr3txlm2/ ├── .gitattributes ├── .gitignore ├── CHANGELOG.md ├── INSTALL.md ├── LICENSE ├── LICENSE-3RD-PARTY ├── README.rst ├── VS.txt ├── requirements-dev.txt ├── requirements.txt ├── res/ │ ├── license.txt │ └── style.css ├── tests/ │ ├── database/ │ │ └── test_db.py │ └── test_utils.py └── version/ ├── app.py ├── app_constants.py ├── asm_manager.py ├── color_line_edit.py ├── database/ │ ├── __init__.py │ ├── db.py │ └── db_constants.py ├── executors.py ├── fetch.py ├── gallery.py ├── gallerydb.py ├── gallerydialog.py ├── hplugins.py ├── io_misc.py ├── main.py ├── misc.py ├── misc_db.py ├── pewnet.py ├── settings.py ├── settingsdialog.py └── utils.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitattributes ================================================ ############################################################################### # Set default behavior to automatically normalize line endings. ############################################################################### * text=auto ############################################################################### # Set default behavior for command prompt diff. # # This is need for earlier builds of msysgit that does not have it on by # default for csharp files. # Note: This is only used by command line ############################################################################### #*.cs diff=csharp ############################################################################### # Set the merge driver for project and solution files # # Merging from the command prompt will add diff markers to the files if there # are conflicts (Merging from VS is not affected by the settings below, in VS # the diff markers are never inserted). Diff markers may cause the following # file extensions to fail to load in VS. An alternative would be to treat # these files as binary and thus will always conflict and require user # intervention with every merge. To do so, just uncomment the entries below ############################################################################### #*.sln merge=binary #*.csproj merge=binary #*.vbproj merge=binary #*.vcxproj merge=binary #*.vcproj merge=binary #*.dbproj merge=binary #*.fsproj merge=binary #*.lsproj merge=binary #*.wixproj merge=binary #*.modelproj merge=binary #*.sqlproj merge=binary #*.wwaproj merge=binary ############################################################################### # behavior for image files # # image files are treated as binary by default. ############################################################################### #*.jpg binary #*.png binary #*.gif binary ############################################################################### # diff behavior for common document formats # # Convert binary document formats to text before diffing them. This feature # is only available from the command line. Turn it on by uncommenting the # entries below. ############################################################################### #*.doc diff=astextplain #*.DOC diff=astextplain #*.docx diff=astextplain #*.DOCX diff=astextplain #*.dot diff=astextplain #*.DOT diff=astextplain #*.pdf diff=astextplain #*.PDF diff=astextplain #*.rtf diff=astextplain #*.RTF diff=astextplain ================================================ FILE: .gitignore ================================================ ## Ignore Visual Studio temporary files, build results, and ## files generated by popular Visual Studio add-ons. # custom *cache *.pyproj *Qt* *__pycache__* version/build version/dist *.sln *Thumbs.db settings.json build dist Happypanda setup.py *.ini *.swp .DS_Store downloads .happypanda # pycharm .idea/ # User-specific files *.suo *.user *.sln.docstates # Build results [Dd]ebug/ [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ x64/ x86/ build/ bld/ [Bb]in/ [Oo]bj/ # Roslyn cache directories *.ide/ # MSTest test Results [Tt]est[Rr]esult*/ [Bb]uild[Ll]og.* #NUNIT *.VisualState.xml TestResult.xml # Build Results of an ATL Project [Dd]ebugPS/ [Rr]eleasePS/ dlldata.c *_i.c *_p.c *_i.h *.ilk *.meta *.obj *.pch *.pdb *.pgc *.pgd *.rsp *.sbr *.tlb *.tli *.tlh *.tmp *.tmp_proj *.log *.vspscc *.vssscc .builds *.pidb *.svclog *.scc # Chutzpah Test files _Chutzpah* # Visual C++ cache files ipch/ *.aps *.ncb *.opensdf *.sdf *.cachefile # Visual Studio profiler *.psess *.vsp *.vspx # TFS 2012 Local Workspace $tf/ # Guidance Automation Toolkit *.gpState # ReSharper is a .NET coding add-in _ReSharper*/ *.[Rr]e[Ss]harper *.DotSettings.user # JustCode is a .NET coding addin-in .JustCode # TeamCity is a build add-in _TeamCity* # DotCover is a Code Coverage Tool *.dotCover # NCrunch _NCrunch_* .*crunch*.local.xml # MightyMoose *.mm.* AutoTest.Net/ # Web workbench (sass) .sass-cache/ # Installshield output folder [Ee]xpress/ # DocProject is a documentation generator add-in DocProject/buildhelp/ DocProject/Help/*.HxT DocProject/Help/*.HxC DocProject/Help/*.hhc DocProject/Help/*.hhk DocProject/Help/*.hhp DocProject/Help/Html2 DocProject/Help/html # Click-Once directory publish/ # Publish Web Output *.[Pp]ublish.xml *.azurePubxml # TODO: Comment the next line if you want to checkin your web deploy settings # but database connection strings (with potential passwords) will be unencrypted *.pubxml *.publishproj # NuGet Packages *.nupkg # The packages folder can be ignored because of Package Restore **/packages/* # except build/, which is used as an MSBuild target. !**/packages/build/ # If using the old MSBuild-Integrated Package Restore, uncomment this: #!**/packages/repositories.config # Windows Azure Build Output csx/ *.build.csdef # Windows Store app package directory AppPackages/ # Others sql/ *.Cache ClientBin/ [Ss]tyle[Cc]op.* ~$* *~ *.dbmdl *.dbproj.schemaview *.pfx *.publishsettings node_modules/ # RIA/Silverlight projects Generated_Code/ # Backup & report files from converting an old project file # to a newer Visual Studio version. Backup files are not needed, # because we have git ;-) _UpgradeReport_Files/ Backup*/ UpgradeLog*.XML UpgradeLog*.htm # SQL Server files *.mdf *.ldf # Business Intelligence projects *.rdl.data *.bim.layout *.bim_*.settings # Microsoft Fakes FakesAssemblies/ /db /thumbnails /version/gui/static/gallery_def_ico.ico /version/gui/static/gallery_ext_ico.ico *.pyperf /temp /main.build /main.dist /version/db /test_g.zip /version/temp /version/modeltest.py /[Yamatogawa] Power Play!.zip /test.zip /test.py /setup.spec /roundcorner.png /nuitka.txt /models.py /info.txt /info.json /horopic2.jpg /happypanda-2015-11-10 23-51-59.hpdb /happypanda-2015-11-10 21-29-04.hpdb /happypanda-2015-11-09 21-08-42.hpdb /failg.zip /exHtmlRandomFileName123.html /deploy.bat /Attameaikko.01.zip /[Yamatogawa] Power Play!.zip /[Yamatogawa] Power Play!.zip *.zip /res/typicons /res/gallery_ext_ico.ico /res/sample.png *.hpdb /plugins /compilerconfig.json /compilerconfig.json.defaults /happypanda.VC.db /happypanda.VC.VC.opendb /.vs/config/applicationhost.config /data/happypanda.db /main.spec ================================================ FILE: CHANGELOG.md ================================================ *Happypanda v1.1* - Fixes - Fixed HP settings unusable without internet connection *Happypanda v1.0* * New stuff - New GUI look - New helpful color widgets added to `Settings -> Visual` [rachmadaniHaryono] - Gallery Contextmenu: - Added `Set rating` to quickly set gallery rating - Added `Lookup Artist` to open a new tab on preffered site with the artist's galleries - Added `Reset read count` under `Advanced` - Gallery Lists are now included when exporting gallery data - New sorting option: Gallery Rating - It is now possible to also append tags to galleries instead of replacing when editing - New gallery download source: `asmhentai.com` [rachmadaniHaryono] - New [special namespaced tag](https://github.com/Pewpews/happypanda/wiki/Gallery-Searching#special-namespaced-tags): `URL` - Use like this: `url:none` or `url:g.e-hentai` - Many quality of life changes * Changed stuff - `g.e-hentai` to `e-hentai` - Old URLs will automatically be converted to new on metadata fetch - Displaying rating on galleries is now optional - Improved search history - Improved gallery downloader (now very reliable) [rachmadaniHaryono] - Galleries will automatically be rated 5 stars on favorite - Gallery List edit popup will now appear in the middle of the application - Added a way to relogin into website * Fixes - E-Hentai login & gallery downloading - `date added` metadata wasn't included when exporting gallery data - `last read` Metadata wasn't included when importing gallery data - backup database name would get unusually long [rachmadaniHaryono] - Fixed HDoujin `info.txt` parsing - Newly downloaded galleries would sometimes cause a crash - Attempting to download exhentai links without being logged in would cause a crash - Using the random gallery opener would in rare cases cause a crash - Moving a gallery would cause a crash from a raised PermissionError exception - Fetching metadata for galleries would return multiple unrelated galleries to choose among - Fetching metadata for galleries with a colored cover whose gallery source is an archive would sometimes cause a crash - Galleries with an empty tag field wouldn't show up on a `tag:none` filter search - Gallery Deleted popup would appear when deleting gallery files from the application - Attempting to download removed galleries would cause a crash - Some gallery importing issues *Happypanda v0.30* - Someone finally convinced me into adding star ratings - *Note:* Ratings won't be fetched from EH since I find them useless... Though I might make it an option later on. - External viewer icon on galleries has been removed in favor of this - Visual make-over - Improved how thumbnails are loaded in gridview - Moving files into a monitored folder will now automatically add the galleries into your inbox - Added the following special namespaced tags: - `path:none` to filter galleries that has a broken source - `rating:integer` to filter galleries that has been rated `integer` - read more about them [here](https://github.com/Pewpews/happypanda/wiki/Gallery-Searching) - Updated DB to version 0.26 - Added `Rating` metadata - Fixed bugs: - Attempting to add galleries on a fresh install was causing an exception - Moving files into a monitored folder, and then accepting the pop-up would cause an exception *Happypanda v0.29* - Increased and improved stability and perfomance - Shortened startup time - Galleries are now added dynamically - New feature: Tabs - New inbox tab for new gallery additions - Checking for duplicates will also make a new tab - Gallery deletion will now process smoothly - It is now possible to edit multiple galleries - Type and Langauge in metadata popup window are now clickable to issue a search - Updated DB to version 0.25 - Added views in series table - Visual changes in gridview - Added a recently added indicator - Gallery filetypes will now be displayed with text - Added new options in settings - Removed option: autoadd scanned galleries - Fixed bugs: + Fixed some metadata fetchings bugs + Fixed database import and export issues + Closing gallery metadata popup window caused an exception + Fetching metadata with no internet connection caused an exception + Invalid folders/archives were being picked up by the monitor + Fixed default language issues + Metadata would sometimes fail when doing a filesearch + Thumbnail cache dir was not being cleared + Adding from directory was not possible with single gallery add method *Happypanda v0.28.1* - Fixed bugs: + Fixed typo in external viewer args + Fixed regex not working when in namespace + Fixed thumbnail generation causing an unhandled exception + Moved directories kept their old path + Fixed auto metdata fetcher failing when mixing galleries with colored covers and galleries with greyscale covers + Gallery Metadata window wouldn't stay open + Fixed a DB bug causing all kinds of errors, including: + Editing a gallery while fetching its metadata would cause an exception + Closing the gallery dialog while fetching metadata would cause an exception *Happypanda v0.28* - Improved perfomance of grid view significantly - Galleries are now draggable + It is now possible to add galleries to a list by dragging them to the list - Improved metadata fetching accuracy from EH - Improved gallery lists with new options + It is now possible to enable several search options on per gallerylist basis + A new *Enforce* option to enforce the gallerylist filter - Improved gallery search + New special namespaced tags: `read_count`, `date_added` and `last_read` + Read more about them in the gallery searching guide + New `<` less than and `>` greater than operator to be used with some special namespaced tags + Read about it in the special namespaced tags section in the gallery searching guide - Brought back the old way of gaining access to EX - Only to be used if you can't gain access to EX by logging in normally - Added ability to specify arguments sent to viewer in settings (in the `Advanced` section) + If when opening a gallery only the first image was viewable by your viewer, try change the arguments sent to the viewer - Updated the database to version 0.24 with the addition of new gallerylist fields - Moved regex search option to searchbar - Added grid spacing option in settings (`Visual->Grid View`) - Added folder and file extensions ignoring in settings (`Application->Ignore`) + Folder and file extensions ignoring will work for the directory monitor and *Add gallery..* and *Populate from folder* gallery adding methods - Added new default language: Chinese - Improved and fixed URL parser in gallery-downloader - Custom languages will now be parsed from filenames along with the default languages - Tags are now sorted alphabetically everywhere - Gallerylists in contextmenu are also now sorted - Reason for why metdata fecthing failed is now shown in the failed-to-get-metadata-gallery popup - The current search term will now be upkeeped (upkept?) when switching between views - Disabled some tray messages on linux to prevent crash - The current gallerylist context will now be shown on the statusbar - The keys `del` and `shift + del` are now bound to gallery deletion - Added *exclude/include in auto metadata fetcher* in contextmenu for selection - Bug fixes: + No thumbnails were created for images with incorrect extensions (namely png images with .jpg extension) + Only accounts with access to EX were able to login + Some filesystem events were not being detected + Name parser was not parsing languages + Some gallery attributes to not be added to the db on initial gallery creation + Attempting to fetch metadata while an instance of auto metadata fetcher was already running caused an exception + Gallery wasn't removed in view when removing from the duplicate-galleries popup + Other minor bugs *Happypanda v0.27* - Many visual changes + Including new ribbon indicating gallery type in gridview - New sidebar widget: + New feature: Gallery lists + New feature: Artists list + Moved *NS & Tags* treelist from settings to sidebar widget - Metadata fetcher: + Galleries with multiple hits found will now come last in the fetching process + Added fallback system to fetch metadata from other sources than EH + Currently supports panda.chaika.moe - Gallery downloader should now be more tolerant to mistakes in URLs - Added a "gallery source is missing" indicator in grid view - Removed EH member_id and pass_hash in favor for EH login method - Added new sort option: *last read* - Added option to exclude/include gallery from auto metadata fetcher in the contextmenu - Added general key shortcuts (read about the not so obvious shortcuts [here](https://github.com/Pewpews/happypanda/wiki/Keyboard-Shortcuts)) - Added support for new metafile: *HDoujin downloader*'s default into.txt file - Added support for panda.chaika.moe URLs when fetching metadata - Updated database to version 0.23: - Gallery lists addition - New unique indexes in some tables - Thumbnail paths are now relative (removing the need to rebuild thumbs when moving Happypanda folder) - Settings: + Added option to force support for high DPI displays + Added option to control the gallery size in grid view + Enabled most *Gallery* options in the *Visual* section for OSX + Added options to customize gallery type ribbon colors + Added options to set default gallery values + Added a way to add custom languages in settings + Added option to send deleted files to recycle bin + Added option to hide the sidebar widget on startup - Bug fixes: + Fixed a bug causing some external viewers to only be able to view the first image + Fixed metadata disappearance bug (hopefully, for real this time!) + Fixed decoding issues preventing some galleries from getting imported + Fixed lots of critical database issues requiring a rebuild for updating users + Fixed gallery downloading from g.e-hentai + Fixed bug causing "Show in library" to not work properly + Fixed a bug causing a hang while fetching metadata + Fixed a bug causing autometadata fetcher to sometimes fail fetching for some galleries + Fixed hand when checking for duplicates + Fixed database rebuild issues + Potentially fixed a bug preventing archives from being imported, courtesy of KuroiKitsu + Many other minor bugs *Happypanda v0.26* - Startup is now slighty faster - New redesigned gallery metadata window! + New chapter view in the metadata window + Artist field is now clickable to issue a search for galleries with same artist - Some GUI changes - New advanced gallery search **(make sure to read the search guide found in `Settings -> About -> Search Guide`)** + Case sensitive searching + Whole terms match searching + Terms excluding + New special namespaced tags (Read about them in `Settings -> About -> Search Guide`) - New import/export database feature found in `Settings -> About -> Database` - Added new column in `Skipped paths` window to show what reason caused a file to be skipped - Gallery downloader + Added new batch urls window to gallery downloader + Gallery downloading from `panda.chaika.moe` is now using its new api + Added context menu's to download items + Added download progress on download items + Doubleclicking on finished download items will open its containing folder - Added autocomplete on the artist field in gallery edit dialog - Activated the `last read` attribute on galleries - Improved hash generation - Introducing metafiles: + Files containing gallery metadata in same folder/archive is now detected on import + Only supports [eze](https://github.com/dnsev-h/eze)'s `info.json` files for now - Settings + Moved alot of options around. **Note: Some options will be reset** + Reworded some options and fixed typos + Enabled the `Database` tab in *About* section with import/export database feature - Updated the database to version 0.22 + Database will now be backed up before upgrading - Clicking on the tray icon ballon will now activate Happypanda - Thumbnail regenerating + Added confirmation popup when about to regenerate thumbnails + Application restart is no longer required after regenerating thumbnails + Added confirmation popup asking about if the thumbnail cache should be cleaned before regenerating - Renamed `Random Gallery Opener` to `Open random gallery` and also moved it to the Gallery menu on the toolbar - `Open random gallery` will now only pick a random gallery in current view. + *E.g. switching to the favorite view will make it pick a random gallery among the favorites* - Fixed bugs: + Fixed a bug causing archives downloaded from g.e/ex to fail when trying to add to library + Fixed a bug where fetching galleries from the database would sometimes throw an exception + Fixed a bug causing people running from source to never see the new update notification + Fixed some popup blur bug + Fixed an annoyance where the text cursor would always move to the end when searching + Fixed a bug where `Show in Folder` and `Open folder/archive` in gallery context menu was doing the same thing + Fixed a bug where tags fetched from chaika included underscores + Fixed bug where the notification widget would sometimes not show messages + Fixed bug where chapters added to gallery with directory source would not open correctly *Happypanda v0.25* - Added *Show in folder* entry in gallery contextmenu - Gallery popups + A contextmenu will now be shown when you rightclick a gallery + Added *Skip* button in the metadata gallery chooser popup (the one asking you which gallery you want to extract metadata from) + The text in metadata gallery chooser popups will now wrap + Added tooltips displaying title and artist when hovering galleries in some popups - Settings + A new button allowing you to recreate your thumbnail cache is now in *Settings* -> *Advanced* -> *Gallery* + Added new tab *Downloader* in *Web* section + Renamed *General* tab in *Web* section to *Metadata* + Some options in settings will now show a tooltip explaining the option on hover - You can now go back to previous or to next search terms with the two new buttons beside the search bar (hidden until you actually search something) + Back and Forward keys has been bound to these two buttons (very OS dependent but something like `ALT + LEFT ARROW` etc.) Back and Forward buttons on your mouse should also probably work (*shrugs*) + Added *Use current gallery link* checkbox option in *Web* section - Toolbar + Renamed *Misc* to *Tools* + New *Scan for new galleries* entry in *Gallery* + New *Gallery Downloader* entry in *Tools* - Gallery downloading + Supports archive and torrent downloading + archives will be automatically imported while torrents will be sent to your torrent client + Currently supports ex/g.e gallery urls and panda.chaika.moe gallery/archive urls - Note: downloading archives from ex/g.e will be handled the same way as if you did it in your browser, i.e. it will cost you GP/credits. - Tray icon + You can now manually check for a new update by right clicking on the tray icon + Triggering the tray icon, i.e. clicking on it, will now activate (showing it) the Happypanda window - Fixed bugs: + Fixed a bug where skipped galleries/paths would get moved + Fixed a bug where gallery archives/folders containing images with `.jpeg` and/or capitalized (`.JPG`, etc.) extensions were treated as invalid gallery sources, or causing the program to behave very weird if they managed to get imported somehow + Fixed a bug where you couldn't search with the Regex option turned on + Fixed a bug where changing gallery covers would fail if the previous cover was not deleted or found. + Fixed a bug where non-existent monitored folders were not detected + Fixed a bug in the user settings (*settings.ini*) parsing, hence the reset + Fixed other minor misc. bugs *Happypanda v0.24.1* - Fixed bugs: + Removing a gallery and its files should now work + Popups was staying on top of all windows *Happypanda v0.24* - Mostly gui fixes/improvements + Changed toolbar style and icons + Added new native spinners + Added spinner for the metadata fetching process + Added spinner for initial load + Added spinner for DB activity + Removed sort contextmenu and added it to the toolbar + Removed some space around galleries in grid view + Added kinetic scrolling when scrolling with middlemouse button - New DB Overview window and tab in settings dialog + you can now see all namespaces and tags in the `Namespace and Tags` tab - Pressing the return-key will now open selected galleries - New options in settings dialog + Make extracting archives before opening optional in `Application -> General` + Open chapters sequentially or all at once in `Application -> General` - Added a confirmation when closing while there is still DB activity to avoid data loss - Added log file rotation + When happypanda.log reaches `10 mb` a new file will be made (rotating between 3 files) - Fixed bugs: + Temporarily fixed a critical bug where galleries wouldn't load + Fixed a bug where the tray icon would stay even after closing the application + Fixed a bug where clicking on a tag with no namespace in the Gallery Metdata Popup would search the tag with a blank namespace + Fixed a minor bug where when opening the settings dialog a small window would appear first in a split second *Happypanda v0.23* - Stability and perfomance increase for very large libraries + Instant startup: Galleries are now lazily loaded + Application now supports very large galleries (tested with 10k galleries) + Gallery searching will now scale with amount of galleries (means, no freezes when searching) + Same with adding new galleries. - The gallery window appearing when you click on a gallery is now interactable + Clicking on a link will open it in your default browser + Clicking on a tag will search for the tag - Added some animation and a spinner - Fixed bugs: + Fixed critical bug where slected galleries were not mapped properly. (Which sometimes resulted in wrong galleries being removed) + Fixed a bug where pressing CTRL + A to select all galleries would tell that i has selected the total amount of galleries multipled by 3 + Fixed a bug where the notificationbar would sometiems not hide itself + & other minor bugs *Happypanda v0.22* - Added support for .rar files. + To enable rar support, specify the path to unrar in Settings -> Application -> General. Follow the instructions for your OS. - Fixed most (if not all) gallery importing issues - Added a way to populate form archive. + Note: Subfolders will always be treated as galleries when populating from an archive. - Fixed a bug where users who tries Happypanda for the first time would see the 'rebuilding galleries' dialog. - & other misc. changes *Happypanda v0.21* - The application will now ask if you want to view skipped paths after searching for galleries - Added 'delete successful' in the notificationbar - Bugfixes: + Fixed critical bug: Could not open chapters + If your gallery still won't open then please try re-adding the gallery. + Fixed bug: Covers for archives with no folder in-between were not being found + & other minor bugs *Happypanda v0.20* - Added support for recursively importing of galleries (applies to archives) + Directories in archives will now be noticed when importing + Directories with archives as chapters will now be properly imported - Added drag and drop feature for directories and archives - Galleries that was unsuccesful during gallery fetching will now be displayed in a popup - Added support for directory or archive ignoring - Added support for changing gallery covers - Added: move imported galleries to a specified folder feature - Increased speed of Populate from folder and Add galleries... - Improved title parser to now remove unneecessary whitespaces - Improved gallery hashing to avoid unnecessary hashing - Added 'Add archive' button in chapter dialog - Popups will now center on parent window correctly + It is now possible to move popups by leftclicking and dragging + Added background blur effect when popups are shown - The rebuild galleries popup will now show real progress - Settings: + Added new option: Treat subfolders as galleries + Added new option: Move imported galleries + Added new option: Scroll to new galleries (disabled) + Added new option: Open random gallery chapters + Added new option: Rename gallery source (disabled) + Added new tab in Advanced section: Gallery + Added new options: Gallery renamer (disabled) + Added new tab in Application section: Ignore + Enabled General tab in Application section + Reenabled Display on gallery options - Contextmenu: + When selecting more galleries only options that apply to selected galleries will be shown + It is now possible to favourite/Unfavourite selected galleries + Reenabled removing of selected galleries + Added: Advanced and Change cover - Updated database to version 0.2 - Bugfixes: + Fixed critical bug: not being able to add chapters + Fixed bug: removing a chapter would always remove the first chapter + Fixed bug: fetched metadata title and artist would not be formatted correctly + & other minor bugs *Happypanda v0.19* - Improved stability - Updated and fixed auto metadata fetcher: + Now twice as fast + No more need to restart application because it froze + Updated to support namespace fetching directly from the official API - Improved tag autocompletion in gallery dialog - Added a system tray to notify you about events such as auto metadata fetcher being done - Sorting: + Added a new sort option: Publication Date + Added an indicator to the current sort option. + Your current sort option will now be saved + Increased pecision of date added - Settings: + Added new options: * Continue auto metadata fetcher from where it left off * Use japanese title + Enabled option: * Auto add new galleries on startup + Removed options: * HTML Parsing or API - Bugfixes: + Fixed critical bug: Fetching metadata from exhentai not working + Fixed critical bug: Duplicates were being created in database + Fixed a bug causing the update checker to always fail. *Happypanda v0.18* - Greatly improved stability - Added numbers to show how many galleries are left when fetching for metadata - Possibly fixed a bug causing the *"big changes are about to occur"* popup to never disappear - Fixed auto metadata fetcher (did not work before) *Happypanda v0.17* - Improved UI - Improved stability - Improved the toolbar - + Added a way to find duplicate galleries + Added a random gallery opener + Added a way to fetch metadata for all your galleries - Added a way to automagically fetch metadata from g.e-/exhentai + Fetching metadata is now safer, and should not get you banned - Added a new sort option: Date added - Added a place for gallery hashes in the database - Added folder monitoring support + You will now be informed when you rename, remove or add a gallery source in one of your monitored folders + The application will scan for new galleries in all of your monitored folders on startup - Added a new section in settings dialog: Application + Added new options in settings dialog + Enabled the 'General' tab in the Web section - Bugfixes: + Fixed a bug where you could only open the first chapter of a gallery + Fixed a bug causing the application to crash when populating new galleries + Fixed some issues occuring when adding archive files + Fixed some issues occuring when editing galleries + other small bugfixes - Disabled gallery source type and external program viewer icons because of memory leak (will be reenabled in a later version) - Cleaned up some code *Happypanda v0.16* - A more proper way to search for namespace and tags is now available - Added support for external image viewers - Added support for CBZ - The settings button will now open up a real settings dialog + Tons of new options are now available in the settings dialog - Restyled the grid view - Restyled the tooltip to now show other metadata in grid view - Added troubleshoot, regex and search guides - Fixed bugs: + Application crashing when adding a gallery + Application crashing when refreshing + Namespace & tags not being shown correctly + & other small bugs *Happypanda v0.15* - More options are now available in contextmenu when rightclicking a gallery - It's now possible to add and remove chapters from a gallery - Added a way to select more galleries + More options are now available in contextmenu for selected galleries - Added more columns to tableview + Language + Link + Chapters - Tweaked the grid view to reduce the lag when scrolling - Added 1 more way to add galleries - Already exisiting galleries will now be ignored - Database will now try to auto update to newest version - Updated Database to version 0.16 (breaking previous versions) - Bugfixes *Happypanda v0.14* - New tableview. Switch easily between grid view and table view with the new button beside the searchbar - Now able to add and read ZIP archives (You don't need to extract anymore). + Added temp folder for when opening a chapter - Changed icons to white icons - Added tag autocomplete in series dialog - Searchbar is now enabled for searching + Autocomplete will complete series' titles + Search for title or author + Tag searching is only partially supported. - Added sort options in contextmenu - Title of series is now included in the 'Opening chapter' string - Happypanda will now check for new version on startup - Happypanda will now log errors. + Added a --debug or -d option to create a detailed log - Updated Database version to 0.15 (supports 0.14 & 0.13) + Now with unique tag mappings + A new metadata: times_read *Happypanda v0.13* - First public release ================================================ FILE: INSTALL.md ================================================ This guide will show you how to run from source. A better option is dowloading the latest version for your OS from https://github.com/Pewpews/happypanda/releases If you have any questions, please find me here [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/Pewpews/happypanda?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) I'll try to answer as soon as possible. First make sure you have python of minimum version 3.4 installed. Download from here https://www.python.org/downloads/ - arch: sudo pacman -S python3 - ubuntu: apt-get install python3 - OSX: see below *Note: make sure to mark the 'Add to path' checkbox when available on windows* # Linux 1. Go where you want happypanda to be downloaded (E.g. `cd ~`), and write `git clone https://github.com/Pewpews/happypanda.git` - If it fails with something like 'unrecognized command 'git'' then do: `sudo pacman -S git` (`apt-get install git` on Ubuntu), and try again 2. Install these dependencies: - Qt5 (Install this first) >= 5.4 + `sudo pacman -S qt5-base` (`apt-get install qt5-default` on Ubuntu) - pip + Python 3.4 should've included pip on install. Incase it didn't: `sudo pacman -S python-pip` + Enter the happypanda folder and write `pip3 install -r requirements.txt` - PyQt5 + I'm pretty sure you can install this through pip3, but if not then just `sudo pacman -S python-pyqt5` on Arch + On Ubuntu - `apt-get install python3-pyqt5` - `apt-get install PyQt5` - `apt-get install python3-pyqt5` - `apt-get install python3-pyqt5.qtsql` 3. In the happypanda directory go to the *version* directory and write `python3 main.py` 4. The program should now be running # Windows 1. Go to the frontpage of the happypanda repo and click Download Zip 2. Extract to desired location 3. Install these dependencies: - Qt5 (Install this first) >= 5.4 + Download from https://www.qt.io/download-open-source/#section-2 - pip + Python 3.4 should've included pip on install. Incase it didn't https://pip.pypa.io/en/latest/installing.html Make sure python is in your PATH. (http://stackoverflow.com/questions/6318156/adding-python-path-on-windows-7) + Now open cmd and `cd` to the happypanda folder + Write: `pip install -r requirements.txt` and press enter - PyQt5 + I'm pretty sure you can install this through pip, but here is the download location http://www.riverbankcomputing.com/software/pyqt/download5 (see Binary Packages for windows) 4. Finally, write `python main.py` to run the program 5. The program should now be running. Note: Try renaming the 'main.py' file to 'main.pyw' and then just doubleclick on it to try running without console (not guaranteed to work) # Mac OS X (Note: PyQt5 MUST be installed via Homebrew and NOT via Pip) 1. Install Homebrew (this makes everything easier) - Open Terminal - Run the following + `ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"` + `brew update && brew upgrade --all` 2. To install Python3, PyQt5, and sip (*still in Terminal*) + `brew install PyQt5` 3. To install other dependencies - Download HappyPanda + Go to github.com/Pewpews/happypanda + Press the "Download ZIP" button + UnZip happypanda-master.zip - In Terminal, navigate to the happypanda-master folder (E.g.: `cd /where/ever/you/put/the/folder/happypanda-master`) + Write `pip3 install -r requirements.txt` 5. Running HappyPanda - Run the following + `cd /where/ever/you/put/the/folder/happypanda-master/version` + (For example `cd /Users/username/Downloads/happypanda-master/version`) + `python3 main.py` ================================================ FILE: LICENSE ================================================ Happypanda is a cross platform manga/doujinshi manager with namespace & tag support; Copyright (C) 2016 Pewpews This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. ================================================ FILE: LICENSE-3RD-PARTY ================================================ ----------------------------------------------------------------------------- The MIT License (MIT) applies to: - Beautiful Soup - Robobrowser ----------------------------------------------------------------------------- Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ----------------------------------------------------------------------------- Apache License, Version 2.0 (the "License") applies to: - requests - watchdog ----------------------------------------------------------------------------- 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. ----------------------------------------------------------------------------- applies to: - scandir ----------------------------------------------------------------------------- Copyright (c) 2012, Ben Hoyt 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 Ben Hoyt 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 HOLDER 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. ----------------------------------------------------------------------------- The ISC License applies to: - rarfile ----------------------------------------------------------------------------- Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ----------------------------------------------------------------------------- The GPL v3 applies to: - PyQt5 ----------------------------------------------------------------------------- This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation; either version 2 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. ----------------------------------------------------------------------------- The LGPL applies to: - Qt5 ----------------------------------------------------------------------------- This library is free software; you can redistribute it and/or modify it under the terms of the GNU Lesser General Public License as published by the Free Software Foundation; either version 2.1 of the License, or (at your option) any later version. This library is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more details. You should have received a copy of the GNU Lesser General Public License along with this library; if not, write to the Free Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. ----------------------------------------------------------------------------- The "BSD" License applies to: - Send2Trash ----------------------------------------------------------------------------- Copyright (c) 2013, Hardcoded Software, http://www.hardcoded.net 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 Hardcoded Software Inc. 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 HOLDER 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. --------------------------------------------------------------------------- The Python Imaging Library (PIL) is Copyright © 1997-2011 by Secret Labs AB Copyright © 1995-2011 by Fredrik Lundh --------------------------------------------------------------------------- By obtaining, using, and/or copying this software and/or its associated documentation, you agree that you have read, understood, and will comply with the following terms and conditions: Permission to use, copy, modify, and distribute this software and its associated documentation for any purpose and without fee is hereby granted, provided that the above copyright notice appears in all copies, and that both that copyright notice and this permission notice appear in supporting documentation, and that the name of Secret Labs AB or the author not be used in advertising or publicity pertaining to distribution of the software without specific, written prior permission. SECRET LABS AB AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE, INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL SECRET LABS AB OR THE AUTHOR BE LIABLE FOR ANY SPECIAL, INDIRECT OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ================================================ FILE: README.rst ================================================ Work on this program has been halted in favor of its successor `HappyPanda X `__ (bugs and such won't be fixed). =========== Follow me on twitter to keep up to date with HPX: .. image:: https://img.shields.io/twitter/follow/pewspew.svg?style=social&label=Follow :target: https://twitter.com/twiddly_ This is a cross platform manga/doujinshi manager with namespace & tag support. Features ======== - Portable, self-contained in folder and cross-platform - Low memory footprint - Advanced gallery search with regex support (`learn more about it here `__) - Gallery tagging: userdefined namespaces and tags - Gallery metadata fetching from the web (supports various sources) - Gallery downloading from the web (supports various sources) \* - Folder monitoring that'll notify you of filesystem changes - Multiple ways of adding galleries to make it as convienient as possible! - Recursive directory/archive scanning - Supports ZIP/CBZ, RAR/CBR and directories with loose files - Very customizable - And lots more... \* Gallery downloading from E-Hentai costs Credits/GP Screenshots =========== .. image:: https://github.com/Pewpews/happypanda/raw/master/misc/screenshot1.png :width: 100% :align: center .. image:: https://github.com/Pewpews/happypanda/raw/master/misc/screenshot2.png :width: 100% :align: center .. image:: https://github.com/Pewpews/happypanda/raw/master/misc/screenshot3.png :width: 100% :align: center How to install and run ====================== Windows ^^^^^^^ #. Download the archive from `releases `__ #. Extract the archive to its own folder #. Find Happypanda.exe and double click on it! Mac and Linux ^^^^^^^^^^^^^ Install from PYPI or see `INSTALL.md `__ PYPI ^^^^^^^^^^^^^ ``pip install happypanda`` (thanks `@Evolution0 `__) and then run with ``happypanda --home`` Note: use of the ``--home`` flag will make happypanda create required files and directories at: On windows: ``'C:\Users\YourName\AppData\Local\Pewpew\Happypanda'`` On mac: ``'/Users/YourName/Library/Application Support/Happypanda'`` On linux: ``'/home/YourName/.local/share/Happypanda'`` Updating ======== | Overwrite your previous installation. | More info in the `wiki `__ PYPI ^^^^^^^^^^^^^ ``pip install --upgrade happypanda`` Misc. ===== For general documentation (how to add galleries and usage of the search), check the `wiki `__. People wanting to import galleries from the Pururin database torrent should find `this `__ useful. Dependencies ============ - Qt5 (Install this first) >= 5.4 - PyQt5 (pip) - requests (pip) - beautifulsoup4 (pip) - watchdog (pip) - scandir (pip) - rarfile (pip) - robobrowser (pip) - Send2Trash (pip) - Pillow (pip) or PIL - python-dateutil (pip) - QtAwesome (pip) - appdirs (pip) Contributing ============ Please refer to ``HappypandaX`` instead. ================================================ FILE: VS.txt ================================================ 1.1 ================================================ FILE: requirements-dev.txt ================================================ -r requirements.txt pytest==3.0.3 ================================================ FILE: requirements.txt ================================================ pyqt5 requests beautifulsoup4 scandir rarfile watchdog robobrowser Send2Trash pillow python-dateutil QtAwesome==0.3.3 ================================================ FILE: res/license.txt ================================================ btn_star2.png |________________________________________ https://www.iconfinder.com/iconsets/woothemesiconset | ------------------------------------------------------ ================================================ FILE: res/style.css ================================================ DoNotDelete { } QLabel#author { font-weight:lighter; } AppWindow > QToolBar, QStatusBar, SideBarWidget { background-color: #2A2D31; border: none; } AppWindow > QToolBar::sunken, AppWindow > QStatusBar::item { border: none; } AppWindow > QStatusBar > QWidget, QStatusBar > QLabel { color:white; border: none; } Loading > QWidget { background-color:rgba(0, 0, 0, 0.65); } BasePopup QLabel, BaseUserChoice QLabel{ color:white; } BasePopup > QFrame, BaseUserChoice > QFrame{ background-color:rgba(0, 0, 0, 0.85); border-radius: 1em; } QScrollBar:vertical { width:1em; } QScrollBar:horizontal { height:1em; } QScrollBar:vertical, QScrollBar:horizontal { border: 0px solid #2A2D31; background:none; margin: 0px 0px 0px 0px; } QScrollBar::handle { background: #2A2D31; } QScrollBar::handle:vertical, QScrollBar::handle:horizontal { min-height: 7em; } QScrollBar::add-line:vertical { background: #2A2D31; height: 0px; subcontrol-position: bottom; subcontrol-origin: margin; } QScrollBar::add-line:horizontal { background: #2A2D31; width: 0px; subcontrol-position: right; subcontrol-origin: margin; } QScrollBar::sub-line:vertical { background: #2A2D31; height: 0px; subcontrol-position: top; subcontrol-origin: margin; } QScrollBar::sub-line:horizontal { background: #2A2D31; width: 0px; subcontrol-position: left; subcontrol-origin: margin; } QScrollBar::add-page, QScrollBar::sub-page { background: none; } QMenu { background-color: #2A2D31; color: white; } QMenu::item:selected { background-color: #d64933; } MangaView { border: 0px solid; } QLabel { color: #d64933; } QPushButton { border: 1px solid #3E4249; border-radius: 1px; padding: 5px; } QPushButton, QToolButton { background-color: #3E4249; color: white; } QPushButton:hover, QToolButton:hover { border: 1px solid #d64933; } QPushButton:pressed, QPushButton:checked { background-color: #d64933; } QToolTip { border-style: none; background-color: #2A2D31; color: white; } NotificationOverlay > QLabel { color: white; background-color: #3E4249; border-style: none; } TagText { padding-right: 0.7em; padding-left: 0.7em; padding-bottom: 0.2em; padding-top: 0.1em; border-radius: 0.45em; border: 0.1em solid #d64933; background-color: rgba(62, 66, 73,0.50) !important; } ArrowWindow { background-color: #2A2D31; color: #d64933; border: 1px solid #d64933; border-radius: 5px; } GalleryMetaWindow QScrollArea QWidget, QHeaderView::section, QHeaderView::section::checked { background-color: #2A2D31; } SettingsDialog, SettingsDialog QScrollArea QWidget, GalleryDialog, GalleryDialog QScrollArea QWidget { color: #2A2D31; } GalleryDialog QScrollArea QWidget QPushButton, SettingsDialog QScrollArea QWidget QPushButton{ color: white; } QHeaderView::section { color: white; } QHeaderView::section { border-style: none; padding: 5px; } QGroupBox::title, QHeaderView::down-arrow, QHeaderView::up-arrow { color: #d64933; } QListView, QTableView { border-style: none; } QProgressBar { border: 2px solid #3E4249; border-radius: 5px; } QProgressBar::chunk { background-color: #d64933; width: 20px; } ================================================ FILE: tests/database/test_db.py ================================================ """test db module.""" from itertools import product from unittest import mock import pytest @pytest.mark.parametrize( 'path_isfile_retval, check_dbv_retval, path_is_dbc_path', product([False, True], repeat=3) ) def test_init_db(path_isfile_retval, check_dbv_retval, path_is_dbc_path): """test sqlite generation and db creation""" with mock.patch('version.database.db.db_constants') as m_dbc, \ mock.patch('version.database.db.sqlite3') as m_sl3, \ mock.patch('version.database.db.os') as m_os, \ mock.patch('version.database.db.create_db_path') as m_create_db_path, \ mock.patch('version.database.db.check_db_version') \ as m_check_dbv: from version.database import db m_os.path.isfile.return_value = path_isfile_retval m_check_dbv.return_value = check_dbv_retval if path_is_dbc_path: path = m_dbc.DB_PATH else: path = mock.Mock() # run res = db.init_db(path) # test if path_isfile_retval: if path == m_dbc.DB_PATH and not check_dbv_retval: m_sl3.assert_has_calls([ mock.call.connect(path, check_same_thread=False), ]) assert res is None return else: m_sl3.assert_has_calls([ mock.call.connect(path, check_same_thread=False), mock.call.connect().execute('PRAGMA foreign_keys = on') ]) else: m_create_db_path.assert_called_once_with() m_sl3.assert_has_calls([ mock.call.connect(path, check_same_thread=False), mock.call.connect().cursor(), mock.call.connect().cursor().execute( 'CREATE TABLE IF NOT EXISTS version(version REAL)'), mock.call.connect().cursor().execute( 'INSERT INTO version(version) VALUES(?)', (m_dbc.CURRENT_DB_VERSION,) ), mock.call.connect().cursor().executescript(db.STRUCTURE_SCRIPT), mock.call.connect().commit(), mock.call.connect().execute('PRAGMA foreign_keys = on') ]) assert res == m_sl3.connect.return_value assert res.isolation_level is None ================================================ FILE: tests/test_utils.py ================================================ """test utils module.""" from unittest import mock from itertools import product import pytest from version.utils import backup_database @pytest.mark.parametrize( 'mock_exists_retval, mock_isdir_retval', product([True, False], repeat=2) ) def test_run_backup_database(mock_exists_retval, mock_isdir_retval): """test run with mock obj as input.""" mock_db_path = mock.Mock() mock_base_path = mock.Mock() mock_name = mock.Mock() with mock.patch('version.utils.os') as mock_os, \ mock.patch('version.utils.shutil') as mock_shutil, \ mock.patch('version.utils.datetime') as mock_datetime: mock_datetime.datetime.today.return_value = '2016-10-25 15:42:47.649416' mock_os.path.split.return_value = (mock_base_path, mock_name) mock_os.path.exists.return_value = mock_exists_retval mock_os.path.isdir.return_value = mock_isdir_retval res = backup_database(mock_db_path) assert res mock_datetime.datetime.today.assert_called_once_with() os_calls = [ mock.call.path.split(mock_db_path), mock.call.path.join(mock_base_path, 'backup'), mock.call.path.isdir(mock_os.path.join.return_value), mock.call.path.join( mock_os.path.join.return_value, "2016-10-25-{}".format(mock_name)), mock.call.path.exists(mock_os.path.join.return_value), ] if mock_exists_retval: if mock_isdir_retval: assert len(mock_os.mock_calls) == 103 else: assert len(mock_os.mock_calls) == 104 os_calls.extend([ mock.call.path.join( mock_os.path.join.return_value, "2016-10-25(1)-2016-10-25-{}".format(mock_name)), mock.call.path.join( mock_os.path.join.return_value, "2016-10-25(2)-2016-10-25-{}".format(mock_name)), ]) assert not mock_shutil.mock_calls else: if mock_isdir_retval: assert len(mock_os.mock_calls) == 5 else: assert len(mock_os.mock_calls) == 6 mock_shutil.copyfile.assert_called_once_with( mock_db_path, mock_os.path.join.return_value) if mock_isdir_retval: assert not mock_os.mkdir.called else: mock_os.mkdir.assert_called_once_with(mock_os.path.join.return_value) mock_os.assert_has_calls(os_calls, any_order=True) ================================================ FILE: version/app.py ================================================ #""" #This file is part of Happypanda. #Happypanda is free software: you can redistribute it and/or modify #it under the terms of the GNU General Public License as published by #the Free Software Foundation, either version 2 of the License, or #any later version. #Happypanda is distributed in the hope that it will be useful, #but WITHOUT ANY WARRANTY; without even the implied warranty of #MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #GNU General Public License for more details. #You should have received a copy of the GNU General Public License #along with Happypanda. If not, see . #""" import sys import logging import os import threading import re import requests import scandir import random import traceback from PyQt5.QtCore import (Qt, QSize, pyqtSignal, QThread, QEvent, QTimer, QObject, QPoint, QPropertyAnimation) from PyQt5.QtGui import (QPixmap, QIcon, QMoveEvent, QCursor, QKeySequence) from PyQt5.QtWidgets import (QMainWindow, QListView, QHBoxLayout, QFrame, QWidget, QVBoxLayout, QLabel, QStackedLayout, QToolBar, QMenuBar, QSizePolicy, QMenu, QAction, QLineEdit, QSplitter, QMessageBox, QFileDialog, QDesktopWidget, QPushButton, QCompleter, QListWidget, QListWidgetItem, QToolTip, QProgressBar, QToolButton, QSystemTrayIcon, QShortcut, QGraphicsBlurEffect, QTableWidget, QTableWidgetItem, QActionGroup) from executors import Executors import app_constants import misc import gallery import io_misc import settingsdialog import gallerydialog import fetch import gallerydb import settings import pewnet import utils import misc_db import database log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical class AppWindow(QMainWindow): "The application's main window" move_listener = pyqtSignal() login_check_invoker = pyqtSignal() db_startup_invoker = pyqtSignal(list) duplicate_check_invoker = pyqtSignal(gallery.GalleryModel) admin_db_method_invoker = pyqtSignal(object) db_activity_checker = pyqtSignal() graphics_blur = QGraphicsBlurEffect() def __init__(self, disable_excepthook=False): super().__init__() if not disable_excepthook: sys.excepthook = self.excepthook app_constants.GENERAL_THREAD = QThread(self) app_constants.GENERAL_THREAD.finished.connect(app_constants.GENERAL_THREAD.deleteLater) app_constants.GENERAL_THREAD.start() self.check_site_logins() self._db_startup_thread = QThread(self) self._db_startup_thread.finished.connect(self._db_startup_thread.deleteLater) self.db_startup = gallerydb.DatabaseStartup() self._db_startup_thread.start() self.db_startup.moveToThread(self._db_startup_thread) self.db_startup.DONE.connect(lambda: self.scan_for_new_galleries() if app_constants.LOOK_NEW_GALLERY_STARTUP else None) self.db_startup_invoker.connect(self.db_startup.startup) self.setAcceptDrops(True) self.initUI() self.startup() QTimer.singleShot(3000, self._check_update) self.setFocusPolicy(Qt.NoFocus) self.set_shortcuts() self.graphics_blur.setParent(self) def set_shortcuts(self): quit = QShortcut(QKeySequence('Ctrl+Q'), self, self.close) search_focus = QShortcut(QKeySequence(QKeySequence.Find), self, lambda:self.search_bar.setFocus(Qt.ShortcutFocusReason)) prev_view = QShortcut(QKeySequence(QKeySequence.PreviousChild), self, self.switch_display) next_view = QShortcut(QKeySequence(QKeySequence.NextChild), self, self.switch_display) help = QShortcut(QKeySequence(QKeySequence.HelpContents), self, lambda:utils.open_web_link("https://github.com/Pewpews/happypanda/wiki")) def check_site_logins(self): # checking logins # need to do this to avoid settings dialog locking up class LoginCheck(QObject): def __init__(self): super().__init__() def check(self): for s in settings.ExProperties.sites: ex = settings.ExProperties(s) if ex.cookies: if s == settings.ExProperties.EHENTAI: pewnet.EHen.check_login(ex.cookies) logincheck = LoginCheck() self.login_check_invoker.connect(logincheck.check) logincheck.moveToThread(app_constants.GENERAL_THREAD) self.login_check_invoker.emit() def init_watchers(self): def remove_gallery(g): index = gallery.CommonView.find_index(self.get_current_view(), g.id, True) if index: gallery.CommonView.remove_gallery(self.get_current_view(), [index]) else: log_e('Could not find gallery to remove from watcher') def update_gallery(g): index = gallery.CommonView.find_index(self.get_current_view(), g.id) if index: gal = index.data(gallery.GalleryModel.GALLERY_ROLE) gal.path = g.path gal.chapters = g.chapters else: log_e('Could not find gallery to update from watcher') self.default_manga_view.replace_gallery(g, False) def created(path): self.gallery_populate([path]) def modified(path, gallery): mod_popup = io_misc.ModifiedPopup(path, gallery, self) def deleted(path, gallery): d_popup = io_misc.DeletedPopup(path, gallery, self) d_popup.UPDATE_SIGNAL.connect(update_gallery) d_popup.REMOVE_SIGNAL.connect(remove_gallery) def moved(new_path, gallery): mov_popup = io_misc.MovedPopup(new_path, gallery, self) mov_popup.UPDATE_SIGNAL.connect(update_gallery) self.watchers = io_misc.Watchers() self.watchers.gallery_handler.CREATE_SIGNAL.connect(created) self.watchers.gallery_handler.MODIFIED_SIGNAL.connect(modified) self.watchers.gallery_handler.MOVED_SIGNAL.connect(moved) self.watchers.gallery_handler.DELETED_SIGNAL.connect(deleted) def startup(self): def normalize_first_time(): settings.set(app_constants.INTERNAL_LEVEL, 'Application', 'first time level') settings.save() def done(status=True): self.db_startup_invoker.emit(gallery.MangaViews.manga_views) #self.db_startup.startup() if app_constants.FIRST_TIME_LEVEL != app_constants.INTERNAL_LEVEL: normalize_first_time() if app_constants.UPDATE_VERSION != app_constants.vs: settings.set(app_constants.vs, 'Application', 'version') if app_constants.UPDATE_VERSION != app_constants.vs: pop = misc.BasePopup(self, blur=False) ml = QVBoxLayout(pop.main_widget) ml.addWidget(QLabel("\nGoodbye Happypanda!\n\n\nHello, this is the last release of 'old' Happypanda.\n"+ "This means that I (personally) won't be adding any new features or fix bugs.\n\n"+ "I have started a new project where I (with the help of others)\n try to create a better Happypanda from scratch.\n\n"+ "Please follow me on twitter (@pewspew) to keep yourself updated!\n")) ml.addLayout(pop.buttons_layout) pop.add_buttons("close")[0].clicked.connect(pop.close) pop.adjustSize() pop.show() if app_constants.ENABLE_MONITOR and \ app_constants.MONITOR_PATHS and all(app_constants.MONITOR_PATHS): self.init_watchers() self.download_manager = pewnet.Downloader() app_constants.DOWNLOAD_MANAGER = self.download_manager self.download_manager.start_manager(4) eh_url = app_constants.DEFAULT_EHEN_URL if 'g.e-h' in eh_url or 'http://' in eh_url: # reset default hen eh_url_n = 'https://e-hentai.org/' settings.set(eh_url_n, 'Web', 'default ehen url') settings.save() app_constants.DEFAULT_EHEN_URL = eh_url_n done() def initUI(self): self.center = QWidget() self._main_layout = QHBoxLayout(self.center) self._main_layout.setSpacing(0) self._main_layout.setContentsMargins(0,0,0,0) self.init_stat_bar() self.manga_views = {} self._current_manga_view = None self.default_manga_view = gallery.MangaViews(app_constants.ViewType.Default, self, True) def refresh_view(): self.current_manga_view.sort_model.refresh() self.db_startup.DONE.connect(refresh_view) self.manga_list_view = self.default_manga_view.list_view self.manga_table_view = self.default_manga_view.table_view self.manga_list_view.gallery_model.STATUSBAR_MSG.connect(self.stat_temp_msg) self.manga_list_view.STATUS_BAR_MSG.connect(self.stat_temp_msg) self.manga_table_view.STATUS_BAR_MSG.connect(self.stat_temp_msg) self.sidebar_list = misc_db.SideBarWidget(self) self.db_startup.DONE.connect(self.sidebar_list.tags_tree.setup_tags) self._main_layout.addWidget(self.sidebar_list) self.current_manga_view = self.default_manga_view #self.display_widget.setSizePolicy(QSizePolicy.Expanding, #QSizePolicy.Preferred) self.download_window = io_misc.GalleryDownloader(self) self.download_window.hide() # init toolbar self.init_toolbar() log_d('Create statusbar: OK') self.system_tray = misc.SystemTray(QIcon(app_constants.APP_ICO_PATH), self) app_constants.SYSTEM_TRAY = self.system_tray tray_menu = QMenu(self) self.system_tray.setContextMenu(tray_menu) self.system_tray.setToolTip('Happypanda {}'.format(app_constants.vs)) tray_quit = QAction('Quit', tray_menu) tray_update = tray_menu.addAction('Check for update') tray_update.triggered.connect(self._check_update) tray_menu.addAction(tray_quit) tray_quit.triggered.connect(self.close) self.system_tray.show() def tray_activate(r=None): if not r or r == QSystemTrayIcon.Trigger: self.showNormal() self.activateWindow() self.system_tray.messageClicked.connect(tray_activate) self.system_tray.activated.connect(tray_activate) log_d('Create system tray: OK') #self.display.addWidget(self.chapter_main) self.setCentralWidget(self.center) self.setWindowIcon(QIcon(app_constants.APP_ICO_PATH)) props = settings.win_read(self, 'AppWindow') if props.resize: x, y = props.resize self.resize(x, y) else: self.resize(app_constants.MAIN_W, app_constants.MAIN_H) self.setMinimumWidth(600) self.setMinimumHeight(400) misc.centerWidget(self) self.init_spinners() self.show() log_d('Show window: OK') self.notification_bar = misc.NotificationOverlay(self) p = self.toolbar.pos() self.notification_bar.move(p.x(), p.y() + self.toolbar.height()) self.notification_bar.resize(self.width()) self.notif_bubble = misc.AppBubble(self) app_constants.NOTIF_BAR = self.notification_bar app_constants.NOTIF_BUBBLE = self.notif_bubble log_d('Create notificationbar: OK') log_d('Window Create: OK') def _check_update(self): class upd_chk(QObject): UPDATE_CHECK = pyqtSignal(str) def __init__(self, **kwargs): super().__init__(**kwargs) def fetch_vs(self): import requests import time log_d('Checking Update') time.sleep(1.5) try: r = requests.get("https://raw.githubusercontent.com/Pewpews/happypanda/master/VS.txt") a = r.text vs = a.strip() self.UPDATE_CHECK.emit(vs) except: log.exception('Checking Update: FAIL') self.UPDATE_CHECK.emit('this is a very long text which is sure to be over limit') def check_update(vs): log_i('Received version: {}\nCurrent version: {}'.format(vs, app_constants.vs)) if vs != app_constants.vs: if len(vs) < 10: self.notification_bar.begin_show() self.notification_bar.add_text("Version {} of Happypanda is".format(vs) + " available. Click here to update!", False) self.notification_bar.clicked.connect(lambda: utils.open_web_link('https://github.com/Pewpews/happypanda/releases')) self.notification_bar.set_clickable(True) else: self.notification_bar.add_text("An error occurred while checking for new version") self.update_instance = upd_chk() thread = QThread(self) self.update_instance.moveToThread(thread) thread.started.connect(self.update_instance.fetch_vs) self.update_instance.UPDATE_CHECK.connect(check_update) self.update_instance.UPDATE_CHECK.connect(self.update_instance.deleteLater) thread.finished.connect(thread.deleteLater) thread.start() def _web_metadata_picker(self, gallery, title_url_list, queue, parent=None): if not parent: parent = self text = "Which gallery do you want to extract metadata from?" s_gallery_popup = misc.SingleGalleryChoices(gallery, title_url_list, text, parent) s_gallery_popup.USER_CHOICE.connect(queue.put) def get_metadata(self, gal=None): if not app_constants.GLOBAL_EHEN_LOCK: metadata_spinner = misc.Spinner(self) metadata_spinner.set_text("Metadata") metadata_spinner.set_size(55) thread = QThread(self) thread.setObjectName('App.get_metadata') fetch_instance = fetch.Fetch() if gal: if not isinstance(gal, list): galleries = [gal] else: galleries = gal else: if app_constants.CONTINUE_AUTO_METADATA_FETCHER: galleries = [g for g in self.current_manga_view.gallery_model._data if not g.exed] else: galleries = self.current_manga_view.gallery_model._data if not galleries: self.notification_bar.add_text('All galleries has already been processed!') return None fetch_instance.galleries = galleries self.notification_bar.begin_show() fetch_instance.moveToThread(thread) def done(status): self.notification_bar.end_show() gallerydb.execute(database.db.DBBase.end, True) try: fetch_instance.deleteLater() except RuntimeError: pass if not isinstance(status, bool): galleries = [] for tup in status: galleries.append(tup[0]) class GalleryContextMenu(QMenu): app_instance = self def __init__(self, parent=None): super().__init__(parent) show_in_library_act = self.addAction('Show in library') show_in_library_act.triggered.connect(self.show_in_library) def show_in_library(self): index = gallery.CommonView.find_index(self.app_instance.get_current_view(), self.gallery_widget.gallery.id, True) if index: gallery.CommonView.scroll_to_index(self.app_instance.get_current_view(), index) g_popup = io_misc.GalleryPopup(('Fecthing metadata for these galleries failed.' + ' Check happypanda.log for details.', galleries), self, menu=GalleryContextMenu) errors = {g[0].id: g[1] for g in status} for g_item in g_popup.get_all_items(): g_item.extra_text.setText("{}".format(errors[g_item.gallery.id])) g_item.extra_text.show() g_popup.graphics_blur.setEnabled(False) close_button = g_popup.add_buttons('Close')[0] close_button.clicked.connect(g_popup.close) database.db.DBBase.begin() fetch_instance.GALLERY_PICKER.connect(self._web_metadata_picker) fetch_instance.GALLERY_EMITTER.connect(self.default_manga_view.replace_gallery) fetch_instance.AUTO_METADATA_PROGRESS.connect(self.notification_bar.add_text) thread.started.connect(fetch_instance.auto_web_metadata) fetch_instance.FINISHED.connect(done) fetch_instance.FINISHED.connect(metadata_spinner.before_hide) thread.finished.connect(thread.deleteLater) thread.start() #fetch_instance.auto_web_metadata() metadata_spinner.show() else: self.notif_bubble.update_text("Oops!", "Auto metadata fetcher is already running...") def init_stat_bar(self): self.status_bar = self.statusBar() self.status_bar.setSizeGripEnabled(False) self.stat_info = QLabel() self.stat_info.setIndent(5) self.sort_main = QAction("Asc", self) sort_menu = QMenu() self.sort_main.setMenu(sort_menu) s_by_title = QAction("Title", sort_menu) s_by_artist = QAction("Artist", sort_menu) sort_menu.addAction(s_by_title) sort_menu.addAction(s_by_artist) self.status_bar.addPermanentWidget(self.stat_info) #self.status_bar.addAction(self.sort_main) self.temp_msg = QLabel() self.temp_timer = QTimer() app_constants.STAT_MSG_METHOD = self.stat_temp_msg def stat_temp_msg(self, msg): self.temp_timer.stop() self.temp_msg.setText(msg) self.status_bar.addWidget(self.temp_msg) self.temp_timer.timeout.connect(self.temp_msg.clear) self.temp_timer.setSingleShot(True) self.temp_timer.start(5000) def stat_row_info(self): r = self.current_manga_view.get_current_view().sort_model.rowCount() t = self.current_manga_view.get_current_view().gallery_model.rowCount() g_l = self.get_current_view().sort_model.current_gallery_list if g_l: self.stat_info.setText("{} | Showing {} of {} ".format(g_l.name, r, t)) else: self.stat_info.setText("Showing {} of {} ".format(r, t)) def set_current_manga_view(self, v): self.current_manga_view = v @property def current_manga_view(self): return self._current_manga_view @current_manga_view.setter def current_manga_view(self, new_view): if self._current_manga_view: self._main_layout.takeAt(1) self._current_manga_view = new_view self._main_layout.insertLayout(1, new_view.view_layout, 1) self.stat_row_info() def init_spinners(self): # fetching spinner self.data_fetch_spinner = misc.Spinner(self, "center") self.data_fetch_spinner.set_size(80) self.manga_list_view.gallery_model.ADD_MORE.connect(self.data_fetch_spinner.show) self.db_startup.START.connect(self.data_fetch_spinner.show) self.db_startup.PROGRESS.connect(self.data_fetch_spinner.set_text) self.manga_list_view.gallery_model.ADDED_ROWS.connect(self.data_fetch_spinner.before_hide) self.db_startup.DONE.connect(self.data_fetch_spinner.before_hide) ## deleting spinner #self.gallery_delete_spinner = misc.Spinner(self) #self.gallery_delete_spinner.set_size(40,40) ##self.gallery_delete_spinner.set_text('Removing...') #self.manga_list_view.gallery_model.rowsAboutToBeRemoved.connect(self.gallery_delete_spinner.show) #self.manga_list_view.gallery_model.rowsRemoved.connect(self.gallery_delete_spinner.before_hide) def search(self, srch_string): "Args should be Search Enums" self.search_bar.setText(srch_string) self.search_backward.setVisible(True) args = [] if app_constants.GALLERY_SEARCH_REGEX: args.append(app_constants.Search.Regex) if app_constants.GALLERY_SEARCH_CASE: args.append(app_constants.Search.Case) if app_constants.GALLERY_SEARCH_STRICT: args.append(app_constants.Search.Strict) self.current_manga_view.get_current_view().sort_model.init_search(srch_string, args) old_cursor_pos = self._search_cursor_pos[0] self.search_bar.end(False) if self.search_bar.cursorPosition() != old_cursor_pos + 1: self.search_bar.setCursorPosition(old_cursor_pos) def switch_display(self): "Switches between fav and catalog display" if self.current_manga_view.fav_is_current(): self.tab_manager.library_btn.click() else: self.tab_manager.favorite_btn.click() def settings(self): sett = settingsdialog.SettingsDialog(self) sett.scroll_speed_changed.connect(self.manga_list_view.updateGeometries) #sett.show() def init_toolbar(self): self.toolbar = QToolBar() self.toolbar.adjustSize() #self.toolbar.setFixedHeight() self.toolbar.setWindowTitle("Show") # text for the contextmenu #self.toolbar.setStyleSheet("QToolBar {border:0px}") # make it user #defined? self.toolbar.setMovable(False) self.toolbar.setFloatable(False) #self.toolbar.setIconSize(QSize(20,20)) self.toolbar.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) self.toolbar.setIconSize(QSize(20,20)) def switch_view(fav): if fav: self.default_manga_view.get_current_view().sort_model.fav_view() else: self.default_manga_view.get_current_view().sort_model.catalog_view() self.tab_manager = misc_db.ToolbarTabManager(self.toolbar, self) self.tab_manager.favorite_btn.clicked.connect(lambda: switch_view(True)) self.tab_manager.library_btn.click() self.tab_manager.library_btn.clicked.connect(lambda: switch_view(False)) self.addition_tab = self.tab_manager.addTab("Inbox", app_constants.ViewType.Addition, icon=app_constants.INBOX_ICON) gallery_k = QKeySequence('Alt+G') new_gallery_k = QKeySequence('Ctrl+N') new_galleries_k = QKeySequence('Ctrl+Shift+N') new_populate_k = QKeySequence('Ctrl+Alt+N') scan_galleries_k = QKeySequence('Ctrl+Alt+S') open_random_k = QKeySequence(QKeySequence.Open) get_all_metadata_k = QKeySequence('Ctrl+Alt+M') gallery_downloader_k = QKeySequence('Ctrl+Alt+D') gallery_menu = QMenu() gallery_action = QToolButton() gallery_action.setIcon(app_constants.PLUS_ICON) gallery_action.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) gallery_action.setShortcut(gallery_k) gallery_action.setText('Gallery ') gallery_action.setPopupMode(QToolButton.InstantPopup) gallery_action.setToolTip('Contains various gallery related features') gallery_action.setMenu(gallery_menu) add_gallery_icon = QIcon(app_constants.PLUS_ICON) gallery_action_add = QAction(add_gallery_icon, "Add a gallery...", self) gallery_action_add.triggered.connect(lambda: gallery.CommonView.spawn_dialog(self)) gallery_action_add.setToolTip('Add a single gallery thoroughly') gallery_action_add.setShortcut(new_gallery_k) gallery_menu.addAction(gallery_action_add) add_more_action = QAction(add_gallery_icon, "Add galleries...", self) add_more_action.setStatusTip('Add galleries from different folders') add_more_action.setShortcut(new_galleries_k) add_more_action.triggered.connect(lambda: self.populate(True)) gallery_menu.addAction(add_more_action) populate_action = QAction(add_gallery_icon, "Populate from directory/archive...", self) populate_action.setStatusTip('Populates the DB with galleries from a single folder or archive') populate_action.triggered.connect(self.populate) populate_action.setShortcut(new_populate_k) gallery_menu.addAction(populate_action) gallery_menu.addSeparator() scan_galleries_action = QAction('Scan for new galleries', self) scan_galleries_action.setIcon(app_constants.SPINNER_ICON) scan_galleries_action.triggered.connect(self.scan_for_new_galleries) scan_galleries_action.setStatusTip('Scan monitored folders for new galleries') scan_galleries_action.setShortcut(scan_galleries_k) gallery_menu.addAction(scan_galleries_action) duplicate_check_simple = QAction("Check for duplicate galleries", self) duplicate_check_simple.setIcon(app_constants.DUPLICATE_ICON) duplicate_check_simple.triggered.connect(lambda: self.duplicate_check()) # triggered emits False gallery_menu.addAction(duplicate_check_simple) self.toolbar.addWidget(gallery_action) spacer_tool = QWidget() spacer_tool.setFixedSize(QSize(5, 1)) self.toolbar.addWidget(spacer_tool) metadata_action = QToolButton() metadata_action.setText('Fetch all metadata') metadata_action.clicked.connect(self.get_metadata) metadata_action.setIcon(app_constants.DOWNLOAD_ICON) metadata_action.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) metadata_action.setShortcut(get_all_metadata_k) self.toolbar.addWidget(metadata_action) spacer_tool2 = QWidget() spacer_tool2.setFixedSize(QSize(1, 1)) self.toolbar.addWidget(spacer_tool2) gallery_action_random = QToolButton() gallery_action_random.setText("Open random gallery") gallery_action_random.clicked.connect(lambda: gallery.CommonView.open_random_gallery(self.get_current_view())) gallery_action_random.setIcon(app_constants.RANDOM_ICON) gallery_action_random.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) gallery_action_random.setShortcut(open_random_k) self.toolbar.addWidget(gallery_action_random) spacer_tool3 = QWidget() spacer_tool3.setFixedSize(QSize(1, 1)) self.toolbar.addWidget(spacer_tool3) gallery_downloader = QToolButton() gallery_downloader.setText("Downloader") gallery_downloader.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) gallery_downloader.clicked.connect(self.download_window.show) gallery_downloader.setShortcut(gallery_downloader_k) gallery_downloader.setIcon(app_constants.MANAGER_ICON) self.toolbar.addWidget(gallery_downloader) spacer_tool4 = QWidget() spacer_tool4.setFixedSize(QSize(5, 1)) self.toolbar.addWidget(spacer_tool4) # debug specfic code if app_constants.DEBUG: def debug_func(): pass debug_btn = QToolButton() debug_btn.setText("DEBUG BUTTON") self.toolbar.addWidget(debug_btn) debug_btn.clicked.connect(debug_func) spacer_middle = QWidget() # aligns buttons to the right spacer_middle.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.toolbar.addWidget(spacer_middle) sort_k = QKeySequence('Alt+S') sort_action = QToolButton() sort_action.setToolButtonStyle(Qt.ToolButtonTextBesideIcon) sort_action.setShortcut(sort_k) sort_action.setIcon(app_constants.SORT_ICON_DESC) sort_menu = misc.SortMenu(self, self.toolbar, sort_action) sort_menu.set_toolbutton_text() sort_action.setMenu(sort_menu) sort_action.setPopupMode(QToolButton.InstantPopup) self.toolbar.addWidget(sort_action) def set_new_sort(s): sort_menu.set_toolbutton_text() self.current_manga_view.list_view.sort(s) sort_menu.new_sort.connect(set_new_sort) spacer_tool4 = QWidget() spacer_tool4.setFixedSize(QSize(5, 1)) self.toolbar.addWidget(spacer_tool4) togle_view_k = QKeySequence('Alt+Space') self.grid_toggle_g_icon = app_constants.GRID_ICON self.grid_toggle_l_icon = app_constants.LIST_ICON self.grid_toggle = QToolButton() self.grid_toggle.setToolButtonStyle(Qt.ToolButtonIconOnly) self.grid_toggle.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum) self.grid_toggle.setShortcut(togle_view_k) if self.current_manga_view.current_view == gallery.MangaViews.View.List: self.grid_toggle.setIcon(self.grid_toggle_l_icon) else: self.grid_toggle.setIcon(self.grid_toggle_g_icon) self.grid_toggle.setObjectName('gridtoggle') self.grid_toggle.clicked.connect(self.toggle_view) self.toolbar.addWidget(self.grid_toggle) spacer_mid2 = QWidget() spacer_mid2.setFixedSize(QSize(5, 1)) self.toolbar.addWidget(spacer_mid2) search_options = QToolButton() search_options.setIconSize(QSize(15,15)) search_options.setPopupMode(QToolButton.InstantPopup) self.toolbar.addWidget(search_options) search_options.setIcon(app_constants.SEARCH_ICON) search_options_menu = QMenu(self) search_options.setMenu(search_options_menu) case_search_option = search_options_menu.addAction('Case Sensitive') case_search_option.setCheckable(True) case_search_option.setChecked(app_constants.GALLERY_SEARCH_CASE) def set_search_case(b): app_constants.GALLERY_SEARCH_CASE = b settings.set(b, 'Application', 'gallery search case') settings.save() case_search_option.toggled.connect(set_search_case) search_options_menu.addSeparator() strict_search_option = search_options_menu.addAction('Match whole terms') strict_search_option.setCheckable(True) strict_search_option.setChecked(app_constants.GALLERY_SEARCH_STRICT) regex_search_option = search_options_menu.addAction('Regex') regex_search_option.setCheckable(True) regex_search_option.setChecked(app_constants.GALLERY_SEARCH_REGEX) def set_search_strict(b): if b: if regex_search_option.isChecked(): regex_search_option.toggle() app_constants.GALLERY_SEARCH_STRICT = b settings.set(b, 'Application', 'gallery search strict') settings.save() strict_search_option.toggled.connect(set_search_strict) def set_search_regex(b): if b: if strict_search_option.isChecked(): strict_search_option.toggle() app_constants.GALLERY_SEARCH_REGEX = b settings.set(b, 'Application', 'allow search regex') settings.save() regex_search_option.toggled.connect(set_search_regex) self.search_bar = misc.LineEdit() remove_txt = self.search_bar.addAction(app_constants.CROSS_ICON, QLineEdit.LeadingPosition) refresh_search = self.search_bar.addAction(app_constants.REFRESH_ICON, QLineEdit.TrailingPosition) refresh_search.triggered.connect(self.current_manga_view.get_current_view().sort_model.refresh) remove_txt.setVisible(False) def clear_txt(): self.search_bar.setText("") self.search_bar.returnPressed.emit() remove_txt.triggered.connect(clear_txt) def hide_cross(txt): remove_txt.setVisible(bool(txt)) self.search_bar.textChanged.connect(hide_cross) self.search_bar.setObjectName('search_bar') self.search_timer = QTimer(self) self.search_timer.setSingleShot(True) self.search_timer.timeout.connect(lambda: self.search(self.search_bar.text())) self._search_cursor_pos = [0, 0] def set_cursor_pos(old, new): self._search_cursor_pos[0] = old self._search_cursor_pos[1] = new self.search_bar.cursorPositionChanged.connect(set_cursor_pos) if app_constants.SEARCH_AUTOCOMPLETE: completer = QCompleter(self) completer_view = misc.CompleterPopupView() completer.setPopup(completer_view) completer_view._setup() completer.setModel(self.manga_list_view.gallery_model) completer.setCaseSensitivity(Qt.CaseInsensitive) completer.setCompletionMode(QCompleter.PopupCompletion) completer.setCompletionRole(Qt.DisplayRole) completer.setCompletionColumn(app_constants.TITLE) completer.setFilterMode(Qt.MatchContains) completer.activated[str].connect(lambda a: self.search(a)) self.search_bar.setCompleter(completer) self.search_bar.returnPressed.connect(lambda: self.search(self.search_bar.text())) if not app_constants.SEARCH_ON_ENTER: self.search_bar.textEdited.connect(lambda: self.search_timer.start(800)) self.search_bar.setPlaceholderText("Search title, artist, namespace & tags") self.search_bar.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.manga_list_view.sort_model.HISTORY_SEARCH_TERM.connect(lambda a: self.search_bar.setText(a)) self.toolbar.addWidget(self.search_bar) def search_history(_, back=True): # clicked signal passes a bool sort_model = self.manga_list_view.sort_model nav = sort_model.PREV if back else sort_model.NEXT history_term = sort_model.navigate_history(nav) if back: self.search_forward.setVisible(True) back_k = QKeySequence(QKeySequence.Back) forward_k = QKeySequence(QKeySequence.Forward) search_backbutton = QToolButton(self.toolbar) search_backbutton.setIcon(app_constants.ARROW_LEFT_ICON) search_backbutton.setFixedWidth(20) search_backbutton.clicked.connect(search_history) search_backbutton.setShortcut(back_k) self.search_backward = self.toolbar.addWidget(search_backbutton) self.search_backward.setVisible(False) search_forwardbutton = QToolButton(self.toolbar) search_forwardbutton.setIcon(app_constants.ARROW_RIGHT_ICON) search_forwardbutton.setFixedWidth(20) search_forwardbutton.clicked.connect(lambda: search_history(None, False)) search_forwardbutton.setShortcut(forward_k) self.search_forward = self.toolbar.addWidget(search_forwardbutton) self.search_forward.setVisible(False) spacer_end = QWidget() # aligns settings action properly spacer_end.setFixedSize(QSize(10, 1)) self.toolbar.addWidget(spacer_end) settings_k = QKeySequence("Ctrl+P") settings_act = QToolButton(self.toolbar) settings_act.setShortcut(settings_k) settings_act.setIcon(QIcon(app_constants.SETTINGS_PATH)) settings_act.clicked.connect(self.settings) self.toolbar.addWidget(settings_act) self.addToolBar(self.toolbar) def get_current_view(self): return self.current_manga_view.get_current_view() def toggle_view(self): """ Toggles the current display view """ if self.current_manga_view.current_view == gallery.MangaViews.View.Table: self.current_manga_view.changeTo(self.current_manga_view.m_l_view_index) self.grid_toggle.setIcon(self.grid_toggle_l_icon) else: self.current_manga_view.changeTo(self.current_manga_view.m_t_view_index) self.grid_toggle.setIcon(self.grid_toggle_g_icon) # TODO: Improve this so that it adds to the gallery dialog, # so user can edit data before inserting (make it a choice) def populate(self, mixed=None): "Populates the database with gallery from local drive'" if mixed: gallery_view = misc.GalleryListView(self, True) gallery_view.SERIES.connect(self.gallery_populate) gallery_view.show() else: msg_box = misc.BasePopup(self) l = QVBoxLayout() msg_box.main_widget.setLayout(l) l.addWidget(QLabel('Directory or Archive?')) l.addLayout(msg_box.buttons_layout) def from_dir(): path = QFileDialog.getExistingDirectory(self, "Choose a directory containing your galleries") if not path: return msg_box.close() app_constants.OVERRIDE_SUBFOLDER_AS_GALLERY = True self.gallery_populate(path, True) def from_arch(): path = QFileDialog.getOpenFileName(self, 'Choose an archive containing your galleries', filter=utils.FILE_FILTER) path = [path[0]] if not all(path) or not path: return msg_box.close() app_constants.OVERRIDE_SUBFOLDER_AS_GALLERY = True self.gallery_populate(path, True) buttons = msg_box.add_buttons('Directory', 'Archive', 'Close') buttons[2].clicked.connect(msg_box.close) buttons[0].clicked.connect(from_dir) buttons[1].clicked.connect(from_arch) msg_box.adjustSize() msg_box.show() def gallery_populate(self, path, validate=False): "Scans the given path for gallery to add into the DB" if len(path) is not 0: data_thread = QThread(self) data_thread.setObjectName('General gallery populate') self.addition_tab.click() self.g_populate_inst = fetch.Fetch() self.g_populate_inst.series_path = path self._g_populate_count = 0 fetch_spinner = misc.Spinner(self) fetch_spinner.set_size(60) fetch_spinner.set_text("Populating") fetch_spinner.show() def finished(status): fetch_spinner.hide() if not status: log_e('Populating DB from gallery folder: Nothing was added!') self.notif_bubble.update_text("Gallery Populate", "Nothing was added. Check happypanda_log for details..") def skipped_gs(s_list): "Skipped galleries" msg_box = QMessageBox(self) msg_box.setIcon(QMessageBox.Question) msg_box.setText('Do you want to view skipped paths?') msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) msg_box.setDefaultButton(QMessageBox.No) if msg_box.exec() == QMessageBox.Yes: list_wid = QTableWidget(self) list_wid.setAttribute(Qt.WA_DeleteOnClose) list_wid.setRowCount(len(s_list)) list_wid.setColumnCount(2) list_wid.setAlternatingRowColors(True) list_wid.setEditTriggers(list_wid.NoEditTriggers) list_wid.setHorizontalHeaderLabels(['Reason', 'Path']) list_wid.setSelectionBehavior(list_wid.SelectRows) list_wid.setSelectionMode(list_wid.SingleSelection) list_wid.setSortingEnabled(True) list_wid.verticalHeader().hide() list_wid.setAutoScroll(False) for x, g in enumerate(s_list): list_wid.setItem(x, 0, QTableWidgetItem(g[1])) list_wid.setItem(x, 1, QTableWidgetItem(g[0])) list_wid.resizeColumnsToContents() list_wid.setWindowTitle('{} skipped paths'.format(len(s_list))) list_wid.setWindowFlags(Qt.Window) list_wid.resize(900,400) list_wid.doubleClicked.connect(lambda i: utils.open_path(list_wid.item(i.row(), 1).text(), list_wid.item(i.row(), 1).text())) list_wid.show() def a_progress(prog): fetch_spinner.set_text("Populating... {}/{}".format(prog, self._g_populate_count)) def add_to_model(gallery): self.addition_tab.view.add_gallery(gallery, app_constants.KEEP_ADDED_GALLERIES) def set_count(c): self._g_populate_count = c self.g_populate_inst.moveToThread(data_thread) self.g_populate_inst.PROGRESS.connect(a_progress) self.g_populate_inst.DATA_COUNT.connect(set_count) self.g_populate_inst.LOCAL_EMITTER.connect(add_to_model) self.g_populate_inst.FINISHED.connect(finished) self.g_populate_inst.FINISHED.connect(self.g_populate_inst.deleteLater) self.g_populate_inst.SKIPPED.connect(skipped_gs) data_thread.finished.connect(data_thread.deleteLater) data_thread.started.connect(self.g_populate_inst.local) data_thread.start() #self.g_populate_inst.local() log_i('Populating DB from directory/archive') def scan_for_new_galleries(self): available_folders = app_constants.ENABLE_MONITOR and \ app_constants.MONITOR_PATHS and all(app_constants.MONITOR_PATHS) if available_folders and not app_constants.SCANNING_FOR_GALLERIES: app_constants.SCANNING_FOR_GALLERIES = True self.notification_bar.add_text("Scanning for new galleries...") log_i('Scanning for new galleries...') try: class ScanDir(QObject): finished = pyqtSignal() fetch_inst = fetch.Fetch(self) def __init__(self, addition_view, addition_tab, parent=None): super().__init__(parent) self.addition_view = addition_view self.addition_tab = addition_tab self._switched = False def switch_tab(self): if not self._switched: self.addition_tab.click() self._switched = True def scan_dirs(self): paths = [] for p in app_constants.MONITOR_PATHS: if os.path.exists(p): dir_content = scandir.scandir(p) for d in dir_content: paths.append(d.path) else: log_e("Monitored path does not exists: {}".format(p.encode(errors='ignore'))) self.fetch_inst.series_path = paths self.fetch_inst.LOCAL_EMITTER.connect(lambda g:self.addition_view.add_gallery(g, app_constants.KEEP_ADDED_GALLERIES)) self.fetch_inst.LOCAL_EMITTER.connect(self.switch_tab) self.fetch_inst.local() #contents = [] #for g in self.scanned_data: # contents.append(g) #paths = sorted(paths) #new_galleries = [] #for x in contents: # y = utils.b_search(paths, os.path.normcase(x.path)) # if not y: # new_galleries.append(x) self.finished.emit() self.deleteLater() #if app_constants.LOOK_NEW_GALLERY_AUTOADD: # QTimer.singleShot(10000, # self.gallery_populate(final_paths)) # return def finished(): app_constants.SCANNING_FOR_GALLERIES = False new_gall_spinner = misc.Spinner(self) new_gall_spinner.set_text("Gallery Scan") new_gall_spinner.show() thread = QThread(self) self.scan_inst = ScanDir(self.addition_tab.view, self.addition_tab) self.scan_inst.moveToThread(thread) self.scan_inst.finished.connect(finished) self.scan_inst.finished.connect(new_gall_spinner.before_hide) thread.started.connect(self.scan_inst.scan_dirs) #self.scan_inst.scan_dirs() thread.finished.connect(thread.deleteLater) thread.start() except: self.notification_bar.add_text('An error occured while attempting to scan for new galleries. Check happypanda.log.') log.exception('An error occured while attempting to scan for new galleries.') app_constants.SCANNING_FOR_GALLERIES = False else: self.notification_bar.add_text("Please specify directory in settings to scan for new galleries!") def dragEnterEvent(self, event): if event.mimeData().hasUrls(): event.acceptProposedAction() else: super().dragEnterEvent(event) def dropEvent(self, event): acceptable = [] unaccept = [] for u in event.mimeData().urls(): path = u.toLocalFile() if os.path.isdir(path) or path.endswith(utils.ARCHIVE_FILES): acceptable.append(path) else: unaccept.append(path) log_i('Acceptable dropped items: {}'.format(len(acceptable))) log_i('Unacceptable dropped items: {}'.format(len(unaccept))) log_d('Dropped items: {}\n{}'.format(acceptable, unaccept).encode(errors='ignore')) if acceptable: self.notification_bar.add_text('Adding dropped items...') log_i('Adding dropped items') l = len(acceptable) == 1 f_item = acceptable[0] if f_item.endswith(utils.ARCHIVE_FILES): f_item = utils.check_archive(f_item) else: f_item = utils.recursive_gallery_check(f_item) f_item_l = len(f_item) < 2 subfolder_as_c = not app_constants.SUBFOLDER_AS_GALLERY if l and subfolder_as_c or l and f_item_l: g_d = gallerydialog.GalleryDialog(self, acceptable[0]) g_d.show() else: self.gallery_populate(acceptable, True) event.accept() else: text = 'File not supported' if len(unaccept) < 2 else 'Files not supported' self.notification_bar.add_text(text) if unaccept: self.notification_bar.add_text('Some unsupported files did not get added') super().dropEvent(event) def resizeEvent(self, event): try: self.notification_bar.resize(event.size().width()) except AttributeError: pass self.move_listener.emit() return super().resizeEvent(event) def moveEvent(self, event): self.move_listener.emit() return super().moveEvent(event) def showEvent(self, event): return super().showEvent(event) def cleanup_exit(self): self.system_tray.hide() # watchers try: self.watchers.stop_all() except AttributeError: pass # settings settings.set(self.manga_list_view.current_sort, 'General', 'current sort') settings.set(app_constants.IGNORE_PATHS, 'Application', 'ignore paths') if not self.isMaximized(): settings.win_save(self, 'AppWindow') # temp dir try: for root, dirs, files in scandir.walk('temp', topdown=False): for name in files: os.remove(os.path.join(root, name)) for name in dirs: os.rmdir(os.path.join(root, name)) log_d('Flush temp on exit: OK') except: log.exception('Flush temp on exit: FAIL') # DB try: log_i("Analyzing database...") gallerydb.GalleryDB.analyze() log_i("Closing database...") gallerydb.GalleryDB.close() except: pass self.download_window.close() # check if there is db activity if not gallerydb.method_queue.empty(): class DBActivityChecker(QObject): FINISHED = pyqtSignal() def __init__(self, **kwargs): super().__init__(**kwargs) def check(self): gallerydb.method_queue.join() self.FINISHED.emit() self.deleteLater() db_activity = DBActivityChecker() db_spinner = misc.Spinner(self) self.db_activity_checker.connect(db_activity.check) db_activity.moveToThread(app_constants.GENERAL_THREAD) db_activity.FINISHED.connect(db_spinner.close) db_spinner.set_text('DB Activity') db_spinner.show() self.db_activity_checker.emit() msg_box = QMessageBox(self) msg_box.setText('Database activity detected!') msg_box.setInformativeText("Closing now might result in data loss." + " Do you still want to close?\n(Wait for the activity spinner to hide before closing)") msg_box.setIcon(QMessageBox.Critical) msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) msg_box.setDefaultButton(QMessageBox.No) if msg_box.exec() == QMessageBox.Yes: return 1 else: return 2 else: return 0 def duplicate_check(self, simple=True): try: self.duplicate_check_invoker.disconnect() except TypeError: pass mode = 'simple' if simple else 'advanced' log_i('Checking for duplicates in mode: {}'.format(mode)) notifbar = app_constants.NOTIF_BAR notifbar.add_text('Checking for duplicates...') duplicate_spinner = misc.Spinner(self) duplicate_spinner.set_text("Duplicate Check") duplicate_spinner.show() dup_tab = self.tab_manager.addTab("Duplicate", app_constants.ViewType.Duplicate) dup_tab.view.set_delete_proxy(self.default_manga_view.gallery_model) class DuplicateCheck(QObject): found_duplicates = pyqtSignal(tuple) finished = pyqtSignal() def __init__(self): super().__init__() def checkSimple(self, model): galleries = model._data duplicates = [] for n, g in enumerate(galleries, 1): notifbar.add_text('Checking gallery {}'.format(n)) log_d('Checking gallery {}'.format(g.title.encode(errors="ignore"))) for y in galleries: title = g.title.strip().lower() == y.title.strip().lower() path = os.path.normcase(g.path) == os.path.normcase(y.path) if g.id != y.id and (title or path): if g not in duplicates: duplicates.append(y) duplicates.append(g) self.found_duplicates.emit((g, y)) self.finished.emit() self._d_checker = DuplicateCheck() self._d_checker.moveToThread(app_constants.GENERAL_THREAD) self._d_checker.found_duplicates.connect(lambda t: dup_tab.view.add_gallery(t, record_time=True)) self._d_checker.finished.connect(dup_tab.click) self._d_checker.finished.connect(self._d_checker.deleteLater) self._d_checker.finished.connect(duplicate_spinner.before_hide) if simple: self.duplicate_check_invoker.connect(self._d_checker.checkSimple) self.duplicate_check_invoker.emit(self.default_manga_view.gallery_model) def excepthook(self, ex_type, ex, tb): log_c(''.join(traceback.format_tb(tb))) log_c('{}: {}'.format(ex_type, ex)) traceback.print_exception(ex_type, ex, tb) w = QMessageBox(self) w.setWindowTitle("Critical Error") w.setIcon(QMessageBox.Critical) w.setText('A critical error has ben encountered. Stability from this point onward cannot be guaranteed.') w.setStandardButtons(QMessageBox.Ok) w.setDefaultButton(QMessageBox.Ok) w.exec_() def closeEvent(self, event): r_code = self.cleanup_exit() if r_code == 1: log_d('Force Exit App: OK') super().closeEvent(event) elif r_code == 2: log_d('Ignore Exit App') event.ignore() else: log_d('Normal Exit App: OK') super().closeEvent(event) if __name__ == '__main__': raise NotImplementedError("Unit testing not implemented yet!") ================================================ FILE: version/app_constants.py ================================================ #This file is part of Happypanda. #Happypanda is free software: you can redistribute it and/or modify #it under the terms of the GNU General Public License as published by #the Free Software Foundation, either version 2 of the License, or #any later version. #Happypanda is distributed in the hope that it will be useful, #but WITHOUT ANY WARRANTY; without even the implied warranty of #MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #GNU General Public License for more details. #You should have received a copy of the GNU General Public License #along with Happypanda. If not, see . """Contains constants to be used by several modules""" import os, sys, enum import qtawesome as qta try: import settings from database import db_constants except ImportError: from . import settings from .database import db_constants # Version number vs = '1.1' DEBUG = False OS_NAME = '' if sys.platform.startswith('darwin'): OS_NAME = "darwin" elif os.name == 'nt': OS_NAME = "windows" elif os.name == 'posix': OS_NAME = "linux" APP_RESTART_CODE = 0 get = settings.get posix_program_dir = os.path.dirname(os.path.realpath(__file__)) if os.name == 'posix': static_dir = os.path.join(posix_program_dir, '../res') bin_dir = os.path.join(posix_program_dir, 'bin') temp_dir = os.path.join(posix_program_dir, 'temp') else: bin_dir = os.path.join(os.getcwd(), 'bin') static_dir = os.path.join(os.getcwd(), "res") temp_dir = os.path.join('temp') # path to unrar tool binary unrar_tool_path = get('', 'Application', 'unrar tool path') # type of download needed by download manager for each site parser # NOTE define here if any new type will be supported in the future. DOWNLOAD_TYPE_ARCHIVE = 0 DOWNLOAD_TYPE_TORRENT = 1 # Note: With this type, file will be sent to torrent program DOWNLOAD_TYPE_OTHER = 2 VALID_GALLERY_CATEGORY = ( 'Doujinshi', 'Manga', 'Artist CG', 'Game CG', 'Western', 'Non-H', 'Image Set', 'Cosplay', 'Miscellaneous', 'Private' ) #default stylesheet path default_stylesheet_path = os.path.join(static_dir,"style.css") user_stylesheet_path = "" INTERNAL_LEVEL = 8 FIRST_TIME_LEVEL = get(INTERNAL_LEVEL, 'Application', 'first time level', int) UPDATE_VERSION = get('0.30', 'Application', 'version', str) FORCE_HIGH_DPI_SUPPORT = get(False, 'Advanced', 'force high dpi support', bool) # sizes MAIN_W = 1061 # main window MAIN_H = 650 # main window SIZE_FACTOR = get(10, 'Visual', 'size factor', int) GRID_SPACING = get(15, 'Visual', 'grid spacing', int) LISTBOX_H_SIZE = 190 LISTBOX_W_SIZE = 950 GRIDBOX_LBL_H = 60 THUMB_H_SIZE = 190 + SIZE_FACTOR THUMB_W_SIZE = 133 + SIZE_FACTOR THUMB_DEFAULT = (THUMB_W_SIZE, THUMB_H_SIZE) THUMB_SMALL = (140, 93) # Columns COLUMNS = tuple(range(11)) TITLE = 0 ARTIST = 1 DESCR = 2 TAGS = 3 TYPE = 4 FAV = 5 CHAPTERS = 6 LANGUAGE = 7 LINK = 8 PUB_DATE = 9 DATE_ADDED = 10 @enum.unique class ViewType(enum.IntEnum): Default = 1 Addition = 2 Duplicate = 3 @enum.unique class ProfileType(enum.Enum): Default = 1 Small = 2 # Application SYSTEM_TRAY = None NOTIF_BAR = None NOTIF_BUBBLE = None STAT_MSG_METHOD = None GENERAL_THREAD = None WHEEL_SCROLL_EFFECT = 10 DOWNLOAD_MANAGER = None # ICONS # IMPORTANT: Neccessary because qtawesome can't function without an instanced QApplication # IMPORTANT: called after instancing qApplication in main.py def load_icons(): global G_LISTS_ICON_WH global G_LISTS_ICON global LIST_ICON global ARTISTS_ICON global ARTIST_ICON global NSTAGS_ICON global PLUS_ICON global ARROW_RIGHT_ICON global ARROW_LEFT_ICON global GRID_ICON global GRIDL_ICON global SEARCH_ICON global CROSS_ICON global CROSS_ICON_WH global MANAGER_ICON global DOWNLOAD_ICON global RANDOM_ICON global DUPLICATE_ICON global SORT_ICON_DESC global SORT_ICON_ASC global REFRESH_ICON global STAR_ICON global CIRCLE_ICON global INBOX_ICON global SPINNER_ICON G_LISTS_ICON_WH = qta.icon("fa.bars", color="white") G_LISTS_ICON = qta.icon("fa.bars", color="black") LIST_ICON = qta.icon("fa.bars", color="white") ARTISTS_ICON = qta.icon("fa.users", color="white") ARTIST_ICON = qta.icon("fa.user", color="black") NSTAGS_ICON = qta.icon("fa.sitemap", color="white") PLUS_ICON = qta.icon("fa.plus", color="white") ARROW_RIGHT_ICON = qta.icon("fa.angle-double-right", color="white") ARROW_LEFT_ICON = qta.icon("fa.angle-double-left", color="white") GRID_ICON = qta.icon("fa.th", color="white") GRIDL_ICON = qta.icon("fa.th-large", color="white") SEARCH_ICON = qta.icon("fa.search", color="white") CROSS_ICON = qta.icon("fa.times", color="black") CROSS_ICON_WH = qta.icon("fa.times", color="white") MANAGER_ICON = qta.icon("fa.tasks", color="white") DOWNLOAD_ICON = qta.icon("fa.arrow-circle-o-down", color="white") RANDOM_ICON = qta.icon("fa.random", color="white") DUPLICATE_ICON = qta.icon("fa.files-o", color="white") SORT_ICON_DESC = qta.icon("fa.sort-amount-desc", color="white") SORT_ICON_ASC = qta.icon("fa.sort-amount-asc", color="white") REFRESH_ICON = qta.icon("fa.refresh", color="black") STAR_ICON = qta.icon("fa.star", color="white") CIRCLE_ICON = qta.icon("fa.circle", color="white") INBOX_ICON = qta.icon("fa.inbox", color="white") SPINNER_ICON = qta.icon("fa.spinner", color="white") # image paths GALLERY_DEF_ICO_PATH = os.path.join(static_dir, "gallery_def_ico.ico") GALLERY_EXT_ICO_PATH = os.path.join(static_dir, "gallery_ext_ico.ico") APP_ICO_PATH = os.path.join(static_dir, "happypanda.ico") SETTINGS_PATH = os.path.join(static_dir, "settings.png") NO_IMAGE_PATH = os.path.join(static_dir, "default.jpg") # Monitored Paths OVERRIDE_MONITOR = False # set true to make watchers to ignore next item (will be set to False) LOOK_NEW_GALLERY_STARTUP = get(True, 'Application', 'look new gallery startup', bool) ENABLE_MONITOR = get(True, 'Application', 'enable monitor', bool) MONITOR_PATHS = [p for p in get([], 'Application', 'monitor paths', list) if os.path.exists(p)] IGNORE_PATHS = get([], 'Application', 'ignore paths', list) IGNORE_EXTS = get([], 'Application', 'ignore exts', list) SCANNING_FOR_GALLERIES = False # if a scan for new galleries is being done TEMP_PATH_IGNORE = [] # GENERAL OVERRIDE_MOVE_IMPORTED_IN_FETCH = False # set to true to make a fetch instance ignore moving files (will be set to false) MOVE_IMPORTED_GALLERIES = get(False, 'Application', 'move imported galleries', bool) IMPORTED_GALLERY_DEF_PATH = get('', 'Application', 'imported gallery def path', str) OPEN_RANDOM_GALLERY_CHAPTERS = get(False, 'Application', 'open random gallery chapters', bool) OVERRIDE_SUBFOLDER_AS_GALLERY = False # set to true to make a fetch instance treat subfolder as galleries (will be set to false) SUBFOLDER_AS_GALLERY = get(False, 'Application', 'subfolder as gallery', bool) RENAME_GALLERY_SOURCE = get(False, 'Application', 'rename gallery source', bool) EXTRACT_CHAPTER_BEFORE_OPENING = get(True, 'Application', 'extract chapter before opening', bool) OPEN_GALLERIES_SEQUENTIALLY = get(False, 'Application', 'open galleries sequentially', bool) SEND_FILES_TO_TRASH = get(True, 'Application', 'send files to trash', bool) SHOW_SIDEBAR_WIDGET = get(False, 'Application', 'show sidebar widget', bool) # ADVANCED GALLERY_DATA_FIX_REGEX = get("", 'Advanced', 'gallery data fix regex', str) GALLERY_DATA_FIX_TITLE = get(True, 'Advanced', 'gallery data fix title', bool) GALLERY_DATA_FIX_ARTIST = get(True, 'Advanced', 'gallery data fix artist', bool) GALLERY_DATA_FIX_REPLACE = get("", 'Advanced', 'gallery data fix replace', str) EXTERNAL_VIEWER_ARGS = get("{$file}", 'Advanced', 'external viewer args', str) # Import/Export EXPORT_FORMAT = get(1, 'Advanced', 'export format', int) EXPORT_PATH = '' # HASH HASH_GALLERY_PAGES = get('all', 'Advanced', 'hash gallery pages', int, str) # WEB INCLUDE_EH_EXPUNGED = get(False, 'Web', 'include eh expunged', bool) GLOBAL_EHEN_TIME = get(5, 'Web', 'global ehen time offset', int) GLOBAL_EHEN_LOCK = False DEFAULT_EHEN_URL = get('https://e-hentai.org/', 'Web', 'default ehen url', str) REPLACE_METADATA = get(False, 'Web', 'replace metadata', bool) ALWAYS_CHOOSE_FIRST_HIT = get(False, 'Web', 'always choose first hit', bool) USE_GALLERY_LINK = get(True, 'Web', 'use gallery link', bool) USE_JPN_TITLE = get(False, 'Web', 'use jpn title', bool) CONTINUE_AUTO_METADATA_FETCHER = get(True, 'Web', 'continue auto metadata fetcher', bool) HEN_DOWNLOAD_TYPE = get(DOWNLOAD_TYPE_ARCHIVE, 'Web', 'hen download type', int) DOWNLOAD_DIRECTORY = get('downloads', 'Web', 'download directory', str) TORRENT_CLIENT = get('', 'Web', 'torrent client', str) HEN_LIST = get(['chaikahen'], 'Web', 'hen list', list) DOWNLOAD_GALLERY_TO_LIB = get(False, 'Web', 'download galleries to library', bool) # External Viewer EXTERNAL_VIEWER_SUPPORT = {'honeyview':['Honeyview.exe']} USE_EXTERNAL_VIEWER = get(False, 'Application', 'use external viewer', bool) EXTERNAL_VIEWER_PATH = os.path.normcase(get('', 'Application', 'external viewer path', str)) _REFRESH_EXTERNAL_VIEWER = False # controls THUMBNAIL_CACHE_SIZE = (1024, get(200, 'Advanced', 'cache size', int)) #1024 is 1mib PREFETCH_ITEM_AMOUNT = get(50, 'Advanced', 'prefetch item amount', int)# amount of items to prefetch SCROLL_SPEED = get(7, 'Advanced', 'scroll speed', int) # controls how many steps it takes when scrolling # POPUP POPUP_WIDTH = get(500, 'Visual', 'popup.w', int) POPUP_HEIGHT = get(300, 'Visual', 'popup.h', int) # Gallery APPEND_TAGS_GALLERIES = get(True, 'Application', 'append tags to gallery', bool) KEEP_ADDED_GALLERIES = get(True, 'Application', 'keep added galleries', bool) GALLERY_METAFILE_KEYWORDS = ('info.json', 'info.txt') CURRENT_SORT = get('title', 'General', 'current sort') HIGH_QUALITY_THUMBS = get(False, 'Visual', 'high quality thumbs', bool) DISPLAY_RATING = get(True, 'Visual', 'display gallery rating', bool) DISPLAY_GALLERY_TYPE = get(False, 'Visual', 'display gallery type', bool) if not sys.platform.startswith('darwin') else False DISPLAY_GALLERY_RIBBON = get(True, 'Visual', 'display gallery ribbon', bool) GALLERY_FONT = (get('Segoe UI', 'Visual', 'gallery font family', str), get(11, 'Visual', 'gallery font size', int)) GALLERY_FONT_ELIDE = get(True, 'Visual', 'gallery font elide', bool) G_DEF_LANGUAGE = get('English', 'General', 'gallery default language', str) G_CUSTOM_LANGUAGES = get([], 'General', 'gallery custom languages', list) G_DEF_STATUS = get('Completed', 'General', 'gallery default status', str) G_DEF_TYPE = get('Doujinshi', 'General', 'gallery default type', str) G_LANGUAGES = ["English", "Japanese", "Chinese", "Other"] G_STATUS = ["Ongoing", "Completed", "Unknown"] G_TYPES = ["Manga", "Doujinshi", "Artist CG Sets", "Game CG Sets", "Western", "Image Sets", "Non-H", "Cosplay", "Other"] @enum.unique class GalleryState(enum.Enum): Default = 1 New = 2 # Colors GRID_VIEW_TITLE_COLOR = get('#ffffff', 'Visual', 'grid view title color', str) GRID_VIEW_ARTIST_COLOR = get('#e2e2e2', 'Visual', 'grid view artist color', str) GRID_VIEW_LABEL_COLOR = get('#d64933', 'Visual', 'grid view label color', str) GRID_VIEW_T_MANGA_COLOR = get('#3498db', 'Visual', 'grid view t manga color', str) GRID_VIEW_T_DOUJIN_COLOR = get('#e74c3c', 'Visual', 'grid view t doujin color', str) GRID_VIEW_T_ARTIST_CG_COLOR = get('#16a085', 'Visual', 'grid view t artist cg color', str) GRID_VIEW_T_GAME_CG_COLOR = get('#2ecc71', 'Visual', 'grid view t game cg color', str) GRID_VIEW_T_WESTERN_COLOR = get('#ecf0f1', 'Visual', 'grid view t western color', str) GRID_VIEW_T_IMAGE_COLOR = get('#f39c12', 'Visual', 'grid view t image color', str) GRID_VIEW_T_NON_H_COLOR = get('#f1c40f', 'Visual', 'grid view t non-h color', str) GRID_VIEW_T_COSPLAY_COLOR = get('#9b59b6', 'Visual', 'grid view t cosplay color', str) GRID_VIEW_T_OTHER_COLOR = get('#34495e', 'Visual', 'grid view t other color', str) # Search SEARCH_AUTOCOMPLETE = get(True, 'Application', 'search autocomplete', bool) GALLERY_SEARCH_REGEX = get(False, 'Application', 'allow search regex', bool) SEARCH_ON_ENTER = get(False, 'Application', 'search on enter', bool) GALLERY_SEARCH_STRICT = get(False, 'Application', 'gallery search strict', bool) GALLERY_SEARCH_CASE = get(False, 'Application', 'gallery search case', bool) @enum.unique class Search(enum.Enum): Strict = 1 Case = 2 Regex = 3 # Grid Tooltip GRID_TOOLTIP = get(True, 'Visual', 'grid tooltip', bool) TOOLTIP_TITLE = get(False, 'Visual', 'tooltip title', bool) TOOLTIP_AUTHOR = get(False, 'Visual', 'tooltip author', bool) TOOLTIP_CHAPTERS = get(True, 'Visual', 'tooltip chapters', bool) TOOLTIP_STATUS = get(True, 'Visual', 'tooltip status', bool) TOOLTIP_TYPE = get(True, 'Visual', 'tooltip type', bool) TOOLTIP_LANG = get(False, 'Visual', 'tooltip lang', bool) TOOLTIP_DESCR = get(False, 'Visual', 'tooltip descr', bool) TOOLTIP_TAGS = get(False, 'Visual', 'tooltip tags', bool) TOOLTIP_LAST_READ = get(True, 'Visual', 'tooltip last read', bool) TOOLTIP_TIMES_READ = get(True, 'Visual', 'tooltip times read', bool) TOOLTIP_PUB_DATE = get(False, 'Visual', 'tooltip pub date', bool) TOOLTIP_DATE_ADDED = get(True, 'Visual', 'tooltip date added', bool) GALLERY_ADDITION_DATA = [] GALLERY_DATA = [] # contains the most up to date gallery data GALLERY_LISTS = set() # contains the most up to dat gallery lists # Exceptions class MetadataFetchFail(Exception): pass class InternalPagesMismatch(Exception): pass class ChapterExists(Exception): pass class ChapterWrongParentGallery(Exception): pass class CreateArchiveFail(Exception): pass class FileNotFoundInArchive(Exception): pass class WrongURL(Exception): pass class NeedLogin(Exception): pass class WrongLogin(Exception): pass class HTMLParsing(Exception): pass class GNotAvailable(Exception): pass class TitleParsingError(Exception): pass EXTERNAL_VIEWER_INFO =\ """{$folder} = path to folder {$file} = path to first image Tip: IrfanView uses {$file} """ WHAT_IS_FILTER =\ """[FILTER] Filters are basically predefined gallery search terms. Every time a gallery matches the specific filter it gets automatically added to the list! Filter works the same way a gallery search does so make sure to read the guide in Settings -> About -> Search Guide. You can write any valid gallery search term. [ENFORCE] With Enforce enabled the list will only allow galleries that match the specified filter into the list. """ SUPPORTED_DOWNLOAD_URLS=\ """Supported URLs: - exhentai/g.e-hentai/e-hentai gallery urls, e.g.: https://e-hentai.org/g/618395/0439fa3666/ - panda.chaika.moe gallery and archive urls http://panda.chaika.moe/[0]/[1]/ where [0] is 'gallery' or 'archive' and [1] are numbers - asmhentai.com gallery urls, e.g: http://asmhentai.com/g/102845/ """ SUPPORTED_METADATA_URLS=\ """Supported gallery URLs: - exhentai/g.e-hentai gallery urls, e.g.: http://g.e-hentai.org/g/618395/0439fa3666/ - panda.chaika.moe gallery and archive urls http://panda.chaika.moe/[0]/[1]/ where [0] is 'gallery' or 'archive' and [1] is numbers """ EXHEN_COOKIE_TUTORIAL =\ """ How do I find these two values?
All browsers
1. Navigate to e-hentai.org (needs to be logged in) or exhentai.org
2. Right click on page --> Inspect element
3. Go on 'Console' tab
4. Write : 'document.cookie'
5. A line of values should appear that correspond to active cookies
6. Look for the 'ipb_member_id' and 'ipb_pass_hash' values
""" REGEXCHEAT =\ """ Untitled Document.md

Characters I

Expression Meaning
. Match any character except newline
^ Match the start of the string
$ Match the end of the string
* Match 0 or more repetitions
+ Match 1 or more repetitions
? Match 0 or 1 repetitions

Special Sequences I

Expression Meaning
\A Match only at start of string
\\b Match empty string, only at beginning or end of a word
\B Match empty string, only when it is not at beginning or end of word
\d Match digits # same as [0-9]
\D Match any non digit # same as [^0-9]

Characters II

Expression Meaning
*? Match 0 or more repetitions non-greedy
+? Match 1 or more repetitions non-greedy
?? Match 0 or 1 repetitions non-greedy
\ Escape special characters
[] Match a set of characters
[a-z] Match any lowercase ASCII letter
[lower-upper] Match a set of characters from lower to upper
[^] Match characters NOT in a set
A|B Match either A or B regular expressions (non-greedy)

Special Sequences II

Expression Meaning
\s Match whitespace characters # same as [ \t\n\r\f\v]
\S Match non whitespace characters #same as [^ \t\n\r\f\v]
\w Match unicode word characters # same as [a-zA-Z0-9_]
\W Match any character not a Unicode word character # same as [^a-zA-Z0-9_]
\Z Match only at end of string

Characters III

Expression Meaning
{m} Match exactly m copies
{m,n} Match from m to n repetitions
{,n} Match from 0 to n repetitions
{m,} Match from m to infinite repetitions
{m,n}? Match from m to n repetitions non-greedy (as few as possible)

Groups I

Expression Meaning
(match) Use to specify a group for which match can be retrieved later
(?:match) Non-capturing version parenthesis (match cannot be retrieved later)
(?P<name>) Capture group with name “name”
(?P=name) Back reference group named “name” in same pattern
(?#comment) Comment

Lookahead / Behind I

Expression Meaning
(?=match) Lookahead assertion - match if contents matches next, but don’t consume any of the string.
(?!match) Negative lookahead assertion - match if contents do not match next
(?<=match) Positive lookbehind assertion - match if current position in string is preceded by match
(?<!match) Negative lookbehind assertion - match if current position is not preceded by match
(?(id/name)yes|no) Match “yes” pattern if id or name exists, otherwise match “no” pattern
""" ABOUT =\ """

Creator: Pewpews

Twitter: @pewspew

Chat: Gitter chat

Email: happypandabugs@gmail.com

Current version: {}

Current database version: {}

License: GENERAL PUBLIC LICENSE, Version 2

Happypanda was created using:

  • Python 3.5
  • The Qt5 Framework
  • Various python libraries (see github repo)

Contributors (github): rachmadaniHaryono (big thanks!), nonamethanks, ImoutoChan, Moshidesu, peaceanpizza, utterbull, LePearlo

""".format(vs, db_constants.CURRENT_DB_VERSION) TROUBLE_GUIDE =\ """ Untitled Document.md

When you encounter a bug, I encourage you to follow these steps to make it easier for me to troubleshoot.

  1. Can you start a new instance of Happypanda and reproduce the bug?
    • If that’s not the case then skip the steps below and go to How to report
      1. First close all instances of Happypanda.
      2. Open a command prompt *(terminal in nix) and navigate to where Happypanda is installed. Eg.: cd path/to/happypanda
      3. Now type the name of the main executable with a -d following, eg.: happypanda.exe -d or main.py -d if you’re running from source.
      4. The program will now open and create a new file named happypanda_debug.log
      5. Now you try to reproduce the error/bug

How to report

If you completed the steps above, make sure to include the happypanda_debug.log file which was created and a description of how you reproduced the error/bug.

  1. Navigate to where you installed Happypanda with a file explorer and find happypanda.log. Send it to me with a description of the bug.
  2. You have 3 options of contacting me:
    • Go to the github repo issue page and create a new issue
    • Enter the gitter chat here and tell me about your issue
    • If for some reason you don’t want anything to do with github, feel free to email me: happypandabugs@gmail.com
""" SEARCH_TUTORIAL_TAGS =\ """ Untitled Document.md

Constraints

  • " (quote), (whitespace)(unless in quotes), , (comma), : (semi-colon), [ (starting bracket) and ] (closing bracket) are ignored and not included in the search
  • terms and namespaces are separated by (white space) (if not in quotes) and/or a , (comma)
  • to include whitespace you must put the term in quotes: "this is a valid term"
  • to exclude a term, prefix the term with a - (hyphen): -"i want to exclude this"

Searching

-> points where the terms will be searched

  • term => show only galleries with term in them -> (title, artist, language, namespace & tags)
  • -term => exclude all galleries with term in them -> (title, artist, language, namespace & tags)
  • ns:term => show only galleries where term is in the ns namespace in them -> (namespace & tags)
  • -ns:term => exclude all galleries where term is in the ns namespace in them -> (namespace & tags)
  • ns:[term1, term2, ...] => equivalent to ns:term1, ns:term2
  • -ns:[term1, term2, ...] => equivalent to -ns:term1, -ns:term2

Protips & warnings

  • When grouping tags under the same namespace in brackets, excluding tags (-term) and including tags (term) can both be used:
    • ns:[term, -term1, term2, ...] -> equivalent to ns:term, -ns:term1, ns:term2
  • ns:-term is NOT equivalent to -ns:term
  • You can enchance your search with regex. Regex can be used anywhere in your search terms
    • Enable regex in settings
  • Clicking on the search icon on the search bar will give you more options to search with

Special namespaced tags

They work just like normal namespaced tags, meaning that the constraints above also apply to them!

  • Reserved means that it shouldn’t be used on your galleries. It won’t be searched for in your gallery.
  • Operator means that less than < and greater than > are supported. They should be used just like the exclude operator -. E.g.: ns:<term or ns:>[term1, term2].
  • term means… well, your term… what you want to search for… any kind of characters not in Constraints above
  • integer means that only numbers are allowed
  • date means a date format. Most (if not all) date formats are supported (try it out yourself)

Namespaced tag(s) Reserved Filtered galleries Operator
tag:none, tag:null * Galleries with no namespace & tags set
artist:none, artist:null * Galleries with no artist set
status:none, status:null * Galleries with no status set
language:none, language:null * Galleries with no language set
type:none, type:null * Galleries with no type set
path:none, path:null * Galleries that has been moved/deleted from the filesystem
descr:none, descr:null, description:none, description:null * Galleries with no description set
"pub date":none, "pub date":null, pub_date:none, pub_date:null, publication:none, publication:null * Galleries with no publication date set
title:term Galleries with term in their title OR has matching namespace & tag
artist:term Galleries with term in their artist OR has matching namespace & tag
language:term, lang:term Galleries with term in their language OR has matching namespace & tag
type:term Galleries with term in their type OR has matching namespace & tag
status:term Galleries with term in their status OR has matching namespace & tag
descr:term, description:term Galleries with term in their description OR has matching namespace & tag
chapter:integer, chapters:integer Galleries with integer chapters OR has matching namespace & tag *
read_count:integer, "read count":integer, times_read:integer, "times read":integer Galleries read integer times OR has matching namespace & tag *
date_added:date, "date added":date Galleries added on date OR has matching namespace & tag *
pub_date:date, "pub date":date, publication:date Galleries published on date OR has matching namespace & tag *
last_read:date, "last read":date Galleries last read on date OR has matching namespace & tag *
rating:integer, stars:integer Galleries that has been rated integer OR has matching namespace & tag *
""" KEYBOARD_SHORTCUTS_INFO =\ """ Untitled Document.md

The not so obvious keyboard shortcuts:


Action Windows Mac KDE Gnome
Quit Ctrl+Q same same same
Focus search bar Ctrl+F same same same
Next view Ctrl+Tab, Forward, Ctrl+F6 Ctrl+}, Forward, Ctrl+Tab Ctrl+Tab, Forward, Ctrl+Comma Ctrl+Tab, Forward
Previous view Ctrl+Shift+Tab, Back, Ctrl+Shift+F6 Ctrl+{, Back, Ctrl+Shift+Tab Ctrl+Shift+Tab, Back, Ctrl+Period Ctrl+Shift+Tab, Back
Next in search history Alt+Right, Shift+Backspace Ctrl+] Alt+Right Alt+Right
Previous in search history Alt+Left, Backspace Ctrl+[ Alt+Left Alt+Left
Help F1 Ctrl+? F1 F1
Toggle gallery menu Alt+G same same same
Toggle view mode Alt+Space same same same
Settings Ctrl+P same same same
""" ================================================ FILE: version/asm_manager.py ================================================ """asmhentai module.""" import logging from pprint import pformat from app_constants import DOWNLOAD_TYPE_OTHER, VALID_GALLERY_CATEGORY from pewnet import ( DLManager as DLManagerObject, Downloader as DownloaderObject, HenItem, ) log = logging.getLogger(__name__) """:class:`logging.Logger`: Logger for module.""" log_i = log.info """:meth:`logging.Logger.info`: Info logger func""" log_d = log.debug """:meth:`logging.Logger.debug`: Debug logger func""" log_w = log.warning """:meth:`logging.Logger.warning`: Warning logger func""" log_e = log.error """:meth:`logging.Logger.error`: Error logger func""" log_c = log.critical """:meth:`logging.Logger.critical`: Critical logger func""" class AsmManager(DLManagerObject): """asmhentai manager. Attributes: url (str): Base url for manager. """ url = 'http://asmhentai.com/' @staticmethod def _find_tags(browser): """find tags from browser. Args: browser: Robobrowser instance. Returns: list: List of doujin/manga tags on the page. """ sibling_tags = browser.select('.tags h3') tags = list(map( lambda x: ( x.text.split(':')[0], x.parent.select('span') ), sibling_tags )) res = [] for tag in tags: for span_tag in tag[1]: res.append('{}:{}'.format(tag[0], span_tag.text)) return res def _get_metadata(self, g_url): """get metadata. for key to fill see HenItem class. Args: g_url: Gallery url. Returns: dict: Metadata from gallery url. """ self.ensure_browser_on_url(url=g_url) html_soup = self._browser res = {} res['title'] = html_soup.select('.info h1')[0].text res['title_jpn'] = html_soup.select('.info h2')[0].text res['filecount'] = html_soup.select('.pages')[0].text.split('Pages:')[1].strip() res['tags'] = self._find_tags(browser=self._browser) if any('Category:' in x for x in res['tags']): res['category'] = [tag.split(':')[1] for tag in res['tags'] if 'Category:' in tag][0] return res def _get_server_id(self, link_parts): """get server id. Args: link_parts (tuple): Tuple of (gallery_id, url_basename) Returns: server_id (str): server id. """ gallery_id, url_basename = link_parts url = 'http://asmhentai.com/gallery/{gallery_id}/{url_basename}/'.format( gallery_id=gallery_id, url_basename=url_basename) self._browser.open(url) link_tags = self._browser.select('img.no_image') # e.g. # link_tag_src = '//images.asmhentai.com/001/12623/1.jpg' link_tag_src = link_tags[0].get('src') return link_tag_src.split('//images.asmhentai.com/')[1].split('/')[0] @staticmethod def _split_href_links_to_parts(links): """Split href links to parts. Args: links (list): List of hrefs. Returns: list of tuple contain url parts. """ return [(x.split('/')[2], x.split('/')[-2]) for x in links] def _get_dl_urls(self, g_url): """get image urls from gallery url. Args: g_url: Gallery url. Returns: list: Image from gallery url. """ # ensure the url self.ensure_browser_on_url(url=g_url) links = self._browser.select('.preview_thumb a') links = [x.get('href') for x in links] # link = '/gallery/168260/22/' links_parts = self._split_href_links_to_parts(links) server_id = self._get_server_id(links_parts[0]) log_d('Server id: {}'.format(server_id)) imgs = list(map( lambda x: 'http://images.asmhentai.com/{}/{}/{}.jpg'.format(server_id, x[0], x[1]), links_parts )) return imgs @staticmethod def _set_ehen_metadata(h_item, dict_metadata): """set ehen metadata. unlike set_metadata method, This will update metadata based on required metadata in Ehen.apply_method. It also use ehen keys if rather than the defined key in asm for better merging with ehen data. (i.e. use 'Artist' instead of 'Artists'). Args: h_item (hen_item.HenItem): Item. dict_metadata (dict): Metadata source. Returns: Updated h_item """ # hardcoded asm to ehen dict e2a_keys = {'Artists': 'Artist', 'Languages': 'Language', 'Characters': 'Character'} new_data_tags = {} for tag in dict_metadata['tags']: namespace, tag_value = tag.split(':', 1) if namespace in e2a_keys: namespace = e2a_keys[namespace] new_data_tags.setdefault(namespace, []).append(tag_value) new_data = { 'title': { 'jpn': dict_metadata['title_jpn'], 'def': dict_metadata['title'], }, 'tags': new_data_tags, 'type': dict_metadata['category'], 'pub_date': '' # asm manager don't parse publication date. it is not exist. } h_item.metadata.update(new_data) return h_item @staticmethod def _set_metadata(h_item, dict_metadata): """set metadata on item from dict_metadata. Args: h_item (hen_item.HenItem): Item. dict_metadata (dict): Metadata source. Returns: Updated h_item """ keys = ['title_jpn', 'title', 'filecount', "tags"] for key in keys: value = dict_metadata.get(key, None) if value: h_item.update_metadata(key=key, value=value) # for hitem gallery value catg_val = dict_metadata.get('category', None) category_dict = {vcatg.lower(): vcatg for vcatg in VALID_GALLERY_CATEGORY} category_value = category_dict.get(catg_val, catg_val) if category_value and category_value in VALID_GALLERY_CATEGORY: h_item.update_metadata(key='category', value=category_value) elif category_value: log_w('Unknown manga category:{}'.format(category_value)) return h_item def from_gallery_url(self, g_url): """Find gallery download url and puts it in download queue. Args: g_url: Gallery url. Returns: Download item """ h_item = HenItem(self._browser.session) h_item.download_type = DOWNLOAD_TYPE_OTHER h_item.gallery_url = g_url # ex/g.e log_d("Opening {}".format(g_url)) dict_metadata = self._get_metadata(g_url=g_url) log_d('dict_metadata:\n{}'.format(pformat(dict_metadata))) h_item.thumb_url = 'http:' + self._browser.select('.cover img')[0].get('src') h_item.fetch_thumb() # name h_item.gallery_name = dict_metadata['title'] # name is the name folder h_item.name = dict_metadata['title'] # get dl link log_d("Getting download URL!") h_item.download_url = self._get_dl_urls(g_url=g_url) h_item = self._set_metadata(h_item=h_item, dict_metadata=dict_metadata) old_metadata = h_item.metadata h_item = self._set_ehen_metadata(h_item=h_item, dict_metadata=dict_metadata) log_d('Old metadata\n{}New metadata\n{}'.format( pformat(old_metadata), pformat(h_item.metadata) )) DownloaderObject.add_to_queue(h_item, self._browser.session) return h_item ================================================ FILE: version/color_line_edit.py ================================================ """LineEdit for color input.""" import sys import logging from PyQt5 import QtWidgets from PyQt5.QtWidgets import ( QLineEdit, QHBoxLayout, QPushButton, QWidget, QColorDialog ) from PyQt5.QtGui import ( QColor, QRegularExpressionValidator, ) from PyQt5.QtCore import ( QRegularExpression, ) log = logging.getLogger(__name__) log_d = log.debug class ColorLineEdit(QLineEdit): """custom line edit for color input. Hex color regex taken from: mkyong.com/regular-expressions/how-to-validate-hex-color-code-with-regular-expression/ Args: hex_color (str): Default hex color. Attributes: default_color (str): Default color. button (QPushButton): Button which reflect the input from user. color_dialog (QColorDialog): Color dialog for this widget. """ hexcolor_regex = r'^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$' button_stylesheet_format = \ 'background-color: {}; border: 1px solid black; border-radius: 5px;' def __init__(self, parent=None, hex_color=None): """init method.""" super(ColorLineEdit, self).__init__(parent) self.init_ui(hex_color=hex_color) def init_ui(self, hex_color=None): """.""" self.setMaxLength(7) self.setPlaceholderText('Hex colors. Eg.: #323232') self.setMaximumWidth(200) # attr self.default_color = hex_color if hex_color is not None else '#fff' self.button = QPushButton() self.button.setMaximumWidth(200) self.button.setStyleSheet(self.button_stylesheet_format.format(self.default_color)) self.color_dialog = QColorDialog() self.button.clicked.connect(self.button_click) regex = QRegularExpression(self.hexcolor_regex) validator = QRegularExpressionValidator(regex, parent=self.validator()) self.setValidator(validator) self.editingFinished.connect(self.update_button_color) def button_click(self): """Function to run when button clicked. Get the text from input, and use it as default arg for color selection dialog. If dialog return valid result, update the button and text input. """ color_number = self.text() current_color = QColor(color_number) color_from_dialog = self.color_dialog.getColor(current_color) if color_from_dialog.isValid(): color_name = color_from_dialog.name() self.button.setStyleSheet(self.button_stylesheet_format.format(color_name)) self.setText(color_name) else: log_d('color is not valid') def update_button_color(self): """Update button's color.""" color_text = self.text() self.button.setStyleSheet(self.button_stylesheet_format.format(color_text)) if __name__ == '__main__': app = QtWidgets.QApplication(sys.argv) hbox_layout = QHBoxLayout() line_edit = ColorLineEdit() hbox_layout.addWidget(line_edit) hbox_layout.addWidget(line_edit.button) window = QWidget() window.setLayout(hbox_layout) window.show() sys.exit(app.exec_()) ================================================ FILE: version/database/__init__.py ================================================ """ This file is part of Happypanda. Happypanda is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 2 of the License, or any later version. Happypanda is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with Happypanda. If not, see . """ ================================================ FILE: version/database/db.py ================================================ #""" #This file is part of Happypanda. #Happypanda is free software: you can redistribute it and/or modify #it under the terms of the GNU General Public License as published by #the Free Software Foundation, either version 2 of the License, or #any later version. #Happypanda is distributed in the hope that it will be useful, #but WITHOUT ANY WARRANTY; without even the implied warranty of #MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #GNU General Public License for more details. #You should have received a copy of the GNU General Public License #along with Happypanda. If not, see . #""" import os, sqlite3, threading, queue import logging, time, shutil from . import db_constants log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical def hashes_sql(cols=False): col_list = [ 'hash_id INTEGER PRIMARY KEY', 'hash BLOB', 'series_id INTEGER', 'chapter_id INTEGER', 'page INTEGER', 'FOREIGN KEY(series_id) REFERENCES series(series_id) ON DELETE CASCADE', 'FOREIGN KEY(chapter_id) REFERENCES chapters(chapter_id) ON DELETE CASCADE', 'UNIQUE(hash, series_id, chapter_id, page)' ] sql = "CREATE TABLE IF NOT EXISTS hashes({});".format(",".join(col_list)) if cols: return sql, col_list return sql def series_sql(cols=False): col_list = [ 'series_id INTEGER PRIMARY KEY', 'title TEXT', 'artist TEXT', 'profile BLOB', 'series_path BLOB', 'is_archive INTEGER', 'path_in_archive BLOB', 'info TEXT', 'fav INTEGER', 'type TEXT', 'link BLOB', 'language TEXT', 'rating INTEGER NOT NULL DEFAULT 0', 'status TEXT', 'pub_date TEXT', 'date_added TEXT', 'last_read TEXT', 'times_read INTEGER', 'exed INTEGER NOT NULL DEFAULT 0', 'db_v REAL', 'view INTEGER DEFAULT 1' ] sql = "CREATE TABLE IF NOT EXISTS series({});".format(",".join(col_list)) if cols: return sql, col_list return sql def chapters_sql(cols=False): col_list = [ 'chapter_id INTEGER PRIMARY KEY', 'series_id INTEGER', "chapter_title TEXT NOT NULL DEFAULT ''", 'chapter_number INTEGER', 'chapter_path BLOB', 'pages INTEGER', 'in_archive INTEGER', 'FOREIGN KEY(series_id) REFERENCES series(series_id) ON DELETE CASCADE' ] sql = "CREATE TABLE IF NOT EXISTS chapters({});".format(",".join(col_list)) if cols: return sql, col_list return sql def namespaces_sql(cols=False): col_list = [ 'namespace_id INTEGER PRIMARY KEY', 'namespace TEXT NOT NULL UNIQUE' ] sql = "CREATE TABLE IF NOT EXISTS namespaces({});".format(",".join(col_list)) if cols: return sql, col_list return sql def tags_sql(cols=False): col_list = [ 'tag_id INTEGER PRIMARY KEY', 'tag TEXT NOT NULL UNIQUE' ] sql = "CREATE TABLE IF NOT EXISTS tags({});".format(",".join(col_list)) if cols: return sql, col_list return sql def tags_mappings_sql(cols=False): col_list = [ 'tags_mappings_id INTEGER PRIMARY KEY', 'namespace_id INTEGER', 'tag_id INTEGER', 'FOREIGN KEY(namespace_id) REFERENCES namespaces(namespace_id) ON DELETE CASCADE', 'FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON DELETE CASCADE', 'UNIQUE(namespace_id, tag_id)' ] sql = "CREATE TABLE IF NOT EXISTS tags_mappings({});".format(",".join(col_list)) if cols: return sql, col_list return sql def series_tags_mappings_sql(cols=False): col_list = [ 'series_id INTEGER', 'tags_mappings_id INTEGER', 'FOREIGN KEY(series_id) REFERENCES series(series_id) ON DELETE CASCADE', 'FOREIGN KEY(tags_mappings_id) REFERENCES tags_mappings(tags_mappings_id) ON DELETE CASCADE', 'UNIQUE(series_id, tags_mappings_id)' ] sql = "CREATE TABLE IF NOT EXISTS series_tags_map({});".format(",".join(col_list)) if cols: return sql, col_list return sql def list_sql(cols=False): col_list = [ 'list_id INTEGER PRIMARY KEY', "list_name TEXT NOT NULL DEFAULT ''", 'list_filter TEXT', "profile BLOB", "type INTEGER DEFAULT 0", "enforce INTEGER DEFAULT 0", "regex INTEGER DEFAULT 0", "l_case INTEGER DEFAULT 0", "strict INTEGER DEFAULT 0", ] sql = "CREATE TABLE IF NOT EXISTS list({});".format(",".join(col_list)) if cols: return sql, col_list return sql def series_list_map_sql(cols=False): col_list = [ 'list_id INTEGER NOT NULL', 'series_id INTEGER INTEGER NOT NULL', 'FOREIGN KEY(list_id) REFERENCES list(list_id) ON DELETE CASCADE', 'FOREIGN KEY(series_id) REFERENCES series(series_id) ON DELETE CASCADE', 'UNIQUE(list_id, series_id)' ] sql = "CREATE TABLE IF NOT EXISTS series_list_map({});".format(",".join(col_list)) if cols: return sql, col_list return sql STRUCTURE_SCRIPT = series_sql()+chapters_sql()+namespaces_sql()+tags_sql()+tags_mappings_sql()+\ series_tags_mappings_sql()+hashes_sql()+list_sql()+series_list_map_sql() def global_db_convert(conn): """ Takes care of converting tables and columns. Don't use this method directly. Use the add_db_revisions instead. """ log_i('Converting tables') c = conn.cursor() series, series_cols = series_sql(True) chapters, chapters_cols = chapters_sql(True) namespaces, namespaces_cols = namespaces_sql(True) tags, tags_cols = tags_sql(True) tags_mappings, tags_mappings_cols = tags_mappings_sql(True) series_tags_mappings, series_tags_mappings_cols = series_tags_mappings_sql(True) hashes, hashes_cols = hashes_sql(True) _list, list_cols = list_sql(True) series_list_map, series_list_map_cols = series_list_map_sql(True) t_d = {} t_d['series'] = series_cols t_d['chapters'] = chapters_cols t_d['namespaces'] = namespaces_cols t_d['tags'] = tags_cols t_d['tags_mappings'] = tags_mappings_cols t_d['series_tags_mappings'] = series_tags_mappings_cols t_d['hashes'] = hashes_cols t_d['list'] = list_cols t_d['series_list_map'] = series_list_map_cols log_d('Checking table structures') c.executescript(STRUCTURE_SCRIPT) conn.commit() log_d('Checking columns') for table in t_d: for col in t_d[table]: try: c.execute('ALTER TABLE {} ADD COLUMN {}'.format(table, col)) log_d('Added new column: {}'.format(col)) except: log_d('Skipped column: {}'.format(col)) conn.commit() log_d('Commited DB changes') return c def add_db_revisions(old_db): """ Adds specific DB revisions items. Note: pass a path to db """ log_i('Converting DB') conn = sqlite3.connect(old_db, check_same_thread=False) conn.row_factory = sqlite3.Row log_i('Converting tables and columns') c = global_db_convert(conn) log_d('Updating DB version') c.execute('UPDATE version SET version=? WHERE 1', (db_constants.CURRENT_DB_VERSION,)) conn.commit() conn.close() return def create_db_path(db_path=db_constants.DB_PATH): head = os.path.split(db_path)[0] os.makedirs(head, exist_ok=True) if not os.path.isfile(db_path): with open(db_path, 'x') as f: pass return db_path def check_db_version(conn): "Checks if DB version is allowed. Raises dialog if not" vs = "SELECT version FROM version" c = conn.cursor() c.execute(vs) log_d('Checking DB Version') db_vs = c.fetchone() db_constants.REAL_DB_VERSION = db_vs[0] if db_vs[0] not in db_constants.DB_VERSION: msg = "Incompatible database" log_c(msg) log_d('Local database version: {}\nProgram database version:{}'.format(db_vs[0], db_constants.CURRENT_DB_VERSION)) #ErrorQueue.put(msg) return False return True def init_db(path=db_constants.DB_PATH): """Initialises the DB. Returns a sqlite3 connection, which will be passed to the db thread. """ def db_layout(cursor): c = cursor # version c.execute(""" CREATE TABLE IF NOT EXISTS version(version REAL) """) c.execute("""INSERT INTO version(version) VALUES(?)""", (db_constants.CURRENT_DB_VERSION,)) log_i("Constructing database layout") log_d("Database Layout:\n\t{}".format(STRUCTURE_SCRIPT)) c.executescript(STRUCTURE_SCRIPT) def new_db(p, new=False): conn = sqlite3.connect(p, check_same_thread=False) conn.row_factory = sqlite3.Row if new: c = conn.cursor() db_layout(c) conn.commit() return conn if os.path.isfile(path): conn = new_db(path) if path == db_constants.DB_PATH and not check_db_version(conn): return None else: create_db_path() conn = new_db(path, True) conn.isolation_level = None conn.execute("PRAGMA foreign_keys = on") return conn class DBBase: "The base DB class. _DB_CONN should be set at runtime on startup" _DB_CONN = None _AUTO_COMMIT = True _STATE = {'active':False} def __init__(self, **kwargs): pass @classmethod def begin(cls): "Useful when modifying for a large amount of data" if not cls._STATE['active']: cls._AUTO_COMMIT = False cls.execute(cls, "BEGIN TRANSACTION") cls._STATE['active'] = True #print("STARTED DB OPTIMIZE") @classmethod def end(cls): "Called to commit and end transaction" if cls._STATE['active']: try: cls.execute(cls, "COMMIT") except sqlite3.OperationalError: pass cls._AUTO_COMMIT = True cls._STATE['active'] = False #print("ENDED DB OPTIMIZE") def execute(self, *args): "Same as cursor.execute" if not self._DB_CONN: raise db_constants.NoDatabaseConnection log_d('DB Query: {}'.format(args).encode(errors='ignore')) if self._AUTO_COMMIT: try: with self._DB_CONN: return self._DB_CONN.execute(*args) except sqlite3.InterfaceError: return self._DB_CONN.execute(*args) else: return self._DB_CONN.execute(*args) def executemany(self, *args): "Same as cursor.executemany" if not self._DB_CONN: raise db_constants.NoDatabaseConnection log_d('DB Query: {}'.format(args).encode(errors='ignore')) if self._AUTO_COMMIT: with self._DB_CONN: return self._DB_CONN.executemany(*args) else: c = self._DB_CONN.executemany(*args) return c def commit(self): self._DB_CONN.commit() @classmethod def analyze(cls): cls._DB_CONN.execute('ANALYZE') @classmethod def close(cls): cls._DB_CONN.close() if __name__ == '__main__': raise RuntimeError("Unit tests not yet implemented") # unit tests here! ================================================ FILE: version/database/db_constants.py ================================================ #""" #This file is part of Happypanda. #Happypanda is free software: you can redistribute it and/or modify #it under the terms of the GNU General Public License as published by #the Free Software Foundation, either version 2 of the License, or #any later version. #Happypanda is distributed in the hope that it will be useful, #but WITHOUT ANY WARRANTY; without even the implied warranty of #MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #GNU General Public License for more details. #You should have received a copy of the GNU General Public License #along with Happypanda. If not, see . #""" import os DB_NAME = 'happypanda.db' THUMB_NAME = "thumbnails" if os.name == 'posix': DB_ROOT = os.path.join(os.path.dirname(os.path.realpath(__file__)), '../db') THUMBNAIL_PATH = os.path.join(DB_ROOT, THUMB_NAME) DB_PATH = os.path.join(DB_ROOT, DB_NAME) else: DB_ROOT = "db" THUMBNAIL_PATH = os.path.join("db", THUMB_NAME) DB_PATH = os.path.join(DB_ROOT, DB_NAME) DB_VERSION = [0.26] # a list of accepted db versions. E.g. v3.5 will be backward compatible with v3.1 etc. CURRENT_DB_VERSION = DB_VERSION[0] REAL_DB_VERSION = DB_VERSION[len(DB_VERSION)-1] METHOD_QUEUE = None METHOD_RETURN = None DATABASE = None class NoDatabaseConnection(Exception): pass ================================================ FILE: version/executors.py ================================================ import logging, uuid, os from concurrent import futures from PyQt5.QtCore import Qt from PyQt5.QtGui import QImage, QPainter, QBrush, QPen from database import db_constants import utils import app_constants log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical def _rounded_qimage(qimg, radius): r_image = QImage(qimg.width(), qimg.height(), QImage.Format_ARGB32) r_image.fill(Qt.transparent) p = QPainter() pen = QPen(Qt.darkGray) pen.setJoinStyle(Qt.RoundJoin) p.begin(r_image) p.setRenderHint(p.Antialiasing) p.setPen(Qt.NoPen) p.setBrush(QBrush(qimg)) p.drawRoundedRect(0, 0, r_image.width(), r_image.height(), radius, radius) p.end() return r_image def _task_thumbnail(gallery_or_path, img=None, width=app_constants.THUMB_W_SIZE, height=app_constants.THUMB_H_SIZE): """ """ log_i("Generating thumbnail") # generate a cache dir if required if not os.path.isdir(db_constants.THUMBNAIL_PATH): os.mkdir(db_constants.THUMBNAIL_PATH) try: if not img: img_path = utils.get_gallery_img(gallery_or_path) else: img_path = img if not img_path: raise IndexError for ext in utils.IMG_FILES: if img_path.lower().endswith(ext): suff = ext # the image ext with dot # generate unique file name file_name = str(uuid.uuid4()) + ".png" new_img_path = os.path.join(db_constants.THUMBNAIL_PATH, (file_name)) if not os.path.isfile(img_path): raise IndexError # Do the scaling try: im_data = utils.PToQImageHelper(img_path) image = QImage(im_data['data'], im_data['im'].size[0], im_data['im'].size[1], im_data['format']) if im_data['colortable']: image.setColorTable(im_data['colortable']) except ValueError: image = QImage() image.load(img_path) if image.isNull(): raise IndexError radius = 5 image = image.scaled(width, height, Qt.KeepAspectRatio, Qt.SmoothTransformation) r_image = _rounded_qimage(image, radius) r_image.save(new_img_path, "PNG", quality=80) except IndexError: new_img_path = app_constants.NO_IMAGE_PATH return new_img_path def _task_load_thumbnail(ppath, thumb_size, on_method=None, **kwargs): if ppath: img = QImage(ppath) if not img.isNull(): size = img.size() if size.width() != thumb_size[0]: # TODO: use _task_thumbnail img = _rounded_qimage(img.scaled(thumb_size[0], thumb_size[1], Qt.KeepAspectRatio, Qt.SmoothTransformation), 5) if on_method: on_method(img, **kwargs) return img class Executors: _thumbnail_exec = futures.ThreadPoolExecutor(3) _profile_exec = futures.ThreadPoolExecutor(2) @classmethod def generate_thumbnail(cls, gallery_or_path, img=None, width=app_constants.THUMB_W_SIZE, height=app_constants.THUMB_H_SIZE, on_method=None, blocking=False): log_i("Generating thumbnail") f = cls._thumbnail_exec.submit(_task_thumbnail, gallery_or_path, img=img, width=width, height=height) if on_method: f.add_done_callback(on_method) if blocking: return f.result() if not on_method: return f log_d("Returning future") @classmethod def load_thumbnail(cls, ppath, thumb_size=app_constants.THUMB_DEFAULT, on_method=None, **kwargs): "**kwargs will be passed to on_method" f = cls._profile_exec.submit(_task_load_thumbnail, ppath, thumb_size, on_method, **kwargs) return f ================================================ FILE: version/fetch.py ================================================ #""" #This file is part of Happypanda. #Happypanda is free software: you can redistribute it and/or modify #it under the terms of the GNU General Public License as published by #the Free Software Foundation, either version 2 of the License, or #any later version. #Happypanda is distributed in the hope that it will be useful, #but WITHOUT ANY WARRANTY; without even the implied warranty of #MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #GNU General Public License for more details. #You should have received a copy of the GNU General Public License #along with Happypanda. If not, see . #""" import os, time, logging, uuid, random, queue, scandir import re as regex from PyQt5.QtCore import QObject, pyqtSignal # need this for interaction with main thread from gallerydb import Gallery, GalleryDB, HashDB, execute import app_constants import pewnet import settings import utils """This file contains functions to fetch gallery data""" log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical class Fetch(QObject): """ A class containing methods to fetch gallery data. Should be executed in a new thread. Contains following methods: local -> runs a local search in the given series_path auto_web_metadata -> does a search online for the given galleries and returns their metdata """ # local signals LOCAL_EMITTER = pyqtSignal(Gallery) FINISHED = pyqtSignal(object) DATA_COUNT = pyqtSignal(int) PROGRESS = pyqtSignal(int) SKIPPED = pyqtSignal(list) # WEB signals GALLERY_EMITTER = pyqtSignal(Gallery, object, object) AUTO_METADATA_PROGRESS = pyqtSignal(str) GALLERY_PICKER = pyqtSignal(object, list, queue.Queue) GALLERY_PICKER_QUEUE = queue.Queue() def __init__(self, parent=None): super().__init__(parent) self.series_path = "" self._data = [] self._curr_gallery = '' # for debugging purposes self.skipped_paths = [] # web self._default_ehen_url = app_constants.DEFAULT_EHEN_URL self.galleries = [] self.galleries_in_queue = [] self.error_galleries = [] self._hen_list = [] #filter self.galleries_from_db = [] self._refresh_filter_list() #download self._to_queue_container = False self._galleries_queue = queue.Queue() def _refresh_filter_list(self): gallery_data = app_constants.GALLERY_DATA + app_constants.GALLERY_ADDITION_DATA filter_list = [] for g in gallery_data: filter_list.append(os.path.normcase(g.path)) self.galleries_from_db = sorted(filter_list) def create_gallery(self, path, folder_name, do_chapters=True, archive=None): is_archive = True if archive else False temp_p = archive if is_archive else path folder_name = folder_name or path if folder_name or path else os.path.split(archive)[1] if utils.check_ignore_list(temp_p) and not GalleryDB.check_exists(temp_p, self.galleries_from_db, False): log_i('Creating gallery: {}'.format(folder_name.encode('utf-8', 'ignore'))) new_gallery = Gallery() images_paths = [] metafile = utils.GMetafile() try: con = scandir.scandir(temp_p) #all of content in the gallery folder log_i('Gallery source is a directory') chapters = sorted([sub.path for sub in con if sub.is_dir() or sub.name.endswith(utils.ARCHIVE_FILES)])\ if do_chapters else [] #subfolders # if gallery has chapters divided into sub folders numb_of_chapters = len(chapters) if numb_of_chapters != 0: log_i('Gallery has {} chapters'.format(numb_of_chapters)) for ch in chapters: chap = new_gallery.chapters.create_chapter() chap.title = utils.title_parser(ch)['title'] chap.path = os.path.join(path, ch) chap.pages = len([x for x in scandir.scandir(chap.path) if x.name.endswith(utils.IMG_FILES)]) metafile.update(utils.GMetafile(chap.path)) else: #else assume that all images are in gallery folder chap = new_gallery.chapters.create_chapter() chap.title = utils.title_parser(os.path.split(path)[1])['title'] chap.path = path metafile.update(utils.GMetafile(chap.path)) chap.pages = len(list(scandir.scandir(path))) parsed = utils.title_parser(folder_name) except NotADirectoryError: try: if is_archive or temp_p.endswith(utils.ARCHIVE_FILES): log_i('Gallery source is an archive') contents = utils.check_archive(temp_p) if contents: new_gallery.is_archive = 1 new_gallery.path_in_archive = '' if not is_archive else path if folder_name.endswith('/'): folder_name = folder_name[:-1] fn = os.path.split(folder_name) folder_name = fn[1] or fn[2] folder_name = folder_name.replace('/','') if folder_name.endswith(utils.ARCHIVE_FILES): n = folder_name for ext in utils.ARCHIVE_FILES: n = n.replace(ext, '') parsed = utils.title_parser(n) else: parsed = utils.title_parser(folder_name) if do_chapters: archive_g = sorted(contents) if not archive_g: log_w('No chapters found for {}'.format(temp_p.encode(errors='ignore'))) raise ValueError for g in archive_g: chap = new_gallery.chapters.create_chapter() chap.in_archive = 1 chap.title = parsed['title'] if not g else utils.title_parser(g.replace('/', ''))['title'] chap.path = g metafile.update(utils.GMetafile(g, temp_p)) arch = utils.ArchiveFile(temp_p) chap.pages = len([x for x in arch.dir_contents(g) if x.endswith(utils.IMG_FILES)]) arch.close() else: chap = new_gallery.chapters.create_chapter() chap.title = utils.title_parser(os.path.split(path)[1])['title'] chap.in_archive = 1 chap.path = path metafile.update(utils.GMetafile(path, temp_p)) arch = utils.ArchiveFile(temp_p) chap.pages = len(arch.dir_contents('')) arch.close() else: raise ValueError else: raise ValueError except ValueError: log_w('Skipped {} in local search'.format(path.encode(errors='ignore'))) self.skipped_paths.append((temp_p, 'Empty archive',)) return except app_constants.CreateArchiveFail: log_w('Skipped {} in local search'.format(path.encode(errors='ignore'))) self.skipped_paths.append((temp_p, 'Error creating archive',)) return except app_constants.TitleParsingError: log_w('Skipped {} in local search'.format(path.encode(errors='ignore'))) self.skipped_paths.append((temp_p, 'Error while parsing folder/archive name',)) return new_gallery.title = parsed['title'] new_gallery.path = temp_p new_gallery.artist = parsed['artist'] new_gallery.language = parsed['language'] new_gallery.info = "" new_gallery.view = app_constants.ViewType.Addition metafile.apply_gallery(new_gallery) if app_constants.MOVE_IMPORTED_GALLERIES and not app_constants.OVERRIDE_MOVE_IMPORTED_IN_FETCH: new_gallery.move_gallery() self.LOCAL_EMITTER.emit(new_gallery) self._data.append(new_gallery) log_i('Gallery successful created: {}'.format(folder_name.encode('utf-8', 'ignore'))) return True else: log_i('Gallery already exists or ignored: {}'.format(folder_name.encode('utf-8', 'ignore'))) self.skipped_paths.append((temp_p, 'Already exists or ignored')) return False def local(self, s_path=None): """ Do a local search in the given series_path. """ self._data.clear() if s_path: self.series_path = s_path try: gallery_l = sorted([p.name for p in scandir.scandir(self.series_path)]) #list of folders in the "Gallery" folder mixed = False except TypeError: gallery_l = self.series_path mixed = True if len(gallery_l) != 0: # if gallery path list is not empty log_i('Gallery folder is not empty') if len(self.galleries_from_db) != len(app_constants.GALLERY_DATA): self._refresh_filter_list() self.DATA_COUNT.emit(len(gallery_l)) #tell model how many items are going to be added log_i('Received {} paths'.format(len(gallery_l))) progress = 0 for folder_name in gallery_l: # folder_name = gallery folder title self._curr_gallery = folder_name if mixed: path = folder_name folder_name = os.path.split(path)[1] else: path = os.path.join(self.series_path, folder_name) if app_constants.SUBFOLDER_AS_GALLERY or app_constants.OVERRIDE_SUBFOLDER_AS_GALLERY: if app_constants.OVERRIDE_SUBFOLDER_AS_GALLERY: app_constants.OVERRIDE_SUBFOLDER_AS_GALLERY = False log_i("Treating each subfolder as gallery") if os.path.isdir(path): gallery_folders, gallery_archives = utils.recursive_gallery_check(path) for gs in gallery_folders: self.create_gallery(gs, os.path.split(gs)[1], False) p_saving = {} for gs in gallery_archives: self.create_gallery(gs[0], os.path.split(gs[0])[1], False, archive=gs[1]) elif path.endswith(utils.ARCHIVE_FILES): for g in utils.check_archive(path): self.create_gallery(g, os.path.split(g)[1], False, archive=path) else: try: if os.path.isdir(path): if not list(scandir.scandir(path)): raise ValueError elif not path.endswith(utils.ARCHIVE_FILES): raise NotADirectoryError log_i("Treating each subfolder as chapter") self.create_gallery(path, folder_name, do_chapters=True) except ValueError: self.skipped_paths.append((path, 'Empty directory')) log_w('Directory is empty: {}'.format(path.encode(errors='ignore'))) except NotADirectoryError: self.skipped_paths.append((path, 'Unsupported file')) log_w('Unsupported file: {}'.format(path.encode(errors='ignore'))) progress += 1 # update the progress bar self.PROGRESS.emit(progress) else: # if gallery folder is empty log_e('Local search error: Invalid directory') log_e('Gallery folder is empty') app_constants.OVERRIDE_MOVE_IMPORTED_IN_FETCH = True # sanity check self.FINISHED.emit(False) # might want to include an error message app_constants.OVERRIDE_MOVE_IMPORTED_IN_FETCH = False # everything went well log_i('Local search: OK') log_i('Created {} items'.format(len(self._data))) if self._to_queue_container: for x in self._data: self._galleries_queue.put(x) else: self.FINISHED.emit(self._data) if self.skipped_paths: self.SKIPPED.emit(self.skipped_paths) def _return_gallery_metadata(self, gallery): "Emits galleries" assert isinstance(gallery, Gallery) if gallery: gallery.exed = 1 self.GALLERY_EMITTER.emit(gallery, None, False) log_d('Success') def fetch_metadata(self, gallery=None, hen=None, proc=False): """ Puts gallery in queue for metadata fetching. Applies received galleries and sends them to gallery model. Set proc to true if you want to process the queue immediately """ if gallery: log_i("Fetching metadata for gallery: {}".format(gallery.title.encode(errors='ignore'))) log_i("Adding to queue: {}".format(gallery.title.encode(errors='ignore'))) if proc: metadata = hen.add_to_queue(gallery.temp_url, True) else: metadata = hen.add_to_queue(gallery.temp_url) self.galleries_in_queue.append(gallery) else: metadata = hen.add_to_queue(proc=True) if metadata == 1: # Gallery is now put in queue return None # We received something from get_metadata if not metadata: # metadata fetching failed if gallery: self.error_galleries.append((gallery, "No metadata found for gallery")) log_i("An error occured while fetching metadata with gallery: {}".format( gallery.title.encode(errors='ignore'))) return None self.AUTO_METADATA_PROGRESS.emit("Applying metadata...") for x, g in enumerate(self.galleries_in_queue, 1): try: data = metadata[g.temp_url] except KeyError: self.AUTO_METADATA_PROGRESS.emit("No metadata found for gallery: {}".format(g.title)) self.error_galleries.append((g, "No metadata found for gallery")) log_w("No metadata found for gallery: {}".format(g.title.encode(errors='ignore'))) continue log_i('({}/{}) Applying metadata for gallery: {}'.format(x, len(self.galleries_in_queue), g.title.encode(errors='ignore'))) if app_constants.REPLACE_METADATA: g = hen.apply_metadata(g, data, False) else: g = hen.apply_metadata(g, data) self._return_gallery_metadata(g) log_i('Successfully applied metadata to gallery: {}'.format(g.title.encode(errors='ignore'))) self.galleries_in_queue.clear() self.AUTO_METADATA_PROGRESS.emit('Finished applying metadata') log_i('Finished applying metadata') def _auto_metadata_process(self, galleries, hen, valid_url, **kwargs): hen.LAST_USED = time.time() self.AUTO_METADATA_PROGRESS.emit("Checking gallery urls...") fetched_galleries = [] checked_pre_url_galleries = [] multiple_hit_galleries = [] for x, gallery in enumerate(galleries, 1): custom_args = {} # send to hen class log_i("Checking gallery url") # coming from GalleryDialog if hasattr(gallery, "_g_dialog_url"): if gallery._g_dialog_url: gallery.temp_url = gallery._g_dialog_url checked_pre_url_galleries.append(gallery) # to process even if this gallery is last and fails if x == len(galleries): self.fetch_metadata(hen=hen) continue if gallery.link and app_constants.USE_GALLERY_LINK: log_i("Using existing gallery url") check = self._website_checker(gallery.link) if check == valid_url: # convert g.e-h to e-h gallery.link = pewnet.HenManager.gtoEh(gallery.link) gallery.temp_url = gallery.link checked_pre_url_galleries.append(gallery) if x == len(galleries): self.fetch_metadata(hen=hen) continue self.AUTO_METADATA_PROGRESS.emit("({}/{}) Generating gallery hash: {}".format(x, len(galleries), gallery.title)) log_i("Generating gallery hash: {}".format(gallery.title.encode(errors='ignore'))) hash = None try: if not gallery.hashes: color_img = kwargs['color'] if 'color' in kwargs else False # used for similarity search on EH hash_dict = execute(HashDB.gen_gallery_hash, False, gallery, 0, 'mid', color_img) if color_img and 'color' in hash_dict: custom_args['color'] = hash_dict['color'] # will be path to filename hash = hash_dict['color'] elif hash_dict: hash = hash_dict['mid'] else: hash = gallery.hashes[random.randint(0, len(gallery.hashes)-1)] except app_constants.CreateArchiveFail: pass if not hash: self.error_galleries.append((gallery, "Could not generate hash")) log_e("Could not generate hash for gallery: {}".format(gallery.title.encode(errors='ignore'))) if x == len(galleries): self.fetch_metadata(hen=hen) continue gallery.hash = hash # dict -> hash:[list of title,url tuples] or None self.AUTO_METADATA_PROGRESS.emit("({}/{}) Searching url for gallery: {}".format(x, len(galleries), gallery.title)) found_url = hen.search(gallery.hash, **custom_args) if found_url == 'error': app_constants.GLOBAL_EHEN_LOCK = False self.FINISHED.emit(True) return if not gallery.hash in found_url: self.error_galleries.append((gallery, "Could not find url for gallery")) self.AUTO_METADATA_PROGRESS.emit("Could not find url for gallery: {}".format(gallery.title)) log_w('Could not find url for gallery: {}'.format(gallery.title.encode(errors='ignore'))) if x == len(galleries): self.fetch_metadata(hen=hen) continue title_url_list = found_url[gallery.hash] if not len(title_url_list) > 1 or app_constants.ALWAYS_CHOOSE_FIRST_HIT: title = title_url_list[0][0] url = title_url_list[0][1] else: multiple_hit_galleries.append([gallery, title_url_list]) if x == len(galleries): self.fetch_metadata(hen=hen) continue if not gallery.link: if isinstance(hen, (pewnet.EHen, pewnet.ExHen)): gallery.link = url self.GALLERY_EMITTER.emit(gallery, None, None) gallery.temp_url = url self.AUTO_METADATA_PROGRESS.emit("({}/{}) Adding to queue: {}".format( x, len(galleries), gallery.title)) self.fetch_metadata(gallery, hen, x == len(galleries)) if checked_pre_url_galleries: for x, gallery in enumerate(checked_pre_url_galleries, 1): self.AUTO_METADATA_PROGRESS.emit("({}/{}) Adding to queue: {}".format( x, len(checked_pre_url_galleries), gallery.title)) self.fetch_metadata(gallery, hen, x == len(checked_pre_url_galleries)) if multiple_hit_galleries: skip_all = False multiple_hit_g_queue = [] for x, g_data in enumerate(multiple_hit_galleries, 1): gallery = g_data[0] log_w("Multiple galleries found for gallery: {}".format(gallery.title.encode(errors='ignore'))) if skip_all: log_w("Skipping gallery") continue title_url_list = g_data[1] self.AUTO_METADATA_PROGRESS.emit("Multiple galleries found for gallery: {}".format(gallery.title)) app_constants.SYSTEM_TRAY.showMessage('Happypanda', 'Multiple galleries found for gallery:\n{}'.format(gallery.title), minimized=True) self.GALLERY_PICKER.emit(gallery, title_url_list, self.GALLERY_PICKER_QUEUE) user_choice = self.GALLERY_PICKER_QUEUE.get() if user_choice == None: skip_all = True if not user_choice: log_w("Skipping gallery") continue title = user_choice[0] url = user_choice[1] if not gallery.link: gallery.link = url if isinstance(hen, (pewnet.EHen, pewnet.ExHen)): self.GALLERY_EMITTER.emit(gallery, None, None) gallery.temp_url = url self.AUTO_METADATA_PROGRESS.emit("({}/{}) Adding to queue: {}".format( x, len(multiple_hit_galleries), gallery.title)) multiple_hit_g_queue.append(gallery) for x, g in enumerate(multiple_hit_g_queue, 1): self.fetch_metadata(g, hen, x == len(multiple_hit_g_queue)) def _website_checker(self, url): log_i("Checking if valid URL: {}".format(url)) if not url: return None if 'g.e-hentai.org/g/' in url: return 'ehen' elif 'exhentai.org/g/' in url: return 'exhen' elif 'panda.chaika.moe/archive/' in url or 'panda.chaika.moe/gallery/' in url: return 'chaikahen' else: log_e('Invalid URL') return None def auto_web_metadata(self): """ Auto fetches metadata for the provided list of galleries. Appends or replaces metadata with the new fetched metadata. """ log_i('Initiating auto metadata fetcher') self._hen_list = pewnet.hen_list_init() if self.galleries and not app_constants.GLOBAL_EHEN_LOCK: log_i('Auto metadata fetcher is now running') app_constants.GLOBAL_EHEN_LOCK = True def fetch_cancelled(rsn=''): if rsn: self.AUTO_METADATA_PROGRESS.emit("Metadata fetching cancelled: {}".format(rsn)) app_constants.SYSTEM_TRAY.showMessage("Metadata", "Metadata fetching cancelled: {}".format(rsn), minimized=True) else: self.AUTO_METADATA_PROGRESS.emit("Metadata fetching cancelled!") app_constants.SYSTEM_TRAY.showMessage("Metadata", "Metadata fetching cancelled!", minimized=True) app_constants.GLOBAL_EHEN_LOCK = False self.FINISHED.emit(False) if 'exhentai' in self._default_ehen_url: try: exprops = settings.ExProperties() hen = pewnet.ExHen(exprops.cookies) if hen.check_login(exprops.cookies): valid_url = 'exhen' log_i("using exhen") else: raise ValueError except ValueError: hen = pewnet.EHen() valid_url = 'ehen' log_i("using ehen") else: hen = pewnet.EHen() valid_url = 'ehen' log_i("Using Exhentai") try: self._auto_metadata_process(self.galleries, hen, valid_url, color=True) except app_constants.MetadataFetchFail as err: fetch_cancelled(err) return if self.error_galleries: if self._hen_list: log_i("Using fallback source") self.AUTO_METADATA_PROGRESS.emit("Using fallback source") for hen in self._hen_list: if not self.error_galleries: break galleries = [x[0] for x in self.error_galleries] self.error_galleries.clear() valid_url = "" if hen == pewnet.ChaikaHen: valid_url = "chaikahen" log_i("using chaika hen") try: self._auto_metadata_process(galleries, hen(), valid_url) except app_constants.MetadataFetchFail as err: fetch_cancelled(err) return if not self.error_galleries: self.AUTO_METADATA_PROGRESS.emit('Successfully fetched metadata! Went through {} galleries successfully!'.format(len(self.galleries))) app_constants.SYSTEM_TRAY.showMessage('Successfully fetched metadata', 'Went through {} galleries successfully!'.format(len(self.galleries)), minimized=True) self.FINISHED.emit(True) else: self.AUTO_METADATA_PROGRESS.emit('Finished fetching metadata! Could not fetch metadata for {} galleries. Check happypanda.log for more details!'.format(len(self.error_galleries))) app_constants.SYSTEM_TRAY.showMessage('Finished fetching metadata', 'Could not fetch metadata for {} galleries. Check happypanda.log for more details!'.format(len(self.error_galleries)), minimized=True) for tup in self.error_galleries: log_e("{}: {}".format(tup[1], tup[0].title.encode(errors='ignore'))) self.FINISHED.emit(self.error_galleries) log_i('Auto metadata fetcher is done') app_constants.GLOBAL_EHEN_LOCK = False else: log_e('Auto metadata fetcher is already running') self.AUTO_METADATA_PROGRESS.emit('Auto metadata fetcher is already running!') self.FINISHED.emit(False) ================================================ FILE: version/gallery.py ================================================ #""" #This file is part of Happypanda. #Happypanda is free software: you can redistribute it and/or modify #it under the terms of the GNU General Public License as published by #the Free Software Foundation, either version 2 of the License, or #any later version. #Happypanda is distributed in the hope that it will be useful, #but WITHOUT ANY WARRANTY; without even the implied warranty of #MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #GNU General Public License for more details. #You should have received a copy of the GNU General Public License #along with Happypanda. If not, see . #""" import threading import logging import os import math import functools import random import datetime import pickle import enum import time import re as regex from PyQt5.QtCore import (Qt, QAbstractListModel, QModelIndex, QVariant, QSize, QRect, QEvent, pyqtSignal, QThread, QTimer, QPointF, QSortFilterProxyModel, QAbstractTableModel, QItemSelectionModel, QPoint, QRectF, QDate, QDateTime, QObject, QEvent, QSizeF, QMimeData, QByteArray, QTime) from PyQt5.QtGui import (QPixmap, QBrush, QColor, QPainter, QPen, QTextDocument, QMouseEvent, QHelpEvent, QPixmapCache, QCursor, QPalette, QKeyEvent, QFont, QTextOption, QFontMetrics, QFontMetricsF, QTextLayout, QPainterPath, QScrollPrepareEvent, QWheelEvent, QPolygonF, QLinearGradient) from PyQt5.QtWidgets import (QListView, QFrame, QLabel, QStyledItemDelegate, QStyle, QMenu, QAction, QToolTip, QVBoxLayout, QSizePolicy, QTableWidget, QScrollArea, QHBoxLayout, QFormLayout, QDesktopWidget, QWidget, QHeaderView, QTableView, QApplication, QMessageBox, QActionGroup, QScroller, QStackedLayout) from executors import Executors import gallerydb import app_constants import misc import gallerydialog import io_misc import utils log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical # attempt at implementing treemodel #class TreeNode: # def __init__(self, parent, row): # self.parent = parent # self.row = row # self.subnodes = self._get_children() # def _get_children(self): # raise NotImplementedError() #class GalleryInfoModel(QAbstractItemModel): # def __init__(self, parent=None): # super().__init__(parent) # self.root_nodes = self._get_root_nodes() # def _get_root_nodes(self): # raise NotImplementedError() # def index(self, row, column, parent): # if not parent.isValid(): # return self.createIndex(row, column, self.root_nodes[row]) # parent_node = parent.internalPointer() # return self.createIndex(row, column, parent_node[row]) # def parent(self, index): # if not index.isValid(): # return QModelIndex() # node = index.internalPointer() # if not node.parent: # return QModelIndex() # else: # return self.createIndex(node.parent.row, 0, node.parent) # def reset(self): # self.root_nodes = self._get_root_nodes() # super().resetInternalData() # def rowCount(self, parent = QModelIndex()): # if not parent.isValid(): # return len(self.root_nodes) # node = parent.internalPointer() # return len(node.subnodes) class GallerySearch(QObject): FINISHED = pyqtSignal() def __init__(self, data): super().__init__() self._data = data self.result = {} # filtering self.fav = False self._gallery_list = None def set_gallery_list(self, g_list): self._gallery_list = g_list def set_data(self, new_data): self._data = new_data self.result = {g.id: True for g in self._data} def set_fav(self, new_fav): self.fav = new_fav def search(self, term, args): term = ' '.join(term.split()) search_pieces = utils.get_terms(term) self._filter(search_pieces, args) self.FINISHED.emit() def _filter(self, terms, args): self.result.clear() for gallery in self._data: if self.fav: if not gallery.fav: continue if self._gallery_list: if not gallery in self._gallery_list: continue all_terms = {t: False for t in terms} allow = False if utils.all_opposite(terms): self.result[gallery.id] = True continue for t in terms: if gallery.contains(t, args): all_terms[t] = True if all(all_terms.values()): allow = True self.result[gallery.id] = allow class SortFilterModel(QSortFilterProxyModel): ROWCOUNT_CHANGE = pyqtSignal() _DO_SEARCH = pyqtSignal(str, object) _CHANGE_SEARCH_DATA = pyqtSignal(list) _CHANGE_FAV = pyqtSignal(bool) _SET_GALLERY_LIST = pyqtSignal(object) HISTORY_SEARCH_TERM = pyqtSignal(str) # Navigate terms NEXT, PREV = range(2) # Views CAT_VIEW, FAV_VIEW = range(2) def __init__(self, parent): super().__init__(parent) self.parent_widget = parent self._data = app_constants.GALLERY_DATA self._search_ready = False self.current_term = '' self._history_count = 50 self._prev_term = '' self.terms_history = [] self.current_term_history = -1 self.current_gallery_list = None self.current_args = [] self.current_view = self.CAT_VIEW self.setDynamicSortFilter(True) self.setFilterCaseSensitivity(Qt.CaseInsensitive) self.setSortLocaleAware(True) self.setSortCaseSensitivity(Qt.CaseInsensitive) self.enable_drag = False def navigate_history(self, direction=PREV): new_term = '' if self.terms_history: if direction == self.NEXT: if self.current_term_history < len(self.terms_history) - 1: self.current_term_history += 1 elif direction == self.PREV: if self.current_term_history > 0: self.current_term_history -= 1 new_term = self.terms_history[self.current_term_history] if new_term != self.current_term: self.init_search(new_term, history=False) return new_term def set_gallery_list(self, g_list=None): self.current_gallery_list = g_list self._SET_GALLERY_LIST.emit(g_list) self.refresh() def fav_view(self): self._CHANGE_FAV.emit(True) self.refresh() self.current_view = self.FAV_VIEW def catalog_view(self): self._CHANGE_FAV.emit(False) self.refresh() self.current_view = self.CAT_VIEW def setup_search(self): if not self._search_ready: self.gallery_search = GallerySearch(self.sourceModel()._data) self.gallery_search.FINISHED.connect(self.invalidateFilter) self.gallery_search.FINISHED.connect(lambda: self.ROWCOUNT_CHANGE.emit()) self.gallery_search.moveToThread(app_constants.GENERAL_THREAD) self._DO_SEARCH.connect(self.gallery_search.search) self._SET_GALLERY_LIST.connect(self.gallery_search.set_gallery_list) self._CHANGE_SEARCH_DATA.connect(self.gallery_search.set_data) self._CHANGE_FAV.connect(self.gallery_search.set_fav) self.sourceModel().rowsInserted.connect(self.refresh) self._search_ready = True def refresh(self): self._DO_SEARCH.emit(self.current_term, self.current_args) def init_search(self, term, args=None, **kwargs): """ Receives a search term and initiates a search args should be a list of Search enums """ if not args: args = self.current_args history = kwargs.pop('history', True) if history: if self._prev_term != term: self._prev_term = term # ny path if self.current_term_history != len(self.terms_history) - 1: self.terms_history = self.terms_history[:self.current_term_history+1] if len(self.terms_history) > self._history_count: self.terms_history = self.terms_history[-self._history_count:] self.terms_history.append(term) self.current_term_history = len(self.terms_history) - 1 if self.current_term_history < 0: self.current_term_history = 0 self.current_term = term if not history: self.HISTORY_SEARCH_TERM.emit(term) self.current_args = args self._DO_SEARCH.emit(term, args) def filterAcceptsRow(self, source_row, parent_index): if self.sourceModel(): index = self.sourceModel().index(source_row, 0, parent_index) if index.isValid(): if self._search_ready: gallery = index.data(Qt.UserRole + 1) try: return self.gallery_search.result[gallery.id] except KeyError: pass else: return True return False def change_model(self, model): self.setSourceModel(model) self._data = self.sourceModel()._data self._CHANGE_SEARCH_DATA.emit(self._data) self.refresh() def change_data(self, data): self._CHANGE_SEARCH_DATA.emit(data) def status_b_msg(self, msg): self.sourceModel().status_b_msg(msg) def canDropMimeData(self, data, action, row, coloumn, index): return False if not data.hasFormat("list/gallery"): return False return True def dropMimeData(self, data, action, row, coloumn, index): if not self.canDropMimeData(data, action, row, coloumn, index): return False if action == Qt.IgnoreAction: return True # if the drop occured on an item if not index.isValid(): return False g_list = pickle.loads(data.data("list/gallery").data()) item_g = index.data(GalleryModel.GALLERY_ROLE) # ignore false positive for g in g_list: if g.id == item_g.id: return False txt = 'galleries' if len(g_list) > 1 else 'gallery' msg = QMessageBox(self.parent_widget) msg.setText("Are you sure you want to merge the galleries into this gallery as chapter(s)?".format(txt)) msg.setStandardButtons(msg.Yes | msg.No) if msg.exec() == msg.No: return False # TODO: finish this return True def mimeTypes(self): return ['list/gallery'] + super().mimeTypes() def mimeData(self, index_list): data = QMimeData() g_list = [] for idx in index_list: g = idx.data(GalleryModel.GALLERY_ROLE) if g != None: g_list.append(g) data.setData("list/gallery", QByteArray(pickle.dumps(g_list))) return data def flags(self, index): default_flags = super().flags(index) if self.enable_drag: if (index.isValid()): return Qt.ItemIsDragEnabled | Qt.ItemIsDropEnabled | default_flags else: return Qt.ItemIsDropEnabled | default_flags return default_flags def supportedDragActions(self): return Qt.ActionMask class StarRating(): # enum EditMode Editable, ReadOnly = range(2) PaintingScaleFactor = 18 def __init__(self, starCount=1, maxStarCount=5): self._starCount = starCount self._maxStarCount = maxStarCount self.starPolygon = QPolygonF([QPointF(1.0, 0.5)]) for i in range(5): self.starPolygon << QPointF(0.5 + 0.5 * math.cos(0.8 * i * math.pi), 0.5 + 0.5 * math.sin(0.8 * i * math.pi)) self.diamondPolygon = QPolygonF() self.diamondPolygon << QPointF(0.4, 0.5) \ << QPointF(0.5, 0.4) \ << QPointF(0.6, 0.5) \ << QPointF(0.5, 0.6) \ << QPointF(0.4, 0.5) def starCount(self): return self._starCount def maxStarCount(self): return self._maxStarCount def setStarCount(self, starCount): self._starCount = starCount def setMaxStarCount(self, maxStarCount): self._maxStarCount = maxStarCount def sizeHint(self): return self.PaintingScaleFactor * QSize(self._maxStarCount, 1) def paint(self, painter, rect, editMode=ReadOnly): painter.save() painter.setRenderHint(QPainter.Antialiasing, True) painter.setPen(Qt.NoPen) painter.setBrush(QBrush(QColor(0, 0, 0, 100))) painter.drawRoundedRect(QRectF(rect), 2, 2) painter.setBrush(QBrush(Qt.yellow)) scaleFactor = self.PaintingScaleFactor yOffset = (rect.height() - scaleFactor) / 2 painter.translate(rect.x(), rect.y() + yOffset) painter.scale(scaleFactor, scaleFactor) for i in range(self._maxStarCount): if i < self._starCount: painter.drawPolygon(self.starPolygon, Qt.WindingFill) elif editMode == StarRating.Editable: painter.drawPolygon(self.diamondPolygon, Qt.WindingFill) painter.translate(1.0, 0.0) painter.restore() class GalleryModel(QAbstractTableModel): """ Model for Model/View/Delegate framework """ GALLERY_ROLE = Qt.UserRole + 1 ARTIST_ROLE = Qt.UserRole + 2 FAV_ROLE = Qt.UserRole + 3 DATE_ADDED_ROLE = Qt.UserRole + 4 PUB_DATE_ROLE = Qt.UserRole + 5 TIMES_READ_ROLE = Qt.UserRole + 6 LAST_READ_ROLE = Qt.UserRole + 7 TIME_ROLE = Qt.UserRole + 8 RATING_ROLE = Qt.UserRole + 9 RATING_COUNT = Qt.UserRole + 10 ROWCOUNT_CHANGE = pyqtSignal() STATUSBAR_MSG = pyqtSignal(str) CUSTOM_STATUS_MSG = pyqtSignal(str) ADDED_ROWS = pyqtSignal() ADD_MORE = pyqtSignal() REMOVING_ROWS = False def __init__(self, data, parent=None): super().__init__(parent) self.dataChanged.connect(lambda: self.status_b_msg("Edited")) self.dataChanged.connect(lambda: self.ROWCOUNT_CHANGE.emit()) self.layoutChanged.connect(lambda: self.ROWCOUNT_CHANGE.emit()) self.CUSTOM_STATUS_MSG.connect(self.status_b_msg) self._TITLE = app_constants.TITLE self._ARTIST = app_constants.ARTIST self._TAGS = app_constants.TAGS self._TYPE = app_constants.TYPE self._FAV = app_constants.FAV self._CHAPTERS = app_constants.CHAPTERS self._LANGUAGE = app_constants.LANGUAGE self._LINK = app_constants.LINK self._DESCR = app_constants.DESCR self._DATE_ADDED = app_constants.DATE_ADDED self._PUB_DATE = app_constants.PUB_DATE self._data = data self._data_count = 0 # number of items added to model self._gallery_to_add = [] self._gallery_to_remove = [] def status_b_msg(self, msg): self.STATUSBAR_MSG.emit(msg) def data(self, index, role=Qt.DisplayRole): if not index.isValid(): return QVariant() if index.row() >= len(self._data) or \ index.row() < 0: return QVariant() current_row = index.row() current_gallery = self._data[current_row] current_column = index.column() def column_checker(): if current_column == self._TITLE: title = current_gallery.title return title elif current_column == self._ARTIST: artist = current_gallery.artist return artist elif current_column == self._TAGS: tags = utils.tag_to_string(current_gallery.tags) return tags elif current_column == self._TYPE: type = current_gallery.type return type elif current_column == self._FAV: if current_gallery.fav == 1: return u'\u2605' else: return '' elif current_column == self._CHAPTERS: return len(current_gallery.chapters) elif current_column == self._LANGUAGE: return current_gallery.language elif current_column == self._LINK: return current_gallery.link elif current_column == self._DESCR: return current_gallery.info elif current_column == self._DATE_ADDED: g_dt = "{}".format(current_gallery.date_added) qdate_g_dt = QDateTime.fromString(g_dt, "yyyy-MM-dd HH:mm:ss") return qdate_g_dt elif current_column == self._PUB_DATE: g_pdt = "{}".format(current_gallery.pub_date) qdate_g_pdt = QDateTime.fromString(g_pdt, "yyyy-MM-dd HH:mm:ss") if qdate_g_pdt.isValid(): return qdate_g_pdt else: return 'No date set' # TODO: name all these roles and put them in app_constants... if role == Qt.DisplayRole: return column_checker() # for artist searching if role == self.ARTIST_ROLE: artist = current_gallery.artist return artist if role == Qt.DecorationRole: pixmap = current_gallery.profile return pixmap if role == Qt.BackgroundRole: bg_color = QColor(242, 242, 242) bg_brush = QBrush(bg_color) return bg_color if app_constants.GRID_TOOLTIP and role == Qt.ToolTipRole: add_bold = [] add_tips = [] if app_constants.TOOLTIP_TITLE: add_bold.append('Title:') add_tips.append(current_gallery.title) if app_constants.TOOLTIP_AUTHOR: add_bold.append('Author:') add_tips.append(current_gallery.artist) if app_constants.TOOLTIP_CHAPTERS: add_bold.append('Chapters:') add_tips.append(len(current_gallery.chapters)) if app_constants.TOOLTIP_STATUS: add_bold.append('Status:') add_tips.append(current_gallery.status) if app_constants.TOOLTIP_TYPE: add_bold.append('Type:') add_tips.append(current_gallery.type) if app_constants.TOOLTIP_LANG: add_bold.append('Language:') add_tips.append(current_gallery.language) if app_constants.TOOLTIP_DESCR: add_bold.append('Description:
') add_tips.append(current_gallery.info) if app_constants.TOOLTIP_TAGS: add_bold.append('Tags:') add_tips.append(utils.tag_to_string(current_gallery.tags)) if app_constants.TOOLTIP_LAST_READ: add_bold.append('Last read:') add_tips.append('{} ago'.format(utils.get_date_age(current_gallery.last_read)) if current_gallery.last_read else "Never!") if app_constants.TOOLTIP_TIMES_READ: add_bold.append('Times read:') add_tips.append(current_gallery.times_read) if app_constants.TOOLTIP_PUB_DATE: add_bold.append('Publication Date:') add_tips.append('{}'.format(current_gallery.pub_date).split(' ')[0]) if app_constants.TOOLTIP_DATE_ADDED: add_bold.append('Date added:') add_tips.append('{}'.format(current_gallery.date_added).split(' ')[0]) tooltip = "" tips = list(zip(add_bold, add_tips)) for tip in tips: tooltip += "{} {}
".format(tip[0], tip[1]) return tooltip if role == self.GALLERY_ROLE: return current_gallery # favorite satus if role == self.FAV_ROLE: return current_gallery.fav if role == self.DATE_ADDED_ROLE: date_added = "{}".format(current_gallery.date_added) qdate_added = QDateTime.fromString(date_added, "yyyy-MM-dd HH:mm:ss") return qdate_added if role == self.PUB_DATE_ROLE: if current_gallery.pub_date: pub_date = "{}".format(current_gallery.pub_date) qpub_date = QDateTime.fromString(pub_date, "yyyy-MM-dd HH:mm:ss") return qpub_date if role == self.TIMES_READ_ROLE: return current_gallery.times_read if role == self.LAST_READ_ROLE: if current_gallery.last_read: last_read = "{}".format(current_gallery.last_read) qlast_read = QDateTime.fromString(last_read, "yyyy-MM-dd HH:mm:ss") return qlast_read if role == self.TIME_ROLE: return current_gallery.qtime if role == self.RATING_ROLE: return StarRating(current_gallery.rating) if role == self.RATING_COUNT: return current_gallery.rating return None def rowCount(self, index=QModelIndex()): if index.isValid(): return 0 return len(self._data) def columnCount(self, parent=QModelIndex()): return len(app_constants.COLUMNS) def headerData(self, section, orientation, role=Qt.DisplayRole): if role == Qt.TextAlignmentRole: return Qt.AlignLeft if role != Qt.DisplayRole: return None if orientation == Qt.Horizontal: if section == self._TITLE: return 'Title' elif section == self._ARTIST: return 'Author' elif section == self._TAGS: return 'Tags' elif section == self._TYPE: return 'Type' elif section == self._FAV: return u'\u2605' elif section == self._CHAPTERS: return 'Chapters' elif section == self._LANGUAGE: return 'Language' elif section == self._LINK: return 'URL' elif section == self._DESCR: return 'Description' elif section == self._DATE_ADDED: return 'Date Added' elif section == self._PUB_DATE: return 'Published' return section + 1 def insertRows(self, position, rows, index=QModelIndex()): self._data_count += rows if not self._gallery_to_add: return False self.beginInsertRows(QModelIndex(), position, position + rows - 1) for r in range(rows): self._data.insert(position, self._gallery_to_add.pop()) self.endInsertRows() return True def replaceRows(self, list_of_gallery, position, rows=1, index=QModelIndex()): "replaces gallery data to the data list WITHOUT adding to DB" for pos, gallery in enumerate(list_of_gallery): del self._data[position + pos] self._data.insert(position + pos, gallery) self.dataChanged.emit(index, index, [Qt.UserRole + 1, Qt.DecorationRole]) def removeRows(self, position, rows, index=QModelIndex()): self._data_count -= rows self.beginRemoveRows(QModelIndex(), position, position + rows - 1) for r in range(rows): try: self._data.remove(self._gallery_to_remove.pop()) except ValueError: return False self.endRemoveRows() return True class GridDelegate(QStyledItemDelegate): "A custom delegate for the model/view framework" POPUP = pyqtSignal() CONTEXT_ON = False def __init__(self, app_inst, parent): super().__init__(parent) QPixmapCache.setCacheLimit(app_constants.THUMBNAIL_CACHE_SIZE[0] * app_constants.THUMBNAIL_CACHE_SIZE[1]) self._painted_indexes = {} self.view = parent self.parent_widget = app_inst self._paint_level = 0 #misc.FileIcon.refresh_default_icon() self.file_icons = misc.FileIcon() if app_constants.USE_EXTERNAL_VIEWER: self.external_icon = self.file_icons.get_external_file_icon() else: self.external_icon = self.file_icons.get_default_file_icon() self.font_size = app_constants.GALLERY_FONT[1] self.font_name = 0 # app_constants.GALLERY_FONT[0] if not self.font_name: self.font_name = QWidget().font().family() self.title_font = QFont() self.title_font.setBold(True) self.title_font.setFamily(self.font_name) self.artist_font = QFont() self.artist_font.setFamily(self.font_name) if self.font_size is not 0: self.title_font.setPixelSize(self.font_size) self.artist_font.setPixelSize(self.font_size) self.title_font_m = QFontMetrics(self.title_font) self.artist_font_m = QFontMetrics(self.artist_font) t_h = self.title_font_m.height() a_h = self.artist_font_m.height() self.text_label_h = a_h + t_h * 2 self.W = app_constants.THUMB_W_SIZE self.H = app_constants.THUMB_H_SIZE + app_constants.GRIDBOX_LBL_H def key(self, key): "Assigns an unique key to indexes" if key in self._painted_indexes: return self._painted_indexes[key] else: k = str(key) self._painted_indexes[key] = k return k def _increment_paint_level(self): self._paint_level += 1 self.view.update() def paint(self, painter, option, index): assert isinstance(painter, QPainter) rec = option.rect.getRect() x = rec[0] y = rec[1] w = rec[2] h = rec[3] if self._paint_level: #if app_constants.HIGH_QUALITY_THUMBS: # painter.setRenderHint(QPainter.SmoothPixmapTransform) painter.setRenderHint(QPainter.Antialiasing) gallery = index.data(Qt.UserRole + 1) star_rating = index.data(GalleryModel.RATING_ROLE) title = gallery.title artist = gallery.artist title_color = app_constants.GRID_VIEW_TITLE_COLOR artist_color = app_constants.GRID_VIEW_ARTIST_COLOR label_color = app_constants.GRID_VIEW_LABEL_COLOR # Enable this to see the defining box #painter.drawRect(option.rect) # define font size if 20 > len(title) > 15: title_size = "font-size:{}px;".format(self.font_size) elif 30 > len(title) > 20: title_size = "font-size:{}px;".format(self.font_size - 1) elif 40 > len(title) >= 30: title_size = "font-size:{}px;".format(self.font_size - 2) elif 50 > len(title) >= 40: title_size = "font-size:{}px;".format(self.font_size - 3) elif len(title) >= 50: title_size = "font-size:{}px;".format(self.font_size - 4) else: title_size = "font-size:{}px;".format(self.font_size) if 30 > len(artist) > 20: artist_size = "font-size:{}px;".format(self.font_size) elif 40 > len(artist) >= 30: artist_size = "font-size:{}px;".format(self.font_size - 1) elif len(artist) >= 40: artist_size = "font-size:{}px;".format(self.font_size - 2) else: artist_size = "font-size:{}px;".format(self.font_size) text_area = QTextDocument() text_area.setDefaultFont(option.font) text_area.setHtml("""
{2}
{3}
""".format(title_size, artist_size, title, artist, title_color, artist_color, 130 + app_constants.SIZE_FACTOR, 1 + app_constants.SIZE_FACTOR)) text_area.setTextWidth(w) #chapter_area = QTextDocument() #chapter_area.setDefaultFont(option.font) #chapter_area.setHtml(""" #{} #""".format("chapter")) #chapter_area.setTextWidth(w) def center_img(width): new_x = x if width < w: diff = w - width offset = diff // 2 new_x += offset return new_x def img_too_big(start_x): txt_layout = misc.text_layout("Thumbnail regeneration needed!", w, self.title_font, self.title_font_m) clipping = QRectF(x, y + h // 4, w, app_constants.GRIDBOX_LBL_H - 10) txt_layout.draw(painter, QPointF(x, y + h // 4), clip=clipping) loaded_image = gallery.get_profile(app_constants.ProfileType.Default) if loaded_image and self._paint_level > 0 and self.view.scroll_speed < 600: # if we can't find a cached image pix_cache = QPixmapCache.find(self.key(loaded_image.cacheKey())) if isinstance(pix_cache, QPixmap): self.image = pix_cache img_x = center_img(self.image.width()) if self.image.width() > w or self.image.height() > h: img_too_big(img_x) else: if self.image.height() < self.image.width(): #to keep aspect ratio painter.drawPixmap(QPoint(img_x,y), self.image) else: painter.drawPixmap(QPoint(img_x,y), self.image) else: self.image = QPixmap.fromImage(loaded_image) img_x = center_img(self.image.width()) QPixmapCache.insert(self.key(loaded_image.cacheKey()), self.image) if self.image.width() > w or self.image.height() > h: img_too_big(img_x) else: if self.image.height() < self.image.width(): #to keep aspect ratio painter.drawPixmap(QPoint(img_x,y), self.image) else: painter.drawPixmap(QPoint(img_x,y), self.image) else: painter.save() painter.setPen(QColor(164,164,164,200)) if gallery.profile: thumb_text = "Loading..." else: thumb_text = "Thumbnail regeneration needed!" txt_layout = misc.text_layout(thumb_text, w, self.title_font, self.title_font_m) clipping = QRectF(x, y + h // 4, w, app_constants.GRIDBOX_LBL_H - 10) txt_layout.draw(painter, QPointF(x, y + h // 4), clip=clipping) painter.restore() # draw ribbon type painter.save() painter.setPen(Qt.NoPen) if app_constants.DISPLAY_GALLERY_RIBBON: type_ribbon_w = type_ribbon_l = w * 0.11 rib_top_1 = QPointF(x + w - type_ribbon_l - type_ribbon_w, y) rib_top_2 = QPointF(x + w - type_ribbon_l, y) rib_side_1 = QPointF(x + w, y + type_ribbon_l) rib_side_2 = QPointF(x + w, y + type_ribbon_l + type_ribbon_w) ribbon_polygon = QPolygonF([rib_top_1, rib_top_2, rib_side_1, rib_side_2]) ribbon_path = QPainterPath() ribbon_path.setFillRule(Qt.WindingFill) ribbon_path.addPolygon(ribbon_polygon) ribbon_path.closeSubpath() painter.setBrush(QBrush(QColor(self._ribbon_color(gallery.type)))) painter.drawPath(ribbon_path) # draw if favourited if gallery.fav == 1: star_ribbon_w = w * 0.1 star_ribbon_l = w * 0.08 rib_top_1 = QPointF(x + star_ribbon_l, y) rib_side_1 = QPointF(x, y + star_ribbon_l) rib_top_2 = QPointF(x + star_ribbon_l + star_ribbon_w, y) rib_side_2 = QPointF(x, y + star_ribbon_l + star_ribbon_w) rib_star_mid_1 = QPointF((rib_top_1.x() + rib_side_1.x()) / 2, (rib_top_1.y() + rib_side_1.y()) / 2) rib_star_factor = star_ribbon_l / 4 rib_star_p1_1 = rib_star_mid_1 + QPointF(rib_star_factor, -rib_star_factor) rib_star_p1_2 = rib_star_p1_1 + QPointF(-rib_star_factor, -rib_star_factor) rib_star_p1_3 = rib_star_mid_1 + QPointF(-rib_star_factor, rib_star_factor) rib_star_p1_4 = rib_star_p1_3 + QPointF(-rib_star_factor, -rib_star_factor) crown_1 = QPolygonF([rib_star_p1_1, rib_star_p1_2, rib_star_mid_1, rib_star_p1_4, rib_star_p1_3]) painter.setBrush(QBrush(QColor(255, 255, 0, 200))) painter.drawPolygon(crown_1) ribbon_polygon = QPolygonF([rib_top_1, rib_side_1, rib_side_2, rib_top_2]) ribbon_path = QPainterPath() ribbon_path.setFillRule(Qt.WindingFill) ribbon_path.addPolygon(ribbon_polygon) ribbon_path.closeSubpath() painter.drawPath(ribbon_path) painter.setPen(QColor(255, 0, 0, 100)) painter.drawPolyline(rib_top_1, rib_star_p1_1, rib_star_p1_2, rib_star_mid_1, rib_star_p1_4, rib_star_p1_3, rib_side_1) painter.drawLine(rib_top_1, rib_top_2) painter.drawLine(rib_top_2, rib_side_2) painter.drawLine(rib_side_1, rib_side_2) painter.restore() if self._paint_level > 0: if app_constants._REFRESH_EXTERNAL_VIEWER: if app_constants.USE_EXTERNAL_VIEWER: self.external_icon = self.file_icons.get_external_file_icon() else: self.external_icon = self.file_icons.get_default_file_icon() type_w = painter.fontMetrics().width(gallery.file_type) type_h = painter.fontMetrics().height() type_p = QPoint(x + 4, y + app_constants.THUMB_H_SIZE - type_h - 5) type_rect = QRect(type_p.x() - 2, type_p.y() - 1, type_w + 4, type_h + 1) if app_constants.DISPLAY_GALLERY_TYPE: type_color = QColor(239, 0, 0, 200) if gallery.file_type == "zip": type_color = QColor(241, 0, 83, 200) elif gallery.file_type == "cbz": type_color = QColor(0, 139, 0, 200) elif gallery.file_type == "rar": type_color = QColor(30, 127, 150, 200) elif gallery.file_type == "cbr": type_color = QColor(210, 0, 13, 200) painter.save() painter.setPen(QPen(Qt.white)) painter.fillRect(type_rect, type_color) painter.drawText(type_p.x(), type_p.y() + painter.fontMetrics().height() - 4, gallery.file_type) painter.restore() if app_constants.DISPLAY_RATING and gallery.rating: star_start_x = type_rect.x()+type_rect.width() if app_constants.DISPLAY_GALLERY_TYPE else x star_width = star_rating.sizeHint().width() star_start_x += ((x+w-star_start_x)-(star_width))/2 star_rating.paint(painter, QRect(star_start_x, type_rect.y(), star_width, type_rect.height())) if gallery.state == app_constants.GalleryState.New: painter.save() painter.setPen(Qt.NoPen) gradient = QLinearGradient() gradient.setStart(x, y + app_constants.THUMB_H_SIZE / 2) gradient.setFinalStop(x, y + app_constants.THUMB_H_SIZE) gradient.setColorAt(0, QColor(255, 255, 255, 0)) gradient.setColorAt(1, QColor(0, 255, 0, 150)) painter.setBrush(QBrush(gradient)) painter.drawRoundedRect(QRectF(x, y + app_constants.THUMB_H_SIZE / 2, w, app_constants.THUMB_H_SIZE / 2), 2, 2) painter.restore() def draw_text_label(lbl_h): #draw the label for text painter.save() painter.translate(x, y + app_constants.THUMB_H_SIZE) box_color = QBrush(QColor(label_color))#QColor(0,0,0,123)) painter.setBrush(box_color) rect = QRect(0, 0, w, lbl_h) #x, y, width, height painter.fillRect(rect, box_color) painter.restore() return rect if option.state & QStyle.State_MouseOver or \ option.state & QStyle.State_Selected: title_layout = misc.text_layout(title, w, self.title_font, self.title_font_m) artist_layout = misc.text_layout(artist, w, self.artist_font, self.artist_font_m) t_h = title_layout.boundingRect().height() a_h = artist_layout.boundingRect().height() if app_constants.GALLERY_FONT_ELIDE: lbl_rect = draw_text_label(min(t_h + a_h + 3, app_constants.GRIDBOX_LBL_H)) else: lbl_rect = draw_text_label(app_constants.GRIDBOX_LBL_H) clipping = QRectF(x, y + app_constants.THUMB_H_SIZE, w, app_constants.GRIDBOX_LBL_H - 10) painter.setPen(QColor(title_color)) title_layout.draw(painter, QPointF(x, y + app_constants.THUMB_H_SIZE), clip=clipping) painter.setPen(QColor(artist_color)) artist_layout.draw(painter, QPointF(x, y + app_constants.THUMB_H_SIZE + t_h), clip=clipping) #painter.fillRect(option.rect, QColor) else: if app_constants.GALLERY_FONT_ELIDE: lbl_rect = draw_text_label(self.text_label_h) else: lbl_rect = draw_text_label(app_constants.GRIDBOX_LBL_H) # draw text painter.save() alignment = QTextOption(Qt.AlignCenter) alignment.setUseDesignMetrics(True) title_rect = QRectF(0,0,w, self.title_font_m.height()) artist_rect = QRectF(0,self.artist_font_m.height(),w, self.artist_font_m.height()) painter.translate(x, y + app_constants.THUMB_H_SIZE) if app_constants.GALLERY_FONT_ELIDE: painter.setFont(self.title_font) painter.setPen(QColor(title_color)) painter.drawText(title_rect, self.title_font_m.elidedText(title, Qt.ElideRight, w - 10), alignment) painter.setPen(QColor(artist_color)) painter.setFont(self.artist_font) alignment.setWrapMode(QTextOption.NoWrap) painter.drawText(artist_rect, self.title_font_m.elidedText(artist, Qt.ElideRight, w - 10), alignment) else: text_area.setDefaultFont(QFont(self.font_name)) text_area.drawContents(painter) ##painter.resetTransform() painter.restore() if option.state & QStyle.State_Selected: painter.save() selected_rect = QRectF(x, y, w, lbl_rect.height() + app_constants.THUMB_H_SIZE) painter.setPen(Qt.NoPen) painter.setBrush(QBrush(QColor(164,164,164,120))) painter.drawRoundedRect(selected_rect, 5, 5) #painter.fillRect(selected_rect, QColor(164,164,164,120)) painter.restore() def warning(txt): painter.save() selected_rect = QRectF(x, y, w, lbl_rect.height() + app_constants.THUMB_H_SIZE) painter.setPen(Qt.NoPen) painter.setBrush(QBrush(QColor(255,0,0,120))) p_path = QPainterPath() p_path.setFillRule(Qt.WindingFill) p_path.addRoundedRect(selected_rect, 5,5) p_path.addRect(x,y, 20, 20) p_path.addRect(x + w - 20,y, 20, 20) painter.drawPath(p_path.simplified()) painter.setPen(QColor("white")) txt_layout = misc.text_layout(txt, w, self.title_font, self.title_font_m) txt_layout.draw(painter, QPointF(x, y + h * 0.3)) painter.restore() if not gallery.id and self.view.view_type != app_constants.ViewType.Addition: warning("This gallery does not exist anymore!") elif gallery.dead_link: warning("Cannot find gallery source!") if app_constants.DEBUG or self.view.view_type == app_constants.ViewType.Duplicate: painter.save() painter.setPen(QPen(Qt.white)) id_txt = "ID: {}".format(gallery.id) type_w = painter.fontMetrics().width(id_txt) type_h = painter.fontMetrics().height() type_p = QPoint(x + 4, y + 50 - type_h - 5) type_rect = QRect(type_p.x() - 2, type_p.y() - 1, type_w + 4, type_h + 1) painter.fillRect(type_rect, QColor(239, 0, 0, 200)) painter.drawText(type_p.x(), type_p.y() + painter.fontMetrics().height() - 4, id_txt) painter.restore() if option.state & QStyle.State_Selected: painter.setPen(QPen(option.palette.highlightedText().color())) else: painter.fillRect(option.rect, QColor(164,164,164,100)) painter.setPen(QColor(164,164,164,200)) txt_layout = misc.text_layout("Fetching...", w, self.title_font, self.title_font_m) clipping = QRectF(x, y + h // 4, w, app_constants.GRIDBOX_LBL_H - 10) txt_layout.draw(painter, QPointF(x, y + h // 4), clip=clipping) def _ribbon_color(self, gallery_type): if gallery_type: gallery_type = gallery_type.lower() if gallery_type == "manga": return app_constants.GRID_VIEW_T_MANGA_COLOR elif gallery_type == "doujinshi": return app_constants.GRID_VIEW_T_DOUJIN_COLOR elif "artist cg" in gallery_type: return app_constants.GRID_VIEW_T_ARTIST_CG_COLOR elif "game cg" in gallery_type: return app_constants.GRID_VIEW_T_GAME_CG_COLOR elif gallery_type == "western": return app_constants.GRID_VIEW_T_WESTERN_COLOR elif "image" in gallery_type: return app_constants.GRID_VIEW_T_IMAGE_COLOR elif gallery_type == "non-h": return app_constants.GRID_VIEW_T_NON_H_COLOR elif gallery_type == "cosplay": return app_constants.GRID_VIEW_T_COSPLAY_COLOR else: return app_constants.GRID_VIEW_T_OTHER_COLOR def sizeHint(self, option, index): return QSize(self.W, self.H) class MangaView(QListView): """ Grid View """ STATUS_BAR_MSG = pyqtSignal(str) def __init__(self, model, v_type, filter_model=None, parent=None): super().__init__(parent) self.parent_widget = parent self.view_type = v_type self.setViewMode(self.IconMode) self.setResizeMode(self.Adjust) self.setWrapping(True) # all items have the same size (perfomance) self.setUniformItemSizes(True) # improve scrolling self.setAutoScroll(True) self.setVerticalScrollMode(self.ScrollPerPixel) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setLayoutMode(self.Batched) self.setMouseTracking(True) self.setAcceptDrops(True) self.setDragEnabled(True) self.viewport().setAcceptDrops(True) self.setDropIndicatorShown(True) self.setDragDropMode(self.DragDrop) self.sort_model = filter_model if filter_model else SortFilterModel(self) self.manga_delegate = GridDelegate(parent, self) self.setItemDelegate(self.manga_delegate) self.setSpacing(app_constants.GRID_SPACING) self.setFlow(QListView.LeftToRight) self.setIconSize(QSize(self.manga_delegate.W, self.manga_delegate.H)) self.setSelectionBehavior(self.SelectItems) self.setSelectionMode(self.ExtendedSelection) self.gallery_model = model self.sort_model.change_model(self.gallery_model) self.sort_model.sort(0) self.setModel(self.sort_model) self.doubleClicked.connect(lambda idx: idx.data(Qt.UserRole + 1).chapters[0].open()) self.setViewportMargins(0,0,0,0) self.gallery_window = misc.GalleryMetaWindow(parent if parent else self) self.gallery_window.arrow_size = (10,10,) self.clicked.connect(lambda idx: self.gallery_window.show_gallery(idx, self)) self.current_sort = app_constants.CURRENT_SORT if self.view_type == app_constants.ViewType.Duplicate: self.sort_model.setSortRole(GalleryModel.TIME_ROLE) else: self.sort(self.current_sort) if app_constants.DEBUG: def debug_print(a): g = a.data(Qt.UserRole + 1) try: print(g) except: print("{}".format(g).encode(errors='ignore')) #log_d(gallerydb.HashDB.gen_gallery_hash(g, 0, 'mid')['mid']) self.clicked.connect(debug_print) self.k_scroller = QScroller.scroller(self) self._scroll_speed_timer = QTimer(self) self._scroll_speed_timer.timeout.connect(self._calculate_scroll_speed) self._scroll_speed_timer.setInterval(500) # ms self._old_scroll_value = 0 self._scroll_zero_once = True self._scroll_speed = 0 self._scroll_speed_timer.start() @property def scroll_speed(self): return self._scroll_speed def _calculate_scroll_speed(self): new_value = self.verticalScrollBar().value() self._scroll_speed = abs(self._old_scroll_value - new_value) self._old_scroll_value = new_value if self.verticalScrollBar().value() in (0, self.verticalScrollBar().maximum()): self._scroll_zero_once = True if self._scroll_zero_once: self.update() self._scroll_zero_once = False # update view if not scrolling if new_value < 400 and self._old_scroll_value > 400: self.update() def get_visible_indexes(self, column=0): "find all galleries in viewport" gridW = self.manga_delegate.W + app_constants.GRID_SPACING * 2 gridH = self.manga_delegate.H + app_constants.GRID_SPACING * 2 region = self.viewport().visibleRegion() idx_found = [] def idx_is_visible(idx): idx_rect = self.visualRect(idx) return region.contains(idx_rect) or region.intersects(idx_rect) #get first index first_idx = self.indexAt(QPoint(gridW // 2, 0)) # to get indexes on the way out of view if not first_idx.isValid(): first_idx = self.indexAt(QPoint(gridW // 2, gridH // 2)) if first_idx.isValid(): nxt_idx = first_idx # now traverse items until index isn't visible while(idx_is_visible(nxt_idx)): idx_found.append(nxt_idx) nxt_idx = nxt_idx.sibling(nxt_idx.row() + 1, column) return idx_found def wheelEvent(self, event): if self.gallery_window.isVisible(): self.gallery_window.hide_animation.start() return super().wheelEvent(event) def mouseMoveEvent(self, event): self.gallery_window.mouseMoveEvent(event) return super().mouseMoveEvent(event) def keyPressEvent(self, event): if event.key() == Qt.Key_Return: s_idx = self.selectedIndexes() if s_idx: for idx in s_idx: self.doubleClicked.emit(idx) elif event.modifiers() == Qt.ShiftModifier and event.key() == Qt.Key_Delete: CommonView.remove_selected(self, True) elif event.key() == Qt.Key_Delete: CommonView.remove_selected(self) return super().keyPressEvent(event) def favorite(self, index): assert isinstance(index, QModelIndex) gallery = index.data(Qt.UserRole + 1) if gallery.fav == 1: gallery.fav = 0 #self.model().replaceRows([gallery], index.row(), 1, index) gallerydb.execute(gallerydb.GalleryDB.modify_gallery, True, gallery.id, {'fav':0}) self.gallery_model.CUSTOM_STATUS_MSG.emit("Unfavorited") else: gallery.fav = 1 gallery.rating = 5 #self.model().replaceRows([gallery], index.row(), 1, index) gallerydb.execute(gallerydb.GalleryDB.modify_gallery, True, gallery.id, {'fav':1, 'rating':5}) self.gallery_model.CUSTOM_STATUS_MSG.emit("Favorited") def del_chapter(self, index, chap_numb): gallery = index.data(Qt.UserRole + 1) if len(gallery.chapters) < 2: CommonView.remove_gallery(self, [index]) else: msgbox = QMessageBox(self) msgbox.setText('Are you sure you want to delete:') msgbox.setIcon(msgbox.Question) msgbox.setInformativeText('Chapter {} of {}'.format(chap_numb + 1, gallery.title)) msgbox.setStandardButtons(msgbox.Yes | msgbox.No) if msgbox.exec() == msgbox.Yes: gallery.chapters.pop(chap_numb, None) self.gallery_model.replaceRows([gallery], index.row()) gallerydb.execute(gallerydb.ChapterDB.del_chapter, True, gallery.id, chap_numb) def sort(self, name): if not self.view_type == app_constants.ViewType.Duplicate: if name == 'title': self.sort_model.setSortRole(Qt.DisplayRole) self.sort_model.sort(0, Qt.AscendingOrder) self.current_sort = 'title' elif name == 'artist': self.sort_model.setSortRole(GalleryModel.ARTIST_ROLE) self.sort_model.sort(0, Qt.AscendingOrder) self.current_sort = 'artist' elif name == 'date_added': self.sort_model.setSortRole(GalleryModel.DATE_ADDED_ROLE) self.sort_model.sort(0, Qt.DescendingOrder) self.current_sort = 'date_added' elif name == 'pub_date': self.sort_model.setSortRole(GalleryModel.PUB_DATE_ROLE) self.sort_model.sort(0, Qt.DescendingOrder) self.current_sort = 'pub_date' elif name == 'times_read': self.sort_model.setSortRole(GalleryModel.TIMES_READ_ROLE) self.sort_model.sort(0, Qt.DescendingOrder) self.current_sort = 'times_read' elif name == 'last_read': self.sort_model.setSortRole(GalleryModel.LAST_READ_ROLE) self.sort_model.sort(0, Qt.DescendingOrder) self.current_sort = 'last_read' elif name == 'rating': self.sort_model.setSortRole(GalleryModel.RATING_COUNT) self.sort_model.sort(0, Qt.DescendingOrder) self.current_sort = 'rating' def contextMenuEvent(self, event): CommonView.contextMenuEvent(self, event) def updateGeometries(self): super().updateGeometries() self.verticalScrollBar().setSingleStep(app_constants.SCROLL_SPEED) class MangaTableView(QTableView): STATUS_BAR_MSG = pyqtSignal(str) def __init__(self, v_type, parent=None): super().__init__(parent) self.view_type = v_type # options self.parent_widget = parent self.setAcceptDrops(True) self.setDragEnabled(True) self.viewport().setAcceptDrops(True) self.setDropIndicatorShown(True) self.setDragDropMode(self.DragDrop) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.setSelectionBehavior(self.SelectRows) self.setSelectionMode(self.ExtendedSelection) self.setShowGrid(True) self.setSortingEnabled(True) h_header = self.horizontalHeader() h_header.setSortIndicatorShown(True) v_header = self.verticalHeader() v_header.sectionResizeMode(QHeaderView.Fixed) v_header.setDefaultSectionSize(24) v_header.hide() palette = self.palette() palette.setColor(palette.Highlight, QColor(88, 88, 88, 70)) palette.setColor(palette.HighlightedText, QColor('black')) self.setPalette(palette) self.setIconSize(QSize(0,0)) self.doubleClicked.connect(lambda idx: idx.data(Qt.UserRole + 1).chapters[0].open()) self.grabGesture(Qt.SwipeGesture) self.k_scroller = QScroller.scroller(self) # display tooltip only for elided text #def viewportEvent(self, event): # if event.type() == QEvent.ToolTip: # h_event = QHelpEvent(event) # index = self.indexAt(h_event.pos()) # if index.isValid(): # size_hint = self.itemDelegate(index).sizeHint(self.viewOptions(), # index) # rect = QRect(0, 0, size_hint.width(), size_hint.height()) # rect_visual = self.visualRect(index) # if rect.width() <= rect_visual.width(): # QToolTip.hideText() # return True # return super().viewportEvent(event) def keyPressEvent(self, event): if event.key() == Qt.Key_Return: s_idx = self.selectionModel().selectedRows() if s_idx: for idx in s_idx: self.doubleClicked.emit(idx) elif event.modifiers() == Qt.ShiftModifier and event.key() == Qt.Key_Delete: CommonView.remove_selected(self, True) elif event.key() == Qt.Key_Delete: CommonView.remove_selected(self) return super().keyPressEvent(event) def contextMenuEvent(self, event): CommonView.contextMenuEvent(self, event) class CommonView: """ Contains identical view implentations """ @staticmethod def remove_selected(view_cls, source=False): s_indexes = [] if isinstance(view_cls, QListView): s_indexes = view_cls.selectedIndexes() elif isinstance(view_cls, QTableView): s_indexes = view_cls.selectionModel().selectedRows() CommonView.remove_gallery(view_cls, s_indexes, source) @staticmethod def remove_gallery(view_cls, index_list, local=False): #view_cls.sort_model.setDynamicSortFilter(False) msgbox = QMessageBox(view_cls) msgbox.setIcon(msgbox.Question) msgbox.setStandardButtons(msgbox.Yes | msgbox.No) if len(index_list) > 1: if not local: msg = 'Are you sure you want to remove {} selected galleries?'.format(len(index_list)) else: msg = 'Are you sure you want to remove {} selected galleries and their files/directories?'.format(len(index_list)) msgbox.setText(msg) else: if not local: msg = 'Are you sure you want to remove this gallery?' else: msg = 'Are you sure you want to remove this gallery and its file/directory?' msgbox.setText(msg) if msgbox.exec() == msgbox.Yes: #view_cls.setUpdatesEnabled(False) gallery_list = [] gallery_db_list = [] log_i('Removing {} galleries'.format(len(index_list))) for index in index_list: gallery = index.data(Qt.UserRole + 1) gallery_list.append(gallery) log_i('Attempt to remove: {} by {}'.format(gallery.title.encode(errors="ignore"), gallery.artist.encode(errors="ignore"))) if gallery.id: gallery_db_list.append(gallery) gallerydb.execute(gallerydb.GalleryDB.del_gallery, True, gallery_db_list, local=local, priority=0) rows = len(gallery_list) view_cls.gallery_model._gallery_to_remove.extend(gallery_list) view_cls.gallery_model.removeRows(view_cls.gallery_model.rowCount() - rows, rows) view_cls.sort_model.refresh() #view_cls.STATUS_BAR_MSG.emit('Gallery removed!') #view_cls.setUpdatesEnabled(True) #view_cls.sort_model.setDynamicSortFilter(True) @staticmethod def find_index(view_cls, gallery_id, sort_model=False): "Finds and returns the index associated with the gallery id" index = None model = view_cls.sort_model if sort_model else view_cls.gallery_model rows = model.rowCount() for r in range(rows): indx = model.index(r, 0) m_gallery = indx.data(Qt.UserRole + 1) if m_gallery.id == gallery_id: index = indx break return index @staticmethod def open_random_gallery(view_cls): try: g = random.randint(0, view_cls.sort_model.rowCount() - 1) except ValueError: return indx = view_cls.sort_model.index(g, 1) chap_numb = 0 if app_constants.OPEN_RANDOM_GALLERY_CHAPTERS: gallery = indx.data(Qt.UserRole + 1) b = len(gallery.chapters) if b > 1: chap_numb = random.randint(0, b - 1) CommonView.scroll_to_index(view_cls, view_cls.sort_model.index(indx.row(), 0)) try: indx.data(Qt.UserRole + 1).chapters[chap_numb].open() except KeyError: log.exception("Failed to open chapter") return; @staticmethod def scroll_to_index(view_cls, idx, select=True): old_value = view_cls.verticalScrollBar().value() view_cls.setAutoScroll(False) view_cls.setUpdatesEnabled(False) view_cls.verticalScrollBar().setValue(0) idx_rect = view_cls.visualRect(idx) view_cls.verticalScrollBar().setValue(old_value) view_cls.setUpdatesEnabled(True) rect = QRectF(idx_rect) if app_constants.DEBUG: print("Scrolling to index:", rect.getRect()) view_cls.k_scroller.ensureVisible(rect, 0, 0) if select: view_cls.setCurrentIndex(idx) view_cls.setAutoScroll(True) view_cls.update() @staticmethod def contextMenuEvent(view_cls, event): grid_view = False table_view = False if isinstance(view_cls, QListView): grid_view = True elif isinstance(view_cls, QTableView): table_view = True handled = False index = view_cls.indexAt(event.pos()) index = view_cls.sort_model.mapToSource(index) selected = False if table_view: s_indexes = view_cls.selectionModel().selectedRows() else: s_indexes = view_cls.selectedIndexes() select_indexes = [] for idx in s_indexes: if idx.isValid() and idx.column() == 0: select_indexes.append(view_cls.sort_model.mapToSource(idx)) if len(select_indexes) > 1: selected = True if index.isValid(): if grid_view: if view_cls.gallery_window.isVisible(): view_cls.gallery_window.hide_animation.start() view_cls.manga_delegate.CONTEXT_ON = True if selected: menu = misc.GalleryMenu(view_cls, index, view_cls.sort_model, view_cls.parent_widget, select_indexes) else: menu = misc.GalleryMenu(view_cls, index, view_cls.sort_model, view_cls.parent_widget) menu.delete_galleries.connect(lambda s: CommonView.remove_gallery(view_cls, select_indexes, s)) menu.edit_gallery.connect(CommonView.spawn_dialog) handled = True if handled: menu.exec_(event.globalPos()) if grid_view: view_cls.manga_delegate.CONTEXT_ON = False event.accept() del menu else: event.ignore() @staticmethod def spawn_dialog(app_inst, gallery=None): dialog = gallerydialog.GalleryDialog(app_inst, gallery) dialog.show() class MangaViews: manga_views = [] @enum.unique class View(enum.Enum): List = 1 Table = 2 def __init__(self, v_type, parent, allow_sidebarwidget=False): self.allow_sidebarwidget = allow_sidebarwidget self._delete_proxy_model = None self.view_type = v_type if v_type == app_constants.ViewType.Default: model = GalleryModel(app_constants.GALLERY_DATA, parent) elif v_type == app_constants.ViewType.Addition: model = GalleryModel(app_constants.GALLERY_ADDITION_DATA, parent) elif v_type == app_constants.ViewType.Duplicate: model = GalleryModel([], parent) #list view self.list_view = MangaView(model, v_type, parent=parent) self.list_view.sort_model.setup_search() self.sort_model = self.list_view.sort_model self.gallery_model = self.list_view.gallery_model #table view self.table_view = MangaTableView(v_type, parent) self.table_view.gallery_model = self.gallery_model self.table_view.sort_model = self.sort_model self.table_view.setModel(self.sort_model) self.table_view.setColumnWidth(app_constants.FAV, 20) self.table_view.setColumnWidth(app_constants.ARTIST, 200) self.table_view.setColumnWidth(app_constants.TITLE, 400) self.table_view.setColumnWidth(app_constants.TAGS, 300) self.table_view.setColumnWidth(app_constants.TYPE, 60) self.table_view.setColumnWidth(app_constants.CHAPTERS, 60) self.table_view.setColumnWidth(app_constants.LANGUAGE, 100) self.table_view.setColumnWidth(app_constants.LINK, 400) self.view_layout = QStackedLayout() # init the chapter view variables self.m_l_view_index = self.view_layout.addWidget(self.list_view) self.m_t_view_index = self.view_layout.addWidget(self.table_view) self.current_view = self.View.List self.manga_views.append(self) if v_type in (app_constants.ViewType.Default, app_constants.ViewType.Addition): self.sort_model.enable_drag = True def _delegate_delete(self): if self._delete_proxy_model: gs = [g for g in self.gallery_model._gallery_to_remove] self._delete_proxy_model._gallery_to_remove = gs self._delete_proxy_model.removeRows(self._delete_proxy_model.rowCount() - len(gs), len(gs)) def set_delete_proxy(self, other_model): self._delete_proxy_model = other_model self.gallery_model.rowsAboutToBeRemoved.connect(self._delegate_delete, Qt.DirectConnection) def add_gallery(self, gallery, db=False, record_time=False): if isinstance(gallery, (list, tuple)): for g in gallery: g.view = self.view_type if self.view_type != app_constants.ViewType.Duplicate: g.state = app_constants.GalleryState.New if db: gallerydb.execute(gallerydb.GalleryDB.add_gallery, True, g) else: if not g.profile: Executors.generate_thumbnail(g, on_method=g.set_profile) rows = len(gallery) self.list_view.gallery_model._gallery_to_add.extend(gallery) if record_time: g.qtime = QTime.currentTime() else: gallery.view = self.view_type if self.view_type != app_constants.ViewType.Duplicate: gallery.state = app_constants.GalleryState.New rows = 1 self.list_view.gallery_model._gallery_to_add.append(gallery) if record_time: g.qtime = QTime.currentTime() if db: gallerydb.execute(gallerydb.GalleryDB.add_gallery, True, gallery) else: if not gallery.profile: Executors.generate_thumbnail(gallery, on_method=gallery.set_profile) self.list_view.gallery_model.insertRows(self.list_view.gallery_model.rowCount(), rows) self.list_view.sort_model.refresh() def replace_gallery(self, list_of_gallery, db_optimize=True): "Replaces the view and DB with given list of gallery, at given position" assert isinstance(list_of_gallery, (list, gallerydb.Gallery)), "Please pass a gallery to replace with" if isinstance(list_of_gallery, gallerydb.Gallery): list_of_gallery = [list_of_gallery] log_d('Replacing {} galleries'.format(len(list_of_gallery))) if db_optimize: gallerydb.execute(gallerydb.GalleryDB.begin, True) for gallery in list_of_gallery: kwdict = {'title':gallery.title, 'profile':gallery.profile, 'artist':gallery.artist, 'info':gallery.info, 'type':gallery.type, 'language':gallery.language, 'rating':gallery.rating, 'status':gallery.status, 'pub_date':gallery.pub_date, 'tags':gallery.tags, 'link':gallery.link, 'series_path':gallery.path, 'chapters':gallery.chapters, 'exed':gallery.exed} gallerydb.execute(gallerydb.GalleryDB.modify_gallery, True, gallery.id, **kwdict) if db_optimize: gallerydb.execute(gallerydb.GalleryDB.end, True) def changeTo(self, idx): "change view" self.view_layout.setCurrentIndex(idx) if idx == self.m_l_view_index: self.current_view = self.View.List elif idx == self.m_t_view_index: self.current_view = self.View.Table def get_current_view(self): if self.current_view == self.View.List: return self.list_view else: return self.table_view def fav_is_current(self): if self.table_view.sort_model.current_view == \ self.table_view.sort_model.CAT_VIEW: return False return True def hide(self): self.view_layout.currentWidget().hide() def show(self): self.view_layout.currentWidget().show() if __name__ == '__main__': raise NotImplementedError("Unit testing not yet implemented") ================================================ FILE: version/gallerydb.py ================================================ #""" #This file is part of Happypanda. #Happypanda is free software: you can redistribute it and/or modify #it under the terms of the GNU General Public License as published by #the Free Software Foundation, either version 2 of the License, or #any later version. #Happypanda is distributed in the hope that it will be useful, #but WITHOUT ANY WARRANTY; without even the implied warranty of #MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #GNU General Public License for more details. #You should have received a copy of the GNU General Public License #along with Happypanda. If not, see . #""" import datetime import os import enum import scandir import threading import logging import queue import io import uuid import functools import re as regex from dateutil import parser as dateparser from PyQt5.QtCore import QObject, pyqtSignal, QTime from utils import (today, ArchiveFile, generate_img_hash, delete_path, ARCHIVE_FILES, get_gallery_img, IMG_FILES) from database import db_constants from database import db from database.db import DBBase from executors import Executors import app_constants import utils log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical method_queue = queue.PriorityQueue() method_return = queue.Queue() db_constants.METHOD_QUEUE = method_queue db_constants.METHOD_RETURN = method_return class PriorityObject: def __init__(self, priority, data): self.p = priority self.data = data def __lt__(self, other): return self.p < other.p def process_methods(): """ Methods are objects. Put a list in the method queue where first index is the method. Named arguments are put in a dict. """ while True: l = method_queue.get().data log_d('Processing a method from queue...') method = l.pop(0) log_d(method) args = [] kwargs = {} get_args = 1 no_return = False while get_args: try: a = l.pop(0) if a == 'no return': no_return = True continue if isinstance(a, dict): kwargs = a else: args.append(a) except IndexError: get_args = 0 args = tuple(args) if args and kwargs: r = method(*args, **kwargs) elif args: r = method(*args) elif kwargs: r = method(**kwargs) else: r = method() if not no_return: method_return.put(r) method_queue.task_done() method_queue_thread = threading.Thread(name='Method Queue Thread', target=process_methods, daemon=True) method_queue_thread.start() def execute(method, no_return, *args, **kwargs): log_d('Added method to queue') log_d('Method name: {}'.format(method.__name__)) arg_list = [method] priority = kwargs.pop("priority", 999) if no_return: arg_list.append('no return') if args: for x in args: arg_list.append(x) if kwargs: arg_list.append(kwargs) method_queue.put(PriorityObject(priority, arg_list)) if not no_return: return method_return.get() def chapter_map(row, chapter): assert isinstance(chapter, Chapter) chapter.title = row['chapter_title'] chapter.path = bytes.decode(row['chapter_path']) chapter.in_archive = row['in_archive'] chapter.pages = row['pages'] return chapter def gallery_map(row, gallery, chapters=True, tags=True, hashes=True): gallery.title = row['title'] gallery.artist = row['artist'] gallery.profile = bytes.decode(row['profile']) gallery.path = bytes.decode(row['series_path']) gallery.is_archive = row['is_archive'] try: gallery.path_in_archive = bytes.decode(row['path_in_archive']) except TypeError: pass gallery.info = row['info'] gallery.language = row['language'] gallery.rating = row['rating'] gallery.status = row['status'] gallery.type = row['type'] gallery.fav = row['fav'] def convert_date(date_str): #2015-10-25 21:44:38 if date_str and date_str != 'None': return datetime.datetime.strptime(date_str, "%Y-%m-%d %H:%M:%S") gallery.pub_date = convert_date(row['pub_date']) gallery.last_read = convert_date(row['last_read']) gallery.date_added = convert_date(row['date_added']) gallery.times_read = row['times_read'] gallery._db_v = row['db_v'] gallery.exed = row['exed'] gallery.view = row['view'] try: gallery.link = bytes.decode(row['link']) except TypeError: gallery.link = row['link'] if chapters: gallery.chapters = ChapterDB.get_chapters_for_gallery(gallery.id) if tags: gallery.tags = TagDB.get_gallery_tags(gallery.id) if hashes: gallery.hashes = HashDB.get_gallery_hashes(gallery.id) gallery.set_defaults() return gallery def default_chap_exec(gallery_or_id, chap, only_values=False): "Pass a Gallery object or gallery id and a Chapter object" if isinstance(gallery_or_id, Gallery): gid = gallery_or_id.id in_archive = gallery_or_id.is_archive else: gid = gallery_or_id in_archive = chap.in_archive if only_values: execute = (gid, chap.title, chap.number, str.encode(chap.path), chap.pages, in_archive) else: execute = (""" INSERT INTO chapters(series_id, chapter_title, chapter_number, chapter_path, pages, in_archive) VALUES(:series_id, :chapter_title, :chapter_number, :chapter_path, :pages, :in_archive)""", {'series_id':gid, 'chapter_title':chap.title, 'chapter_number':chap.number, 'chapter_path':str.encode(chap.path), 'pages':chap.pages, 'in_archive':in_archive}) return execute def default_exec(object): object.set_defaults() def check(obj): if obj == "None": return None else: return obj executing = ["""INSERT INTO series(title, artist, profile, series_path, is_archive, path_in_archive, info, type, fav, language, rating, status, pub_date, date_added, last_read, link, times_read, db_v, exed, view) VALUES(:title, :artist, :profile, :series_path, :is_archive, :path_in_archive, :info, :type, :fav, :language, :rating, :status, :pub_date, :date_added, :last_read, :link, :times_read, :db_v, :exed, :view)""", { 'title':check(object.title), 'artist':check(object.artist), 'profile':str.encode(object.profile), 'series_path':str.encode(object.path), 'is_archive':check(object.is_archive), 'path_in_archive':str.encode(object.path_in_archive), 'info':check(object.info), 'fav':check(object.fav), 'type':check(object.type), 'language':check(object.language), 'rating':check(object.rating), 'status':check(object.status), 'pub_date':check(object.pub_date), 'date_added':check(object.date_added), 'last_read':check(object.last_read), 'link':str.encode(object.link), 'times_read':check(object.times_read), 'db_v':check(db_constants.REAL_DB_VERSION), 'exed':check(object.exed), 'view':check(object.view) }] return executing class GalleryDB(DBBase): """ Provides the following s methods: rebuild_thumb -> Rebuilds gallery thumbnail rebuild_galleries -> Rebuilds the galleries in DB modify_gallery -> Modifies gallery with given gallery id get_all_gallery -> returns a list of all gallery ( class) currently in DB get_gallery_by_path -> Returns gallery with given path get_gallery_by_id -> Returns gallery with given id add_gallery -> adds gallery into db set_gallery_title -> changes gallery title gallery_count -> returns amount of gallery (can be used for indexing) del_gallery -> deletes the gallery with the given id recursively check_exists -> Checks if provided string exists clear_thumb -> Deletes a thumbnail clear_thumb_dir -> Dletes everything in the thumbnail directory """ def __init__(self): raise Exception("GalleryDB should not be instantiated") @staticmethod def rebuild_thumb(gallery): "Rebuilds gallery thumbnail" try: log_i('Recreating thumb {}'.format(gallery.title.encode(errors='ignore'))) if gallery.profile: GalleryDB.clear_thumb(gallery.profile) gallery.profile = Executors.generate_thumbnail(gallery, blocking=True) GalleryDB.modify_gallery(gallery.id, profile=gallery.profile) except: log.exception("Failed rebuilding thumbnail") return False return True @staticmethod def clear_thumb(path): "Deletes a thumbnail" try: if os.path.samefile(path, app_constants.NO_IMAGE_PATH): return except FileNotFoundError: pass try: os.unlink(path) except FileNotFoundError: pass except: log.exception('Failed to delete thumb {}'.format(os.path.split(path)[1].encode(errors='ignore'))) @staticmethod def clear_thumb_dir(): "Deletes everything in the thumbnail directory" if os.path.exists(db_constants.THUMBNAIL_PATH): for thumbfile in scandir.scandir(db_constants.THUMBNAIL_PATH): GalleryDB.clear_thumb(thumbfile.path) @staticmethod def rebuild_gallery(gallery, thumb=False): "Rebuilds the galleries in DB" try: log_i('Rebuilding {}'.format(gallery.title.encode(errors='ignore'))) log_i("Rebuilding gallery {}".format(gallery.id)) HashDB.del_gallery_hashes(gallery.id) GalleryDB.modify_gallery(gallery.id, title=gallery.title, artist=gallery.artist, info=gallery.info, type=gallery.type, fav=gallery.fav, tags=gallery.tags, language=gallery.language, rating=gallery.rating, status=gallery.status, pub_date=gallery.pub_date, link=gallery.link, times_read=gallery.times_read, last_read=gallery.last_read, _db_v=db_constants.CURRENT_DB_VERSION, exed=gallery.exed, is_archive=gallery.is_archive, path_in_archive=gallery.path_in_archive, view=gallery.view) if thumb: GalleryDB.rebuild_thumb(gallery) except: log.exception('Failed rebuilding') return False return True @classmethod def modify_gallery(cls, series_id, title=None, profile=None, artist=None, info=None, type=None, fav=None, tags=None, language=None, rating=None, status=None, pub_date=None, link=None, times_read=None, last_read=None, series_path=None, chapters=None, _db_v=None, hashes=None, exed=None, is_archive=None, path_in_archive=None, view=None, date_added=None): "Modifies gallery with given gallery id" assert isinstance(series_id, int) assert not isinstance(series_id, bool) executing = [] if title != None: assert isinstance(title, str) executing.append(["UPDATE series SET title=? WHERE series_id=?", (title, series_id)]) if profile != None: assert isinstance(profile, str) executing.append(["UPDATE series SET profile=? WHERE series_id=?", (str.encode(profile), series_id)]) if artist != None: assert isinstance(artist, str) executing.append(["UPDATE series SET artist=? WHERE series_id=?", (artist, series_id)]) if info != None: assert isinstance(info, str) executing.append(["UPDATE series SET info=? WHERE series_id=?", (info, series_id)]) if type != None: assert isinstance(type, str) executing.append(["UPDATE series SET type=? WHERE series_id=?", (type, series_id)]) if fav != None: assert isinstance(fav, int) executing.append(["UPDATE series SET fav=? WHERE series_id=?", (fav, series_id)]) if language != None: assert isinstance(language, str) executing.append(["UPDATE series SET language=? WHERE series_id=?", (language, series_id)]) if rating != None: assert isinstance(rating, int) executing.append(["UPDATE series SET rating=? WHERE series_id=?", (rating, series_id)]) if status != None: assert isinstance(status, str) executing.append(["UPDATE series SET status=? WHERE series_id=?", (status, series_id)]) if pub_date != None: executing.append(["UPDATE series SET pub_date=? WHERE series_id=?", (pub_date, series_id)]) if link != None: executing.append(["UPDATE series SET link=? WHERE series_id=?", (link, series_id)]) if times_read != None: executing.append(["UPDATE series SET times_read=? WHERE series_id=?", (times_read, series_id)]) if last_read != None: executing.append(["UPDATE series SET last_read=? WHERE series_id=?", (last_read, series_id)]) if series_path != None: executing.append(["UPDATE series SET series_path=? WHERE series_id=?", (str.encode(series_path), series_id)]) if _db_v != None: executing.append(["UPDATE series SET db_v=? WHERE series_id=?", (_db_v, series_id)]) if exed != None: executing.append(["UPDATE series SET exed=? WHERE series_id=?", (exed, series_id)]) if is_archive != None: executing.append(["UPDATE series SET is_archive=? WHERE series_id=?", (is_archive, series_id)]) if path_in_archive != None: executing.append(["UPDATE series SET path_in_archive=? WHERE series_id=?", (path_in_archive, series_id)]) if view != None: executing.append(["UPDATE series SET view=? WHERE series_id=?", (view, series_id)]) if date_added != None: executing.append(["UPDATE series SET date_added=? WHERE series_id=?", (date_added, series_id)]) if tags != None: assert isinstance(tags, dict) TagDB.modify_tags(series_id, tags) if chapters != None: assert isinstance(chapters, ChaptersContainer) ChapterDB.update_chapter(chapters) if hashes != None: assert isinstance(hashes, Gallery) HashDB.rebuild_gallery_hashes(hashes) for query in executing: cls.execute(cls, *query) @classmethod def get_all_gallery(cls, chapters=True, tags=True, hashes=True): """ Careful, might crash with very large libraries i think... Returns a list of all galleries ( class) currently in DB """ cursor = cls.execute(cls, 'SELECT * FROM series') all_gallery = cursor.fetchall() return GalleryDB.gen_galleries(all_gallery, chapters, tags, hashes) @staticmethod def gen_galleries(gallery_dict, chapters=True, tags=True, hashes=True): """ Map galleries fetched from DB """ gallery_list = [] for gallery_row in gallery_dict: gallery = Gallery() gallery.id = gallery_row['series_id'] gallery = gallery_map(gallery_row, gallery, chapters, tags, hashes) if not os.path.exists(gallery.path): gallery.dead_link = True ListDB.query_gallery(gallery) gallery_list.append(gallery) return gallery_list @classmethod def get_gallery_by_path(cls, path): "Returns gallery with given path" assert isinstance(path, str), "Provided path is invalid" cursor = cls.execute(cls, 'SELECT * FROM series WHERE series_path=?', (str.encode(path),)) row = cursor.fetchone() try: gallery = Gallery() gallery.id = row['series_id'] gallery = gallery_map(row, gallery) return gallery except TypeError: return None @classmethod def get_gallery_by_id(cls, id): "Returns gallery with given id" assert isinstance(id, int), "Provided ID is invalid" cursor = cls.execute(cls, 'SELECT * FROM series WHERE series_id=?', (id,)) row = cursor.fetchone() gallery = Gallery() try: gallery.id = row['series_id'] gallery = gallery_map(row, gallery) return gallery except TypeError: return None @classmethod def add_gallery(cls, object, test_mode=False): "Receives an object of class gallery, and appends it to DB" "Adds gallery of class into database" assert isinstance(object, Gallery), "add_gallery method only accepts gallery items" log_i('Recevied gallery: {}'.format(object.path.encode(errors='ignore'))) #TODO: implement mass gallery adding! User execute_many method for #effeciency! cursor = cls.execute(cls, *default_exec(object)) series_id = cursor.lastrowid object.id = series_id if not object.profile: Executors.generate_thumbnail(object, on_method=object.set_profile) if object.tags: TagDB.add_tags(object) ChapterDB.add_chapters(object) @classmethod def gallery_count(cls): """ Returns the amount of galleries in db. """ cursor = cls.execute(cls, "SELECT count(*) AS 'size' FROM series") return cursor.fetchone()['size'] @classmethod def del_gallery(cls, list_of_gallery, local=False): "Deletes all galleries in the list recursively." assert isinstance(list_of_gallery, list), "Please provide a valid list of galleries to delete" for gallery in list_of_gallery: if local: app_constants.TEMP_PATH_IGNORE.append(os.path.normcase(gallery.path)) if gallery.is_archive: s = delete_path(gallery.path) else: paths = [x.path for x in gallery.chapters] [app_constants.TEMP_PATH_IGNORE.append(os.path.normcase(x)) for x in paths] # to avoid data race? for path in paths: s = delete_path(path) if not s: log_e('Failed to delete chapter {}:{}, {}'.format(chap, gallery.id, gallery.title.encode('utf-8', 'ignore'))) continue s = delete_path(gallery.path) if not s: log_e('Failed to delete gallery:{}, {}'.format(gallery.id, gallery.title.encode('utf-8', 'ignore'))) continue GalleryDB.clear_thumb(gallery.profile) cls.execute(cls, 'DELETE FROM series WHERE series_id=?', (gallery.id,)) gallery.id = None log_i('Successfully deleted: {}'.format(gallery.title.encode('utf-8', 'ignore'))) app_constants.NOTIF_BAR.add_text('Successfully deleted: {}'.format(gallery.title)) @staticmethod def check_exists(name, galleries=None, filter=True): """ Checks if provided string exists in provided sorted list based on path name. Note: key will be normcased """ #pdb.set_trace() if galleries is None: galleries = app_constants.GALLERY_DATA + app_constants.GALLERY_ADDITION_DATA filter = True if filter: filter_list = [] for gallery in galleries: filter_list.append(os.path.normcase(gallery.path)) filter_list = sorted(filter_list) else: filter_list = galleries def binary_search(key): low = 0 high = len(filter_list) - 1 while high >= low: mid = low + (high - low) // 2 if filter_list[mid] < key: low = mid + 1 elif filter_list[mid] > key: high = mid - 1 else: return True return False return binary_search(os.path.normcase(name)) class ChapterDB(DBBase): """ Provides the following database methods: update_chapter -> Updates an existing chapter in DB add_chapter -> adds chapter into db add_chapter_raw -> links chapter to the given seires id, and adds into db get_chapters_for_gallery -> returns a dict with chapters linked to the given series_id get_chapter-> returns a dict with chapter matching the given chapter_number get_chapter_id -> returns id of the chapter number chapter_size -> returns amount of manga (can be used for indexing) del_all_chapters <- Deletes all chapters with the given series_id del_chapter <- Deletes chapter with the given number from gallery """ def __init__(self): raise Exception("ChapterDB should not be instantiated") @classmethod def update_chapter(cls, chapter_container, numbers=[]): """ Updates an existing chapter in DB. Pass a gallery's ChapterContainer, specify number with a list of ints leave empty to update all chapters. """ assert isinstance(chapter_container, ChaptersContainer) and isinstance(numbers, (list, tuple)) if numbers: chapters = [] for n in numbers: chapters.append(chapter_container[n]) else: chapters = chapter_container.get_all_chapters() executing = [] for chap in chapters: new_path = chap.path executing.append((chap.title, str.encode(new_path), chap.pages, chap.in_archive, chap.gallery.id, chap.number,)) cls.executemany(cls, "UPDATE chapters SET chapter_title=?, chapter_path=?, pages=?, in_archive=? WHERE series_id=? AND chapter_number=?", executing) @classmethod def add_chapters(cls, gallery_object): "Adds chapters linked to gallery into database" assert isinstance(gallery_object, Gallery), "Parent gallery need to be of class Gallery" series_id = gallery_object.id executing = [] for chap in gallery_object.chapters: executing.append(default_chap_exec(gallery_object, chap, True)) if not executing: raise Exception cls.executemany(cls, 'INSERT INTO chapters VALUES(NULL, ?, ?, ?, ?, ?, ?)', executing) @classmethod def add_chapters_raw(cls, series_id, chapters_container): "Adds chapter(s) to a gallery with the received series_id" assert isinstance(chapters_container, ChaptersContainer), "chapters_container must be of class ChaptersContainer" executing = [] for chap in chapters_container: if not ChapterDB.get_chapter(series_id, chap.number): executing.append(default_chap_exec(series_id, chap, True)) else: ChapterDB.update_chapter(chapters_container, [chap.number]) cls.executemany(cls, 'INSERT INTO chapters VALUES(NULL, ?, ?, ?, ?, ?, ?)', executing) @classmethod def get_chapters_for_gallery(cls, series_id): """ Returns a ChaptersContainer of chapters matching the received series_id """ assert isinstance(series_id, int), "Please provide a valid gallery ID" cursor = cls.execute(cls, 'SELECT * FROM chapters WHERE series_id=?', (series_id,)) rows = cursor.fetchall() chapters = ChaptersContainer() for row in rows: chap = chapters.create_chapter(row['chapter_number']) chapter_map(row, chap) return chapters @classmethod def get_chapter(cls, series_id, chap_numb): """Returns a ChaptersContainer of chapters matching the recieved chapter_number return None for no match """ assert isinstance(chap_numb, int), "Please provide a valid chapter number" cursor = cls.execute(cls, 'SELECT * FROM chapters WHERE series_id=? AND chapter_number=?', (series_id, chap_numb,)) try: rows = cursor.fetchall() chapters = ChaptersContainer() for row in rows: chap = chapters.create_chapter(row['chapter_number']) chapter_map(row, chap) except TypeError: return None return chapters @classmethod def get_chapter_id(cls, series_id, chapter_number): "Returns id of the chapter number" assert isinstance(series_id, int) and isinstance(chapter_number, int),\ "Passed args must be of int not {} and {}".format(type(series_id), type(chapter_number)) cursor = cls.execute(cls, 'SELECT chapter_id FROM chapters WHERE series_id=? AND chapter_number=?', (series_id, chapter_number,)) try: row = cursor.fetchone() chp_id = row['chapter_id'] return chp_id except KeyError: return None except TypeError: return None @staticmethod def chapter_size(gallery_id): """Returns the amount of chapters for the given gallery id """ pass @classmethod def del_all_chapters(cls, series_id): "Deletes all chapters with the given series_id" assert isinstance(series_id, int), "Please provide a valid gallery ID" cls.execute(cls, 'DELETE FROM chapters WHERE series_id=?', (series_id,)) @classmethod def del_chapter(cls, series_id, chap_number): "Deletes chapter with the given number from gallery" assert isinstance(series_id, int), "Please provide a valid gallery ID" assert isinstance(chap_number, int), "Please provide a valid chapter number" cls.execute(cls, 'DELETE FROM chapters WHERE series_id=? AND chapter_number=?', (series_id, chap_number,)) class TagDB(DBBase): """ Tags are returned in a dict where {"namespace":["tag1","tag2"]} The namespace "default" will be used for tags without namespaces. Provides the following methods: del_tags <- Deletes the tags with corresponding tag_ids from DB del_gallery_tags_mapping <- Deletes the tags and gallery mappings with corresponding series_ids from DB get_gallery_tags -> Returns all tags and namespaces found for the given series_id; get_tag_gallery -> Returns all galleries with the given tag get_ns_tags -> "Returns a dict with namespace as key and list of tags as value" get_ns_tags_to_gallery -> Returns all galleries linked to the namespace tags. Receives a dict like this: {"namespace":["tag1","tag2"]} get_tags_from_namespace -> Returns all galleries linked to the namespace add_tags <- Adds the given dict_of_tags to the given series_id modify_tags <- Modifies the given tags get_all_tags -> Returns all tags in database get_all_ns -> Returns all namespaces in database """ def __init__(self): raise Exception("TagsDB should not be instantiated") @staticmethod def del_tags(list_of_tags_id): "Deletes the tags with corresponding tag_ids from DB" pass @classmethod def del_gallery_mapping(cls, series_id): "Deletes the tags and gallery mappings with corresponding series_ids from DB" assert isinstance(series_id, int), "Please provide a valid gallery id" # delete all mappings related to the given series_id cls.execute(cls, 'DELETE FROM series_tags_map WHERE series_id=?', [series_id]) @classmethod def get_gallery_tags(cls, series_id): "Returns all tags and namespaces found for the given series_id" if not isinstance(series_id, int): return {} cursor = cls.execute(cls, 'SELECT tags_mappings_id FROM series_tags_map WHERE series_id=?', (series_id,)) tags = {} result = cursor.fetchall() for tag_map_row in result: # iterate all tag_mappings_ids try: if not tag_map_row: continue # get tag and namespace c = cls.execute(cls, 'SELECT namespace_id, tag_id FROM tags_mappings WHERE tags_mappings_id=?', (tag_map_row['tags_mappings_id'],)) for row in c.fetchall(): # iterate all rows # get namespace c = cls.execute(cls, 'SELECT namespace FROM namespaces WHERE namespace_id=?', (row['namespace_id'],)) try: namespace = c.fetchone()['namespace'] except TypeError: continue # get tag c = cls.execute(cls, 'SELECT tag FROM tags WHERE tag_id=?', (row['tag_id'],)) try: tag = c.fetchone()['tag'] except TypeError: continue # add them to dict if not namespace in tags: tags[namespace] = [tag] else: # namespace already exists in dict tags[namespace].append(tag) except IndexError: continue return tags @classmethod def add_tags(cls, object): "Adds the given dict_of_tags to the given series_id" assert isinstance(object, Gallery), "Please provide a valid gallery of class gallery" series_id = object.id dict_of_tags = object.tags def look_exists(tag_or_ns, what): """check if tag or namespace already exists in base returns id, else returns None""" c = cls.execute(cls, 'SELECT {}_id FROM {}s WHERE {} = ?'.format(what, what, what), (tag_or_ns,)) try: # exists return c.fetchone()['{}_id'.format(what)] except TypeError: # doesnt exist return None except IndexError: return None tags_mappings_id_list = [] # first let's add the tags and namespaces to db for namespace in dict_of_tags: tags_list = dict_of_tags[namespace] # don't add if it already exists try: namespace_id = look_exists(namespace, "namespace") if not namespace_id: raise ValueError except ValueError: c = cls.execute(cls, 'INSERT INTO namespaces(namespace) VALUES(?)', (namespace,)) namespace_id = c.lastrowid tags_id_list = [] for tag in tags_list: try: tag_id = look_exists(tag, "tag") if not tag_id: raise ValueError except ValueError: c = cls.execute(cls, 'INSERT INTO tags(tag) VALUES(?)', (tag,)) tag_id = c.lastrowid tags_id_list.append(tag_id) def look_exist_tag_map(tag_id): "Checks DB if the tag_id already exists with the namespace_id, returns id else None" c = cls.execute(cls, 'SELECT tags_mappings_id FROM tags_mappings WHERE namespace_id=? AND tag_id=?', (namespace_id, tag_id,)) try: # exists return c.fetchone()['tags_mappings_id'] except TypeError: # doesnt exist return None except IndexError: return None # time to map the tags to the namespace now for tag_id in tags_id_list: # First check if tags mappings exists try: t_map_id = look_exist_tag_map(tag_id) if t_map_id: tags_mappings_id_list.append(t_map_id) else: raise TypeError except TypeError: c = cls.execute(cls, 'INSERT INTO tags_mappings(namespace_id, tag_id) VALUES(?, ?)', (namespace_id, tag_id,)) # add the tags_mappings_id to our list tags_mappings_id_list.append(c.lastrowid) # Lastly we map the series_id to the tags_mappings executing = [] for tags_map in tags_mappings_id_list: executing.append((series_id, tags_map,)) #cls.execute(cls, 'INSERT INTO series_tags_map(series_id, tags_mappings_id) #VALUES(?, ?)', (series_id, tags_map,)) cls.executemany(cls, 'INSERT OR IGNORE INTO series_tags_map(series_id, tags_mappings_id) VALUES(?, ?)', executing) @staticmethod def modify_tags(series_id, dict_of_tags): "Modifies the given tags" # We first delete all mappings TagDB.del_gallery_mapping(series_id) # Now we add the new tags to DB weak_gallery = Gallery() weak_gallery.id = series_id weak_gallery.tags = dict_of_tags TagDB.add_tags(weak_gallery) @staticmethod def get_tag_gallery(tag): "Returns all galleries with the given tag" pass @classmethod def get_ns_tags(cls): "Returns a dict of all tags with namespace as key and list of tags as value" cursor = cls.execute(cls, 'SELECT namespace_id, tag_id FROM tags_mappings') ns_tags = {} ns_id_history = {} # to avoid unesseccary DB fetching for t in cursor.fetchall(): try: # get namespace if not t['namespace_id'] in ns_id_history: c = cls.execute(cls, 'SELECT namespace FROM namespaces WHERE namespace_id=?', (t['namespace_id'],)) ns = c.fetchone()['namespace'] ns_id_history[t['namespace_id']] = ns else: ns = ns_id_history[t['namespace_id']] # get tag c = cls.execute(cls, 'SELECT tag FROM tags WHERE tag_id=?', (t['tag_id'],)) tag = c.fetchone()['tag'] # put in dict if ns in ns_tags: ns_tags[ns].append(tag) else: ns_tags[ns] = [tag] except: continue return ns_tags @staticmethod def get_tags_from_namespace(namespace): "Returns a dict with namespace as key and list of tags as value" pass @staticmethod def get_ns_tags_to_gallery(ns_tags): """ Returns all galleries linked to the namespace tags. Receives a dict like this: {"namespace":["tag1","tag2"]} """ pass @classmethod def get_all_tags(cls): """ Returns all tags in database in a list """ cursor = cls.execute(cls, 'SELECT tag FROM tags') tags = [t['tag'] for t in cursor.fetchall()] return tags @classmethod def get_all_ns(cls): """ Returns all namespaces in database in a list """ cursor = cls.execute(cls, 'SELECT namespace FROM namespaces') ns = [n['namespace'] for n in cursor.fetchall()] return ns class ListDB(DBBase): """ """ @classmethod def init_lists(cls): "Creates and returns lists fetched from DB" lists = [] c = cls.execute(cls, 'SELECT * FROM list') list_rows = c.fetchall() for l_row in list_rows: l = GalleryList(l_row['list_name'], filter=l_row['list_filter'], id=l_row['list_id']) if l_row['type'] == GalleryList.COLLECTION: l.type = GalleryList.COLLECTION elif l_row['type'] == GalleryList.REGULAR: l.type = GalleryList.REGULAR profile = l_row['profile'] if profile: l.profile = bytes.decode(profile) l.enforce = bool(l_row['enforce']) l.regex = bool(l_row['regex']) l.case = bool(l_row['l_case']) l.strict = bool(l_row['strict']) lists.append(l) app_constants.GALLERY_LISTS.add(l) return lists @classmethod def query_gallery(cls, gallery): "Maps gallery to the correct lists" c = cls.execute(cls, 'SELECT list_id FROM series_list_map WHERE series_id=?', (gallery.id,)) list_rows = [x['list_id'] for x in c.fetchall()] for l in app_constants.GALLERY_LISTS: if l._id in list_rows: l.add_gallery(gallery, False, _check_filter=False) @classmethod def modify_list(cls, gallery_list): assert isinstance(gallery_list, GalleryList) if gallery_list._id: cls.execute(cls, """UPDATE list SET list_name=?, list_filter=?, profile=?, type=?, enforce=?, regex=?, l_case=?, strict=? WHERE list_id=?""", (gallery_list.name, gallery_list.filter, str.encode(gallery_list.profile), gallery_list.type, int(gallery_list.enforce), int(gallery_list.regex), int(gallery_list.case), int(gallery_list.strict), gallery_list._id)) @classmethod def add_list(cls, gallery_list): "Adds a list of GalleryList class to DB" assert isinstance(gallery_list, GalleryList) if gallery_list._id: ListDB.modify_list(gallery_list) else: c = cls.execute(cls, """INSERT INTO list(list_name, list_filter, profile, type, enforce, regex, l_case, strict) VALUES(?, ?, ?, ?, ?, ?, ?, ?)""", (gallery_list.name, gallery_list.filter, str.encode(gallery_list.profile), gallery_list.type, int(gallery_list.enforce), int(gallery_list.regex), int(gallery_list.case), int(gallery_list.strict))) gallery_list._id = c.lastrowid ListDB.add_gallery_to_list(gallery_list.galleries(), gallery_list) @classmethod def _g_id_or_list(cls, gallery_or_id_or_list): "Returns gallery ids" if isinstance(gallery_or_id_or_list, (Gallery, int)): gallery_or_id_or_list = [gallery_or_id_or_list] if isinstance(gallery_or_id_or_list, list): if gallery_or_id_or_list: if isinstance(gallery_or_id_or_list[0], Gallery): gallery_or_id_or_list = [g.id for g in gallery_or_id_or_list] return gallery_or_id_or_list @classmethod def add_gallery_to_list(cls, gallery_or_id_or_list, gallery_list): assert isinstance(gallery_list, GalleryList) "Maps provided gallery or list of galleries or gallery id to list" g_ids = ListDB._g_id_or_list(gallery_or_id_or_list) values = [(gallery_list._id, x) for x in g_ids] cls.executemany(cls, 'INSERT OR IGNORE INTO series_list_map(list_id, series_id) VALUES(?, ?)', values) @classmethod def remove_list(cls, gallery_list): "Deletes list from DB" assert isinstance(gallery_list, GalleryList) if gallery_list._id: cls.execute(cls, 'DELETE FROM list WHERE list_id=?', (gallery_list._id,)) try: app_constants.GALLERY_LISTS.remove(gallery_list) except KeyError: pass @classmethod def remove_gallery_from_list(cls, gallery_or_id_or_list, gallery_list): assert isinstance(gallery_list, GalleryList) "Removes provided gallery or list of galleries or gallery id from list" if gallery_list._id: g_ids = ListDB._g_id_or_list(gallery_or_id_or_list) values = [(gallery_list._id, x) for x in g_ids] cls.executemany(cls, 'DELETE FROM series_list_map WHERE list_id=? AND series_id=?', values) class HashDB(DBBase): """ Contains the following methods: find_gallery -> returns galleries which matches the given list of hashes get_gallery_hashes -> returns all hashes with the given gallery id in a list get_gallery_hash -> returns hash of chapter specified. If page is specified, returns hash of chapter page gen_gallery_hashes <- generates hashes for gallery's chapters and inserts them to db rebuild_gallery_hashes <- inserts hashes into DB only if it doesnt already exist """ @classmethod def find_gallery(cls, hashes): assert isinstance(hashes, list) gallery_ids = {} hash_status = [] for hash in hashes: r = cls.execute(cls, 'SELECT series_id FROM hashes WHERE hash=?', (hash,)) try: g_ids = r.fetchall() for r in g_ids: g_id = r['series_id'] if g_id not in gallery_ids: gallery_ids[g_id] = 1 else: gallery_ids[g_id] = gallery_ids[g_id] + 1 if g_ids: hash_status.append(True) else: hash_status.append(False) except KeyError: hash_status.append(False) except TypeError: hash_status.append(False) if all(hash_status): # the one with most matching hashes g_id = None h_match_count = 0 for g in gallery_ids: if gallery_ids[g] > h_match_count: h_match_count = gallery_ids[h] g_id = g if g_id: weak_gallery = Gallery() weak_gallery.id = g_id return weak_gallery return None @classmethod def get_gallery_hashes(cls, gallery_id): "Returns all hashes with the given gallery id in a list" cursor = cls.execute(cls, 'SELECT hash FROM hashes WHERE series_id=?', (gallery_id,)) hashes = [] try: for row in cursor.fetchall(): hashes.append(row['hash']) except IndexError: return [] return hashes @classmethod def get_gallery_hash(cls, gallery_id, chapter, page=None): """ returns hash of chapter. If page is specified, returns hash of chapter page """ assert isinstance(gallery_id, int) assert isinstance(chapter, int) if page: assert isinstance(page, int) chap_id = ChapterDB.get_chapter_id(gallery_id, chapter) if not chap_id: return None if page: exceuting = ["SELECT hash FROM hashes WHERE series_id=? AND chapter_id=? AND page=?", (gallery_id, chap_id, page)] else: exceuting = ["SELECT hash FROM hashes WHERE series_id=? AND chapter_id=?", (gallery_id, chap_id)] hashes = [] c = cls.execute(cls, *exceuting) for h in c.fetchall(): try: hashes.append(h['hash']) except KeyError: pass return hashes @classmethod def gen_gallery_hash(cls, gallery, chapter, page=None, color_img=False, _name=None): """ Generate hash for a specific chapter. Set page to only generate specific page page: 'mid' or number or list of numbers color_img: if true then a hash to colored img will be returned if possible Returns dict with chapter number or 'mid' as key and hash as value """ assert isinstance(gallery, Gallery) assert isinstance(chapter, int) if page != None: assert isinstance(page, (int, str, list)) skip_gen = False if gallery.id: chap_id = ChapterDB.get_chapter_id(gallery.id, chapter) c = cls.execute(cls, 'SELECT hash, page FROM hashes WHERE series_id=? AND chapter_id=?', (gallery.id, chap_id,)) hashes = {} for r in c.fetchall(): try: if r['hash'] and r['page'] != None: hashes[r['page']] = r['hash'] except TypeError: pass if isinstance(page, (int, list)): if isinstance(page, int): _page = [page] else: _page = page h = {} t = False for p in _page: if p in hashes: h[p] = hashes[p] else: t = True if not t: skip_gen = True hashes = h elif gallery.chapters[chapter].pages == len(hashes.keys()): skip_gen = True if page == "mid": try: hashes = {'mid':hashes[len(hashes) // 2]} except KeyError: skip_gen = False if not skip_gen or color_img: def look_exists(page): """check if hash already exists in database returns hash, else returns None""" c = cls.execute(cls, 'SELECT hash FROM hashes WHERE page=? AND chapter_id=?', (page, chap_id,)) try: # exists return c.fetchone()['hash'] except TypeError: # doesnt exist return None except IndexError: return None if gallery.dead_link: log_e("Could not generate hash of dead gallery: {}".format(gallery.title.encode(errors='ignore'))) return {} try: chap = gallery.chapters[chapter] except KeyError: utils.make_chapters(gallery) try: chap = gallery.chapters[chapter] except KeyError: return {} executing = [] try: if gallery.is_archive: raise NotADirectoryError imgs = sorted([x.path for x in scandir.scandir(chap.path) if x.path.endswith(utils.IMG_FILES)]) pages = {} for n, i in enumerate(imgs): pages[n] = i if page != None: pages = {} if color_img: # if first img is colored, then return filepath of that if not utils.image_greyscale(imgs[0]): return {'color':imgs[0]} if page == 'mid': imgs = imgs[len(imgs) // 2] pages[len(imgs) // 2] = imgs elif isinstance(page, list): try: for p in page: pages[p] = imgs[p] except IndexError: raise app_constants.InternalPagesMismatch else: imgs = imgs[page] pages = {page:imgs} hashes = {} if gallery.id != None: for p in pages: h = look_exists(p) if not h: with open(pages[p], 'rb') as f: h = generate_img_hash(f) executing.append((h, gallery.id, chap_id, p,)) hashes[p] = h else: for i in pages: with open(pages[i], 'rb') as f: hashes[i] = generate_img_hash(f) except NotADirectoryError: temp_dir = os.path.join(app_constants.temp_dir, str(uuid.uuid4())) is_archive = gallery.is_archive try: if is_archive: zip = ArchiveFile(gallery.path) else: zip = ArchiveFile(chap.path) except app_constants.CreateArchiveFail: log_e('Could not generate hash: CreateZipFail') return {} pages = {} if page != None: p = 0 con = sorted(zip.dir_contents(chap.path)) if color_img: # if first img is colored, then return hash of that f_bytes = io.BytesIO(zip.open(con[0], False)) if not utils.image_greyscale(f_bytes): return {'color':zip.extract(con[0])} f_bytes.close() if page == 'mid': p = len(con) // 2 img = con[p] pages = {p:zip.open(img, True)} elif isinstance(page, list): for x in page: pages[x] = zip.open(con[x], True) else: p = page img = con[p] pages = {p:zip.open(img, True)} else: imgs = sorted(zip.dir_contents(chap.path)) for n, img in enumerate(imgs): pages[n] = zip.open(img, True) zip.close() hashes = {} if gallery.id != None: for p in pages: h = look_exists(p) if not h: h = generate_img_hash(pages[p]) executing.append((h, gallery.id, chap_id, p,)) hashes[p] = h else: for i in pages: hashes[i] = generate_img_hash(pages[i]) if executing: cls.executemany(cls, 'INSERT INTO hashes(hash, series_id, chapter_id, page) VALUES(?, ?, ?, ?)', executing) if page == 'mid': r_hash = {'mid':list(hashes.values())[0]} else: r_hash = hashes if _name != None: try: r_hash[_name] = r_hash[page] except KeyError: pass return r_hash @classmethod def gen_gallery_hashes(cls, gallery): "Generates hashes for gallery's first chapter and inserts them to DB" return HashDB.gen_gallery_hash(gallery, 0) @staticmethod def rebuild_gallery_hashes(gallery): "Inserts hashes into DB only if it doesnt already exist" assert isinstance(gallery, Gallery) hashes = HashDB.get_gallery_hashes(gallery.id) if not hashes: hashes = HashDB.gen_gallery_hashes(gallery) return hashes @classmethod def del_gallery_hashes(cls, gallery_id): "Deletes all hashes linked to the given gallery id" cls.execute(cls, 'DELETE FROM hashes WHERE series_id=?', (gallery_id,)) class GalleryList: """ Provides access to lists.. methods: - add_gallery <- adds a gallery of Gallery class to list - remove_gallery <- removes galleries matching the provided gallery id - clear <- removes all galleries from the list - galleries -> returns a list with all galleries in list - scan <- scans for galleries matching the listfilter and adds them to gallery """ # types REGULAR, COLLECTION = range(2) def __init__(self, name, list_of_galleries=[], filter=None, id=None, _db=True): self._id = id # shouldnt ever be touched self.name = name self.profile = '' self.type = self.REGULAR self.filter = filter self.enforce = False self.regex = False self.case = False self.strict = False self._galleries = set() self._ids_chache = [] self._scanning = False self.add_gallery(list_of_galleries, _db) def add_gallery(self, gallery_or_list_of, _db=True, _check_filter=True): "add_gallery <- adds a gallery of Gallery class to list" assert isinstance(gallery_or_list_of, (Gallery, list)) if isinstance(gallery_or_list_of, Gallery): gallery_or_list_of = [gallery_or_list_of] if _check_filter and self.filter and self.enforce: execute(self.scan, True, gallery_or_list_of) return new_galleries = [] for gallery in gallery_or_list_of: self._galleries.add(gallery) if not utils.b_search(self._ids_chache, gallery.id): new_galleries.append(gallery) self._ids_chache.append(gallery.id) # uses timsort algorithm so it's ok self._ids_chache.sort() if _db: execute(ListDB.add_gallery_to_list, True, new_galleries, self) def remove_gallery(self, gallery_id_or_list_of): "remove_gallery <- removes galleries matching the provided gallery id" if isinstance(gallery_id_or_list_of, int): gallery_id_or_list_of = [gallery_id_or_list_of] g_ids = gallery_id_or_list_of g_ids_to_delete = [] g_to_delete = [] for g in self._galleries: if g.id in g_ids: g_to_delete.append(g) try: self._ids_chache.remove(g.id) except ValueError: pass g_ids_to_delete.append(g.id) for g in g_to_delete: self._galleries.remove(g) execute(ListDB.remove_gallery_from_list, True, g_ids_to_delete, self) def clear(self): "removes all galleries from the list" if self._galleries: execute(ListDB.remove_gallery_from_list, True, list(self._galleries), self) self._galleries.clear() self._ids_chache.clear() def galleries(self): "returns a list with all galleries in list" return list(self._galleries) def __contains__(self, g): return utils.b_search(self._ids_chache, g.id) def add_to_db(self): app_constants.GALLERY_LISTS.add(self) execute(ListDB.add_list, True, self) def scan(self, galleries=None): if self.filter and not self._scanning: self._scanning = True if isinstance(galleries, Gallery): galleries = [galleries] if not galleries: galleries = app_constants.GALLERY_DATA new_galleries = [] filter_term = ' '.join(self.filter.split()) args = [] if self.regex: args.append(app_constants.Search.Regex) if self.case: args.append(app_constants.Search.Case) if self.strict: args.append(app_constants.Search.Strict) search_pieces = utils.get_terms(filter_term) def _search_g(gallery): all_terms = {t: False for t in search_pieces} for t in search_pieces: if gallery.contains(t, args): all_terms[t] = True if all(all_terms.values()): return True return False for gallery in galleries: if _search_g(gallery): new_galleries.append(gallery) if self.enforce: g_to_remove = [] for g in self.galleries(): if not _search_g(g): g_to_remove.append(g.id) if g_to_remove: self.remove_gallery(g_to_remove) self.add_gallery(new_galleries, _check_filter=False) self._scanning = False def __lt__(self, other): return self.name < other.name class Gallery: """ Base class for a gallery. Available data: id -> Not to be editied. Do not touch. title <- [list of titles] or str profile <- path to thumbnail path <- path to gallery artist <- str chapters <- {:} chapter_size <- int of number of chapters info <- str fav <- int (1 for true 0 for false) rating <- float type <- str (Manga? Doujin? Other?) language <- str status <- "unknown", "completed" or "ongoing" tags <- list of str pub_date <- date date_added <- date, will be defaulted to today if not specified last_read <- timestamp (e.g. time.time()) times_read <- an integer telling us how many times the gallery has been opened hashes <- a list of hashes of the gallery's chapters exed <- indicator on if gallery metadata has been fetched valid <- a bool indicating the validity of the gallery Takes ownership of ChaptersContainer """ def __init__(self): self.id = None # Will be defaulted. self.title = "" self.profile = "" self._path = "" self.path_in_archive = "" self.is_archive = 0 self.artist = "" self._chapters = ChaptersContainer(self) self.info = "" self.fav = 0 self.rating = 0 self.type = "" self.link = "" self.language = "" self.status = "" self.tags = {} self.pub_date = None self.date_added = datetime.datetime.now().replace(microsecond=0) self.last_read = None self.times_read = 0 self.valid = False self._db_v = None self.hashes = [] self.exed = 0 self.file_type = "folder" self.view = app_constants.ViewType.Default # default view self._grid_visible = False self._list_view_selected = False self._profile_qimage = {} self._profile_load_status = {} self.dead_link = False self.state = app_constants.GalleryState.Default self.qtime = QTime() # used by views to record addition @property def path(self): return self._path @path.setter def path(self, n_p): self._path = n_p _, ext = os.path.splitext(n_p) if ext: self.file_type = ext[1:].lower() # remove dot def set_defaults(self): if not self.type: self.type = app_constants.G_DEF_TYPE.capitalize() if not self.language: self.language = app_constants.G_DEF_LANGUAGE.capitalize() if not self.status: self.status = app_constants.G_DEF_STATUS.capitalize() def reset_profile(self): self._profile_load_status.clear() self._profile_qimage.clear() def _profile_loaded(self, img, ptype=None, method=None): self._profile_load_status[ptype] = img if method and img: method(self, img) def get_profile(self, ptype, on_method=None): psize = app_constants.THUMB_DEFAULT if ptype == app_constants.ProfileType.Small: psize = app_constants.THUMB_SMALL if ptype in self._profile_qimage: f = self._profile_qimage[ptype] if not f.done(): return if f.result(): return f.result() img = self._profile_load_status.get(ptype) if not img: self._profile_qimage[ptype] = Executors.load_thumbnail(self.profile, psize, on_method=self._profile_loaded, ptype=ptype, method=on_method) return img def set_profile(self, future): "set with profile with future object" self.profile = future.result() if self.id != None: execute(GalleryDB.modify_gallery, True, self.id, profile=self.profile, priority=0) @property def chapters(self): return self._chapters @chapters.setter def chapters(self, chp_cont): assert isinstance(chp_cont, ChaptersContainer) chp_cont.set_parent(self) self._chapters = chp_cont def merge(galleries): "Merge galleries into this galleries, adding them as chapters" pass def gen_hashes(self): "Generate hashes while inserting them into DB" if not self.hashes: hash = HashDB.gen_gallery_hashes(self) if hash: self.hashes = hash return True else: return False else: return True def validate(self): "Validates gallery, returns status" # TODO: Extend this validity = [] status = False #if not self.hashes: # HashDB.gen_gallery_hashes(self) # self.hashes = HashDB.get_gallery_hashes(self.id) if all(validity): status = True self.valid = True return status def invalidities(self): """ Checks all attributes for invalidities. Returns list of string with invalid attribute names """ return [] def _keyword_search(self, ns, tag, args=[]): term = '' lt, gt = range(2) def _search(term): if app_constants.Search.Regex in args: if utils.regex_search(tag, term, args): return True else: if app_constants.DEBUG: log_d("{} {}".format(tag, term)) if utils.search_term(tag, term, args): return True return False def _operator_parse(tag): o = None if tag: if tag[0] == '<': o = lt tag = tag[1:] elif tag[0] == '>': o = gt tag = tag[1:] return tag, o def _operator_supported(attr, date=False): try: o_tag, o = _operator_parse(tag) if date: o_tag = dateparser.parse(o_tag, dayfirst=True) if o_tag: o_tag = o_tag.date() else: o_tag = int(o_tag) if o != None: if o == gt: return o_tag < attr elif o == lt: return o_tag > attr else: return o_tag == attr except ValueError: return False if ns == 'Title': term = self.title elif ns in ['Language', 'Lang']: term = self.language elif ns == 'Type': term = self.type elif ns == 'Status': term = self.status elif ns == 'Artist': term = self.artist elif ns == 'Url': term = self.link elif ns in ['Descr', 'Description']: term = self.info elif ns in ['Chapter', 'Chapters']: return _operator_supported(self.chapters.count()) elif ns in ['Read_count', 'Read count', 'Times_read', 'Times read']: return _operator_supported(self.times_read) elif ns in ['Rating', 'Stars']: return _operator_supported(self.rating) elif ns in ['Date_added', 'Date added']: return _operator_supported(self.date_added.date(), True) elif ns in ['Pub_date', 'Publication', 'Pub date']: if self.pub_date: return _operator_supported(self.pub_date.date(), True) return False elif ns in ['Last_read', 'Last read']: if self.last_read: return _operator_supported(self.last_read.date(), True) return False return _search(term) def __contains__(self, key): assert isinstance(key, Chapter), "Can only check for chapters in gallery" return self.chapters.__contains__(key) def contains(self, key, args=[]): "Check if gallery contains keyword" is_exclude = False if key[0] == '-' else True key = key[1:] if not is_exclude else key default = False if is_exclude else True if key: # check in title/artist/language found = False if not ':' in key: for g_attr in [self.title, self.artist, self.language]: if not g_attr: continue if app_constants.Search.Regex in args: if utils.regex_search(key, g_attr, args=args): found = True break else: if utils.search_term(key, g_attr, args=args): found = True break # check in tag if not found: tags = key.split(':') ns = tag = '' # only namespace is lowered and capitalized for now if len(tags) > 1: ns = tags[0].lower().capitalize() tag = tags[1] else: tag = tags[0] # very special keywords if ns: key_word = ['none', 'null'] if ns == 'Tag' and tag in key_word: if not self.tags or len(self.tags) == 1 and 'default' in self.tags and not self.tags['default']: return is_exclude elif ns == 'Artist' and tag in key_word: if not self.artist: return is_exclude elif ns == 'Status' and tag in key_word: if not self.status or self.status == 'Unknown': return is_exclude elif ns == 'Language' and tag in key_word: if not self.language: return is_exclude elif ns == 'Url' and tag in key_word: if not self.link: return is_exclude elif ns in ('Descr', 'Description') and tag in key_word: if not self.info or self.info == 'No description..': return is_exclude elif ns == 'Type' and tag in key_word: if not self.type: return is_exclude elif ns in ('Publication', 'Pub_date', 'Pub date') and tag in key_word: if not self.pub_date: return is_exclude elif ns == 'Path' and tag in key_word: if self.dead_link: return is_exclude if app_constants.Search.Regex in args: if ns: if self._keyword_search(ns, tag, args = args): return is_exclude for x in self.tags: if utils.regex_search(ns, x): for t in self.tags[x]: if utils.regex_search(tag, t, True, args=args): return is_exclude else: for x in self.tags: for t in self.tags[x]: if utils.regex_search(tag, t, True, args=args): return is_exclude else: if ns: if self._keyword_search(ns, tag, args=args): return is_exclude if ns in self.tags: for t in self.tags[ns]: if utils.search_term(tag, t, True, args=args): return is_exclude else: for x in self.tags: for t in self.tags[x]: if utils.search_term(tag, t, True, args=args): return is_exclude else: return is_exclude return default def move_gallery(self, new_path=''): log_i("Moving gallery...") log_d("Old gallery path: {}".format(self.path)) old_head, old_tail = os.path.split(self.path) try: self.path = utils.move_files(self.path, new_path) except PermissionError: log.exception("Failed to move gallery") app_constants.NOTIF_BAR.add_text("Permission Error: Failed to move gallery ({})".format(self.title)) return new_head, new_tail = os.path.split(self.path) for chap in self.chapters: if not chap.in_archive: head, tail = os.path.split(chap.path) log_d("old chapter path: {}".format(chap.path)) if os.path.exists(os.path.join(self.path, tail)): chap.path = os.path.join(self.path, tail) continue if os.path.join(old_head, old_tail) == os.path.join(head, tail): chap.path = self.path continue if self.is_archive: utils.move_files(chap.path, os.path.join(new_head, tail)) else: utils.move_files(chap.path, os.path.join(self.path, tail)) def __lt__(self, other): return self.id < other.id def __str__(self): s = "" for x in sorted(self.__dict__): s += "{:>20}: {:>15}\n".format(x, str(self.__dict__[x])) return s class Chapter: """ Base class for a chapter Contains following attributes: parent -> The ChapterContainer it belongs in gallery -> The Gallery it belongs to title -> title of chapter path -> path to chapter number -> chapter number pages -> chapter pages in_archive -> 1 if the chapter path is in an archive else 0 """ def __init__(self, parent, gallery, number=0, path='', pages=0, in_archive=0, title=''): self.parent = parent self.gallery = gallery self.title = title self.path = path self.number = number self.pages = pages self.in_archive = in_archive def __lt__(self, other): return self.number < other.number def __str__(self): s = """ Chapter: {} Title: {} Path: {} Pages: {} in_archive: {} """.format(self.number, self.title, self.path, self.pages, self.in_archive) return s @property def next_chapter(self): try: return self.parent[self.number + 1] except KeyError: return None @property def previous_chapter(self): try: return self.parent[self.number - 1] except KeyError: return None def open(self, stat_msg=True): if stat_msg: txt = "Opening chapter {} of {}".format(self.number + 1, self.gallery.title) app_constants.STAT_MSG_METHOD(txt) app_constants.NOTIF_BAR.add_text(txt) if self.in_archive: if self.gallery.is_archive: execute(utils.open_chapter, True, self.path, self.gallery.path) else: execute(utils.open_chapter, True, '', self.path) else: execute(utils.open_chapter, True, self.path) self.gallery.times_read += 1 self.gallery.last_read = datetime.datetime.now().replace(microsecond=0) execute(GalleryDB.modify_gallery, True, self.gallery.id, times_read=self.gallery.times_read, last_read=self.gallery.last_read) class ChaptersContainer: """ A container for chapters. Acts like a list/dict of chapters. Iterable returns a ordered list of chapters Sets to gallery.chapters """ def __init__(self, gallery=None): self.parent = None self._data = {} if gallery: gallery.chapters = self def set_parent(self, gallery): assert isinstance(gallery, (Gallery, None)) self.parent = gallery for n in self._data: chap = self._data[n] chap.gallery = gallery def add_chapter(self, chp, overwrite=True, db=False): "Add a chapter of Chapter class to this container" assert isinstance(chp, Chapter), "Chapter must be an instantiated Chapter class" if not overwrite: try: _ = self._data[chp.number] raise app_constants.ChapterExists except KeyError: pass chp.gallery = self.parent chp.parent = self self[chp.number] = chp if db: # TODO: implement this pass def create_chapter(self, number=None): """ Creates Chapter class with the next chapter number or passed number arg and adds to container The chapter will be returned """ if number: chp = Chapter(self, self.parent, number=number) self[number] = chp else: next_number = 0 for n in list(self._data.keys()): if n > next_number: next_number = n else: next_number += 1 chp = Chapter(self, self.parent, number=next_number) self[next_number] = chp return chp def update_chapter_pages(self, number): "Returns status on success" if self.parent.dead_link: return False chap = self[number] if chap.in_archive: _archive = utils.ArchiveFile(chap.gallery.path) chap.pages = len([x for x in _archive.dir_contents(chap.path) if x.endswith(IMG_FILES)]) _archive.close() else: chap.pages = len([x for x in scandir.scandir(chap.path) if x.path.endswith(IMG_FILES)]) execute(ChapterDB.update_chapter, True, self, [chap.number]) return True def pages(self): p = 0 for c in self: p += c.pages return p def get_chapter(self, number): return self[number] def get_all_chapters(self): return list(self._data.values()) def count(self): return len(self) def pop(self, key, default=None): return self._data.pop(key, default) def __len__(self): return len(self._data) def __getitem__(self, key): return self._data[key] def __setitem__(self, key, value): assert isinstance(key, int), "Key must be a chapter number" assert isinstance(value, Chapter), "Value must be an instantiated Chapter class" if value.gallery != self.parent: raise app_constants.ChapterWrongParentGallery self._data[key] = value def __delitem__(self, key): del self._data[key] def __iter__(self): return iter([self[c] for c in sorted(self._data.keys())]) def __bool__(self): return bool(self._data) def __str__(self): s = "" for c in self: s += '\n' + '{}'.format(c) if not s: return '{}' return s def __contains__(self, key): if key.gallery == self.parent and key in [self.data[c] for c in self._data]: return True return False class AdminDB(QObject): DONE = pyqtSignal(bool) PROGRESS = pyqtSignal(int) DATA_COUNT = pyqtSignal(int) def __init__(self, parent=None): super().__init__(parent) def from_v021_to_v022(self, old_db_path=db_constants.DB_PATH): log_i("Started rebuilding database") if DBBase._DB_CONN: DBBase._DB_CONN.close() DBBase._DB_CONN = db.init_db(old_db_path) db_galleries = execute(GalleryDB.get_all_gallery, False, False, True, True) galleries = [] for g in db_galleries: if not os.path.exists(g.path): log_i("Gallery doesn't exist anymore: {}".format(g.title.encode(errors="ignore"))) else: galleries.append(g) n_galleries = [] # get all chapters log_i("Getting chapters...") chap_rows = DBBase().execute("SELECT * FROM chapters").fetchall() data_count = len(chap_rows) * 2 self.DATA_COUNT.emit(data_count) for n, chap_row in enumerate(chap_rows, -1): log_d('Next chapter row') for gallery in galleries: if gallery.id == chap_row['series_id']: log_d('Found gallery for chapter row') chaps = ChaptersContainer(gallery) chap = chaps.create_chapter(chap_row['chapter_number']) c_path = bytes.decode(chap_row['chapter_path']) if c_path: try: t = utils.title_parser(os.path.split(c_path)[1])['title'] except IndexError: t = c_path else: t = '' chap.title = t chap.path = c_path chap.in_archive = chap_row['in_archive'] if gallery.is_archive: zip = utils.ArchiveFile(gallery.path) chap.pages = len(zip.dir_contents(chap.path)) zip.close() else: chap.pages = len(list(scandir.scandir(gallery.path))) n_galleries.append(gallery) galleries.remove(gallery) break self.PROGRESS.emit(n) log_d("G: {} C:{}".format(len(n_galleries), data_count - 1)) log_i("Database magic...") if os.path.exists(db_constants.THUMBNAIL_PATH): for root, dirs, files in scandir.walk(db_constants.THUMBNAIL_PATH, topdown=False): for name in files: os.remove(os.path.join(root, name)) for name in dirs: os.rmdir(os.path.join(root, name)) head = os.path.split(old_db_path)[0] DBBase._DB_CONN.close() t_db_path = os.path.join(head, 'temp.db') conn = db.init_db(t_db_path) DBBase._DB_CONN = conn for n, g in enumerate(n_galleries, len(chap_rows) - 1): log_d('Adding new gallery') GalleryDB.add_gallery(g) self.PROGRESS.emit(n) conn.commit() conn.close() log_i("Cleaning up...") if os.path.exists(old_db_path): utils.backup_database(old_db_path) os.remove(old_db_path) if os.path.exists(db_constants.DB_PATH): os.remove(db_constants.DB_PATH) os.rename(t_db_path, db_constants.DB_PATH) self.PROGRESS.emit(data_count) log_i("Finished rebuilding database") self.DONE.emit(True) return True def rebuild_database(self): "Rebuilds database" log_i("Initiating database rebuild") utils.backup_database() log_i("Getting galleries...") galleries = GalleryDB.get_all_gallery() self.DATA_COUNT.emit(len(galleries)) db.DBBase._DB_CONN.close() log_i("Removing old database...") log_i("Creating new database...") temp_db = os.path.join(db_constants.DB_ROOT, "happypanda_temp.db") if os.path.exists(temp_db): os.remove(temp_db) db.DBBase._DB_CONN = db.init_db(temp_db) DBBase.begin() log_i("Adding galleries...") GalleryDB.clear_thumb_dir() for n, g in enumerate(galleries): if not os.path.exists(g.path): log_i("Gallery doesn't exist anymore: {}".format(g.title.encode(errors="ignore"))) else: GalleryDB.add_gallery(g) self.PROGRESS.emit(n) DBBase.end() DBBase._DB_CONN.close() os.remove(db_constants.DB_PATH) os.rename(temp_db, db_constants.DB_PATH) db.DBBase._DB_CONN = db.init_db(db_constants.DB_PATH) self.PROGRESS.emit(len(galleries)) log_i("Succesfully rebuilt database") self.DONE.emit(True) return True def rebuild_galleries(self): galleries = execute(GalleryDB.get_all_gallery, False) if galleries: self.DATA_COUNT.emit(len(galleries)) log_i('Rebuilding galleries') for n, g in enumerate(galleries, 1): execute(GalleryDB.rebuild_gallery, False, g) self.PROGRESS.emit(n) self.DONE.emit(True) def rebuild_thumbs(self, clear_first): if clear_first: log_i("Clearing thumbanils dir..") GalleryDB.clear_thumb_dir() gs = [] gs.extend(app_constants.GALLERY_DATA) gs.extend(app_constants.GALLERY_ADDITION_DATA) self.DATA_COUNT.emit(len(app_constants.GALLERY_DATA)) log_i('Regenerating thumbnails') for n, g in enumerate(gs, 1): execute(GalleryDB.rebuild_thumb, False, g) g.reset_profile() self.PROGRESS.emit(n) self.DONE.emit(True) class DatabaseStartup(QObject): """ Fetches and emits database records START: emitted when fetching from DB occurs DONE: emitted when the initial fetching from DB finishes """ START = pyqtSignal() DONE = pyqtSignal() PROGRESS = pyqtSignal(str) _DB = DBBase() def __init__(self): super().__init__() ListDB.init_lists() self._fetch_count = 500 self._offset = 0 self._fetching = False self.count = 0 self._finished = False self._loaded_galleries = [] def startup(self, manga_views): self.START.emit() self._fetching = True self.count = GalleryDB.gallery_count() remaining = self.count while remaining > 0: self.PROGRESS.emit("Loading galleries: {}".format(remaining)) rec_to_fetch = min(remaining, self._fetch_count) self.fetch_galleries(self._offset, rec_to_fetch, manga_views) self._offset += rec_to_fetch remaining = self.count - self._offset [v.list_view.manga_delegate._increment_paint_level() for v in manga_views] self.PROGRESS.emit("Loading chapters...") self.fetch_chapters() self.PROGRESS.emit("Loading tags...") self.fetch_tags() [v.list_view.manga_delegate._increment_paint_level() for v in manga_views] self.PROGRESS.emit("Loading hashes...") self.fetch_hashes() self._fetching = False self.DONE.emit() def fetch_galleries(self, f, t, manga_views): c = execute(self._DB.execute, False, 'SELECT * FROM series LIMIT {}, {}'.format(f, t)) if c: new_data = c.fetchall() gallery_list = execute(GalleryDB.gen_galleries, False, new_data, {"chapters":False, "tags":False, "hashes":False}) #self._current_data.extend(gallery_list) if gallery_list: self._loaded_galleries.extend(gallery_list) for view in manga_views: view_galleries = [g for g in gallery_list if g.view == view.view_type] view.gallery_model._gallery_to_add = view_galleries view.gallery_model.insertRows(view.gallery_model.rowCount(), len(view_galleries)) def fetch_chapters(self): for g in self._loaded_galleries: g.chapters = execute(ChapterDB.get_chapters_for_gallery, False, g.id) def fetch_tags(self): for g in self._loaded_galleries: g.tags = execute(TagDB.get_gallery_tags, False, g.id) def fetch_hashes(self): for g in self._loaded_galleries: g.hashes = execute(HashDB.get_gallery_hashes, False, g.id) if __name__ == '__main__': #unit testing here pass ================================================ FILE: version/gallerydialog.py ================================================ import queue, os, threading, random, logging, time, scandir from datetime import datetime from PyQt5.QtWidgets import (QWidget, QVBoxLayout, QDesktopWidget, QGroupBox, QHBoxLayout, QFormLayout, QLabel, QLineEdit, QPushButton, QProgressBar, QTextEdit, QComboBox, QDateEdit, QFileDialog, QMessageBox, QScrollArea, QCheckBox, QSizePolicy, QSpinBox) from PyQt5.QtCore import (pyqtSignal, Qt, QPoint, QDate, QThread, QTimer) import app_constants import utils import gallerydb import fetch import misc import database import settings log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical class GalleryDialog(QWidget): """ A window for adding/modifying gallery. Pass a list of QModelIndexes to edit their data or pass a path to preset path """ def __init__(self, parent, arg=None): super().__init__(parent, Qt.Dialog) self.setAttribute(Qt.WA_DeleteOnClose) self.setAutoFillBackground(True) self.parent_widget = parent m_l = QVBoxLayout() self.main_layout = QVBoxLayout() dummy = QWidget(self) scroll_area = QScrollArea(self) scroll_area.setWidgetResizable(True) scroll_area.setFrameStyle(scroll_area.StyledPanel) dummy.setLayout(self.main_layout) scroll_area.setWidget(dummy) m_l.addWidget(scroll_area, 3) final_buttons = QHBoxLayout() final_buttons.setAlignment(Qt.AlignRight) m_l.addLayout(final_buttons) self.done = QPushButton("Done") self.done.setDefault(True) cancel = QPushButton("Cancel") final_buttons.addWidget(cancel) final_buttons.addWidget(self.done) self._multiple_galleries = False self._edit_galleries = [] def new_gallery(): self.setWindowTitle('Add a new gallery') self.newUI() self.commonUI() self.done.clicked.connect(self.accept) cancel.clicked.connect(self.reject) if arg: if isinstance(arg, (list, gallerydb.Gallery)): if isinstance(arg, gallerydb.Gallery): self.setWindowTitle('Edit gallery') self._edit_galleries.append(arg) else: self.setWindowTitle('Edit {} galleries'.format(len(arg))) self._multiple_galleries = True self._edit_galleries.extend(arg) self.commonUI() self.setGallery(arg) self.done.clicked.connect(self.accept_edit) cancel.clicked.connect(self.reject_edit) elif isinstance(arg, str): new_gallery() self.choose_dir(arg) else: new_gallery() log_d('GalleryDialog: Create UI: successful') self.setLayout(m_l) if self._multiple_galleries: self.resize(500, 480) else: self.resize(500, 600) frect = self.frameGeometry() frect.moveCenter(QDesktopWidget().availableGeometry().center()) self.move(frect.topLeft()) self._fetch_inst = fetch.Fetch() self._fetch_thread = QThread(self) self._fetch_thread.setObjectName("GalleryDialog metadata thread") self._fetch_inst.moveToThread(self._fetch_thread) self._fetch_thread.started.connect(self._fetch_inst.auto_web_metadata) def commonUI(self): if not self._multiple_galleries: f_web = QGroupBox("Metadata from the Web") f_web.setCheckable(False) self.main_layout.addWidget(f_web) web_main_layout = QVBoxLayout() web_info = misc.ClickedLabel("Which gallery URLs are supported? (hover)", parent=self) web_info.setToolTip(app_constants.SUPPORTED_METADATA_URLS) web_info.setToolTipDuration(999999999) web_main_layout.addWidget(web_info) web_layout = QHBoxLayout() web_main_layout.addLayout(web_layout) f_web.setLayout(web_main_layout) def basic_web(name): return QLabel(name), QLineEdit(), QPushButton("Get metadata"), QProgressBar() url_lbl, self.url_edit, url_btn, url_prog = basic_web("URL:") url_btn.clicked.connect(lambda: self.web_metadata(self.url_edit.text(), url_btn, url_prog)) url_prog.setTextVisible(False) url_prog.setMinimum(0) url_prog.setMaximum(0) web_layout.addWidget(url_lbl, 0, Qt.AlignLeft) web_layout.addWidget(self.url_edit, 0) web_layout.addWidget(url_btn, 0, Qt.AlignRight) web_layout.addWidget(url_prog, 0, Qt.AlignRight) self.url_edit.setPlaceholderText("Insert supported gallery URLs or just press the button!") url_prog.hide() f_gallery = QGroupBox("Gallery Info") f_gallery.setCheckable(False) self.main_layout.addWidget(f_gallery) gallery_layout = QFormLayout() f_gallery.setLayout(gallery_layout) def checkbox_layout(widget): if self._multiple_galleries: l = QHBoxLayout() l.addWidget(widget.g_check) widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) l.addWidget(widget) return l else: widget.g_check.setChecked(True) widget.g_check.hide() return widget def add_check(widget): widget.g_check = QCheckBox(self) return widget self.title_edit = add_check(QLineEdit()) self.author_edit = add_check(QLineEdit()) author_completer = misc.GCompleter(self, False, True, False) author_completer.setCaseSensitivity(Qt.CaseInsensitive) self.author_edit.setCompleter(author_completer) self.descr_edit = add_check(QTextEdit()) self.descr_edit.setAcceptRichText(True) self.lang_box = add_check(QComboBox()) self.lang_box.addItems(app_constants.G_LANGUAGES) self.lang_box.addItems(app_constants.G_CUSTOM_LANGUAGES) self.rating_box = add_check(QSpinBox()) self.rating_box.setMaximum(5) self.rating_box.setMinimum(0) self._find_combobox_match(self.lang_box, app_constants.G_DEF_LANGUAGE, 0) tags_l = QVBoxLayout() tag_info = misc.ClickedLabel("How do i write namespace & tags? (hover)", parent=self) tag_info.setToolTip("Ways to write tags:\n\nNormal tags:\ntag1, tag2, tag3\n\n"+ "Namespaced tags:\nns1:tag1, ns1:tag2\n\nNamespaced tags with one or more"+ " tags under same namespace:\nns1:[tag1, tag2, tag3], ns2:[tag1, tag2]\n\n"+ "Those three ways of writing namespace & tags can be combined freely.\n"+ "Tags are seperated by a comma, NOT whitespace.\nNamespaces will be capitalized while tags"+ " will be lowercased.") tag_info.setToolTipDuration(99999999) tags_l.addWidget(tag_info) self.tags_edit = add_check(misc.CompleterTextEdit()) self.tags_edit.setCompleter(misc.GCompleter(self, False, False)) self.tags_append = QCheckBox("Append tags", self) self.tags_append.setChecked(False) if not self._multiple_galleries: self.tags_append.hide() if self._multiple_galleries: self.tags_append.setChecked(app_constants.APPEND_TAGS_GALLERIES) tags_ml = QVBoxLayout() tags_ml.addWidget(self.tags_append) tags_ml.addLayout(checkbox_layout(self.tags_edit), 5) tags_l.addLayout(tags_ml, 3) else: tags_l.addWidget(checkbox_layout(self.tags_edit), 5) self.tags_edit.setPlaceholderText("Press Tab to autocomplete (Ctrl + E to show popup)") self.type_box = add_check(QComboBox()) self.type_box.addItems(app_constants.G_TYPES) self._find_combobox_match(self.type_box, app_constants.G_DEF_TYPE, 0) #self.type_box.currentIndexChanged[int].connect(self.doujin_show) #self.doujin_parent = QLineEdit() #self.doujin_parent.setVisible(False) self.status_box = add_check(QComboBox()) self.status_box.addItems(app_constants.G_STATUS) self._find_combobox_match(self.status_box, app_constants.G_DEF_STATUS, 0) self.pub_edit = add_check(QDateEdit()) self.pub_edit.setCalendarPopup(True) self.pub_edit.setDate(QDate.currentDate()) self.path_lbl = misc.ClickedLabel("") self.path_lbl.setWordWrap(True) self.path_lbl.clicked.connect(lambda a: utils.open_path(a, a) if a else None) link_layout = QHBoxLayout() self.link_lbl = add_check(QLabel("")) self.link_lbl.setWordWrap(True) self.link_edit = QLineEdit() link_layout.addWidget(self.link_edit) if self._multiple_galleries: link_layout.addLayout(checkbox_layout(self.link_lbl)) else: link_layout.addWidget(checkbox_layout(self.link_lbl)) self.link_edit.hide() self.link_btn = QPushButton("Modify") self.link_btn.setFixedWidth(50) self.link_btn2 = QPushButton("Set") self.link_btn2.setFixedWidth(40) self.link_btn.clicked.connect(self.link_modify) self.link_btn2.clicked.connect(self.link_set) link_layout.addWidget(self.link_btn) link_layout.addWidget(self.link_btn2) self.link_btn2.hide() rating_ = checkbox_layout(self.rating_box) lang_ = checkbox_layout(self.lang_box) if self._multiple_galleries: rating_.insertWidget(0, QLabel("Rating:")) lang_.addLayout(rating_) lang_l = lang_ else: lang_l = QHBoxLayout() lang_l.addWidget(lang_) lang_l.addWidget(QLabel("Rating:"), 0, Qt.AlignRight) lang_l.addWidget(rating_) gallery_layout.addRow("Title:", checkbox_layout(self.title_edit)) gallery_layout.addRow("Author:", checkbox_layout(self.author_edit)) gallery_layout.addRow("Description:", checkbox_layout(self.descr_edit)) gallery_layout.addRow("Language:", lang_l) gallery_layout.addRow("Tags:", tags_l) gallery_layout.addRow("Type:", checkbox_layout(self.type_box)) gallery_layout.addRow("Status:", checkbox_layout(self.status_box)) gallery_layout.addRow("Publication Date:", checkbox_layout(self.pub_edit)) gallery_layout.addRow("Path:", self.path_lbl) gallery_layout.addRow("URL:", link_layout) self.title_edit.setFocus() def resizeEvent(self, event): self.tags_edit.setFixedHeight(event.size().height()//8) self.descr_edit.setFixedHeight(event.size().height()//12.5) return super().resizeEvent(event) def _find_combobox_match(self, combobox, key, default): f_index = combobox.findText(key, Qt.MatchFixedString) if f_index != -1: combobox.setCurrentIndex(f_index) return True else: combobox.setCurrentIndex(default) return False def setGallery(self, gallery): "To be used for when editing a gallery" if isinstance(gallery, gallerydb.Gallery): self.gallery = gallery if not self._multiple_galleries: self.url_edit.setText(gallery.link) self.title_edit.setText(gallery.title) self.author_edit.setText(gallery.artist) self.descr_edit.setText(gallery.info) self.rating_box.setValue(gallery.rating) self.tags_edit.setText(utils.tag_to_string(gallery.tags)) if not self._find_combobox_match(self.lang_box, gallery.language, 1): self._find_combobox_match(self.lang_box, app_constants.G_DEF_LANGUAGE, 1) if not self._find_combobox_match(self.type_box, gallery.type, 0): self._find_combobox_match(self.type_box, app_constants.G_DEF_TYPE, 0) if not self._find_combobox_match(self.status_box, gallery.status, 0): self._find_combobox_match(self.status_box, app_constants.G_DEF_STATUS, 0) gallery_pub_date = "{}".format(gallery.pub_date).split(' ') try: self.gallery_time = datetime.strptime(gallery_pub_date[1], '%H:%M:%S').time() except IndexError: pass qdate_pub_date = QDate.fromString(gallery_pub_date[0], "yyyy-MM-dd") self.pub_edit.setDate(qdate_pub_date) self.link_lbl.setText(gallery.link) self.path_lbl.setText(gallery.path) elif isinstance(gallery, list): g = gallery[0] if all(map(lambda x: x.title == g.title, gallery)): self.title_edit.setText(g.title) self.title_edit.g_check.setChecked(True) if all(map(lambda x: x.artist == g.artist, gallery)): self.author_edit.setText(g.artist) self.author_edit.g_check.setChecked(True) if all(map(lambda x: x.info == g.info, gallery)): self.descr_edit.setText(g.info) self.descr_edit.g_check.setChecked(True) if all(map(lambda x: x.tags == g.tags, gallery)): self.tags_edit.setText(utils.tag_to_string(g.tags)) self.tags_edit.g_check.setChecked(True) if all(map(lambda x: x.language == g.language, gallery)): if not self._find_combobox_match(self.lang_box, g.language, 1): self._find_combobox_match(self.lang_box, app_constants.G_DEF_LANGUAGE, 1) self.lang_box.g_check.setChecked(True) if all(map(lambda x: x.rating == g.rating, gallery)): self.rating_box.setValue(g.rating) self.rating_box.g_check.setChecked(True) if all(map(lambda x: x.type == g.type, gallery)): if not self._find_combobox_match(self.type_box, g.type, 0): self._find_combobox_match(self.type_box, app_constants.G_DEF_TYPE, 0) self.type_box.g_check.setChecked(True) if all(map(lambda x: x.status == g.status, gallery)): if not self._find_combobox_match(self.status_box, g.status, 0): self._find_combobox_match(self.status_box, app_constants.G_DEF_STATUS, 0) self.status_box.g_check.setChecked(True) if all(map(lambda x: x.pub_date == g.pub_date, gallery)): gallery_pub_date = "{}".format(g.pub_date).split(' ') try: self.gallery_time = datetime.strptime(gallery_pub_date[1], '%H:%M:%S').time() except IndexError: pass qdate_pub_date = QDate.fromString(gallery_pub_date[0], "yyyy-MM-dd") self.pub_edit.setDate(qdate_pub_date) self.pub_edit.g_check.setChecked(True) if all(map(lambda x: x.link == g.link, gallery)): self.link_lbl.setText(g.link) self.link_lbl.g_check.setChecked(True) def newUI(self): f_local = QGroupBox("Directory/Archive") f_local.setCheckable(False) self.main_layout.addWidget(f_local) local_layout = QHBoxLayout() f_local.setLayout(local_layout) choose_folder = QPushButton("From Directory") choose_folder.clicked.connect(lambda: self.choose_dir('f')) local_layout.addWidget(choose_folder) choose_archive = QPushButton("From Archive") choose_archive.clicked.connect(lambda: self.choose_dir('a')) local_layout.addWidget(choose_archive) self.file_exists_lbl = QLabel() local_layout.addWidget(self.file_exists_lbl) self.file_exists_lbl.hide() def choose_dir(self, mode): """ Pass which mode to open the folder explorer in: 'f': directory 'a': files Or pass a predefined path """ self.done.show() self.file_exists_lbl.hide() if mode == 'a': name = QFileDialog.getOpenFileName(self, 'Choose archive', filter=utils.FILE_FILTER) name = name[0] elif mode == 'f': name = QFileDialog.getExistingDirectory(self, 'Choose folder') elif mode: if os.path.exists(mode): name = mode else: return None if not name: return head, tail = os.path.split(name) name = os.path.join(head, tail) parsed = utils.title_parser(tail) self.title_edit.setText(parsed['title']) self.author_edit.setText(parsed['artist']) self.path_lbl.setText(name) if not parsed['language']: parsed['language'] = app_constants.G_DEF_LANGUAGE l_i = self.lang_box.findText(parsed['language']) if l_i != -1: self.lang_box.setCurrentIndex(l_i) if gallerydb.GalleryDB.check_exists(name): self.file_exists_lbl.setText('Gallery already exists.') self.file_exists_lbl.show() # check galleries gs = 1 if name.endswith(utils.ARCHIVE_FILES): gs = len(utils.check_archive(name)) elif os.path.isdir(name): g_dirs, g_archs = utils.recursive_gallery_check(name) gs = len(g_dirs) + len(g_archs) if gs == 0: self.file_exists_lbl.setText('Invalid gallery source.') self.file_exists_lbl.show() self.done.hide() if app_constants.SUBFOLDER_AS_GALLERY: if gs > 1: self.file_exists_lbl.setText('More than one galleries detected in source! Use other methods to add.') self.file_exists_lbl.show() self.done.hide() def check(self): if not self._multiple_galleries: if len(self.title_edit.text()) is 0: self.title_edit.setFocus() self.title_edit.setStyleSheet("border-style:outset;border-width:2px;border-color:red;") return False elif len(self.author_edit.text()) is 0: self.author_edit.setText("Unknown") if len(self.path_lbl.text()) == 0 or self.path_lbl.text() == 'No path specified': self.path_lbl.setStyleSheet("color:red") self.path_lbl.setText('No path specified') return False return True def reject(self): if self.check(): msgbox = QMessageBox() msgbox.setText("Noo oniichan! You were about to add a new gallery.") msgbox.setInformativeText("Do you really want to discard?") msgbox.setStandardButtons(QMessageBox.Yes | QMessageBox.No) msgbox.setDefaultButton(QMessageBox.No) if msgbox.exec() == QMessageBox.Yes: self.close() else: self.close() def web_metadata(self, url, btn_widget, pgr_widget): if not self.path_lbl.text(): return self.link_lbl.setText(url) btn_widget.hide() pgr_widget.show() def status(stat): def do_hide(): try: pgr_widget.hide() btn_widget.show() except RuntimeError: pass if stat: do_hide() else: danger = """QProgressBar::chunk { background: QLinearGradient( x1: 0, y1: 0, x2: 1, y2: 0,stop: 0 #FF0350,stop: 0.4999 #FF0020,stop: 0.5 #FF0019,stop: 1 #FF0000 ); border-bottom-right-radius: 5px; border-bottom-left-radius: 5px; border: .px solid black;}""" pgr_widget.setStyleSheet(danger) QTimer.singleShot(3000, do_hide) def gallery_picker(gallery, title_url_list, q): self.parent_widget._web_metadata_picker(gallery, title_url_list, q, self) try: dummy_gallery = self.make_gallery(self.gallery, False) except AttributeError: dummy_gallery = self.make_gallery(gallerydb.Gallery(), False, True) if not dummy_gallery: status(False) return None dummy_gallery._g_dialog_url = url self._fetch_inst.galleries = [dummy_gallery] self._disconnect() self._fetch_inst.GALLERY_PICKER.connect(gallery_picker) self._fetch_inst.GALLERY_EMITTER.connect(self.set_web_metadata) self._fetch_inst.FINISHED.connect(status) self._fetch_thread.start() def set_web_metadata(self, metadata): assert isinstance(metadata, gallerydb.Gallery) self.link_lbl.setText(metadata.link) self.title_edit.setText(metadata.title) self.author_edit.setText(metadata.artist) tags = "" lang = ['English', 'Japanese'] self._find_combobox_match(self.lang_box, metadata.language, 2) self.tags_edit.setText(utils.tag_to_string(metadata.tags)) pub_string = "{}".format(metadata.pub_date) pub_date = QDate.fromString(pub_string.split()[0], "yyyy-MM-dd") self.pub_edit.setDate(pub_date) self._find_combobox_match(self.type_box, metadata.type, 0) def make_gallery(self, new_gallery, add_to_model=True, new=False): def is_checked(widget): return widget.g_check.isChecked() if self.check(): if is_checked(self.title_edit): new_gallery.title = self.title_edit.text() log_d('Adding gallery title') if is_checked(self.author_edit): new_gallery.artist = self.author_edit.text() log_d('Adding gallery artist') if not self._multiple_galleries: new_gallery.path = self.path_lbl.text() log_d('Adding gallery path') if is_checked(self.descr_edit): new_gallery.info = self.descr_edit.toPlainText() log_d('Adding gallery descr') if is_checked(self.type_box): new_gallery.type = self.type_box.currentText() log_d('Adding gallery type') if is_checked(self.lang_box): new_gallery.language = self.lang_box.currentText() log_d('Adding gallery lang') if is_checked(self.rating_box): new_gallery.rating = self.rating_box.value() log_d('Adding gallery rating') if is_checked(self.status_box): new_gallery.status = self.status_box.currentText() log_d('Adding gallery status') if is_checked(self.tags_edit): if self.tags_append.isChecked(): new_gallery.tags = utils.tag_to_dict(utils.tag_to_string(new_gallery.tags)+","+ self.tags_edit.toPlainText()) else: new_gallery.tags = utils.tag_to_dict(self.tags_edit.toPlainText()) log_d('Adding gallery: tagging to dict') if is_checked(self.pub_edit): qpub_d = self.pub_edit.date().toString("ddMMyyyy") dpub_d = datetime.strptime(qpub_d, "%d%m%Y").date() try: d_t = self.gallery_time except AttributeError: d_t = datetime.now().time().replace(microsecond=0) dpub_d = datetime.combine(dpub_d, d_t) new_gallery.pub_date = dpub_d log_d('Adding gallery pub date') if is_checked(self.link_lbl): new_gallery.link = self.link_lbl.text() log_d('Adding gallery link') if new: if not new_gallery.chapters: log_d('Starting chapters') thread = threading.Thread(target=utils.make_chapters, args=(new_gallery,)) thread.start() thread.join() log_d('Finished chapters') if new and app_constants.MOVE_IMPORTED_GALLERIES: app_constants.OVERRIDE_MONITOR = True new_gallery.move_gallery() if add_to_model: self.parent_widget.default_manga_view.add_gallery(new_gallery, True) log_i('Sent gallery to model') else: if add_to_model: self.parent_widget.default_manga_view.replace_gallery([new_gallery], False) return new_gallery def link_set(self): t = self.link_edit.text() self.link_edit.hide() self.link_lbl.show() self.link_lbl.setText(t) self.link_btn2.hide() self.link_btn.show() def link_modify(self): t = self.link_lbl.text() self.link_lbl.hide() self.link_edit.show() self.link_edit.setText(t) self.link_btn.hide() self.link_btn2.show() def _disconnect(self): try: self._fetch_inst.GALLERY_PICKER.disconnect() self._fetch_inst.GALLERY_EMITTER.disconnect() self._fetch_inst.FINISHED.disconnect() except TypeError: pass def delayed_close(self): if self._fetch_thread.isRunning(): self._fetch_thread.finished.connect(self.close) self.hide() else: self.close() def accept(self): self.make_gallery(gallerydb.Gallery(), new=True) self.delayed_close() def accept_edit(self): gallerydb.execute(database.db.DBBase.begin, True) app_constants.APPEND_TAGS_GALLERIES = self.tags_append.isChecked() settings.set(app_constants.APPEND_TAGS_GALLERIES, 'Application', 'append tags to gallery') for g in self._edit_galleries: self.make_gallery(g) self.delayed_close() gallerydb.execute(database.db.DBBase.end, True) def reject_edit(self): self.delayed_close() ================================================ FILE: version/hplugins.py ================================================ import logging import os import uuid import threading import sys from PyQt5.QtCore import pyqtWrapperType log = logging.getLogger(__name__) log_i = lambda a: None log_d = lambda a: None log_w = lambda a: None log_e = lambda a: None log_c = lambda a: None class PluginError(ValueError): pass class PluginIDError(PluginError): pass class PluginNameError(PluginIDError): pass class PluginMethodError(PluginError): pass class Plugins: "" _connections = [] _plugins = {} _pluginsbyids = {} hooks = {} def register(self, plugin): assert isinstance(plugin, HPluginMeta) self.hooks[plugin.ID] = {} self._plugins[plugin.NAME] = plugin() # TODO: name conflicts? self._pluginsbyids[plugin.ID] = self._plugins[plugin.NAME] def _connectHooks(self): for plugin_name, pluginid, h_name, handler in self._connections: log_i("{}:{} connection to {}:{}".format(plugin_name, handler, pluginid, h_name)) print(self.hooks) try: p = self.hooks[pluginid] except KeyError: log_e("Could not find plugin with plugin id: {}".format(pluginid)) return try: h = p[h_name] except KeyError: log_e("Could not find pluginhook with name: {}".format(h_name)) return h.addHandler(handler, (plugin_name, pluginid)) return True def __getattr__(self, key): try: return self._plugins[key] except KeyError: raise PluginNameError(key) registered = Plugins() class HPluginMeta(pyqtWrapperType): def __init__(cls, name, bases, dct): if not name.endswith("HPlugin"): log_e("Main plugin class should end with name HPlugin") return if not hasattr(cls, "ID"): log_e("ID attribute is missing") return cls.ID = cls.ID.replace('-', '') if not hasattr(cls, "NAME"): log_e("NAME attribute is missing") return if not hasattr(cls, "VERSION"): log_e("VERSION attribute is missing") return if not hasattr(cls, "AUTHOR"): log_e("AUTHOR attribute is missing") return if not hasattr(cls, "DESCRIPTION"): log_e("DESCRIPTION attribute is missing") return try: val = uuid.UUID(cls.ID, version=4) assert val.hex == cls.ID except ValueError: log_e("Invalid plugin id. UUID4 is required.") return except AssertionError: log_e("Invalid plugin id. A valid UUID4 is required.") return if not isinstance(cls.NAME, str): log_e("Plugin name should be a string") return if not isinstance(cls.VERSION, tuple): log_e("Plugin version should be a tuple with 3 integers") return if not isinstance(cls.AUTHOR, str): log_e("Plugin author should be a string") return if not isinstance(cls.DESCRIPTION, str): log_e("Plugin description should be a string") return super().__init__(name, bases, dct) setattr(cls, "connectPlugin", cls.connectPlugin) setattr(cls, "newHook", cls.createHook) setattr(cls, "connectHook", cls.connectHook) setattr(cls, "__getattr__", cls.__getattr__) registered.register(cls) def connectPlugin(cls, pluginid, plugin_name): """ Connect to other plugins Params: pluginid: PluginID of the plugin you want to connect to plugin_name: Name you want to referrer the other plugin as Other methods of other plugins can be used as such: self.plugin_name.method() """ class OtherHPlugin: def __init__(self, pluginid): self._id = pluginid.replace('-', '') def __getattr__(self, key): try: plugin = registered._pluginsbyids[self._id] pluginmethod = getattr(plugin, key, None) if pluginmethod: return pluginmethod else: raise PluginMethodError(key) except KeyError: raise PluginIDError(self._id) setattr(cls, plugin_name, OtherHPlugin(pluginid)) def connectHook(self, pluginid, hook_name, handler): """ Connect to other plugins' hooks Params: pluginid: PluginID of the plugin that has the hook you want to connect to hook_name: Exact name of the hook you want to connect to handler: Your custom method that should be executed when the other plugin uses its hook. """ assert isinstance(pluginid, str) and isinstance(hook_name, str) and callable(handler), "" registered._connections.append((self.NAME, pluginid.replace('-', ''), hook_name, handler)) def createHook(self, hook_name): """ Create hooks that other plugins can extend Params: hook_name: Name of the hook you want to create. Hook will be used as such: self.hook_name() """ assert isinstance(hook_name, str), "" class Hook: _handlers = set() def addHandler(self, handler, pluginfo): self._handlers.add((handler, pluginfo)) def __call__(self, *args, **kwargs): handler_returns = [] for handlers, pluginfo in self._handlers: try: handler_returns.append(handlers(*args, **kwargs)) except Exception as e: raise PluginError("{}:{}".format(pluginfo[0], pluginfo[1])) return handler_returns h = Hook() registered.hooks[self.ID][hook_name] = h def __getattr__(self, key): try: return registered.hooks[self.ID][key] except KeyError: return PluginMethodError(key) #def startConnectionLoop(): # def autoConnectHooks(): # run = True # while run: # run = registered._connectHooks() # auto_t = threading.Thread(target=autoConnectHooks) # auto_t.start() ================================================ FILE: version/io_misc.py ================================================ import logging, os, json, datetime, random, re, queue from watchdog.events import FileSystemEventHandler, DirDeletedEvent from watchdog.observers import Observer from threading import Timer from PyQt5.QtCore import (Qt, QObject, pyqtSignal, QTimer, QSize, QThread) from PyQt5.QtGui import (QPixmap, QIcon, QColor, QTextOption, QKeySequence) from PyQt5.QtWidgets import (QWidget, QHBoxLayout, QVBoxLayout, QLabel, QFrame, QPushButton, QMessageBox, QFileDialog, QScrollArea, QLineEdit, QFormLayout, QGroupBox, QSizePolicy, QTableWidget, QTableWidgetItem, QPlainTextEdit, QShortcut, QMenu, qApp) import app_constants import misc import gallerydb import utils import pewnet import settings import fetch from asm_manager import AsmManager log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical class GalleryDownloaderUrlExtracter(QWidget): url_emit = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent, flags=Qt.Window|Qt.WindowStaysOnTopHint) self.main_layout = QVBoxLayout(self) self.text_area = QPlainTextEdit(self) self.text_area.setPlaceholderText("URLs are seperated by a newline") self.main_layout.addWidget(self.text_area) self.text_area.setWordWrapMode(QTextOption.NoWrap) add_to_queue = QPushButton('Add to queue') add_to_queue.adjustSize() add_to_queue.setFixedWidth(add_to_queue.width()) add_to_queue.clicked.connect(self.add_to_queue) self.main_layout.addWidget(add_to_queue, 0, Qt.AlignRight) self.setWindowIcon(QIcon(app_constants.APP_ICO_PATH)) self.show() def add_to_queue(self): txt = self.text_area.document().toPlainText() urls = txt.split('\n') for u in urls: if u: self.url_emit.emit(u) self.close() class GalleryDownloaderItem(QObject): """ HenItem wrapper """ d_item_ready = pyqtSignal(object) def __init__(self, hitem): super().__init__() assert isinstance(hitem, pewnet.HenItem) self.d_item_ready.connect(self.done) self.item = hitem url = self.item.gallery_url self.profile_item = QTableWidgetItem(self.item.name) self.profile_item.setData(Qt.UserRole+1, hitem) self.profile_item.setToolTip(url) def set_profile(item): self.profile_item.setIcon(QIcon(item.thumb)) self.item.thumb_rdy.connect(set_profile) # status self.status_item = QTableWidgetItem('In queue...') self.status_item.setToolTip(url) def set_finished(item): self.status_item.setText('Finished!') self.d_item_ready.emit(self) self.item.file_rdy.connect(set_finished) # other self.cost_item = QTableWidgetItem(self.item.cost) self.cost_item.setToolTip(url) self.size_item = QTableWidgetItem(self.item.size) self.size_item.setToolTip(url) _type = 'Unknown' if hitem.download_type == app_constants.DOWNLOAD_TYPE_ARCHIVE: _type = 'Archive' if hitem.download_type == app_constants.DOWNLOAD_TYPE_OTHER: _type = 'Other' if hitem.download_type == app_constants.DOWNLOAD_TYPE_TORRENT: _type = 'Torrent' self.type_item = QTableWidgetItem(_type) self.type_item.setToolTip(url) self.status_timer = QTimer() self.status_timer.timeout.connect(self.check_progress) self.status_timer.start(500) def check_progress(self): if self.item.current_state == self.item.DOWNLOADING: btomb = 1048576 self.status_item.setText("{0:.2f}/{1:.2f} MB".format(self.item.current_size/btomb, self.item.total_size/btomb)) self.size_item.setText("{0:.2f} MB".format(self.item.total_size/btomb)) elif self.item.current_state == self.item.CANCELLED: self.status_item.setText("Cancelled!") self.status_timer.stop() def done(self): self.status_timer.stop() if self.item.download_type == app_constants.DOWNLOAD_TYPE_TORRENT: self.status_item.setText("Sent to torrent client!") else: self.status_item.setText("Creating gallery...") class GalleryDownloaderList(QTableWidget): """ """ init_fetch_instance = pyqtSignal(list) def __init__(self, app_inst, parent=None): super().__init__(parent) self.app_inst = app_inst self.setColumnCount(5) self.setIconSize(QSize(50, 100)) self.setAlternatingRowColors(True) self.setEditTriggers(self.NoEditTriggers) self.setFocusPolicy(Qt.NoFocus) v_header = self.verticalHeader() v_header.setSectionResizeMode(v_header.Fixed) v_header.setDefaultSectionSize(100) v_header.hide() self.setDragEnabled(False) self.setShowGrid(True) self.setSelectionBehavior(self.SelectRows) self.setSelectionMode(self.SingleSelection) self.setSortingEnabled(True) palette = self.palette() palette.setColor(palette.Highlight, QColor(88, 88, 88, 70)) palette.setColor(palette.HighlightedText, QColor('black')) self.setPalette(palette) self.setHorizontalHeaderLabels( [' ', 'Status', 'Size', 'Cost', 'Type']) self.horizontalHeader().setStretchLastSection(True) self.horizontalHeader().setSectionResizeMode(0, self.horizontalHeader().Stretch) self.horizontalHeader().setSectionResizeMode(1, self.horizontalHeader().ResizeToContents) self.horizontalHeader().setSectionResizeMode(2, self.horizontalHeader().ResizeToContents) self.horizontalHeader().setSectionResizeMode(3, self.horizontalHeader().ResizeToContents) self.horizontalHeader().setSectionResizeMode(4, self.horizontalHeader().ResizeToContents) self._finish_checker = QTimer(self) self._finish_checker.timeout.connect(self._gallery_to_model) self._finish_checker.start(2000) self._download_items = {} self.fetch_instance = fetch.Fetch() self.fetch_instance._to_queue_container = True self.fetch_instance.moveToThread(app_constants.GENERAL_THREAD) self.init_fetch_instance.connect(self.fetch_instance.local) def open_item(idx): hitem = self._get_hitem(idx) if hitem.current_state == hitem.DOWNLOADING: hitem.open(True) self.doubleClicked.connect(open_item) def add_entry(self, hitem): assert isinstance(hitem, pewnet.HenItem) g_item = GalleryDownloaderItem(hitem) if not hitem.download_type == app_constants.DOWNLOAD_TYPE_TORRENT: g_item.d_item_ready.connect(self._init_gallery) self.insertRow(0) self.setSortingEnabled(False) self.setItem(0, 0, g_item.profile_item) self.setItem(0, 1, g_item.status_item) self.setItem(0, 2, g_item.size_item) self.setItem(0, 3, g_item.cost_item) self.setItem(0, 4, g_item.type_item) self.setSortingEnabled(True) def _get_hitem(self, idx): r = idx.row() return self.item(r, 0).data(Qt.UserRole+1) def contextMenuEvent(self, event): idx = self.indexAt(event.pos()) if idx.isValid(): hitem = self._get_hitem(idx) clipboard = qApp.clipboard() menu = QMenu() if hitem.current_state == hitem.DOWNLOADING: menu.addAction("Cancel", hitem.cancel) if hitem.current_state == hitem.FINISHED: menu.addAction("Open", hitem.open) menu.addAction("Show in folder", lambda: hitem.open(True)) menu.addAction("Copy path", lambda: clipboard.setText(hitem.file)) menu.addAction("Copy gallery URL", lambda: clipboard.setText(hitem.gallery_url)) menu.addAction("Copy download URL", lambda: clipboard.setText(hitem.download_url)) if not hitem.current_state == hitem.DOWNLOADING: menu.addAction("Remove", lambda: self.removeRow(idx.row())) menu.exec_(event.globalPos()) event.accept() del menu else: event.ignore() def _init_gallery(self, download_item): """Init gallery. Args: download_item(:class:`.gallery_downloader_item_obj.GalleryDownloaderItemObject`): Downloaded item. """ assert isinstance(download_item, GalleryDownloaderItem) # NOTE: try to use ehen's apply_metadata first # manager have to edit item.metadata to match this method file = download_item.item.file app_constants.TEMP_PATH_IGNORE.append(os.path.normcase(file)) self._download_items[file] = download_item self._download_items[utils.move_files(file, only_path=True)] = download_item # better safe than sorry if download_item.item.download_type == app_constants.DOWNLOAD_TYPE_OTHER: pass # do stuff here? self.init_fetch_instance.emit([file]) def _gallery_to_model(self): try: gallery = self.fetch_instance._galleries_queue.get_nowait() except queue.Empty: return log_i("Adding downloaded gallery to library") try: d_item = self._download_items[gallery.path] gallery.link = d_item.item.gallery_url if d_item.item.metadata: gallery = pewnet.EHen.apply_metadata(gallery, d_item.item.metadata) if app_constants.DOWNLOAD_GALLERY_TO_LIB: self.app_inst.default_manga_view.add_gallery(gallery, True) d_item.status_item.setText('Added to library!') log_i("Added downloaded gallery to library") else: self.app_inst.addition_tab.view.add_gallery(gallery, True) d_item.status_item.setText('Added to inbox!') log_i("Added downloaded gallery to inbox") except KeyError: d_item.status_item.setText('Gallery could not be added!') log_i("Could not add downloaded gallery to library") def clear_list(self): for r in range(self.rowCount()-1, -1, -1): status = self.item(r, 1) if '!' in status.text(): self.removeRow(r) class GalleryDownloader(QWidget): """ A gallery downloader window """ def __init__(self, parent): super().__init__(None, )#Qt.CustomizeWindowHint | Qt.WindowTitleHint | Qt.WindowMinMaxButtonsHint) self.setAttribute(Qt.WA_DeleteOnClose, False) main_layout = QVBoxLayout(self) self.parent_widget = parent self.url_inserter = QLineEdit() self.url_inserter.setPlaceholderText("Hover to see supported URLs") self.url_inserter.setToolTip(app_constants.SUPPORTED_DOWNLOAD_URLS) self.url_inserter.setToolTipDuration(999999999) self.url_inserter.returnPressed.connect(self.add_download_entry) main_layout.addWidget(self.url_inserter) self.info_lbl = QLabel(self) self.info_lbl.setAlignment(Qt.AlignCenter) main_layout.addWidget(self.info_lbl) self.info_lbl.hide() buttons_layout = QHBoxLayout() url_window_btn = QPushButton('Batch URLs') url_window_btn.adjustSize() url_window_btn.setFixedWidth(url_window_btn.width()) self._urls_queue = [] def batch_url_win(): self._batch_url = GalleryDownloaderUrlExtracter() self._batch_url.url_emit.connect(lambda u: self._urls_queue.append(u)) self._batch_url.url_emit.connect(lambda u: self.info_lbl.setText("Adding URLs to queue...") if u else None) url_window_btn.clicked.connect(batch_url_win) clear_all_btn = QPushButton('Clear List') clear_all_btn.adjustSize() clear_all_btn.setFixedWidth(clear_all_btn.width()) buttons_layout.addWidget(url_window_btn, 0, Qt.AlignLeft) buttons_layout.addWidget(clear_all_btn, 0, Qt.AlignRight) main_layout.addLayout(buttons_layout) self.download_list = GalleryDownloaderList(parent, self) clear_all_btn.clicked.connect(self.download_list.clear_list) download_list_scroll = QScrollArea(self) download_list_scroll.setBackgroundRole(self.palette().Base) download_list_scroll.setWidgetResizable(True) download_list_scroll.setWidget(self.download_list) main_layout.addWidget(download_list_scroll, 1) self.resize(480,600) self.setWindowIcon(QIcon(app_constants.APP_ICO_PATH)) self._url_checker = QTimer(self) self._url_checker.timeout.connect(lambda: self.add_download_entry(extractor=True)) self._url_checker.start(500) def add_download_entry(self, url=None, extractor=False): if extractor: try: url = self._urls_queue.pop(0) except IndexError: return self.info_lbl.hide() h_item = None try: if not url: url = self.url_inserter.text() if not url: return self.url_inserter.clear() url = url.lower() log_i('Adding download entry: {}'.format(url)) manager = self.website_validator(url) if isinstance(manager, pewnet.HenManager): url = pewnet.HenManager.gtoEh(url) h_item = manager.from_gallery_url(url) except app_constants.WrongURL: self.info_lbl.setText("Failed to add:\n{}".format(url)) self.info_lbl.show() return except app_constants.NeedLogin: self.info_lbl.setText("Login is required to download:\n{}".format(url)) self.info_lbl.show() return except app_constants.HTMLParsing: self.info_lbl.setText("HTML parsing error:\n{}".format(url)) self.info_lbl.show() return except app_constants.WrongLogin: self.info_lbl.setText("Wrong login info to download:\n{}".format(url)) self.info_lbl.show() return except app_constants.GNotAvailable: self.info_lbl.setText("Gallery has been removed:\n{}".format(url)) self.info_lbl.show() return if h_item: log_i('Successfully added to download entry: {}'.format(h_item.gallery_name if h_item.gallery_name else 'an item')) self.download_list.add_entry(h_item) def website_validator(self, url): match_prefix = "^(http\:\/\/|https\:\/\/)?(www\.)?([^\.]?)" # http:// or https:// + www. match_base = "(.*\.)+" # base. Replace with domain match_tld = "[a-zA-Z0-9][a-zA-Z0-9\-]*" # com end = "/?$" # ATTENTION: the prefix will automatically get prepended to the pattern string! Don't try to match it. def regex_validate(r): if re.fullmatch(match_prefix+r+end, url): return True return False if regex_validate("((g\.e-hentai)\.org\/g\/[0-9]+\/[a-z0-9]+)"): manager = pewnet.HenManager() elif regex_validate("((?. #""" import sys, logging, logging.handlers, os, argparse, platform, scandir import traceback from PyQt5.QtWidgets import QApplication from PyQt5.QtCore import QFile, Qt from PyQt5.QtGui import QFontDatabase from database import db, db_constants import app import app_constants import gallerydb import utils #IMPORTANT STUFF def start(test=False): app_constants.APP_RESTART_CODE = -123456789 if os.name == 'posix': main_path = os.path.dirname(os.path.realpath(__file__)) log_path = os.path.join(main_path, 'happypanda.log') debug_log_path = os.path.join(main_path, 'happypanda_debug.log') else: log_path = 'happypanda.log' debug_log_path = 'happypanda_debug.log' if os.path.exists('cacert.pem'): os.environ["REQUESTS_CA_BUNDLE"] = os.path.join(os.getcwd(), "cacert.pem") parser = argparse.ArgumentParser(prog='Happypanda', description='A manga/doujinshi manager with tagging support') parser.add_argument('-d', '--debug', action='store_true', help='happypanda_debug_log.log will be created in main directory') parser.add_argument('-v', '--version', action='version', version='Happypanda v{}'.format(app_constants.vs)) parser.add_argument('-e', '--exceptions', action='store_true', help='Disable custom excepthook') parser.add_argument('-x', '--dev', action='store_true', help='Development Switch') args = parser.parse_args() log_handlers = [] log_level = logging.INFO if args.dev: log_handlers.append(logging.StreamHandler()) if args.debug: print("happypanda_debug.log created at {}".format(os.getcwd())) # create log try: with open(debug_log_path, 'x') as f: pass except FileExistsError: pass log_handlers.append(logging.FileHandler(debug_log_path, 'w', 'utf-8')) log_level = logging.DEBUG app_constants.DEBUG = True else: try: with open(log_path, 'x') as f: pass except FileExistsError: pass log_handlers.append(logging.handlers.RotatingFileHandler( log_path, maxBytes=1000000*10, encoding='utf-8', backupCount=2)) # Fix for logging not working # clear the handlers first before adding these custom handler # http://stackoverflow.com/a/15167862 logging.getLogger('').handlers = [] logging.basicConfig(level=log_level, format='%(asctime)-8s %(levelname)-6s %(name)-6s %(message)s', datefmt='%d-%m %H:%M', handlers=tuple(log_handlers)) log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical if not args.exceptions: def uncaught_exceptions(ex_type, ex, tb): log_c(''.join(traceback.format_tb(tb))) log_c('{}: {}'.format(ex_type, ex)) traceback.print_exception(ex_type, ex, tb) sys.excepthook = uncaught_exceptions if app_constants.FORCE_HIGH_DPI_SUPPORT: log_i("Enabling high DPI display support") os.environ.putenv("QT_DEVICE_PIXEL_RATIO", "auto") effects = [Qt.UI_AnimateCombo, Qt.UI_FadeMenu, Qt.UI_AnimateMenu, Qt.UI_AnimateTooltip, Qt.UI_FadeTooltip] for effect in effects: QApplication.setEffectEnabled(effect) application = QApplication(sys.argv) application.setOrganizationName('Pewpews') application.setOrganizationDomain('https://github.com/Pewpews/happypanda') application.setApplicationName('Happypanda') application.setApplicationDisplayName('Happypanda') application.setApplicationVersion('v{}'.format(app_constants.vs)) application.setAttribute(Qt.AA_UseHighDpiPixmaps) application.font().setStyleStrategy(application.font().PreferAntialias) log_i('Starting Happypanda...'.format(app_constants.vs)) if args.debug: log_i('Running in debug mode'.format(app_constants.vs)) import pprint sys.displayhook = pprint.pprint app_constants.load_icons() log_i('Happypanda Version {}'.format(app_constants.vs)) log_i('OS: {} {}\n'.format(platform.system(), platform.release())) conn = None try: conn = db.init_db() log_d('Init DB Conn: OK') log_i("DB Version: {}".format(db_constants.REAL_DB_VERSION)) except: log_c('Invalid database') log.exception('Database connection failed!') from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QMessageBox msg_box = QMessageBox() msg_box.setWindowIcon(QIcon(app_constants.APP_ICO_PATH)) msg_box.setText('Invalid database') msg_box.setInformativeText("Do you want to create a new database?") msg_box.setIcon(QMessageBox.Critical) msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) msg_box.setDefaultButton(QMessageBox.Yes) if msg_box.exec() == QMessageBox.Yes: pass else: application.exit() log_d('Normal Exit App: OK') sys.exit() def start_main_window(conn): db.DBBase._DB_CONN = conn #if args.test: # import threading, time # ser_list = [] # for x in range(5000): # s = gallerydb.gallery() # s.profile = app_constants.NO_IMAGE_PATH # s.title = 'Test {}'.format(x) # s.artist = 'Author {}'.format(x) # s.path = app_constants.static_dir # s.type = 'Test' # s.language = 'English' # s.info = 'I am number {}'.format(x) # ser_list.append(s) # done = False # thread_list = [] # i = 0 # while not done: # try: # if threading.active_count() > 5000: # thread_list = [] # done = True # else: # thread_list.append( # threading.Thread(target=gallerydb.galleryDB.add_gallery, # args=(ser_list[i],))) # thread_list[i].start() # i += 1 # print(i) # print('Threads running: {}'.format(threading.activeCount())) # except IndexError: # done = True WINDOW = app.AppWindow(args.exceptions) # styling d_style = app_constants.default_stylesheet_path u_style = app_constants.user_stylesheet_path if len(u_style) is not 0: try: style_file = QFile(u_style) log_i('Select userstyle: OK') except: style_file = QFile(d_style) log_i('Select defaultstyle: OK') else: style_file = QFile(d_style) log_i('Select defaultstyle: OK') style_file.open(QFile.ReadOnly) style = str(style_file.readAll(), 'utf-8') application.setStyleSheet(style) try: os.mkdir(app_constants.temp_dir) except FileExistsError: try: for root, dirs, files in scandir.walk('temp', topdown=False): for name in files: os.remove(os.path.join(root, name)) for name in dirs: os.rmdir(os.path.join(root, name)) except: log.exception("Empty temp: FAIL") log_d('Create temp: OK') if test: return application, WINDOW return application.exec_() def db_upgrade(): log_d('Database connection failed') from PyQt5.QtGui import QIcon from PyQt5.QtWidgets import QMessageBox msg_box = QMessageBox() msg_box.setWindowIcon(QIcon(app_constants.APP_ICO_PATH)) msg_box.setText('Incompatible database!') msg_box.setInformativeText("Do you want to upgrade to newest version?" + " It shouldn't take more than a second. Don't start a new instance!") msg_box.setIcon(QMessageBox.Critical) msg_box.setStandardButtons(QMessageBox.Yes | QMessageBox.No) msg_box.setDefaultButton(QMessageBox.Yes) if msg_box.exec() == QMessageBox.Yes: utils.backup_database() import threading db_p = db_constants.DB_PATH db.add_db_revisions(db_p) conn = db.init_db() return start_main_window(conn) else: application.exit() log_d('Normal Exit App: OK') return 0 if conn: return start_main_window(conn) else: return db_upgrade() if __name__ == '__main__': current_exit_code = 0 while current_exit_code == app_constants.APP_RESTART_CODE: current_exit_code = start() sys.exit(current_exit_code) ================================================ FILE: version/misc.py ================================================ #""" #This file is part of Happypanda. #Happypanda is free software: you can redistribute it and/or modify #it under the terms of the GNU General Public License as published by #the Free Software Foundation, either version 2 of the License, or #any later version. #Happypanda is distributed in the hope that it will be useful, #but WITHOUT ANY WARRANTY; without even the implied warranty of #MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #GNU General Public License for more details. #You should have received a copy of the GNU General Public License #along with Happypanda. If not, see . #""" import os import threading import queue import time import logging import math import random import functools import scandir from datetime import datetime from PyQt5.QtCore import (Qt, QDate, QPoint, pyqtSignal, QThread, QTimer, QObject, QSize, QRect, QFileInfo, QMargins, QPropertyAnimation, QRectF, QTimeLine, QMargins, QPropertyAnimation, QByteArray, QPointF, QSizeF, QProcess) from PyQt5.QtGui import (QTextCursor, QIcon, QMouseEvent, QFont, QPixmapCache, QPalette, QPainter, QBrush, QColor, QPen, QPixmap, QMovie, QPaintEvent, QFontMetrics, QPolygonF, QRegion, QCursor, QTextOption, QTextLayout, QPalette) from PyQt5.QtWidgets import (QWidget, QProgressBar, QLabel, QVBoxLayout, QHBoxLayout, QDialog, QGridLayout, QLineEdit, QFormLayout, QPushButton, QTextEdit, QComboBox, QDateEdit, QGroupBox, QDesktopWidget, QMessageBox, QFileDialog, QCompleter, QListWidgetItem, QListWidget, QApplication, QSizePolicy, QCheckBox, QFrame, QListView, QAbstractItemView, QTreeView, QSpinBox, QAction, QStackedLayout, QTabWidget, QGridLayout, QScrollArea, QLayout, QButtonGroup, QRadioButton, QFileIconProvider, QFontDialog, QColorDialog, QScrollArea, QSystemTrayIcon, QMenu, QGraphicsBlurEffect, QActionGroup, QCommonStyle, QApplication, QTableWidget, QTableWidgetItem, QTableView, QSplitter, QSplitterHandle, QStyledItemDelegate, QStyleOption) from utils import (tag_to_string, tag_to_dict, title_parser, ARCHIVE_FILES, ArchiveFile, IMG_FILES) from executors import Executors import utils import app_constants import gallerydb import fetch import settings log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical def text_layout(text, width, font, font_metrics, alignment=Qt.AlignCenter): "Lays out wrapped text" text_option = QTextOption(alignment) text_option.setUseDesignMetrics(True) text_option.setWrapMode(QTextOption.WordWrap) layout = QTextLayout(text, font) layout.setTextOption(text_option) leading = font_metrics.leading() height = 0 layout.setCacheEnabled(True) layout.beginLayout() while True: line = layout.createLine() if not line.isValid(): break line.setLineWidth(width) height += leading line.setPosition(QPointF(0, height)) height += line.height() layout.endLayout() return layout def centerWidget(widget, parent_widget=None): if parent_widget: r = parent_widget.rect() else: r = QDesktopWidget().availableGeometry() widget.setGeometry(QCommonStyle.alignedRect(Qt.LeftToRight, Qt.AlignCenter, widget.size(), r)) def clearLayout(layout): if layout != None: while layout.count(): child = layout.takeAt(0) if child.widget() is not None: child.widget().deleteLater() elif child.layout() is not None: clearLayout(child.layout()) def create_animation(parent, prop): p_array = QByteArray().append(prop) return QPropertyAnimation(parent, p_array) class ArrowHandle(QWidget): "Arrow Handle" IN, OUT = range(2) CLICKED = pyqtSignal(int) def __init__(self, parent): super().__init__(parent) self.parent_widget = parent self.current_arrow = self.IN self.arrow_height = 20 self.setFixedWidth(10) self.setCursor(Qt.PointingHandCursor) def paintEvent(self, event): rect = self.rect() x, y, w, h = rect.getRect() painter = QPainter(self) painter.setPen(QColor("white")) painter.setBrush(QBrush(QColor(0,0,0,100))) painter.fillRect(rect, QColor(0,0,0,100)) arrow_points = [] # for horizontal if self.current_arrow == self.IN: arrow_1 = QPointF(x + w, h / 2 - self.arrow_height / 2) middle_point = QPointF(x, h / 2) arrow_2 = QPointF(x + w, h / 2 + self.arrow_height / 2) else: arrow_1 = QPointF(x, h / 2 - self.arrow_height / 2) middle_point = QPointF(x + w, h / 2) arrow_2 = QPointF(x, h / 2 + self.arrow_height / 2) arrow_points.append(arrow_1) arrow_points.append(middle_point) arrow_points.append(arrow_2) painter.drawPolygon(QPolygonF(arrow_points)) def click(self): if self.current_arrow == self.IN: self.current_arrow = self.OUT self.CLICKED.emit(1) else: self.current_arrow = self.IN self.CLICKED.emit(0) self.update() def mousePressEvent(self, event): if event.button() == Qt.LeftButton: self.click() return super().mousePressEvent(event) class Line(QFrame): "'v' for vertical line or 'h' for horizontail line, color is hex string" def __init__(self, orentiation, parent=None): super().__init__(parent) self.setFrameStyle(self.StyledPanel) if orentiation == 'v': self.setFrameShape(self.VLine) else: self.setFrameShape(self.HLine) self.setFrameShadow(self.Sunken) class CompleterPopupView(QListView): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def _setup(self): self.fade_animation = create_animation(self, 'windowOpacity') self.fade_animation.setDuration(200) self.fade_animation.setStartValue(0.0) self.fade_animation.setEndValue(1.0) self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setFrameStyle(self.StyledPanel) def showEvent(self, event): self.setWindowOpacity(0) self.fade_animation.start() super().showEvent(event) class ElidedLabel(QLabel): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) def paintEvent(self, event): painter = QPainter(self) metrics = QFontMetrics(self.font()) elided = metrics.elidedText(self.text(), Qt.ElideRight, self.width()) painter.drawText(self.rect(), self.alignment(), elided) class BaseMoveWidget(QWidget): def __init__(self, parent=None, **kwargs): move_listener = kwargs.pop('move_listener', True) super().__init__(parent, **kwargs) self.parent_widget = parent self.setAttribute(Qt.WA_DeleteOnClose) if parent and move_listener: try: parent.move_listener.connect(self.update_move) except AttributeError: pass def update_move(self, new_size=None): if new_size: self.move(new_size) return if self.parent_widget: self.move(self.parent_widget.window().frameGeometry().center() - \ self.window().rect().center()) class SortMenu(QMenu): new_sort = pyqtSignal(str) def __init__(self, app_inst, parent=None, toolbutton=None): super().__init__(parent) self.parent_widget = app_inst self.toolbutton = toolbutton self.sort_actions = QActionGroup(self, exclusive=True) asc_desc_act = QAction("Asc/Desc", self) asc_desc_act.triggered.connect(self.asc_desc) s_title = self.sort_actions.addAction(QAction("Title", self.sort_actions, checkable=True)) s_title.triggered.connect(functools.partial(self.new_sort.emit, 'title')) s_artist = self.sort_actions.addAction(QAction("Author", self.sort_actions, checkable=True)) s_artist.triggered.connect(functools.partial(self.new_sort.emit, 'artist')) s_date = self.sort_actions.addAction(QAction("Date Added", self.sort_actions, checkable=True)) s_date.triggered.connect(functools.partial(self.new_sort.emit, 'date_added')) s_pub_d = self.sort_actions.addAction(QAction("Date Published", self.sort_actions, checkable=True)) s_pub_d.triggered.connect(functools.partial(self.new_sort.emit, 'pub_date')) s_times_read = self.sort_actions.addAction(QAction("Read Count", self.sort_actions, checkable=True)) s_times_read.triggered.connect(functools.partial(self.new_sort.emit, 'times_read')) s_last_read = self.sort_actions.addAction(QAction("Last Read", self.sort_actions, checkable=True)) s_last_read.triggered.connect(functools.partial(self.new_sort.emit, 'last_read')) s_rating = self.sort_actions.addAction(QAction("Rating", self.sort_actions, checkable=True)) s_rating.triggered.connect(functools.partial(self.new_sort.emit, 'rating')) self.addAction(asc_desc_act) self.addSeparator() self.addAction(s_artist) self.addAction(s_date) self.addAction(s_pub_d) self.addAction(s_last_read) self.addAction(s_title) self.addAction(s_rating) self.addAction(s_times_read) self.set_current_sort() def set_toolbutton_text(self): act = self.sort_actions.checkedAction() if self.toolbutton: self.toolbutton.setText(act.text()) def set_current_sort(self): def check_key(act, key): if self.parent_widget.current_manga_view.list_view.current_sort == key: act.setChecked(True) for act in self.sort_actions.actions(): if act.text() == 'Title': check_key(act, 'title') elif act.text() == 'Artist': check_key(act, 'artist') elif act.text() == 'Date Added': check_key(act, 'date_added') elif act.text() == 'Date Published': check_key(act, 'pub_date') elif act.text() == 'Read Count': check_key(act, 'times_read') elif act.text() == 'Last Read': check_key(act, 'last_read') elif act.text() == 'Rating': check_key(act, 'rating') def asc_desc(self): if self.parent_widget.current_manga_view.sort_model.sortOrder() == Qt.AscendingOrder: if self.toolbutton: self.toolbutton.setIcon(app_constants.SORT_ICON_DESC) self.parent_widget.current_manga_view.sort_model.sort(0, Qt.DescendingOrder) else: if self.toolbutton: self.toolbutton.setIcon(app_constants.SORT_ICON_ASC) self.parent_widget.current_manga_view.sort_model.sort(0, Qt.AscendingOrder) def showEvent(self, event): self.set_current_sort() super().showEvent(event) class ToolbarButton(QPushButton): select = pyqtSignal(object) close_tab = pyqtSignal(object) def __init__(self, parent=None, txt=''): super().__init__(parent) self.setText(txt) self._selected = False self.clicked.connect(lambda: self.select.emit(self)) self._enable_contextmenu = True @property def selected(self): return self._selected @selected.setter def selected(self, b): self._selected = b def contextMenuEvent(self, event): if self._enable_contextmenu: m = QMenu(self) m.addAction("Close Tab").triggered.connect(lambda: self.close_tab.emit(self)) m.exec_(event.globalPos()) event.accept() else: event.ignore() class TransparentWidget(BaseMoveWidget): def __init__(self, parent = None, **kwargs): super().__init__(parent, **kwargs) self.setAttribute(Qt.WA_TranslucentBackground) class ArrowWindow(TransparentWidget): LEFT, RIGHT, TOP, BOTTOM = range(4) def __init__(self, parent): super().__init__(parent, flags=Qt.Window | Qt.FramelessWindowHint, move_listener=False) self.setAttribute(Qt.WA_ShowWithoutActivating) self.resize(550,300) self.direction = self.LEFT self._arrow_size = QSizeF(30, 30) self.content_margin = 0 @property def arrow_size(self): return self._arrow_size @arrow_size.setter def arrow_size(self, w_h_tuple): "a tuple of width and height" if not isinstance(w_h_tuple, (tuple, list)) or len(w_h_tuple) != 2: return if self.direction in (self.LEFT, self.RIGHT): s = QSizeF(w_h_tuple[1], w_h_tuple[0]) else: s = QSizeF(w_h_tuple[0], w_h_tuple[1]) self._arrow_size = s self.update() def paintEvent(self, event): assert isinstance(event, QPaintEvent) opt = QStyleOption() opt.initFrom(self) painter = QPainter(self) painter.setRenderHint(painter.Antialiasing) size = self.size() if self.direction in (self.LEFT, self.RIGHT): actual_size = QSizeF(size.width() - self.arrow_size.width(), size.height()) else: actual_size = QSizeF(size.width(), size.height() - self.arrow_size.height()) starting_point = QPointF(0, 0) if self.direction == self.LEFT: starting_point = QPointF(self.arrow_size.width(), 0) elif self.direction == self.TOP: starting_point = QPointF(0, self.arrow_size.height()) #painter.save() #painter.translate(starting_point) self.style().drawPrimitive(QCommonStyle.PE_Widget, opt, painter, self) #painter.restore() painter.setBrush(QBrush(painter.pen().color())) # draw background background_rect = QRectF(starting_point, actual_size) #painter.drawRoundedRect(background_rect, 5, 5) # calculate the arrow arrow_points = [] if self.direction == self.LEFT: middle_point = QPointF(0, actual_size.height() / 2) arrow_1 = QPointF(self.arrow_size.width(), middle_point.y() - self.arrow_size.height() / 2) arrow_2 = QPointF(self.arrow_size.width(), middle_point.y() + self.arrow_size.height() / 2) arrow_points.append(arrow_1) arrow_points.append(middle_point) arrow_points.append(arrow_2) elif self.direction == self.RIGHT: middle_point = QPointF(actual_size.width() + self.arrow_size.width(), actual_size.height() / 2) arrow_1 = QPointF(actual_size.width(), middle_point.y() + self.arrow_size.height() / 2) arrow_2 = QPointF(actual_size.width(), middle_point.y() - self.arrow_size.height() / 2) arrow_points.append(arrow_1) arrow_points.append(middle_point) arrow_points.append(arrow_2) elif self.direction == self.TOP: middle_point = QPointF(actual_size.width() / 2, 0) arrow_1 = QPointF(actual_size.width() / 2 + self.arrow_size.width() / 2, self.arrow_size.height()) arrow_2 = QPointF(actual_size.width() / 2 - self.arrow_size.width() / 2, self.arrow_size.height()) arrow_points.append(arrow_1) arrow_points.append(middle_point) arrow_points.append(arrow_2) elif self.direction == self.BOTTOM: middle_point = QPointF(actual_size.width() / 2, actual_size.height() + self.arrow_size.height()) arrow_1 = QPointF(actual_size.width() / 2 - self.arrow_size.width() / 2, actual_size.height()) arrow_2 = QPointF(actual_size.width() / 2 + self.arrow_size.width() / 2, actual_size.height()) arrow_points.append(arrow_1) arrow_points.append(middle_point) arrow_points.append(arrow_2) # draw it! painter.drawPolygon(QPolygonF(arrow_points)) class GalleryMetaWindow(ArrowWindow): def __init__(self, parent): super().__init__(parent) # gallery data stuff self.content_margin = 10 self.current_gallery = None self.g_widget = self.GalleryLayout(self, parent) self.hide_timer = QTimer() self.hide_timer.timeout.connect(self.delayed_hide) self.hide_timer.setSingleShot(True) self.hide_animation = create_animation(self, 'windowOpacity') self.hide_animation.setDuration(250) self.hide_animation.setStartValue(1.0) self.hide_animation.setEndValue(0.0) self.hide_animation.finished.connect(self.hide) self.show_animation = create_animation(self, 'windowOpacity') self.show_animation.setDuration(350) self.show_animation.setStartValue(0.0) self.show_animation.setEndValue(1.0) self.setFocusPolicy(Qt.NoFocus) self.setAttribute(Qt.WA_ShowWithoutActivating) def show(self): if not self.hide_animation.Running: self.setWindowOpacity(0) super().show() self.show_animation.start() else: self.hide_animation.stop() super().show() self.show_animation.setStartValue(self.windowOpacity()) self.show_animation.start() def focusOutEvent(self, event): self.delayed_hide() return super().focusOutEvent(event) def _mouse_in_gallery(self): mouse_p = QCursor.pos() h = self.idx_top_l.x() <= mouse_p.x() <= self.idx_top_r.x() v = self.idx_top_l.y() <= mouse_p.y() <= self.idx_btm_l.y() if h and v: return True return False def mouseMoveEvent(self, event): if self.isVisible(): if not self._mouse_in_gallery(): if not self.hide_timer.isActive(): self.hide_timer.start(300) return super().mouseMoveEvent(event) def delayed_hide(self): if not self.underMouse() and not self._mouse_in_gallery(): self.hide_animation.start() def show_gallery(self, index, view): self.resize(app_constants.POPUP_WIDTH, app_constants.POPUP_HEIGHT) self.view = view desktop_w = QDesktopWidget().width() desktop_h = QDesktopWidget().height() margin_offset = 20 # should be higher than gallery_touch_offset gallery_touch_offset = 10 # How far away the window is from touching gallery index_rect = view.visualRect(index) self.idx_top_l = index_top_left = view.mapToGlobal(index_rect.topLeft()) self.idx_top_r = index_top_right = view.mapToGlobal(index_rect.topRight()) self.idx_btm_l = index_btm_left = view.mapToGlobal(index_rect.bottomLeft()) index_btm_right = view.mapToGlobal(index_rect.bottomRight()) if app_constants.DEBUG: for idx in (index_top_left, index_top_right, index_btm_left, index_btm_right): print(idx.x(), idx.y()) # adjust placement def check_left(): middle = (index_top_left.y() + index_btm_left.y()) / 2 # middle of gallery left side left = (index_top_left.x() - self.width() - margin_offset) > 0 # if the width can be there top = (middle - (self.height() / 2) - margin_offset) > 0 # if the top half of window can be there btm = (middle + (self.height() / 2) + margin_offset) < desktop_h # same as above, just for the bottom if left and top and btm: self.direction = self.RIGHT x = index_top_left.x() - gallery_touch_offset - self.width() y = middle - (self.height() / 2) appear_point = QPoint(int(x), int(y)) self.move(appear_point) return True return False def check_right(): middle = (index_top_right.y() + index_btm_right.y()) / 2 # middle of gallery right side right = (index_top_right.x() + self.width() + margin_offset) < desktop_w # if the width can be there top = (middle - (self.height() / 2) - margin_offset) > 0 # if the top half of window can be there btm = (middle + (self.height() / 2) + margin_offset) < desktop_h # same as above, just for the bottom if right and top and btm: self.direction = self.LEFT x = index_top_right.x() + gallery_touch_offset y = middle - (self.height() / 2) appear_point = QPoint(int(x), int(y)) self.move(appear_point) return True return False def check_top(): middle = (index_top_left.x() + index_top_right.x()) / 2 # middle of gallery top side top = (index_top_right.y() - self.height() - margin_offset) > 0 # if the height can be there left = (middle - (self.width() / 2) - margin_offset) > 0 # if the left half of window can be there right = (middle + (self.width() / 2) + margin_offset) < desktop_w # same as above, just for the right if top and left and right: self.direction = self.BOTTOM x = middle - (self.width() / 2) y = index_top_left.y() - gallery_touch_offset - self.height() appear_point = QPoint(int(x), int(y)) self.move(appear_point) return True return False def check_bottom(override=False): middle = (index_btm_left.x() + index_btm_right.x()) / 2 # middle of gallery bottom side btm = (index_btm_right.y() + self.height() + margin_offset) < desktop_h # if the height can be there left = (middle - (self.width() / 2) - margin_offset) > 0 # if the left half of window can be there right = (middle + (self.width() / 2) + margin_offset) < desktop_w # same as above, just for the right if (btm and left and right) or override: self.direction = self.TOP x = middle - (self.width() / 2) y = index_btm_left.y() + gallery_touch_offset appear_point = QPoint(int(x), int(y)) self.move(appear_point) return True return False for pos in (check_bottom, check_right, check_left, check_top): if pos(): break else: # default pos is bottom check_bottom(True) self._set_gallery(index.data(Qt.UserRole + 1)) self.show() def closeEvent(self, ev): ev.ignore() self.delayed_hide() def _set_gallery(self, gallery): self.current_gallery = gallery self.g_widget.apply_gallery(gallery) self.g_widget.resize(self.width() - self.content_margin, self.height() - self.content_margin) if self.direction == self.LEFT: start_point = QPoint(self.arrow_size.width(), 0) elif self.direction == self.TOP: start_point = QPoint(0, self.arrow_size.height()) else: start_point = QPoint(0, 0) # title #title_region = QRegion(0, 0, self.g_title_lbl.width(), #self.g_title_lbl.height()) self.g_widget.move(start_point) class GalleryLayout(QFrame): class ChapterList(QTableWidget): def __init__(self, parent): super().__init__(parent) self.setColumnCount(3) self.setEditTriggers(self.NoEditTriggers) self.setFocusPolicy(Qt.NoFocus) self.verticalHeader().setSectionResizeMode(self.verticalHeader().ResizeToContents) self.horizontalHeader().setSectionResizeMode(0, self.horizontalHeader().ResizeToContents) self.horizontalHeader().setSectionResizeMode(1, self.horizontalHeader().Stretch) self.horizontalHeader().setSectionResizeMode(2, self.horizontalHeader().ResizeToContents) self.horizontalHeader().hide() self.verticalHeader().hide() self.setSelectionMode(self.SingleSelection) self.setSelectionBehavior(self.SelectRows) self.setShowGrid(False) self.viewport().setBackgroundRole(self.palette().Dark) palette = self.viewport().palette() palette.setColor(palette.Highlight, QColor(88, 88, 88, 70)) palette.setColor(palette.HighlightedText, QColor('black')) self.viewport().setPalette(palette) self.setWordWrap(False) self.setTextElideMode(Qt.ElideRight) self.doubleClicked.connect(lambda idx: self._get_chap(idx).open()) def set_chapters(self, chapter_container): for r in range(self.rowCount()): self.removeRow(0) def t_item(txt=''): t = QTableWidgetItem(txt) t.setBackground(QBrush(QColor('#585858'))) return t for chap in chapter_container: c_row = self.rowCount() + 1 self.setRowCount(c_row) c_row -= 1 n = t_item() n.setData(Qt.DisplayRole, chap.number + 1) n.setData(Qt.UserRole + 1, chap) self.setItem(c_row, 0, n) title = chap.title if not title: title = chap.gallery.title t = t_item(title) self.setItem(c_row, 1, t) p = t_item(str(chap.pages)) self.setItem(c_row, 2, p) self.sortItems(0) def _get_chap(self, idx): r = idx.row() t = self.item(r, 0) return t.data(Qt.UserRole + 1) def contextMenuEvent(self, event): idx = self.indexAt(event.pos()) if idx.isValid(): chap = self._get_chap(idx) menu = QMenu(self) open = menu.addAction('Open', lambda: chap.open()) def open_source(): text = 'Opening archive...' if chap.in_archive else 'Opening folder...' app_constants.STAT_MSG_METHOD(text) path = chap.gallery.path if chap.in_archive else chap.path utils.open_path(path) t = "Open archive" if chap.in_archive else "Open folder" open_path = menu.addAction(t, open_source) menu.exec_(event.globalPos()) event.accept() del menu else: event.ignore() def __init__(self, parent, appwindow): super().__init__(parent) self.setFocusPolicy(Qt.NoFocus) self.appwindow = appwindow self.setStyleSheet('color:white;') main_layout = QHBoxLayout(self) self.stacked_l = stacked_l = QStackedLayout() general_info = QWidget(self) chapter_info = QWidget(self) chapter_layout = QVBoxLayout(chapter_info) self.general_index = stacked_l.addWidget(general_info) self.chap_index = stacked_l.addWidget(chapter_info) self.chapter_list = self.ChapterList(self) back_btn = TagText('Back') back_btn.clicked.connect(lambda: stacked_l.setCurrentIndex(self.general_index)) chapter_layout.addWidget(back_btn, 0, Qt.AlignCenter) chapter_layout.addWidget(self.chapter_list) self.left_layout = QFormLayout() self.main_left_layout = QVBoxLayout(general_info) self.main_left_layout.addLayout(self.left_layout) self.right_layout = QFormLayout() main_layout.addLayout(stacked_l, 1) main_layout.addWidget(Line('v')) main_layout.addLayout(self.right_layout) def get_label(txt): lbl = QLabel(txt) lbl.setWordWrap(True) return lbl self.g_title_lbl = get_label('') self.g_title_lbl.setStyleSheet('color:white;font-weight:bold;') self.left_layout.addRow(self.g_title_lbl) self.g_artist_lbl = ClickedLabel() self.g_artist_lbl.setWordWrap(True) self.g_artist_lbl.clicked.connect(lambda a: appwindow.search('artist:"{}"'.format(a))) self.g_artist_lbl.setStyleSheet('color:#bdc3c7;') self.g_artist_lbl.setToolTip("Click to see more from this artist") self.left_layout.addRow(self.g_artist_lbl) for lbl in (self.g_title_lbl, self.g_artist_lbl): lbl.setAlignment(Qt.AlignCenter) self.left_layout.addRow(Line('h')) first_layout = QHBoxLayout() self.g_type_lbl = ClickedLabel() self.g_type_lbl.setStyleSheet('text-decoration: underline') self.g_type_lbl.clicked.connect(lambda a: appwindow.search('type:"{}"'.format(a))) self.g_lang_lbl = ClickedLabel() self.g_lang_lbl.setStyleSheet('text-decoration: underline') self.g_lang_lbl.clicked.connect(lambda a: appwindow.search('language:"{}"'.format(a))) self.g_chapters_lbl = TagText('Chapters') self.g_chapters_lbl.clicked.connect(lambda: stacked_l.setCurrentIndex(self.chap_index)) self.g_chap_count_lbl = QLabel() self.right_layout.addRow(self.g_type_lbl) self.right_layout.addRow(self.g_lang_lbl) self.right_layout.addRow(self.g_chap_count_lbl) #first_layout.addWidget(self.g_lang_lbl, 0, Qt.AlignLeft) first_layout.addWidget(self.g_chapters_lbl, 0, Qt.AlignCenter) #first_layout.addWidget(self.g_type_lbl, 0, Qt.AlignRight) self.left_layout.addRow(first_layout) self.g_status_lbl = QLabel() self.g_d_added_lbl = QLabel() self.g_pub_lbl = QLabel() self.g_last_read_lbl = QLabel() self.g_read_count_lbl = QLabel() self.g_pages_total_lbl = QLabel() self.right_layout.addRow(self.g_read_count_lbl) self.right_layout.addRow('Pages:', self.g_pages_total_lbl) self.right_layout.addRow('Status:', self.g_status_lbl) self.right_layout.addRow('Added:', self.g_d_added_lbl) self.right_layout.addRow('Published:', self.g_pub_lbl) self.right_layout.addRow('Last read:', self.g_last_read_lbl) self.g_info_lbl = get_label('') self.left_layout.addRow(self.g_info_lbl) self.g_url_lbl = ClickedLabel() self.g_url_lbl.clicked.connect(lambda: utils.open_web_link(self.g_url_lbl.text())) self.g_url_lbl.setWordWrap(True) self.left_layout.addRow('URL:', self.g_url_lbl) #self.left_layout.addRow(Line('h')) self.tags_scroll = QScrollArea(self) self.tags_widget = QWidget(self.tags_scroll) self.tags_widget.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) self.tags_layout = QFormLayout(self.tags_widget) self.tags_layout.setSizeConstraint(self.tags_layout.SetMaximumSize) self.tags_scroll.setWidget(self.tags_widget) self.tags_scroll.setWidgetResizable(True) self.tags_scroll.setFrameShape(QFrame.NoFrame) self.main_left_layout.addWidget(self.tags_scroll) def has_tags(self, tags): t_len = len(tags) if not t_len: return False if t_len == 1: if 'default' in tags: if not tags['default']: return False return True def apply_gallery(self, gallery): self.stacked_l.setCurrentIndex(self.general_index) self.chapter_list.set_chapters(gallery.chapters) self.g_title_lbl.setText(gallery.title) self.g_artist_lbl.setText(gallery.artist) self.g_lang_lbl.setText(gallery.language) chap_txt = "chapters" if gallery.chapters.count() > 1 else "chapter" self.g_chap_count_lbl.setText('{} {}'.format(gallery.chapters.count(), chap_txt)) self.g_type_lbl.setText(gallery.type) pages = gallery.chapters.pages() self.g_pages_total_lbl.setText('{}'.format(pages)) self.g_status_lbl.setText(gallery.status) self.g_d_added_lbl.setText(gallery.date_added.strftime('%d %b %Y')) if gallery.pub_date: self.g_pub_lbl.setText(gallery.pub_date.strftime('%d %b %Y')) else: self.g_pub_lbl.setText('Unknown') last_read_txt = '{} ago'.format(utils.get_date_age(gallery.last_read)) if gallery.last_read else "Unknown" self.g_last_read_lbl.setText(last_read_txt) self.g_read_count_lbl.setText('Read {} times'.format(gallery.times_read)) self.g_info_lbl.setText(gallery.info) if gallery.link: self.g_url_lbl.setText(gallery.link) self.g_url_lbl.show() else: self.g_url_lbl.hide() clearLayout(self.tags_layout) if self.has_tags(gallery.tags): ns_layout = QFormLayout() self.tags_layout.addRow(ns_layout) for namespace in sorted(gallery.tags): tags_lbls = FlowLayout() if namespace == 'default': self.tags_layout.insertRow(0, tags_lbls) else: self.tags_layout.addRow(namespace, tags_lbls) for n, tag in enumerate(sorted(gallery.tags[namespace]), 1): if namespace == 'default': t = TagText(search_widget=self.appwindow) else: t = TagText(search_widget=self.appwindow, namespace=namespace) t.setText(tag) tags_lbls.addWidget(t) t.setAutoFillBackground(True) self.tags_widget.adjustSize() class Spinner(TransparentWidget): """ Spinner widget """ activated = pyqtSignal() deactivated = pyqtSignal() about_to_show, about_to_hide = range(2) _OFFSET_X_TOPRIGHT = [0] def __init__(self, parent, position='topright'): "Position can be: 'center', 'topright' or QPoint" super().__init__(parent, flags=Qt.Window | Qt.FramelessWindowHint, move_listener=False) self.setAttribute(Qt.WA_ShowWithoutActivating) self.fps = 21 self.border = 2 self.line_width = 5 self.arc_length = 100 self.seconds_per_spin = 1 self.text_layout = None self.text = '' self._text_margin = 5 self._timer = QTimer(self) self._timer.timeout.connect(self._on_timer_timeout) # keep track of the current start angle to avoid # unnecessary repaints self._start_angle = 0 self._offset_x_topright = self._OFFSET_X_TOPRIGHT[0] self.margin = 10 self._position = position self._min_size = 0 self.state_timer = QTimer() self.current_state = self.about_to_show self.state_timer.timeout.connect(super().hide) self.state_timer.setSingleShot(True) # animation self.fade_animation = create_animation(self, 'windowOpacity') self.fade_animation.setDuration(800) self.fade_animation.setStartValue(0.0) self.fade_animation.setEndValue(1.0) self.setWindowOpacity(0.0) self._update_layout() self.set_size(50) self._set_position(position) def _update_layout(self): self.text_layout = text_layout(self.text, self.width() - self._text_margin, self.font(), self.fontMetrics()) self.setFixedHeight(self._min_size + self.text_layout.boundingRect().height()) def set_size(self, w): self.setFixedWidth(w) self._min_size = w self._update_layout() self.update() def set_text(self, txt): self.text = txt self._update_layout() self.update() def _set_position(self, new_pos): "'center', 'topright' or QPoint" p = self.parent_widget # topleft if new_pos == "topright": def topright(): return QPoint(p.pos().x() + p.width() - 65 - self._offset_x_topright, p.pos().y() + p.toolbar.height() + 55) self.move(topright()) p.move_listener.connect(lambda: self.update_move(topright())) elif new_pos == "center": p.move_listener.connect(lambda: self.update_move(QPoint(p.pos().x() + p.width() // 2, p.pos().y() + p.height() // 2))) elif isinstance(new_pos, QPoint): p.move_listener.connect(lambda: self.update_move(new_pos)) def paintEvent(self, event): # call the base paint event: super().paintEvent(event) painter = QPainter() painter.begin(self) try: painter.setRenderHint(QPainter.Antialiasing) txt_rect = QRectF(0,0,0,0) if not self.text: txt_rect.setHeight(self.fontMetrics().height()) painter.save() painter.setPen(Qt.NoPen) painter.setBrush(QBrush(QColor(88,88,88,180))) painter.drawRoundedRect(QRect(0,0, self.width(), self.height() - txt_rect.height()), 5, 5) painter.restore() pen = QPen(QColor('#F2F2F2')) pen.setWidth(self.line_width) painter.setPen(pen) border = self.border + int(math.ceil(self.line_width / 2.0)) r = QRectF((txt_rect.height()) / 2, (txt_rect.height() / 2), self.width() - txt_rect.height(), self.width() - txt_rect.height()) r.adjust(border, border, -border, -border) # draw the arc: painter.drawArc(r, -self._start_angle * 16, self.arc_length * 16) # draw text if there is if self.text: txt_rect = self.text_layout.boundingRect() self.text_layout.draw(painter, QPointF(self._text_margin, self.height() - txt_rect.height() - self._text_margin / 2)) r = None finally: painter.end() painter = None def showEvent(self, event): if self._position == "topright": self._OFFSET_X_TOPRIGHT[0] += + self.width() + self.margin if not self._timer.isActive(): self.fade_animation.start() self.current_state = self.about_to_show self.state_timer.stop() self.activated.emit() self._timer.start(1000 / max(1, self.fps)) super().showEvent(event) def hideEvent(self, event): self._timer.stop() self.deactivated.emit() super().hideEvent(event) def before_hide(self): if self.current_state == self.about_to_hide: return self.current_state = self.about_to_hide if self._position == "topright": self._OFFSET_X_TOPRIGHT[0] -= self.width() + self.margin self.state_timer.start(5000) def closeEvent(self, event): self._timer.stop() super().closeEvent(event) def _on_timer_timeout(self): if not self.isVisible(): return # calculate the spin angle as a function of the current time so that all # spinners appear in sync! t = time.time() whole_seconds = int(t) p = (whole_seconds % self.seconds_per_spin) + (t - whole_seconds) angle = int((360 * p) / self.seconds_per_spin) if angle == self._start_angle: return self._start_angle = angle self.update() class GalleryMenu(QMenu): delete_galleries = pyqtSignal(bool) edit_gallery = pyqtSignal(object, object) def __init__(self, view, index, sort_model, app_window, selected_indexes=None): super().__init__(app_window) self.parent_widget = app_window self.view = view self.sort_model = sort_model self.index = index self.gallery = index.data(Qt.UserRole + 1) self.selected = selected_indexes if self.view.view_type == app_constants.ViewType.Default: if not self.selected: favourite_act = self.addAction('Favorite', lambda: self.parent_widget.manga_list_view.favorite(self.index)) favourite_act.setCheckable(True) if self.gallery.fav: favourite_act.setChecked(True) favourite_act.setText('Unfavorite') else: favourite_act.setChecked(False) else: favourite_act = self.addAction('Favorite selected', self.favourite_select) favourite_act.setCheckable(True) f = [] for idx in self.selected: if idx.data(Qt.UserRole + 1).fav: f.append(True) else: f.append(False) if all(f): favourite_act.setChecked(True) favourite_act.setText('Unfavorite selected') else: favourite_act.setChecked(False) elif self.view.view_type == app_constants.ViewType.Addition: send_to_lib = self.addAction('Send to library', self.send_to_lib) add_to_ignore = self.addAction('Ignore and remove', self.add_to_ignore) self.addSeparator() rating = self.addAction('Set rating') rating_menu = QMenu(self) rating.setMenu(rating_menu) for x in range(0, 6): rating_menu.addAction('{}'.format(x), functools.partial(self.set_rating, x)) self.addSeparator() if not self.selected and isinstance(view, QTableView): chapters_menu = self.addAction('Chapters') open_chapters = QMenu(self) chapters_menu.setMenu(open_chapters) for number, chap in enumerate(self.gallery.chapters, 1): chap_action = QAction("Open chapter {}".format(number), open_chapters, triggered = functools.partial(chap.open)) open_chapters.addAction(chap_action) if self.selected: open_f_chapters = self.addAction('Open first chapters', self.open_first_chapters) if self.view.view_type != app_constants.ViewType.Duplicate: if not self.selected: add_chapters = self.addAction('Add chapters', self.add_chapters) if self.view.view_type == app_constants.ViewType.Default: add_to_list_txt = "Add selected to list" if self.selected else "Add to list" add_to_list = self.addAction(add_to_list_txt) add_to_list_menu = QMenu(self) add_to_list.setMenu(add_to_list_menu) for g_list in sorted(app_constants.GALLERY_LISTS): add_to_list_menu.addAction(g_list.name, functools.partial(self.add_to_list, g_list)) self.addSeparator() web_menu_act = self.addAction('Web') web_menu = QMenu(self) web_menu_act.setMenu(web_menu) if not self.selected: get_metadata = web_menu.addAction('Fetch metadata', lambda: self.parent_widget.get_metadata(index.data(Qt.UserRole + 1))) else: gals = [] for idx in self.selected: gals.append(idx.data(Qt.UserRole + 1)) get_select_metadata = web_menu.addAction('Fetch metadata for selected', lambda: self.parent_widget.get_metadata(gals)) web_menu.addSeparator() if self.index.data(Qt.UserRole + 1).link and not self.selected: op_link = web_menu.addAction('Open URL', self.op_link) web_menu.addSeparator() if self.selected and all([idx.data(Qt.UserRole + 1).link for idx in self.selected]): op_links = web_menu.addAction('Open URLs', lambda: self.op_link(True)) web_menu.addSeparator() artist_lookup = web_menu.addAction("Lookup Artists" if self.selected else "Lookup Artist", lambda: self.lookup_web("artist")) self.addSeparator() edit = self.addAction('Edit', lambda: self.edit_gallery.emit(self.parent_widget, self.index.data(Qt.UserRole + 1) if not self.selected else [idx.data(Qt.UserRole + 1) for idx in self.selected])) self.addSeparator() if not self.selected: text = 'folder' if not self.index.data(Qt.UserRole + 1).is_archive else 'archive' op_folder_act = self.addAction('Open {}'.format(text), self.op_folder) op_cont_folder_act = self.addAction('Show in folder', lambda: self.op_folder(containing=True)) else: text = 'folders' if not self.index.data(Qt.UserRole + 1).is_archive else 'archives' op_folder_select = self.addAction('Open {}'.format(text), lambda: self.op_folder(True)) op_cont_folder_select = self.addAction('Show in folders', lambda: self.op_folder(True, True)) remove_act = self.addAction('Remove') remove_menu = QMenu(self) remove_act.setMenu(remove_menu) if self.view.view_type == app_constants.ViewType.Default: if self.sort_model.current_gallery_list: remove_f_g_list_txt = "Remove selected from list" if self.selected else "Remove from list" remove_f_g_list = remove_menu.addAction(remove_f_g_list_txt, self.remove_from_list) if not self.selected: remove_g = remove_menu.addAction('Remove gallery', lambda: self.delete_galleries.emit(False)) remove_ch = remove_menu.addAction('Remove chapter') remove_ch_menu = QMenu(self) remove_ch.setMenu(remove_ch_menu) for number, chap_number in enumerate(range(len(self.index.data(Qt.UserRole + 1).chapters)), 1): chap_action = QAction("Remove chapter {}".format(number), remove_ch_menu, triggered = functools.partial(self.parent_widget.manga_list_view.del_chapter, index, chap_number)) remove_ch_menu.addAction(chap_action) else: remove_select_g = remove_menu.addAction('Remove selected', lambda: self.delete_galleries.emit(False)) remove_menu.addSeparator() if not self.selected: remove_source_g = remove_menu.addAction('Remove and delete files', lambda: self.delete_galleries.emit(True)) else: remove_source_select_g = remove_menu.addAction('Remove selected and delete files', lambda: self.delete_galleries.emit(True)) self.addSeparator() advanced = self.addAction('Advanced') adv_menu = QMenu(self) advanced.setMenu(adv_menu) if not self.selected: change_cover = adv_menu.addAction('Change cover...', self.change_cover) if self.selected: allow_metadata_count = 0 for i in self.selected: if i.data(Qt.UserRole + 1).exed: allow_metadata_count += 1 self.allow_metadata_exed = allow_metadata_count >= len(self.selected) // 2 else: self.allow_metadata_exed = False if not self.gallery.exed else True if self.selected: allow_metadata_txt = "Include selected in auto metadata fetch" if self.allow_metadata_exed else "Exclude selected in auto metadata fetch" else: allow_metadata_txt = "Include in 'Fetch all metadata'" if self.allow_metadata_exed else "Exclude in 'Fetch all metadata'" adv_menu.addAction(allow_metadata_txt, self.allow_metadata_fetch) adv_menu.addAction("Reset read count", self.reset_read_count) def lookup_web(self, txt): tag = [] if txt == 'artist': if self.selected: for i in self.selected: tag.append('artist:' + i.data(Qt.UserRole + 2).strip()) else: tag.append('artist:' + self.index.data(Qt.UserRole + 2).strip()) [utils.lookup_tag(t) for t in tag] def set_rating(self, x): def save_rating(g): gallerydb.execute(gallerydb.GalleryDB.modify_gallery, True, g.id, rating=g.rating) if self.selected: [(setattr(g, "rating", x), save_rating(g)) for g in [idx.data(Qt.UserRole + 1) for idx in self.selected]] else: self.gallery.rating = x save_rating(self.gallery) def add_to_ignore(self): if self.selected: gs = self.selected else: gs = [self.index] galleries = [idx.data(Qt.UserRole + 1) for idx in gs] paths = set() for g in galleries: for chap in g.chapters: if not chap.in_archive: paths.add(chap.path) else: paths.add(g.path) app_constants.IGNORE_PATHS.extend(paths) settings.set(app_constants.IGNORE_PATHS, 'Application', 'ignore paths') self.delete_galleries.emit(False) def send_to_lib(self): if self.selected: gs = self.selected else: gs = [self.index] galleries = [idx.data(Qt.UserRole + 1) for idx in gs] rows = len(galleries) self.view.gallery_model._gallery_to_remove.extend(galleries) self.view.gallery_model.removeRows(self.view.gallery_model.rowCount() - rows, rows) self.parent_widget.default_manga_view.add_gallery(galleries) for g in galleries: gallerydb.execute(gallerydb.GalleryDB.modify_gallery, True, g.id, view=g.view) self.view.sort_model.refresh() self.view.clearSelection() def allow_metadata_fetch(self): exed = 0 if self.allow_metadata_exed else 1 if self.selected: for idx in self.selected: g = idx.data(Qt.UserRole + 1) g.exed = exed gallerydb.execute(gallerydb.GalleryDB.modify_gallery, True, g.id, {'exed':exed}) else: self.gallery.exed = exed gallerydb.execute(gallerydb.GalleryDB.modify_gallery, True, self.gallery.id, {'exed':exed}) def reset_read_count(self): if self.selected: for idx in self.selected: g = idx.data(Qt.UserRole + 1) g.times_read = 0 gallerydb.execute(gallerydb.GalleryDB.modify_gallery, True, g.id, {'times_read':0}) else: self.gallery.times_read = 0 gallerydb.execute(gallerydb.GalleryDB.modify_gallery, True, self.gallery.id, {'times_read':0}) def add_to_list(self, g_list): galleries = [] if self.selected: for idx in self.selected: galleries.append(idx.data(Qt.UserRole + 1)) else: galleries.append(self.gallery) g_list.add_gallery(galleries) def remove_from_list(self): g_list = self.sort_model.current_gallery_list if self.selected: g_ids = [] for idx in self.selected: g_ids.append(idx.data(Qt.UserRole + 1).id) else: g_ids = self.gallery.id self.sort_model.current_gallery_list.remove_gallery(g_ids) self.sort_model.init_search(self.sort_model.current_term) def favourite_select(self): for idx in self.selected: self.parent_widget.manga_list_view.favorite(idx) def change_cover(self): gallery = self.index.data(Qt.UserRole + 1) log_i('Attempting to change cover of {}'.format(gallery.title.encode(errors='ignore'))) if gallery.is_archive: try: zip = utils.ArchiveFile(gallery.path) except utils.app_constants.CreateArchiveFail: app_constants.NOTIF_BAR.add_text('Attempt to change cover failed. Could not create archive.') return path = zip.extract_all() else: path = gallery.path new_cover = QFileDialog.getOpenFileName(self, 'Select a new gallery cover', filter='Image {}'.format(utils.IMG_FILTER), directory=path)[0] if new_cover and new_cover.lower().endswith(utils.IMG_FILES): gallerydb.GalleryDB.clear_thumb(gallery.profile) Executors.generate_thumbnail(gallery, img=new_cover, on_method=gallery.set_profile) gallery.reset_profile() log_i('Changed cover successfully!') def open_first_chapters(self): txt = "Opening first chapters of selected galleries" app_constants.STAT_MSG_METHOD(txt) for idx in self.selected: idx.data(Qt.UserRole + 1).chapters[0].open(False) def op_link(self, select=False): if select: for x in self.selected: gal = x.data(Qt.UserRole + 1) utils.open_web_link(gal.link) else: utils.open_web_link(self.index.data(Qt.UserRole + 1).link) def op_folder(self, select=False, containing=False): if select: for x in self.selected: text = 'Opening archives...' if self.index.data(Qt.UserRole + 1).is_archive else 'Opening folders...' text = 'Opening containing folders...' if containing else text self.view.STATUS_BAR_MSG.emit(text) gal = x.data(Qt.UserRole + 1) path = os.path.split(gal.path)[0] if containing else gal.path if containing: utils.open_path(path, gal.path) else: utils.open_path(path) else: text = 'Opening archive...' if self.index.data(Qt.UserRole + 1).is_archive else 'Opening folder...' text = 'Opening containing folder...' if containing else text self.view.STATUS_BAR_MSG.emit(text) gal = self.index.data(Qt.UserRole + 1) path = os.path.split(gal.path)[0] if containing else gal.path if containing: utils.open_path(path, gal.path) else: utils.open_path(path) def add_chapters(self): def add_chdb(chaps_container): gallery = self.index.data(Qt.UserRole + 1) log_i('Adding new chapter for {}'.format(gallery.title.encode(errors='ignore'))) gallerydb.execute(gallerydb.ChapterDB.add_chapters_raw, False, gallery.id, chaps_container) ch_widget = ChapterAddWidget(self.index.data(Qt.UserRole + 1), self.parent_widget) ch_widget.CHAPTERS.connect(add_chdb) ch_widget.show() class SystemTray(QSystemTrayIcon): """ Pass True to minimized arg in showMessage method to only show message if application is minimized. """ def __init__(self, icon, parent=None): super().__init__(icon, parent=None) self.parent_widget = parent def showMessage(self, title, msg, icon=QSystemTrayIcon.Information, msecs=10000, minimized=False): # NOTE: Crashes on linux # TODO: Fix this!! if not app_constants.OS_NAME == "linux": if minimized: if self.parent_widget.isMinimized() or not self.parent_widget.isActiveWindow(): return super().showMessage(title, msg, icon, msecs) else: return super().showMessage(title, msg, icon, msecs) class ClickedLabel(QLabel): """ A QLabel which emits clicked signal on click """ clicked = pyqtSignal(str) def __init__(self, s="", **kwargs): super().__init__(s, **kwargs) self.setTextInteractionFlags(Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard) def enterEvent(self, event): if self.text(): self.setCursor(Qt.PointingHandCursor) else: self.setCursor(Qt.ArrowCursor) return super().enterEvent(event) def mousePressEvent(self, event): self.clicked.emit(self.text()) return super().mousePressEvent(event) class TagText(QPushButton): def __init__(self, *args, **kwargs): self.search_widget = kwargs.pop('search_widget', None) self.namespace = kwargs.pop('namespace', None) super().__init__(*args, **kwargs) if self.search_widget: if self.namespace: self.clicked.connect(lambda: self.search_widget.search('"{}":"{}"'.format(self.namespace, self.text()))) else: self.clicked.connect(lambda: self.search_widget.search('"{}"'.format(self.text()))) def mousePressEvent(self, ev): assert isinstance(ev, QMouseEvent) if ev.button() == Qt.RightButton: if self.search_widget: menu = QMenu(self) menu.addAction("Lookup tag", lambda: utils.lookup_tag( self.text() if not self.namespace else '{}:{}'.format(self.namespace, self.text()))) menu.exec(ev.globalPos()) return super().mousePressEvent(ev) def enterEvent(self, event): if self.text(): self.setCursor(Qt.PointingHandCursor) else: self.setCursor(Qt.ArrowCursor) return super().enterEvent(event) class BasePopup(TransparentWidget): graphics_blur = None def __init__(self, parent=None, **kwargs): blur = True if kwargs: blur = kwargs.pop('blur', True) if kwargs: super().__init__(parent, **kwargs) else: super().__init__(parent, flags= Qt.Dialog | Qt.FramelessWindowHint) else: super().__init__(parent, flags= Qt.Dialog | Qt.FramelessWindowHint) main_layout = QVBoxLayout() self.main_widget = QFrame() self.main_widget.setFrameStyle(QFrame.StyledPanel) self.setLayout(main_layout) main_layout.addWidget(self.main_widget) self.generic_buttons = QHBoxLayout() self.generic_buttons.addWidget(Spacer('h')) self.yes_button = QPushButton('Yes') self.no_button = QPushButton('No') self.buttons_layout = QHBoxLayout() self.buttons_layout.addWidget(Spacer('h'), 3) self.generic_buttons.addWidget(self.yes_button) self.generic_buttons.addWidget(self.no_button) self.setMaximumWidth(500) self.resize(500,350) self.curr_pos = QPoint() if parent and blur: try: self.graphics_blur = parent.graphics_blur parent.setGraphicsEffect(self.graphics_blur) except AttributeError: pass # animation self.fade_animation = create_animation(self, 'windowOpacity') self.fade_animation.setDuration(800) self.fade_animation.setStartValue(0.0) self.fade_animation.setEndValue(1.0) self.setWindowOpacity(0.0) def mousePressEvent(self, event): self.curr_pos = event.pos() return super().mousePressEvent(event) def mouseMoveEvent(self, event): if event.buttons() == Qt.LeftButton: diff = event.pos() - self.curr_pos newpos = self.pos() + diff self.move(newpos) return super().mouseMoveEvent(event) def showEvent(self, event): self.activateWindow() self.fade_animation.start() if self.graphics_blur: self.graphics_blur.setEnabled(True) return super().showEvent(event) def closeEvent(self, event): if self.graphics_blur: self.graphics_blur.setEnabled(False) return super().closeEvent(event) def hideEvent(self, event): if self.graphics_blur: self.graphics_blur.setEnabled(False) return super().hideEvent(event) def add_buttons(self, *args): """ Pass names of buttons, from right to left. Returns list of buttons in same order as they came in. Note: Remember to add buttons_layout to main layout! """ b = [] for name in args: button = QPushButton(name) self.buttons_layout.addWidget(button) b.append(button) return b class AppBubble(BasePopup): "For application notifications" def __init__(self, parent): super().__init__(parent, flags= Qt.Window | Qt.FramelessWindowHint, blur=False) self.hide_timer = QTimer(self) self.hide_timer.timeout.connect(self.hide) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum) main_layout = QVBoxLayout(self.main_widget) self.title = QLabel() self.title.setTextFormat(Qt.RichText) main_layout.addWidget(self.title) self.content = QLabel() self.content.setWordWrap(True) self.content.setTextFormat(Qt.RichText) self.content.setOpenExternalLinks(True) main_layout.addWidget(self.content) self.adjustSize() def update_text(self, title, txt='', duration=20): "Duration in seconds!" if self.hide_timer.isActive(): self.hide_timer.stop() self.title.setText('

{}

'.format(title)) self.content.setText(txt) self.hide_timer.start(duration * 1000) self.show() self.adjustSize() self.update_move() def update_move(self): if self.parent_widget: tl = self.parent_widget.geometry().topLeft() x = tl.x() + self.parent_widget.width() - self.width() - 10 y = tl.y() + self.parent_widget.height() - self.height() - self.parent_widget.statusBar().height() self.move(x, y) def mousePressEvent(self, event): if event.button() == Qt.RightButton: self.close() super().mousePressEvent(event) class AppDialog(BasePopup): # modes PROGRESS, MESSAGE = range(2) closing_down = pyqtSignal() def __init__(self, parent, mode=PROGRESS): self.mode = mode if mode == self.MESSAGE: super().__init__(parent, flags=Qt.Dialog) else: super().__init__(parent) self.parent_widget = parent main_layout = QVBoxLayout() self.info_lbl = QLabel() self.info_lbl.setAlignment(Qt.AlignCenter) main_layout.addWidget(self.info_lbl) if mode == self.PROGRESS: self.info_lbl.setText("Updating your galleries to newest version...") self.info_lbl.setWordWrap(True) class progress(QProgressBar): reached_maximum = pyqtSignal() def __init__(self, parent=None): super().__init__(parent) def setValue(self, v): if v == self.maximum(): self.reached_maximum.emit() return super().setValue(v) self.prog = progress(self) self.prog.reached_maximum.connect(self.close) main_layout.addWidget(self.prog) self.note_info = QLabel("Note: This popup will close itself when everything is ready") self.note_info.setAlignment(Qt.AlignCenter) self.restart_info = QLabel("Please wait.. It is safe to restart if there is no sign of progress.") self.restart_info.setAlignment(Qt.AlignCenter) main_layout.addWidget(self.note_info) main_layout.addWidget(self.restart_info) elif mode == self.MESSAGE: self.info_lbl.setText("An exception has ben encountered.\nContact the developer to get this fixed." + "\nStability from this point onward cannot be guaranteed.") self.setWindowTitle("It was too big!") self.main_widget.setLayout(main_layout) self.adjustSize() def closeEvent(self, event): self.parent_widget.setEnabled(True) if self.mode == self.MESSAGE: self.closing_down.emit() return super().closeEvent(event) else: return super().closeEvent(event) def showEvent(self, event): self.parent_widget.setEnabled(False) return super().showEvent(event) def init_restart(self): if self.mode == self.PROGRESS: self.prog.hide() self.note_info.hide() self.restart_info.hide() log_i('Application requires restart') self.note_info.setText("Application requires restart!") class NotificationOverlay(QWidget): """ A notifaction bar """ clicked = pyqtSignal() _show_signal = pyqtSignal() _hide_signal = pyqtSignal() _unset_cursor = pyqtSignal() _set_cursor = pyqtSignal(object) def __init__(self, parent=None): super().__init__(parent) self._main_layout = QHBoxLayout(self) self._default_height = 20 self._dynamic_height = 0 self._lbl = QLabel() self._main_layout.addWidget(self._lbl) self._lbl.setAlignment(Qt.AlignCenter) self.setContentsMargins(-10,-10,-10,-10) self._click = False self._override_hide = False self.text_queue = [] self.slide_animation = create_animation(self, 'minimumHeight') self.slide_animation.setDuration(500) self.slide_animation.setStartValue(0) self.slide_animation.setEndValue(self._default_height) self.slide_animation.valueChanged.connect(self.set_dynamic_height) self._show_signal.connect(self.show) self._hide_signal.connect(self.hide) self._unset_cursor.connect(self.unsetCursor) self._set_cursor.connect(self.setCursor) def set_dynamic_height(self, h): self._dynamic_height = h def mousePressEvent(self, event): if self._click: self.clicked.emit() return super().mousePressEvent(event) def set_clickable(self, d=True): self._click = d self._set_cursor.emit(Qt.PointingHandCursor) def resize(self, x, y=0): return super().resize(x, self._dynamic_height) def add_text(self, text, autohide=True): """ Add new text to the bar, deleting the previous one """ try: self._reset() except TypeError: pass if not self.isVisible(): self._show_signal.emit() self._lbl.setText(text) if autohide: if not self._override_hide: threading.Timer(10, self._hide_signal.emit).start() def begin_show(self): """ Control how long you will show notification bar. end_show() must be called to hide the bar. """ self._override_hide = True self._show_signal.emit() def end_show(self): self._override_hide = False QTimer.singleShot(5000, self._hide_signal.emit) def _reset(self): self._unset_cursor.emit() self._click = False self.clicked.disconnect() def showEvent(self, event): self.slide_animation.start() return super().showEvent(event) class GalleryShowcaseWidget(QWidget): """ Pass a gallery or set a gallery via -> set_gallery """ double_clicked = pyqtSignal(gallerydb.Gallery) def __init__(self, gallery=None, parent=None, menu=None): super().__init__(parent) self.setAttribute(Qt.WA_DeleteOnClose) self.main_layout = QVBoxLayout(self) self.parent_widget = parent if menu: menu.gallery_widget = self self._menu = menu self.gallery = gallery self.extra_text = QLabel() self.profile = QLabel(self) self.profile.setAlignment(Qt.AlignCenter) self.text = QLabel(self) self.font_M = self.text.fontMetrics() self.main_layout.addWidget(self.extra_text) self.extra_text.hide() self.main_layout.addWidget(self.profile) self.main_layout.addWidget(self.text) self.h = 0 self.w = 0 if gallery: self.h = 220 self.w = 143 self.set_gallery(gallery, (self.w, self.h)) self.resize(self.w, self.h) self.setMouseTracking(True) @property def menu(self): return self._menu @menu.setter def contextmenu(self, new_menu): new_menu.gallery_widget = self self._menu = new_menu def set_pixmap(self, gallery, img): self.profile.setPixmap(QPixmap.fromImage(img)) def set_gallery(self, gallery, size=app_constants.THUMB_SMALL): assert isinstance(size, (list, tuple)) self.w = size[0] self.h = size[1] self.gallery = gallery img = gallery.get_profile(app_constants.ProfileType.Small, self.set_pixmap) if img: self.profile.setPixmap(QPixmap.fromImage(img)) title = self.font_M.elidedText(gallery.title, Qt.ElideRight, self.w) artist = self.font_M.elidedText(gallery.artist, Qt.ElideRight, self.w) self.text.setText("{}\n{}".format(title, artist)) self.setToolTip("{}\n{}".format(gallery.title, gallery.artist)) self.resize(self.w, self.h + 50) def paintEvent(self, event): painter = QPainter(self) if self.underMouse(): painter.setBrush(QBrush(QColor(164,164,164,120))) painter.drawRect(self.text.pos().x() - 2, self.profile.pos().y() - 5, self.text.width() + 2, self.profile.height() + self.text.height() + 12) super().paintEvent(event) def enterEvent(self, event): self.update() return super().enterEvent(event) def leaveEvent(self, event): self.update() return super().leaveEvent(event) def mouseDoubleClickEvent(self, event): self.double_clicked.emit(self.gallery) return super().mouseDoubleClickEvent(event) def contextMenuEvent(self, event): if self._menu: self._menu.exec_(event.globalPos()) event.accept() else: event.ignore() class SingleGalleryChoices(BasePopup): """ Represent a single gallery with a list of choices below. Pass a gallery and a list of tuple/list where the first index is a string in each if text is passed, the text will be shown alongside gallery, else gallery be centered """ USER_CHOICE = pyqtSignal(object) def __init__(self, gallery, tuple_first_idx, text=None, parent=None): super().__init__(parent, flags= Qt.Dialog | Qt.FramelessWindowHint) main_layout = QVBoxLayout() self.main_widget.setLayout(main_layout) g_showcase = GalleryShowcaseWidget() g_showcase.set_gallery(gallery, (170 // 1.40, 170)) if text: t_layout = QHBoxLayout() main_layout.addLayout(t_layout) t_layout.addWidget(g_showcase, 1) info = QLabel(text) info.setWordWrap(True) t_layout.addWidget(info) else: main_layout.addWidget(g_showcase, 0, Qt.AlignCenter) self.list_w = QListWidget(self) self.list_w.setAlternatingRowColors(True) self.list_w.setWordWrap(True) self.list_w.setTextElideMode(Qt.ElideNone) main_layout.addWidget(self.list_w, 3) main_layout.addLayout(self.buttons_layout) for t in tuple_first_idx: item = CustomListItem(t) item.setText(t[0]) self.list_w.addItem(item) self.buttons = self.add_buttons('Skip All', 'Skip', 'Choose',) self.buttons[2].clicked.connect(self.finish) self.buttons[1].clicked.connect(self.skip) self.buttons[0].clicked.connect(self.skipall) self.resize(400, 400) self.show() def finish(self): item = self.list_w.selectedItems() if item: item = item[0] self.USER_CHOICE.emit(item.item) self.close() def skip(self): self.USER_CHOICE.emit(()) self.close() def skipall(self): self.USER_CHOICE.emit(None) self.close() class BaseUserChoice(QDialog): USER_CHOICE = pyqtSignal(object) def __init__(self, parent, **kwargs): super().__init__(parent, **kwargs) self.setAttribute(Qt.WA_DeleteOnClose) self.setAttribute(Qt.WA_TranslucentBackground) main_widget = QFrame(self) layout = QVBoxLayout(self) layout.addWidget(main_widget) self.main_layout = QFormLayout(main_widget) def accept(self, choice): self.USER_CHOICE.emit(choice) super().accept() class TorrentItem: def __init__(self, url, name="", date=None, size=None, seeds=None, peers=None, uploader=None): self.url = url self.name = name self.date = date self.size = size self.seeds = seeds self.peers = peers self.uploader = uploader class TorrentUserChoice(BaseUserChoice): def __init__(self, parent, torrentitems=[], **kwargs): super().__init__(parent, **kwargs) title = QLabel('Torrents') title.setAlignment(Qt.AlignCenter) self.main_layout.addRow(title) self._list_w = QListWidget(self) self.main_layout.addRow(self._list_w) for t in torrentitems: self.add_torrent_item(t) btn_layout = QHBoxLayout() choose_btn = QPushButton('Choose') choose_btn.clicked.connect(self.accept) btn_layout.addWidget(Spacer('h')) btn_layout.addWidget(choose_btn) self.main_layout.addRow(btn_layout) def add_torrent_item(self, item): list_item = CustomListItem(item) list_item.setText("{}\nSeeds:{}\tPeers:{}\tSize:{}\tDate:{}\tUploader:{}".format(item.name, item.seeds, item.peers, item.size, item.date, item.uploader)) self._list_w.addItem(list_item) def accept(self): items = self._list_w.selectedItems() if items: item = items[0] super().accept(item.item) class LoadingOverlay(QWidget): def __init__(self, parent=None): super().__init__(parent) palette = QPalette(self.palette()) palette.setColor(palette.Background, Qt.transparent) self.setPalette(palette) def paintEngine(self, event): painter = QPainter() painter.begin(self) painter.setRenderHint(QPainter.Antialiasing) painter.fillRect(event.rect(), QBrush(QColor(255,255,255,127))) painter.setPen(QPen(Qt.NoPen)) for i in range(6): if (self.counter / 5) % 6 == i: painter.setBrush(QBrush(QColor(127 + (self.counter % 5) * 32,127,127))) else: painter.setBrush(QBrush(QColor(127,127,127))) painter.drawEllipse(self.width() / 2 + 30 * math.cos(2 * math.pi * i / 6.0) - 10, self.height() / 2 + 30 * math.sin(2 * math.pi * i / 6.0) - 10, 20,20) painter.end() def showEvent(self, event): self.timer = self.startTimer(50) self.counter = 0 super().showEvent(event) def timerEvent(self, event): self.counter += 1 self.update() if self.counter == 60: self.killTimer(self.timer) self.hide() class FileIcon: def __init__(self): self.ico_types = {} def get_file_icon(self, path): if os.path.isdir(path): if not 'dir' in self.ico_types: self.ico_types['dir'] = QFileIconProvider().icon(QFileInfo(path)) return self.ico_types['dir'] elif path.endswith(utils.ARCHIVE_FILES): suff = '' for s in utils.ARCHIVE_FILES: if path.endswith(s): suff = s if not suff in self.ico_types: self.ico_types[suff] = QFileIconProvider().icon(QFileInfo(path)) return self.ico_types[suff] @staticmethod def get_external_file_icon(): if app_constants._REFRESH_EXTERNAL_VIEWER: if os.path.exists(app_constants.GALLERY_EXT_ICO_PATH): os.remove(app_constants.GALLERY_EXT_ICO_PATH) info = QFileInfo(app_constants.EXTERNAL_VIEWER_PATH) icon = QFileIconProvider().icon(info) pixmap = icon.pixmap(QSize(32, 32)) pixmap.save(app_constants.GALLERY_EXT_ICO_PATH, quality=100) app_constants._REFRESH_EXTERNAL_VIEWER = False return QIcon(app_constants.GALLERY_EXT_ICO_PATH) @staticmethod def refresh_default_icon(): if os.path.exists(app_constants.GALLERY_DEF_ICO_PATH): os.remove(app_constants.GALLERY_DEF_ICO_PATH) def get_file(n): gallery = gallerydb.GalleryDB.get_gallery_by_id(n) if not gallery: return False file = "" if gallery.path.endswith(tuple(ARCHIVE_FILES)): try: zip = ArchiveFile(gallery.path) except utils.app_constants.CreateArchiveFail: return False for name in zip.namelist(): if name.lower().endswith(tuple(IMG_FILES)): folder = os.path.join(app_constants.temp_dir, '{}{}'.format(name, n)) zip.extract(name, folder) file = os.path.join(folder, name) break else: for p in scandir.scandir(gallery.chapters[0].path): if p.name.lower().endswith(tuple(IMG_FILES)): file = p.path break return file # TODO: fix this! (When there are no ids below 300? (because they go # deleted)) for x in range(1, 300): try: file = get_file(x) break except FileNotFoundError: continue except app_constants.CreateArchiveFail: continue if not file: return None icon = QFileIconProvider().icon(QFileInfo(file)) pixmap = icon.pixmap(QSize(32, 32)) pixmap.save(app_constants.GALLERY_DEF_ICO_PATH, quality=100) return True @staticmethod def get_default_file_icon(): s = True if not os.path.isfile(app_constants.GALLERY_DEF_ICO_PATH): s = FileIcon.refresh_default_icon() if s: return QIcon(app_constants.GALLERY_DEF_ICO_PATH) else: return None #def center_parent(parent, child): # "centers child window in parent" # centerparent = QPoint( # parent.x() + (parent.frameGeometry().width() - # child.frameGeometry().width())//2, # parent.y() + (parent.frameGeometry().width() - # child.frameGeometry().width())//2) # desktop = QApplication.desktop() # sg_rect = desktop.screenGeometry(desktop.screenNumber(parent)) # child_frame = child.frameGeometry() # if centerparent.x() < sg_rect.left(): # centerparent.setX(sg_rect.left()) # elif (centerparent.x() + child_frame.width()) > sg_rect.right(): # centerparent.setX(sg_rect.right() - child_frame.width()) # if centerparent.y() < sg_rect.top(): # centerparent.setY(sg_rect.top()) # elif (centerparent.y() + child_frame.height()) > sg_rect.bottom(): # centerparent.setY(sg_rect.bottom() - child_frame.height()) # child.move(centerparent) class Spacer(QWidget): """ To be used as a spacer. Default mode is both. Specify mode with string: v, h or both """ def __init__(self, mode='both', parent=None): super().__init__(parent) if mode == 'h': self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) elif mode == 'v': self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) else: self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) class FlowLayout(QLayout): def __init__(self, parent=None, margin=0, spacing=-1): super(FlowLayout, self).__init__(parent) if parent is not None: self.setContentsMargins(margin, margin, margin, margin) self.setSpacing(spacing) self.itemList = [] def __del__(self): item = self.takeAt(0) while item: item = self.takeAt(0) def addItem(self, item): self.itemList.append(item) def count(self): return len(self.itemList) # to keep it in style with the others.. def rowCount(self): return self.count() def itemAt(self, index): if index >= 0 and index < len(self.itemList): return self.itemList[index] return None def takeAt(self, index): if index >= 0 and index < len(self.itemList): return self.itemList.pop(index) return None def expandingDirections(self): return Qt.Orientations(Qt.Orientation(0)) def hasHeightForWidth(self): return True def heightForWidth(self, width): height = self.doLayout(QRect(0, 0, width, 0), True) return height def setGeometry(self, rect): super(FlowLayout, self).setGeometry(rect) self.doLayout(rect, False) def sizeHint(self): return self.minimumSize() def minimumSize(self): size = QSize() for item in self.itemList: size = size.expandedTo(item.minimumSize()) margin, _, _, _ = self.getContentsMargins() size += QSize(2 * margin, 2 * margin) return size def doLayout(self, rect, testOnly): x = rect.x() y = rect.y() lineHeight = 0 for item in self.itemList: wid = item.widget() spaceX = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Horizontal) spaceY = self.spacing() + wid.style().layoutSpacing(QSizePolicy.PushButton, QSizePolicy.PushButton, Qt.Vertical) nextX = x + item.sizeHint().width() + spaceX if nextX - spaceX > rect.right() and lineHeight > 0: x = rect.x() y = y + lineHeight + spaceY nextX = x + item.sizeHint().width() + spaceX lineHeight = 0 if not testOnly: item.setGeometry(QRect(QPoint(x, y), item.sizeHint())) x = nextX lineHeight = max(lineHeight, item.sizeHint().height()) return y + lineHeight - rect.y() class LineEdit(QLineEdit): """ Custom Line Edit which sacrifices contextmenu for selectAll """ def __init__(self, parent=None): super().__init__(parent) def mousePressEvent(self, event): if event.button() == Qt.RightButton: self.selectAll() else: super().mousePressEvent(event) def contextMenuEvent(self, QContextMenuEvent): pass def sizeHint(self): s = super().sizeHint() return QSize(400, s.height()) class PathLineEdit(QLineEdit): """ A lineedit which open a filedialog on right/left click Set dir to false if you want files. """ def __init__(self, parent=None, dir=True, filters=utils.FILE_FILTER): super().__init__(parent) self.folder = dir self.filters = filters self.setPlaceholderText('Right/Left-click to open folder explorer.') self.setToolTip('Right/Left-click to open folder explorer.') def openExplorer(self): if self.folder: path = QFileDialog.getExistingDirectory(self, 'Choose folder') else: path = QFileDialog.getOpenFileName(self, 'Choose file', filter=self.filters) path = path[0] if len(path) != 0: self.setText(path) def mousePressEvent(self, event): assert isinstance(event, QMouseEvent) if len(self.text()) == 0: if event.button() == Qt.LeftButton: self.openExplorer() else: return super().mousePressEvent(event) if event.button() == Qt.RightButton: self.openExplorer() super().mousePressEvent(event) class ChapterAddWidget(QWidget): CHAPTERS = pyqtSignal(gallerydb.ChaptersContainer) def __init__(self, gallery, parent=None): super().__init__(parent) self.setWindowFlags(Qt.Window) self.setAttribute(Qt.WA_DeleteOnClose) self.current_chapters = gallery.chapters.count() self.added_chaps = 0 self.gallery = gallery layout = QFormLayout() self.setLayout(layout) lbl = QLabel('{} by {}'.format(gallery.title, gallery.artist)) layout.addRow('Gallery:', lbl) layout.addRow('Current chapters:', QLabel('{}'.format(self.current_chapters))) new_btn = QPushButton('Add directory') new_btn.clicked.connect(lambda: self.add_new_chapter('f')) new_btn.adjustSize() new_btn_a = QPushButton('Add archive') new_btn_a.clicked.connect(lambda: self.add_new_chapter('a')) new_btn_a.adjustSize() add_btn = QPushButton('Finish') add_btn.clicked.connect(self.finish) add_btn.adjustSize() new_l = QHBoxLayout() new_l.addWidget(add_btn, 1, alignment=Qt.AlignLeft) new_l.addWidget(Spacer('h')) new_l.addWidget(new_btn, alignment=Qt.AlignRight) new_l.addWidget(new_btn_a, alignment=Qt.AlignRight) layout.addRow(new_l) frame = QFrame() frame.setFrameShape(frame.StyledPanel) layout.addRow(frame) self.chapter_l = QVBoxLayout() frame.setLayout(self.chapter_l) self.setMaximumHeight(550) self.setFixedWidth(500) if parent: self.move(parent.window().frameGeometry().topLeft() + parent.window().rect().center() - self.rect().center()) else: frect = self.frameGeometry() frect.moveCenter(QDesktopWidget().availableGeometry().center()) self.move(frect.topLeft()) self.setWindowTitle('Add Chapters') def add_new_chapter(self, mode): chap_layout = QHBoxLayout() self.added_chaps += 1 curr_chap = self.current_chapters + self.added_chaps chp_numb = QSpinBox(self) chp_numb.setMinimum(curr_chap - 1) chp_numb.setMaximum(curr_chap + 1) chp_numb.setValue(curr_chap) curr_chap_lbl = QLabel('Chapter {}'.format(curr_chap)) def ch_lbl(n): curr_chap_lbl.setText('Chapter {}'.format(n)) chp_numb.valueChanged[int].connect(ch_lbl) if mode == 'f': chp_path = PathLineEdit() chp_path.setPlaceholderText('Right/Left-click to open folder explorer.' + ' Leave empty to not add.') elif mode == 'a': chp_path = PathLineEdit(dir=False) chp_path.setPlaceholderText('Right/Left-click to open folder explorer.' + ' Leave empty to not add.') chp_path.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) if mode == 'f': chap_layout.addWidget(QLabel('D')) elif mode == 'a': chap_layout.addWidget(QLabel('A')) chap_layout.addWidget(chp_path, 3) chap_layout.addWidget(chp_numb, 0) self.chapter_l.addWidget(curr_chap_lbl, alignment=Qt.AlignLeft) self.chapter_l.addLayout(chap_layout) def finish(self): chapters = self.gallery.chapters widgets = [] x = True while x: x = self.chapter_l.takeAt(0) if x: widgets.append(x) for l in range(1, len(widgets), 1): layout = widgets[l] try: line_edit = layout.itemAt(1).widget() spin_box = layout.itemAt(2).widget() except AttributeError: continue p = line_edit.text() c = spin_box.value() - 1 # because of 0-based index if os.path.exists(p): chap = chapters.create_chapter(c) chap.title = utils.title_parser(os.path.split(p)[1])['title'] chap.path = p if os.path.isdir(p): chap.pages = len(list(scandir.scandir(p))) elif p.endswith(utils.ARCHIVE_FILES): chap.in_archive = 1 arch = utils.ArchiveFile(p) chap.pages = len(arch.dir_contents('')) arch.close() self.CHAPTERS.emit(chapters) self.close() class CustomListItem(QListWidgetItem): def __init__(self, item=None, parent=None, txt='', type=QListWidgetItem.Type): super().__init__(txt, parent, type) self.item = item class CustomTableItem(QTableWidgetItem): def __init__(self, item=None, txt='', type=QTableWidgetItem.Type): super().__init__(txt, type) self.item = item class GalleryListView(QWidget): SERIES = pyqtSignal(list) def __init__(self, parent=None, modal=False): super().__init__(parent) self.setWindowFlags(Qt.Dialog) self.setAttribute(Qt.WA_DeleteOnClose) layout = QVBoxLayout() self.setLayout(layout) if modal: frame = QFrame() frame.setFrameShape(frame.StyledPanel) modal_layout = QHBoxLayout() frame.setLayout(modal_layout) layout.addWidget(frame) info = QLabel('This mode let\'s you add galleries from ' + 'different folders.') f_folder = QPushButton('Add directories') f_folder.clicked.connect(self.from_folder) f_files = QPushButton('Add archives') f_files.clicked.connect(self.from_files) modal_layout.addWidget(info, 3, Qt.AlignLeft) modal_layout.addWidget(f_folder, 0, Qt.AlignRight) modal_layout.addWidget(f_files, 0, Qt.AlignRight) check_layout = QHBoxLayout() layout.addLayout(check_layout) if modal: check_layout.addWidget(QLabel('Please uncheck galleries you do' + ' not want to add. (Exisiting galleries won\'t be added'), 3) else: check_layout.addWidget(QLabel('Please uncheck galleries you do' + ' not want to add. (Existing galleries are hidden)'), 3) self.check_all = QCheckBox('Check/Uncheck All', self) self.check_all.setChecked(True) self.check_all.stateChanged.connect(self.all_check_state) check_layout.addWidget(self.check_all) self.view_list = QListWidget() self.view_list.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn) self.view_list.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.view_list.setAlternatingRowColors(True) self.view_list.setEditTriggers(self.view_list.NoEditTriggers) layout.addWidget(self.view_list) add_btn = QPushButton('Add checked') add_btn.clicked.connect(self.return_gallery) cancel_btn = QPushButton('Cancel') cancel_btn.clicked.connect(self.close_window) btn_layout = QHBoxLayout() spacer = QWidget() spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) btn_layout.addWidget(spacer) btn_layout.addWidget(add_btn) btn_layout.addWidget(cancel_btn) layout.addLayout(btn_layout) self.resize(500,550) frect = self.frameGeometry() frect.moveCenter(QDesktopWidget().availableGeometry().center()) self.move(frect.topLeft()) self.setWindowTitle('Gallery List') self.count = 0 def all_check_state(self, new_state): row = 0 done = False while not done: item = self.view_list.item(row) if item: row += 1 if new_state == Qt.Unchecked: item.setCheckState(Qt.Unchecked) else: item.setCheckState(Qt.Checked) else: done = True def add_gallery(self, item, name): """ Constructs an widgetitem to hold the provided item, and adds it to the view_list """ assert isinstance(name, str) gallery_item = CustomListItem(item) gallery_item.setText(name) gallery_item.setFlags(gallery_item.flags() | Qt.ItemIsUserCheckable) gallery_item.setCheckState(Qt.Checked) self.view_list.addItem(gallery_item) self.count += 1 def update_count(self): self.setWindowTitle('Gallery List ({})'.format(self.count)) def return_gallery(self): gallery_list = [] row = 0 done = False while not done: item = self.view_list.item(row) if not item: done = True else: if item.checkState() == Qt.Checked: gallery_list.append(item.item) row += 1 self.SERIES.emit(gallery_list) self.close() def from_folder(self): file_dialog = QFileDialog() file_dialog.setFileMode(QFileDialog.DirectoryOnly) file_dialog.setOption(QFileDialog.DontUseNativeDialog, True) file_view = file_dialog.findChild(QListView, 'listView') if file_view: file_view.setSelectionMode(QAbstractItemView.MultiSelection) f_tree_view = file_dialog.findChild(QTreeView) if f_tree_view: f_tree_view.setSelectionMode(QAbstractItemView.MultiSelection) if file_dialog.exec(): for path in file_dialog.selectedFiles(): self.add_gallery(path, os.path.split(path)[1]) def from_files(self): gallery_list = QFileDialog.getOpenFileNames(self, 'Select 1 or more gallery to add', filter='Archives ({})'.format(utils.FILE_FILTER)) for path in gallery_list[0]: #Warning: will break when you add more filters if len(path) != 0: self.add_gallery(path, os.path.split(path)[1]) def close_window(self): msgbox = QMessageBox() msgbox.setText('Are you sure you want to cancel?') msgbox.setStandardButtons(msgbox.Yes | msgbox.No) msgbox.setDefaultButton(msgbox.No) msgbox.setIcon(msgbox.Question) if msgbox.exec() == QMessageBox.Yes: self.close() class Loading(BasePopup): ON = False #to prevent multiple instances def __init__(self, parent=None): super().__init__(parent) self.progress = QProgressBar() self.progress.setStyleSheet("color:white") self.text = QLabel() self.text.setAlignment(Qt.AlignCenter) self.text.setStyleSheet("color:white;background-color:transparent;") inner_layout_ = QVBoxLayout() inner_layout_.addWidget(self.text, 0, Qt.AlignHCenter) inner_layout_.addWidget(self.progress) self.main_widget.setLayout(inner_layout_) self.resize(300,100) #frect = self.frameGeometry() #frect.moveCenter(QDesktopWidget().availableGeometry().center()) #self.move(parent.window().frameGeometry().topLeft() + # parent.window().rect().center() - # self.rect().center() - QPoint(self.rect().width(),0)) #self.setAttribute(Qt.WA_DeleteOnClose) #self.setWindowFlags(Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint) def mousePressEvent(self, QMouseEvent): pass def setText(self, string): if string != self.text.text(): self.text.setText(string) class CompleterTextEdit(QTextEdit): """ A textedit with autocomplete """ def __init__(self, **kwargs): super().__init__(**kwargs) self._completer = None log_d('Instantiate CompleterTextEdit: OK') def setCompleter(self, c): if self._completer is not None: self._completer.activated.disconnect() self._completer = c c.setWidget(self) c.setCompletionMode(QCompleter.PopupCompletion) c.setCaseSensitivity(Qt.CaseInsensitive) c.activated.connect(self.insertCompletion) def completer(self): return self._completer def insertCompletion(self, completion): if self._completer.widget() is not self: return tc = self.textCursor() extra = len(completion) - len(self._completer.completionPrefix()) tc.movePosition(QTextCursor.Left) tc.movePosition(QTextCursor.EndOfWord) tc.insertText(completion[-extra:]) self.setTextCursor(tc) def textUnderCursor(self): tc = self.textCursor() tc.select(QTextCursor.WordUnderCursor) return tc.selectedText() def focusInEvent(self, e): if self._completer is not None: self._completer.setWidget(self) super().focusInEvent(e) def keyPressEvent(self, e): if self._completer is not None and self._completer.popup().isVisible(): # The following keys are forwarded by the completer to the widget. if e.key() in (Qt.Key_Enter, Qt.Key_Return, Qt.Key_Escape, Qt.Key_Tab, Qt.Key_Backtab): e.ignore() # Let the completer do default behavior. return isShortcut = e.modifiers() == Qt.ControlModifier and e.key() == Qt.Key_E if self._completer is None or not isShortcut: # Do not process the shortcut when we have a completer. super().keyPressEvent(e) ctrlOrShift = e.modifiers() & (Qt.ControlModifier | Qt.ShiftModifier) if self._completer is None or (ctrlOrShift and len(e.text()) == 0): return eow = "~!@#$%^&*()_+{}|:\"<>?,./;'[]\\-=" hasModifier = (e.modifiers() != Qt.NoModifier) and not ctrlOrShift completionPrefix = self.textUnderCursor() if not isShortcut and (hasModifier or len(e.text()) == 0 or len(completionPrefix) < 3 or e.text()[-1] in eow): self._completer.popup().hide() return if completionPrefix != self._completer.completionPrefix(): self._completer.setCompletionPrefix(completionPrefix) self._completer.popup().setCurrentIndex(self._completer.completionModel().index(0, 0)) cr = self.cursorRect() cr.setWidth(self._completer.popup().sizeHintForColumn(0) + self._completer.popup().verticalScrollBar().sizeHint().width()) if self._completer: self._completer.complete(cr) class GCompleter(QCompleter): def __init__(self, parent=None, title=True, artist=True, tags=True): self.all_data = [] d = set() for g in app_constants.GALLERY_DATA: if title: d.add(g.title) if artist: d.add(g.artist) if tags: for ns in g.tags: d.add(ns) for t in g.tags[ns]: d.add(t) self.all_data.extend(d) super().__init__(self.all_data, parent) self.setCaseSensitivity(Qt.CaseInsensitive) class ChapterListItem(QFrame): move_pos = pyqtSignal(int, object) def __init__(self, chapter, parent=None): super().__init__(parent) main_layout = QHBoxLayout(self) chapter_layout = QFormLayout() self.number_lbl = QLabel(str(chapter.number + 1), self) self.number_lbl.adjustSize() self.number_lbl.setFixedSize(self.number_lbl.size()) self.chapter_lbl = ElidedLabel(self) self.set_chapter_title(chapter) main_layout.addWidget(self.number_lbl) chapter_layout.addRow(self.chapter_lbl) g_title = '' if chapter.gallery: g_title = chapter.gallery.title self.gallery_lbl = ElidedLabel(g_title, self) g_lbl_font = QFont(self.gallery_lbl.font()) g_lbl_font.setPixelSize(g_lbl_font.pixelSize() - 2) g_lbl_font.setItalic(True) self.gallery_lbl.setFont(g_lbl_font) chapter_layout.addRow(self.gallery_lbl) self.chapter = chapter main_layout.addLayout(chapter_layout) buttons_layout = QVBoxLayout() buttons_layout.setSpacing(0) up_btn = QPushButton('▲') up_btn.adjustSize() up_btn.setFixedSize(up_btn.size()) up_btn.clicked.connect(lambda: self.move_pos.emit(0, self)) down_btn = QPushButton('▼') down_btn.adjustSize() down_btn.setFixedSize(down_btn.size()) down_btn.clicked.connect(lambda: self.move_pos.emit(1, self)) buttons_layout.addWidget(up_btn) buttons_layout.addWidget(down_btn) main_layout.addLayout(buttons_layout) def set_chapter_title(self, chapter): if chapter.title: self.chapter_lbl.setText(chapter.title) else: self.chapter_lbl.setText("Chapter " + str(chapter.number + 1)) ================================================ FILE: version/misc_db.py ================================================ #This file is part of Happypanda. #Happypanda is free software: you can redistribute it and/or modify #it under the terms of the GNU General Public License as published by #the Free Software Foundation, either version 2 of the License, or #any later version. #Happypanda is distributed in the hope that it will be useful, #but WITHOUT ANY WARRANTY; without even the implied warranty of #MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #GNU General Public License for more details. #You should have received a copy of the GNU General Public License #along with Happypanda. If not, see . import pickle import logging from PyQt5.QtWidgets import (QTreeWidget, QTreeWidgetItem, QWidget, QVBoxLayout, QTabWidget, QAction, QGraphicsScene, QSizePolicy, QMenu, QAction, QApplication, QListWidget, QHBoxLayout, QPushButton, QStackedLayout, QFrame, QSizePolicy, QListView, QFormLayout, QLineEdit, QLabel, QStyledItemDelegate, QStyleOptionViewItem, QCheckBox, QButtonGroup, QPlainTextEdit) from PyQt5.QtCore import (Qt, QTimer, pyqtSignal, QRect, QSize, QEasingCurve, QSortFilterProxyModel, QIdentityProxyModel, QModelIndex, QPointF, QRectF, QObject) from PyQt5.QtGui import (QIcon, QStandardItem, QFont, QPainter, QColor, QBrush, QPixmap, QPalette) import gallerydb import app_constants import utils import misc import gallery log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical class ToolbarTabManager(QObject): "" def __init__(self, toolbar, parent=None): super().__init__(parent) self.parent_widget = parent self.toolbar = toolbar self._actions = [] self._last_selected = None self.idx_widget = self.toolbar.addWidget(QWidget(self.toolbar)) self.idx_widget.setVisible(False) self.agroup = QButtonGroup(self) self.agroup.setExclusive(True) self.library_btn = None self.favorite_btn = self.addTab("Favorites", delegate_paint=False, icon=app_constants.STAR_ICON) self.library_btn = self.addTab("Library", delegate_paint=False, icon=app_constants.GRIDL_ICON) self.idx_widget = self.toolbar.addWidget(QWidget(self.toolbar)) self.idx_widget.setVisible(False) self.toolbar.addSeparator() def _manage_selected(self, b): if self._last_selected == b: return if self._last_selected: self._last_selected.selected = False self._last_selected.view.list_view.sort_model.rowsInserted.disconnect(self.parent_widget.stat_row_info) self._last_selected.view.list_view.sort_model.rowsRemoved.disconnect(self.parent_widget.stat_row_info) self._last_selected.view.hide() b.selected = True self._last_selected = b self.parent_widget.current_manga_view = b.view b.view.list_view.sort_model.rowsInserted.connect(self.parent_widget.stat_row_info) b.view.list_view.sort_model.rowsRemoved.connect(self.parent_widget.stat_row_info) b.view.show() def addTab(self, name, view_type=app_constants.ViewType.Default, delegate_paint=True, allow_sidebarwidget=False, icon=None): if self.toolbar: t = misc.ToolbarButton(self.toolbar, name) if icon: t.setIcon(icon) else: t.setIcon(app_constants.CIRCLE_ICON) t.setCheckable(True) self.agroup.addButton(t) t.select.connect(self._manage_selected) t.close_tab.connect(self.removeTab) if self.library_btn: t.view = gallery.MangaViews(view_type, self.parent_widget, allow_sidebarwidget) t.view.hide() t.close_tab.connect(lambda:self.library_btn.click()) if not allow_sidebarwidget: t.clicked.connect(self.parent_widget.sidebar_list.arrow_handle.click) else: t.view = self.parent_widget.default_manga_view if delegate_paint: t.view.list_view.manga_delegate._paint_level = 9000 # over nine thousand!!! self._actions.append(self.toolbar.insertWidget(self.idx_widget, t)) return t def removeTab(self, button_or_index): if self.toolbar: if isinstance(button_or_index, int): self.toolbar.removeAction(self._actions.pop(button_or_index)) else: act_to_remove = None for act in self._actions: w = self.toolbar.widgetForAction(act) if w == button_or_index: self.toolbar.removeAction(act) act_to_remove = act break if act_to_remove: self._actions.remove(act) class NoTooltipModel(QIdentityProxyModel): def __init__(self, model, parent=None): super().__init__(parent) self.setSourceModel(model) def data(self, index, role=Qt.DisplayRole): if role == Qt.ToolTipRole: return None if role == Qt.DecorationRole: return app_constants.ARTIST_ICON return self.sourceModel().data(index, role) class UniqueInfoModel(QSortFilterProxyModel): def __init__(self, gallerymodel, role, parent=None): super().__init__(parent) self.setSourceModel(NoTooltipModel(gallerymodel, parent)) self._unique = set() self._unique_role = role self.custom_filter = None self.setDynamicSortFilter(True) def filterAcceptsRow(self, source_row, parent_index): if self.sourceModel(): idx = self.sourceModel().index(source_row, 0, parent_index) if idx.isValid(): unique = idx.data(self._unique_role) if unique: if not unique in self._unique: if self.custom_filter != None: if not idx.data(Qt.UserRole + 1) in self.custom_filter: return False self._unique.add(unique) return True return False def invalidate(self): self._unique.clear() super().invalidate() class ListDelegate(QStyledItemDelegate): def __init__(self, parent=None): self.parent_widget = parent super().__init__(parent) self.create_new_list_txt = 'Create new list...' def sizeHint(self, option, index): size = super().sizeHint(option, index) if index.data(Qt.DisplayRole) == self.create_new_list_txt: return size return QSize(size.width(), size.height() * 2) class GalleryArtistsList(QListView): artist_clicked = pyqtSignal(str) def __init__(self, gallerymodel, parent=None): super().__init__(parent) self.g_artists_model = UniqueInfoModel(gallerymodel, gallerymodel.ARTIST_ROLE, self) self.setModel(self.g_artists_model) self.setModelColumn(app_constants.ARTIST) self.g_artists_model.setSortRole(gallerymodel.ARTIST_ROLE) self.g_artists_model.sort(0) self.doubleClicked.connect(self._artist_clicked) self.ARTIST_ROLE = gallerymodel.ARTIST_ROLE def _artist_clicked(self, idx): if idx.isValid(): self.artist_clicked.emit(idx.data(self.ARTIST_ROLE)) def set_current_glist(self, g_list=None): if g_list: self.g_artists_model.custom_filter = g_list else: self.g_artists_model.custom_filter = None self.g_artists_model.invalidate() class TagsTreeView(QTreeWidget): TAG_SEARCH = pyqtSignal(str) NEW_LIST = pyqtSignal(str, gallerydb.GalleryList) def __init__(self, parent): super().__init__(parent) self.setSelectionBehavior(self.SelectItems) self.setSelectionMode(self.ExtendedSelection) self.clipboard = QApplication.clipboard() self.itemDoubleClicked.connect(lambda i: self.search_tags([i]) if i.parent() else None) def _convert_to_str(self, items): tags = {} d_tags = [] for item in items: ns_item = item.parent() if ns_item.text(0) == 'No namespace': d_tags.append(item.text(0)) continue if ns_item.text(0) in tags: tags[ns_item.text(0)].append(item.text(0)) else: tags[ns_item.text(0)] = [item.text(0)] search_txt = utils.tag_to_string(tags) d_search_txt = '' for x, d_t in enumerate(d_tags, 1): if x == len(d_tags): d_search_txt += '{}'.format(d_t) else: d_search_txt += '{}, '.format(d_t) final_txt = search_txt + ', ' + d_search_txt if search_txt else d_search_txt return final_txt def search_tags(self, items): self.TAG_SEARCH.emit(self._convert_to_str(items)) def create_list(self, items): g_list = gallerydb.GalleryList("New List", filter=self._convert_to_str(items)) g_list.add_to_db() self.NEW_LIST.emit(g_list.name, g_list) def contextMenuEvent(self, event): handled = False selected = False s_items = self.selectedItems() if len(s_items) > 1: selected = True ns_count = 0 for item in s_items: if not item.text(0).islower(): ns_count += 1 contains_ns = True if ns_count > 0 else False def copy(with_ns=False): if with_ns: ns_item = s_items[0].parent() ns = ns_item.text(0) tag = s_items[0].text(0) txt = "{}:{}".format(ns, tag) self.clipboard.setText(txt) else: item = s_items[0] self.clipboard.setText(item.text(0)) if s_items: menu = QMenu(self) if not selected: copy_act = menu.addAction('Copy') copy_act.triggered.connect(copy) if not contains_ns: if s_items[0].parent().text(0) != 'No namespace': copy_ns_act = menu.addAction('Copy with namespace') copy_ns_act.triggered.connect(lambda: copy(True)) if not contains_ns: search_act = menu.addAction('Search') search_act.triggered.connect(lambda: self.search_tags(s_items)) create_list_filter_act = menu.addAction('Create list with selected') create_list_filter_act.triggered.connect(lambda: self.create_list(s_items)) handled = True if handled: menu.exec_(event.globalPos()) event.accept() del menu else: event.ignore() def setup_tags(self): self.clear() tags = gallerydb.execute(gallerydb.TagDB.get_ns_tags, False) items = [] for ns in tags: top_item = QTreeWidgetItem(self) if ns == 'default': top_item.setText(0, 'No namespace') else: top_item.setText(0, ns) for tag in tags[ns]: child_item = QTreeWidgetItem(top_item) child_item.setText(0, tag) self.sortItems(0, Qt.AscendingOrder) class GalleryListEdit(misc.BasePopup): apply = pyqtSignal() def __init__(self, parent=None): super().__init__(parent, blur=False) main_layout = QFormLayout(self.main_widget) self.name_edit = QLineEdit(self) main_layout.addRow("Name:", self.name_edit) self.filter_edit = QPlainTextEdit(self) self.filter_edit.setPlaceholderText("tag1, namespace:tag2, namespace2:[tag1, tag2] ...") self.filter_edit.setFixedHeight(100) what_is_filter = misc.ClickedLabel("What is Filter/Enforce? (Hover)") what_is_filter.setToolTip(app_constants.WHAT_IS_FILTER) what_is_filter.setToolTipDuration(9999999999) self.enforce = QCheckBox(self) self.regex = QCheckBox(self) self.case = QCheckBox(self) self.strict = QCheckBox(self) main_layout.addRow(what_is_filter) main_layout.addRow("Filter", self.filter_edit) main_layout.addRow("Enforce", self.enforce) main_layout.addRow("Regex", self.regex) main_layout.addRow("Case sensitive", self.case) main_layout.addRow("Match whole terms", self.strict) main_layout.addRow(self.buttons_layout) self.add_buttons("Close")[0].clicked.connect(self.hide) self.add_buttons("Apply")[0].clicked.connect(self.accept) old_v = self.width() self.adjustSize() self.resize(old_v, self.height()) def set_list(self, gallery_list, item): self.gallery_list = gallery_list self.name_edit.setText(gallery_list.name) self.enforce.setChecked(gallery_list.enforce) self.regex.setChecked(gallery_list.regex) self.case.setChecked(gallery_list.case) self.strict.setChecked(gallery_list.strict) self.item = item if gallery_list.filter: self.filter_edit.setPlainText(gallery_list.filter) else: self.filter_edit.setPlainText('') def accept(self): name = self.name_edit.text() self.item.setText(name) self.gallery_list.name = name self.gallery_list.filter = self.filter_edit.toPlainText() self.gallery_list.enforce = self.enforce.isChecked() self.gallery_list.regex = self.regex.isChecked() self.gallery_list.case = self.case.isChecked() self.gallery_list.strict = self.strict.isChecked() gallerydb.execute(gallerydb.ListDB.modify_list, True, self.gallery_list) self.apply.emit() self.hide() class GalleryListContextMenu(QMenu): def __init__(self, item, sidebar): super().__init__(sidebar) self.sidebar_widget = sidebar self.item = item self.gallery_list = item.item edit = self.addAction("Edit", self.edit_list) clear = self.addAction("Clear", self.clear_list) remove = self.addAction("Delete", self.remove_list) def edit_list(self): self.sidebar_widget.gallery_list_edit.set_list(self.gallery_list, self.item) self.sidebar_widget.gallery_list_edit.show() def remove_list(self): self.sidebar_widget.takeItem(self.sidebar_widget.row(self.item)) gallerydb.execute(gallerydb.ListDB.remove_list, True, self.gallery_list) self.sidebar_widget.GALLERY_LIST_REMOVED.emit() def clear_list(self): self.gallery_list.clear() self.sidebar_widget.GALLERY_LIST_CLICKED.emit(self.gallery_list) class GalleryLists(QListWidget): CREATE_LIST_TYPE = misc.CustomListItem.UserType + 1 GALLERY_LIST_CLICKED = pyqtSignal(gallerydb.GalleryList) GALLERY_LIST_REMOVED = pyqtSignal() def __init__(self, parent): super().__init__(parent) self.gallery_list_edit = GalleryListEdit(parent.parent_widget) self.gallery_list_edit.hide() self._g_list_icon = app_constants.G_LISTS_ICON self._font_selected = QFont(self.font()) self._font_selected.setBold(True) self._font_selected.setUnderline(True) self.itemDoubleClicked.connect(self._item_double_clicked) self.setItemDelegate(ListDelegate(self)) self.itemDelegate().closeEditor.connect(self._add_new_list) self.setEditTriggers(self.NoEditTriggers) self.viewport().setAcceptDrops(True) self._in_proccess_item = None self.current_selected = None self.gallery_list_edit.apply.connect(lambda: self._item_double_clicked(self.current_selected)) self.setup_lists() def dragEnterEvent(self, event): if event.mimeData().hasFormat("list/gallery"): event.acceptProposedAction() else: event.ignore() def dragMoveEvent(self, event): item = self.itemAt(event.pos()) self.clearSelection() if item: item.setSelected(True) event.accept() def dropEvent(self, event): galleries = [] galleries = pickle.loads(event.mimeData().data("list/gallery").data()) g_list_item = self.itemAt(event.pos()) if galleries and g_list_item: txt = "{} galleries".format(len(galleries)) if len(galleries) > 1 else galleries[0].title app_constants.NOTIF_BUBBLE.update_text(g_list_item.item.name, 'Added: {}!'.format(txt), 7) log_i('Added {} to {}...'.format(txt, g_list_item.item.name)) g_list_item.item.add_gallery(galleries) super().dropEvent(event) def _add_new_list(self, lineedit=None, hint=None, gallery_list=None): if not self._in_proccess_item.text(): self.takeItem(self.row(self._in_proccess_item)) return new_item = self._in_proccess_item if not gallery_list: new_list = gallerydb.GalleryList(new_item.text()) new_list.add_to_db() else: new_list = gallery_list new_item.item = new_list new_item.setIcon(self._g_list_icon) self.sortItems() def create_new_list(self, name=None, gallery_list=None): new_item = misc.CustomListItem() self._in_proccess_item = new_item new_item.setFlags(new_item.flags() | Qt.ItemIsEditable) new_item.setIcon(QIcon(app_constants.LIST_ICON)) self.insertItem(0, new_item) if name: new_item.setText(name) self._add_new_list(gallery_list=gallery_list) else: self.editItem(new_item) def _item_double_clicked(self, item): if item: self._reset_selected() if item.item.filter: app_constants.NOTIF_BUBBLE.update_text(item.item.name, "Updating list..", 5) gallerydb.execute(item.item.scan, True) self.GALLERY_LIST_CLICKED.emit(item.item) item.setFont(self._font_selected) self.current_selected = item def _reset_selected(self): if self.current_selected: self.current_selected.setFont(self.font()) def setup_lists(self): for g_l in app_constants.GALLERY_LISTS: if g_l.type == gallerydb.GalleryList.REGULAR: self.create_new_list(g_l.name, g_l) def contextMenuEvent(self, event): item = self.itemAt(event.pos()) if item and item.type() != self.CREATE_LIST_TYPE: menu = GalleryListContextMenu(item, self) menu.exec_(event.globalPos()) event.accept() return event.ignore() class SideBarWidget(QFrame): """ """ def __init__(self, parent): super().__init__(parent) self.setAcceptDrops(True) self.parent_widget = parent self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.MinimumExpanding) self._widget_layout = QHBoxLayout(self) # widget stuff self._d_widget = QWidget(self) self._d_widget.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.MinimumExpanding) self._widget_layout.addWidget(self._d_widget) self.main_layout = QVBoxLayout(self._d_widget) self.main_layout.setSpacing(0) self.main_layout.setContentsMargins(0,0,0,0) self.arrow_handle = misc.ArrowHandle(self) self.arrow_handle.CLICKED.connect(self.slide) self._widget_layout.addWidget(self.arrow_handle) self.setContentsMargins(0,0,-self.arrow_handle.width(),0) self.show_all_galleries_btn = QPushButton("Show all galleries") self.show_all_galleries_btn.clicked.connect(lambda:parent.manga_list_view.sort_model.set_gallery_list()) self.show_all_galleries_btn.clicked.connect(self.show_all_galleries_btn.hide) self.show_all_galleries_btn.setIcon(app_constants.CROSS_ICON_WH) self.show_all_galleries_btn.hide() self.main_layout.addWidget(self.show_all_galleries_btn) self.main_buttons_layout = QHBoxLayout() self.main_layout.addLayout(self.main_buttons_layout) # buttons bgroup = QButtonGroup(self) bgroup.setExclusive(True) self.lists_btn = QPushButton("") self.lists_btn.setIcon(app_constants.G_LISTS_ICON_WH) self.lists_btn.setCheckable(True) bgroup.addButton(self.lists_btn) self.artist_btn = QPushButton("") self.artist_btn.setIcon(app_constants.ARTISTS_ICON) self.artist_btn.setCheckable(True) bgroup.addButton(self.artist_btn) self.ns_tags_btn = QPushButton("") self.ns_tags_btn.setIcon(app_constants.NSTAGS_ICON) self.ns_tags_btn.setCheckable(True) bgroup.addButton(self.ns_tags_btn) self.lists_btn.setChecked(True) self.main_buttons_layout.addWidget(self.lists_btn) self.main_buttons_layout.addWidget(self.artist_btn) self.main_buttons_layout.addWidget(self.ns_tags_btn) # buttons contents self.stacked_layout = QStackedLayout() self.main_layout.addLayout(self.stacked_layout) # lists gallery_lists_dummy = QWidget(self) self.lists = GalleryLists(self) create_new_list_btn = QPushButton() create_new_list_btn.setIcon(QIcon(app_constants.PLUS_ICON)) create_new_list_btn.setIconSize(QSize(15, 15)) create_new_list_btn.clicked.connect(lambda: self.lists.create_new_list()) create_new_list_btn.adjustSize() create_new_list_btn.setFixedSize(create_new_list_btn.width(), create_new_list_btn.height()) create_new_list_btn.setToolTip("Create a new list!") lists_l = QVBoxLayout(gallery_lists_dummy) lists_l.setContentsMargins(0,0,0,0) lists_l.setSpacing(0) lists_l.addWidget(self.lists) lists_l.addWidget(create_new_list_btn) lists_index = self.stacked_layout.addWidget(gallery_lists_dummy) self.lists.GALLERY_LIST_CLICKED.connect(parent.manga_list_view.sort_model.set_gallery_list) self.lists.GALLERY_LIST_CLICKED.connect(self.show_all_galleries_btn.show) self.lists.GALLERY_LIST_REMOVED.connect(self.show_all_galleries_btn.click) self.lists_btn.clicked.connect(lambda:self.stacked_layout.setCurrentIndex(lists_index)) self.show_all_galleries_btn.clicked.connect(self.lists.clearSelection) self.show_all_galleries_btn.clicked.connect(self.lists._reset_selected) # artists self.artists_list = GalleryArtistsList(parent.manga_list_view.gallery_model, self) self.artists_list.artist_clicked.connect(lambda a: parent.search('artist:"{}"'.format(a))) artists_list_index = self.stacked_layout.addWidget(self.artists_list) self.artist_btn.clicked.connect(lambda:self.stacked_layout.setCurrentIndex(artists_list_index)) #self.lists.GALLERY_LIST_CLICKED.connect(self.artists_list.set_current_glist) self.show_all_galleries_btn.clicked.connect(self.artists_list.clearSelection) #self.show_all_galleries_btn.clicked.connect(lambda:self.artists_list.set_current_glist()) # ns_tags self.tags_tree = TagsTreeView(self) self.tags_tree.TAG_SEARCH.connect(parent.search) self.tags_tree.NEW_LIST.connect(self.lists.create_new_list) self.tags_tree.setHeaderHidden(True) self.show_all_galleries_btn.clicked.connect(self.tags_tree.clearSelection) self.tags_layout = QVBoxLayout(self.tags_tree) ns_tags_index = self.stacked_layout.addWidget(self.tags_tree) self.ns_tags_btn.clicked.connect(lambda:self.stacked_layout.setCurrentIndex(ns_tags_index)) self.slide_animation = misc.create_animation(self, "maximumSize") self.slide_animation.stateChanged.connect(self._slide_hide) self.slide_animation.setEasingCurve(QEasingCurve.InOutQuad) def _slide_hide(self, state): size = self.sizeHint() if state == self.slide_animation.Stopped: if self.arrow_handle.current_arrow == self.arrow_handle.OUT: self._d_widget.hide() elif self.slide_animation.Running: if self.arrow_handle.current_arrow == self.arrow_handle.IN: if not self.parent_widget.current_manga_view.allow_sidebarwidget: self.arrow_handle.current_arrow = self.arrow_handle.OUT self.arrow_handle.update() else: self._d_widget.show() def slide(self, state): self.slide_animation.setEndValue(QSize(self.arrow_handle.width() * 2, self.height())) if state: self.slide_animation.setDirection(self.slide_animation.Forward) self.slide_animation.start() else: self.slide_animation.setDirection(self.slide_animation.Backward) self.slide_animation.start() def showEvent(self, event): super().showEvent(event) if not app_constants.SHOW_SIDEBAR_WIDGET: self.arrow_handle.click() def _init_size(self, event=None): h = self.parent_widget.height() self._max_width = 250 self.updateGeometry() self.setMaximumWidth(self._max_width) self.slide_animation.setStartValue(QSize(self._max_width, h)) def resizeEvent(self, event): self._init_size(event) return super().resizeEvent(event) class DBOverview(QWidget): """ """ about_to_close = pyqtSignal() def __init__(self, parent, window=False): if window: super().__init__(None, Qt.Window) else: super().__init__(parent) self.setAttribute(Qt.WA_DeleteOnClose) self.parent_widget = parent main_layout = QVBoxLayout(self) tabbar = QTabWidget(self) main_layout.addWidget(tabbar) # Tags stats self.tags_stats = QListWidget(self) tabbar.addTab(self.tags_stats, 'Statistics') tabbar.setTabEnabled(1, False) # About AD self.about_db = QWidget(self) tabbar.addTab(self.about_db, 'DB Info') tabbar.setTabEnabled(2, False) self.resize(300, 600) self.setWindowTitle('DB Overview') self.setWindowIcon(QIcon(app_constants.APP_ICO_PATH)) def setup_stats(self): pass def setup_about_db(self): pass def closeEvent(self, event): self.about_to_close.emit() return super().closeEvent(event) ================================================ FILE: version/pewnet.py ================================================ #""" #This file is part of Happypanda. #Happypanda is free software: you can redistribute it and/or modify #it under the terms of the GNU General Public License as published by #the Free Software Foundation, either version 2 of the License, or #any later version. #Happypanda is distributed in the hope that it will be useful, #but WITHOUT ANY WARRANTY; without even the implied warranty of #MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #GNU General Public License for more details. #You should have received a copy of the GNU General Public License #along with Happypanda. If not, see . #""" import html import logging import os import random import re as regex import requests import shutil import threading import time import uuid from datetime import datetime from queue import Queue from tempfile import ( NamedTemporaryFile, mkstemp ) from bs4 import BeautifulSoup from robobrowser import RoboBrowser from robobrowser.exceptions import RoboError from PyQt5.QtCore import QObject, pyqtSignal import app_constants import utils import settings from utils import makedirs_if_not_exists log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical class DownloaderItem(QObject): "Convenience class" IN_QUEUE, DOWNLOADING, FINISHED, CANCELLED = range(4) file_rdy = pyqtSignal(object) def __init__(self, url="", session=None): super().__init__() self.session = session self.download_url = url self.file = "" self.name = "" self.total_size = 0 self.current_size = 0 self.current_state = self.IN_QUEUE def cancel(self): self.current_state = self.CANCELLED def open(self, containing=False): if self.file: if containing: p = os.path.split(self.file)[0] utils.open_path(p, self.file) else: utils.open_path(self.file) class Downloader(QObject): """ A download manager. Emits signal item_finished with tuple of url and path to file when a download finishes """ _inc_queue = Queue() _browser_session = None _threads = [] item_finished = pyqtSignal(object) active_items = [] def __init__(self): super().__init__() # download dir self.base = os.path.abspath(app_constants.DOWNLOAD_DIRECTORY) if not os.path.exists(self.base): os.mkdir(self.base) @staticmethod def add_to_queue(item, session=None, dir=None): """ Add a DownloaderItem or url An optional requests.Session object can be specified A temp dir to be used can be specified Returns a downloader item """ if isinstance(item, str): item = DownloaderItem(item) log_i("Adding item to download queue: {}".format(item.download_url)) if dir: Downloader._inc_queue.put({'dir':dir, 'item':item}) else: Downloader._inc_queue.put(item) Downloader._session = session return item @staticmethod def remove_file(filename): """Remove file and ignore any error when doing it. Args: filename: filename to be removed. """ try: os.remove(filename) except: pass @staticmethod def _get_total_size(response): """get total size from requests response. Args: response (requests.Response): Response from request. """ try: return int(response.headers['content-length']) except KeyError: return 0 def _get_response(self, url): """get response from url. Args: url : Url of the response Returns: requests.Response: Response from url """ if self._browser_session: r = self._browser_session.get(url, stream=True) else: r = requests.get(url, stream=True) return r def _get_item_and_temp_base(self): """get item and temporary folder if specified. Returns: tuple: (item, temp_base), where temp_base is the temporary folder. """ item = self._inc_queue.get() temp_base = None if isinstance(item, dict): temp_base = item['dir'] item = item['item'] return item, temp_base def _get_filename(self, item, temp_base=None): """get filename based on input. Args: item: Download item temp_base: Optional temporary folder Returns: str: Edited filename """ file_name = item.name if item.name else str(uuid.uuid4()) invalid_chars = '\\/:*?"<>|' for x in invalid_chars: file_name = file_name.replace(x, '') file_name = os.path.join(self.base, file_name) if not temp_base else \ os.path.join(temp_base, file_name) return file_name @staticmethod def _download_with_simple_method(target_file, response, item, interrupt_state): """download single file with simple method. Args: target_file: Target filename where url will be downloaded. response (requests.Response): Response from url. item: Download item. interrupt_state (bool): Interrupt state. Returns: tuple: (item, interrupt_state) where both variables is the changed variables from input. """ chunk_size = 1024 with open(target_file, 'wb') as f: for data in response.iter_content(chunk_size=chunk_size): if item.current_state == item.CANCELLED: interrupt_state = True break if data: item.current_size += len(data) f.write(data) f.flush() return item, interrupt_state @staticmethod def _download_with_catch_error( target_file, response, item, interrupt_state, use_tempfile=False, catch_errors=None ): """Download single file from url response and return changed item and interrupt state. Args: target_file: Target filename where url will be downloaded. response (requests.Response): Response from url. item: Download item. interrupt_state (bool): Interrupt state. use_tempfile (bool): Use tempfile when downloading or not. catch_errors (tuple): List of error that will be catched when downloading. Returns: tuple: (item, interrupt_state) where both variables is the changed variables from input. """ if catch_errors is None: catch_errors = tuple() # compatibility DownloaderObject = Downloader download_finished = False while not download_finished: try: item, interrupt_state = DownloaderObject._download_single_file( target_file=target_file, response=response, item=item, interrupt_state=interrupt_state, use_tempfile=use_tempfile ) download_finished = True except catch_errors as err: log_d('Redownloading because following error.\n{}'.format(err)) return item, interrupt_state @staticmethod def _download_with_tempfile_windows( target_file, response, item, interrupt_state ): """Download file with tempfile return changed item and interrupt state. method used on window taken and modified from http://stackoverflow.com/a/15259358 Args: target_file: Target filename where url will be downloaded. response (requests.Response): Response from url. item: Download item. interrupt_state (bool): Interrupt state. Returns: tuple: (item, interrupt_state) where both variables is the changed variables from input. """ # compatibilty DownloaderObject = Downloader closed_ = False deleted_ = False file_, tempfile = mkstemp() try: item, interrupt_state = DownloaderObject._download_single_file( target_file=tempfile, response=response, item=item, interrupt_state=interrupt_state, use_tempfile=False ) if item.current_state != item.CANCELLED: os.close(file_) closed_ = True shutil.copyfile(tempfile, target_file) os.remove(tempfile) deleted_ = True finally: if not closed_: os.close(file_) if not deleted_: os.remove(tempfile) return item, interrupt_state @staticmethod def _download_single_file( target_file, response, item, interrupt_state, use_tempfile=False, catch_errors=None ): """Download single file from url response and return changed item and interrupt state. this method is wrapper for these methods:: - _download_with_catch_error - _download_with_simple_method Note: item's current size may not give exact size. especially when there is multiple interupt and tempfile is used. Args: target_file: Target filename where url will be downloaded. response (requests.Response): Response from url. item: Download item. interrupt_state (bool): Interrupt state. use_tempfile (bool): Use tempfile when downloading or not. catch_errors (tuple): List of error that will be catched when downloading. Returns: tuple: (item, interrupt_state) where both variables is the changed variables from input. """ # compatibilty DownloaderObject = Downloader if catch_errors: item, interrupt_state = DownloaderObject._download_with_catch_error( target_file=target_file, response=response, item=item, interrupt_state=interrupt_state, use_tempfile=use_tempfile, catch_errors=catch_errors ) elif use_tempfile: if app_constants.OS_NAME == 'windows': item, interrupt_state = DownloaderObject._download_with_tempfile_windows( target_file=target_file, response=response, item=item, interrupt_state=interrupt_state, ) else: # unix with NamedTemporaryFile() as tempfile: item, interrupt_state = DownloaderObject._download_single_file( target_file=tempfile.name, response=response, item=item, interrupt_state=interrupt_state, use_tempfile=False ) if item.current_state != item.CANCELLED: shutil.copyfile(tempfile.name, target_file) else: item, interrupt_state = DownloaderObject._download_with_simple_method( target_file=target_file, response=response, item=item, interrupt_state=interrupt_state, ) return item, interrupt_state @staticmethod def _rename_file(filename, filename_part, max_loop=100): """Custom rename file method. Args: filename: Target filename. filename_part: Temporary filename max_loop (int): Maximal loop on error when renaming the file. Returns: str: Filename or filename_part """ # compatibility file_name = filename file_name_part = filename_part n = 0 file_split = os.path.split(file_name) while n < max_loop: try: if file_split[1]: src_file = file_split[0] target_file = "({}){}".format(n, file_split[1]) else: src_file = file_name_part target_file = "({}){}".format(n, file_name) os.rename(src_file, target_file) break except: n += 1 if n > max_loop: file_name = file_name_part return file_name @staticmethod def _get_total_size_prediction(known_filesize, urls_len): """get total size prediction. Args: known_filesize (list): List of known filesize. urls_len (int): Number of urls_len Returns: int: Total size predictions. """ if not known_filesize: # empty list return 0 if len(known_filesize) == urls_len: return int(sum(known_filesize)) return int(sum(known_filesize) * urls_len / len(known_filesize)) @staticmethod def _get_local_filesize(path): """Get local filesize. Args: path: Path of the file. Returns: filesize of the file or zero. """ try: return os.path.getsize(path) except OSError: return 0 def _download_item_with_multiple_dl_url(self, item, folder, interrupt_state): """download item with multiple download url. This method is modified from _download_item_with_single_dl_url method. Important changes:: - Create new folder for download. - Method to calculate total size - item.file is now folder name instead of filename Args: item: Item with single download url. folder (str): Folder for downloaded file. interrupt_state (bool): Interrupt state Returns: Modified item """ download_url = item.download_url total_known_filesize = [] download_url_len = len(download_url) makedirs_if_not_exists(folder) for single_url in download_url: # response r = self._get_response(url=single_url) # get total size current_response_filesize = self._get_total_size(response=r) total_known_filesize.append(current_response_filesize) item.total_size = self._get_total_size_prediction( known_filesize=total_known_filesize, urls_len=download_url_len) url_basename = os.path.basename(single_url) target_file = os.path.join(folder, url_basename) target_filesize = self._get_local_filesize(path=target_file) if target_filesize == current_response_filesize and target_filesize != 0: item.current_size += current_response_filesize log_d('File is already downloaded.\n{}'.format(target_file)) else: # downloading to temp file (file_name_part) item, interrupt_state = self._download_single_file( target_file=target_file, response=r, item=item, interrupt_state=interrupt_state, use_tempfile=True, catch_errors=(requests.ConnectionError,) # NOTE: # You can't catch when in list, only tuple # This causes a TypeError: # try: # raise Exception # except [Exception] as err: # pass # But this doesn't: # try: # raise Exception # except (Exception,) as err: # pass ) if not interrupt_state: item.current_state = item.FINISHED item.file = folder # emit item.file_rdy.emit(item) self.item_finished.emit(item) return item def _download_item_with_single_dl_url(self, item, filename, interrupt_state): """download item with single download url. Args: item: Item with single download url. filename (str): Filename for downloaded file. interrupt_state (bool): Interrupt state Returns: Modified item """ # compatibility file_name = filename interrupt = interrupt_state download_url = item.download_url file_name_part = file_name + '.part' # response r = self._get_response(url=download_url) # get total size item.total_size = self._get_total_size(response=r) # downloading to temp file (file_name_part) item, interrupt = self._download_single_file( target_file=file_name_part, response=r, item=item, interrupt_state=interrupt) if not interrupt: # post operation when no interrupt try: os.rename(file_name_part, file_name) except OSError: file_name = self._rename_file( filename=file_name, filename_part=file_name_part) item.file = file_name item.current_state = item.FINISHED # emit item.file_rdy.emit(item) self.item_finished.emit(item) else: self.remove_file(filename=file_name_part) return item def _downloading(self): "The downloader. Put in a thread." while True: log_d("Download items in queue: {}".format(self._inc_queue.qsize())) interrupt = False item, temp_base = self._get_item_and_temp_base() log_d("Stating item download") item.current_state = item.DOWNLOADING file_name = self._get_filename(item=item, temp_base=temp_base) download_url = item.download_url log_d("Download url:{}".format(download_url)) self.active_items.append(item) if isinstance(item.download_url, list): # NOTE: file_name will be used as folder name when multiple url. item = self._download_item_with_multiple_dl_url( item=item, folder=file_name, interrupt_state=interrupt) else: item = self._download_item_with_single_dl_url( item=item, filename=file_name, interrupt_state=interrupt) log_d("Items in queue {}".format(self._inc_queue.empty())) log_d("Finished downloading: {}".format(download_url)) self.active_items.remove(item) self._inc_queue.task_done() def start_manager(self, max_tasks): "Starts download manager where max simultaneous is mask_tasks" log_i("Starting download manager with {} jobs".format(max_tasks)) for x in range(max_tasks): thread = threading.Thread( target=self._downloading, name='Downloader {}'.format(x), daemon=True) thread.start() self._threads.append(thread) class HenItem(DownloaderItem): "A convenience class that most methods in DLManager and its subclasses returns" thumb_rdy = pyqtSignal(object) def __init__(self, session=None): super().__init__(session=session) self.thumb_url = "" # an url to gallery thumb self.thumb = None self.cost = "0" self.size = "" self.metadata = {} self.gallery_name = "" self.gallery_url = "" self.download_type = app_constants.DOWNLOAD_TYPE_OTHER self.torrents_found = 0 self.file_rdy.connect(self.check_type) def fetch_thumb(self): "Fetches thumbnail. Emits thumb_rdy, when done" def thumb_fetched(): self.thumb = self._thumb_item.file self.thumb_rdy.emit(self) self._thumb_item = Downloader.add_to_queue(self.thumb_url, self.session, app_constants.temp_dir) self._thumb_item.file_rdy.connect(thumb_fetched) def check_type(self): if self.download_type == app_constants.DOWNLOAD_TYPE_TORRENT: utils.open_torrent(self.file) def update_metadata(self, key, value): """ Recommended way of inserting metadata. Keeps the original EH API response structure Remember to call commit_metadata when done! """ if not self.metadata: self.metadata = { "gmetadata": [ { "gid":1, "title": "", "title_jpn": "", "category": "Manga", "uploader": "", "Posted": "", "filecount": "0", "filesize": 0, "expunged": False, "rating": "0", "torrentcount": "0", "tags":[] } ] } try: metadata = self.metadata['gmetadata'][0] except KeyError: return metadata[key] = value def commit_metadata(self): "Call this method when done updating metadata" g_id = 'sample' try: d_m = {self.metadata['gmetadata'][0]['gid']:g_id} except KeyError: return self.metadata = EHen.parse_metadata(self.metadata, d_m)[g_id] class DLManager(QObject): "Base class for site-specific download managers" _browser = RoboBrowser(history=True, user_agent="Mozilla/5.0 (Windows NT 6.3; rv:36.0) Gecko/20100101 Firefox/36.0", parser='html.parser', allow_redirects=False) def __init__(self, download_type=app_constants.DOWNLOAD_TYPE_OTHER): super().__init__() self._download_type = download_type def _error(self): pass def from_gallery_url(self, url): """ Needs to be implemented in site-specific subclass URL checking and class instantiating is done in GalleryDownloader class in io_misc.py Basic procedure for this method: - open url with self._browser and do the parsing - create HenItem and fill out its attributes - specify download type (important) from app_constants - fetch optional thumbnail on HenItem - set download url on HenItem (important) - add h_item to download queue - return h-item if everything went successfully, else return none Metadata should imitiate the offical EH API response. It is recommended to use update_metadata in HenItem when adding metadata see the ChaikaManager class for a complete example EH API: http://ehwiki.org/wiki/API """ raise NotImplementedError def ensure_browser_on_url(self, url): """open browser on input url if not already. Args: url: Url where browser to open (or alreadery opened) """ open_url = False # assume not opening the url try: current_url = self._browser.url if current_url != url: open_url = True except RoboError: open_url = True if open_url: self._browser.open(url) class ChaikaManager(DLManager): "panda.chaika.moe manager" def __init__(self): super().__init__() self.url = "http://panda.chaika.moe/" self.api = "http://panda.chaika.moe/jsearch/?" def from_gallery_url(self, url): h_item = HenItem(self._browser.session) h_item.download_type = self._download_type chaika_id = os.path.split(url) if chaika_id[1]: chaika_id = chaika_id[1] else: chaika_id = os.path.split(chaika_id[0])[1] if '/gallery/' in url: a_id = self._gallery_page(chaika_id, h_item) if not a_id: return self._archive_page(a_id, h_item) elif '/archive' in url: g_id = self._archive_page(chaika_id, h_item) if not g_id: return self._gallery_page(g_id, h_item) else: return h_item.commit_metadata() h_item.name = h_item.gallery_name+'.zip' Downloader.add_to_queue(h_item, self._browser.session) return h_item def _gallery_page(self, g_id, h_item): "Returns url to archive and updates h_item metadata from the /gallery/g_id page" g_url = self.api + "gallery={}".format(g_id) r = requests.get(g_url) try: r.raise_for_status() chaika = r.json() h_item.update_metadata('title', chaika['title']) h_item.update_metadata('title_jpn', chaika['title_jpn']) h_item.update_metadata('category', chaika['category']) h_item.update_metadata('rating', chaika['rating']) h_item.update_metadata('filecount', chaika['filecount']) h_item.update_metadata('filesize', chaika['filesize']) h_item.update_metadata('posted', chaika['posted']) h_item.gallery_name = chaika['title'] h_item.gallery_url = self.url + "gallery/{}".format(g_id) h_item.size = "{0:.2f} MB".format(chaika['filesize']/1048576) tags = [] for t in chaika['tags']: tag = t.replace('_', ' ') tags.append(tag) h_item.update_metadata('tags', tags) if chaika['archives']: h_item.download_url = self.url + chaika['archives'][0]['download'][1:] return chaika['archives'][0]['id'] except AttributeError: log.exception("HTML parsing error") raise app_constants.HTMLParsing except requests.ConnectionError: log.exception("Connection Error") def _archive_page(self, a_id, h_item): "Returns url to gallery and updates h_item metadata from the /archive/a_id page" a_url = self.api + "archive={}".format(a_id) r = requests.get(a_url) try: r.raise_for_status() chaika = r.json() return chaika['gallery'] except requests.ConnectionError: log.exception('Error parsing chaika') class HenManager(DLManager): "G.e or Ex gallery manager" def __init__(self): super().__init__() if app_constants.HEN_DOWNLOAD_TYPE: self._download_type = app_constants.DOWNLOAD_TYPE_TORRENT else: self._download_type = app_constants.DOWNLOAD_TYPE_ARCHIVE self.e_url = 'https://e-hentai.org/' exprops = settings.ExProperties() cookies = exprops.cookies if not cookies: if exprops.username and exprops.password: cookies = EHen.login(exprops.username, exprops.password) else: raise app_constants.NeedLogin self._browser.session.cookies.update(cookies) def _archive_url_d(self, gid, token, key): "Returns the archiver download url" base = self.e_url + 'archiver.php?' d_url = base + 'gid=' + str(gid) + '&token=' + token + '&or=' + key return d_url def _torrent_url_d(self, gid, token): "Returns the torrent download url and filename" try: base = self.e_url + 'gallerytorrents.php?' torrent_page = base + 'gid=' + str(gid) + '&t=' + token self._browser.open(torrent_page) torrents = self._browser.find_all('table') if not torrents: return torrent = None # [seeds, url, name] for t in torrents: parts = t.find_all('tr') # url & name url = parts[2].td.a.get('href') name = parts[2].td.a.text + '.torrent' # seeds peers etc... NOT uploader meta = [x.text for x in parts[0].find_all('td')] seed_txt = meta[3] # extract number seeds = int(seed_txt.split(' ')[1]) if not torrent: torrent = [seeds, url, name] else: if seeds > torrent[0]: torrent = [seeds, url, name] _, url, name = torrent # just get download url # TODO: make user choose? return url, name except AttributeError: raise app_constants.HTMLParsing @staticmethod def gtoEh(g_url): "convert g.e-h to e-h" if 'g.e-hentai' in g_url: g_url = g_url.replace('g.e-hentai', 'e-hentai') if not 'https' in g_url and 'http' in g_url: g_url = g_url.replace('http', 'https') return g_url def from_gallery_url(self, g_url): """ Finds gallery download url and puts it in download queue """ if not g_url: return False if 'exhentai' in g_url: hen = ExHen(settings.ExProperties().cookies) if not hen.check_login(hen.cookies) == 2: raise app_constants.NeedLogin else: hen = EHen() if not hen.check_login(hen.cookies): raise app_constants.NeedLogin log_d("Using {}".format(hen.__repr__())) api_metadata, gallery_gid_dict = hen.add_to_queue(g_url, True, False) gallery = api_metadata['gmetadata'][0] log_d("EH API:\n\t".format(gallery)) h_item = HenItem(self._browser.session) h_item.download_type = self._download_type h_item.gallery_url = g_url h_item.metadata = EHen.parse_metadata(api_metadata, gallery_gid_dict) try: h_item.metadata = h_item.metadata[g_url] except KeyError: raise app_constants.WrongURL h_item.thumb_url = gallery['thumb'] h_item.gallery_name = gallery['title'] h_item.size = "{0:.2f} MB".format(gallery['filesize']/1048576) if self._download_type == app_constants.DOWNLOAD_TYPE_ARCHIVE: try: d_url = self._archive_url_d(gallery['gid'], gallery['token'], gallery['archiver_key']) # ex/g.e log_d("Opening {}".format(d_url)) self._browser.open(d_url) # check for availability log_d(self._browser.parsed) if 'gallery is currently unavailable' in '{}'.format(self._browser.parsed): raise app_constants.GNotAvailable download_btn = self._browser.get_form() if download_btn: log_d("Parsing download button!") f_div = self._browser.find('div', id='db') divs = f_div.find_all('div') h_item.cost = divs[0].find('strong').text h_item.cost = divs[0].find('strong').text h_item.size = divs[1].find('strong').text self._browser.submit_form(download_btn) log_d("Submitted download button!") if self._browser.response.status_code == 302: self._browser.open(self._browser.response.headers['location'], "post") # get dl link log_d("Getting download URL!") continue_p = self._browser.find("p", id="continue") if continue_p: dl = continue_p.a.get('href') else: dl_a = self._browser.find('a') dl = dl_a.get('href') if 'forums.e-hentai.org' in dl: raise app_constants.NeedLogin self._browser.open(dl) succes_test = self._browser.find('p') if succes_test and 'successfully' in succes_test.text: gallery_dl = self._browser.find('a').get('href') gallery_dl = self._browser.url.split('/archive')[0] + gallery_dl f_name = succes_test.find('strong').text h_item.download_url = gallery_dl h_item.fetch_thumb() h_item.name = f_name Downloader.add_to_queue(h_item, self._browser.session) return h_item except AttributeError: log.exception("HTML parsing error") raise app_constants.HTMLParsing elif self._download_type == app_constants.DOWNLOAD_TYPE_TORRENT: h_item.torrents_found = int(gallery['torrentcount']) h_item.fetch_thumb() if h_item.torrents_found > 0: g_id_token = EHen.parse_url(g_url) if g_id_token: url_and_file = self._torrent_url_d(g_id_token[0], g_id_token[1]) if url_and_file: h_item.download_url = url_and_file[0] h_item.name = url_and_file[1] Downloader.add_to_queue(h_item, self._browser.session) return h_item else: return h_item return False class ExHenManager(HenManager): "ExHentai Manager" def __init__(self): super().__init__() self.e_url = "https://exhentai.org/" class CommenHen: "Contains common methods" LOCK = threading.Lock() TIME_RAND = app_constants.GLOBAL_EHEN_TIME QUEUE = [] COOKIES = {} LAST_USED = time.time() HEADERS = {'user-agent':"Mozilla/5.0 (Windows NT 6.3; rv:36.0) Gecko/20100101 Firefox/36.0"} _QUEUE_LIMIT = 25 _browser = RoboBrowser(user_agent=HEADERS['user-agent'], parser='html.parser') def begin_lock(self): log_d('locked') self.LOCK.acquire() t1 = time.time() while int(time.time() - self.LAST_USED) < self.TIME_RAND: t = random.randint(3, self.TIME_RAND) time.sleep(t) t2 = time.time() - t1 log_d("Slept for {}".format(t2)) def end_lock(self): log_d('unlocked') self.LAST_USED = time.time() self.LOCK.release() def add_to_queue(self, url='', proc=False, parse=True): """Add url the the queue, when the queue has reached _QUEUE_LIMIT entries will auto process :proc -> proccess queue :parse -> return parsed metadata """ if url: self.QUEUE.append(url) log_i("Status on queue: {}/{}".format(len(self.QUEUE), self._QUEUE_LIMIT)) try: if proc: if parse: return self.parse_metadata(*self.process_queue()) return self.process_queue() if len(self.QUEUE) >= self._QUEUE_LIMIT: if parse: return self.parse_metadata(*self.process_queue()) return self.process_queue() else: return 1 except TypeError: return None def process_queue(self): """ Process the queue if entries exists, deletes entries. Note: Will only process _QUEUE_LIMIT entries (first come first out) while additional entries will get deleted. """ log_i("Processing queue...") if len(self.QUEUE) < 1: return None try: if len(self.QUEUE) >= self._QUEUE_LIMIT: api_data, galleryid_dict = self.get_metadata(self.QUEUE[:self._QUEUE_LIMIT]) else: api_data, galleryid_dict = self.get_metadata(self.QUEUE) except TypeError: return None finally: log_i("Flushing queue...") self.QUEUE.clear() return api_data, galleryid_dict @classmethod def login(cls, user, password, relogin=False): pass @classmethod def check_login(cls, cookies): pass def check_cookie(self, cookie): cookies = self.COOKIES.keys() present = [] for c in cookie: if c in cookies: present.append(True) else: present.append(False) if not all(present): log_i("Updating cookies...") try: self.COOKIES.update(cookie) except requests.cookies.CookieConflictError: pass def handle_error(self, response): pass @classmethod def parse_metadata(cls, metadata_json, dict_metadata): """ :metadata_json <- raw data provided by site :dict_metadata <- a dict with gallery id's as keys and url as value returns a dict with url as key and gallery metadata as value """ pass def get_metadata(self, list_of_urls, cookies=None): """ Fetches the metadata from the provided list of urls returns raw api data and a dict with gallery id as key and url as value """ pass @classmethod def apply_metadata(cls, gallery, data, append=True): """ Applies fetched metadata to gallery """ pass def search(self, search_string, **kwargs): """ Searches for the provided string or list of hashes, returns a dict with search_string:[list of title & url tuples] of hits found or emtpy dict if no hits are found. """ pass class NHen(CommenHen): "Fetches galleries from nhen" LOGIN_URL = "http://nhentai.net/login/" @classmethod def login(cls, user, password, relogin=False): exprops = settings.ExProperties(settings.ExProperties.NHENTAI) if not relogin: if cls.COOKIES: if cls.check_login(cls.COOKIES): return cls.COOKIES elif exprops.cookies: if cls.check_login(exprops.cookies): cls.COOKIES.update(exprops.cookies) return cls.COOKIES cls._browser.open(cls.LOGIN_URL) login_form = cls._browser.get_form() if login_form: login_form['username'].value = user login_form['password'].value = password cls._browser.submit_form(login_form) n_c = cls._browser.session.cookies.get_dict() if not cls.check_login(n_c): log_w("NH login failed") raise app_constants.WrongLogin log_i("NH login succes") exprops.cookies = n_c exprops.username = user exprops.password = password exprops.save() cls.COOKIES.update(n_c) return n_c @classmethod def check_login(cls, cookies): if "sessionid" in cookies: return True @classmethod def apply_metadata(cls, gallery, data, append = True): return super().apply_metadata(gallery, data, append) def search(self, search_string, cookies = None, **kwargs): pass class EHen(CommenHen): "Fetches galleries from ehen" def __init__(self, cookies = None): self.cookies = cookies if cookies else settings.ExProperties().cookies self.e_url = "https://e-hentai.org/api.php" self.e_url_o = "https://e-hentai.org/" @classmethod def apply_metadata(cls, g, data, append = True): "Applies metadata to gallery, returns gallery" if app_constants.USE_JPN_TITLE: try: title = data['title']['jpn'] except KeyError: title = data['title']['def'] else: title = data['title']['def'] if 'Language' in data['tags']: try: lang = [x for x in data['tags']['Language'] if not x == 'translated'][0].capitalize() except IndexError: lang = "" else: lang = "" title_artist_dict = utils.title_parser(title) if not append: g.title = title_artist_dict['title'] if title_artist_dict['artist']: g.artist = title_artist_dict['artist'] g.language = title_artist_dict['language'].capitalize() if 'Artist' in data['tags']: g.artist = data['tags']['Artist'][0].capitalize() if lang: g.language = lang g.type = data['type'] g.pub_date = data['pub_date'] g.tags = data['tags'] if 'url' in data: g.link = data['url'] else: g.link = g.temp_url else: if not g.title: g.title = title_artist_dict['title'] if not g.artist: g.artist = title_artist_dict['artist'] if 'Artist' in data['tags']: g.artist = data['tags']['Artist'][0].capitalize() if not g.language: g.language = title_artist_dict['language'].capitalize() if lang: g.language = lang if not g.type or g.type == 'Other': g.type = data['type'] if not g.pub_date: g.pub_date = data['pub_date'] if not g.tags: g.tags = data['tags'] else: for ns in data['tags']: if ns in g.tags: for tag in data['tags'][ns]: if not tag in g.tags[ns]: g.tags[ns].append(tag) else: g.tags[ns] = data['tags'][ns] if 'url' in data: if not g.link: g.link = data['url'] else: if not g.link: g.link = g.temp_url return g @classmethod def check_login(cls, cookies): """ Checks if user is logged in """ if cookies.get('ipb_member_id') and cookies.get('ipb_pass_hash'): # check if there is access to ex ex = settings.ExProperties() if ex.custom: # this is to avoid spamming ex with requests return ex.custom.get('login') else: custom = {} custom['login'] = 0 s = requests.Session() s.cookies.update(cookies) s.headers.update(cls.HEADERS) try: r = cls.handle_error(cls, s.get('https://exhentai.org/'), wait=False) except requests.ConnectionError: log.exception("connection error") return 0 if r: custom['login'] = 2 # access to ex if r is None: custom['login'] = 1 # we get sadpanda ex.custom = custom ex.save() return custom['login'] return 0 # we've been banned, wrong credentials or haven't signed in def handle_error(self, response, wait=True): content_type = response.headers['content-type'] text = response.text if 'image/gif' in content_type: app_constants.NOTIF_BAR.add_text('Provided exhentai credentials are incorrect!') log_e('Provided exhentai credentials are incorrect!') if wait: time.sleep(5) return None elif 'text/html' and 'Your IP address has been' in text: app_constants.NOTIF_BAR.add_text("Your IP address has been temporarily banned from g.e-/exhentai") log_e('Your IP address has been temp banned from g.e- and ex-hentai') if wait: time.sleep(5) return False elif 'text/html' in content_type and 'You are opening' in text: time.sleep(random.randint(10,50)) return True @classmethod def parse_url(cls, url): "Parses url into a list of gallery id and token" gallery_id_token = regex.search('(?<=g/)([0-9]+)/([a-zA-Z0-9]+)', url) if not gallery_id_token: log_e("Error extracting g_id and g_token from url: {}".format(url)) return None gallery_id_token = gallery_id_token.group() gallery_id, gallery_token = gallery_id_token.split('/') parsed_url = [int(gallery_id), gallery_token] return parsed_url def get_metadata(self, list_of_urls, cookies=None): """ Fetches the metadata from the provided list of urls through the official API. returns raw api data and a dict with gallery id as key and url as value """ assert isinstance(list_of_urls, list) if len(list_of_urls) > 25: log_e('More than 25 urls are provided. Aborting.') return None payload = {"method": "gdata", "gidlist": [], "namespace": 1 } dict_metadata = {} for url in list_of_urls: parsed_url = EHen.parse_url(url.strip()) if parsed_url: dict_metadata[parsed_url[0]] = url # gallery id payload['gidlist'].append(parsed_url) if payload['gidlist']: self.begin_lock() try: if cookies: self.check_cookie(cookies) r = requests.post(self.e_url, json=payload, timeout=30, headers=self.HEADERS, cookies=self.COOKIES) else: r = requests.post(self.e_url, json=payload, timeout=30, headers=self.HEADERS) except requests.ConnectionError as err: self.end_lock() log_e("Could not fetch metadata: {}".format(err)) raise app_constants.MetadataFetchFail("connection error") self.end_lock() if not self.handle_error(r): return 'error' else: return None try: r.raise_for_status() except: log.exception('Could not fetch metadata: status error') return None return r.json(), dict_metadata @classmethod def parse_metadata(cls, metadata_json, dict_metadata): """ :metadata_json <- raw data provided by E-H API :dict_metadata <- a dict with gallery id's as keys and url as value returns a dict with url as key and gallery metadata as value """ def invalid_token_check(g_dict): if 'error' in g_dict: return False else: return True parsed_metadata = {} for gallery in metadata_json['gmetadata']: url = dict_metadata[gallery['gid']] if invalid_token_check(gallery): new_gallery = {} def fix_titles(text): t = html.unescape(text) t = " ".join(t.split()) return t try: gallery['title_jpn'] = fix_titles(gallery['title_jpn']) gallery['title'] = fix_titles(gallery['title']) new_gallery['title'] = {'def':gallery['title'], 'jpn':gallery['title_jpn']} except KeyError: gallery['title'] = fix_titles(gallery['title']) new_gallery['title'] = {'def':gallery['title']} new_gallery['type'] = gallery['category'] new_gallery['pub_date'] = datetime.fromtimestamp(int(gallery['posted'])) tags = {'default':[]} for t in gallery['tags']: if ':' in t: ns_tag = t.split(':') namespace = ns_tag[0].capitalize() tag = ns_tag[1].lower().replace('_', ' ') if not namespace in tags: tags[namespace] = [] tags[namespace].append(tag) else: tags['default'].append(t.lower().replace('_', ' ')) new_gallery['tags'] = tags parsed_metadata[url] = new_gallery else: log_e("Error in received response with URL: {}".format(url)) return parsed_metadata @classmethod def login(cls, user, password, relogin=False): """ Logs into g.e-h """ log_i("Attempting EH Login") eh_c = {} exprops = settings.ExProperties() if not relogin: if cls.COOKIES: if cls.check_login(cls.COOKIES): return cls.COOKIES elif exprops.cookies: if cls.check_login(exprops.cookies): cls.COOKIES.update(exprops.cookies) return cls.COOKIES p = { 'ipb_member_id':user, 'ipb_pass_hash':password } s = requests.Session() s.headers.update(cls.HEADERS) s.cookies.update(p) r = s.get('https://e-hentai.org/') if not cls.check_login(s.cookies): log_w("EH login failed") raise app_constants.WrongLogin log_i("EH login succes") exprops.cookies = s.cookies exprops.username = user exprops.password = password exprops.save() cls.COOKIES.update(s.cookies) return s.cookies def search(self, search_string, **kwargs): """ Searches ehentai for the provided string or list of hashes, returns a dict with search_string:[list of title & url tuples] of hits found or emtpy dict if no hits are found. """ assert isinstance(search_string, (str, list)) if isinstance(search_string, str): search_string = [search_string] cookies = kwargs.pop('cookies', {}) def no_hits_found_check(soup): "return true if hits are found" if not soup: log_e("There is no soup!") f_div = soup.body.find_all('div') for d in f_div: if 'No hits found' in d.text: return False return True def do_filesearch(filepath): file_search_delay = 5 if "exhentai" in self.e_url_o: f_url = "https://exhentai.org/upload/image_lookup.php/" else: f_url = "https://upload.e-hentai.org/image_lookup.php/" if cookies: self.check_cookie(cookies) self._browser.session.cookies.update(self.COOKIES) log_d("searching with color img: {}".format(filepath)) files = {'sfile': open(filepath,'rb')} values = {'fs_similar': '1'} if app_constants.INCLUDE_EH_EXPUNGED: values['fs_exp'] = '1' try: r = self._browser.session.post(f_url, files=files, data=values) except requests.ConnectionError: time.sleep(file_search_delay+3) r = self._browser.session.post(f_url, files=files, data=values) s = BeautifulSoup(r.text, "html.parser") if "Please wait a bit longer between each file search." in "{}".format(s): log_e("Retrying filesearch due to interval response with delay: {}".format(file_search_delay)) time.sleep(file_search_delay) s = do_filesearch(filepath) return s found_galleries = {} log_i('Initiating hash search on ehentai') log_d("search strings: ".format(search_string)) for h in search_string: log_d('Hash search: {}'.format(h)) self.begin_lock() try: if 'color' in kwargs: soup = do_filesearch(h) else: hash_url = self.e_url_o + '?f_shash=' hash_search = hash_url + h if app_constants.INCLUDE_EH_EXPUNGED: hash_search + '&fs_exp=1' if cookies: self.check_cookie(cookies) r = requests.get(hash_search, timeout=30, headers=self.HEADERS, cookies=self.COOKIES) else: r = requests.get(hash_search, timeout=30, headers=self.HEADERS) log_d("searching with greyscale img: {}".format(hash_search)) if not self.handle_error(r): return 'error' soup = BeautifulSoup(r.text, "html.parser") except requests.ConnectionError as err: self.end_lock() log.exception("Could not search for gallery: {}".format(err)) raise app_constants.MetadataFetchFail("connection error") self.end_lock() if not no_hits_found_check(soup): log_e('No hits found with hash/image: {}'.format(h)) continue log_i('Parsing html') try: if soup.body: found_galleries[h] = [] # list view or grid view type = soup.find(attrs={'class':'itg'}).name if type == 'div': visible_galleries = soup.find_all('div', attrs={'class':'id1'}) elif type == 'table': visible_galleries = soup.find_all('div', attrs={'class':'it5'}) log_i('Found {} visible galleries'.format(len(visible_galleries))) for gallery in visible_galleries: title = gallery.text g_url = gallery.a.attrs['href'] found_galleries[h].append((title,g_url)) except AttributeError: log.exception('Unparseable html') log_d("\n{}\n".format(soup.prettify())) continue if found_galleries: log_i('Found {} out of {} galleries'.format(len(found_galleries), len(search_string))) return found_galleries else: log_w('Could not find any galleries') return {} class ExHen(EHen): "Fetches gallery metadata from exhen" def __init__(self, cookies=None): super().__init__(cookies) self.e_url = "https://exhentai.org/api.php" self.e_url_o = "https://exhentai.org/" def get_metadata(self, list_of_urls): return super().get_metadata(list_of_urls, self.cookies) def search(self, hash_string, **kwargs): return super().search(hash_string, cookies=self.cookies, **kwargs) class ChaikaHen(CommenHen): "Fetches gallery metadata from panda.chaika.moe" g_url = "http://panda.chaika.moe/gallery/" g_api_url = "http://panda.chaika.moe/jsearch?gallery=" a_api_url = "http://panda.chaika.moe/jsearch?archive=" def __init__(self): self.url = "http://panda.chaika.moe/jsearch?sha1=" self._QUEUE_LIMIT = 1 def search(self, search_string, **kwargs): """ search_string should be a list of hashes will actually just put urls together return search_string:[list of title & url tuples] """ if not isinstance(search_string, (list,tuple)): search_string = [search_string] x = {} for h in search_string: x[h] = [("", self.url+h)] return x def get_metadata(self, list_of_urls): """ Fetches the metadata from the provided list of urls through the official API. returns raw api data and a dict with gallery id as key and url as value """ data = [] g_id_data = {} g_id = 1 for url in list_of_urls: hash_search = True chaika_g_id = None old_url = url re_string = "^(http\:\/\/|https\:\/\/)?(www\.)?([^\.]?)(panda\.chaika\.moe\/(archive|gallery)\/[0-9]+)" # to validate chaika urls if regex.match(re_string, url): g_or_a_id = regex.search("([0-9]+)", url).group() if 'gallery' in url: url = self.g_api_url+g_or_a_id chaika_g_id = g_or_a_id else: url = self.a_api_url+g_or_a_id hash_search = False try: try: r = requests.get(url) except requests.ConnectionError as err: log_e("Could not fetch metadata: {}".format(err)) raise app_constants.MetadataFetchFail("connection error") r.raise_for_status() if not r.json(): return None if hash_search: g_data = r.json()[0] # TODO: multiple archives can be returned! Please fix! else: g_data = r.json() if chaika_g_id: g_data['gallery'] = chaika_g_id g_data['gid'] = g_id data.append(g_data) if hash_search: g_id_data[g_id] = url else: g_id_data[g_id] = old_url g_id += 1 except requests.RequestException: log_e("Could not fetch metadata: status error") return None return data, g_id_data @classmethod def parse_metadata(cls, data, dict_metadata): """ :data <- raw data provided by site :dict_metadata <- a dict with gallery id's as keys and url as value returns a dict with url as key and gallery metadata as value """ eh_api_data = { "gmetadata":[] } g_urls = {} for d in data: eh_api_data['gmetadata'].append(d) # to get correct gallery urls g_urls[dict_metadata[d['gid']]] = cls.g_url + str(d['gallery']) + '/' p_metadata = EHen.parse_metadata(eh_api_data, dict_metadata) # to get correct gallery urls instead of .....jsearch?sha1=----long-hash---- for url in g_urls: p_metadata[url]['url'] = g_urls[url] return p_metadata @classmethod def apply_metadata(cls, g, data, append = True): "Applies metadata to gallery, returns gallery" return EHen.apply_metadata(g, data, append) def hen_list_init(): h_list = [] for h in app_constants.HEN_LIST: if h == "ehen": h_list.append(EHen) elif h == "exhen": h_list.append(ExHen) elif h == "chaikahen": h_list.append(ChaikaHen) return h_list ================================================ FILE: version/settings.py ================================================ #""" #This file is part of Happypanda. #Happypanda is free software: you can redistribute it and/or modify #it under the terms of the GNU General Public License as published by #the Free Software Foundation, either version 2 of the License, or #any later version. #Happypanda is distributed in the hope that it will be useful, #but WITHOUT ANY WARRANTY; without even the implied warranty of #MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #GNU General Public License for more details. #You should have received a copy of the GNU General Public License #along with Happypanda. If not, see . #""" import json, configparser, os, logging, pickle log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical if os.name == 'posix': settings_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), 'settings.ini') phappypanda_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), '.happypanda') else: settings_path = 'settings.ini' phappypanda_path = '.happypanda' if not os.path.isfile(settings_path): open(settings_path, 'x') class Config(configparser.ConfigParser): def __init__(self): super().__init__() def read(self, filenames, encoding = None): self.custom_cls_file = filenames super().read(filenames, encoding) def save(self, encoding = 'utf-8', space_around_delimeters=True): try: if not isinstance(self.custom_cls_file, str) and \ hasattr(self.custom_cls_file, '__iter__'): for file in self.custom_cls_file: with open(file, 'w', encoding=encoding) as cf: self.write(cf, space_around_delimeters) else: with open(self.custom_cls_file, 'w') as cf: self.write(cf, space_around_delimeters) except PermissionError: log_e('Could not save settings: PermissionError') except: log.exception('Could not save settings') config = Config() config.read(settings_path) def save(): config.save() ExProperties.save() def get(default, section, key=None, type_class=str, subtype_class=None): """ Tries to find the given entries in config, returning default if none is found. Default type is str. Subtype will be used for when try_excepting fails """ value = default try: if key: try: value = config[section][key] except KeyError: value = default else: try: value = config[section] except KeyError: value = default try: if value.lower() == 'false': value = False elif value.lower() == 'true': value = True elif value.lower() == 'none': value = None elif type_class in (list, tuple): value = type_class([x for x in value.split('>|<') if x]) else: if subtype_class: try: value = type_class(value) except: value = subtype_class(value) else: value = type_class(value) except AttributeError: pass except: return default return value except: return default def set(value, section, key=None): """ Adds a new entry in config. Remember everything is converted to string """ val_as_str = value if not section in config: config[section] = {} if isinstance(value, (list, tuple)): val_as_str = "" for n, v in enumerate(value): if n == len(value)-1: val_as_str += "{}".format(v) else: val_as_str += "{}>|<".format(v) if key: config[section][key] = str(val_as_str) else: config[section] = str(val_as_str) class Properties: pass # wow this is really bad, can't be arsed to fix it class ExProperties(Properties): # sites EHENTAI, NHENTAI = range(2) sites = (EHENTAI, NHENTAI,) _INFO = {} def __init__(self, site=EHENTAI): self.site = site if not self._INFO: if os.path.exists(phappypanda_path): with open(phappypanda_path, 'rb') as f: self.__class__._INFO = pickle.load(f) for s in self.sites: if s in self.__class__._INFO: if 'custom' in self.__class__._INFO[s]: self.__class__._INFO[s]['custom'].clear() @classmethod def save(self): if self._INFO: with open(phappypanda_path, 'wb') as f: pickle.dump(self._INFO, f, 4) @property def cookies(self): if self._INFO: if self.site in self._INFO: return self._INFO[self.site].get('cookies') return {} @cookies.setter def cookies(self, c): if not self.site in self._INFO: self._INFO[self.site] = {} self._INFO[self.site]['cookies'] = c @property def username(self): if self._INFO: if self.site in self._INFO: return self._INFO[self.site].get('username') @username.setter def username(self, us): if not self.site in self._INFO: self._INFO[self.site] = {} self._INFO[self.site]['username'] = us @property def password(self): if self._INFO: if self.site in self._INFO: return self._INFO[self.site].get('password') @password.setter def password(self, ps): if not self.site in self._INFO: self._INFO[self.site] = {} self._INFO[self.site]['password'] = ps @property def custom(self): if self._INFO: if self.site in self._INFO: return self._INFO[self.site].get('custom') @custom.setter def custom(self, ps): if not self.site in self._INFO: self._INFO[self.site] = {} self._INFO[self.site]['custom'] = ps class WinProperties(Properties): def __init__(self): self._resize = None self._pos = (0, 0) @property def resize(self): return self._resize @resize.setter def resize(self, size): assert isinstance(size, list) or isinstance(size, tuple) self._resize = tuple(size) @property def pos(self): return self._pos @pos.setter def pos(self, point): assert isinstance(point, list) or isinstance(point, tuple) self._pos = tuple(point) def win_read(cls, name): "Reads window properties" assert isinstance(name, str) props = WinProperties() try: props.resize = (int(config[name]['resize.w']), int(config[name]['resize.h'])) props.pos = (int(config[name]['pos.x']), int(config[name]['pos.y'])) except KeyError: pass return props def win_save(cls, name, winprops=None): """ Saves window properties. Saves current window properties if no winproperties is passed """ assert isinstance(name, str) if not winprops: if not name in config: config[name] = {} config[name]['resize.w'] = str(cls.size().width()) config[name]['resize.h'] = str(cls.size().height()) config[name]['pos.x'] = str(cls.pos().x()) config[name]['pos.y'] = str(cls.pos().y()) else: assert isinstance(winprops, WinProperties), \ 'You must pass a winproperties derived from WinProperties class' config.save() ================================================ FILE: version/settingsdialog.py ================================================ import logging, os, sys from PyQt5.QtWidgets import (QVBoxLayout, QHBoxLayout, QListWidget, QWidget, QListWidgetItem, QStackedLayout, QPushButton, QLabel, QTabWidget, QLineEdit, QGroupBox, QFormLayout, QCheckBox, QRadioButton, QSpinBox, QSizePolicy, QScrollArea, QFontDialog, QMessageBox, QComboBox, QFileDialog, QSlider) from PyQt5.QtCore import pyqtSignal, Qt from PyQt5.QtGui import QPalette, QPixmapCache from color_line_edit import ColorLineEdit from misc import FlowLayout, Spacer, PathLineEdit, AppDialog, Line import misc import settings import app_constants import misc_db import gallerydb import utils import io_misc import pewnet log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical class SettingsDialog(QWidget): "A settings dialog" scroll_speed_changed = pyqtSignal() init_gallery_rebuild = pyqtSignal(bool) init_gallery_eximport = pyqtSignal(object) def __init__(self, parent=None): super().__init__(parent, flags=Qt.Window) self.init_gallery_rebuild.connect(self.accept) self.parent_widget = parent self.setAttribute(Qt.WA_DeleteOnClose) self.resize(700, 500) self.restore_values() self.initUI() self.setWindowTitle('Settings') self.show() def initUI(self): main_layout = QVBoxLayout(self) sub_layout = QHBoxLayout() # Left Panel left_panel = QListWidget() left_panel.setViewMode(left_panel.ListMode) #left_panel.setIconSize(QSize(40,40)) left_panel.setTextElideMode(Qt.ElideRight) left_panel.setMaximumWidth(200) left_panel.itemClicked.connect(self.change) #web.setText('Web') self.application = QListWidgetItem() self.application.setText('Application') self.web = QListWidgetItem() self.web.setText('Web') self.visual = QListWidgetItem() self.visual.setText('Visual') self.advanced = QListWidgetItem() self.advanced.setText('Advanced') self.about = QListWidgetItem() self.about.setText('About') #main.setIcon(QIcon(os.path.join(app_constants.static_dir, 'plus2.png'))) left_panel.addItem(self.application) left_panel.addItem(self.web) left_panel.addItem(self.visual) left_panel.addItem(self.advanced) left_panel.addItem(self.about) left_panel.setMaximumWidth(100) # right panel self.right_panel = QStackedLayout() self.init_right_panel() # bottom bottom_layout = QHBoxLayout() ok_btn = QPushButton('Ok') ok_btn.clicked.connect(self.accept) cancel_btn = QPushButton('Cancel') cancel_btn.clicked.connect(self.close) info_lbl = QLabel() info_lbl.setText(''+ 'Visit GitHub Repo | Options marked with * requires application restart.') info_lbl.setTextFormat(Qt.RichText) info_lbl.setTextInteractionFlags(Qt.TextBrowserInteraction) info_lbl.setOpenExternalLinks(True) self.spacer = QWidget() self.spacer.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) bottom_layout.addWidget(info_lbl, 0, Qt.AlignLeft) bottom_layout.addWidget(self.spacer) bottom_layout.addWidget(ok_btn, 0, Qt.AlignRight) bottom_layout.addWidget(cancel_btn, 0, Qt.AlignRight) sub_layout.addWidget(left_panel) sub_layout.addLayout(self.right_panel) main_layout.addLayout(sub_layout) main_layout.addLayout(bottom_layout) self.restore_options() def change(self, item): def curr_index(index): if index != self.right_panel.currentIndex(): self.right_panel.setCurrentIndex(index) if item == self.application: curr_index(self.application_index) elif item == self.web: curr_index(self.web_index) elif item == self.visual: curr_index(self.visual_index) elif item == self.advanced: curr_index(self.advanced_index) elif item == self.about: curr_index(self.about_index) def restore_values(self): # Visual self.high_quality_thumbs = app_constants.HIGH_QUALITY_THUMBS self.style_sheet = app_constants.user_stylesheet_path # Advanced self.scroll_speed = app_constants.SCROLL_SPEED self.cache_size = app_constants.THUMBNAIL_CACHE_SIZE self.prefetch_item_amnt = app_constants.PREFETCH_ITEM_AMOUNT def restore_options(self): # App / General self.g_languages.addItems(app_constants.G_LANGUAGES) self.g_languages.addItems(app_constants.G_CUSTOM_LANGUAGES) self._find_combobox_match(self.g_languages, app_constants.G_DEF_LANGUAGE, 0) self.g_type.addItems(app_constants.G_TYPES) self._find_combobox_match(self.g_type, app_constants.G_DEF_TYPE, 0) self.g_status.addItems(app_constants.G_STATUS) self._find_combobox_match(self.g_status, app_constants.G_DEF_STATUS, 0) self.sidebar_widget_hidden.setChecked(app_constants.SHOW_SIDEBAR_WIDGET) self.send_2_trash.setChecked(app_constants.SEND_FILES_TO_TRASH) self.subfolder_as_chapters.setChecked(app_constants.SUBFOLDER_AS_GALLERY) self.extract_gallery_before_opening.setChecked(app_constants.EXTRACT_CHAPTER_BEFORE_OPENING) self.open_galleries_sequentially.setChecked(app_constants.OPEN_GALLERIES_SEQUENTIALLY) self.move_imported_gs.setChecked(app_constants.MOVE_IMPORTED_GALLERIES) self.move_imported_def_path.setText(app_constants.IMPORTED_GALLERY_DEF_PATH) self.open_random_g_chapters.setChecked(app_constants.OPEN_RANDOM_GALLERY_CHAPTERS) self.rename_g_source_group.setChecked(app_constants.RENAME_GALLERY_SOURCE) self.path_to_unrar.setText(app_constants.unrar_tool_path) self.keep_added_gallery.setChecked(not app_constants.KEEP_ADDED_GALLERIES) # App / General / External Viewer self.external_viewer_path.setText(app_constants.EXTERNAL_VIEWER_PATH) # App / Monitor / Misc self.enable_monitor.setChecked(app_constants.ENABLE_MONITOR) self.look_new_gallery_startup.setChecked(app_constants.LOOK_NEW_GALLERY_STARTUP) # App / Monitor / Folders for path in app_constants.MONITOR_PATHS: self.add_folder_monitor(path) # App / Monitor / Ignore list for ext in app_constants.IGNORE_EXTS: if ext == 'Folder': self.ignore_folder.setChecked(True) if ext == 'ZIP': self.ignore_zip.setChecked(True) if ext == 'CBZ': self.ignore_cbz.setChecked(True) if ext == 'RAR': self.ignore_rar.setChecked(True) if ext == 'CBR': self.ignore_cbr.setChecked(True) for path in app_constants.IGNORE_PATHS: self.add_ignore_path(path) # Web / metadata if 'e-hentai' in app_constants.DEFAULT_EHEN_URL: self.default_ehen_url.setChecked(True) else: self.exhentai_ehen_url.setChecked(True) self.include_expunged.setChecked(app_constants.INCLUDE_EH_EXPUNGED) self.replace_metadata.setChecked(app_constants.REPLACE_METADATA) self.always_first_hit.setChecked(app_constants.ALWAYS_CHOOSE_FIRST_HIT) self.web_time_offset.setValue(app_constants.GLOBAL_EHEN_TIME) self.continue_a_metadata_fetcher.setChecked(app_constants.CONTINUE_AUTO_METADATA_FETCHER) self.use_jpn_title.setChecked(app_constants.USE_JPN_TITLE) self.use_gallery_link.setChecked(app_constants.USE_GALLERY_LINK) self.fallback_chaika.setChecked(True) if 'chaikahen' in app_constants.HEN_LIST else None # Web / Download if app_constants.HEN_DOWNLOAD_TYPE == 0: self.archive_download.setChecked(True) else: self.torrent_download.setChecked(True) self.download_directory.setText(app_constants.DOWNLOAD_DIRECTORY) self.torrent_client.setText(app_constants.TORRENT_CLIENT) self.download_gallery_lib.setChecked(app_constants.DOWNLOAD_GALLERY_TO_LIB) # Visual / Grid View self.g_popup_width.setValue(app_constants.POPUP_WIDTH) self.g_popup_height.setValue(app_constants.POPUP_HEIGHT) # Visual / Grid View / Tooltip self.grid_tooltip_group.setChecked(app_constants.GRID_TOOLTIP) self.visual_grid_tooltip_title.setChecked(app_constants.TOOLTIP_TITLE) self.visual_grid_tooltip_author.setChecked(app_constants.TOOLTIP_AUTHOR) self.visual_grid_tooltip_chapters.setChecked(app_constants.TOOLTIP_CHAPTERS) self.visual_grid_tooltip_status.setChecked(app_constants.TOOLTIP_STATUS) self.visual_grid_tooltip_type.setChecked(app_constants.TOOLTIP_TYPE) self.visual_grid_tooltip_lang.setChecked(app_constants.TOOLTIP_LANG) self.visual_grid_tooltip_descr.setChecked(app_constants.TOOLTIP_DESCR) self.visual_grid_tooltip_tags.setChecked(app_constants.TOOLTIP_TAGS) self.visual_grid_tooltip_last_read.setChecked(app_constants.TOOLTIP_LAST_READ) self.visual_grid_tooltip_times_read.setChecked(app_constants.TOOLTIP_TIMES_READ) self.visual_grid_tooltip_pub_date.setChecked(app_constants.TOOLTIP_PUB_DATE) self.visual_grid_tooltip_date_added.setChecked(app_constants.TOOLTIP_DATE_ADDED) # Visual / Grid View / Gallery self.gallery_rating.setChecked(app_constants.DISPLAY_RATING) self.gallery_type_ico.setChecked(app_constants.DISPLAY_GALLERY_TYPE) if app_constants.GALLERY_FONT_ELIDE: self.gallery_text_elide.setChecked(True) else: self.gallery_text_fit.setChecked(True) self.font_lbl.setText(app_constants.GALLERY_FONT[0]) self.font_size_lbl.setValue(app_constants.GALLERY_FONT[1]) if app_constants.SEARCH_ON_ENTER: self.search_on_enter.setChecked(True) else: self.search_every_keystroke.setChecked(True) self.gallery_size.setValue(app_constants.SIZE_FACTOR//10) self.grid_spacing.setValue(app_constants.GRID_SPACING) # Visual / Grid View / Colors self.grid_label_color.setText(app_constants.GRID_VIEW_LABEL_COLOR) self.grid_title_color.setText(app_constants.GRID_VIEW_TITLE_COLOR) self.grid_artist_color.setText(app_constants.GRID_VIEW_ARTIST_COLOR) self.colors_ribbon_group.setChecked(app_constants.DISPLAY_GALLERY_RIBBON) self.ribbon_manga_color.setText(app_constants.GRID_VIEW_T_MANGA_COLOR) self.ribbon_doujin_color.setText(app_constants.GRID_VIEW_T_DOUJIN_COLOR) self.ribbon_artist_cg_color.setText(app_constants.GRID_VIEW_T_ARTIST_CG_COLOR) self.ribbon_game_cg_color.setText(app_constants.GRID_VIEW_T_GAME_CG_COLOR) self.ribbon_western_color.setText(app_constants.GRID_VIEW_T_WESTERN_COLOR) self.ribbon_image_color.setText(app_constants.GRID_VIEW_T_IMAGE_COLOR) self.ribbon_non_h_color.setText(app_constants.GRID_VIEW_T_NON_H_COLOR) self.ribbon_cosplay_color.setText(app_constants.GRID_VIEW_T_COSPLAY_COLOR) self.ribbon_other_color.setText(app_constants.GRID_VIEW_T_OTHER_COLOR) # Advanced / Misc self.external_viewer_args.setText(app_constants.EXTERNAL_VIEWER_ARGS) self.force_high_dpi_support.setChecked(app_constants.FORCE_HIGH_DPI_SUPPORT) # Advanced / Gallery / Gallery Text Fixer self.g_data_regex_fix_edit.setText(app_constants.GALLERY_DATA_FIX_REGEX) self.g_data_replace_fix_edit.setText(app_constants.GALLERY_DATA_FIX_REPLACE) self.g_data_fixer_title.setChecked(app_constants.GALLERY_DATA_FIX_TITLE) self.g_data_fixer_artist.setChecked(app_constants.GALLERY_DATA_FIX_ARTIST) def accept(self): set = settings.set # App / General app_constants.SHOW_SIDEBAR_WIDGET = self.sidebar_widget_hidden.isChecked() set(app_constants.SHOW_SIDEBAR_WIDGET, 'Application', 'show sidebar widget') app_constants.SEND_FILES_TO_TRASH = self.send_2_trash.isChecked() set(app_constants.SEND_FILES_TO_TRASH, 'Application', 'send files to trash') # App / General / Gallery app_constants.KEEP_ADDED_GALLERIES = not self.keep_added_gallery.isChecked() set(app_constants.KEEP_ADDED_GALLERIES, 'Application', 'keep added galleries') g_custom_lang = [] for x in range(self.g_languages.count()): l = self.g_languages.itemText(x).capitalize() if l and not l in app_constants.G_LANGUAGES: g_custom_lang.append(l) app_constants.G_CUSTOM_LANGUAGES = g_custom_lang set(app_constants.G_CUSTOM_LANGUAGES, 'General', 'gallery custom languages') if self.g_languages.currentText(): app_constants.G_DEF_LANGUAGE = self.g_languages.currentText() set(app_constants.G_DEF_LANGUAGE, 'General', 'gallery default language') app_constants.G_DEF_STATUS = self.g_status.currentText() set(app_constants.G_DEF_STATUS, 'General', 'gallery default status') app_constants.G_DEF_TYPE = self.g_type.currentText() set(app_constants.G_DEF_TYPE, 'General', 'gallery default type') app_constants.SUBFOLDER_AS_GALLERY = self.subfolder_as_chapters.isChecked() set(app_constants.SUBFOLDER_AS_GALLERY, 'Application', 'subfolder as gallery') app_constants.EXTRACT_CHAPTER_BEFORE_OPENING = self.extract_gallery_before_opening.isChecked() set(app_constants.EXTRACT_CHAPTER_BEFORE_OPENING, 'Application', 'extract chapter before opening') app_constants.OPEN_GALLERIES_SEQUENTIALLY = self.open_galleries_sequentially.isChecked() set(app_constants.OPEN_GALLERIES_SEQUENTIALLY, 'Application', 'open galleries sequentially') app_constants.MOVE_IMPORTED_GALLERIES = self.move_imported_gs.isChecked() set(app_constants.MOVE_IMPORTED_GALLERIES, 'Application', 'move imported galleries') if not self.move_imported_def_path.text() or os.path.exists(self.move_imported_def_path.text()): app_constants.IMPORTED_GALLERY_DEF_PATH = self.move_imported_def_path.text() set(app_constants.IMPORTED_GALLERY_DEF_PATH, 'Application', 'imported gallery def path') app_constants.OPEN_RANDOM_GALLERY_CHAPTERS = self.open_random_g_chapters.isChecked() set(app_constants.OPEN_RANDOM_GALLERY_CHAPTERS, 'Application', 'open random gallery chapters') app_constants.RENAME_GALLERY_SOURCE = self.rename_g_source_group.isChecked() set(app_constants.RENAME_GALLERY_SOURCE, 'Application', 'rename gallery source') app_constants.unrar_tool_path = self.path_to_unrar.text() set(app_constants.unrar_tool_path, 'Application', 'unrar tool path') # App / General / Search app_constants.SEARCH_AUTOCOMPLETE = self.search_autocomplete.isChecked() set(app_constants.SEARCH_AUTOCOMPLETE, 'Application', 'search autocomplete') if self.search_on_enter.isChecked(): app_constants.SEARCH_ON_ENTER = True else: app_constants.SEARCH_ON_ENTER = False set(app_constants.SEARCH_ON_ENTER, 'Application', 'search on enter') # App / General / External Viewer if not self.external_viewer_path.text(): app_constants.USE_EXTERNAL_VIEWER = False set(False, 'Application', 'use external viewer') else: app_constants.USE_EXTERNAL_VIEWER = True set(True, 'Application', 'use external viewer') app_constants._REFRESH_EXTERNAL_VIEWER = True app_constants.EXTERNAL_VIEWER_PATH = self.external_viewer_path.text() set(app_constants.EXTERNAL_VIEWER_PATH,'Application', 'external viewer path') # App / Monitor / misc app_constants.ENABLE_MONITOR = self.enable_monitor.isChecked() set(app_constants.ENABLE_MONITOR, 'Application', 'enable monitor') app_constants.LOOK_NEW_GALLERY_STARTUP = self.look_new_gallery_startup.isChecked() set(app_constants.LOOK_NEW_GALLERY_STARTUP, 'Application', 'look new gallery startup') # App / Monitor / folders paths = [] folder_p_widgets = self.take_all_layout_widgets(self.folders_layout) for x, l_edit in enumerate(folder_p_widgets): p = l_edit.text() if p: paths.append(p) set(paths, 'Application', 'monitor paths') app_constants.MONITOR_PATHS = paths # App / Monitor / ignore list exts_list = [] for ext in [self.ignore_folder, self.ignore_zip, self.ignore_cbz, self.ignore_rar, self.ignore_cbr]: if ext.isChecked(): exts_list.append(ext.text()) set(exts_list, 'Application', 'ignore exts') app_constants.IGNORE_EXTS = exts_list paths = [] ignore_p_widgets = self.take_all_layout_widgets(self.ignore_path_l) for x, l_edit in enumerate(ignore_p_widgets): p = l_edit.text() if p: paths.append(p) set(paths, 'Application', 'ignore paths') app_constants.IGNORE_PATHS = paths # Web / Downloader if self.archive_download.isChecked(): app_constants.HEN_DOWNLOAD_TYPE = 0 else: app_constants.HEN_DOWNLOAD_TYPE = 1 set(app_constants.HEN_DOWNLOAD_TYPE, 'Web', 'hen download type') app_constants.DOWNLOAD_DIRECTORY = self.download_directory.text() set(app_constants.DOWNLOAD_DIRECTORY, 'Web', 'download directory') app_constants.TORRENT_CLIENT = self.torrent_client.text() set(app_constants.TORRENT_CLIENT, 'Web', 'torrent client') app_constants.DOWNLOAD_GALLERY_TO_LIB = self.download_gallery_lib.isChecked() set(app_constants.DOWNLOAD_GALLERY_TO_LIB, 'Web', 'download galleries to library') # Web / Metdata if self.default_ehen_url.isChecked(): app_constants.DEFAULT_EHEN_URL = 'https://e-hentai.org/' else: app_constants.DEFAULT_EHEN_URL = 'https://exhentai.org/' set(app_constants.DEFAULT_EHEN_URL, 'Web', 'default ehen url') app_constants.INCLUDE_EH_EXPUNGED = self.include_expunged.isChecked() set(app_constants.INCLUDE_EH_EXPUNGED, 'Web', 'include eh expunged') app_constants.REPLACE_METADATA = self.replace_metadata.isChecked() set(app_constants.REPLACE_METADATA, 'Web', 'replace metadata') app_constants.ALWAYS_CHOOSE_FIRST_HIT = self.always_first_hit.isChecked() set(app_constants.ALWAYS_CHOOSE_FIRST_HIT, 'Web', 'always choose first hit') app_constants.GLOBAL_EHEN_TIME = self.web_time_offset.value() set(app_constants.GLOBAL_EHEN_TIME, 'Web', 'global ehen time offset') app_constants.CONTINUE_AUTO_METADATA_FETCHER = self.continue_a_metadata_fetcher.isChecked() set(app_constants.CONTINUE_AUTO_METADATA_FETCHER, 'Web', 'continue auto metadata fetcher') app_constants.USE_JPN_TITLE = self.use_jpn_title.isChecked() set(app_constants.USE_JPN_TITLE, 'Web', 'use jpn title') app_constants.USE_GALLERY_LINK = self.use_gallery_link.isChecked() set(app_constants.USE_GALLERY_LINK, 'Web', 'use gallery link') # fallback sources henlist = [] if self.fallback_chaika.isChecked(): henlist.append('chaikahen') app_constants.HEN_LIST = henlist set(app_constants.HEN_LIST, 'Web', 'hen list') # Visual / Grid View app_constants.POPUP_WIDTH = self.g_popup_width.value() set(app_constants.POPUP_WIDTH, 'Visual', 'popup.w') app_constants.POPUP_HEIGHT = self.g_popup_height.value() set(app_constants.POPUP_HEIGHT, 'Visual', 'popup.h') # Visual / Grid View / Tooltip app_constants.GRID_TOOLTIP = self.grid_tooltip_group.isChecked() set(app_constants.GRID_TOOLTIP, 'Visual', 'grid tooltip') app_constants.TOOLTIP_TITLE = self.visual_grid_tooltip_title.isChecked() set(app_constants.TOOLTIP_TITLE, 'Visual', 'tooltip title') app_constants.TOOLTIP_AUTHOR = self.visual_grid_tooltip_author.isChecked() set(app_constants.TOOLTIP_AUTHOR, 'Visual', 'tooltip author') app_constants.TOOLTIP_CHAPTERS = self.visual_grid_tooltip_chapters.isChecked() set(app_constants.TOOLTIP_CHAPTERS, 'Visual', 'tooltip chapters') app_constants.TOOLTIP_STATUS = self.visual_grid_tooltip_status.isChecked() set(app_constants.TOOLTIP_STATUS, 'Visual', 'tooltip status') app_constants.TOOLTIP_TYPE = self.visual_grid_tooltip_type.isChecked() set(app_constants.TOOLTIP_TYPE, 'Visual', 'tooltip type') app_constants.TOOLTIP_LANG = self.visual_grid_tooltip_lang.isChecked() set(app_constants.TOOLTIP_LANG, 'Visual', 'tooltip lang') app_constants.TOOLTIP_DESCR = self.visual_grid_tooltip_descr.isChecked() set(app_constants.TOOLTIP_DESCR, 'Visual', 'tooltip descr') app_constants.TOOLTIP_TAGS = self.visual_grid_tooltip_tags.isChecked() set(app_constants.TOOLTIP_TAGS, 'Visual', 'tooltip tags') app_constants.TOOLTIP_LAST_READ = self.visual_grid_tooltip_last_read.isChecked() set(app_constants.TOOLTIP_LAST_READ, 'Visual', 'tooltip last read') app_constants.TOOLTIP_TIMES_READ = self.visual_grid_tooltip_times_read.isChecked() set(app_constants.TOOLTIP_TIMES_READ, 'Visual', 'tooltip times read') app_constants.TOOLTIP_PUB_DATE = self.visual_grid_tooltip_pub_date.isChecked() set(app_constants.TOOLTIP_PUB_DATE, 'Visual', 'tooltip pub date') app_constants.TOOLTIP_DATE_ADDED = self.visual_grid_tooltip_date_added.isChecked() set(app_constants.TOOLTIP_DATE_ADDED, 'Visual', 'tooltip date added') # Visual / Grid View / Gallery app_constants.DISPLAY_RATING = self.gallery_rating.isChecked() set(app_constants.DISPLAY_RATING, 'Visual', 'display gallery rating') app_constants.DISPLAY_GALLERY_TYPE = self.gallery_type_ico.isChecked() set(app_constants.DISPLAY_GALLERY_TYPE, 'Visual', 'display gallery type') if self.gallery_text_elide.isChecked(): app_constants.GALLERY_FONT_ELIDE = True else: app_constants.GALLERY_FONT_ELIDE = False set(app_constants.GALLERY_FONT_ELIDE, 'Visual', 'gallery font elide') app_constants.GALLERY_FONT = (self.font_lbl.text(), self.font_size_lbl.value()) set(app_constants.GALLERY_FONT[0], 'Visual', 'gallery font family') set(app_constants.GALLERY_FONT[1], 'Visual', 'gallery font size') app_constants.SIZE_FACTOR = self.gallery_size.value() * 10 set(app_constants.SIZE_FACTOR, 'Visual', 'size factor') app_constants.GRID_SPACING = self.grid_spacing.value() set(app_constants.GRID_SPACING, 'Visual', 'grid spacing') # Visual / Grid View / Colors app_constants.DISPLAY_GALLERY_RIBBON = self.colors_ribbon_group.isChecked() set(app_constants.DISPLAY_GALLERY_RIBBON, 'Visual', 'display gallery ribbon') if self.color_checker(self.grid_title_color.text()): app_constants.GRID_VIEW_TITLE_COLOR = self.grid_title_color.text() set(app_constants.GRID_VIEW_TITLE_COLOR, 'Visual', 'grid view title color') if self.color_checker(self.grid_artist_color.text()): app_constants.GRID_VIEW_ARTIST_COLOR = self.grid_artist_color.text() set(app_constants.GRID_VIEW_ARTIST_COLOR, 'Visual', 'grid view artist color') if self.color_checker(self.grid_label_color.text()): app_constants.GRID_VIEW_LABEL_COLOR = self.grid_label_color.text() set(app_constants.GRID_VIEW_LABEL_COLOR, 'Visual', 'grid view label color') if self.color_checker(self.ribbon_manga_color.text()): app_constants.GRID_VIEW_T_MANGA_COLOR = self.ribbon_manga_color.text() set(app_constants.GRID_VIEW_T_MANGA_COLOR, 'Visual', 'grid view t manga color') if self.color_checker(self.ribbon_doujin_color.text()): app_constants.GRID_VIEW_T_DOUJIN_COLOR = self.ribbon_doujin_color.text() set(app_constants.GRID_VIEW_T_DOUJIN_COLOR, 'Visual', 'grid view t doujin color') if self.color_checker(self.ribbon_artist_cg_color.text()): app_constants.GRID_VIEW_T_ARTIST_CG_COLOR = self.ribbon_artist_cg_color.text() set(app_constants.GRID_VIEW_T_ARTIST_CG_COLOR, 'Visual', 'grid view t artist cg color') if self.color_checker(self.ribbon_game_cg_color.text()): app_constants.GRID_VIEW_T_GAME_CG_COLOR = self.ribbon_game_cg_color.text() set(app_constants.GRID_VIEW_T_GAME_CG_COLOR, 'Visual', 'grid view t game cg color') if self.color_checker(self.ribbon_western_color.text()): app_constants.GRID_VIEW_T_WESTERN_COLOR = self.ribbon_western_color.text() set(app_constants.GRID_VIEW_T_WESTERN_COLOR, 'Visual', 'grid view t western color') if self.color_checker(self.ribbon_image_color.text()): app_constants.GRID_VIEW_T_IMAGE_COLOR = self.ribbon_image_color.text() set(app_constants.GRID_VIEW_T_IMAGE_COLOR, 'Visual', 'grid view t image color') if self.color_checker(self.ribbon_non_h_color.text()): app_constants.GRID_VIEW_T_NON_H_COLOR = self.ribbon_non_h_color.text() set(app_constants.GRID_VIEW_T_NON_H_COLOR, 'Visual', 'grid view t non-h color') if self.color_checker(self.ribbon_cosplay_color.text()): app_constants.GRID_VIEW_T_COSPLAY_COLOR = self.ribbon_cosplay_color.text() set(app_constants.GRID_VIEW_T_COSPLAY_COLOR, 'Visual', 'grid view t cosplay color') if self.color_checker(self.ribbon_other_color.text()): app_constants.GRID_VIEW_T_OTHER_COLOR = self.ribbon_other_color.text() set(app_constants.GRID_VIEW_T_OTHER_COLOR, 'Visual', 'grid view t other color') # Advanced / Misc app_constants.EXTERNAL_VIEWER_ARGS = self.external_viewer_args.text() set(app_constants.EXTERNAL_VIEWER_ARGS, 'Advanced', 'external viewer args') # Advanced / Misc / Grid View app_constants.SCROLL_SPEED = self.scroll_speed set(self.scroll_speed, 'Advanced', 'scroll speed') self.scroll_speed_changed.emit() app_constants.THUMBNAIL_CACHE_SIZE = self.cache_size set(self.cache_size[1], 'Advanced', 'cache size') QPixmapCache.setCacheLimit(self.cache_size[0]* self.cache_size[1]) app_constants.FORCE_HIGH_DPI_SUPPORT = self.force_high_dpi_support.isChecked() set(app_constants.FORCE_HIGH_DPI_SUPPORT, 'Advanced', 'force high dpi support') # Advanced / General / Gallery Text Fixer app_constants.GALLERY_DATA_FIX_REGEX = self.g_data_regex_fix_edit.text() set(app_constants.GALLERY_DATA_FIX_REGEX, 'Advanced', 'gallery data fix regex') app_constants.GALLERY_DATA_FIX_TITLE = self.g_data_fixer_title.isChecked() set(app_constants.GALLERY_DATA_FIX_TITLE, 'Advanced', 'gallery data fix title') app_constants.GALLERY_DATA_FIX_ARTIST = self.g_data_fixer_artist.isChecked() set(app_constants.GALLERY_DATA_FIX_ARTIST, 'Advanced', 'gallery data fix artist') app_constants.GALLERY_DATA_FIX_REPLACE = self.g_data_replace_fix_edit.text() set(app_constants.GALLERY_DATA_FIX_REPLACE, 'Advanced', 'gallery data fix replace') # About / DB Overview settings.save() self.close() def init_right_panel(self): #def title_def(title): # title_lbl = QLabel(title) # f = QFont() # f.setPixelSize(16) # title_lbl.setFont(f) # return title_lbl def groupbox(name, layout, parent, add_groupbox_in_layout=None): """ Makes a groupbox and a layout for you Returns groupbox and layout """ g = QGroupBox(name, parent) l = layout(g) if add_groupbox_in_layout: if isinstance(add_groupbox_in_layout, QFormLayout): add_groupbox_in_layout.addRow(g) else: add_groupbox_in_layout.addWidget(g) return g, l def option_lbl_checkbox(text, optiontext, parent=None): l = QLabel(text) c = QCheckBox(text, parent) return l, c def new_tab(name, parent, scroll=False): """ Creates a new tab. Returns new tab page widget and it's layout """ new_t = QWidget(parent) new_l = QFormLayout(new_t) if scroll: scr = QScrollArea(parent) scr.setBackgroundRole(QPalette.Base) scr.setWidget(new_t) scr.setWidgetResizable(True) parent.addTab(scr, name) return new_t, new_l else: parent.addTab(new_t, name) return new_t, new_l # App application = QTabWidget(self) self.application_index = self.right_panel.addWidget(application) application_general, app_general_m_l = new_tab('General', application, True) # App / General self.sidebar_widget_hidden = QCheckBox("Show sidebar widget on startup") app_general_m_l.addRow(self.sidebar_widget_hidden) self.send_2_trash = QCheckBox("Send deleted files to recycle bin", self) self.send_2_trash.setToolTip("When unchecked, files will get deleted permanently and be unrecoverable!") app_general_m_l.addRow(self.send_2_trash) self.keep_added_gallery = QCheckBox("Remove galleries added in inbox on exit") self.keep_added_gallery.setToolTip("When turned off, galleries in inbox will not be deleted on exit") app_general_m_l.addRow(self.keep_added_gallery) # App / General / Search app_search, app_search_layout = groupbox('Search', QFormLayout, application_general) app_general_m_l.addRow(app_search) # App / General / Search / autocomplete self.search_autocomplete = QCheckBox('*') self.search_autocomplete.setChecked(app_constants.SEARCH_AUTOCOMPLETE) self.search_autocomplete.setToolTip('Turn autocomplete on/off') app_search_layout.addRow('Autocomplete', self.search_autocomplete) # App / General / Search / search behaviour self.search_every_keystroke = QRadioButton('Search on every keystroke *', app_search) app_search_layout.addRow(self.search_every_keystroke) self.search_on_enter = QRadioButton('Search on return-key *', app_search) app_search_layout.addRow(self.search_on_enter) # App / General / External Viewer app_external_viewer, app_external_viewer_l = groupbox('External Viewer', QFormLayout, application_general, app_general_m_l) external_viewer_p_info = QLabel("Tip: If your preffered image viewer doesn't work, try changing the arguments sent in the Advanced section") external_viewer_p_info.setWordWrap(True) app_external_viewer_l.addRow(external_viewer_p_info) self.external_viewer_path = PathLineEdit(app_external_viewer, False, '') self.external_viewer_path.setPlaceholderText('Right/Left-click to open folder explorer.'+ ' Leave empty to use default viewer') self.external_viewer_path.setToolTip('Right/Left-click to open folder explorer.'+ ' Leave empty to use default viewer') self.external_viewer_path.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) app_external_viewer_l.addRow('Path:', self.external_viewer_path) # App / General / Rar Support app_rar_group, app_rar_layout = groupbox('RAR Support *', QFormLayout, self) app_general_m_l.addRow(app_rar_group) rar_info = QLabel('Specify the path to the unrar tool to enable rar support.\n'+ 'Windows: "unrar.exe" should be in the "bin" directory if you installed from the'+ ' self-extracting archive provided on github.\nOSX: You can install this via HomeBrew.'+ ' Path should be something like: "/usr/local/bin/unrar".\nLinux: Should already be'+ ' installed. You can just type "unrar". If it\'s not installed, use your package manager: pacman -S unrar') rar_info.setWordWrap(True) app_rar_layout.addRow(rar_info) self.path_to_unrar = PathLineEdit(self, False, filters='') app_rar_layout.addRow('UnRAR tool path:', self.path_to_unrar) # App / Gallery app_gallery_page, app_gallery_l = new_tab('Gallery', application, True) g_def_values, g_def_values_l = groupbox("Default values", QFormLayout, app_gallery_page) app_gallery_l.addRow(g_def_values) self.g_languages = QComboBox(self) self.g_languages.setInsertPolicy(QComboBox.InsertAlphabetically) self.g_languages.setEditable(True) g_def_values_l.addRow("Default Language", self.g_languages) self.g_type = QComboBox(self) g_def_values_l.addRow("Default Type", self.g_type) self.g_status = QComboBox(self) g_def_values_l.addRow("Default Status", self.g_status) self.subfolder_as_chapters = QCheckBox("Subdirectiories should be treated as standalone galleries instead of chapters (applies in archives too)") self.subfolder_as_chapters.setToolTip("This option will enable creating standalone galleries for each subdirectiories found recursively when importing."+ "\nDefault action is treating each subfolder found as chapters of a gallery.") extract_gallery_info = QLabel("Note: This option has no effect when turned off if path to external viewer is not specified.") self.extract_gallery_before_opening = QCheckBox("Extract archive before opening (Uncheck only if your viewer supports it)") self.open_galleries_sequentially = QCheckBox("Open chapters sequentially (Note: has no effect if path to viewer is not specified)") subf_info = QLabel("Behaviour of 'Scan for new galleries on startup' option will be affected.") subf_info.setWordWrap(True) app_gallery_l.addRow('Note:', subf_info) app_gallery_l.addRow(self.subfolder_as_chapters) app_gallery_l.addRow(extract_gallery_info) app_gallery_l.addRow(self.extract_gallery_before_opening) app_gallery_l.addRow(self.open_galleries_sequentially) self.move_imported_gs, move_imported_gs_l = groupbox('Move imported galleries', QFormLayout, app_gallery_page) self.move_imported_gs.setCheckable(True) self.move_imported_gs.setToolTip("Move imported galleries to specified folder.") self.move_imported_def_path = PathLineEdit() move_imported_gs_l.addRow('Directory:', self.move_imported_def_path) app_gallery_l.addRow(self.move_imported_gs) self.rename_g_source_group, rename_g_source_l = groupbox('Rename gallery source (Coming soon)', QFormLayout, app_gallery_page) self.rename_g_source_group.setCheckable(True) self.rename_g_source_group.setDisabled(True) app_gallery_l.addRow(self.rename_g_source_group) rename_g_source_l.addRow(QLabel("Check what to include when renaming gallery source. (Same order)")) rename_g_source_flow_l = FlowLayout() rename_g_source_l.addRow(rename_g_source_flow_l) self.rename_artist = QCheckBox("Artist") self.rename_title = QCheckBox("Title") self.rename_lang = QCheckBox("Language") self.rename_title.setChecked(True) self.rename_title.setDisabled(True) rename_g_source_flow_l.addWidget(self.rename_artist) rename_g_source_flow_l.addWidget(self.rename_title) rename_g_source_flow_l.addWidget(self.rename_lang) random_gallery_opener, random_g_opener_l = groupbox('Random Gallery Opener', QFormLayout, app_gallery_page) app_gallery_l.addRow(random_gallery_opener) self.open_random_g_chapters = QCheckBox("Open random gallery chapters") random_g_opener_l.addRow(self.open_random_g_chapters) # App / Monitor app_monitor_page = QScrollArea() app_monitor_page.setBackgroundRole(QPalette.Base) app_monitor_dummy = QWidget() app_monitor_page.setWidgetResizable(True) app_monitor_page.setWidget(app_monitor_dummy) application.addTab(app_monitor_page, 'Monitoring') app_monitor_m_l = QVBoxLayout(app_monitor_dummy) # App / Monitor / misc app_monitor_misc_group = QGroupBox('General *', self) app_monitor_m_l.addWidget(app_monitor_misc_group) app_monitor_misc_m_l = QFormLayout(app_monitor_misc_group) monitor_info = QLabel('Directory monitoring will monitor the specified directories for any'+ ' filesystem events. For example if you delete a gallery source in one of your'+ ' monitored directories the application will inform you and ask if'+ ' you want to delete the gallery from the application as well.') monitor_info.setWordWrap(True) app_monitor_misc_m_l.addRow(monitor_info) self.enable_monitor = QCheckBox('Enable directory monitoring') app_monitor_misc_m_l.addRow(self.enable_monitor) self.look_new_gallery_startup = QCheckBox('Scan for new galleries on startup') app_monitor_misc_m_l.addRow(self.look_new_gallery_startup) # App / Monitor / folders app_monitor_group = QGroupBox('Directories *', self) app_monitor_m_l.addWidget(app_monitor_group, 1) app_monitor_folders_m_l = QVBoxLayout(app_monitor_group) app_monitor_folders_add = QPushButton('+') app_monitor_folders_add.clicked.connect(self.add_folder_monitor) app_monitor_folders_add.setMaximumWidth(20) app_monitor_folders_add.setMaximumHeight(20) app_monitor_folders_m_l.addWidget(app_monitor_folders_add, 0, Qt.AlignRight) self.folders_layout = QFormLayout() app_monitor_folders_m_l.addLayout(self.folders_layout) # App / Ignore app_ignore, app_ignore_m_l = new_tab('Ignore', application, True) ignore_ext_group, ignore_ext_l = groupbox('Folder && File extensions (Check to ignore)', QVBoxLayout, app_monitor_dummy) app_ignore_m_l.addRow(ignore_ext_group) ignore_ext_list_l = FlowLayout() ignore_ext_l.addLayout(ignore_ext_list_l) self.ignore_folder = QCheckBox("Folder", ignore_ext_group) ignore_ext_list_l.addWidget(self.ignore_folder) self.ignore_zip = QCheckBox("ZIP", ignore_ext_group) ignore_ext_list_l.addWidget(self.ignore_zip) self.ignore_cbz = QCheckBox("CBZ", ignore_ext_group) ignore_ext_list_l.addWidget(self.ignore_cbz) self.ignore_rar = QCheckBox("RAR", ignore_ext_group) ignore_ext_list_l.addWidget(self.ignore_rar) self.ignore_cbr = QCheckBox("CBR", ignore_ext_group) ignore_ext_list_l.addWidget(self.ignore_cbr) app_ignore_group, app_ignore_list_l = groupbox('List', QVBoxLayout, app_monitor_dummy) app_ignore_m_l.addRow(app_ignore_group) add_buttons_l = QHBoxLayout() app_ignore_add_a = QPushButton('Add archive') app_ignore_add_a.clicked.connect(lambda: self.add_ignore_path(dir=False)) app_ignore_add_f = QPushButton('Add directory') app_ignore_add_f.clicked.connect(self.add_ignore_path) add_buttons_l.addWidget(app_ignore_add_a, 0, Qt.AlignRight) add_buttons_l.addWidget(app_ignore_add_f, 1, Qt.AlignRight) app_ignore_list_l.addLayout(add_buttons_l) self.ignore_path_l = QFormLayout() app_ignore_list_l.addLayout(self.ignore_path_l) # Web web = QTabWidget(self) self.web_index = self.right_panel.addWidget(web) # Web / Logins logins_page, logins_layout = new_tab("Logins", web, True) def login(userlineedit, passlineedit, statuslbl, baseHen_class, partial_txt, relogin=False): statuslbl.setText("Logging in...") statuslbl.show() try: c_h = baseHen_class.login(userlineedit.text(), passlineedit.text(), relogin) result = baseHen_class.check_login(c_h) if result == 1: statuslbl.setText("{}".format(partial_txt)) elif result: statuslbl.setText("Logged in!") else: statuslbl.setText("Logging in failed!") except app_constants.WrongLogin: statuslbl.setText("Wrong login information!") def make_login_forms(layout, exprops, baseHen_class, partial_txt='You have partial access!', info=''): status = QLabel(logins_page) status.setText("Not logged in!") layout.addRow(status) user = QLineEdit(logins_page) usertxt = 'Username:' passtxt = 'Password:' if baseHen_class == pewnet.EHen: usertxt = 'IPB Member ID:' passtxt = 'IPB Pass Hash:' layout.addRow(usertxt, user) passw = QLineEdit(logins_page) layout.addRow(passtxt, passw) passw.setEchoMode(QLineEdit.Password) log_btn = QPushButton("Login") b_l = QHBoxLayout() b_l.addWidget(Spacer('h')) b_l.addWidget(log_btn) layout.addRow(b_l) if info: layout.addRow(QLabel(info)) result = baseHen_class.check_login(exprops.cookies) if result == 1: status.setText("{}".format(partial_txt)) elif result: status.setText("Logged in!") if result: user.setText(exprops.username) passw.setText(exprops.password) log_btn.setText("Relogin") log_btn.clicked.connect(lambda: login(user, passw, status, baseHen_class, partial_txt, True)) else: log_btn.clicked.connect(lambda: login(user, passw, status, baseHen_class, partial_txt)) return user, passw, status # ehentai exprops = settings.ExProperties ehentai_group, ehentai_l = groupbox("E-Hentai", QFormLayout, logins_page) logins_layout.addRow(ehentai_group) ehentai_user, ehentai_pass, ehentai_status = make_login_forms(ehentai_l, exprops(), pewnet.EHen, "You have partial access (e-hentai). You do not have access to exhentai.", app_constants.EXHEN_COOKIE_TUTORIAL) # nhentai #nhentai_group, nhentai_l = groupbox("NHentai", QFormLayout, logins_page) #logins_layout.addRow(nhentai_group) #nhentai_user, nhentai_pass, nhentai_status = make_login_forms(nhentai_l, exprops(exprops.NHENTAI), pewnet.NHen) # Web / Downloader web_downloader, web_downloader_l = new_tab('Downloader', web) hen_download_group, hen_download_group_l = groupbox('E-Hentai', QFormLayout, web_downloader) web_downloader_l.addRow(hen_download_group) self.archive_download = QRadioButton('Archive', hen_download_group) self.torrent_download = QRadioButton('Torrent', hen_download_group) download_type_l = QHBoxLayout() download_type_l.addWidget(self.archive_download) download_type_l.addWidget(self.torrent_download, 1) hen_download_group_l.addRow('Download Type:', download_type_l) self.download_directory = PathLineEdit(web_downloader) web_downloader_l.addRow('Destination:', self.download_directory) self.torrent_client = PathLineEdit(web_downloader, False, '') web_downloader_l.addRow(QLabel("Leave empty to use default torrent client."+ "\nIt is NOT recommended to import a file while it's still downloading.")) web_downloader_l.addRow('Torrent client:', self.torrent_client) self.download_gallery_lib = QCheckBox("Send downloaded galleries directly to library") web_downloader_l.addRow(self.download_gallery_lib) # Web / Metadata web_metadata_page = QScrollArea() web_metadata_page.setBackgroundRole(QPalette.Base) web_metadata_page.setWidgetResizable(True) web.addTab(web_metadata_page, 'Metadata') web_metadata_dummy = QWidget() web_metadata_page.setWidget(web_metadata_dummy) web_metadata_m_l = QFormLayout(web_metadata_dummy) self.default_ehen_url = QRadioButton('e-hentai.org', web_metadata_page) self.exhentai_ehen_url = QRadioButton('exhentai.org (login needed)', web_metadata_page) ehen_url_l = QHBoxLayout() ehen_url_l.addWidget(self.default_ehen_url) ehen_url_l.addWidget(self.exhentai_ehen_url, 1) web_metadata_m_l.addRow('Default EH:', ehen_url_l) self.include_expunged = QCheckBox('Allow fetching from expunged galleries') web_metadata_m_l.addRow(self.include_expunged) self.continue_a_metadata_fetcher = QCheckBox('Skip galleries that has already been processed in auto metadata fetcher') web_metadata_m_l.addRow(self.continue_a_metadata_fetcher) self.use_jpn_title = QCheckBox('Apply japanese title instead of english title') self.use_jpn_title.setToolTip('Applies the japanese title instead of the english') web_metadata_m_l.addRow(self.use_jpn_title) time_offset_info = QLabel('A delay between EH requests to avoid getting temp banned.') self.web_time_offset = QSpinBox() self.web_time_offset.setMaximumWidth(40) self.web_time_offset.setMinimum(3) self.web_time_offset.setMaximum(99) web_metadata_m_l.addRow(time_offset_info) web_metadata_m_l.addRow('Delay in seconds:', self.web_time_offset) replace_metadata_info = QLabel('By default metadata is appended to a gallery.\n'+ 'Enabling this option makes it so that a gallery\'s old data'+ ' is deleted and replaced with the new data.') replace_metadata_info.setWordWrap(True) self.replace_metadata = QCheckBox('Replace old metadata with new metadata') web_metadata_m_l.addRow(replace_metadata_info) web_metadata_m_l.addRow(self.replace_metadata) self.always_first_hit = QCheckBox('Always choose first gallery found') web_metadata_m_l.addRow(self.always_first_hit) use_gallery_link_info = QLabel("Enable this option to fetch metadata using the currently applied URL on the gallery") self.use_gallery_link = QCheckBox('Use currently applied gallery URL') self.use_gallery_link.setToolTip("Metadata will be fetched from the current gallery URL"+ " if it's a supported gallery url") web_metadata_m_l.addRow(use_gallery_link_info) web_metadata_m_l.addRow(self.use_gallery_link) fallback_source_info = QLabel("Specify which sources metadata fetcher should fallback to when a gallery is not found.") fallback_source_l = FlowLayout() web_metadata_m_l.addRow(fallback_source_info) web_metadata_m_l.addRow(fallback_source_l) self.fallback_chaika = QCheckBox("panda.chaika.moe") fallback_source_l.addWidget(self.fallback_chaika) # Visual visual = QTabWidget(self) self.visual_index = self.right_panel.addWidget(visual) visual_general_page = QWidget() visual.addTab(visual_general_page, 'General') # grid view grid_view_general_page, grid_view_layout = new_tab("Grid View", visual, True) # grid view / popup grid_popup, grid_popup_l = groupbox("Popup", QFormLayout, grid_view_general_page) grid_view_layout.addRow(grid_popup) self.g_popup_width = QSpinBox(grid_popup) self.g_popup_width.setRange(200, 100000) self.g_popup_width.setFixedWidth(120) grid_popup_l.addRow("Popup Width:", self.g_popup_width) self.g_popup_height = QSpinBox(grid_popup) self.g_popup_height.setRange(100, 1000000) self.g_popup_height.setFixedWidth(120) grid_popup_l.addRow("Popup Height:", self.g_popup_height) # grid view / tooltip self.grid_tooltip_group = QGroupBox('Tooltip', grid_view_general_page) self.grid_tooltip_group.setCheckable(True) grid_view_layout.addRow(self.grid_tooltip_group) grid_tooltip_layout = QFormLayout() self.grid_tooltip_group.setLayout(grid_tooltip_layout) grid_tooltip_layout.addRow(QLabel('Control what is'+ ' displayed in the tooltip when hovering a gallery')) grid_tooltips_hlayout = FlowLayout() grid_tooltip_layout.addRow(grid_tooltips_hlayout) self.visual_grid_tooltip_title = QCheckBox('Title') grid_tooltips_hlayout.addWidget(self.visual_grid_tooltip_title) self.visual_grid_tooltip_author = QCheckBox('Author') grid_tooltips_hlayout.addWidget(self.visual_grid_tooltip_author) self.visual_grid_tooltip_chapters = QCheckBox('Chapters') grid_tooltips_hlayout.addWidget(self.visual_grid_tooltip_chapters) self.visual_grid_tooltip_status = QCheckBox('Status') grid_tooltips_hlayout.addWidget(self.visual_grid_tooltip_status) self.visual_grid_tooltip_type = QCheckBox('Type') grid_tooltips_hlayout.addWidget(self.visual_grid_tooltip_type) self.visual_grid_tooltip_lang = QCheckBox('Language') grid_tooltips_hlayout.addWidget(self.visual_grid_tooltip_lang) self.visual_grid_tooltip_descr = QCheckBox('Description') grid_tooltips_hlayout.addWidget(self.visual_grid_tooltip_descr) self.visual_grid_tooltip_tags = QCheckBox('Tags') grid_tooltips_hlayout.addWidget(self.visual_grid_tooltip_tags) self.visual_grid_tooltip_last_read = QCheckBox('Last read') grid_tooltips_hlayout.addWidget(self.visual_grid_tooltip_last_read) self.visual_grid_tooltip_times_read = QCheckBox('Times read') grid_tooltips_hlayout.addWidget(self.visual_grid_tooltip_times_read) self.visual_grid_tooltip_pub_date = QCheckBox('Publication Date') grid_tooltips_hlayout.addWidget(self.visual_grid_tooltip_pub_date) self.visual_grid_tooltip_date_added = QCheckBox('Date added') grid_tooltips_hlayout.addWidget(self.visual_grid_tooltip_date_added) # grid view / gallery grid_gallery_group = QGroupBox('Gallery', grid_view_general_page) grid_view_layout.addRow(grid_gallery_group) grid_gallery_main_l = QFormLayout() grid_gallery_main_l.setFormAlignment(Qt.AlignLeft) grid_gallery_group.setLayout(grid_gallery_main_l) grid_gallery_display = FlowLayout() grid_gallery_main_l.addRow('Display on gallery:', grid_gallery_display) self.gallery_rating = QCheckBox('Rating') grid_gallery_display.addWidget(self.gallery_rating) self.gallery_type_ico = QCheckBox('File Type') grid_gallery_display.addWidget(self.gallery_type_ico) if sys.platform.startswith('darwin'): self.gallery_rating.setEnabled(False) self.gallery_type_ico.setEnabled(False) gallery_text_mode = QWidget() grid_gallery_main_l.addRow('Text Mode:', gallery_text_mode) gallery_text_mode_l = QHBoxLayout() gallery_text_mode.setLayout(gallery_text_mode_l) self.gallery_text_elide = QRadioButton('Elide text', gallery_text_mode) self.gallery_text_fit = QRadioButton('Fit text', gallery_text_mode) gallery_text_mode_l.addWidget(self.gallery_text_elide, 0, Qt.AlignLeft) gallery_text_mode_l.addWidget(self.gallery_text_fit, 0, Qt.AlignLeft) gallery_text_mode_l.addWidget(Spacer('h'), 1, Qt.AlignLeft) gallery_font = QHBoxLayout() grid_gallery_main_l.addRow('Font:*', gallery_font) self.font_lbl = QLabel() self.font_size_lbl = QSpinBox() self.font_size_lbl.setMaximum(100) self.font_size_lbl.setMinimum(1) self.font_size_lbl.setToolTip('Font size in pixels') choose_font = QPushButton('Choose font') choose_font.clicked.connect(self.choose_font) gallery_font.addWidget(self.font_lbl, 0, Qt.AlignLeft) gallery_font.addWidget(self.font_size_lbl, 0, Qt.AlignLeft) gallery_font.addWidget(choose_font, 0, Qt.AlignLeft) gallery_font.addWidget(Spacer('h'), 1, Qt.AlignLeft) class NoWheelSlider(QSlider): def __init__(self, ori, p): super().__init__(ori, p) def wheelEvent(self, ev): ev.ignore() gallery_size_lbl = QLabel(self) self.gallery_size = NoWheelSlider(Qt.Horizontal, self) self.gallery_size.valueChanged.connect(lambda x: gallery_size_lbl.setText(str(x+2))) self.gallery_size.setMinimum(-2) self.gallery_size.setMaximum(10) self.gallery_size.setSingleStep(1) self.gallery_size.setPageStep(3) self.gallery_size.setTickInterval(1) self.gallery_size.setTickPosition(QSlider.TicksBothSides) self.gallery_size.setToolTip("Changes size of grid in gridview. Remember to re-generate thumbnails! DEFAULT=3") gallery_size_l = QHBoxLayout() gallery_size_l.addWidget(gallery_size_lbl) gallery_size_l.addWidget(self.gallery_size) grid_gallery_main_l.addRow(QLabel("Note: A manual re-generation of thumbnails is required. Advanced -> Gallery")) grid_gallery_main_l.addRow("Thumbnail Size:*", gallery_size_l) self.grid_spacing = QSpinBox(self) self.grid_spacing.setMinimum(1) self.grid_spacing.setMaximum(99) self.grid_spacing.setToolTip("Changes space between thumbnails in gridview. DEFAULT=15") self.grid_spacing.adjustSize() self.grid_spacing.setFixedWidth(self.grid_spacing.width()) grid_gallery_main_l.addRow("Spacing:*", self.grid_spacing) # grid view / colors grid_colors_group = QGroupBox('Colors', grid_view_general_page) grid_view_layout.addRow(grid_colors_group) grid_colors_l = QFormLayout() grid_colors_group.setLayout(grid_colors_l) def color_lineedit(): l = QLineEdit() l.setPlaceholderText('Hex colors. Eg.: #323232') l.setMaximumWidth(200) return l self.grid_label_color, hbox_layout = self._get_color_line_edit_and_hbox_layout( hex_color=app_constants.GRID_VIEW_LABEL_COLOR) grid_colors_l.addRow('Label color:', hbox_layout) self.grid_title_color, hbox_layout = self._get_color_line_edit_and_hbox_layout( hex_color=app_constants.GRID_VIEW_TITLE_COLOR) grid_colors_l.addRow('Title color:', hbox_layout) self.grid_artist_color, hbox_layout = self._get_color_line_edit_and_hbox_layout( hex_color=app_constants.GRID_VIEW_ARTIST_COLOR) grid_colors_l.addRow('Artist color:', hbox_layout) # grid view / colors / ribbon self.colors_ribbon_group, colors_ribbon_l = groupbox('Ribbon', QFormLayout, grid_colors_group) self.colors_ribbon_group.setCheckable(True) grid_colors_l.addRow(self.colors_ribbon_group) self.ribbon_manga_color, hbox_layout = self._get_color_line_edit_and_hbox_layout( app_constants.GRID_VIEW_T_MANGA_COLOR) colors_ribbon_l.addRow('Manga', hbox_layout) self.ribbon_doujin_color, hbox_layout = self._get_color_line_edit_and_hbox_layout( app_constants.GRID_VIEW_T_DOUJIN_COLOR) colors_ribbon_l.addRow('Doujinshi', hbox_layout) self.ribbon_artist_cg_color, hbox_layout = self._get_color_line_edit_and_hbox_layout( app_constants.GRID_VIEW_T_ARTIST_CG_COLOR) colors_ribbon_l.addRow('Artist CG', hbox_layout) self.ribbon_game_cg_color, hbox_layout = self._get_color_line_edit_and_hbox_layout( app_constants.GRID_VIEW_T_GAME_CG_COLOR) colors_ribbon_l.addRow('Game CG', hbox_layout) self.ribbon_western_color, hbox_layout = self._get_color_line_edit_and_hbox_layout( app_constants.GRID_VIEW_T_WESTERN_COLOR) colors_ribbon_l.addRow('Western', hbox_layout) self.ribbon_image_color, hbox_layout = self._get_color_line_edit_and_hbox_layout( app_constants.GRID_VIEW_T_IMAGE_COLOR) colors_ribbon_l.addRow('Image', hbox_layout) self.ribbon_non_h_color, hbox_layout = self._get_color_line_edit_and_hbox_layout( app_constants.GRID_VIEW_T_NON_H_COLOR) colors_ribbon_l.addRow('Non-H', hbox_layout) self.ribbon_cosplay_color, hbox_layout = self._get_color_line_edit_and_hbox_layout( app_constants.GRID_VIEW_T_COSPLAY_COLOR) colors_ribbon_l.addRow('Cosplay', hbox_layout) self.ribbon_other_color, hbox_layout = self._get_color_line_edit_and_hbox_layout( app_constants.GRID_VIEW_T_OTHER_COLOR) colors_ribbon_l.addRow('Other', hbox_layout) # Style style_page = QWidget(self) visual.addTab(style_page, 'Style') visual.setTabEnabled(0, False) visual.setTabEnabled(2, False) visual.setCurrentIndex(1) # Advanced advanced = QTabWidget(self) self.advanced_index = self.right_panel.addWidget(advanced) advanced_misc_scroll = QScrollArea(self) advanced_misc_scroll.setBackgroundRole(QPalette.Base) advanced_misc_scroll.setWidgetResizable(True) advanced_misc = QWidget() advanced_misc_scroll.setWidget(advanced_misc) advanced.addTab(advanced_misc_scroll, 'Misc') advanced_misc_main_layout = QVBoxLayout() advanced_misc.setLayout(advanced_misc_main_layout) misc_controls_layout = QFormLayout() advanced_misc_main_layout.addLayout(misc_controls_layout) high_dpi_info = QLabel("Warning: This option may incur some scaling or painting artifacts") misc_controls_layout.addRow(high_dpi_info) self.force_high_dpi_support = QCheckBox("Force High DPI support *", self) misc_controls_layout.addRow(self.force_high_dpi_support) external_view_group, external_view_l = groupbox("External Viewer Arguments", QFormLayout, advanced) misc_controls_layout.addRow(external_view_group) external_viewer_info = QLabel(app_constants.EXTERNAL_VIEWER_INFO) external_viewer_info.setWordWrap(True) self.external_viewer_args = QLineEdit(advanced) external_view_l.addRow("Available tokens:", external_viewer_info) external_view_l.addRow("Arguments:", self.external_viewer_args) # Advanced / Misc / Grid View misc_gridview = QGroupBox('Grid View') misc_controls_layout.addRow(misc_gridview) misc_gridview_layout = QFormLayout() misc_gridview.setLayout(misc_gridview_layout) # Advanced / Misc / Grid View / scroll speed scroll_speed_spin_box = QSpinBox() scroll_speed_spin_box.setFixedWidth(60) scroll_speed_spin_box.setToolTip('Control the speed when scrolling in'+ ' grid view. DEFAULT: 7') scroll_speed_spin_box.setValue(self.scroll_speed) def scroll_speed(v): self.scroll_speed = v scroll_speed_spin_box.valueChanged[int].connect(scroll_speed) misc_gridview_layout.addRow('Scroll speed:', scroll_speed_spin_box) # Advanced / Misc / Grid View / cache size cache_size_spin_box = QSpinBox() cache_size_spin_box.setFixedWidth(120) cache_size_spin_box.setMaximum(999999999) cache_size_spin_box.setToolTip('This can greatly reduce lags/freezes in the grid view.' + ' Increase the value if you experience lag when scrolling'+ ' through galleries. DEFAULT: 200 MiB') def cache_size(c): self.cache_size = (self.cache_size[0], c) cache_size_spin_box.setValue(self.cache_size[1]) cache_size_spin_box.valueChanged[int].connect(cache_size) misc_gridview_layout.addRow('Cache Size (MiB):', cache_size_spin_box) # Advanced / Gallery advanced_gallery, advanced_gallery_m_l = new_tab('Gallery', advanced) def rebuild_thumbs(): confirm_msg = QMessageBox(QMessageBox.Question, '', 'Are you sure you want to regenerate your thumbnails.', QMessageBox.Yes | QMessageBox.No, self) if confirm_msg.exec() == QMessageBox.Yes: clear_cache_confirm = QMessageBox(QMessageBox.Question, '', 'Do you want to delete all old thumbnails before regenerating?', QMessageBox.Yes | QMessageBox.No, self) clear_cache = False if clear_cache_confirm.exec() == QMessageBox.Yes: clear_cache = True app_spinner = misc.Spinner(self.parent_widget) app_spinner.set_size(60) app_spinner.set_text("Thumbnails") app_spinner.admin_db = gallerydb.AdminDB() app_spinner.admin_db.moveToThread(app_constants.GENERAL_THREAD) app_spinner.admin_db.DONE.connect(app_spinner.admin_db.deleteLater) app_spinner.admin_db.DONE.connect(app_spinner.before_hide) self.init_gallery_rebuild.connect(app_spinner.admin_db.rebuild_thumbs) self.init_gallery_rebuild.emit(clear_cache) app_spinner.show() rebuild_thumbs_info = QLabel("Clears thumbnail cache and rebuilds it, which can take a while. Tip: Useful when changing thumbnail size.") rebuild_thumbs_btn = QPushButton('Regenerate Thumbnails') rebuild_thumbs_btn.adjustSize() rebuild_thumbs_btn.setFixedWidth(rebuild_thumbs_btn.width()) rebuild_thumbs_btn.clicked.connect(rebuild_thumbs) advanced_gallery_m_l.addRow(rebuild_thumbs_info) advanced_gallery_m_l.addRow(rebuild_thumbs_btn) g_data_fixer_group, g_data_fixer_l = groupbox('Gallery Renamer', QFormLayout, advanced_gallery) g_data_fixer_group.setEnabled(False) advanced_gallery_m_l.addRow(g_data_fixer_group) g_data_regex_fix_lbl = QLabel("Rename a gallery through regular expression."+ " A regex cheatsheet is located at About -> Regex Cheatsheet.") g_data_regex_fix_lbl.setWordWrap(True) g_data_fixer_l.addRow(g_data_regex_fix_lbl) self.g_data_regex_fix_edit = QLineEdit() self.g_data_regex_fix_edit.setPlaceholderText("Valid regex") g_data_fixer_l.addRow('Regex:', self.g_data_regex_fix_edit) self.g_data_replace_fix_edit = QLineEdit() self.g_data_replace_fix_edit.setPlaceholderText("Leave empty to delete matches") g_data_fixer_l.addRow('Replace with:', self.g_data_replace_fix_edit) g_data_fixer_options = FlowLayout() g_data_fixer_l.addRow(g_data_fixer_options) self.g_data_fixer_title = QCheckBox("Title", g_data_fixer_group) self.g_data_fixer_artist = QCheckBox("Artist", g_data_fixer_group) g_data_fixer_options.addWidget(self.g_data_fixer_title) g_data_fixer_options.addWidget(self.g_data_fixer_artist) # Advanced / Database advanced_db_page, advanced_db_page_l = new_tab('Database', advanced) # Advanced / Database / Import/Export def init_export(): confirm_msg = QMessageBox(QMessageBox.Question, '', 'Are you sure you want to export your database?', QMessageBox.Yes | QMessageBox.No, self) if confirm_msg.exec() == QMessageBox.Yes: app_popup = AppDialog(self.parent_widget) app_popup.info_lbl.setText("Exporting database...") app_popup.export_instance = io_misc.ImportExport() app_popup.export_instance.moveToThread(app_constants.GENERAL_THREAD) app_popup.export_instance.finished.connect(app_popup.export_instance.deleteLater) app_popup.export_instance.finished.connect(app_popup.close) app_popup.export_instance.amount.connect(app_popup.prog.setMaximum) app_popup.export_instance.progress.connect(app_popup.prog.setValue) self.init_gallery_eximport.connect(app_popup.export_instance.export_data) self.init_gallery_eximport.emit(None) app_popup.adjustSize() app_popup.show() self.close() def init_import(): path = QFileDialog.getOpenFileName(self, 'Choose happypanda database file', filter='*.hpdb') path = path[0] if len(path) != 0: app_popup = AppDialog(self.parent_widget) app_popup.restart_info.hide() app_popup.info_lbl.setText("Importing database file...") app_popup.note_info.setText("Application requires a restart after importing") app_popup.import_instance = io_misc.ImportExport() app_popup.import_instance.moveToThread(app_constants.GENERAL_THREAD) app_popup.import_instance.finished.connect(app_popup.import_instance.deleteLater) app_popup.import_instance.finished.connect(app_popup.init_restart) app_popup.import_instance.amount.connect(app_popup.prog.setMaximum) app_popup.import_instance.imported_g.connect(app_popup.info_lbl.setText) app_popup.import_instance.progress.connect(app_popup.prog.setValue) self.init_gallery_eximport.connect(app_popup.import_instance.import_data) self.init_gallery_eximport.emit(path) app_popup.adjustSize() app_popup.show() self.close() advanced_impexp, advanced_impexp_l = groupbox('Import/Export', QFormLayout, advanced_db_page) advanced_db_page_l.addRow(advanced_impexp) self.export_format = QComboBox(advanced_db_page) #self.export_format.addItem('Text File', 0) self.export_format.addItem('HPDB', 1) self.export_format.adjustSize() self.export_format.setFixedWidth(self.export_format.width()) advanced_impexp_l.addRow('Export Format:', self.export_format) self.export_path = PathLineEdit(advanced_impexp, filters='') advanced_impexp_l.addRow('Export Path:', self.export_path) import_btn = QPushButton('Import database') import_btn.clicked.connect(init_import) export_btn = QPushButton('Export database') export_btn.clicked.connect(init_export) ex_imp_btn_l = QHBoxLayout() ex_imp_btn_l.addWidget(import_btn) ex_imp_btn_l.addWidget(export_btn) advanced_impexp_l.addRow(ex_imp_btn_l) # About about = QTabWidget(self) self.about_index = self.right_panel.addWidget(about) about_happypanda_page, about_layout = new_tab("About Happypanda", about, False) info_lbl = QLabel(app_constants.ABOUT) info_lbl.setWordWrap(True) info_lbl.setOpenExternalLinks(True) about_layout.addWidget(info_lbl) about_layout.addWidget(Spacer('v')) open_hp_folder = QPushButton('Open Happypanda Directory') open_hp_folder.clicked.connect(self.open_hp_folder) open_hp_folder.adjustSize() open_hp_folder.setFixedWidth(open_hp_folder.width()) about_layout.addWidget(open_hp_folder) ## About / DB Overview #about_db_overview, about_db_overview_m_l = new_tab('DB Overview', about) #about_stats_tab_widget = misc_db.DBOverview(self.parent_widget) #about_db_overview_m_l.addRow(about_stats_tab_widget) #about_db_overview.setEnabled(False) # About / Troubleshooting about_troubleshoot_page = QWidget() about.addTab(about_troubleshoot_page, 'Bug Reporting') troubleshoot_layout = QVBoxLayout() about_troubleshoot_page.setLayout(troubleshoot_layout) guide_lbl = QLabel(app_constants.TROUBLE_GUIDE) guide_lbl.setTextFormat(Qt.RichText) guide_lbl.setOpenExternalLinks(True) guide_lbl.setWordWrap(True) troubleshoot_layout.addWidget(guide_lbl, 0, Qt.AlignTop) troubleshoot_layout.addWidget(Spacer('v')) # About / Search tutorial about_search_tut, about_search_tut_l = new_tab("Search Guide", about, True) g_search_lbl = QLabel(app_constants.SEARCH_TUTORIAL_TAGS) g_search_lbl.setWordWrap(True) about_search_tut_l.addRow(g_search_lbl) # About / Regex Cheatsheet about_s_regex, about_s_regex_l = new_tab("Regex Cheatsheet", about, True) reg_info = QLabel(app_constants.REGEXCHEAT) reg_info.setWordWrap(True) about_s_regex_l.addRow(reg_info) # About / Keyboard shortcuts about_k_shortcuts, about_k_shortcuts_l = new_tab("Keyboard Shortcuts", about, True) k_short_info = QLabel(app_constants.KEYBOARD_SHORTCUTS_INFO) k_short_info.setWordWrap(True) about_k_shortcuts_l.addRow(k_short_info) @staticmethod def _get_color_line_edit_and_hbox_layout(hex_color=None): """get ColorLineEdit and hbox layout.""" color_line_edit = ColorLineEdit(hex_color=hex_color) hbox_layout = QHBoxLayout() hbox_layout.addWidget(color_line_edit) hbox_layout.addWidget(color_line_edit.button) return color_line_edit, hbox_layout def add_folder_monitor(self, path=''): if not isinstance(path, str): path = '' l_edit = PathLineEdit() l_edit.setText(path) n = self.folders_layout.rowCount() + 1 self.folders_layout.addRow('{}'.format(n), l_edit) def add_ignore_path(self, path='', dir=True): if not isinstance(path, str): path = '' l_edit = PathLineEdit(dir=dir) l_edit.setText(path) n = self.ignore_path_l.rowCount() + 1 self.ignore_path_l.addRow('{}'.format(n), l_edit) def color_checker(self, txt): allow = False if len(txt) == 7: if txt[0] == '#': allow = True return allow def take_all_layout_widgets(self, l): n = l.rowCount() items = [] for x in range(n): item = l.takeAt(x+1) items.append(item.widget()) return items def choose_font(self): tup = QFontDialog.getFont(self) font = tup[0] if tup[1]: self.font_lbl.setText(font.family()) self.font_size_lbl.setValue(font.pointSize()) def open_hp_folder(self): if os.name == 'posix': utils.open_path(app_constants.posix_program_dir) else: utils.open_path(os.getcwd()) def reject(self): self.close() def _find_combobox_match(self, combobox, key, default): f_index = combobox.findText(key, Qt.MatchFixedString) if f_index != -1: combobox.setCurrentIndex(f_index) else: combobox.setCurrentIndex(default) ================================================ FILE: version/utils.py ================================================ #""" #This file is part of Happypanda. #Happypanda is free software: you can redistribute it and/or modify #it under the terms of the GNU General Public License as published by #the Free Software Foundation, either version 2 of the License, or #any later version. #Happypanda is distributed in the hope that it will be useful, #but WITHOUT ANY WARRANTY; without even the implied warranty of #MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #GNU General Public License for more details. #You should have received a copy of the GNU General Public License #along with Happypanda. If not, see . #""" import datetime import os import subprocess import sys import logging import zipfile import hashlib import shutil import uuid import re import scandir import rarfile import json import send2trash import functools import time from PyQt5.QtGui import QImage, qRgba from PIL import Image,ImageChops try: import app_constants from database import db_constants except: from . import app_constants from .database import db_constants log = logging.getLogger(__name__) log_i = log.info log_d = log.debug log_w = log.warning log_e = log.error log_c = log.critical IMG_FILES = ('.jpg','.bmp','.png','.gif', '.jpeg') ARCHIVE_FILES = ('.zip', '.cbz', '.rar', '.cbr') FILE_FILTER = '*.zip *.cbz *.rar *.cbr' IMG_FILTER = '*.jpg *.bmp *.png *.jpeg' rarfile.PATH_SEP = '/' rarfile.UNRAR_TOOL = app_constants.unrar_tool_path if not app_constants.unrar_tool_path: FILE_FILTER = '*.zip *.cbz' ARCHIVE_FILES = ('.zip', '.cbz') class GMetafile: def __init__(self, path=None, archive=''): self.metadata = { "title":'', "artist":'', "type":'', "tags":{}, "language":'', "pub_date":'', "link":'', "info":'', } self.files = [] if path is None: return if archive: zip = ArchiveFile(archive) c = zip.dir_contents(path) for x in c: if x.endswith(app_constants.GALLERY_METAFILE_KEYWORDS): self.files.append(open(zip.extract(x), encoding='utf-8')) else: for p in scandir.scandir(path): if p.name in app_constants.GALLERY_METAFILE_KEYWORDS: self.files.append(open(p.path, encoding='utf-8')) if self.files: self.detect() else: log_d('No metafile found...') def _eze(self, fp): if not fp.name.endswith('.json'): return j = json.load(fp, encoding='utf-8') eze = ['gallery_info', 'image_api_key', 'image_info'] # eze if all(x in j for x in eze): log_i('Detected metafile: eze') ezedata = j['gallery_info'] t_parser = title_parser(ezedata['title']) self.metadata['title'] = t_parser['title'] self.metadata['type'] = ezedata['category'] for ns in ezedata['tags']: self.metadata['tags'][ns.capitalize()] = ezedata['tags'][ns] self.metadata['tags']['default'] = self.metadata['tags'].pop('Misc', []) self.metadata['artist'] = self.metadata['tags']['Artist'][0].capitalize()\ if 'Artist' in self.metadata['tags'] else t_parser['artist'] self.metadata['language'] = ezedata['language'] d = ezedata['upload_date'] # should be zero padded d[1] = int("0" + str(d[1])) if len(str(d[1])) == 1 else d[1] d[3] = int("0" + str(d[1])) if len(str(d[1])) == 1 else d[1] self.metadata['pub_date'] = datetime.datetime.strptime("{} {} {}".format(d[0], d[1], d[3]), "%Y %m %d") l = ezedata['source'] self.metadata['link'] = 'http://' + l['site'] + '.org/g/' + str(l['gid']) + '/' + l['token'] return True def _hdoujindler(self, fp): "HDoujin Downloader" if fp.name.endswith('info.txt'): log_i('Detected metafile: HDoujin text') lines = fp.readlines() if lines: for line in lines: splitted = line.split(':', 1) if len(splitted) > 1: other = splitted[1].strip() if not other: continue l = splitted[0].lower() if "title" == l: self.metadata['title'] = other if "artist" == l: self.metadata['artist'] = other.capitalize() if "tags" == l: self.metadata['tags'].update(tag_to_dict(other)) if "description" == l: self.metadata['info'] = other if "circle" in l: if not "group" in self.metadata['tags']: self.metadata['tags']['group'] = [] self.metadata['tags']['group'].append(other.strip().lower()) if "url" == l: self.metadata['link'] = other return True ## Doesnt work for some reason.. too lazy to debug #elif fp.name.endswith('info.json'): # log_i('Detected metafile: HDoujin json') # j = json.load(fp, encoding='utf-8') # j = j['manga_info'] # self.metadata['title'] = j['title'] # for n, a in enumerate(j['artist']): # at = a # if not n+1 == len(j['artist']): # at += ', ' # self.metadata['artist'] += at # tags = {} # for x in j['tags']: # ns = 'default' if x == 'misc' else x.capitalize() # tags[ns] = [] # for y in j[tags][x]: # tags[ns].append(y.strip().lower()) # self.metadata['tags'] = tags # self.metadata['link'] = j['url'] # self.metadata['info'] = j['description'] # for x in j['circle']: # if not "group" in self.metadata['tags']: # self.metadata['tags']['group'] = [] # self.metadata['tags']['group'].append(x.strip().lower()) # return True def detect(self): for fp in self.files: with fp: z = False for x in [self._eze, self._hdoujindler]: try: if x(fp): z = True break except Exception: log.exception('Error in parsing metafile') continue if not z: log_i('Incompatible metafiles found') def update(self, other): self.metadata.update((x, y) for x, y in other.metadata.items() if y) def apply_gallery(self, gallery): log_i('Applying metafile to gallery') if self.metadata['title']: gallery.title = self.metadata['title'] if self.metadata['artist']: gallery.artist = self.metadata['artist'] if self.metadata['type']: gallery.type = self.metadata['type'] if self.metadata['tags']: gallery.tags = self.metadata['tags'] if self.metadata['language']: gallery.language = self.metadata['language'] if self.metadata['pub_date']: gallery.pub_date = self.metadata['pub_date'] if self.metadata['link']: gallery.link = self.metadata['link'] if self.metadata['info']: gallery.info = self.metadata['info'] return gallery def backup_database(db_path=db_constants.DB_PATH): log_i("Perfoming database backup") date = "{}".format(datetime.datetime.today()).split(' ')[0] base_path, name = os.path.split(db_path) backup_dir = os.path.join(base_path, 'backup') if not os.path.isdir(backup_dir): os.mkdir(backup_dir) db_name = "{}-{}".format(date, name) current_try = 0 orig_db_name = db_name while current_try < 50: if current_try: db_name = "{}({})-{}".format(date, current_try, orig_db_name) try: dst_path = os.path.join(backup_dir, db_name) if os.path.exists(dst_path): raise ValueError shutil.copyfile(db_path, dst_path) break except ValueError: current_try += 1 log_i("Database backup perfomed: {}".format(db_name)) return True def get_date_age(date): """ Take a datetime and return its "age" as a string. The age can be in second, minute, hour, day, month or year. Only the biggest unit is considered, e.g. if it's 2 days and 3 hours, "2 days" will be returned. Make sure date is not in the future, or else it won't work. """ def formatn(n, s): '''Add "s" if it's plural''' if n == 1: return "1 %s" % s elif n > 1: return "%d %ss" % (n, s) def q_n_r(a, b): '''Return quotient and remaining''' return a / b, a % b class PrettyDelta: def __init__(self, dt): now = datetime.datetime.now() delta = now - dt self.day = delta.days self.second = delta.seconds self.year, self.day = q_n_r(self.day, 365) self.month, self.day = q_n_r(self.day, 30) self.hour, self.second = q_n_r(self.second, 3600) self.minute, self.second = q_n_r(self.second, 60) def format(self): for period in ['year', 'month', 'day', 'hour', 'minute', 'second']: n = getattr(self, period) if n > 0.9: return formatn(n, period) return "0 second" return PrettyDelta(date).format() def all_opposite(*args): "Returns true if all items in iterable evaluae to false" for iterable in args: for x in iterable: if x: return False return True def update_gallery_path(new_path, gallery): "Updates a gallery's chapters path" for chap in gallery.chapters: head, tail = os.path.split(chap.path) if gallery.path == chap.path: chap.path = new_path elif gallery.path == head: chap.path = os.path.join(new_path, tail) gallery.path = new_path return gallery def move_files(path, dest='', only_path=False): """ Move files to a new destination. If dest is not set, imported_galleries_def_path will be used instead. """ if not dest: dest = app_constants.IMPORTED_GALLERY_DEF_PATH if not dest: return path f = os.path.split(path)[1] new_path = os.path.join(dest, f) if not only_path: log_i("Moving to: {}".format(new_path)) if new_path == os.path.join(*os.path.split(path)): # need to unpack to make sure we get the corrct sep return path if not os.path.exists(new_path): app_constants.TEMP_PATH_IGNORE.append(os.path.normcase(new_path)) if not only_path: new_path = shutil.move(path, new_path) else: return path return new_path def check_ignore_list(key): k = os.path.normcase(key) if os.path.isdir(key) and 'Folder' in app_constants.IGNORE_EXTS: return False _, ext = os.path.splitext(key) if ext in app_constants.IGNORE_EXTS: return False for path in app_constants.IGNORE_PATHS: p = os.path.normcase(path) if p in k: return False return True def gallery_text_fixer(gallery): regex_str = app_constants.GALLERY_DATA_FIX_REGEX if regex_str: try: valid_regex = re.compile(regex_str) except re.error: return None if not valid_regex: return None def replace_regex(text): new_text = re.sub(regex_str, app_constants.GALLERY_DATA_FIX_REPLACE, text) return new_text if app_constants.GALLERY_DATA_FIX_TITLE: gallery.title = replace_regex(gallery.title) if app_constants.GALLERY_DATA_FIX_ARTIST: gallery.artist = replace_regex(gallery.artist) return gallery def b_search(data, key): if key: lo = 0 hi = len(data) - 1 while hi >= lo: mid = lo + (hi - lo) // 2 if data[mid] < key: lo = mid + 1 elif data[mid] > key: hi = mid - 1 else: return data[mid] return None def generate_img_hash(src): """ Generates sha1 hash based on the given bytes. Returns hex-digits """ chunk = 8129 sha1 = hashlib.sha1() buffer = src.read(chunk) log_d("Generating hash") while len(buffer) > 0: sha1.update(buffer) buffer = src.read(chunk) return sha1.hexdigest() class ArchiveFile(): """ Work with archive files, raises exception if instance fails. namelist -> returns a list with all files in archive extract <- Extracts one specific file to given path open -> open the given file in archive, returns bytes close -> close archive """ zip, rar = range(2) def __init__(self, filepath): self.type = 0 try: if filepath.endswith(ARCHIVE_FILES): if filepath.endswith(ARCHIVE_FILES[:2]): self.archive = zipfile.ZipFile(os.path.normcase(filepath)) b_f = self.archive.testzip() self.type = self.zip elif filepath.endswith(ARCHIVE_FILES[2:]): self.archive = rarfile.RarFile(os.path.normcase(filepath)) b_f = self.archive.testrar() self.type = self.rar # test for corruption if b_f: log_w('Bad file found in archive {}'.format(filepath.encode(errors='ignore'))) raise app_constants.CreateArchiveFail else: log_e('Archive: Unsupported file format') raise app_constants.CreateArchiveFail except: log.exception('Create archive: FAIL') raise app_constants.CreateArchiveFail def namelist(self): filelist = self.archive.namelist() return filelist def is_dir(self, name): """ Checks if the provided name in the archive is a directory or not """ if not name: return False if not name in self.namelist(): log_e('File {} not found in archive'.format(name)) raise app_constants.FileNotFoundInArchive if self.type == self.zip: if name.endswith('/'): return True elif self.type == self.rar: info = self.archive.getinfo(name) return info.isdir() return False def dir_list(self, only_top_level=False): """ Returns a list of all directories found recursively. For directories not in toplevel a path in the archive to the diretory will be returned. """ if only_top_level: if self.type == self.zip: return [x for x in self.namelist() if x.endswith('/') and x.count('/') == 1] elif self.type == self.rar: potential_dirs = [x for x in self.namelist() if x.count('/') == 0] return [x.filename for x in [self.archive.getinfo(y) for y in potential_dirs] if x.isdir()] else: if self.type == self.zip: return [x for x in self.namelist() if x.endswith('/') and x.count('/') >= 1] elif self.type == self.rar: return [x.filename for x in self.archive.infolist() if x.isdir()] def dir_contents(self, dir_name): """ Returns a list of contents in the directory An empty string will return the contents of the top folder """ if dir_name and not dir_name in self.namelist(): log_e('Directory {} not found in archive'.format(dir_name)) raise app_constants.FileNotFoundInArchive if not dir_name: if self.type == self.zip: con = [x for x in self.namelist() if x.count('/') == 0 or \ (x.count('/') == 1 and x.endswith('/'))] elif self.type == self.rar: con = [x for x in self.namelist() if x.count('/') == 0] return con if self.type == self.zip: dir_con_start = [x for x in self.namelist() if x.startswith(dir_name)] return [x for x in dir_con_start if x.count('/') == dir_name.count('/') and \ (x.count('/') == dir_name.count('/') and not x.endswith('/')) or \ (x.count('/') == 1 + dir_name.count('/') and x.endswith('/'))] elif self.type == self.rar: return [x for x in self.namelist() if x.startswith(dir_name) and \ x.count('/') == 1 + dir_name.count('/')] return [] def extract(self, file_to_ext, path=None): """ Extracts one file from archive to given path Creates a temp_dir if path is not specified Returns path to the extracted file """ if not path: path = os.path.join(app_constants.temp_dir, str(uuid.uuid4())) os.mkdir(path) if not file_to_ext: return self.extract_all(path) else: if self.type == self.zip: membs = [] for name in self.namelist(): if name.startswith(file_to_ext) and name != file_to_ext: membs.append(name) temp_p = self.archive.extract(file_to_ext, path) for m in membs: self.archive.extract(m, path) elif self.type == self.rar: temp_p = os.path.join(path, file_to_ext) self.archive.extract(file_to_ext, path) return temp_p def extract_all(self, path=None, member=None): """ Extracts all files to given path, and returns path If path is not specified, a temp dir will be created """ if not path: path = os.path.join(app_constants.temp_dir, str(uuid.uuid4())) os.mkdir(path) if member: self.archive.extractall(path, member) self.archive.extractall(path) return path def open(self, file_to_open, fp=False): """ Returns bytes. If fp set to true, returns file-like object. """ if fp: return self.archive.open(file_to_open) else: return self.archive.open(file_to_open).read() def close(self): self.archive.close() def check_archive(archive_path): """ Checks archive path for potential galleries. Returns a list with a path in archive to galleries if there is no directories """ try: zip = ArchiveFile(archive_path) except app_constants.CreateArchiveFail: return [] if not zip: return [] galleries = [] zip_dirs = zip.dir_list() def gallery_eval(d): con = zip.dir_contents(d) if con: gallery_probability = len(con) for n in con: if not n.lower().endswith(IMG_FILES): gallery_probability -= 1 if gallery_probability >= (len(con) * 0.8): return d if zip_dirs: # There are directories in the top folder # check parent r = gallery_eval('') if r: galleries.append('') for d in zip_dirs: r = gallery_eval(d) if r: galleries.append(r) zip.close() else: # all pages are in top folder if isinstance(gallery_eval(''), str): galleries.append('') zip.close() return galleries def recursive_gallery_check(path): """ Recursively checks a folder for any potential galleries Returns a list of paths for directories and a list of tuples where first index is path to gallery in archive and second index is path to archive. Like this: ["C:path/to/g"] and [("path/to/g/in/a", "C:path/to/a")] """ gallery_dirs = [] gallery_arch = [] found_paths = 0 for root, subfolders, files in scandir.walk(path): if files: for f in files: if f.endswith(ARCHIVE_FILES): arch_path = os.path.join(root, f) for g in check_archive(arch_path): found_paths += 1 gallery_arch.append((g, arch_path)) if not subfolders: if not files: continue gallery_probability = len(files) for f in files: if not f.lower().endswith(IMG_FILES): gallery_probability -= 1 if gallery_probability >= (len(files) * 0.8): found_paths += 1 gallery_dirs.append(root) log_i('Found {} in {}'.format(found_paths, path).encode(errors='ignore')) return gallery_dirs, gallery_arch def today(): "Returns current date in a list: [dd, Mmm, yyyy]" _date = datetime.date.today() day = _date.strftime("%d") month = _date.strftime("%b") year = _date.strftime("%Y") return [day, month, year] def external_viewer_checker(path): check_dict = app_constants.EXTERNAL_VIEWER_SUPPORT viewer = os.path.split(path)[1] for x in check_dict: allow = False for n in check_dict[x]: if viewer.lower() in n.lower(): allow = True break if allow: return x def open_chapter(chapterpath, archive=None): is_archive = True if archive else False if not is_archive: chapterpath = os.path.normpath(chapterpath) temp_p = archive if is_archive else chapterpath custom_args = app_constants.EXTERNAL_VIEWER_ARGS send_folder_t = '{$folder}' send_image_t = '{$file}' send_folder = True if app_constants.USE_EXTERNAL_VIEWER: send_folder = True if custom_args: if send_folder_t in custom_args: send_folder = True elif send_image_t in custom_args: send_folder = False def find_f_img_folder(): filepath = os.path.join(temp_p, [x for x in sorted([y.name for y in scandir.scandir(temp_p)])\ if x.lower().endswith(IMG_FILES) and not x.startswith('.')][0]) # Find first page return temp_p if send_folder else filepath def find_f_img_archive(extract=True): zip = ArchiveFile(temp_p) if extract: app_constants.NOTIF_BAR.add_text('Extracting...') t_p = os.path.join('temp', str(uuid.uuid4())) os.mkdir(t_p) if is_archive or chapterpath.endswith(ARCHIVE_FILES): if os.path.isdir(chapterpath): t_p = chapterpath elif chapterpath.endswith(ARCHIVE_FILES): zip2 = ArchiveFile(chapterpath) f_d = sorted(zip2.dir_list(True)) if f_d: f_d = f_d[0] t_p = zip2.extract(f_d, t_p) else: t_p = zip2.extract('', t_p) else: t_p = zip.extract(chapterpath, t_p) else: zip.extract_all(t_p) # Compatibility reasons.. TODO: REMOVE IN BETA if send_folder: filepath = t_p else: filepath = os.path.join(t_p, [x for x in sorted([y.name for y in scandir.scandir(t_p)])\ if x.lower().endswith(IMG_FILES) and not x.startswith('.')][0]) # Find first page filepath = os.path.abspath(filepath) else: if is_archive or chapterpath.endswith(ARCHIVE_FILES): con = zip.dir_contents('') f_img = [x for x in sorted(con) if x.lower().endswith(IMG_FILES) and not x.startswith('.')] if not f_img: log_w('Extracting archive.. There are no images in the top-folder. ({})'.format(archive)) return find_f_img_archive() filepath = os.path.normpath(archive) else: app_constants.NOTIF_BAR.add_text("Fatal error: Unsupported gallery!") raise ValueError("Unsupported gallery version") return filepath try: try: # folder filepath = find_f_img_folder() except NotADirectoryError: # archive try: if not app_constants.EXTRACT_CHAPTER_BEFORE_OPENING and app_constants.EXTERNAL_VIEWER_PATH: filepath = find_f_img_archive(False) else: filepath = find_f_img_archive() except app_constants.CreateArchiveFail: log.exception('Could not open chapter') app_constants.NOTIF_BAR.add_text('Could not open chapter. Check happypanda.log for more details.') return except FileNotFoundError: log.exception('Could not find chapter {}'.format(chapterpath)) app_constants.NOTIF_BAR.add_text("Chapter does no longer exist!") return except IndexError: log.exception('No images found: {}'.format(chapterpath)) app_constants.NOTIF_BAR.add_text("No images found in chapter!") return if send_folder_t in custom_args: custom_args = custom_args.replace(send_folder_t, filepath) elif send_image_t in custom_args: custom_args = custom_args.replace(send_image_t, filepath) else: custom_args = filepath try: app_constants.NOTIF_BAR.add_text('Opening chapter...') if not app_constants.USE_EXTERNAL_VIEWER: if sys.platform.startswith('darwin'): subprocess.call(('open', custom_args)) elif os.name == 'nt': os.startfile(custom_args) elif os.name == 'posix': subprocess.call(('xdg-open', custom_args)) else: ext_path = app_constants.EXTERNAL_VIEWER_PATH viewer = external_viewer_checker(ext_path) if viewer == 'honeyview': if app_constants.OPEN_GALLERIES_SEQUENTIALLY: subprocess.call((ext_path, custom_args)) else: subprocess.Popen((ext_path, custom_args)) else: if app_constants.OPEN_GALLERIES_SEQUENTIALLY: subprocess.check_call((ext_path, custom_args)) else: subprocess.Popen((ext_path, custom_args)) except subprocess.CalledProcessError: app_constants.NOTIF_BAR.add_text("Could not open chapter. Invalid external viewer.") log.exception('Could not open chapter. Invalid external viewer.') except: app_constants.NOTIF_BAR.add_text("Could not open chapter for unknown reasons. Check happypanda.log!") log_e('Could not open chapter {}'.format(os.path.split(chapterpath)[1])) def get_gallery_img(gallery_or_path, chap_number=0): """ Returns a path to image in gallery chapter """ archive = None if isinstance(gallery_or_path, str): path = gallery_or_path else: path = gallery_or_path.chapters[chap_number].path if gallery_or_path.is_archive: archive = gallery_or_path.path # TODO: add chapter support try: name = os.path.split(path)[1] except IndexError: name = os.path.split(path)[0] is_archive = True if archive or name.endswith(ARCHIVE_FILES) else False real_path = archive if archive else path img_path = None if is_archive: try: log_i('Getting image from archive') zip = ArchiveFile(real_path) temp_path = os.path.join(app_constants.temp_dir, str(uuid.uuid4())) os.mkdir(temp_path) if not archive: f_img_name = sorted([img for img in zip.namelist() if img.lower().endswith(IMG_FILES) and not img.startswith('.')])[0] else: f_img_name = sorted([img for img in zip.dir_contents(path) if img.lower().endswith(IMG_FILES) and not img.startswith('.')])[0] img_path = zip.extract(f_img_name, temp_path) zip.close() except app_constants.CreateArchiveFail: img_path = app_constants.NO_IMAGE_PATH elif os.path.isdir(real_path): log_i('Getting image from folder') first_img = sorted([img.name for img in scandir.scandir(real_path) if img.name.lower().endswith(tuple(IMG_FILES)) and not img.name.startswith('.')]) if first_img: img_path = os.path.join(real_path, first_img[0]) if img_path: return os.path.abspath(img_path) else: log_e("Could not get gallery image") def tag_to_string(gallery_tag, simple=False): """ Takes gallery tags and converts it to string, returns string if simple is set to True, returns a CSV string, else a dict-like string """ assert isinstance(gallery_tag, dict), "Please provide a dict like this: {'namespace':['tag1']}" string = "" if not simple: for n, namespace in enumerate(sorted(gallery_tag), 1): if len(gallery_tag[namespace]) != 0: if namespace != 'default': string += namespace + ":" # find tags if namespace != 'default' and len(gallery_tag[namespace]) > 1: string += '[' for x, tag in enumerate(sorted(gallery_tag[namespace]), 1): # if we are at the end of the list if x == len(gallery_tag[namespace]): string += tag else: string += tag + ', ' if namespace != 'default' and len(gallery_tag[namespace]) > 1: string += ']' # if we aren't at the end of the list if not n == len(gallery_tag): string += ', ' else: for n, namespace in enumerate(sorted(gallery_tag), 1): if len(gallery_tag[namespace]) != 0: if namespace != 'default': string += namespace + "," # find tags for x, tag in enumerate(sorted(gallery_tag[namespace]), 1): # if we are at the end of the list if x == len(gallery_tag[namespace]): string += tag else: string += tag + ', ' # if we aren't at the end of the list if not n == len(gallery_tag): string += ', ' return string def tag_to_dict(string, ns_capitalize=True): "Receives a string of tags and converts it to a dict of tags" namespace_tags = {'default':[]} level = 0 # so we know if we are in a list buffer = "" stripped_set = set() # we only need unique values for n, x in enumerate(string, 1): if x == '[': level += 1 # we are now entering a list if x == ']': level -= 1 # we are now exiting a list if x == ',': # if we meet a comma # we trim our buffer if we are at top level if level is 0: # add to list stripped_set.add(buffer.strip()) buffer = "" else: buffer += x elif n == len(string): # or at end of string buffer += x # add to list stripped_set.add(buffer.strip()) buffer = "" else: buffer += x def tags_in_list(br_tags): "Receives a string of tags enclosed in brackets, returns a list with tags" unique_tags = set() tags = br_tags.replace('[', '').replace(']','') tags = tags.split(',') for t in tags: if len(t) != 0: unique_tags.add(t.strip().lower()) return list(unique_tags) unique_tags = set() for ns_tag in stripped_set: splitted_tag = ns_tag.split(':') # if there is a namespace if len(splitted_tag) > 1 and len(splitted_tag[0]) != 0: if splitted_tag[0] != 'default': if ns_capitalize: namespace = splitted_tag[0].capitalize() else: namespace = splitted_tag[0] else: namespace = splitted_tag[0] tags = splitted_tag[1] # if tags are enclosed in brackets if '[' in tags and ']' in tags: tags = tags_in_list(tags) tags = [x for x in tags if len(x) != 0] # if namespace is already in our list if namespace in namespace_tags: for t in tags: # if tag not already in ns list if not t in namespace_tags[namespace]: namespace_tags[namespace].append(t) else: # to avoid empty strings namespace_tags[namespace] = tags else: # only one tag if len(tags) != 0: if namespace in namespace_tags: namespace_tags[namespace].append(tags) else: namespace_tags[namespace] = [tags] else: # no namespace specified tag = splitted_tag[0] if len(tag) != 0: unique_tags.add(tag.lower()) if len(unique_tags) != 0: for t in unique_tags: namespace_tags['default'].append(t) return namespace_tags import re as regex def title_parser(title): "Receives a title to parse. Returns dict with 'title', 'artist' and language" log_d("Parsing title: {}".format(title)) title = " ".join(title.split()) if '/' in title: try: title = os.path.split(title)[1] if not title: title = title except IndexError: pass for x in ARCHIVE_FILES: if title.endswith(x): title = title[:-len(x)] parsed_title = {'title':"", 'artist':"", 'language':""} try: a = regex.findall('((?<=\[) *[^\]]+( +\S+)* *(?=\]))', title) assert len(a) != 0 try: artist = a[0][0].strip() except IndexError: artist = '' parsed_title['artist'] = artist try: assert a[1] lang = app_constants.G_LANGUAGES + app_constants.G_CUSTOM_LANGUAGES for x in a: l = x[0].strip() l = l.lower() l = l.capitalize() if l in lang: parsed_title['language'] = l break else: parsed_title['language'] = app_constants.G_DEF_LANGUAGE except IndexError: parsed_title['language'] = app_constants.G_DEF_LANGUAGE t = title for x in a: t = t.replace(x[0], '') t = t.replace('[]', '') final_title = t.strip() parsed_title['title'] = final_title except AssertionError: parsed_title['title'] = title return parsed_title import webbrowser def open_web_link(url): if not url: return try: webbrowser.open_new_tab(url) except: log_e('Could not open URL in browser') def open_path(path, select=''): "" try: if sys.platform.startswith('darwin'): subprocess.Popen(['open', path]) elif os.name == 'nt': if select: subprocess.Popen(r'explorer.exe /select,"{}"'.format(os.path.normcase(select)), shell=True) else: os.startfile(path) elif os.name == 'posix': subprocess.Popen(('xdg-open', path)) else: app_constants.NOTIF_BAR.add_text("I don't know how you've managed to do this.. If you see this, you're in deep trouble...") log_e('Could not open path: no OS found') except: app_constants.NOTIF_BAR.add_text("Could not open specified location. It might not exist anymore.") log_e('Could not open path') def open_torrent(path): if not app_constants.TORRENT_CLIENT: open_path(path) else: subprocess.Popen([app_constants.TORRENT_CLIENT, path]) def delete_path(path): "Deletes the provided recursively" s = True if os.path.exists(path): error = '' if app_constants.SEND_FILES_TO_TRASH: try: send2trash.send2trash(path) except: log.exception("Unable to send file to trash") error = 'Unable to send file to trash' else: try: if os.path.isfile(path): os.remove(path) else: shutil.rmtree(path) except PermissionError: error = 'PermissionError' except FileNotFoundError: pass if error: p = os.path.split(path)[1] log_e('Failed to delete: {}:{}'.format(error, p)) app_constants.NOTIF_BAR.add_text('An error occured while trying to delete: {}'.format(error)) s = False return s def regex_search(a, b, override_case=False, args=[]): "Looks for a in b" if a and b: try: if not app_constants.Search.Case in args or override_case: if regex.search(a, b, regex.IGNORECASE): return True else: if regex.search(a, b): return True except regex.error: pass return False def search_term(a, b, override_case=False, args=[]): "Searches for a in b" if a and b: if not app_constants.Search.Case in args or override_case: b = b.lower() a = a.lower() if app_constants.Search.Strict in args: if a == b: return True else: if a in b: return True return False def get_terms(term): "Dividies term into pieces. Returns a list with the pieces" # some variables we will use pieces = [] piece = '' qoute_level = 0 bracket_level = 0 brackets_tags = {} current_bracket_ns = '' end_of_bracket = False blacklist = ['[', ']', '"', ','] for n, x in enumerate(term): # if we meet brackets if x == '[': bracket_level += 1 brackets_tags[piece] = set() # we want unique tags! current_bracket_ns = piece elif x == ']': bracket_level -= 1 end_of_bracket = True # if we meet a double qoute if x == '"': if qoute_level > 0: qoute_level -= 1 else: qoute_level += 1 # if we meet a whitespace, comma or end of term and are not in a double qoute if (x == ' ' or x == ',' or n == len(term) - 1) and qoute_level == 0: # if end of term and x is allowed if (n == len(term) - 1) and not x in blacklist and x != ' ': piece += x if piece: if bracket_level > 0 or end_of_bracket: # if we are inside a bracket we put piece in the set end_of_bracket = False if piece.startswith(current_bracket_ns): piece = piece[len(current_bracket_ns):] if piece: try: brackets_tags[current_bracket_ns].add(piece) except KeyError: # keyerror when there is a closing bracket without a starting bracket pass else: pieces.append(piece) # else put it in the normal list piece = '' continue # else append to the buffers if not x in blacklist: if qoute_level > 0: # we want to include everything if in double qoute piece += x elif x != ' ': piece += x # now for the bracket tags for ns in brackets_tags: for tag in brackets_tags[ns]: ns_tag = ns # if they want to exlucde this tag if tag[0] == '-': if ns_tag[0] != '-': ns_tag = '-' + ns tag = tag[1:] # remove the '-' # put them together ns_tag += tag # done pieces.append(ns_tag) return pieces def image_greyscale(filepath): """ Check if image is monochrome (1 channel or 3 identical channels) """ log_d("Checking if img is monochrome: {}".format(filepath)) im = Image.open(filepath).convert("RGB") if im.mode not in ("L", "RGB"): return False if im.mode == "RGB": rgb = im.split() if ImageChops.difference(rgb[0],rgb[1]).getextrema()[1] != 0: return False if ImageChops.difference(rgb[0],rgb[2]).getextrema()[1] != 0: return False return True def PToQImageHelper(im): """ The Python Imaging Library (PIL) is Copyright © 1997-2011 by Secret Labs AB Copyright © 1995-2011 by Fredrik Lundh """ def rgb(r, g, b, a=255): """(Internal) Turns an RGB color into a Qt compatible color integer.""" # use qRgb to pack the colors, and then turn the resulting long # into a negative integer with the same bitpattern. return (qRgba(r, g, b, a) & 0xffffffff) def align8to32(bytes, width, mode): """ converts each scanline of data from 8 bit to 32 bit aligned """ bits_per_pixel = { '1': 1, 'L': 8, 'P': 8, }[mode] # calculate bytes per line and the extra padding if needed bits_per_line = bits_per_pixel * width full_bytes_per_line, remaining_bits_per_line = divmod(bits_per_line, 8) bytes_per_line = full_bytes_per_line + (1 if remaining_bits_per_line else 0) extra_padding = -bytes_per_line % 4 # already 32 bit aligned by luck if not extra_padding: return bytes new_data = [] for i in range(len(bytes) // bytes_per_line): new_data.append(bytes[i*bytes_per_line:(i+1)*bytes_per_line] + b'\x00' * extra_padding) return b''.join(new_data) data = None colortable = None # handle filename, if given instead of image name if hasattr(im, "toUtf8"): # FIXME - is this really the best way to do this? if str is bytes: im = unicode(im.toUtf8(), "utf-8") else: im = str(im.toUtf8(), "utf-8") if isinstance(im, (bytes, str)): im = Image.open(im) if im.mode == "1": format = QImage.Format_Mono elif im.mode == "L": format = QImage.Format_Indexed8 colortable = [] for i in range(256): colortable.append(rgb(i, i, i)) elif im.mode == "P": format = QImage.Format_Indexed8 colortable = [] palette = im.getpalette() for i in range(0, len(palette), 3): colortable.append(rgb(*palette[i:i+3])) elif im.mode == "RGB": data = im.tobytes("raw", "BGRX") format = QImage.Format_RGB32 elif im.mode == "RGBA": try: data = im.tobytes("raw", "BGRA") except SystemError: # workaround for earlier versions r, g, b, a = im.split() im = Image.merge("RGBA", (b, g, r, a)) format = QImage.Format_ARGB32 else: raise ValueError("unsupported image mode %r" % im.mode) # must keep a reference, or Qt will crash! __data = data or align8to32(im.tobytes(), im.size[0], im.mode) return { 'data': __data, 'im': im, 'format': format, 'colortable': colortable } def make_chapters(gallery_object): chap_container = gallery_object.chapters path = gallery_object.path metafile = GMetafile() try: log_d('Listing dir...') con = scandir.scandir(path) # list all folders in gallery dir log_i('Gallery source is a directory') log_d('Sorting') chapters = sorted([sub.path for sub in con if sub.is_dir() or sub.name.endswith(ARCHIVE_FILES)]) #subfolders # if gallery has chapters divided into sub folders if len(chapters) != 0: log_d('Chapters divided in folders..') for ch in chapters: chap = chap_container.create_chapter() chap.title = title_parser(ch)['title'] chap.path = os.path.join(path, ch) metafile.update(GMetafile(chap.path)) chap.pages = len([x for x in scandir.scandir(chap.path) if x.name.endswith(IMG_FILES)]) else: #else assume that all images are in gallery folder chap = chap_container.create_chapter() chap.title = title_parser(os.path.split(path)[1])['title'] chap.path = path metafile.update(GMetafile(path)) chap.pages = len([x for x in scandir.scandir(path) if x.name.endswith(IMG_FILES)]) except NotADirectoryError: if path.endswith(ARCHIVE_FILES): gallery_object.is_archive = 1 log_i("Gallery source is an archive") archive_g = sorted(check_archive(path)) for g in archive_g: chap = chap_container.create_chapter() chap.path = g chap.in_archive = 1 metafile.update(GMetafile(g, path)) arch = ArchiveFile(path) chap.pages = len(arch.dir_contents(g)) arch.close() metafile.apply_gallery(gallery_object) def timeit(func): @functools.wraps(func) def newfunc(*args, **kwargs): startTime = time.time() func(*args, **kwargs) elapsedTime = time.time() - startTime print('function [{}] finished in {} ms'.format( func.__name__, int(elapsedTime * 1000))) return newfunc def makedirs_if_not_exists(folder): """Create directory if not exists. Args: folder: Target folder. """ if not os.path.isdir(folder): os.makedirs(folder) def lookup_tag(tag): "Issues a tag lookup on preferred site" assert isinstance(tag, str), "str not " + str(type(tag)) # remove whitespace at edges and replace whitespace with + tag = tag.strip().lower().replace(' ', '+') url = app_constants.DEFAULT_EHEN_URL if not url.endswith('/'): url += '/' if not ':' in tag: tag = 'misc:' + tag url += 'tag/' + tag open_web_link(url)