Compare commits

...

513 Commits

Author SHA1 Message Date
4b082e0930 docs(contributor): contrib-readme-action has updated readme 2023-02-17 18:13:41 +00:00
891f39684d TPB Detection 2023-02-17 10:12:56 -08:00
5ecaf89d15 Update AUTHORS 2023-02-17 01:23:54 +00:00
2491999a33 Update copyright statements to ComicTagger Authors 2023-02-16 17:23:13 -08:00
9c7bf2e235 Update AUTHORS 2023-02-17 01:14:29 +00:00
0c1093d58e docs(contributor): contrib-readme-action has updated readme 2023-02-17 01:14:27 +00:00
a41c5a8af5 Automate contributions 2023-02-16 17:13:26 -08:00
3422a1093d Merge branch 'mizaki-showcontrols' into develop 2023-02-10 00:31:24 -08:00
4eb9e008ce Update pre-commit 2023-02-10 00:25:20 -08:00
5e86605a46 Fix docstring typos 2023-02-10 00:25:18 -08:00
8146b0c90e Merge branch 'talker-cleanup' into develop 2023-02-10 00:24:48 -08:00
983937cdea Mark internal functions in ComicVineTalker 2023-02-10 00:23:02 -08:00
e5b15abf91 clean up talker 2023-02-10 00:23:00 -08:00
4a5d02119e Merge branch 'settings-consistency' into develop 2023-02-10 00:22:44 -08:00
4b6c9fd066 Fix comicarchive_test.py 2023-02-10 00:14:58 -08:00
79a6cef794 Hide invisible controls to prevent bottom margin on source logos. 2023-02-10 00:43:05 +00:00
43cb68b38b Fix 'Default Preferences' button in the settings window 2023-02-04 11:34:49 -08:00
ad68726e1d Use consistent naming for settings
config: always values
setting: always the definition/description not the value
2023-02-04 11:33:21 -08:00
ba4b779145 Remove legacy settings 2023-02-03 20:14:31 -08:00
d987a811e3 Consolidate plugin code 2023-02-03 20:13:58 -08:00
ee426e6473 Merge branch 'mizaki-talker_settings' into develop 2023-02-03 18:14:26 -08:00
9aa42c1ca7 Add series match threshold back into search_for_series as it is no longer available via the talkers own settings. 2023-02-03 21:38:17 +00:00
d12325b7f8 Simplify parse_settings. Prefix talker_ to group name. Add back setting CV key via commandline. Other small changes as requested. 2023-02-02 00:53:13 +00:00
ce5205902a After merge isort 2023-02-01 23:53:02 +00:00
94aabcdd40 Merge branch 'develop' into talker_settings
# Conflicts:
#	comictaggerlib/ctoptions/__init__.py
#	comictaggerlib/main.py
#	comictalker/talkers/comicvine.py
2023-02-01 23:38:13 +00:00
839a918330 typed talkers var 2023-02-01 23:22:04 +00:00
053295e028 Merge branch 'mizaki-source_logo_url' into develop 2023-02-01 08:03:16 -08:00
c6e3266f60 More verbose attrib string 2023-02-01 15:39:24 +00:00
7c4e5b775b Merge branch 'plugableArchivers' into develop 2023-01-31 19:44:07 -08:00
bc02a9a2a2 Use a persistent setting group for archiver settings 2023-01-31 19:41:19 -08:00
2c5d419ee9 Remove legacy rar settings 2023-01-31 00:32:19 -08:00
46899255c8 Generate settings for an archivers executable 2023-01-30 21:36:47 -08:00
6a650514fa Rename new settings talker methods. Move parse_settings for talkers to earlier and only pass talkers own settings. 2023-01-30 01:59:23 +00:00
0f10e6e848 Create simple dict of talkers with objects. Moved thresh setting back to talkers (general) as it is called outside of talker. 2023-01-26 00:52:02 +00:00
0d69ba3c49 Rename talkers_general to talkers. Moved plugin option register to own file. Due to chicken and egg, first get talker classes then create objects. 2023-01-25 19:10:58 +00:00
d0e3b487eb Mark label for external links. attrib str to be complete. 2023-01-22 17:16:33 +00:00
c80627575a Add docstrings to Archiver 2023-01-21 15:24:27 -08:00
92eb79df71 Fix console_scripts entry point 2023-01-21 00:27:39 -08:00
ad48ad757c Fix plugin order 2023-01-20 19:32:32 -08:00
2de241cdd5 Fix typing 2023-01-20 19:32:06 -08:00
5d66815765 Add attrib string for source. Add logo and URL to issues window. 2023-01-20 00:29:02 +00:00
100e0f2101 Load plugins in init. 2023-01-15 17:38:50 +00:00
55e3b7c7e0 Use name for URL display. Window sizes. 2023-01-13 21:27:40 +00:00
f6698f7f0a Call load_archive_plugins in ComicArchive __init__ 2023-01-12 17:00:11 -08:00
50614d52fc Update PyInstaller hook 2023-01-12 15:47:34 -08:00
712986ee69 Turn comicapi.archivers.* into plugins 2023-01-12 14:45:49 -08:00
2f7e3921ef Separate archivers into their own packages 2023-01-12 14:45:17 -08:00
80f42fdc3f Move log header to execute immediately after the log is configured 2023-01-12 14:43:12 -08:00
725b2c66d3 Use imageWidget for source logo and URL. 2023-01-12 16:58:50 +00:00
5394b9f667 Fix tests. Probably not the correct way to do this? 2023-01-12 15:10:39 +00:00
fad103a7ad Use setting option for talker selection 2023-01-07 00:29:12 +00:00
87cd106b28 Add source logo and URL to series window 2023-01-04 23:51:39 +00:00
2d8c47edca Use new settings system for plugin 2023-01-02 01:04:15 +00:00
0ac5b59a1e Merge branch 'mizaki-rename_namespace_fix' into develop 2022-12-31 20:49:45 -08:00
7c735b3555 Fix rename namespace 2023-01-01 02:07:42 +00:00
9d8cf41cd3 Fix try block parsing credits in ComicCacher 2022-12-31 12:36:32 -08:00
ee3a06db46 Merge branch 'crop-border' into develop 2022-12-31 12:35:29 -08:00
7df2e3fdc0 Automatically crop black borders from covers 2022-12-31 11:52:23 -08:00
20e7de5b5f Fix reference to the user cache directory 2022-12-31 02:26:44 -08:00
f83f72fa12 Improve issue number handling regarding the '#' 2022-12-31 02:15:17 -08:00
fb4786159d Handle issue numbers with more than 3 digits 2022-12-30 21:50:10 -08:00
734b83cade Switch comictalker TypedDicts to dataclasses 2022-12-23 01:58:10 -08:00
746c98ad1c Add temp to .gitignore 2022-12-23 00:09:46 -08:00
9f00af4bba Change issue id and series id to strings 2022-12-23 00:09:19 -08:00
92fa4a874b Improve typing in ComicVineTalker 2022-12-22 10:47:37 -08:00
a33b00d77e Update ComicTalker documentation 2022-12-22 10:47:35 -08:00
a7f6349aa4 Merge branch 'volume-to-series' into develop 2022-12-22 10:45:58 -08:00
d4b4544b2f Replace most instances of volume in ComicVineTalker with series
All remaining uses of the word volume are used directly by the api and
are documented that it refers to the series
2022-12-22 10:30:48 -08:00
521d5634f3 Fix tests 2022-12-22 10:16:32 -08:00
1d9840913a Change all references of volume to series 2022-12-22 10:16:05 -08:00
53a0b23230 Collapse formatting 2022-12-15 20:21:53 -08:00
9004ee1a6b Merge branch 'settings' into develop 2022-12-15 20:17:50 -08:00
440479da8c Update to settngs 0.3.0
Use the namespace instead of a dictionary
Cleanup setting names
2022-12-15 20:10:35 -08:00
e5c3692bb9 Fail if an error occurs when loading settings 2022-12-15 18:58:53 -08:00
103379e548 Split settings out into a separate package 2022-12-14 23:16:54 -08:00
eca421e0f2 Split out settings functions 2022-12-13 08:50:38 -08:00
18566a0592 Fix setting cmdline arguments 2022-12-13 08:50:08 -08:00
48c6372cf4 Fix --no-overwrite 2022-12-10 18:35:41 -08:00
f3917c6e4d Add comments to tests 2022-12-10 18:05:27 -08:00
9bb5225301 Restrict pillow version to <10 until PyQt6 is supported 2022-12-06 17:06:13 -08:00
e9cef87154 Move test cases to the testing package
Add comments to tests
2022-12-06 17:00:21 -08:00
da01dde2b9 Fix color space on CMYK images 2022-12-06 08:38:24 -08:00
53445759f7 Add tests 2022-12-06 00:22:51 -08:00
9aff3ae38e Generalize settings
Add comments and docstrings
Create parent directories when saving
Add merging to normalize_options
Change get_option to return if the value is the default value
2022-12-06 00:22:49 -08:00
0302511f5f Settings tests 2022-12-06 00:22:48 -08:00
028949f216 Make logs use the .log extension 2022-12-06 00:22:46 -08:00
af0d7b878b Set logging level on comictalker 2022-12-06 00:22:44 -08:00
460a5bc4f4 Cleanup 2022-12-06 00:20:29 -08:00
3f6f8540c4 Fix wait_and_retry_on_rate_limit 2022-12-06 00:20:27 -08:00
17d865b72f Refactor cli.py into a class 2022-12-06 00:20:26 -08:00
da21dc110d Update help 2022-12-06 00:20:24 -08:00
3870cd0f53 Update help for --config 2022-12-06 00:20:23 -08:00
ed1df400d8 Add replacement settings 2022-12-06 00:20:21 -08:00
82d737407f Simplify --only-set-cv-key 2022-12-06 00:20:20 -08:00
d0719e7201 Fix log dir 2022-12-06 00:20:18 -08:00
19112ac79b Update Settings 2022-12-06 00:20:01 -08:00
a64d753d77 Fix package selection 2022-12-01 19:54:55 -08:00
970752435c Merge branch 'mizaki-fixii_keys' into develop 2022-11-29 15:15:42 -08:00
b1436ee76e Merge branch 'resize-volume-columns' into develop 2022-11-29 14:28:32 -08:00
8eba44cce4 Increase default size of VolumeSelectionWindow 2022-11-29 14:28:08 -08:00
5fc5a14bd9 Wider catch of series and issue_number being empty 2022-11-29 16:59:05 +00:00
10f36e9868 Allow searching without a comic archive selected 2022-11-28 21:44:01 -08:00
aab7e37bb2 Use contentsRect().width() instead of width 2022-11-28 20:55:50 -08:00
2860093b6f Set the minimum row height to the default on VolumeSelectionWindow 2022-11-28 20:54:24 -08:00
ad7b270650 Automatically resize the row height on the VolumeSelectionWindow 2022-11-28 15:34:15 -08:00
70dcb9768a Better resize columns in the VolumeSelectionWindow 2022-11-28 15:28:47 -08:00
873d976662 keys may be None if there is no comic archive. IssueString.as_string will convert None to empty string so use None comparison before. 2022-11-28 00:56:19 +00:00
fc4eb4f002 Cleanup
Move most options passed in to ComicVineTalker to ComicTalker
Give ComicCacher and ComicTalker a version argument to remove all
  references to comictaggerlib
Update default arguments to reflect what is required to use these classes
2022-11-25 19:22:01 -08:00
129e19ac9d Remove cast from taggerwindow.py 2022-11-25 19:22:00 -08:00
0dede72692 Re-add --only-set-cv-key feature 2022-11-25 19:21:58 -08:00
83ac9f91b5 Make errors loading the ComicVineTalker object explicit 2022-11-25 19:21:57 -08:00
858bc303d8 Stop setting the notes field in map_comic_issue_to_metadata 2022-11-25 19:21:55 -08:00
005d7b72f4 Fix tests 2022-11-25 19:21:54 -08:00
91b863fcb1 Merge branch 'mizaki-infosources' into dev 2022-11-25 19:21:25 -08:00
e5f6a7d1d6 Add warning about settings 2022-11-25 17:09:22 -08:00
e7f937ecd2 Enable version checking 2022-11-25 17:08:26 -08:00
d75f39fe93 Remove logos dir 2022-11-24 23:58:24 +00:00
12d9befc25 Remove unneeded code from fetch_issue_data. 2022-11-24 23:56:12 +00:00
3e8ee864b7 Remove setting options and logo_url. 2022-11-24 23:35:35 +00:00
134c4a60e9 Add some docstrings. 2022-11-24 23:26:48 +00:00
3f9e5457f6 Fix make clean 2022-11-24 09:41:51 -08:00
cc2ef8593c Update pre-commit 2022-11-24 01:25:24 -08:00
c5a5fc8bdb Fix issue with combine_notes 2022-11-24 01:24:15 -08:00
1cbed64299 Fix an issue with normalizing the platform in filerenamer.py 2022-11-23 12:36:19 -08:00
c608ff80a1 Improve typing 2022-11-22 17:11:56 -08:00
52cc692b58 Remove some TODOs. 2022-11-23 00:22:48 +00:00
31894a66ec Remove repair_urls function, taken care of in format results functions. 2022-11-19 21:59:10 +00:00
aa11a47164 HTML table patch 2022-11-18 23:22:39 +00:00
093d20a52b Remove all the cool settings changes. 2022-11-18 23:18:41 +00:00
38c3014222 Use strip().splitlines() in cacher to prevent [''] return. Some clean up. 2022-11-17 15:55:38 +00:00
df87f81698 Remove volume only functions used for testing. 2022-11-13 23:25:08 +00:00
cf12e891b0 Fix CV API test. Fix sending last source details in settings for API test and website link. 2022-11-12 23:13:53 +00:00
76fb565d4e Merge branch 'mizaki-iiemptyurl' into develop 2022-11-11 17:09:45 -08:00
06ffd9f6be Add logo/text button to source tab that links to webpage. 2022-11-12 01:09:17 +00:00
dfef425af3 Better handle missing talkers and default to comic vine. 2022-11-10 17:03:39 +00:00
880b1be401 Return zero score if there is no image url. Fixes #392 2022-11-10 16:15:27 +00:00
04ad588a58 Use source name in tag notes. 2022-11-08 16:33:46 +00:00
6b4abcf061 Update current talker object with new settings. 2022-11-08 16:32:37 +00:00
629b28f258 Small fixes after merge. 2022-11-07 02:03:36 +00:00
c34902449f Merge branch 'develop' into infosources
# Conflicts:
#	comictaggerlib/cli.py
#	comictaggerlib/comicvinetalker.py
#	comictaggerlib/taggerwindow.py
#	tests/comicvinetalker_test.py
#	tests/conftest.py
2022-11-07 01:50:47 +00:00
63e6174cf2 Not all fields are required in ComicVolume and ComicIssue but cacher would fail if any optional field were missing. 2022-11-07 01:38:19 +00:00
9da14e0f95 Fix source switching. Use start year if cover date is missing. 2022-11-07 01:19:03 +00:00
c469fdb25e Make 7zip support optional 2022-11-06 08:27:45 -08:00
67be086638 Move map comic data to utils along with remove html. Alter tests. 2022-11-05 16:49:59 +00:00
a724fd8430 Compensate for a split empty string returning ['']. I don't see a way around this? 2022-11-05 01:21:51 +00:00
685ce014b6 Fix tests for comicvinetalker 2022-11-04 16:27:30 -07:00
62bf1d3808 Update macOS packaging 2022-11-04 16:16:19 -07:00
d55d75cd79 Append notes instead of overwriting them
Add issue_id to GenericMetadata
2022-11-04 15:39:40 -07:00
19e5f10a7b Revert "Revert passing only issue id to fetch_comic_data. Instead send issue id, volume id and issue number. This is because MU will not have the issue number from the API call. Now, if it has been parsed from the file name it will be available for use by the MU talker."
This reverts commit e5e9617052.
2022-11-04 16:16:07 +00:00
e5e9617052 Revert passing only issue id to fetch_comic_data. Instead send issue id, volume id and issue number. This is because MU will not have the issue number from the API call. Now, if it has been parsed from the file name it will be available for use by the MU talker. 2022-11-04 00:52:22 +00:00
b4f6820f56 remove_fetch_alternate_cover_urls.patch 2022-11-03 23:32:35 +00:00
b07aa03c5f Use xlate for all int conversion in CV talker and compare cache issues to expected number. 2022-11-03 22:35:46 +00:00
2f54b1b36b A few minor logging tweaks. 2022-11-03 15:39:13 +00:00
70293a0819 Require PyInstaller >= 5.6.2 2022-11-01 13:51:10 -07:00
8592fdee74 Revert "Install PyInstaller from git until >5.6.1 is available"
This reverts commit 79137a12f8.
2022-11-01 13:49:52 -07:00
075faaea5a Removed TODO's checked and/or fixed. 2022-11-01 16:13:46 +00:00
870dc5e9b6 Move issue_id to first position of fetch_comic_data as most used. 2022-10-30 17:52:55 +00:00
86402af8b1 Merge branch 'develop' into infosources
# Conflicts:
#	comictaggerlib/comicvinetalker.py
2022-10-30 11:39:01 +00:00
d7976cf9d2 Hack tests. 2022-10-30 11:16:03 +00:00
b67765d9aa Merge to develop. 2022-10-30 11:07:53 +00:00
618e15600f Fix retrieving issues from cache when volume is incomplete 2022-10-29 19:21:11 -07:00
8cac2c255f Merge branch 'develop' into infosources
# Conflicts:
#	comictaggerlib/comicvinetalker.py
#	comictaggerlib/coverimagewidget.py
#	comictaggerlib/main.py
#	comictaggerlib/pagebrowser.py
#	comictaggerlib/pagelisteditor.py
#	comictaggerlib/settings.py
#	comictaggerlib/settingswindow.py
2022-10-30 01:31:58 +01:00
4f42fef4fc Return issue id from series search and use issue id for API. 2022-10-30 00:15:05 +01:00
73dd33dc64 Fix tags in GitHub Actions checkout 2022-10-29 13:09:13 -07:00
3774ab0568 Force install PyInstaller from git until >5.6.1 is available 2022-10-29 11:04:46 -07:00
f8807675d6 Cache issue info 2022-10-29 11:02:21 -07:00
79137a12f8 Install PyInstaller from git until >5.6.1 is available 2022-10-29 10:10:37 -07:00
d33d274725 Fix fetching alternate cover urls (fixes #372) 2022-10-29 10:10:35 -07:00
26851475ea Clean up loading cover images. Probably more to do. 2022-10-29 16:41:34 +01:00
a06d88efc0 Fix up full issue cache types. 2022-10-29 01:33:42 +01:00
dcf853515c Tidy CV logger errors. 2022-10-28 22:32:33 +01:00
bf06b94284 Enable cache for full issue information. 2022-10-28 22:15:14 +01:00
561dc28044 Don't proxy talker (really this time). Remove talker custom logging. Move static_options and settings_options to root of class object. Temp hack to keep talker menu genration working until settings revamp. 2022-10-27 23:36:57 +01:00
43ec4848ef Update pre-commit 2022-10-25 21:49:47 -07:00
aad83c8c03 Update PyInstaller usage
Switch to rapidfuzz from thefuzz
Add associations to macOS app bundle
2022-10-25 21:48:01 -07:00
4514ae80d0 Switch to API data for alt images, remove unneeded functions and removed async as new approach needed. See comments about fetch_partial_volume_data 2022-10-26 00:29:30 +01:00
cab69a32be Remove proxying from ComicTalker. Add some checks for talkers. 2022-10-25 00:37:18 +01:00
c5ad75370f Work around having to scrape alt covers from CV. Use cache to get issue page url for scrape. 2022-10-24 16:30:58 +01:00
d23258f359 Change ComicVolume, ComicIssue to image_url and image_thumb_url. Add/change search/volume DB layout to remove duplication of data. Fixup some test. 2022-10-23 22:40:15 +01:00
c9cd58fecb Remove fetch_issue_cover_urls and async_fetch_issue_cover_urls. Reduce API calls by using data already available with coverimagewidget. 2022-10-22 01:43:56 +01:00
58904a927f Set release name properly 2022-10-19 19:27:30 -07:00
fb1616aaa1 Remove CV parse date. Strings names. 2022-10-20 00:32:40 +01:00
4be12d857d Reuse CV test data in comic_issue_result data. Cover possible empty volume data in get_volume_issues_info. 2022-10-19 23:30:11 +01:00
e1ab72ec2a Rename super_url to image_url in comiccacher. Merge fetch_issue_data_by_issue_id into fetch_comic_data. Fill comic volume info in comiccacher:get_volume_issues_info 2022-10-19 19:33:51 +01:00
8a8dea8aa4 Fix autotagstartwindow.ui missed from merge. 2022-10-15 23:36:52 +01:00
43464724bd Convert all start_year to int. 2022-10-15 23:20:50 +01:00
34163fe9d7 Update the comicvine_api fixture in conftest.py to actually return the comicvinetalker. 2022-10-15 02:02:10 +01:00
9aa29f1445 Merge fetch_issue_data and fetch_volume_data to fetch_comic_data. 2022-10-14 01:10:46 +01:00
3ea44b7ca7 Remove fetch_issue_page_url from comictalker etc. 2022-10-12 23:08:47 +01:00
c1c8f4eb6e black 2022-10-12 00:11:57 +01:00
a14c24a78a Fix for issueidentifier_test 2022-10-11 16:52:41 +01:00
18d861a2be More test fixes that may need to be looked at further. 2022-10-09 23:43:52 +01:00
ac15a4dd72 More test fixes. 2022-10-06 01:14:03 +01:00
6a98afb89c After second merge. 2022-10-06 00:34:32 +01:00
21873d3830 Merge branch 'develop' into infosources
# Conflicts:
#	comictaggerlib/autotagstartwindow.py
#	comictaggerlib/cli.py
#	comictalker/talkers/comicvine.py
2022-10-05 01:58:46 +01:00
2daf9b3ed8 Style and typo fixes 2022-10-04 16:15:55 -07:00
a6d55cd21a Update MetadataFormatter
Several custom conversions (the s in {title!s}) have been created
u - str.upper()
l - str.casefold()
S - str.swapcase()
t - str.title()
c - str.Capitalize()

A new syntax has been added '{title+str}' and '{title-str}':
The + indicates an alternate value.
The - indicates a default value.

If the title of a comic is not set then
'{title-str}' will output 'str'
and
'{title+str} will output ''

If the title of a comic is 'hello' then
'{title+str}' will output 'str'
and
'{title-str}' will output 'hello'
2022-10-04 16:15:20 -07:00
d37e4607ee After merge. Testing files still to update. 2022-10-04 23:50:55 +01:00
00e95178cd Initial support for multiple comic information sources 2022-10-04 01:08:14 +01:00
4034123e6d Fix rar tests again 2022-10-02 21:47:07 -07:00
5587bfac31 Fix rar tests 2022-10-02 21:13:26 -07:00
4b6d35fd3a Fix CBL tagging 2022-10-02 19:33:12 -07:00
3cf75cf2ec Update importlib_matadata usage and requirements 2022-09-19 22:54:48 -07:00
30dbe758d4 Fix windows tests 2022-09-19 22:52:45 -07:00
55384790f8 Forcefully raise an OSError on windows 2022-09-17 01:59:15 -07:00
acaf5ed510 Fix issues with renaming
Stop a crash when renaming
Properly handle replacements on linux/macos
2022-09-17 01:28:26 -07:00
d213db3129 Use correct syntax for pips --no-binary flag 2022-09-15 22:09:04 -07:00
6a717377df Automatically set release name from tag message 2022-09-10 22:35:30 -07:00
904561fb8e Merge branch 'pyicu' into develop 2022-09-10 21:48:04 -07:00
be6b71dec7 Put unix specific commands in OS specific blocks 2022-09-10 21:11:48 -07:00
63b654a173 Update ci to install pyicu 2022-09-10 19:51:26 -07:00
bc25acde9f Fix sorting
Switch natsort to use os_sorted
Remove directories when returning a list of files in a comic
Update tests to account for '!cover.jpg'
2022-09-10 19:48:50 -07:00
03677ce4b8 Fix renaming
Make ComicArchive.path always absolute
Fix unique_file not preserving the extension
Fix incorrect output when renaming in CLI mode
Fix handling of platform when renaming
2022-08-19 20:20:37 -07:00
535afcb4c6 Fix replacements 2022-08-19 19:59:58 -07:00
06255f7848 Perform replacements on literal text and format values 2022-08-18 13:48:23 -07:00
00e649bb4c Move colon handling when renaming to the MetadataFormatter class
Fixes #356
2022-08-17 16:16:38 -07:00
078f569ec6 Fix codeblock in README.md 2022-08-14 10:51:08 -07:00
315cf7d920 Merge pull request #355 from Xav83/patch-1
Adds the Chocolatey package as a way to install ComicTagger
2022-08-14 10:47:24 -07:00
e9cc6a16a8 Note that @Xav83 is the maintainer of the chocolatey package
Co-authored-by: Xavier Jouvenot <x.jouvenot@gmail.com>
2022-08-14 10:45:51 -07:00
26eb6985fe Adds the Chocolatey package as a way to install ComicTagger
Adds the Chocolatey package in the list of possibilities to install ComicTagger
2022-08-13 11:52:09 +02:00
be983c61bc Fix #353
The two primary cases fixed are:
Ms. Marvel
spider-man/deadpool

The first issue removed 'Ms.' which is a problem as many comics have
series that the only difference in the title is the
designation/honorific.

The second issue is that the '/' was removed and not replaced with
anything causing a search for 'mandeadpool' which will not show useful
results.

Consequently all designations/honorifics are now untouched
All punctuation is replaced with a space
2022-08-12 07:10:36 -07:00
77a53a6834 Update dependencies
Includes changes from pyupgrade
2022-08-10 20:55:46 -07:00
860a3147d2 Construct URL correctly 2022-08-10 16:33:40 -07:00
8ecb87fa26 Install all optional dependencies in CI 2022-08-08 19:10:57 -07:00
f17f560705 Fix tests on windows
Make the speedup dependency to thefuzz optional it requires a C compiler
2022-08-08 19:03:25 -07:00
aadeb07c49 Fix issues
Refactor add_to_path with tests
Fix type hints for titles_match
Use casefold in get_language
Fix using the recursive flag in cli mode
Add http status code to ComicVine exceptions
Fix parenthesis getting removed when renaming
Add more tests
2022-08-08 18:05:06 -07:00
e07fe9e8d1 Construct URLs more consistently 2022-07-29 22:05:22 -07:00
f2a68d6c8b Fix rename and add test 2022-07-29 22:05:03 -07:00
94be266e17 Handle the 'primary' key missing in get_primary_credit
Fixes #342
Add better exception handling for the formatter
2022-07-27 23:24:34 -07:00
5a19eaf9a0 Fix serializing of sets 2022-07-25 11:22:44 -07:00
28cbbbece7 Fix #334 2022-07-23 10:05:04 -07:00
40314367c9 Improve formatting and consistency 2022-07-18 12:17:13 -07:00
6e7660c3d9 Tests
Add tests for IssueIdentifier
Change tags to a set from a string
Add copy and replace convenience functions on GenericMetadata
Update deprecated resampling code for Pillow
Change comicvine test data to be the same as the test comic
Cleanup tests
2022-07-18 12:06:49 -07:00
99030fae6b Merge branch 'unicode_search' into develop 2022-07-13 23:16:59 -07:00
947dc81c74 use thefuzz
use thefuzz

use thefuzz
2022-07-13 23:11:17 -07:00
c0880c9afe Account for aliases field from CV 2022-07-13 23:11:14 -07:00
e6414fba96 Allow non-ascii in ComicVine searches 2022-07-13 22:45:45 -07:00
a00891f622 Add more tests 2022-07-13 22:27:31 -07:00
9ba8b2876c Ensure homebrew is in the path if it exists 2022-07-12 09:28:51 -07:00
46d3e99d48 Fix tests 2022-07-12 07:43:33 -07:00
d206f5f581 Fixing source_name position 2022-07-12 07:31:42 -07:00
ec83667d77 Adding source_name to add_issue_select_details. 2022-07-12 07:31:42 -07:00
0bbf417133 Tests
Add tests for ComicCacher and ComicVineTalker
Move fixtures to conftest.py
Move test data to testing module
2022-07-11 18:40:12 -07:00
a3e1153283 Improve rar executable handling
Show a message when a CBR/RAR archive is added and rar is not available
Ensure that an empty value for the rar executable becomes 'rar'
2022-07-10 15:21:15 -07:00
ccb461ae76 Improve rename
Implement rename on ComicArchive
Simplify unique_file with pathlib
Fix issues during renaming and simplify with pathlib
Allow exporting as zip to export 7-zip archives
2022-07-09 23:13:18 -07:00
d24b51f94e Apply black formatting and fix mypy issues 2022-07-09 22:56:52 -07:00
def2635ac2 Ignore aspect ratio on background image
Fixes #327
2022-07-07 16:10:12 -07:00
b72fcaa9a9 Add source field to cache DB.
Add source to cache db.

Rename comicvinecacher to comiccacher and update refs.

Fix comment spacing.

Move source_name to end to reduce changes.

Move source_name to end to reduce changes. Fixed.

Fix syntax.

Fix various issues with DB changes.

Move new source_name to bottom.

Remove source_name from CV_.

Revert id to volume_id
2022-07-05 11:29:10 -07:00
3ddfacd89e Fix #325
The aspect ratio mode was missed in b9af606
2022-07-04 18:03:18 -07:00
6eb5fa7ac7 Fix #324
Co-authored-by: Mizaki <jinxybob@hotmail.com>
2022-07-04 15:53:44 -07:00
68efcc74fb Updates
Use casefold in place of lower
Make lint job fail if errors are detected
Use join instead of utils.list_to_string
Simplify get_recursive_filelist with the glob library
Fix handling of un-parseable numbers in xlate
2022-07-01 16:22:01 -07:00
3d84af3746 Convert GenericMetadata to a dataclass
dataclasses allow for simple comparison and object creation

Add more tests
2022-07-01 16:15:43 -07:00
cb5b321539 Update filerenamer
Remove space separated right partition of previous literal text
2022-06-26 01:53:40 -07:00
20ec8c38c2 Fixes
Add importlib_metadata to requirements.txt
Add comments stating origin of new parser
2022-06-23 22:59:09 -07:00
8bdf91ab96 Merge branch 'rating' into develop 2022-06-23 18:13:34 -07:00
fbbd36ab4d make tests and testing proper modules 2022-06-23 13:27:36 -07:00
95643fdace Fix community rating
The user rating control is replaced with critical rating which is now
represented as a float.
utils.xlate has been updated to have an is_float parameter
Metadata is reloaded on save so that changes can be seen
e.g. for CBL tags the critical rating field only stores integers
2022-06-23 13:18:42 -07:00
6c65c2ad56 Make importlib usage compatible with python 3.9 2022-06-23 13:05:27 -07:00
292a69a204 Allow pushes to run CI again 2022-06-10 16:32:21 -07:00
5c6e7d6f3e Allow multiple types to be specified using -t fixes #24 2022-06-10 16:20:58 -07:00
7e033857ba Replace pkg_resources with importlib.metadata 2022-06-10 16:18:58 -07:00
d9c02b0115 Allow changing the ComicVine URL fixes #104 2022-06-10 15:23:58 -07:00
b9af606f87 Improve filename parsing and cover image scaling
Cover image scaling now uses the smooth transformation option in Qt
Filename parsing now identifies a single number as a filename
e.g. '52.cbz' gets parsed as issue: 52 and series: 52
2022-06-09 12:31:57 -07:00
d3c29ae40a Ignore tags on the CI workflow 2022-06-08 09:06:46 -07:00
ff73cbf2f9 Fix small issues
Fix spelling errors
Remove Redundant exception types
Remove dead code
Change the forum link to point to GitHub discussions
2022-06-07 20:22:33 -07:00
3369a24343 Update GitHub Actions
Separate release/packaging and CI
Add an ignore for flake8 on ctversion.py as it is generated
Cleanup unused portions of the makefile
Use 'build' to generate PyPi distribution
Python venv on windows uses the Scripts directory
2022-06-07 19:39:01 -07:00
ce693b55f1 Fix file write semantics for Windows 2022-06-07 12:53:27 -07:00
db37ec7204 Add a literal search option 2022-06-07 12:16:23 -07:00
470b5c0a17 Fix adding files to GUI via running ComicTagger with more filenames
Add flake8-print to ensure all logging uses the logging package
2022-06-06 20:04:51 -07:00
04409a55c7 Handle more exceptions
Handle exceptions during metadata save fixes #309
Handle exceptions during metadata read fixes #126 and #309
2022-06-06 20:04:51 -07:00
bb7fbb4e38 Add pre-commit.ci config 2022-06-06 20:04:34 -07:00
5bb48cf816 fix rar test 2022-06-06 20:04:34 -07:00
b5e6e41043 Add a log window to see the current log 2022-06-06 20:04:34 -07:00
62d927a104 Fix #308
Add null check when loading community_rating
Use iterators instead of while loops
2022-06-05 15:23:20 -07:00
4c9fa4f716 Update template help and default template 2022-06-02 18:32:41 -07:00
e8fa51ad45 Ensure comicapi is as consistent as possible 2022-06-02 18:32:33 -07:00
fd4c453854 Apply pre-commit configuration 2022-06-02 18:32:16 -07:00
c19ed49e05 Move to argparse for argument parsing 2022-06-02 18:28:54 -07:00
36adf91744 Merge branch 'MichaelFitzurka-feature/301-double-page-modified' into develop 2022-05-24 11:45:08 -07:00
8b73a87360 Merge branch 'cleanup' into develop 2022-05-24 11:44:54 -07:00
8c591a8a3b Remove unused imports 2022-05-24 11:44:26 -07:00
c5772c75e5 Cleanup setCheckState
Fix word splitting when auto-tagging
Remove commented code
2022-05-24 11:38:10 -07:00
ff02d25eea Merge branch 'tests' into develop 2022-05-24 11:30:38 -07:00
98a7ee35ee Add tests 2022-05-24 11:30:25 -07:00
59d48619b1 Merge branch 'volume' into develop 2022-05-24 11:30:15 -07:00
10056c4229 Improve volume handling
Include changes by @gramster from #120
During filename parsing set the issue to the volume if there is no issue
2022-05-24 11:27:24 -07:00
7e772abda7 Toggled to Clicked 2022-05-24 10:25:44 -04:00
09ea531a90 Fixing double page always flagging as modified 2022-05-23 09:46:46 -04:00
710d9bf6a5 Fix packaging issues
Add wordninja datafile to pyinstaller
Add publishers.json to the correct package
2022-05-20 00:19:33 -07:00
bb81f921ff Fix Qt typing references to strings 2022-05-19 22:29:46 -07:00
1468b1932f Fix crash on startup
Add publishers.json to pip package
Add exception handling to prevent crash
2022-05-19 20:13:59 -07:00
74d95b6a50 Add typing_extensions 2022-05-19 18:17:22 -07:00
d33fb6ef31 Fix build errors
Add wordninja to requirements.txt
Fix typing to allow unrar-cffi to be optional
2022-05-19 18:08:05 -07:00
4201558483 Merge branch 'wordSplit' into develop 2022-05-19 17:58:45 -07:00
983b3d08f6 Merge branch 'clearMetadata' into develop 2022-05-19 13:39:41 -07:00
eec715551a Allow overwriting existing metadata 2022-05-19 13:28:36 -07:00
d3f552173e Merge branch 'AutoImprint' into develop 2022-05-19 13:28:18 -07:00
3e3dcb03f9 Typed 2022-05-19 13:19:19 -07:00
44b0e70399 Merge branch 'fixComicremoval' into develop 2022-05-16 15:23:15 -07:00
38aedac101 Ensure that comics are properly removed when using remove_archive_list 2022-05-16 15:21:59 -07:00
9a9d97f3bb Fix #291
ComicTagger now accounts for any single unicode numeric value
2022-05-14 01:59:44 -07:00
a4cb8b51a6 Restore test cbz
Add test to ensure that metadata is read correctly
Add tests for IssueString
2022-05-14 01:59:39 -07:00
1bbdebff42 Merge branch 'filenameParser' into develop 2022-05-06 00:33:36 -07:00
783c4e1c5b Merge branch 'uiCleanup' into develop 2022-05-06 00:33:30 -07:00
eb5360a38b Merge branch 'renameFix' into develop 2022-05-06 00:33:24 -07:00
205d337751 Add new filename parser
I created a new, mostly over complicated, filename parser
The new parser works well in many cases and will collect more data than
the original parser but will sometimes give odd results because of how
complicated it has been made e.g.
'100 page giant' will cause issues however '100-page giant' will not

Remove the parse scan info setting as it was not respected in many cases
2022-05-06 00:30:33 -07:00
d469ee82d8 Cleanup ui files
Qt Designer has new defaults since these were originally generated
2022-05-04 00:06:32 -07:00
c464283962 Merge branch 'removeIndent' into develop 2022-04-30 00:01:53 -07:00
48467b14b5 Remove utils.indent, python 3.9 provides a similar function 2022-04-30 00:01:00 -07:00
70df9d0682 Update filerenamer
Fixes an out of range exception during smart cleanup
Enforces field names to be present in format templates
Instead of removing previous text if a replacement is empty only strip
specifically "-_({[#" off the right of the string
2022-04-29 23:45:28 -07:00
049971a78a Merge branch 'removeRenamer' into develop 2022-04-29 23:29:24 -07:00
052e95e53b Remove old file renamer
Use PureWindowsPath objects in templates and tests, this allows both
path separators to be used and compared regardless of platform
2022-04-29 23:27:58 -07:00
fa0c193730 Merge branch 'MichaelFitzurka-feature-258/community-rating' into develop 2022-04-29 23:22:58 -07:00
a98eb2f81b Merge branch 'buildFix' into develop 2022-04-29 23:14:46 -07:00
ae4de0b3e6 Update build settings
Update excluded folders for flake8
Ensure pip install -e is used in both cases to install ComicTagger
Set required python version to 3.9
2022-04-29 23:06:57 -07:00
84b762877f Changes as per comments 2022-04-27 10:15:53 -04:00
2bb7aaeddf Merge branch 'MichaelFitzurka-feature-278/remove-empty-tags' into develop 2022-04-26 04:25:51 -07:00
08434a703e Remove empty versus clearing. 2022-04-22 09:48:47 -04:00
552a319298 Adding CommunityRating. fitxes #258 2022-04-22 09:39:32 -04:00
b9e72bf7a1 Merge branch 'cleanup' into develop 2022-04-20 13:15:44 -07:00
135544c0db Code cleanup 2022-04-20 13:13:03 -07:00
c297fd7fe7 Merge branch 'removeEnum' into develop 2022-04-20 11:44:42 -07:00
168f24b139 Partial revert of 'e616aa8373688fe0ee7394ddad5b409653354271'
Changing PageType to an Enum creates too many issues
2022-04-20 11:41:42 -07:00
89ddea7e9b Update documentation
Add CONTRIBUTING.md
Update install instructions in README
Update Build badge in README
2022-04-19 21:55:34 -07:00
bfe005cb63 Merge branch 'fixSerialization' into develop 2022-04-19 14:55:50 -07:00
48c2e91f7e Fix pip reference 2022-04-19 14:49:14 -07:00
02f365b93f Fix Makefile
make check now uses a venv
make CI uses the environment
Fix rar test
2022-04-19 14:45:36 -07:00
d78c3e3039 Fix serialization errors
Add tests to ensure issue is fixed
Add make check
Add pytest to make CI
2022-04-19 13:16:33 -07:00
f18513fd0e Fix Template help 2022-04-19 00:44:29 -07:00
caa94c4e28 Merge branch 'Renaming' into develop 2022-04-18 22:56:49 -07:00
7037877a77 Add a strict mode to file renaming
Strict renaming removes all reserved names and characters regardless
 of operating system, with out strict mode only for the current
 Operating System
Add more edge cases to smart cleanup
Add more tests for file renaming
2022-04-18 22:55:13 -07:00
6cccf22d54 Allow switching between old and new rename templates
Show a message dialog explaining that there is a new template format
Add a dynamic label to show the effect of a rename
Add tests for FileRenamer
Remove the filename parameter from the determine_name function
2022-04-18 20:12:20 -07:00
ceb2b2861e Merge branch 'filename_tests' into develop 2022-04-18 20:11:06 -07:00
298f50cb45 Merge branch 'configDir' into develop 2022-04-18 20:10:50 -07:00
e616aa8373 Merge branch 'CodeCleanup' into develop 2022-04-18 20:10:08 -07:00
0fe881df59 Code cleanup 2022-04-18 19:40:04 -07:00
f3f48ea958 Add the ability to specify a config directory 2022-04-18 19:08:38 -07:00
9a9d36dc65 Add more tests for parsing filenames 2022-04-18 19:06:09 -07:00
028b728d82 Improve file renaming
Moves to Python format strings for renaming, handles directory
structures, moving of files to a destination directory, sanitizes
file paths with pathvalidate and takes a different approach to
smart filename cleanup using the Python string.Formatter class

Moving to Python format strings means we can point to python
documentation for syntax and all we have to do is document the
properties and types that are attached to the GenericMetadata class.

Switching to pathvalidate allows comictagger to more simply handle both
directories and symbols in filenames.

The only changes to the string.Formatter class is:
1. format_field returns
an empty string if the value is none or an empty string regardless of
the format specifier.
2. _vformat drops the previous literal text if the field value
is an empty string and lstrips the following literal text of closing
special characters.
2022-04-18 18:52:53 -07:00
23f323f52d Add filename tests 2022-04-15 02:46:57 -07:00
49210e67c5 Fix rar_support variable 2022-04-14 16:25:25 -07:00
e519bf79be Merge branch 'MichaelFitzurka-feature/263-pages-keyboard' into develop 2022-04-14 16:23:51 -07:00
4f08610a28 Fix CI 2022-04-14 13:16:51 -07:00
544bdcb4e3 Using shortcuts and actions. 2022-04-14 12:22:53 -04:00
f3095144f5 Merge branch 'feature/149-add-tests' into develop 2022-04-12 15:20:58 -07:00
75f31c7cb2 Merge branch 'fileEncoding' into develop 2022-04-11 18:02:26 -07:00
f7f4e41c95 Catch exception when displaying raw tags 2022-04-11 17:16:07 -07:00
6da177471b Fix #242
Fix file encoding inconsistencies, windows defaults to cp1252, which is
not Unicode compatible.
Add logging for all exceptions in the comicapi package
Ensure that all exceptions are logged and shown to the user
2022-04-11 14:52:41 -07:00
8a74e5b02b Keyboard commands for the Pages tab to make editing easier. 2022-04-10 18:10:09 -04:00
5658f261b0 Merge branch 'MichaelFitzurka-feature/m-age-rating' into develop 2022-04-10 11:05:06 -07:00
6da3bf764e Merge branch 'feature/m-age-rating' of https://github.com/MichaelFitzurka/comictagger into MichaelFitzurka-feature/m-age-rating 2022-04-10 11:04:48 -07:00
5e06d35057 Merge branch 'feature/253-recalc-page-dims' of https://github.com/MichaelFitzurka/comictagger into MichaelFitzurka-feature/253-recalc-page-dims 2022-04-10 11:00:10 -07:00
82bcc876b3 Merge branch 'MichaelFitzurka-feature/183-comment-html-fix' into develop 2022-04-10 10:59:40 -07:00
d7a6882577 Merge branch 'feature/183-comment-html-fix' of https://github.com/MichaelFitzurka/comictagger into MichaelFitzurka-feature/183-comment-html-fix 2022-04-10 10:59:00 -07:00
5e7e1b1513 Merge branch 'MichaelFitzurka-feature/246-dbl-page' into develop 2022-04-10 10:57:46 -07:00
cd9a02c255 Merge branch 'feature/246-dbl-page' of https://github.com/MichaelFitzurka/comictagger into MichaelFitzurka-feature/246-dbl-page 2022-04-10 10:54:49 -07:00
b47f816dd5 Merge branch 'abuchanan920-develop' into develop 2022-04-10 10:50:41 -07:00
d1a649c0ba Adding "M" age rating for 2.0 schema 2022-04-06 11:49:54 -04:00
b7759506fe Menu command to clear out page size,height,width on demand, to then recalculate on save. 2022-04-05 16:23:26 -04:00
97777d61d2 Fixing some HTML to comment translations. 2022-04-05 16:16:27 -04:00
e622b56dae Adding attribs to ImageMetadata class. 2022-04-05 11:23:18 -04:00
a24251e5b4 Merge branch 'comictagger:develop' into develop 2022-04-05 10:38:36 -04:00
d4470a2015 Use more idiomatic regular expression string
Co-authored-by: Timmy Welch <timmy@narnian.us>
2022-04-05 10:37:33 -04:00
d37022b71f Merge branch 'comictagger:develop' into feature/246-dbl-page 2022-04-05 09:59:20 -04:00
5f38241bcb Double Page functionality. 2022-04-05 09:52:59 -04:00
4fb9461491 Stop a crash when the logs folder already exists 2022-04-05 00:58:19 -07:00
c9b5bd625f Fix parsing of filenames that end with an ID such as [__######__] 2022-04-04 22:34:31 -04:00
558072a330 Create the logs folder before attempting to use it 2022-04-04 19:28:38 -07:00
26fa7eeabb Merge branch 'logging' into develop 2022-04-04 19:16:54 -07:00
c50cef568e Add basic logging 2022-04-04 19:10:22 -07:00
2db80399a6 Merge branch 'MichaelFitzurka-feature/247-empty-tags' into develop 2022-04-04 14:16:29 -07:00
4936c31c18 black changed some single quotes to double quotes. 2022-04-04 16:36:46 -04:00
ada88d719f Empty metadata should not assign an empty tag. 2022-04-03 16:50:27 -04:00
1b28623fe3 Bookmark functionality. Fixes #212. 2022-04-03 15:44:20 -04:00
593f568ea7 method renamed to match new changes. 2022-04-03 15:39:03 -04:00
7b4dba35b5 Ensure that tags are overwritten when saving metadata 2022-04-02 15:41:50 -07:00
c95e700025 Merge branch 'CodeCleanup' into develop 2022-04-02 15:36:03 -07:00
e10f7dd7a7 Code cleanup
Remove no longer used google scripts
Remove convenience files from comicataggerlib and import comicapi directly
Add type-hints to facilitate auto-complete tools
Make PyQt5 code more compatible with PyQt6

Implement automatic tooling
isort and black for code formatting
Line length has been set to 120
flake8 for code standards with exceptions:
E203 - Whitespace before ':'  - format compatiblity with black
E501 - Line too long          - flake8 line limit cannot be set
E722 - Do not use bare except - fixing bare except statements is a
                                lot of overhead and there are already
                                many in the codebase

These changes, along with some manual fixes creates much more readable code.
See examples below:

diff --git a/comicapi/comet.py b/comicapi/comet.py
index d1741c5..52dc195 100644
--- a/comicapi/comet.py
+++ b/comicapi/comet.py
@@ -166,7 +166,2 @@ class CoMet:

-            if credit['role'].lower() in set(self.editor_synonyms):
-                ET.SubElement(
-                    root,
-                    'editor').text = "{0}".format(
-                    credit['person'])

@@ -174,2 +169,4 @@ class CoMet:
         self.indent(root)
+            if credit["role"].lower() in set(self.editor_synonyms):
+                ET.SubElement(root, "editor").text = str(credit["person"])

diff --git a/comictaggerlib/autotagmatchwindow.py b/comictaggerlib/autotagmatchwindow.py
index 4338176..9219f01 100644
--- a/comictaggerlib/autotagmatchwindow.py
+++ b/comictaggerlib/autotagmatchwindow.py
@@ -63,4 +63,3 @@ class AutoTagMatchWindow(QtWidgets.QDialog):
             self.skipButton, QtWidgets.QDialogButtonBox.ActionRole)
-        self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setText(
-            "Accept and Write Tags")
+        self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setText("Accept and Write Tags")

diff --git a/comictaggerlib/cli.py b/comictaggerlib/cli.py
index 688907d..dbd0c2e 100644
--- a/comictaggerlib/cli.py
+++ b/comictaggerlib/cli.py
@@ -293,7 +293,3 @@ def process_file_cli(filename, opts, settings, match_results):
                 if opts.raw:
-                    print((
-                        "{0}".format(
-                            str(
-                                ca.readRawCIX(),
-                                errors='ignore'))))
+                    print(ca.read_raw_cix())
                 else:
2022-04-02 14:21:37 -07:00
84dc148cff Merge branch 'MichaelFitzurka-feature/239-add-web-btn' into develop 2022-04-02 12:57:14 -07:00
14c9609efe Merge branch 'MichaelFitzurka-feature/232-inv-page-type' into develop 2022-04-02 12:57:04 -07:00
2a3620ea21 Replacing requests validation with urlparse. 2022-04-01 09:48:53 -04:00
8c5d4869f9 Updates to comments. 2022-03-31 13:34:40 -04:00
c0aa665347 Adding web link convenience button to open a valid url value in a browser window. 2022-03-31 12:40:43 -04:00
6900368251 Displaying invalid value with Error indicator, that way the user can see what is the invalid value and has the option to leave it or change it. 2022-03-31 10:25:00 -04:00
ac1bdf2f9c Merge branch 'abuchanan920-develop' into develop 2022-03-29 22:29:48 -07:00
c840724c9c Merge branch 'rhaussmann-natsort_fix' into develop 2022-03-29 22:23:00 -07:00
220606a046 Merge branch 'comictagger:develop' into natsort_fix 2022-03-29 09:28:38 -06:00
223269cc2e update requirements 2022-03-29 09:23:05 -06:00
31b96fdbb9 Merge branch 'feature/179-7zip' into develop 2022-03-28 23:29:02 -07:00
908a500e7e One more. 2022-03-26 12:45:33 -04:00
ae20a2eec8 Updates as requested. 2022-03-26 12:42:33 -04:00
287c5f39c1 Merge branch 'comictagger:develop' into feature/179-7zip 2022-03-26 12:27:34 -04:00
cfd2489228 Merge branch 'feature-227-data-src-alt-covers' into develop 2022-03-21 17:52:22 -07:00
86a83021a6 Update to look for images in data-src as well as src. 2022-03-21 15:29:31 -04:00
d7595f5ca1 Merge branch 'comictagger:develop' into feature/179-7zip 2022-03-21 09:27:47 -04:00
5a2bb66d5b Merge branch 'unicodeFix' into develop 2022-03-20 10:43:02 -07:00
5de2ce65a4 Remove print statements
Fixes #223
2022-03-20 10:40:30 -07:00
95d167561d Fix locale for macOS 2022-03-20 02:10:11 -07:00
7d2702c3b6 Update pyinstaller 2022-03-20 02:09:47 -07:00
d0f96b6511 Ensure XML is UTF-8 encoded 2022-03-19 18:17:38 -07:00
ba71e61d87 Added 7zip support thru py7zr.
Tweaked save of archive file and images in comicarchive.
2022-03-18 15:14:42 -04:00
191d72554c Explicitly specify unsigned integer sort to fix comic page order 2022-03-14 13:27:03 -04:00
628251c75b Merge branch 'metadataEdit' into develop 2022-02-21 20:22:28 -08:00
71499c3d7c Merge branch 'bugFixes' into develop
Closes #65,#59,#154,#180,#187,#209
2022-02-21 20:06:44 -08:00
03b8bf4671 Bug fixes
Closes #65,#59,#154,#180,#187,#209
2022-02-21 20:05:07 -08:00
773735bf6e Merge pull request #213 from lordwelch/series_sort
Cleanup settings from #200
2022-01-22 17:29:26 -08:00
b62e291749 Cleanup settings from #200
Rename blacklist to filter to be more accurate
2022-01-22 15:00:22 -08:00
a66b5ea0e3 Series sorting filtering (#200)
Because additional series results are now returned due to #143 the series selection window can with a large number of results that are not usually sorted in a useful way.

I've created 3 settings that can help finding the corect series quickly

use the publisher black list - can be toggled from the series selction screen, as well as a setting for is default behaviour
a setting to make the result initially sorted by start year instead of the default no of issues
a setting to initially put exact and near matches at the top of the list
2022-01-22 14:40:45 -08:00
615650f822 Update xml instead of overwrite 2022-01-05 22:01:00 -08:00
ed16199940 Merge pull request #132 from lordwelch/FixLanguageSort
Sort language correctly
2021-12-15 23:41:40 -08:00
7005bd296e Merge pull request #131 from lordwelch/PageListEditorExtendedSelection
Allow extended selection in the page list editor
2021-12-15 23:40:08 -08:00
cdeca34791 Add experimental word splitting to the filename parser
Adds a global setting as well as a setting that is only in effect
during auto-tagging
2021-12-15 10:58:34 -08:00
aefe778b36 Add publisher and imprint handling
Imprint handling has been added to utils and uses a subclassed dict to
return a tuple for imprint matching, this may not be the best idea but
it works for now.

Add settings option auto_imprint
Add cli flag -a, --auto-import
2021-12-15 10:54:16 -08:00
c6e1dc87dc Allow extended selection in the page list editor 2021-12-15 10:53:01 -08:00
ef37158e57 Sort language correctly 2021-12-15 10:52:25 -08:00
444e67100c Merge pull request #207 from jpcranford/patch-1
Fixed typo
2021-12-15 08:49:15 -08:00
82d054fd05 Fixed typo 2021-12-14 16:52:48 -07:00
f82c024f8d Merge pull request #206 from lordwelch/rarOptionalFix
Fix rarfile import as by default it is optional
2021-12-12 18:49:05 -08:00
da4daa6a8a Fix rarfile import as by default it is optional 2021-12-12 18:46:28 -08:00
6e1e8959c9 Merge pull request #204 from lordwelch/buildSystem
Update build
2021-12-12 18:15:58 -08:00
aedc5bedb4 Update build
Separate dependencies into files and add optional dependencies
Update natsort usage to be compliant with the latest version (#203)
Set PyQt5 to 5.15.3, 5.15.4 has issues with pyinstaller
Add pyproject.toml with setuptools, isort and black configuration
Add optional dependencies (#191)
Update README (#174)
2021-10-23 21:39:58 -07:00
93f5061c8f Add GitHub Actions yaml file (#201)
Upload artifacts this allows easy testing of macOS and Windows binaries
Update unrar-cffi for Python 3.9 wheels
2021-09-29 01:17:04 -07:00
d46e171bd6 Merge pull request #199 from lordwelch/seriesSearch
Improve issue identification
2021-09-26 17:09:54 -07:00
e7fe520660 Improve issue identification
Move title sanitizing code to utils module
Update issue identifier to compare sanitized names
2021-09-26 17:06:30 -07:00
91f288e8f4 Update travis
hold windows to 3.7.9 as unrar-cffi only has windows wheels for 3.7
switch to using builtin python for macOS
remove ssl dlls from comictagger.spec
require pyinstaller=4.3 to allow macOS codesigning
Update python usage
restrict builds to tags and pull requests
2021-09-26 12:51:17 -07:00
d7bd3bb94b Merge pull request #198 from lordwelch/143-regression
Fix regression of #143
2021-09-25 23:01:38 -07:00
9e0b0ac01c Fix regression of #143 2021-09-25 22:59:59 -07:00
03a8d906ea Merge pull request #189 from lordwelch/seriesSearch
Series search
2021-09-21 19:59:26 -07:00
fff28cf6ae Improve searchForSeries
Refactor removearticles to only remove articles
Add normalization on the search string and the series name results

Searching now only compares ASCII a-z and 0-9 and all other characters
are replaced with single space, this is done to both the search string
and the result. This fixes an with names that are separated by a
hyphen (-) in the filename but in the Comic Vine name are separated by a
slash (/) and other similar issues.
2021-08-29 17:35:34 -07:00
9ee95b8d5e Merge pull request #192 from lordwelch/fixes
Fix errors
2021-08-16 17:37:19 -07:00
11bf5a9709 Move to python requests module
Add requests to requirements.txt
Requests is much simpler and fixes all ssl errors.
Comic Vine now requires a unique useragent string
2021-08-11 20:13:53 -07:00
af4b3af14e Cleanup metadata handling
Mainly corrects for consistency in most situations
CoMet is not touched as there is no support in the gui and has an odd requirements on attributes
2021-08-07 21:54:29 -07:00
9bb7fbbc9e Fix errors
Libraries updated and these are no longer needed
2021-08-05 17:21:21 -07:00
beb7c57a6b fix: change accidental overwrite of reserved __dir__ 2019-10-20 00:36:13 +02:00
ce48730bd5 fix: choco install multiple packages breaks with version 2019-10-20 00:25:52 +02:00
806b65db24 freeze windows python version to 3.7.5 2019-10-20 00:20:57 +02:00
cdf9a40227 fix: add setup.py install before testing 2019-10-20 00:08:11 +02:00
0adac47968 add pytest run to travis ci 2019-10-20 00:02:03 +02:00
096a89eab4 add pytest 2019-10-19 23:57:49 +02:00
f877d620af allow for alpha releases in travis 2019-10-06 16:25:31 +02:00
c175e46b15 Increase comicvine search results per request to max (#164) 2019-10-06 07:14:11 -07:00
f0bc669d40 PyPI release (#163) 2019-10-06 07:01:33 -07:00
db3db48e5c Better console handling on Windows (#162) 2019-10-06 05:15:18 -07:00
cec585f8e0 Changed: use unrar-cffi for cbr handling (#151) 2019-10-05 23:59:52 +02:00
d71a48d8d4 Better support for CLI mode on windows (#158) 2019-10-05 23:55:34 +02:00
9e4a560911 Better support for macOS dark mode (#159) 2019-10-05 23:53:56 +02:00
f244255386 update urls to new github comictagger organization 2019-10-05 16:31:12 +02:00
254e2c25ee Brand new README file (#156) 2019-10-05 16:09:04 +02:00
7455cf17c8 fix broken drag & drop on macOS (#142) 2019-09-29 23:02:44 +01:00
d93cb50896 add version info to mac info_plist (#146) 2019-09-29 22:11:42 +01:00
3316cab775 fix travis regex 2019-09-28 17:05:15 +02:00
c01f00f6c3 multi platform build on travis (#145) 2019-09-28 17:01:05 +02:00
06ff25550e use setuptools_scm to handle version 2019-09-28 14:59:36 +02:00
1f7ef44556 remove obsolete download_url (https://git.io/JeZrE) 2019-09-28 14:57:09 +02:00
fabf2b4df6 Merge tag '1.2.0+2' into develop
1.2.0+2
2019-09-25 01:55:29 +02:00
0fbaeb861e Merge branch 'release/1.2.0+2' 2019-09-25 01:55:15 +02:00
3dcc04a318 try to fix appveyor deployment 2019-09-25 01:55:03 +02:00
933e053df3 Merge tag '1.2.0+1' into develop
1.2.0+1
2019-09-25 01:30:32 +02:00
5f22a583e8 Merge branch 'release/1.2.0+1' 2019-09-25 01:30:03 +02:00
3174b49d94 bump version to force appveyor deploy 2019-09-25 01:29:50 +02:00
93ce311359 Release 1.2.0 2019-09-25 00:51:28 +02:00
bc43c5e329 Release 1.2.0 2019-09-25 00:50:50 +02:00
9bf7aa20fb bump version to 1.2.0 2019-09-25 00:49:52 +02:00
5416bb15c3 Appveyor GitHub release (#139) 2019-09-24 23:36:08 +01:00
562a659195 Travis build for macOS build (#100) 2019-09-24 23:30:23 +01:00
1d3d6e2741 bump version 1.1.32-rc1 2019-09-22 12:47:19 +01:00
c9724527b5 Fixed TLS version for the Comic Vine (#135)
* Fixed TLS version for the comicvine

* Fixed TLS version for the Comic Vine - Auto-Identify and Auto-Tag functions
2019-09-22 12:40:59 +01:00
2891209b4e bump version 2019-02-04 20:27:37 +01:00
5b87e19d3e Limit Comic Vine search result queries (#119)
* Tweaked search string based on new comic vine search behavior
Placated Beaufitul Soup by passing the parser

* Limit search results fetching after recent Comic Vine changes.
Also, minor debug comment tweaks.
2019-02-04 20:16:44 +01:00
tlc
674e24fc41 Enable Zip64 (#96) 2018-09-20 00:09:24 +02:00
91f82fd6d3 Python3 and QT5 upgrade (#109)
* Tweaked search string based on new comic vine search behavior
Placated Beaufitul Soup by passing the parser

* First cut at porting to Python 3 and PyQt5

* remove debug print

* tweaked progress dialog handling for issues on ubuntu gui

* Handle bad key more gracefullu

* More integration of unrarlib into settings and rest of app

* Better handling of "personal" unrar lib setting

* PEP 440-compliant version string

* Tuned linux rar help strings

* Got setup working again
* Attempts to build unrar on install
* Some minimal desktop integration on various platforms

* Fix wrong shortfile

* More setup.py enhancements
* Use proper temp file
* Added comment block at top

* Comment out desktop integration attempt for now

* Updated some links and info

* Fixed the html a bit

* Repaired some images that caused libpng to complain

* update readme re:  py3qt5 branch changes

* another note

* #108 feat: try to simplify windows build using only pip and python3

* #108 feat: fix python location on appveyor (try 1)

* #108 feat: use venv (try 2)

* #108 feat: use venv (try 3)

* #108 feat: update to latest pyinstaller develop branch

* #108 feat: update to latest pyinstaller develop branch (again)

* #108: add ssl libraries for windows packaging

* #108: refresh env in win build to pick the right mingw

* #108: change order of win build script operations

* #113: fix subprocess usage in pyinstaller package

* bump version
2018-09-19 22:05:39 +02:00
cf43513d52 feat: add appveyor configuration 2018-01-17 13:35:10 -08:00
a7288a94cc #98 Multiplatform pyinstaller dist (#99)
Multiplatform pyinstaller dist (#98)
2018-01-14 16:41:27 +01:00
d0918c92e4 #87 Update comic vine url and ssl config (#93)
* #87 fix ssl comicvine communication

* handle missing libunrar. update macos makefile. remove version check window. bump version.

* update release notes

* #87 fix ssl context in several places. update comicvine api url.

* fix drag and drop issues on macOS

* bump version to 1.1.16-beta-rc2

* use PNG conversion for Windows build
2017-12-21 15:19:45 +01:00
4ff2061568 Merge pull request #74 from Alkpone/master
Bugs in move2folder.py script
2015-03-22 10:49:21 +01:00
08c402149b Prevent error when no file has been detected
Script raised an unhandled exception:  local variable 'fmt_str' referenced before assignment
Traceback (most recent call last):
  File "/volume1/@appstore/comictagger/comictaggerlib/options.py", line 233, in launch_script
    script.main()
  File "/volume1/@appstore/comictagger/scripts/move2folder.py", line 90, in main
    print >> sys.stderr, fmt_str.format("")
UnboundLocalError: local variable 'fmt_str' referenced before assignment
2015-03-21 14:32:55 +01:00
184dbf0684 Prevent error when running the script
Script raised an unhandled exception:  coercing to Unicode: need string or buffer, NoneType found
Traceback (most recent call last):
  File "/root/comictagger/comictaggerlib/options.py", line 233, in launch_script
    script.main()
  File "scripts/move2folder.py", line 80, in main
    ca = ComicArchive(filename, settings.rar_exe_path)
  File "/root/comictagger/comicapi/comicarchive.py", line 648, in __init__
    with open(fname, 'rb') as fd:
TypeError: coercing to Unicode: need string or buffer, NoneType found
2015-03-21 14:17:05 +01:00
ed0050ba05 fixed typo 2015-03-06 11:26:47 +01:00
68030a1024 updated to unrar 0.3 2015-03-01 16:14:01 +01:00
983ad1fcf4 Merge branch 'fcanc-master' 2015-03-01 15:44:11 +01:00
d959ac0401 Huge code cleanup
- `autopep8 -aa` for general cleanup;
- Changed order of imports, they should be ordered into 3 groups:
1. standard library imports;
2. 3rd party packages;
3. project imports.
- I commented various imports that were reported as unused by my IDE.
If everything goes fine we can consider to delete them;
- The Apache license disclaimers are now comments since triple-quotes
should be used only for docstrings;
- Fix - `utils.centerWindowOnParent` did not resolve, changed to
`centerWindowOnParent`
2015-02-22 03:30:32 +01:00
2a550db02a Merge pull request #1 from davide-romanini/master
Merge davide-romanini commits
2015-02-18 20:44:28 +01:00
6369fa5fda updated readme 2015-02-16 16:34:38 +01:00
d5a13a4206 various fixes after merging comicstream-integr 2015-02-16 16:19:38 +01:00
b2532ce03a Merge branch 'comicstream-integr' 2015-02-16 16:18:00 +01:00
79a67d8c29 Merge pull request #71 from branch 'fcanc-master' 2015-02-16 14:51:57 +01:00
d9bd38674c added new dependencies to requirements.txt. with new unrar needs UNRAR_LIB_PATH to be set to start 2015-02-16 14:27:13 +01:00
a0154aaaae Merge commit '17f74cf2968a4e0aa01d7309afe7e1407b8abef2' into comicstream-integr 2015-02-16 14:09:21 +01:00
17f74cf296 Squashed 'comicapi/' changes from b7d2458..18f87d3
18f87d3 using comicapi subtree classes

git-subtree-dir: comicapi
git-subtree-split: 18f87d35b1b2cf5e135fad353419eda11209a6be
2015-02-16 14:09:21 +01:00
3f112cd578 Merge commit 'f6439049d8d8b5a4709f1b78afbfd289d00e8c25' as 'comicapi' 2015-02-16 13:27:21 +01:00
f6439049d8 Squashed 'comicapi/' content from commit b7d2458
git-subtree-dir: comicapi
git-subtree-split: b7d2458b80467a47be1d1d58b31ffcac62c2743c
2015-02-16 13:27:21 +01:00
2fe818872c removed splitted comicapi 2015-02-16 13:25:35 +01:00
a419969b85 autopep8 -aa
—aggressive, level 2
2015-02-15 12:55:04 +01:00
ee52448f17 autopep8 -a
—aggressive, level 1
2015-02-15 12:44:09 +01:00
79103990fa autopep8
automatically formats Python code to conform to the PEP 8 style guide —
default usage (whitespace changes only)
2015-02-15 11:44:00 +01:00
22dbafbc00 Code cleanup, round 1
Some formatting cleanup, plus print modernization, & typos correction.
2015-02-14 00:08:07 +01:00
0df283778c Indentation
Replaced tabs with spaces, and removed some trailing spaces.
2015-02-12 23:57:46 +01:00
a6282b5449 Move2folder script
Added a script to organize comics in a folder tree by Publisher/Series
(Volume).
2015-02-12 19:15:17 +01:00
5574280ad6 Filename parser tweaks
Fixes the Scan Info tag being left blank when the filename doesn’t
provide an issue number.
2015-02-12 19:09:33 +01:00
19b907b742 refactor (continue) 2015-02-11 19:45:45 +01:00
a9ff8f37b0 refactor core comicarchive classes in its own package comicapi 2015-02-11 19:45:02 +01:00
0769111f8c #70 added support for the day field on the gui 2015-02-09 21:50:02 +01:00
cf6ae8b5ae aligned with comicstreamer updates
refactor qt specific functions in utils.py in new ui.qtutils module
2015-02-02 17:20:48 +01:00
1d6846ced3 gitignore
changed to README.md for github.
2015-01-23 17:42:22 +01:00
d516d80093 Removed unused FileTableWidget, and explicitly set the column count. This fixes a problem on ArchLinux systems
git-svn-id: http://comictagger.googlecode.com/svn/trunk@744 6c5673fe-1810-88d6-992b-cd32ca31540c
2014-07-06 18:19:50 +00:00
bf9ab71fd9 release notes update
git-svn-id: http://comictagger.googlecode.com/svn/trunk@737 6c5673fe-1810-88d6-992b-cd32ca31540c
2014-06-14 03:56:46 +00:00
33b00ad323 Text tweaks
git-svn-id: http://comictagger.googlecode.com/svn/trunk@736 6c5673fe-1810-88d6-992b-cd32ca31540c
2014-06-14 03:56:32 +00:00
301ff084f1 fixes for webp, api key handling, and CV rate limit
git-svn-id: http://comictagger.googlecode.com/svn/trunk@734 6c5673fe-1810-88d6-992b-cd32ca31540c
2014-06-13 06:26:44 +00:00
0c146bb245 minor fix
git-svn-id: http://comictagger.googlecode.com/svn/trunk@733 6c5673fe-1810-88d6-992b-cd32ca31540c
2014-06-13 06:26:13 +00:00
08cc4a1acb Use pip-installed pyinstaller
git-svn-id: http://comictagger.googlecode.com/svn/trunk@732 6c5673fe-1810-88d6-992b-cd32ca31540c
2014-06-13 06:25:35 +00:00
f97a1653d9 dos-ified release_notes file
git-svn-id: http://comictagger.googlecode.com/svn/trunk@728 6c5673fe-1810-88d6-992b-cd32ca31540c
2014-04-18 15:44:38 +00:00
d9dbab301a prep for release
git-svn-id: http://comictagger.googlecode.com/svn/trunk@727 6c5673fe-1810-88d6-992b-cd32ca31540c
2014-04-18 15:42:05 +00:00
3d93197101 Added warning when rar is tried be loaded, and unrar tool isn't known
Fixed a bug when erroneous message is show when file is attempted to be reloaded

git-svn-id: http://comictagger.googlecode.com/svn/trunk@726 6c5673fe-1810-88d6-992b-cd32ca31540c
2014-04-12 06:08:07 +00:00
190 changed files with 21327 additions and 15514 deletions

6
.flake8 Normal file
View File

@ -0,0 +1,6 @@
[flake8]
max-line-length = 120
extend-ignore = E203, E501, A003
extend-exclude = venv, scripts, build, dist, comictaggerlib/ctversion.py
per-file-ignores =
comictaggerlib/cli.py: T20

117
.github/workflows/build.yaml vendored Normal file
View File

@ -0,0 +1,117 @@
name: CI
env:
PIP: pip
PYTHON: python
on:
pull_request:
push:
branches:
- '**'
tags-ignore:
- '**'
jobs:
lint:
permissions:
checks: write
contents: read
pull-requests: write
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: [3.9]
os: [ubuntu-latest]
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: syphar/restore-virtualenv@v1.2
id: cache-virtualenv
- uses: syphar/restore-pip-download-cache@v1
if: steps.cache-virtualenv.outputs.cache-hit != 'true'
- name: Install build dependencies
run: |
python -m pip install --upgrade --upgrade-strategy eager -r requirements_dev.txt
- uses: reviewdog/action-setup@v1
with:
reviewdog_version: nightly
- run: flake8 | reviewdog -f=flake8 -reporter=github-pr-review -tee -level=error -fail-on-error
env:
REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
build-and-test:
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: [3.9]
os: [ubuntu-latest, macos-10.15, windows-latest]
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: syphar/restore-virtualenv@v1.2
id: cache-virtualenv
- uses: syphar/restore-pip-download-cache@v1
if: steps.cache-virtualenv.outputs.cache-hit != 'true'
- name: Install build dependencies
run: |
python -m pip install --upgrade --upgrade-strategy eager -r requirements_dev.txt
- name: Install Windows build dependencies
run: |
choco install -y zip
if: runner.os == 'Windows'
- name: Install macos dependencies
run: |
brew install icu4c pkg-config
export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
python -m pip install --no-binary=pyicu pyicu
if: runner.os == 'macOS'
- name: Install linux dependencies
run: |
sudo apt-get install pkg-config libicu-dev
export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
python -m pip install --no-binary=pyicu pyicu
if: runner.os == 'Linux'
- name: Build and install PyPi packages
run: |
make clean pydist
python -m pip install "dist/$(python setup.py --fullname)-py3-none-any.whl[all]"
- name: build
run: |
make dist
- name: Archive production artifacts
uses: actions/upload-artifact@v2
if: runner.os != 'Linux' # linux binary currently has a segfault when running on latest fedora
with:
name: "${{ format('ComicTagger-{0}', runner.os) }}"
path: |
dist/*.zip
- name: PyTest
run: |
python -m pytest

41
.github/workflows/contributions.yaml vendored Normal file
View File

@ -0,0 +1,41 @@
name: Contributions
on:
push:
branches:
- '**'
tags-ignore:
- '**'
jobs:
contrib-readme-job:
permissions:
contents: write
runs-on: ubuntu-latest
env:
CI_COMMIT_AUTHOR: github-actions[bot]
CI_COMMIT_EMAIL: <41898282+github-actions[bot]@users.noreply.github.com>
CI_COMMIT_MESSAGE: Update AUTHORS
name: A job to automate contrib in readme
steps:
- name: Contribute List
uses: akhilmhdh/contributors-readme-action@v2.3.6
with:
use_username: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Update AUTHORS
run: |
git config --global log.mailmap true
git log --reverse '--format=%aN <%aE>' | cat -n | sort -uk2 | sort -n | cut -f2- >AUTHORS
- name: Commit and push AUTHORS
run: |
git pull
git config --global user.name "${{ env.CI_COMMIT_AUTHOR }}"
git config --global user.email "${{ env.CI_COMMIT_EMAIL }}"
git commit -a -m "${{ env.CI_COMMIT_MESSAGE }}"
git push

93
.github/workflows/package.yaml vendored Normal file
View File

@ -0,0 +1,93 @@
name: Package
env:
PIP: pip
PYTHON: python
on:
push:
tags:
- "[0-9]+.[0-9]+.[0-9]+*"
jobs:
package:
permissions:
contents: write
runs-on: ${{ matrix.os }}
strategy:
matrix:
python-version: [3.9]
os: [ubuntu-latest, macos-10.15, windows-latest]
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- uses: syphar/restore-virtualenv@v1.2
id: cache-virtualenv
- uses: syphar/restore-pip-download-cache@v1
if: steps.cache-virtualenv.outputs.cache-hit != 'true'
- name: Install build dependencies
run: |
python -m pip install --upgrade --upgrade-strategy eager -r requirements_dev.txt
- name: Install Windows build dependencies
run: |
choco install -y zip
if: runner.os == 'Windows'
- name: Install macos dependencies
run: |
brew install icu4c pkg-config
export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
python -m pip install --no-binary=pyicu pyicu
if: runner.os == 'macOS'
- name: Install linux dependencies
run: |
sudo apt-get install pkg-config libicu-dev
export PKG_CONFIG_PATH="/usr/local/opt/icu4c/lib/pkgconfig";
export PATH="/usr/local/opt/icu4c/bin:/usr/local/opt/icu4c/sbin:$PATH"
python -m pip install --no-binary=pyicu pyicu
if: runner.os == 'Linux'
- name: Build, Install and Test PyPi packages
run: |
make clean pydist
python -m pip install "dist/$(python setup.py --fullname)-py3-none-any.whl[all]"
python -m flake8
python -m pytest
- name: "Publish distribution 📦 to PyPI"
if: startsWith(github.ref, 'refs/tags/') && runner.os == 'Linux'
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_API_TOKEN }}
packages_dir: dist
- name: Build PyInstaller package
run: |
make dist
- name: Get release name
if: startsWith(github.ref, 'refs/tags/')
shell: bash
run: |
git fetch --depth=1 origin +refs/tags/*:refs/tags/* # github is dumb
echo "release_name=$(git tag -l --format "%(refname:strip=2): %(contents:lines=1)" ${{ github.ref_name }})" >> $GITHUB_ENV
- name: Release
uses: softprops/action-gh-release@v1
if: startsWith(github.ref, 'refs/tags/')
with:
name: "${{ env.release_name }}"
prerelease: "${{ contains(github.ref, '-') }}" # alpha-releases should be 1.3.0-alpha.x full releases should be 1.3.0
draft: false
files: |
dist/!(*Linux).zip
dist/*${{ fromJSON('["never", ""]')[runner.os == 'Linux'] }}.whl

160
.gitignore vendored Normal file
View File

@ -0,0 +1,160 @@
# generated by setuptools_scm
ctversion.py
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion
*.iml
## Directory-based project format:
.idea/
### Other editors
.*.swp
nbproject/
.vscode
comictaggerlib/_version.py
*.exe
*.zip
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# for testing
temp/

9
.mailmap Normal file
View File

@ -0,0 +1,9 @@
Andrew W. Buchanan <buchanan@difference.com>
Davide Romanini <d.romanini@cineca.it> <davide.romanini@gmail.com>
Davide Romanini <d.romanini@cineca.it> <user159033@92-63-141-211.rdns.melbourne.co.uk>
Michael Fitzurka <MichaelFitzurka@users.noreply.github.com> <MichaelFitzurka@github.com>
Timmy Welch <timmy@narnian.us>
beville <beville@users.noreply.github.com> <(no author)@6c5673fe-1810-88d6-992b-cd32ca31540c>
beville <beville@users.noreply.github.com> <beville@6c5673fe-1810-88d6-992b-cd32ca31540c>
beville <beville@users.noreply.github.com> <beville@gmail.com@6c5673fe-1810-88d6-992b-cd32ca31540c>
beville <beville@users.noreply.github.com> <beville@users.noreply.github.com>

46
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,46 @@
exclude: ^scripts
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.4.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
- id: debug-statements
- id: name-tests-test
- id: requirements-txt-fixer
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v2.2.0
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/PyCQA/isort
rev: 5.12.0
hooks:
- id: isort
args: [--af,--add-import, 'from __future__ import annotations']
- repo: https://github.com/asottile/pyupgrade
rev: v3.3.1
hooks:
- id: pyupgrade
args: [--py39-plus]
- repo: https://github.com/psf/black
rev: 23.1.0
hooks:
- id: black
- repo: https://github.com/PyCQA/autoflake
rev: v2.0.1
hooks:
- id: autoflake
args: [-i, --remove-all-unused-imports, --ignore-init-module-imports]
- repo: https://github.com/PyCQA/flake8
rev: 6.0.0
hooks:
- id: flake8
additional_dependencies: [flake8-encodings, flake8-warnings, flake8-builtins, flake8-length, flake8-print]
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.991
hooks:
- id: mypy
additional_dependencies: [types-setuptools, types-requests]
ci:
skip: [mypy]

59
.travis.yml Normal file
View File

@ -0,0 +1,59 @@
language: python
# Only build tags
if: type = pull_request OR tag IS present
branches:
only:
- develop
- /^\d+\.\d+\.\d+.*$/
env:
global:
- PYTHON=python3
- PIP=pip3
- SETUPTOOLS_SCM_PRETEND_VERSION=$TRAVIS_TAG
- MAKE=make
matrix:
include:
- os: linux
python: 3.8
- name: "Python: 3.7"
os: osx
language: shell
python: 3.7
env: PYTHON=python3 PIP="python3 -m pip"
cache:
- directories:
- $HOME/Library/Caches/pip
- os: windows
language: bash
env: PATH=/C/Python37:/C/Python37/Scripts:$PATH MAKE=mingw32-make PIP=pip PYTHON=python
before_install:
- if [ "$TRAVIS_OS_NAME" = "windows" ]; then choco install -y python --version 3.7.9; choco install -y mingw zip; fi
install:
- $PIP install -r requirements_dev.txt
- $PIP install -r requirements-GUI.txt
- $PIP install -r requirements-CBR.txt
script:
- if [ "$TRAVIS_OS_NAME" != "linux" ]; then $MAKE dist ; fi
deploy:
- name: "$TRAVIS_TAG"
body: Released ComicTagger $TRAVIS_TAG
provider: releases
skip_cleanup: true
api_key:
secure: RgohcOJOfLhXXT12bMWaLwOqhe+ClSCYXjYuUJuWK4/E1fdd1xu1ebdQU+MI/R8cZ0Efz3sr2n3NkO/Aa8gN68xEfuF7RVRMm64P9oPrfZgGdsD6H43rU/6kN8bgaDRmCYpLTfXaJ+/gq0x1QDkhWJuceF2BYEGGvL0BvS/TUsLyjVxs8ujTplLyguXHNEv4/7Yz7SBNZZmUHjBuq/y+l8ds3ra9rSgAVAN1tMXoFKJPv+SNNkpTo5WUNMPzBnN041F1rzqHwYDLog2V7Krp9JkXzheRFdAr51/tJBYzEd8AtYVdYvaIvoO6A4PiTZ7MpsmcZZPAWqLQU00UTm/PhT/LVR+7+f8lOBG07RgNNHB+edjDRz3TAuqyuZl9wURWTZKTPuO49TkZMz7Wm0DRNZHvBm1IXLeSG7Tll2YL1+WpZNZg+Dhro2J1QD3vxDXafhMdTCB4z0q5aKpG93IT0p6oXOO0oEGOPZYbA2c5R3SXWSyqd1E1gdhbVjIZr59h++TEf1zz07tvWHqPuAF/Ly/j+dIcY2wj0EzRWaSASWgUpTnMljAkHtWhqDw4GXGDRkRUWRJl1d0/JyVqCeIdRzDQNl8/q7BcO3F1zqr1PgnYdz0lfwWxL1/ekw2vHOJE/GOdkyvX0aJrnaOV338mjJbfGHYv4ESc9ow1kdtIbiU=
file_glob: true
file: dist/*.zip
draft: true
on:
tags: true
condition: $TRAVIS_OS_NAME != "linux"
- provider: pypi
user: __token__
password:
secure: h+y5WkE8igf864dnsbGPFvOBkyPkuBYtnDRt+EgxHd71EZnV2YP7ns2Cx12su/SVVDdZCBlmHVtkhl6Jmqy+0rTkSYx+3mlBOqyl8Cj5+BlP/dP7Bdmhs2uLZk2YYL1avbC0A6eoNJFtCkjurnB/jCGE433rvMECWJ5x2HsQTKchCmDAEdAZbRBJrzLFsrIC+6NXW1IJZjd+OojbhLSyVar2Jr32foh6huTcBu/x278V1+zIC/Rwy3W67+3c4aZxYrI47FoYFza0jjFfr3EoSkKYUSByMTIvhWaqB2gIsF0T160jgDd8Lcgej+86ACEuG0v01VE7xoougqlOaJ94eAmapeM7oQXzekSwSAxcK3JQSfgWk/AvPhp07T4pQ8vCZmky6yqvVp1EzfKarTeub1rOnv+qo1znKLrBtOoq6t8pOAeczDdIDs51XT/hxaijpMRCM8vHxN4Kqnc4DY+3KcF7UFyH1ifQJHQe71tLBsM/GnAcJM5/3ykFVGvRJ716p4aa6IoGsdNk6bqlysNh7nURDl+bfm+CDXRkO2jkFwUFNqPHW7JwY6ZFx+b5SM3TzC3obJhfMS7OC37fo2geISOTR0xVie6NvpN6TjNAxFTfDxWJI7yH3Al2w43B3uYDd97WeiN+B+HVWtdaER87IVSRbRqFrRub+V+xrozT0y0=
skip_existing: true
skip_cleanup: true
on:
tags: true
condition: $TRAVIS_OS_NAME = "linux"

15
AUTHORS Normal file
View File

@ -0,0 +1,15 @@
beville <beville@users.noreply.github.com>
Davide Romanini <d.romanini@cineca.it>
fcanc <f.canc@icloud.com>
Alban Seurat <alkpone@alkpone.com>
tlc <tlc@users.noreply.github.com>
Marek Pawlak <francuz14@gmail.com>
Timmy Welch <timmy@narnian.us>
J.P. Cranford <philipcranford4@gmail.com>
thFrgttn <39759781+thFrgttn@users.noreply.github.com>
Andrew W. Buchanan <buchanan@difference.com>
Michael Fitzurka <MichaelFitzurka@users.noreply.github.com>
Richard Haussmann <richard.haussmann@gmail.com>
Mizaki <jinxybob@hotmail.com>
Xavier Jouvenot <x.jouvenot@gmail.com>
github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>

137
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,137 @@
# How to contribute
If your not sure what you can do or you need to ask a question or just want to talk about ComicTagger head over to the [discussions tab](https://github.com/comictagger/comictagger/discussions/categories/general) and start a discussion
## Tests
We have tests written using pytest! Some of them even pass! If you are contributing code any tests you can write are appreciated.
A great place to start is extending the tests that are already made.
For example the file tests/filenames.py has lists of filenames to be parsed in the format:
```py
pytest.param(
"Star Wars - War of the Bounty Hunters - IG-88 (2021) (Digital) (Kileko-Empire).cbz",
"number ends series, no-issue",
{
"issue": "",
"series": "Star Wars - War of the Bounty Hunters - IG-88",
"volume": "",
"year": "2021",
"remainder": "(Digital) (Kileko-Empire)",
"issue_count": "",
},
marks=pytest.mark.xfail,
)
```
A test consists of 3-4 parts
1. The filename to be parsed
2. The reason it might fail
3. What the result of parsing the filename should be
4. `marks=pytest.mark.xfail` This marks the test as expected to fail
If you are not comfortable creating a pull request you can [open an issue](https://github.com/comictagger/comictagger/issues/new/choose) or [start a discussion](https://github.com/comictagger/comictagger/discussions/new)
## Submitting changes
Please open a [GitHub Pull Request](https://github.com/comictagger/comictagger/pull/new/develop) with a clear list of what you've done (read more about [pull requests](http://help.github.com/pull-requests/)). When you send a pull request, we will love you forever if you include tests. We can always use more test coverage. Please run the code tools below and make sure all of your commits are atomic (one feature per commit).
## Contributing Code
Currently only python 3.9 is supported however 3.10 will probably work if you try it
Those on linux should install `Pillow` from the system package manager if possible and if the GUI and/or the CBR/RAR comicbooks are going to be used `pyqt5` and `unrar-cffi` should be installed from the system package manager
Those on macOS will need to ensure that you are using python3 in x86 mode either by installing an x86 only version of python or using the universal installer and using `python3-intel64` instead of `python3`
1. Clone the repository
```
git clone https://github.com/comictagger/comictagger.git
```
2. It is preferred to use a virtual env for running from source, adding the `--system-site-packages` allows packages already installed via the system package manager to be used:
```
python3 -m venv --system-site-packages venv
```
3. Activate the virtual env:
```
. venv/bin/activate
```
or if on windows PowerShell
```
. venv/bin/activate.ps1
```
4. install dependencies:
```bash
pip install -r requirements_dev.txt -r requirements.txt
# if installing optional dependencies
pip install -r requirements-GUI.txt -r requirements-CBR.txt
```
5. install ComicTagger
```
pip install .
```
6. (optionally) run pytest to ensure that their are no failures (xfailed means expected failure)
```
$ pytest
============================= test session starts ==============================
platform darwin -- Python 3.9.12, pytest-7.1.1, pluggy-1.0.0
rootdir: /Users/timmy/build/source/comictagger
collected 61 items
tests/test_FilenameParser.py ..x......x.xxx.xx....xxxxxx.xx.x..xxxxxxx [ 67%]
tests/test_comicarchive.py x... [ 73%]
tests/test_rename.py ..xxx.xx..XXX.XX [100%]
================== 27 passed, 29 xfailed, 5 xpassed in 2.68s ===================
```
7. Make your changes
8. run code tools and correct any issues
```bash
black .
isort .
flake8 .
pytest
```
black: formats all of the code consistently so there are no surprises<br>
isort: sorts imports so that you can always find where an import is located<br>
flake8: checks for code quality and style (warns for unused imports and similar issues)<br>
pytest: runs tests for ComicTagger functionality
if on mac or linux most of this can be accomplished by running
```
make install
# or make PYTHON=python3-intel64 install
. venv/bin/activate
make CI
```
There is also `make check` which will run all of the code tools in a read-only capacity
```
$ make check
venv/bin/black --check .
All done! ✨ 🍰 ✨
52 files would be left unchanged.
venv/bin/isort --check .
Skipped 6 files
venv/bin/flake8 .
venv/bin/pytest
============================= test session starts ==============================
platform darwin -- Python 3.9.12, pytest-7.1.1, pluggy-1.0.0
rootdir: /Users/timmy/build/source/comictagger
collected 61 items
tests/test_FilenameParser.py ..x......x.xxx.xx....xxxxxx.xx.x..xxxxxxx [ 67%]
tests/test_comicarchive.py x... [ 73%]
tests/test_rename.py ..xxx.xx..XXX.XX [100%]
================== 27 passed, 29 xfailed, 5 xpassed in 2.68s ===================
```

View File

@ -1,4 +1,7 @@
include README.txt
include README.md
include release_notes.txt
include requirements.txt
recursive-include scripts *.py *.txt
recursive-include scripts *.py *.txt
recursive-include desktop-integration *
include windows/app.ico
include mac/app.icns

113
Makefile
View File

@ -1,61 +1,64 @@
TAGGER_BASE ?= $(HOME)/Dropbox/tagger/comictagger
TAGGER_SRC := $(TAGGER_BASE)/comictaggerlib
VERSION_STR := $(shell grep version $(TAGGER_SRC)/ctversion.py| cut -d= -f2 | sed 's/\"//g')
PASSWORD := $(shell cat $(TAGGER_BASE)/project_password.txt)
UPLOAD_TOOL := $(TAGGER_BASE)/google/googlecode_upload.py
all: clean
PIP ?= pip3
PYTHON ?= python3
VERSION_STR := $(shell $(PYTHON) setup.py --version)
SITE_PACKAGES := $(shell $(PYTHON) -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')
PACKAGE_PATH = $(SITE_PACKAGES)/comictagger.egg-link
VENV := $(shell echo $${VIRTUAL_ENV-venv})
PY3 := $(shell command -v $(PYTHON) 2> /dev/null)
PYTHON_VENV := $(VENV)/bin/python
INSTALL_STAMP := $(VENV)/.install.stamp
ifeq ($(OS),Windows_NT)
PYTHON_VENV := $(VENV)/Scripts/python.exe
OS_VERSION=win-$(PROCESSOR_ARCHITECTURE)
APP_NAME=comictagger.exe
FINAL_NAME=ComicTagger-$(VERSION_STR)-$(OS_VERSION).exe
else ifeq ($(shell uname -s),Darwin)
OS_VERSION=osx-$(shell defaults read loginwindow SystemVersionStampAsString)-$(shell uname -m)
APP_NAME=ComicTagger.app
FINAL_NAME=ComicTagger-$(VERSION_STR)-$(OS_VERSION).app
else
APP_NAME=comictagger
FINAL_NAME=ComicTagger-$(VERSION_STR)-$(shell uname -s)
endif
.PHONY: all clean pydist dist CI check
all: clean dist
$(PYTHON_VENV):
@if [ -z $(PY3) ]; then echo "Python 3 could not be found."; exit 2; fi
$(PY3) -m venv $(VENV)
clean:
rm -rf *~ *.pyc *.pyo
rm -rf scripts/*.pyc
cd comictaggerlib; rm -f *~ *.pyc *.pyo
rm -rf dist MANIFEST
rm -rf *.deb
rm -rf logdict*.log
make -C mac clean
make -C windows clean
rm -rf build
find . -maxdepth 4 -type d -name "__pycache__" -print -depth -exec rm -rf {} \;
rm -rf $(PACKAGE_PATH) $(INSTALL_STAMP) build dist MANIFEST comictaggerlib/ctversion.py
$(MAKE) -C mac clean
CI: install
$(PYTHON_VENV) -m black .
$(PYTHON_VENV) -m isort .
$(PYTHON_VENV) -m flake8 .
$(PYTHON_VENV) -m pytest
check: install
$(PYTHON_VENV) -m black --check .
$(PYTHON_VENV) -m isort --check .
$(PYTHON_VENV) -m flake8 .
$(PYTHON_VENV) -m pytest
pydist:
mkdir -p release
rm -f release/*.zip
python setup.py sdist --formats=zip #,gztar
mv dist/comictagger-$(VERSION_STR).zip release
@echo When satisfied with release, do this:
@echo make svn_tag
$(PYTHON_VENV) -m build
remove_test_install:
sudo rm -rf /usr/local/bin/comictagger.py
sudo rm -rf /usr/local/lib/python2.7/dist-packages/comictagger*
#deb:
# fpm -s python -t deb \
# -n 'comictagger' \
# --category 'utilities' \
# --maintainer 'comictagger@gmail.com' \
# --after-install debian_scripts/after_install.sh \
# --before-remove debian_scripts/before_remove.sh \
# -d 'python >= 2.6' \
# -d 'python < 2.8' \
# -d 'python-imaging' \
# -d 'python-bs4' \
# --deb-suggests 'rar' \
# --deb-suggests 'unrar-free' \
# --python-install-bin /usr/share/comictagger \
# --python-install-lib /usr/share/comictagger \
# setup.py
#
# # For now, don't require PyQt, since command-line is available without it
# #-d 'python-qt4 >= 4.8'
upload:
#$(UPLOAD_TOOL) -p comictagger -s "ComicTagger $(VERSION_STR) Source" -l Featured,Type-Source -u beville -w $(PASSWORD) "release/comictagger-$(VERSION_STR).zip"
#$(UPLOAD_TOOL) -p comictagger -s "ComicTagger $(VERSION_STR) Mac OS X" -l Featured,Type-Archive -u beville -w $(PASSWORD) "release/ComicTagger-$(VERSION_STR).dmg"
#$(UPLOAD_TOOL) -p comictagger -s "ComicTagger $(VERSION_STR) Windows" -l Featured,Type-Installer -u beville -w $(PASSWORD) "release/ComicTagger v$(VERSION_STR).exe"
#python setup.py register
python setup.py sdist --formats=zip upload
svn_tag:
svn copy https://comictagger.googlecode.com/svn/trunk \
https://comictagger.googlecode.com/svn/tags/$(VERSION_STR) -m "Release $(VERSION_STR)"
install: $(INSTALL_STAMP)
$(INSTALL_STAMP): $(PYTHON_VENV) requirements.txt requirements_dev.txt
$(PYTHON_VENV) -m pip install -r requirements_dev.txt
$(PYTHON_VENV) -m pip install -e .
touch $(INSTALL_STAMP)
dist:
pyinstaller -y comictagger.spec
cd dist && zip -m -r $(FINAL_NAME).zip $(APP_NAME)

172
README.md Normal file
View File

@ -0,0 +1,172 @@
[![CI](https://github.com/comictagger/comictagger/actions/workflows/build.yaml/badge.svg?branch=develop&event=push)](https://github.com/comictagger/comictagger/actions/workflows/build.yaml)
[![GitHub release (latest by date)](https://img.shields.io/github/downloads/comictagger/comictagger/latest/total)](https://github.com/comictagger/comictagger/releases/latest)
[![PyPI](https://img.shields.io/pypi/v/comictagger)](https://pypi.org/project/comictagger/)
[![PyPI - Downloads](https://img.shields.io/pypi/dm/comictagger)](https://pypistats.org/packages/comictagger)
[![Chocolatey package](https://img.shields.io/chocolatey/dt/comictagger?color=blue&label=chocolatey)](https://community.chocolatey.org/packages/comictagger)
[![PyPI - License](https://img.shields.io/pypi/l/comictagger)](https://opensource.org/licenses/Apache-2.0)
[![GitHub Discussions](https://img.shields.io/github/discussions/comictagger/comictagger)](https://github.com/comictagger/comictagger/discussions)
[![Gitter chat](https://badges.gitter.im/gitterHQ/gitter.png)](https://gitter.im/comictagger/community)
[![Google Group](https://img.shields.io/badge/discuss-on%20groups-%23207de5)](https://groups.google.com/forum/#!forum/comictagger)
[![Twitter](https://img.shields.io/badge/%40comictagger-twitter-lightgrey)](https://twitter.com/comictagger)
[![Facebook](https://img.shields.io/badge/comictagger-facebook-lightgrey)](https://www.facebook.com/ComicTagger-139615369550787/)
# ComicTagger
ComicTagger is a **multi-platform** app for **writing metadata to digital comics**, written in Python and PyQt.
![ComicTagger logo](https://raw.githubusercontent.com/comictagger/comictagger/develop/comictaggerlib/graphics/app.png)
## Features
* Runs on macOS, Microsoft Windows, and Linux systems
* Get comic information from [Comic Vine](https://comicvine.gamespot.com/)
* **Automatic issue matching** using advanced image processing techniques
* **Batch processing** in the GUI for tagging hundreds or more comics at a time
* Support for **ComicRack** and **ComicBookLover** tagging formats
* Native full support for **CBZ** digital comics
* Native read only support for **CBR** digital comics: full support enabled installing additional [rar tools](https://www.rarlab.com/download.htm)
* Command line interface (CLI) enabling **custom scripting** and **batch operations on large collections**
For details, screen-shots, and more, visit [the Wiki](https://github.com/comictagger/comictagger/wiki)
## Installation
### Binaries
Windows and macOS binaries are provided in the [Releases Page](https://github.com/comictagger/comictagger/releases).
Just unzip the archive in any folder and run, no additional installation steps are required.
### PIP installation
A pip package is provided, you can install it with:
```
$ pip3 install comictagger[GUI]
```
There are two optional dependencies GUI and CBR. You can install the optional dependencies by specifying one or more of `GUI`,`CBR` or `all` in braces e.g. `comictagger[CBR,GUI]`
### Chocolatey installation (Windows only)
A [Chocolatey package](https://community.chocolatey.org/packages/comictagger), maintained by @Xav83, is provided, you can install it with:
```powershell
choco install comictagger
```
### From source
1. Ensure you have python 3.9 installed
2. Clone this repository `git clone https://github.com/comictagger/comictagger.git`
3. `pip3 install -r requirements_dev.txt`
7. `pip3 install .` or `pip3 install .[GUI]`
## Contributors
<!-- readme: beville,davide-romanini,collaborators,contributors -start -->
<table>
<tr>
<td align="center">
<a href="https://github.com/beville">
<img src="https://avatars.githubusercontent.com/u/7294848?v=4" width="100;" alt="beville"/>
<br />
<sub><b>beville</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/davide-romanini">
<img src="https://avatars.githubusercontent.com/u/731199?v=4" width="100;" alt="davide-romanini"/>
<br />
<sub><b>davide-romanini</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/lordwelch">
<img src="https://avatars.githubusercontent.com/u/7547075?v=4" width="100;" alt="lordwelch"/>
<br />
<sub><b>lordwelch</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/mizaki">
<img src="https://avatars.githubusercontent.com/u/1141189?v=4" width="100;" alt="mizaki"/>
<br />
<sub><b>mizaki</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/MichaelFitzurka">
<img src="https://avatars.githubusercontent.com/u/27830765?v=4" width="100;" alt="MichaelFitzurka"/>
<br />
<sub><b>MichaelFitzurka</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/fcanc">
<img src="https://avatars.githubusercontent.com/u/4999486?v=4" width="100;" alt="fcanc"/>
<br />
<sub><b>fcanc</b></sub>
</a>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/abuchanan920">
<img src="https://avatars.githubusercontent.com/u/368793?v=4" width="100;" alt="abuchanan920"/>
<br />
<sub><b>abuchanan920</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/AlbanSeurat">
<img src="https://avatars.githubusercontent.com/u/500180?v=4" width="100;" alt="AlbanSeurat"/>
<br />
<sub><b>AlbanSeurat</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/rhaussmann">
<img src="https://avatars.githubusercontent.com/u/7084007?v=4" width="100;" alt="rhaussmann"/>
<br />
<sub><b>rhaussmann</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/jpcranford">
<img src="https://avatars.githubusercontent.com/u/21347202?v=4" width="100;" alt="jpcranford"/>
<br />
<sub><b>jpcranford</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/PawlakMarek">
<img src="https://avatars.githubusercontent.com/u/26022173?v=4" width="100;" alt="PawlakMarek"/>
<br />
<sub><b>PawlakMarek</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/Xav83">
<img src="https://avatars.githubusercontent.com/u/6787157?v=4" width="100;" alt="Xav83"/>
<br />
<sub><b>Xav83</b></sub>
</a>
</td></tr>
<tr>
<td align="center">
<a href="https://github.com/thFrgttn">
<img src="https://avatars.githubusercontent.com/u/39759781?v=4" width="100;" alt="thFrgttn"/>
<br />
<sub><b>thFrgttn</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/tlc">
<img src="https://avatars.githubusercontent.com/u/19436?v=4" width="100;" alt="tlc"/>
<br />
<sub><b>tlc</b></sub>
</a>
</td></tr>
</table>
<!-- readme: beville,davide-romanini,collaborators,contributors -end -->

View File

@ -1,31 +0,0 @@
ComicTagger is a multi-platform app for writing metadata to digital comics, written in Python and PyQt.
Features:
* Runs on Mac OSX, Microsoft Windows, and Linux systems
* Communicates with an online database (Comic Vine) for acquiring metadata
* Uses image processing to automatically match a given archive with the correct issue data
* Batch processing in the GUI for tagging hundreds or more comics at a time
* Reads and writes multiple tagging schemes ( ComicBookLover and ComicRack, with more planned).
* Reads and writes RAR and Zip archives (external tools needed for writing RAR)
* Command line interface (CLI) on all platforms (including Windows), which supports batch operations, and which can be used in native scripts for complex operations. For example, to recusrively scrape and tag all archives in a folder
comictagger.py -R -s -o -f -t cr -v -i --nooverwrite /path/to/comics/
For details, screenshots, release notes, and more, visit http://code.google.com/p/comictagger/
Requires:
* python 2.6 or 2.7
* configparser
* python imaging (PIL) >= 1.1.6
* beautifulsoup > 4.1
Optional requirement (for GUI):
* pyqt4
Install and run:
* ComicTagger can be run directly from this directory, using the launcher script "comictagger.py"
* To install on your system use: "python setup.py install". Take note in the output where comictagger.py goes!

3
comicapi/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from __future__ import annotations
__author__ = "dromanin"

View File

@ -0,0 +1,7 @@
from __future__ import annotations
import os
def get_hook_dirs() -> list[str]:
return [os.path.dirname(__file__)]

View File

@ -0,0 +1,6 @@
from __future__ import annotations
from PyInstaller.utils.hooks import collect_data_files, collect_entry_point
datas, hiddenimports = collect_entry_point("comicapi.archiver")
datas += collect_data_files("comicapi.data")

View File

@ -0,0 +1,15 @@
from __future__ import annotations
from comicapi.archivers.archiver import Archiver
from comicapi.archivers.folder import FolderArchiver
from comicapi.archivers.rar import RarArchiver
from comicapi.archivers.sevenzip import SevenZipArchiver
from comicapi.archivers.zip import ZipArchiver
class UnknownArchiver(Archiver):
def name(self) -> str:
return "Unknown"
__all__ = ["Archiver", "UnknownArchiver", "FolderArchiver", "RarArchiver", "ZipArchiver", "SevenZipArchiver"]

View File

@ -0,0 +1,130 @@
from __future__ import annotations
import pathlib
from typing import Protocol, runtime_checkable
@runtime_checkable
class Archiver(Protocol):
"""Archiver Protocol"""
"""The path to the archive"""
path: pathlib.Path
"""
The name of the executable used for this archiver. This should be the base name of the executable.
For example if 'rar.exe' is needed this should be "rar".
If an executable is not used this should be the empty string.
"""
exe: str = ""
"""
Whether or not this archiver is enabled.
If external imports are required and are not available this should be false. See rar.py and sevenzip.py.
"""
enabled: bool = True
def __init__(self):
self.path = pathlib.Path()
def get_comment(self) -> str:
"""
Returns the comment from the current archive as a string.
Should always return a string. If comments are not supported in the archive the empty string should be returned.
"""
return ""
def set_comment(self, comment: str) -> bool:
"""
Returns True if the comment was successfully set on the current archive.
Should always return a boolean. If comments are not supported in the archive False should be returned.
"""
return False
def supports_comment(self) -> bool:
"""
Returns True if the current archive supports comments.
Should always return a boolean. If comments are not supported in the archive False should be returned.
"""
return False
def read_file(self, archive_file: str) -> bytes:
"""
Reads the named file from the current archive.
archive_file should always come from the output of get_filename_list.
Should always return a bytes object. Exceptions should be of the type OSError.
"""
raise NotImplementedError
def remove_file(self, archive_file: str) -> bool:
"""
Removes the named file from the current archive.
archive_file should always come from the output of get_filename_list.
Should always return a boolean. Failures should return False.
Rebuilding the archive without the named file is a standard way to remove a file.
"""
return False
def write_file(self, archive_file: str, data: bytes) -> bool:
"""
Writes the named file to the current archive.
Should always return a boolean. Failures should return False.
"""
return False
def get_filename_list(self) -> list[str]:
"""
Returns a list of filenames in the current archive.
Should always return a list of string. Failures should return an empty list.
"""
return []
def copy_from_archive(self, other_archive: Archiver) -> bool:
"""
Copies the contents of another achive to the current archive.
Should always return a boolean. Failures should return False.
"""
return False
def is_writable(self) -> bool:
"""
Retuns True if the current archive is writeable
Should always return a boolean. Failures should return False.
"""
return False
def extension(self) -> str:
"""
Returns the extension that this archiver should use eg ".cbz".
Should always return a string. Failures should return the empty string.
"""
return ""
def name(self) -> str:
"""
Returns the name of this archiver for display purposes eg "CBZ".
Should always return a string. Failures should return the empty string.
"""
return ""
@classmethod
def is_valid(cls, path: pathlib.Path) -> bool:
"""
Returns True if the given path can be opened by this archiver.
Should always return a boolean. Failures should return False.
"""
return False
@classmethod
def open(cls, path: pathlib.Path) -> Archiver:
"""
Opens the given archive.
Should always return a an Archver.
Should never cause an exception no file operations should take place in this method,
is_valid will always be called before open.
"""
archiver = cls()
archiver.path = path
return archiver

View File

@ -0,0 +1,97 @@
from __future__ import annotations
import logging
import os
import pathlib
from comicapi.archivers import Archiver
logger = logging.getLogger(__name__)
class FolderArchiver(Archiver):
"""Folder implementation"""
def __init__(self) -> None:
super().__init__()
self.comment_file_name = "ComicTaggerFolderComment.txt"
def get_comment(self) -> str:
try:
return self.read_file(self.comment_file_name).decode("utf-8")
except OSError:
return ""
def set_comment(self, comment: str) -> bool:
if (self.path / self.comment_file_name).exists() or comment:
return self.write_file(self.comment_file_name, comment.encode("utf-8"))
return True
def read_file(self, archive_file: str) -> bytes:
try:
with open(self.path / archive_file, mode="rb") as f:
data = f.read()
except OSError as e:
logger.error("Error reading folder archive [%s]: %s :: %s", e, self.path, archive_file)
raise
return data
def remove_file(self, archive_file: str) -> bool:
try:
(self.path / archive_file).unlink(missing_ok=True)
except OSError as e:
logger.error("Error removing file for folder archive [%s]: %s :: %s", e, self.path, archive_file)
return False
else:
return True
def write_file(self, archive_file: str, data: bytes) -> bool:
try:
file_path = self.path / archive_file
file_path.parent.mkdir(exist_ok=True, parents=True)
with open(self.path / archive_file, mode="wb") as f:
f.write(data)
except OSError as e:
logger.error("Error writing folder archive [%s]: %s :: %s", e, self.path, archive_file)
return False
else:
return True
def get_filename_list(self) -> list[str]:
filenames = []
try:
for root, _dirs, files in os.walk(self.path):
for f in files:
filenames.append(os.path.relpath(os.path.join(root, f), self.path).replace(os.path.sep, "/"))
return filenames
except OSError as e:
logger.error("Error listing files in folder archive [%s]: %s", e, self.path)
return []
def copy_from_archive(self, other_archive: Archiver) -> bool:
"""Replace the current zip with one copied from another archive"""
try:
for filename in other_archive.get_filename_list():
data = other_archive.read_file(filename)
if data is not None:
self.write_file(filename, data)
# preserve the old comment
comment = other_archive.get_comment()
if comment is not None:
if not self.set_comment(comment):
return False
except Exception:
logger.exception("Error while copying archive from %s to %s", other_archive.path, self.path)
return False
else:
return True
def name(self) -> str:
return "Folder"
@classmethod
def is_valid(cls, path: pathlib.Path) -> bool:
return path.is_dir()

262
comicapi/archivers/rar.py Normal file
View File

@ -0,0 +1,262 @@
from __future__ import annotations
import logging
import os
import pathlib
import platform
import shutil
import subprocess
import tempfile
import time
from comicapi.archivers import Archiver
try:
from unrar.cffi import rarfile
rar_support = True
except ImportError:
rar_support = False
logger = logging.getLogger(__name__)
if not rar_support:
logger.error("unrar-cffi unavailable")
class RarArchiver(Archiver):
"""RAR implementation"""
enabled = rar_support
exe = "rar"
def __init__(self) -> None:
super().__init__()
# windows only, keeps the cmd.exe from popping up
if platform.system() == "Windows":
self.startupinfo = subprocess.STARTUPINFO() # type: ignore
self.startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW # type: ignore
else:
self.startupinfo = None
def get_comment(self) -> str:
rarc = self.get_rar_obj()
return rarc.comment.decode("utf-8") if rarc else ""
def set_comment(self, comment: str) -> bool:
if rar_support and self.exe:
try:
# write comment to temp file
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_file = pathlib.Path(tmp_dir) / "rar_comment.txt"
tmp_file.write_text(comment, encoding="utf-8")
working_dir = os.path.dirname(os.path.abspath(self.path))
# use external program to write comment to Rar archive
proc_args = [
self.exe,
"c",
f"-w{working_dir}",
"-c-",
f"-z{tmp_file}",
str(self.path),
]
subprocess.run(
proc_args,
startupinfo=self.startupinfo,
stdout=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
check=True,
)
if platform.system() == "Darwin":
time.sleep(1)
except (subprocess.CalledProcessError, OSError) as e:
logger.exception("Error writing comment to rar archive [%s]: %s", e, self.path)
return False
else:
return True
else:
return False
def read_file(self, archive_file: str) -> bytes:
rarc = self.get_rar_obj()
if rarc is None:
return b""
tries = 0
while tries < 7:
try:
tries = tries + 1
data: bytes = rarc.open(archive_file).read()
entries = [(rarc.getinfo(archive_file), data)]
if entries[0][0].file_size != len(entries[0][1]):
logger.info(
"Error reading rar archive [file is not expected size: %d vs %d] %s :: %s :: tries #%d",
entries[0][0].file_size,
len(entries[0][1]),
self.path,
archive_file,
tries,
)
continue
except OSError as e:
logger.error("Error reading rar archive [%s]: %s :: %s :: tries #%d", e, self.path, archive_file, tries)
time.sleep(1)
except Exception as e:
logger.error(
"Unexpected exception reading rar archive [%s]: %s :: %s :: tries #%d",
e,
self.path,
archive_file,
tries,
)
break
else:
# Success. Entries is a list of of tuples: ( rarinfo, filedata)
if len(entries) == 1:
return entries[0][1]
raise OSError
raise OSError
def remove_file(self, archive_file: str) -> bool:
if self.exe:
# use external program to remove file from Rar archive
result = subprocess.run(
[self.exe, "d", "-c-", self.path, archive_file],
startupinfo=self.startupinfo,
stdout=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if platform.system() == "Darwin":
time.sleep(1)
if result.returncode != 0:
logger.error(
"Error removing file from rar archive [exitcode: %d]: %s :: %s",
result.returncode,
self.path,
archive_file,
)
return False
return True
else:
return False
def write_file(self, archive_file: str, data: bytes) -> bool:
if self.exe:
archive_path = pathlib.PurePosixPath(archive_file)
archive_name = archive_path.name
archive_parent = str(archive_path.parent).lstrip("./")
# use external program to write file to Rar archive
result = subprocess.run(
[self.exe, "a", f"-si{archive_name}", f"-ap{archive_parent}", "-c-", "-ep", self.path],
input=data,
startupinfo=self.startupinfo,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if platform.system() == "Darwin":
time.sleep(1)
if result.returncode != 0:
logger.error(
"Error writing rar archive [exitcode: %d]: %s :: %s", result.returncode, self.path, archive_file
)
return False
else:
return True
else:
return False
def get_filename_list(self) -> list[str]:
rarc = self.get_rar_obj()
tries = 0
if rar_support and rarc:
while tries < 7:
try:
tries = tries + 1
namelist = []
for item in rarc.infolist():
if item.file_size != 0:
namelist.append(item.filename)
except OSError as e:
logger.error("Error listing files in rar archive [%s]: %s :: attempt #%d", e, self.path, tries)
time.sleep(1)
else:
return namelist
return []
def copy_from_archive(self, other_archive: Archiver) -> bool:
"""Replace the current archive with one copied from another archive"""
try:
with tempfile.TemporaryDirectory() as tmp_dir:
tmp_path = pathlib.Path(tmp_dir)
rar_cwd = tmp_path / "rar"
rar_cwd.mkdir(exist_ok=True)
rar_path = (tmp_path / self.path.name).with_suffix(".rar")
for filename in other_archive.get_filename_list():
(rar_cwd / filename).parent.mkdir(exist_ok=True, parents=True)
data = other_archive.read_file(filename)
if data is not None:
with open(rar_cwd / filename, mode="w+b") as tmp_file:
tmp_file.write(data)
result = subprocess.run(
[self.exe, "a", "-r", "-c-", str(rar_path.absolute()), "."],
cwd=rar_cwd.absolute(),
startupinfo=self.startupinfo,
stdout=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if result.returncode != 0:
logger.error("Error while copying to rar archive [exitcode: %d]: %s", result.returncode, self.path)
return False
self.path.unlink(missing_ok=True)
shutil.move(rar_path, self.path)
except Exception as e:
logger.exception("Error while copying to rar archive [%s]: from %s to %s", e, other_archive.path, self.path)
return False
else:
return True
def is_writable(self) -> bool:
return bool(self.exe and (os.path.exists(self.exe) or shutil.which(self.exe)))
def extension(self) -> str:
return ".cbr"
def name(self) -> str:
return "RAR"
@classmethod
def is_valid(cls, path: pathlib.Path) -> bool:
if rar_support:
return rarfile.is_rarfile(str(path))
return False
def get_rar_obj(self) -> rarfile.RarFile | None:
if rar_support:
try:
rarc = rarfile.RarFile(str(self.path))
except (OSError, rarfile.RarFileError) as e:
logger.error("Unable to get rar object [%s]: %s", e, self.path)
else:
return rarc
return None

View File

@ -0,0 +1,131 @@
from __future__ import annotations
import logging
import os
import pathlib
import shutil
import tempfile
from comicapi.archivers import Archiver
try:
import py7zr
z7_support = True
except ImportError:
z7_support = False
logger = logging.getLogger(__name__)
class SevenZipArchiver(Archiver):
"""7Z implementation"""
enabled = z7_support
def __init__(self) -> None:
super().__init__()
# @todo: Implement Comment?
def get_comment(self) -> str:
return ""
def set_comment(self, comment: str) -> bool:
return False
def read_file(self, archive_file: str) -> bytes:
data = b""
try:
with py7zr.SevenZipFile(self.path, "r") as zf:
data = zf.read(archive_file)[archive_file].read()
except (py7zr.Bad7zFile, OSError) as e:
logger.error("Error reading 7zip archive [%s]: %s :: %s", e, self.path, archive_file)
raise
return data
def remove_file(self, archive_file: str) -> bool:
return self.rebuild([archive_file])
def write_file(self, archive_file: str, data: bytes) -> bool:
# At the moment, no other option but to rebuild the whole
# archive w/o the indicated file. Very sucky, but maybe
# another solution can be found
files = self.get_filename_list()
if archive_file in files:
if not self.rebuild([archive_file]):
return False
try:
# now just add the archive file as a new one
with py7zr.SevenZipFile(self.path, "a") as zf:
zf.writestr(data, archive_file)
return True
except (py7zr.Bad7zFile, OSError) as e:
logger.error("Error writing 7zip archive [%s]: %s :: %s", e, self.path, archive_file)
return False
def get_filename_list(self) -> list[str]:
try:
with py7zr.SevenZipFile(self.path, "r") as zf:
namelist: list[str] = [file.filename for file in zf.list() if not file.is_directory]
return namelist
except (py7zr.Bad7zFile, OSError) as e:
logger.error("Error listing files in 7zip archive [%s]: %s", e, self.path)
return []
def rebuild(self, exclude_list: list[str]) -> bool:
"""Zip helper func
This recompresses the zip archive, without the files in the exclude_list
"""
try:
# py7zr treats all archives as if they used solid compression
# so we need to get the filename list first to read all the files at once
with py7zr.SevenZipFile(self.path, mode="r") as zin:
targets = [f for f in zin.getnames() if f not in exclude_list]
with tempfile.NamedTemporaryFile(dir=os.path.dirname(self.path), delete=False) as tmp_file:
with py7zr.SevenZipFile(tmp_file.file, mode="w") as zout:
with py7zr.SevenZipFile(self.path, mode="r") as zin:
for filename, buffer in zin.read(targets).items():
zout.writef(buffer, filename)
self.path.unlink(missing_ok=True)
tmp_file.close() # Required on windows
shutil.move(tmp_file.name, self.path)
except (py7zr.Bad7zFile, OSError) as e:
logger.error("Error rebuilding 7zip file [%s]: %s", e, self.path)
return False
return True
def copy_from_archive(self, other_archive: Archiver) -> bool:
"""Replace the current zip with one copied from another archive"""
try:
with py7zr.SevenZipFile(self.path, "w") as zout:
for filename in other_archive.get_filename_list():
data = other_archive.read_file(
filename
) # This will be very inefficient if other_archive is a 7z file
if data is not None:
zout.writestr(data, filename)
except Exception as e:
logger.error("Error while copying to 7zip archive [%s]: from %s to %s", e, other_archive.path, self.path)
return False
else:
return True
def is_writable(self) -> bool:
return True
def extension(self) -> str:
return ".cb7"
def name(self) -> str:
return "Seven Zip"
@classmethod
def is_valid(cls, path: pathlib.Path) -> bool:
return py7zr.is_7zfile(path)

190
comicapi/archivers/zip.py Normal file
View File

@ -0,0 +1,190 @@
from __future__ import annotations
import logging
import os
import pathlib
import shutil
import struct
import tempfile
import zipfile
from typing import cast
from comicapi.archivers import Archiver
logger = logging.getLogger(__name__)
class ZipArchiver(Archiver):
"""ZIP implementation"""
def __init__(self) -> None:
super().__init__()
def get_comment(self) -> str:
with zipfile.ZipFile(self.path, "r") as zf:
comment = zf.comment.decode("utf-8")
return comment
def set_comment(self, comment: str) -> bool:
with zipfile.ZipFile(self.path, mode="a") as zf:
zf.comment = bytes(comment, "utf-8")
return True
def read_file(self, archive_file: str) -> bytes:
with zipfile.ZipFile(self.path, mode="r") as zf:
try:
data = zf.read(archive_file)
except (zipfile.BadZipfile, OSError) as e:
logger.error("Error reading zip archive [%s]: %s :: %s", e, self.path, archive_file)
raise
return data
def remove_file(self, archive_file: str) -> bool:
return self.rebuild([archive_file])
def write_file(self, archive_file: str, data: bytes) -> bool:
# At the moment, no other option but to rebuild the whole
# zip archive w/o the indicated file. Very sucky, but maybe
# another solution can be found
files = self.get_filename_list()
if archive_file in files:
if not self.rebuild([archive_file]):
return False
try:
# now just add the archive file as a new one
with zipfile.ZipFile(self.path, mode="a", allowZip64=True, compression=zipfile.ZIP_DEFLATED) as zf:
zf.writestr(archive_file, data)
return True
except (zipfile.BadZipfile, OSError) as e:
logger.error("Error writing zip archive [%s]: %s :: %s", e, self.path, archive_file)
return False
def get_filename_list(self) -> list[str]:
try:
with zipfile.ZipFile(self.path, mode="r") as zf:
namelist = [file.filename for file in zf.infolist() if not file.is_dir()]
return namelist
except (zipfile.BadZipfile, OSError) as e:
logger.error("Error listing files in zip archive [%s]: %s", e, self.path)
return []
def rebuild(self, exclude_list: list[str]) -> bool:
"""Zip helper func
This recompresses the zip archive, without the files in the exclude_list
"""
try:
with zipfile.ZipFile(
tempfile.NamedTemporaryFile(dir=os.path.dirname(self.path), delete=False), "w", allowZip64=True
) as zout:
with zipfile.ZipFile(self.path, mode="r") as zin:
for item in zin.infolist():
buffer = zin.read(item.filename)
if item.filename not in exclude_list:
zout.writestr(item, buffer)
# preserve the old comment
zout.comment = zin.comment
# replace with the new file
self.path.unlink(missing_ok=True)
zout.close() # Required on windows
shutil.move(cast(str, zout.filename), self.path)
except (zipfile.BadZipfile, OSError) as e:
logger.error("Error rebuilding zip file [%s]: %s", e, self.path)
return False
return True
def copy_from_archive(self, other_archive: Archiver) -> bool:
"""Replace the current zip with one copied from another archive"""
try:
with zipfile.ZipFile(self.path, mode="w", allowZip64=True) as zout:
for filename in other_archive.get_filename_list():
data = other_archive.read_file(filename)
if data is not None:
zout.writestr(filename, data)
# preserve the old comment
comment = other_archive.get_comment()
if comment is not None:
if not self.write_zip_comment(self.path, comment):
return False
except Exception as e:
logger.error("Error while copying to zip archive [%s]: from %s to %s", e, other_archive.path, self.path)
return False
else:
return True
def is_writable(self) -> bool:
return True
def extension(self) -> str:
return ".cbz"
def name(self) -> str:
return "ZIP"
@classmethod
def is_valid(cls, path: pathlib.Path) -> bool:
return zipfile.is_zipfile(path)
def write_zip_comment(self, filename: pathlib.Path | str, comment: str) -> bool:
"""
This is a custom function for writing a comment to a zip file,
since the built-in one doesn't seem to work on Windows and Mac OS/X
Fortunately, the zip comment is at the end of the file, and it's
easy to manipulate. See this website for more info:
see: http://en.wikipedia.org/wiki/Zip_(file_format)#Structure
"""
# get file size
statinfo = os.stat(filename)
file_length = statinfo.st_size
try:
with open(filename, mode="r+b") as file:
# the starting position, relative to EOF
pos = -4
found = False
# walk backwards to find the "End of Central Directory" record
while (not found) and (-pos != file_length):
# seek, relative to EOF
file.seek(pos, 2)
value = file.read(4)
# look for the end of central directory signature
if bytearray(value) == bytearray([0x50, 0x4B, 0x05, 0x06]):
found = True
else:
# not found, step back another byte
pos = pos - 1
if found:
# now skip forward 20 bytes to the comment length word
pos += 20
file.seek(pos, 2)
# Pack the length of the comment string
fmt = "H" # one 2-byte integer
comment_length = struct.pack(fmt, len(comment)) # pack integer in a binary string
# write out the length
file.write(comment_length)
file.seek(pos + 2, 2)
# write out the comment itself
file.write(comment.encode("utf-8"))
file.truncate()
else:
raise Exception("Could not find the End of Central Directory record!")
except Exception as e:
logger.error("Error writing comment to zip archive [%s]: %s", e, self.path)
return False
else:
return True

212
comicapi/comet.py Normal file
View File

@ -0,0 +1,212 @@
"""A class to encapsulate CoMet data"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from typing import Any
from comicapi import utils
from comicapi.genericmetadata import GenericMetadata
logger = logging.getLogger(__name__)
class CoMet:
writer_synonyms = ["writer", "plotter", "scripter"]
penciller_synonyms = ["artist", "penciller", "penciler", "breakdowns"]
inker_synonyms = ["inker", "artist", "finishes"]
colorist_synonyms = ["colorist", "colourist", "colorer", "colourer"]
letterer_synonyms = ["letterer"]
cover_synonyms = ["cover", "covers", "coverartist", "cover artist"]
editor_synonyms = ["editor"]
def metadata_from_string(self, string: str) -> GenericMetadata:
tree = ET.ElementTree(ET.fromstring(string))
return self.convert_xml_to_metadata(tree)
def string_from_metadata(self, metadata: GenericMetadata) -> str:
tree = self.convert_metadata_to_xml(metadata)
return str(ET.tostring(tree.getroot(), encoding="utf-8", xml_declaration=True).decode("utf-8"))
def convert_metadata_to_xml(self, metadata: GenericMetadata) -> ET.ElementTree:
# shorthand for the metadata
md = metadata
# build a tree structure
root = ET.Element("comet")
root.attrib["xmlns:comet"] = "http://www.denvog.com/comet/"
root.attrib["xmlns:xsi"] = "http://www.w3.org/2001/XMLSchema-instance"
root.attrib["xsi:schemaLocation"] = "http://www.denvog.com http://www.denvog.com/comet/comet.xsd"
# helper func
def assign(comet_entry: str, md_entry: Any) -> None:
if md_entry is not None:
ET.SubElement(root, comet_entry).text = str(md_entry)
# title is manditory
if md.title is None:
md.title = ""
assign("title", md.title)
assign("series", md.series)
assign("issue", md.issue) # must be int??
assign("volume", md.volume)
assign("description", md.comments)
assign("publisher", md.publisher)
assign("pages", md.page_count)
assign("format", md.format)
assign("language", md.language)
assign("rating", md.maturity_rating)
assign("price", md.price)
assign("isVersionOf", md.is_version_of)
assign("rights", md.rights)
assign("identifier", md.identifier)
assign("lastMark", md.last_mark)
assign("genre", md.genre) # TODO repeatable
if md.characters is not None:
char_list = [c.strip() for c in md.characters.split(",")]
for c in char_list:
assign("character", c)
if md.manga is not None and md.manga == "YesAndRightToLeft":
assign("readingDirection", "rtl")
if md.year is not None:
date_str = f"{md.year:04}"
if md.month is not None:
date_str += f"-{md.month:02}"
assign("date", date_str)
assign("coverImage", md.cover_image)
# loop thru credits, and build a list for each role that CoMet supports
for credit in metadata.credits:
if credit["role"].casefold() in set(self.writer_synonyms):
ET.SubElement(root, "writer").text = str(credit["person"])
if credit["role"].casefold() in set(self.penciller_synonyms):
ET.SubElement(root, "penciller").text = str(credit["person"])
if credit["role"].casefold() in set(self.inker_synonyms):
ET.SubElement(root, "inker").text = str(credit["person"])
if credit["role"].casefold() in set(self.colorist_synonyms):
ET.SubElement(root, "colorist").text = str(credit["person"])
if credit["role"].casefold() in set(self.letterer_synonyms):
ET.SubElement(root, "letterer").text = str(credit["person"])
if credit["role"].casefold() in set(self.cover_synonyms):
ET.SubElement(root, "coverDesigner").text = str(credit["person"])
if credit["role"].casefold() in set(self.editor_synonyms):
ET.SubElement(root, "editor").text = str(credit["person"])
ET.indent(root)
# wrap it in an ElementTree instance, and save as XML
tree = ET.ElementTree(root)
return tree
def convert_xml_to_metadata(self, tree: ET.ElementTree) -> GenericMetadata:
root = tree.getroot()
if root.tag != "comet":
raise Exception("Not a CoMet file")
metadata = GenericMetadata()
md = metadata
# Helper function
def get(tag: str) -> Any:
node = root.find(tag)
if node is not None:
return node.text
return None
md.series = utils.xlate(get("series"))
md.title = utils.xlate(get("title"))
md.issue = utils.xlate(get("issue"))
md.volume = utils.xlate(get("volume"), True)
md.comments = utils.xlate(get("description"))
md.publisher = utils.xlate(get("publisher"))
md.language = utils.xlate(get("language"))
md.format = utils.xlate(get("format"))
md.page_count = utils.xlate(get("pages"), True)
md.maturity_rating = utils.xlate(get("rating"))
md.price = utils.xlate(get("price"), is_float=True)
md.is_version_of = utils.xlate(get("isVersionOf"))
md.rights = utils.xlate(get("rights"))
md.identifier = utils.xlate(get("identifier"))
md.last_mark = utils.xlate(get("lastMark"))
md.genre = utils.xlate(get("genre")) # TODO - repeatable field
_, md.month, md.year = utils.parse_date_str(utils.xlate(get("date")))
md.cover_image = utils.xlate(get("coverImage"))
reading_direction = utils.xlate(get("readingDirection"))
if reading_direction is not None and reading_direction == "rtl":
md.manga = "YesAndRightToLeft"
# loop for character tags
char_list = []
for n in root:
if n.tag == "character":
char_list.append((n.text or "").strip())
md.characters = ", ".join(char_list)
# Now extract the credit info
for n in root:
if any(
[
n.tag == "writer",
n.tag == "penciller",
n.tag == "inker",
n.tag == "colorist",
n.tag == "letterer",
n.tag == "editor",
]
):
metadata.add_credit((n.text or "").strip(), n.tag.title())
if n.tag == "coverDesigner":
metadata.add_credit((n.text or "").strip(), "Cover")
metadata.is_empty = False
return metadata
# verify that the string actually contains CoMet data in XML format
def validate_string(self, string: str) -> bool:
try:
tree = ET.ElementTree(ET.fromstring(string))
root = tree.getroot()
if root.tag != "comet":
return False
except ET.ParseError:
return False
return True
def write_to_external_file(self, filename: str, metadata: GenericMetadata) -> None:
tree = self.convert_metadata_to_xml(metadata)
tree.write(filename, encoding="utf-8")
def read_from_external_file(self, filename: str) -> GenericMetadata:
tree = ET.parse(filename)
return self.convert_xml_to_metadata(tree)

604
comicapi/comicarchive.py Normal file
View File

@ -0,0 +1,604 @@
"""A class to represent a single comic, be it file or folder of images"""
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
import io
import logging
import os
import pathlib
import shutil
import sys
from typing import cast
import natsort
import wordninja
from comicapi import filenamelexer, filenameparser, utils
from comicapi.archivers import Archiver, UnknownArchiver, ZipArchiver
from comicapi.comet import CoMet
from comicapi.comicbookinfo import ComicBookInfo
from comicapi.comicinfoxml import ComicInfoXml
from comicapi.genericmetadata import GenericMetadata, PageType
if sys.version_info < (3, 10):
from importlib_metadata import entry_points
else:
from importlib.metadata import entry_points
try:
from PIL import Image
pil_available = True
except ImportError:
pil_available = False
logger = logging.getLogger(__name__)
if not pil_available:
logger.error("PIL unavalable")
archivers: list[type[Archiver]] = []
def load_archive_plugins() -> None:
if not archivers:
builtin: list[type[Archiver]] = []
for arch in entry_points(group="comicapi.archiver"):
try:
archiver: type[Archiver] = arch.load()
if archiver.enabled:
if arch.module.startswith("comicapi"):
builtin.append(archiver)
else:
archivers.append(archiver)
except Exception:
logger.warning("Failed to load talker: %s", arch.name)
archivers.extend(builtin)
class MetaDataStyle:
CBI = 0
CIX = 1
COMET = 2
name = ["ComicBookLover", "ComicRack", "CoMet"]
short_name = ["cbl", "cr", "comet"]
class ComicArchive:
logo_data = b""
def __init__(self, path: pathlib.Path | str, default_image_path: pathlib.Path | str | None = None) -> None:
self.cbi_md: GenericMetadata | None = None
self.cix_md: GenericMetadata | None = None
self.comet_filename: str | None = None
self.comet_md: GenericMetadata | None = None
self._has_cbi: bool | None = None
self._has_cix: bool | None = None
self._has_comet: bool | None = None
self.path = pathlib.Path(path).absolute()
self.page_count: int | None = None
self.page_list: list[str] = []
self.ci_xml_filename = "ComicInfo.xml"
self.comet_default_filename = "CoMet.xml"
self.reset_cache()
self.default_image_path = default_image_path
self.archiver: Archiver = UnknownArchiver.open(self.path)
load_archive_plugins()
for archiver in archivers:
if archiver.is_valid(self.path):
self.archiver = archiver.open(self.path)
break
if not ComicArchive.logo_data and self.default_image_path:
with open(self.default_image_path, mode="rb") as fd:
ComicArchive.logo_data = fd.read()
def reset_cache(self) -> None:
"""Clears the cached data"""
self._has_cix = None
self._has_cbi = None
self._has_comet = None
self.comet_filename = None
self.page_count = None
self.page_list = []
self.cix_md = None
self.cbi_md = None
self.comet_md = None
def load_cache(self, style_list: list[int]) -> None:
for style in style_list:
self.read_metadata(style)
def rename(self, path: pathlib.Path | str) -> None:
new_path = pathlib.Path(path).absolute()
if new_path == self.path:
return
os.makedirs(new_path.parent, 0o777, True)
shutil.move(self.path, new_path)
self.path = new_path
self.archiver.path = pathlib.Path(path)
def is_writable(self, check_archive_status: bool = True) -> bool:
if isinstance(self.archiver, UnknownArchiver):
return False
if check_archive_status and not self.archiver.is_writable():
return False
if not (os.access(self.path, os.W_OK) or os.access(self.path.parent, os.W_OK)):
return False
return True
def is_writable_for_style(self, data_style: int) -> bool:
return not (data_style == MetaDataStyle.CBI and not self.archiver.supports_comment)
def is_zip(self) -> bool:
return self.archiver.name() == "ZIP"
def seems_to_be_a_comic_archive(self) -> bool:
if not (isinstance(self.archiver, UnknownArchiver)) and self.get_number_of_pages() > 0:
return True
return False
def extension(self) -> str:
return self.archiver.extension()
def read_metadata(self, style: int) -> GenericMetadata:
if style == MetaDataStyle.CIX:
return self.read_cix()
if style == MetaDataStyle.CBI:
return self.read_cbi()
if style == MetaDataStyle.COMET:
return self.read_comet()
return GenericMetadata()
def write_metadata(self, metadata: GenericMetadata, style: int) -> bool:
retcode = False
if style == MetaDataStyle.CIX:
retcode = self.write_cix(metadata)
if style == MetaDataStyle.CBI:
retcode = self.write_cbi(metadata)
if style == MetaDataStyle.COMET:
retcode = self.write_comet(metadata)
return retcode
def has_metadata(self, style: int) -> bool:
if style == MetaDataStyle.CIX:
return self.has_cix()
if style == MetaDataStyle.CBI:
return self.has_cbi()
if style == MetaDataStyle.COMET:
return self.has_comet()
return False
def remove_metadata(self, style: int) -> bool:
retcode = True
if style == MetaDataStyle.CIX:
retcode = self.remove_cix()
elif style == MetaDataStyle.CBI:
retcode = self.remove_cbi()
elif style == MetaDataStyle.COMET:
retcode = self.remove_co_met()
return retcode
def get_page(self, index: int) -> bytes:
image_data = b""
filename = self.get_page_name(index)
if filename:
try:
image_data = self.archiver.read_file(filename) or b""
except Exception:
logger.error("Error reading in page %d. Substituting logo page.", index)
image_data = ComicArchive.logo_data
return image_data
def get_page_name(self, index: int) -> str:
if index is None:
return ""
page_list = self.get_page_name_list()
num_pages = len(page_list)
if num_pages == 0 or index >= num_pages:
return ""
return page_list[index]
def get_scanner_page_index(self) -> int | None:
scanner_page_index = None
# make a guess at the scanner page
name_list = self.get_page_name_list()
count = self.get_number_of_pages()
# too few pages to really know
if count < 5:
return None
# count the length of every filename, and count occurrences
length_buckets: dict[int, int] = {}
for name in name_list:
fname = os.path.split(name)[1]
length = len(fname)
if length in length_buckets:
length_buckets[length] += 1
else:
length_buckets[length] = 1
# sort by most common
sorted_buckets = sorted(length_buckets.items(), key=lambda tup: (tup[1], tup[0]), reverse=True)
# statistical mode occurrence is first
mode_length = sorted_buckets[0][0]
# we are only going to consider the final image file:
final_name = os.path.split(name_list[count - 1])[1]
common_length_list = []
for name in name_list:
if len(os.path.split(name)[1]) == mode_length:
common_length_list.append(os.path.split(name)[1])
prefix = os.path.commonprefix(common_length_list)
if mode_length <= 7 and prefix == "":
# probably all numbers
if len(final_name) > mode_length:
scanner_page_index = count - 1
# see if the last page doesn't start with the same prefix as most others
elif not final_name.startswith(prefix):
scanner_page_index = count - 1
return scanner_page_index
def get_page_name_list(self, sort_list: bool = True) -> list[str]:
if not self.page_list:
# get the list file names in the archive, and sort
files: list[str] = self.archiver.get_filename_list()
# seems like some archive creators are on Windows, and don't know about case-sensitivity!
if sort_list:
files = cast(list[str], natsort.os_sorted(files))
# make a sub-list of image files
self.page_list = []
for name in files:
if (
os.path.splitext(name)[1].casefold() in [".jpg", ".jpeg", ".png", ".gif", ".webp"]
and os.path.basename(name)[0] != "."
):
self.page_list.append(name)
return self.page_list
def get_number_of_pages(self) -> int:
if self.page_count is None:
self.page_count = len(self.get_page_name_list())
return self.page_count
def read_cbi(self) -> GenericMetadata:
if self.cbi_md is None:
raw_cbi = self.read_raw_cbi()
if raw_cbi:
self.cbi_md = ComicBookInfo().metadata_from_string(raw_cbi)
else:
self.cbi_md = GenericMetadata()
self.cbi_md.set_default_page_list(self.get_number_of_pages())
return self.cbi_md
def read_raw_cbi(self) -> str:
if not self.has_cbi():
return ""
return self.archiver.get_comment()
def has_cbi(self) -> bool:
if self._has_cbi is None:
if not self.seems_to_be_a_comic_archive():
self._has_cbi = False
else:
comment = self.archiver.get_comment()
self._has_cbi = ComicBookInfo().validate_string(comment)
return self._has_cbi
def write_cbi(self, metadata: GenericMetadata) -> bool:
if metadata is not None:
try:
self.apply_archive_info_to_metadata(metadata)
cbi_string = ComicBookInfo().string_from_metadata(metadata)
write_success = self.archiver.set_comment(cbi_string)
if write_success:
self._has_cbi = True
self.cbi_md = metadata
self.reset_cache()
return write_success
except Exception as e:
logger.error("Error saving CBI! for %s: %s", self.path, e)
return False
def remove_cbi(self) -> bool:
if self.has_cbi():
write_success = self.archiver.set_comment("")
if write_success:
self._has_cbi = False
self.cbi_md = None
self.reset_cache()
return write_success
return True
def read_cix(self) -> GenericMetadata:
if self.cix_md is None:
raw_cix = self.read_raw_cix()
if raw_cix:
self.cix_md = ComicInfoXml().metadata_from_string(raw_cix)
else:
self.cix_md = GenericMetadata()
# validate the existing page list (make sure count is correct)
if len(self.cix_md.pages) != 0:
if len(self.cix_md.pages) != self.get_number_of_pages():
# pages array doesn't match the actual number of images we're seeing
# in the archive, so discard the data
self.cix_md.pages = []
if len(self.cix_md.pages) == 0:
self.cix_md.set_default_page_list(self.get_number_of_pages())
return self.cix_md
def read_raw_cix(self) -> bytes:
if not self.has_cix():
return b""
try:
raw_cix = self.archiver.read_file(self.ci_xml_filename) or b""
except Exception as e:
logger.error("Error reading in raw CIX! for %s: %s", self.path, e)
raw_cix = b""
return raw_cix
def write_cix(self, metadata: GenericMetadata) -> bool:
if metadata is not None:
try:
self.apply_archive_info_to_metadata(metadata, calc_page_sizes=True)
raw_cix = self.read_raw_cix()
cix_string = ComicInfoXml().string_from_metadata(metadata, xml=raw_cix)
write_success = self.archiver.write_file(self.ci_xml_filename, cix_string.encode("utf-8"))
if write_success:
self._has_cix = True
self.cix_md = metadata
self.reset_cache()
return write_success
except Exception as e:
logger.error("Error saving CIX! for %s: %s", self.path, e)
return False
def remove_cix(self) -> bool:
if self.has_cix():
write_success = self.archiver.remove_file(self.ci_xml_filename)
if write_success:
self._has_cix = False
self.cix_md = None
self.reset_cache()
return write_success
return True
def has_cix(self) -> bool:
if self._has_cix is None:
if not self.seems_to_be_a_comic_archive():
self._has_cix = False
elif self.ci_xml_filename in self.archiver.get_filename_list():
self._has_cix = True
else:
self._has_cix = False
return self._has_cix
def read_comet(self) -> GenericMetadata:
if self.comet_md is None:
raw_comet = self.read_raw_comet()
if raw_comet is None or raw_comet == "":
self.comet_md = GenericMetadata()
else:
self.comet_md = CoMet().metadata_from_string(raw_comet)
self.comet_md.set_default_page_list(self.get_number_of_pages())
# use the coverImage value from the comet_data to mark the cover in this struct
# walk through list of images in file, and find the matching one for md.coverImage
# need to remove the existing one in the default
if self.comet_md.cover_image is not None:
cover_idx = 0
for idx, f in enumerate(self.get_page_name_list()):
if self.comet_md.cover_image == f:
cover_idx = idx
break
if cover_idx != 0:
del self.comet_md.pages[0]["Type"]
self.comet_md.pages[cover_idx]["Type"] = PageType.FrontCover
return self.comet_md
def read_raw_comet(self) -> str:
raw_comet = ""
if not self.has_comet():
raw_comet = ""
else:
try:
raw_bytes = self.archiver.read_file(cast(str, self.comet_filename))
if raw_bytes:
raw_comet = raw_bytes.decode("utf-8")
except OSError as e:
logger.exception("Error reading in raw CoMet!: %s", e)
return raw_comet
def write_comet(self, metadata: GenericMetadata) -> bool:
if metadata is not None:
if not self.has_comet():
self.comet_filename = self.comet_default_filename
self.apply_archive_info_to_metadata(metadata)
# Set the coverImage value, if it's not the first page
cover_idx = int(metadata.get_cover_page_index_list()[0])
if cover_idx != 0:
metadata.cover_image = self.get_page_name(cover_idx)
comet_string = CoMet().string_from_metadata(metadata)
write_success = self.archiver.write_file(cast(str, self.comet_filename), comet_string.encode("utf-8"))
if write_success:
self._has_comet = True
self.comet_md = metadata
self.reset_cache()
return write_success
return False
def remove_co_met(self) -> bool:
if self.has_comet():
write_success = self.archiver.remove_file(cast(str, self.comet_filename))
if write_success:
self._has_comet = False
self.comet_md = None
self.reset_cache()
return write_success
return True
def has_comet(self) -> bool:
if self._has_comet is None:
self._has_comet = False
if not self.seems_to_be_a_comic_archive():
return self._has_comet
# look at all xml files in root, and search for CoMet data, get first
for n in self.archiver.get_filename_list():
if os.path.dirname(n) == "" and os.path.splitext(n)[1].casefold() == ".xml":
# read in XML file, and validate it
data = ""
try:
d = self.archiver.read_file(n)
if d:
data = d.decode("utf-8")
except Exception as e:
logger.warning("Error reading in Comet XML for validation! from %s: %s", self.path, e)
if CoMet().validate_string(data):
# since we found it, save it!
self.comet_filename = n
self._has_comet = True
break
return self._has_comet
def apply_archive_info_to_metadata(self, md: GenericMetadata, calc_page_sizes: bool = False) -> None:
md.page_count = self.get_number_of_pages()
if calc_page_sizes:
for index, p in enumerate(md.pages):
idx = int(p["Image"])
if pil_available:
if "ImageSize" not in p or "ImageHeight" not in p or "ImageWidth" not in p:
data = self.get_page(idx)
if data:
try:
if isinstance(data, bytes):
im = Image.open(io.BytesIO(data))
else:
im = Image.open(io.StringIO(data))
w, h = im.size
p["ImageSize"] = str(len(data))
p["ImageHeight"] = str(h)
p["ImageWidth"] = str(w)
except Exception as e:
logger.warning("Error decoding image [%s] %s :: image %s", e, self.path, index)
p["ImageSize"] = str(len(data))
else:
if "ImageSize" not in p:
data = self.get_page(idx)
p["ImageSize"] = str(len(data))
def metadata_from_filename(
self,
complicated_parser: bool = False,
remove_c2c: bool = False,
remove_fcbd: bool = False,
remove_publisher: bool = False,
split_words: bool = False,
) -> GenericMetadata:
metadata = GenericMetadata()
filename = self.path.name
if split_words:
filename = " ".join(wordninja.split(self.path.stem)) + self.path.suffix
if complicated_parser:
lex = filenamelexer.Lex(filename)
p = filenameparser.Parse(
lex.items, remove_c2c=remove_c2c, remove_fcbd=remove_fcbd, remove_publisher=remove_publisher
)
metadata.alternate_number = utils.xlate(p.filename_info["alternate"])
metadata.issue = utils.xlate(p.filename_info["issue"])
metadata.issue_count = utils.xlate(p.filename_info["issue_count"])
metadata.publisher = utils.xlate(p.filename_info["publisher"])
metadata.series = utils.xlate(p.filename_info["series"])
metadata.title = utils.xlate(p.filename_info["title"])
metadata.volume = utils.xlate(p.filename_info["volume"])
metadata.volume_count = utils.xlate(p.filename_info["volume_count"])
metadata.year = utils.xlate(p.filename_info["year"])
metadata.scan_info = utils.xlate(p.filename_info["remainder"])
metadata.format = "FCBD" if p.filename_info["fcbd"] else None
if p.filename_info["annual"]:
metadata.format = "Annual"
else:
fnp = filenameparser.FileNameParser()
fnp.parse_filename(filename)
if fnp.issue:
metadata.issue = fnp.issue
if fnp.series:
metadata.series = fnp.series
if fnp.volume:
metadata.volume = utils.xlate(fnp.volume, True)
if fnp.year:
metadata.year = utils.xlate(fnp.year, True)
if fnp.issue_count:
metadata.issue_count = utils.xlate(fnp.issue_count, True)
if fnp.remainder:
metadata.scan_info = fnp.remainder
metadata.is_empty = False
return metadata
def export_as_zip(self, zip_filename: pathlib.Path) -> bool:
if self.archiver.name() == "ZIP":
# nothing to do, we're already a zip
return True
zip_archiver = ZipArchiver.open(zip_filename)
return zip_archiver.copy_from_archive(self.archiver)

174
comicapi/comicbookinfo.py Normal file
View File

@ -0,0 +1,174 @@
"""A class to encapsulate the ComicBookInfo data"""
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
import json
import logging
from collections import defaultdict
from datetime import datetime
from typing import Any, Literal, TypedDict
from comicapi import utils
from comicapi.genericmetadata import GenericMetadata
logger = logging.getLogger(__name__)
CBILiteralType = Literal[
"series",
"title",
"issue",
"publisher",
"publicationMonth",
"publicationYear",
"numberOfIssues",
"comments",
"genre",
"volume",
"numberOfVolumes",
"language",
"country",
"rating",
"credits",
"tags",
]
class Credits(TypedDict):
person: str
role: str
primary: bool
class ComicBookInfoJson(TypedDict, total=False):
series: str
title: str
publisher: str
publicationMonth: int
publicationYear: int
issue: int
numberOfIssues: int
volume: int
numberOfVolumes: int
rating: int
genre: str
language: str
country: str
credits: list[Credits]
tags: list[str]
comments: str
CBIContainer = TypedDict("CBIContainer", {"appID": str, "lastModified": str, "ComicBookInfo/1.0": ComicBookInfoJson})
class ComicBookInfo:
def metadata_from_string(self, string: str) -> GenericMetadata:
cbi_container = json.loads(string)
metadata = GenericMetadata()
cbi = defaultdict(lambda: None, cbi_container["ComicBookInfo/1.0"])
metadata.series = utils.xlate(cbi["series"])
metadata.title = utils.xlate(cbi["title"])
metadata.issue = utils.xlate(cbi["issue"])
metadata.publisher = utils.xlate(cbi["publisher"])
metadata.month = utils.xlate(cbi["publicationMonth"], True)
metadata.year = utils.xlate(cbi["publicationYear"], True)
metadata.issue_count = utils.xlate(cbi["numberOfIssues"], True)
metadata.comments = utils.xlate(cbi["comments"])
metadata.genre = utils.xlate(cbi["genre"])
metadata.volume = utils.xlate(cbi["volume"], True)
metadata.volume_count = utils.xlate(cbi["numberOfVolumes"], True)
metadata.language = utils.xlate(cbi["language"])
metadata.country = utils.xlate(cbi["country"])
metadata.critical_rating = utils.xlate(cbi["rating"], True)
metadata.credits = [
Credits(
person=x["person"] if "person" in x else "",
role=x["role"] if "role" in x else "",
primary=x["primary"] if "primary" in x else False,
)
for x in cbi["credits"]
]
metadata.tags = set(cbi["tags"]) if cbi["tags"] is not None else set()
# make sure credits and tags are at least empty lists and not None
if metadata.credits is None:
metadata.credits = []
# need the language string to be ISO
if metadata.language:
metadata.language = utils.get_language_iso(metadata.language)
metadata.is_empty = False
return metadata
def string_from_metadata(self, metadata: GenericMetadata) -> str:
cbi_container = self.create_json_dictionary(metadata)
return json.dumps(cbi_container)
def validate_string(self, string: bytes | str) -> bool:
"""Verify that the string actually contains CBI data in JSON format"""
try:
cbi_container = json.loads(string)
except json.JSONDecodeError:
return False
return "ComicBookInfo/1.0" in cbi_container
def create_json_dictionary(self, metadata: GenericMetadata) -> CBIContainer:
"""Create the dictionary that we will convert to JSON text"""
cbi_container = CBIContainer(
{
"appID": "ComicTagger/1.0.0",
"lastModified": str(datetime.now()),
"ComicBookInfo/1.0": {},
}
) # TODO: ctversion.version,
# helper func
def assign(cbi_entry: CBILiteralType, md_entry: Any) -> None:
if md_entry is not None or isinstance(md_entry, str) and md_entry != "":
cbi_container["ComicBookInfo/1.0"][cbi_entry] = md_entry
assign("series", utils.xlate(metadata.series))
assign("title", utils.xlate(metadata.title))
assign("issue", utils.xlate(metadata.issue))
assign("publisher", utils.xlate(metadata.publisher))
assign("publicationMonth", utils.xlate(metadata.month, True))
assign("publicationYear", utils.xlate(metadata.year, True))
assign("numberOfIssues", utils.xlate(metadata.issue_count, True))
assign("comments", utils.xlate(metadata.comments))
assign("genre", utils.xlate(metadata.genre))
assign("volume", utils.xlate(metadata.volume, True))
assign("numberOfVolumes", utils.xlate(metadata.volume_count, True))
assign("language", utils.xlate(utils.get_language_from_iso(metadata.language)))
assign("country", utils.xlate(metadata.country))
assign("rating", utils.xlate(metadata.critical_rating, True))
assign("credits", metadata.credits)
assign("tags", list(metadata.tags))
return cbi_container
def write_to_external_file(self, filename: str, metadata: GenericMetadata) -> None:
cbi_container = self.create_json_dictionary(metadata)
with open(filename, "w", encoding="utf-8") as f:
f.write(json.dumps(cbi_container, indent=4))

265
comicapi/comicinfoxml.py Normal file
View File

@ -0,0 +1,265 @@
"""A class to encapsulate ComicRack's ComicInfo.xml data"""
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
import logging
import xml.etree.ElementTree as ET
from collections import OrderedDict
from typing import Any, cast
from xml.etree.ElementTree import ElementTree
from comicapi import utils
from comicapi.genericmetadata import GenericMetadata, ImageMetadata
from comicapi.issuestring import IssueString
logger = logging.getLogger(__name__)
class ComicInfoXml:
writer_synonyms = ["writer", "plotter", "scripter"]
penciller_synonyms = ["artist", "penciller", "penciler", "breakdowns"]
inker_synonyms = ["inker", "artist", "finishes"]
colorist_synonyms = ["colorist", "colourist", "colorer", "colourer"]
letterer_synonyms = ["letterer"]
cover_synonyms = ["cover", "covers", "coverartist", "cover artist"]
editor_synonyms = ["editor"]
def get_parseable_credits(self) -> list[str]:
parsable_credits = []
parsable_credits.extend(self.writer_synonyms)
parsable_credits.extend(self.penciller_synonyms)
parsable_credits.extend(self.inker_synonyms)
parsable_credits.extend(self.colorist_synonyms)
parsable_credits.extend(self.letterer_synonyms)
parsable_credits.extend(self.cover_synonyms)
parsable_credits.extend(self.editor_synonyms)
return parsable_credits
def metadata_from_string(self, string: bytes) -> GenericMetadata:
tree = ET.ElementTree(ET.fromstring(string))
return self.convert_xml_to_metadata(tree)
def string_from_metadata(self, metadata: GenericMetadata, xml: bytes = b"") -> str:
tree = self.convert_metadata_to_xml(metadata, xml)
tree_str = ET.tostring(tree.getroot(), encoding="utf-8", xml_declaration=True).decode("utf-8")
return str(tree_str)
def convert_metadata_to_xml(self, metadata: GenericMetadata, xml: bytes = b"") -> ElementTree:
# shorthand for the metadata
md = metadata
if xml:
root = ET.ElementTree(ET.fromstring(xml)).getroot()
else:
# build a tree structure
root = ET.Element("ComicInfo")
root.attrib["xmlns:xsi"] = "http://www.w3.org/2001/XMLSchema-instance"
root.attrib["xmlns:xsd"] = "http://www.w3.org/2001/XMLSchema"
# helper func
def assign(cix_entry: str, md_entry: Any) -> None:
if md_entry is not None and md_entry:
et_entry = root.find(cix_entry)
if et_entry is not None:
et_entry.text = str(md_entry)
else:
ET.SubElement(root, cix_entry).text = str(md_entry)
else:
et_entry = root.find(cix_entry)
if et_entry is not None:
root.remove(et_entry)
assign("Title", md.title)
assign("Series", md.series)
assign("Number", md.issue)
assign("Count", md.issue_count)
assign("Volume", md.volume)
assign("AlternateSeries", md.alternate_series)
assign("AlternateNumber", md.alternate_number)
assign("StoryArc", md.story_arc)
assign("SeriesGroup", md.series_group)
assign("AlternateCount", md.alternate_count)
assign("Summary", md.comments)
assign("Notes", md.notes)
assign("Year", md.year)
assign("Month", md.month)
assign("Day", md.day)
# need to specially process the credits, since they are structured
# differently than CIX
credit_writer_list = []
credit_penciller_list = []
credit_inker_list = []
credit_colorist_list = []
credit_letterer_list = []
credit_cover_list = []
credit_editor_list = []
# first, loop thru credits, and build a list for each role that CIX
# supports
for credit in metadata.credits:
if credit["role"].casefold() in set(self.writer_synonyms):
credit_writer_list.append(credit["person"].replace(",", ""))
if credit["role"].casefold() in set(self.penciller_synonyms):
credit_penciller_list.append(credit["person"].replace(",", ""))
if credit["role"].casefold() in set(self.inker_synonyms):
credit_inker_list.append(credit["person"].replace(",", ""))
if credit["role"].casefold() in set(self.colorist_synonyms):
credit_colorist_list.append(credit["person"].replace(",", ""))
if credit["role"].casefold() in set(self.letterer_synonyms):
credit_letterer_list.append(credit["person"].replace(",", ""))
if credit["role"].casefold() in set(self.cover_synonyms):
credit_cover_list.append(credit["person"].replace(",", ""))
if credit["role"].casefold() in set(self.editor_synonyms):
credit_editor_list.append(credit["person"].replace(",", ""))
# second, convert each list to string, and add to XML struct
assign("Writer", ", ".join(credit_writer_list))
assign("Penciller", ", ".join(credit_penciller_list))
assign("Inker", ", ".join(credit_inker_list))
assign("Colorist", ", ".join(credit_colorist_list))
assign("Letterer", ", ".join(credit_letterer_list))
assign("CoverArtist", ", ".join(credit_cover_list))
assign("Editor", ", ".join(credit_editor_list))
assign("Publisher", md.publisher)
assign("Imprint", md.imprint)
assign("Genre", md.genre)
assign("Web", md.web_link)
assign("PageCount", md.page_count)
assign("LanguageISO", md.language)
assign("Format", md.format)
assign("AgeRating", md.maturity_rating)
assign("CommunityRating", md.critical_rating)
assign("BlackAndWhite", "Yes" if md.black_and_white else None)
assign("Manga", md.manga)
assign("Characters", md.characters)
assign("Teams", md.teams)
assign("Locations", md.locations)
assign("ScanInformation", md.scan_info)
# loop and add the page entries under pages node
pages_node = root.find("Pages")
if pages_node is not None:
pages_node.clear()
else:
pages_node = ET.SubElement(root, "Pages")
for page_dict in md.pages:
page_node = ET.SubElement(pages_node, "Page")
page_node.attrib = OrderedDict(sorted((k, str(v)) for k, v in page_dict.items()))
ET.indent(root)
# wrap it in an ElementTree instance, and save as XML
tree = ET.ElementTree(root)
return tree
def convert_xml_to_metadata(self, tree: ElementTree) -> GenericMetadata:
root = tree.getroot()
if root.tag != "ComicInfo":
raise Exception("Not a ComicInfo file")
def get(name: str) -> str | None:
tag = root.find(name)
if tag is None:
return None
return tag.text
md = GenericMetadata()
md.series = utils.xlate(get("Series"))
md.title = utils.xlate(get("Title"))
md.issue = IssueString(utils.xlate(get("Number"))).as_string()
md.issue_count = utils.xlate(get("Count"), True)
md.volume = utils.xlate(get("Volume"), True)
md.alternate_series = utils.xlate(get("AlternateSeries"))
md.alternate_number = IssueString(utils.xlate(get("AlternateNumber"))).as_string()
md.alternate_count = utils.xlate(get("AlternateCount"), True)
md.comments = utils.xlate(get("Summary"))
md.notes = utils.xlate(get("Notes"))
md.year = utils.xlate(get("Year"), True)
md.month = utils.xlate(get("Month"), True)
md.day = utils.xlate(get("Day"), True)
md.publisher = utils.xlate(get("Publisher"))
md.imprint = utils.xlate(get("Imprint"))
md.genre = utils.xlate(get("Genre"))
md.web_link = utils.xlate(get("Web"))
md.language = utils.xlate(get("LanguageISO"))
md.format = utils.xlate(get("Format"))
md.manga = utils.xlate(get("Manga"))
md.characters = utils.xlate(get("Characters"))
md.teams = utils.xlate(get("Teams"))
md.locations = utils.xlate(get("Locations"))
md.page_count = utils.xlate(get("PageCount"), True)
md.scan_info = utils.xlate(get("ScanInformation"))
md.story_arc = utils.xlate(get("StoryArc"))
md.series_group = utils.xlate(get("SeriesGroup"))
md.maturity_rating = utils.xlate(get("AgeRating"))
md.critical_rating = utils.xlate(get("CommunityRating"), is_float=True)
tmp = utils.xlate(get("BlackAndWhite"))
if tmp is not None and tmp.casefold() in ["yes", "true", "1"]:
md.black_and_white = True
# Now extract the credit info
for n in root:
if any(
[
n.tag == "Writer",
n.tag == "Penciller",
n.tag == "Inker",
n.tag == "Colorist",
n.tag == "Letterer",
n.tag == "Editor",
]
):
if n.text is not None:
for name in n.text.split(","):
md.add_credit(name.strip(), n.tag)
if n.tag == "CoverArtist":
if n.text is not None:
for name in n.text.split(","):
md.add_credit(name.strip(), "Cover")
# parse page data now
pages_node = root.find("Pages")
if pages_node is not None:
for page in pages_node:
p: dict[str, Any] = page.attrib
if "Image" in p:
p["Image"] = int(p["Image"])
if "DoublePage" in p:
p["DoublePage"] = True if p["DoublePage"].casefold() in ("yes", "true", "1") else False
md.pages.append(cast(ImageMetadata, p))
md.is_empty = False
return md
def write_to_external_file(self, filename: str, metadata: GenericMetadata, xml: bytes = b"") -> None:
tree = self.convert_metadata_to_xml(metadata, xml)
tree.write(filename, encoding="utf-8", xml_declaration=True)
def read_from_external_file(self, filename: str) -> GenericMetadata:
tree = ET.parse(filename)
return self.convert_xml_to_metadata(tree)

View File

@ -0,0 +1,5 @@
from __future__ import annotations
import pathlib
data_path = pathlib.Path(__file__).parent

View File

@ -0,0 +1,130 @@
{
"Marvel":{
"marvel comics": "",
"aircel comics": "Aircel Comics",
"aircel": "Aircel Comics",
"atlas comics": "Atlas Comics",
"atlas": "Atlas Comics",
"crossgen comics": "CrossGen comics",
"crossgen": "CrossGen comics",
"curtis magazines": "Curtis Magazines",
"disney books group": "Disney Books Group",
"disney books": "Disney Books Group",
"disney kingdoms": "Disney Kingdoms",
"epic comics group": "Epic Comics",
"epic comics": "Epic Comics",
"epic": "Epic Comics",
"eternity comics": "Eternity Comics",
"humorama": "Humorama",
"icon comics": "Icon Comics",
"infinite comics": "Infinite Comics",
"malibu comics": "Malibu Comics",
"malibu": "Malibu Comics",
"marvel 2099": "Marvel 2099",
"marvel absurd": "Marvel Absurd",
"marvel adventures": "Marvel Adventures",
"marvel age": "Marvel Age",
"marvel books": "Marvel Books",
"marvel comics 2": "Marvel Comics 2",
"marvel digital comics unlimited": "Marvel Unlimited",
"marvel edge": "Marvel Edge",
"marvel frontier": "Marvel Frontier",
"marvel illustrated": "Marvel Illustrated",
"marvel knights": "Marvel Knights",
"marvel magazine group": "Marvel Magazine Group",
"marvel mangaverse": "Marvel Mangaverse",
"marvel monsters group": "Marvel Monsters Group",
"marvel music": "Marvel Music",
"marvel next": "Marvel Next",
"marvel noir": "Marvel Noir",
"marvel press": "Marvel Press",
"marvel uk": "Marvel UK",
"marvel unlimited": "Marvel Unlimited",
"max": "MAX",
"mc2": "Marvel Comics 2",
"new universe": "New Universe",
"non-pareil publishing corp.": "Non-Pareil Publishing Corp.",
"paramount comics": "Paramount Comics",
"power comics": "Power Comics",
"razorline": "Razorline",
"star comics": "Star Comics",
"timely comics": "Timely Comics",
"timely": "Timely Comics",
"tsunami": "Tsunami",
"ultimate comics": "Ultimate Comics",
"ultimate marvel": "Ultimate Marvel",
"vital publications, inc.": "Vital Publications, Inc."
},
"DC Comics":{
"dc_comics": "",
"dc": "",
"dccomics": "",
"!mpact comics": "Impact Comics",
"all star dc": "All-Star",
"all star": "All-Star",
"all-star dc": "All-Star",
"all-star": "All-Star",
"america's best comics": "America's Best Comics",
"black label": "DC Black Label",
"cliffhanger": "Cliffhanger",
"cmx manga": "CMX Manga",
"dc black label": "DC Black Label",
"dc focus": "DC Focus",
"dc ink": "DC Ink",
"dc zoom": "DC Zoom",
"earth m": "Earth M",
"earth one": "Earth One",
"earth-m": "Earth M",
"elseworlds": "Elseworlds",
"eo": "Earth One",
"first wave": "First Wave",
"focus": "DC Focus",
"helix": "Helix",
"homage comics": "Homage Comics",
"impact comics": "Impact Comics",
"impact! comics": "Impact Comics",
"johnny dc": "Johnny DC",
"mad": "Mad",
"minx": "Minx",
"paradox press": "Paradox Press",
"piranha press": "Piranha Press",
"sandman universe": "Sandman Universe",
"tangent comics": "Tangent Comics",
"tsr": "TSR",
"vertigo": "Vertigo",
"wildstorm productions": "WildStorm Productions",
"wildstorm signature": "WildStorm Productions",
"wildstorm": "WildStorm Productions",
"wonder comics": "Wonder Comics",
"young animal": "Young Animal",
"zuda comics": "Zuda Comics",
"zuda": "Zuda Comics"
},
"Dark Horse Comics":{
"berger books": "Berger Books",
"comics' greatest world": "Dark Horse Heroes",
"dark horse digital": "Dark Horse Digital",
"dark horse heroes": "Dark Horse Heroes",
"dark horse manga": "Dark Horse Manga",
"dh deluxe": "DH Deluxe",
"dh press": "DH Press",
"kitchen sink books": "Kitchen Sink Books",
"legend": "Legend",
"m press": "M Press",
"maverick": "Maverick"
},
"Archie Comics":{
"archie action": "Archie Action",
"archie adventure Series": "Archie Adventure Series",
"archie horror": "Archie Horror",
"dark circle Comics": "Dark Circle Comics",
"dark circle": "Dark Circle Comics",
"mighty comics Group": "Mighty Comics Group",
"radio comics": "Mighty Comics Group",
"red circle Comics": "Dark Circle Comics",
"red circle": "Dark Circle Comics"
}
}

352
comicapi/filenamelexer.py Normal file
View File

@ -0,0 +1,352 @@
# Extracted and mutilated from https://github.com/lordwelch/wsfmt
# Which was extracted and mutilated from https://github.com/golang/go/tree/master/src/text/template/parse
from __future__ import annotations
import calendar
import os
import unicodedata
from enum import Enum, auto
from typing import Any, Callable
class ItemType(Enum):
Error = auto() # Error occurred; value is text of error
EOF = auto()
Text = auto() # Text
LeftParen = auto()
Number = auto() # Simple number
IssueNumber = auto() # Preceded by a # Symbol
RightParen = auto()
Space = auto() # Run of spaces separating arguments
Dot = auto()
LeftBrace = auto()
RightBrace = auto()
LeftSBrace = auto()
RightSBrace = auto()
Symbol = auto()
Skip = auto() # __ or -- no title, issue or series information beyond
Operator = auto()
Calendar = auto()
InfoSpecifier = auto() # Specifies type of info e.g. v1 for 'volume': 1
ArchiveType = auto()
Honorific = auto()
Keywords = auto()
FCBD = auto()
ComicType = auto()
Publisher = auto()
C2C = auto()
braces = [
ItemType.LeftBrace,
ItemType.LeftParen,
ItemType.LeftSBrace,
ItemType.RightBrace,
ItemType.RightParen,
ItemType.RightSBrace,
]
eof = chr(0)
key = {
"fcbd": ItemType.FCBD,
"freecomicbookday": ItemType.FCBD,
"cbr": ItemType.ArchiveType,
"cbz": ItemType.ArchiveType,
"cbt": ItemType.ArchiveType,
"cb7": ItemType.ArchiveType,
"rar": ItemType.ArchiveType,
"zip": ItemType.ArchiveType,
"tar": ItemType.ArchiveType,
"7z": ItemType.ArchiveType,
"annual": ItemType.ComicType,
"volume": ItemType.InfoSpecifier,
"vol.": ItemType.InfoSpecifier,
"vol": ItemType.InfoSpecifier,
"v": ItemType.InfoSpecifier,
"of": ItemType.InfoSpecifier,
"dc": ItemType.Publisher,
"marvel": ItemType.Publisher,
"covers": ItemType.InfoSpecifier,
"c2c": ItemType.C2C,
"mr": ItemType.Honorific,
"ms": ItemType.Honorific,
"mrs": ItemType.Honorific,
"dr": ItemType.Honorific,
}
class Item:
def __init__(self, typ: ItemType, pos: int, val: str) -> None:
self.typ: ItemType = typ
self.pos: int = pos
self.val: str = val
def __repr__(self) -> str:
return f"{self.val}: index: {self.pos}: {self.typ}"
class Lexer:
def __init__(self, string: str) -> None:
self.input: str = string # The string being scanned
# The next lexing function to enter
self.state: Callable[[Lexer], Callable | None] | None = None # type: ignore[type-arg]
self.pos: int = -1 # Current position in the input
self.start: int = 0 # Start position of this item
self.lastPos: int = 0 # Position of most recent item returned by nextItem
self.paren_depth: int = 0 # Nesting depth of ( ) exprs
self.brace_depth: int = 0 # Nesting depth of { }
self.sbrace_depth: int = 0 # Nesting depth of [ ]
self.items: list[Item] = []
# Next returns the next rune in the input.
def get(self) -> str:
if int(self.pos) >= len(self.input) - 1:
self.pos += 1
return eof
self.pos += 1
return self.input[self.pos]
# Peek returns but does not consume the next rune in the input.
def peek(self) -> str:
if int(self.pos) >= len(self.input) - 1:
return eof
return self.input[self.pos + 1]
def backup(self) -> None:
self.pos -= 1
# Emit passes an item back to the client.
def emit(self, t: ItemType) -> None:
self.items.append(Item(t, self.start, self.input[self.start : self.pos + 1]))
self.start = self.pos + 1
# Ignore skips over the pending input before this point.
def ignore(self) -> None:
self.start = self.pos
# Accept consumes the next rune if it's from the valid se:
def accept(self, valid: str) -> bool:
if self.get() in valid:
return True
self.backup()
return False
# AcceptRun consumes a run of runes from the valid set.
def accept_run(self, valid: str) -> None:
while self.get() in valid:
continue
self.backup()
def scan_number(self) -> bool:
digits = "0123456789"
self.accept_run(digits)
if self.accept("."):
if self.accept(digits):
self.accept_run(digits)
else:
self.backup()
if self.accept("s"):
if not self.accept("t"):
self.backup()
elif self.accept("nr"):
if not self.accept("d"):
self.backup()
elif self.accept("t"):
if not self.accept("h"):
self.backup()
return True
# Runs the state machine for the lexer.
def run(self) -> None:
self.state = lex_filename
while self.state is not None:
self.state = self.state(self)
# Errorf returns an error token and terminates the scan by passing
# Back a nil pointer that will be the next state, terminating self.nextItem.
def errorf(lex: Lexer, message: str) -> Callable[[Lexer], Callable | None] | None: # type: ignore[type-arg]
lex.items.append(Item(ItemType.Error, lex.start, message))
return None
# Scans the elements inside action delimiters.
def lex_filename(lex: Lexer) -> Callable[[Lexer], Callable | None] | None: # type: ignore[type-arg]
r = lex.get()
if r == eof:
if lex.paren_depth != 0:
return errorf(lex, "unclosed left paren")
if lex.brace_depth != 0:
return errorf(lex, "unclosed left paren")
lex.emit(ItemType.EOF)
return None
elif is_space(r):
if r == "_" and lex.peek() == "_":
lex.get()
lex.emit(ItemType.Skip)
else:
return lex_space
elif r == ".":
r = lex.peek()
if r < "0" or "9" < r:
lex.emit(ItemType.Dot)
return lex_filename
lex.backup()
return lex_number
elif r == "'":
r = lex.peek()
if r in "0123456789":
return lex_number
lex.emit(ItemType.Text) # TODO: Change to Text
elif "0" <= r <= "9":
lex.backup()
return lex_number
elif r == "#":
if "0" <= lex.peek() <= "9":
return lex_number
lex.emit(ItemType.Symbol)
elif is_operator(r):
if r == "-" and lex.peek() == "-":
lex.get()
lex.emit(ItemType.Skip)
else:
return lex_operator
elif is_alpha_numeric(r):
lex.backup()
return lex_text
elif r == "(":
lex.emit(ItemType.LeftParen)
lex.paren_depth += 1
elif r == ")":
lex.emit(ItemType.RightParen)
lex.paren_depth -= 1
if lex.paren_depth < 0:
return errorf(lex, "unexpected right paren " + r)
elif r == "{":
lex.emit(ItemType.LeftBrace)
lex.brace_depth += 1
elif r == "}":
lex.emit(ItemType.RightBrace)
lex.brace_depth -= 1
if lex.brace_depth < 0:
return errorf(lex, "unexpected right brace " + r)
elif r == "[":
lex.emit(ItemType.LeftSBrace)
lex.sbrace_depth += 1
elif r == "]":
lex.emit(ItemType.RightSBrace)
lex.sbrace_depth -= 1
if lex.sbrace_depth < 0:
return errorf(lex, "unexpected right brace " + r)
elif is_symbol(r):
lex.emit(ItemType.Symbol)
else:
return errorf(lex, "unrecognized character in action: " + r)
return lex_filename
def lex_operator(lex: Lexer) -> Callable: # type: ignore[type-arg]
lex.accept_run("-|:;")
lex.emit(ItemType.Operator)
return lex_filename
# LexSpace scans a run of space characters.
# One space has already been seen.
def lex_space(lex: Lexer) -> Callable: # type: ignore[type-arg]
while is_space(lex.peek()):
lex.get()
lex.emit(ItemType.Space)
return lex_filename
# Lex_text scans an alphanumeric.
def lex_text(lex: Lexer) -> Callable: # type: ignore[type-arg]
while True:
r = lex.get()
if is_alpha_numeric(r):
if r.isnumeric(): # E.g. v1
word = lex.input[lex.start : lex.pos]
if word.casefold() in key and key[word.casefold()] == ItemType.InfoSpecifier:
lex.backup()
lex.emit(key[word.casefold()])
return lex_filename
else:
if r == "'" and lex.peek() == "s":
lex.get()
else:
lex.backup()
word = lex.input[lex.start : lex.pos + 1]
if word.casefold() == "vol" and lex.peek() == ".":
lex.get()
word = lex.input[lex.start : lex.pos + 1]
if word.casefold() in key:
lex.emit(key[word.casefold()])
elif cal(word):
lex.emit(ItemType.Calendar)
else:
lex.emit(ItemType.Text)
break
return lex_filename
def cal(value: str) -> set[Any]:
month_abbr = [i for i, x in enumerate(calendar.month_abbr) if x == value.title()]
month_name = [i for i, x in enumerate(calendar.month_name) if x == value.title()]
day_abbr = [i for i, x in enumerate(calendar.day_abbr) if x == value.title()]
day_name = [i for i, x in enumerate(calendar.day_name) if x == value.title()]
return set(month_abbr + month_name + day_abbr + day_name)
def lex_number(lex: Lexer) -> Callable[[Lexer], Callable | None] | None: # type: ignore[type-arg]
if not lex.scan_number():
return errorf(lex, "bad number syntax: " + lex.input[lex.start : lex.pos])
# Complex number logic removed. Messes with math operations without space
if lex.input[lex.start] == "#":
lex.emit(ItemType.IssueNumber)
elif not lex.input[lex.pos].isdigit():
# Assume that 80th is just text and not a number
lex.emit(ItemType.Text)
else:
lex.emit(ItemType.Number)
return lex_filename
def is_space(character: str) -> bool:
return character in "_ \t"
# IsAlphaNumeric reports whether r is an alphabetic, digit, or underscore.
def is_alpha_numeric(character: str) -> bool:
return character.isalpha() or character.isnumeric()
def is_operator(character: str) -> bool:
return character in "-|:;/\\"
def is_symbol(character: str) -> bool:
return unicodedata.category(character)[0] in "PS"
def Lex(filename: str) -> Lexer:
lex = Lexer(string=os.path.basename(filename))
lex.run()
return lex

1149
comicapi/filenameparser.py Normal file

File diff suppressed because it is too large Load Diff

461
comicapi/genericmetadata.py Normal file
View File

@ -0,0 +1,461 @@
"""A class for internal metadata storage
The goal of this class is to handle ALL the data that might come from various
tagging schemes and databases, such as ComicVine or GCD. This makes conversion
possible, however lossy it might be
"""
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
import copy
import dataclasses
import logging
from typing import Any, TypedDict
from comicapi import utils
logger = logging.getLogger(__name__)
class PageType:
"""
These page info classes are exactly the same as the CIX scheme, since
it's unique
"""
FrontCover = "FrontCover"
InnerCover = "InnerCover"
Roundup = "Roundup"
Story = "Story"
Advertisement = "Advertisement"
Editorial = "Editorial"
Letters = "Letters"
Preview = "Preview"
BackCover = "BackCover"
Other = "Other"
Deleted = "Deleted"
class ImageMetadata(TypedDict, total=False):
Type: str
Bookmark: str
DoublePage: bool
Image: int
ImageSize: str
ImageHeight: str
ImageWidth: str
class CreditMetadata(TypedDict):
person: str
role: str
primary: bool
@dataclasses.dataclass
class GenericMetadata:
writer_synonyms = ["writer", "plotter", "scripter"]
penciller_synonyms = ["artist", "penciller", "penciler", "breakdowns"]
inker_synonyms = ["inker", "artist", "finishes"]
colorist_synonyms = ["colorist", "colourist", "colorer", "colourer"]
letterer_synonyms = ["letterer"]
cover_synonyms = ["cover", "covers", "coverartist", "cover artist"]
editor_synonyms = ["editor"]
is_empty: bool = True
tag_origin: str | None = None
issue_id: str | None = None
series: str | None = None
issue: str | None = None
title: str | None = None
publisher: str | None = None
month: int | None = None
year: int | None = None
day: int | None = None
issue_count: int | None = None
volume: int | None = None
genre: str | None = None
language: str | None = None # 2 letter iso code
comments: str | None = None # use same way as Summary in CIX
volume_count: int | None = None
critical_rating: float | None = None # rating in CBL; CommunityRating in CIX
country: str | None = None
alternate_series: str | None = None
alternate_number: str | None = None
alternate_count: int | None = None
imprint: str | None = None
notes: str | None = None
web_link: str | None = None
format: str | None = None
manga: str | None = None
black_and_white: bool | None = None
page_count: int | None = None
maturity_rating: str | None = None
story_arc: str | None = None
series_group: str | None = None
scan_info: str | None = None
characters: str | None = None
teams: str | None = None
locations: str | None = None
credits: list[CreditMetadata] = dataclasses.field(default_factory=list)
tags: set[str] = dataclasses.field(default_factory=set)
pages: list[ImageMetadata] = dataclasses.field(default_factory=list)
# Some CoMet-only items
price: str | None = None
is_version_of: str | None = None
rights: str | None = None
identifier: str | None = None
last_mark: str | None = None
cover_image: str | None = None
def __post_init__(self) -> None:
for key, value in self.__dict__.items():
if value and key != "is_empty":
self.is_empty = False
break
def copy(self) -> GenericMetadata:
return copy.deepcopy(self)
def replace(self, /, **kwargs: Any) -> GenericMetadata:
tmp = self.copy()
tmp.__dict__.update(kwargs)
return tmp
def overlay(self, new_md: GenericMetadata) -> None:
"""Overlay a metadata object on this one
That is, when the new object has non-None values, over-write them
to this one.
"""
def assign(cur: str, new: Any) -> None:
if new is not None:
if isinstance(new, str) and len(new) == 0:
setattr(self, cur, None)
else:
setattr(self, cur, new)
if not new_md.is_empty:
self.is_empty = False
assign("series", new_md.series)
assign("issue", new_md.issue)
assign("issue_count", new_md.issue_count)
assign("title", new_md.title)
assign("publisher", new_md.publisher)
assign("day", new_md.day)
assign("month", new_md.month)
assign("year", new_md.year)
assign("volume", new_md.volume)
assign("volume_count", new_md.volume_count)
assign("genre", new_md.genre)
assign("language", new_md.language)
assign("country", new_md.country)
assign("critical_rating", new_md.critical_rating)
assign("alternate_series", new_md.alternate_series)
assign("alternate_number", new_md.alternate_number)
assign("alternate_count", new_md.alternate_count)
assign("imprint", new_md.imprint)
assign("web_link", new_md.web_link)
assign("format", new_md.format)
assign("manga", new_md.manga)
assign("black_and_white", new_md.black_and_white)
assign("maturity_rating", new_md.maturity_rating)
assign("story_arc", new_md.story_arc)
assign("series_group", new_md.series_group)
assign("scan_info", new_md.scan_info)
assign("characters", new_md.characters)
assign("teams", new_md.teams)
assign("locations", new_md.locations)
assign("comments", new_md.comments)
assign("notes", new_md.notes)
assign("price", new_md.price)
assign("is_version_of", new_md.is_version_of)
assign("rights", new_md.rights)
assign("identifier", new_md.identifier)
assign("last_mark", new_md.last_mark)
self.overlay_credits(new_md.credits)
# TODO
# not sure if the tags and pages should broken down, or treated
# as whole lists....
# For now, go the easy route, where any overlay
# value wipes out the whole list
if len(new_md.tags) > 0:
assign("tags", new_md.tags)
if len(new_md.pages) > 0:
assign("pages", new_md.pages)
def overlay_credits(self, new_credits: list[CreditMetadata]) -> None:
for c in new_credits:
primary = bool("primary" in c and c["primary"])
# Remove credit role if person is blank
if c["person"] == "":
for r in reversed(self.credits):
if r["role"].casefold() == c["role"].casefold():
self.credits.remove(r)
# otherwise, add it!
else:
self.add_credit(c["person"], c["role"], primary)
def set_default_page_list(self, count: int) -> None:
# generate a default page list, with the first page marked as the cover
for i in range(count):
page_dict = ImageMetadata(Image=i)
if i == 0:
page_dict["Type"] = PageType.FrontCover
self.pages.append(page_dict)
def get_archive_page_index(self, pagenum: int) -> int:
# convert the displayed page number to the page index of the file in the archive
if pagenum < len(self.pages):
return int(self.pages[pagenum]["Image"])
return 0
def get_cover_page_index_list(self) -> list[int]:
# return a list of archive page indices of cover pages
coverlist = []
for p in self.pages:
if "Type" in p and p["Type"] == PageType.FrontCover:
coverlist.append(int(p["Image"]))
if len(coverlist) == 0:
coverlist.append(0)
return coverlist
def add_credit(self, person: str, role: str, primary: bool = False) -> None:
credit = CreditMetadata(person=person, role=role, primary=primary)
# look to see if it's not already there...
found = False
for c in self.credits:
if c["person"].casefold() == person.casefold() and c["role"].casefold() == role.casefold():
# no need to add it. just adjust the "primary" flag as needed
c["primary"] = primary
found = True
break
if not found:
self.credits.append(credit)
def get_primary_credit(self, role: str) -> str:
primary = ""
for credit in self.credits:
if "role" not in credit or "person" not in credit:
continue
if (primary == "" and credit["role"].casefold() == role.casefold()) or (
credit["role"].casefold() == role.casefold() and "primary" in credit and credit["primary"]
):
primary = credit["person"]
return primary
def __str__(self) -> str:
vals: list[tuple[str, Any]] = []
if self.is_empty:
return "No metadata"
def add_string(tag: str, val: Any) -> None:
if val is not None and str(val) != "":
vals.append((tag, val))
def add_attr_string(tag: str) -> None:
add_string(tag, getattr(self, tag))
add_attr_string("series")
add_attr_string("issue")
add_attr_string("issue_count")
add_attr_string("title")
add_attr_string("publisher")
add_attr_string("year")
add_attr_string("month")
add_attr_string("day")
add_attr_string("volume")
add_attr_string("volume_count")
add_attr_string("genre")
add_attr_string("language")
add_attr_string("country")
add_attr_string("critical_rating")
add_attr_string("alternate_series")
add_attr_string("alternate_number")
add_attr_string("alternate_count")
add_attr_string("imprint")
add_attr_string("web_link")
add_attr_string("format")
add_attr_string("manga")
add_attr_string("price")
add_attr_string("is_version_of")
add_attr_string("rights")
add_attr_string("identifier")
add_attr_string("last_mark")
if self.black_and_white:
add_attr_string("black_and_white")
add_attr_string("maturity_rating")
add_attr_string("story_arc")
add_attr_string("series_group")
add_attr_string("scan_info")
add_attr_string("characters")
add_attr_string("teams")
add_attr_string("locations")
add_attr_string("comments")
add_attr_string("notes")
add_string("tags", ", ".join(self.tags))
for c in self.credits:
primary = ""
if "primary" in c and c["primary"]:
primary = " [P]"
add_string("credit", c["role"] + ": " + c["person"] + primary)
# find the longest field name
flen = 0
for i in vals:
flen = max(flen, len(i[0]))
flen += 1
# format the data nicely
outstr = ""
fmt_str = "{0: <" + str(flen) + "} {1}\n"
for i in vals:
outstr += fmt_str.format(i[0] + ":", i[1])
return outstr
def fix_publisher(self) -> None:
if self.publisher is None:
return
if self.imprint is None:
self.imprint = ""
imprint, publisher = utils.get_publisher(self.publisher)
self.publisher = publisher
if self.imprint.casefold() in publisher.casefold():
self.imprint = None
if self.imprint is None or self.imprint == "":
self.imprint = imprint
elif self.imprint.casefold() in imprint.casefold():
self.imprint = imprint
md_test: GenericMetadata = GenericMetadata(
is_empty=False,
tag_origin=None,
series="Cory Doctorow's Futuristic Tales of the Here and Now",
issue="1",
title="Anda's Game",
publisher="IDW Publishing",
month=10,
year=2007,
day=1,
issue_count=6,
volume=1,
genre="Sci-Fi",
language="en",
comments=(
"For 12-year-old Anda, getting paid real money to kill the characters of players who were cheating"
" in her favorite online computer game was a win-win situation. Until she found out who was paying her,"
" and what those characters meant to the livelihood of children around the world."
),
volume_count=None,
critical_rating=3.0,
country=None,
alternate_series="Tales",
alternate_number="2",
alternate_count=7,
imprint="craphound.com",
notes="Tagged with ComicTagger 1.3.2a5 using info from Comic Vine on 2022-04-16 15:52:26. [Issue ID 140529]",
web_link="https://comicvine.gamespot.com/cory-doctorows-futuristic-tales-of-the-here-and-no/4000-140529/",
format="Series",
manga="No",
black_and_white=None,
page_count=24,
maturity_rating="Everyone 10+",
story_arc="Here and Now",
series_group="Futuristic Tales",
scan_info="(CC BY-NC-SA 3.0)",
characters="Anda",
teams="Fahrenheit",
locations="lonely cottage ",
credits=[
CreditMetadata(primary=False, person="Dara Naraghi", role="Writer"),
CreditMetadata(primary=False, person="Esteve Polls", role="Penciller"),
CreditMetadata(primary=False, person="Esteve Polls", role="Inker"),
CreditMetadata(primary=False, person="Neil Uyetake", role="Letterer"),
CreditMetadata(primary=False, person="Sam Kieth", role="Cover"),
CreditMetadata(primary=False, person="Ted Adams", role="Editor"),
],
tags=set(),
pages=[
ImageMetadata(Image=0, ImageHeight="1280", ImageSize="195977", ImageWidth="800", Type=PageType.FrontCover),
ImageMetadata(Image=1, ImageHeight="2039", ImageSize="611993", ImageWidth="1327"),
ImageMetadata(Image=2, ImageHeight="2039", ImageSize="783726", ImageWidth="1327"),
ImageMetadata(Image=3, ImageHeight="2039", ImageSize="679584", ImageWidth="1327"),
ImageMetadata(Image=4, ImageHeight="2039", ImageSize="788179", ImageWidth="1327"),
ImageMetadata(Image=5, ImageHeight="2039", ImageSize="864433", ImageWidth="1327"),
ImageMetadata(Image=6, ImageHeight="2039", ImageSize="765606", ImageWidth="1327"),
ImageMetadata(Image=7, ImageHeight="2039", ImageSize="876427", ImageWidth="1327"),
ImageMetadata(Image=8, ImageHeight="2039", ImageSize="852622", ImageWidth="1327"),
ImageMetadata(Image=9, ImageHeight="2039", ImageSize="800205", ImageWidth="1327"),
ImageMetadata(Image=10, ImageHeight="2039", ImageSize="746243", ImageWidth="1326"),
ImageMetadata(Image=11, ImageHeight="2039", ImageSize="718062", ImageWidth="1327"),
ImageMetadata(Image=12, ImageHeight="2039", ImageSize="532179", ImageWidth="1326"),
ImageMetadata(Image=13, ImageHeight="2039", ImageSize="686708", ImageWidth="1327"),
ImageMetadata(Image=14, ImageHeight="2039", ImageSize="641907", ImageWidth="1327"),
ImageMetadata(Image=15, ImageHeight="2039", ImageSize="805388", ImageWidth="1327"),
ImageMetadata(Image=16, ImageHeight="2039", ImageSize="668927", ImageWidth="1326"),
ImageMetadata(Image=17, ImageHeight="2039", ImageSize="710605", ImageWidth="1327"),
ImageMetadata(Image=18, ImageHeight="2039", ImageSize="761398", ImageWidth="1326"),
ImageMetadata(Image=19, ImageHeight="2039", ImageSize="743807", ImageWidth="1327"),
ImageMetadata(Image=20, ImageHeight="2039", ImageSize="552911", ImageWidth="1326"),
ImageMetadata(Image=21, ImageHeight="2039", ImageSize="556827", ImageWidth="1327"),
ImageMetadata(Image=22, ImageHeight="2039", ImageSize="675078", ImageWidth="1326"),
ImageMetadata(
Bookmark="Interview",
Image=23,
ImageHeight="2032",
ImageSize="800965",
ImageWidth="1338",
Type=PageType.Letters,
),
],
price=None,
is_version_of=None,
rights=None,
identifier=None,
last_mark=None,
cover_image=None,
)

115
comicapi/issuestring.py Normal file
View File

@ -0,0 +1,115 @@
"""Support for mixed digit/string type Issue field
Class for handling the odd permutations of an 'issue number' that the
comics industry throws at us.
e.g.: "12", "12.1", "0", "-1", "5AU", "100-2"
"""
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
import logging
import unicodedata
logger = logging.getLogger(__name__)
class IssueString:
def __init__(self, text: str | None) -> None:
# break up the issue number string into 2 parts: the numeric and suffix string.
# (assumes that the numeric portion is always first)
self.num = None
self.suffix = ""
if text is None:
return
text = str(text)
if len(text) == 0:
return
# skip the minus sign if it's first
if text[0] == "-":
start = 1
else:
start = 0
# if it's still not numeric at start skip it
if text[start].isdigit() or text[start] == ".":
# walk through the string, look for split point (the first non-numeric)
decimal_count = 0
for idx in range(start, len(text)):
if text[idx] not in "0123456789.":
break
# special case: also split on second "."
if text[idx] == ".":
decimal_count += 1
if decimal_count > 1:
break
else:
idx = len(text)
# move trailing numeric decimal to suffix
# (only if there is other junk after )
if text[idx - 1] == "." and len(text) != idx:
idx = idx - 1
# if there is no numeric after the minus, make the minus part of the suffix
if idx == 1 and start == 1:
idx = 0
part1 = text[0:idx]
part2 = text[idx : len(text)]
if part1 != "":
self.num = float(part1)
self.suffix = part2
else:
self.suffix = text
def as_string(self, pad: int = 0) -> str:
# return the float, left side zero-padded, with suffix attached
if self.num is None:
return self.suffix
negative = self.num < 0
num_f = abs(self.num)
num_int = int(num_f)
num_s = str(num_int)
if float(num_int) != num_f:
num_s = str(num_f)
num_s += self.suffix
# create padding
padding = ""
length = len(str(num_int))
if length < pad:
padding = "0" * (pad - length)
num_s = padding + num_s
if negative:
num_s = "-" + num_s
return num_s
def as_float(self) -> float | None:
# return the float, with no suffix
if len(self.suffix) == 1 and self.suffix.isnumeric():
return (self.num or 0) + unicodedata.numeric(self.suffix)
return self.num

271
comicapi/utils.py Normal file
View File

@ -0,0 +1,271 @@
"""Some generic utilities"""
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
import glob
import json
import logging
import os
import pathlib
import unicodedata
from collections import defaultdict
from collections.abc import Mapping
from shutil import which # noqa: F401
from typing import Any
import pycountry
import rapidfuzz.fuzz
import comicapi.data
logger = logging.getLogger(__name__)
class UtilsVars:
already_fixed_encoding = False
def combine_notes(existing_notes: str | None, new_notes: str | None, split: str) -> str:
split_notes, split_str, untouched_notes = (existing_notes or "").rpartition(split)
if split_notes or split_str:
return (split_notes + (new_notes or "")).strip()
else:
return (untouched_notes + "\n" + (new_notes or "")).strip()
def parse_date_str(date_str: str) -> tuple[int | None, int | None, int | None]:
day = None
month = None
year = None
if date_str:
parts = date_str.split("-")
year = xlate(parts[0], True)
if len(parts) > 1:
month = xlate(parts[1], True)
if len(parts) > 2:
day = xlate(parts[2], True)
return day, month, year
def get_recursive_filelist(pathlist: list[str]) -> list[str]:
"""Get a recursive list of of all files under all path items in the list"""
filelist: list[str] = []
for p in pathlist:
if os.path.isdir(p):
filelist.extend(x for x in glob.glob(f"{p}{os.sep}/**", recursive=True) if not os.path.isdir(x))
elif str(p) not in filelist:
filelist.append(str(p))
return filelist
def add_to_path(dirname: str) -> None:
if dirname:
dirname = os.path.abspath(dirname)
paths = [os.path.normpath(x) for x in os.environ["PATH"].split(os.pathsep)]
if dirname not in paths:
paths.insert(0, dirname)
os.environ["PATH"] = os.pathsep.join(paths)
def xlate(data: Any, is_int: bool = False, is_float: bool = False) -> Any:
if data is None or data == "":
return None
if is_int or is_float:
i: str | int | float
if isinstance(data, (int, float)):
i = data
else:
i = str(data).translate(defaultdict(lambda: None, zip((ord(c) for c in "1234567890."), "1234567890.")))
if i == "":
return None
try:
if is_float:
return float(i)
return int(float(i))
except ValueError:
return None
return str(data)
def remove_articles(text: str) -> str:
text = text.casefold()
articles = [
"&",
"a",
"am",
"an",
"and",
"as",
"at",
"be",
"but",
"by",
"for",
"if",
"is",
"issue",
"it",
"it's",
"its",
"itself",
"of",
"or",
"so",
"the",
"the",
"with",
]
new_text = ""
for word in text.split():
if word not in articles:
new_text += word + " "
new_text = new_text[:-1]
return new_text
def sanitize_title(text: str, basic: bool = False) -> str:
# normalize unicode and convert to ascii. Does not work for everything eg ½ to 12 not 1/2
text = unicodedata.normalize("NFKD", text).casefold()
# comicvine keeps apostrophes a part of the word
text = text.replace("'", "")
text = text.replace('"', "")
if not basic:
# comicvine ignores punctuation and accents
# remove all characters that are not a letter, separator (space) or number
# replace any "dash punctuation" with a space
# makes sure that batman-superman and self-proclaimed stay separate words
text = "".join(
c if unicodedata.category(c)[0] not in "P" else " " for c in text if unicodedata.category(c)[0] in "LZNP"
)
# remove extra space and articles and all lower case
text = remove_articles(text).strip()
return text
def titles_match(search_title: str, record_title: str, threshold: int = 90) -> bool:
sanitized_search = sanitize_title(search_title)
sanitized_record = sanitize_title(record_title)
ratio = int(rapidfuzz.fuzz.ratio(sanitized_search, sanitized_record))
logger.debug(
"search title: %s ; record title: %s ; ratio: %d ; match threshold: %d",
search_title,
record_title,
ratio,
threshold,
)
return ratio >= threshold
def unique_file(file_name: pathlib.Path) -> pathlib.Path:
name = file_name.stem
counter = 1
while True:
if not file_name.exists():
return file_name
file_name = file_name.with_stem(name + " (" + str(counter) + ")")
counter += 1
languages: dict[str | None, str | None] = defaultdict(lambda: None)
countries: dict[str | None, str | None] = defaultdict(lambda: None)
for c in pycountry.countries:
if "alpha_2" in c._fields:
countries[c.alpha_2] = c.name
for lng in pycountry.languages:
if "alpha_2" in lng._fields:
languages[lng.alpha_2] = lng.name
def get_language_from_iso(iso: str | None) -> str | None:
return languages[iso]
def get_language_iso(string: str | None) -> str | None:
if string is None:
return None
lang = string.casefold()
try:
return getattr(pycountry.languages.lookup(string), "alpha_2", None)
except LookupError:
pass
return lang
def get_publisher(publisher: str) -> tuple[str, str]:
imprint = ""
for pub in publishers.values():
imprint, publisher, ok = pub[publisher]
if ok:
break
return (imprint, publisher)
def update_publishers(new_publishers: Mapping[str, Mapping[str, str]]) -> None:
for publisher in new_publishers:
if publisher in publishers:
publishers[publisher].update(new_publishers[publisher])
else:
publishers[publisher] = ImprintDict(publisher, new_publishers[publisher])
class ImprintDict(dict): # type: ignore
"""
ImprintDict takes a publisher and a dict or mapping of lowercased
imprint names to the proper imprint name. Retrieving a value from an
ImprintDict returns a tuple of (imprint, publisher, keyExists).
if the key does not exist the key is returned as the publisher unchanged
"""
def __init__(self, publisher: str, mapping: tuple | Mapping = (), **kwargs: dict) -> None: # type: ignore
super().__init__(mapping, **kwargs)
self.publisher = publisher
def __missing__(self, key: str) -> None:
return None
def __getitem__(self, k: str) -> tuple[str, str, bool]:
item = super().__getitem__(k.casefold())
if k.casefold() == self.publisher.casefold():
return ("", self.publisher, True)
if item is None:
return ("", k, False)
else:
return (item, self.publisher, True)
def copy(self) -> ImprintDict:
return ImprintDict(self.publisher, super().copy())
publishers: dict[str, ImprintDict] = {}
def load_publishers() -> None:
try:
update_publishers(json.loads((comicapi.data.data_path / "publishers.json").read_text("utf-8")))
except Exception:
logger.exception("Failed to load publishers.json; The are no publishers or imprints loaded")

View File

@ -1,5 +1,10 @@
#!/usr/bin/env python
from comictaggerlib.main import ctmain
#!/usr/bin/env python3
from __future__ import annotations
if __name__ == '__main__':
ctmain()
import localefix
from comictaggerlib.main import App
if __name__ == "__main__":
localefix.configure_locale()
App().run()

241
comictagger.spec Normal file
View File

@ -0,0 +1,241 @@
# -*- mode: python ; coding: utf-8 -*-
import platform
from comictaggerlib import ctversion
enable_console = False
block_cipher = None
a = Analysis(
["comictagger.py"],
pathex=[],
binaries=[],
datas=[],
hiddenimports=[],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher,
noarchive=False,
)
exe_binaries = []
exe_zipfiles = []
exe_datas = []
exe_exclude_binaries = True
coll_binaries = a.binaries
coll_zipfiles = a.zipfiles
coll_datas = a.datas
if platform.system() in ["Windows"]:
enable_console = True
exe_binaries = a.binaries
exe_zipfiles = a.zipfiles
exe_datas = a.datas
exe_exclude_binaries = False
coll_binaries = []
coll_zipfiles = []
coll_datas = []
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
a.scripts,
exe_binaries,
exe_zipfiles,
exe_datas,
[],
exclude_binaries=exe_exclude_binaries,
name="comictagger",
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=True,
upx_exclude=[],
runtime_tmpdir=None,
console=enable_console,
disable_windowed_traceback=False,
argv_emulation=False,
target_arch=None,
codesign_identity=None,
entitlements_file=None,
icon="windows/app.ico",
)
if platform.system() not in ["Windows"]:
coll = COLLECT(
exe,
coll_binaries,
coll_zipfiles,
coll_datas,
strip=False,
upx=True,
upx_exclude=[],
name="comictagger",
)
app = BUNDLE(
coll,
name="ComicTagger.app",
icon="mac/app.icns",
info_plist={
"NSHighResolutionCapable": "True",
"NSPrincipalClass": "NSApplication",
"NSRequiresAquaSystemAppearance": "False",
"CFBundleDisplayName": "ComicTagger",
"CFBundleShortVersionString": ctversion.version,
"CFBundleVersion": ctversion.version,
"CFBundleDocumentTypes": [
{
"CFBundleTypeRole": "Editor",
"LSHandlerRank": "Default",
"LSItemContentTypes": [
"public.folder",
],
"CFBundleTypeName": "Folder",
},
{
"CFBundleTypeExtensions": [
"cbz",
],
"LSTypeIsPackage": False,
"NSPersistentStoreTypeKey": "Binary",
"CFBundleTypeIconSystemGenerated": True,
"CFBundleTypeName": "ZIP Comic Archive",
"LSItemContentTypes": [
"public.zip-comic-archive",
"com.simplecomic.cbz-archive",
"com.macitbetter.cbz-archive",
"public.cbz-archive",
"cx.c3.cbz-archive",
"com.yacreader.yacreader.cbz",
"com.milke.cbz-archive",
"com.bitcartel.comicbooklover.cbz",
"public.archive.cbz",
"public.zip-archive",
],
"CFBundleTypeRole": "Editor",
"LSHandlerRank": "Default",
},
{
"CFBundleTypeExtensions": [
"cb7",
],
"LSTypeIsPackage": False,
"NSPersistentStoreTypeKey": "Binary",
"CFBundleTypeIconSystemGenerated": True,
"CFBundleTypeName": "7-Zip Comic Archive",
"LSItemContentTypes": [
"org.7-zip.7-zip-archive",
"com.simplecomic.cb7-archive",
"public.cb7-archive",
"com.macitbetter.cb7-archive",
"cx.c3.cb7-archive",
"org.7-zip.7-zip-comic-archive",
],
"CFBundleTypeRole": "Editor",
"LSHandlerRank": "Default",
},
{
"CFBundleTypeExtensions": [
"cbr",
],
"LSTypeIsPackage": False,
"NSPersistentStoreTypeKey": "Binary",
"CFBundleTypeIconSystemGenerated": True,
"CFBundleTypeName": "RAR Comic Archive",
"LSItemContentTypes": [
"com.rarlab.rar-archive",
"com.rarlab.rar-comic-archive",
"com.simplecomic.cbr-archive",
"com.macitbetter.cbr-archive",
"public.cbr-archive",
"cx.c3.cbr-archive",
"com.bitcartel.comicbooklover.cbr",
"com.milke.cbr-archive",
"public.archive.cbr",
"com.yacreader.yacreader.cbr",
],
"CFBundleTypeRole": "Editor",
"LSHandlerRank": "Default",
},
],
"UTImportedTypeDeclarations": [
{
"UTTypeIdentifier": "com.rarlab.rar-archive",
"UTTypeDescription": "RAR Archive",
"UTTypeConformsTo": [
"public.data",
"public.archive",
],
"UTTypeTagSpecification": {
"public.mime-type": [
"application/x-rar",
"application/x-rar-compressed",
],
"public.filename-extension": [
"rar",
],
},
},
{
"UTTypeConformsTo": [
"public.data",
"public.archive",
"com.rarlab.rar-archive",
],
"UTTypeIdentifier": "com.rarlab.rar-comic-archive",
"UTTypeDescription": "RAR Comic Archive",
"UTTypeTagSpecification": {
"public.mime-type": [
"application/vnd.comicbook-rar",
"application/x-cbr",
],
"public.filename-extension": [
"cbr",
],
},
},
{
"UTTypeConformsTo": [
"public.data",
"public.archive",
"public.zip-archive",
],
"UTTypeIdentifier": "public.zip-comic-archive",
"UTTypeDescription": "ZIP Comic Archive",
"UTTypeTagSpecification": {
"public.filename-extension": [
"cbz",
],
},
},
{
"UTTypeConformsTo": [
"public.data",
"public.archive",
"org.7-zip.7-zip-archive",
],
"UTTypeIdentifier": "org.7-zip.7-zip-comic-archive",
"UTTypeDescription": "7-Zip Comic Archive",
"UTTypeTagSpecification": {
"public.mime-type": [
"application/vnd.comicbook+7-zip",
"application/x-cb7-compressed",
],
"public.filename-extension": [
"cb7",
],
},
},
],
},
bundle_identifier=None,
)

View File

@ -1,18 +0,0 @@
The unrar.dll library is freeware. This means:
1. All copyrights to RAR and the unrar.dll are exclusively
owned by the author - Alexander Roshal.
2. The unrar.dll library may be used in any software to handle RAR
archives without limitations free of charge.
3. THE RAR ARCHIVER AND THE UNRAR.DLL LIBRARY ARE DISTRIBUTED "AS IS".
NO WARRANTY OF ANY KIND IS EXPRESSED OR IMPLIED. YOU USE AT
YOUR OWN RISK. THE AUTHOR WILL NOT BE LIABLE FOR DATA LOSS,
DAMAGES, LOSS OF PROFITS OR ANY OTHER KIND OF LOSS WHILE USING
OR MISUSING THIS SOFTWARE.
Thank you for your interest in RAR and unrar.dll.
Alexander L. Roshal

View File

@ -1,140 +0,0 @@
#ifndef _UNRAR_DLL_
#define _UNRAR_DLL_
#define ERAR_END_ARCHIVE 10
#define ERAR_NO_MEMORY 11
#define ERAR_BAD_DATA 12
#define ERAR_BAD_ARCHIVE 13
#define ERAR_UNKNOWN_FORMAT 14
#define ERAR_EOPEN 15
#define ERAR_ECREATE 16
#define ERAR_ECLOSE 17
#define ERAR_EREAD 18
#define ERAR_EWRITE 19
#define ERAR_SMALL_BUF 20
#define ERAR_UNKNOWN 21
#define ERAR_MISSING_PASSWORD 22
#define RAR_OM_LIST 0
#define RAR_OM_EXTRACT 1
#define RAR_OM_LIST_INCSPLIT 2
#define RAR_SKIP 0
#define RAR_TEST 1
#define RAR_EXTRACT 2
#define RAR_VOL_ASK 0
#define RAR_VOL_NOTIFY 1
#define RAR_DLL_VERSION 4
#ifdef _UNIX
#define CALLBACK
#define PASCAL
#define LONG long
#define HANDLE void *
#define LPARAM long
#define UINT unsigned int
#endif
struct RARHeaderData
{
char ArcName[260];
char FileName[260];
unsigned int Flags;
unsigned int PackSize;
unsigned int UnpSize;
unsigned int HostOS;
unsigned int FileCRC;
unsigned int FileTime;
unsigned int UnpVer;
unsigned int Method;
unsigned int FileAttr;
char *CmtBuf;
unsigned int CmtBufSize;
unsigned int CmtSize;
unsigned int CmtState;
};
struct RARHeaderDataEx
{
char ArcName[1024];
wchar_t ArcNameW[1024];
char FileName[1024];
wchar_t FileNameW[1024];
unsigned int Flags;
unsigned int PackSize;
unsigned int PackSizeHigh;
unsigned int UnpSize;
unsigned int UnpSizeHigh;
unsigned int HostOS;
unsigned int FileCRC;
unsigned int FileTime;
unsigned int UnpVer;
unsigned int Method;
unsigned int FileAttr;
char *CmtBuf;
unsigned int CmtBufSize;
unsigned int CmtSize;
unsigned int CmtState;
unsigned int Reserved[1024];
};
struct RAROpenArchiveData
{
char *ArcName;
unsigned int OpenMode;
unsigned int OpenResult;
char *CmtBuf;
unsigned int CmtBufSize;
unsigned int CmtSize;
unsigned int CmtState;
};
struct RAROpenArchiveDataEx
{
char *ArcName;
wchar_t *ArcNameW;
unsigned int OpenMode;
unsigned int OpenResult;
char *CmtBuf;
unsigned int CmtBufSize;
unsigned int CmtSize;
unsigned int CmtState;
unsigned int Flags;
unsigned int Reserved[32];
};
enum UNRARCALLBACK_MESSAGES {
UCM_CHANGEVOLUME,UCM_PROCESSDATA,UCM_NEEDPASSWORD
};
typedef int (CALLBACK *UNRARCALLBACK)(UINT msg,LPARAM UserData,LPARAM P1,LPARAM P2);
typedef int (PASCAL *CHANGEVOLPROC)(char *ArcName,int Mode);
typedef int (PASCAL *PROCESSDATAPROC)(unsigned char *Addr,int Size);
#ifdef __cplusplus
extern "C" {
#endif
HANDLE PASCAL RAROpenArchive(struct RAROpenArchiveData *ArchiveData);
HANDLE PASCAL RAROpenArchiveEx(struct RAROpenArchiveDataEx *ArchiveData);
int PASCAL RARCloseArchive(HANDLE hArcData);
int PASCAL RARReadHeader(HANDLE hArcData,struct RARHeaderData *HeaderData);
int PASCAL RARReadHeaderEx(HANDLE hArcData,struct RARHeaderDataEx *HeaderData);
int PASCAL RARProcessFile(HANDLE hArcData,int Operation,char *DestPath,char *DestName);
int PASCAL RARProcessFileW(HANDLE hArcData,int Operation,wchar_t *DestPath,wchar_t *DestName);
void PASCAL RARSetCallback(HANDLE hArcData,UNRARCALLBACK Callback,LPARAM UserData);
void PASCAL RARSetChangeVolProc(HANDLE hArcData,CHANGEVOLPROC ChangeVolProc);
void PASCAL RARSetProcessDataProc(HANDLE hArcData,PROCESSDATAPROC ProcessDataProc);
void PASCAL RARSetPassword(HANDLE hArcData,char *Password);
int PASCAL RARGetDllVersion();
#ifdef __cplusplus
}
#endif
#endif

View File

@ -1,606 +0,0 @@
UnRAR.dll Manual
~~~~~~~~~~~~~~~~
UnRAR.dll is a 32-bit Windows dynamic-link library which provides
file extraction from RAR archives.
Exported functions
====================================================================
HANDLE PASCAL RAROpenArchive(struct RAROpenArchiveData *ArchiveData)
====================================================================
Description
~~~~~~~~~~~
Open RAR archive and allocate memory structures
Parameters
~~~~~~~~~~
ArchiveData Points to RAROpenArchiveData structure
struct RAROpenArchiveData
{
char *ArcName;
UINT OpenMode;
UINT OpenResult;
char *CmtBuf;
UINT CmtBufSize;
UINT CmtSize;
UINT CmtState;
};
Structure fields:
ArcName
Input parameter which should point to zero terminated string
containing the archive name.
OpenMode
Input parameter.
Possible values
RAR_OM_LIST
Open archive for reading file headers only.
RAR_OM_EXTRACT
Open archive for testing and extracting files.
RAR_OM_LIST_INCSPLIT
Open archive for reading file headers only. If you open an archive
in such mode, RARReadHeader[Ex] will return all file headers,
including those with "file continued from previous volume" flag.
In case of RAR_OM_LIST such headers are automatically skipped.
So if you process RAR volumes in RAR_OM_LIST_INCSPLIT mode, you will
get several file header records for same file if file is split between
volumes. For such files only the last file header record will contain
the correct file CRC and if you wish to get the correct packed size,
you need to sum up packed sizes of all parts.
OpenResult
Output parameter.
Possible values
0 Success
ERAR_NO_MEMORY Not enough memory to initialize data structures
ERAR_BAD_DATA Archive header broken
ERAR_BAD_ARCHIVE File is not valid RAR archive
ERAR_UNKNOWN_FORMAT Unknown encryption used for archive headers
ERAR_EOPEN File open error
CmtBuf
Input parameter which should point to the buffer for archive
comments. Maximum comment size is limited to 64Kb. Comment text is
zero terminated. If the comment text is larger than the buffer
size, the comment text will be truncated. If CmtBuf is set to
NULL, comments will not be read.
CmtBufSize
Input parameter which should contain size of buffer for archive
comments.
CmtSize
Output parameter containing size of comments actually read into the
buffer, cannot exceed CmtBufSize.
CmtState
Output parameter.
Possible values
0 comments not present
1 Comments read completely
ERAR_NO_MEMORY Not enough memory to extract comments
ERAR_BAD_DATA Broken comment
ERAR_UNKNOWN_FORMAT Unknown comment format
ERAR_SMALL_BUF Buffer too small, comments not completely read
Return values
~~~~~~~~~~~~~
Archive handle or NULL in case of error
========================================================================
HANDLE PASCAL RAROpenArchiveEx(struct RAROpenArchiveDataEx *ArchiveData)
========================================================================
Description
~~~~~~~~~~~
Similar to RAROpenArchive, but uses RAROpenArchiveDataEx structure
allowing to specify Unicode archive name and returning information
about archive flags.
Parameters
~~~~~~~~~~
ArchiveData Points to RAROpenArchiveDataEx structure
struct RAROpenArchiveDataEx
{
char *ArcName;
wchar_t *ArcNameW;
unsigned int OpenMode;
unsigned int OpenResult;
char *CmtBuf;
unsigned int CmtBufSize;
unsigned int CmtSize;
unsigned int CmtState;
unsigned int Flags;
unsigned int Reserved[32];
};
Structure fields:
ArcNameW
Input parameter which should point to zero terminated Unicode string
containing the archive name or NULL if Unicode name is not specified.
Flags
Output parameter. Combination of bit flags.
Possible values
0x0001 - Volume attribute (archive volume)
0x0002 - Archive comment present
0x0004 - Archive lock attribute
0x0008 - Solid attribute (solid archive)
0x0010 - New volume naming scheme ('volname.partN.rar')
0x0020 - Authenticity information present
0x0040 - Recovery record present
0x0080 - Block headers are encrypted
0x0100 - First volume (set only by RAR 3.0 and later)
Reserved[32]
Reserved for future use. Must be zero.
Information on other structure fields and function return values
is available above, in RAROpenArchive function description.
====================================================================
int PASCAL RARCloseArchive(HANDLE hArcData)
====================================================================
Description
~~~~~~~~~~~
Close RAR archive and release allocated memory. It must be called when
archive processing is finished, even if the archive processing was stopped
due to an error.
Parameters
~~~~~~~~~~
hArcData
This parameter should contain the archive handle obtained from the
RAROpenArchive function call.
Return values
~~~~~~~~~~~~~
0 Success
ERAR_ECLOSE Archive close error
====================================================================
int PASCAL RARReadHeader(HANDLE hArcData,
struct RARHeaderData *HeaderData)
====================================================================
Description
~~~~~~~~~~~
Read header of file in archive.
Parameters
~~~~~~~~~~
hArcData
This parameter should contain the archive handle obtained from the
RAROpenArchive function call.
HeaderData
It should point to RARHeaderData structure:
struct RARHeaderData
{
char ArcName[260];
char FileName[260];
UINT Flags;
UINT PackSize;
UINT UnpSize;
UINT HostOS;
UINT FileCRC;
UINT FileTime;
UINT UnpVer;
UINT Method;
UINT FileAttr;
char *CmtBuf;
UINT CmtBufSize;
UINT CmtSize;
UINT CmtState;
};
Structure fields:
ArcName
Output parameter which contains a zero terminated string of the
current archive name. May be used to determine the current volume
name.
FileName
Output parameter which contains a zero terminated string of the
file name in OEM (DOS) encoding.
Flags
Output parameter which contains file flags:
0x01 - file continued from previous volume
0x02 - file continued on next volume
0x04 - file encrypted with password
0x08 - file comment present
0x10 - compression of previous files is used (solid flag)
bits 7 6 5
0 0 0 - dictionary size 64 Kb
0 0 1 - dictionary size 128 Kb
0 1 0 - dictionary size 256 Kb
0 1 1 - dictionary size 512 Kb
1 0 0 - dictionary size 1024 Kb
1 0 1 - dictionary size 2048 KB
1 1 0 - dictionary size 4096 KB
1 1 1 - file is directory
Other bits are reserved.
PackSize
Output parameter means packed file size or size of the
file part if file was split between volumes.
UnpSize
Output parameter - unpacked file size.
HostOS
Output parameter - operating system used for archiving:
0 - MS DOS;
1 - OS/2.
2 - Win32
3 - Unix
FileCRC
Output parameter which contains unpacked file CRC. In case of file parts
split between volumes only the last part contains the correct CRC
and it is accessible only in RAR_OM_LIST_INCSPLIT listing mode.
FileTime
Output parameter - contains date and time in standard MS DOS format.
UnpVer
Output parameter - RAR version needed to extract file.
It is encoded as 10 * Major version + minor version.
Method
Output parameter - packing method.
FileAttr
Output parameter - file attributes.
CmtBuf
File comments support is not implemented in the new DLL version yet.
Now CmtState is always 0.
/*
* Input parameter which should point to the buffer for file
* comments. Maximum comment size is limited to 64Kb. Comment text is
* a zero terminated string in OEM encoding. If the comment text is
* larger than the buffer size, the comment text will be truncated.
* If CmtBuf is set to NULL, comments will not be read.
*/
CmtBufSize
Input parameter which should contain size of buffer for archive
comments.
CmtSize
Output parameter containing size of comments actually read into the
buffer, should not exceed CmtBufSize.
CmtState
Output parameter.
Possible values
0 Absent comments
1 Comments read completely
ERAR_NO_MEMORY Not enough memory to extract comments
ERAR_BAD_DATA Broken comment
ERAR_UNKNOWN_FORMAT Unknown comment format
ERAR_SMALL_BUF Buffer too small, comments not completely read
Return values
~~~~~~~~~~~~~
0 Success
ERAR_END_ARCHIVE End of archive
ERAR_BAD_DATA File header broken
====================================================================
int PASCAL RARReadHeaderEx(HANDLE hArcData,
struct RARHeaderDataEx *HeaderData)
====================================================================
Description
~~~~~~~~~~~
Similar to RARReadHeader, but uses RARHeaderDataEx structure,
containing information about Unicode file names and 64 bit file sizes.
struct RARHeaderDataEx
{
char ArcName[1024];
wchar_t ArcNameW[1024];
char FileName[1024];
wchar_t FileNameW[1024];
unsigned int Flags;
unsigned int PackSize;
unsigned int PackSizeHigh;
unsigned int UnpSize;
unsigned int UnpSizeHigh;
unsigned int HostOS;
unsigned int FileCRC;
unsigned int FileTime;
unsigned int UnpVer;
unsigned int Method;
unsigned int FileAttr;
char *CmtBuf;
unsigned int CmtBufSize;
unsigned int CmtSize;
unsigned int CmtState;
unsigned int Reserved[1024];
};
====================================================================
int PASCAL RARProcessFile(HANDLE hArcData,
int Operation,
char *DestPath,
char *DestName)
====================================================================
Description
~~~~~~~~~~~
Performs action and moves the current position in the archive to
the next file. Extract or test the current file from the archive
opened in RAR_OM_EXTRACT mode. If the mode RAR_OM_LIST is set,
then a call to this function will simply skip the archive position
to the next file.
Parameters
~~~~~~~~~~
hArcData
This parameter should contain the archive handle obtained from the
RAROpenArchive function call.
Operation
File operation.
Possible values
RAR_SKIP Move to the next file in the archive. If the
archive is solid and RAR_OM_EXTRACT mode was set
when the archive was opened, the current file will
be processed - the operation will be performed
slower than a simple seek.
RAR_TEST Test the current file and move to the next file in
the archive. If the archive was opened with
RAR_OM_LIST mode, the operation is equal to
RAR_SKIP.
RAR_EXTRACT Extract the current file and move to the next file.
If the archive was opened with RAR_OM_LIST mode,
the operation is equal to RAR_SKIP.
DestPath
This parameter should point to a zero terminated string containing the
destination directory to which to extract files to. If DestPath is equal
to NULL, it means extract to the current directory. This parameter has
meaning only if DestName is NULL.
DestName
This parameter should point to a string containing the full path and name
to assign to extracted file or it can be NULL to use the default name.
If DestName is defined (not NULL), it overrides both the original file
name saved in the archive and path specigied in DestPath setting.
Both DestPath and DestName must be in OEM encoding. If necessary,
use CharToOem to convert text to OEM before passing to this function.
Return values
~~~~~~~~~~~~~
0 Success
ERAR_BAD_DATA File CRC error
ERAR_BAD_ARCHIVE Volume is not valid RAR archive
ERAR_UNKNOWN_FORMAT Unknown archive format
ERAR_EOPEN Volume open error
ERAR_ECREATE File create error
ERAR_ECLOSE File close error
ERAR_EREAD Read error
ERAR_EWRITE Write error
Note: if you wish to cancel extraction, return -1 when processing
UCM_PROCESSDATA callback message.
====================================================================
int PASCAL RARProcessFileW(HANDLE hArcData,
int Operation,
wchar_t *DestPath,
wchar_t *DestName)
====================================================================
Description
~~~~~~~~~~~
Unicode version of RARProcessFile. It uses Unicode DestPath
and DestName parameters, other parameters and return values
are the same as in RARProcessFile.
====================================================================
void PASCAL RARSetCallback(HANDLE hArcData,
int PASCAL (*CallbackProc)(UINT msg,LPARAM UserData,LPARAM P1,LPARAM P2),
LPARAM UserData);
====================================================================
Description
~~~~~~~~~~~
Set a user-defined callback function to process Unrar events.
Parameters
~~~~~~~~~~
hArcData
This parameter should contain the archive handle obtained from the
RAROpenArchive function call.
CallbackProc
It should point to a user-defined callback function.
The function will be passed four parameters:
msg Type of event. Described below.
UserData User defined value passed to RARSetCallback.
P1 and P2 Event dependent parameters. Described below.
Possible events
UCM_CHANGEVOLUME Process volume change.
P1 Points to the zero terminated name
of the next volume.
P2 The function call mode:
RAR_VOL_ASK Required volume is absent. The function should
prompt user and return a positive value
to retry or return -1 value to terminate
operation. The function may also specify a new
volume name, placing it to the address specified
by P1 parameter.
RAR_VOL_NOTIFY Required volume is successfully opened.
This is a notification call and volume name
modification is not allowed. The function should
return a positive value to continue or -1
to terminate operation.
UCM_PROCESSDATA Process unpacked data. It may be used to read
a file while it is being extracted or tested
without actual extracting file to disk.
Return a positive value to continue process
or -1 to cancel the archive operation
P1 Address pointing to the unpacked data.
Function may refer to the data but must not
change it.
P2 Size of the unpacked data. It is guaranteed
only that the size will not exceed the maximum
dictionary size (4 Mb in RAR 3.0).
UCM_NEEDPASSWORD DLL needs a password to process archive.
This message must be processed if you wish
to be able to handle archives with encrypted
file names. It can be also used as replacement
of RARSetPassword function even for usual
encrypted files with non-encrypted names.
P1 Address pointing to the buffer for a password.
You need to copy a password here.
P2 Size of the password buffer.
UserData
User data passed to callback function.
Other functions of UnRAR.dll should not be called from the callback
function.
Return values
~~~~~~~~~~~~~
None
====================================================================
void PASCAL RARSetChangeVolProc(HANDLE hArcData,
int PASCAL (*ChangeVolProc)(char *ArcName,int Mode));
====================================================================
Obsoleted, use RARSetCallback instead.
====================================================================
void PASCAL RARSetProcessDataProc(HANDLE hArcData,
int PASCAL (*ProcessDataProc)(unsigned char *Addr,int Size))
====================================================================
Obsoleted, use RARSetCallback instead.
====================================================================
void PASCAL RARSetPassword(HANDLE hArcData,
char *Password);
====================================================================
Description
~~~~~~~~~~~
Set a password to decrypt files.
Parameters
~~~~~~~~~~
hArcData
This parameter should contain the archive handle obtained from the
RAROpenArchive function call.
Password
It should point to a string containing a zero terminated password.
Return values
~~~~~~~~~~~~~
None
====================================================================
void PASCAL RARGetDllVersion();
====================================================================
Description
~~~~~~~~~~~
Returns API version.
Parameters
~~~~~~~~~~
None.
Return values
~~~~~~~~~~~~~
Returns an integer value denoting UnRAR.dll API version, which is also
defined in unrar.h as RAR_DLL_VERSION. API version number is incremented
only in case of noticeable changes in UnRAR.dll API. Do not confuse it
with version of UnRAR.dll stored in DLL resources, which is incremented
with every DLL rebuild.
If RARGetDllVersion() returns a value lower than UnRAR.dll which your
application was designed for, it may indicate that DLL version is too old
and it will fail to provide all necessary functions to your application.
This function is absent in old versions of UnRAR.dll, so it is safer
to use LoadLibrary and GetProcAddress to access this function.

View File

@ -1,80 +0,0 @@
List of unrar.dll API changes. We do not include performance and reliability
improvements into this list, but this library and RAR/UnRAR tools share
the same source code. So the latest version of unrar.dll usually contains
same decompression algorithm changes as the latest UnRAR version.
============================================================================
-- 18 January 2008
all LONG parameters of CallbackProc function were changed
to LPARAM type for 64 bit mode compatibility.
-- 12 December 2007
Added new RAR_OM_LIST_INCSPLIT open mode for function RAROpenArchive.
-- 14 August 2007
Added NoCrypt\unrar_nocrypt.dll without decryption code for those
applications where presence of encryption or decryption code is not
allowed because of legal restrictions.
-- 14 December 2006
Added ERAR_MISSING_PASSWORD error type. This error is returned
if empty password is specified for encrypted file.
-- 12 June 2003
Added RARProcessFileW function, Unicode version of RARProcessFile
-- 9 August 2002
Added RAROpenArchiveEx function allowing to specify Unicode archive
name and get archive flags.
-- 24 January 2002
Added RARReadHeaderEx function allowing to read Unicode file names
and 64 bit file sizes.
-- 23 January 2002
Added ERAR_UNKNOWN error type (it is used for all errors which
do not have special ERAR code yet) and UCM_NEEDPASSWORD callback
message.
Unrar.dll automatically opens all next volumes not only when extracting,
but also in RAR_OM_LIST mode.
-- 27 November 2001
RARSetChangeVolProc and RARSetProcessDataProc are replaced by
the single callback function installed with RARSetCallback.
Unlike old style callbacks, the new function accepts the user defined
parameter. Unrar.dll still supports RARSetChangeVolProc and
RARSetProcessDataProc for compatibility purposes, but if you write
a new application, better use RARSetCallback.
File comments support is not implemented in the new DLL version yet.
Now CmtState is always 0.
-- 13 August 2001
Added RARGetDllVersion function, so you may distinguish old unrar.dll,
which used C style callback functions and the new one with PASCAL callbacks.
-- 10 May 2001
Callback functions in RARSetChangeVolProc and RARSetProcessDataProc
use PASCAL style call convention now.

View File

@ -1 +0,0 @@
This is x64 version of unrar.dll.

View File

@ -1,177 +0,0 @@
# Copyright (c) 2003-2005 Jimmy Retzlaff, 2008 Konstantin Yegupov
#
# 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.
"""
pyUnRAR2 is a ctypes based wrapper around the free UnRAR.dll.
It is an modified version of Jimmy Retzlaff's pyUnRAR - more simple,
stable and foolproof.
Notice that it has INCOMPATIBLE interface.
It enables reading and unpacking of archives created with the
RAR/WinRAR archivers. There is a low-level interface which is very
similar to the C interface provided by UnRAR. There is also a
higher level interface which makes some common operations easier.
"""
__version__ = '0.99.3'
try:
WindowsError
in_windows = True
except NameError:
in_windows = False
if in_windows:
from windows import RarFileImplementation
else:
from unix import RarFileImplementation
import fnmatch, time, weakref
class RarInfo(object):
"""Represents a file header in an archive. Don't instantiate directly.
Use only to obtain information about file.
YOU CANNOT EXTRACT FILE CONTENTS USING THIS OBJECT.
USE METHODS OF RarFile CLASS INSTEAD.
Properties:
index - index of file within the archive
filename - name of the file in the archive including path (if any)
datetime - file date/time as a struct_time suitable for time.strftime
isdir - True if the file is a directory
size - size in bytes of the uncompressed file
comment - comment associated with the file
Note - this is not currently intended to be a Python file-like object.
"""
def __init__(self, rarfile, data):
self.rarfile = weakref.proxy(rarfile)
self.index = data['index']
self.filename = data['filename']
self.isdir = data['isdir']
self.size = data['size']
self.datetime = data['datetime']
self.comment = data['comment']
def __str__(self):
try :
arcName = self.rarfile.archiveName
except ReferenceError:
arcName = "[ARCHIVE_NO_LONGER_LOADED]"
return '<RarInfo "%s" in "%s">' % (self.filename, arcName)
class RarFile(RarFileImplementation):
def __init__(self, archiveName, password=None):
"""Instantiate the archive.
archiveName is the name of the RAR file.
password is used to decrypt the files in the archive.
Properties:
comment - comment associated with the archive
>>> print RarFile('test.rar').comment
This is a test.
"""
self.archiveName = archiveName
RarFileImplementation.init(self, password)
def __del__(self):
self.destruct()
def infoiter(self):
"""Iterate over all the files in the archive, generating RarInfos.
>>> import os
>>> for fileInArchive in RarFile('test.rar').infoiter():
... print os.path.split(fileInArchive.filename)[-1],
... print fileInArchive.isdir,
... print fileInArchive.size,
... print fileInArchive.comment,
... print tuple(fileInArchive.datetime)[0:5],
... print time.strftime('%a, %d %b %Y %H:%M', fileInArchive.datetime)
test True 0 None (2003, 6, 30, 1, 59) Mon, 30 Jun 2003 01:59
test.txt False 20 None (2003, 6, 30, 2, 1) Mon, 30 Jun 2003 02:01
this.py False 1030 None (2002, 2, 8, 16, 47) Fri, 08 Feb 2002 16:47
"""
for params in RarFileImplementation.infoiter(self):
yield RarInfo(self, params)
def infolist(self):
"""Return a list of RarInfos, descripting the contents of the archive."""
return list(self.infoiter())
def read_files(self, condition='*'):
"""Read specific files from archive into memory.
If "condition" is a list of numbers, then return files which have those positions in infolist.
If "condition" is a string, then it is treated as a wildcard for names of files to extract.
If "condition" is a function, it is treated as a callback function, which accepts a RarInfo object
and returns boolean True (extract) or False (skip).
If "condition" is omitted, all files are returned.
Returns list of tuples (RarInfo info, str contents)
"""
checker = condition2checker(condition)
return RarFileImplementation.read_files(self, checker)
def extract(self, condition='*', path='.', withSubpath=True, overwrite=True):
"""Extract specific files from archive to disk.
If "condition" is a list of numbers, then extract files which have those positions in infolist.
If "condition" is a string, then it is treated as a wildcard for names of files to extract.
If "condition" is a function, it is treated as a callback function, which accepts a RarInfo object
and returns either boolean True (extract) or boolean False (skip).
DEPRECATED: If "condition" callback returns string (only supported for Windows) -
that string will be used as a new name to save the file under.
If "condition" is omitted, all files are extracted.
"path" is a directory to extract to
"withSubpath" flag denotes whether files are extracted with their full path in the archive.
"overwrite" flag denotes whether extracted files will overwrite old ones. Defaults to true.
Returns list of RarInfos for extracted files."""
checker = condition2checker(condition)
return RarFileImplementation.extract(self, checker, path, withSubpath, overwrite)
def condition2checker(condition):
"""Converts different condition types to callback"""
if type(condition) in [str, unicode]:
def smatcher(info):
return fnmatch.fnmatch(info.filename, condition)
return smatcher
elif type(condition) in [list, tuple] and type(condition[0]) in [int, long]:
def imatcher(info):
return info.index in condition
return imatcher
elif callable(condition):
return condition
else:
raise TypeError

View File

@ -1,30 +0,0 @@
# Copyright (c) 2003-2005 Jimmy Retzlaff, 2008 Konstantin Yegupov
#
# 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.
# Low level interface - see UnRARDLL\UNRARDLL.TXT
class ArchiveHeaderBroken(Exception): pass
class InvalidRARArchive(Exception): pass
class FileOpenError(Exception): pass
class IncorrectRARPassword(Exception): pass
class InvalidRARArchiveUsage(Exception): pass

View File

@ -1,138 +0,0 @@
import os, sys
import UnRAR2
from UnRAR2.rar_exceptions import *
def cleanup(dir='test'):
for path, dirs, files in os.walk(dir):
for fn in files:
os.remove(os.path.join(path, fn))
for dir in dirs:
os.removedirs(os.path.join(path, dir))
# basic test
cleanup()
rarc = UnRAR2.RarFile('test.rar')
rarc.infolist()
assert rarc.comment == "This is a test."
for info in rarc.infoiter():
saveinfo = info
assert (str(info)=="""<RarInfo "test" in "test.rar">""")
break
rarc.extract()
assert os.path.exists('test'+os.sep+'test.txt')
assert os.path.exists('test'+os.sep+'this.py')
del rarc
assert (str(saveinfo)=="""<RarInfo "test" in "[ARCHIVE_NO_LONGER_LOADED]">""")
cleanup()
# extract all the files in test.rar
cleanup()
UnRAR2.RarFile('test.rar').extract()
assert os.path.exists('test'+os.sep+'test.txt')
assert os.path.exists('test'+os.sep+'this.py')
cleanup()
# extract all the files in test.rar matching the wildcard *.txt
cleanup()
UnRAR2.RarFile('test.rar').extract('*.txt')
assert os.path.exists('test'+os.sep+'test.txt')
assert not os.path.exists('test'+os.sep+'this.py')
cleanup()
# check the name and size of each file, extracting small ones
cleanup()
archive = UnRAR2.RarFile('test.rar')
assert archive.comment == 'This is a test.'
archive.extract(lambda rarinfo: rarinfo.size <= 1024)
for rarinfo in archive.infoiter():
if rarinfo.size <= 1024 and not rarinfo.isdir:
assert rarinfo.size == os.stat(rarinfo.filename).st_size
assert file('test'+os.sep+'test.txt', 'rt').read() == 'This is only a test.'
assert not os.path.exists('test'+os.sep+'this.py')
cleanup()
# extract this.py, overriding it's destination
cleanup('test2')
archive = UnRAR2.RarFile('test.rar')
archive.extract('*.py', 'test2', False)
assert os.path.exists('test2'+os.sep+'this.py')
cleanup('test2')
# extract test.txt to memory
cleanup()
archive = UnRAR2.RarFile('test.rar')
entries = UnRAR2.RarFile('test.rar').read_files('*test.txt')
assert len(entries)==1
assert entries[0][0].filename.endswith('test.txt')
assert entries[0][1]=='This is only a test.'
# extract all the files in test.rar with overwriting
cleanup()
fo = open('test'+os.sep+'test.txt',"wt")
fo.write("blah")
fo.close()
UnRAR2.RarFile('test.rar').extract('*.txt')
assert open('test'+os.sep+'test.txt',"rt").read()!="blah"
cleanup()
# extract all the files in test.rar without overwriting
cleanup()
fo = open('test'+os.sep+'test.txt',"wt")
fo.write("blahblah")
fo.close()
UnRAR2.RarFile('test.rar').extract('*.txt', overwrite = False)
assert open('test'+os.sep+'test.txt',"rt").read()=="blahblah"
cleanup()
# list big file in an archive
list(UnRAR2.RarFile('test_nulls.rar').infoiter())
# extract files from an archive with protected files
cleanup()
rarc = UnRAR2.RarFile('test_protected_files.rar', password="protected")
rarc.extract()
assert os.path.exists('test'+os.sep+'top_secret_xxx_file.txt')
cleanup()
errored = False
try:
UnRAR2.RarFile('test_protected_files.rar', password="proteqted").extract()
except IncorrectRARPassword:
errored = True
assert not os.path.exists('test'+os.sep+'top_secret_xxx_file.txt')
assert errored
cleanup()
# extract files from an archive with protected headers
cleanup()
UnRAR2.RarFile('test_protected_headers.rar', password="secret").extract()
assert os.path.exists('test'+os.sep+'top_secret_xxx_file.txt')
cleanup()
errored = False
try:
UnRAR2.RarFile('test_protected_headers.rar', password="seqret").extract()
except IncorrectRARPassword:
errored = True
assert not os.path.exists('test'+os.sep+'top_secret_xxx_file.txt')
assert errored
cleanup()
# make sure docstring examples are working
import doctest
doctest.testmod(UnRAR2)
# update documentation
import pydoc
pydoc.writedoc(UnRAR2)
# cleanup
try:
os.remove('__init__.pyc')
except:
pass

View File

@ -1,218 +0,0 @@
# Copyright (c) 2003-2005 Jimmy Retzlaff, 2008 Konstantin Yegupov
#
# 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.
# Unix version uses unrar command line executable
import subprocess
import gc
import os, os.path
import time, re
from rar_exceptions import *
class UnpackerNotInstalled(Exception): pass
rar_executable_cached = None
rar_executable_version = None
def call_unrar(params):
"Calls rar/unrar command line executable, returns stdout pipe"
global rar_executable_cached
if rar_executable_cached is None:
for command in ('unrar', 'rar'):
try:
subprocess.Popen([command], stdout=subprocess.PIPE)
rar_executable_cached = command
break
except OSError:
pass
if rar_executable_cached is None:
raise UnpackerNotInstalled("No suitable RAR unpacker installed")
assert type(params) == list, "params must be list"
args = [rar_executable_cached] + params
try:
gc.disable() # See http://bugs.python.org/issue1336
return subprocess.Popen(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
finally:
gc.enable()
class RarFileImplementation(object):
def init(self, password=None):
global rar_executable_version
self.password = password
stdoutdata, stderrdata = self.call('v', []).communicate()
for line in stderrdata.splitlines():
if line.strip().startswith("Cannot open"):
raise FileOpenError
if line.find("CRC failed")>=0:
raise IncorrectRARPassword
accum = []
source = iter(stdoutdata.splitlines())
line = ''
while not (line.startswith('UNRAR')):
line = source.next()
signature = line
# The code below is mighty flaky
# and will probably crash on localized versions of RAR
# but I see no safe way to rewrite it using a CLI tool
if signature.startswith("UNRAR 4"):
rar_executable_version = 4
while not (line.startswith('Comment:') or line.startswith('Pathname/Comment')):
if line.strip().endswith('is not RAR archive'):
raise InvalidRARArchive
line = source.next()
while not line.startswith('Pathname/Comment'):
accum.append(line.rstrip('\n'))
line = source.next()
if len(accum):
accum[0] = accum[0][9:] # strip out "Comment:" part
self.comment = '\n'.join(accum[:-1])
else:
self.comment = None
elif signature.startswith("UNRAR 5"):
rar_executable_version = 5
line = source.next()
while not line.startswith('Archive:'):
if line.strip().endswith('is not RAR archive'):
raise InvalidRARArchive
accum.append(line.rstrip('\n'))
line = source.next()
if len(accum):
self.comment = '\n'.join(accum[:-1]).strip()
else:
self.comment = None
else:
raise UnpackerNotInstalled("Unsupported RAR version, expected 4.x or 5.x, found: "
+ signature.split(" ")[1])
def escaped_password(self):
return '-' if self.password == None else self.password
def call(self, cmd, options=[], files=[]):
options2 = options + ['p'+self.escaped_password()]
soptions = ['-'+x for x in options2]
return call_unrar([cmd]+soptions+['--',self.archiveName]+files)
def infoiter(self):
command = "v" if rar_executable_version == 4 else "l"
stdoutdata, stderrdata = self.call(command, ['c-']).communicate()
for line in stderrdata.splitlines():
if line.strip().startswith("Cannot open"):
raise FileOpenError
accum = []
source = iter(stdoutdata.splitlines())
line = ''
while not line.startswith('-----------'):
if line.strip().endswith('is not RAR archive'):
raise InvalidRARArchive
if line.startswith("CRC failed") or line.startswith("Checksum error"):
raise IncorrectRARPassword
line = source.next()
line = source.next()
i = 0
re_spaces = re.compile(r"\s+")
if rar_executable_version == 4:
while not line.startswith('-----------'):
accum.append(line)
if len(accum)==2:
data = {}
data['index'] = i
# asterisks mark password-encrypted files
data['filename'] = accum[0].strip().lstrip("*") # asterisks marks password-encrypted files
fields = re_spaces.split(accum[1].strip())
data['size'] = int(fields[0])
attr = fields[5]
data['isdir'] = 'd' in attr.lower()
data['datetime'] = time.strptime(fields[3]+" "+fields[4], '%d-%m-%y %H:%M')
data['comment'] = None
yield data
accum = []
i += 1
line = source.next()
elif rar_executable_version == 5:
while not line.startswith('-----------'):
fields = line.strip().lstrip("*").split()
data = {}
data['index'] = i
data['filename'] = " ".join(fields[4:])
data['size'] = int(fields[1])
attr = fields[0]
data['isdir'] = 'd' in attr.lower()
data['datetime'] = time.strptime(fields[2]+" "+fields[3], '%d-%m-%y %H:%M')
data['comment'] = None
yield data
i += 1
line = source.next()
def read_files(self, checker):
res = []
for info in self.infoiter():
checkres = checker(info)
if checkres==True and not info.isdir:
pipe = self.call('p', ['inul'], [info.filename]).stdout
res.append((info, pipe.read()))
return res
def extract(self, checker, path, withSubpath, overwrite):
res = []
command = 'x'
if not withSubpath:
command = 'e'
options = []
if overwrite:
options.append('o+')
else:
options.append('o-')
if not path.endswith(os.sep):
path += os.sep
names = []
for info in self.infoiter():
checkres = checker(info)
if type(checkres) in [str, unicode]:
raise NotImplementedError("Condition callbacks returning strings are deprecated and only supported in Windows")
if checkres==True and not info.isdir:
names.append(info.filename)
res.append(info)
names.append(path)
proc = self.call(command, options, names)
stdoutdata, stderrdata = proc.communicate()
if stderrdata.find("CRC failed")>=0 or stderrdata.find("Checksum error")>=0:
raise IncorrectRARPassword
return res
def destruct(self):
pass

View File

@ -1,309 +0,0 @@
# Copyright (c) 2003-2005 Jimmy Retzlaff, 2008 Konstantin Yegupov
#
# 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.
# Low level interface - see UnRARDLL\UNRARDLL.TXT
from __future__ import generators
import ctypes, ctypes.wintypes
import os, os.path, sys
import Queue
import time
from rar_exceptions import *
ERAR_END_ARCHIVE = 10
ERAR_NO_MEMORY = 11
ERAR_BAD_DATA = 12
ERAR_BAD_ARCHIVE = 13
ERAR_UNKNOWN_FORMAT = 14
ERAR_EOPEN = 15
ERAR_ECREATE = 16
ERAR_ECLOSE = 17
ERAR_EREAD = 18
ERAR_EWRITE = 19
ERAR_SMALL_BUF = 20
ERAR_UNKNOWN = 21
RAR_OM_LIST = 0
RAR_OM_EXTRACT = 1
RAR_SKIP = 0
RAR_TEST = 1
RAR_EXTRACT = 2
RAR_VOL_ASK = 0
RAR_VOL_NOTIFY = 1
RAR_DLL_VERSION = 3
# enum UNRARCALLBACK_MESSAGES
UCM_CHANGEVOLUME = 0
UCM_PROCESSDATA = 1
UCM_NEEDPASSWORD = 2
architecture_bits = ctypes.sizeof(ctypes.c_voidp)*8
dll_name = "unrar.dll"
if architecture_bits == 64:
dll_name = "x64\\unrar64.dll"
try:
unrar = ctypes.WinDLL(os.path.join(os.path.split(__file__)[0], 'UnRARDLL', dll_name))
except WindowsError:
unrar = ctypes.WinDLL(dll_name)
class RAROpenArchiveDataEx(ctypes.Structure):
def __init__(self, ArcName=None, ArcNameW=u'', OpenMode=RAR_OM_LIST):
self.CmtBuf = ctypes.c_buffer(64*1024)
ctypes.Structure.__init__(self, ArcName=ArcName, ArcNameW=ArcNameW, OpenMode=OpenMode, _CmtBuf=ctypes.addressof(self.CmtBuf), CmtBufSize=ctypes.sizeof(self.CmtBuf))
_fields_ = [
('ArcName', ctypes.c_char_p),
('ArcNameW', ctypes.c_wchar_p),
('OpenMode', ctypes.c_uint),
('OpenResult', ctypes.c_uint),
('_CmtBuf', ctypes.c_voidp),
('CmtBufSize', ctypes.c_uint),
('CmtSize', ctypes.c_uint),
('CmtState', ctypes.c_uint),
('Flags', ctypes.c_uint),
('Reserved', ctypes.c_uint*32),
]
class RARHeaderDataEx(ctypes.Structure):
def __init__(self):
self.CmtBuf = ctypes.c_buffer(64*1024)
ctypes.Structure.__init__(self, _CmtBuf=ctypes.addressof(self.CmtBuf), CmtBufSize=ctypes.sizeof(self.CmtBuf))
_fields_ = [
('ArcName', ctypes.c_char*1024),
('ArcNameW', ctypes.c_wchar*1024),
('FileName', ctypes.c_char*1024),
('FileNameW', ctypes.c_wchar*1024),
('Flags', ctypes.c_uint),
('PackSize', ctypes.c_uint),
('PackSizeHigh', ctypes.c_uint),
('UnpSize', ctypes.c_uint),
('UnpSizeHigh', ctypes.c_uint),
('HostOS', ctypes.c_uint),
('FileCRC', ctypes.c_uint),
('FileTime', ctypes.c_uint),
('UnpVer', ctypes.c_uint),
('Method', ctypes.c_uint),
('FileAttr', ctypes.c_uint),
('_CmtBuf', ctypes.c_voidp),
('CmtBufSize', ctypes.c_uint),
('CmtSize', ctypes.c_uint),
('CmtState', ctypes.c_uint),
('Reserved', ctypes.c_uint*1024),
]
def DosDateTimeToTimeTuple(dosDateTime):
"""Convert an MS-DOS format date time to a Python time tuple.
"""
dosDate = dosDateTime >> 16
dosTime = dosDateTime & 0xffff
day = dosDate & 0x1f
month = (dosDate >> 5) & 0xf
year = 1980 + (dosDate >> 9)
second = 2*(dosTime & 0x1f)
minute = (dosTime >> 5) & 0x3f
hour = dosTime >> 11
return time.localtime(time.mktime((year, month, day, hour, minute, second, 0, 1, -1)))
def _wrap(restype, function, argtypes):
result = function
result.argtypes = argtypes
result.restype = restype
return result
RARGetDllVersion = _wrap(ctypes.c_int, unrar.RARGetDllVersion, [])
RAROpenArchiveEx = _wrap(ctypes.wintypes.HANDLE, unrar.RAROpenArchiveEx, [ctypes.POINTER(RAROpenArchiveDataEx)])
RARReadHeaderEx = _wrap(ctypes.c_int, unrar.RARReadHeaderEx, [ctypes.wintypes.HANDLE, ctypes.POINTER(RARHeaderDataEx)])
_RARSetPassword = _wrap(ctypes.c_int, unrar.RARSetPassword, [ctypes.wintypes.HANDLE, ctypes.c_char_p])
def RARSetPassword(*args, **kwargs):
_RARSetPassword(*args, **kwargs)
RARProcessFile = _wrap(ctypes.c_int, unrar.RARProcessFile, [ctypes.wintypes.HANDLE, ctypes.c_int, ctypes.c_char_p, ctypes.c_char_p])
RARCloseArchive = _wrap(ctypes.c_int, unrar.RARCloseArchive, [ctypes.wintypes.HANDLE])
UNRARCALLBACK = ctypes.WINFUNCTYPE(ctypes.c_int, ctypes.c_uint, ctypes.c_long, ctypes.c_long, ctypes.c_long)
RARSetCallback = _wrap(ctypes.c_int, unrar.RARSetCallback, [ctypes.wintypes.HANDLE, UNRARCALLBACK, ctypes.c_long])
RARExceptions = {
ERAR_NO_MEMORY : MemoryError,
ERAR_BAD_DATA : ArchiveHeaderBroken,
ERAR_BAD_ARCHIVE : InvalidRARArchive,
ERAR_EOPEN : FileOpenError,
}
class PassiveReader:
"""Used for reading files to memory"""
def __init__(self, usercallback = None):
self.buf = []
self.ucb = usercallback
def _callback(self, msg, UserData, P1, P2):
if msg == UCM_PROCESSDATA:
data = (ctypes.c_char*P2).from_address(P1).raw
if self.ucb!=None:
self.ucb(data)
else:
self.buf.append(data)
return 1
def get_result(self):
return ''.join(self.buf)
class RarInfoIterator(object):
def __init__(self, arc):
self.arc = arc
self.index = 0
self.headerData = RARHeaderDataEx()
self.res = RARReadHeaderEx(self.arc._handle, ctypes.byref(self.headerData))
if self.res==ERAR_BAD_DATA:
raise IncorrectRARPassword
self.arc.lockStatus = "locked"
self.arc.needskip = False
def __iter__(self):
return self
def next(self):
if self.index>0:
if self.arc.needskip:
RARProcessFile(self.arc._handle, RAR_SKIP, None, None)
self.res = RARReadHeaderEx(self.arc._handle, ctypes.byref(self.headerData))
if self.res:
raise StopIteration
self.arc.needskip = True
data = {}
data['index'] = self.index
data['filename'] = self.headerData.FileName
data['datetime'] = DosDateTimeToTimeTuple(self.headerData.FileTime)
data['isdir'] = ((self.headerData.Flags & 0xE0) == 0xE0)
data['size'] = self.headerData.UnpSize + (self.headerData.UnpSizeHigh << 32)
if self.headerData.CmtState == 1:
data['comment'] = self.headerData.CmtBuf.value
else:
data['comment'] = None
self.index += 1
return data
def __del__(self):
self.arc.lockStatus = "finished"
def generate_password_provider(password):
def password_provider_callback(msg, UserData, P1, P2):
if msg == UCM_NEEDPASSWORD and password!=None:
(ctypes.c_char*P2).from_address(P1).value = password
return 1
return password_provider_callback
class RarFileImplementation(object):
def init(self, password=None):
self.password = password
archiveData = RAROpenArchiveDataEx(ArcNameW=self.archiveName, OpenMode=RAR_OM_EXTRACT)
self._handle = RAROpenArchiveEx(ctypes.byref(archiveData))
self.c_callback = UNRARCALLBACK(generate_password_provider(self.password))
RARSetCallback(self._handle, self.c_callback, 1)
if archiveData.OpenResult != 0:
raise RARExceptions[archiveData.OpenResult]
if archiveData.CmtState == 1:
self.comment = archiveData.CmtBuf.value
else:
self.comment = None
if password:
RARSetPassword(self._handle, password)
self.lockStatus = "ready"
def destruct(self):
if self._handle and RARCloseArchive:
RARCloseArchive(self._handle)
def make_sure_ready(self):
if self.lockStatus == "locked":
raise InvalidRARArchiveUsage("cannot execute infoiter() without finishing previous one")
if self.lockStatus == "finished":
self.destruct()
self.init(self.password)
def infoiter(self):
self.make_sure_ready()
return RarInfoIterator(self)
def read_files(self, checker):
res = []
for info in self.infoiter():
if checker(info) and not info.isdir:
reader = PassiveReader()
c_callback = UNRARCALLBACK(reader._callback)
RARSetCallback(self._handle, c_callback, 1)
tmpres = RARProcessFile(self._handle, RAR_TEST, None, None)
if tmpres==ERAR_BAD_DATA:
raise IncorrectRARPassword
self.needskip = False
res.append((info, reader.get_result()))
return res
def extract(self, checker, path, withSubpath, overwrite):
res = []
for info in self.infoiter():
checkres = checker(info)
if checkres!=False and not info.isdir:
if checkres==True:
fn = info.filename
if not withSubpath:
fn = os.path.split(fn)[-1]
target = os.path.join(path, fn)
else:
raise DeprecationWarning, "Condition callbacks returning strings are deprecated and only supported in Windows"
target = checkres
if overwrite or (not os.path.exists(target)):
tmpres = RARProcessFile(self._handle, RAR_EXTRACT, None, target)
if tmpres==ERAR_BAD_DATA:
raise IncorrectRARPassword
self.needskip = False
res.append(info)
return res

View File

@ -0,0 +1 @@
from __future__ import annotations

View File

@ -0,0 +1,11 @@
from __future__ import annotations
import os
import comicapi.__pyinstaller
def get_hook_dirs() -> list[str]:
hooks = [os.path.dirname(__file__)]
hooks.extend(comicapi.__pyinstaller.get_hook_dirs())
return hooks

View File

@ -0,0 +1,7 @@
from __future__ import annotations
from PyInstaller.utils.hooks import collect_data_files
datas = []
datas += collect_data_files("comictaggerlib.ui")
datas += collect_data_files("comictaggerlib.graphics")

View File

@ -0,0 +1,7 @@
from __future__ import annotations
import os
from PyInstaller.utils.hooks import get_module_file_attribute
datas = [(os.path.join(os.path.dirname(get_module_file_attribute("wordninja")), "wordninja"), "wordninja")]

View File

@ -0,0 +1,50 @@
from __future__ import annotations
import logging
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from comictaggerlib.ui import ui_path
logger = logging.getLogger(__name__)
class QTextEditLogger(QtCore.QObject, logging.Handler):
qlog = QtCore.pyqtSignal(str)
def __init__(self, formatter: logging.Formatter, level: int) -> None:
super().__init__()
self.setFormatter(formatter)
self.setLevel(level)
def emit(self, record: logging.LogRecord) -> None:
msg = self.format(record)
self.qlog.emit(msg.strip())
class ApplicationLogWindow(QtWidgets.QDialog):
def __init__(self, log_handler: QTextEditLogger, parent: QtCore.QObject | None = None) -> None:
super().__init__(parent)
uic.loadUi(ui_path / "logwindow.ui", self)
self.log_handler = log_handler
self.log_handler.qlog.connect(self.textEdit.append)
f = QtGui.QFont("menlo")
f.setStyleHint(QtGui.QFont.Monospace)
self.setFont(f)
self._button = QtWidgets.QPushButton(self)
self._button.setText("Test Me")
layout = self.layout()
layout.addWidget(self._button)
# Connect signal to slot
self._button.clicked.connect(self.test)
self.textEdit.setTabStopDistance(self.textEdit.tabStopDistance() * 2)
def test(self) -> None:
logger.debug("damn, a bug")
logger.info("something to remember")
logger.warning("that's not right")
logger.error("foobar")

View File

@ -1,233 +1,259 @@
"""
A PyQT4 dialog to select from automated issue matches
"""
"""A PyQT4 dialog to select from automated issue matches"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import sys
import logging
import os
from PyQt4 import QtCore, QtGui, uic
from typing import Callable
from PyQt4.QtCore import QUrl, pyqtSignal, QByteArray
import settngs
from PyQt5 import QtCore, QtGui, QtWidgets, uic
from imagefetcher import ImageFetcher
from settings import ComicTaggerSettings
from comicarchive import MetaDataStyle
from coverimagewidget import CoverImageWidget
from comicvinetalker import ComicVineTalker
import utils
from comicapi.comicarchive import MetaDataStyle
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.resulttypes import IssueResult, MultipleMatch
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import reduce_widget_font_size
from comictalker.comictalker import ComicTalker
class AutoTagMatchWindow(QtGui.QDialog):
volume_id = 0
def __init__(self, parent, match_set_list, style, fetch_func):
super(AutoTagMatchWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('matchselectionwindow.ui' ), self)
logger = logging.getLogger(__name__)
self.altCoverWidget = CoverImageWidget( self.altCoverContainer, CoverImageWidget.AltCoverMode )
gridlayout = QtGui.QGridLayout( self.altCoverContainer )
gridlayout.addWidget( self.altCoverWidget )
gridlayout.setContentsMargins(0,0,0,0)
self.archiveCoverWidget = CoverImageWidget( self.archiveCoverContainer, CoverImageWidget.ArchiveMode )
gridlayout = QtGui.QGridLayout( self.archiveCoverContainer )
gridlayout.addWidget( self.archiveCoverWidget )
gridlayout.setContentsMargins(0,0,0,0)
class AutoTagMatchWindow(QtWidgets.QDialog):
def __init__(
self,
parent: QtWidgets.QWidget,
match_set_list: list[MultipleMatch],
style: int,
fetch_func: Callable[[IssueResult], GenericMetadata],
config: settngs.Namespace,
talker: ComicTalker,
) -> None:
super().__init__(parent)
utils.reduceWidgetFontSize( self.twList )
utils.reduceWidgetFontSize( self.teDescription, 1 )
uic.loadUi(ui_path / "matchselectionwindow.ui", self)
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
self.skipButton = QtGui.QPushButton(self.tr("Skip to Next"))
self.buttonBox.addButton(self.skipButton, QtGui.QDialogButtonBox.ActionRole)
self.buttonBox.button(QtGui.QDialogButtonBox.Ok).setText("Accept and Write Tags")
self.config = config
self.match_set_list = match_set_list
self.style = style
self.fetch_func = fetch_func
self.current_match_set: MultipleMatch = match_set_list[0]
self.current_match_set_idx = 0
self.twList.currentItemChanged.connect(self.currentItemChanged)
self.twList.cellDoubleClicked.connect(self.cellDoubleClicked)
self.skipButton.clicked.connect(self.skipToNext)
self.updateData()
self.altCoverWidget = CoverImageWidget(
self.altCoverContainer, CoverImageWidget.AltCoverMode, config.runtime_config.user_cache_dir, talker
)
gridlayout = QtWidgets.QGridLayout(self.altCoverContainer)
gridlayout.addWidget(self.altCoverWidget)
gridlayout.setContentsMargins(0, 0, 0, 0)
def updateData( self):
self.archiveCoverWidget = CoverImageWidget(self.archiveCoverContainer, CoverImageWidget.ArchiveMode, None, None)
gridlayout = QtWidgets.QGridLayout(self.archiveCoverContainer)
gridlayout.addWidget(self.archiveCoverWidget)
gridlayout.setContentsMargins(0, 0, 0, 0)
self.current_match_set = self.match_set_list[ self.current_match_set_idx ]
reduce_widget_font_size(self.twList)
reduce_widget_font_size(self.teDescription, 1)
if self.current_match_set_idx + 1 == len( self.match_set_list ):
self.buttonBox.button(QtGui.QDialogButtonBox.Cancel).setDisabled(True)
#self.buttonBox.button(QtGui.QDialogButtonBox.Ok).setText("Accept")
self.skipButton.setText(self.tr("Skip"))
self.setCoverImage()
self.populateTable()
self.twList.resizeColumnsToContents()
self.twList.selectRow( 0 )
path = self.current_match_set.ca.path
self.setWindowTitle( u"Select correct match or skip ({0} of {1}): {2}".format(
self.current_match_set_idx+1,
len( self.match_set_list ),
os.path.split(path)[1] ))
def populateTable( self ):
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
while self.twList.rowCount() > 0:
self.twList.removeRow(0)
self.twList.setSortingEnabled(False)
self.skipButton = QtWidgets.QPushButton("Skip to Next")
self.buttonBox.addButton(self.skipButton, QtWidgets.QDialogButtonBox.ButtonRole.ActionRole)
self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Ok).setText("Accept and Write Tags")
row = 0
for match in self.current_match_set.matches:
self.twList.insertRow(row)
item_text = match['series']
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setData( QtCore.Qt.UserRole, (match,))
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 0, item)
self.match_set_list = match_set_list
self._style = style
self.fetch_func = fetch_func
if match['publisher'] is not None:
item_text = u"{0}".format(match['publisher'])
else:
item_text = u"Unknown"
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 1, item)
month_str = u""
year_str = u"????"
if match['month'] is not None:
month_str = u"-{0:02d}".format(int(match['month']))
if match['year'] is not None:
year_str = u"{0}".format(match['year'])
self.current_match_set_idx = 0
item_text = year_str + month_str
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 2, item)
self.twList.currentItemChanged.connect(self.current_item_changed)
self.twList.cellDoubleClicked.connect(self.cell_double_clicked)
self.skipButton.clicked.connect(self.skip_to_next)
item_text = match['issue_title']
if item_text is None:
item_text = ""
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 3, item)
row += 1
self.update_data()
self.twList.resizeColumnsToContents()
self.twList.setSortingEnabled(True)
self.twList.sortItems( 2 , QtCore.Qt.AscendingOrder )
self.twList.selectRow(0)
self.twList.resizeColumnsToContents()
self.twList.horizontalHeader().setStretchLastSection(True)
def update_data(self) -> None:
self.current_match_set = self.match_set_list[self.current_match_set_idx]
def cellDoubleClicked( self, r, c ):
self.accept()
def currentItemChanged( self, curr, prev ):
if self.current_match_set_idx + 1 == len(self.match_set_list):
self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Cancel).setDisabled(True)
self.skipButton.setText("Skip")
if curr is None:
return
if prev is not None and prev.row() == curr.row():
return
self.altCoverWidget.setIssueID( self.currentMatch()['issue_id'] )
if self.currentMatch()['description'] is None:
self.teDescription.setText ( "" )
else:
self.teDescription.setText ( self.currentMatch()['description'] )
def setCoverImage( self ):
ca = self.current_match_set.ca
self.archiveCoverWidget.setArchive(ca)
self.set_cover_image()
self.populate_table()
self.twList.resizeColumnsToContents()
self.twList.selectRow(0)
def currentMatch( self ):
row = self.twList.currentRow()
match = self.twList.item(row, 0).data( QtCore.Qt.UserRole ).toPyObject()[0]
return match
def accept(self):
path = self.current_match_set.ca.path
self.setWindowTitle(
"Select correct match or skip ({} of {}): {}".format(
self.current_match_set_idx + 1,
len(self.match_set_list),
os.path.split(path)[1],
)
)
self.saveMatch()
self.current_match_set_idx += 1
if self.current_match_set_idx == len( self.match_set_list ):
# no more items
QtGui.QDialog.accept(self)
else:
self.updateData()
def populate_table(self) -> None:
if not self.current_match_set:
return
def skipToNext( self ):
self.current_match_set_idx += 1
if self.current_match_set_idx == len( self.match_set_list ):
# no more items
QtGui.QDialog.reject(self)
else:
self.updateData()
def reject(self):
reply = QtGui.QMessageBox.question(self,
self.tr("Cancel Matching"),
self.tr("Are you sure you wish to cancel the matching process?"),
QtGui.QMessageBox.Yes, QtGui.QMessageBox.No )
if reply == QtGui.QMessageBox.No:
return
self.twList.setRowCount(0)
QtGui.QDialog.reject(self)
def saveMatch( self ):
match = self.currentMatch()
ca = self.current_match_set.ca
self.twList.setSortingEnabled(False)
md = ca.readMetadata( self.style )
if md.isEmpty:
md = ca.metadataFromFilename()
# now get the particular issue data
cv_md = self.fetch_func( match )
if cv_md is None:
QtGui.QMessageBox.critical(self, self.tr("Network Issue"), self.tr("Could not connect to ComicVine to get issue details!"))
return
row = 0
for match in self.current_match_set.matches:
self.twList.insertRow(row)
QtGui.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor))
md.overlay( cv_md )
success = ca.writeMetadata( md, self.style )
ca.loadCache( [ MetaDataStyle.CBI, MetaDataStyle.CIX ] )
QtGui.QApplication.restoreOverrideCursor()
if not success:
QtGui.QMessageBox.warning(self, self.tr("Write Error"), self.tr("Saving the tags to the archive seemed to fail!"))
item_text = match["series"]
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setData(QtCore.Qt.ItemDataRole.UserRole, (match,))
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 0, item)
if match["publisher"] is not None:
item_text = str(match["publisher"])
else:
item_text = "Unknown"
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 1, item)
month_str = ""
year_str = "????"
if match["month"] is not None:
month_str = f"-{int(match['month']):02d}"
if match["year"] is not None:
year_str = str(match["year"])
item_text = year_str + month_str
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 2, item)
item_text = match["issue_title"]
if item_text is None:
item_text = ""
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 3, item)
row += 1
self.twList.resizeColumnsToContents()
self.twList.setSortingEnabled(True)
self.twList.sortItems(2, QtCore.Qt.SortOrder.AscendingOrder)
self.twList.selectRow(0)
self.twList.resizeColumnsToContents()
self.twList.horizontalHeader().setStretchLastSection(True)
def cell_double_clicked(self, r: int, c: int) -> None:
self.accept()
def current_item_changed(self, curr: QtCore.QModelIndex, prev: QtCore.QModelIndex) -> None:
if curr is None:
return None
if prev is not None and prev.row() == curr.row():
return None
self.altCoverWidget.set_issue_details(
self.current_match()["issue_id"],
[self.current_match()["image_url"], *self.current_match()["alt_image_urls"]],
)
if self.current_match()["description"] is None:
self.teDescription.setText("")
else:
self.teDescription.setText(self.current_match()["description"])
def set_cover_image(self) -> None:
ca = self.current_match_set.ca
self.archiveCoverWidget.set_archive(ca)
def current_match(self) -> IssueResult:
row = self.twList.currentRow()
match: IssueResult = self.twList.item(row, 0).data(QtCore.Qt.ItemDataRole.UserRole)[0]
return match
def accept(self) -> None:
self.save_match()
self.current_match_set_idx += 1
if self.current_match_set_idx == len(self.match_set_list):
# no more items
QtWidgets.QDialog.accept(self)
else:
self.update_data()
def skip_to_next(self) -> None:
self.current_match_set_idx += 1
if self.current_match_set_idx == len(self.match_set_list):
# no more items
QtWidgets.QDialog.reject(self)
else:
self.update_data()
def reject(self) -> None:
reply = QtWidgets.QMessageBox.question(
self,
"Cancel Matching",
"Are you sure you wish to cancel the matching process?",
QtWidgets.QMessageBox.StandardButton.Yes,
QtWidgets.QMessageBox.StandardButton.No,
)
if reply == QtWidgets.QMessageBox.StandardButton.No:
return
QtWidgets.QDialog.reject(self)
def save_match(self) -> None:
match = self.current_match()
ca = self.current_match_set.ca
md = ca.read_metadata(self._style)
if md.is_empty:
md = ca.metadata_from_filename(
self.config.filename_complicated_parser,
self.config.filename_remove_c2c,
self.config.filename_remove_fcbd,
self.config.filename_remove_publisher,
)
# now get the particular issue data
ct_md = self.fetch_func(match)
if ct_md is None:
QtWidgets.QMessageBox.critical(self, "Network Issue", "Could not retrieve issue details!")
return
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
md.overlay(ct_md)
success = ca.write_metadata(md, self._style)
ca.load_cache([MetaDataStyle.CBI, MetaDataStyle.CIX])
QtWidgets.QApplication.restoreOverrideCursor()
if not success:
QtWidgets.QMessageBox.warning(self, "Write Error", "Saving the tags to the archive seemed to fail!")

View File

@ -1,69 +1,73 @@
"""
A PyQT4 dialog to show ID log and progress
"""
"""A PyQT4 dialog to show ID log and progress"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
import logging
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
from PyQt5 import QtCore, QtWidgets, uic
http://www.apache.org/licenses/LICENSE-2.0
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import reduce_widget_font_size
from comictalker.comictalker import ComicTalker
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.
"""
logger = logging.getLogger(__name__)
import sys
from PyQt4 import QtCore, QtGui, uic
import os
from settings import ComicTaggerSettings
from coverimagewidget import CoverImageWidget
import utils
class AutoTagProgressWindow(QtGui.QDialog):
def __init__(self, parent):
super(AutoTagProgressWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('autotagprogresswindow.ui' ), self)
self.archiveCoverWidget = CoverImageWidget( self.archiveCoverContainer, CoverImageWidget.DataMode, False )
gridlayout = QtGui.QGridLayout( self.archiveCoverContainer )
gridlayout.addWidget( self.archiveCoverWidget )
gridlayout.setContentsMargins(0,0,0,0)
class AutoTagProgressWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget, talker: ComicTalker) -> None:
super().__init__(parent)
self.testCoverWidget = CoverImageWidget( self.testCoverContainer, CoverImageWidget.DataMode, False )
gridlayout = QtGui.QGridLayout( self.testCoverContainer )
gridlayout.addWidget( self.testCoverWidget )
gridlayout.setContentsMargins(0,0,0,0)
self.isdone = False
uic.loadUi(ui_path / "autotagprogresswindow.ui", self)
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
self.archiveCoverWidget = CoverImageWidget(
self.archiveCoverContainer, CoverImageWidget.DataMode, None, None, False
)
gridlayout = QtWidgets.QGridLayout(self.archiveCoverContainer)
gridlayout.addWidget(self.archiveCoverWidget)
gridlayout.setContentsMargins(0, 0, 0, 0)
utils.reduceWidgetFontSize( self.textEdit )
def setArchiveImage( self, img_data):
self.setCoverImage( img_data, self.archiveCoverWidget)
self.testCoverWidget = CoverImageWidget(self.testCoverContainer, CoverImageWidget.DataMode, None, None, False)
gridlayout = QtWidgets.QGridLayout(self.testCoverContainer)
gridlayout.addWidget(self.testCoverWidget)
gridlayout.setContentsMargins(0, 0, 0, 0)
def setTestImage( self, img_data):
self.setCoverImage( img_data, self.testCoverWidget)
self.isdone = False
def setCoverImage( self, img_data , widget):
widget.setImageData( img_data )
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.processEvents()
def reject(self):
QtGui.QDialog.reject(self)
self.isdone = True
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
reduce_widget_font_size(self.textEdit)
def set_archive_image(self, img_data: bytes) -> None:
self.set_cover_image(img_data, self.archiveCoverWidget)
def set_test_image(self, img_data: bytes) -> None:
self.set_cover_image(img_data, self.testCoverWidget)
def set_cover_image(self, img_data: bytes, widget: CoverImageWidget) -> None:
widget.set_image_data(img_data)
QtCore.QCoreApplication.processEvents()
QtCore.QCoreApplication.processEvents()
def reject(self) -> None:
QtWidgets.QDialog.reject(self)
self.isdone = True

View File

@ -1,122 +1,106 @@
"""
A PyQT4 dialog to confirm and set options for auto-tag
"""
"""A PyQT4 dialog to confirm and set config for auto-tag"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
import logging
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
import settngs
from PyQt5 import QtCore, QtWidgets, uic
http://www.apache.org/licenses/LICENSE-2.0
from comictaggerlib.ui import ui_path
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.
"""
logger = logging.getLogger(__name__)
from PyQt4 import QtCore, QtGui, uic
from settings import ComicTaggerSettings
from settingswindow import SettingsWindow
from filerenamer import FileRenamer
import os
import utils
class AutoTagStartWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget, config: settngs.Namespace, msg: str) -> None:
super().__init__(parent)
class AutoTagStartWindow(QtGui.QDialog):
def __init__( self, parent, settings, msg ):
super(AutoTagStartWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('autotagstartwindow.ui' ), self)
self.label.setText( msg )
uic.loadUi(ui_path / "autotagstartwindow.ui", self)
self.label.setText(msg)
self.setWindowFlags(self.windowFlags() &
~QtCore.Qt.WindowContextHelpButtonHint )
self.setWindowFlags(
QtCore.Qt.WindowType(self.windowFlags() & ~QtCore.Qt.WindowType.WindowContextHelpButtonHint)
)
self.settings = settings
self.cbxSaveOnLowConfidence.setCheckState( QtCore.Qt.Unchecked )
self.cbxDontUseYear.setCheckState( QtCore.Qt.Unchecked )
self.cbxAssumeIssueOne.setCheckState( QtCore.Qt.Unchecked )
self.cbxIgnoreLeadingDigitsInFilename.setCheckState( QtCore.Qt.Unchecked )
self.cbxRemoveAfterSuccess.setCheckState( QtCore.Qt.Unchecked )
self.cbxSpecifySearchString.setCheckState( QtCore.Qt.Unchecked )
self.leNameLengthMatchTolerance.setText( str(self.settings.id_length_delta_thresh) )
self.leSearchString.setEnabled( False )
if self.settings.save_on_low_confidence:
self.cbxSaveOnLowConfidence.setCheckState( QtCore.Qt.Checked)
if self.settings.dont_use_year_when_identifying:
self.cbxDontUseYear.setCheckState( QtCore.Qt.Checked)
if self.settings.assume_1_if_no_issue_num:
self.cbxAssumeIssueOne.setCheckState( QtCore.Qt.Checked)
if self.settings.ignore_leading_numbers_in_filename:
self.cbxIgnoreLeadingDigitsInFilename.setCheckState( QtCore.Qt.Checked)
if self.settings.remove_archive_after_successful_match:
self.cbxRemoveAfterSuccess.setCheckState( QtCore.Qt.Checked)
self.config = config
nlmtTip = (
""" <html>The <b>Name Length Match Tolerance</b> is for eliminating automatic
search matches that are too long compared to your series name search. The higher
it is, the more likely to have a good match, but each search will take longer and
use more bandwidth. Too low, and only the very closest lexical matches will be
explored.</html>""" )
self.leNameLengthMatchTolerance.setToolTip(nlmtTip)
ssTip = (
"""<html>
The <b>series search string</b> specifies the search string to be used for all selected archives.
Use this when trying to match archives with hard-to-parse or incorrect filenames. All archives selected
should be from the same series.
</html>"""
)
self.leSearchString.setToolTip(ssTip)
self.cbxSpecifySearchString.setToolTip(ssTip)
validator = QtGui.QIntValidator(0, 99, self)
self.leNameLengthMatchTolerance.setValidator(validator)
self.cbxSpecifySearchString.stateChanged.connect(self.searchStringToggle)
self.autoSaveOnLow = False
self.dontUseYear = False
self.assumeIssueOne = False
self.ignoreLeadingDigitsInFilename = False
self.removeAfterSuccess = False
self.searchString = None
self.nameLengthMatchTolerance = self.settings.id_length_delta_thresh
self.cbxSpecifySearchString.setChecked(False)
self.cbxSplitWords.setChecked(False)
self.sbNameMatchSearchThresh.setValue(self.config.identifier_series_match_identify_thresh)
self.leSearchString.setEnabled(False)
def searchStringToggle(self):
enable = self.cbxSpecifySearchString.isChecked()
self.leSearchString.setEnabled( enable )
self.cbxSaveOnLowConfidence.setChecked(self.config.autotag_save_on_low_confidence)
self.cbxDontUseYear.setChecked(self.config.autotag_dont_use_year_when_identifying)
self.cbxAssumeIssueOne.setChecked(self.config.autotag_assume_1_if_no_issue_num)
self.cbxIgnoreLeadingDigitsInFilename.setChecked(self.config.autotag_ignore_leading_numbers_in_filename)
self.cbxRemoveAfterSuccess.setChecked(self.config.autotag_remove_archive_after_successful_match)
self.cbxWaitForRateLimit.setChecked(self.config.autotag_wait_and_retry_on_rate_limit)
self.cbxAutoImprint.setChecked(self.config.talker_auto_imprint)
def accept( self ):
QtGui.QDialog.accept(self)
nlmt_tip = """<html>The <b>Name Match Ratio Threshold: Auto-Identify</b> is for eliminating automatic
search matches that are too long compared to your series name search. The lower
it is, the more likely to have a good match, but each search will take longer and
use more bandwidth. Too high, and only the very closest matches will be explored.</html>"""
self.autoSaveOnLow = self.cbxSaveOnLowConfidence.isChecked()
self.dontUseYear = self.cbxDontUseYear.isChecked()
self.assumeIssueOne = self.cbxAssumeIssueOne.isChecked()
self.ignoreLeadingDigitsInFilename = self.cbxIgnoreLeadingDigitsInFilename.isChecked()
self.removeAfterSuccess = self.cbxRemoveAfterSuccess.isChecked()
self.nameLengthMatchTolerance = int(self.leNameLengthMatchTolerance.text())
#persist some settings
self.settings.save_on_low_confidence = self.autoSaveOnLow
self.settings.dont_use_year_when_identifying = self.dontUseYear
self.settings.assume_1_if_no_issue_num = self.assumeIssueOne
self.settings.ignore_leading_numbers_in_filename = self.ignoreLeadingDigitsInFilename
self.settings.remove_archive_after_successful_match = self.removeAfterSuccess
if self.cbxSpecifySearchString.isChecked():
self.searchString = unicode(self.leSearchString.text())
if len(self.searchString) == 0:
self.searchString = None
self.sbNameMatchSearchThresh.setToolTip(nlmt_tip)
ss_tip = """<html>
The <b>series search string</b> specifies the search string to be used for all selected archives.
Use this when trying to match archives with hard-to-parse or incorrect filenames. All archives selected
should be from the same series.
</html>"""
self.leSearchString.setToolTip(ss_tip)
self.cbxSpecifySearchString.setToolTip(ss_tip)
self.cbxSpecifySearchString.stateChanged.connect(self.search_string_toggle)
self.auto_save_on_low = False
self.dont_use_year = False
self.assume_issue_one = False
self.ignore_leading_digits_in_filename = False
self.remove_after_success = False
self.wait_and_retry_on_rate_limit = False
self.search_string = ""
self.name_length_match_tolerance = self.config.talker_series_match_search_thresh
self.split_words = self.cbxSplitWords.isChecked()
def search_string_toggle(self) -> None:
enable = self.cbxSpecifySearchString.isChecked()
self.leSearchString.setEnabled(enable)
def accept(self) -> None:
QtWidgets.QDialog.accept(self)
self.auto_save_on_low = self.cbxSaveOnLowConfidence.isChecked()
self.dont_use_year = self.cbxDontUseYear.isChecked()
self.assume_issue_one = self.cbxAssumeIssueOne.isChecked()
self.ignore_leading_digits_in_filename = self.cbxIgnoreLeadingDigitsInFilename.isChecked()
self.remove_after_success = self.cbxRemoveAfterSuccess.isChecked()
self.name_length_match_tolerance = self.sbNameMatchSearchThresh.value()
self.wait_and_retry_on_rate_limit = self.cbxWaitForRateLimit.isChecked()
self.split_words = self.cbxSplitWords.isChecked()
# persist some settings
self.config.autotag_save_on_low_confidence = self.auto_save_on_low
self.config.autotag_dont_use_year_when_identifying = self.dont_use_year
self.config.autotag_assume_1_if_no_issue_num = self.assume_issue_one
self.config.autotag_ignore_leading_numbers_in_filename = self.ignore_leading_digits_in_filename
self.config.autotag_remove_archive_after_successful_match = self.remove_after_success
self.config.autotag_wait_and_retry_on_rate_limit = self.wait_and_retry_on_rate_limit
if self.cbxSpecifySearchString.isChecked():
self.search_string = self.leSearchString.text()

View File

@ -1,102 +1,100 @@
"""
Class to manage modifying metadata specifically for CBL/CBI
"""
"""A class to manage modifying metadata specifically for CBL/CBI"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
import logging
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
import settngs
http://www.apache.org/licenses/LICENSE-2.0
from comicapi.genericmetadata import CreditMetadata, GenericMetadata
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import os
import utils
logger = logging.getLogger(__name__)
class CBLTransformer:
def __init__( self, metadata, settings ):
self.metadata = metadata
self.settings = settings
def __init__(self, metadata: GenericMetadata, config: settngs.Namespace) -> None:
self.metadata = metadata
self.config = config
def apply( self ):
# helper funcs
def append_to_tags_if_unique( item ):
if item.lower() not in (tag.lower() for tag in self.metadata.tags):
self.metadata.tags.append( item )
def add_string_list_to_tags( str_list ):
if str_list is not None and str_list != "":
items = [ s.strip() for s in str_list.split(',') ]
for item in items:
append_to_tags_if_unique( item )
def apply(self) -> GenericMetadata:
# helper funcs
def append_to_tags_if_unique(item: str) -> None:
if item.casefold() not in (tag.casefold() for tag in self.metadata.tags):
self.metadata.tags.add(item)
if self.settings.assume_lone_credit_is_primary:
# helper
def setLonePrimary( role_list ):
lone_credit = None
count = 0
for c in self.metadata.credits:
if c['role'].lower() in role_list:
count += 1
lone_credit = c
if count > 1:
lone_credit = None
break
if lone_credit is not None:
lone_credit['primary'] = True
return lone_credit, count
#need to loop three times, once for 'writer', 'artist', and then 'penciler' if no artist
setLonePrimary( ['writer'] )
c, count = setLonePrimary( ['artist'] )
if c is None and count == 0:
c, count = setLonePrimary( ['penciler', 'penciller'] )
if c is not None:
c['primary'] = False
self.metadata.addCredit( c['person'], 'Artist', True )
def add_string_list_to_tags(str_list: str | None) -> None:
if str_list:
items = [s.strip() for s in str_list.split(",")]
for item in items:
append_to_tags_if_unique(item)
if self.settings.copy_characters_to_tags:
add_string_list_to_tags( self.metadata.characters )
if self.config.cbl_assume_lone_credit_is_primary:
# helper
def set_lone_primary(role_list: list[str]) -> tuple[CreditMetadata | None, int]:
lone_credit: CreditMetadata | None = None
count = 0
for c in self.metadata.credits:
if c["role"].casefold() in role_list:
count += 1
lone_credit = c
if count > 1:
lone_credit = None
break
if lone_credit is not None:
lone_credit["primary"] = True
return lone_credit, count
if self.settings.copy_teams_to_tags:
add_string_list_to_tags( self.metadata.teams )
if self.settings.copy_locations_to_tags:
add_string_list_to_tags( self.metadata.locations )
# need to loop three times, once for 'writer', 'artist', and then
# 'penciler' if no artist
set_lone_primary(["writer"])
c, count = set_lone_primary(["artist"])
if c is None and count == 0:
c, count = set_lone_primary(["penciler", "penciller"])
if c is not None:
c["primary"] = False
self.metadata.add_credit(c["person"], "Artist", True)
if self.settings.copy_storyarcs_to_tags:
add_string_list_to_tags( self.metadata.storyArc )
if self.settings.copy_notes_to_comments:
if self.metadata.notes is not None:
if self.metadata.comments is None:
self.metadata.comments = ""
else:
self.metadata.comments += "\n\n"
if self.metadata.notes not in self.metadata.comments:
self.metadata.comments += self.metadata.notes
if self.config.cbl_copy_characters_to_tags:
add_string_list_to_tags(self.metadata.characters)
if self.settings.copy_weblink_to_comments:
if self.metadata.webLink is not None:
if self.metadata.comments is None:
self.metadata.comments = ""
else:
self.metadata.comments += "\n\n"
if self.metadata.webLink not in self.metadata.comments:
self.metadata.comments += self.metadata.webLink
if self.config.cbl_copy_teams_to_tags:
add_string_list_to_tags(self.metadata.teams)
return self.metadata
if self.config.cbl_copy_locations_to_tags:
add_string_list_to_tags(self.metadata.locations)
if self.config.cbl_copy_storyarcs_to_tags:
add_string_list_to_tags(self.metadata.story_arc)
if self.config.cbl_copy_notes_to_comments:
if self.metadata.notes is not None:
if self.metadata.comments is None:
self.metadata.comments = ""
else:
self.metadata.comments += "\n\n"
if self.metadata.notes not in self.metadata.comments:
self.metadata.comments += self.metadata.notes
if self.config.cbl_copy_weblink_to_comments:
if self.metadata.web_link is not None:
if self.metadata.comments is None:
self.metadata.comments = ""
else:
self.metadata.comments += "\n\n"
if self.metadata.web_link not in self.metadata.comments:
self.metadata.comments += self.metadata.web_link
return self.metadata

File diff suppressed because it is too large Load Diff

View File

@ -1,260 +0,0 @@
"""
A python class to encapsulate CoMet data
"""
"""
Copyright 2012-2014 Anthony Beville
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.
"""
from datetime import datetime
import zipfile
from pprint import pprint
import xml.etree.ElementTree as ET
from genericmetadata import GenericMetadata
import utils
class CoMet:
writer_synonyms = ['writer', 'plotter', 'scripter']
penciller_synonyms = [ 'artist', 'penciller', 'penciler', 'breakdowns' ]
inker_synonyms = [ 'inker', 'artist', 'finishes' ]
colorist_synonyms = [ 'colorist', 'colourist', 'colorer', 'colourer' ]
letterer_synonyms = [ 'letterer']
cover_synonyms = [ 'cover', 'covers', 'coverartist', 'cover artist' ]
editor_synonyms = [ 'editor']
def metadataFromString( self, string ):
tree = ET.ElementTree(ET.fromstring( string ))
return self.convertXMLToMetadata( tree )
def stringFromMetadata( self, metadata ):
header = '<?xml version="1.0" encoding="UTF-8"?>\n'
tree = self.convertMetadataToXML( self, metadata )
return header + ET.tostring(tree.getroot())
def indent( self, elem, level=0 ):
# for making the XML output readable
i = "\n" + level*" "
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = i + " "
if not elem.tail or not elem.tail.strip():
elem.tail = i
for elem in elem:
self.indent( elem, level+1 )
if not elem.tail or not elem.tail.strip():
elem.tail = i
else:
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = i
def convertMetadataToXML( self, filename, metadata ):
#shorthand for the metadata
md = metadata
# build a tree structure
root = ET.Element("comet")
root.attrib['xmlns:comet'] = "http://www.denvog.com/comet/"
root.attrib['xmlns:xsi'] = "http://www.w3.org/2001/XMLSchema-instance"
root.attrib['xsi:schemaLocation'] = "http://www.denvog.com http://www.denvog.com/comet/comet.xsd"
#helper func
def assign( comet_entry, md_entry):
if md_entry is not None:
ET.SubElement(root, comet_entry).text = u"{0}".format(md_entry)
# title is manditory
if md.title is None:
md.title = ""
assign( 'title', md.title )
assign( 'series', md.series )
assign( 'issue', md.issue ) #must be int??
assign( 'volume', md.volume )
assign( 'description', md.comments )
assign( 'publisher', md.publisher )
assign( 'pages', md.pageCount )
assign( 'format', md.format )
assign( 'language', md.language )
assign( 'rating', md.maturityRating )
assign( 'price', md.price )
assign( 'isVersionOf', md.isVersionOf )
assign( 'rights', md.rights )
assign( 'identifier', md.identifier )
assign( 'lastMark', md.lastMark )
assign( 'genre', md.genre ) # TODO repeatable
if md.characters is not None:
char_list = [ c.strip() for c in md.characters.split(',') ]
for c in char_list:
assign( 'character', c )
if md.manga is not None and md.manga == "YesAndRightToLeft":
assign( 'readingDirection', "rtl")
date_str = ""
if md.year is not None:
date_str = str(md.year).zfill(4)
if md.month is not None:
date_str += "-" + str(md.month).zfill(2)
assign( 'date', date_str )
assign( 'coverImage', md.coverImage )
# need to specially process the credits, since they are structured differently than CIX
credit_writer_list = list()
credit_penciller_list = list()
credit_inker_list = list()
credit_colorist_list = list()
credit_letterer_list = list()
credit_cover_list = list()
credit_editor_list = list()
# loop thru credits, and build a list for each role that CoMet supports
for credit in metadata.credits:
if credit['role'].lower() in set( self.writer_synonyms ):
ET.SubElement(root, 'writer').text = u"{0}".format(credit['person'])
if credit['role'].lower() in set( self.penciller_synonyms ):
ET.SubElement(root, 'penciller').text = u"{0}".format(credit['person'])
if credit['role'].lower() in set( self.inker_synonyms ):
ET.SubElement(root, 'inker').text = u"{0}".format(credit['person'])
if credit['role'].lower() in set( self.colorist_synonyms ):
ET.SubElement(root, 'colorist').text = u"{0}".format(credit['person'])
if credit['role'].lower() in set( self.letterer_synonyms ):
ET.SubElement(root, 'letterer').text = u"{0}".format(credit['person'])
if credit['role'].lower() in set( self.cover_synonyms ):
ET.SubElement(root, 'coverDesigner').text = u"{0}".format(credit['person'])
if credit['role'].lower() in set( self.editor_synonyms ):
ET.SubElement(root, 'editor').text = u"{0}".format(credit['person'])
# self pretty-print
self.indent(root)
# wrap it in an ElementTree instance, and save as XML
tree = ET.ElementTree(root)
return tree
def convertXMLToMetadata( self, tree ):
root = tree.getroot()
if root.tag != 'comet':
raise 1
return None
metadata = GenericMetadata()
md = metadata
# Helper function
def xlate( tag ):
node = root.find( tag )
if node is not None:
return node.text
else:
return None
md.series = xlate( 'series' )
md.title = xlate( 'title' )
md.issue = xlate( 'issue' )
md.volume = xlate( 'volume' )
md.comments = xlate( 'description' )
md.publisher = xlate( 'publisher' )
md.language = xlate( 'language' )
md.format = xlate( 'format' )
md.pageCount = xlate( 'pages' )
md.maturityRating = xlate( 'rating' )
md.price = xlate( 'price' )
md.isVersionOf = xlate( 'isVersionOf' )
md.rights = xlate( 'rights' )
md.identifier = xlate( 'identifier' )
md.lastMark = xlate( 'lastMark' )
md.genre = xlate( 'genre' ) # TODO - repeatable field
date = xlate( 'date' )
if date is not None:
parts = date.split('-')
if len( parts) > 0:
md.year = parts[0]
if len( parts) > 1:
md.month = parts[1]
md.coverImage = xlate( 'coverImage' )
readingDirection = xlate( 'readingDirection' )
if readingDirection is not None and readingDirection == "rtl":
md.manga = "YesAndRightToLeft"
# loop for character tags
char_list = []
for n in root:
if n.tag == 'character':
char_list.append(n.text.strip())
md.characters = utils.listToString( char_list )
# Now extract the credit info
for n in root:
if ( n.tag == 'writer' or
n.tag == 'penciller' or
n.tag == 'inker' or
n.tag == 'colorist' or
n.tag == 'letterer' or
n.tag == 'editor'
):
metadata.addCredit( n.text.strip(), n.tag.title() )
if n.tag == 'coverDesigner':
metadata.addCredit( n.text.strip(), "Cover" )
metadata.isEmpty = False
return metadata
#verify that the string actually contains CoMet data in XML format
def validateString( self, string ):
try:
tree = ET.ElementTree(ET.fromstring( string ))
root = tree.getroot()
if root.tag != 'comet':
raise Exception
except:
return False
return True
def writeToExternalFile( self, filename, metadata ):
tree = self.convertMetadataToXML( self, metadata )
#ET.dump(tree)
tree.write(filename, encoding='utf-8')
def readFromExternalFile( self, filename ):
tree = ET.parse( filename )
return self.convertXMLToMetadata( tree )

File diff suppressed because it is too large Load Diff

View File

@ -1,152 +0,0 @@
"""
A python class to encapsulate the ComicBookInfo data
"""
"""
Copyright 2012-2014 Anthony Beville
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import json
from datetime import datetime
import zipfile
from genericmetadata import GenericMetadata
import utils
import ctversion
class ComicBookInfo:
def metadataFromString( self, string ):
cbi_container = json.loads( unicode(string, 'utf-8') )
metadata = GenericMetadata()
cbi = cbi_container[ 'ComicBookInfo/1.0' ]
#helper func
# If item is not in CBI, return None
def xlate( cbi_entry):
if cbi_entry in cbi:
return cbi[cbi_entry]
else:
return None
metadata.series = xlate( 'series' )
metadata.title = xlate( 'title' )
metadata.issue = xlate( 'issue' )
metadata.publisher = xlate( 'publisher' )
metadata.month = xlate( 'publicationMonth' )
metadata.year = xlate( 'publicationYear' )
metadata.issueCount = xlate( 'numberOfIssues' )
metadata.comments = xlate( 'comments' )
metadata.credits = xlate( 'credits' )
metadata.genre = xlate( 'genre' )
metadata.volume = xlate( 'volume' )
metadata.volumeCount = xlate( 'numberOfVolumes' )
metadata.language = xlate( 'language' )
metadata.country = xlate( 'country' )
metadata.criticalRating = xlate( 'rating' )
metadata.tags = xlate( 'tags' )
# make sure credits and tags are at least empty lists and not None
if metadata.credits is None:
metadata.credits = []
if metadata.tags is None:
metadata.tags = []
#need to massage the language string to be ISO
if metadata.language is not None:
# reverse look-up
pattern = metadata.language
metadata.language = None
for key in utils.getLanguageDict():
if utils.getLanguageDict()[ key ] == pattern.encode('utf-8'):
metadata.language = key
break
metadata.isEmpty = False
return metadata
def stringFromMetadata( self, metadata ):
cbi_container = self.createJSONDictionary( metadata )
return json.dumps( cbi_container )
#verify that the string actually contains CBI data in JSON format
def validateString( self, string ):
try:
cbi_container = json.loads( string )
except:
return False
return ( 'ComicBookInfo/1.0' in cbi_container )
def createJSONDictionary( self, metadata ):
# Create the dictionary that we will convert to JSON text
cbi = dict()
cbi_container = {'appID' : 'ComicTagger/' + ctversion.version,
'lastModified' : str(datetime.now()),
'ComicBookInfo/1.0' : cbi }
#helper func
def assign( cbi_entry, md_entry):
if md_entry is not None:
cbi[cbi_entry] = md_entry
#helper func
def toInt(s):
i = None
if type(s) in [ str, unicode, int ]:
try:
i = int(s)
except ValueError:
pass
return i
assign( 'series', metadata.series )
assign( 'title', metadata.title )
assign( 'issue', metadata.issue )
assign( 'publisher', metadata.publisher )
assign( 'publicationMonth', toInt(metadata.month) )
assign( 'publicationYear', toInt(metadata.year) )
assign( 'numberOfIssues', toInt(metadata.issueCount) )
assign( 'comments', metadata.comments )
assign( 'genre', metadata.genre )
assign( 'volume', toInt(metadata.volume) )
assign( 'numberOfVolumes', toInt(metadata.volumeCount) )
assign( 'language', utils.getLanguageFromISO(metadata.language) )
assign( 'country', metadata.country )
assign( 'rating', metadata.criticalRating )
assign( 'credits', metadata.credits )
assign( 'tags', metadata.tags )
return cbi_container
def writeToExternalFile( self, filename, metadata ):
cbi_container = self.createJSONDictionary(metadata)
f = open(filename, 'w')
f.write(json.dumps(cbi_container, indent=4))
f.close

View File

@ -1,293 +0,0 @@
"""
A python class to encapsulate ComicRack's ComicInfo.xml data
"""
"""
Copyright 2012-2014 Anthony Beville
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.
"""
from datetime import datetime
import zipfile
from pprint import pprint
import xml.etree.ElementTree as ET
from genericmetadata import GenericMetadata
import utils
class ComicInfoXml:
writer_synonyms = ['writer', 'plotter', 'scripter']
penciller_synonyms = [ 'artist', 'penciller', 'penciler', 'breakdowns' ]
inker_synonyms = [ 'inker', 'artist', 'finishes' ]
colorist_synonyms = [ 'colorist', 'colourist', 'colorer', 'colourer' ]
letterer_synonyms = [ 'letterer']
cover_synonyms = [ 'cover', 'covers', 'coverartist', 'cover artist' ]
editor_synonyms = [ 'editor']
def getParseableCredits( self ):
parsable_credits = []
parsable_credits.extend( self.writer_synonyms )
parsable_credits.extend( self.penciller_synonyms )
parsable_credits.extend( self.inker_synonyms )
parsable_credits.extend( self.colorist_synonyms )
parsable_credits.extend( self.letterer_synonyms )
parsable_credits.extend( self.cover_synonyms )
parsable_credits.extend( self.editor_synonyms )
return parsable_credits
def metadataFromString( self, string ):
tree = ET.ElementTree(ET.fromstring( string ))
return self.convertXMLToMetadata( tree )
def stringFromMetadata( self, metadata ):
header = '<?xml version="1.0"?>\n'
tree = self.convertMetadataToXML( self, metadata )
return header + ET.tostring(tree.getroot())
def indent( self, elem, level=0 ):
# for making the XML output readable
i = "\n" + level*" "
if len(elem):
if not elem.text or not elem.text.strip():
elem.text = i + " "
if not elem.tail or not elem.tail.strip():
elem.tail = i
for elem in elem:
self.indent( elem, level+1 )
if not elem.tail or not elem.tail.strip():
elem.tail = i
else:
if level and (not elem.tail or not elem.tail.strip()):
elem.tail = i
def convertMetadataToXML( self, filename, metadata ):
#shorthand for the metadata
md = metadata
# build a tree structure
root = ET.Element("ComicInfo")
root.attrib['xmlns:xsi']="http://www.w3.org/2001/XMLSchema-instance"
root.attrib['xmlns:xsd']="http://www.w3.org/2001/XMLSchema"
#helper func
def assign( cix_entry, md_entry):
if md_entry is not None:
ET.SubElement(root, cix_entry).text = u"{0}".format(md_entry)
assign( 'Title', md.title )
assign( 'Series', md.series )
assign( 'Number', md.issue )
assign( 'Count', md.issueCount )
assign( 'Volume', md.volume )
assign( 'AlternateSeries', md.alternateSeries )
assign( 'AlternateNumber', md.alternateNumber )
assign( 'StoryArc', md.storyArc )
assign( 'SeriesGroup', md.seriesGroup )
assign( 'AlternateCount', md.alternateCount )
assign( 'Summary', md.comments )
assign( 'Notes', md.notes )
assign( 'Year', md.year )
assign( 'Month', md.month )
assign( 'Day', md.day )
# need to specially process the credits, since they are structured differently than CIX
credit_writer_list = list()
credit_penciller_list = list()
credit_inker_list = list()
credit_colorist_list = list()
credit_letterer_list = list()
credit_cover_list = list()
credit_editor_list = list()
# first, loop thru credits, and build a list for each role that CIX supports
for credit in metadata.credits:
if credit['role'].lower() in set( self.writer_synonyms ):
credit_writer_list.append(credit['person'].replace(",",""))
if credit['role'].lower() in set( self.penciller_synonyms ):
credit_penciller_list.append(credit['person'].replace(",",""))
if credit['role'].lower() in set( self.inker_synonyms ):
credit_inker_list.append(credit['person'].replace(",",""))
if credit['role'].lower() in set( self.colorist_synonyms ):
credit_colorist_list.append(credit['person'].replace(",",""))
if credit['role'].lower() in set( self.letterer_synonyms ):
credit_letterer_list.append(credit['person'].replace(",",""))
if credit['role'].lower() in set( self.cover_synonyms ):
credit_cover_list.append(credit['person'].replace(",",""))
if credit['role'].lower() in set( self.editor_synonyms ):
credit_editor_list.append(credit['person'].replace(",",""))
# second, convert each list to string, and add to XML struct
if len( credit_writer_list ) > 0:
node = ET.SubElement(root, 'Writer')
node.text = utils.listToString( credit_writer_list )
if len( credit_penciller_list ) > 0:
node = ET.SubElement(root, 'Penciller')
node.text = utils.listToString( credit_penciller_list )
if len( credit_inker_list ) > 0:
node = ET.SubElement(root, 'Inker')
node.text = utils.listToString( credit_inker_list )
if len( credit_colorist_list ) > 0:
node = ET.SubElement(root, 'Colorist')
node.text = utils.listToString( credit_colorist_list )
if len( credit_letterer_list ) > 0:
node = ET.SubElement(root, 'Letterer')
node.text = utils.listToString( credit_letterer_list )
if len( credit_cover_list ) > 0:
node = ET.SubElement(root, 'CoverArtist')
node.text = utils.listToString( credit_cover_list )
if len( credit_editor_list ) > 0:
node = ET.SubElement(root, 'Editor')
node.text = utils.listToString( credit_editor_list )
assign( 'Publisher', md.publisher )
assign( 'Imprint', md.imprint )
assign( 'Genre', md.genre )
assign( 'Web', md.webLink )
assign( 'PageCount', md.pageCount )
assign( 'LanguageISO', md.language )
assign( 'Format', md.format )
assign( 'AgeRating', md.maturityRating )
if md.blackAndWhite is not None and md.blackAndWhite:
ET.SubElement(root, 'BlackAndWhite').text = "Yes"
assign( 'Manga', md.manga )
assign( 'Characters', md.characters )
assign( 'Teams', md.teams )
assign( 'Locations', md.locations )
assign( 'ScanInformation', md.scanInfo )
# loop and add the page entries under pages node
if len( md.pages ) > 0:
pages_node = ET.SubElement(root, 'Pages')
for page_dict in md.pages:
page_node = ET.SubElement(pages_node, 'Page')
page_node.attrib = page_dict
# self pretty-print
self.indent(root)
# wrap it in an ElementTree instance, and save as XML
tree = ET.ElementTree(root)
return tree
def convertXMLToMetadata( self, tree ):
root = tree.getroot()
if root.tag != 'ComicInfo':
raise 1
return None
metadata = GenericMetadata()
md = metadata
# Helper function
def xlate( tag ):
node = root.find( tag )
if node is not None:
return node.text
else:
return None
md.series = xlate( 'Series' )
md.title = xlate( 'Title' )
md.issue = xlate( 'Number' )
md.issueCount = xlate( 'Count' )
md.volume = xlate( 'Volume' )
md.alternateSeries = xlate( 'AlternateSeries' )
md.alternateNumber = xlate( 'AlternateNumber' )
md.alternateCount = xlate( 'AlternateCount' )
md.comments = xlate( 'Summary' )
md.notes = xlate( 'Notes' )
md.year = xlate( 'Year' )
md.month = xlate( 'Month' )
md.day = xlate( 'Day' )
md.publisher = xlate( 'Publisher' )
md.imprint = xlate( 'Imprint' )
md.genre = xlate( 'Genre' )
md.webLink = xlate( 'Web' )
md.language = xlate( 'LanguageISO' )
md.format = xlate( 'Format' )
md.manga = xlate( 'Manga' )
md.characters = xlate( 'Characters' )
md.teams = xlate( 'Teams' )
md.locations = xlate( 'Locations' )
md.pageCount = xlate( 'PageCount' )
md.scanInfo = xlate( 'ScanInformation' )
md.storyArc = xlate( 'StoryArc' )
md.seriesGroup = xlate( 'SeriesGroup' )
md.maturityRating = xlate( 'AgeRating' )
tmp = xlate( 'BlackAndWhite' )
md.blackAndWhite = False
if tmp is not None and tmp.lower() in [ "yes", "true", "1" ]:
md.blackAndWhite = True
# Now extract the credit info
for n in root:
if ( n.tag == 'Writer' or
n.tag == 'Penciller' or
n.tag == 'Inker' or
n.tag == 'Colorist' or
n.tag == 'Letterer' or
n.tag == 'Editor'
):
if n.text is not None:
for name in n.text.split(','):
metadata.addCredit( name.strip(), n.tag )
if n.tag == 'CoverArtist':
if n.text is not None:
for name in n.text.split(','):
metadata.addCredit( name.strip(), "Cover" )
# parse page data now
pages_node = root.find( "Pages" )
if pages_node is not None:
for page in pages_node:
metadata.pages.append( page.attrib )
#print page.attrib
metadata.isEmpty = False
return metadata
def writeToExternalFile( self, filename, metadata ):
tree = self.convertMetadataToXML( self, metadata )
#ET.dump(tree)
tree.write(filename, encoding='utf-8')
def readFromExternalFile( self, filename ):
tree = ET.parse( filename )
return self.convertXMLToMetadata( tree )

View File

@ -1,459 +0,0 @@
"""
A python class to manage caching of data from Comic Vine
"""
"""
Copyright 2012-2014 Anthony Beville
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.
"""
from pprint import pprint
import sqlite3 as lite
import sys
import os
import datetime
import ctversion
from settings import ComicTaggerSettings
import utils
class ComicVineCacher:
def __init__(self ):
self.settings_folder = ComicTaggerSettings.getSettingsFolder()
self.db_file = os.path.join( self.settings_folder, "cv_cache.db")
self.version_file = os.path.join( self.settings_folder, "cache_version.txt")
#verify that cache is from same version as this one
data = ""
try:
with open( self.version_file, 'rb' ) as f:
data = f.read()
f.close()
except:
pass
if data != ctversion.version:
self.clearCache()
if not os.path.exists( self.db_file ):
self.create_cache_db()
def clearCache( self ):
try:
os.unlink( self.db_file )
except:
pass
try:
os.unlink( self.version_file )
except:
pass
def create_cache_db( self ):
#create the version file
with open( self.version_file, 'w' ) as f:
f.write( ctversion.version )
# this will wipe out any existing version
open( self.db_file, 'w').close()
con = lite.connect( self.db_file )
# create tables
with con:
cur = con.cursor()
#name,id,start_year,publisher,image,description,count_of_issues
cur.execute("CREATE TABLE VolumeSearchCache(" +
"search_term TEXT," +
"id INT," +
"name TEXT," +
"start_year INT," +
"publisher TEXT," +
"count_of_issues INT," +
"image_url TEXT," +
"description TEXT," +
"timestamp DATE DEFAULT (datetime('now','localtime')) ) "
)
cur.execute("CREATE TABLE Volumes(" +
"id INT," +
"name TEXT," +
"publisher TEXT," +
"count_of_issues INT," +
"start_year INT," +
"timestamp DATE DEFAULT (datetime('now','localtime')), " +
"PRIMARY KEY (id) )"
)
cur.execute("CREATE TABLE AltCovers(" +
"issue_id INT," +
"url_list TEXT," +
"timestamp DATE DEFAULT (datetime('now','localtime')), " +
"PRIMARY KEY (issue_id) )"
)
cur.execute("CREATE TABLE Issues(" +
"id INT," +
"volume_id INT," +
"name TEXT," +
"issue_number TEXT," +
"super_url TEXT," +
"thumb_url TEXT," +
"cover_date TEXT," +
"site_detail_url TEXT," +
"description TEXT," +
"timestamp DATE DEFAULT (datetime('now','localtime')), " +
"PRIMARY KEY (id ) )"
)
def add_search_results( self, search_term, cv_search_results ):
con = lite.connect( self.db_file )
with con:
con.text_factory = unicode
cur = con.cursor()
# remove all previous entries with this search term
cur.execute("DELETE FROM VolumeSearchCache WHERE search_term = ?", [ search_term.lower() ])
# now add in new results
for record in cv_search_results:
timestamp = datetime.datetime.now()
if record['publisher'] is None:
pub_name = ""
else:
pub_name = record['publisher']['name']
if record['image'] is None:
url = ""
else:
url = record['image']['super_url']
cur.execute("INSERT INTO VolumeSearchCache " +
"(search_term, id, name, start_year, publisher, count_of_issues, image_url, description ) " +
"VALUES( ?, ?, ?, ?, ?, ?, ?, ? )" ,
( search_term.lower(),
record['id'],
record['name'],
record['start_year'],
pub_name,
record['count_of_issues'],
url,
record['description'])
)
def get_search_results( self, search_term ):
results = list()
con = lite.connect( self.db_file )
with con:
con.text_factory = unicode
cur = con.cursor()
# purge stale search results
a_day_ago = datetime.datetime.today()-datetime.timedelta(days=1)
cur.execute( "DELETE FROM VolumeSearchCache WHERE timestamp < ?", [ str(a_day_ago) ] )
# fetch
cur.execute("SELECT * FROM VolumeSearchCache WHERE search_term=?", [ search_term.lower() ] )
rows = cur.fetchall()
# now process the results
for record in rows:
result = dict()
result['id'] = record[1]
result['name'] = record[2]
result['start_year'] = record[3]
result['publisher'] = dict()
result['publisher']['name'] = record[4]
result['count_of_issues'] = record[5]
result['image'] = dict()
result['image']['super_url'] = record[6]
result['description'] = record[7]
results.append(result)
return results
def add_alt_covers( self, issue_id, url_list ):
con = lite.connect( self.db_file )
with con:
con.text_factory = unicode
cur = con.cursor()
# remove all previous entries with this search term
cur.execute("DELETE FROM AltCovers WHERE issue_id = ?", [ issue_id ])
url_list_str = utils.listToString(url_list)
# now add in new record
cur.execute("INSERT INTO AltCovers " +
"(issue_id, url_list ) " +
"VALUES( ?, ? )" ,
( issue_id,
url_list_str)
)
def get_alt_covers( self, issue_id ):
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
con.text_factory = unicode
# purge stale issue info - probably issue data won't change much....
a_month_ago = datetime.datetime.today()-datetime.timedelta(days=30)
cur.execute( "DELETE FROM AltCovers WHERE timestamp < ?", [ str(a_month_ago) ] )
cur.execute("SELECT url_list FROM AltCovers WHERE issue_id=?", [ issue_id ])
row = cur.fetchone()
if row is None :
return None
else:
url_list_str = row[0]
if len(url_list_str) == 0:
return []
raw_list = url_list_str.split(",")
url_list = []
for item in raw_list:
url_list.append( str(item).strip())
return url_list
def add_volume_info( self, cv_volume_record ):
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
timestamp = datetime.datetime.now()
if cv_volume_record['publisher'] is None:
pub_name = ""
else:
pub_name = cv_volume_record['publisher']['name']
data = {
"name": cv_volume_record['name'],
"publisher": pub_name,
"count_of_issues": cv_volume_record['count_of_issues'],
"start_year": cv_volume_record['start_year'],
"timestamp": timestamp
}
self.upsert( cur, "volumes", "id", cv_volume_record['id'], data)
def add_volume_issues_info( self, volume_id, cv_volume_issues ):
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
timestamp = datetime.datetime.now()
# add in issues
for issue in cv_volume_issues:
data = {
"volume_id": volume_id,
"name": issue['name'],
"issue_number": issue['issue_number'],
"site_detail_url": issue['site_detail_url'],
"cover_date": issue['cover_date'],
"super_url": issue['image']['super_url'],
"thumb_url": issue['image']['thumb_url'],
"description": issue['description'],
"timestamp": timestamp
}
self.upsert( cur, "issues" , "id", issue['id'], data)
def get_volume_info( self, volume_id ):
result = None
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
con.text_factory = unicode
# purge stale volume info
a_week_ago = datetime.datetime.today()-datetime.timedelta(days=7)
cur.execute( "DELETE FROM Volumes WHERE timestamp < ?", [ str(a_week_ago) ] )
# fetch
cur.execute("SELECT id,name,publisher,count_of_issues,start_year FROM Volumes WHERE id = ?", [ volume_id ] )
row = cur.fetchone()
if row is None :
return result
result = dict()
#since ID is primary key, there is only one row
result['id'] = row[0]
result['name'] = row[1]
result['publisher'] = dict()
result['publisher']['name'] = row[2]
result['count_of_issues'] = row[3]
result['start_year'] = row[4]
result['issues'] = list()
return result
def get_volume_issues_info( self, volume_id ):
result = None
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
con.text_factory = unicode
# purge stale issue info - probably issue data won't change much....
a_week_ago = datetime.datetime.today()-datetime.timedelta(days=7)
cur.execute( "DELETE FROM Issues WHERE timestamp < ?", [ str(a_week_ago) ] )
# fetch
results = list()
cur.execute("SELECT id,name,issue_number,site_detail_url,cover_date,super_url,thumb_url,description FROM Issues WHERE volume_id = ?", [ volume_id ] )
rows = cur.fetchall()
# now process the results
for row in rows:
record = dict()
record['id'] = row[0]
record['name'] = row[1]
record['issue_number'] = row[2]
record['site_detail_url'] = row[3]
record['cover_date'] = row[4]
record['image'] = dict()
record['image']['super_url'] = row[5]
record['image']['thumb_url'] = row[6]
record['description'] = row[7]
results.append(record)
if len(results) == 0:
return None
return results
def add_issue_select_details( self, issue_id, image_url, thumb_image_url, cover_date, site_detail_url ):
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
con.text_factory = unicode
timestamp = datetime.datetime.now()
data = {
"super_url": image_url,
"thumb_url": thumb_image_url,
"cover_date": cover_date,
"site_detail_url": site_detail_url,
"timestamp": timestamp
}
self.upsert( cur, "issues" , "id", issue_id, data)
def get_issue_select_details( self, issue_id ):
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
con.text_factory = unicode
cur.execute("SELECT super_url,thumb_url,cover_date,site_detail_url FROM Issues WHERE id=?", [ issue_id ])
row = cur.fetchone()
details = dict()
if row is None or row[0] is None :
details['image_url'] = None
details['thumb_image_url'] = None
details['cover_date'] = None
details['site_detail_url'] = None
else:
details['image_url'] = row[0]
details['thumb_image_url'] = row[1]
details['cover_date'] = row[2]
details['site_detail_url'] = row[3]
return details
def upsert( self, cur, tablename, pkname, pkval, data):
"""
This does an insert if the given PK doesn't exist, and an update it if does
"""
# TODO - look into checking if UPDATE is needed
# TODO - should the cursor be created here, and not up the stack?
ins_count = len(data) + 1
keys = ""
vals = list()
ins_slots = ""
set_slots = ""
for key in data:
if keys != "":
keys += ", "
if ins_slots != "":
ins_slots += ", "
if set_slots != "":
set_slots += ", "
keys += key
vals.append( data[key] )
ins_slots += "?"
set_slots += key + " = ?"
keys += ", " + pkname
vals.append( pkval )
ins_slots += ", ?"
condition = pkname + " = ?"
sql_ins = ( "INSERT OR IGNORE INTO " + tablename +
" ( " + keys + " ) " +
" VALUES ( " + ins_slots + " )" )
cur.execute( sql_ins , vals )
sql_upd = ( "UPDATE " + tablename +
" SET " + set_slots + " WHERE " + condition )
cur.execute( sql_upd , vals )

View File

@ -1,741 +0,0 @@
"""
A python class to manage communication with Comic Vine's REST API
"""
"""
Copyright 2012-2014 Anthony Beville
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import json
from pprint import pprint
import urllib2, urllib
import math
import re
import time
import datetime
import ctversion
import sys
from bs4 import BeautifulSoup
try:
from PyQt4.QtNetwork import QNetworkAccessManager, QNetworkRequest
from PyQt4.QtCore import QUrl, pyqtSignal, QObject, QByteArray
except ImportError:
# No Qt, so define a few dummy QObjects to help us compile
class QObject():
def __init__(self,*args):
pass
class pyqtSignal():
def __init__(self,*args):
pass
def emit(a,b,c):
pass
import utils
from settings import ComicTaggerSettings
from comicvinecacher import ComicVineCacher
from genericmetadata import GenericMetadata
from issuestring import IssueString
class CVTypeID:
Volume = "4050"
Issue = "4000"
class ComicVineTalkerException(Exception):
pass
class ComicVineTalker(QObject):
logo_url = "http://static.comicvine.com/bundles/comicvinesite/images/logo.png"
def __init__(self, api_key=""):
QObject.__init__(self)
self.api_base_url = "http://www.comicvine.com/api"
# key that is registered to comictagger
self.api_key = '27431e6787042105bd3e47e169a624521f89f3a4'
self.log_func = None
def setLogFunc( self , log_func ):
self.log_func = log_func
def writeLog( self , text ):
if self.log_func is None:
#sys.stdout.write(text.encode( errors='replace') )
#sys.stdout.flush()
print >> sys.stderr, text
else:
self.log_func( text )
def parseDateStr( self, date_str):
day = None
month = None
year = None
if date_str is not None:
parts = date_str.split('-')
year = parts[0]
if len(parts) > 1:
month = parts[1]
if len(parts) > 2:
day = parts[2]
return day, month, year
def testKey( self ):
test_url = self.api_base_url + "/issue/1/?api_key=" + self.api_key + "&format=json&field_list=name"
resp = urllib2.urlopen( test_url )
content = resp.read()
cv_response = json.loads( content )
# Bogus request, but if the key is wrong, you get error 100: "Invalid API Key"
return cv_response[ 'status_code' ] != 100
def getUrlContent( self, url ):
# connect to server:
# if there is a 500 error, try a few more times before giving up
# any other error, just bail
#print "ATB---", url
for tries in range(3):
try:
resp = urllib2.urlopen( url )
return resp.read()
except urllib2.HTTPError as e:
if e.getcode() == 500:
self.writeLog( "Try #{0}: ".format(tries+1) )
time.sleep(1)
self.writeLog( str(e) + "\n" )
if e.getcode() != 500:
break
except Exception as e:
self.writeLog( str(e) + "\n" )
raise ComicVineTalkerException("Network Error!")
raise ComicVineTalkerException("Error on Comic Vine server")
def searchForSeries( self, series_name , callback=None, refresh_cache=False ):
# remove cruft from the search string
series_name = utils.removearticles( series_name ).lower().strip()
# before we search online, look in our cache, since we might have
# done this same search recently
cvc = ComicVineCacher( )
if not refresh_cache:
cached_search_results = cvc.get_search_results( series_name )
if len (cached_search_results) > 0:
return cached_search_results
original_series_name = series_name
# We need to make the series name into an "AND"ed query list
query_word_list = series_name.split()
and_list = ['AND'] * (len(query_word_list)-1)
and_list.append('')
# zipper up the two lists
query_list = zip(query_word_list, and_list)
# flatten the list
query_list = [ item for sublist in query_list for item in sublist]
# convert back to a string
query_string = " ".join( query_list ).strip()
#print "Query string = ", query_string
query_string = urllib.quote_plus(query_string.encode("utf-8"))
search_url = self.api_base_url + "/search/?api_key=" + self.api_key + "&format=json&resources=volume&query=" + query_string + "&field_list=name,id,start_year,publisher,image,description,count_of_issues"
content = self.getUrlContent(search_url + "&page=1")
cv_response = json.loads(content)
if cv_response[ 'status_code' ] != 1:
self.writeLog( "Comic Vine query failed with error: [{0}]. \n".format( cv_response[ 'error' ] ))
return None
search_results = list()
# see http://api.comicvine.com/documentation/#handling_responses
limit = cv_response['limit']
current_result_count = cv_response['number_of_page_results']
total_result_count = cv_response['number_of_total_results']
if callback is None:
self.writeLog( "Found {0} of {1} results\n".format( cv_response['number_of_page_results'], cv_response['number_of_total_results']))
search_results.extend( cv_response['results'])
page = 1
if callback is not None:
callback( current_result_count, total_result_count )
# see if we need to keep asking for more pages...
while ( current_result_count < total_result_count ):
if callback is None:
self.writeLog("getting another page of results {0} of {1}...\n".format( current_result_count, total_result_count))
page += 1
content = self.getUrlContent(search_url + "&page="+str(page))
cv_response = json.loads(content)
if cv_response[ 'status_code' ] != 1:
self.writeLog( "Comic Vine query failed with error: [{0}]. \n".format( cv_response[ 'error' ] ))
return None
search_results.extend( cv_response['results'])
current_result_count += cv_response['number_of_page_results']
if callback is not None:
callback( current_result_count, total_result_count )
#for record in search_results:
# #print( u"{0}: {1} ({2})".format(record['id'], record['name'] , record['start_year'] ) )
# #print record
# #record['count_of_issues'] = record['count_of_isssues']
#print u"{0}: {1} ({2})".format(search_results['results'][0]['id'], search_results['results'][0]['name'] , search_results['results'][0]['start_year'] )
# cache these search results
cvc.add_search_results( original_series_name, search_results )
return search_results
def fetchVolumeData( self, series_id ):
# before we search online, look in our cache, since we might already
# have this info
cvc = ComicVineCacher( )
cached_volume_result = cvc.get_volume_info( series_id )
if cached_volume_result is not None:
return cached_volume_result
volume_url = self.api_base_url + "/volume/" + CVTypeID.Volume + "-" + str(series_id) + "/?api_key=" + self.api_key + "&field_list=name,id,start_year,publisher,count_of_issues&format=json"
content = self.getUrlContent(volume_url)
cv_response = json.loads(content)
if cv_response[ 'status_code' ] != 1:
print >> sys.stderr, "Comic Vine query failed with error: [{0}]. ".format( cv_response[ 'error' ] )
return None
volume_results = cv_response['results']
cvc.add_volume_info( volume_results )
return volume_results
def fetchIssuesByVolume( self, series_id ):
# before we search online, look in our cache, since we might already
# have this info
cvc = ComicVineCacher( )
cached_volume_issues_result = cvc.get_volume_issues_info( series_id )
if cached_volume_issues_result is not None:
return cached_volume_issues_result
#---------------------------------
issues_url = self.api_base_url + "/issues/" + "?api_key=" + self.api_key + "&filter=volume:" + str(series_id) + "&field_list=id,volume,issue_number,name,image,cover_date,site_detail_url,description&format=json"
content = self.getUrlContent(issues_url)
cv_response = json.loads(content)
if cv_response[ 'status_code' ] != 1:
print >> sys.stderr, "Comic Vine query failed with error: [{0}]. ".format( cv_response[ 'error' ] )
return None
#------------------------------------
limit = cv_response['limit']
current_result_count = cv_response['number_of_page_results']
total_result_count = cv_response['number_of_total_results']
#print "ATB total_result_count", total_result_count
#print "ATB Found {0} of {1} results".format( cv_response['number_of_page_results'], cv_response['number_of_total_results'])
volume_issues_result = cv_response['results']
page = 1
offset = 0
# see if we need to keep asking for more pages...
while ( current_result_count < total_result_count ):
#print "ATB getting another page of issue results {0} of {1}...".format( current_result_count, total_result_count)
page += 1
offset += cv_response['number_of_page_results']
#print issues_url+ "&offset="+str(offset)
content = self.getUrlContent(issues_url + "&offset="+str(offset))
cv_response = json.loads(content)
if cv_response[ 'status_code' ] != 1:
self.writeLog( "Comic Vine query failed with error: [{0}]. \n".format( cv_response[ 'error' ] ))
return None
volume_issues_result.extend( cv_response['results'])
current_result_count += cv_response['number_of_page_results']
self.repairUrls( volume_issues_result )
cvc.add_volume_issues_info( series_id, volume_issues_result )
return volume_issues_result
def fetchIssuesByVolumeIssueNumAndYear( self, volume_id_list, issue_number, year ):
volume_filter = "volume:"
for vid in volume_id_list:
volume_filter += str(vid) + "|"
year_filter = ""
if year is not None and str(year).isdigit():
year_filter = ",cover_date:{0}-1-1|{1}-1-1".format(year, int(year)+1)
issue_number = urllib.quote_plus(unicode(issue_number).encode("utf-8"))
filter = "&filter=" + volume_filter + year_filter + ",issue_number:" + issue_number
issues_url = self.api_base_url + "/issues/" + "?api_key=" + self.api_key + filter + "&field_list=id,volume,issue_number,name,image,cover_date,site_detail_url,description&format=json"
content = self.getUrlContent(issues_url)
cv_response = json.loads(content)
if cv_response[ 'status_code' ] != 1:
print >> sys.stderr, "Comic Vine query failed with error: [{0}]. ".format( cv_response[ 'error' ] )
return None
#------------------------------------
limit = cv_response['limit']
current_result_count = cv_response['number_of_page_results']
total_result_count = cv_response['number_of_total_results']
#print "ATB total_result_count", total_result_count
#print "ATB Found {0} of {1} results\n".format( cv_response['number_of_page_results'], cv_response['number_of_total_results'])
filtered_issues_result = cv_response['results']
page = 1
offset = 0
# see if we need to keep asking for more pages...
while ( current_result_count < total_result_count ):
#print "ATB getting another page of issue results {0} of {1}...\n".format( current_result_count, total_result_count)
page += 1
offset += cv_response['number_of_page_results']
#print issues_url+ "&offset="+str(offset)
content = self.getUrlContent(issues_url + "&offset="+str(offset))
cv_response = json.loads(content)
if cv_response[ 'status_code' ] != 1:
self.writeLog( "Comic Vine query failed with error: [{0}]. \n".format( cv_response[ 'error' ] ))
return None
filtered_issues_result.extend( cv_response['results'])
current_result_count += cv_response['number_of_page_results']
self.repairUrls( filtered_issues_result )
return filtered_issues_result
def fetchIssueData( self, series_id, issue_number, settings ):
volume_results = self.fetchVolumeData( series_id )
issues_list_results = self.fetchIssuesByVolume( series_id )
found = False
for record in issues_list_results:
if IssueString(issue_number).asString() is None:
issue_number = 1
if IssueString(record['issue_number']).asString().lower() == IssueString(issue_number).asString().lower():
found = True
break
if (found):
issue_url = self.api_base_url + "/issue/" + CVTypeID.Issue + "-" + str(record['id']) + "/?api_key=" + self.api_key + "&format=json"
content = self.getUrlContent(issue_url)
cv_response = json.loads(content)
if cv_response[ 'status_code' ] != 1:
print >> sys.stderr, "Comic Vine query failed with error: [{0}]. ".format( cv_response[ 'error' ] )
return None
issue_results = cv_response['results']
else:
return None
# now, map the comicvine data to generic metadata
return self.mapCVDataToMetadata( volume_results, issue_results, settings )
def fetchIssueDataByIssueID( self, issue_id, settings ):
issue_url = self.api_base_url + "/issue/" + CVTypeID.Issue + "-" + str(issue_id) + "/?api_key=" + self.api_key + "&format=json"
content = self.getUrlContent(issue_url)
cv_response = json.loads(content)
if cv_response[ 'status_code' ] != 1:
print >> sys.stderr, "Comic Vine query failed with error: [{0}]. ".format( cv_response[ 'error' ] )
return None
issue_results = cv_response['results']
volume_results = self.fetchVolumeData( issue_results['volume']['id'] )
# now, map the comicvine data to generic metadata
md = self.mapCVDataToMetadata( volume_results, issue_results, settings )
md.isEmpty = False
return md
def mapCVDataToMetadata(self, volume_results, issue_results, settings ):
# now, map the comicvine data to generic metadata
metadata = GenericMetadata()
metadata.series = issue_results['volume']['name']
num_s = IssueString(issue_results['issue_number']).asString()
metadata.issue = num_s
metadata.title = issue_results['name']
metadata.publisher = volume_results['publisher']['name']
metadata.day, metadata.month, metadata.year = self.parseDateStr( issue_results['cover_date'] )
#metadata.issueCount = volume_results['count_of_issues']
metadata.comments = self.cleanup_html(issue_results['description'], settings.remove_html_tables)
if settings.use_series_start_as_volume:
metadata.volume = volume_results['start_year']
metadata.notes = "Tagged with ComicTagger {0} using info from Comic Vine on {1}. [Issue ID {2}]".format(
ctversion.version,
datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
issue_results['id'])
#metadata.notes += issue_results['site_detail_url']
metadata.webLink = issue_results['site_detail_url']
person_credits = issue_results['person_credits']
for person in person_credits:
if person.has_key('role'):
roles = person['role'].split(',')
for role in roles:
# can we determine 'primary' from CV??
metadata.addCredit( person['name'], role.title().strip(), False )
character_credits = issue_results['character_credits']
character_list = list()
for character in character_credits:
character_list.append( character['name'] )
metadata.characters = utils.listToString( character_list )
team_credits = issue_results['team_credits']
team_list = list()
for team in team_credits:
team_list.append( team['name'] )
metadata.teams = utils.listToString( team_list )
location_credits = issue_results['location_credits']
location_list = list()
for location in location_credits:
location_list.append( location['name'] )
metadata.locations = utils.listToString( location_list )
story_arc_credits = issue_results['story_arc_credits']
arc_list = []
for arc in story_arc_credits:
arc_list.append(arc['name'])
if len(arc_list) > 0:
metadata.storyArc = utils.listToString(arc_list)
return metadata
def cleanup_html( self, string, remove_html_tables):
"""
converter = html2text.HTML2Text()
#converter.emphasis_mark = '*'
#converter.ignore_links = True
converter.body_width = 0
print html2text.html2text(string)
return string
#return converter.handle(string)
"""
if string is None:
return ""
# find any tables
soup = BeautifulSoup(string)
tables = soup.findAll('table')
# remove all newlines first
string = string.replace("\n", "")
#put in our own
string = string.replace("<br>", "\n")
string = string.replace("</p>", "\n\n")
string = string.replace("<h4>", "*")
string = string.replace("</h4>", "*\n")
#remove the tables
p = re.compile(r'<table[^<]*?>.*?<\/table>')
if remove_html_tables:
string = p.sub('',string)
string = string.replace("*List of covers and their creators:*","")
else:
string = p.sub('{}',string)
# now strip all other tags
p = re.compile(r'<[^<]*?>')
newstring = p.sub('',string)
newstring = newstring.replace('&nbsp;',' ')
newstring = newstring.replace('&amp;','&')
newstring = newstring.strip()
if not remove_html_tables:
# now rebuild the tables into text from BSoup
try:
table_strings = []
for table in tables:
rows = []
hdrs = []
col_widths = []
for hdr in table.findAll('th'):
item = hdr.string.strip()
hdrs.append(item)
col_widths.append(len(item))
rows.append(hdrs)
for row in table.findAll('tr'):
cols = []
col = row.findAll('td')
i = 0
for c in col:
item = c.string.strip()
cols.append(item)
if len(item) > col_widths[i]:
col_widths[i] = len(item)
i += 1
if len(cols) != 0:
rows.append(cols)
# now we have the data, make it into text
fmtstr =""
for w in col_widths:
fmtstr += " {{:{}}}|".format(w+1)
width = sum(col_widths) + len(col_widths)*2
print "width=" , width
table_text = ""
counter = 0
for row in rows:
table_text += fmtstr.format(*row) + "\n"
if counter == 0 and len(hdrs)!= 0:
table_text += "-" * width + "\n"
counter += 1
table_strings.append(table_text)
newstring = newstring.format(*table_strings)
except:
# we caught an error rebuilding the table.
# just bail and remove the formatting
print "table parse error"
newstring.replace("{}", "")
return newstring
def fetchIssueDate( self, issue_id ):
details = self.fetchIssueSelectDetails( issue_id )
day, month, year = self.parseDateStr( details['cover_date'] )
return month, year
def fetchIssueCoverURLs( self, issue_id ):
details = self.fetchIssueSelectDetails( issue_id )
return details['image_url'], details['thumb_image_url']
def fetchIssuePageURL( self, issue_id ):
details = self.fetchIssueSelectDetails( issue_id )
return details['site_detail_url']
def fetchIssueSelectDetails( self, issue_id ):
#cached_image_url,cached_thumb_url,cached_month,cached_year = self.fetchCachedIssueSelectDetails( issue_id )
cached_details = self.fetchCachedIssueSelectDetails( issue_id )
if cached_details['image_url'] is not None:
return cached_details
issue_url = self.api_base_url + "/issue/" + CVTypeID.Issue + "-" + str(issue_id) + "/?api_key=" + self.api_key + "&format=json&field_list=image,cover_date,site_detail_url"
content = self.getUrlContent(issue_url)
details = dict()
details['image_url'] = None
details['thumb_image_url'] = None
details['cover_date'] = None
details['site_detail_url'] = None
cv_response = json.loads(content)
if cv_response[ 'status_code' ] != 1:
print >> sys.stderr, "Comic Vine query failed with error: [{0}]. ".format( cv_response[ 'error' ] )
return details
details['image_url'] = cv_response['results']['image']['super_url']
details['thumb_image_url'] = cv_response['results']['image']['thumb_url']
details['cover_date'] = cv_response['results']['cover_date']
details['site_detail_url'] = cv_response['results']['site_detail_url']
if details['image_url'] is not None:
self.cacheIssueSelectDetails( issue_id,
details['image_url'],
details['thumb_image_url'],
details['cover_date'],
details['site_detail_url'] )
#print details['site_detail_url']
return details
def fetchCachedIssueSelectDetails( self, issue_id ):
# before we search online, look in our cache, since we might already
# have this info
cvc = ComicVineCacher( )
return cvc.get_issue_select_details( issue_id )
def cacheIssueSelectDetails( self, issue_id, image_url, thumb_url, cover_date, page_url ):
cvc = ComicVineCacher( )
cvc.add_issue_select_details( issue_id, image_url, thumb_url, cover_date, page_url )
def fetchAlternateCoverURLs(self, issue_id, issue_page_url):
url_list = self.fetchCachedAlternateCoverURLs( issue_id )
if url_list is not None:
return url_list
# scrape the CV issue page URL to get the alternate cover URLs
resp = urllib2.urlopen( issue_page_url )
content = resp.read()
alt_cover_url_list = self.parseOutAltCoverUrls( content)
# cache this alt cover URL list
self.cacheAlternateCoverURLs( issue_id, alt_cover_url_list )
return alt_cover_url_list
def parseOutAltCoverUrls( self, page_html ):
soup = BeautifulSoup( page_html )
alt_cover_url_list = []
# Using knowledge of the layout of the ComicVine issue page here:
# look for the divs that are in the classes 'content-pod' and 'alt-cover'
div_list = soup.find_all( 'div')
covers_found = 0
for d in div_list:
if d.has_key('class'):
c = d['class']
if 'imgboxart' in c and 'issue-cover' in c:
covers_found += 1
if covers_found != 1:
alt_cover_url_list.append( d.img['src'] )
return alt_cover_url_list
def fetchCachedAlternateCoverURLs( self, issue_id ):
# before we search online, look in our cache, since we might already
# have this info
cvc = ComicVineCacher( )
url_list = cvc.get_alt_covers( issue_id )
if url_list is not None:
return url_list
else:
return None
def cacheAlternateCoverURLs( self, issue_id, url_list ):
cvc = ComicVineCacher( )
cvc.add_alt_covers( issue_id, url_list )
#---------------------------------------------------------------------------
urlFetchComplete = pyqtSignal( str , str, int)
def asyncFetchIssueCoverURLs( self, issue_id ):
self.issue_id = issue_id
details = self.fetchCachedIssueSelectDetails( issue_id )
if details['image_url'] is not None:
self.urlFetchComplete.emit( details['image_url'],details['thumb_image_url'], self.issue_id )
return
issue_url = self.api_base_url + "/issue/" + CVTypeID.Issue + "-" + str(issue_id) + "/?api_key=" + self.api_key + "&format=json&field_list=image,cover_date,site_detail_url"
self.nam = QNetworkAccessManager()
self.nam.finished.connect( self.asyncFetchIssueCoverURLComplete )
self.nam.get(QNetworkRequest(QUrl(issue_url)))
def asyncFetchIssueCoverURLComplete( self, reply ):
# read in the response
data = reply.readAll()
try:
cv_response = json.loads(str(data))
except:
print >> sys.stderr, "Comic Vine query failed to get JSON data"
print >> sys.stderr, str(data)
return
if cv_response[ 'status_code' ] != 1:
print >> sys.stderr, "Comic Vine query failed with error: [{0}]. ".format( cv_response[ 'error' ] )
return
image_url = cv_response['results']['image']['super_url']
thumb_url = cv_response['results']['image']['thumb_url']
cover_date = cv_response['results']['cover_date']
page_url = cv_response['results']['site_detail_url']
self.cacheIssueSelectDetails( self.issue_id, image_url, thumb_url, cover_date, page_url )
self.urlFetchComplete.emit( image_url, thumb_url, self.issue_id )
altUrlListFetchComplete = pyqtSignal( list, int)
def asyncFetchAlternateCoverURLs( self, issue_id, issue_page_url ):
# This async version requires the issue page url to be provided!
self.issue_id = issue_id
url_list = self.fetchCachedAlternateCoverURLs( issue_id )
if url_list is not None:
self.altUrlListFetchComplete.emit( url_list, int(self.issue_id) )
return
self.nam = QNetworkAccessManager()
self.nam.finished.connect( self.asyncFetchAlternateCoverURLsComplete )
self.nam.get(QNetworkRequest(QUrl(str(issue_page_url))))
def asyncFetchAlternateCoverURLsComplete( self, reply ):
# read in the response
html = str(reply.readAll())
alt_cover_url_list = self.parseOutAltCoverUrls( html )
# cache this alt cover URL list
self.cacheAlternateCoverURLs( self.issue_id, alt_cover_url_list )
self.altUrlListFetchComplete.emit( alt_cover_url_list, int(self.issue_id) )
def repairUrls(self, issue_list):
#make sure there are URLs for the image fields
for issue in issue_list:
if issue['image'] is None:
issue['image'] = dict()
issue['image']['super_url'] = ComicVineTalker.logo_url
issue['image']['thumb_url'] = ComicVineTalker.logo_url

View File

@ -1,312 +1,295 @@
"""A PyQt5 widget to display cover images
Display cover images from either a local archive, or from Comic Vine.
TODO: This should be re-factored using subclasses!
"""
A PyQt4 widget display cover images from either local archive, or from ComicVine
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
(TODO: This should be re-factored using subclasses!)
"""
import logging
import pathlib
"""
Copyright 2012-2014 Anthony Beville
from PyQt5 import QtCore, QtGui, QtWidgets, uic
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
from comicapi.comicarchive import ComicArchive
from comictaggerlib.graphics import graphics_path
from comictaggerlib.imagefetcher import ImageFetcher
from comictaggerlib.imagepopup import ImagePopup
from comictaggerlib.pageloader import PageLoader
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import get_qimage_from_data, reduce_widget_font_size
from comictalker.comictalker import ComicTalker
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import os
from PyQt4.QtCore import *
from PyQt4.QtGui import *
from PyQt4 import uic
from settings import ComicTaggerSettings
from genericmetadata import GenericMetadata, PageType
from comicarchive import MetaDataStyle
from comicvinetalker import ComicVineTalker, ComicVineTalkerException
from imagefetcher import ImageFetcher
from pageloader import PageLoader
from imagepopup import ImagePopup
import utils
# helper func to allow a label to be clickable
def clickable(widget):
class Filter(QObject):
dblclicked = pyqtSignal()
def eventFilter(self, obj, event):
if obj == widget:
if event.type() == QEvent.MouseButtonDblClick:
self.dblclicked.emit()
return True
return False
filter = Filter(widget)
widget.installEventFilter(filter)
return filter.dblclicked
logger = logging.getLogger(__name__)
class CoverImageWidget(QWidget):
ArchiveMode = 0
AltCoverMode = 1
URLMode = 1
DataMode = 3
def __init__(self, parent, mode, expand_on_click = True ):
super(CoverImageWidget, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('coverimagewidget.ui' ), self)
def clickable(widget: QtWidgets.QWidget) -> QtCore.pyqtBoundSignal:
"""Allow a label to be clickable"""
utils.reduceWidgetFontSize( self.label )
class Filter(QtCore.QObject):
dblclicked = QtCore.pyqtSignal()
self.mode = mode
self.comicVine = ComicVineTalker()
self.page_loader = None
self.showControls = True
def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool:
if obj == widget:
if event.type() == QtCore.QEvent.Type.MouseButtonDblClick:
self.dblclicked.emit()
return True
return False
self.btnLeft.setIcon(QIcon(ComicTaggerSettings.getGraphic('left.png')))
self.btnRight.setIcon(QIcon(ComicTaggerSettings.getGraphic('right.png')))
self.btnLeft.clicked.connect( self.decrementImage )
self.btnRight.clicked.connect( self.incrementImage )
self.resetWidget()
if expand_on_click:
clickable(self.lblImage).connect(self.showPopup)
else:
self.lblImage.setToolTip( "" )
flt = Filter(widget)
widget.installEventFilter(flt)
return flt.dblclicked
self.updateContent()
def resetWidget(self):
self.comic_archive = None
self.issue_id = None
self.comicVine = None
self.cover_fetcher = None
self.url_list = []
if self.page_loader is not None:
self.page_loader.abandoned = True
self.page_loader = None
self.imageIndex = -1
self.imageCount = 1
self.imageData = None
def clear( self ):
self.resetWidget()
self.updateContent()
def incrementImage( self ):
self.imageIndex += 1
if self.imageIndex == self.imageCount:
self.imageIndex = 0
self.updateContent()
class CoverImageWidget(QtWidgets.QWidget):
ArchiveMode = 0
AltCoverMode = 1
URLMode = 1
DataMode = 3
def decrementImage( self ):
self.imageIndex -= 1
if self.imageIndex == -1:
self.imageIndex = self.imageCount -1
self.updateContent()
def setArchive( self, ca, page=0 ):
if self.mode == CoverImageWidget.ArchiveMode:
self.resetWidget()
self.comic_archive = ca
self.imageIndex = page
self.imageCount = ca.getNumberOfPages()
self.updateContent()
image_fetch_complete = QtCore.pyqtSignal(QtCore.QByteArray)
def setURL( self, url ):
if self.mode == CoverImageWidget.URLMode:
self.resetWidget()
self.updateContent()
self.url_list = [ url ]
self.imageIndex = 0
self.imageCount = 1
self.updateContent()
def __init__(
self,
parent: QtWidgets.QWidget,
mode: int,
cache_folder: pathlib.Path | None,
talker: ComicTalker | None,
expand_on_click: bool = True,
) -> None:
super().__init__(parent)
def setIssueID( self, issue_id ):
if self.mode == CoverImageWidget.AltCoverMode:
self.resetWidget()
self.updateContent()
self.issue_id = issue_id
if mode not in (self.AltCoverMode, self.URLMode) or cache_folder is None:
self.cover_fetcher = None
self.talker = None
else:
self.cover_fetcher = ImageFetcher(cache_folder)
self.talker = None
uic.loadUi(ui_path / "coverimagewidget.ui", self)
self.comicVine = ComicVineTalker()
self.comicVine.urlFetchComplete.connect( self.primaryUrlFetchComplete )
self.comicVine.asyncFetchIssueCoverURLs( int(self.issue_id) )
reduce_widget_font_size(self.label)
def setImageData( self, image_data ):
if self.mode == CoverImageWidget.DataMode:
self.resetWidget()
if image_data is None:
self.imageIndex = -1
else:
self.imageIndex = 0
self.imageData = image_data
self.updateContent()
def primaryUrlFetchComplete( self, primary_url, thumb_url, issue_id ):
self.url_list.append(str(primary_url))
self.imageIndex = 0
self.imageCount = len(self.url_list)
self.updateContent()
self.cache_folder = cache_folder
self.mode: int = mode
self.page_loader: PageLoader | None = None
self.showControls = True
#defer the alt cover search
QTimer.singleShot(1, self.startAltCoverSearch)
self.current_pixmap = QtGui.QPixmap()
def startAltCoverSearch( self ):
self.comic_archive: ComicArchive | None = None
self.issue_id: str = ""
self.issue_url: str | None = None
self.url_list: list[str] = []
if self.page_loader is not None:
self.page_loader.abandoned = True
self.page_loader = None
self.imageIndex = -1
self.imageCount = 1
self.imageData = b""
# now we need to get the list of alt cover URLs
self.label.setText("Searching for alt. covers...")
# page URL should already be cached, so no need to defer
self.comicVine = ComicVineTalker()
issue_page_url = self.comicVine.fetchIssuePageURL( self.issue_id )
self.comicVine.altUrlListFetchComplete.connect( self.altCoverUrlListFetchComplete )
self.comicVine.asyncFetchAlternateCoverURLs( int(self.issue_id), issue_page_url)
def altCoverUrlListFetchComplete( self, url_list, issue_id ):
if len(url_list) > 0:
self.url_list.extend(url_list)
self.imageCount = len(self.url_list)
self.updateControls()
self.btnLeft.setIcon(QtGui.QIcon(str(graphics_path / "left.png")))
self.btnRight.setIcon(QtGui.QIcon(str(graphics_path / "right.png")))
def setPage( self, pagenum ):
if self.mode == CoverImageWidget.ArchiveMode:
self.imageIndex = pagenum
self.updateContent()
def updateContent( self ):
self.updateImage()
self.updateControls()
def updateImage( self ):
if self.imageIndex == -1:
self.loadDefault()
elif self.mode in [ CoverImageWidget.AltCoverMode, CoverImageWidget.URLMode ]:
self.loadURL()
elif self.mode == CoverImageWidget.DataMode:
self.coverRemoteFetchComplete( self.imageData, 0 )
else:
self.loadPage()
def updateControls( self ):
if not self.showControls or self.mode == CoverImageWidget.DataMode:
self.btnLeft.hide()
self.btnRight.hide()
self.label.hide()
return
if self.imageIndex == -1 or self.imageCount == 1:
self.btnLeft.setEnabled(False)
self.btnRight.setEnabled(False)
self.btnLeft.hide()
self.btnRight.hide()
else:
self.btnLeft.setEnabled(True)
self.btnRight.setEnabled(True)
self.btnLeft.show()
self.btnRight.show()
if self.imageIndex == -1 or self.imageCount == 1:
self.label.setText("")
elif self.mode == CoverImageWidget.AltCoverMode:
self.label.setText("Cover {0} ( of {1} )".format(self.imageIndex+1, self.imageCount))
else:
self.label.setText("Page {0} ( of {1} )".format(self.imageIndex+1, self.imageCount))
def loadURL( self ):
self.loadDefault()
self.cover_fetcher = ImageFetcher( )
self.cover_fetcher.fetchComplete.connect(self.coverRemoteFetchComplete)
self.cover_fetcher.fetch( self.url_list[self.imageIndex] )
#print "ATB cover fetch started...."
# called when the image is done loading from internet
def coverRemoteFetchComplete( self, image_data, issue_id ):
img = QImage()
img.loadFromData( image_data )
self.current_pixmap = QPixmap(img)
self.setDisplayPixmap( 0, 0)
#print "ATB cover fetch complete!"
self.btnLeft.clicked.connect(self.decrement_image)
self.btnRight.clicked.connect(self.increment_image)
self.image_fetch_complete.connect(self.cover_remote_fetch_complete)
if expand_on_click:
clickable(self.lblImage).connect(self.show_popup)
else:
self.lblImage.setToolTip("")
def loadPage( self ):
if self.comic_archive is not None:
if self.page_loader is not None:
self.page_loader.abandoned = True
self.page_loader = PageLoader( self.comic_archive, self.imageIndex )
self.page_loader.loadComplete.connect( self.pageLoadComplete )
self.page_loader.start()
self.update_content()
def pageLoadComplete( self, img ):
self.current_pixmap = QPixmap(img)
self.setDisplayPixmap( 0, 0)
self.page_loader = None
def loadDefault( self ):
self.current_pixmap = QPixmap(ComicTaggerSettings.getGraphic('nocover.png'))
#print "loadDefault called"
self.setDisplayPixmap( 0, 0)
def reset_widget(self) -> None:
self.comic_archive = None
self.issue_id = ""
self.issue_url = None
self.url_list = []
if self.page_loader is not None:
self.page_loader.abandoned = True
self.page_loader = None
self.imageIndex = -1
self.imageCount = 1
self.imageData = b""
def resizeEvent( self, resize_event ):
if self.current_pixmap is not None:
delta_w = resize_event.size().width() - resize_event.oldSize().width()
delta_h = resize_event.size().height() - resize_event.oldSize().height()
#print "ATB resizeEvent deltas", resize_event.size().width(), resize_event.size().height()
self.setDisplayPixmap( delta_w , delta_h )
def setDisplayPixmap( self, delta_w , delta_h ):
# the deltas let us know what the new width and height of the label will be
"""
new_h = self.frame.height() + delta_h
new_w = self.frame.width() + delta_w
print "ATB setDisplayPixmap deltas", delta_w , delta_h
print "ATB self.frame", self.frame.width(), self.frame.height()
print "ATB self.", self.width(), self.height()
frame_w = new_w
frame_h = new_h
"""
new_h = self.frame.height()
new_w = self.frame.width()
frame_w = self.frame.width()
frame_h = self.frame.height()
def clear(self) -> None:
self.reset_widget()
self.update_content()
new_h -= 4
new_w -= 4
if new_h < 0:
new_h = 0;
if new_w < 0:
new_w = 0;
def increment_image(self) -> None:
self.imageIndex += 1
if self.imageIndex == self.imageCount:
self.imageIndex = 0
self.update_content()
#print "ATB setDisplayPixmap deltas", delta_w , delta_h
#print "ATB self.frame", frame_w, frame_h
#print "ATB new size", new_w, new_h
# scale the pixmap to fit in the frame
scaled_pixmap = self.current_pixmap.scaled(new_w, new_h, Qt.KeepAspectRatio)
self.lblImage.setPixmap( scaled_pixmap )
# move and resize the label to be centered in the fame
img_w = scaled_pixmap.width()
img_h = scaled_pixmap.height()
self.lblImage.resize( img_w, img_h )
self.lblImage.move( (frame_w - img_w)/2, (frame_h - img_h)/2 )
def showPopup( self ):
self.popup = ImagePopup(self, self.current_pixmap)
def decrement_image(self) -> None:
self.imageIndex -= 1
if self.imageIndex == -1:
self.imageIndex = self.imageCount - 1
self.update_content()
def set_archive(self, ca: ComicArchive, page: int = 0) -> None:
if self.mode == CoverImageWidget.ArchiveMode:
self.reset_widget()
self.comic_archive = ca
self.imageIndex = page
self.imageCount = ca.get_number_of_pages()
self.update_content()
def set_url(self, url: str) -> None:
if self.mode == CoverImageWidget.URLMode:
self.reset_widget()
self.update_content()
self.url_list = [url]
self.imageIndex = 0
self.imageCount = 1
self.update_content()
def set_issue_details(self, issue_id: str, url_list: list[str]) -> None:
if self.mode == CoverImageWidget.AltCoverMode:
self.reset_widget()
self.update_content()
self.issue_id = issue_id
self.set_url_list(url_list)
def set_image_data(self, image_data: bytes) -> None:
if self.mode == CoverImageWidget.DataMode:
self.reset_widget()
if image_data:
self.imageIndex = 0
self.imageData = image_data
else:
self.imageIndex = -1
self.update_content()
def set_url_list(self, url_list: list[str]) -> None:
self.url_list = url_list
self.imageIndex = 0
self.imageCount = len(self.url_list)
self.update_content()
self.update_controls()
def set_page(self, pagenum: int) -> None:
if self.mode == CoverImageWidget.ArchiveMode:
self.imageIndex = pagenum
self.update_content()
def update_content(self) -> None:
self.update_image()
self.update_controls()
def update_image(self) -> None:
if self.imageIndex == -1:
self.load_default()
elif self.mode in [CoverImageWidget.AltCoverMode, CoverImageWidget.URLMode]:
self.load_url()
elif self.mode == CoverImageWidget.DataMode:
self.cover_remote_fetch_complete(self.imageData)
else:
self.load_page()
def update_controls(self) -> None:
if not self.showControls or self.mode == CoverImageWidget.DataMode:
self.btnLeft.hide()
self.btnRight.hide()
self.label.hide()
return
if self.imageIndex == -1 or self.imageCount == 1:
self.btnLeft.setEnabled(False)
self.btnRight.setEnabled(False)
self.btnLeft.hide()
self.btnRight.hide()
else:
self.btnLeft.setEnabled(True)
self.btnRight.setEnabled(True)
self.btnLeft.show()
self.btnRight.show()
if self.imageIndex == -1 or self.imageCount == 1:
self.label.setText("")
elif self.mode == CoverImageWidget.AltCoverMode:
self.label.setText(f"Cover {self.imageIndex + 1} (of {self.imageCount})")
else:
self.label.setText(f"Page {self.imageIndex + 1} (of {self.imageCount})")
def load_url(self) -> None:
assert isinstance(self.cache_folder, pathlib.Path)
self.load_default()
self.cover_fetcher = ImageFetcher(self.cache_folder)
ImageFetcher.image_fetch_complete = self.image_fetch_complete.emit
self.cover_fetcher.fetch(self.url_list[self.imageIndex])
# called when the image is done loading from internet
def cover_remote_fetch_complete(self, image_data: bytes) -> None:
img = get_qimage_from_data(image_data)
self.current_pixmap = QtGui.QPixmap.fromImage(img)
self.set_display_pixmap()
def load_page(self) -> None:
if self.comic_archive is not None:
if self.page_loader is not None:
self.page_loader.abandoned = True
self.page_loader = PageLoader(self.comic_archive, self.imageIndex)
self.page_loader.loadComplete.connect(self.page_load_complete)
self.page_loader.start()
def page_load_complete(self, image_data: bytes) -> None:
img = get_qimage_from_data(image_data)
self.current_pixmap = QtGui.QPixmap.fromImage(img)
self.set_display_pixmap()
self.page_loader = None
def load_default(self) -> None:
self.current_pixmap = QtGui.QPixmap(str(graphics_path / "nocover.png"))
self.set_display_pixmap()
def resizeEvent(self, resize_event: QtGui.QResizeEvent) -> None:
if self.current_pixmap is not None:
self.set_display_pixmap()
def set_display_pixmap(self) -> None:
"""The deltas let us know what the new width and height of the label will be"""
new_h = self.frame.height()
new_w = self.frame.width()
frame_w = self.frame.width()
frame_h = self.frame.height()
new_h -= 4
new_w -= 4
new_h = max(new_h, 0)
new_w = max(new_w, 0)
# scale the pixmap to fit in the frame
scaled_pixmap = self.current_pixmap.scaled(
new_w, new_h, QtCore.Qt.AspectRatioMode.KeepAspectRatio, QtCore.Qt.SmoothTransformation
)
self.lblImage.setPixmap(scaled_pixmap)
# move and resize the label to be centered in the fame
img_w = scaled_pixmap.width()
img_h = scaled_pixmap.height()
self.lblImage.resize(img_w, img_h)
self.lblImage.move(int((frame_w - img_w) / 2), int((frame_h - img_h) / 2))
def show_popup(self) -> None:
ImagePopup(self, self.current_pixmap)

View File

@ -1,99 +1,96 @@
"""
A PyQT4 dialog to edit credits
"""
"""A PyQT4 dialog to edit credits"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
import logging
from typing import Any
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
from PyQt5 import QtWidgets, uic
http://www.apache.org/licenses/LICENSE-2.0
from comictaggerlib.ui import ui_path
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.
"""
logger = logging.getLogger(__name__)
from PyQt4 import QtCore, QtGui, uic
from settings import ComicTaggerSettings
import os
class CreditEditorWindow(QtWidgets.QDialog):
ModeEdit = 0
ModeNew = 1
class CreditEditorWindow(QtGui.QDialog):
ModeEdit = 0
ModeNew = 1
def __init__(self, parent, mode, role, name, primary ):
super(CreditEditorWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('crediteditorwindow.ui' ), self)
self.mode = mode
if self.mode == self.ModeEdit:
self.setWindowTitle("Edit Credit")
else:
self.setWindowTitle("New Credit")
def __init__(self, parent: QtWidgets.QWidget, mode: int, role: str, name: str, primary: bool) -> None:
super().__init__(parent)
# Add the entries to the role combobox
self.cbRole.addItem( "" )
self.cbRole.addItem( "Writer" )
self.cbRole.addItem( "Artist" )
self.cbRole.addItem( "Penciller" )
self.cbRole.addItem( "Inker" )
self.cbRole.addItem( "Colorist" )
self.cbRole.addItem( "Letterer" )
self.cbRole.addItem( "Cover Artist" )
self.cbRole.addItem( "Editor" )
self.cbRole.addItem( "Other" )
self.cbRole.addItem( "Plotter" )
self.cbRole.addItem( "Scripter" )
self.leName.setText( name )
if role is not None and role != "":
i = self.cbRole.findText( role )
if i == -1:
self.cbRole.setEditText( role )
else:
self.cbRole.setCurrentIndex( i )
uic.loadUi(ui_path / "crediteditorwindow.ui", self)
if primary:
self.cbPrimary.setCheckState( QtCore.Qt.Checked )
self.cbRole.currentIndexChanged.connect(self.roleChanged)
self.cbRole.editTextChanged.connect(self.roleChanged)
self.updatePrimaryButton()
self.mode = mode
def updatePrimaryButton( self ):
enabled =self.currentRoleCanBePrimary()
self.cbPrimary.setEnabled( enabled )
if self.mode == self.ModeEdit:
self.setWindowTitle("Edit Credit")
else:
self.setWindowTitle("New Credit")
def currentRoleCanBePrimary( self ):
role = self.cbRole.currentText()
if str(role).lower() == "writer" or str(role).lower() == "artist":
return True
else:
return False
def roleChanged( self, s ):
self.updatePrimaryButton()
def getCredits( self ):
primary = self.currentRoleCanBePrimary() and self.cbPrimary.isChecked()
return self.cbRole.currentText(), self.leName.text(), primary
# Add the entries to the role combobox
self.cbRole.addItem("")
self.cbRole.addItem("Writer")
self.cbRole.addItem("Artist")
self.cbRole.addItem("Penciller")
self.cbRole.addItem("Inker")
self.cbRole.addItem("Colorist")
self.cbRole.addItem("Letterer")
self.cbRole.addItem("Cover Artist")
self.cbRole.addItem("Editor")
self.cbRole.addItem("Other")
self.cbRole.addItem("Plotter")
self.cbRole.addItem("Scripter")
self.leName.setText(name)
def accept( self ):
if self.cbRole.currentText() == "" or self.leName.text() == "":
QtGui.QMessageBox.warning(self, self.tr("Whoops"), self.tr("You need to enter both role and name for a credit."))
else:
QtGui.QDialog.accept(self)
if role is not None and role != "":
i = self.cbRole.findText(role)
if i == -1:
self.cbRole.setEditText(role)
else:
self.cbRole.setCurrentIndex(i)
self.cbPrimary.setChecked(primary)
self.cbRole.currentIndexChanged.connect(self.role_changed)
self.cbRole.editTextChanged.connect(self.role_changed)
self.update_primary_button()
def update_primary_button(self) -> None:
enabled = self.current_role_can_be_primary()
self.cbPrimary.setEnabled(enabled)
def current_role_can_be_primary(self) -> bool:
role = self.cbRole.currentText()
if role.casefold() in ("artist", "writer"):
return True
return False
def role_changed(self, s: Any) -> None:
self.update_primary_button()
def get_credits(self) -> tuple[str, str, bool]:
primary = self.current_role_can_be_primary() and self.cbPrimary.isChecked()
return self.cbRole.currentText(), self.leName.text(), primary
def accept(self) -> None:
if self.cbRole.currentText() == "" or self.leName.text() == "":
QtWidgets.QMessageBox.warning(self, "Whoops", "You need to enter both role and name for a credit.")
else:
QtWidgets.QDialog.accept(self)

View File

@ -0,0 +1,24 @@
from __future__ import annotations
from comictaggerlib.ctsettings.commandline import (
initial_commandline_parser,
register_commandline_settings,
validate_commandline_settings,
)
from comictaggerlib.ctsettings.file import register_file_settings, validate_file_settings
from comictaggerlib.ctsettings.plugin import register_plugin_settings, validate_plugin_settings
from comictaggerlib.ctsettings.types import ComicTaggerPaths
from comictalker import ComicTalker
talkers: dict[str, ComicTalker] = {}
__all__ = [
"initial_commandline_parser",
"register_commandline_settings",
"register_file_settings",
"register_plugin_settings",
"validate_commandline_settings",
"validate_file_settings",
"validate_plugin_settings",
"ComicTaggerPaths",
]

View File

@ -0,0 +1,306 @@
"""CLI settings for ComicTagger"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
import argparse
import logging
import os
import platform
import settngs
from comicapi import utils
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib import ctversion
from comictaggerlib.ctsettings.types import ComicTaggerPaths, metadata_type, parse_metadata_from_string
logger = logging.getLogger(__name__)
def initial_commandline_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(add_help=False)
# Ensure this stays up to date with register_settings
parser.add_argument(
"--config",
help="Config directory defaults to ~/.ComicTagger\non Linux/Mac and %%APPDATA%% on Windows\n",
type=ComicTaggerPaths,
default=ComicTaggerPaths(),
)
parser.add_argument("-v", "--verbose", action="count", default=0, help="Be noisy when doing what it does.")
return parser
def register_settings(parser: settngs.Manager) -> None:
parser.add_setting(
"--config",
help="Config directory defaults to ~/.Config/ComicTagger\non Linux, ~/Library/Application Support/ComicTagger on Mac and %%APPDATA%%\\ComicTagger on Windows\n",
type=ComicTaggerPaths,
default=ComicTaggerPaths(),
file=False,
)
parser.add_setting(
"-v",
"--verbose",
action="count",
default=0,
help="Be noisy when doing what it does.",
file=False,
)
parser.add_setting(
"--abort-on-conflict",
action="store_true",
help="""Don't export to zip if intended new filename\nexists (otherwise, creates a new unique filename).\n\n""",
file=False,
)
parser.add_setting(
"--delete-original",
action="store_true",
dest="delete_after_zip_export",
help="""Delete original archive after successful\nexport to Zip. (only relevant for -e)""",
file=False,
)
parser.add_setting(
"-f",
"--parse-filename",
"--parsefilename",
action="store_true",
help="""Parse the filename to get some info,\nspecifically series name, issue number,\nvolume, and publication year.\n\n""",
file=False,
)
parser.add_setting(
"--id",
dest="issue_id",
type=int,
help="""Use the issue ID when searching online.\nOverrides all other metadata.\n\n""",
file=False,
)
parser.add_setting(
"-o",
"--online",
action="store_true",
help="""Search online and attempt to identify file\nusing existing metadata and images in archive.\nMay be used in conjunction with -f and -m.\n\n""",
file=False,
)
parser.add_setting(
"-m",
"--metadata",
default=GenericMetadata(),
type=parse_metadata_from_string,
help="""Explicitly define, as a list, some tags to be used. e.g.:\n"series=Plastic Man, publisher=Quality Comics"\n"series=Kickers^, Inc., issue=1, year=1986"\nName-Value pairs are comma separated. Use a\n"^" to escape an "=" or a ",", as shown in\nthe example above. Some names that can be\nused: series, issue, issue_count, year,\npublisher, title\n\n""",
file=False,
)
parser.add_setting(
"-i",
"--interactive",
action="store_true",
help="""Interactively query the user when there are\nmultiple matches for an online search.\n\n""",
file=False,
)
parser.add_setting(
"--abort",
dest="abort_on_low_confidence",
action=argparse.BooleanOptionalAction,
default=True,
help="""Abort save operation when online match\nis of low confidence.\n\n""",
file=False,
)
parser.add_setting(
"--summary",
default=True,
action=argparse.BooleanOptionalAction,
help="Show the summary after a save operation.\n\n",
file=False,
)
parser.add_setting(
"--raw",
action="store_true",
help="""With -p, will print out the raw tag block(s)\nfrom the file.\n""",
file=False,
)
parser.add_setting(
"-R",
"--recursive",
action="store_true",
help="Recursively include files in sub-folders.",
file=False,
)
parser.add_setting(
"-S",
"--script",
help="""Run an "add-on" python script that uses the\nComicTagger library for custom processing.\nScript arguments can follow the script name.\n\n""",
file=False,
)
parser.add_setting(
"--split-words",
action="store_true",
help="""Splits words before parsing the filename.\ne.g. 'judgedredd' to 'judge dredd'\n\n""",
file=False,
)
parser.add_setting(
"-n",
"--dryrun",
action="store_true",
help="Don't actually modify file (only relevant for -d, -s, or -r).\n\n",
file=False,
)
parser.add_setting("--darkmode", action="store_true", help="Windows only. Force a dark pallet", file=False)
parser.add_setting("-g", "--glob", action="store_true", help="Windows only. Enable globbing", file=False)
parser.add_setting("--quiet", "-q", action="store_true", help="Don't say much (for print mode).", file=False)
parser.add_setting(
"-t",
"--type",
metavar="{CR,CBL,COMET}",
default=[],
type=metadata_type,
help="""Specify TYPE as either CR, CBL or COMET\n(as either ComicRack, ComicBookLover,\nor CoMet style tags, respectively).\nUse commas for multiple types.\nFor searching the metadata will use the first listed:\neg '-t cbl,cr' with no CBL tags, CR will be used if they exist\n\n""",
file=False,
)
parser.add_setting(
"--overwrite",
dest="overwrite",
action=argparse.BooleanOptionalAction,
default=True,
help="""Apply metadata to already tagged archives (relevant for -s or -c).""",
file=False,
)
parser.add_setting("files", nargs="*", file=False)
def register_commands(parser: settngs.Manager) -> None:
parser.add_setting("--version", action="store_true", help="Display version.", file=False)
parser.add_setting(
"-p",
"--print",
action="store_true",
help="""Print out tag info from file. Specify type\n(via -t) to get only info of that tag type.\n\n""",
file=False,
)
parser.add_setting(
"-d",
"--delete",
action="store_true",
help="Deletes the tag block of specified type (via -t).\n",
file=False,
)
parser.add_setting(
"-c",
"--copy",
type=metadata_type,
metavar="{CR,CBL,COMET}",
help="Copy the specified source tag block to\ndestination style specified via -t\n(potentially lossy operation).\n\n",
file=False,
)
parser.add_setting(
"-s",
"--save",
action="store_true",
help="Save out tags as specified type (via -t).\nMust specify also at least -o, -f, or -m.\n\n",
file=False,
)
parser.add_setting(
"-r",
"--rename",
action="store_true",
help="Rename the file based on specified tag style.",
file=False,
)
parser.add_setting(
"-e",
"--export-to-zip",
action="store_true",
help="Export RAR archive to Zip format.",
file=False,
)
parser.add_setting(
"--only-set-cv-key",
action="store_true",
help="Only set the Comic Vine API key and quit.\n\n",
file=False,
)
def register_commandline_settings(parser: settngs.Manager) -> None:
parser.add_group("commands", register_commands, True)
parser.add_group("runtime", register_settings)
def validate_commandline_settings(
config: settngs.Config[settngs.Namespace], parser: settngs.Manager
) -> settngs.Config[settngs.Namespace]:
if config[0].commands_version:
parser.exit(
status=1,
message=f"ComicTagger {ctversion.version}: Copyright (c) 2012-2022 ComicTagger Team\n"
"Distributed under Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)\n",
)
config[0].runtime_no_gui = any(
[
config[0].commands_print,
config[0].commands_delete,
config[0].commands_save,
config[0].commands_copy,
config[0].commands_rename,
config[0].commands_export_to_zip,
config[0].commands_only_set_cv_key,
]
)
if platform.system() == "Windows" and config[0].runtime_glob:
# no globbing on windows shell, so do it for them
import glob
globs = config[0].runtime_files
config[0].runtime_files = []
for item in globs:
config[0].runtime_files.extend(glob.glob(item))
if not config[0].commands_only_set_cv_key and config[0].runtime_no_gui and not config[0].runtime_files:
parser.exit(message="Command requires at least one filename!\n", status=1)
if config[0].commands_delete and not config[0].runtime_type:
parser.exit(message="Please specify the type to delete with -t\n", status=1)
if config[0].commands_save and not config[0].runtime_type:
parser.exit(message="Please specify the type to save with -t\n", status=1)
if config[0].commands_copy:
if not config[0].runtime_type:
parser.exit(message="Please specify the type to copy to with -t\n", status=1)
if len(config[0].commands_copy) > 1:
parser.exit(message="Please specify only one type to copy to with -c\n", status=1)
config[0].commands_copy = config[0].commands_copy[0]
if config[0].runtime_recursive:
config[0].runtime_file_list = utils.get_recursive_filelist(config[0].runtime_files)
else:
config[0].runtime_file_list = config[0].runtime_files
# take a crack at finding rar exe if it's not in the path
if not utils.which("rar"):
if platform.system() == "Windows":
# look in some likely places for Windows machines
if os.path.exists(r"C:\Program Files\WinRAR\Rar.exe"):
utils.add_to_path(r"C:\Program Files\WinRAR")
elif os.path.exists(r"C:\Program Files (x86)\WinRAR\Rar.exe"):
utils.add_to_path(r"C:\Program Files (x86)\WinRAR")
else:
if os.path.exists("/opt/homebrew/bin"):
utils.add_to_path("/opt/homebrew/bin")
return config

View File

@ -0,0 +1,243 @@
from __future__ import annotations
import argparse
import uuid
import settngs
from comictaggerlib.ctsettings.types import AppendAction
from comictaggerlib.defaults import DEFAULT_REPLACEMENTS, Replacement, Replacements
def general(parser: settngs.Manager) -> None:
# General Settings
parser.add_setting("check_for_new_version", default=False, cmdline=False)
def internal(parser: settngs.Manager) -> None:
# automatic settings
parser.add_setting("install_id", default=uuid.uuid4().hex, cmdline=False)
parser.add_setting("save_data_style", default=0, cmdline=False)
parser.add_setting("load_data_style", default=0, cmdline=False)
parser.add_setting("last_opened_folder", default="", cmdline=False)
parser.add_setting("window_width", default=0, cmdline=False)
parser.add_setting("window_height", default=0, cmdline=False)
parser.add_setting("window_x", default=0, cmdline=False)
parser.add_setting("window_y", default=0, cmdline=False)
parser.add_setting("form_width", default=-1, cmdline=False)
parser.add_setting("list_width", default=-1, cmdline=False)
parser.add_setting("sort_column", default=-1, cmdline=False)
parser.add_setting("sort_direction", default=0, cmdline=False)
def identifier(parser: settngs.Manager) -> None:
# identifier settings
parser.add_setting("--series-match-identify-thresh", default=91, type=int, help="")
parser.add_setting(
"-b",
"--border-crop-percent",
default=10,
type=int,
help="ComicTagger will automatically add an additional cover that has any black borders cropped. If the difference in height is less than %(default)s%% the cover will not be cropped.",
)
parser.add_setting(
"--publisher-filter",
default=["Panini Comics", "Abril", "Planeta DeAgostini", "Editorial Televisa", "Dino Comics"],
action=AppendAction,
help="When enabled filters the listed publishers from all search results",
)
parser.add_setting(
"--tpb-detection",
default=False,
action=argparse.BooleanOptionalAction,
help="Enables TPB detection. May stop comics not marked as a TPB in ComicVine from being found.",
)
def dialog(parser: settngs.Manager) -> None:
# Show/ask dialog flags
parser.add_setting("show_disclaimer", default=True, cmdline=False)
parser.add_setting("dont_notify_about_this_version", default="", cmdline=False)
parser.add_setting("ask_about_usage_stats", default=True, cmdline=False)
def filename(parser: settngs.Manager) -> None:
# filename parsing settings
parser.add_setting(
"--complicated-parser",
default=False,
action=argparse.BooleanOptionalAction,
help="Enables the new parser which tries to extract more information from filenames",
)
parser.add_setting(
"--remove-c2c",
default=False,
action=argparse.BooleanOptionalAction,
help="Removes c2c from filenames. Requires --complicated-parser",
)
parser.add_setting(
"--remove-fcbd",
default=False,
action=argparse.BooleanOptionalAction,
help="Removes FCBD/free comic book day from filenames. Requires --complicated-parser",
)
parser.add_setting(
"--remove-publisher",
default=False,
action=argparse.BooleanOptionalAction,
help="Attempts to remove publisher names from filenames, currently limited to Marvel and DC. Requires --complicated-parser",
)
def talker(parser: settngs.Manager) -> None:
# General settings for talkers
parser.add_setting("--source", default="comicvine", help="Use a specified source by source ID")
parser.add_setting("--series-match-search-thresh", default=90, type=int)
parser.add_setting(
"--clear-metadata",
default=True,
help="Clears all existing metadata during import, default is to merges metadata.\nMay be used in conjunction with -o, -f and -m.\n\n",
dest="clear_metadata_on_import",
action=argparse.BooleanOptionalAction,
)
parser.add_setting(
"-a",
"--auto-imprint",
action=argparse.BooleanOptionalAction,
default=False,
help="Enables the auto imprint functionality.\ne.g. if the publisher is set to 'vertigo' it\nwill be updated to 'DC Comics' and the imprint\nproperty will be set to 'Vertigo'.\n\n",
)
parser.add_setting(
"--sort-series-by-year", default=True, action=argparse.BooleanOptionalAction, help="Sorts series by year"
)
parser.add_setting(
"--exact-series-matches-first",
default=True,
action=argparse.BooleanOptionalAction,
help="Puts series that are an exact match at the top of the list",
)
parser.add_setting(
"--always-use-publisher-filter",
default=False,
action=argparse.BooleanOptionalAction,
help="Enables the publisher filter",
)
parser.add_setting(
"--clear-form-before-populating",
default=False,
action=argparse.BooleanOptionalAction,
help="Clears all existing metadata when applying metadata from comic source",
)
def cbl(parser: settngs.Manager) -> None:
# CBL Transform settings
parser.add_setting("--assume-lone-credit-is-primary", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--copy-characters-to-tags", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--copy-teams-to-tags", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--copy-locations-to-tags", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--copy-storyarcs-to-tags", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--copy-notes-to-comments", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--copy-weblink-to-comments", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--apply-transform-on-import", default=False, action=argparse.BooleanOptionalAction)
parser.add_setting("--apply-transform-on-bulk-operation", default=False, action=argparse.BooleanOptionalAction)
def rename(parser: settngs.Manager) -> None:
# Rename settings
parser.add_setting("--template", default="{series} #{issue} ({year})", help="The teplate to use when renaming")
parser.add_setting(
"--issue-number-padding",
default=3,
type=int,
help="The minimum number of digits to use for the issue number when renaming",
)
parser.add_setting(
"--use-smart-string-cleanup",
default=True,
action=argparse.BooleanOptionalAction,
help="Attempts to intelligently cleanup whitespace when renaming",
)
parser.add_setting(
"--auto-extension",
dest="set_extension_based_on_archive",
default=True,
action=argparse.BooleanOptionalAction,
help="Automatically sets the extension based on the archive type e.g. cbr for rar, cbz for zip",
)
parser.add_setting("--dir", default="", help="The directory to move renamed files to")
parser.add_setting(
"--move",
dest="move_to_dir",
default=False,
action=argparse.BooleanOptionalAction,
help="Enables moving renamed files to a separate directory",
)
parser.add_setting(
"--strict",
default=False,
action=argparse.BooleanOptionalAction,
help="Ensures that filenames are valid for all OSs",
)
parser.add_setting("replacements", default=DEFAULT_REPLACEMENTS, cmdline=False)
def autotag(parser: settngs.Manager) -> None:
# Auto-tag stickies
parser.add_setting(
"--save-on-low-confidence",
default=False,
action=argparse.BooleanOptionalAction,
help="Automatically save metadata on low-confidence matches",
)
parser.add_setting(
"--dont-use-year-when-identifying",
default=False,
action=argparse.BooleanOptionalAction,
help="Ignore the year metadata attribute when identifying a comic",
)
parser.add_setting(
"-1",
"--assume-issue-one",
dest="assume_1_if_no_issue_num",
action=argparse.BooleanOptionalAction,
help="Assume issue number is 1 if not found (relevant for -s).\n\n",
default=False,
)
parser.add_setting(
"--ignore-leading-numbers-in-filename",
default=False,
action=argparse.BooleanOptionalAction,
help="When searching ignore leading numbers in the filename",
)
parser.add_setting("remove_archive_after_successful_match", default=False, cmdline=False)
parser.add_setting(
"-w",
"--wait-on-rate-limit",
dest="wait_and_retry_on_rate_limit",
action=argparse.BooleanOptionalAction,
default=True,
help="When encountering a Comic Vine rate limit\nerror, wait and retry query.\n\n",
)
def validate_file_settings(config: settngs.Config[settngs.Namespace]) -> settngs.Config[settngs.Namespace]:
config[0].identifier_publisher_filter = [x.strip() for x in config[0].identifier_publisher_filter if x.strip()]
config[0].rename_replacements = Replacements(
[Replacement(x[0], x[1], x[2]) for x in config[0].rename_replacements[0]],
[Replacement(x[0], x[1], x[2]) for x in config[0].rename_replacements[1]],
)
return config
def register_file_settings(parser: settngs.Manager) -> None:
parser.add_group("general", general, False)
parser.add_group("internal", internal, False)
parser.add_group("identifier", identifier, False)
parser.add_group("dialog", dialog, False)
parser.add_group("filename", filename, False)
parser.add_group("talker", talker, False)
parser.add_group("cbl", cbl, False)
parser.add_group("rename", rename, False)
parser.add_group("autotag", autotag, False)

View File

@ -0,0 +1,75 @@
from __future__ import annotations
import logging
import os
import settngs
import comicapi.comicarchive
import comictaggerlib.ctsettings
logger = logging.getLogger("comictagger")
def archiver(manager: settngs.Manager) -> None:
exe_registered: set[str] = set()
for archiver in comicapi.comicarchive.archivers:
if archiver.exe and archiver.exe not in exe_registered:
manager.add_setting(
f"--{archiver.exe.replace(' ', '-').replace('_', '-').strip().strip('-')}",
default=archiver.exe,
help="Path to the %(default)s executable\n\n",
)
exe_registered.add(archiver.exe)
def register_talker_settings(manager: settngs.Manager) -> None:
for talker_name, talker in comictaggerlib.ctsettings.talkers.items():
try:
manager.add_persistent_group("talker_" + talker_name, talker.register_settings, False)
except Exception:
logger.exception("Failed to register settings for %s", talker_name)
def validate_archive_settings(config: settngs.Config[settngs.Namespace]) -> settngs.Config[settngs.Namespace]:
if "archiver" not in config[1]:
return config
cfg = settngs.normalize_config(config, file=True, cmdline=True, defaults=False)
for archiver in comicapi.comicarchive.archivers:
exe_name = settngs.sanitize_name(archiver.exe)
if (
exe_name in cfg[0]["archiver"]
and cfg[0]["archiver"][exe_name]
and cfg[0]["archiver"][exe_name] != archiver.exe
):
if os.path.basename(cfg[0]["archiver"][exe_name]) == archiver.exe:
comicapi.utils.add_to_path(os.path.dirname(cfg[0]["archiver"][exe_name]))
else:
archiver.exe = cfg[0]["archiver"][exe_name]
return config
def validate_talker_settings(config: settngs.Config[settngs.Namespace]) -> settngs.Config[settngs.Namespace]:
# Apply talker settings from config file
cfg = settngs.normalize_config(config, True, True)
for talker_name, talker in list(comictaggerlib.ctsettings.talkers.items()):
try:
talker.parse_settings(cfg[0]["talker_" + talker_name])
except Exception as e:
# Remove talker as we failed to apply the settings
del comictaggerlib.ctsettings.talkers[talker_name]
logger.exception("Failed to initialize talker settings: %s", e)
return config
def validate_plugin_settings(config: settngs.Config[settngs.Namespace]) -> settngs.Config[settngs.Namespace]:
config = validate_archive_settings(config)
config = validate_talker_settings(config)
return config
def register_plugin_settings(manager: settngs.Manager) -> None:
manager.add_persistent_group("archiver", archiver, False)
register_talker_settings(manager)

View File

@ -0,0 +1,186 @@
from __future__ import annotations
import argparse
import pathlib
from collections.abc import Sequence
from typing import Any, Callable
from appdirs import AppDirs
from comicapi.comicarchive import MetaDataStyle
from comicapi.genericmetadata import GenericMetadata
class ComicTaggerPaths(AppDirs):
def __init__(self, config_path: pathlib.Path | str | None = None) -> None:
super().__init__("ComicTagger", None, None, False, False)
self.path: pathlib.Path | None = None
if config_path:
self.path = pathlib.Path(config_path).absolute()
@property
def user_data_dir(self) -> pathlib.Path:
if self.path:
return self.path
return pathlib.Path(super().user_data_dir)
@property
def user_config_dir(self) -> pathlib.Path:
if self.path:
return self.path
return pathlib.Path(super().user_config_dir)
@property
def user_cache_dir(self) -> pathlib.Path:
if self.path:
path = self.path / "cache"
return path
return pathlib.Path(super().user_cache_dir)
@property
def user_state_dir(self) -> pathlib.Path:
if self.path:
return self.path
return pathlib.Path(super().user_state_dir)
@property
def user_log_dir(self) -> pathlib.Path:
if self.path:
path = self.path / "log"
return path
return pathlib.Path(super().user_log_dir)
@property
def site_data_dir(self) -> pathlib.Path:
return pathlib.Path(super().site_data_dir)
@property
def site_config_dir(self) -> pathlib.Path:
return pathlib.Path(super().site_config_dir)
def metadata_type(types: str) -> list[int]:
result = []
types = types.casefold()
for typ in types.split(","):
typ = typ.strip()
if typ not in MetaDataStyle.short_name:
choices = ", ".join(MetaDataStyle.short_name)
raise argparse.ArgumentTypeError(f"invalid choice: {typ} (choose from {choices.upper()})")
result.append(MetaDataStyle.short_name.index(typ))
return result
def _copy_items(items: Sequence[Any] | None) -> Sequence[Any]:
if items is None:
return []
# The copy module is used only in the 'append' and 'append_const'
# actions, and it is needed only when the default value isn't a list.
# Delay its import for speeding up the common case.
if type(items) is list:
return items[:]
import copy
return copy.copy(items)
class AppendAction(argparse.Action):
def __init__(
self,
option_strings: list[str],
dest: str,
nargs: str | None = None,
const: Any = None,
default: Any = None,
type: Callable[[str], Any] | None = None, # noqa: A002
choices: list[Any] | None = None,
required: bool = False,
help: str | None = None, # noqa: A002
metavar: str | None = None,
):
self.called = False
if nargs == 0:
raise ValueError(
"nargs for append actions must be != 0; if arg "
"strings are not supplying the value to append, "
"the append const action may be more appropriate"
)
if const is not None and nargs != argparse.OPTIONAL:
raise ValueError("nargs must be %r to supply const" % argparse.OPTIONAL)
super().__init__(
option_strings=option_strings,
dest=dest,
nargs=nargs,
const=const,
default=default,
type=type,
choices=choices,
required=required,
help=help,
metavar=metavar,
)
def __call__(
self,
parser: argparse.ArgumentParser,
namespace: argparse.Namespace,
values: str | Sequence[Any] | None,
option_string: str | None = None,
) -> None:
if values:
if not self.called:
setattr(namespace, self.dest, [])
items = getattr(namespace, self.dest, None)
items = _copy_items(items)
items.append(values) # type: ignore
setattr(namespace, self.dest, items)
def parse_metadata_from_string(mdstr: str) -> GenericMetadata:
"""The metadata string is a comma separated list of name-value pairs
The names match the attributes of the internal metadata struct (for now)
The caret is the special "escape character", since it's not common in
natural language text
example = "series=Kickers^, Inc. ,issue=1, year=1986"
"""
escaped_comma = "^,"
escaped_equals = "^="
replacement_token = "<_~_>"
md = GenericMetadata()
# First, replace escaped commas with with a unique token (to be changed back later)
mdstr = mdstr.replace(escaped_comma, replacement_token)
tmp_list = mdstr.split(",")
md_list = []
for item in tmp_list:
item = item.replace(replacement_token, ",")
md_list.append(item)
# Now build a nice dict from the list
md_dict = {}
for item in md_list:
# Make sure to fix any escaped equal signs
i = item.replace(escaped_equals, replacement_token)
key, value = i.split("=")
value = value.replace(replacement_token, "=").strip()
key = key.strip()
if key.casefold() == "credit":
cred_attribs = value.split(":")
role = cred_attribs[0]
person = cred_attribs[1] if len(cred_attribs) > 1 else ""
primary = len(cred_attribs) > 2
md.add_credit(person.strip(), role.strip(), primary)
else:
md_dict[key] = value
# Map the dict to the metadata object
for key, value in md_dict.items():
if not hasattr(md, key):
raise argparse.ArgumentTypeError(f"'{key}' is not a valid tag name")
else:
md.is_empty = False
setattr(md, key, value)
return md

View File

@ -1,3 +0,0 @@
# This file should contan only these comments, and the line below.
# Used by packaging makefiles and app
version="1.1.13-beta"

View File

@ -0,0 +1,29 @@
from __future__ import annotations
from typing import NamedTuple
class Replacement(NamedTuple):
find: str
replce: str
strict_only: bool
class Replacements(NamedTuple):
literal_text: list[Replacement]
format_value: list[Replacement]
DEFAULT_REPLACEMENTS = Replacements(
literal_text=[
Replacement(": ", " - ", True),
Replacement(":", "-", True),
],
format_value=[
Replacement(": ", " - ", True),
Replacement(":", "-", True),
Replacement("/", "-", False),
Replacement("//", "--", False),
Replacement("\\", "-", True),
],
)

View File

@ -1,65 +1,60 @@
"""
A PyQT4 dialog to confirm and set options for export to zip
"""
"""A PyQT4 dialog to confirm and set options for export to zip"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
import logging
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
from PyQt5 import QtCore, QtWidgets, uic
http://www.apache.org/licenses/LICENSE-2.0
from comictaggerlib.ui import ui_path
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.
"""
logger = logging.getLogger(__name__)
from PyQt4 import QtCore, QtGui, uic
from settings import ComicTaggerSettings
from settingswindow import SettingsWindow
from filerenamer import FileRenamer
import os
import utils
class ExportConflictOpts:
dontCreate = 1
overwrite = 2
createUnique = 3
class ExportWindow(QtGui.QDialog):
def __init__( self, parent, settings, msg ):
super(ExportWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('exportwindow.ui' ), self)
self.label.setText( msg )
dontCreate = 1
overwrite = 2
createUnique = 3
self.setWindowFlags(self.windowFlags() &
~QtCore.Qt.WindowContextHelpButtonHint )
self.settings = settings
self.cbxDeleteOriginal.setCheckState( QtCore.Qt.Unchecked )
self.cbxAddToList.setCheckState( QtCore.Qt.Checked )
self.radioDontCreate.setChecked( True )
self.deleteOriginal = False
self.addToList = True
self.fileConflictBehavior = ExportConflictOpts.dontCreate
class ExportWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget, msg: str) -> None:
super().__init__(parent)
def accept( self ):
QtGui.QDialog.accept(self)
uic.loadUi(ui_path / "exportwindow.ui", self)
self.label.setText(msg)
self.deleteOriginal = self.cbxDeleteOriginal.isChecked()
self.addToList = self.cbxAddToList.isChecked()
if self.radioDontCreate.isChecked():
self.fileConflictBehavior = ExportConflictOpts.dontCreate
elif self.radioCreateNew.isChecked():
self.fileConflictBehavior = ExportConflictOpts.createUnique
#else:
# self.fileConflictBehavior = ExportConflictOpts.overwrite
self.setWindowFlags(
QtCore.Qt.WindowType(self.windowFlags() & ~QtCore.Qt.WindowType.WindowContextHelpButtonHint)
)
self.cbxDeleteOriginal.setChecked(False)
self.cbxAddToList.setChecked(True)
self.radioDontCreate.setChecked(True)
self.deleteOriginal = False
self.addToList = True
self.fileConflictBehavior = ExportConflictOpts.dontCreate
def accept(self) -> None:
QtWidgets.QDialog.accept(self)
self.deleteOriginal = self.cbxDeleteOriginal.isChecked()
self.addToList = self.cbxAddToList.isChecked()
if self.radioDontCreate.isChecked():
self.fileConflictBehavior = ExportConflictOpts.dontCreate
elif self.radioCreateNew.isChecked():
self.fileConflictBehavior = ExportConflictOpts.createUnique

View File

@ -1,277 +0,0 @@
"""
Functions for parsing comic info from filename
This should probably be re-written, but, well, it mostly works!
"""
"""
Copyright 2012-2014 Anthony Beville
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.
"""
# Some portions of this code were modified from pyComicMetaThis project
# http://code.google.com/p/pycomicmetathis/
import re
import os
from urllib import unquote
class FileNameParser:
def repl(self, m):
return ' ' * len(m.group())
def fixSpaces( self, string, remove_dashes=True ):
if remove_dashes:
placeholders = ['[-_]',' +']
else:
placeholders = ['[_]',' +']
for ph in placeholders:
string = re.sub(ph, self.repl, string )
return string #.strip()
def getIssueCount( self,filename, issue_end ):
count = ""
filename = filename[issue_end:]
# replace any name seperators with spaces
tmpstr = self.fixSpaces(filename)
found = False
match = re.search('(?<=\sof\s)\d+(?=\s)', tmpstr, re.IGNORECASE)
if match:
count = match.group()
found = True
if not found:
match = re.search('(?<=\(of\s)\d+(?=\))', tmpstr, re.IGNORECASE)
if match:
count = match.group()
found = True
count = count.lstrip("0")
return count
def getIssueNumber( self, filename ):
# Returns a tuple of issue number string, and start and end indexs in the filename
# (The indexes will be used to split the string up for further parsing)
found = False
issue = ''
start = 0
end = 0
# first, look for multiple "--", this means it's formatted differently from most:
if "--" in filename:
# the pattern seems to be that anything to left of the first "--" is the series name followed by issue
filename = re.sub("--.*", self.repl, filename)
elif "__" in filename:
# the pattern seems to be that anything to left of the first "__" is the series name followed by issue
filename = re.sub("__.*", self.repl, filename)
filename = filename.replace("+", " ")
# replace parenthetical phrases with spaces
filename = re.sub( "\(.*?\)", self.repl, filename)
filename = re.sub( "\[.*?\]", self.repl, filename)
# replace any name seperators with spaces
filename = self.fixSpaces(filename)
# remove any "of NN" phrase with spaces (problem: this could break on some titles)
filename = re.sub( "of [\d]+", self.repl, filename)
#print u"[{0}]".format(filename)
# we should now have a cleaned up filename version with all the words in
# the same positions as original filename
# make a list of each word and its position
word_list = list()
for m in re.finditer("\S+", filename):
word_list.append( (m.group(0), m.start(), m.end()) )
# remove the first word, since it can't be the issue number
if len(word_list) > 1:
word_list = word_list[1:]
else:
#only one word?? just bail.
return issue, start, end
# Now try to search for the likely issue number word in the list
# first look for a word with "#" followed by digits with optional sufix
# this is almost certainly the issue number
for w in reversed(word_list):
if re.match("#[-]?(([0-9]*\.[0-9]+|[0-9]+)(\w*))", w[0]):
found = True
break
# same as above but w/o a '#', and only look at the last word in the list
if not found:
w = word_list[-1]
if re.match("[-]?(([0-9]*\.[0-9]+|[0-9]+)(\w*))", w[0]):
found = True
# now try to look for a # followed by any characters
if not found:
for w in reversed(word_list):
if re.match("#\S+", w[0]):
found = True
break
if found:
issue = w[0]
start = w[1]
end = w[2]
if issue[0] == '#':
issue = issue[1:]
return issue, start, end
def getSeriesName(self, filename, issue_start ):
# use the issue number string index to split the filename string
if issue_start != 0:
filename = filename[:issue_start]
# in case there is no issue number, remove some obvious stuff
if "--" in filename:
# the pattern seems to be that anything to left of the first "--" is the series name followed by issue
filename = re.sub("--.*", self.repl, filename)
elif "__" in filename:
# the pattern seems to be that anything to left of the first "__" is the series name followed by issue
filename = re.sub("__.*", self.repl, filename)
filename = filename.replace("+", " ")
tmpstr = self.fixSpaces(filename, remove_dashes=False)
series = tmpstr
volume = ""
#save the last word
try:
last_word = series.split()[-1]
except:
last_word = ""
# remove any parenthetical phrases
series = re.sub( "\(.*?\)", "", series)
# search for volume number
match = re.search('(.+)([vV]|[Vv][oO][Ll]\.?\s?)(\d+)\s*$', series)
if match:
series = match.group(1)
volume = match.group(3)
# if a volume wasn't found, see if the last word is a year in parentheses
# since that's a common way to designate the volume
if volume == "":
#match either (YEAR), (YEAR-), or (YEAR-YEAR2)
match = re.search("(\()(\d{4})(-(\d{4}|)|)(\))", last_word)
if match:
volume = match.group(2)
series = series.strip()
# if we don't have an issue number (issue_start==0), look
# for hints i.e. "TPB", "one-shot", "OS", "OGN", etc that might
# be removed to help search online
if issue_start == 0:
one_shot_words = [ "tpb", "os", "one-shot", "ogn", "gn" ]
try:
last_word = series.split()[-1]
if last_word.lower() in one_shot_words:
series = series.rsplit(' ', 1)[0]
except:
pass
return series, volume.strip()
def getYear( self,filename, issue_end):
filename = filename[issue_end:]
year = ""
# look for four digit number with "(" ")" or "--" around it
match = re.search('(\(\d\d\d\d\))|(--\d\d\d\d--)', filename)
if match:
year = match.group()
# remove non-numerics
year = re.sub("[^0-9]", "", year)
return year
def getRemainder( self, filename, year, count, issue_end ):
#make a guess at where the the non-interesting stuff begins
remainder = ""
if "--" in filename:
remainder = filename.split("--",1)[1]
elif "__" in filename:
remainder = filename.split("__",1)[1]
elif issue_end != 0:
remainder = filename[issue_end:]
remainder = self.fixSpaces(remainder, remove_dashes=False)
if year != "":
remainder = remainder.replace(year,"",1)
if count != "":
remainder = remainder.replace("of "+count,"",1)
remainder = remainder.replace("()","")
return remainder.strip()
def parseFilename( self, filename ):
# remove the path
filename = os.path.basename(filename)
# remove the extension
filename = os.path.splitext(filename)[0]
#url decode, just in case
filename = unquote(filename)
# sometimes archives get messed up names from too many decodings
# often url encodings will break and leave "_28" and "_29" in place
# of "(" and ")" see if there are a number of these, and replace them
if filename.count("_28") > 1 and filename.count("_29") > 1:
filename = filename.replace("_28", "(")
filename = filename.replace("_29", ")")
self.issue, issue_start, issue_end = self.getIssueNumber(filename)
self.series, self.volume = self.getSeriesName(filename, issue_start)
self.year = self.getYear(filename, issue_end)
self.issue_count = self.getIssueCount(filename, issue_end)
self.remainder = self.getRemainder( filename, self.year, self.issue_count, issue_end )
if self.issue != "":
# strip off leading zeros
self.issue = self.issue.lstrip("0")
if self.issue == "":
self.issue = "0"
if self.issue[0] == ".":
self.issue = "0" + self.issue

View File

@ -1,150 +1,244 @@
"""
Functions for renaming files based on metadata
"""
"""
Copyright 2012-2014 Anthony Beville
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.
"""
"""Functions for renaming files based on metadata"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
import calendar
import logging
import os
import re
import datetime
import utils
from issuestring import IssueString
import pathlib
import string
from typing import Any, cast
from pathvalidate import Platform, normalize_platform, sanitize_filename
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata
from comicapi.issuestring import IssueString
from comictaggerlib.defaults import DEFAULT_REPLACEMENTS, Replacement, Replacements
logger = logging.getLogger(__name__)
def get_rename_dir(ca: ComicArchive, rename_dir: str | pathlib.Path | None) -> pathlib.Path:
folder = ca.path.parent.absolute()
if rename_dir is not None:
if isinstance(rename_dir, str):
rename_dir = rename_dir.strip()
folder = pathlib.Path(rename_dir).absolute()
return folder
class MetadataFormatter(string.Formatter):
def __init__(
self, smart_cleanup: bool = False, platform: str = "auto", replacements: Replacements = DEFAULT_REPLACEMENTS
) -> None:
super().__init__()
self.smart_cleanup = smart_cleanup
self.platform = normalize_platform(platform)
self.replacements = replacements
def format_field(self, value: Any, format_spec: str) -> str:
if value is None or value == "":
return ""
return cast(str, super().format_field(value, format_spec))
def convert_field(self, value: Any, conversion: str) -> str:
if conversion == "u":
return str(value).upper()
if conversion == "l":
return str(value).casefold()
if conversion == "c":
return str(value).capitalize()
if conversion == "S":
return str(value).swapcase()
if conversion == "t":
return str(value).title()
return cast(str, super().convert_field(value, conversion))
def handle_replacements(self, string: str, replacements: list[Replacement]) -> str:
for find, replace, strict_only in replacements:
if self.is_strict() or not strict_only:
string = string.replace(find, replace)
return string
def none_replacement(self, value: Any, replacement: str, r: str) -> Any:
if r == "-" and value is None or value == "":
return replacement
if r == "+" and value is not None:
return replacement
return value
def split_replacement(self, field_name: str) -> tuple[str, str, str]:
if "-" in field_name:
return field_name.rpartition("-")
if "+" in field_name:
return field_name.rpartition("+")
return field_name, "", ""
def is_strict(self) -> bool:
return self.platform in [Platform.UNIVERSAL, Platform.WINDOWS]
def _vformat(
self,
format_string: str,
args: list[Any],
kwargs: dict[str, Any],
used_args: set[Any],
recursion_depth: int,
auto_arg_index: int = 0,
) -> tuple[str, int]:
if recursion_depth < 0:
raise ValueError("Max string recursion exceeded")
result = []
lstrip = False
for literal_text, field_name, format_spec, conversion in self.parse(format_string):
# output the literal text
if literal_text:
if lstrip:
literal_text = literal_text.lstrip("-_)}]#")
if self.smart_cleanup:
literal_text = self.handle_replacements(literal_text, self.replacements.literal_text)
lspace = literal_text[0].isspace() if literal_text else False
rspace = literal_text[-1].isspace() if literal_text else False
literal_text = " ".join(literal_text.split())
if literal_text == "":
literal_text = " "
else:
if lspace:
literal_text = " " + literal_text
if rspace:
literal_text += " "
result.append(literal_text)
lstrip = False
# if there's a field, output it
if field_name is not None and field_name != "":
field_name, r, replacement = self.split_replacement(field_name)
field_name = field_name.casefold()
# this is some markup, find the object and do the formatting
# handle arg indexing when digit field_names are given.
if field_name.isdigit():
raise ValueError("cannot use a number as a field name")
# given the field_name, find the object it references
# and the argument it came from
obj, arg_used = self.get_field(field_name, args, kwargs)
used_args.add(arg_used)
obj = self.none_replacement(obj, replacement, r)
# do any conversion on the resulting object
obj = self.convert_field(obj, conversion) # type: ignore
# expand the format spec, if needed
format_spec, _ = self._vformat(
cast(str, format_spec), args, kwargs, used_args, recursion_depth - 1, auto_arg_index=False
)
# format the object and append to the result
fmt_obj = self.format_field(obj, format_spec)
if fmt_obj == "" and result and self.smart_cleanup and literal_text:
if self.str_contains(result[-1], "({["):
lstrip = True
if result:
if " " in result[-1]:
result[-1], _, _ = result[-1].rstrip().rpartition(" ")
result[-1] = result[-1].rstrip("-_({[#")
if self.smart_cleanup:
# colons and slashes get special treatment
fmt_obj = self.handle_replacements(fmt_obj, self.replacements.format_value)
fmt_obj = " ".join(fmt_obj.split())
fmt_obj = str(sanitize_filename(fmt_obj, platform=self.platform))
result.append(fmt_obj)
return "".join(result), False
def str_contains(self, chars: str, string: str) -> bool:
for char in chars:
if char in string:
return True
return False
class FileRenamer:
def __init__( self, metadata ):
self.setMetadata( metadata )
self.setTemplate( "%series% v%volume% #%issue% (of %issuecount%) (%year%)" )
self.smart_cleanup = True
self.issue_zero_padding = 3
def __init__(
self,
metadata: GenericMetadata | None,
platform: str = "auto",
replacements: Replacements = DEFAULT_REPLACEMENTS,
) -> None:
self.template = "{publisher}/{series}/{series} v{volume} #{issue} (of {issue_count}) ({year})"
self.smart_cleanup = True
self.issue_zero_padding = 3
self.metadata = metadata or GenericMetadata()
self.move = False
self.platform = platform
self.replacements = replacements
def setMetadata( self, metadata ):
self.metdata = metadata
def set_metadata(self, metadata: GenericMetadata) -> None:
self.metadata = metadata
def setIssueZeroPadding( self, count ):
self.issue_zero_padding = count
def set_issue_zero_padding(self, count: int) -> None:
self.issue_zero_padding = count
def setSmartCleanup( self, on ):
self.smart_cleanup = on
def set_smart_cleanup(self, on: bool) -> None:
self.smart_cleanup = on
def setTemplate( self, template ):
self.template = template
def replaceToken( self, text, value, token ):
#helper func
def isToken( word ):
return (word[0] == "%" and word[-1:] == "%")
def set_template(self, template: str) -> None:
self.template = template
if value is not None:
return text.replace( token, unicode(value) )
else:
if self.smart_cleanup:
# smart cleanup means we want to remove anything appended to token if it's empty
# (e.g "#%issue%" or "v%volume%" )
# (TODO: This could fail if there is more than one token appended together, I guess)
text_list = text.split()
#special case for issuecount, remove preceding non-token word, as in "...(of %issuecount%)..."
if token == '%issuecount%':
for idx,word in enumerate( text_list ):
if token in word and not isToken(text_list[idx -1]) :
text_list[idx -1] = ""
text_list = [ x for x in text_list if token not in x ]
return " ".join( text_list )
else:
return text.replace( token, "" )
def determineName( self, filename, ext=None ):
def determine_name(self, ext: str) -> str:
class Default(dict[str, Any]):
def __missing__(self, key: str) -> str:
return "{" + key + "}"
md = self.metdata
new_name = self.template
preferred_encoding = utils.get_actual_preferred_encoding()
md = self.metadata
#print u"{0}".format(md)
new_name = self.replaceToken( new_name, md.series, '%series%')
new_name = self.replaceToken( new_name, md.volume, '%volume%')
if md.issue is not None:
issue_str = u"{0}".format( IssueString(md.issue).asString(pad=self.issue_zero_padding) )
else:
issue_str = None
new_name = self.replaceToken( new_name, issue_str, '%issue%')
new_name = self.replaceToken( new_name, md.issueCount, '%issuecount%')
new_name = self.replaceToken( new_name, md.year, '%year%')
new_name = self.replaceToken( new_name, md.publisher, '%publisher%')
new_name = self.replaceToken( new_name, md.title, '%title%')
new_name = self.replaceToken( new_name, md.month, '%month%')
month_name = None
if md.month is not None:
if (type(md.month) == str and md.month.isdigit()) or type(md.month) == int:
if int(md.month) in range(1,13):
dt = datetime.datetime( 1970, int(md.month), 1, 0, 0)
month_name = dt.strftime(u"%B".encode(preferred_encoding)).decode(preferred_encoding)
new_name = self.replaceToken( new_name, month_name, '%month_name%')
# padding for issue
md.issue = IssueString(md.issue).as_string(pad=self.issue_zero_padding)
new_name = self.replaceToken( new_name, md.genre, '%genre%')
new_name = self.replaceToken( new_name, md.language, '%language_code%')
new_name = self.replaceToken( new_name, md.criticalRating , '%criticalrating%')
new_name = self.replaceToken( new_name, md.alternateSeries, '%alternateseries%')
new_name = self.replaceToken( new_name, md.alternateNumber, '%alternatenumber%')
new_name = self.replaceToken( new_name, md.alternateCount, '%alternatecount%')
new_name = self.replaceToken( new_name, md.imprint, '%imprint%')
new_name = self.replaceToken( new_name, md.format, '%format%')
new_name = self.replaceToken( new_name, md.maturityRating, '%maturityrating%')
new_name = self.replaceToken( new_name, md.storyArc, '%storyarc%')
new_name = self.replaceToken( new_name, md.seriesGroup, '%seriesgroup%')
new_name = self.replaceToken( new_name, md.scanInfo, '%scaninfo%')
if self.smart_cleanup:
# remove empty braces,brackets, parentheses
new_name = re.sub("\(\s*[-:]*\s*\)", "", new_name )
new_name = re.sub("\[\s*[-:]*\s*\]", "", new_name )
new_name = re.sub("\{\s*[-:]*\s*\}", "", new_name )
template = self.template
# remove duplicate spaces
new_name = u" ".join(new_name.split())
new_name = ""
# remove remove duplicate -, _,
new_name = re.sub("[-_]{2,}\s+", "-- ", new_name )
new_name = re.sub("(\s--)+", " --", new_name )
new_name = re.sub("(\s-)+", " -", new_name )
# remove dash or double dash at end of line
new_name = re.sub("[-]{1,2}\s*$", "", new_name )
# remove duplicate spaces (again!)
new_name = u" ".join(new_name.split())
fmt = MetadataFormatter(self.smart_cleanup, platform=self.platform, replacements=self.replacements)
md_dict = vars(md)
for role in ["writer", "penciller", "inker", "colorist", "letterer", "cover artist", "editor"]:
md_dict[role] = md.get_primary_credit(role)
if ext is None:
ext = os.path.splitext( filename )[1]
if (isinstance(md.month, int) or isinstance(md.month, str) and md.month.isdigit()) and 0 < int(md.month) < 13:
md_dict["month_name"] = calendar.month_name[int(md.month)]
md_dict["month_abbr"] = calendar.month_abbr[int(md.month)]
else:
md_dict["month_name"] = ""
md_dict["month_abbr"] = ""
new_name += ext
# some tweaks to keep various filesystems happy
new_name = new_name.replace("/", "-")
new_name = new_name.replace(" :", " -")
new_name = new_name.replace(": ", " - ")
new_name = new_name.replace(":", "-")
new_name = new_name.replace("?", "")
return new_name
new_basename = ""
for component in pathlib.PureWindowsPath(template).parts:
new_basename = str(
sanitize_filename(fmt.vformat(component, args=[], kwargs=Default(md_dict)), platform=self.platform)
).strip()
new_name = os.path.join(new_name, new_basename)
new_name += ext
new_basename += ext
# remove padding
md.issue = IssueString(md.issue).as_string()
if self.move:
return new_name.strip()
return new_basename.strip()

View File

@ -1,406 +1,419 @@
# coding=utf-8
"""
A PyQt4 widget for managing list of comic archive files
"""
"""
Copyright 2012-2014 Anthony Beville
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.
"""
"""A PyQt5 widget for managing list of comic archive files"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
import logging
import os
import sys
import platform
from typing import Callable, cast
from PyQt4.QtCore import *
from PyQt4.QtGui import *
from PyQt4 import uic
from PyQt4.QtCore import pyqtSignal
import settngs
from PyQt5 import QtCore, QtWidgets, uic
from settings import ComicTaggerSettings
from comicarchive import ComicArchive
from comicarchive import MetaDataStyle
from genericmetadata import GenericMetadata, PageType
import utils
from comicapi import utils
from comicapi.comicarchive import ComicArchive
from comictaggerlib.graphics import graphics_path
from comictaggerlib.optionalmsgdialog import OptionalMessageDialog
from comictaggerlib.settingswindow import linuxRarHelp, macRarHelp, windowsRarHelp
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import center_window_on_parent, reduce_widget_font_size
class FileTableWidget( QTableWidget ):
def __init__(self, parent ):
super(FileTableWidget, self).__init__(parent)
self.setColumnCount(5)
self.setHorizontalHeaderLabels (["File", "Folder", "CR", "CBL", ""])
self.horizontalHeader().setStretchLastSection( True )
logger = logging.getLogger(__name__)
class FileTableWidgetItem(QTableWidgetItem):
def __lt__(self, other):
return (self.data(Qt.UserRole).toBool() <
other.data(Qt.UserRole).toBool())
class FileTableWidgetItem(QtWidgets.QTableWidgetItem):
def __lt__(self, other: object) -> bool:
return self.data(QtCore.Qt.ItemDataRole.UserRole) < other.data(QtCore.Qt.ItemDataRole.UserRole) # type: ignore
class FileInfo( ):
def __init__(self, ca ):
self.ca = ca
class FileSelectionList(QWidget):
selectionChanged = pyqtSignal(QVariant)
listCleared = pyqtSignal()
fileColNum = 0
CRFlagColNum = 1
CBLFlagColNum = 2
typeColNum = 3
readonlyColNum = 4
folderColNum = 5
dataColNum = fileColNum
def __init__(self, parent , settings ):
super(FileSelectionList, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('fileselectionlist.ui' ), self)
self.settings = settings
utils.reduceWidgetFontSize( self.twList )
self.twList.currentItemChanged.connect( self.currentItemChangedCB )
self.currentItem = None
self.setContextMenuPolicy(Qt.ActionsContextMenu)
self.modifiedFlag = False
selectAllAction = QAction("Select All", self)
removeAction = QAction("Remove Selected Items", self)
self.separator = QAction("",self)
self.separator.setSeparator(True)
selectAllAction.setShortcut( 'Ctrl+A' )
removeAction.setShortcut( 'Ctrl+X' )
selectAllAction.triggered.connect(self.selectAll)
removeAction.triggered.connect(self.removeSelection)
self.addAction(selectAllAction)
self.addAction(removeAction)
self.addAction(self.separator)
def getSorting(self):
col = self.twList.horizontalHeader().sortIndicatorSection()
order = self.twList.horizontalHeader().sortIndicatorOrder()
return col, order
def setSorting(self, col, order):
col = self.twList.horizontalHeader().setSortIndicator( col, order)
def addAppAction( self, action ):
self.insertAction( None , action )
def setModifiedFlag( self, modified ):
self.modifiedFlag = modified
def selectAll( self ):
self.twList.setRangeSelected( QTableWidgetSelectionRange ( 0, 0, self.twList.rowCount()-1, 5 ), True )
def deselectAll( self ):
self.twList.setRangeSelected( QTableWidgetSelectionRange ( 0, 0, self.twList.rowCount()-1, 5 ), False )
def removeArchiveList( self, ca_list ):
self.twList.setSortingEnabled(False)
for ca in ca_list:
for row in range(self.twList.rowCount()):
row_ca = self.getArchiveByRow( row )
if row_ca == ca:
self.twList.removeRow(row)
break
self.twList.setSortingEnabled(True)
def getArchiveByRow( self, row):
fi = self.twList.item(row, FileSelectionList.dataColNum).data( Qt.UserRole ).toPyObject()
return fi.ca
def getCurrentArchive( self ):
return self.getArchiveByRow( self.twList.currentRow() )
def removeSelection( self ):
row_list = []
for item in self.twList.selectedItems():
if item.column() == 0:
row_list.append(item.row())
if len(row_list) == 0:
return
if self.twList.currentRow() in row_list:
if not self.modifiedFlagVerification( "Remove Archive",
"If you close this archive, data in the form will be lost. Are you sure?"):
return
row_list.sort()
row_list.reverse()
self.twList.currentItemChanged.disconnect( self.currentItemChangedCB )
self.twList.setSortingEnabled(False)
for i in row_list:
self.twList.removeRow(i)
self.twList.setSortingEnabled(True)
self.twList.currentItemChanged.connect( self.currentItemChangedCB )
if self.twList.rowCount() > 0:
# since on a removal, we select row 0, make sure callback occurs if we're already there
if self.twList.currentRow() == 0:
self.currentItemChangedCB( self.twList.currentItem(), None)
self.twList.selectRow(0)
else:
self.listCleared.emit()
def addPathList( self, pathlist ):
filelist = utils.get_recursive_filelist( pathlist )
# we now have a list of files to add
progdialog = QProgressDialog("", "Cancel", 0, len(filelist), self)
progdialog.setWindowTitle( "Adding Files" )
#progdialog.setWindowModality(Qt.WindowModal)
progdialog.setWindowModality(Qt.ApplicationModal)
progdialog.show()
firstAdded = None
self.twList.setSortingEnabled(False)
for idx,f in enumerate(filelist):
QCoreApplication.processEvents()
if progdialog.wasCanceled():
break
progdialog.setValue(idx)
progdialog.setLabelText(f)
utils.centerWindowOnParent( progdialog )
QCoreApplication.processEvents()
row = self.addPathItem( f )
if firstAdded is None and row is not None:
firstAdded = row
progdialog.close()
if firstAdded is not None:
self.twList.selectRow(firstAdded)
else:
if len(pathlist) == 1 and os.path.isfile(pathlist[0]):
QMessageBox.information(self, self.tr("File Open"), self.tr("Selected file doesn't seem to be a comic archive."))
else:
QMessageBox.information(self, self.tr("File/Folder Open"), self.tr("No comic archives were found."))
self.twList.setSortingEnabled(True)
# Adjust column size
self.twList.resizeColumnsToContents()
self.twList.setColumnWidth(FileSelectionList.CRFlagColNum, 35)
self.twList.setColumnWidth(FileSelectionList.CBLFlagColNum, 35)
self.twList.setColumnWidth(FileSelectionList.readonlyColNum, 35)
self.twList.setColumnWidth(FileSelectionList.typeColNum, 45)
if self.twList.columnWidth(FileSelectionList.fileColNum) > 250:
self.twList.setColumnWidth(FileSelectionList.fileColNum, 250)
if self.twList.columnWidth(FileSelectionList.folderColNum ) > 200:
self.twList.setColumnWidth(FileSelectionList.folderColNum, 200)
def isListDupe( self, path ):
r = 0
while r < self.twList.rowCount():
ca = self.getArchiveByRow( r )
if ca.path == path:
return True
r = r + 1
return False
def addPathItem( self, path):
path = unicode( path )
path = os.path.abspath( path )
#print "processing", path
if self.isListDupe(path):
return None
ca = ComicArchive( path, self.settings.rar_exe_path )
if ca.seemsToBeAComicArchive() :
row = self.twList.rowCount()
self.twList.insertRow( row )
fi = FileInfo( ca )
filename_item = QTableWidgetItem()
folder_item = QTableWidgetItem()
cix_item = FileTableWidgetItem()
cbi_item = FileTableWidgetItem()
readonly_item = FileTableWidgetItem()
type_item = QTableWidgetItem()
filename_item.setFlags(Qt.ItemIsSelectable| Qt.ItemIsEnabled)
filename_item.setData( Qt.UserRole , fi )
self.twList.setItem(row, FileSelectionList.fileColNum, filename_item)
folder_item.setFlags(Qt.ItemIsSelectable| Qt.ItemIsEnabled)
self.twList.setItem(row, FileSelectionList.folderColNum, folder_item)
type_item.setFlags(Qt.ItemIsSelectable| Qt.ItemIsEnabled)
self.twList.setItem(row, FileSelectionList.typeColNum, type_item)
cix_item.setFlags(Qt.ItemIsSelectable| Qt.ItemIsEnabled)
cix_item.setTextAlignment(Qt.AlignHCenter)
self.twList.setItem(row, FileSelectionList.CRFlagColNum, cix_item)
cbi_item.setFlags(Qt.ItemIsSelectable| Qt.ItemIsEnabled)
cbi_item.setTextAlignment(Qt.AlignHCenter)
self.twList.setItem(row, FileSelectionList.CBLFlagColNum, cbi_item)
readonly_item.setFlags(Qt.ItemIsSelectable| Qt.ItemIsEnabled)
readonly_item.setTextAlignment(Qt.AlignHCenter)
self.twList.setItem(row, FileSelectionList.readonlyColNum, readonly_item)
self.updateRow( row )
return row
def updateRow( self, row ):
fi = self.twList.item( row, FileSelectionList.dataColNum ).data( Qt.UserRole ).toPyObject()
filename_item = self.twList.item( row, FileSelectionList.fileColNum )
folder_item = self.twList.item( row, FileSelectionList.folderColNum )
cix_item = self.twList.item( row, FileSelectionList.CRFlagColNum )
cbi_item = self.twList.item( row, FileSelectionList.CBLFlagColNum )
type_item = self.twList.item( row, FileSelectionList.typeColNum )
readonly_item = self.twList.item( row, FileSelectionList.readonlyColNum )
item_text = os.path.split(fi.ca.path)[0]
folder_item.setText( item_text )
folder_item.setData( Qt.ToolTipRole, item_text )
item_text = os.path.split(fi.ca.path)[1]
filename_item.setText( item_text )
filename_item.setData( Qt.ToolTipRole, item_text )
if fi.ca.isZip():
item_text = "ZIP"
elif fi.ca.isRar():
item_text = "RAR"
else:
item_text = ""
type_item.setText( item_text )
type_item.setData( Qt.ToolTipRole, item_text )
class FileInfo:
def __init__(self, ca: ComicArchive) -> None:
self.ca: ComicArchive = ca
if fi.ca.hasCIX():
cix_item.setCheckState(Qt.Checked)
cix_item.setData(Qt.UserRole, True)
else:
cix_item.setData(Qt.UserRole, False)
cix_item.setCheckState(Qt.Unchecked)
class FileSelectionList(QtWidgets.QWidget):
selectionChanged = QtCore.pyqtSignal(QtCore.QVariant)
listCleared = QtCore.pyqtSignal()
if fi.ca.hasCBI():
cbi_item.setCheckState(Qt.Checked)
cbi_item.setData(Qt.UserRole, True)
else:
cbi_item.setData(Qt.UserRole, False)
cbi_item.setCheckState(Qt.Unchecked)
fileColNum = 0
CRFlagColNum = 1
CBLFlagColNum = 2
typeColNum = 3
readonlyColNum = 4
folderColNum = 5
dataColNum = fileColNum
if not fi.ca.isWritable():
readonly_item.setCheckState(Qt.Checked)
readonly_item.setData(Qt.UserRole, True)
else:
readonly_item.setData(Qt.UserRole, False)
readonly_item.setCheckState(Qt.Unchecked)
def __init__(
self, parent: QtWidgets.QWidget, config: settngs.Namespace, dirty_flag_verification: Callable[[str, str], bool]
) -> None:
super().__init__(parent)
# Reading these will force them into the ComicArchive's cache
fi.ca.readCIX()
fi.ca.hasCBI()
uic.loadUi(ui_path / "fileselectionlist.ui", self)
def getSelectedArchiveList( self ):
ca_list = []
for r in range( self.twList.rowCount() ):
item = self.twList.item(r, FileSelectionList.dataColNum)
if self.twList.isItemSelected(item):
fi = item.data( Qt.UserRole ).toPyObject()
ca_list.append(fi.ca)
self.config = config
return ca_list
def updateCurrentRow( self ):
self.updateRow( self.twList.currentRow() )
reduce_widget_font_size(self.twList)
def updateSelectedRows( self ):
self.twList.setSortingEnabled(False)
for r in range( self.twList.rowCount() ):
item = self.twList.item(r, FileSelectionList.dataColNum)
if self.twList.isItemSelected(item):
self.updateRow( r )
self.twList.setSortingEnabled(True)
def currentItemChangedCB( self, curr, prev ):
self.twList.setColumnCount(6)
self.twList.currentItemChanged.connect(self.current_item_changed_cb)
new_idx = curr.row()
old_idx = -1
if prev is not None:
old_idx = prev.row()
#print "old {0} new {1}".format(old_idx, new_idx)
if old_idx == new_idx:
return
# don't allow change if modified
if prev is not None and new_idx != old_idx:
if not self.modifiedFlagVerification( "Change Archive",
"If you change archives now, data in the form will be lost. Are you sure?"):
self.twList.currentItemChanged.disconnect( self.currentItemChangedCB )
self.twList.setCurrentItem( prev )
self.twList.currentItemChanged.connect( self.currentItemChangedCB )
# Need to defer this revert selection, for some reason
QTimer.singleShot(1, self.revertSelection)
return
self.currentItem = None
self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.ActionsContextMenu)
self.dirty_flag = False
fi = self.twList.item( new_idx, FileSelectionList.dataColNum ).data( Qt.UserRole ).toPyObject()
self.selectionChanged.emit( QVariant(fi))
def revertSelection( self ):
self.twList.selectRow( self.twList.currentRow() )
def modifiedFlagVerification( self, title, desc):
if self.modifiedFlag:
reply = QMessageBox.question(self,
self.tr(title),
self.tr(desc),
QMessageBox.Yes, QMessageBox.No )
if reply != QMessageBox.Yes:
return False
return True
# Attempt to use a special checkbox widget in the cell.
# Couldn't figure out how to disable it with "enabled" colors
#w = QWidget()
#cb = QCheckBox(w)
#cb.setCheckState(Qt.Checked)
#layout = QHBoxLayout()
#layout.addWidget( cb )
#layout.setAlignment(Qt.AlignHCenter)
#layout.setMargin(2)
#w.setLayout(layout)
#self.twList.setCellWidget( row, 2, w )
select_all_action = QtWidgets.QAction("Select All", self)
remove_action = QtWidgets.QAction("Remove Selected Items", self)
self.separator = QtWidgets.QAction("", self)
self.separator.setSeparator(True)
select_all_action.setShortcut("Ctrl+A")
remove_action.setShortcut("Ctrl+X")
select_all_action.triggered.connect(self.select_all)
remove_action.triggered.connect(self.remove_selection)
self.addAction(select_all_action)
self.addAction(remove_action)
self.addAction(self.separator)
self.dirty_flag_verification = dirty_flag_verification
self.rar_ro_shown = False
def get_sorting(self) -> tuple[int, int]:
col = self.twList.horizontalHeader().sortIndicatorSection()
order = self.twList.horizontalHeader().sortIndicatorOrder()
return int(col), int(order)
def set_sorting(self, col: int, order: QtCore.Qt.SortOrder) -> None:
self.twList.horizontalHeader().setSortIndicator(col, order)
def add_app_action(self, action: QtWidgets.QAction) -> None:
self.insertAction(QtWidgets.QAction(), action)
def set_modified_flag(self, modified: bool) -> None:
self.dirty_flag = modified
def select_all(self) -> None:
self.twList.setRangeSelected(QtWidgets.QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, 5), True)
def deselect_all(self) -> None:
self.twList.setRangeSelected(QtWidgets.QTableWidgetSelectionRange(0, 0, self.twList.rowCount() - 1, 5), False)
def remove_archive_list(self, ca_list: list[ComicArchive]) -> None:
self.twList.setSortingEnabled(False)
current_removed = False
for ca in ca_list:
for row in range(self.twList.rowCount()):
row_ca = self.get_archive_by_row(row)
if row_ca == ca:
if row == self.twList.currentRow():
current_removed = True
self.twList.removeRow(row)
break
self.twList.setSortingEnabled(True)
if self.twList.rowCount() > 0 and current_removed:
# since on a removal, we select row 0, make sure callback occurs if
# we're already there
if self.twList.currentRow() == 0:
self.current_item_changed_cb(self.twList.currentItem(), None)
self.twList.selectRow(0)
elif self.twList.rowCount() <= 0:
self.listCleared.emit()
def get_archive_by_row(self, row: int) -> ComicArchive | None:
if row >= 0:
fi: FileInfo = self.twList.item(row, FileSelectionList.dataColNum).data(QtCore.Qt.ItemDataRole.UserRole)
return fi.ca
return None
def get_current_archive(self) -> ComicArchive | None:
return self.get_archive_by_row(self.twList.currentRow())
def remove_selection(self) -> None:
row_list = []
for item in self.twList.selectedItems():
if item.column() == 0:
row_list.append(item.row())
if len(row_list) == 0:
return
if self.twList.currentRow() in row_list:
if not self.dirty_flag_verification(
"Remove Archive", "If you close this archive, data in the form will be lost. Are you sure?"
):
return
row_list.sort()
row_list.reverse()
self.twList.currentItemChanged.disconnect(self.current_item_changed_cb)
self.twList.setSortingEnabled(False)
for i in row_list:
self.twList.removeRow(i)
self.twList.setSortingEnabled(True)
self.twList.currentItemChanged.connect(self.current_item_changed_cb)
if self.twList.rowCount() > 0:
# since on a removal, we select row 0, make sure callback occurs if
# we're already there
if self.twList.currentRow() == 0:
self.current_item_changed_cb(self.twList.currentItem(), None)
self.twList.selectRow(0)
else:
self.listCleared.emit()
def add_path_list(self, pathlist: list[str]) -> None:
filelist = utils.get_recursive_filelist(pathlist)
# we now have a list of files to add
# Prog dialog on Linux flakes out for small range, so scale up
progdialog = QtWidgets.QProgressDialog("", "Cancel", 0, len(filelist), parent=self)
progdialog.setWindowTitle("Adding Files")
progdialog.setWindowModality(QtCore.Qt.WindowModality.ApplicationModal)
progdialog.setMinimumDuration(300)
center_window_on_parent(progdialog)
QtCore.QCoreApplication.processEvents()
first_added = None
rar_added_ro = False
self.twList.setSortingEnabled(False)
for idx, f in enumerate(filelist):
QtCore.QCoreApplication.processEvents()
if progdialog.wasCanceled():
break
progdialog.setValue(idx + 1)
progdialog.setLabelText(f)
center_window_on_parent(progdialog)
QtCore.QCoreApplication.processEvents()
row = self.add_path_item(f)
if row is not None:
ca = self.get_archive_by_row(row)
rar_added_ro = bool(ca and ca.archiver.name() == "RAR" and not ca.archiver.is_writable())
if first_added is None:
first_added = row
progdialog.hide()
QtCore.QCoreApplication.processEvents()
if first_added is not None:
self.twList.selectRow(first_added)
else:
if len(pathlist) == 1 and os.path.isfile(pathlist[0]):
QtWidgets.QMessageBox.information(
self, "File Open", "Selected file doesn't seem to be a comic archive."
)
else:
QtWidgets.QMessageBox.information(self, "File/Folder Open", "No readable comic archives were found.")
if rar_added_ro:
self.rar_ro_message()
self.twList.setSortingEnabled(True)
# Adjust column size
self.twList.resizeColumnsToContents()
self.twList.setColumnWidth(FileSelectionList.CRFlagColNum, 35)
self.twList.setColumnWidth(FileSelectionList.CBLFlagColNum, 35)
self.twList.setColumnWidth(FileSelectionList.readonlyColNum, 35)
self.twList.setColumnWidth(FileSelectionList.typeColNum, 45)
if self.twList.columnWidth(FileSelectionList.fileColNum) > 250:
self.twList.setColumnWidth(FileSelectionList.fileColNum, 250)
if self.twList.columnWidth(FileSelectionList.folderColNum) > 200:
self.twList.setColumnWidth(FileSelectionList.folderColNum, 200)
def rar_ro_message(self) -> None:
if not self.rar_ro_shown:
if platform.system() == "Windows":
rar_help = windowsRarHelp
elif platform.system() == "Darwin":
rar_help = macRarHelp
else:
rar_help = linuxRarHelp
OptionalMessageDialog.msg_no_checkbox(
self,
"RAR Files are Read-Only",
"It looks like you have opened a RAR/CBR archive,\n"
"however ComicTagger cannot currently write to them without the rar program and are marked read only!\n\n"
f"{rar_help}",
)
self.rar_ro_shown = True
def is_list_dupe(self, path: str) -> bool:
return self.get_current_list_row(path) >= 0
def get_current_list_row(self, path: str) -> int:
for r in range(self.twList.rowCount()):
ca = cast(ComicArchive, self.get_archive_by_row(r))
if str(ca.path) == path:
return r
return -1
def add_path_item(self, path: str) -> int:
path = str(path)
path = os.path.abspath(path)
if self.is_list_dupe(path):
return self.get_current_list_row(path)
ca = ComicArchive(path, str(graphics_path / "nocover.png"))
if ca.seems_to_be_a_comic_archive():
row: int = self.twList.rowCount()
self.twList.insertRow(row)
fi = FileInfo(ca)
filename_item = QtWidgets.QTableWidgetItem()
folder_item = QtWidgets.QTableWidgetItem()
cix_item = FileTableWidgetItem()
cbi_item = FileTableWidgetItem()
readonly_item = FileTableWidgetItem()
type_item = QtWidgets.QTableWidgetItem()
filename_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
filename_item.setData(QtCore.Qt.ItemDataRole.UserRole, fi)
self.twList.setItem(row, FileSelectionList.fileColNum, filename_item)
folder_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, FileSelectionList.folderColNum, folder_item)
type_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, FileSelectionList.typeColNum, type_item)
cix_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
cix_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
self.twList.setItem(row, FileSelectionList.CRFlagColNum, cix_item)
cbi_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
cbi_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
self.twList.setItem(row, FileSelectionList.CBLFlagColNum, cbi_item)
readonly_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
readonly_item.setTextAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter)
self.twList.setItem(row, FileSelectionList.readonlyColNum, readonly_item)
self.update_row(row)
return row
return -1
def update_row(self, row: int) -> None:
if row >= 0:
fi: FileInfo = self.twList.item(row, FileSelectionList.dataColNum).data(QtCore.Qt.ItemDataRole.UserRole)
filename_item = self.twList.item(row, FileSelectionList.fileColNum)
folder_item = self.twList.item(row, FileSelectionList.folderColNum)
cix_item = self.twList.item(row, FileSelectionList.CRFlagColNum)
cbi_item = self.twList.item(row, FileSelectionList.CBLFlagColNum)
type_item = self.twList.item(row, FileSelectionList.typeColNum)
readonly_item = self.twList.item(row, FileSelectionList.readonlyColNum)
item_text = os.path.split(fi.ca.path)[0]
folder_item.setText(item_text)
folder_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item_text = os.path.split(fi.ca.path)[1]
filename_item.setText(item_text)
filename_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item_text = fi.ca.archiver.name()
type_item.setText(item_text)
type_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
if fi.ca.has_cix():
cix_item.setCheckState(QtCore.Qt.CheckState.Checked)
cix_item.setData(QtCore.Qt.ItemDataRole.UserRole, True)
else:
cix_item.setData(QtCore.Qt.ItemDataRole.UserRole, False)
cix_item.setCheckState(QtCore.Qt.CheckState.Unchecked)
if fi.ca.has_cbi():
cbi_item.setCheckState(QtCore.Qt.CheckState.Checked)
cbi_item.setData(QtCore.Qt.ItemDataRole.UserRole, True)
else:
cbi_item.setData(QtCore.Qt.ItemDataRole.UserRole, False)
cbi_item.setCheckState(QtCore.Qt.CheckState.Unchecked)
if not fi.ca.is_writable():
readonly_item.setCheckState(QtCore.Qt.CheckState.Checked)
readonly_item.setData(QtCore.Qt.ItemDataRole.UserRole, True)
else:
readonly_item.setData(QtCore.Qt.ItemDataRole.UserRole, False)
readonly_item.setCheckState(QtCore.Qt.CheckState.Unchecked)
# Reading these will force them into the ComicArchive's cache
try:
fi.ca.read_cix()
except Exception:
pass
fi.ca.has_cbi()
def get_selected_archive_list(self) -> list[ComicArchive]:
ca_list: list[ComicArchive] = []
for r in range(self.twList.rowCount()):
item = self.twList.item(r, FileSelectionList.dataColNum)
if item.isSelected():
fi: FileInfo = item.data(QtCore.Qt.ItemDataRole.UserRole)
ca_list.append(fi.ca)
return ca_list
def update_current_row(self) -> None:
self.update_row(self.twList.currentRow())
def update_selected_rows(self) -> None:
self.twList.setSortingEnabled(False)
for r in range(self.twList.rowCount()):
item = self.twList.item(r, FileSelectionList.dataColNum)
if item.isSelected():
self.update_row(r)
self.twList.setSortingEnabled(True)
def current_item_changed_cb(self, curr: QtCore.QModelIndex | None, prev: QtCore.QModelIndex | None) -> None:
if curr is not None:
new_idx = curr.row()
old_idx = -1
if prev is not None:
old_idx = prev.row()
if old_idx == new_idx:
return
# don't allow change if modified
if prev is not None and new_idx != old_idx:
if not self.dirty_flag_verification(
"Change Archive", "If you change archives now, data in the form will be lost. Are you sure?"
):
self.twList.currentItemChanged.disconnect(self.current_item_changed_cb)
self.twList.setCurrentItem(prev)
self.twList.currentItemChanged.connect(self.current_item_changed_cb)
# Need to defer this revert selection, for some reason
QtCore.QTimer.singleShot(1, self.revert_selection)
return
fi = self.twList.item(new_idx, FileSelectionList.dataColNum).data(QtCore.Qt.ItemDataRole.UserRole)
self.selectionChanged.emit(QtCore.QVariant(fi))
def revert_selection(self) -> None:
self.twList.selectRow(self.twList.currentRow())

View File

@ -1,316 +0,0 @@
"""
A python class for internal metadata storage
The goal of this class is to handle ALL the data that might come from various
tagging schemes and databases, such as ComicVine or GCD. This makes conversion
possible, however lossy it might be
"""
"""
Copyright 2012-2014 Anthony Beville
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import utils
# These page info classes are exactly the same as the CIX scheme, since it's unique
class PageType:
FrontCover = "FrontCover"
InnerCover = "InnerCover"
Roundup = "Roundup"
Story = "Story"
Advertisement = "Advertisement"
Editorial = "Editorial"
Letters = "Letters"
Preview = "Preview"
BackCover = "BackCover"
Other = "Other"
Deleted = "Deleted"
"""
class PageInfo:
Image = 0
Type = PageType.Story
DoublePage = False
ImageSize = 0
Key = ""
ImageWidth = 0
ImageHeight = 0
"""
class GenericMetadata:
def __init__(self):
self.isEmpty = True
self.tagOrigin = None
self.series = None
self.issue = None
self.title = None
self.publisher = None
self.month = None
self.year = None
self.day = None
self.issueCount = None
self.volume = None
self.genre = None
self.language = None # 2 letter iso code
self.comments = None # use same way as Summary in CIX
self.volumeCount = None
self.criticalRating = None
self.country = None
self.alternateSeries = None
self.alternateNumber = None
self.alternateCount = None
self.imprint = None
self.notes = None
self.webLink = None
self.format = None
self.manga = None
self.blackAndWhite = None
self.pageCount = None
self.maturityRating = None
self.storyArc = None
self.seriesGroup = None
self.scanInfo = None
self.characters = None
self.teams = None
self.locations = None
self.credits = list()
self.tags = list()
self.pages = list()
# Some CoMet-only items
self.price = None
self.isVersionOf = None
self.rights = None
self.identifier = None
self.lastMark = None
self.coverImage = None
def overlay( self, new_md ):
# Overlay a metadata object on this one
# that is, when the new object has non-None
# values, over-write them to this one
def assign( cur, new ):
if new is not None:
if type(new) == str and len(new) == 0:
setattr(self, cur, None)
else:
setattr(self, cur, new)
if not new_md.isEmpty:
self.isEmpty = False
assign( 'series', new_md.series )
assign( "issue", new_md.issue )
assign( "issueCount", new_md.issueCount )
assign( "title", new_md.title )
assign( "publisher", new_md.publisher )
assign( "day", new_md.day )
assign( "month", new_md.month )
assign( "year", new_md.year )
assign( "volume", new_md.volume )
assign( "volumeCount", new_md.volumeCount )
assign( "genre", new_md.genre )
assign( "language", new_md.language )
assign( "country", new_md.country )
assign( "criticalRating", new_md.criticalRating )
assign( "alternateSeries", new_md.alternateSeries )
assign( "alternateNumber", new_md.alternateNumber )
assign( "alternateCount", new_md.alternateCount )
assign( "imprint", new_md.imprint )
assign( "webLink", new_md.webLink )
assign( "format", new_md.format )
assign( "manga", new_md.manga )
assign( "blackAndWhite", new_md.blackAndWhite )
assign( "maturityRating", new_md.maturityRating )
assign( "storyArc", new_md.storyArc )
assign( "seriesGroup", new_md.seriesGroup )
assign( "scanInfo", new_md.scanInfo )
assign( "characters", new_md.characters )
assign( "teams", new_md.teams )
assign( "locations", new_md.locations )
assign( "comments", new_md.comments )
assign( "notes", new_md.notes )
assign( "price", new_md.price )
assign( "isVersionOf", new_md.isVersionOf )
assign( "rights", new_md.rights )
assign( "identifier", new_md.identifier )
assign( "lastMark", new_md.lastMark )
self.overlayCredits( new_md.credits )
# TODO
# not sure if the tags and pages should broken down, or treated
# as whole lists....
# For now, go the easy route, where any overlay
# value wipes out the whole list
if len(new_md.tags) > 0:
assign( "tags", new_md.tags )
if len(new_md.pages) > 0:
assign( "pages", new_md.pages )
def overlayCredits( self, new_credits ):
for c in new_credits:
if c.has_key('primary') and c['primary']:
primary = True
else:
primary = False
# Remove credit role if person is blank
if c['person'] == "":
for r in reversed(self.credits):
if r['role'].lower() == c['role'].lower():
self.credits.remove(r)
# otherwise, add it!
else:
self.addCredit( c['person'], c['role'], primary )
def setDefaultPageList( self, count ):
# generate a default page list, with the first page marked as the cover
for i in range(count):
page_dict = dict()
page_dict['Image'] = str(i)
if i == 0:
page_dict['Type'] = PageType.FrontCover
self.pages.append( page_dict )
def getArchivePageIndex( self, pagenum ):
# convert the displayed page number to the page index of the file in the archive
if pagenum < len( self.pages ):
return int( self.pages[pagenum]['Image'] )
else:
return 0
def getCoverPageIndexList( self ):
# return a list of archive page indices of cover pages
coverlist = []
for p in self.pages:
if 'Type' in p and p['Type'] == PageType.FrontCover:
coverlist.append( int(p['Image']))
if len(coverlist) == 0:
coverlist.append( 0 )
return coverlist
def addCredit( self, person, role, primary = False ):
credit = dict()
credit['person'] = person
credit['role'] = role
if primary:
credit['primary'] = primary
# look to see if it's not already there...
found = False
for c in self.credits:
if ( c['person'].lower() == person.lower() and
c['role'].lower() == role.lower() ):
# no need to add it. just adjust the "primary" flag as needed
c['primary'] = primary
found = True
break
if not found:
self.credits.append(credit)
def __str__( self ):
vals = []
if self.isEmpty:
return "No metadata"
def add_string( tag, val ):
if val is not None and u"{0}".format(val) != "":
vals.append( (tag, val) )
def add_attr_string( tag ):
val = getattr(self,tag)
add_string( tag, getattr(self,tag) )
add_attr_string( "series" )
add_attr_string( "issue" )
add_attr_string( "issueCount" )
add_attr_string( "title" )
add_attr_string( "publisher" )
add_attr_string( "year" )
add_attr_string( "month" )
add_attr_string( "day" )
add_attr_string( "volume" )
add_attr_string( "volumeCount" )
add_attr_string( "genre" )
add_attr_string( "language" )
add_attr_string( "country" )
add_attr_string( "criticalRating" )
add_attr_string( "alternateSeries" )
add_attr_string( "alternateNumber" )
add_attr_string( "alternateCount" )
add_attr_string( "imprint" )
add_attr_string( "webLink" )
add_attr_string( "format" )
add_attr_string( "manga" )
add_attr_string( "price" )
add_attr_string( "isVersionOf" )
add_attr_string( "rights" )
add_attr_string( "identifier" )
add_attr_string( "lastMark" )
if self.blackAndWhite:
add_attr_string( "blackAndWhite" )
add_attr_string( "maturityRating" )
add_attr_string( "storyArc" )
add_attr_string( "seriesGroup" )
add_attr_string( "scanInfo" )
add_attr_string( "characters" )
add_attr_string( "teams" )
add_attr_string( "locations" )
add_attr_string( "comments" )
add_attr_string( "notes" )
add_string( "tags", utils.listToString( self.tags ) )
for c in self.credits:
primary = ""
if c.has_key('primary') and c['primary']:
primary = " [P]"
add_string( "credit", c['role']+": "+c['person'] + primary)
# find the longest field name
flen = 0
for i in vals:
flen = max( flen, len(i[0]) )
flen += 1
#format the data nicely
outstr = ""
fmt_str = u"{0: <" + str(flen) + "} {1}\n"
for i in vals:
outstr += fmt_str.format( i[0]+":", i[1] )
return outstr

View File

@ -0,0 +1,5 @@
from __future__ import annotations
import pathlib
graphics_path = pathlib.Path(__file__).parent

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

143
comictaggerlib/gui.py Normal file
View File

@ -0,0 +1,143 @@
from __future__ import annotations
import logging.handlers
import os
import platform
import sys
import traceback
import types
import settngs
from comictaggerlib.graphics import graphics_path
from comictalker.comictalker import ComicTalker
logger = logging.getLogger("comictagger")
try:
qt_available = True
from PyQt5 import QtCore, QtGui, QtWidgets
def show_exception_box(log_msg: str) -> None:
"""Checks if a QApplication instance is available and shows a messagebox with the exception message.
If unavailable (non-console application), log an additional notice.
"""
if QtWidgets.QApplication.instance() is not None:
errorbox = QtWidgets.QMessageBox()
errorbox.setText(log_msg)
errorbox.exec()
QtWidgets.QApplication.exit(1)
else:
logger.debug("No QApplication instance available.")
class UncaughtHook(QtCore.QObject):
_exception_caught = QtCore.pyqtSignal(object)
def __init__(self) -> None:
super().__init__()
# this registers the exception_hook() function as hook with the Python interpreter
sys.excepthook = self.exception_hook
# connect signal to execute the message box function always on main thread
self._exception_caught.connect(show_exception_box)
def exception_hook(
self, exc_type: type[BaseException], exc_value: BaseException, exc_traceback: types.TracebackType | None
) -> None:
"""Function handling uncaught exceptions.
It is triggered each time an uncaught exception occurs.
"""
if issubclass(exc_type, KeyboardInterrupt):
# ignore keyboard interrupt to support console applications
sys.__excepthook__(exc_type, exc_value, exc_traceback)
else:
exc_info = (exc_type, exc_value, exc_traceback)
trace_back = "".join(traceback.format_tb(exc_traceback))
log_msg = f"{exc_type.__name__}: {exc_value}\n\n{trace_back}"
logger.critical("Uncaught exception: %s: %s", exc_type.__name__, exc_value, exc_info=exc_info)
# trigger message box show
self._exception_caught.emit(f"Oops. An unexpected error occurred:\n{log_msg}")
qt_exception_hook = UncaughtHook()
from comictaggerlib.taggerwindow import TaggerWindow
class Application(QtWidgets.QApplication):
openFileRequest = QtCore.pyqtSignal(QtCore.QUrl, name="openfileRequest")
# Handles "Open With" from Finder on macOS
def event(self, event: QtCore.QEvent) -> bool:
if event.type() == QtCore.QEvent.FileOpen:
logger.info(event.url().toLocalFile())
self.openFileRequest.emit(event.url())
return True
return super().event(event)
except ImportError as e:
def show_exception_box(log_msg: str) -> None:
...
logger.error(str(e))
qt_available = False
def open_tagger_window(
talkers: dict[str, ComicTalker], config: settngs.Config[settngs.Namespace], error: tuple[str, bool] | None
) -> None:
os.environ["QtWidgets.QT_AUTO_SCREEN_SCALE_FACTOR"] = "1"
args = []
if config[0].runtime_darkmode:
args.extend(["-platform", "windows:darkmode=2"])
args.extend(sys.argv)
app = Application(args)
if error is not None:
show_exception_box(error[0])
if error[1]:
raise SystemExit(1)
# needed to catch initial open file events (macOS)
app.openFileRequest.connect(lambda x: config[0].runtime_files.append(x.toLocalFile()))
if platform.system() == "Darwin":
# Set the MacOS dock icon
app.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png")))
if platform.system() == "Windows":
# For pure python, tell windows that we're not python,
# so we can have our own taskbar icon
import ctypes
myappid = "comictagger" # arbitrary string
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) # type: ignore[attr-defined]
# force close of console window
swp_hidewindow = 0x0080
console_wnd = ctypes.windll.kernel32.GetConsoleWindow() # type: ignore[attr-defined]
if console_wnd != 0:
ctypes.windll.user32.SetWindowPos(console_wnd, None, 0, 0, 0, 0, swp_hidewindow) # type: ignore[attr-defined]
if platform.system() != "Linux":
img = QtGui.QPixmap(str(graphics_path / "tags.png"))
splash = QtWidgets.QSplashScreen(img)
splash.show()
splash.raise_()
QtWidgets.QApplication.processEvents()
try:
tagger_window = TaggerWindow(config[0].runtime_files, config, talkers)
tagger_window.setWindowIcon(QtGui.QIcon(str(graphics_path / "app.png")))
tagger_window.show()
# Catch open file events (macOS)
app.openFileRequest.connect(tagger_window.open_file_event)
if platform.system() != "Linux":
splash.finish(tagger_window)
sys.exit(app.exec())
except Exception:
logger.exception("GUI mode failed")
QtWidgets.QMessageBox.critical(
QtWidgets.QMainWindow(), "Error", "Unhandled exception in app:\n" + traceback.format_exc()
)

View File

@ -1,198 +1,169 @@
"""
A python class to manage fetching and caching of images by URL
"""
"""A class to manage fetching and caching of images by URL"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import sqlite3 as lite
import os
import datetime
import logging
import os
import pathlib
import shutil
import sqlite3 as lite
import tempfile
import urllib2, urllib
try:
from PyQt4.QtNetwork import QNetworkAccessManager, QNetworkRequest
from PyQt4.QtCore import QUrl, pyqtSignal, QObject, QByteArray
from PyQt4 import QtGui
import requests
from comictaggerlib import ctversion
try:
from PyQt5 import QtCore, QtNetwork
qt_available = True
except ImportError:
# No Qt, so define a few dummy QObjects to help us compile
class QObject():
def __init__(self,*args):
pass
class QByteArray():
pass
class pyqtSignal():
def __init__(self,*args):
pass
def emit(a,b,c):
pass
qt_available = False
logger = logging.getLogger(__name__)
from settings import ComicTaggerSettings
class ImageFetcherException(Exception):
pass
class ImageFetcher(QObject):
fetchComplete = pyqtSignal( QByteArray , int)
...
def __init__(self ):
QObject.__init__(self)
self.settings_folder = ComicTaggerSettings.getSettingsFolder()
self.db_file = os.path.join( self.settings_folder, "image_url_cache.db" )
self.cache_folder = os.path.join( self.settings_folder, "image_cache" )
if not os.path.exists( self.db_file ):
self.create_image_db()
def clearCache( self ):
os.unlink( self.db_file )
if os.path.isdir( self.cache_folder ):
shutil.rmtree( self.cache_folder )
def fetch_complete(image_data: bytes | QtCore.QByteArray) -> None:
...
def fetch( self, url, user_data=None, blocking=False ):
"""
If called with blocking=True, this will block until the image is
fetched.
If called with blocking=False, this will run the fetch in the
background, and emit a signal when done
"""
class ImageFetcher:
image_fetch_complete = fetch_complete
self.user_data = user_data
self.fetched_url = url
# first look in the DB
image_data = self.get_image_from_cache( url )
if blocking:
if image_data is None:
try:
image_data = urllib.urlopen(url).read()
except Exception as e:
print e
raise ImageFetcherException("Network Error!")
def __init__(self, cache_folder: pathlib.Path) -> None:
self.db_file = cache_folder / "image_url_cache.db"
self.cache_folder = cache_folder / "image_cache"
# save the image to the cache
self.add_image_to_cache( self.fetched_url, image_data )
return image_data
else:
# if we found it, just emit the signal asap
if image_data is not None:
self.fetchComplete.emit( QByteArray(image_data), self.user_data )
return
# didn't find it. look online
self.nam = QNetworkAccessManager()
self.nam.finished.connect(self.finishRequest)
self.nam.get(QNetworkRequest(QUrl(url)))
#we'll get called back when done...
self.user_data = None
self.fetched_url = ""
def finishRequest(self, reply):
# read in the image data
image_data = reply.readAll()
# save the image to the cache
self.add_image_to_cache( self.fetched_url, image_data )
if not os.path.exists(self.db_file):
self.create_image_db()
self.fetchComplete.emit( QByteArray(image_data), self.user_data )
if qt_available:
self.nam = QtNetwork.QNetworkAccessManager()
def create_image_db( self ):
# this will wipe out any existing version
open( self.db_file, 'w').close()
def clear_cache(self) -> None:
os.unlink(self.db_file)
if os.path.isdir(self.cache_folder):
shutil.rmtree(self.cache_folder)
# wipe any existing image cache folder too
if os.path.isdir( self.cache_folder ):
shutil.rmtree( self.cache_folder )
os.makedirs( self.cache_folder )
def fetch(self, url: str, blocking: bool = False) -> bytes:
"""
If called with blocking=True, this will block until the image is
fetched.
If called with blocking=False, this will run the fetch in the
background, and emit a signal when done
"""
con = lite.connect( self.db_file )
# create tables
with con:
cur = con.cursor()
self.fetched_url = url
cur.execute("CREATE TABLE Images(" +
"url TEXT," +
"filename TEXT," +
"timestamp TEXT," +
"PRIMARY KEY (url) )"
)
# first look in the DB
image_data = self.get_image_from_cache(url)
# Async for retrieving covers seems to work well
if blocking: # if blocking or not qt_available:
if not image_data:
try:
image_data = requests.get(url, headers={"user-agent": "comictagger/" + ctversion.version}).content
except Exception as e:
logger.exception("Fetching url failed: %s")
raise ImageFetcherException("Network Error!") from e
# save the image to the cache
self.add_image_to_cache(self.fetched_url, image_data)
return image_data
def add_image_to_cache( self, url, image_data ):
if qt_available:
# if we found it, just emit the signal asap
if image_data:
ImageFetcher.image_fetch_complete(QtCore.QByteArray(image_data))
return b""
con = lite.connect( self.db_file )
# didn't find it. look online
self.nam.finished.connect(self.finish_request)
self.nam.get(QtNetwork.QNetworkRequest(QtCore.QUrl(url)))
with con:
cur = con.cursor()
timestamp = datetime.datetime.now()
tmp_fd, filename = tempfile.mkstemp(dir=self.cache_folder, prefix="img")
f = os.fdopen(tmp_fd, 'w+b')
f.write( image_data )
f.close()
cur.execute("INSERT or REPLACE INTO Images VALUES( ?, ?, ? )" ,
(url,
filename,
timestamp )
)
# we'll get called back when done...
return b""
def get_image_from_cache( self, url ):
con = lite.connect( self.db_file )
with con:
cur = con.cursor()
cur.execute("SELECT filename FROM Images WHERE url=?", [ url ])
row = cur.fetchone()
def finish_request(self, reply: QtNetwork.QNetworkReply) -> None:
# read in the image data
logger.debug("request finished")
image_data = reply.readAll()
if row is None :
return None
else:
filename = row[0]
image_data = None
# save the image to the cache
self.add_image_to_cache(self.fetched_url, image_data)
try:
with open( filename, 'rb' ) as f:
image_data = f.read()
f.close()
except IOError as e:
pass
return image_data
ImageFetcher.image_fetch_complete(image_data)
def create_image_db(self) -> None:
# this will wipe out any existing version
open(self.db_file, "wb").close()
# wipe any existing image cache folder too
if os.path.isdir(self.cache_folder):
shutil.rmtree(self.cache_folder)
os.makedirs(self.cache_folder)
con = lite.connect(self.db_file)
# create tables
with con:
cur = con.cursor()
cur.execute("CREATE TABLE Images(url TEXT,filename TEXT,timestamp TEXT,PRIMARY KEY (url))")
def add_image_to_cache(self, url: str, image_data: bytes | QtCore.QByteArray) -> None:
con = lite.connect(self.db_file)
with con:
cur = con.cursor()
timestamp = datetime.datetime.now()
tmp_fd, filename = tempfile.mkstemp(dir=self.cache_folder, prefix="img")
with os.fdopen(tmp_fd, "w+b") as f:
f.write(bytes(image_data))
cur.execute("INSERT or REPLACE INTO Images VALUES(?, ?, ?)", (url, filename, timestamp))
def get_image_from_cache(self, url: str) -> bytes:
con = lite.connect(self.db_file)
with con:
cur = con.cursor()
cur.execute("SELECT filename FROM Images WHERE url=?", [url])
row = cur.fetchone()
if row is None:
return b""
filename = row[0]
image_data = b""
try:
with open(filename, "rb") as f:
image_data = f.read()
f.close()
except OSError:
pass
return image_data

334
comictaggerlib/imagehasher.py Executable file → Normal file
View File

@ -1,198 +1,188 @@
"""
A pthyon class to manage creating image content hashes, and calculate hamming distances
"""
"""A class to manage creating image content hashes, and calculate hamming distances"""
#
# Copyright 2013 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2013 Anthony Beville
import io
import logging
from functools import reduce
from typing import TypeVar
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
try:
from PIL import Image
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import StringIO
import sys
try:
import Image
pil_available = True
pil_available = True
except ImportError:
pil_available = False
pil_available = False
logger = logging.getLogger(__name__)
class ImageHasher:
def __init__(self, path: str | None = None, data: bytes = b"", width: int = 8, height: int = 8) -> None:
self.width = width
self.height = height
class ImageHasher(object):
def __init__(self, path=None, data=None, width=8, height=8):
#self.hash_size = size
self.width = width
self.height = height
if path is None and not data:
raise OSError
if path is None and data is None:
raise IOError
else:
try:
if path is not None:
self.image = Image.open(path)
else:
self.image = Image.open(StringIO.StringIO(data))
except:
print "Image data seems corrupted!"
# just generate a bogus image
self.image = Image.new( "L", (1,1))
try:
if path is not None:
self.image = Image.open(path)
else:
self.image = Image.open(io.BytesIO(data))
except Exception:
logger.exception("Image data seems corrupted!")
# just generate a bogus image
self.image = Image.new("L", (1, 1))
def average_hash(self):
try:
image = self.image.resize((self.width, self.height), Image.ANTIALIAS).convert("L")
except Exception as e:
sys.exc_clear()
print "average_hash error:", e
return long(0)
pixels = list(image.getdata())
avg = sum(pixels) / len(pixels)
def compare_value_to_avg(i):
return ( 1 if i > avg else 0 )
bitlist = map(compare_value_to_avg, pixels)
# build up an int value from the bit list, one bit at a time
def set_bit( x, (idx, val) ):
return (x | (val << idx))
result = reduce(set_bit, enumerate(bitlist), 0)
#print "{0:016x}".format(result)
return result
def average_hash(self) -> int:
try:
image = self.image.resize((self.width, self.height), Image.Resampling.LANCZOS).convert("L")
except Exception:
logger.exception("average_hash error")
return 0
def average_hash2( self ):
pass
"""
# Got this one from somewhere on the net. Not a clue how the 'convolve2d'
# works!
pixels = list(image.getdata())
avg = sum(pixels) / len(pixels)
from numpy import array
from scipy.signal import convolve2d
im = self.image.resize((self.width, self.height), Image.ANTIALIAS).convert('L')
def compare_value_to_avg(i: int) -> int:
return 1 if i > avg else 0
in_data = array((im.getdata())).reshape(self.width, self.height)
filt = array([[0,1,0],[1,-4,1],[0,1,0]])
filt_data = convolve2d(in_data,filt,mode='same',boundary='symm').flatten()
result = reduce(lambda x, (y, z): x | (z << y),
enumerate(map(lambda i: 0 if i < 0 else 1, filt_data)),
0)
#print "{0:016x}".format(result)
return result
"""
def dct_average_hash(self):
pass
"""
# Algorithm source: http://syntaxcandy.blogspot.com/2012/08/perceptual-hash.html
1. Reduce size. Like Average Hash, pHash starts with a small image.
However, the image is larger than 8x8; 32x32 is a good size. This
is really done to simplify the DCT computation and not because it
is needed to reduce the high frequencies.
bitlist = list(map(compare_value_to_avg, pixels))
2. Reduce color. The image is reduced to a grayscale just to further
simplify the number of computations.
3. Compute the DCT. The DCT separates the image into a collection of
frequencies and scalars. While JPEG uses an 8x8 DCT, this algorithm
uses a 32x32 DCT.
4. Reduce the DCT. This is the magic step. While the DCT is 32x32,
just keep the top-left 8x8. Those represent the lowest frequencies in
the picture.
5. Compute the average value. Like the Average Hash, compute the mean DCT
value (using only the 8x8 DCT low-frequency values and excluding the first
term since the DC coefficient can be significantly different from the other
values and will throw off the average). Thanks to David Starkweather for the
added information about pHash. He wrote: "the dct hash is based on the low 2D
DCT coefficients starting at the second from lowest, leaving out the first DC
term. This excludes completely flat image information (i.e. solid colors) from
being included in the hash description."
6. Further reduce the DCT. This is the magic step. Set the 64 hash bits to 0 or
1 depending on whether each of the 64 DCT values is above or below the average
value. The result doesn't tell us the actual low frequencies; it just tells us
the very-rough relative scale of the frequencies to the mean. The result will not
vary as long as the overall structure of the image remains the same; this can
survive gamma and color histogram adjustments without a problem.
7. Construct the hash. Set the 64 bits into a 64-bit integer. The order does not
matter, just as long as you are consistent.
"""
"""
import numpy
import scipy.fftpack
numpy.set_printoptions(threshold=10000, linewidth=200, precision=2, suppress=True)
# build up an int value from the bit list, one bit at a time
def set_bit(x: int, idx_val: tuple[int, int]) -> int:
(idx, val) = idx_val
return x | (val << idx)
# Step 1,2
im = self.image.resize((32, 32), Image.ANTIALIAS).convert("L")
in_data = numpy.asarray(im)
# Step 3
dct = scipy.fftpack.dct( in_data.astype(float) )
# Step 4
# Just skip the top and left rows when slicing, as suggested somewhere else...
lofreq_dct = dct[1:9, 1:9].flatten()
# Step 5
avg = ( lofreq_dct.sum() ) / ( lofreq_dct.size )
median = numpy.median( lofreq_dct )
result = reduce(set_bit, enumerate(bitlist), 0)
thresh = avg
return result
# Step 6
def compare_value_to_thresh(i):
return ( 1 if i > thresh else 0 )
bitlist = map(compare_value_to_thresh, lofreq_dct)
#Step 7
def set_bit( x, (idx, val) ):
return (x | (val << idx))
result = reduce(set_bit, enumerate(bitlist), long(0))
def average_hash2(self) -> None:
"""
# Got this one from somewhere on the net. Not a clue how the 'convolve2d' works!
from numpy import array
from scipy.signal import convolve2d
im = self.image.resize((self.width, self.height), Image.ANTIALIAS).convert('L')
in_data = array((im.getdata())).reshape(self.width, self.height)
filt = array([[0,1,0],[1,-4,1],[0,1,0]])
filt_data = convolve2d(in_data,filt,mode='same',boundary='symm').flatten()
result = reduce(lambda x, (y, z): x | (z << y),
enumerate(map(lambda i: 0 if i < 0 else 1, filt_data)),
0)
return result
"""
def dct_average_hash(self) -> None:
"""
# Algorithm source: http://syntaxcandy.blogspot.com/2012/08/perceptual-hash.html
1. Reduce size. Like Average Hash, pHash starts with a small image.
However, the image is larger than 8x8; 32x32 is a good size. This
is really done to simplify the DCT computation and not because it
is needed to reduce the high frequencies.
2. Reduce color. The image is reduced to a grayscale just to further
simplify the number of computations.
3. Compute the DCT. The DCT separates the image into a collection of
frequencies and scalars. While JPEG uses an 8x8 DCT, this algorithm
uses a 32x32 DCT.
4. Reduce the DCT. This is the magic step. While the DCT is 32x32,
just keep the top-left 8x8. Those represent the lowest frequencies in
the picture.
5. Compute the average value. Like the Average Hash, compute the mean DCT
value (using only the 8x8 DCT low-frequency values and excluding the first
term since the DC coefficient can be significantly different from the other
values and will throw off the average). Thanks to David Starkweather for the
added information about pHash. He wrote: "the dct hash is based on the low 2D
DCT coefficients starting at the second from lowest, leaving out the first DC
term. This excludes completely flat image information (i.e. solid colors) from
being included in the hash description."
6. Further reduce the DCT. This is the magic step. Set the 64 hash bits to 0 or
1 depending on whether each of the 64 DCT values is above or below the average
value. The result doesn't tell us the actual low frequencies; it just tells us
the very-rough relative scale of the frequencies to the mean. The result will not
vary as long as the overall structure of the image remains the same; this can
survive gamma and color histogram adjustments without a problem.
7. Construct the hash. Set the 64 bits into a 64-bit integer. The order does not
matter, just as long as you are consistent.
#print "{0:016x}".format(result)
return result
"""
import numpy
import scipy.fftpack
numpy.set_printoptions(threshold=10000, linewidth=200, precision=2, suppress=True)
#accepts 2 hashes (longs or hex strings) and returns the hamming distance
@staticmethod
def hamming_distance(h1, h2):
if type(h1) == long or type(h1) == int:
n1 = h1
n2 = h2
else:
# convert hex strings to ints
n1 = long( h1, 16)
n2 = long( h2, 16)
# xor the two numbers
n = n1 ^ n2
# Step 1,2
im = self.image.resize((32, 32), Image.ANTIALIAS).convert("L")
in_data = numpy.asarray(im)
#count up the 1's in the binary string
return sum( b == '1' for b in bin(n)[2:] )
# Step 3
dct = scipy.fftpack.dct(in_data.astype(float))
# Step 4
# Just skip the top and left rows when slicing, as suggested somewhere else...
lofreq_dct = dct[1:9, 1:9].flatten()
# Step 5
avg = (lofreq_dct.sum()) / (lofreq_dct.size)
median = numpy.median(lofreq_dct)
thresh = avg
# Step 6
def compare_value_to_thresh(i):
return (1 if i > thresh else 0)
bitlist = map(compare_value_to_thresh, lofreq_dct)
#Step 7
def set_bit(x, (idx, val)):
return (x | (val << idx))
result = reduce(set_bit, enumerate(bitlist), long(0))
return result
"""
# accepts 2 hashes (longs or hex strings) and returns the hamming distance
T = TypeVar("T", int, str)
@staticmethod
def hamming_distance(h1: T, h2: T) -> int:
if isinstance(h1, int) or isinstance(h2, int):
n1 = h1
n2 = h2
else:
# convert hex strings to ints
n1 = int(h1, 16)
n2 = int(h2, 16)
# xor the two numbers
n = n1 ^ n2
# count up the 1's in the binary string
return sum(b == "1" for b in bin(n)[2:])

View File

@ -1,86 +1,93 @@
"""
A PyQT4 widget to display a popup image
"""
"""A PyQT4 widget to display a popup image"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
import logging
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
from PyQt5 import QtCore, QtGui, QtWidgets, sip, uic
http://www.apache.org/licenses/LICENSE-2.0
from comictaggerlib.graphics import graphics_path
from comictaggerlib.ui import ui_path
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import sys
from PyQt4 import QtCore, QtGui, uic
import os
from settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
class ImagePopup(QtGui.QDialog):
def __init__(self, parent, image_pixmap):
super(ImagePopup, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('imagepopup.ui' ), self)
class ImagePopup(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget, image_pixmap: QtGui.QPixmap) -> None:
super().__init__(parent)
QtGui.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor))
uic.loadUi(ui_path / "imagepopup.ui", self)
#self.setWindowModality(QtCore.Qt.WindowModal)
self.setWindowFlags(QtCore.Qt.Popup)
self.setWindowState(QtCore.Qt.WindowFullScreen)
self.imagePixmap = image_pixmap
screen_size = QtGui.QDesktopWidget().screenGeometry()
self.resize(screen_size.width(), screen_size.height())
self.move( 0, 0)
# This is a total hack. Uses a snapshot of the desktop, and overlays a
# translucent screen over it. Probably can do it better by setting opacity of a
# widget
self.desktopBg = QtGui.QPixmap.grabWindow(QtGui.QApplication.desktop ().winId(),
0,0, screen_size.width(), screen_size.height())
bg = QtGui.QPixmap(ComicTaggerSettings.getGraphic('popup_bg.png'))
self.clientBgPixmap = bg.scaled(screen_size.width(), screen_size.height())
self.setMask(self.clientBgPixmap.mask())
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
self.applyImagePixmap()
self.showFullScreen()
self.raise_( )
QtGui.QApplication.restoreOverrideCursor()
self.setWindowFlags(QtCore.Qt.WindowType.Popup)
self.setWindowState(QtCore.Qt.WindowState.WindowFullScreen)
def paintEvent (self, event):
self.painter = QtGui.QPainter(self)
self.painter.setRenderHint(QtGui.QPainter.Antialiasing)
self.painter.drawPixmap(0, 0, self.desktopBg)
self.painter.drawPixmap(0, 0, self.clientBgPixmap)
self.painter.end()
self.imagePixmap = image_pixmap
def applyImagePixmap( self ):
win_h = self.height()
win_w = self.width()
if self.imagePixmap.width() > win_w or self.imagePixmap.height() > win_h:
# scale the pixmap to fit in the frame
display_pixmap = self.imagePixmap.scaled(win_w, win_h, QtCore.Qt.KeepAspectRatio)
self.lblImage.setPixmap( display_pixmap )
else:
display_pixmap = self.imagePixmap
self.lblImage.setPixmap( display_pixmap )
# move and resize the label to be centered in the fame
img_w = display_pixmap.width()
img_h = display_pixmap.height()
self.lblImage.resize( img_w, img_h )
self.lblImage.move( (win_w - img_w)/2, (win_h - img_h)/2 )
screen_size = QtGui.QGuiApplication.primaryScreen().geometry()
QtWidgets.QApplication.primaryScreen()
self.resize(screen_size.width(), screen_size.height())
self.move(0, 0)
def mousePressEvent( self , event):
self.close()
# This is a total hack. Uses a snapshot of the desktop, and overlays a
# translucent screen over it. Probably can do it better by setting opacity of a widget
# TODO: macOS denies this
screen = QtWidgets.QApplication.primaryScreen()
self.desktopBg = screen.grabWindow(sip.voidptr(0), 0, 0, screen_size.width(), screen_size.height())
bg = QtGui.QPixmap(str(graphics_path / "popup_bg.png"))
self.clientBgPixmap = bg.scaled(
screen_size.width(),
screen_size.height(),
QtCore.Qt.AspectRatioMode.IgnoreAspectRatio,
QtCore.Qt.SmoothTransformation,
)
self.setMask(self.clientBgPixmap.mask())
self.apply_image_pixmap()
self.showFullScreen()
self.raise_()
QtWidgets.QApplication.restoreOverrideCursor()
def paintEvent(self, event: QtGui.QPaintEvent) -> None:
painter = QtGui.QPainter(self)
painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing)
painter.drawPixmap(0, 0, self.desktopBg)
painter.drawPixmap(0, 0, self.clientBgPixmap)
painter.end()
def apply_image_pixmap(self) -> None:
win_h = self.height()
win_w = self.width()
if self.imagePixmap.width() > win_w or self.imagePixmap.height() > win_h:
# scale the pixmap to fit in the frame
display_pixmap = self.imagePixmap.scaled(
win_w, win_h, QtCore.Qt.AspectRatioMode.KeepAspectRatio, QtCore.Qt.SmoothTransformation
)
self.lblImage.setPixmap(display_pixmap)
else:
display_pixmap = self.imagePixmap
self.lblImage.setPixmap(display_pixmap)
# move and resize the label to be centered in the fame
img_w = display_pixmap.width()
img_h = display_pixmap.height()
self.lblImage.resize(img_w, img_h)
self.lblImage.move(int((win_w - img_w) / 2), int((win_h - img_h) / 2))
def mousePressEvent(self, event: QtGui.QMouseEvent) -> None:
self.close()

File diff suppressed because it is too large Load Diff

View File

@ -1,174 +1,199 @@
"""
A PyQT4 dialog to select specific issue from list
"""
"""A PyQT4 dialog to select specific issue from list"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
import logging
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
import settngs
from PyQt5 import QtCore, QtGui, QtWidgets, uic
http://www.apache.org/licenses/LICENSE-2.0
from comicapi.issuestring import IssueString
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import reduce_widget_font_size
from comictalker.comictalker import ComicTalker, TalkerError
from comictalker.resulttypes import ComicIssue
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.
"""
logger = logging.getLogger(__name__)
import sys
import os
import re
from PyQt4 import QtCore, QtGui, uic
from PyQt4.QtCore import QUrl, pyqtSignal, QByteArray
from PyQt4.QtNetwork import QNetworkAccessManager, QNetworkRequest
class IssueNumberTableWidgetItem(QtWidgets.QTableWidgetItem):
def __lt__(self, other: object) -> bool:
assert isinstance(other, QtWidgets.QTableWidgetItem)
self_str: str = self.data(QtCore.Qt.ItemDataRole.DisplayRole)
other_str: str = other.data(QtCore.Qt.ItemDataRole.DisplayRole)
return (IssueString(self_str).as_float() or 0) < (IssueString(other_str).as_float() or 0)
from comicvinetalker import ComicVineTalker, ComicVineTalkerException
from imagefetcher import ImageFetcher
from settings import ComicTaggerSettings
from issuestring import IssueString
from coverimagewidget import CoverImageWidget
import utils
class IssueNumberTableWidgetItem(QtGui.QTableWidgetItem):
def __lt__(self, other):
selfStr = self.data(QtCore.Qt.DisplayRole).toString()
otherStr = other.data(QtCore.Qt.DisplayRole).toString()
return (IssueString(selfStr).asFloat() <
IssueString(otherStr).asFloat())
class IssueSelectionWindow(QtWidgets.QDialog):
def __init__(
self,
parent: QtWidgets.QWidget,
config: settngs.Namespace,
talker: ComicTalker,
series_id: str,
issue_number: str,
) -> None:
super().__init__(parent)
class IssueSelectionWindow(QtGui.QDialog):
volume_id = 0
def __init__(self, parent, settings, series_id, issue_number):
super(IssueSelectionWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('issueselectionwindow.ui' ), self)
uic.loadUi(ui_path / "issueselectionwindow.ui", self)
self.coverWidget = CoverImageWidget( self.coverImageContainer, CoverImageWidget.AltCoverMode )
gridlayout = QtGui.QGridLayout( self.coverImageContainer )
gridlayout.addWidget( self.coverWidget )
gridlayout.setContentsMargins(0,0,0,0)
self.coverWidget = CoverImageWidget(
self.coverImageContainer, CoverImageWidget.AltCoverMode, config.runtime_config.user_cache_dir, talker
)
gridlayout = QtWidgets.QGridLayout(self.coverImageContainer)
gridlayout.addWidget(self.coverWidget)
gridlayout.setContentsMargins(0, 0, 0, 0)
utils.reduceWidgetFontSize( self.twList )
utils.reduceWidgetFontSize( self.teDescription, 1 )
reduce_widget_font_size(self.twList)
reduce_widget_font_size(self.teDescription, 1)
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
self.series_id = series_id
self.settings = settings
self.url_fetch_thread = None
if issue_number is None or issue_number == "":
self.issue_number = 1
else:
self.issue_number = issue_number
self.series_id = series_id
self.issue_id: str = ""
self.config = config
self.talker = talker
self.url_fetch_thread = None
self.issue_list: list[ComicIssue] = []
self.initial_id = None
self.performQuery()
self.twList.resizeColumnsToContents()
self.twList.currentItemChanged.connect(self.currentItemChanged)
self.twList.cellDoubleClicked.connect(self.cellDoubleClicked)
#now that the list has been sorted, find the initial record, and select it
if self.initial_id is None:
self.twList.selectRow( 0 )
else:
for r in range(0, self.twList.rowCount()):
issue_id, b = self.twList.item( r, 0 ).data( QtCore.Qt.UserRole ).toInt()
if (issue_id == self.initial_id):
self.twList.selectRow( r )
break
def performQuery( self ):
# Display talker logo and set url
self.lblIssuesSourceName.setText(talker.attribution)
QtGui.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor))
self.imageIssuesSourceWidget = CoverImageWidget(
self.imageIssuesSourceLogo,
CoverImageWidget.URLMode,
config.runtime_config.user_cache_dir,
talker,
False,
)
self.imageIssuesSourceWidget.showControls = False
gridlayoutIssuesSourceLogo = QtWidgets.QGridLayout(self.imageIssuesSourceLogo)
gridlayoutIssuesSourceLogo.addWidget(self.imageIssuesSourceWidget)
gridlayoutIssuesSourceLogo.setContentsMargins(0, 2, 0, 0)
self.imageIssuesSourceWidget.set_url(talker.logo_url)
try:
comicVine = ComicVineTalker( )
volume_data = comicVine.fetchVolumeData( self.series_id )
self.issue_list = comicVine.fetchIssuesByVolume( self.series_id )
except ComicVineTalkerException:
QtGui.QApplication.restoreOverrideCursor()
QtGui.QMessageBox.critical(self, self.tr("Network Issue"), self.tr("Could not connect to ComicVine to list issues!"))
return
if issue_number is None or issue_number == "":
self.issue_number = "1"
else:
self.issue_number = issue_number
while self.twList.rowCount() > 0:
self.twList.removeRow(0)
self.initial_id: str = ""
self.perform_query()
self.twList.setSortingEnabled(False)
self.twList.resizeColumnsToContents()
self.twList.currentItemChanged.connect(self.current_item_changed)
self.twList.cellDoubleClicked.connect(self.cell_double_clicked)
row = 0
for record in self.issue_list:
self.twList.insertRow(row)
item_text = record['issue_number']
item = IssueNumberTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setData( QtCore.Qt.UserRole ,record['id'])
item.setData(QtCore.Qt.DisplayRole, item_text)
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 0, item)
item_text = record['cover_date']
if item_text is None:
item_text = ""
#remove the day of "YYYY-MM-DD"
parts = item_text.split("-")
if len(parts) > 1:
item_text = parts[0] + "-" + parts[1]
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 1, item)
# now that the list has been sorted, find the initial record, and
# select it
if self.initial_id is None:
self.twList.selectRow(0)
else:
for r in range(0, self.twList.rowCount()):
issue_id = self.twList.item(r, 0).data(QtCore.Qt.ItemDataRole.UserRole)
if issue_id == self.initial_id:
self.twList.selectRow(r)
break
item_text = record['name']
if item_text is None:
item_text = ""
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 2, item)
if IssueString(record['issue_number']).asString().lower() == IssueString(self.issue_number).asString().lower():
self.initial_id = record['id']
row += 1
self.twList.setSortingEnabled(True)
self.twList.sortItems( 0 , QtCore.Qt.AscendingOrder )
def perform_query(self) -> None:
QtWidgets.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.CursorShape.WaitCursor))
QtGui.QApplication.restoreOverrideCursor()
try:
self.issue_list = self.talker.fetch_issues_by_series(self.series_id)
except TalkerError as e:
QtWidgets.QApplication.restoreOverrideCursor()
QtWidgets.QMessageBox.critical(self, f"{e.source} {e.code_name} Error", f"{e}")
return
def cellDoubleClicked( self, r, c ):
self.accept()
def currentItemChanged( self, curr, prev ):
self.twList.setRowCount(0)
if curr is None:
return
if prev is not None and prev.row() == curr.row():
return
self.issue_id, b = self.twList.item( curr.row(), 0 ).data( QtCore.Qt.UserRole ).toInt()
self.twList.setSortingEnabled(False)
# list selection was changed, update the the issue cover
for record in self.issue_list:
if record['id'] == self.issue_id:
self.issue_number = record['issue_number']
self.coverWidget.setIssueID( int(self.issue_id) )
if record['description'] is None:
self.teDescription.setText ( "" )
else:
self.teDescription.setText ( record['description'] )
break
row = 0
for record in self.issue_list:
self.twList.insertRow(row)
item_text = record.issue_number
item = IssueNumberTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setData(QtCore.Qt.ItemDataRole.UserRole, record.id)
item.setData(QtCore.Qt.ItemDataRole.DisplayRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 0, item)
item_text = record.cover_date
if item_text is None:
item_text = ""
# remove the day of "YYYY-MM-DD"
parts = item_text.split("-")
if len(parts) > 1:
item_text = parts[0] + "-" + parts[1]
qtw_item = QtWidgets.QTableWidgetItem(item_text)
qtw_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
qtw_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 1, qtw_item)
item_text = record.name
if item_text is None:
item_text = ""
qtw_item = QtWidgets.QTableWidgetItem(item_text)
qtw_item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
qtw_item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 2, qtw_item)
if (
IssueString(record.issue_number).as_string().casefold()
== IssueString(self.issue_number).as_string().casefold()
):
self.initial_id = record.id
row += 1
self.twList.setSortingEnabled(True)
self.twList.sortItems(0, QtCore.Qt.SortOrder.AscendingOrder)
QtWidgets.QApplication.restoreOverrideCursor()
def cell_double_clicked(self, r: int, c: int) -> None:
self.accept()
def current_item_changed(self, curr: QtCore.QModelIndex | None, prev: QtCore.QModelIndex | None) -> None:
if curr is None:
return
if prev is not None and prev.row() == curr.row():
return
self.issue_id = self.twList.item(curr.row(), 0).data(QtCore.Qt.ItemDataRole.UserRole)
# list selection was changed, update the the issue cover
for record in self.issue_list:
if record.id == self.issue_id:
self.issue_number = record.issue_number
self.coverWidget.set_issue_details(self.issue_id, [record.image_url, *record.alt_image_urls])
if record.description is None:
self.teDescription.setText("")
else:
self.teDescription.setText(record.description)
break

View File

@ -1,134 +0,0 @@
"""
Class for handling the odd permutations of an 'issue number' that the comics industry throws at us
e.g.:
"12"
"12.1"
"0"
"-1"
"5AU"
"100-2"
"""
"""
Copyright 2012-2014 Anthony Beville
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import utils
import math
import re
class IssueString:
def __init__(self, text):
# break up the issue number string into 2 parts: the numeric and suffix string.
# ( assumes that the numeric portion is always first )
self.num = None
self.suffix = ""
if text is None:
return
if type(text) == int:
text = str(text)
if len(text) == 0:
return
text = unicode(text)
#skip the minus sign if it's first
if text[0] == '-':
start = 1
else:
start = 0
# if it's still not numeric at start skip it
if text[start].isdigit() or text[start] == ".":
# walk through the string, look for split point (the first non-numeric)
decimal_count = 0
for idx in range( start, len(text) ):
if text[idx] not in "0123456789.":
break
# special case: also split on second "."
if text[idx] == ".":
decimal_count += 1
if decimal_count > 1:
break
else:
idx = len(text)
# move trailing numeric decimal to suffix
# (only if there is other junk after )
if text[idx-1] == "." and len(text) != idx:
idx = idx -1
# if there is no numeric after the minus, make the minus part of the suffix
if idx == 1 and start == 1:
idx = 0
part1 = text[0:idx]
part2 = text[idx:len(text)]
if part1 != "":
self.num = float( part1 )
self.suffix = part2
else:
self.suffix = text
#print "num: {0} suf: {1}".format(self.num, self.suffix)
def asString( self, pad = 0 ):
#return the float, left side zero-padded, with suffix attached
if self.num is None:
return self.suffix
negative = self.num < 0
num_f = abs(self.num)
num_int = int( num_f )
num_s = str( num_int )
if float( num_int ) != num_f:
num_s = str( num_f )
num_s += self.suffix
# create padding
padding = ""
l = len( str(num_int))
if l < pad :
padding = "0" * (pad - l)
num_s = padding + num_s
if negative:
num_s = "-" + num_s
return num_s
def asFloat( self ):
#return the float, with no suffix
return self.num
def asInt( self ):
#return the int version of the float
if self.num is None:
return None
return int( self.num )

57
comictaggerlib/log.py Normal file
View File

@ -0,0 +1,57 @@
from __future__ import annotations
import logging.handlers
import pathlib
import platform
import sys
from comictaggerlib.ctversion import version
logger = logging.getLogger("comictagger")
def get_filename(filename: str) -> str:
filename, _, number = filename.rpartition(".")
return filename.removesuffix("log") + number + ".log"
def get_file_handler(filename: pathlib.Path) -> logging.FileHandler:
file_handler = logging.handlers.RotatingFileHandler(filename, encoding="utf-8", backupCount=10)
file_handler.namer = get_filename
if filename.is_file() and filename.stat().st_size > 0:
file_handler.doRollover()
return file_handler
def setup_logging(verbose: int, log_dir: pathlib.Path) -> None:
logging.getLogger("comicapi").setLevel(logging.DEBUG)
logging.getLogger("comictaggerlib").setLevel(logging.DEBUG)
logging.getLogger("comictalker").setLevel(logging.DEBUG)
log_file = log_dir / "ComicTagger.log"
log_dir.mkdir(parents=True, exist_ok=True)
stream_handler = logging.StreamHandler()
file_handler = get_file_handler(log_file)
if verbose > 1:
stream_handler.setLevel(logging.DEBUG)
elif verbose > 0:
stream_handler.setLevel(logging.INFO)
else:
stream_handler.setLevel(logging.WARNING)
logging.basicConfig(
handlers=[stream_handler, file_handler],
level=logging.WARNING,
format="%(asctime)s | %(name)s | %(levelname)s | %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
logger.info(
"ComicTagger Version: %s running on: %s PyInstaller: %s",
version,
platform.system(),
"Yes" if getattr(sys, "frozen", None) else "No",
)

View File

@ -1,40 +1,51 @@
"""
A PyQT4 dialog to a text file or log
"""
"""A PyQT4 dialog to a text file or log"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
import logging
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
from PyQt5 import QtCore, QtWidgets, uic
http://www.apache.org/licenses/LICENSE-2.0
from comictaggerlib.ui import qtutils, ui_path
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import sys
from PyQt4 import QtCore, QtGui, uic
import os
from settings import ComicTaggerSettings
logger = logging.getLogger(__name__)
class LogWindow(QtGui.QDialog):
def __init__(self, parent):
super(LogWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('logwindow.ui' ), self)
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
class LogWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget) -> None:
super().__init__(parent)
def setText( self, text ):
self.textEdit.setPlainText( text )
uic.loadUi(ui_path / "logwindow.ui", self)
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
def set_text(self, text: str | bytes | None) -> None:
try:
if text is not None:
if isinstance(text, bytes):
text = text.decode("utf-8")
self.textEdit.setPlainText(text)
except AttributeError:
pass
except Exception as e:
logger.exception("Displaying raw tags failed")
qtutils.qt_error("Displaying raw tags failed:", e)

226
comictaggerlib/main.py Executable file → Normal file
View File

@ -1,79 +1,169 @@
"""
A python app to (automatically) tag comic archives
"""
"""A python app to (automatically) tag comic archives"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import sys
import argparse
import json
import logging.handlers
import signal
import os
import traceback
import platform
import sys
import utils
import cli
from settings import ComicTaggerSettings
from options import Options
import settngs
import comicapi
import comictalker
from comictaggerlib import cli, ctsettings
from comictaggerlib.ctversion import version
from comictaggerlib.log import setup_logging
if sys.version_info < (3, 10):
import importlib_metadata
else:
import importlib.metadata as importlib_metadata
logger = logging.getLogger("comictagger")
try:
qt_available = True
from PyQt4 import QtCore, QtGui
from taggerwindow import TaggerWindow
except ImportError as e:
qt_available = False
#---------------------------------------
from comictaggerlib import gui
def ctmain():
utils.fix_output_encoding()
settings = ComicTaggerSettings()
qt_available = gui.qt_available
except Exception:
logger.exception("Qt unavailable")
qt_available = False
opts = Options()
opts.parseCmdLineArgs()
signal.signal(signal.SIGINT, signal.SIG_DFL)
if not qt_available and not opts.no_gui:
opts.no_gui = True
print >> sys.stderr, "PyQt4 is not available. ComicTagger is limited to command-line mode."
if opts.no_gui:
cli.cli_mode( opts, settings )
else:
app = QtGui.QApplication(sys.argv)
if platform.system() != "Linux":
img = QtGui.QPixmap(ComicTaggerSettings.getGraphic('tags.png'))
splash = QtGui.QSplashScreen(img)
splash.show()
splash.raise_()
app.processEvents()
try:
tagger_window = TaggerWindow( opts.file_list, settings, opts=opts )
tagger_window.show()
logger.setLevel(logging.DEBUG)
if platform.system() != "Linux":
splash.finish( tagger_window )
sys.exit(app.exec_())
except Exception, e:
QtGui.QMessageBox.critical(QtGui.QMainWindow(), "Error", "Unhandled exception in app:\n" + traceback.format_exc() )
def update_publishers(config: settngs.Config[settngs.Namespace]) -> None:
json_file = config[0].runtime_config.user_config_dir / "publishers.json"
if json_file.exists():
try:
comicapi.utils.update_publishers(json.loads(json_file.read_text("utf-8")))
except Exception as e:
logger.exception("Failed to load publishers from %s: %s", json_file, e)
class App:
"""docstring for App"""
def __init__(self) -> None:
self.config: settngs.Config[settngs.Namespace]
self.initial_arg_parser = ctsettings.initial_commandline_parser()
self.config_load_success = False
def run(self) -> None:
conf = self.initialize()
self.initialize_dirs(conf.config)
self.load_plugins(conf)
self.register_settings()
self.config = self.parse_settings(conf.config)
self.main()
def load_plugins(self, opts: argparse.Namespace) -> None:
comicapi.comicarchive.load_archive_plugins()
ctsettings.talkers = comictalker.get_talkers(version, opts.config.user_cache_dir)
def initialize(self) -> argparse.Namespace:
conf, _ = self.initial_arg_parser.parse_known_args()
assert conf is not None
setup_logging(conf.verbose, conf.config.user_log_dir)
return conf
def register_settings(self) -> None:
self.manager = settngs.Manager(
"""A utility for reading and writing metadata to comic archives.\n\n\nIf no options are given, %(prog)s will run in windowed mode.""",
"For more help visit the wiki at: https://github.com/comictagger/comictagger/wiki",
)
ctsettings.register_commandline_settings(self.manager)
ctsettings.register_file_settings(self.manager)
ctsettings.register_plugin_settings(self.manager)
def parse_settings(self, config_paths: ctsettings.ComicTaggerPaths) -> settngs.Config[settngs.Namespace]:
cfg, self.config_load_success = self.manager.parse_config(config_paths.user_config_dir / "settings.json")
config = self.manager.get_namespace(cfg)
config = ctsettings.validate_commandline_settings(config, self.manager)
config = ctsettings.validate_file_settings(config)
config = ctsettings.validate_plugin_settings(config)
return config
def initialize_dirs(self, paths: ctsettings.ComicTaggerPaths) -> None:
paths.user_data_dir.mkdir(parents=True, exist_ok=True)
paths.user_config_dir.mkdir(parents=True, exist_ok=True)
paths.user_cache_dir.mkdir(parents=True, exist_ok=True)
paths.user_state_dir.mkdir(parents=True, exist_ok=True)
paths.user_log_dir.mkdir(parents=True, exist_ok=True)
logger.debug("user_data_dir: %s", paths.user_data_dir)
logger.debug("user_config_dir: %s", paths.user_config_dir)
logger.debug("user_cache_dir: %s", paths.user_cache_dir)
logger.debug("user_state_dir: %s", paths.user_state_dir)
logger.debug("user_log_dir: %s", paths.user_log_dir)
def main(self) -> None:
assert self.config is not None
# config already loaded
error = None
signal.signal(signal.SIGINT, signal.SIG_DFL)
logger.debug("Installed Packages")
for pkg in sorted(importlib_metadata.distributions(), key=lambda x: x.name):
logger.debug("%s\t%s", pkg.metadata["Name"], pkg.metadata["Version"])
comicapi.utils.load_publishers()
update_publishers(self.config)
if not qt_available and not self.config[0].runtime_no_gui:
self.config[0].runtime_no_gui = True
logger.warning("PyQt5 is not available. ComicTagger is limited to command-line mode.")
# manage the CV API key
# None comparison is used so that the empty string can unset the value
if self.config[0].talker_comicvine_cv_api_key is not None or self.config[0].talker_comicvine_cv_url is not None:
settings_path = self.config[0].runtime_config.user_config_dir / "settings.json"
if self.config_load_success:
self.manager.save_file(self.config[0], settings_path)
if self.config[0].commands_only_set_cv_key:
if self.config_load_success:
print("Key set") # noqa: T201
return
if not self.config_load_success:
error = (
f"Failed to load settings, check the log located in '{self.config[0].runtime_config.user_log_dir}' for more details",
True,
)
talkers = ctsettings.talkers
del ctsettings.talkers
if self.config[0].runtime_no_gui:
if error and error[1]:
print(f"A fatal error occurred please check the log for more information: {error[0]}") # noqa: T201
raise SystemExit(1)
try:
cli.CLI(self.config[0], talkers).run()
except Exception:
logger.exception("CLI mode failed")
else:
gui.open_tagger_window(talkers, self.config, error)
def main() -> None:
App().run()

View File

@ -1,160 +1,166 @@
"""
A PyQT4 dialog to select from automated issue matches
"""
"""A PyQT4 dialog to select from automated issue matches"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import sys
import logging
import os
from PyQt4 import QtCore, QtGui, uic
from PyQt4.QtCore import QUrl, pyqtSignal, QByteArray
import settngs
from PyQt5 import QtCore, QtWidgets, uic
from imagefetcher import ImageFetcher
from settings import ComicTaggerSettings
from comicarchive import MetaDataStyle
from coverimagewidget import CoverImageWidget
from comicvinetalker import ComicVineTalker
import utils
from comicapi.comicarchive import ComicArchive
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.resulttypes import IssueResult
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import reduce_widget_font_size
from comictalker.comictalker import ComicTalker
class MatchSelectionWindow(QtGui.QDialog):
volume_id = 0
def __init__(self, parent, matches, comic_archive):
super(MatchSelectionWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('matchselectionwindow.ui' ), self)
logger = logging.getLogger(__name__)
self.altCoverWidget = CoverImageWidget( self.altCoverContainer, CoverImageWidget.AltCoverMode )
gridlayout = QtGui.QGridLayout( self.altCoverContainer )
gridlayout.addWidget( self.altCoverWidget )
gridlayout.setContentsMargins(0,0,0,0)
self.archiveCoverWidget = CoverImageWidget( self.archiveCoverContainer, CoverImageWidget.ArchiveMode )
gridlayout = QtGui.QGridLayout( self.archiveCoverContainer )
gridlayout.addWidget( self.archiveCoverWidget )
gridlayout.setContentsMargins(0,0,0,0)
class MatchSelectionWindow(QtWidgets.QDialog):
def __init__(
self,
parent: QtWidgets.QWidget,
matches: list[IssueResult],
comic_archive: ComicArchive,
config: settngs.Namespace,
talker: ComicTalker,
) -> None:
super().__init__(parent)
utils.reduceWidgetFontSize( self.twList )
utils.reduceWidgetFontSize( self.teDescription, 1 )
uic.loadUi(ui_path / "matchselectionwindow.ui", self)
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
self.altCoverWidget = CoverImageWidget(
self.altCoverContainer, CoverImageWidget.AltCoverMode, config.runtime_config.user_cache_dir, talker
)
gridlayout = QtWidgets.QGridLayout(self.altCoverContainer)
gridlayout.addWidget(self.altCoverWidget)
gridlayout.setContentsMargins(0, 0, 0, 0)
self.matches = matches
self.comic_archive = comic_archive
self.twList.currentItemChanged.connect(self.currentItemChanged)
self.twList.cellDoubleClicked.connect(self.cellDoubleClicked)
self.archiveCoverWidget = CoverImageWidget(self.archiveCoverContainer, CoverImageWidget.ArchiveMode, None, None)
gridlayout = QtWidgets.QGridLayout(self.archiveCoverContainer)
gridlayout.addWidget(self.archiveCoverWidget)
gridlayout.setContentsMargins(0, 0, 0, 0)
self.updateData()
reduce_widget_font_size(self.twList)
reduce_widget_font_size(self.teDescription, 1)
def updateData( self):
self.setCoverImage()
self.populateTable()
self.twList.resizeColumnsToContents()
self.twList.selectRow( 0 )
path = self.comic_archive.path
self.setWindowTitle( u"Select correct match: {0}".format(
os.path.split(path)[1] ))
def populateTable( self ):
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
while self.twList.rowCount() > 0:
self.twList.removeRow(0)
self.twList.setSortingEnabled(False)
self.matches: list[IssueResult] = matches
self.comic_archive = comic_archive
row = 0
for match in self.matches:
self.twList.insertRow(row)
item_text = match['series']
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setData( QtCore.Qt.UserRole, (match,))
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 0, item)
self.twList.currentItemChanged.connect(self.current_item_changed)
self.twList.cellDoubleClicked.connect(self.cell_double_clicked)
if match['publisher'] is not None:
item_text = u"{0}".format(match['publisher'])
else:
item_text = u"Unknown"
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 1, item)
month_str = u""
year_str = u"????"
if match['month'] is not None:
month_str = u"-{0:02d}".format(int(match['month']))
if match['year'] is not None:
year_str = u"{0}".format(match['year'])
self.update_data()
item_text = year_str + month_str
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 2, item)
def update_data(self) -> None:
self.set_cover_image()
self.populate_table()
self.twList.resizeColumnsToContents()
self.twList.selectRow(0)
item_text = match['issue_title']
if item_text is None:
item_text = ""
item = QtGui.QTableWidgetItem(item_text)
item.setData( QtCore.Qt.ToolTipRole, item_text )
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
self.twList.setItem(row, 3, item)
row += 1
path = self.comic_archive.path
self.setWindowTitle(f"Select correct match: {os.path.split(path)[1]}")
self.twList.resizeColumnsToContents()
self.twList.setSortingEnabled(True)
self.twList.sortItems( 2 , QtCore.Qt.AscendingOrder )
self.twList.selectRow(0)
self.twList.resizeColumnsToContents()
self.twList.horizontalHeader().setStretchLastSection(True)
def populate_table(self) -> None:
self.twList.setRowCount(0)
def cellDoubleClicked( self, r, c ):
self.accept()
def currentItemChanged( self, curr, prev ):
self.twList.setSortingEnabled(False)
if curr is None:
return
if prev is not None and prev.row() == curr.row():
return
self.altCoverWidget.setIssueID( self.currentMatch()['issue_id'] )
if self.currentMatch()['description'] is None:
self.teDescription.setText ( "" )
else:
self.teDescription.setText ( self.currentMatch()['description'] )
def setCoverImage( self ):
self.archiveCoverWidget.setArchive( self.comic_archive)
row = 0
for match in self.matches:
self.twList.insertRow(row)
def currentMatch( self ):
row = self.twList.currentRow()
match = self.twList.item(row, 0).data( QtCore.Qt.UserRole ).toPyObject()[0]
return match
item_text = match["series"]
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setData(QtCore.Qt.ItemDataRole.UserRole, (match,))
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 0, item)
if match["publisher"] is not None:
item_text = str(match["publisher"])
else:
item_text = "Unknown"
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 1, item)
month_str = ""
year_str = "????"
if match["month"] is not None:
month_str = f"-{int(match['month']):02d}"
if match["year"] is not None:
year_str = str(match["year"])
item_text = year_str + month_str
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 2, item)
item_text = match["issue_title"]
if item_text is None:
item_text = ""
item = QtWidgets.QTableWidgetItem(item_text)
item.setData(QtCore.Qt.ItemDataRole.ToolTipRole, item_text)
item.setFlags(QtCore.Qt.ItemFlag.ItemIsSelectable | QtCore.Qt.ItemFlag.ItemIsEnabled)
self.twList.setItem(row, 3, item)
row += 1
self.twList.resizeColumnsToContents()
self.twList.setSortingEnabled(True)
self.twList.sortItems(2, QtCore.Qt.SortOrder.AscendingOrder)
self.twList.selectRow(0)
self.twList.resizeColumnsToContents()
self.twList.horizontalHeader().setStretchLastSection(True)
def cell_double_clicked(self, r: int, c: int) -> None:
self.accept()
def current_item_changed(self, curr: QtCore.QModelIndex, prev: QtCore.QModelIndex) -> None:
if curr is None:
return
if prev is not None and prev.row() == curr.row():
return
self.altCoverWidget.set_issue_details(
self.current_match()["issue_id"],
[self.current_match()["image_url"], *self.current_match()["alt_image_urls"]],
)
if self.current_match()["description"] is None:
self.teDescription.setText("")
else:
self.teDescription.setText(self.current_match()["description"])
def set_cover_image(self) -> None:
self.archiveCoverWidget.set_archive(self.comic_archive)
def current_match(self) -> IssueResult:
row = self.twList.currentRow()
match: IssueResult = self.twList.item(row, 0).data(QtCore.Qt.ItemDataRole.UserRole)[0]
return match

View File

@ -1,106 +1,119 @@
"""A PyQt5 dialog to show a message and let the user check a box
Example usage:
checked = OptionalMessageDialog.msg(self, "Disclaimer",
"This is beta software, and you are using it at your own risk!",
)
said_yes, checked = OptionalMessageDialog.question(self, "QtWidgets.Question",
"Are you sure you wish to do this?",
)
"""
A PyQt4 dialog to show a message and let the user check a box
"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
import logging
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
from PyQt5 import QtCore, QtWidgets
http://www.apache.org/licenses/LICENSE-2.0
logger = logging.getLogger(__name__)
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.
"""
"""
example usage:
checked = OptionalMessageDialog.msg( self, "Disclaimer",
"This is beta software, and you are using it at your own risk!",
)
said_yes, checked = OptionalMessageDialog.question( self, "Question",
"Are you sure you wish to do this?",
)
"""
from PyQt4.QtCore import *
from PyQt4.QtGui import *
StyleMessage = 0
StyleQuestion = 1
class OptionalMessageDialog(QDialog):
def __init__(self, parent, style, title, msg, check_state=Qt.Unchecked, check_text=None ):
QDialog.__init__(self, parent)
self.setWindowTitle( title )
self.was_accepted = False
l = QVBoxLayout( self )
self.theLabel = QLabel( msg )
self.theLabel.setWordWrap(True)
self.theLabel.setTextFormat( Qt.RichText )
self.theLabel.setOpenExternalLinks(True)
self.theLabel.setTextInteractionFlags( Qt.TextSelectableByMouse | Qt.LinksAccessibleByMouse | Qt.LinksAccessibleByKeyboard )
l.addWidget( self.theLabel )
l.insertSpacing ( -1, 10 )
if check_text is None:
if style == StyleQuestion:
check_text = "Remember this answer"
else:
check_text = "Don't show this dialog again"
self.theCheckBox = QCheckBox(check_text)
self.theCheckBox.setCheckState( check_state )
l.addWidget( self.theCheckBox )
class OptionalMessageDialog(QtWidgets.QDialog):
def __init__(
self, parent: QtWidgets.QWidget, style: int, title: str, msg: str, checked: bool = False, check_text: str = ""
) -> None:
super().__init__(parent)
btnbox_style = QDialogButtonBox.Ok
if style == StyleQuestion:
btnbox_style = QDialogButtonBox.Yes|QDialogButtonBox.No
self.theButtonBox = QDialogButtonBox(
btnbox_style,
parent=self,
accepted=self.accept,
rejected=self.reject)
l.addWidget( self.theButtonBox )
def accept( self ):
self.was_accepted = True
QDialog.accept(self)
self.setWindowTitle(title)
self.was_accepted = False
layout = QtWidgets.QVBoxLayout(self)
def reject( self ):
self.was_accepted = False
QDialog.reject(self)
self.theLabel = QtWidgets.QLabel(msg)
self.theLabel.setWordWrap(True)
self.theLabel.setTextFormat(QtCore.Qt.TextFormat.RichText)
self.theLabel.setOpenExternalLinks(True)
self.theLabel.setTextInteractionFlags(
QtCore.Qt.TextInteractionFlag.TextSelectableByMouse
| QtCore.Qt.TextInteractionFlag.LinksAccessibleByMouse
| QtCore.Qt.TextInteractionFlag.LinksAccessibleByKeyboard
)
@staticmethod
def msg( parent, title, msg, check_state=Qt.Unchecked, check_text=None):
d = OptionalMessageDialog( parent, StyleMessage, title, msg, check_state=check_state, check_text=check_text )
layout.addWidget(self.theLabel)
layout.insertSpacing(-1, 10)
d.exec_()
return d.theCheckBox.isChecked()
if not check_text:
if style == StyleQuestion:
check_text = "Remember this answer"
else:
check_text = "Don't show this message again"
@staticmethod
def question( parent, title, msg, check_state=Qt.Unchecked, check_text=None ):
d = OptionalMessageDialog( parent, StyleQuestion, title, msg, check_state=check_state, check_text=check_text )
d.exec_()
return d.was_accepted, d.theCheckBox.isChecked()
self.theCheckBox = QtWidgets.QCheckBox(check_text)
self.theCheckBox.setChecked(checked)
layout.addWidget(self.theCheckBox)
btnbox_style: QtWidgets.QDialogButtonBox.StandardButtons | QtWidgets.QDialogButtonBox.StandardButton
if style == StyleQuestion:
btnbox_style = QtWidgets.QDialogButtonBox.StandardButton.Yes | QtWidgets.QDialogButtonBox.StandardButton.No
else:
btnbox_style = QtWidgets.QDialogButtonBox.StandardButton.Ok
self.theButtonBox = QtWidgets.QDialogButtonBox(btnbox_style, parent=self)
self.theButtonBox.accepted.connect(self.accept)
self.theButtonBox.rejected.connect(self.reject)
layout.addWidget(self.theButtonBox)
def accept(self) -> None:
self.was_accepted = True
QtWidgets.QDialog.accept(self)
def reject(self) -> None:
self.was_accepted = False
QtWidgets.QDialog.reject(self)
@staticmethod
def msg(parent: QtWidgets.QWidget, title: str, msg: str, checked: bool = False, check_text: str = "") -> bool:
d = OptionalMessageDialog(parent, StyleMessage, title, msg, checked=checked, check_text=check_text)
d.exec()
return d.theCheckBox.isChecked()
@staticmethod
def question(
parent: QtWidgets.QWidget, title: str, msg: str, checked: bool = False, check_text: str = ""
) -> tuple[bool, bool]:
d = OptionalMessageDialog(parent, StyleQuestion, title, msg, checked=checked, check_text=check_text)
d.exec()
return d.was_accepted, d.theCheckBox.isChecked()
@staticmethod
def msg_no_checkbox(
parent: QtWidgets.QWidget, title: str, msg: str, checked: bool = False, check_text: str = ""
) -> bool:
d = OptionalMessageDialog(parent, StyleMessage, title, msg, checked=checked, check_text=check_text)
d.theCheckBox.hide()
d.exec()
return d.theCheckBox.isChecked()

View File

@ -1,372 +0,0 @@
"""
CLI options class for comictagger app
"""
"""
Copyright 2012-2014 Anthony Beville
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import sys
import getopt
import platform
import os
import traceback
import ctversion
import utils
try:
import argparse
except:
pass
from genericmetadata import GenericMetadata
from comicarchive import MetaDataStyle
from versionchecker import VersionChecker
class Options:
help_text = """
Usage: {0} [OPTION]... [FILE LIST]
A utility for reading and writing metadata to comic archives.
If no options are given, {0} will run in windowed mode
-p, --print Print out tag info from file. Specify type
(via -t) to get only info of that tag type
--raw With -p, will print out the raw tag block(s)
from the file
-d, --delete Deletes the tag block of specified type (via -t)
-c, --copy=SOURCE Copy the specified source tag block to destination style
specified via via -t (potentially lossy operation)
-s, --save Save out tags as specified type (via -t)
Must specify also at least -o, -p, or -m
--nooverwrite Don't modify tag block if it already exists ( relevent for -s or -c )
-1, --assume-issue-one Assume issue number is 1 if not found ( relevent for -s )
-n, --dryrun Don't actually modify file (only relevent for -d, -s, or -r)
-t, --type=TYPE Specify TYPE as either "CR", "CBL", or "COMET" (as either
ComicRack, ComicBookLover, or CoMet style tags, respectivly)
-f, --parsefilename Parse the filename to get some info, specifically
series name, issue number, volume, and publication
year
-i, --interactive Interactively query the user when there are multiple matches for
an online search
--nosummary Suppress the default summary after a save operation
-o, --online Search online and attempt to identify file using
existing metadata and images in archive. May be used
in conjuntion with -f and -m
--id=ID Use the issue ID when searching online. Overrides all other metadata
-m, --metadata=LIST Explicity define, as a list, some tags to be used
e.g. "series=Plastic Man , publisher=Quality Comics"
"series=Kickers^, Inc., issue=1, year=1986"
Name-Value pairs are comma separated. Use a "^" to
escape an "=" or a ",", as shown in the example above
Some names that can be used:
series, issue, issueCount, year, publisher, title
-r, --rename Rename the file based on specified tag style.
--noabort Don't abort save operation when online match is of low confidence
-e, --export-to-zip Export RAR archive to Zip format
--delete-rar Delete original RAR archive after successful export to Zip
--abort-on-conflict Don't export to zip if intended new filename exists (Otherwise, creates
a new unique filename)
-S, --script=FILE Run an "add-on" python script that uses the comictagger library for custom
processing. Script arguments can follow the script name
-R, --recursive Recursively include files in sub-folders
-v, --verbose Be noisy when doing what it does
--terse Don't say much (for print mode)
--version Display version
-h, --help Display this message
For more help visit the wiki at: http://code.google.com/p/comictagger/
"""
def __init__(self):
self.data_style = None
self.no_gui = False
self.filename = None
self.verbose = False
self.terse = False
self.metadata = None
self.print_tags = False
self.copy_tags = False
self.delete_tags = False
self.export_to_zip = False
self.abort_export_on_conflict = False
self.delete_rar_after_export = False
self.search_online = False
self.dryrun = False
self.abortOnLowConfidence = True
self.save_tags = False
self.parse_filename = False
self.show_save_summary = True
self.raw = False
self.rename_file = False
self.no_overwrite = False
self.interactive = False
self.issue_id = None
self.recursive = False
self.run_script = False
self.script = None
self.assume_issue_is_one_if_not_set = False
self.file_list = []
def display_msg_and_quit( self, msg, code, show_help=False ):
appname = os.path.basename(sys.argv[0])
if msg is not None:
print( msg )
if show_help:
print self.help_text.format(appname)
else:
print "For more help, run with '--help'"
sys.exit(code)
def parseMetadataFromString( self, mdstr ):
# The metadata string is a comma separated list of name-value pairs
# The names match the attributes of the internal metadata struct (for now)
# The caret is the special "escape character", since it's not common in
# natural language text
# example = "series=Kickers^, Inc. ,issue=1, year=1986"
escaped_comma = "^,"
escaped_equals = "^="
replacement_token = "<_~_>"
md = GenericMetadata()
# First, replace escaped commas with with a unique token (to be changed back later)
mdstr = mdstr.replace( escaped_comma, replacement_token)
tmp_list = mdstr.split(",")
md_list = []
for item in tmp_list:
item = item.replace( replacement_token, "," )
md_list.append(item)
# Now build a nice dict from the list
md_dict = dict()
for item in md_list:
# Make sure to fix any escaped equal signs
i = item.replace( escaped_equals, replacement_token)
key,value = i.split("=")
value = value.replace( replacement_token, "=" ).strip()
key = key.strip()
if key.lower() == "credit":
cred_attribs = value.split(":")
role = cred_attribs[0]
person = ( cred_attribs[1] if len( cred_attribs ) > 1 else "" )
primary = (cred_attribs[2] if len( cred_attribs ) > 2 else None )
md.addCredit( person.strip(), role.strip(), True if primary is not None else False )
else:
md_dict[key] = value
# Map the dict to the metadata object
for key in md_dict:
if not hasattr(md, key):
print "Warning: '{0}' is not a valid tag name".format(key)
else:
md.isEmpty = False
setattr( md, key, md_dict[key] )
#print md
return md
def launch_script(self, scriptfile):
# we were given a script. special case for the args:
# 1. ignore everthing before the -S,
# 2. pass all the ones that follow (including script name) to the script
script_args = list()
for idx, arg in enumerate(sys.argv):
if arg in [ '-S', '--script']:
#found script!
script_args = sys.argv[idx+1:]
break
sys.argv = script_args
if not os.path.exists(scriptfile):
print "Can't find {0}".format( scriptfile )
else:
# I *think* this makes sense:
# assume the base name of the file is the module name
# add the folder of the given file to the python path
# import module
dirname = os.path.dirname(scriptfile)
module_name = os.path.splitext(os.path.basename(scriptfile))[0]
sys.path = [dirname] + sys.path
try:
script = __import__(module_name)
# Determine if the entry point exists before trying to run it
if "main" in dir(script):
script.main()
else:
print "Can't find entry point \"main()\" in module \"{0}\"".format( module_name )
except Exception as e:
print "Script raised an unhandled exception: ", e
print traceback.format_exc()
sys.exit(0)
def parseCmdLineArgs(self):
if platform.system() == "Darwin" and hasattr(sys, "frozen") and sys.frozen == 1:
# remove the PSN ("process serial number") argument from OS/X
input_args = [a for a in sys.argv[1:] if "-psn_0_" not in a ]
else:
input_args = sys.argv[1:]
# first check if we're launching a script:
for n in range(len(input_args)):
if ( input_args[n] in [ "-S", "--script" ] and
n+1 < len(input_args)):
# insert a "--" which will cause getopt to ignore the remaining args
# so they will be passed to the script
input_args.insert(n+2, "--")
break
# parse command line options
try:
opts, args = getopt.getopt( input_args,
"hpdt:fm:vonsrc:ieRS:1",
[ "help", "print", "delete", "type=", "copy=", "parsefilename", "metadata=", "verbose",
"online", "dryrun", "save", "rename" , "raw", "noabort", "terse", "nooverwrite",
"interactive", "nosummary", "version", "id=" , "recursive", "script=",
"export-to-zip", "delete-rar", "abort-on-conflict", "assume-issue-one" ] )
except getopt.GetoptError as err:
self.display_msg_and_quit( str(err), 2 )
# process options
for o, a in opts:
if o in ("-h", "--help"):
self.display_msg_and_quit( None, 0, show_help=True )
if o in ("-v", "--verbose"):
self.verbose = True
if o in ("-S", "--script"):
self.run_script = True
self.script = a
if o in ("-R", "--recursive"):
self.recursive = True
if o in ("-p", "--print"):
self.print_tags = True
if o in ("-d", "--delete"):
self.delete_tags = True
if o in ("-i", "--interactive"):
self.interactive = True
if o in ("-c", "--copy"):
self.copy_tags = True
if a.lower() == "cr":
self.copy_source = MetaDataStyle.CIX
elif a.lower() == "cbl":
self.copy_source = MetaDataStyle.CBI
elif a.lower() == "comet":
self.copy_source = MetaDataStyle.COMET
else:
self.display_msg_and_quit( "Invalid copy tag source type", 1 )
if o in ("-o", "--online"):
self.search_online = True
if o in ("-n", "--dryrun"):
self.dryrun = True
if o in ("-m", "--metadata"):
self.metadata = self.parseMetadataFromString(a)
if o in ("-s", "--save"):
self.save_tags = True
if o in ("-r", "--rename"):
self.rename_file = True
if o in ("-e", "--export_to_zip"):
self.export_to_zip = True
if o == "--delete-rar":
self.delete_rar_after_export = True
if o == "--abort-on-conflict":
self.abort_export_on_conflict = True
if o in ("-f", "--parsefilename"):
self.parse_filename = True
if o == "--id":
self.issue_id = a
if o == "--raw":
self.raw = True
if o == "--noabort":
self.abortOnLowConfidence = False
if o == "--terse":
self.terse = True
if o == "--nosummary":
self.show_save_summary = False
if o in ("-1", "--assume-issue-one"):
self.assume_issue_is_one_if_not_set = True
if o == "--nooverwrite":
self.no_overwrite = True
if o == "--version":
print "ComicTagger {0}: Copyright (c) 2012-2014 Anthony Beville".format(ctversion.version)
print "Distributed under Apache License 2.0 (http://www.apache.org/licenses/LICENSE-2.0)"
new_version = VersionChecker().getLatestVersion("", False)
if new_version is not None and new_version != ctversion.version:
print "----------------------------------------"
print "New version available online: {0}".format(new_version)
print "----------------------------------------"
sys.exit(0)
if o in ("-t", "--type"):
if a.lower() == "cr":
self.data_style = MetaDataStyle.CIX
elif a.lower() == "cbl":
self.data_style = MetaDataStyle.CBI
elif a.lower() == "comet":
self.data_style = MetaDataStyle.COMET
else:
self.display_msg_and_quit( "Invalid tag type", 1 )
if self.print_tags or self.delete_tags or self.save_tags or self.copy_tags or self.rename_file or self.export_to_zip:
self.no_gui = True
count = 0
if self.run_script: count += 1
if self.print_tags: count += 1
if self.delete_tags: count += 1
if self.save_tags: count += 1
if self.copy_tags: count += 1
if self.rename_file: count += 1
if self.export_to_zip: count +=1
if count > 1:
self.display_msg_and_quit( "Must choose only one action of print, delete, save, copy, rename, export, or run script", 1 )
if self.script is not None:
self.launch_script( self.script )
if len(args) > 0:
if platform.system() == "Windows":
# no globbing on windows shell, so do it for them
import glob
self.file_list = []
for item in args:
self.file_list.extend(glob.glob(item))
if len(self.file_list) > 0:
self.filename = self.file_list[0]
else:
self.filename = args[0]
self.file_list = args
if self.no_gui and self.filename is None:
self.display_msg_and_quit( "Command requires at least one filename!", 1 )
if self.delete_tags and self.data_style is None:
self.display_msg_and_quit( "Please specify the type to delete with -t", 1 )
if self.save_tags and self.data_style is None:
self.display_msg_and_quit( "Please specify the type to save with -t", 1 )
if self.copy_tags and self.data_style is None:
self.display_msg_and_quit( "Please specify the type to copy to with -t", 1 )
#if self.rename_file and self.data_style is None:
# self.display_msg_and_quit( "Please specify the type to use for renaming with -t", 1 )
if self.recursive:
self.file_list = utils.get_recursive_filelist( self.file_list )

View File

@ -1,110 +1,114 @@
"""
A PyQT4 dialog to show pages of a comic archive
"""
"""
Copyright 2012-2014 Anthony Beville
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.
"""
"""A PyQT4 dialog to show pages of a comic archive"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
import logging
import platform
import sys
from PyQt4 import QtCore, QtGui, uic
import os
from settings import ComicTaggerSettings
from coverimagewidget import CoverImageWidget
class PageBrowserWindow(QtGui.QDialog):
def __init__(self, parent, metadata):
super(PageBrowserWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('pagebrowser.ui' ), self)
self.pageWidget = CoverImageWidget( self.pageContainer, CoverImageWidget.ArchiveMode )
gridlayout = QtGui.QGridLayout( self.pageContainer )
gridlayout.addWidget( self.pageWidget )
gridlayout.setContentsMargins(0,0,0,0)
self.pageWidget.showControls = False
from PyQt5 import QtCore, QtGui, QtWidgets, uic
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
self.comic_archive = None
self.page_count = 0
self.current_page_num = 0
self.metadata = metadata
self.buttonBox.button(QtGui.QDialogButtonBox.Close).setDefault(True)
if platform.system() == "Darwin":
self.btnPrev.setText("<<")
self.btnNext.setText(">>")
else:
self.btnPrev.setIcon(QtGui.QIcon( ComicTaggerSettings.getGraphic('left.png' )))
self.btnNext.setIcon(QtGui.QIcon( ComicTaggerSettings.getGraphic('right.png')))
self.btnNext.clicked.connect( self.nextPage )
self.btnPrev.clicked.connect( self.prevPage )
self.show()
self.btnNext.setEnabled( False )
self.btnPrev.setEnabled( False )
def reset( self ):
self.comic_archive = None
self.page_count = 0
self.current_page_num = 0
self.metadata = None
self.btnNext.setEnabled( False )
self.btnPrev.setEnabled( False )
self.pageWidget.clear()
from comicapi.comicarchive import ComicArchive
from comicapi.genericmetadata import GenericMetadata
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.graphics import graphics_path
from comictaggerlib.ui import ui_path
def setComicArchive(self, ca):
logger = logging.getLogger(__name__)
self.comic_archive = ca
self.page_count = ca.getNumberOfPages()
self.current_page_num = 0
self.pageWidget.setArchive( self.comic_archive )
self.setPage()
if self.page_count > 1:
self.btnNext.setEnabled( True )
self.btnPrev.setEnabled( True )
def nextPage(self):
if self.current_page_num + 1 < self.page_count:
self.current_page_num += 1
else:
self.current_page_num = 0
self.setPage()
class PageBrowserWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget, metadata: GenericMetadata) -> None:
super().__init__(parent)
def prevPage(self):
if self.current_page_num - 1 >= 0:
self.current_page_num -= 1
else:
self.current_page_num = self.page_count - 1
self.setPage()
def setPage( self ):
if self.metadata is not None:
archive_page_index = self.metadata.getArchivePageIndex( self.current_page_num )
else:
archive_page_index = self.current_page_num
self.pageWidget.setPage( archive_page_index )
self.setWindowTitle("Page Browser - Page {0} (of {1}) ".format(self.current_page_num+1, self.page_count ) )
uic.loadUi(ui_path / "pagebrowser.ui", self)
self.pageWidget = CoverImageWidget(self.pageContainer, CoverImageWidget.ArchiveMode, None, None)
gridlayout = QtWidgets.QGridLayout(self.pageContainer)
gridlayout.addWidget(self.pageWidget)
gridlayout.setContentsMargins(0, 0, 0, 0)
self.pageWidget.showControls = False
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
self.comic_archive: ComicArchive | None = None
self.page_count = 0
self.current_page_num = 0
self.metadata = metadata
self.buttonBox.button(QtWidgets.QDialogButtonBox.StandardButton.Close).setDefault(True)
if platform.system() == "Darwin":
self.btnPrev.setText("<<")
self.btnNext.setText(">>")
else:
self.btnPrev.setIcon(QtGui.QIcon(str(graphics_path / "left.png")))
self.btnNext.setIcon(QtGui.QIcon(str(graphics_path / "right.png")))
self.btnNext.clicked.connect(self.next_page)
self.btnPrev.clicked.connect(self.prev_page)
self.show()
self.btnNext.setEnabled(False)
self.btnPrev.setEnabled(False)
def reset(self) -> None:
self.comic_archive = None
self.page_count = 0
self.current_page_num = 0
self.metadata = GenericMetadata()
self.btnNext.setEnabled(False)
self.btnPrev.setEnabled(False)
self.pageWidget.clear()
def set_comic_archive(self, ca: ComicArchive) -> None:
self.comic_archive = ca
self.page_count = ca.get_number_of_pages()
self.current_page_num = 0
self.pageWidget.set_archive(self.comic_archive)
self.set_page()
if self.page_count > 1:
self.btnNext.setEnabled(True)
self.btnPrev.setEnabled(True)
def next_page(self) -> None:
if self.current_page_num + 1 < self.page_count:
self.current_page_num += 1
else:
self.current_page_num = 0
self.set_page()
def prev_page(self) -> None:
if self.current_page_num - 1 >= 0:
self.current_page_num -= 1
else:
self.current_page_num = self.page_count - 1
self.set_page()
def set_page(self) -> None:
if not self.metadata.is_empty:
archive_page_index = self.metadata.get_archive_page_index(self.current_page_num)
else:
archive_page_index = self.current_page_num
self.pageWidget.set_page(archive_page_index)
self.setWindowTitle(f"Page Browser - Page {self.current_page_num + 1} (of {self.page_count}) ")

View File

@ -1,273 +1,396 @@
"""
A PyQt4 widget for editing the page list info
"""
"""A PyQt5 widget for editing the page list info"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
import logging
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
from PyQt5 import QtCore, QtGui, QtWidgets, uic
http://www.apache.org/licenses/LICENSE-2.0
from comicapi.comicarchive import ComicArchive, MetaDataStyle
from comicapi.genericmetadata import ImageMetadata, PageType
from comictaggerlib.coverimagewidget import CoverImageWidget
from comictaggerlib.ui import ui_path
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import os
from PyQt4.QtCore import *
from PyQt4.QtGui import *
from PyQt4 import uic
from settings import ComicTaggerSettings
from genericmetadata import GenericMetadata, PageType
from comicarchive import MetaDataStyle
from pageloader import PageLoader
from coverimagewidget import CoverImageWidget
logger = logging.getLogger(__name__)
def itemMoveEvents( widget ):
def item_move_events(widget: QtWidgets.QWidget) -> QtCore.pyqtBoundSignal:
class Filter(QtCore.QObject):
mysignal = QtCore.pyqtSignal(str)
class Filter(QObject):
mysignal = pyqtSignal( str )
def eventFilter(self, obj, event):
if obj == widget:
#print event.type()
if event.type() == QEvent.ChildRemoved:
#print "ChildRemoved"
self.mysignal.emit("finish")
if event.type() == QEvent.ChildAdded:
#print "ChildAdded"
self.mysignal.emit("start")
return True
return False
def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool:
if obj == widget:
if event.type() == QtCore.QEvent.Type.ChildRemoved:
self.mysignal.emit("finish")
if event.type() == QtCore.QEvent.Type.ChildAdded:
self.mysignal.emit("start")
return True
filter = Filter( widget )
widget.installEventFilter( filter )
return filter.mysignal
return False
class PageListEditor(QWidget):
firstFrontCoverChanged = pyqtSignal( int )
listOrderChanged = pyqtSignal( )
modified = pyqtSignal( )
pageTypeNames = {
PageType.FrontCover: "Front Cover",
PageType.InnerCover: "Inner Cover",
PageType.Advertisement: "Advertisement",
PageType.Roundup: "Roundup",
PageType.Story: "Story",
PageType.Editorial: "Editorial",
PageType.Letters: "Letters",
PageType.Preview: "Preview",
PageType.BackCover: "Back Cover",
PageType.Other: "Other",
PageType.Deleted: "Deleted",
}
def __init__(self, parent ):
super(PageListEditor, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('pagelisteditor.ui' ), self)
self.pageWidget = CoverImageWidget( self.pageContainer, CoverImageWidget.ArchiveMode )
gridlayout = QGridLayout( self.pageContainer )
gridlayout.addWidget( self.pageWidget )
gridlayout.setContentsMargins(0,0,0,0)
self.pageWidget.showControls = False
self.resetPage()
# Add the entries to the manga combobox
self.comboBox.addItem( "", "" )
self.comboBox.addItem( self.pageTypeNames[ PageType.FrontCover], PageType.FrontCover )
self.comboBox.addItem( self.pageTypeNames[ PageType.InnerCover], PageType.InnerCover )
self.comboBox.addItem( self.pageTypeNames[ PageType.Advertisement], PageType.Advertisement )
self.comboBox.addItem( self.pageTypeNames[ PageType.Roundup], PageType.Roundup )
self.comboBox.addItem( self.pageTypeNames[ PageType.Story], PageType.Story )
self.comboBox.addItem( self.pageTypeNames[ PageType.Editorial], PageType.Editorial )
self.comboBox.addItem( self.pageTypeNames[ PageType.Letters], PageType.Letters )
self.comboBox.addItem( self.pageTypeNames[ PageType.Preview], PageType.Preview )
self.comboBox.addItem( self.pageTypeNames[ PageType.BackCover], PageType.BackCover )
self.comboBox.addItem( self.pageTypeNames[ PageType.Other], PageType.Other )
self.comboBox.addItem( self.pageTypeNames[ PageType.Deleted], PageType.Deleted )
self.listWidget.itemSelectionChanged.connect( self.changePage )
itemMoveEvents(self.listWidget).connect(self.itemMoveEvent)
self.comboBox.activated.connect( self.changePageType )
self.btnUp.clicked.connect( self.moveCurrentUp )
self.btnDown.clicked.connect( self.moveCurrentDown )
self.pre_move_row = -1
self.first_front_page = None
def resetPage( self ):
self.pageWidget.clear()
self.comboBox.setDisabled(True)
self.comic_archive = None
self.pages_list = None
def moveCurrentUp( self ):
row = self.listWidget.currentRow()
if row > 0:
item = self.listWidget.takeItem ( row )
self.listWidget.insertItem( row-1, item )
self.listWidget.setCurrentRow( row-1 )
self.listOrderChanged.emit()
self.emitFrontCoverChange()
self.modified.emit()
def moveCurrentDown( self ):
row = self.listWidget.currentRow()
if row < self.listWidget.count()-1:
item = self.listWidget.takeItem ( row )
self.listWidget.insertItem( row+1, item )
self.listWidget.setCurrentRow( row+1 )
self.listOrderChanged.emit()
self.emitFrontCoverChange()
self.modified.emit()
def itemMoveEvent(self, s):
#print "move event: ", s, self.listWidget.currentRow()
if s == "start":
self.pre_move_row = self.listWidget.currentRow()
if s == "finish":
if self.pre_move_row != self.listWidget.currentRow():
self.listOrderChanged.emit()
self.emitFrontCoverChange()
self.modified.emit()
def changePageType( self , i):
new_type = self.comboBox.itemData(i).toString()
if self.getCurrentPageType() != new_type:
self.setCurrentPageType( new_type )
self.emitFrontCoverChange()
self.modified.emit()
def changePage( self ):
row = self.listWidget.currentRow()
pagetype = self.getCurrentPageType()
i = self.comboBox.findData( pagetype )
self.comboBox.setCurrentIndex( i )
#idx = int(str (self.listWidget.item( row ).text()))
idx = int(self.listWidget.item( row ).data(Qt.UserRole).toPyObject()[0]['Image'])
if self.comic_archive is not None:
self.pageWidget.setArchive( self.comic_archive, idx )
def getFirstFrontCover( self ):
frontCover = 0
for i in range( self.listWidget.count() ):
item = self.listWidget.item( i )
page_dict = item.data(Qt.UserRole).toPyObject()[0]
if 'Type' in page_dict and page_dict['Type'] == PageType.FrontCover:
frontCover = int(page_dict['Image'])
break
return frontCover
def getCurrentPageType( self ):
row = self.listWidget.currentRow()
page_dict = self.listWidget.item( row ).data(Qt.UserRole).toPyObject()[0]
if 'Type' in page_dict:
return page_dict['Type']
else:
return ""
def setCurrentPageType( self, t ):
row = self.listWidget.currentRow()
page_dict = self.listWidget.item( row ).data(Qt.UserRole).toPyObject()[0]
if t == "":
if 'Type' in page_dict:
del(page_dict['Type'])
else:
page_dict['Type'] = str(t)
item = self.listWidget.item( row )
# wrap the dict in a tuple to keep from being converted to QStrings
item.setData(Qt.UserRole, (page_dict,) )
item.setText( self.listEntryText( page_dict ) )
def setData( self, comic_archive, pages_list ):
self.comic_archive = comic_archive
self.pages_list = pages_list
if pages_list is not None and len(pages_list) > 0:
self.comboBox.setDisabled(False)
self.listWidget.itemSelectionChanged.disconnect( self.changePage )
self.listWidget.clear()
for p in pages_list:
item = QListWidgetItem( self.listEntryText( p ) )
# wrap the dict in a tuple to keep from being converted to QStrings
item.setData(Qt.UserRole, (p, ))
self.listWidget.addItem( item )
self.first_front_page = self.getFirstFrontCover()
self.listWidget.itemSelectionChanged.connect( self.changePage )
self.listWidget.setCurrentRow ( 0 )
def listEntryText(self, page_dict):
text = str(int(page_dict['Image']) + 1)
if 'Type' in page_dict:
text += " (" + self.pageTypeNames[page_dict['Type']] + ")"
return text
def getPageList( self ):
page_list = []
for i in range( self.listWidget.count() ):
item = self.listWidget.item( i )
page_list.append( item.data(Qt.UserRole).toPyObject()[0] )
return page_list
def emitFrontCoverChange( self ):
if self.first_front_page != self.getFirstFrontCover():
self.first_front_page = self.getFirstFrontCover()
self.firstFrontCoverChanged.emit( self.first_front_page )
def setMetadataStyle( self, data_style ):
# depending on the current data style, certain fields are disabled
inactive_color = QColor(255, 170, 150)
active_palette = self.comboBox.palette()
inactive_palette3 = self.comboBox.palette()
inactive_palette3.setColor(QPalette.Base, inactive_color)
filt = Filter(widget)
widget.installEventFilter(filt)
return filt.mysignal
if data_style == MetaDataStyle.CIX:
self.btnUp.setEnabled( True )
self.btnDown.setEnabled( True )
self.comboBox.setEnabled( True )
self.listWidget.setEnabled( True )
self.listWidget.setPalette(active_palette)
elif data_style == MetaDataStyle.CBI:
self.btnUp.setEnabled( False )
self.btnDown.setEnabled( False )
self.comboBox.setEnabled( False )
self.listWidget.setEnabled( False )
class PageListEditor(QtWidgets.QWidget):
firstFrontCoverChanged = QtCore.pyqtSignal(int)
listOrderChanged = QtCore.pyqtSignal()
modified = QtCore.pyqtSignal()
self.listWidget.setPalette(inactive_palette3)
elif data_style == MetaDataStyle.CoMet:
pass
# make sure combo is disabled when no list
if self.comic_archive is None:
self.comboBox.setEnabled( False )
pageTypeNames = {
PageType.FrontCover: "Front Cover",
PageType.InnerCover: "Inner Cover",
PageType.Advertisement: "Advertisement",
PageType.Roundup: "Roundup",
PageType.Story: "Story",
PageType.Editorial: "Editorial",
PageType.Letters: "Letters",
PageType.Preview: "Preview",
PageType.BackCover: "Back Cover",
PageType.Other: "Other",
PageType.Deleted: "Deleted",
}
def __init__(self, parent: QtWidgets.QWidget) -> None:
super().__init__(parent)
uic.loadUi(ui_path / "pagelisteditor.ui", self)
self.pageWidget = CoverImageWidget(self.pageContainer, CoverImageWidget.ArchiveMode, None, None)
gridlayout = QtWidgets.QGridLayout(self.pageContainer)
gridlayout.addWidget(self.pageWidget)
gridlayout.setContentsMargins(0, 0, 0, 0)
self.pageWidget.showControls = False
self.reset_page()
# Add the entries to the page type combobox
self.add_page_type_item("", "", "Alt+0", False)
self.add_page_type_item(self.pageTypeNames[PageType.FrontCover], PageType.FrontCover, "Alt+F")
self.add_page_type_item(self.pageTypeNames[PageType.InnerCover], PageType.InnerCover, "Alt+I")
self.add_page_type_item(self.pageTypeNames[PageType.Advertisement], PageType.Advertisement, "Alt+A")
self.add_page_type_item(self.pageTypeNames[PageType.Roundup], PageType.Roundup, "Alt+R")
self.add_page_type_item(self.pageTypeNames[PageType.Story], PageType.Story, "Alt+S")
self.add_page_type_item(self.pageTypeNames[PageType.Editorial], PageType.Editorial, "Alt+E")
self.add_page_type_item(self.pageTypeNames[PageType.Letters], PageType.Letters, "Alt+L")
self.add_page_type_item(self.pageTypeNames[PageType.Preview], PageType.Preview, "Alt+P")
self.add_page_type_item(self.pageTypeNames[PageType.BackCover], PageType.BackCover, "Alt+B")
self.add_page_type_item(self.pageTypeNames[PageType.Other], PageType.Other, "Alt+O")
self.add_page_type_item(self.pageTypeNames[PageType.Deleted], PageType.Deleted, "Alt+X")
self.listWidget.itemSelectionChanged.connect(self.change_page)
item_move_events(self.listWidget).connect(self.item_move_event)
self.cbPageType.activated.connect(self.change_page_type)
self.chkDoublePage.clicked.connect(self.toggle_double_page)
self.leBookmark.editingFinished.connect(self.save_bookmark)
self.btnUp.clicked.connect(self.move_current_up)
self.btnDown.clicked.connect(self.move_current_down)
self.pre_move_row = -1
self.first_front_page: int | None = None
self.comic_archive: ComicArchive | None = None
self.pages_list: list[ImageMetadata] = []
def reset_page(self) -> None:
self.pageWidget.clear()
self.cbPageType.setDisabled(True)
self.chkDoublePage.setDisabled(True)
self.leBookmark.setDisabled(True)
self.comic_archive = None
self.pages_list = []
def add_page_type_item(self, text: str, user_data: str, shortcut: str, show_shortcut: bool = True) -> None:
if show_shortcut:
text = text + " (" + shortcut + ")"
self.cbPageType.addItem(text, user_data)
action_item = QtWidgets.QAction(shortcut, self)
action_item.triggered.connect(lambda: self.select_page_type_item(self.cbPageType.findData(user_data)))
action_item.setShortcut(shortcut)
self.addAction(action_item)
def select_page_type_item(self, idx: int) -> None:
if self.cbPageType.isEnabled():
self.cbPageType.setCurrentIndex(idx)
self.change_page_type(idx)
def get_new_indexes(self, movement: int) -> list[tuple[int, int]]:
selection = self.listWidget.selectionModel().selectedRows()
selection.sort(reverse=movement > 0)
new_indexes: list[int] = []
old_indexes: list[int] = []
for x in selection:
current = x.row()
old_indexes.append(current)
if 0 <= current + movement <= self.listWidget.count() - 1:
if len(new_indexes) < 1 or current + movement != new_indexes[-1]:
current += movement
new_indexes.append(current)
old_indexes.sort()
new_indexes.sort()
return list(zip(new_indexes, old_indexes))
def set_selection(self, indexes: list[tuple[int, int]]) -> list[tuple[int, int]]:
selection_ranges: list[tuple[int, int]] = []
first = 0
for i, sel in enumerate(indexes):
if i == 0:
first = sel[0]
continue
if sel[0] != indexes[i - 1][0] + 1:
selection_ranges.append((first, indexes[i - 1][0]))
first = sel[0]
selection_ranges.append((first, indexes[-1][0]))
selection = QtCore.QItemSelection()
for x in selection_ranges:
selection.merge(
QtCore.QItemSelection(self.listWidget.model().index(x[0], 0), self.listWidget.model().index(x[1], 0)),
QtCore.QItemSelectionModel.SelectionFlag.Select,
)
self.listWidget.selectionModel().select(selection, QtCore.QItemSelectionModel.SelectionFlag.ClearAndSelect)
return selection_ranges
def move_current_up(self) -> None:
row = self.listWidget.currentRow()
selection = self.get_new_indexes(-1)
for sel in selection:
item = self.listWidget.takeItem(sel[1])
self.listWidget.insertItem(sel[0], item)
if row > 0:
self.listWidget.setCurrentRow(row - 1)
self.set_selection(selection)
self.listOrderChanged.emit()
self.emit_front_cover_change()
self.modified.emit()
def move_current_down(self) -> None:
row = self.listWidget.currentRow()
selection = self.get_new_indexes(1)
selection.sort(reverse=True)
for sel in selection:
item = self.listWidget.takeItem(sel[1])
self.listWidget.insertItem(sel[0], item)
if row < self.listWidget.count() - 1:
self.listWidget.setCurrentRow(row + 1)
self.listOrderChanged.emit()
self.emit_front_cover_change()
self.set_selection(selection)
self.modified.emit()
def item_move_event(self, s: str) -> None:
if s == "start":
self.pre_move_row = self.listWidget.currentRow()
if s == "finish":
if self.pre_move_row != self.listWidget.currentRow():
self.listOrderChanged.emit()
self.emit_front_cover_change()
self.modified.emit()
def change_page_type(self, i: int) -> None:
new_type = self.cbPageType.itemData(i)
if self.get_current_page_type() != new_type:
self.set_current_page_type(new_type)
self.emit_front_cover_change()
self.modified.emit()
def change_page(self) -> None:
row = self.listWidget.currentRow()
pagetype = self.get_current_page_type()
i = self.cbPageType.findData(pagetype)
self.cbPageType.setCurrentIndex(i)
self.chkDoublePage.setChecked("DoublePage" in self.listWidget.item(row).data(QtCore.Qt.UserRole)[0])
if "Bookmark" in self.listWidget.item(row).data(QtCore.Qt.UserRole)[0]:
self.leBookmark.setText(self.listWidget.item(row).data(QtCore.Qt.UserRole)[0]["Bookmark"])
else:
self.leBookmark.setText("")
idx = int(self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)[0]["Image"])
if self.comic_archive is not None:
self.pageWidget.set_archive(self.comic_archive, idx)
def get_first_front_cover(self) -> int:
front_cover = 0
for i in range(self.listWidget.count()):
item = self.listWidget.item(i)
page_dict: ImageMetadata = item.data(QtCore.Qt.ItemDataRole.UserRole)[0]
if "Type" in page_dict and page_dict["Type"] == PageType.FrontCover:
front_cover = int(page_dict["Image"])
break
return front_cover
def get_current_page_type(self) -> str:
row = self.listWidget.currentRow()
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)[0]
if "Type" in page_dict:
return page_dict["Type"]
return ""
def set_current_page_type(self, t: str) -> None:
row = self.listWidget.currentRow()
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.ItemDataRole.UserRole)[0]
if t == "":
if "Type" in page_dict:
del page_dict["Type"]
else:
page_dict["Type"] = t
item = self.listWidget.item(row)
# wrap the dict in a tuple to keep from being converted to QtWidgets.QStrings
item.setData(QtCore.Qt.ItemDataRole.UserRole, (page_dict,))
item.setText(self.list_entry_text(page_dict))
def toggle_double_page(self) -> None:
row = self.listWidget.currentRow()
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.UserRole)[0]
cbx = self.sender()
if isinstance(cbx, QtWidgets.QCheckBox) and cbx.isChecked():
if "DoublePage" not in page_dict:
page_dict["DoublePage"] = True
self.modified.emit()
elif "DoublePage" in page_dict:
del page_dict["DoublePage"]
self.modified.emit()
item = self.listWidget.item(row)
# wrap the dict in a tuple to keep from being converted to QStrings
item.setData(QtCore.Qt.UserRole, (page_dict,))
item.setText(self.list_entry_text(page_dict))
self.listWidget.setFocus()
def save_bookmark(self) -> None:
row = self.listWidget.currentRow()
page_dict: ImageMetadata = self.listWidget.item(row).data(QtCore.Qt.UserRole)[0]
current_bookmark = ""
if "Bookmark" in page_dict:
current_bookmark = page_dict["Bookmark"]
if self.leBookmark.text().strip():
new_bookmark = str(self.leBookmark.text().strip())
if current_bookmark != new_bookmark:
page_dict["Bookmark"] = new_bookmark
self.modified.emit()
elif current_bookmark != "":
del page_dict["Bookmark"]
self.modified.emit()
item = self.listWidget.item(row)
# wrap the dict in a tuple to keep from being converted to QStrings
item.setData(QtCore.Qt.UserRole, (page_dict,))
item.setText(self.list_entry_text(page_dict))
self.listWidget.setFocus()
def set_data(self, comic_archive: ComicArchive, pages_list: list[ImageMetadata]) -> None:
self.comic_archive = comic_archive
self.pages_list = pages_list
if pages_list is not None and len(pages_list) > 0:
self.cbPageType.setDisabled(False)
self.chkDoublePage.setDisabled(False)
self.leBookmark.setDisabled(False)
self.listWidget.itemSelectionChanged.disconnect(self.change_page)
self.listWidget.clear()
for p in pages_list:
item = QtWidgets.QListWidgetItem(self.list_entry_text(p))
# wrap the dict in a tuple to keep from being converted to QtWidgets.QStrings
item.setData(QtCore.Qt.ItemDataRole.UserRole, (p,))
self.listWidget.addItem(item)
self.first_front_page = self.get_first_front_cover()
self.listWidget.itemSelectionChanged.connect(self.change_page)
self.listWidget.setCurrentRow(0)
def list_entry_text(self, page_dict: ImageMetadata) -> str:
text = str(int(page_dict["Image"]) + 1)
if "Type" in page_dict:
if page_dict["Type"] in self.pageTypeNames:
text += " (" + self.pageTypeNames[page_dict["Type"]] + ")"
else:
text += " (Error: " + page_dict["Type"] + ")"
if "DoublePage" in page_dict:
text += ""
if "Bookmark" in page_dict:
text += " 🔖"
return text
def get_page_list(self) -> list[ImageMetadata]:
page_list = []
for i in range(self.listWidget.count()):
item = self.listWidget.item(i)
page_list.append(item.data(QtCore.Qt.ItemDataRole.UserRole)[0])
return page_list
def emit_front_cover_change(self) -> None:
if self.first_front_page != self.get_first_front_cover():
self.first_front_page = self.get_first_front_cover()
self.firstFrontCoverChanged.emit(self.first_front_page)
def set_metadata_style(self, data_style: int) -> None:
# depending on the current data style, certain fields are disabled
inactive_color = QtGui.QColor(255, 170, 150)
active_palette = self.cbPageType.palette()
inactive_palette3 = self.cbPageType.palette()
inactive_palette3.setColor(QtGui.QPalette.ColorRole.Base, inactive_color)
if data_style == MetaDataStyle.CIX:
self.btnUp.setEnabled(True)
self.btnDown.setEnabled(True)
self.cbPageType.setEnabled(True)
self.chkDoublePage.setEnabled(True)
self.leBookmark.setEnabled(True)
self.listWidget.setEnabled(True)
self.leBookmark.setPalette(active_palette)
self.listWidget.setPalette(active_palette)
elif data_style == MetaDataStyle.CBI:
self.btnUp.setEnabled(False)
self.btnDown.setEnabled(False)
self.cbPageType.setEnabled(False)
self.chkDoublePage.setEnabled(False)
self.leBookmark.setEnabled(False)
self.listWidget.setEnabled(False)
self.leBookmark.setPalette(inactive_palette3)
self.listWidget.setPalette(inactive_palette3)
elif data_style == MetaDataStyle.COMET:
pass
# make sure combo is disabled when no list
if self.comic_archive is None:
self.cbPageType.setEnabled(False)
self.chkDoublePage.setEnabled(False)
self.leBookmark.setEnabled(False)

View File

@ -1,77 +1,68 @@
"""
A PyQT4 class to load a page image from a ComicArchive in a background thread
"""
"""A PyQT4 class to load a page image from a ComicArchive in a background thread"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
import logging
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
from PyQt5 import QtCore
http://www.apache.org/licenses/LICENSE-2.0
from comicapi.comicarchive import ComicArchive
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.
"""
logger = logging.getLogger(__name__)
from PyQt4 import QtCore, QtGui, uic
from PyQt4.QtCore import pyqtSignal
from comicarchive import ComicArchive
class PageLoader(QtCore.QThread):
"""
This class holds onto a reference of each instance in a list since
problems occur if the ref count goes to zero and the GC tries to reap
the object while the thread is going.
If the client class wants to stop the thread, they should mark it as
"abandoned", and no signals will be issued.
"""
"""
This class holds onto a reference of each instance in a list
since problems occur if the ref count goes to zero and the GC
tries to reap the object while the thread is going.
loadComplete = QtCore.pyqtSignal(bytes)
If the client class wants to stop the thread, they should mark
it as "abandoned", and no signals will be issued
"""
class PageLoader( QtCore.QThread ):
instanceList: list[QtCore.QThread] = []
mutex = QtCore.QMutex()
loadComplete = pyqtSignal( QtGui.QImage )
instanceList = []
mutex = QtCore.QMutex()
# Remove all finished threads from the list
@staticmethod
def reap_instances() -> None:
for obj in reversed(PageLoader.instanceList):
if obj.isFinished():
PageLoader.instanceList.remove(obj)
"""
Remove all finished threads from the list
"""
@staticmethod
def reapInstances():
for obj in reversed(PageLoader.instanceList ):
if obj.isFinished():
PageLoader.instanceList.remove(obj)
def __init__(self, ca: ComicArchive, page_num: int) -> None:
QtCore.QThread.__init__(self)
self.ca: ComicArchive = ca
self.page_num: int = page_num
self.abandoned = False
def __init__(self, ca, page_num ):
QtCore.QThread.__init__(self)
self.ca = ca
self.page_num = page_num
self.abandoned = False
# remove any old instances, and then add ourself
PageLoader.mutex.lock()
PageLoader.reap_instances()
PageLoader.instanceList.append(self)
PageLoader.mutex.unlock()
# remove any old instances, and then add ourself
PageLoader.mutex.lock()
PageLoader.reapInstances()
PageLoader.instanceList.append( self )
PageLoader.mutex.unlock()
def run(self):
image_data = self.ca.getPage( self.page_num )
if self.abandoned:
return
def run(self) -> None:
image_data = self.ca.get_page(self.page_num)
if self.abandoned:
return
if image_data is not None:
img = QtGui.QImage()
img.loadFromData( image_data )
if self.abandoned:
return
self.loadComplete.emit( img )
if image_data:
if self.abandoned:
return
self.loadComplete.emit(image_data)

View File

@ -1,42 +1,42 @@
"""
A PyQT4 dialog to show ID log and progress
"""
"""A PyQt5 dialog to show ID log and progress"""
#
# Copyright 2012-2014 ComicTagger Authors
#
# 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.
from __future__ import annotations
"""
Copyright 2012-2014 Anthony Beville
import logging
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
from PyQt5 import QtCore, QtWidgets, uic
http://www.apache.org/licenses/LICENSE-2.0
from comictaggerlib.ui import ui_path
from comictaggerlib.ui.qtutils import reduce_widget_font_size
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
import sys
from PyQt4 import QtCore, QtGui, uic
import os
from settings import ComicTaggerSettings
import utils
class IDProgressWindow(QtGui.QDialog):
def __init__(self, parent):
super(IDProgressWindow, self).__init__(parent)
uic.loadUi(ComicTaggerSettings.getUIFile('progresswindow.ui' ), self)
self.setWindowFlags(self.windowFlags() |
QtCore.Qt.WindowSystemMenuHint |
QtCore.Qt.WindowMaximizeButtonHint)
utils.reduceWidgetFontSize( self.textEdit )
logger = logging.getLogger(__name__)
class IDProgressWindow(QtWidgets.QDialog):
def __init__(self, parent: QtWidgets.QWidget) -> None:
super().__init__(parent)
uic.loadUi(ui_path / "progresswindow.ui", self)
self.setWindowFlags(
QtCore.Qt.WindowType(
self.windowFlags()
| QtCore.Qt.WindowType.WindowSystemMenuHint
| QtCore.Qt.WindowType.WindowMaximizeButtonHint
)
)
reduce_widget_font_size(self.textEdit)

Some files were not shown because too many files have changed in this diff Show More