Compare commits
326 Commits
1.0.2-beta
...
1.1.16-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0484d05462 | ||
|
|
0766bf7064 | ||
|
|
7e8fc143fd | ||
|
|
43a913294e | ||
|
|
70e28c7863 | ||
|
|
14713d8ad0 | ||
|
|
4ff2061568 | ||
|
|
08c402149b | ||
|
|
184dbf0684 | ||
|
|
ed0050ba05 | ||
|
|
68030a1024 | ||
|
|
983ad1fcf4 | ||
|
|
d959ac0401 | ||
|
|
2a550db02a | ||
|
|
6369fa5fda | ||
|
|
d5a13a4206 | ||
|
|
b2532ce03a | ||
|
|
79a67d8c29 | ||
|
|
d9bd38674c | ||
|
|
a0154aaaae | ||
|
|
17f74cf296 | ||
|
|
3f112cd578 | ||
|
|
f6439049d8 | ||
|
|
2fe818872c | ||
|
|
a419969b85 | ||
|
|
ee52448f17 | ||
|
|
79103990fa | ||
|
|
22dbafbc00 | ||
|
|
0df283778c | ||
|
|
a6282b5449 | ||
|
|
5574280ad6 | ||
|
|
19b907b742 | ||
|
|
a9ff8f37b0 | ||
|
|
0769111f8c | ||
|
|
cf6ae8b5ae | ||
|
|
1d6846ced3 | ||
|
|
d516d80093 | ||
|
|
bf9ab71fd9 | ||
|
|
33b00ad323 | ||
|
|
301ff084f1 | ||
|
|
0c146bb245 | ||
|
|
08cc4a1acb | ||
|
|
f97a1653d9 | ||
|
|
d9dbab301a | ||
|
|
3d93197101 | ||
|
|
752a1d8923 | ||
|
|
68002daffa | ||
|
|
ad5062c582 | ||
|
|
2680468f34 | ||
|
|
6156fc296a | ||
|
|
0feed294d4 | ||
|
|
e57736b955 | ||
|
|
70fcdc0129 | ||
|
|
9a64195ebd | ||
|
|
b0f229f851 | ||
|
|
877a5ccd85 | ||
|
|
c0f2e2f771 | ||
|
|
0adfc9beb3 | ||
|
|
d0bc41d7ee | ||
|
|
fa46a065a4 | ||
|
|
8fcd5ba7d6 | ||
|
|
759cdc6b40 | ||
|
|
1405d9ff0e | ||
|
|
d8fcbbad0a | ||
|
|
3eca25db34 | ||
|
|
c8a5a89369 | ||
|
|
ff578ea819 | ||
|
|
1c730c25d5 | ||
|
|
35b7b39b86 | ||
|
|
719c711484 | ||
|
|
afbbc9d00c | ||
|
|
b8e0a45fc8 | ||
|
|
b7360dd33e | ||
|
|
d9f1956426 | ||
|
|
b5c7f36410 | ||
|
|
0b0663d935 | ||
|
|
eee1f65436 | ||
|
|
9a8d4149f2 | ||
|
|
b02a205668 | ||
|
|
57284dfbed | ||
|
|
afcbde7fc6 | ||
|
|
151fac5bf1 | ||
|
|
57c1efdab9 | ||
|
|
6b272cef87 | ||
|
|
1cdc732739 | ||
|
|
d1b00d162d | ||
|
|
3dd3980bc1 | ||
|
|
cbf475eb26 | ||
|
|
ac8b575659 | ||
|
|
ac8ef286a4 | ||
|
|
f567dc37be | ||
|
|
15c5fc5258 | ||
|
|
cc985b52a5 | ||
|
|
910b0386be | ||
|
|
0fece23405 | ||
|
|
eee320e0c7 | ||
|
|
accabf8e21 | ||
|
|
acc253d35c | ||
|
|
ede0154efe | ||
|
|
5b805b1428 | ||
|
|
2e6b2a89db | ||
|
|
c028bb4ddc | ||
|
|
b70beb5684 | ||
|
|
128af4521b | ||
|
|
43cf7a80c8 | ||
|
|
3223ed190c | ||
|
|
9e2817c037 | ||
|
|
6e7bd10fb9 | ||
|
|
c099205779 | ||
|
|
47d8da0e80 | ||
|
|
0f7e88e58c | ||
|
|
65902a15b1 | ||
|
|
a68b2babeb | ||
|
|
4098802e43 | ||
|
|
9c14258e9f | ||
|
|
33bdbe8be8 | ||
|
|
a76864c109 | ||
|
|
cb68d07751 | ||
|
|
8e9fccdbbc | ||
|
|
39990fc2b4 | ||
|
|
e8c315d834 | ||
|
|
f8a06a8746 | ||
|
|
9415087da7 | ||
|
|
9aee5c32eb | ||
|
|
fcdb4a3889 | ||
|
|
534a326258 | ||
|
|
0390ff5919 | ||
|
|
b800ae1751 | ||
|
|
a2c17982d3 | ||
|
|
0347befae6 | ||
|
|
af54b79790 | ||
|
|
dd04ae98a0 | ||
|
|
31b76fba92 | ||
|
|
9f4a4b0eb0 | ||
|
|
575a23c6bf | ||
|
|
5d84f09359 | ||
|
|
3072583482 | ||
|
|
8d867cf78a | ||
|
|
36c79b5a2a | ||
|
|
dfdaf731b4 | ||
|
|
67bff8586c | ||
|
|
9e4cbea6e4 | ||
|
|
d150b2ce54 | ||
|
|
a20949cc4d | ||
|
|
e3fceb20a2 | ||
|
|
f4e00d9ef3 | ||
|
|
1980bd5988 | ||
|
|
db54affc74 | ||
|
|
0edb9444ef | ||
|
|
b22c25f53f | ||
|
|
76e6666a79 | ||
|
|
a804a10e0e | ||
|
|
fe413b12c1 | ||
|
|
e38dc2f063 | ||
|
|
5e5418090b | ||
|
|
56c1f8582a | ||
|
|
00f8c0a280 | ||
|
|
1d915eb155 | ||
|
|
b7b8060ef2 | ||
|
|
2d190b076a | ||
|
|
cd92b1afea | ||
|
|
4d21a001d6 | ||
|
|
4af59d2315 | ||
|
|
c9c98b6c11 | ||
|
|
1ff43db2ce | ||
|
|
822f6b4729 | ||
|
|
44a8dc6815 | ||
|
|
a35576895c | ||
|
|
631662b30c | ||
|
|
cbe3f5a2dc | ||
|
|
73f8bd426b | ||
|
|
0642604480 | ||
|
|
1d95f5076e | ||
|
|
53b0c2e8f9 | ||
|
|
f59f5fe981 | ||
|
|
67545d8a13 | ||
|
|
ab3e3b40c4 | ||
|
|
188024c2db | ||
|
|
324b56a623 | ||
|
|
782d424392 | ||
|
|
cf63bfda9d | ||
|
|
903d4c647c | ||
|
|
407b83fe90 | ||
|
|
27edc80d2b | ||
|
|
01f48f8b91 | ||
|
|
527e690170 | ||
|
|
d100572aa4 | ||
|
|
42640c4ad5 | ||
|
|
a61972e503 | ||
|
|
464e147223 | ||
|
|
8759784561 | ||
|
|
ee5b4a689e | ||
|
|
71ccf1eea8 | ||
|
|
a9ee7c463b | ||
|
|
6f683a71c7 | ||
|
|
24b192b22c | ||
|
|
b6b1a4737f | ||
|
|
00202cc865 | ||
|
|
235524b06d | ||
|
|
8a7f822970 | ||
|
|
ff3f048bb4 | ||
|
|
abda202f32 | ||
|
|
2d4ac84de0 | ||
|
|
86732e7827 | ||
|
|
693b5b1978 | ||
|
|
e3d3ecfd31 | ||
|
|
ce6b81ab73 | ||
|
|
501365b5a3 | ||
|
|
c6741d4392 | ||
|
|
42feae53dd | ||
|
|
c65695b8dc | ||
|
|
4da71e262b | ||
|
|
c519fd33d5 | ||
|
|
07ef0211b9 | ||
|
|
c45b56a5b6 | ||
|
|
6f27fc7669 | ||
|
|
4530ac017c | ||
|
|
400fe6efa3 | ||
|
|
ac7a12d18d | ||
|
|
c2ff11fab7 | ||
|
|
34019ff338 | ||
|
|
176bc43888 | ||
|
|
2e290c4c74 | ||
|
|
74a374d46b | ||
|
|
58f5f10c78 | ||
|
|
7d8ed954a9 | ||
|
|
078b3cef3c | ||
|
|
22ef0250ca | ||
|
|
cc53162dcc | ||
|
|
fa309cfcef | ||
|
|
4d57b0cf79 | ||
|
|
6ea5d28609 | ||
|
|
9d56a2ce9a | ||
|
|
811759478a | ||
|
|
28e2d93314 | ||
|
|
93b3117699 | ||
|
|
10e6a1019e | ||
|
|
2024555780 | ||
|
|
e15c3fa3e6 | ||
|
|
8aa6403f51 | ||
|
|
fb5fca1dc4 | ||
|
|
75d5b1a695 | ||
|
|
e56d9bddbf | ||
|
|
7d9aa70dc0 | ||
|
|
6d72ed2a69 | ||
|
|
9b584f78a0 | ||
|
|
dfe0e74f9c | ||
|
|
a11c08a2ee | ||
|
|
9159204883 | ||
|
|
605e27ce99 | ||
|
|
2dc08b36ea | ||
|
|
60dae4f1fb | ||
|
|
85728d33bb | ||
|
|
2ade08aa89 | ||
|
|
50909962d3 | ||
|
|
cc02023730 | ||
|
|
5bdc40b9f5 | ||
|
|
4f3e63db07 | ||
|
|
b8893b853f | ||
|
|
6da6f38673 | ||
|
|
369dcbb5a1 | ||
|
|
ec010f29e8 | ||
|
|
22867bc9e6 | ||
|
|
dde1913e07 | ||
|
|
5b5842a5f8 | ||
|
|
fbf086886f | ||
|
|
c1ff6c4b26 | ||
|
|
99b110d052 | ||
|
|
3df498eed4 | ||
|
|
b5ab2a6ac9 | ||
|
|
5c91960f04 | ||
|
|
3b52fd3213 | ||
|
|
9366457b88 | ||
|
|
1cb7ef66db | ||
|
|
ee6a05deae | ||
|
|
c978883584 | ||
|
|
9b5508ecba | ||
|
|
8e1c6fae7c | ||
|
|
59e662f5a7 | ||
|
|
6486d97ee3 | ||
|
|
8c088440c5 | ||
|
|
320ee1c5d1 | ||
|
|
e123720354 | ||
|
|
d39d4e79ad | ||
|
|
8d7eeece30 | ||
|
|
3b64e1a3ec | ||
|
|
81ae9bd635 | ||
|
|
27846772e9 | ||
|
|
baf697b919 | ||
|
|
59ede8d446 | ||
|
|
8b748a3343 | ||
|
|
75471aaddc | ||
|
|
7225f261f1 | ||
|
|
c466264d43 | ||
|
|
14e801b717 | ||
|
|
af4b467814 | ||
|
|
1b3feaa167 | ||
|
|
2526fa0ca8 | ||
|
|
a878d36dcf | ||
|
|
90de6433b6 | ||
|
|
d9abc364f1 | ||
|
|
e542b6df1f | ||
|
|
a7a6b085f1 | ||
|
|
0078f76e8c | ||
|
|
1a01cb60d9 | ||
|
|
0f81ce4c24 | ||
|
|
c4ef4137d0 | ||
|
|
cdc6d71356 | ||
|
|
2357a6378e | ||
|
|
9503d0fef4 | ||
|
|
c46dda4540 | ||
|
|
894c23f64f | ||
|
|
9360fa954c | ||
|
|
74408e56fd | ||
|
|
dd8e54fa6b | ||
|
|
b378840878 | ||
|
|
c0a6406dc9 | ||
|
|
df3544e734 | ||
|
|
d40de5b67e | ||
|
|
25b63dfc65 | ||
|
|
70f50c8595 | ||
|
|
e9aba4e119 | ||
|
|
53aca0ee08 | ||
|
|
9aa41823b4 | ||
|
|
8d4a336b50 | ||
|
|
3c96e68fde |
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/.idea/
|
||||
/nbproject/
|
||||
*.pyc
|
||||
4
MANIFEST.in
Normal file
@@ -0,0 +1,4 @@
|
||||
include README.txt
|
||||
include release_notes.txt
|
||||
include requirements.txt
|
||||
recursive-include scripts *.py *.txt
|
||||
64
Makefile
@@ -1,25 +1,61 @@
|
||||
TAGGER_BASE := $(HOME)/Dropbox/tagger/comictagger
|
||||
VERSION_STR := $(shell grep version $(TAGGER_BASE)/ctversion.py| cut -d= -f2 | sed 's/\"//g')
|
||||
|
||||
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
|
||||
|
||||
clean:
|
||||
rm -f *~ *.pyc *.pyo
|
||||
rm -f logdict*.log
|
||||
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
|
||||
|
||||
zip:
|
||||
cd release; \
|
||||
rm -rf *zip comictagger-src-$(VERSION_STR) ; \
|
||||
svn checkout https://comictagger.googlecode.com/svn/trunk/ comictagger-src-$(VERSION_STR); \
|
||||
zip -r comictagger-src-$(VERSION_STR).zip comictagger-src-$(VERSION_STR); \
|
||||
rm -rf comictagger-src-$(VERSION_STR)
|
||||
|
||||
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
|
||||
|
||||
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)"
|
||||
|
||||
https://comictagger.googlecode.com/svn/tags/$(VERSION_STR) -m "Release $(VERSION_STR)"
|
||||
|
||||
|
||||
53
README.md
Normal file
@@ -0,0 +1,53 @@
|
||||
This is a fork derived from google code:
|
||||
|
||||
https://code.google.com/p/comictagger/
|
||||
|
||||
|
||||
Changes in this fork:
|
||||
- using different unrar library https://pypi.python.org/pypi/unrar/. The previous one used unrar.dll on windows and
|
||||
hackish wrapping of unrar command on linux, while this new one should use unrarlib on both platforms. From my tests
|
||||
it is more stable and faster. *Requires unrarlib availability, check unrar module documentation for more
|
||||
information*.
|
||||
- extracted core libraries in its own package comicapi, shared in a new repository using git subtree for better
|
||||
alignment with comicstreamer
|
||||
- support for *day of month* field in the GUI
|
||||
- merge of changes from fcanc fork
|
||||
|
||||
Todo:
|
||||
- more tests in non-linux platforms
|
||||
- repackage for simple user installation
|
||||
|
||||
Follows original readme:
|
||||
|
||||
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 recursively scrape and tag all archives in a folder
|
||||
comictagger.py -R -s -o -f -t cr -v -i --nooverwrite /path/to/comics/
|
||||
|
||||
For details, screen-shots, 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!
|
||||
@@ -1,202 +0,0 @@
|
||||
"""
|
||||
A PyQT4 dialog to select from automated issue matches
|
||||
"""
|
||||
|
||||
"""
|
||||
Copyright 2012 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 os
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
|
||||
from PyQt4.QtCore import QUrl, pyqtSignal, QByteArray
|
||||
|
||||
from imagefetcher import ImageFetcher
|
||||
from settings import ComicTaggerSettings
|
||||
|
||||
class AutoTagMatchWindow(QtGui.QDialog):
|
||||
|
||||
volume_id = 0
|
||||
|
||||
def __init__(self, parent, match_set_list, style, fetch_func):
|
||||
super(AutoTagMatchWindow, self).__init__(parent)
|
||||
|
||||
uic.loadUi(os.path.join(ComicTaggerSettings.baseDir(), 'autotagmatchwindow.ui' ), self)
|
||||
|
||||
self.skipButton = QtGui.QPushButton(self.tr("Skip"))
|
||||
self.buttonBox.addButton(self.skipButton, QtGui.QDialogButtonBox.ActionRole)
|
||||
self.buttonBox.button(QtGui.QDialogButtonBox.Ok).setText("Accept and Next")
|
||||
|
||||
self.match_set_list = match_set_list
|
||||
self.style = style
|
||||
self.fetch_func = fetch_func
|
||||
|
||||
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()
|
||||
|
||||
def updateData( self):
|
||||
|
||||
self.current_match_set = self.match_set_list[ self.current_match_set_idx ]
|
||||
|
||||
|
||||
if self.current_match_set_idx + 1 == len( self.match_set_list ):
|
||||
self.skipButton.setDisabled(True)
|
||||
|
||||
self.setCoverImage()
|
||||
self.populateTable()
|
||||
self.twList.resizeColumnsToContents()
|
||||
self.current_row = 0
|
||||
self.twList.selectRow( 0 )
|
||||
|
||||
path = self.current_match_set.ca.path
|
||||
self.setWindowTitle( "Select correct match ({0} of {1}): {2}".format(
|
||||
self.current_match_set_idx+1,
|
||||
len( self.match_set_list ),
|
||||
os.path.split(path)[1] ))
|
||||
|
||||
def populateTable( self ):
|
||||
|
||||
while self.twList.rowCount() > 0:
|
||||
self.twList.removeRow(0)
|
||||
|
||||
self.twList.setSortingEnabled(False)
|
||||
|
||||
row = 0
|
||||
for match in self.current_match_set.matches:
|
||||
self.twList.insertRow(row)
|
||||
|
||||
item_text = match['series']
|
||||
item = QtGui.QTableWidgetItem(item_text)
|
||||
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
|
||||
self.twList.setItem(row, 0, item)
|
||||
|
||||
if match['publisher'] is not None:
|
||||
item_text = u"{0}".format(match['publisher'])
|
||||
else:
|
||||
item_text = u"Unknown"
|
||||
item = QtGui.QTableWidgetItem(item_text)
|
||||
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
|
||||
self.twList.setItem(row, 1, item)
|
||||
|
||||
item_text = ""
|
||||
if match['month'] is not None:
|
||||
item_text = u"{0}/".format(match['month'])
|
||||
if match['year'] is not None:
|
||||
item_text += u"{0}".format(match['year'])
|
||||
else:
|
||||
item_text += u"????"
|
||||
item = QtGui.QTableWidgetItem(item_text)
|
||||
item.setFlags(QtCore.Qt.ItemIsSelectable| QtCore.Qt.ItemIsEnabled)
|
||||
self.twList.setItem(row, 2, item)
|
||||
|
||||
row += 1
|
||||
|
||||
|
||||
def cellDoubleClicked( self, r, c ):
|
||||
self.accept()
|
||||
|
||||
def currentItemChanged( self, curr, prev ):
|
||||
|
||||
if curr is None:
|
||||
return
|
||||
if prev is not None and prev.row() == curr.row():
|
||||
return
|
||||
|
||||
self.current_row = curr.row()
|
||||
|
||||
# list selection was changed, update the the issue cover
|
||||
self.labelThumbnail.setPixmap(QtGui.QPixmap(os.path.join(ComicTaggerSettings.baseDir(), 'graphics/nocover.png' )))
|
||||
|
||||
self.cover_fetcher = ImageFetcher( )
|
||||
self.cover_fetcher.fetchComplete.connect(self.coverFetchComplete)
|
||||
self.cover_fetcher.fetch( self.current_match_set.matches[self.current_row]['img_url'] )
|
||||
|
||||
# called when the image is done loading
|
||||
def coverFetchComplete( self, image_data, issue_id ):
|
||||
img = QtGui.QImage()
|
||||
img.loadFromData( image_data )
|
||||
self.labelThumbnail.setPixmap(QtGui.QPixmap(img))
|
||||
|
||||
def setCoverImage( self ):
|
||||
ca = self.current_match_set.ca
|
||||
cover_idx = ca.readMetadata(self.style).getCoverPageIndexList()[0]
|
||||
image_data = ca.getPage( cover_idx )
|
||||
self.labelCover.setScaledContents(True)
|
||||
if image_data is not None:
|
||||
img = QtGui.QImage()
|
||||
img.loadFromData( image_data )
|
||||
self.labelCover.setPixmap(QtGui.QPixmap(img))
|
||||
else:
|
||||
self.labelCover.setPixmap(QtGui.QPixmap(os.path.join(ComicTaggerSettings.baseDir(), 'graphics/nocover.png' )))
|
||||
|
||||
def accept(self):
|
||||
|
||||
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 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
|
||||
|
||||
QtGui.QDialog.reject(self)
|
||||
|
||||
def saveMatch( self ):
|
||||
|
||||
match = self.current_match_set.matches[self.current_row]
|
||||
ca = self.current_match_set.ca
|
||||
|
||||
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
|
||||
|
||||
QtGui.QApplication.setOverrideCursor(QtGui.QCursor(QtCore.Qt.WaitCursor))
|
||||
md.overlay( cv_md )
|
||||
success = ca.writeMetadata( md, self.style )
|
||||
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!"))
|
||||
@@ -1,161 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>dialogMatchSelect</class>
|
||||
<widget class="QDialog" name="dialogMatchSelect">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>831</width>
|
||||
<height>506</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Select Match</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="1">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="labelCover">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>300</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>TextLabel</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QTableWidget" name="twList">
|
||||
<property name="font">
|
||||
<font>
|
||||
<pointsize>9</pointsize>
|
||||
</font>
|
||||
</property>
|
||||
<property name="selectionMode">
|
||||
<enum>QAbstractItemView::SingleSelection</enum>
|
||||
</property>
|
||||
<property name="selectionBehavior">
|
||||
<enum>QAbstractItemView::SelectRows</enum>
|
||||
</property>
|
||||
<property name="rowCount">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="columnCount">
|
||||
<number>3</number>
|
||||
</property>
|
||||
<attribute name="horizontalHeaderStretchLastSection">
|
||||
<bool>true</bool>
|
||||
</attribute>
|
||||
<attribute name="verticalHeaderVisible">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Series</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Publisher</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Date</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="labelThumbnail">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>300</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::Panel</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Sunken</enum>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="scaledContents">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QDialogButtonBox" name="buttonBox">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>accepted()</signal>
|
||||
<receiver>dialogMatchSelect</receiver>
|
||||
<slot>accept()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>248</x>
|
||||
<y>254</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>157</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>rejected()</signal>
|
||||
<receiver>dialogMatchSelect</receiver>
|
||||
<slot>reject()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>316</x>
|
||||
<y>260</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>286</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
@@ -1,67 +0,0 @@
|
||||
"""
|
||||
A PyQT4 dialog to show ID log and progress
|
||||
"""
|
||||
|
||||
"""
|
||||
Copyright 2012 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
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
import os
|
||||
from settings import ComicTaggerSettings
|
||||
|
||||
|
||||
class AutoTagProgressWindow(QtGui.QDialog):
|
||||
|
||||
|
||||
def __init__(self, parent):
|
||||
super(AutoTagProgressWindow, self).__init__(parent)
|
||||
|
||||
uic.loadUi(os.path.join(ComicTaggerSettings.baseDir(), 'autotagprogresswindow.ui' ), self)
|
||||
self.lblTest.setPixmap(QtGui.QPixmap(os.path.join(ComicTaggerSettings.baseDir(), 'graphics/nocover.png' )))
|
||||
self.lblArchive.setPixmap(QtGui.QPixmap(os.path.join(ComicTaggerSettings.baseDir(), 'graphics/nocover.png' )))
|
||||
self.isdone = False
|
||||
|
||||
# we can't specify relative font sizes in the UI designer, so
|
||||
# make font for scroll window a smidge smaller
|
||||
f = self.textEdit.font()
|
||||
if f.pointSize() > 10:
|
||||
f.setPointSize( f.pointSize() - 2 )
|
||||
self.textEdit.setFont( f )
|
||||
|
||||
def setArchiveImage( self, img_data):
|
||||
self.setCoverImage( img_data, self.lblArchive )
|
||||
|
||||
def setTestImage( self, img_data):
|
||||
self.setCoverImage( img_data, self.lblTest )
|
||||
|
||||
def setCoverImage( self, img_data , label):
|
||||
if img_data is not None:
|
||||
img = QtGui.QImage()
|
||||
img.loadFromData( img_data )
|
||||
label.setPixmap(QtGui.QPixmap(img))
|
||||
label.setScaledContents(True)
|
||||
else:
|
||||
label.setPixmap(QtGui.QPixmap(os.path.join(ComicTaggerSettings.baseDir(), 'graphics/nocover.png' )))
|
||||
label.setScaledContents(True)
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
|
||||
def reject(self):
|
||||
QtGui.QDialog.reject(self)
|
||||
self.isdone = True
|
||||
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
"""
|
||||
A PyQT4 dialog to confirm and set options for auto-tag
|
||||
"""
|
||||
|
||||
"""
|
||||
Copyright 2012 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 PyQt4 import QtCore, QtGui, uic
|
||||
from settings import ComicTaggerSettings
|
||||
from settingswindow import SettingsWindow
|
||||
from filerenamer import FileRenamer
|
||||
import os
|
||||
import utils
|
||||
|
||||
|
||||
class AutoTagStartWindow(QtGui.QDialog):
|
||||
|
||||
def __init__( self, parent, settings, msg ):
|
||||
super(AutoTagStartWindow, self).__init__(parent)
|
||||
|
||||
uic.loadUi(os.path.join(ComicTaggerSettings.baseDir(), 'autotagstartwindow.ui' ), self)
|
||||
self.label.setText( msg )
|
||||
|
||||
self.settings = settings
|
||||
|
||||
self.cbxSaveOnLowConfidence.setCheckState( QtCore.Qt.Unchecked )
|
||||
self.cbxDontUseYear.setCheckState( QtCore.Qt.Unchecked )
|
||||
self.cbxAssumeIssueOne.setCheckState( QtCore.Qt.Unchecked )
|
||||
|
||||
self.autoSaveOnLow = False
|
||||
self.dontUseYear = False
|
||||
self.assumeIssueOne = False
|
||||
|
||||
|
||||
def accept( self ):
|
||||
QtGui.QDialog.accept(self)
|
||||
|
||||
self.autoSaveOnLow = self.cbxSaveOnLowConfidence.isChecked()
|
||||
self.dontUseYear = self.cbxDontUseYear.isChecked()
|
||||
self.assumeIssueOne = self.cbxAssumeIssueOne.isChecked()
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>dialogExport</class>
|
||||
<widget class="QDialog" name="dialogExport">
|
||||
<property name="windowModality">
|
||||
<enum>Qt::NonModal</enum>
|
||||
</property>
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>524</width>
|
||||
<height>248</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Auto-Tag</string>
|
||||
</property>
|
||||
<property name="modal">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="0">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<layout class="QFormLayout" name="formLayout">
|
||||
<property name="fieldGrowthPolicy">
|
||||
<enum>QFormLayout::AllNonFixedFieldsGrow</enum>
|
||||
</property>
|
||||
<item row="1" column="1">
|
||||
<widget class="QCheckBox" name="cbxDontUseYear">
|
||||
<property name="text">
|
||||
<string>Don't use publication year in indentification process</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="1">
|
||||
<widget class="QCheckBox" name="cbxSaveOnLowConfidence">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Save on low confidence match</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="1">
|
||||
<widget class="QCheckBox" name="cbxAssumeIssueOne">
|
||||
<property name="text">
|
||||
<string>If no issue number, assume "1"</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QDialogButtonBox" name="buttonBox">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>accepted()</signal>
|
||||
<receiver>dialogExport</receiver>
|
||||
<slot>accept()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>346</x>
|
||||
<y>187</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>277</x>
|
||||
<y>104</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>rejected()</signal>
|
||||
<receiver>dialogExport</receiver>
|
||||
<slot>reject()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>346</x>
|
||||
<y>187</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>277</x>
|
||||
<y>104</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
@@ -1,99 +0,0 @@
|
||||
"""
|
||||
Class to manage modifying metadata specifically for CBL/CBI
|
||||
"""
|
||||
|
||||
"""
|
||||
Copyright 2012 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 os
|
||||
import utils
|
||||
|
||||
|
||||
class CBLTransformer:
|
||||
def __init__( self, metadata, settings ):
|
||||
self.metadata = metadata
|
||||
self.settings = settings
|
||||
|
||||
|
||||
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 )
|
||||
|
||||
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 )
|
||||
|
||||
if self.settings.copy_characters_to_tags:
|
||||
add_string_list_to_tags( self.metadata.characters )
|
||||
|
||||
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 )
|
||||
|
||||
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.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
|
||||
|
||||
return self.metadata
|
||||
|
||||
|
||||
|
||||
260
comet.py
@@ -1,260 +0,0 @@
|
||||
"""
|
||||
A python class to encapsulate CoMet data
|
||||
"""
|
||||
|
||||
"""
|
||||
Copyright 2012 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 )
|
||||
|
||||
@@ -33,7 +33,7 @@ similar to the C interface provided by UnRAR. There is also a
|
||||
higher level interface which makes some common operations easier.
|
||||
"""
|
||||
|
||||
__version__ = '0.99.2'
|
||||
__version__ = '0.99.3'
|
||||
|
||||
try:
|
||||
WindowsError
|
||||
@@ -12,13 +12,11 @@ def cleanup(dir='test'):
|
||||
os.removedirs(os.path.join(path, dir))
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# reuse RarArchive object, en
|
||||
# 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">""")
|
||||
@@ -98,7 +96,8 @@ list(UnRAR2.RarFile('test_nulls.rar').infoiter())
|
||||
|
||||
# extract files from an archive with protected files
|
||||
cleanup()
|
||||
UnRAR2.RarFile('test_protected_files.rar', password="protected").extract()
|
||||
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
|
||||
@@ -33,6 +33,7 @@ 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"
|
||||
@@ -59,10 +60,10 @@ def call_unrar(params):
|
||||
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():
|
||||
@@ -73,18 +74,42 @@ class RarFileImplementation(object):
|
||||
accum = []
|
||||
source = iter(stdoutdata.splitlines())
|
||||
line = ''
|
||||
while not (line.startswith('Comment:') or line.startswith('Pathname/Comment')):
|
||||
if line.strip().endswith('is not RAR archive'):
|
||||
raise InvalidRARArchive
|
||||
while not (line.startswith('UNRAR')):
|
||||
line = source.next()
|
||||
while not line.startswith('Pathname/Comment'):
|
||||
accum.append(line.rstrip('\n'))
|
||||
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()
|
||||
if len(accum):
|
||||
accum[0] = accum[0][9:]
|
||||
self.comment = '\n'.join(accum[:-1])
|
||||
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:
|
||||
self.comment = None
|
||||
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
|
||||
@@ -97,7 +122,8 @@ class RarFileImplementation(object):
|
||||
|
||||
def infoiter(self):
|
||||
|
||||
stdoutdata, stderrdata = self.call('v', ['c-']).communicate()
|
||||
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"):
|
||||
@@ -106,31 +132,48 @@ class RarFileImplementation(object):
|
||||
accum = []
|
||||
source = iter(stdoutdata.splitlines())
|
||||
line = ''
|
||||
while not line.startswith('--------------'):
|
||||
while not line.startswith('-----------'):
|
||||
if line.strip().endswith('is not RAR archive'):
|
||||
raise InvalidRARArchive
|
||||
if line.find("CRC failed")>=0:
|
||||
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+")
|
||||
while not line.startswith('--------------'):
|
||||
accum.append(line)
|
||||
if len(accum)==2:
|
||||
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'] = accum[0].strip()
|
||||
info = re_spaces.split(accum[1].strip())
|
||||
data['size'] = int(info[0])
|
||||
attr = info[5]
|
||||
data['filename'] = " ".join(fields[4:])
|
||||
data['size'] = int(fields[1])
|
||||
attr = fields[0]
|
||||
data['isdir'] = 'd' in attr.lower()
|
||||
data['datetime'] = time.strptime(info[3]+" "+info[4], '%d-%m-%y %H:%M')
|
||||
data['datetime'] = time.strptime(fields[2]+" "+fields[3], '%d-%m-%y %H:%M')
|
||||
data['comment'] = None
|
||||
yield data
|
||||
accum = []
|
||||
i += 1
|
||||
line = source.next()
|
||||
line = source.next()
|
||||
|
||||
|
||||
def read_files(self, checker):
|
||||
res = []
|
||||
@@ -151,7 +194,7 @@ class RarFileImplementation(object):
|
||||
if overwrite:
|
||||
options.append('o+')
|
||||
else:
|
||||
options.append('o-')
|
||||
options.append('o-')
|
||||
if not path.endswith(os.sep):
|
||||
path += os.sep
|
||||
names = []
|
||||
@@ -165,7 +208,7 @@ class RarFileImplementation(object):
|
||||
names.append(path)
|
||||
proc = self.call(command, options, names)
|
||||
stdoutdata, stderrdata = proc.communicate()
|
||||
if stderrdata.find("CRC failed")>=0:
|
||||
if stderrdata.find("CRC failed")>=0 or stderrdata.find("Checksum error")>=0:
|
||||
raise IncorrectRARPassword
|
||||
return res
|
||||
|
||||
1
comicapi/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
__author__ = 'dromanin'
|
||||
276
comicapi/comet.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""A 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.
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
#from datetime import datetime
|
||||
#from pprint import pprint
|
||||
#import zipfile
|
||||
|
||||
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)
|
||||
1173
comicapi/comicarchive.py
Normal file
144
comicapi/comicbookinfo.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""A 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)
|
||||
|
||||
def validateString(self, string):
|
||||
"""Verify that the string actually contains CBI data in JSON format"""
|
||||
|
||||
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/' + '1.0.0', # 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
|
||||
290
comicapi/comicinfoxml.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""A 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.
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
#from datetime import datetime
|
||||
#from pprint import pprint
|
||||
#import zipfile
|
||||
|
||||
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)
|
||||
292
comicapi/filenameparser.py
Normal file
@@ -0,0 +1,292 @@
|
||||
"""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 separators 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 indexes 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 separators 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 suffix
|
||||
# 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-digits
|
||||
year = re.sub("[^0-9]", "", year)
|
||||
return year
|
||||
|
||||
def getRemainder(self, filename, year, count, volume, 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 volume != "":
|
||||
remainder = remainder.replace("Vol." + volume, "", 1)
|
||||
if year != "":
|
||||
remainder = remainder.replace(year, "", 1)
|
||||
if count != "":
|
||||
remainder = remainder.replace("of " + count, "", 1)
|
||||
|
||||
remainder = remainder.replace("()", "")
|
||||
remainder = remainder.replace(
|
||||
" ",
|
||||
" ") # cleans some whitespace mess
|
||||
|
||||
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 decodes
|
||||
# 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)
|
||||
|
||||
# provides proper value when the filename doesn't have a issue number
|
||||
if issue_end == 0:
|
||||
issue_end = len(self.series)
|
||||
|
||||
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,
|
||||
self.volume,
|
||||
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
|
||||
321
comicapi/genericmetadata.py
Normal file
@@ -0,0 +1,321 @@
|
||||
"""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 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
|
||||
|
||||
|
||||
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 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 isinstance(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 'primary' in c 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 '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 = u"{0: <" + str(flen) + "} {1}\n"
|
||||
for i in vals:
|
||||
outstr += fmt_str.format(i[0] + ":", i[1])
|
||||
|
||||
return outstr
|
||||
133
comicapi/issuestring.py
Normal file
@@ -0,0 +1,133 @@
|
||||
# coding=utf-8
|
||||
"""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 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 isinstance(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
|
||||
if self.suffix == u"½":
|
||||
if self.num is not None:
|
||||
return self.num + .5
|
||||
else:
|
||||
return .5
|
||||
return self.num
|
||||
|
||||
def asInt(self):
|
||||
# return the int version of the float
|
||||
if self.num is None:
|
||||
return None
|
||||
return int(self.num)
|
||||
592
comicapi/utils.py
Normal file
@@ -0,0 +1,592 @@
|
||||
# coding=utf-8
|
||||
"""Some generic utilities"""
|
||||
|
||||
# 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 os
|
||||
import re
|
||||
import platform
|
||||
import locale
|
||||
import codecs
|
||||
|
||||
|
||||
class UtilsVars:
|
||||
already_fixed_encoding = False
|
||||
|
||||
|
||||
def get_actual_preferred_encoding():
|
||||
preferred_encoding = locale.getpreferredencoding()
|
||||
if platform.system() == "Darwin":
|
||||
preferred_encoding = "utf-8"
|
||||
return preferred_encoding
|
||||
|
||||
|
||||
def fix_output_encoding():
|
||||
if not UtilsVars.already_fixed_encoding:
|
||||
# this reads the environment and inits the right locale
|
||||
locale.setlocale(locale.LC_ALL, "")
|
||||
|
||||
# try to make stdout/stderr encodings happy for unicode printing
|
||||
preferred_encoding = get_actual_preferred_encoding()
|
||||
sys.stdout = codecs.getwriter(preferred_encoding)(sys.stdout)
|
||||
sys.stderr = codecs.getwriter(preferred_encoding)(sys.stderr)
|
||||
UtilsVars.already_fixed_encoding = True
|
||||
|
||||
|
||||
def get_recursive_filelist(pathlist):
|
||||
"""Get a recursive list of of all files under all path items in the list"""
|
||||
|
||||
filename_encoding = sys.getfilesystemencoding()
|
||||
filelist = []
|
||||
for p in pathlist:
|
||||
# if path is a folder, walk it recursively, and all files underneath
|
||||
if isinstance(p, str):
|
||||
# make sure string is unicode
|
||||
p = p.decode(filename_encoding) # , 'replace')
|
||||
elif not isinstance(p, unicode):
|
||||
# it's probably a QString
|
||||
p = unicode(p)
|
||||
|
||||
if os.path.isdir(p):
|
||||
for root, dirs, files in os.walk(p):
|
||||
for f in files:
|
||||
if isinstance(f, str):
|
||||
# make sure string is unicode
|
||||
f = f.decode(filename_encoding, 'replace')
|
||||
elif not isinstance(f, unicode):
|
||||
# it's probably a QString
|
||||
f = unicode(f)
|
||||
filelist.append(os.path.join(root, f))
|
||||
else:
|
||||
filelist.append(p)
|
||||
|
||||
return filelist
|
||||
|
||||
|
||||
def listToString(l):
|
||||
string = ""
|
||||
if l is not None:
|
||||
for item in l:
|
||||
if len(string) > 0:
|
||||
string += ", "
|
||||
string += item
|
||||
return string
|
||||
|
||||
|
||||
def addtopath(dirname):
|
||||
if dirname is not None and dirname != "":
|
||||
|
||||
# verify that path doesn't already contain the given dirname
|
||||
tmpdirname = re.escape(dirname)
|
||||
pattern = r"{sep}{dir}$|^{dir}{sep}|{sep}{dir}{sep}|^{dir}$".format(
|
||||
dir=tmpdirname,
|
||||
sep=os.pathsep)
|
||||
|
||||
match = re.search(pattern, os.environ['PATH'])
|
||||
if not match:
|
||||
os.environ['PATH'] = dirname + os.pathsep + os.environ['PATH']
|
||||
|
||||
|
||||
def which(program):
|
||||
"""Returns path of the executable, if it exists"""
|
||||
|
||||
def is_exe(fpath):
|
||||
return os.path.isfile(fpath) and os.access(fpath, os.X_OK)
|
||||
|
||||
fpath, fname = os.path.split(program)
|
||||
if fpath:
|
||||
if is_exe(program):
|
||||
return program
|
||||
else:
|
||||
for path in os.environ["PATH"].split(os.pathsep):
|
||||
exe_file = os.path.join(path, program)
|
||||
if is_exe(exe_file):
|
||||
return exe_file
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def removearticles(text):
|
||||
text = text.lower()
|
||||
articles = ['and', 'the', 'a', '&', 'issue']
|
||||
newText = ''
|
||||
for word in text.split(' '):
|
||||
if word not in articles:
|
||||
newText += word + ' '
|
||||
|
||||
newText = newText[:-1]
|
||||
|
||||
# now get rid of some other junk
|
||||
newText = newText.replace(":", "")
|
||||
newText = newText.replace(",", "")
|
||||
newText = newText.replace("-", " ")
|
||||
|
||||
# since the CV API changed, searches for series names with periods
|
||||
# now explicitly require the period to be in the search key,
|
||||
# so the line below is removed (for now)
|
||||
#newText = newText.replace(".", "")
|
||||
|
||||
return newText
|
||||
|
||||
|
||||
def unique_file(file_name):
|
||||
counter = 1
|
||||
# returns ('/path/file', '.ext')
|
||||
file_name_parts = os.path.splitext(file_name)
|
||||
while True:
|
||||
if not os.path.lexists(file_name):
|
||||
return file_name
|
||||
file_name = file_name_parts[
|
||||
0] + ' (' + str(counter) + ')' + file_name_parts[1]
|
||||
counter += 1
|
||||
|
||||
|
||||
# -o- coding: utf-8 -o-
|
||||
# ISO639 python dict
|
||||
# official list in http://www.loc.gov/standards/iso639-2/php/code_list.php
|
||||
|
||||
lang_dict = {
|
||||
'ab': 'Abkhaz',
|
||||
'aa': 'Afar',
|
||||
'af': 'Afrikaans',
|
||||
'ak': 'Akan',
|
||||
'sq': 'Albanian',
|
||||
'am': 'Amharic',
|
||||
'ar': 'Arabic',
|
||||
'an': 'Aragonese',
|
||||
'hy': 'Armenian',
|
||||
'as': 'Assamese',
|
||||
'av': 'Avaric',
|
||||
'ae': 'Avestan',
|
||||
'ay': 'Aymara',
|
||||
'az': 'Azerbaijani',
|
||||
'bm': 'Bambara',
|
||||
'ba': 'Bashkir',
|
||||
'eu': 'Basque',
|
||||
'be': 'Belarusian',
|
||||
'bn': 'Bengali',
|
||||
'bh': 'Bihari',
|
||||
'bi': 'Bislama',
|
||||
'bs': 'Bosnian',
|
||||
'br': 'Breton',
|
||||
'bg': 'Bulgarian',
|
||||
'my': 'Burmese',
|
||||
'ca': 'Catalan; Valencian',
|
||||
'ch': 'Chamorro',
|
||||
'ce': 'Chechen',
|
||||
'ny': 'Chichewa; Chewa; Nyanja',
|
||||
'zh': 'Chinese',
|
||||
'cv': 'Chuvash',
|
||||
'kw': 'Cornish',
|
||||
'co': 'Corsican',
|
||||
'cr': 'Cree',
|
||||
'hr': 'Croatian',
|
||||
'cs': 'Czech',
|
||||
'da': 'Danish',
|
||||
'dv': 'Divehi; Maldivian;',
|
||||
'nl': 'Dutch',
|
||||
'dz': 'Dzongkha',
|
||||
'en': 'English',
|
||||
'eo': 'Esperanto',
|
||||
'et': 'Estonian',
|
||||
'ee': 'Ewe',
|
||||
'fo': 'Faroese',
|
||||
'fj': 'Fijian',
|
||||
'fi': 'Finnish',
|
||||
'fr': 'French',
|
||||
'ff': 'Fula',
|
||||
'gl': 'Galician',
|
||||
'ka': 'Georgian',
|
||||
'de': 'German',
|
||||
'el': 'Greek, Modern',
|
||||
'gn': 'Guaraní',
|
||||
'gu': 'Gujarati',
|
||||
'ht': 'Haitian',
|
||||
'ha': 'Hausa',
|
||||
'he': 'Hebrew (modern)',
|
||||
'hz': 'Herero',
|
||||
'hi': 'Hindi',
|
||||
'ho': 'Hiri Motu',
|
||||
'hu': 'Hungarian',
|
||||
'ia': 'Interlingua',
|
||||
'id': 'Indonesian',
|
||||
'ie': 'Interlingue',
|
||||
'ga': 'Irish',
|
||||
'ig': 'Igbo',
|
||||
'ik': 'Inupiaq',
|
||||
'io': 'Ido',
|
||||
'is': 'Icelandic',
|
||||
'it': 'Italian',
|
||||
'iu': 'Inuktitut',
|
||||
'ja': 'Japanese',
|
||||
'jv': 'Javanese',
|
||||
'kl': 'Kalaallisut',
|
||||
'kn': 'Kannada',
|
||||
'kr': 'Kanuri',
|
||||
'ks': 'Kashmiri',
|
||||
'kk': 'Kazakh',
|
||||
'km': 'Khmer',
|
||||
'ki': 'Kikuyu, Gikuyu',
|
||||
'rw': 'Kinyarwanda',
|
||||
'ky': 'Kirghiz, Kyrgyz',
|
||||
'kv': 'Komi',
|
||||
'kg': 'Kongo',
|
||||
'ko': 'Korean',
|
||||
'ku': 'Kurdish',
|
||||
'kj': 'Kwanyama, Kuanyama',
|
||||
'la': 'Latin',
|
||||
'lb': 'Luxembourgish',
|
||||
'lg': 'Luganda',
|
||||
'li': 'Limburgish',
|
||||
'ln': 'Lingala',
|
||||
'lo': 'Lao',
|
||||
'lt': 'Lithuanian',
|
||||
'lu': 'Luba-Katanga',
|
||||
'lv': 'Latvian',
|
||||
'gv': 'Manx',
|
||||
'mk': 'Macedonian',
|
||||
'mg': 'Malagasy',
|
||||
'ms': 'Malay',
|
||||
'ml': 'Malayalam',
|
||||
'mt': 'Maltese',
|
||||
'mi': 'Māori',
|
||||
'mr': 'Marathi (Marāṭhī)',
|
||||
'mh': 'Marshallese',
|
||||
'mn': 'Mongolian',
|
||||
'na': 'Nauru',
|
||||
'nv': 'Navajo, Navaho',
|
||||
'nb': 'Norwegian Bokmål',
|
||||
'nd': 'North Ndebele',
|
||||
'ne': 'Nepali',
|
||||
'ng': 'Ndonga',
|
||||
'nn': 'Norwegian Nynorsk',
|
||||
'no': 'Norwegian',
|
||||
'ii': 'Nuosu',
|
||||
'nr': 'South Ndebele',
|
||||
'oc': 'Occitan',
|
||||
'oj': 'Ojibwe, Ojibwa',
|
||||
'cu': 'Old Church Slavonic',
|
||||
'om': 'Oromo',
|
||||
'or': 'Oriya',
|
||||
'os': 'Ossetian, Ossetic',
|
||||
'pa': 'Panjabi, Punjabi',
|
||||
'pi': 'Pāli',
|
||||
'fa': 'Persian',
|
||||
'pl': 'Polish',
|
||||
'ps': 'Pashto, Pushto',
|
||||
'pt': 'Portuguese',
|
||||
'qu': 'Quechua',
|
||||
'rm': 'Romansh',
|
||||
'rn': 'Kirundi',
|
||||
'ro': 'Romanian, Moldavan',
|
||||
'ru': 'Russian',
|
||||
'sa': 'Sanskrit (Saṁskṛta)',
|
||||
'sc': 'Sardinian',
|
||||
'sd': 'Sindhi',
|
||||
'se': 'Northern Sami',
|
||||
'sm': 'Samoan',
|
||||
'sg': 'Sango',
|
||||
'sr': 'Serbian',
|
||||
'gd': 'Scottish Gaelic',
|
||||
'sn': 'Shona',
|
||||
'si': 'Sinhala, Sinhalese',
|
||||
'sk': 'Slovak',
|
||||
'sl': 'Slovene',
|
||||
'so': 'Somali',
|
||||
'st': 'Southern Sotho',
|
||||
'es': 'Spanish; Castilian',
|
||||
'su': 'Sundanese',
|
||||
'sw': 'Swahili',
|
||||
'ss': 'Swati',
|
||||
'sv': 'Swedish',
|
||||
'ta': 'Tamil',
|
||||
'te': 'Telugu',
|
||||
'tg': 'Tajik',
|
||||
'th': 'Thai',
|
||||
'ti': 'Tigrinya',
|
||||
'bo': 'Tibetan',
|
||||
'tk': 'Turkmen',
|
||||
'tl': 'Tagalog',
|
||||
'tn': 'Tswana',
|
||||
'to': 'Tonga',
|
||||
'tr': 'Turkish',
|
||||
'ts': 'Tsonga',
|
||||
'tt': 'Tatar',
|
||||
'tw': 'Twi',
|
||||
'ty': 'Tahitian',
|
||||
'ug': 'Uighur, Uyghur',
|
||||
'uk': 'Ukrainian',
|
||||
'ur': 'Urdu',
|
||||
'uz': 'Uzbek',
|
||||
've': 'Venda',
|
||||
'vi': 'Vietnamese',
|
||||
'vo': 'Volapük',
|
||||
'wa': 'Walloon',
|
||||
'cy': 'Welsh',
|
||||
'wo': 'Wolof',
|
||||
'fy': 'Western Frisian',
|
||||
'xh': 'Xhosa',
|
||||
'yi': 'Yiddish',
|
||||
'yo': 'Yoruba',
|
||||
'za': 'Zhuang, Chuang',
|
||||
'zu': 'Zulu',
|
||||
}
|
||||
|
||||
|
||||
countries = [
|
||||
('AF', 'Afghanistan'),
|
||||
('AL', 'Albania'),
|
||||
('DZ', 'Algeria'),
|
||||
('AS', 'American Samoa'),
|
||||
('AD', 'Andorra'),
|
||||
('AO', 'Angola'),
|
||||
('AI', 'Anguilla'),
|
||||
('AQ', 'Antarctica'),
|
||||
('AG', 'Antigua And Barbuda'),
|
||||
('AR', 'Argentina'),
|
||||
('AM', 'Armenia'),
|
||||
('AW', 'Aruba'),
|
||||
('AU', 'Australia'),
|
||||
('AT', 'Austria'),
|
||||
('AZ', 'Azerbaijan'),
|
||||
('BS', 'Bahamas'),
|
||||
('BH', 'Bahrain'),
|
||||
('BD', 'Bangladesh'),
|
||||
('BB', 'Barbados'),
|
||||
('BY', 'Belarus'),
|
||||
('BE', 'Belgium'),
|
||||
('BZ', 'Belize'),
|
||||
('BJ', 'Benin'),
|
||||
('BM', 'Bermuda'),
|
||||
('BT', 'Bhutan'),
|
||||
('BO', 'Bolivia'),
|
||||
('BA', 'Bosnia And Herzegowina'),
|
||||
('BW', 'Botswana'),
|
||||
('BV', 'Bouvet Island'),
|
||||
('BR', 'Brazil'),
|
||||
('BN', 'Brunei Darussalam'),
|
||||
('BG', 'Bulgaria'),
|
||||
('BF', 'Burkina Faso'),
|
||||
('BI', 'Burundi'),
|
||||
('KH', 'Cambodia'),
|
||||
('CM', 'Cameroon'),
|
||||
('CA', 'Canada'),
|
||||
('CV', 'Cape Verde'),
|
||||
('KY', 'Cayman Islands'),
|
||||
('CF', 'Central African Rep'),
|
||||
('TD', 'Chad'),
|
||||
('CL', 'Chile'),
|
||||
('CN', 'China'),
|
||||
('CX', 'Christmas Island'),
|
||||
('CC', 'Cocos Islands'),
|
||||
('CO', 'Colombia'),
|
||||
('KM', 'Comoros'),
|
||||
('CG', 'Congo'),
|
||||
('CK', 'Cook Islands'),
|
||||
('CR', 'Costa Rica'),
|
||||
('CI', 'Cote D`ivoire'),
|
||||
('HR', 'Croatia'),
|
||||
('CU', 'Cuba'),
|
||||
('CY', 'Cyprus'),
|
||||
('CZ', 'Czech Republic'),
|
||||
('DK', 'Denmark'),
|
||||
('DJ', 'Djibouti'),
|
||||
('DM', 'Dominica'),
|
||||
('DO', 'Dominican Republic'),
|
||||
('TP', 'East Timor'),
|
||||
('EC', 'Ecuador'),
|
||||
('EG', 'Egypt'),
|
||||
('SV', 'El Salvador'),
|
||||
('GQ', 'Equatorial Guinea'),
|
||||
('ER', 'Eritrea'),
|
||||
('EE', 'Estonia'),
|
||||
('ET', 'Ethiopia'),
|
||||
('FK', 'Falkland Islands (Malvinas)'),
|
||||
('FO', 'Faroe Islands'),
|
||||
('FJ', 'Fiji'),
|
||||
('FI', 'Finland'),
|
||||
('FR', 'France'),
|
||||
('GF', 'French Guiana'),
|
||||
('PF', 'French Polynesia'),
|
||||
('TF', 'French S. Territories'),
|
||||
('GA', 'Gabon'),
|
||||
('GM', 'Gambia'),
|
||||
('GE', 'Georgia'),
|
||||
('DE', 'Germany'),
|
||||
('GH', 'Ghana'),
|
||||
('GI', 'Gibraltar'),
|
||||
('GR', 'Greece'),
|
||||
('GL', 'Greenland'),
|
||||
('GD', 'Grenada'),
|
||||
('GP', 'Guadeloupe'),
|
||||
('GU', 'Guam'),
|
||||
('GT', 'Guatemala'),
|
||||
('GN', 'Guinea'),
|
||||
('GW', 'Guinea-bissau'),
|
||||
('GY', 'Guyana'),
|
||||
('HT', 'Haiti'),
|
||||
('HN', 'Honduras'),
|
||||
('HK', 'Hong Kong'),
|
||||
('HU', 'Hungary'),
|
||||
('IS', 'Iceland'),
|
||||
('IN', 'India'),
|
||||
('ID', 'Indonesia'),
|
||||
('IR', 'Iran'),
|
||||
('IQ', 'Iraq'),
|
||||
('IE', 'Ireland'),
|
||||
('IL', 'Israel'),
|
||||
('IT', 'Italy'),
|
||||
('JM', 'Jamaica'),
|
||||
('JP', 'Japan'),
|
||||
('JO', 'Jordan'),
|
||||
('KZ', 'Kazakhstan'),
|
||||
('KE', 'Kenya'),
|
||||
('KI', 'Kiribati'),
|
||||
('KP', 'Korea (North)'),
|
||||
('KR', 'Korea (South)'),
|
||||
('KW', 'Kuwait'),
|
||||
('KG', 'Kyrgyzstan'),
|
||||
('LA', 'Laos'),
|
||||
('LV', 'Latvia'),
|
||||
('LB', 'Lebanon'),
|
||||
('LS', 'Lesotho'),
|
||||
('LR', 'Liberia'),
|
||||
('LY', 'Libya'),
|
||||
('LI', 'Liechtenstein'),
|
||||
('LT', 'Lithuania'),
|
||||
('LU', 'Luxembourg'),
|
||||
('MO', 'Macau'),
|
||||
('MK', 'Macedonia'),
|
||||
('MG', 'Madagascar'),
|
||||
('MW', 'Malawi'),
|
||||
('MY', 'Malaysia'),
|
||||
('MV', 'Maldives'),
|
||||
('ML', 'Mali'),
|
||||
('MT', 'Malta'),
|
||||
('MH', 'Marshall Islands'),
|
||||
('MQ', 'Martinique'),
|
||||
('MR', 'Mauritania'),
|
||||
('MU', 'Mauritius'),
|
||||
('YT', 'Mayotte'),
|
||||
('MX', 'Mexico'),
|
||||
('FM', 'Micronesia'),
|
||||
('MD', 'Moldova'),
|
||||
('MC', 'Monaco'),
|
||||
('MN', 'Mongolia'),
|
||||
('MS', 'Montserrat'),
|
||||
('MA', 'Morocco'),
|
||||
('MZ', 'Mozambique'),
|
||||
('MM', 'Myanmar'),
|
||||
('NA', 'Namibia'),
|
||||
('NR', 'Nauru'),
|
||||
('NP', 'Nepal'),
|
||||
('NL', 'Netherlands'),
|
||||
('AN', 'Netherlands Antilles'),
|
||||
('NC', 'New Caledonia'),
|
||||
('NZ', 'New Zealand'),
|
||||
('NI', 'Nicaragua'),
|
||||
('NE', 'Niger'),
|
||||
('NG', 'Nigeria'),
|
||||
('NU', 'Niue'),
|
||||
('NF', 'Norfolk Island'),
|
||||
('MP', 'Northern Mariana Islands'),
|
||||
('NO', 'Norway'),
|
||||
('OM', 'Oman'),
|
||||
('PK', 'Pakistan'),
|
||||
('PW', 'Palau'),
|
||||
('PA', 'Panama'),
|
||||
('PG', 'Papua New Guinea'),
|
||||
('PY', 'Paraguay'),
|
||||
('PE', 'Peru'),
|
||||
('PH', 'Philippines'),
|
||||
('PN', 'Pitcairn'),
|
||||
('PL', 'Poland'),
|
||||
('PT', 'Portugal'),
|
||||
('PR', 'Puerto Rico'),
|
||||
('QA', 'Qatar'),
|
||||
('RE', 'Reunion'),
|
||||
('RO', 'Romania'),
|
||||
('RU', 'Russian Federation'),
|
||||
('RW', 'Rwanda'),
|
||||
('KN', 'Saint Kitts And Nevis'),
|
||||
('LC', 'Saint Lucia'),
|
||||
('VC', 'St Vincent/Grenadines'),
|
||||
('WS', 'Samoa'),
|
||||
('SM', 'San Marino'),
|
||||
('ST', 'Sao Tome'),
|
||||
('SA', 'Saudi Arabia'),
|
||||
('SN', 'Senegal'),
|
||||
('SC', 'Seychelles'),
|
||||
('SL', 'Sierra Leone'),
|
||||
('SG', 'Singapore'),
|
||||
('SK', 'Slovakia'),
|
||||
('SI', 'Slovenia'),
|
||||
('SB', 'Solomon Islands'),
|
||||
('SO', 'Somalia'),
|
||||
('ZA', 'South Africa'),
|
||||
('ES', 'Spain'),
|
||||
('LK', 'Sri Lanka'),
|
||||
('SH', 'St. Helena'),
|
||||
('PM', 'St.Pierre'),
|
||||
('SD', 'Sudan'),
|
||||
('SR', 'Suriname'),
|
||||
('SZ', 'Swaziland'),
|
||||
('SE', 'Sweden'),
|
||||
('CH', 'Switzerland'),
|
||||
('SY', 'Syrian Arab Republic'),
|
||||
('TW', 'Taiwan'),
|
||||
('TJ', 'Tajikistan'),
|
||||
('TZ', 'Tanzania'),
|
||||
('TH', 'Thailand'),
|
||||
('TG', 'Togo'),
|
||||
('TK', 'Tokelau'),
|
||||
('TO', 'Tonga'),
|
||||
('TT', 'Trinidad And Tobago'),
|
||||
('TN', 'Tunisia'),
|
||||
('TR', 'Turkey'),
|
||||
('TM', 'Turkmenistan'),
|
||||
('TV', 'Tuvalu'),
|
||||
('UG', 'Uganda'),
|
||||
('UA', 'Ukraine'),
|
||||
('AE', 'United Arab Emirates'),
|
||||
('UK', 'United Kingdom'),
|
||||
('US', 'United States'),
|
||||
('UY', 'Uruguay'),
|
||||
('UZ', 'Uzbekistan'),
|
||||
('VU', 'Vanuatu'),
|
||||
('VA', 'Vatican City State'),
|
||||
('VE', 'Venezuela'),
|
||||
('VN', 'Viet Nam'),
|
||||
('VG', 'Virgin Islands (British)'),
|
||||
('VI', 'Virgin Islands (U.S.)'),
|
||||
('EH', 'Western Sahara'),
|
||||
('YE', 'Yemen'),
|
||||
('YU', 'Yugoslavia'),
|
||||
('ZR', 'Zaire'),
|
||||
('ZM', 'Zambia'),
|
||||
('ZW', 'Zimbabwe')
|
||||
]
|
||||
|
||||
|
||||
def getLanguageDict():
|
||||
return lang_dict
|
||||
|
||||
|
||||
def getLanguageFromISO(iso):
|
||||
if iso is None:
|
||||
return None
|
||||
else:
|
||||
return lang_dict[iso]
|
||||
969
comicarchive.py
@@ -1,969 +0,0 @@
|
||||
"""
|
||||
A python class to represent a single comic, be it file or folder of images
|
||||
"""
|
||||
|
||||
"""
|
||||
Copyright 2012 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 zipfile
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import subprocess
|
||||
import platform
|
||||
if platform.system() == "Windows":
|
||||
import _subprocess
|
||||
import time
|
||||
|
||||
import StringIO
|
||||
try:
|
||||
import Image
|
||||
pil_available = True
|
||||
except ImportError:
|
||||
pil_available = False
|
||||
|
||||
sys.path.insert(0, os.path.abspath(".") )
|
||||
import UnRAR2
|
||||
from UnRAR2.rar_exceptions import *
|
||||
|
||||
from options import Options, MetaDataStyle
|
||||
from comicinfoxml import ComicInfoXml
|
||||
from comicbookinfo import ComicBookInfo
|
||||
from comet import CoMet
|
||||
from genericmetadata import GenericMetadata, PageType
|
||||
from filenameparser import FileNameParser
|
||||
from settings import ComicTaggerSettings
|
||||
|
||||
class ZipArchiver:
|
||||
|
||||
def __init__( self, path ):
|
||||
self.path = path
|
||||
|
||||
def getArchiveComment( self ):
|
||||
zf = zipfile.ZipFile( self.path, 'r' )
|
||||
comment = zf.comment
|
||||
zf.close()
|
||||
return comment
|
||||
|
||||
def setArchiveComment( self, comment ):
|
||||
return self.writeZipComment( self.path, comment )
|
||||
|
||||
def readArchiveFile( self, archive_file ):
|
||||
data = ""
|
||||
zf = zipfile.ZipFile( self.path, 'r' )
|
||||
try:
|
||||
data = zf.read( archive_file )
|
||||
except zipfile.BadZipfile:
|
||||
print "bad zipfile: {0} :: {1}".format(self.path, archive_file)
|
||||
raise IOError
|
||||
except Exception:
|
||||
print "bad zipfile: {0} :: {1}".format(self.path, archive_file)
|
||||
raise IOError
|
||||
finally:
|
||||
zf.close()
|
||||
return data
|
||||
|
||||
def removeArchiveFile( self, archive_file ):
|
||||
try:
|
||||
self.rebuildZipFile( [ archive_file ] )
|
||||
except:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def writeArchiveFile( self, archive_file, data ):
|
||||
# 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
|
||||
try:
|
||||
self.rebuildZipFile( [ archive_file ] )
|
||||
|
||||
#now just add the archive file as a new one
|
||||
zf = zipfile.ZipFile(self.path, mode='a', compression=zipfile.ZIP_DEFLATED )
|
||||
zf.writestr( archive_file, data )
|
||||
zf.close()
|
||||
return True
|
||||
except:
|
||||
return False
|
||||
|
||||
def getArchiveFilenameList( self ):
|
||||
zf = zipfile.ZipFile( self.path, 'r' )
|
||||
namelist = zf.namelist()
|
||||
zf.close()
|
||||
return namelist
|
||||
|
||||
# zip helper func
|
||||
def rebuildZipFile( self, exclude_list ):
|
||||
|
||||
# this recompresses the zip archive, without the files in the exclude_list
|
||||
#print "Rebuilding zip {0} without {1}".format( self.path, exclude_list )
|
||||
|
||||
# generate temp file
|
||||
tmp_fd, tmp_name = tempfile.mkstemp( dir=os.path.dirname(self.path) )
|
||||
os.close( tmp_fd )
|
||||
|
||||
zin = zipfile.ZipFile (self.path, 'r')
|
||||
zout = zipfile.ZipFile (tmp_name, 'w')
|
||||
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
|
||||
|
||||
zout.close()
|
||||
zin.close()
|
||||
|
||||
# replace with the new file
|
||||
os.remove( self.path )
|
||||
os.rename( tmp_name, self.path )
|
||||
|
||||
|
||||
def writeZipComment( self, filename, comment ):
|
||||
"""
|
||||
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:
|
||||
fo = open(filename, "r+b")
|
||||
|
||||
#the starting position, relative to EOF
|
||||
pos = -4
|
||||
|
||||
found = False
|
||||
value = bytearray()
|
||||
|
||||
# walk backwards to find the "End of Central Directory" record
|
||||
while ( not found ) and ( -pos != file_length ):
|
||||
# seek, relative to EOF
|
||||
fo.seek( pos, 2)
|
||||
|
||||
value = fo.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
|
||||
#print pos,"{1} int: {0:x}".format(bytearray(value)[0], value)
|
||||
|
||||
if found:
|
||||
|
||||
# now skip forward 20 bytes to the comment length word
|
||||
pos += 20
|
||||
fo.seek( pos, 2)
|
||||
|
||||
# Pack the length of the comment string
|
||||
format = "H" # one 2-byte integer
|
||||
comment_length = struct.pack(format, len(comment)) # pack integer in a binary string
|
||||
|
||||
# write out the length
|
||||
fo.write( comment_length )
|
||||
fo.seek( pos+2, 2)
|
||||
|
||||
# write out the comment itself
|
||||
fo.write( comment )
|
||||
fo.truncate()
|
||||
fo.close()
|
||||
else:
|
||||
raise Exception('Failed to write comment to zip file!')
|
||||
except:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def copyFromArchive( self, otherArchive ):
|
||||
# Replace the current zip with one copied from another archive
|
||||
try:
|
||||
zout = zipfile.ZipFile (self.path, 'w')
|
||||
for fname in otherArchive.getArchiveFilenameList():
|
||||
data = otherArchive.readArchiveFile( fname )
|
||||
if data is not None:
|
||||
zout.writestr( fname, data )
|
||||
zout.close()
|
||||
|
||||
#preserve the old comment
|
||||
comment = otherArchive.getArchiveComment()
|
||||
if comment is not None:
|
||||
if not self.writeZipComment( self.path, comment ):
|
||||
return False
|
||||
except Exception as e:
|
||||
print "Error while copying to {0}: {1}".format(self.path, e)
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
#------------------------------------------
|
||||
# RAR implementation
|
||||
|
||||
class RarArchiver:
|
||||
|
||||
devnull = None
|
||||
def __init__( self, path ):
|
||||
self.path = path
|
||||
self.rar_exe_path = None
|
||||
|
||||
if RarArchiver.devnull is None:
|
||||
RarArchiver.devnull = open(os.devnull, "w")
|
||||
|
||||
# windows only, keeps the cmd.exe from popping up
|
||||
if platform.system() == "Windows":
|
||||
self.startupinfo = subprocess.STARTUPINFO()
|
||||
self.startupinfo.dwFlags |= _subprocess.STARTF_USESHOWWINDOW
|
||||
else:
|
||||
self.startupinfo = None
|
||||
|
||||
def __del__(self):
|
||||
#RarArchiver.devnull.close()
|
||||
pass
|
||||
|
||||
def getArchiveComment( self ):
|
||||
|
||||
rarc = self.getRARObj()
|
||||
return rarc.comment
|
||||
|
||||
def setArchiveComment( self, comment ):
|
||||
|
||||
if self.rar_exe_path is not None:
|
||||
try:
|
||||
# write comment to temp file
|
||||
tmp_fd, tmp_name = tempfile.mkstemp()
|
||||
f = os.fdopen(tmp_fd, 'w+b')
|
||||
f.write( comment )
|
||||
f.close()
|
||||
|
||||
working_dir = os.path.dirname( os.path.abspath( self.path ) )
|
||||
|
||||
# use external program to write comment to Rar archive
|
||||
subprocess.call([self.rar_exe_path, 'c', '-w' + working_dir , '-c-', '-z' + tmp_name, self.path],
|
||||
startupinfo=self.startupinfo,
|
||||
stdout=RarArchiver.devnull)
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
time.sleep(1)
|
||||
|
||||
os.remove( tmp_name)
|
||||
except:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def readArchiveFile( self, archive_file ):
|
||||
|
||||
# Make sure to escape brackets, since some funky stuff is going on
|
||||
# underneath with "fnmatch"
|
||||
archive_file = archive_file.replace("[", '[[]')
|
||||
entries = []
|
||||
|
||||
rarc = self.getRARObj()
|
||||
|
||||
tries = 0
|
||||
while tries < 10:
|
||||
try:
|
||||
tries = tries+1
|
||||
entries = rarc.read_files( archive_file )
|
||||
|
||||
if entries[0][0].size != len(entries[0][1]):
|
||||
print "readArchiveFile(): [file is not expected size: {0} vs {1}] {2}:{3} [attempt # {4}]".format(
|
||||
entries[0][0].size,len(entries[0][1]), self.path, archive_file, tries)
|
||||
continue
|
||||
|
||||
except (OSError, IOError) as e:
|
||||
print "readArchiveFile(): [{0}] {1}:{2} attempt#{3}".format(str(e), self.path, archive_file, tries)
|
||||
time.sleep(1)
|
||||
except Exception as e:
|
||||
print "Unexpected exception in readArchiveFile(): [{0}] for {1}:{2} attempt#{3}".format(str(e), self.path, archive_file, tries)
|
||||
break
|
||||
|
||||
else:
|
||||
#Success"
|
||||
#entries is a list of of tuples: ( rarinfo, filedata)
|
||||
if tries > 1:
|
||||
print "Attempted read_files() {0} times".format(tries)
|
||||
if (len(entries) == 1):
|
||||
return entries[0][1]
|
||||
else:
|
||||
raise IOError
|
||||
|
||||
raise IOError
|
||||
|
||||
|
||||
|
||||
def writeArchiveFile( self, archive_file, data ):
|
||||
|
||||
if self.rar_exe_path is not None:
|
||||
try:
|
||||
tmp_folder = tempfile.mkdtemp()
|
||||
|
||||
tmp_file = os.path.join( tmp_folder, archive_file )
|
||||
|
||||
working_dir = os.path.dirname( os.path.abspath( self.path ) )
|
||||
|
||||
# TODO: will this break if 'archive_file' is in a subfolder. i.e. "foo/bar.txt"
|
||||
# will need to create the subfolder above, I guess...
|
||||
f = open(tmp_file, 'w')
|
||||
f.write( data )
|
||||
f.close()
|
||||
|
||||
# use external program to write file to Rar archive
|
||||
subprocess.call([self.rar_exe_path, 'a', '-w' + working_dir ,'-c-', '-ep', self.path, tmp_file],
|
||||
startupinfo=self.startupinfo,
|
||||
stdout=RarArchiver.devnull)
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
time.sleep(1)
|
||||
os.remove( tmp_file)
|
||||
os.rmdir( tmp_folder)
|
||||
except:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def removeArchiveFile( self, archive_file ):
|
||||
if self.rar_exe_path is not None:
|
||||
try:
|
||||
# use external program to remove file from Rar archive
|
||||
subprocess.call([self.rar_exe_path, 'd','-c-', self.path, archive_file],
|
||||
startupinfo=self.startupinfo,
|
||||
stdout=RarArchiver.devnull)
|
||||
|
||||
if platform.system() == "Darwin":
|
||||
time.sleep(1)
|
||||
except:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def getArchiveFilenameList( self ):
|
||||
|
||||
rarc = self.getRARObj()
|
||||
#namelist = [ item.filename for item in rarc.infolist() ]
|
||||
#return namelist
|
||||
|
||||
tries = 0
|
||||
while tries < 10:
|
||||
try:
|
||||
tries = tries+1
|
||||
namelist = [ item.filename for item in rarc.infolist() ]
|
||||
|
||||
except (OSError, IOError) as e:
|
||||
print "getArchiveFilenameList(): [{0}] {1} attempt#{2}".format(str(e), self.path, tries)
|
||||
time.sleep(1)
|
||||
|
||||
else:
|
||||
#Success"
|
||||
return namelist
|
||||
|
||||
raise e
|
||||
|
||||
|
||||
def getRARObj( self ):
|
||||
tries = 0
|
||||
while tries < 10:
|
||||
try:
|
||||
tries = tries+1
|
||||
rarc = UnRAR2.RarFile( self.path )
|
||||
|
||||
except (OSError, IOError) as e:
|
||||
print "getRARObj(): [{0}] {1} attempt#{2}".format(str(e), self.path, tries)
|
||||
time.sleep(1)
|
||||
|
||||
else:
|
||||
#Success"
|
||||
return rarc
|
||||
|
||||
raise e
|
||||
|
||||
#------------------------------------------
|
||||
# Folder implementation
|
||||
class FolderArchiver:
|
||||
|
||||
def __init__( self, path ):
|
||||
self.path = path
|
||||
self.comment_file_name = "ComicTaggerFolderComment.txt"
|
||||
|
||||
def getArchiveComment( self ):
|
||||
return self.readArchiveFile( self.comment_file_name )
|
||||
|
||||
def setArchiveComment( self, comment ):
|
||||
return self.writeArchiveFile( self.comment_file_name, comment )
|
||||
|
||||
def readArchiveFile( self, archive_file ):
|
||||
|
||||
data = ""
|
||||
fname = os.path.join( self.path, archive_file )
|
||||
try:
|
||||
with open( fname, 'rb' ) as f:
|
||||
data = f.read()
|
||||
f.close()
|
||||
except IOError as e:
|
||||
pass
|
||||
|
||||
return data
|
||||
|
||||
def writeArchiveFile( self, archive_file, data ):
|
||||
|
||||
fname = os.path.join( self.path, archive_file )
|
||||
try:
|
||||
with open(fname, 'w+') as f:
|
||||
f.write( data )
|
||||
f.close()
|
||||
except:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def removeArchiveFile( self, archive_file ):
|
||||
|
||||
fname = os.path.join( self.path, archive_file )
|
||||
try:
|
||||
os.remove( fname )
|
||||
except:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
def getArchiveFilenameList( self ):
|
||||
return self.listFiles( self.path )
|
||||
|
||||
def listFiles( self, folder ):
|
||||
|
||||
itemlist = list()
|
||||
|
||||
for item in os.listdir( folder ):
|
||||
itemlist.append( item )
|
||||
if os.path.isdir( item ):
|
||||
itemlist.extend( self.listFiles( os.path.join( folder, item ) ))
|
||||
|
||||
return itemlist
|
||||
|
||||
#------------------------------------------
|
||||
# Unknown implementation
|
||||
class UnknownArchiver:
|
||||
|
||||
def __init__( self, path ):
|
||||
self.path = path
|
||||
|
||||
def getArchiveComment( self ):
|
||||
return ""
|
||||
def setArchiveComment( self, comment ):
|
||||
return False
|
||||
def readArchiveFile( self ):
|
||||
return ""
|
||||
def writeArchiveFile( self, archive_file, data ):
|
||||
return False
|
||||
def removeArchiveFile( self, archive_file ):
|
||||
return False
|
||||
def getArchiveFilenameList( self ):
|
||||
return []
|
||||
|
||||
#------------------------------------------------------------------
|
||||
class ComicArchive:
|
||||
|
||||
class ArchiveType:
|
||||
Zip, Rar, Folder, Unknown = range(4)
|
||||
|
||||
def __init__( self, path ):
|
||||
self.path = path
|
||||
self.ci_xml_filename = 'ComicInfo.xml'
|
||||
self.comet_default_filename = 'CoMet.xml'
|
||||
self.resetCache()
|
||||
|
||||
if self.zipTest():
|
||||
self.archive_type = self.ArchiveType.Zip
|
||||
self.archiver = ZipArchiver( self.path )
|
||||
|
||||
elif self.rarTest():
|
||||
self.archive_type = self.ArchiveType.Rar
|
||||
self.archiver = RarArchiver( self.path )
|
||||
|
||||
elif os.path.isdir( self.path ):
|
||||
self.archive_type = self.ArchiveType.Folder
|
||||
self.archiver = FolderArchiver( self.path )
|
||||
else:
|
||||
self.archive_type = self.ArchiveType.Unknown
|
||||
self.archiver = UnknownArchiver( self.path )
|
||||
|
||||
# Clears the cached data
|
||||
def resetCache( self ):
|
||||
self.has_cix = None
|
||||
self.has_cbi = None
|
||||
self.has_comet = None
|
||||
self.comet_filename = None
|
||||
self.page_count = None
|
||||
self.page_list = None
|
||||
self.cix_md = None
|
||||
self.cbi_md = None
|
||||
self.comet_md = None
|
||||
|
||||
def rename( self, path ):
|
||||
self.path = path
|
||||
self.archiver.path = path
|
||||
|
||||
def setExternalRarProgram( self, rar_exe_path ):
|
||||
if self.isRar():
|
||||
self.archiver.rar_exe_path = rar_exe_path
|
||||
|
||||
def zipTest( self ):
|
||||
return zipfile.is_zipfile( self.path )
|
||||
|
||||
def rarTest( self ):
|
||||
try:
|
||||
rarc = UnRAR2.RarFile( self.path )
|
||||
except: # InvalidRARArchive:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def isZip( self ):
|
||||
return self.archive_type == self.ArchiveType.Zip
|
||||
|
||||
def isRar( self ):
|
||||
return self.archive_type == self.ArchiveType.Rar
|
||||
|
||||
def isFolder( self ):
|
||||
return self.archive_type == self.ArchiveType.Folder
|
||||
|
||||
def isWritable( self, check_rar_status=True ):
|
||||
if self.archive_type == self.ArchiveType.Unknown :
|
||||
return False
|
||||
|
||||
elif check_rar_status and self.isRar() and self.archiver.rar_exe_path is None:
|
||||
return False
|
||||
|
||||
elif not os.access(self.path, os.W_OK):
|
||||
return False
|
||||
|
||||
elif ((self.archive_type != self.ArchiveType.Folder) and
|
||||
(not os.access( os.path.dirname( os.path.abspath(self.path)), os.W_OK ))):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def isWritableForStyle( self, data_style ):
|
||||
|
||||
if self.isRar() and data_style == MetaDataStyle.CBI:
|
||||
return False
|
||||
|
||||
return self.isWritable()
|
||||
|
||||
def seemsToBeAComicArchive( self ):
|
||||
|
||||
# Do we even care about extensions??
|
||||
ext = os.path.splitext(self.path)[1].lower()
|
||||
|
||||
if (
|
||||
( self.isZip() or self.isRar() or self.isFolder() )
|
||||
and
|
||||
( self.getNumberOfPages() > 2)
|
||||
|
||||
):
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
|
||||
def readMetadata( self, style ):
|
||||
|
||||
if style == MetaDataStyle.CIX:
|
||||
return self.readCIX()
|
||||
elif style == MetaDataStyle.CBI:
|
||||
return self.readCBI()
|
||||
elif style == MetaDataStyle.COMET:
|
||||
return self.readCoMet()
|
||||
else:
|
||||
return GenericMetadata()
|
||||
|
||||
def writeMetadata( self, metadata, style ):
|
||||
|
||||
retcode = None
|
||||
if style == MetaDataStyle.CIX:
|
||||
retcode = self.writeCIX( metadata )
|
||||
elif style == MetaDataStyle.CBI:
|
||||
retcode = self.writeCBI( metadata )
|
||||
elif style == MetaDataStyle.COMET:
|
||||
retcode = self.writeCoMet( metadata )
|
||||
return retcode
|
||||
|
||||
|
||||
def hasMetadata( self, style ):
|
||||
|
||||
if style == MetaDataStyle.CIX:
|
||||
return self.hasCIX()
|
||||
elif style == MetaDataStyle.CBI:
|
||||
return self.hasCBI()
|
||||
elif style == MetaDataStyle.COMET:
|
||||
return self.hasCoMet()
|
||||
else:
|
||||
return False
|
||||
|
||||
def removeMetadata( self, style ):
|
||||
retcode = True
|
||||
if style == MetaDataStyle.CIX:
|
||||
retcode = self.removeCIX()
|
||||
elif style == MetaDataStyle.CBI:
|
||||
retcode = self.removeCBI()
|
||||
elif style == MetaDataStyle.COMET:
|
||||
retcode = self.removeCoMet()
|
||||
return retcode
|
||||
|
||||
def getPage( self, index ):
|
||||
|
||||
image_data = None
|
||||
|
||||
filename = self.getPageName( index )
|
||||
|
||||
if filename is not None:
|
||||
try:
|
||||
image_data = self.archiver.readArchiveFile( filename )
|
||||
except IOError:
|
||||
print "Error reading in page. Substituting logo page."
|
||||
fname = os.path.join(ComicTaggerSettings.baseDir(), 'graphics/nocover.png' )
|
||||
with open(fname) as x:
|
||||
image_data = x.read()
|
||||
|
||||
return image_data
|
||||
|
||||
def getPageName( self, index ):
|
||||
|
||||
page_list = self.getPageNameList()
|
||||
|
||||
num_pages = len( page_list )
|
||||
if num_pages == 0 or index >= num_pages:
|
||||
return None
|
||||
|
||||
return page_list[index]
|
||||
|
||||
def getPageNameList( self , sort_list=True):
|
||||
|
||||
if self.page_list is None:
|
||||
# get the list file names in the archive, and sort
|
||||
files = self.archiver.getArchiveFilenameList()
|
||||
|
||||
# seems like some archive creators are on Windows, and don't know about case-sensitivity!
|
||||
if sort_list:
|
||||
files.sort(key=lambda x: x.lower())
|
||||
|
||||
# make a sub-list of image files
|
||||
self.page_list = []
|
||||
for name in files:
|
||||
if ( name[-4:].lower() in [ ".jpg", "jpeg", ".png" ] and os.path.basename(name)[0] != "." ):
|
||||
self.page_list.append(name)
|
||||
|
||||
return self.page_list
|
||||
|
||||
def getNumberOfPages( self ):
|
||||
|
||||
if self.page_count is None:
|
||||
self.page_count = len( self.getPageNameList( ) )
|
||||
return self.page_count
|
||||
|
||||
def readCBI( self ):
|
||||
if self.cbi_md is None:
|
||||
raw_cbi = self.readRawCBI()
|
||||
if raw_cbi is None:
|
||||
self.cbi_md = GenericMetadata()
|
||||
else:
|
||||
self.cbi_md = ComicBookInfo().metadataFromString( raw_cbi )
|
||||
|
||||
self.cbi_md.setDefaultPageList( self.getNumberOfPages() )
|
||||
|
||||
return self.cbi_md
|
||||
|
||||
def readRawCBI( self ):
|
||||
if ( not self.hasCBI() ):
|
||||
return None
|
||||
|
||||
return self.archiver.getArchiveComment()
|
||||
|
||||
def hasCBI(self):
|
||||
if self.has_cbi is None:
|
||||
|
||||
#if ( not ( self.isZip() or self.isRar()) or not self.seemsToBeAComicArchive() ):
|
||||
if not self.seemsToBeAComicArchive():
|
||||
self.has_cbi = False
|
||||
else:
|
||||
comment = self.archiver.getArchiveComment()
|
||||
self.has_cbi = ComicBookInfo().validateString( comment )
|
||||
|
||||
return self.has_cbi
|
||||
|
||||
def writeCBI( self, metadata ):
|
||||
if metadata is not None:
|
||||
self.applyArchiveInfoToMetadata( metadata )
|
||||
cbi_string = ComicBookInfo().stringFromMetadata( metadata )
|
||||
write_success = self.archiver.setArchiveComment( cbi_string )
|
||||
if write_success:
|
||||
self.has_cbi = True
|
||||
self.cbi_md = metadata
|
||||
else:
|
||||
self.resetCache()
|
||||
return write_success
|
||||
else:
|
||||
return False
|
||||
|
||||
def removeCBI( self ):
|
||||
if self.hasCBI():
|
||||
write_success = self.archiver.setArchiveComment( "" )
|
||||
if write_success:
|
||||
self.has_cbi = False
|
||||
self.cbi_md = None
|
||||
else:
|
||||
self.resetCache()
|
||||
return write_success
|
||||
return True
|
||||
|
||||
def readCIX( self ):
|
||||
if self.cix_md is None:
|
||||
raw_cix = self.readRawCIX()
|
||||
if raw_cix is None or raw_cix == "":
|
||||
self.cix_md = GenericMetadata()
|
||||
else:
|
||||
self.cix_md = ComicInfoXml().metadataFromString( raw_cix )
|
||||
|
||||
#validate the existing page list (make sure count is correct)
|
||||
if len ( self.cix_md.pages ) != 0 :
|
||||
if len ( self.cix_md.pages ) != self.getNumberOfPages():
|
||||
# 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.setDefaultPageList( self.getNumberOfPages() )
|
||||
|
||||
return self.cix_md
|
||||
|
||||
def readRawCIX( self ):
|
||||
if not self.hasCIX():
|
||||
return None
|
||||
try:
|
||||
raw_cix = self.archiver.readArchiveFile( self.ci_xml_filename )
|
||||
except IOError:
|
||||
print "Error reading in raw CIX!"
|
||||
raw_cix = ""
|
||||
return raw_cix
|
||||
|
||||
def writeCIX(self, metadata):
|
||||
|
||||
if metadata is not None:
|
||||
self.applyArchiveInfoToMetadata( metadata, calc_page_sizes=True )
|
||||
cix_string = ComicInfoXml().stringFromMetadata( metadata )
|
||||
write_success = self.archiver.writeArchiveFile( self.ci_xml_filename, cix_string )
|
||||
if write_success:
|
||||
self.has_cix = True
|
||||
self.cix_md = metadata
|
||||
else:
|
||||
self.resetCache()
|
||||
return write_success
|
||||
else:
|
||||
return False
|
||||
|
||||
def removeCIX( self ):
|
||||
if self.hasCIX():
|
||||
write_success = self.archiver.removeArchiveFile( self.ci_xml_filename )
|
||||
if write_success:
|
||||
self.has_cix = False
|
||||
self.cix_md = None
|
||||
else:
|
||||
self.resetCache()
|
||||
return write_success
|
||||
return True
|
||||
|
||||
|
||||
def hasCIX(self):
|
||||
if self.has_cix is None:
|
||||
|
||||
if not self.seemsToBeAComicArchive():
|
||||
self.has_cix = False
|
||||
elif self.ci_xml_filename in self.archiver.getArchiveFilenameList():
|
||||
self.has_cix = True
|
||||
else:
|
||||
self.has_cix = False
|
||||
return self.has_cix
|
||||
|
||||
|
||||
def readCoMet( self ):
|
||||
if self.comet_md is None:
|
||||
raw_comet = self.readRawCoMet()
|
||||
if raw_comet is None or raw_comet == "":
|
||||
self.comet_md = GenericMetadata()
|
||||
else:
|
||||
self.comet_md = CoMet().metadataFromString( raw_comet )
|
||||
|
||||
self.comet_md.setDefaultPageList( self.getNumberOfPages() )
|
||||
#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.coverImage is not None:
|
||||
cover_idx = 0
|
||||
for idx,f in enumerate(self.getPageNameList()):
|
||||
if self.comet_md.coverImage == 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 readRawCoMet( self ):
|
||||
if not self.hasCoMet():
|
||||
print self.path, "doesn't have CoMet data!"
|
||||
return None
|
||||
|
||||
try:
|
||||
raw_comet = self.archiver.readArchiveFile( self.comet_filename )
|
||||
except IOError:
|
||||
print "Error reading in raw CoMet!"
|
||||
raw_comet = ""
|
||||
return raw_comet
|
||||
|
||||
def writeCoMet(self, metadata):
|
||||
|
||||
if metadata is not None:
|
||||
if not self.hasCoMet():
|
||||
self.comet_filename = self.comet_default_filename
|
||||
|
||||
self.applyArchiveInfoToMetadata( metadata )
|
||||
# Set the coverImage value, if it's not the first page
|
||||
cover_idx = int(metadata.getCoverPageIndexList()[0])
|
||||
if cover_idx != 0:
|
||||
metadata.coverImage = self.getPageName( cover_idx )
|
||||
|
||||
comet_string = CoMet().stringFromMetadata( metadata )
|
||||
write_success = self.archiver.writeArchiveFile( self.comet_filename, comet_string )
|
||||
if write_success:
|
||||
self.has_comet = True
|
||||
self.comet_md = metadata
|
||||
else:
|
||||
self.resetCache()
|
||||
return write_success
|
||||
else:
|
||||
return False
|
||||
|
||||
def removeCoMet( self ):
|
||||
if self.hasCoMet():
|
||||
write_success = self.archiver.removeArchiveFile( self.comet_filename )
|
||||
if write_success:
|
||||
self.has_comet = False
|
||||
self.comet_md = None
|
||||
else:
|
||||
self.resetCache()
|
||||
return write_success
|
||||
return True
|
||||
|
||||
def hasCoMet(self):
|
||||
if self.has_comet is None:
|
||||
self.has_comet = False
|
||||
if not self.seemsToBeAComicArchive():
|
||||
return self.has_comet
|
||||
|
||||
#look at all xml files in root, and search for CoMet data, get first
|
||||
for n in self.archiver.getArchiveFilenameList():
|
||||
if ( os.path.dirname(n) == "" and
|
||||
os.path.splitext(n)[1].lower() == '.xml'):
|
||||
# read in XML file, and validate it
|
||||
try:
|
||||
data = self.archiver.readArchiveFile( n )
|
||||
except:
|
||||
data = ""
|
||||
print "Error reading in Comet XML for validation!"
|
||||
if CoMet().validateString( data ):
|
||||
# since we found it, save it!
|
||||
self.comet_filename = n
|
||||
self.has_comet = True
|
||||
break
|
||||
|
||||
return self.has_comet
|
||||
|
||||
|
||||
|
||||
def applyArchiveInfoToMetadata( self, md, calc_page_sizes=False):
|
||||
md.pageCount = self.getNumberOfPages()
|
||||
|
||||
if calc_page_sizes:
|
||||
for p in 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.getPage( idx )
|
||||
if data is not None:
|
||||
try:
|
||||
im = Image.open(StringIO.StringIO(data))
|
||||
w,h = im.size
|
||||
|
||||
p['ImageSize'] = str(len(data))
|
||||
p['ImageHeight'] = str(h)
|
||||
p['ImageWidth'] = str(w)
|
||||
except IOError:
|
||||
p['ImageSize'] = str(len(data))
|
||||
|
||||
else:
|
||||
if 'ImageSize' not in p:
|
||||
data = self.getPage( idx )
|
||||
p['ImageSize'] = str(len(data))
|
||||
|
||||
|
||||
|
||||
def metadataFromFilename( self ):
|
||||
|
||||
metadata = GenericMetadata()
|
||||
|
||||
fnp = FileNameParser()
|
||||
fnp.parseFilename( self.path )
|
||||
|
||||
if fnp.issue != "":
|
||||
metadata.issue = fnp.issue
|
||||
if fnp.series != "":
|
||||
metadata.series = fnp.series
|
||||
if fnp.volume != "":
|
||||
metadata.volume = fnp.volume
|
||||
if fnp.year != "":
|
||||
metadata.year = fnp.year
|
||||
if fnp.issue_count != "":
|
||||
metadata.issueCount = fnp.issue_count
|
||||
|
||||
metadata.isEmpty = False
|
||||
|
||||
return metadata
|
||||
|
||||
def exportAsZip( self, zipfilename ):
|
||||
if self.archive_type == self.ArchiveType.Zip:
|
||||
# nothing to do, we're already a zip
|
||||
return True
|
||||
|
||||
zip_archiver = ZipArchiver( zipfilename )
|
||||
return zip_archiver.copyFromArchive( self.archiver )
|
||||
|
||||
152
comicbookinfo.py
@@ -1,152 +0,0 @@
|
||||
"""
|
||||
A python class to encapsulate the ComicBookInfo data
|
||||
"""
|
||||
|
||||
"""
|
||||
Copyright 2012 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) == str or type(s) == 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
|
||||
|
||||
289
comicinfoxml.py
@@ -1,289 +0,0 @@
|
||||
"""
|
||||
A python class to encapsulate ComicRack's ComicInfo.xml data
|
||||
"""
|
||||
|
||||
"""
|
||||
Copyright 2012 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( 'Series', md.series )
|
||||
assign( 'Number', md.issue )
|
||||
assign( 'Title', md.title )
|
||||
assign( 'Count', md.issueCount )
|
||||
assign( 'Volume', md.volume )
|
||||
assign( 'AlternateSeries', md.alternateSeries )
|
||||
assign( 'AlternateNumber', md.alternateNumber )
|
||||
assign( 'AlternateCount', md.alternateCount )
|
||||
assign( 'Summary', md.comments )
|
||||
assign( 'Notes', md.notes )
|
||||
assign( 'Year', md.year )
|
||||
assign( 'Month', md.month )
|
||||
assign( 'Publisher', md.publisher )
|
||||
assign( 'Imprint', md.imprint )
|
||||
assign( 'Genre', md.genre )
|
||||
assign( 'Web', md.webLink )
|
||||
assign( 'PageCount', md.pageCount )
|
||||
assign( 'Format', md.format )
|
||||
assign( 'LanguageISO', md.language )
|
||||
assign( 'Manga', md.manga )
|
||||
assign( 'Characters', md.characters )
|
||||
assign( 'Teams', md.teams )
|
||||
assign( 'Locations', md.locations )
|
||||
assign( 'ScanInformation', md.scanInfo )
|
||||
assign( 'StoryArc', md.storyArc )
|
||||
assign( 'SeriesGroup', md.seriesGroup )
|
||||
assign( 'AgeRating', md.maturityRating )
|
||||
|
||||
if md.blackAndWhite is not None and md.blackAndWhite:
|
||||
ET.SubElement(root, 'BlackAndWhite').text = "Yes"
|
||||
|
||||
# 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 )
|
||||
|
||||
# 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.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'
|
||||
):
|
||||
for name in n.text.split(','):
|
||||
metadata.addCredit( name.strip(), n.tag )
|
||||
|
||||
if n.tag == 'CoverArtist':
|
||||
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 )
|
||||
|
||||
517
comictagger.py
@@ -1,507 +1,20 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
"""
|
||||
A python script to tag comic archives
|
||||
"""
|
||||
|
||||
"""
|
||||
Copyright 2012 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 signal
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import traceback
|
||||
import time
|
||||
from pprint import pprint
|
||||
import json
|
||||
import platform
|
||||
import locale
|
||||
import sys
|
||||
|
||||
filename_encoding = sys.getfilesystemencoding()
|
||||
|
||||
try:
|
||||
qt_available = True
|
||||
from PyQt4 import QtCore, QtGui
|
||||
from taggerwindow import TaggerWindow
|
||||
except ImportError as e:
|
||||
qt_available = False
|
||||
if getattr(sys, 'frozen', False):
|
||||
# we are running in a bundle
|
||||
frozen = 'ever so'
|
||||
bundle_dir = sys._MEIPASS
|
||||
else:
|
||||
# we are running in a normal Python environment
|
||||
bundle_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# setup libunrar
|
||||
if not os.environ.get("UNRAR_LIB_PATH", None):
|
||||
os.environ["UNRAR_LIB_PATH"] = bundle_dir + "/libunrar.so"
|
||||
|
||||
from settings import ComicTaggerSettings
|
||||
from options import Options, MetaDataStyle
|
||||
from comicarchive import ComicArchive
|
||||
from issueidentifier import IssueIdentifier
|
||||
from genericmetadata import GenericMetadata
|
||||
from comicvinetalker import ComicVineTalker, ComicVineTalkerException
|
||||
from filerenamer import FileRenamer
|
||||
from cbltransformer import CBLTransformer
|
||||
from comictaggerlib.main import ctmain
|
||||
|
||||
import utils
|
||||
import codecs
|
||||
|
||||
class MultipleMatch():
|
||||
def __init__( self, filename, match_list):
|
||||
self.filename = filename
|
||||
self.matches = match_list
|
||||
|
||||
class OnlineMatchResults():
|
||||
def __init__(self):
|
||||
self.goodMatches = []
|
||||
self.noMatches = []
|
||||
self.multipleMatches = []
|
||||
self.writeFailures = []
|
||||
|
||||
#-----------------------------
|
||||
|
||||
def actual_issue_data_fetch( match, settings ):
|
||||
|
||||
# now get the particular issue data
|
||||
try:
|
||||
cv_md = ComicVineTalker().fetchIssueData( match['volume_id'], match['issue_number'], settings )
|
||||
except ComicVineTalkerException:
|
||||
print "Network error while getting issue details. Save aborted"
|
||||
return None
|
||||
|
||||
if settings.apply_cbl_transform_on_cv_import:
|
||||
cv_md = CBLTransformer( cv_md, settings ).apply()
|
||||
|
||||
return cv_md
|
||||
|
||||
def actual_metadata_save( ca, opts, md ):
|
||||
|
||||
if not opts.dryrun:
|
||||
# write out the new data
|
||||
if not ca.writeMetadata( md, opts.data_style ):
|
||||
print "The tag save seemed to fail!"
|
||||
return False
|
||||
else:
|
||||
print "Save complete."
|
||||
else:
|
||||
if opts.terse:
|
||||
print "dry-run option was set, so nothing was written"
|
||||
else:
|
||||
print "dry-run option was set, so nothing was written, but here is the final set of tags:"
|
||||
print u"{0}".format(md)
|
||||
return True
|
||||
|
||||
|
||||
def post_process_matches( match_results, opts, settings ):
|
||||
# now go through the match results
|
||||
if opts.show_save_summary:
|
||||
if len( match_results.goodMatches ) > 0:
|
||||
print "\nSuccessful matches:"
|
||||
print "------------------"
|
||||
for f in match_results.goodMatches:
|
||||
print f
|
||||
|
||||
if len( match_results.noMatches ) > 0:
|
||||
print "\nNo matches:"
|
||||
print "------------------"
|
||||
for f in match_results.noMatches:
|
||||
print f
|
||||
|
||||
if len( match_results.writeFailures ) > 0:
|
||||
print "\nFile Write Failures:"
|
||||
print "------------------"
|
||||
for f in match_results.writeFailures:
|
||||
print f
|
||||
|
||||
if not opts.show_save_summary and not opts.interactive:
|
||||
#jusr quit if we're not interactive or showing the summary
|
||||
return
|
||||
|
||||
if len( match_results.multipleMatches ) > 0:
|
||||
print "\nMultiple matches:"
|
||||
print "------------------"
|
||||
for mm in match_results.multipleMatches:
|
||||
print mm.filename
|
||||
for (counter,m) in enumerate(mm.matches):
|
||||
print u" {0}. {1} #{2} [{3}] ({4}/{5}) - {6}".format(counter,
|
||||
m['series'],
|
||||
m['issue_number'],
|
||||
m['publisher'],
|
||||
m['month'],
|
||||
m['year'],
|
||||
m['issue_title'])
|
||||
if opts.interactive:
|
||||
while True:
|
||||
i = raw_input("Choose a match #, or 's' to skip: ")
|
||||
if (i.isdigit() and int(i) in range(len(mm.matches))) or i == 's':
|
||||
break
|
||||
if i != 's':
|
||||
# save the data!
|
||||
# we know at this point, that the file is all good to go
|
||||
ca = ComicArchive( mm.filename )
|
||||
md = create_local_metadata( opts, ca, ca.hasMetadata(opts.data_style) )
|
||||
cv_md = actual_issue_data_fetch(mm.matches[int(i)], settings)
|
||||
md.overlay( cv_md )
|
||||
actual_metadata_save( ca, opts, md )
|
||||
|
||||
|
||||
print
|
||||
|
||||
|
||||
def cli_mode( opts, settings ):
|
||||
if len( opts.file_list ) < 1:
|
||||
print "You must specify at least one filename. Use the -h option for more info"
|
||||
return
|
||||
|
||||
match_results = OnlineMatchResults()
|
||||
|
||||
for f in opts.file_list:
|
||||
f = f.decode(filename_encoding, 'replace')
|
||||
process_file_cli( f, opts, settings, match_results )
|
||||
sys.stdout.flush()
|
||||
|
||||
post_process_matches( match_results, opts, settings )
|
||||
|
||||
|
||||
def create_local_metadata( opts, ca, has_desired_tags ):
|
||||
|
||||
md = GenericMetadata()
|
||||
md.setDefaultPageList( ca.getNumberOfPages() )
|
||||
|
||||
if has_desired_tags:
|
||||
md = ca.readMetadata( opts.data_style )
|
||||
|
||||
# now, overlay the parsed filename info
|
||||
if opts.parse_filename:
|
||||
md.overlay( ca.metadataFromFilename() )
|
||||
|
||||
# finally, use explicit stuff
|
||||
if opts.metadata is not None:
|
||||
md.overlay( opts.metadata )
|
||||
|
||||
return md
|
||||
|
||||
def process_file_cli( filename, opts, settings, match_results ):
|
||||
|
||||
batch_mode = len( opts.file_list ) > 1
|
||||
|
||||
ca = ComicArchive(filename)
|
||||
if settings.rar_exe_path != "":
|
||||
ca.setExternalRarProgram( settings.rar_exe_path )
|
||||
|
||||
if not ca.seemsToBeAComicArchive():
|
||||
print "Sorry, but "+ filename + " is not a comic archive!"
|
||||
return
|
||||
|
||||
#if not ca.isWritableForStyle( opts.data_style ) and ( opts.delete_tags or opts.save_tags or opts.rename_file ):
|
||||
if not ca.isWritable( ) and ( opts.delete_tags or opts.copy_tags or opts.save_tags or opts.rename_file ):
|
||||
print "This archive is not writable for that tag type"
|
||||
return
|
||||
|
||||
has = [ False, False, False ]
|
||||
if ca.hasCIX(): has[ MetaDataStyle.CIX ] = True
|
||||
if ca.hasCBI(): has[ MetaDataStyle.CBI ] = True
|
||||
if ca.hasCoMet(): has[ MetaDataStyle.COMET ] = True
|
||||
|
||||
if opts.print_tags:
|
||||
|
||||
|
||||
if opts.data_style is None:
|
||||
page_count = ca.getNumberOfPages()
|
||||
|
||||
brief = ""
|
||||
|
||||
if batch_mode:
|
||||
brief = "{0}: ".format(filename)
|
||||
|
||||
if ca.isZip(): brief += "ZIP archive "
|
||||
elif ca.isRar(): brief += "RAR archive "
|
||||
elif ca.isFolder(): brief += "Folder archive "
|
||||
|
||||
brief += "({0: >3} pages)".format(page_count)
|
||||
brief += " tags:[ "
|
||||
|
||||
if not ( has[ MetaDataStyle.CBI ] or has[ MetaDataStyle.CIX ] or has[ MetaDataStyle.COMET ] ):
|
||||
brief += "none "
|
||||
else:
|
||||
if has[ MetaDataStyle.CBI ]: brief += "CBL "
|
||||
if has[ MetaDataStyle.CIX ]: brief += "CR "
|
||||
if has[ MetaDataStyle.COMET ]: brief += "CoMet "
|
||||
brief += "]"
|
||||
|
||||
print brief
|
||||
|
||||
if opts.terse:
|
||||
return
|
||||
|
||||
print
|
||||
|
||||
if opts.data_style is None or opts.data_style == MetaDataStyle.CIX:
|
||||
if has[ MetaDataStyle.CIX ]:
|
||||
print "------ComicRack tags--------"
|
||||
if opts.raw:
|
||||
print u"{0}".format(unicode(ca.readRawCIX(), errors='ignore'))
|
||||
else:
|
||||
print u"{0}".format(ca.readCIX())
|
||||
|
||||
if opts.data_style is None or opts.data_style == MetaDataStyle.CBI:
|
||||
if has[ MetaDataStyle.CBI ]:
|
||||
print "------ComicBookLover tags--------"
|
||||
if opts.raw:
|
||||
pprint(json.loads(ca.readRawCBI()))
|
||||
else:
|
||||
print u"{0}".format(ca.readCBI())
|
||||
|
||||
if opts.data_style is None or opts.data_style == MetaDataStyle.COMET:
|
||||
if has[ MetaDataStyle.COMET ]:
|
||||
print "------CoMet tags--------"
|
||||
if opts.raw:
|
||||
print u"{0}".format(ca.readRawCoMet())
|
||||
else:
|
||||
print u"{0}".format(ca.readCoMet())
|
||||
|
||||
|
||||
elif opts.delete_tags:
|
||||
style_name = MetaDataStyle.name[ opts.data_style ]
|
||||
if has[ opts.data_style ]:
|
||||
if not opts.dryrun:
|
||||
if not ca.removeMetadata( opts.data_style ):
|
||||
print "{0}: Tag removal seemed to fail!".format( filename )
|
||||
else:
|
||||
print "{0}: Removed {1} tags.".format( filename, style_name )
|
||||
else:
|
||||
print "{0}: dry-run. {1} tags not removed".format( filename, style_name )
|
||||
else:
|
||||
print "{0}: This archive doesn't have {1} tags to remove.".format( filename, style_name )
|
||||
|
||||
elif opts.copy_tags:
|
||||
dst_style_name = MetaDataStyle.name[ opts.data_style ]
|
||||
if opts.no_overwrite and has[ opts.data_style ]:
|
||||
print "{0}: Already has {1} tags. Not overwriting.".format(filename, dst_style_name)
|
||||
return
|
||||
if opts.copy_source == opts.data_style:
|
||||
print "{0}: Destination and source are same: {1}. Nothing to do.".format(filename, dst_style_name)
|
||||
return
|
||||
|
||||
src_style_name = MetaDataStyle.name[ opts.copy_source ]
|
||||
if has[ opts.copy_source ]:
|
||||
if not opts.dryrun:
|
||||
md = ca.readMetadata( opts.copy_source )
|
||||
|
||||
if settings.apply_cbl_transform_on_bulk_operation and opts.data_style == MetaDataStyle.CBI:
|
||||
md = CBLTransformer( md, settings ).apply()
|
||||
|
||||
if not ca.writeMetadata( md, opts.data_style ):
|
||||
print u"{0}: Tag copy seemed to fail!".format( filename )
|
||||
else:
|
||||
print u"{0}: Copied {1} tags to {2} .".format( filename, src_style_name, dst_style_name )
|
||||
else:
|
||||
print u"{0}: dry-run. {1} tags not copied".format( filename, src_style_name )
|
||||
else:
|
||||
print u"{0}: This archive doesn't have {1} tags to copy.".format( filename, src_style_name )
|
||||
|
||||
|
||||
elif opts.save_tags:
|
||||
|
||||
if opts.no_overwrite and has[ opts.data_style ]:
|
||||
print u"{0}: Already has {1} tags. Not overwriting.".format(filename, MetaDataStyle.name[ opts.data_style ])
|
||||
return
|
||||
|
||||
if batch_mode:
|
||||
print u"Processing {0}: ".format(filename)
|
||||
|
||||
md = create_local_metadata( opts, ca, has[ opts.data_style ] )
|
||||
|
||||
# now, search online
|
||||
if opts.search_online:
|
||||
if opts.issue_id is not None:
|
||||
# we were given the actual ID to search with
|
||||
try:
|
||||
cv_md = ComicVineTalker().fetchIssueDataByIssueID( opts.issue_id, settings )
|
||||
except ComicVineTalkerException:
|
||||
print "Network error while getting issue details. Save aborted"
|
||||
return None
|
||||
|
||||
if cv_md is None:
|
||||
print "No match for ID {0} was found.".format(opts.issue_id)
|
||||
return None
|
||||
|
||||
if settings.apply_cbl_transform_on_cv_import:
|
||||
cv_md = CBLTransformer( cv_md, settings ).apply()
|
||||
else:
|
||||
ii = IssueIdentifier( ca, settings )
|
||||
|
||||
if md is None or md.isEmpty:
|
||||
print "No metadata given to search online with!"
|
||||
return
|
||||
|
||||
def myoutput( text ):
|
||||
if opts.verbose:
|
||||
IssueIdentifier.defaultWriteOutput( text )
|
||||
|
||||
# use our overlayed MD struct to search
|
||||
ii.setAdditionalMetadata( md )
|
||||
ii.onlyUseAdditionalMetaData = True
|
||||
ii.setOutputFunction( myoutput )
|
||||
ii.cover_page_index = md.getCoverPageIndexList()[0]
|
||||
matches = ii.search()
|
||||
|
||||
result = ii.search_result
|
||||
|
||||
found_match = False
|
||||
choices = False
|
||||
low_confidence = False
|
||||
|
||||
if result == ii.ResultNoMatches:
|
||||
pass
|
||||
elif result == ii.ResultFoundMatchButBadCoverScore:
|
||||
low_confidence = True
|
||||
found_match = True
|
||||
elif result == ii.ResultFoundMatchButNotFirstPage :
|
||||
found_match = True
|
||||
elif result == ii.ResultMultipleMatchesWithBadImageScores:
|
||||
low_confidence = True
|
||||
choices = True
|
||||
elif result == ii.ResultOneGoodMatch:
|
||||
found_match = True
|
||||
elif result == ii.ResultMultipleGoodMatches:
|
||||
choices = True
|
||||
|
||||
if choices:
|
||||
print "Online search: Multiple matches. Save aborted"
|
||||
match_results.multipleMatches.append(MultipleMatch(filename,matches))
|
||||
return
|
||||
if low_confidence and opts.abortOnLowConfidence:
|
||||
print "Online search: Low confidence match. Save aborted"
|
||||
match_results.noMatches.append(filename)
|
||||
return
|
||||
if not found_match:
|
||||
print "Online search: No match found. Save aborted"
|
||||
match_results.noMatches.append(filename)
|
||||
return
|
||||
|
||||
|
||||
# we got here, so we have a single match
|
||||
|
||||
# now get the particular issue data
|
||||
cv_md = actual_issue_data_fetch(matches[0], settings)
|
||||
if cv_md is None:
|
||||
return
|
||||
|
||||
md.overlay( cv_md )
|
||||
|
||||
# ok, done building our metadata. time to save
|
||||
if not actual_metadata_save( ca, opts, md ):
|
||||
match_results.writeFailures.append(filename)
|
||||
else:
|
||||
match_results.goodMatches.append(filename)
|
||||
|
||||
elif opts.rename_file:
|
||||
|
||||
msg_hdr = ""
|
||||
if batch_mode:
|
||||
msg_hdr = u"{0}: ".format(filename)
|
||||
|
||||
if opts.data_style is not None:
|
||||
use_tags = has[ opts.data_style ]
|
||||
else:
|
||||
use_tags = False
|
||||
|
||||
md = create_local_metadata( opts, ca, use_tags )
|
||||
|
||||
if md.series is None:
|
||||
print msg_hdr + "Can't rename without series name"
|
||||
return
|
||||
|
||||
new_ext = None # default
|
||||
if settings.rename_extension_based_on_archive:
|
||||
if ca.isZip():
|
||||
new_ext = ".cbz"
|
||||
elif ca.isRar():
|
||||
new_ext = ".cbr"
|
||||
|
||||
renamer = FileRenamer( md )
|
||||
renamer.setTemplate( settings.rename_template )
|
||||
renamer.setIssueZeroPadding( settings.rename_issue_number_padding )
|
||||
renamer.setSmartCleanup( settings.rename_use_smart_string_cleanup )
|
||||
|
||||
new_name = renamer.determineName( filename, ext=new_ext )
|
||||
|
||||
if new_name == os.path.basename(filename):
|
||||
print msg_hdr + "Filename is already good!"
|
||||
return
|
||||
|
||||
folder = os.path.dirname( os.path.abspath( filename ) )
|
||||
new_abs_path = utils.unique_file( os.path.join( folder, new_name ) )
|
||||
|
||||
suffix = ""
|
||||
if not opts.dryrun:
|
||||
# rename the file
|
||||
os.rename( filename, new_abs_path )
|
||||
else:
|
||||
suffix = " (dry-run, no change)"
|
||||
|
||||
print u"renamed '{0}' -> '{1}' {2}".format(os.path.basename(filename), new_name, suffix)
|
||||
|
||||
#-----------------------------
|
||||
|
||||
def main():
|
||||
|
||||
# try to make stdout encodings happy for unicode
|
||||
if platform.system() == "Darwin":
|
||||
preferred_encoding = "utf-8"
|
||||
else:
|
||||
preferred_encoding = locale.getpreferredencoding()
|
||||
sys.stdout = codecs.getwriter(preferred_encoding)(sys.stdout)
|
||||
|
||||
opts = Options()
|
||||
opts.parseCmdLineArgs()
|
||||
|
||||
settings = ComicTaggerSettings()
|
||||
# make sure unrar program is in the path for the UnRAR class
|
||||
utils.addtopath(os.path.dirname(settings.unrar_exe_path))
|
||||
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
|
||||
if not qt_available and not opts.no_gui:
|
||||
opts.no_gui = True
|
||||
print "QT is not available."
|
||||
|
||||
if opts.no_gui:
|
||||
cli_mode( opts, settings )
|
||||
|
||||
else:
|
||||
|
||||
app = QtGui.QApplication(sys.argv)
|
||||
|
||||
if platform.system() != "Linux":
|
||||
img = QtGui.QPixmap(os.path.join(ComicTaggerSettings.baseDir(), 'graphics/tags.png' ))
|
||||
splash = QtGui.QSplashScreen(img)
|
||||
splash.show()
|
||||
splash.raise_()
|
||||
app.processEvents()
|
||||
|
||||
try:
|
||||
tagger_window = TaggerWindow( opts.file_list, settings )
|
||||
tagger_window.show()
|
||||
|
||||
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() )
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
ctmain()
|
||||
|
||||
0
comictaggerlib/__init__.py
Normal file
245
comictaggerlib/autotagmatchwindow.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""A PyQT4 dialog to select from automated issue matches"""
|
||||
|
||||
# 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 os
|
||||
#import sys
|
||||
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
#from PyQt4.QtCore import QUrl, pyqtSignal, QByteArray
|
||||
|
||||
from settings import ComicTaggerSettings
|
||||
from comicarchive import MetaDataStyle
|
||||
from coverimagewidget import CoverImageWidget
|
||||
from comictaggerlib.ui.qtutils import reduceWidgetFontSize
|
||||
#from imagefetcher import ImageFetcher
|
||||
#from comicvinetalker import ComicVineTalker
|
||||
#import utils
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
reduceWidgetFontSize(self.twList)
|
||||
reduceWidgetFontSize(self.teDescription, 1)
|
||||
|
||||
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.match_set_list = match_set_list
|
||||
self.style = style
|
||||
self.fetch_func = fetch_func
|
||||
|
||||
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()
|
||||
|
||||
def updateData(self):
|
||||
|
||||
self.current_match_set = self.match_set_list[
|
||||
self.current_match_set_idx]
|
||||
|
||||
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):
|
||||
|
||||
while self.twList.rowCount() > 0:
|
||||
self.twList.removeRow(0)
|
||||
|
||||
self.twList.setSortingEnabled(False)
|
||||
|
||||
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)
|
||||
|
||||
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'])
|
||||
|
||||
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)
|
||||
|
||||
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.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 cellDoubleClicked(self, r, c):
|
||||
self.accept()
|
||||
|
||||
def currentItemChanged(self, curr, prev):
|
||||
|
||||
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)
|
||||
|
||||
def currentMatch(self):
|
||||
row = self.twList.currentRow()
|
||||
match = self.twList.item(row, 0).data(
|
||||
QtCore.Qt.UserRole).toPyObject()[0]
|
||||
return match
|
||||
|
||||
def accept(self):
|
||||
|
||||
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 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
|
||||
|
||||
QtGui.QDialog.reject(self)
|
||||
|
||||
def saveMatch(self):
|
||||
|
||||
match = self.currentMatch()
|
||||
ca = self.current_match_set.ca
|
||||
|
||||
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 Comic Vine to get issue details!"))
|
||||
return
|
||||
|
||||
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!"))
|
||||
69
comictaggerlib/autotagprogresswindow.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""A PyQT4 dialog to show ID log and progress"""
|
||||
|
||||
# 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 os
|
||||
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
|
||||
from settings import ComicTaggerSettings
|
||||
from coverimagewidget import CoverImageWidget
|
||||
from comictaggerlib.ui.qtutils import reduceWidgetFontSize
|
||||
#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)
|
||||
|
||||
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
|
||||
|
||||
self.setWindowFlags(self.windowFlags() |
|
||||
QtCore.Qt.WindowSystemMenuHint |
|
||||
QtCore.Qt.WindowMaximizeButtonHint)
|
||||
|
||||
reduceWidgetFontSize(self.textEdit)
|
||||
|
||||
def setArchiveImage(self, img_data):
|
||||
self.setCoverImage(img_data, self.archiveCoverWidget)
|
||||
|
||||
def setTestImage(self, img_data):
|
||||
self.setCoverImage(img_data, self.testCoverWidget)
|
||||
|
||||
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
|
||||
127
comictaggerlib/autotagstartwindow.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""A PyQT4 dialog to confirm and set options for auto-tag"""
|
||||
|
||||
# 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 os
|
||||
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
|
||||
from settings import ComicTaggerSettings
|
||||
#from settingswindow import SettingsWindow
|
||||
#from filerenamer import FileRenamer
|
||||
#import utils
|
||||
|
||||
|
||||
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)
|
||||
|
||||
self.setWindowFlags(self.windowFlags() &
|
||||
~QtCore.Qt.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)
|
||||
if self.settings.wait_and_retry_on_rate_limit:
|
||||
self.cbxWaitForRateLimit.setCheckState(QtCore.Qt.Checked)
|
||||
|
||||
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.waitAndRetryOnRateLimit = False
|
||||
self.searchString = None
|
||||
self.nameLengthMatchTolerance = self.settings.id_length_delta_thresh
|
||||
|
||||
def searchStringToggle(self):
|
||||
enable = self.cbxSpecifySearchString.isChecked()
|
||||
self.leSearchString.setEnabled(enable)
|
||||
|
||||
def accept(self):
|
||||
QtGui.QDialog.accept(self)
|
||||
|
||||
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())
|
||||
self.waitAndRetryOnRateLimit = self.cbxWaitForRateLimit.isChecked()
|
||||
|
||||
# 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
|
||||
self.settings.wait_and_retry_on_rate_limit = self.waitAndRetryOnRateLimit
|
||||
|
||||
if self.cbxSpecifySearchString.isChecked():
|
||||
self.searchString = unicode(self.leSearchString.text())
|
||||
if len(self.searchString) == 0:
|
||||
self.searchString = None
|
||||
97
comictaggerlib/cbltransformer.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""A class to manage modifying metadata specifically for CBL/CBI"""
|
||||
|
||||
# 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 os
|
||||
|
||||
#import utils
|
||||
|
||||
|
||||
class CBLTransformer:
|
||||
|
||||
def __init__(self, metadata, settings):
|
||||
self.metadata = metadata
|
||||
self.settings = settings
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
if self.settings.copy_characters_to_tags:
|
||||
add_string_list_to_tags(self.metadata.characters)
|
||||
|
||||
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)
|
||||
|
||||
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.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
|
||||
|
||||
return self.metadata
|
||||
584
comictaggerlib/cli.py
Normal file
@@ -0,0 +1,584 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
"""ComicTagger CLI functions"""
|
||||
|
||||
# Copyright 2013 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 os
|
||||
from pprint import pprint
|
||||
import json
|
||||
#import signal
|
||||
#import traceback
|
||||
#import time
|
||||
#import platform
|
||||
#import locale
|
||||
#import codecs
|
||||
|
||||
filename_encoding = sys.getfilesystemencoding()
|
||||
|
||||
from settings import ComicTaggerSettings
|
||||
from options import Options
|
||||
from comicarchive import ComicArchive, MetaDataStyle
|
||||
from issueidentifier import IssueIdentifier
|
||||
from genericmetadata import GenericMetadata
|
||||
from comicvinetalker import ComicVineTalker, ComicVineTalkerException
|
||||
from filerenamer import FileRenamer
|
||||
from cbltransformer import CBLTransformer
|
||||
import utils
|
||||
|
||||
|
||||
class MultipleMatch():
|
||||
|
||||
def __init__(self, filename, match_list):
|
||||
self.filename = filename
|
||||
self.matches = match_list
|
||||
|
||||
|
||||
class OnlineMatchResults():
|
||||
|
||||
def __init__(self):
|
||||
self.goodMatches = []
|
||||
self.noMatches = []
|
||||
self.multipleMatches = []
|
||||
self.lowConfidenceMatches = []
|
||||
self.writeFailures = []
|
||||
self.fetchDataFailures = []
|
||||
|
||||
#-----------------------------
|
||||
|
||||
|
||||
def actual_issue_data_fetch(match, settings, opts):
|
||||
|
||||
# now get the particular issue data
|
||||
try:
|
||||
comicVine = ComicVineTalker()
|
||||
comicVine.wait_for_rate_limit = opts.wait_and_retry_on_rate_limit
|
||||
cv_md = comicVine.fetchIssueData(
|
||||
match['volume_id'], match['issue_number'], settings)
|
||||
except ComicVineTalkerException:
|
||||
print >> sys.stderr, "Network error while getting issue details. Save aborted"
|
||||
return None
|
||||
|
||||
if settings.apply_cbl_transform_on_cv_import:
|
||||
cv_md = CBLTransformer(cv_md, settings).apply()
|
||||
|
||||
return cv_md
|
||||
|
||||
|
||||
def actual_metadata_save(ca, opts, md):
|
||||
|
||||
if not opts.dryrun:
|
||||
# write out the new data
|
||||
if not ca.writeMetadata(md, opts.data_style):
|
||||
print >> sys.stderr, "The tag save seemed to fail!"
|
||||
return False
|
||||
else:
|
||||
print >> sys.stderr, "Save complete."
|
||||
else:
|
||||
if opts.terse:
|
||||
print >> sys.stderr, "dry-run option was set, so nothing was written"
|
||||
else:
|
||||
print >> sys.stderr, "dry-run option was set, so nothing was written, but here is the final set of tags:"
|
||||
print(u"{0}".format(md))
|
||||
return True
|
||||
|
||||
|
||||
def display_match_set_for_choice(label, match_set, opts, settings):
|
||||
print(u"{0} -- {1}:".format(match_set.filename, label))
|
||||
|
||||
# sort match list by year
|
||||
match_set.matches.sort(key=lambda k: k['year'])
|
||||
|
||||
for (counter, m) in enumerate(match_set.matches):
|
||||
counter += 1
|
||||
print(
|
||||
u" {0}. {1} #{2} [{3}] ({4}/{5}) - {6}".format(
|
||||
counter,
|
||||
m['series'],
|
||||
m['issue_number'],
|
||||
m['publisher'],
|
||||
m['month'],
|
||||
m['year'],
|
||||
m['issue_title']))
|
||||
if opts.interactive:
|
||||
while True:
|
||||
i = raw_input("Choose a match #, or 's' to skip: ")
|
||||
if (i.isdigit() and int(i) in range(
|
||||
1, len(match_set.matches) + 1)) or i == 's':
|
||||
break
|
||||
if i != 's':
|
||||
i = int(i) - 1
|
||||
# save the data!
|
||||
# we know at this point, that the file is all good to go
|
||||
ca = ComicArchive(
|
||||
match_set.filename,
|
||||
settings.rar_exe_path,
|
||||
ComicTaggerSettings.getGraphic('nocover.png'))
|
||||
md = create_local_metadata(
|
||||
opts, ca, ca.hasMetadata(opts.data_style))
|
||||
cv_md = actual_issue_data_fetch(
|
||||
match_set.matches[int(i)], settings, opts)
|
||||
md.overlay(cv_md)
|
||||
actual_metadata_save(ca, opts, md)
|
||||
|
||||
|
||||
def post_process_matches(match_results, opts, settings):
|
||||
# now go through the match results
|
||||
if opts.show_save_summary:
|
||||
if len(match_results.goodMatches) > 0:
|
||||
print("\nSuccessful matches:\n------------------")
|
||||
for f in match_results.goodMatches:
|
||||
print(f)
|
||||
|
||||
if len(match_results.noMatches) > 0:
|
||||
print("\nNo matches:\n------------------")
|
||||
for f in match_results.noMatches:
|
||||
print(f)
|
||||
|
||||
if len(match_results.writeFailures) > 0:
|
||||
print("\nFile Write Failures:\n------------------")
|
||||
for f in match_results.writeFailures:
|
||||
print(f)
|
||||
|
||||
if len(match_results.fetchDataFailures) > 0:
|
||||
print("\nNetwork Data Fetch Failures:\n------------------")
|
||||
for f in match_results.fetchDataFailures:
|
||||
print(f)
|
||||
|
||||
if not opts.show_save_summary and not opts.interactive:
|
||||
# just quit if we're not interactive or showing the summary
|
||||
return
|
||||
|
||||
if len(match_results.multipleMatches) > 0:
|
||||
print(
|
||||
"\nArchives with multiple high-confidence matches:\n------------------")
|
||||
for match_set in match_results.multipleMatches:
|
||||
display_match_set_for_choice(
|
||||
"Multiple high-confidence matches", match_set, opts, settings)
|
||||
|
||||
if len(match_results.lowConfidenceMatches) > 0:
|
||||
print("\nArchives with low-confidence matches:\n------------------")
|
||||
for match_set in match_results.lowConfidenceMatches:
|
||||
if len(match_set.matches) == 1:
|
||||
label = "Single low-confidence match"
|
||||
else:
|
||||
label = "Multiple low-confidence matches"
|
||||
|
||||
display_match_set_for_choice(label, match_set, opts, settings)
|
||||
|
||||
|
||||
def cli_mode(opts, settings):
|
||||
if len(opts.file_list) < 1:
|
||||
print >> sys.stderr, "You must specify at least one filename. Use the -h option for more info"
|
||||
return
|
||||
|
||||
match_results = OnlineMatchResults()
|
||||
|
||||
for f in opts.file_list:
|
||||
if isinstance(f, str):
|
||||
f = f.decode(filename_encoding, 'replace')
|
||||
process_file_cli(f, opts, settings, match_results)
|
||||
sys.stdout.flush()
|
||||
|
||||
post_process_matches(match_results, opts, settings)
|
||||
|
||||
|
||||
def create_local_metadata(opts, ca, has_desired_tags):
|
||||
|
||||
md = GenericMetadata()
|
||||
md.setDefaultPageList(ca.getNumberOfPages())
|
||||
|
||||
if has_desired_tags:
|
||||
md = ca.readMetadata(opts.data_style)
|
||||
|
||||
# now, overlay the parsed filename info
|
||||
if opts.parse_filename:
|
||||
md.overlay(ca.metadataFromFilename())
|
||||
|
||||
# finally, use explicit stuff
|
||||
if opts.metadata is not None:
|
||||
md.overlay(opts.metadata)
|
||||
|
||||
return md
|
||||
|
||||
|
||||
def process_file_cli(filename, opts, settings, match_results):
|
||||
|
||||
batch_mode = len(opts.file_list) > 1
|
||||
|
||||
ca = ComicArchive(
|
||||
filename,
|
||||
settings.rar_exe_path,
|
||||
ComicTaggerSettings.getGraphic('nocover.png'))
|
||||
|
||||
if not os.path.lexists(filename):
|
||||
print >> sys.stderr, "Cannot find " + filename
|
||||
return
|
||||
|
||||
if not ca.seemsToBeAComicArchive():
|
||||
print >> sys.stderr, "Sorry, but " + \
|
||||
filename + " is not a comic archive!"
|
||||
return
|
||||
|
||||
# if not ca.isWritableForStyle(opts.data_style) and (opts.delete_tags or
|
||||
# opts.save_tags or opts.rename_file):
|
||||
if not ca.isWritable() and (
|
||||
opts.delete_tags or opts.copy_tags or opts.save_tags or opts.rename_file):
|
||||
print >> sys.stderr, "This archive is not writable for that tag type"
|
||||
return
|
||||
|
||||
has = [False, False, False]
|
||||
if ca.hasCIX():
|
||||
has[MetaDataStyle.CIX] = True
|
||||
if ca.hasCBI():
|
||||
has[MetaDataStyle.CBI] = True
|
||||
if ca.hasCoMet():
|
||||
has[MetaDataStyle.COMET] = True
|
||||
|
||||
if opts.print_tags:
|
||||
|
||||
if opts.data_style is None:
|
||||
page_count = ca.getNumberOfPages()
|
||||
|
||||
brief = ""
|
||||
|
||||
if batch_mode:
|
||||
brief = u"{0}: ".format(filename)
|
||||
|
||||
if ca.isZip():
|
||||
brief += "ZIP archive "
|
||||
elif ca.isRar():
|
||||
brief += "RAR archive "
|
||||
elif ca.isFolder():
|
||||
brief += "Folder archive "
|
||||
|
||||
brief += "({0: >3} pages)".format(page_count)
|
||||
brief += " tags:[ "
|
||||
|
||||
if not (has[MetaDataStyle.CBI] or has[
|
||||
MetaDataStyle.CIX] or has[MetaDataStyle.COMET]):
|
||||
brief += "none "
|
||||
else:
|
||||
if has[MetaDataStyle.CBI]:
|
||||
brief += "CBL "
|
||||
if has[MetaDataStyle.CIX]:
|
||||
brief += "CR "
|
||||
if has[MetaDataStyle.COMET]:
|
||||
brief += "CoMet "
|
||||
brief += "]"
|
||||
|
||||
print brief
|
||||
|
||||
if opts.terse:
|
||||
return
|
||||
|
||||
print
|
||||
|
||||
if opts.data_style is None or opts.data_style == MetaDataStyle.CIX:
|
||||
if has[MetaDataStyle.CIX]:
|
||||
print("--------- ComicRack tags ---------")
|
||||
if opts.raw:
|
||||
print(
|
||||
u"{0}".format(
|
||||
unicode(
|
||||
ca.readRawCIX(),
|
||||
errors='ignore')))
|
||||
else:
|
||||
print(u"{0}".format(ca.readCIX()))
|
||||
|
||||
if opts.data_style is None or opts.data_style == MetaDataStyle.CBI:
|
||||
if has[MetaDataStyle.CBI]:
|
||||
print("------- ComicBookLover tags -------")
|
||||
if opts.raw:
|
||||
pprint(json.loads(ca.readRawCBI()))
|
||||
else:
|
||||
print(u"{0}".format(ca.readCBI()))
|
||||
|
||||
if opts.data_style is None or opts.data_style == MetaDataStyle.COMET:
|
||||
if has[MetaDataStyle.COMET]:
|
||||
print("----------- CoMet tags -----------")
|
||||
if opts.raw:
|
||||
print(u"{0}".format(ca.readRawCoMet()))
|
||||
else:
|
||||
print(u"{0}".format(ca.readCoMet()))
|
||||
|
||||
elif opts.delete_tags:
|
||||
style_name = MetaDataStyle.name[opts.data_style]
|
||||
if has[opts.data_style]:
|
||||
if not opts.dryrun:
|
||||
if not ca.removeMetadata(opts.data_style):
|
||||
print(u"{0}: Tag removal seemed to fail!".format(filename))
|
||||
else:
|
||||
print(
|
||||
u"{0}: Removed {1} tags.".format(filename, style_name))
|
||||
else:
|
||||
print(
|
||||
u"{0}: dry-run. {1} tags not removed".format(filename, style_name))
|
||||
else:
|
||||
print(u"{0}: This archive doesn't have {1} tags to remove.".format(
|
||||
filename, style_name))
|
||||
|
||||
elif opts.copy_tags:
|
||||
dst_style_name = MetaDataStyle.name[opts.data_style]
|
||||
if opts.no_overwrite and has[opts.data_style]:
|
||||
print(u"{0}: Already has {1} tags. Not overwriting.".format(
|
||||
filename, dst_style_name))
|
||||
return
|
||||
if opts.copy_source == opts.data_style:
|
||||
print(
|
||||
u"{0}: Destination and source are same: {1}. Nothing to do.".format(
|
||||
filename,
|
||||
dst_style_name))
|
||||
return
|
||||
|
||||
src_style_name = MetaDataStyle.name[opts.copy_source]
|
||||
if has[opts.copy_source]:
|
||||
if not opts.dryrun:
|
||||
md = ca.readMetadata(opts.copy_source)
|
||||
|
||||
if settings.apply_cbl_transform_on_bulk_operation and opts.data_style == MetaDataStyle.CBI:
|
||||
md = CBLTransformer(md, settings).apply()
|
||||
|
||||
if not ca.writeMetadata(md, opts.data_style):
|
||||
print(u"{0}: Tag copy seemed to fail!".format(filename))
|
||||
else:
|
||||
print(u"{0}: Copied {1} tags to {2} .".format(
|
||||
filename, src_style_name, dst_style_name))
|
||||
else:
|
||||
print(
|
||||
u"{0}: dry-run. {1} tags not copied".format(filename, src_style_name))
|
||||
else:
|
||||
print(u"{0}: This archive doesn't have {1} tags to copy.".format(
|
||||
filename, src_style_name))
|
||||
|
||||
elif opts.save_tags:
|
||||
|
||||
if opts.no_overwrite and has[opts.data_style]:
|
||||
print(u"{0}: Already has {1} tags. Not overwriting.".format(
|
||||
filename, MetaDataStyle.name[opts.data_style]))
|
||||
return
|
||||
|
||||
if batch_mode:
|
||||
print(u"Processing {0}...".format(filename))
|
||||
|
||||
md = create_local_metadata(opts, ca, has[opts.data_style])
|
||||
if md.issue is None or md.issue == "":
|
||||
if opts.assume_issue_is_one_if_not_set:
|
||||
md.issue = "1"
|
||||
|
||||
# now, search online
|
||||
if opts.search_online:
|
||||
if opts.issue_id is not None:
|
||||
# we were given the actual ID to search with
|
||||
try:
|
||||
comicVine = ComicVineTalker()
|
||||
comicVine.wait_for_rate_limit = opts.wait_and_retry_on_rate_limit
|
||||
cv_md = comicVine.fetchIssueDataByIssueID(
|
||||
opts.issue_id, settings)
|
||||
except ComicVineTalkerException:
|
||||
print >> sys.stderr, "Network error while getting issue details. Save aborted"
|
||||
match_results.fetchDataFailures.append(filename)
|
||||
return
|
||||
|
||||
if cv_md is None:
|
||||
print >> sys.stderr, "No match for ID {0} was found.".format(
|
||||
opts.issue_id)
|
||||
match_results.noMatches.append(filename)
|
||||
return
|
||||
|
||||
if settings.apply_cbl_transform_on_cv_import:
|
||||
cv_md = CBLTransformer(cv_md, settings).apply()
|
||||
else:
|
||||
ii = IssueIdentifier(ca, settings)
|
||||
|
||||
if md is None or md.isEmpty:
|
||||
print >> sys.stderr, "No metadata given to search online with!"
|
||||
match_results.noMatches.append(filename)
|
||||
return
|
||||
|
||||
def myoutput(text):
|
||||
if opts.verbose:
|
||||
IssueIdentifier.defaultWriteOutput(text)
|
||||
|
||||
# use our overlayed MD struct to search
|
||||
ii.setAdditionalMetadata(md)
|
||||
ii.onlyUseAdditionalMetaData = True
|
||||
ii.waitAndRetryOnRateLimit = opts.wait_and_retry_on_rate_limit
|
||||
ii.setOutputFunction(myoutput)
|
||||
ii.cover_page_index = md.getCoverPageIndexList()[0]
|
||||
matches = ii.search()
|
||||
|
||||
result = ii.search_result
|
||||
|
||||
found_match = False
|
||||
choices = False
|
||||
low_confidence = False
|
||||
|
||||
if result == ii.ResultNoMatches:
|
||||
pass
|
||||
elif result == ii.ResultFoundMatchButBadCoverScore:
|
||||
low_confidence = True
|
||||
found_match = True
|
||||
elif result == ii.ResultFoundMatchButNotFirstPage:
|
||||
found_match = True
|
||||
elif result == ii.ResultMultipleMatchesWithBadImageScores:
|
||||
low_confidence = True
|
||||
choices = True
|
||||
elif result == ii.ResultOneGoodMatch:
|
||||
found_match = True
|
||||
elif result == ii.ResultMultipleGoodMatches:
|
||||
choices = True
|
||||
|
||||
if choices:
|
||||
if low_confidence:
|
||||
print >> sys.stderr, "Online search: Multiple low confidence matches. Save aborted"
|
||||
match_results.lowConfidenceMatches.append(
|
||||
MultipleMatch(filename, matches))
|
||||
return
|
||||
else:
|
||||
print >> sys.stderr, "Online search: Multiple good matches. Save aborted"
|
||||
match_results.multipleMatches.append(
|
||||
MultipleMatch(filename, matches))
|
||||
return
|
||||
if low_confidence and opts.abortOnLowConfidence:
|
||||
print >> sys.stderr, "Online search: Low confidence match. Save aborted"
|
||||
match_results.lowConfidenceMatches.append(
|
||||
MultipleMatch(filename, matches))
|
||||
return
|
||||
if not found_match:
|
||||
print >> sys.stderr, "Online search: No match found. Save aborted"
|
||||
match_results.noMatches.append(filename)
|
||||
return
|
||||
|
||||
# we got here, so we have a single match
|
||||
|
||||
# now get the particular issue data
|
||||
cv_md = actual_issue_data_fetch(matches[0], settings, opts)
|
||||
if cv_md is None:
|
||||
match_results.fetchDataFailures.append(filename)
|
||||
return
|
||||
|
||||
md.overlay(cv_md)
|
||||
|
||||
# ok, done building our metadata. time to save
|
||||
if not actual_metadata_save(ca, opts, md):
|
||||
match_results.writeFailures.append(filename)
|
||||
else:
|
||||
match_results.goodMatches.append(filename)
|
||||
|
||||
elif opts.rename_file:
|
||||
|
||||
msg_hdr = ""
|
||||
if batch_mode:
|
||||
msg_hdr = u"{0}: ".format(filename)
|
||||
|
||||
if opts.data_style is not None:
|
||||
use_tags = has[opts.data_style]
|
||||
else:
|
||||
use_tags = False
|
||||
|
||||
md = create_local_metadata(opts, ca, use_tags)
|
||||
|
||||
if md.series is None:
|
||||
print >> sys.stderr, msg_hdr + "Can't rename without series name"
|
||||
return
|
||||
|
||||
new_ext = None # default
|
||||
if settings.rename_extension_based_on_archive:
|
||||
if ca.isZip():
|
||||
new_ext = ".cbz"
|
||||
elif ca.isRar():
|
||||
new_ext = ".cbr"
|
||||
|
||||
renamer = FileRenamer(md)
|
||||
renamer.setTemplate(settings.rename_template)
|
||||
renamer.setIssueZeroPadding(settings.rename_issue_number_padding)
|
||||
renamer.setSmartCleanup(settings.rename_use_smart_string_cleanup)
|
||||
|
||||
new_name = renamer.determineName(filename, ext=new_ext)
|
||||
|
||||
if new_name == os.path.basename(filename):
|
||||
print >> sys.stderr, msg_hdr + "Filename is already good!"
|
||||
return
|
||||
|
||||
folder = os.path.dirname(os.path.abspath(filename))
|
||||
new_abs_path = utils.unique_file(os.path.join(folder, new_name))
|
||||
|
||||
suffix = ""
|
||||
if not opts.dryrun:
|
||||
# rename the file
|
||||
os.rename(filename, new_abs_path)
|
||||
else:
|
||||
suffix = " (dry-run, no change)"
|
||||
|
||||
print(
|
||||
u"renamed '{0}' -> '{1}' {2}".format(os.path.basename(filename), new_name, suffix))
|
||||
|
||||
elif opts.export_to_zip:
|
||||
msg_hdr = ""
|
||||
if batch_mode:
|
||||
msg_hdr = u"{0}: ".format(filename)
|
||||
|
||||
if not ca.isRar():
|
||||
print >> sys.stderr, msg_hdr + "Archive is not a RAR."
|
||||
return
|
||||
|
||||
rar_file = os.path.abspath(os.path.abspath(filename))
|
||||
new_file = os.path.splitext(rar_file)[0] + ".cbz"
|
||||
|
||||
if opts.abort_export_on_conflict and os.path.lexists(new_file):
|
||||
print msg_hdr + "{0} already exists in the that folder.".format(os.path.split(new_file)[1])
|
||||
return
|
||||
|
||||
new_file = utils.unique_file(os.path.join(new_file))
|
||||
|
||||
delete_success = False
|
||||
export_success = False
|
||||
if not opts.dryrun:
|
||||
if ca.exportAsZip(new_file):
|
||||
export_success = True
|
||||
if opts.delete_rar_after_export:
|
||||
try:
|
||||
os.unlink(rar_file)
|
||||
except:
|
||||
print >> sys.stderr, msg_hdr + \
|
||||
"Error deleting original RAR after export"
|
||||
delete_success = False
|
||||
else:
|
||||
delete_success = True
|
||||
else:
|
||||
# last export failed, so remove the zip, if it exists
|
||||
if os.path.lexists(new_file):
|
||||
os.remove(new_file)
|
||||
else:
|
||||
msg = msg_hdr + \
|
||||
u"Dry-run: Would try to create {0}".format(
|
||||
os.path.split(new_file)[1])
|
||||
if opts.delete_rar_after_export:
|
||||
msg += u" and delete orginal."
|
||||
print(msg)
|
||||
return
|
||||
|
||||
msg = msg_hdr
|
||||
if export_success:
|
||||
msg += u"Archive exported successfully to: {0}".format(
|
||||
os.path.split(new_file)[1])
|
||||
if opts.delete_rar_after_export and delete_success:
|
||||
msg += u" (Original deleted) "
|
||||
else:
|
||||
msg += u"Archive failed to export!"
|
||||
|
||||
print(msg)
|
||||
1
comictaggerlib/comet.py
Normal file
@@ -0,0 +1 @@
|
||||
from comicapi.comet import *
|
||||
1
comictaggerlib/comicarchive.py
Normal file
@@ -0,0 +1 @@
|
||||
from comicapi.comicarchive import *
|
||||
1
comictaggerlib/comicbookinfo.py
Normal file
@@ -0,0 +1 @@
|
||||
from comicapi.comicbookinfo import *
|
||||
1
comictaggerlib/comicinfoxml.py
Normal file
@@ -0,0 +1 @@
|
||||
from comicapi.comicinfoxml import *
|
||||
469
comictaggerlib/comicvinecacher.py
Normal file
@@ -0,0 +1,469 @@
|
||||
"""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.
|
||||
|
||||
import sqlite3 as lite
|
||||
import os
|
||||
import datetime
|
||||
#import sys
|
||||
#from pprint import pprint
|
||||
|
||||
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)
|
||||
804
comictaggerlib/comicvinetalker.py
Normal file
@@ -0,0 +1,804 @@
|
||||
"""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
|
||||
import urllib2
|
||||
import urllib
|
||||
import re
|
||||
import time
|
||||
import datetime
|
||||
import sys
|
||||
import ssl
|
||||
#from pprint import pprint
|
||||
#import math
|
||||
|
||||
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 ctversion
|
||||
import utils
|
||||
from comicvinecacher import ComicVineCacher
|
||||
from genericmetadata import GenericMetadata
|
||||
from issuestring import IssueString
|
||||
#from settings import ComicTaggerSettings
|
||||
|
||||
|
||||
class CVTypeID:
|
||||
Volume = "4050"
|
||||
Issue = "4000"
|
||||
|
||||
|
||||
class ComicVineTalkerException(Exception):
|
||||
Unknown = -1
|
||||
Network = -2
|
||||
InvalidKey = 100
|
||||
RateLimit = 107
|
||||
|
||||
def __init__(self, code=-1, desc=""):
|
||||
self.desc = desc
|
||||
self.code = code
|
||||
|
||||
def __str__(self):
|
||||
if (self.code == ComicVineTalkerException.Unknown or
|
||||
self.code == ComicVineTalkerException.Network):
|
||||
return self.desc
|
||||
else:
|
||||
return "CV error #{0}: [{1}]. \n".format(self.code, self.desc)
|
||||
|
||||
|
||||
class ComicVineTalker(QObject):
|
||||
|
||||
logo_url = "http://static.comicvine.com/bundles/comicvinesite/images/logo.png"
|
||||
api_key = ""
|
||||
|
||||
@staticmethod
|
||||
def getRateLimitMessage():
|
||||
if ComicVineTalker.api_key == "":
|
||||
return "Comic Vine rate limit exceeded. You should configue your own Comic Vine API key."
|
||||
else:
|
||||
return "Comic Vine rate limit exceeded. Please wait a bit."
|
||||
|
||||
def __init__(self):
|
||||
QObject.__init__(self)
|
||||
|
||||
self.api_base_url = "https://comicvine.gamespot.com/api"
|
||||
self.wait_for_rate_limit = False
|
||||
|
||||
# key that is registered to comictagger
|
||||
default_api_key = '27431e6787042105bd3e47e169a624521f89f3a4'
|
||||
|
||||
if ComicVineTalker.api_key == "":
|
||||
self.api_key = default_api_key
|
||||
else:
|
||||
self.api_key = ComicVineTalker.api_key
|
||||
|
||||
self.log_func = None
|
||||
|
||||
# always use a tls context for urlopen
|
||||
self.ssl = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
|
||||
|
||||
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, key):
|
||||
|
||||
test_url = self.api_base_url + "/issue/1/?api_key=" + \
|
||||
key + "&format=json&field_list=name"
|
||||
resp = urllib2.urlopen(test_url, context=self.ssl)
|
||||
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
|
||||
|
||||
"""
|
||||
Get the contect from the CV server. If we're in "wait mode" and status code is a rate limit error
|
||||
sleep for a bit and retry.
|
||||
"""
|
||||
|
||||
def getCVContent(self, url):
|
||||
total_time_waited = 0
|
||||
limit_wait_time = 1
|
||||
counter = 0
|
||||
wait_times = [1, 2, 3, 4]
|
||||
while True:
|
||||
content = self.getUrlContent(url)
|
||||
cv_response = json.loads(content)
|
||||
if self.wait_for_rate_limit and cv_response[
|
||||
'status_code'] == ComicVineTalkerException.RateLimit:
|
||||
self.writeLog(
|
||||
"Rate limit encountered. Waiting for {0} minutes\n".format(limit_wait_time))
|
||||
time.sleep(limit_wait_time * 60)
|
||||
total_time_waited += limit_wait_time
|
||||
limit_wait_time = wait_times[counter]
|
||||
if counter < 3:
|
||||
counter += 1
|
||||
# don't wait much more than 20 minutes
|
||||
if total_time_waited < 20:
|
||||
continue
|
||||
if cv_response['status_code'] != 1:
|
||||
self.writeLog(
|
||||
"Comic Vine query failed with error #{0}: [{1}]. \n".format(
|
||||
cv_response['status_code'],
|
||||
cv_response['error']))
|
||||
raise ComicVineTalkerException(
|
||||
cv_response['status_code'], cv_response['error'])
|
||||
else:
|
||||
# it's all good
|
||||
break
|
||||
return cv_response
|
||||
|
||||
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, context=self.ssl)
|
||||
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(
|
||||
ComicVineTalkerException.Network, "Network Error!")
|
||||
|
||||
raise ComicVineTalkerException(
|
||||
ComicVineTalkerException.Unknown, "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"
|
||||
cv_response = self.getCVContent(search_url + "&page=1")
|
||||
|
||||
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
|
||||
|
||||
cv_response = self.getCVContent(search_url + "&page=" + str(page))
|
||||
|
||||
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"
|
||||
|
||||
cv_response = self.getCVContent(volume_url)
|
||||
|
||||
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"
|
||||
cv_response = self.getCVContent(issues_url)
|
||||
|
||||
#------------------------------------
|
||||
|
||||
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)
|
||||
cv_response = self.getCVContent(
|
||||
issues_url + "&offset=" + str(offset))
|
||||
|
||||
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"
|
||||
|
||||
cv_response = self.getCVContent(issues_url)
|
||||
|
||||
#------------------------------------
|
||||
|
||||
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)
|
||||
cv_response = self.getCVContent(
|
||||
issues_url + "&offset=" + str(offset))
|
||||
|
||||
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"
|
||||
|
||||
cv_response = self.getCVContent(issue_url)
|
||||
issue_results = cv_response['results']
|
||||
|
||||
else:
|
||||
return None
|
||||
|
||||
# Now, map the Comic Vine 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"
|
||||
cv_response = self.getCVContent(issue_url)
|
||||
|
||||
issue_results = cv_response['results']
|
||||
|
||||
volume_results = self.fetchVolumeData(issue_results['volume']['id'])
|
||||
|
||||
# Now, map the Comic Vine 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 Comic Vine 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 'role' in person:
|
||||
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(' ', ' ')
|
||||
newstring = newstring.replace('&', '&')
|
||||
|
||||
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"
|
||||
|
||||
details = dict()
|
||||
details['image_url'] = None
|
||||
details['thumb_image_url'] = None
|
||||
details['cover_date'] = None
|
||||
details['site_detail_url'] = None
|
||||
|
||||
cv_response = self.getCVContent(issue_url)
|
||||
|
||||
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, context=self.ssl)
|
||||
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 Comic Vine 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 'class' in d:
|
||||
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
|
||||
325
comictaggerlib/coverimagewidget.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""A PyQt4 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!
|
||||
"""
|
||||
|
||||
# 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 os
|
||||
|
||||
from PyQt4.QtCore import *
|
||||
from PyQt4.QtGui import *
|
||||
from PyQt4 import uic
|
||||
|
||||
from settings import ComicTaggerSettings
|
||||
from comicvinetalker import ComicVineTalker, ComicVineTalkerException
|
||||
from imagefetcher import ImageFetcher
|
||||
from pageloader import PageLoader
|
||||
from imagepopup import ImagePopup
|
||||
from comictaggerlib.ui.qtutils import reduceWidgetFontSize, getQImageFromData
|
||||
#from genericmetadata import GenericMetadata, PageType
|
||||
#from comicarchive import MetaDataStyle
|
||||
#import utils
|
||||
|
||||
|
||||
def clickable(widget):
|
||||
"""# Allow a label to be clickable"""
|
||||
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
|
||||
reduceWidgetFontSize(self.label)
|
||||
|
||||
self.mode = mode
|
||||
self.comicVine = ComicVineTalker()
|
||||
self.page_loader = None
|
||||
self.showControls = True
|
||||
|
||||
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("")
|
||||
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
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 setIssueID(self, issue_id):
|
||||
if self.mode == CoverImageWidget.AltCoverMode:
|
||||
self.resetWidget()
|
||||
self.updateContent()
|
||||
|
||||
self.issue_id = issue_id
|
||||
|
||||
self.comicVine = ComicVineTalker()
|
||||
self.comicVine.urlFetchComplete.connect(
|
||||
self.primaryUrlFetchComplete)
|
||||
self.comicVine.asyncFetchIssueCoverURLs(int(self.issue_id))
|
||||
|
||||
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()
|
||||
|
||||
# defer the alt cover search
|
||||
QTimer.singleShot(1, self.startAltCoverSearch)
|
||||
|
||||
def startAltCoverSearch(self):
|
||||
|
||||
# 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()
|
||||
|
||||
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 = getQImageFromData(image_data)
|
||||
self.current_pixmap = QPixmap(img)
|
||||
self.setDisplayPixmap(0, 0)
|
||||
#print("ATB cover fetch complete!")
|
||||
|
||||
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()
|
||||
|
||||
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 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()
|
||||
|
||||
new_h -= 4
|
||||
new_w -= 4
|
||||
|
||||
if new_h < 0:
|
||||
new_h = 0
|
||||
if new_w < 0:
|
||||
new_w = 0
|
||||
|
||||
# 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)
|
||||
96
comictaggerlib/crediteditorwindow.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""A PyQT4 dialog to edit credits"""
|
||||
|
||||
# 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 os
|
||||
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
|
||||
from settings import ComicTaggerSettings
|
||||
|
||||
|
||||
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")
|
||||
|
||||
# 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)
|
||||
|
||||
if primary:
|
||||
self.cbPrimary.setCheckState(QtCore.Qt.Checked)
|
||||
|
||||
self.cbRole.currentIndexChanged.connect(self.roleChanged)
|
||||
self.cbRole.editTextChanged.connect(self.roleChanged)
|
||||
|
||||
self.updatePrimaryButton()
|
||||
|
||||
def updatePrimaryButton(self):
|
||||
enabled = self.currentRoleCanBePrimary()
|
||||
self.cbPrimary.setEnabled(enabled)
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
3
comictaggerlib/ctversion.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# This file should contain only these comments, and the line below.
|
||||
# Used by packaging makefiles and app
|
||||
version = "1.1.16-beta-rc2"
|
||||
64
comictaggerlib/exportwindow.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""A PyQT4 dialog to confirm and set options for export to zip"""
|
||||
|
||||
# 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 os
|
||||
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
|
||||
from settings import ComicTaggerSettings
|
||||
#from settingswindow import SettingsWindow
|
||||
#from filerenamer import FileRenamer
|
||||
#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)
|
||||
|
||||
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
|
||||
|
||||
def accept(self):
|
||||
QtGui.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
|
||||
# else:
|
||||
# self.fileConflictBehavior = ExportConflictOpts.overwrite
|
||||
1
comictaggerlib/filenameparser.py
Normal file
@@ -0,0 +1 @@
|
||||
from comicapi.filenameparser import *
|
||||
156
comictaggerlib/filerenamer.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""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.
|
||||
|
||||
import os
|
||||
import re
|
||||
import datetime
|
||||
|
||||
import utils
|
||||
from issuestring import IssueString
|
||||
|
||||
|
||||
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 setMetadata(self, metadata):
|
||||
self.metdata = metadata
|
||||
|
||||
def setIssueZeroPadding(self, count):
|
||||
self.issue_zero_padding = count
|
||||
|
||||
def setSmartCleanup(self, on):
|
||||
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:] == "%")
|
||||
|
||||
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):
|
||||
|
||||
md = self.metdata
|
||||
new_name = self.template
|
||||
preferred_encoding = utils.get_actual_preferred_encoding()
|
||||
|
||||
# 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 (isinstance(md.month, str) and md.month.isdigit()) or isinstance(
|
||||
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%')
|
||||
|
||||
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)
|
||||
|
||||
# remove duplicate spaces
|
||||
new_name = u" ".join(new_name.split())
|
||||
|
||||
# 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())
|
||||
|
||||
if ext is None:
|
||||
ext = os.path.splitext(filename)[1]
|
||||
|
||||
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
|
||||
456
comictaggerlib/fileselectionlist.py
Normal file
@@ -0,0 +1,456 @@
|
||||
# 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.
|
||||
|
||||
import platform
|
||||
import os
|
||||
#import os
|
||||
#import sys
|
||||
|
||||
from PyQt4.QtCore import *
|
||||
from PyQt4.QtGui import *
|
||||
from PyQt4 import uic
|
||||
from PyQt4.QtCore import pyqtSignal
|
||||
|
||||
from settings import ComicTaggerSettings
|
||||
from comicarchive import ComicArchive
|
||||
from optionalmsgdialog import OptionalMessageDialog
|
||||
from comictaggerlib.ui.qtutils import reduceWidgetFontSize, centerWindowOnParent
|
||||
import utils
|
||||
#from comicarchive import MetaDataStyle
|
||||
#from genericmetadata import GenericMetadata, PageType
|
||||
|
||||
|
||||
class FileTableWidgetItem(QTableWidgetItem):
|
||||
|
||||
def __lt__(self, other):
|
||||
return (self.data(Qt.UserRole).toBool() <
|
||||
other.data(Qt.UserRole).toBool())
|
||||
|
||||
|
||||
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
|
||||
|
||||
reduceWidgetFontSize(self.twList)
|
||||
|
||||
self.twList.setColumnCount(6)
|
||||
#self.twlist.setHorizontalHeaderLabels (["File", "Folder", "CR", "CBL", ""])
|
||||
# self.twList.horizontalHeader().setStretchLastSection(True)
|
||||
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)
|
||||
centerWindowOnParent(progdialog)
|
||||
QCoreApplication.processEvents()
|
||||
row = self.addPathItem(f)
|
||||
if firstAdded is None and row is not None:
|
||||
firstAdded = row
|
||||
|
||||
progdialog.close()
|
||||
if (self.settings.show_no_unrar_warning and
|
||||
self.settings.unrar_exe_path == "" and
|
||||
self.settings.rar_exe_path == "" and
|
||||
platform.system() != "Windows"):
|
||||
for f in filelist:
|
||||
ext = os.path.splitext(f)[1].lower()
|
||||
if ext == ".rar" or ext == ".cbr":
|
||||
checked = OptionalMessageDialog.msg(self, "No unrar tool",
|
||||
"""
|
||||
It looks like you've tried to open at least one CBR or RAR file.<br><br>
|
||||
In order for ComicTagger to read this kind of file, you will have to configure
|
||||
the location of the unrar tool in the settings. Until then, ComicTagger
|
||||
will not be able recognize these kind of files.
|
||||
"""
|
||||
)
|
||||
self.settings.show_no_unrar_warning = not checked
|
||||
break
|
||||
|
||||
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 getCurrentListRow(self, path):
|
||||
r = 0
|
||||
while r < self.twList.rowCount():
|
||||
ca = self.getArchiveByRow(r)
|
||||
if ca.path == path:
|
||||
return r
|
||||
r = r + 1
|
||||
|
||||
return -1
|
||||
|
||||
def addPathItem(self, path):
|
||||
path = unicode(path)
|
||||
path = os.path.abspath(path)
|
||||
# print "processing", path
|
||||
|
||||
if self.isListDupe(path):
|
||||
return self.getCurrentListRow(path)
|
||||
|
||||
ca = ComicArchive(
|
||||
path,
|
||||
self.settings.rar_exe_path,
|
||||
ComicTaggerSettings.getGraphic('nocover.png'))
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
# Reading these will force them into the ComicArchive's cache
|
||||
fi.ca.readCIX()
|
||||
fi.ca.hasCBI()
|
||||
|
||||
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)
|
||||
|
||||
return ca_list
|
||||
|
||||
def updateCurrentRow(self):
|
||||
self.updateRow(self.twList.currentRow())
|
||||
|
||||
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):
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
1
comictaggerlib/genericmetadata.py
Normal file
@@ -0,0 +1 @@
|
||||
from comicapi.genericmetadata import *
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
BIN
comictaggerlib/graphics/left.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
comictaggerlib/graphics/longbox.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
BIN
comictaggerlib/graphics/popup_bg.png
Normal file
|
After Width: | Height: | Size: 362 B |
BIN
comictaggerlib/graphics/right.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
195
comictaggerlib/imagefetcher.py
Normal file
@@ -0,0 +1,195 @@
|
||||
"""A class to manage fetching and caching of images by URL"""
|
||||
|
||||
# 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 shutil
|
||||
import tempfile
|
||||
import urllib
|
||||
import ssl
|
||||
#import urllib2
|
||||
|
||||
try:
|
||||
from PyQt4.QtNetwork import QNetworkAccessManager, QNetworkRequest
|
||||
from PyQt4.QtCore import QUrl, pyqtSignal, QObject, QByteArray
|
||||
from PyQt4 import QtGui
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
# always use a tls context for urlopen
|
||||
self.ssl = ssl.SSLContext(ssl.PROTOCOL_TLSv1)
|
||||
|
||||
def clearCache(self):
|
||||
os.unlink(self.db_file)
|
||||
if os.path.isdir(self.cache_folder):
|
||||
shutil.rmtree(self.cache_folder)
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
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, context=self.ssl).read()
|
||||
except Exception as e:
|
||||
print(e)
|
||||
raise ImageFetcherException("Network Error!")
|
||||
|
||||
# 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...
|
||||
|
||||
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)
|
||||
|
||||
self.fetchComplete.emit(QByteArray(image_data), self.user_data)
|
||||
|
||||
def create_image_db(self):
|
||||
|
||||
# this will wipe out any existing version
|
||||
open(self.db_file, 'w').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, image_data):
|
||||
|
||||
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")
|
||||
f = os.fdopen(tmp_fd, 'w+b')
|
||||
f.write(image_data)
|
||||
f.close()
|
||||
|
||||
cur.execute("INSERT or REPLACE INTO Images VALUES(?, ?, ?)",
|
||||
(url,
|
||||
filename,
|
||||
timestamp)
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
if row is None:
|
||||
return None
|
||||
else:
|
||||
filename = row[0]
|
||||
image_data = None
|
||||
|
||||
try:
|
||||
with open(filename, 'rb') as f:
|
||||
image_data = f.read()
|
||||
f.close()
|
||||
except IOError as e:
|
||||
pass
|
||||
|
||||
return image_data
|
||||
193
comictaggerlib/imagehasher.py
Executable file
@@ -0,0 +1,193 @@
|
||||
"""A class to manage creating image content hashes, and calculate hamming distances"""
|
||||
|
||||
# Copyright 2013 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 StringIO
|
||||
import sys
|
||||
from functools import reduce
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
from PIL import WebPImagePlugin
|
||||
pil_available = True
|
||||
except ImportError:
|
||||
pil_available = False
|
||||
|
||||
|
||||
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 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))
|
||||
|
||||
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):
|
||||
(idx, val) = idx_val
|
||||
return (x | (val << idx))
|
||||
|
||||
result = reduce(set_bit, enumerate(bitlist), 0)
|
||||
|
||||
# print("{0:016x}".format(result))
|
||||
return result
|
||||
|
||||
def average_hash2(self):
|
||||
pass
|
||||
"""
|
||||
# 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)
|
||||
#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.
|
||||
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
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))
|
||||
|
||||
|
||||
#print("{0:016x}".format(result))
|
||||
return result
|
||||
"""
|
||||
|
||||
# accepts 2 hashes (longs or hex strings) and returns the hamming distance
|
||||
|
||||
@staticmethod
|
||||
def hamming_distance(h1, h2):
|
||||
if isinstance(h1, long) or isinstance(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
|
||||
|
||||
# count up the 1's in the binary string
|
||||
return sum(b == '1' for b in bin(n)[2:])
|
||||
92
comictaggerlib/imagepopup.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""A PyQT4 widget to display a popup image"""
|
||||
|
||||
# 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 os
|
||||
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
|
||||
from settings import ComicTaggerSettings
|
||||
|
||||
|
||||
class ImagePopup(QtGui.QDialog):
|
||||
|
||||
def __init__(self, parent, image_pixmap):
|
||||
super(ImagePopup, self).__init__(parent)
|
||||
|
||||
uic.loadUi(ComicTaggerSettings.getUIFile('imagepopup.ui'), self)
|
||||
|
||||
QtGui.QApplication.setOverrideCursor(
|
||||
QtGui.QCursor(QtCore.Qt.WaitCursor))
|
||||
|
||||
# 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())
|
||||
|
||||
self.applyImagePixmap()
|
||||
self.showFullScreen()
|
||||
self.raise_()
|
||||
QtGui.QApplication.restoreOverrideCursor()
|
||||
|
||||
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()
|
||||
|
||||
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)
|
||||
|
||||
def mousePressEvent(self, event):
|
||||
self.close()
|
||||
722
comictaggerlib/issueidentifier.py
Normal file
@@ -0,0 +1,722 @@
|
||||
"""A class to automatically identify 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.
|
||||
|
||||
import sys
|
||||
import StringIO
|
||||
#import math
|
||||
#import urllib2
|
||||
#import urllib
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
from PIL import WebPImagePlugin
|
||||
pil_available = True
|
||||
except ImportError:
|
||||
pil_available = False
|
||||
|
||||
from genericmetadata import GenericMetadata
|
||||
from comicvinetalker import ComicVineTalker, ComicVineTalkerException
|
||||
from imagehasher import ImageHasher
|
||||
from imagefetcher import ImageFetcher, ImageFetcherException
|
||||
from issuestring import IssueString
|
||||
import utils
|
||||
#from settings import ComicTaggerSettings
|
||||
#from comicvinecacher import ComicVineCacher
|
||||
|
||||
|
||||
class IssueIdentifierNetworkError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class IssueIdentifierCancelled(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class IssueIdentifier:
|
||||
|
||||
ResultNoMatches = 0
|
||||
ResultFoundMatchButBadCoverScore = 1
|
||||
ResultFoundMatchButNotFirstPage = 2
|
||||
ResultMultipleMatchesWithBadImageScores = 3
|
||||
ResultOneGoodMatch = 4
|
||||
ResultMultipleGoodMatches = 5
|
||||
|
||||
def __init__(self, comic_archive, settings):
|
||||
self.comic_archive = comic_archive
|
||||
self.image_hasher = 1
|
||||
|
||||
self.onlyUseAdditionalMetaData = False
|
||||
|
||||
# a decent hamming score, good enough to call it a match
|
||||
self.min_score_thresh = 16
|
||||
|
||||
# for alternate covers, be more stringent, since we're a bit more
|
||||
# scattershot in comparisons
|
||||
self.min_alternate_score_thresh = 12
|
||||
|
||||
# the min distance a hamming score must be to separate itself from
|
||||
# closest neighbor
|
||||
self.min_score_distance = 4
|
||||
|
||||
# a very strong hamming score, almost certainly the same image
|
||||
self.strong_score_thresh = 8
|
||||
|
||||
# used to eliminate series names that are too long based on our search
|
||||
# string
|
||||
self.length_delta_thresh = settings.id_length_delta_thresh
|
||||
|
||||
# used to eliminate unlikely publishers
|
||||
self.publisher_blacklist = [
|
||||
s.strip().lower() for s in settings.id_publisher_blacklist.split(',')]
|
||||
|
||||
self.additional_metadata = GenericMetadata()
|
||||
self.output_function = IssueIdentifier.defaultWriteOutput
|
||||
self.callback = None
|
||||
self.coverUrlCallback = None
|
||||
self.search_result = self.ResultNoMatches
|
||||
self.cover_page_index = 0
|
||||
self.cancel = False
|
||||
self.waitAndRetryOnRateLimit = False
|
||||
|
||||
def setScoreMinThreshold(self, thresh):
|
||||
self.min_score_thresh = thresh
|
||||
|
||||
def setScoreMinDistance(self, distance):
|
||||
self.min_score_distance = distance
|
||||
|
||||
def setAdditionalMetadata(self, md):
|
||||
self.additional_metadata = md
|
||||
|
||||
def setNameLengthDeltaThreshold(self, delta):
|
||||
self.length_delta_thresh = delta
|
||||
|
||||
def setPublisherBlackList(self, blacklist):
|
||||
self.publisher_blacklist = blacklist
|
||||
|
||||
def setHasherAlgorithm(self, algo):
|
||||
self.image_hasher = algo
|
||||
pass
|
||||
|
||||
def setOutputFunction(self, func):
|
||||
self.output_function = func
|
||||
pass
|
||||
|
||||
def calculateHash(self, image_data):
|
||||
if self.image_hasher == '3':
|
||||
return ImageHasher(data=image_data).dct_average_hash()
|
||||
elif self.image_hasher == '2':
|
||||
return ImageHasher(data=image_data).average_hash2()
|
||||
else:
|
||||
return ImageHasher(data=image_data).average_hash()
|
||||
|
||||
def getAspectRatio(self, image_data):
|
||||
try:
|
||||
im = Image.open(StringIO.StringIO(image_data))
|
||||
w, h = im.size
|
||||
return float(h) / float(w)
|
||||
except:
|
||||
return 1.5
|
||||
|
||||
def cropCover(self, image_data):
|
||||
|
||||
im = Image.open(StringIO.StringIO(image_data))
|
||||
w, h = im.size
|
||||
|
||||
try:
|
||||
cropped_im = im.crop((int(w / 2), 0, w, h))
|
||||
except Exception as e:
|
||||
sys.exc_clear()
|
||||
print "cropCover() error:", e
|
||||
return None
|
||||
|
||||
output = StringIO.StringIO()
|
||||
cropped_im.save(output, format="PNG")
|
||||
cropped_image_data = output.getvalue()
|
||||
output.close()
|
||||
|
||||
return cropped_image_data
|
||||
|
||||
def setProgressCallback(self, cb_func):
|
||||
self.callback = cb_func
|
||||
|
||||
def setCoverURLCallback(self, cb_func):
|
||||
self.coverUrlCallback = cb_func
|
||||
|
||||
def getSearchKeys(self):
|
||||
|
||||
ca = self.comic_archive
|
||||
search_keys = dict()
|
||||
search_keys['series'] = None
|
||||
search_keys['issue_number'] = None
|
||||
search_keys['month'] = None
|
||||
search_keys['year'] = None
|
||||
search_keys['issue_count'] = None
|
||||
|
||||
if ca is None:
|
||||
return
|
||||
|
||||
if self.onlyUseAdditionalMetaData:
|
||||
search_keys['series'] = self.additional_metadata.series
|
||||
search_keys['issue_number'] = self.additional_metadata.issue
|
||||
search_keys['year'] = self.additional_metadata.year
|
||||
search_keys['month'] = self.additional_metadata.month
|
||||
search_keys['issue_count'] = self.additional_metadata.issueCount
|
||||
return search_keys
|
||||
|
||||
# see if the archive has any useful meta data for searching with
|
||||
if ca.hasCIX():
|
||||
internal_metadata = ca.readCIX()
|
||||
elif ca.hasCBI():
|
||||
internal_metadata = ca.readCBI()
|
||||
else:
|
||||
internal_metadata = ca.readCBI()
|
||||
|
||||
# try to get some metadata from filename
|
||||
md_from_filename = ca.metadataFromFilename()
|
||||
|
||||
# preference order:
|
||||
# 1. Additional metadata
|
||||
# 1. Internal metadata
|
||||
# 1. Filename metadata
|
||||
|
||||
if self.additional_metadata.series is not None:
|
||||
search_keys['series'] = self.additional_metadata.series
|
||||
elif internal_metadata.series is not None:
|
||||
search_keys['series'] = internal_metadata.series
|
||||
else:
|
||||
search_keys['series'] = md_from_filename.series
|
||||
|
||||
if self.additional_metadata.issue is not None:
|
||||
search_keys['issue_number'] = self.additional_metadata.issue
|
||||
elif internal_metadata.issue is not None:
|
||||
search_keys['issue_number'] = internal_metadata.issue
|
||||
else:
|
||||
search_keys['issue_number'] = md_from_filename.issue
|
||||
|
||||
if self.additional_metadata.year is not None:
|
||||
search_keys['year'] = self.additional_metadata.year
|
||||
elif internal_metadata.year is not None:
|
||||
search_keys['year'] = internal_metadata.year
|
||||
else:
|
||||
search_keys['year'] = md_from_filename.year
|
||||
|
||||
if self.additional_metadata.month is not None:
|
||||
search_keys['month'] = self.additional_metadata.month
|
||||
elif internal_metadata.month is not None:
|
||||
search_keys['month'] = internal_metadata.month
|
||||
else:
|
||||
search_keys['month'] = md_from_filename.month
|
||||
|
||||
if self.additional_metadata.issueCount is not None:
|
||||
search_keys['issue_count'] = self.additional_metadata.issueCount
|
||||
elif internal_metadata.issueCount is not None:
|
||||
search_keys['issue_count'] = internal_metadata.issueCount
|
||||
else:
|
||||
search_keys['issue_count'] = md_from_filename.issueCount
|
||||
|
||||
return search_keys
|
||||
|
||||
@staticmethod
|
||||
def defaultWriteOutput(text):
|
||||
sys.stdout.write(text)
|
||||
sys.stdout.flush()
|
||||
|
||||
def log_msg(self, msg, newline=True):
|
||||
self.output_function(msg)
|
||||
if newline:
|
||||
self.output_function("\n")
|
||||
|
||||
def getIssueCoverMatchScore(
|
||||
self,
|
||||
comicVine,
|
||||
issue_id,
|
||||
primary_img_url,
|
||||
primary_thumb_url,
|
||||
page_url,
|
||||
localCoverHashList,
|
||||
useRemoteAlternates=False,
|
||||
useLog=True):
|
||||
# localHashes is a list of pre-calculated hashs.
|
||||
# useRemoteAlternates - indicates to use alternate covers from CV
|
||||
|
||||
try:
|
||||
url_image_data = ImageFetcher().fetch(
|
||||
primary_thumb_url, blocking=True)
|
||||
except ImageFetcherException:
|
||||
self.log_msg(
|
||||
"Network issue while fetching cover image from Comic Vine. Aborting...")
|
||||
raise IssueIdentifierNetworkError
|
||||
|
||||
if self.cancel:
|
||||
raise IssueIdentifierCancelled
|
||||
|
||||
# alert the GUI, if needed
|
||||
if self.coverUrlCallback is not None:
|
||||
self.coverUrlCallback(url_image_data)
|
||||
|
||||
remote_cover_list = []
|
||||
item = dict()
|
||||
item['url'] = primary_img_url
|
||||
|
||||
item['hash'] = self.calculateHash(url_image_data)
|
||||
remote_cover_list.append(item)
|
||||
|
||||
if self.cancel:
|
||||
raise IssueIdentifierCancelled
|
||||
|
||||
if useRemoteAlternates:
|
||||
alt_img_url_list = comicVine.fetchAlternateCoverURLs(
|
||||
issue_id, page_url)
|
||||
for alt_url in alt_img_url_list:
|
||||
try:
|
||||
alt_url_image_data = ImageFetcher().fetch(
|
||||
alt_url, blocking=True)
|
||||
except ImageFetcherException:
|
||||
self.log_msg(
|
||||
"Network issue while fetching alt. cover image from Comic Vine. Aborting...")
|
||||
raise IssueIdentifierNetworkError
|
||||
|
||||
if self.cancel:
|
||||
raise IssueIdentifierCancelled
|
||||
|
||||
# alert the GUI, if needed
|
||||
if self.coverUrlCallback is not None:
|
||||
self.coverUrlCallback(alt_url_image_data)
|
||||
|
||||
item = dict()
|
||||
item['url'] = alt_url
|
||||
item['hash'] = self.calculateHash(alt_url_image_data)
|
||||
remote_cover_list.append(item)
|
||||
|
||||
if self.cancel:
|
||||
raise IssueIdentifierCancelled
|
||||
|
||||
if useLog and useRemoteAlternates:
|
||||
self.log_msg(
|
||||
"[{0} alt. covers]".format(len(remote_cover_list) - 1), False)
|
||||
if useLog:
|
||||
self.log_msg("[ ", False)
|
||||
|
||||
score_list = []
|
||||
done = False
|
||||
for local_cover_hash in localCoverHashList:
|
||||
for remote_cover_item in remote_cover_list:
|
||||
score = ImageHasher.hamming_distance(
|
||||
local_cover_hash, remote_cover_item['hash'])
|
||||
score_item = dict()
|
||||
score_item['score'] = score
|
||||
score_item['url'] = remote_cover_item['url']
|
||||
score_item['hash'] = remote_cover_item['hash']
|
||||
score_list.append(score_item)
|
||||
if useLog:
|
||||
self.log_msg("{0}".format(score), False)
|
||||
|
||||
if score <= self.strong_score_thresh:
|
||||
# such a good score, we can quit now, since for sure we
|
||||
# have a winner
|
||||
done = True
|
||||
break
|
||||
if done:
|
||||
break
|
||||
|
||||
if useLog:
|
||||
self.log_msg(" ]", False)
|
||||
|
||||
best_score_item = min(score_list, key=lambda x: x['score'])
|
||||
|
||||
return best_score_item
|
||||
|
||||
# def validate(self, issue_id):
|
||||
# create hash list
|
||||
# score = self.getIssueMatchScore(issue_id, hash_list, useRemoteAlternates = True)
|
||||
# if score < 20:
|
||||
# return True
|
||||
# else:
|
||||
# return False
|
||||
|
||||
def search(self):
|
||||
|
||||
ca = self.comic_archive
|
||||
self.match_list = []
|
||||
self.cancel = False
|
||||
self.search_result = self.ResultNoMatches
|
||||
|
||||
if not pil_available:
|
||||
self.log_msg(
|
||||
"Python Imaging Library (PIL) is not available and is needed for issue identification.")
|
||||
return self.match_list
|
||||
|
||||
if not ca.seemsToBeAComicArchive():
|
||||
self.log_msg(
|
||||
"Sorry, but " + opts.filename + " is not a comic archive!")
|
||||
return self.match_list
|
||||
|
||||
cover_image_data = ca.getPage(self.cover_page_index)
|
||||
cover_hash = self.calculateHash(cover_image_data)
|
||||
|
||||
# check the aspect ratio
|
||||
# if it's wider than it is high, it's probably a two page spread
|
||||
# if so, crop it and calculate a second hash
|
||||
narrow_cover_hash = None
|
||||
aspect_ratio = self.getAspectRatio(cover_image_data)
|
||||
if aspect_ratio < 1.0:
|
||||
right_side_image_data = self.cropCover(cover_image_data)
|
||||
if right_side_image_data is not None:
|
||||
narrow_cover_hash = self.calculateHash(right_side_image_data)
|
||||
|
||||
#self.log_msg("Cover hash = {0:016x}".format(cover_hash))
|
||||
|
||||
keys = self.getSearchKeys()
|
||||
# normalize the issue number
|
||||
keys['issue_number'] = IssueString(keys['issue_number']).asString()
|
||||
|
||||
# we need, at minimum, a series and issue number
|
||||
if keys['series'] is None or keys['issue_number'] is None:
|
||||
self.log_msg("Not enough info for a search!")
|
||||
return []
|
||||
|
||||
self.log_msg("Going to search for:")
|
||||
self.log_msg("\tSeries: " + keys['series'])
|
||||
self.log_msg("\tIssue: " + keys['issue_number'])
|
||||
if keys['issue_count'] is not None:
|
||||
self.log_msg("\tCount: " + str(keys['issue_count']))
|
||||
if keys['year'] is not None:
|
||||
self.log_msg("\tYear: " + str(keys['year']))
|
||||
if keys['month'] is not None:
|
||||
self.log_msg("\tMonth: " + str(keys['month']))
|
||||
|
||||
#self.log_msg("Publisher Blacklist: " + str(self.publisher_blacklist))
|
||||
comicVine = ComicVineTalker()
|
||||
comicVine.wait_for_rate_limit = self.waitAndRetryOnRateLimit
|
||||
|
||||
comicVine.setLogFunc(self.output_function)
|
||||
|
||||
# self.log_msg(("Searching for " + keys['series'] + "...")
|
||||
self.log_msg(u"Searching for {0} #{1} ...".format(
|
||||
keys['series'], keys['issue_number']))
|
||||
try:
|
||||
cv_search_results = comicVine.searchForSeries(keys['series'])
|
||||
except ComicVineTalkerException:
|
||||
self.log_msg(
|
||||
"Network issue while searching for series. Aborting...")
|
||||
return []
|
||||
|
||||
#self.log_msg("Found " + str(len(cv_search_results)) + " initial results")
|
||||
if self.cancel:
|
||||
return []
|
||||
|
||||
if cv_search_results is None:
|
||||
return []
|
||||
|
||||
series_second_round_list = []
|
||||
|
||||
#self.log_msg("Removing results with too long names, banned publishers, or future start dates")
|
||||
for item in cv_search_results:
|
||||
length_approved = False
|
||||
publisher_approved = True
|
||||
date_approved = True
|
||||
|
||||
# remove any series that starts after the issue year
|
||||
if keys['year'] is not None and str(
|
||||
keys['year']).isdigit() and item['start_year'] is not None and str(
|
||||
item['start_year']).isdigit():
|
||||
if int(keys['year']) < int(item['start_year']):
|
||||
date_approved = False
|
||||
|
||||
# assume that our search name is close to the actual name, say
|
||||
# within ,e.g. 5 chars
|
||||
shortened_key = utils.removearticles(keys['series'])
|
||||
shortened_item_name = utils.removearticles(item['name'])
|
||||
if len(shortened_item_name) < (
|
||||
len(shortened_key) + self.length_delta_thresh):
|
||||
length_approved = True
|
||||
|
||||
# remove any series from publishers on the blacklist
|
||||
if item['publisher'] is not None:
|
||||
publisher = item['publisher']['name']
|
||||
if publisher is not None and publisher.lower(
|
||||
) in self.publisher_blacklist:
|
||||
publisher_approved = False
|
||||
|
||||
if length_approved and publisher_approved and date_approved:
|
||||
series_second_round_list.append(item)
|
||||
|
||||
self.log_msg(
|
||||
"Searching in " + str(len(series_second_round_list)) + " series")
|
||||
|
||||
if self.callback is not None:
|
||||
self.callback(0, len(series_second_round_list))
|
||||
|
||||
# now sort the list by name length
|
||||
series_second_round_list.sort(
|
||||
key=lambda x: len(x['name']), reverse=False)
|
||||
|
||||
# build a list of volume IDs
|
||||
volume_id_list = list()
|
||||
for series in series_second_round_list:
|
||||
volume_id_list.append(series['id'])
|
||||
|
||||
try:
|
||||
issue_list = comicVine.fetchIssuesByVolumeIssueNumAndYear(
|
||||
volume_id_list,
|
||||
keys['issue_number'],
|
||||
keys['year'])
|
||||
|
||||
except ComicVineTalkerException:
|
||||
self.log_msg(
|
||||
"Network issue while searching for series details. Aborting...")
|
||||
return []
|
||||
|
||||
if issue_list is None:
|
||||
return []
|
||||
|
||||
shortlist = list()
|
||||
# now re-associate the issues and volumes
|
||||
for issue in issue_list:
|
||||
for series in series_second_round_list:
|
||||
if series['id'] == issue['volume']['id']:
|
||||
shortlist.append((series, issue))
|
||||
break
|
||||
|
||||
if keys['year'] is None:
|
||||
self.log_msg(u"Found {0} series that have an issue #{1}".format(
|
||||
len(shortlist), keys['issue_number']))
|
||||
else:
|
||||
self.log_msg(
|
||||
u"Found {0} series that have an issue #{1} from {2}".format(
|
||||
len(shortlist),
|
||||
keys['issue_number'],
|
||||
keys['year']))
|
||||
|
||||
# now we have a shortlist of volumes with the desired issue number
|
||||
# Do first round of cover matching
|
||||
counter = len(shortlist)
|
||||
for series, issue in shortlist:
|
||||
if self.callback is not None:
|
||||
self.callback(counter, len(shortlist) * 3)
|
||||
counter += 1
|
||||
|
||||
self.log_msg(u"Examining covers for ID: {0} {1} ({2}) ...".format(
|
||||
series['id'],
|
||||
series['name'],
|
||||
series['start_year']), newline=False)
|
||||
|
||||
# parse out the cover date
|
||||
day, month, year = comicVine.parseDateStr(issue['cover_date'])
|
||||
|
||||
# Now check the cover match against the primary image
|
||||
hash_list = [cover_hash]
|
||||
if narrow_cover_hash is not None:
|
||||
hash_list.append(narrow_cover_hash)
|
||||
|
||||
try:
|
||||
image_url = issue['image']['super_url']
|
||||
thumb_url = issue['image']['thumb_url']
|
||||
page_url = issue['site_detail_url']
|
||||
|
||||
score_item = self.getIssueCoverMatchScore(
|
||||
comicVine,
|
||||
issue['id'],
|
||||
image_url,
|
||||
thumb_url,
|
||||
page_url,
|
||||
hash_list,
|
||||
useRemoteAlternates=False)
|
||||
except:
|
||||
self.match_list = []
|
||||
return self.match_list
|
||||
|
||||
match = dict()
|
||||
match['series'] = u"{0} ({1})".format(
|
||||
series['name'], series['start_year'])
|
||||
match['distance'] = score_item['score']
|
||||
match['issue_number'] = keys['issue_number']
|
||||
match['cv_issue_count'] = series['count_of_issues']
|
||||
match['url_image_hash'] = score_item['hash']
|
||||
match['issue_title'] = issue['name']
|
||||
match['issue_id'] = issue['id']
|
||||
match['volume_id'] = series['id']
|
||||
match['month'] = month
|
||||
match['year'] = year
|
||||
match['publisher'] = None
|
||||
if series['publisher'] is not None:
|
||||
match['publisher'] = series['publisher']['name']
|
||||
match['image_url'] = image_url
|
||||
match['thumb_url'] = thumb_url
|
||||
match['page_url'] = page_url
|
||||
match['description'] = issue['description']
|
||||
|
||||
self.match_list.append(match)
|
||||
|
||||
self.log_msg(" --> {0}".format(match['distance']), newline=False)
|
||||
|
||||
self.log_msg("")
|
||||
|
||||
if len(self.match_list) == 0:
|
||||
self.log_msg(":-(no matches!")
|
||||
self.search_result = self.ResultNoMatches
|
||||
return self.match_list
|
||||
|
||||
# sort list by image match scores
|
||||
self.match_list.sort(key=lambda k: k['distance'])
|
||||
|
||||
l = []
|
||||
for i in self.match_list:
|
||||
l.append(i['distance'])
|
||||
|
||||
self.log_msg("Compared to covers in {0} issue(s):".format(
|
||||
len(self.match_list)), newline=False)
|
||||
self.log_msg(str(l))
|
||||
|
||||
def print_match(item):
|
||||
self.log_msg(u"-----> {0} #{1} {2} ({3}/{4}) -- score: {5}".format(
|
||||
item['series'],
|
||||
item['issue_number'],
|
||||
item['issue_title'],
|
||||
item['month'],
|
||||
item['year'],
|
||||
item['distance']))
|
||||
|
||||
best_score = self.match_list[0]['distance']
|
||||
|
||||
if best_score >= self.min_score_thresh:
|
||||
# we have 1 or more low-confidence matches (all bad cover scores)
|
||||
# look at a few more pages in the archive, and also alternate
|
||||
# covers online
|
||||
self.log_msg(
|
||||
"Very weak scores for the cover. Analyzing alternate pages and covers...")
|
||||
hash_list = [cover_hash]
|
||||
if narrow_cover_hash is not None:
|
||||
hash_list.append(narrow_cover_hash)
|
||||
for i in range(1, min(3, ca.getNumberOfPages())):
|
||||
image_data = ca.getPage(i)
|
||||
page_hash = self.calculateHash(image_data)
|
||||
hash_list.append(page_hash)
|
||||
|
||||
second_match_list = []
|
||||
counter = 2 * len(self.match_list)
|
||||
for m in self.match_list:
|
||||
if self.callback is not None:
|
||||
self.callback(counter, len(self.match_list) * 3)
|
||||
counter += 1
|
||||
self.log_msg(
|
||||
u"Examining alternate covers for ID: {0} {1} ...".format(
|
||||
m['volume_id'],
|
||||
m['series']),
|
||||
newline=False)
|
||||
try:
|
||||
score_item = self.getIssueCoverMatchScore(
|
||||
comicVine,
|
||||
m['issue_id'],
|
||||
m['image_url'],
|
||||
m['thumb_url'],
|
||||
m['page_url'],
|
||||
hash_list,
|
||||
useRemoteAlternates=True)
|
||||
except:
|
||||
self.match_list = []
|
||||
return self.match_list
|
||||
self.log_msg("--->{0}".format(score_item['score']))
|
||||
self.log_msg("")
|
||||
|
||||
if score_item['score'] < self.min_alternate_score_thresh:
|
||||
second_match_list.append(m)
|
||||
m['distance'] = score_item['score']
|
||||
|
||||
if len(second_match_list) == 0:
|
||||
if len(self.match_list) == 1:
|
||||
self.log_msg("No matching pages in the issue.")
|
||||
self.log_msg(
|
||||
u"--------------------------------------------------------------------------")
|
||||
print_match(self.match_list[0])
|
||||
self.log_msg(
|
||||
u"--------------------------------------------------------------------------")
|
||||
self.search_result = self.ResultFoundMatchButBadCoverScore
|
||||
else:
|
||||
self.log_msg(
|
||||
u"--------------------------------------------------------------------------")
|
||||
self.log_msg(
|
||||
u"Multiple bad cover matches! Need to use other info...")
|
||||
self.log_msg(
|
||||
u"--------------------------------------------------------------------------")
|
||||
self.search_result = self.ResultMultipleMatchesWithBadImageScores
|
||||
return self.match_list
|
||||
else:
|
||||
# We did good, found something!
|
||||
self.log_msg("Success in secondary/alternate cover matching!")
|
||||
|
||||
self.match_list = second_match_list
|
||||
# sort new list by image match scores
|
||||
self.match_list.sort(key=lambda k: k['distance'])
|
||||
best_score = self.match_list[0]['distance']
|
||||
self.log_msg(
|
||||
"[Second round cover matching: best score = {0}]".format(best_score))
|
||||
# now drop down into the rest of the processing
|
||||
|
||||
if self.callback is not None:
|
||||
self.callback(99, 100)
|
||||
|
||||
# now pare down list, remove any item more than specified distant from
|
||||
# the top scores
|
||||
for item in reversed(self.match_list):
|
||||
if item['distance'] > best_score + self.min_score_distance:
|
||||
self.match_list.remove(item)
|
||||
|
||||
# One more test for the case choosing limited series first issue vs a trade with the same cover:
|
||||
# if we have a given issue count > 1 and the volume from CV has
|
||||
# count==1, remove it from match list
|
||||
if len(self.match_list) >= 2 and keys[
|
||||
'issue_count'] is not None and keys['issue_count'] != 1:
|
||||
new_list = list()
|
||||
for match in self.match_list:
|
||||
if match['cv_issue_count'] != 1:
|
||||
new_list.append(match)
|
||||
else:
|
||||
self.log_msg(
|
||||
"Removing volume {0} [{1}] from consideration (only 1 issue)".format(
|
||||
match['series'],
|
||||
match['volume_id']))
|
||||
|
||||
if len(new_list) > 0:
|
||||
self.match_list = new_list
|
||||
|
||||
if len(self.match_list) == 1:
|
||||
self.log_msg(
|
||||
u"--------------------------------------------------------------------------")
|
||||
print_match(self.match_list[0])
|
||||
self.log_msg(
|
||||
u"--------------------------------------------------------------------------")
|
||||
self.search_result = self.ResultOneGoodMatch
|
||||
|
||||
elif len(self.match_list) == 0:
|
||||
self.log_msg(
|
||||
u"--------------------------------------------------------------------------")
|
||||
self.log_msg("No matches found :(")
|
||||
self.log_msg(
|
||||
u"--------------------------------------------------------------------------")
|
||||
self.search_result = self.ResultNoMatches
|
||||
else:
|
||||
# we've got multiple good matches:
|
||||
self.log_msg("More than one likely candidate.")
|
||||
self.search_result = self.ResultMultipleGoodMatches
|
||||
self.log_msg(
|
||||
u"--------------------------------------------------------------------------")
|
||||
for item in self.match_list:
|
||||
print_match(item)
|
||||
self.log_msg(
|
||||
u"--------------------------------------------------------------------------")
|
||||
|
||||
return self.match_list
|
||||
190
comictaggerlib/issueselectionwindow.py
Normal file
@@ -0,0 +1,190 @@
|
||||
"""A PyQT4 dialog to select specific issue from list"""
|
||||
|
||||
# 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 os
|
||||
#import re
|
||||
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
#from PyQt4.QtCore import QUrl, pyqtSignal, QByteArray
|
||||
#from PyQt4.QtNetwork import QNetworkAccessManager, QNetworkRequest
|
||||
|
||||
from comicvinetalker import ComicVineTalker, ComicVineTalkerException
|
||||
from settings import ComicTaggerSettings
|
||||
from issuestring import IssueString
|
||||
from coverimagewidget import CoverImageWidget
|
||||
from comictaggerlib.ui.qtutils import reduceWidgetFontSize
|
||||
#from imagefetcher import ImageFetcher
|
||||
#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(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)
|
||||
|
||||
self.coverWidget = CoverImageWidget(
|
||||
self.coverImageContainer, CoverImageWidget.AltCoverMode)
|
||||
gridlayout = QtGui.QGridLayout(self.coverImageContainer)
|
||||
gridlayout.addWidget(self.coverWidget)
|
||||
gridlayout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
reduceWidgetFontSize(self.twList)
|
||||
reduceWidgetFontSize(self.teDescription, 1)
|
||||
|
||||
self.setWindowFlags(self.windowFlags() |
|
||||
QtCore.Qt.WindowSystemMenuHint |
|
||||
QtCore.Qt.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.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):
|
||||
|
||||
QtGui.QApplication.setOverrideCursor(
|
||||
QtGui.QCursor(QtCore.Qt.WaitCursor))
|
||||
|
||||
try:
|
||||
comicVine = ComicVineTalker()
|
||||
volume_data = comicVine.fetchVolumeData(self.series_id)
|
||||
self.issue_list = comicVine.fetchIssuesByVolume(self.series_id)
|
||||
except ComicVineTalkerException as e:
|
||||
QtGui.QApplication.restoreOverrideCursor()
|
||||
if e.code == ComicVineTalkerException.RateLimit:
|
||||
QtGui.QMessageBox.critical(
|
||||
self,
|
||||
self.tr("Comic Vine Error"),
|
||||
ComicVineTalker.getRateLimitMessage())
|
||||
else:
|
||||
QtGui.QMessageBox.critical(
|
||||
self,
|
||||
self.tr("Network Issue"),
|
||||
self.tr("Could not connect to Comic Vine to list issues!"))
|
||||
return
|
||||
|
||||
while self.twList.rowCount() > 0:
|
||||
self.twList.removeRow(0)
|
||||
|
||||
self.twList.setSortingEnabled(False)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
QtGui.QApplication.restoreOverrideCursor()
|
||||
|
||||
def cellDoubleClicked(self, r, c):
|
||||
self.accept()
|
||||
|
||||
def currentItemChanged(self, curr, prev):
|
||||
|
||||
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()
|
||||
|
||||
# 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
|
||||
1
comictaggerlib/issuestring.py
Normal file
@@ -0,0 +1 @@
|
||||
from comicapi.issuestring import *
|
||||
37
comictaggerlib/logwindow.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""A PyQT4 dialog to a text file or log"""
|
||||
|
||||
# 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 os
|
||||
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
|
||||
from settings import ComicTaggerSettings
|
||||
|
||||
|
||||
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)
|
||||
|
||||
def setText(self, text):
|
||||
self.textEdit.setPlainText(text)
|
||||
87
comictaggerlib/main.py
Executable file
@@ -0,0 +1,87 @@
|
||||
"""A python app to (automatically) tag comic archives"""
|
||||
|
||||
# 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 signal
|
||||
import traceback
|
||||
import platform
|
||||
#import os
|
||||
|
||||
try:
|
||||
qt_available = True
|
||||
from PyQt4 import QtCore, QtGui
|
||||
from taggerwindow import TaggerWindow
|
||||
except ImportError as e:
|
||||
qt_available = False
|
||||
|
||||
import utils
|
||||
import cli
|
||||
from settings import ComicTaggerSettings
|
||||
from options import Options
|
||||
from comicvinetalker import ComicVineTalker
|
||||
|
||||
|
||||
def ctmain():
|
||||
utils.fix_output_encoding()
|
||||
settings = ComicTaggerSettings()
|
||||
|
||||
opts = Options()
|
||||
opts.parseCmdLineArgs()
|
||||
|
||||
# manage the CV API key
|
||||
if opts.cv_api_key:
|
||||
if opts.cv_api_key != settings.cv_api_key:
|
||||
settings.cv_api_key = opts.cv_api_key
|
||||
settings.save()
|
||||
if opts.only_set_key:
|
||||
print("Key set")
|
||||
return
|
||||
|
||||
ComicVineTalker.api_key = settings.cv_api_key
|
||||
|
||||
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()
|
||||
|
||||
if platform.system() != "Linux":
|
||||
splash.finish(tagger_window)
|
||||
|
||||
sys.exit(app.exec_())
|
||||
except Exception as e:
|
||||
QtGui.QMessageBox.critical(
|
||||
QtGui.QMainWindow(),
|
||||
"Error",
|
||||
"Unhandled exception in app:\n" +
|
||||
traceback.format_exc())
|
||||
160
comictaggerlib/matchselectionwindow.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""A PyQT4 dialog to select from automated issue matches"""
|
||||
|
||||
# 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 os
|
||||
#import sys
|
||||
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
#from PyQt4.QtCore import QUrl, pyqtSignal, QByteArray
|
||||
|
||||
from settings import ComicTaggerSettings
|
||||
from coverimagewidget import CoverImageWidget
|
||||
from comictaggerlib.ui.qtutils import reduceWidgetFontSize
|
||||
#from imagefetcher import ImageFetcher
|
||||
#from comicarchive import MetaDataStyle
|
||||
#from comicvinetalker import ComicVineTalker
|
||||
#import utils
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
reduceWidgetFontSize(self.twList)
|
||||
reduceWidgetFontSize(self.teDescription, 1)
|
||||
|
||||
self.setWindowFlags(self.windowFlags() |
|
||||
QtCore.Qt.WindowSystemMenuHint |
|
||||
QtCore.Qt.WindowMaximizeButtonHint)
|
||||
|
||||
self.matches = matches
|
||||
self.comic_archive = comic_archive
|
||||
|
||||
self.twList.currentItemChanged.connect(self.currentItemChanged)
|
||||
self.twList.cellDoubleClicked.connect(self.cellDoubleClicked)
|
||||
|
||||
self.updateData()
|
||||
|
||||
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):
|
||||
|
||||
while self.twList.rowCount() > 0:
|
||||
self.twList.removeRow(0)
|
||||
|
||||
self.twList.setSortingEnabled(False)
|
||||
|
||||
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)
|
||||
|
||||
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'])
|
||||
|
||||
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)
|
||||
|
||||
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.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 cellDoubleClicked(self, r, c):
|
||||
self.accept()
|
||||
|
||||
def currentItemChanged(self, curr, prev):
|
||||
|
||||
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)
|
||||
|
||||
def currentMatch(self):
|
||||
row = self.twList.currentRow()
|
||||
match = self.twList.item(row, 0).data(
|
||||
QtCore.Qt.UserRole).toPyObject()[0]
|
||||
return match
|
||||
117
comictaggerlib/optionalmsgdialog.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""A PyQt4 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, "Question",
|
||||
"Are you sure you wish to do this?",
|
||||
)
|
||||
"""
|
||||
|
||||
# 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 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 message again"
|
||||
|
||||
self.theCheckBox = QCheckBox(check_text)
|
||||
|
||||
self.theCheckBox.setCheckState(check_state)
|
||||
|
||||
l.addWidget(self.theCheckBox)
|
||||
|
||||
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)
|
||||
|
||||
def reject(self):
|
||||
self.was_accepted = False
|
||||
QDialog.reject(self)
|
||||
|
||||
@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)
|
||||
|
||||
d.exec_()
|
||||
return d.theCheckBox.isChecked()
|
||||
|
||||
@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()
|
||||
424
comictaggerlib/options.py
Normal file
@@ -0,0 +1,424 @@
|
||||
"""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
|
||||
|
||||
try:
|
||||
import argparse
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
from genericmetadata import GenericMetadata
|
||||
from comicarchive import MetaDataStyle
|
||||
from versionchecker import VersionChecker
|
||||
import ctversion
|
||||
import utils
|
||||
|
||||
|
||||
class Options:
|
||||
help_text = """Usage: {0} [option] ... [file [files ...]]
|
||||
|
||||
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 -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
|
||||
(relevant for -s or -c).
|
||||
-1, --assume-issue-one Assume issue number is 1 if not found
|
||||
(relevant for -s).
|
||||
-n, --dryrun Don't actually modify file (only relevant for
|
||||
-d, -s, or -r).
|
||||
-t, --type=TYPE Specify TYPE as either "CR", "CBL", or
|
||||
"COMET" (as either ComicRack, ComicBookLover,
|
||||
or CoMet style tags, respectively).
|
||||
-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 conjunction with -f and -m.
|
||||
--id=ID Use the issue ID when searching online.
|
||||
Overrides all other metadata.
|
||||
-m, --metadata=LIST Explicitly 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.
|
||||
--cv-api-key=KEY Use the given Comic Vine API Key (persisted
|
||||
in settings).
|
||||
--only-set-cv-key Only set the Comic Vine API key and quit.
|
||||
-w, --wait-on-cv-rate-limit When encountering a Comic Vine rate limit
|
||||
error, wait and retry query.
|
||||
-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.cv_api_key = None
|
||||
self.only_set_key = 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.wait_and_retry_on_rate_limit = False
|
||||
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 everything 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:vownsrc: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", "cv-api-key=", "only-set-cv-key",
|
||||
"wait-on-cv-rate-limit"])
|
||||
|
||||
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 in ("-w", "--wait-on-cv-rate-limit"):
|
||||
self.wait_and_retry_on_rate_limit = 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 == "--cv-api-key":
|
||||
self.cv_api_key = a
|
||||
if o == "--only-set-cv-key":
|
||||
self.only_set_key = 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)")
|
||||
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 or self.only_set_key:
|
||||
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 self.only_set_key:
|
||||
count += 1
|
||||
|
||||
if count > 1:
|
||||
self.display_msg_and_quit(
|
||||
"Must choose only one action of print, delete, save, copy, rename, export, set key, 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.only_set_key and self.cv_api_key is None:
|
||||
self.display_msg_and_quit("Key not given!", 1)
|
||||
|
||||
if (self.only_set_key == False) and 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)
|
||||
114
comictaggerlib/pagebrowser.py
Normal file
@@ -0,0 +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.
|
||||
|
||||
import platform
|
||||
#import sys
|
||||
#import os
|
||||
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
|
||||
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
|
||||
|
||||
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()
|
||||
|
||||
def setComicArchive(self, ca):
|
||||
|
||||
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()
|
||||
|
||||
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))
|
||||
281
comictaggerlib/pagelisteditor.py
Normal file
@@ -0,0 +1,281 @@
|
||||
"""A PyQt4 widget for editing the page list info"""
|
||||
|
||||
# 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 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 coverimagewidget import CoverImageWidget
|
||||
#from pageloader import PageLoader
|
||||
|
||||
|
||||
def itemMoveEvents(widget):
|
||||
|
||||
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
|
||||
|
||||
filter = Filter(widget)
|
||||
widget.installEventFilter(filter)
|
||||
return filter.mysignal
|
||||
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
70
comictaggerlib/pageloader.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""A PyQT4 class to load a page image from a ComicArchive in a background thread"""
|
||||
|
||||
# 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 PyQt4 import QtCore, QtGui, uic
|
||||
from PyQt4.QtCore import pyqtSignal
|
||||
|
||||
from comictaggerlib.ui.qtutils import getQImageFromData
|
||||
#from comicarchive import ComicArchive
|
||||
#import utils
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
loadComplete = pyqtSignal(QtGui.QImage)
|
||||
|
||||
instanceList = []
|
||||
mutex = QtCore.QMutex()
|
||||
|
||||
# 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, 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.reapInstances()
|
||||
PageLoader.instanceList.append(self)
|
||||
PageLoader.mutex.unlock()
|
||||
|
||||
def run(self):
|
||||
image_data = self.ca.getPage(self.page_num)
|
||||
if self.abandoned:
|
||||
return
|
||||
|
||||
if image_data is not None:
|
||||
img = getQImageFromData(image_data)
|
||||
|
||||
if self.abandoned:
|
||||
return
|
||||
|
||||
self.loadComplete.emit(img)
|
||||
38
comictaggerlib/progresswindow.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""A PyQT4 dialog to show ID log and progress"""
|
||||
|
||||
# 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 os
|
||||
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
|
||||
from comictaggerlib.ui.qtutils import reduceWidgetFontSize
|
||||
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)
|
||||
|
||||
reduceWidgetFontSize(self.textEdit)
|
||||
163
comictaggerlib/renamewindow.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""A PyQT4 dialog to confirm rename"""
|
||||
|
||||
# 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 os
|
||||
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
|
||||
from settings import ComicTaggerSettings
|
||||
from settingswindow import SettingsWindow
|
||||
from filerenamer import FileRenamer
|
||||
from comicarchive import MetaDataStyle
|
||||
import utils
|
||||
|
||||
|
||||
class RenameWindow(QtGui.QDialog):
|
||||
|
||||
def __init__(self, parent, comic_archive_list, data_style, settings):
|
||||
super(RenameWindow, self).__init__(parent)
|
||||
|
||||
uic.loadUi(ComicTaggerSettings.getUIFile('renamewindow.ui'), self)
|
||||
self.label.setText(
|
||||
"Preview (based on {0} tags):".format(
|
||||
MetaDataStyle.name[data_style]))
|
||||
|
||||
self.setWindowFlags(self.windowFlags() |
|
||||
QtCore.Qt.WindowSystemMenuHint |
|
||||
QtCore.Qt.WindowMaximizeButtonHint)
|
||||
|
||||
self.settings = settings
|
||||
self.comic_archive_list = comic_archive_list
|
||||
self.data_style = data_style
|
||||
|
||||
self.btnSettings.clicked.connect(self.modifySettings)
|
||||
self.configRenamer()
|
||||
self.doPreview()
|
||||
|
||||
def configRenamer(self):
|
||||
self.renamer = FileRenamer(None)
|
||||
self.renamer.setTemplate(self.settings.rename_template)
|
||||
self.renamer.setIssueZeroPadding(
|
||||
self.settings.rename_issue_number_padding)
|
||||
self.renamer.setSmartCleanup(
|
||||
self.settings.rename_use_smart_string_cleanup)
|
||||
|
||||
def doPreview(self):
|
||||
self.rename_list = []
|
||||
while self.twList.rowCount() > 0:
|
||||
self.twList.removeRow(0)
|
||||
|
||||
self.twList.setSortingEnabled(False)
|
||||
|
||||
for ca in self.comic_archive_list:
|
||||
|
||||
new_ext = None # default
|
||||
if self.settings.rename_extension_based_on_archive:
|
||||
if ca.isZip():
|
||||
new_ext = ".cbz"
|
||||
elif ca.isRar():
|
||||
new_ext = ".cbr"
|
||||
|
||||
md = ca.readMetadata(self.data_style)
|
||||
if md.isEmpty:
|
||||
md = ca.metadataFromFilename(self.settings.parse_scan_info)
|
||||
self.renamer.setMetadata(md)
|
||||
new_name = self.renamer.determineName(ca.path, ext=new_ext)
|
||||
|
||||
row = self.twList.rowCount()
|
||||
self.twList.insertRow(row)
|
||||
folder_item = QtGui.QTableWidgetItem()
|
||||
old_name_item = QtGui.QTableWidgetItem()
|
||||
new_name_item = QtGui.QTableWidgetItem()
|
||||
|
||||
item_text = os.path.split(ca.path)[0]
|
||||
folder_item.setFlags(
|
||||
QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
|
||||
self.twList.setItem(row, 0, folder_item)
|
||||
folder_item.setText(item_text)
|
||||
folder_item.setData(QtCore.Qt.ToolTipRole, item_text)
|
||||
|
||||
item_text = os.path.split(ca.path)[1]
|
||||
old_name_item.setFlags(
|
||||
QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
|
||||
self.twList.setItem(row, 1, old_name_item)
|
||||
old_name_item.setText(item_text)
|
||||
old_name_item.setData(QtCore.Qt.ToolTipRole, item_text)
|
||||
|
||||
new_name_item.setFlags(
|
||||
QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled)
|
||||
self.twList.setItem(row, 2, new_name_item)
|
||||
new_name_item.setText(new_name)
|
||||
new_name_item.setData(QtCore.Qt.ToolTipRole, new_name)
|
||||
|
||||
dict_item = dict()
|
||||
dict_item['archive'] = ca
|
||||
dict_item['new_name'] = new_name
|
||||
self.rename_list.append(dict_item)
|
||||
|
||||
# Adjust column sizes
|
||||
self.twList.setVisible(False)
|
||||
self.twList.resizeColumnsToContents()
|
||||
self.twList.setVisible(True)
|
||||
if self.twList.columnWidth(0) > 200:
|
||||
self.twList.setColumnWidth(0, 200)
|
||||
|
||||
self.twList.setSortingEnabled(True)
|
||||
|
||||
def modifySettings(self):
|
||||
settingswin = SettingsWindow(self, self.settings)
|
||||
settingswin.setModal(True)
|
||||
settingswin.showRenameTab()
|
||||
settingswin.exec_()
|
||||
if settingswin.result():
|
||||
self.configRenamer()
|
||||
self.doPreview()
|
||||
|
||||
def accept(self):
|
||||
|
||||
progdialog = QtGui.QProgressDialog(
|
||||
"", "Cancel", 0, len(self.rename_list), self)
|
||||
progdialog.setWindowTitle("Renaming Archives")
|
||||
progdialog.setWindowModality(QtCore.Qt.WindowModal)
|
||||
progdialog.show()
|
||||
|
||||
for idx, item in enumerate(self.rename_list):
|
||||
|
||||
QtCore.QCoreApplication.processEvents()
|
||||
if progdialog.wasCanceled():
|
||||
break
|
||||
progdialog.setValue(idx)
|
||||
idx += 1
|
||||
progdialog.setLabelText(item['new_name'])
|
||||
|
||||
if item['new_name'] == os.path.basename(item['archive'].path):
|
||||
print item['new_name'], "Filename is already good!"
|
||||
continue
|
||||
|
||||
if not item['archive'].isWritable(check_rar_status=False):
|
||||
continue
|
||||
|
||||
folder = os.path.dirname(os.path.abspath(item['archive'].path))
|
||||
new_abs_path = utils.unique_file(
|
||||
os.path.join(folder, item['new_name']))
|
||||
|
||||
os.rename(item['archive'].path, new_abs_path)
|
||||
|
||||
item['archive'].rename(new_abs_path)
|
||||
|
||||
progdialog.close()
|
||||
|
||||
QtGui.QDialog.accept(self)
|
||||
522
comictaggerlib/settings.py
Normal file
@@ -0,0 +1,522 @@
|
||||
"""Settings 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 os
|
||||
import sys
|
||||
import configparser
|
||||
import platform
|
||||
import codecs
|
||||
import uuid
|
||||
|
||||
import utils
|
||||
|
||||
|
||||
class ComicTaggerSettings:
|
||||
|
||||
@staticmethod
|
||||
def getSettingsFolder():
|
||||
filename_encoding = sys.getfilesystemencoding()
|
||||
if platform.system() == "Windows":
|
||||
folder = os.path.join(os.environ['APPDATA'], 'ComicTagger')
|
||||
else:
|
||||
folder = os.path.join(os.path.expanduser('~'), '.ComicTagger')
|
||||
if folder is not None:
|
||||
folder = folder.decode(filename_encoding)
|
||||
return folder
|
||||
|
||||
frozen_win_exe_path = None
|
||||
|
||||
@staticmethod
|
||||
def baseDir():
|
||||
if getattr(sys, 'frozen', None):
|
||||
if platform.system() == "Darwin":
|
||||
return sys._MEIPASS
|
||||
else: # Windows
|
||||
# Preserve this value, in case sys.argv gets changed importing
|
||||
# a plugin script
|
||||
if ComicTaggerSettings.frozen_win_exe_path is None:
|
||||
ComicTaggerSettings.frozen_win_exe_path = os.path.dirname(
|
||||
os.path.abspath(sys.argv[0]))
|
||||
return ComicTaggerSettings.frozen_win_exe_path
|
||||
else:
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
@staticmethod
|
||||
def getGraphic(filename):
|
||||
graphic_folder = os.path.join(
|
||||
ComicTaggerSettings.baseDir(), 'graphics')
|
||||
return os.path.join(graphic_folder, filename)
|
||||
|
||||
@staticmethod
|
||||
def getUIFile(filename):
|
||||
ui_folder = os.path.join(ComicTaggerSettings.baseDir(), 'ui')
|
||||
return os.path.join(ui_folder, filename)
|
||||
|
||||
def setDefaultValues(self):
|
||||
|
||||
# General Settings
|
||||
self.rar_exe_path = ""
|
||||
self.unrar_exe_path = ""
|
||||
self.allow_cbi_in_rar = True
|
||||
self.check_for_new_version = True
|
||||
self.send_usage_stats = False
|
||||
|
||||
# automatic settings
|
||||
self.install_id = uuid.uuid4().hex
|
||||
self.last_selected_save_data_style = 0
|
||||
self.last_selected_load_data_style = 0
|
||||
self.last_opened_folder = ""
|
||||
self.last_main_window_width = 0
|
||||
self.last_main_window_height = 0
|
||||
self.last_main_window_x = 0
|
||||
self.last_main_window_y = 0
|
||||
self.last_form_side_width = -1
|
||||
self.last_list_side_width = -1
|
||||
self.last_filelist_sorted_column = -1
|
||||
self.last_filelist_sorted_order = 0
|
||||
|
||||
# identifier settings
|
||||
self.id_length_delta_thresh = 5
|
||||
self.id_publisher_blacklist = "Panini Comics, Abril, Planeta DeAgostini, Editorial Televisa"
|
||||
|
||||
# Show/ask dialog flags
|
||||
self.ask_about_cbi_in_rar = True
|
||||
self.show_disclaimer = True
|
||||
self.dont_notify_about_this_version = ""
|
||||
self.ask_about_usage_stats = True
|
||||
self.show_no_unrar_warning = True
|
||||
|
||||
# filename parsing settings
|
||||
self.parse_scan_info = True
|
||||
|
||||
# Comic Vine settings
|
||||
self.use_series_start_as_volume = False
|
||||
self.clear_form_before_populating_from_cv = False
|
||||
self.remove_html_tables = False
|
||||
self.cv_api_key = ""
|
||||
|
||||
# CBL Tranform settings
|
||||
|
||||
self.assume_lone_credit_is_primary = False
|
||||
self.copy_characters_to_tags = False
|
||||
self.copy_teams_to_tags = False
|
||||
self.copy_locations_to_tags = False
|
||||
self.copy_storyarcs_to_tags = False
|
||||
self.copy_notes_to_comments = False
|
||||
self.copy_weblink_to_comments = False
|
||||
self.apply_cbl_transform_on_cv_import = False
|
||||
self.apply_cbl_transform_on_bulk_operation = False
|
||||
|
||||
# Rename settings
|
||||
self.rename_template = "%series% #%issue% (%year%)"
|
||||
self.rename_issue_number_padding = 3
|
||||
self.rename_use_smart_string_cleanup = True
|
||||
self.rename_extension_based_on_archive = True
|
||||
|
||||
# Auto-tag stickies
|
||||
self.save_on_low_confidence = False
|
||||
self.dont_use_year_when_identifying = False
|
||||
self.assume_1_if_no_issue_num = False
|
||||
self.ignore_leading_numbers_in_filename = False
|
||||
self.remove_archive_after_successful_match = False
|
||||
self.wait_and_retry_on_rate_limit = False
|
||||
|
||||
def __init__(self):
|
||||
|
||||
self.settings_file = ""
|
||||
self.folder = ""
|
||||
self.setDefaultValues()
|
||||
|
||||
self.config = configparser.RawConfigParser()
|
||||
self.folder = ComicTaggerSettings.getSettingsFolder()
|
||||
|
||||
if not os.path.exists(self.folder):
|
||||
os.makedirs(self.folder)
|
||||
|
||||
self.settings_file = os.path.join(self.folder, "settings")
|
||||
|
||||
# if config file doesn't exist, write one out
|
||||
if not os.path.exists(self.settings_file):
|
||||
self.save()
|
||||
else:
|
||||
self.load()
|
||||
|
||||
# take a crack at finding rar exes, if not set already
|
||||
if self.rar_exe_path == "":
|
||||
if platform.system() == "Windows":
|
||||
# look in some likely places for Windows machines
|
||||
if os.path.exists("C:\Program Files\WinRAR\Rar.exe"):
|
||||
self.rar_exe_path = "C:\Program Files\WinRAR\Rar.exe"
|
||||
elif os.path.exists("C:\Program Files (x86)\WinRAR\Rar.exe"):
|
||||
self.rar_exe_path = "C:\Program Files (x86)\WinRAR\Rar.exe"
|
||||
else:
|
||||
# see if it's in the path of unix user
|
||||
if utils.which("rar") is not None:
|
||||
self.rar_exe_path = utils.which("rar")
|
||||
if self.rar_exe_path != "":
|
||||
self.save()
|
||||
|
||||
if self.unrar_exe_path == "":
|
||||
if platform.system() != "Windows":
|
||||
# see if it's in the path of unix user
|
||||
if utils.which("unrar") is not None:
|
||||
self.unrar_exe_path = utils.which("unrar")
|
||||
if self.unrar_exe_path != "":
|
||||
self.save()
|
||||
|
||||
# make sure unrar/rar programs are now in the path for the UnRAR class to
|
||||
# use
|
||||
utils.addtopath(os.path.dirname(self.unrar_exe_path))
|
||||
utils.addtopath(os.path.dirname(self.rar_exe_path))
|
||||
|
||||
def reset(self):
|
||||
os.unlink(self.settings_file)
|
||||
self.__init__()
|
||||
|
||||
def load(self):
|
||||
|
||||
def readline_generator(f):
|
||||
line = f.readline()
|
||||
while line:
|
||||
yield line
|
||||
line = f.readline()
|
||||
|
||||
#self.config.readfp(codecs.open(self.settings_file, "r", "utf8"))
|
||||
self.config.read_file(
|
||||
readline_generator(codecs.open(self.settings_file, "r", "utf8")))
|
||||
|
||||
self.rar_exe_path = self.config.get('settings', 'rar_exe_path')
|
||||
self.unrar_exe_path = self.config.get('settings', 'unrar_exe_path')
|
||||
if self.config.has_option('settings', 'check_for_new_version'):
|
||||
self.check_for_new_version = self.config.getboolean(
|
||||
'settings', 'check_for_new_version')
|
||||
if self.config.has_option('settings', 'send_usage_stats'):
|
||||
self.send_usage_stats = self.config.getboolean(
|
||||
'settings', 'send_usage_stats')
|
||||
|
||||
if self.config.has_option('auto', 'install_id'):
|
||||
self.install_id = self.config.get('auto', 'install_id')
|
||||
if self.config.has_option('auto', 'last_selected_load_data_style'):
|
||||
self.last_selected_load_data_style = self.config.getint(
|
||||
'auto', 'last_selected_load_data_style')
|
||||
if self.config.has_option('auto', 'last_selected_save_data_style'):
|
||||
self.last_selected_save_data_style = self.config.getint(
|
||||
'auto', 'last_selected_save_data_style')
|
||||
if self.config.has_option('auto', 'last_opened_folder'):
|
||||
self.last_opened_folder = self.config.get(
|
||||
'auto', 'last_opened_folder')
|
||||
if self.config.has_option('auto', 'last_main_window_width'):
|
||||
self.last_main_window_width = self.config.getint(
|
||||
'auto', 'last_main_window_width')
|
||||
if self.config.has_option('auto', 'last_main_window_height'):
|
||||
self.last_main_window_height = self.config.getint(
|
||||
'auto', 'last_main_window_height')
|
||||
if self.config.has_option('auto', 'last_main_window_x'):
|
||||
self.last_main_window_x = self.config.getint(
|
||||
'auto', 'last_main_window_x')
|
||||
if self.config.has_option('auto', 'last_main_window_y'):
|
||||
self.last_main_window_y = self.config.getint(
|
||||
'auto', 'last_main_window_y')
|
||||
if self.config.has_option('auto', 'last_form_side_width'):
|
||||
self.last_form_side_width = self.config.getint(
|
||||
'auto', 'last_form_side_width')
|
||||
if self.config.has_option('auto', 'last_list_side_width'):
|
||||
self.last_list_side_width = self.config.getint(
|
||||
'auto', 'last_list_side_width')
|
||||
if self.config.has_option('auto', 'last_filelist_sorted_column'):
|
||||
self.last_filelist_sorted_column = self.config.getint(
|
||||
'auto', 'last_filelist_sorted_column')
|
||||
if self.config.has_option('auto', 'last_filelist_sorted_order'):
|
||||
self.last_filelist_sorted_order = self.config.getint(
|
||||
'auto', 'last_filelist_sorted_order')
|
||||
|
||||
if self.config.has_option('identifier', 'id_length_delta_thresh'):
|
||||
self.id_length_delta_thresh = self.config.getint(
|
||||
'identifier', 'id_length_delta_thresh')
|
||||
if self.config.has_option('identifier', 'id_publisher_blacklist'):
|
||||
self.id_publisher_blacklist = self.config.get(
|
||||
'identifier', 'id_publisher_blacklist')
|
||||
|
||||
if self.config.has_option('filenameparser', 'parse_scan_info'):
|
||||
self.parse_scan_info = self.config.getboolean(
|
||||
'filenameparser', 'parse_scan_info')
|
||||
|
||||
if self.config.has_option('dialogflags', 'ask_about_cbi_in_rar'):
|
||||
self.ask_about_cbi_in_rar = self.config.getboolean(
|
||||
'dialogflags', 'ask_about_cbi_in_rar')
|
||||
if self.config.has_option('dialogflags', 'show_disclaimer'):
|
||||
self.show_disclaimer = self.config.getboolean(
|
||||
'dialogflags', 'show_disclaimer')
|
||||
if self.config.has_option(
|
||||
'dialogflags', 'dont_notify_about_this_version'):
|
||||
self.dont_notify_about_this_version = self.config.get(
|
||||
'dialogflags', 'dont_notify_about_this_version')
|
||||
if self.config.has_option('dialogflags', 'ask_about_usage_stats'):
|
||||
self.ask_about_usage_stats = self.config.getboolean(
|
||||
'dialogflags', 'ask_about_usage_stats')
|
||||
if self.config.has_option('dialogflags', 'show_no_unrar_warning'):
|
||||
self.show_no_unrar_warning = self.config.getboolean(
|
||||
'dialogflags', 'show_no_unrar_warning')
|
||||
|
||||
if self.config.has_option('comicvine', 'use_series_start_as_volume'):
|
||||
self.use_series_start_as_volume = self.config.getboolean(
|
||||
'comicvine', 'use_series_start_as_volume')
|
||||
if self.config.has_option(
|
||||
'comicvine', 'clear_form_before_populating_from_cv'):
|
||||
self.clear_form_before_populating_from_cv = self.config.getboolean(
|
||||
'comicvine', 'clear_form_before_populating_from_cv')
|
||||
if self.config.has_option('comicvine', 'remove_html_tables'):
|
||||
self.remove_html_tables = self.config.getboolean(
|
||||
'comicvine', 'remove_html_tables')
|
||||
if self.config.has_option('comicvine', 'cv_api_key'):
|
||||
self.cv_api_key = self.config.get('comicvine', 'cv_api_key')
|
||||
|
||||
if self.config.has_option(
|
||||
'cbl_transform', 'assume_lone_credit_is_primary'):
|
||||
self.assume_lone_credit_is_primary = self.config.getboolean(
|
||||
'cbl_transform', 'assume_lone_credit_is_primary')
|
||||
if self.config.has_option('cbl_transform', 'copy_characters_to_tags'):
|
||||
self.copy_characters_to_tags = self.config.getboolean(
|
||||
'cbl_transform', 'copy_characters_to_tags')
|
||||
if self.config.has_option('cbl_transform', 'copy_teams_to_tags'):
|
||||
self.copy_teams_to_tags = self.config.getboolean(
|
||||
'cbl_transform', 'copy_teams_to_tags')
|
||||
if self.config.has_option('cbl_transform', 'copy_locations_to_tags'):
|
||||
self.copy_locations_to_tags = self.config.getboolean(
|
||||
'cbl_transform', 'copy_locations_to_tags')
|
||||
if self.config.has_option('cbl_transform', 'copy_notes_to_comments'):
|
||||
self.copy_notes_to_comments = self.config.getboolean(
|
||||
'cbl_transform', 'copy_notes_to_comments')
|
||||
if self.config.has_option('cbl_transform', 'copy_storyarcs_to_tags'):
|
||||
self.copy_storyarcs_to_tags = self.config.getboolean(
|
||||
'cbl_transform', 'copy_storyarcs_to_tags')
|
||||
if self.config.has_option('cbl_transform', 'copy_weblink_to_comments'):
|
||||
self.copy_weblink_to_comments = self.config.getboolean(
|
||||
'cbl_transform', 'copy_weblink_to_comments')
|
||||
if self.config.has_option(
|
||||
'cbl_transform', 'apply_cbl_transform_on_cv_import'):
|
||||
self.apply_cbl_transform_on_cv_import = self.config.getboolean(
|
||||
'cbl_transform', 'apply_cbl_transform_on_cv_import')
|
||||
if self.config.has_option(
|
||||
'cbl_transform', 'apply_cbl_transform_on_bulk_operation'):
|
||||
self.apply_cbl_transform_on_bulk_operation = self.config.getboolean(
|
||||
'cbl_transform',
|
||||
'apply_cbl_transform_on_bulk_operation')
|
||||
|
||||
if self.config.has_option('rename', 'rename_template'):
|
||||
self.rename_template = self.config.get('rename', 'rename_template')
|
||||
if self.config.has_option('rename', 'rename_issue_number_padding'):
|
||||
self.rename_issue_number_padding = self.config.getint(
|
||||
'rename', 'rename_issue_number_padding')
|
||||
if self.config.has_option('rename', 'rename_use_smart_string_cleanup'):
|
||||
self.rename_use_smart_string_cleanup = self.config.getboolean(
|
||||
'rename', 'rename_use_smart_string_cleanup')
|
||||
if self.config.has_option(
|
||||
'rename', 'rename_extension_based_on_archive'):
|
||||
self.rename_extension_based_on_archive = self.config.getboolean(
|
||||
'rename', 'rename_extension_based_on_archive')
|
||||
|
||||
if self.config.has_option('autotag', 'save_on_low_confidence'):
|
||||
self.save_on_low_confidence = self.config.getboolean(
|
||||
'autotag', 'save_on_low_confidence')
|
||||
if self.config.has_option('autotag', 'dont_use_year_when_identifying'):
|
||||
self.dont_use_year_when_identifying = self.config.getboolean(
|
||||
'autotag', 'dont_use_year_when_identifying')
|
||||
if self.config.has_option('autotag', 'assume_1_if_no_issue_num'):
|
||||
self.assume_1_if_no_issue_num = self.config.getboolean(
|
||||
'autotag', 'assume_1_if_no_issue_num')
|
||||
if self.config.has_option(
|
||||
'autotag', 'ignore_leading_numbers_in_filename'):
|
||||
self.ignore_leading_numbers_in_filename = self.config.getboolean(
|
||||
'autotag', 'ignore_leading_numbers_in_filename')
|
||||
if self.config.has_option(
|
||||
'autotag', 'remove_archive_after_successful_match'):
|
||||
self.remove_archive_after_successful_match = self.config.getboolean(
|
||||
'autotag',
|
||||
'remove_archive_after_successful_match')
|
||||
if self.config.has_option('autotag', 'wait_and_retry_on_rate_limit'):
|
||||
self.wait_and_retry_on_rate_limit = self.config.getboolean(
|
||||
'autotag', 'wait_and_retry_on_rate_limit')
|
||||
|
||||
def save(self):
|
||||
|
||||
if not self.config.has_section('settings'):
|
||||
self.config.add_section('settings')
|
||||
|
||||
self.config.set(
|
||||
'settings', 'check_for_new_version', self.check_for_new_version)
|
||||
self.config.set('settings', 'rar_exe_path', self.rar_exe_path)
|
||||
self.config.set('settings', 'unrar_exe_path', self.unrar_exe_path)
|
||||
self.config.set('settings', 'send_usage_stats', self.send_usage_stats)
|
||||
|
||||
if not self.config.has_section('auto'):
|
||||
self.config.add_section('auto')
|
||||
|
||||
self.config.set('auto', 'install_id', self.install_id)
|
||||
self.config.set(
|
||||
'auto',
|
||||
'last_selected_load_data_style',
|
||||
self.last_selected_load_data_style)
|
||||
self.config.set(
|
||||
'auto',
|
||||
'last_selected_save_data_style',
|
||||
self.last_selected_save_data_style)
|
||||
self.config.set('auto', 'last_opened_folder', self.last_opened_folder)
|
||||
self.config.set(
|
||||
'auto', 'last_main_window_width', self.last_main_window_width)
|
||||
self.config.set(
|
||||
'auto', 'last_main_window_height', self.last_main_window_height)
|
||||
self.config.set('auto', 'last_main_window_x', self.last_main_window_x)
|
||||
self.config.set('auto', 'last_main_window_y', self.last_main_window_y)
|
||||
self.config.set(
|
||||
'auto', 'last_form_side_width', self.last_form_side_width)
|
||||
self.config.set(
|
||||
'auto', 'last_list_side_width', self.last_list_side_width)
|
||||
self.config.set(
|
||||
'auto',
|
||||
'last_filelist_sorted_column',
|
||||
self.last_filelist_sorted_column)
|
||||
self.config.set(
|
||||
'auto',
|
||||
'last_filelist_sorted_order',
|
||||
self.last_filelist_sorted_order)
|
||||
|
||||
if not self.config.has_section('identifier'):
|
||||
self.config.add_section('identifier')
|
||||
|
||||
self.config.set(
|
||||
'identifier',
|
||||
'id_length_delta_thresh',
|
||||
self.id_length_delta_thresh)
|
||||
self.config.set(
|
||||
'identifier',
|
||||
'id_publisher_blacklist',
|
||||
self.id_publisher_blacklist)
|
||||
|
||||
if not self.config.has_section('dialogflags'):
|
||||
self.config.add_section('dialogflags')
|
||||
|
||||
self.config.set(
|
||||
'dialogflags', 'ask_about_cbi_in_rar', self.ask_about_cbi_in_rar)
|
||||
self.config.set('dialogflags', 'show_disclaimer', self.show_disclaimer)
|
||||
self.config.set(
|
||||
'dialogflags',
|
||||
'dont_notify_about_this_version',
|
||||
self.dont_notify_about_this_version)
|
||||
self.config.set(
|
||||
'dialogflags', 'ask_about_usage_stats', self.ask_about_usage_stats)
|
||||
self.config.set(
|
||||
'dialogflags', 'show_no_unrar_warning', self.show_no_unrar_warning)
|
||||
|
||||
if not self.config.has_section('filenameparser'):
|
||||
self.config.add_section('filenameparser')
|
||||
|
||||
self.config.set(
|
||||
'filenameparser', 'parse_scan_info', self.parse_scan_info)
|
||||
|
||||
if not self.config.has_section('comicvine'):
|
||||
self.config.add_section('comicvine')
|
||||
|
||||
self.config.set(
|
||||
'comicvine',
|
||||
'use_series_start_as_volume',
|
||||
self.use_series_start_as_volume)
|
||||
self.config.set('comicvine', 'clear_form_before_populating_from_cv',
|
||||
self.clear_form_before_populating_from_cv)
|
||||
self.config.set(
|
||||
'comicvine', 'remove_html_tables', self.remove_html_tables)
|
||||
self.config.set('comicvine', 'cv_api_key', self.cv_api_key)
|
||||
|
||||
if not self.config.has_section('cbl_transform'):
|
||||
self.config.add_section('cbl_transform')
|
||||
|
||||
self.config.set(
|
||||
'cbl_transform',
|
||||
'assume_lone_credit_is_primary',
|
||||
self.assume_lone_credit_is_primary)
|
||||
self.config.set(
|
||||
'cbl_transform',
|
||||
'copy_characters_to_tags',
|
||||
self.copy_characters_to_tags)
|
||||
self.config.set(
|
||||
'cbl_transform', 'copy_teams_to_tags', self.copy_teams_to_tags)
|
||||
self.config.set(
|
||||
'cbl_transform',
|
||||
'copy_locations_to_tags',
|
||||
self.copy_locations_to_tags)
|
||||
self.config.set(
|
||||
'cbl_transform',
|
||||
'copy_storyarcs_to_tags',
|
||||
self.copy_storyarcs_to_tags)
|
||||
self.config.set(
|
||||
'cbl_transform',
|
||||
'copy_notes_to_comments',
|
||||
self.copy_notes_to_comments)
|
||||
self.config.set(
|
||||
'cbl_transform',
|
||||
'copy_weblink_to_comments',
|
||||
self.copy_weblink_to_comments)
|
||||
self.config.set(
|
||||
'cbl_transform',
|
||||
'apply_cbl_transform_on_cv_import',
|
||||
self.apply_cbl_transform_on_cv_import)
|
||||
self.config.set(
|
||||
'cbl_transform',
|
||||
'apply_cbl_transform_on_bulk_operation',
|
||||
self.apply_cbl_transform_on_bulk_operation)
|
||||
|
||||
if not self.config.has_section('rename'):
|
||||
self.config.add_section('rename')
|
||||
|
||||
self.config.set('rename', 'rename_template', self.rename_template)
|
||||
self.config.set(
|
||||
'rename',
|
||||
'rename_issue_number_padding',
|
||||
self.rename_issue_number_padding)
|
||||
self.config.set(
|
||||
'rename',
|
||||
'rename_use_smart_string_cleanup',
|
||||
self.rename_use_smart_string_cleanup)
|
||||
self.config.set('rename', 'rename_extension_based_on_archive',
|
||||
self.rename_extension_based_on_archive)
|
||||
|
||||
if not self.config.has_section('autotag'):
|
||||
self.config.add_section('autotag')
|
||||
self.config.set(
|
||||
'autotag', 'save_on_low_confidence', self.save_on_low_confidence)
|
||||
self.config.set(
|
||||
'autotag',
|
||||
'dont_use_year_when_identifying',
|
||||
self.dont_use_year_when_identifying)
|
||||
self.config.set(
|
||||
'autotag',
|
||||
'assume_1_if_no_issue_num',
|
||||
self.assume_1_if_no_issue_num)
|
||||
self.config.set('autotag', 'ignore_leading_numbers_in_filename',
|
||||
self.ignore_leading_numbers_in_filename)
|
||||
self.config.set('autotag', 'remove_archive_after_successful_match',
|
||||
self.remove_archive_after_successful_match)
|
||||
self.config.set(
|
||||
'autotag',
|
||||
'wait_and_retry_on_rate_limit',
|
||||
self.wait_and_retry_on_rate_limit)
|
||||
|
||||
with codecs.open(self.settings_file, 'wb', 'utf8') as configfile:
|
||||
self.config.write(configfile)
|
||||
|
||||
# make sure the basedir is cached, in case we're on Windows running a
|
||||
# script from frozen binary
|
||||
ComicTaggerSettings.baseDir()
|
||||
272
comictaggerlib/settingswindow.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""A PyQT4 dialog to enter app settings"""
|
||||
|
||||
# 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 platform
|
||||
import os
|
||||
|
||||
from PyQt4 import QtCore, QtGui, uic
|
||||
|
||||
from settings import ComicTaggerSettings
|
||||
from comicvinecacher import ComicVineCacher
|
||||
from comicvinetalker import ComicVineTalker
|
||||
from imagefetcher import ImageFetcher
|
||||
import utils
|
||||
|
||||
|
||||
windowsRarHelp = """
|
||||
<html><head/><body><p>In order to write to CBR/RAR archives,
|
||||
you will need to have the tools from
|
||||
<a href="http://www.win-rar.com/download.html">
|
||||
<span style=" text-decoration: underline; color:#0000ff;">WinRAR</span>
|
||||
</a> installed. </p></body></html>
|
||||
"""
|
||||
|
||||
linuxRarHelp = """
|
||||
<html><head/><body><p>In order to read/write to CBR/RAR archives,
|
||||
you will need to have the shareware tools from WinRar installed.
|
||||
Your package manager should have unrar, and probably rar.
|
||||
If not, download them <a href="http://www.win-rar.com/download.html">
|
||||
<span style=" text-decoration: underline; color:#0000ff;">here</span>
|
||||
</a>, and install in your path. </p></body></html>
|
||||
"""
|
||||
macRarHelp = """
|
||||
<html><head/><body><p>In order to read/write to CBR/RAR archives,
|
||||
you will need the shareware tools from
|
||||
<a href="http://www.win-rar.com/download.html">
|
||||
<span style=" text-decoration: underline; color:#0000ff;">WinRAR</span>
|
||||
</a>. </p></body></html>
|
||||
"""
|
||||
|
||||
|
||||
class SettingsWindow(QtGui.QDialog):
|
||||
|
||||
def __init__(self, parent, settings):
|
||||
super(SettingsWindow, self).__init__(parent)
|
||||
|
||||
uic.loadUi(ComicTaggerSettings.getUIFile('settingswindow.ui'), self)
|
||||
|
||||
self.setWindowFlags(self.windowFlags() &
|
||||
~QtCore.Qt.WindowContextHelpButtonHint)
|
||||
|
||||
self.settings = settings
|
||||
self.name = "Settings"
|
||||
|
||||
if platform.system() == "Windows":
|
||||
self.lblUnrar.hide()
|
||||
self.leUnrarExePath.hide()
|
||||
self.btnBrowseUnrar.hide()
|
||||
self.lblRarHelp.setText(windowsRarHelp)
|
||||
|
||||
elif platform.system() == "Linux":
|
||||
self.lblRarHelp.setText(linuxRarHelp)
|
||||
|
||||
elif platform.system() == "Darwin":
|
||||
self.lblRarHelp.setText(macRarHelp)
|
||||
self.name = "Preferences"
|
||||
|
||||
self.setWindowTitle("ComicTagger " + self.name)
|
||||
self.lblDefaultSettings.setText(
|
||||
"Revert to default " + self.name.lower())
|
||||
self.btnResetSettings.setText("Default " + self.name)
|
||||
|
||||
nldtTip = (
|
||||
"""<html>The <b>Default 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.leNameLengthDeltaThresh.setToolTip(nldtTip)
|
||||
|
||||
pblTip = (
|
||||
"""<html>
|
||||
The <b>Publisher Blacklist</b> is for eliminating automatic matches to certain publishers
|
||||
that you know are incorrect. Useful for avoiding international re-prints with same
|
||||
covers or series names. Enter publisher names separated by commas.
|
||||
</html>"""
|
||||
)
|
||||
self.tePublisherBlacklist.setToolTip(pblTip)
|
||||
|
||||
validator = QtGui.QIntValidator(1, 4, self)
|
||||
self.leIssueNumPadding.setValidator(validator)
|
||||
|
||||
validator = QtGui.QIntValidator(0, 99, self)
|
||||
self.leNameLengthDeltaThresh.setValidator(validator)
|
||||
|
||||
self.settingsToForm()
|
||||
|
||||
self.btnBrowseRar.clicked.connect(self.selectRar)
|
||||
self.btnBrowseUnrar.clicked.connect(self.selectUnrar)
|
||||
self.btnClearCache.clicked.connect(self.clearCache)
|
||||
self.btnResetSettings.clicked.connect(self.resetSettings)
|
||||
self.btnTestKey.clicked.connect(self.testAPIKey)
|
||||
|
||||
def settingsToForm(self):
|
||||
|
||||
# Copy values from settings to form
|
||||
self.leRarExePath.setText(self.settings.rar_exe_path)
|
||||
self.leUnrarExePath.setText(self.settings.unrar_exe_path)
|
||||
self.leNameLengthDeltaThresh.setText(
|
||||
str(self.settings.id_length_delta_thresh))
|
||||
self.tePublisherBlacklist.setPlainText(
|
||||
self.settings.id_publisher_blacklist)
|
||||
|
||||
if self.settings.check_for_new_version:
|
||||
self.cbxCheckForNewVersion.setCheckState(QtCore.Qt.Checked)
|
||||
|
||||
if self.settings.parse_scan_info:
|
||||
self.cbxParseScanInfo.setCheckState(QtCore.Qt.Checked)
|
||||
|
||||
if self.settings.use_series_start_as_volume:
|
||||
self.cbxUseSeriesStartAsVolume.setCheckState(QtCore.Qt.Checked)
|
||||
if self.settings.clear_form_before_populating_from_cv:
|
||||
self.cbxClearFormBeforePopulating.setCheckState(QtCore.Qt.Checked)
|
||||
if self.settings.remove_html_tables:
|
||||
self.cbxRemoveHtmlTables.setCheckState(QtCore.Qt.Checked)
|
||||
self.leKey.setText(str(self.settings.cv_api_key))
|
||||
|
||||
if self.settings.assume_lone_credit_is_primary:
|
||||
self.cbxAssumeLoneCreditIsPrimary.setCheckState(QtCore.Qt.Checked)
|
||||
if self.settings.copy_characters_to_tags:
|
||||
self.cbxCopyCharactersToTags.setCheckState(QtCore.Qt.Checked)
|
||||
if self.settings.copy_teams_to_tags:
|
||||
self.cbxCopyTeamsToTags.setCheckState(QtCore.Qt.Checked)
|
||||
if self.settings.copy_locations_to_tags:
|
||||
self.cbxCopyLocationsToTags.setCheckState(QtCore.Qt.Checked)
|
||||
if self.settings.copy_storyarcs_to_tags:
|
||||
self.cbxCopyStoryArcsToTags.setCheckState(QtCore.Qt.Checked)
|
||||
if self.settings.copy_notes_to_comments:
|
||||
self.cbxCopyNotesToComments.setCheckState(QtCore.Qt.Checked)
|
||||
if self.settings.copy_weblink_to_comments:
|
||||
self.cbxCopyWebLinkToComments.setCheckState(QtCore.Qt.Checked)
|
||||
if self.settings.apply_cbl_transform_on_cv_import:
|
||||
self.cbxApplyCBLTransformOnCVIMport.setCheckState(
|
||||
QtCore.Qt.Checked)
|
||||
if self.settings.apply_cbl_transform_on_bulk_operation:
|
||||
self.cbxApplyCBLTransformOnBatchOperation.setCheckState(
|
||||
QtCore.Qt.Checked)
|
||||
|
||||
self.leRenameTemplate.setText(self.settings.rename_template)
|
||||
self.leIssueNumPadding.setText(
|
||||
str(self.settings.rename_issue_number_padding))
|
||||
if self.settings.rename_use_smart_string_cleanup:
|
||||
self.cbxSmartCleanup.setCheckState(QtCore.Qt.Checked)
|
||||
if self.settings.rename_extension_based_on_archive:
|
||||
self.cbxChangeExtension.setCheckState(QtCore.Qt.Checked)
|
||||
|
||||
def accept(self):
|
||||
|
||||
# Copy values from form to settings and save
|
||||
self.settings.rar_exe_path = str(self.leRarExePath.text())
|
||||
self.settings.unrar_exe_path = str(self.leUnrarExePath.text())
|
||||
|
||||
# make sure unrar/rar program is now in the path for the UnRAR class
|
||||
utils.addtopath(os.path.dirname(self.settings.unrar_exe_path))
|
||||
utils.addtopath(os.path.dirname(self.settings.rar_exe_path))
|
||||
|
||||
if not str(self.leNameLengthDeltaThresh.text()).isdigit():
|
||||
self.leNameLengthDeltaThresh.setText("0")
|
||||
|
||||
if not str(self.leIssueNumPadding.text()).isdigit():
|
||||
self.leIssueNumPadding.setText("0")
|
||||
|
||||
self.settings.check_for_new_version = self.cbxCheckForNewVersion.isChecked()
|
||||
|
||||
self.settings.id_length_delta_thresh = int(
|
||||
self.leNameLengthDeltaThresh.text())
|
||||
self.settings.id_publisher_blacklist = str(
|
||||
self.tePublisherBlacklist.toPlainText())
|
||||
|
||||
self.settings.parse_scan_info = self.cbxParseScanInfo.isChecked()
|
||||
|
||||
self.settings.use_series_start_as_volume = self.cbxUseSeriesStartAsVolume.isChecked()
|
||||
self.settings.clear_form_before_populating_from_cv = self.cbxClearFormBeforePopulating.isChecked()
|
||||
self.settings.remove_html_tables = self.cbxRemoveHtmlTables.isChecked()
|
||||
self.settings.cv_api_key = unicode(self.leKey.text())
|
||||
ComicVineTalker.api_key = self.settings.cv_api_key
|
||||
self.settings.assume_lone_credit_is_primary = self.cbxAssumeLoneCreditIsPrimary.isChecked()
|
||||
self.settings.copy_characters_to_tags = self.cbxCopyCharactersToTags.isChecked()
|
||||
self.settings.copy_teams_to_tags = self.cbxCopyTeamsToTags.isChecked()
|
||||
self.settings.copy_locations_to_tags = self.cbxCopyLocationsToTags.isChecked()
|
||||
self.settings.copy_storyarcs_to_tags = self.cbxCopyStoryArcsToTags.isChecked()
|
||||
self.settings.copy_notes_to_comments = self.cbxCopyNotesToComments.isChecked()
|
||||
self.settings.copy_weblink_to_comments = self.cbxCopyWebLinkToComments.isChecked()
|
||||
self.settings.apply_cbl_transform_on_cv_import = self.cbxApplyCBLTransformOnCVIMport.isChecked()
|
||||
self.settings.apply_cbl_transform_on_bulk_operation = self.cbxApplyCBLTransformOnBatchOperation.isChecked()
|
||||
|
||||
self.settings.rename_template = str(self.leRenameTemplate.text())
|
||||
self.settings.rename_issue_number_padding = int(
|
||||
self.leIssueNumPadding.text())
|
||||
self.settings.rename_use_smart_string_cleanup = self.cbxSmartCleanup.isChecked()
|
||||
self.settings.rename_extension_based_on_archive = self.cbxChangeExtension.isChecked()
|
||||
|
||||
self.settings.save()
|
||||
QtGui.QDialog.accept(self)
|
||||
|
||||
def selectRar(self):
|
||||
self.selectFile(self.leRarExePath, "RAR")
|
||||
|
||||
def selectUnrar(self):
|
||||
self.selectFile(self.leUnrarExePath, "UnRAR")
|
||||
|
||||
def clearCache(self):
|
||||
ImageFetcher().clearCache()
|
||||
ComicVineCacher().clearCache()
|
||||
QtGui.QMessageBox.information(
|
||||
self, self.name, "Cache has been cleared.")
|
||||
|
||||
def testAPIKey(self):
|
||||
if ComicVineTalker().testKey(unicode(self.leKey.text())):
|
||||
QtGui.QMessageBox.information(
|
||||
self, "API Key Test", "Key is valid!")
|
||||
else:
|
||||
QtGui.QMessageBox.warning(
|
||||
self, "API Key Test", "Key is NOT valid.")
|
||||
|
||||
def resetSettings(self):
|
||||
self.settings.reset()
|
||||
self.settingsToForm()
|
||||
QtGui.QMessageBox.information(
|
||||
self,
|
||||
self.name,
|
||||
self.name +
|
||||
" have been returned to default values.")
|
||||
|
||||
def selectFile(self, control, name):
|
||||
|
||||
dialog = QtGui.QFileDialog(self)
|
||||
dialog.setFileMode(QtGui.QFileDialog.ExistingFile)
|
||||
|
||||
if platform.system() == "Windows":
|
||||
if name == "RAR":
|
||||
filter = self.tr("Rar Program (Rar.exe)")
|
||||
else:
|
||||
filter = self.tr("Programs (*.exe)")
|
||||
dialog.setNameFilter(filter)
|
||||
else:
|
||||
# QtCore.QDir.Executable | QtCore.QDir.Files)
|
||||
dialog.setFilter(QtCore.QDir.Files)
|
||||
pass
|
||||
|
||||
dialog.setDirectory(os.path.dirname(str(control.text())))
|
||||
dialog.setWindowTitle("Find " + name + " program")
|
||||
|
||||
if (dialog.exec_()):
|
||||
fileList = dialog.selectedFiles()
|
||||
control.setText(str(fileList[0]))
|
||||
|
||||
def showRenameTab(self):
|
||||
self.tabWidget.setCurrentIndex(5)
|
||||
2198
comictaggerlib/taggerwindow.py
Normal file
0
comictaggerlib/ui/__init__.py
Normal file
174
comictaggerlib/ui/autotagmatchwindow.ui
Normal file
@@ -0,0 +1,174 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>dialogMatchSelect</class>
|
||||
<widget class="QDialog" name="dialogMatchSelect">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>943</width>
|
||||
<height>467</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Select Match</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="0">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<item>
|
||||
<widget class="QWidget" name="archiveCoverContainer" native="true">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>350</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>350</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QSplitter" name="splitter">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="orientation">
|
||||
<enum>Qt::Vertical</enum>
|
||||
</property>
|
||||
<property name="childrenCollapsible">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<widget class="QTableWidget" name="twList">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>7</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="selectionMode">
|
||||
<enum>QAbstractItemView::SingleSelection</enum>
|
||||
</property>
|
||||
<property name="selectionBehavior">
|
||||
<enum>QAbstractItemView::SelectRows</enum>
|
||||
</property>
|
||||
<property name="rowCount">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="columnCount">
|
||||
<number>4</number>
|
||||
</property>
|
||||
<attribute name="horizontalHeaderStretchLastSection">
|
||||
<bool>true</bool>
|
||||
</attribute>
|
||||
<attribute name="verticalHeaderVisible">
|
||||
<bool>false</bool>
|
||||
</attribute>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Series</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Publisher</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Date</string>
|
||||
</property>
|
||||
</column>
|
||||
<column>
|
||||
<property name="text">
|
||||
<string>Title</string>
|
||||
</property>
|
||||
</column>
|
||||
</widget>
|
||||
<widget class="QTextEdit" name="teDescription">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>3</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QWidget" name="altCoverContainer" native="true">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>350</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>200</width>
|
||||
<height>350</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QDialogButtonBox" name="buttonBox">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>accepted()</signal>
|
||||
<receiver>dialogMatchSelect</receiver>
|
||||
<slot>accept()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>248</x>
|
||||
<y>254</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>157</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>rejected()</signal>
|
||||
<receiver>dialogMatchSelect</receiver>
|
||||
<slot>reject()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>316</x>
|
||||
<y>260</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>286</x>
|
||||
<y>274</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
@@ -21,6 +21,44 @@
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="0" column="1">
|
||||
<widget class="QWidget" name="archiveCoverContainer">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>165</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>165</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="4">
|
||||
<widget class="QWidget" name="testCoverContainer">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>165</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>165</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QProgressBar" name="progressBar">
|
||||
@@ -66,50 +104,6 @@
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="lblArchive">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Fixed" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>165</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>165</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>TextLabel</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="2">
|
||||
<widget class="QLabel" name="lblTest">
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>165</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>110</width>
|
||||
<height>165</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>TextLabel</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
239
comictaggerlib/ui/autotagstartwindow.ui
Normal file
@@ -0,0 +1,239 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>dialogExport</class>
|
||||
<widget class="QDialog" name="dialogExport">
|
||||
<property name="windowModality">
|
||||
<enum>Qt::NonModal</enum>
|
||||
</property>
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>519</width>
|
||||
<height>378</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="MinimumExpanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Auto-Tag</string>
|
||||
</property>
|
||||
<property name="modal">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_2">
|
||||
<item row="0" column="0">
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="wordWrap">
|
||||
<bool>true</bool>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<item row="6" column="0">
|
||||
<widget class="QCheckBox" name="cbxSpecifySearchString">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Specify series search string for all selected archives:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QCheckBox" name="cbxSaveOnLowConfidence">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Save on low confidence match</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QCheckBox" name="cbxIgnoreLeadingDigitsInFilename">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Ignore leading (sequence) numbers in filename</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="5" column="0">
|
||||
<widget class="QCheckBox" name="cbxRemoveAfterSuccess">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Remove archives from list after successful tagging</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="3" column="0">
|
||||
<widget class="QCheckBox" name="cbxWaitForRateLimit">
|
||||
<property name="text">
|
||||
<string>Wait and retry when Comic Vine rate limit is exceeded (experimental)</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="1" column="0">
|
||||
<widget class="QCheckBox" name="cbxDontUseYear">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Don't use publication year in indentification process</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QCheckBox" name="cbxAssumeIssueOne">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Minimum" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>If no issue number, assume "1"</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="10" column="0">
|
||||
<widget class="QLineEdit" name="leNameLengthMatchTolerance">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>50</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="7" column="0">
|
||||
<widget class="QLineEdit" name="leSearchString">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="8" column="0">
|
||||
<widget class="QLabel" name="label_3">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>Adjust Name Length Match Tolerance:</string>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="2" column="0">
|
||||
<widget class="QLabel" name="label_2">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="minimumSize">
|
||||
<size>
|
||||
<width>40</width>
|
||||
<height>0</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item row="4" column="0">
|
||||
<widget class="QDialogButtonBox" name="buttonBox">
|
||||
<property name="orientation">
|
||||
<enum>Qt::Horizontal</enum>
|
||||
</property>
|
||||
<property name="standardButtons">
|
||||
<set>QDialogButtonBox::Cancel|QDialogButtonBox::Ok</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>accepted()</signal>
|
||||
<receiver>dialogExport</receiver>
|
||||
<slot>accept()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>346</x>
|
||||
<y>187</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>277</x>
|
||||
<y>104</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
<connection>
|
||||
<sender>buttonBox</sender>
|
||||
<signal>rejected()</signal>
|
||||
<receiver>dialogExport</receiver>
|
||||
<slot>reject()</slot>
|
||||
<hints>
|
||||
<hint type="sourcelabel">
|
||||
<x>346</x>
|
||||
<y>187</y>
|
||||
</hint>
|
||||
<hint type="destinationlabel">
|
||||
<x>277</x>
|
||||
<y>104</y>
|
||||
</hint>
|
||||
</hints>
|
||||
</connection>
|
||||
</connections>
|
||||
</ui>
|
||||
123
comictaggerlib/ui/coverimagewidget.ui
Normal file
@@ -0,0 +1,123 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ui version="4.0">
|
||||
<class>coverImageWidget</class>
|
||||
<widget class="QWidget" name="coverImageWidget">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>292</width>
|
||||
<height>353</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Form</string>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout">
|
||||
<property name="horizontalSpacing">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<property name="verticalSpacing">
|
||||
<number>4</number>
|
||||
</property>
|
||||
<property name="margin">
|
||||
<number>0</number>
|
||||
</property>
|
||||
<item row="1" column="0">
|
||||
<layout class="QHBoxLayout" name="horizontalLayout">
|
||||
<property name="spacing">
|
||||
<number>2</number>
|
||||
</property>
|
||||
<item>
|
||||
<widget class="QPushButton" name="btnLeft">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>30</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="MinimumExpanding" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
<property name="alignment">
|
||||
<set>Qt::AlignCenter</set>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
<item>
|
||||
<widget class="QPushButton" name="btnRight">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="maximumSize">
|
||||
<size>
|
||||
<width>30</width>
|
||||
<height>16777215</height>
|
||||
</size>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string/>
|
||||
</property>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</item>
|
||||
<item row="0" column="0">
|
||||
<widget class="QFrame" name="frame">
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Expanding" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="frameShape">
|
||||
<enum>QFrame::NoFrame</enum>
|
||||
</property>
|
||||
<property name="frameShadow">
|
||||
<enum>QFrame::Raised</enum>
|
||||
</property>
|
||||
<widget class="QLabel" name="lblImage">
|
||||
<property name="geometry">
|
||||
<rect>
|
||||
<x>60</x>
|
||||
<y>50</y>
|
||||
<width>91</width>
|
||||
<height>61</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="toolTip">
|
||||
<string>Double-click to expand</string>
|
||||
</property>
|
||||
<property name="text">
|
||||
<string>TextLabel</string>
|
||||
</property>
|
||||
</widget>
|
||||
</widget>
|
||||
</item>
|
||||
</layout>
|
||||
</widget>
|
||||
<resources/>
|
||||
<connections/>
|
||||
</ui>
|
||||
@@ -9,18 +9,24 @@
|
||||
<rect>
|
||||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>515</width>
|
||||
<height>307</height>
|
||||
<width>533</width>
|
||||
<height>202</height>
|
||||
</rect>
|
||||
</property>
|
||||
<property name="sizePolicy">
|
||||
<sizepolicy hsizetype="Preferred" vsizetype="Expanding">
|
||||
<horstretch>0</horstretch>
|
||||
<verstretch>0</verstretch>
|
||||
</sizepolicy>
|
||||
</property>
|
||||
<property name="windowTitle">
|
||||
<string>Export to Zip Archive</string>
|
||||
</property>
|
||||
<property name="modal">
|
||||
<bool>false</bool>
|
||||
</property>
|
||||
<layout class="QGridLayout" name="gridLayout_3">
|
||||
<item row="0" column="0">
|
||||
<layout class="QVBoxLayout" name="verticalLayout_2">
|
||||
<item>
|
||||
<layout class="QVBoxLayout" name="verticalLayout">
|
||||
<item>
|
||||
<widget class="QLabel" name="label">
|
||||