1"""
2Copyright (c) 2017 Wolthera van Hövell tot Westerflier <griffinvalley@gmail.com>
3
4This file is part of the Comics Project Management Tools(CPMT).
5
6CPMT is free software: you can redistribute it and/or modify
7it under the terms of the GNU General Public License as published by
8the Free Software Foundation, either version 3 of the License, or
9(at your option) any later version.
10
11CPMT is distributed in the hope that it will be useful,
12but WITHOUT ANY WARRANTY; without even the implied warranty of
13MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14GNU General Public License for more details.
15
16You should have received a copy of the GNU General Public License
17along with the CPMT.  If not, see <http://www.gnu.org/licenses/>.
18"""
19
20"""
21This is a docker that helps you organise your comics project.
22"""
23import sys
24import json
25import os
26import zipfile  # quick reading of documents
27import shutil
28import enum
29from math import floor
30import xml.etree.ElementTree as ET
31from PyQt5.QtCore import QElapsedTimer, QSize, Qt, QRect, QFileSystemWatcher, QTimer
32from PyQt5.QtGui import QStandardItem, QStandardItemModel, QImage, QIcon, QPixmap, QFontMetrics, QPainter, QPalette, QFont
33from PyQt5.QtWidgets import QHBoxLayout, QVBoxLayout, QListView, QToolButton, QMenu, QAction, QPushButton, QSpacerItem, QSizePolicy, QWidget, QAbstractItemView, QProgressDialog, QDialog, QFileDialog, QDialogButtonBox, qApp, QSplitter, QSlider, QLabel, QStyledItemDelegate, QStyle, QMessageBox
34import math
35from krita import *
36from . import comics_metadata_dialog, comics_exporter, comics_export_dialog, comics_project_setup_wizard, comics_template_dialog, comics_project_settings_dialog, comics_project_page_viewer, comics_project_translation_scraper
37
38"""
39A very simple class so we can have a label that is single line, but doesn't force the
40widget size to be bigger.
41This is used by the project name.
42"""
43
44
45class Elided_Text_Label(QLabel):
46    mainText = str()
47
48    def __init__(self, parent=None):
49        super(QLabel, self).__init__(parent)
50        self.setMinimumWidth(self.fontMetrics().width("..."))
51        self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum)
52
53    def setMainText(self, text=str()):
54        self.mainText = text
55        self.elideText()
56
57    def elideText(self):
58        self.setText(self.fontMetrics().elidedText(self.mainText, Qt.ElideRight, self.width()))
59
60    def resizeEvent(self, event):
61        self.elideText()
62
63class CPE(enum.IntEnum):
64    TITLE = Qt.DisplayRole
65    URL = Qt.UserRole + 1
66    KEYWORDS = Qt.UserRole+2
67    DESCRIPTION = Qt.UserRole+3
68    LASTEDIT = Qt.UserRole+4
69    EDITOR = Qt.UserRole+5
70    IMAGE = Qt.DecorationRole
71
72class comic_page_delegate(QStyledItemDelegate):
73
74    def __init__(self, devicePixelRatioF, parent=None):
75        super(QStyledItemDelegate, self).__init__(parent)
76        self.devicePixelRatioF = devicePixelRatioF
77
78    def paint(self, painter, option, index):
79
80        if (index.isValid() == False):
81            return
82        painter.save()
83        painter.setOpacity(0.6)
84        if(option.state & QStyle.State_Selected):
85            painter.fillRect(option.rect, option.palette.highlight())
86        if (option.state & QStyle.State_MouseOver):
87            painter.setOpacity(0.25)
88            painter.fillRect(option.rect, option.palette.highlight())
89        painter.setOpacity(1.0)
90        painter.setFont(option.font)
91        metrics = QFontMetrics(option.font)
92        regular = QFont(option.font)
93        italics = QFont(option.font)
94        italics.setItalic(True)
95        icon = QIcon(index.data(CPE.IMAGE))
96        rect = option.rect
97        margin = 4
98        decoratonSize = QSize(option.decorationSize)
99        imageSize = icon.actualSize(option.decorationSize)
100        imageSizeHighDPI = imageSize*self.devicePixelRatioF
101        leftSideThumbnail = (decoratonSize.width()-imageSize.width())/2
102        if (rect.width() < decoratonSize.width()):
103            leftSideThumbnail = max(0, (rect.width()-imageSize.width())/2)
104        topSizeThumbnail = ((rect.height()-imageSize.height())/2)+rect.top()
105        thumbImage = icon.pixmap(imageSizeHighDPI).toImage()
106        thumbImage.setDevicePixelRatio(self.devicePixelRatioF)
107        painter.drawImage(QRect(leftSideThumbnail, topSizeThumbnail, imageSize.width(), imageSize.height()), thumbImage)
108
109        labelWidth = rect.width()-decoratonSize.width()-(margin*3)
110
111        if (decoratonSize.width()+(margin*2)< rect.width()):
112
113            textRect = QRect(decoratonSize.width()+margin, margin+rect.top(), labelWidth, metrics.height())
114            textTitle = metrics.elidedText(str(index.row()+1)+". "+index.data(CPE.TITLE), Qt.ElideRight, labelWidth)
115            painter.drawText(textRect, Qt.TextWordWrap, textTitle)
116
117            if rect.height()/(metrics.lineSpacing()+margin) > 5 or index.data(CPE.KEYWORDS) is not None:
118                painter.setOpacity(0.6)
119                textRect = QRect(textRect.left(), textRect.bottom()+margin, labelWidth, metrics.height())
120                if textRect.bottom() < rect.bottom():
121                    textKeyWords = index.data(CPE.KEYWORDS)
122                    if textKeyWords == None:
123                        textKeyWords = i18n("No keywords")
124                        painter.setOpacity(0.3)
125                        painter.setFont(italics)
126                    textKeyWords = metrics.elidedText(textKeyWords, Qt.ElideRight, labelWidth)
127                    painter.drawText(textRect, Qt.TextWordWrap, textKeyWords)
128
129            painter.setFont(regular)
130
131            if rect.height()/(metrics.lineSpacing()+margin) > 3:
132                painter.setOpacity(0.6)
133                textRect = QRect(textRect.left(), textRect.bottom()+margin, labelWidth, metrics.height())
134                if textRect.bottom()+metrics.height() < rect.bottom():
135                    textLastEdit = index.data(CPE.LASTEDIT)
136                    if textLastEdit is None:
137                        textLastEdit = i18n("No last edit timestamp")
138                    if index.data(CPE.EDITOR) is not None:
139                        textLastEdit += " - " + index.data(CPE.EDITOR)
140                    if (index.data(CPE.LASTEDIT) is None) and (index.data(CPE.EDITOR) is None):
141                        painter.setOpacity(0.3)
142                        painter.setFont(italics)
143                    textLastEdit = metrics.elidedText(textLastEdit, Qt.ElideRight, labelWidth)
144                    painter.drawText(textRect, Qt.TextWordWrap, textLastEdit)
145
146            painter.setFont(regular)
147
148            descRect = QRect(textRect.left(), textRect.bottom()+margin, labelWidth, (rect.bottom()-margin) - (textRect.bottom()+margin))
149            if textRect.bottom()+metrics.height() < rect.bottom():
150                textRect.setBottom(textRect.bottom()+(margin/2))
151                textRect.setLeft(textRect.left()-(margin/2))
152                painter.setOpacity(0.4)
153                painter.drawLine(textRect.bottomLeft(), textRect.bottomRight())
154                painter.setOpacity(1.0)
155                textDescription = index.data(CPE.DESCRIPTION)
156                if textDescription is None:
157                    textDescription = i18n("No description")
158                    painter.setOpacity(0.3)
159                    painter.setFont(italics)
160                linesTotal = floor(descRect.height()/metrics.lineSpacing())
161                if linesTotal == 1:
162                    textDescription = metrics.elidedText(textDescription, Qt.ElideRight, labelWidth)
163                    painter.drawText(descRect, Qt.TextWordWrap, textDescription)
164                else:
165                    descRect.setHeight(linesTotal*metrics.lineSpacing())
166                    totalDescHeight = metrics.boundingRect(descRect, Qt.TextWordWrap, textDescription).height()
167                    if totalDescHeight>descRect.height():
168                        if totalDescHeight-metrics.lineSpacing()>descRect.height():
169                            painter.setOpacity(0.5)
170                            painter.drawText(descRect, Qt.TextWordWrap, textDescription)
171                            descRect.setHeight((linesTotal-1)*metrics.lineSpacing())
172                            painter.drawText(descRect, Qt.TextWordWrap, textDescription)
173                            descRect.setHeight((linesTotal-2)*metrics.lineSpacing())
174                            painter.drawText(descRect, Qt.TextWordWrap, textDescription)
175                        else:
176                            painter.setOpacity(0.75)
177                            painter.drawText(descRect, Qt.TextWordWrap, textDescription)
178                            descRect.setHeight((linesTotal-1)*metrics.lineSpacing())
179                            painter.drawText(descRect, Qt.TextWordWrap, textDescription)
180                    else:
181                        painter.drawText(descRect, Qt.TextWordWrap, textDescription)
182
183            painter.setFont(regular)
184
185        painter.restore()
186
187
188"""
189This is a Krita docker called 'Comics Manager'.
190
191It allows people to create comics project files, load those files, add pages, remove pages, move pages, manage the metadata,
192and finally export the result.
193
194The logic behind this docker is that it is very easy to get lost in a comics project due to the massive amount of files.
195By having a docker that gives the user quick access to the pages and also allows them to do all of the meta-stuff, like
196meta data, but also reordering the pages, the chaos of managing the project should take up less time, and more time can be focused on actual writing and drawing.
197"""
198
199
200class comics_project_manager_docker(DockWidget):
201    setupDictionary = {}
202    stringName = i18n("Comics Manager")
203    projecturl = None
204    pagesWatcher = None
205    updateurls = []
206
207    def __init__(self):
208        super().__init__()
209        self.setWindowTitle(self.stringName)
210
211        # Setup layout:
212        base = QHBoxLayout()
213        widget = QWidget()
214        widget.setLayout(base)
215        baseLayout = QSplitter()
216        base.addWidget(baseLayout)
217        self.setWidget(widget)
218        buttonLayout = QVBoxLayout()
219        buttonBox = QWidget()
220        buttonBox.setLayout(buttonLayout)
221        baseLayout.addWidget(buttonBox)
222
223        # Comic page list and pages model
224        self.comicPageList = QListView()
225        self.comicPageList.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
226        self.comicPageList.setDragEnabled(True)
227        self.comicPageList.setDragDropMode(QAbstractItemView.InternalMove)
228        self.comicPageList.setDefaultDropAction(Qt.MoveAction)
229        self.comicPageList.setAcceptDrops(True)
230        self.comicPageList.setItemDelegate(comic_page_delegate(self.devicePixelRatioF()))
231        self.pagesModel = QStandardItemModel()
232        self.comicPageList.doubleClicked.connect(self.slot_open_page)
233        self.comicPageList.setIconSize(QSize(128, 128))
234        # self.comicPageList.itemDelegate().closeEditor.connect(self.slot_write_description)
235        self.pagesModel.layoutChanged.connect(self.slot_write_config)
236        self.pagesModel.rowsInserted.connect(self.slot_write_config)
237        self.pagesModel.rowsRemoved.connect(self.slot_write_config)
238        self.pagesModel.rowsMoved.connect(self.slot_write_config)
239        self.comicPageList.setModel(self.pagesModel)
240        pageBox = QWidget()
241        pageBox.setLayout(QVBoxLayout())
242        zoomSlider = QSlider(Qt.Horizontal, None)
243        zoomSlider.setRange(1, 8)
244        zoomSlider.setValue(4)
245        zoomSlider.setTickInterval(1)
246        zoomSlider.setMinimumWidth(10)
247        zoomSlider.valueChanged.connect(self.slot_scale_thumbnails)
248        self.projectName = Elided_Text_Label()
249        pageBox.layout().addWidget(self.projectName)
250        pageBox.layout().addWidget(zoomSlider)
251        pageBox.layout().addWidget(self.comicPageList)
252        baseLayout.addWidget(pageBox)
253
254        self.btn_project = QToolButton()
255        self.btn_project.setPopupMode(QToolButton.MenuButtonPopup)
256        self.btn_project.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
257        menu_project = QMenu()
258        self.action_new_project = QAction(i18n("New Project"), self)
259        self.action_new_project.triggered.connect(self.slot_new_project)
260        self.action_load_project = QAction(i18n("Open Project"), self)
261        self.action_load_project.triggered.connect(self.slot_open_config)
262        menu_project.addAction(self.action_new_project)
263        menu_project.addAction(self.action_load_project)
264        self.btn_project.setMenu(menu_project)
265        self.btn_project.setDefaultAction(self.action_load_project)
266        buttonLayout.addWidget(self.btn_project)
267
268        # Settings dropdown with actions for the different settings menus.
269        self.btn_settings = QToolButton()
270        self.btn_settings.setPopupMode(QToolButton.MenuButtonPopup)
271        self.btn_settings.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
272        self.action_edit_project_settings = QAction(i18n("Project Settings"), self)
273        self.action_edit_project_settings.triggered.connect(self.slot_edit_project_settings)
274        self.action_edit_meta_data = QAction(i18n("Meta Data"), self)
275        self.action_edit_meta_data.triggered.connect(self.slot_edit_meta_data)
276        self.action_edit_export_settings = QAction(i18n("Export Settings"), self)
277        self.action_edit_export_settings.triggered.connect(self.slot_edit_export_settings)
278        menu_settings = QMenu()
279        menu_settings.addAction(self.action_edit_project_settings)
280        menu_settings.addAction(self.action_edit_meta_data)
281        menu_settings.addAction(self.action_edit_export_settings)
282        self.btn_settings.setDefaultAction(self.action_edit_project_settings)
283        self.btn_settings.setMenu(menu_settings)
284        buttonLayout.addWidget(self.btn_settings)
285        self.btn_settings.setDisabled(True)
286
287        # Add page drop down with different page actions.
288        self.btn_add_page = QToolButton()
289        self.btn_add_page.setPopupMode(QToolButton.MenuButtonPopup)
290        self.btn_add_page.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Minimum)
291
292        self.action_add_page = QAction(i18n("Add Page"), self)
293        self.action_add_page.triggered.connect(self.slot_add_new_page_single)
294        self.action_add_template = QAction(i18n("Add Page from Template"), self)
295        self.action_add_template.triggered.connect(self.slot_add_new_page_from_template)
296        self.action_add_existing = QAction(i18n("Add Existing Pages"), self)
297        self.action_add_existing.triggered.connect(self.slot_add_page_from_url)
298        self.action_remove_selected_page = QAction(i18n("Remove Page"), self)
299        self.action_remove_selected_page.triggered.connect(self.slot_remove_selected_page)
300        self.action_resize_all_pages = QAction(i18n("Batch Resize"), self)
301        self.action_resize_all_pages.triggered.connect(self.slot_batch_resize)
302        self.btn_add_page.setDefaultAction(self.action_add_page)
303        self.action_show_page_viewer = QAction(i18n("View Page In Window"), self)
304        self.action_show_page_viewer.triggered.connect(self.slot_show_page_viewer)
305        self.action_scrape_authors = QAction(i18n("Scrape Author Info"), self)
306        self.action_scrape_authors.setToolTip(i18n("Search for author information in documents and add it to the author list. This does not check for duplicates."))
307        self.action_scrape_authors.triggered.connect(self.slot_scrape_author_list)
308        self.action_scrape_translations = QAction(i18n("Scrape Text for Translation"), self)
309        self.action_scrape_translations.triggered.connect(self.slot_scrape_translations)
310        actionList = []
311        menu_page = QMenu()
312        actionList.append(self.action_add_page)
313        actionList.append(self.action_add_template)
314        actionList.append(self.action_add_existing)
315        actionList.append(self.action_remove_selected_page)
316        actionList.append(self.action_resize_all_pages)
317        actionList.append(self.action_show_page_viewer)
318        actionList.append(self.action_scrape_authors)
319        actionList.append(self.action_scrape_translations)
320        menu_page.addActions(actionList)
321        self.btn_add_page.setMenu(menu_page)
322        buttonLayout.addWidget(self.btn_add_page)
323        self.btn_add_page.setDisabled(True)
324
325        self.comicPageList.setContextMenuPolicy(Qt.ActionsContextMenu)
326        self.comicPageList.addActions(actionList)
327
328        # Export button that... exports.
329        self.btn_export = QPushButton(i18n("Export Comic"))
330        self.btn_export.clicked.connect(self.slot_export)
331        buttonLayout.addWidget(self.btn_export)
332        self.btn_export.setDisabled(True)
333
334        self.btn_project_url = QPushButton(i18n("Copy Location"))
335        self.btn_project_url.setToolTip(i18n("Copies the path of the project to the clipboard. Useful for quickly copying to a file manager or the like."))
336        self.btn_project_url.clicked.connect(self.slot_copy_project_url)
337        self.btn_project_url.setDisabled(True)
338        buttonLayout.addWidget(self.btn_project_url)
339
340        self.page_viewer_dialog = comics_project_page_viewer.comics_project_page_viewer()
341
342        self.pagesWatcher = QFileSystemWatcher()
343        self.pagesWatcher.fileChanged.connect(self.slot_start_delayed_check_page_update)
344
345        buttonLayout.addItem(QSpacerItem(0, 0, QSizePolicy.Minimum, QSizePolicy.MinimumExpanding))
346
347    """
348    Open the config file and load the json file into a dictionary.
349    """
350
351    def slot_open_config(self):
352        self.path_to_config = QFileDialog.getOpenFileName(caption=i18n("Please select the JSON comic config file."), filter=str(i18n("JSON files") + "(*.json)"))[0]
353        if os.path.exists(self.path_to_config) is True:
354            if os.access(self.path_to_config, os.W_OK) is False:
355                QMessageBox.warning(None, i18n("Config cannot be used"), i18n("Krita doesn't have write access to this folder, so new files cannot be made. Please configure the folder access or move the project to a folder that can be written to."), QMessageBox.Ok)
356                return
357            configFile = open(self.path_to_config, "r", newline="", encoding="utf-16")
358            self.setupDictionary = json.load(configFile)
359            self.projecturl = os.path.dirname(str(self.path_to_config))
360            configFile.close()
361            self.load_config()
362    """
363    Further config loading.
364    """
365
366    def load_config(self):
367        self.projectName.setMainText(text=str(self.setupDictionary["projectName"]))
368        self.fill_pages()
369        self.btn_settings.setEnabled(True)
370        self.btn_add_page.setEnabled(True)
371        self.btn_export.setEnabled(True)
372        self.btn_project_url.setEnabled(True)
373
374    """
375    Fill the pages model with the pages from the pages list.
376    """
377
378    def fill_pages(self):
379        self.loadingPages = True
380        self.pagesModel.clear()
381        if len(self.pagesWatcher.files())>0:
382            self.pagesWatcher.removePaths(self.pagesWatcher.files())
383        pagesList = []
384        if "pages" in self.setupDictionary.keys():
385            pagesList = self.setupDictionary["pages"]
386        progress = QProgressDialog()
387        progress.setMinimum(0)
388        progress.setMaximum(len(pagesList))
389        progress.setWindowTitle(i18n("Loading Pages..."))
390        for url in pagesList:
391            absurl = os.path.join(self.projecturl, url)
392            relative = os.path.relpath(absurl, self.projecturl)
393            if (os.path.exists(absurl)):
394                #page = Application.openDocument(absurl)
395                page = zipfile.ZipFile(absurl, "r")
396                thumbnail = QImage.fromData(page.read("mergedimage.png"))
397                if thumbnail.isNull():
398                    thumbnail = QImage.fromData(page.read("preview.png"))
399                thumbnail.setDevicePixelRatio(self.devicePixelRatioF())
400                pageItem = QStandardItem()
401                dataList = self.get_description_and_title(page.read("documentinfo.xml"))
402                if (dataList[0].isspace() or len(dataList[0]) < 1):
403                    dataList[0] = os.path.basename(url)
404                pageItem.setText(dataList[0].replace("_", " "))
405                pageItem.setDragEnabled(True)
406                pageItem.setDropEnabled(False)
407                pageItem.setEditable(False)
408                pageItem.setIcon(QIcon(QPixmap.fromImage(thumbnail)))
409                pageItem.setData(dataList[1], role = CPE.DESCRIPTION)
410                pageItem.setData(relative, role = CPE.URL)
411                self.pagesWatcher.addPath(absurl)
412                pageItem.setData(dataList[2], role = CPE.KEYWORDS)
413                pageItem.setData(dataList[3], role = CPE.LASTEDIT)
414                pageItem.setData(dataList[4], role = CPE.EDITOR)
415                pageItem.setToolTip(relative)
416                page.close()
417                self.pagesModel.appendRow(pageItem)
418                progress.setValue(progress.value() + 1)
419        progress.setValue(len(pagesList))
420        self.loadingPages = False
421    """
422    Function that is triggered by the zoomSlider
423    Resizes the thumbnails.
424    """
425
426    def slot_scale_thumbnails(self, multiplier=4):
427        self.comicPageList.setIconSize(QSize(multiplier * 32, multiplier * 32))
428
429    """
430    Function that takes the documentinfo.xml and parses it for the title, subject and abstract tags,
431    to get the title and description.
432
433    @returns a stringlist with the name on 0 and the description on 1.
434    """
435
436    def get_description_and_title(self, string):
437        xmlDoc = ET.fromstring(string)
438        calligra = str("{http://www.calligra.org/DTD/document-info}")
439        name = ""
440        if ET.iselement(xmlDoc[0].find(calligra + 'title')):
441            name = xmlDoc[0].find(calligra + 'title').text
442            if name is None:
443                name = " "
444        desc = ""
445        if ET.iselement(xmlDoc[0].find(calligra + 'subject')):
446            desc = xmlDoc[0].find(calligra + 'subject').text
447        if desc is None or desc.isspace() or len(desc) < 1:
448            if ET.iselement(xmlDoc[0].find(calligra + 'abstract')):
449                desc = xmlDoc[0].find(calligra + 'abstract').text
450                if desc is not None:
451                    if desc.startswith("<![CDATA["):
452                        desc = desc[len("<![CDATA["):]
453                    if desc.startswith("]]>"):
454                        desc = desc[:-len("]]>")]
455        keywords = ""
456        if ET.iselement(xmlDoc[0].find(calligra + 'keyword')):
457            keywords = xmlDoc[0].find(calligra + 'keyword').text
458        date = ""
459        if ET.iselement(xmlDoc[0].find(calligra + 'date')):
460            date = xmlDoc[0].find(calligra + 'date').text
461        author = []
462        if ET.iselement(xmlDoc[1].find(calligra + 'creator-first-name')):
463            string = xmlDoc[1].find(calligra + 'creator-first-name').text
464            if string is not None:
465                author.append(string)
466        if ET.iselement(xmlDoc[1].find(calligra + 'creator-last-name')):
467            string = xmlDoc[1].find(calligra + 'creator-last-name').text
468            if string is not None:
469                author.append(string)
470        if ET.iselement(xmlDoc[1].find(calligra + 'full-name')):
471            string = xmlDoc[1].find(calligra + 'full-name').text
472            if string is not None:
473                author.append(string)
474
475        return [name, desc, keywords, date, " ".join(author)]
476
477    """
478    Scrapes authors from the author data in the document info and puts them into the author list.
479    Doesn't check for duplicates.
480    """
481
482    def slot_scrape_author_list(self):
483        listOfAuthors = []
484        if "authorList" in self.setupDictionary.keys():
485            listOfAuthors = self.setupDictionary["authorList"]
486        if "pages" in self.setupDictionary.keys():
487            for relurl in self.setupDictionary["pages"]:
488                absurl = os.path.join(self.projecturl, relurl)
489                page = zipfile.ZipFile(absurl, "r")
490                xmlDoc = ET.fromstring(page.read("documentinfo.xml"))
491                calligra = str("{http://www.calligra.org/DTD/document-info}")
492                authorelem = xmlDoc.find(calligra + 'author')
493                author = {}
494                if ET.iselement(authorelem.find(calligra + 'full-name')):
495                    author["nickname"] = str(authorelem.find(calligra + 'full-name').text)
496
497                if ET.iselement(authorelem.find(calligra + 'creator-first-name')):
498                    author["first-name"] = str(authorelem.find(calligra + 'creator-first-name').text)
499
500                if ET.iselement(authorelem.find(calligra + 'initial')):
501                    author["initials"] = str(authorelem.find(calligra + 'initial').text)
502
503                if ET.iselement(authorelem.find(calligra + 'creator-last-name')):
504                    author["last-name"] = str(authorelem.find(calligra + 'creator-last-name').text)
505
506                if ET.iselement(authorelem.find(calligra + 'email')):
507                    author["email"] = str(authorelem.find(calligra + 'email').text)
508
509                if ET.iselement(authorelem.find(calligra + 'contact')):
510                    contact = authorelem.find(calligra + 'contact')
511                    contactMode = contact.get("type")
512                    if contactMode == "email":
513                        author["email"] = str(contact.text)
514                    if contactMode == "homepage":
515                        author["homepage"] = str(contact.text)
516
517                if ET.iselement(authorelem.find(calligra + 'position')):
518                    author["role"] = str(authorelem.find(calligra + 'position').text)
519                listOfAuthors.append(author)
520                page.close()
521        self.setupDictionary["authorList"] = listOfAuthors
522
523    """
524    Edit the general project settings like the project name, concept, pages location, export location, template location, metadata
525    """
526
527    def slot_edit_project_settings(self):
528        dialog = comics_project_settings_dialog.comics_project_details_editor(self.projecturl)
529        dialog.setConfig(self.setupDictionary, self.projecturl)
530
531        if dialog.exec_() == QDialog.Accepted:
532            self.setupDictionary = dialog.getConfig(self.setupDictionary)
533            self.slot_write_config()
534            self.projectName.setMainText(str(self.setupDictionary["projectName"]))
535
536    """
537    This allows users to select existing pages and add them to the pages list. The pages are currently not copied to the pages folder. Useful for existing projects.
538    """
539
540    def slot_add_page_from_url(self):
541        # get the pages.
542        urlList = QFileDialog.getOpenFileNames(caption=i18n("Which existing pages to add?"), directory=self.projecturl, filter=str(i18n("Krita files") + "(*.kra)"))[0]
543
544        # get the existing pages list.
545        pagesList = []
546        if "pages" in self.setupDictionary.keys():
547            pagesList = self.setupDictionary["pages"]
548
549        # And add each url in the url list to the pages list and the model.
550        for url in urlList:
551            if self.projecturl not in urlList:
552                newUrl = os.path.join(self.projecturl, self.setupDictionary["pagesLocation"], os.path.basename(url))
553                shutil.move(url, newUrl)
554                url = newUrl
555            relative = os.path.relpath(url, self.projecturl)
556            if url not in pagesList:
557                page = zipfile.ZipFile(url, "r")
558                thumbnail = QImage.fromData(page.read("preview.png"))
559                dataList = self.get_description_and_title(page.read("documentinfo.xml"))
560                if (dataList[0].isspace() or len(dataList[0]) < 1):
561                    dataList[0] = os.path.basename(url)
562                newPageItem = QStandardItem()
563                newPageItem.setIcon(QIcon(QPixmap.fromImage(thumbnail)))
564                newPageItem.setDragEnabled(True)
565                newPageItem.setDropEnabled(False)
566                newPageItem.setEditable(False)
567                newPageItem.setText(dataList[0].replace("_", " "))
568                newPageItem.setData(dataList[1], role = CPE.DESCRIPTION)
569                newPageItem.setData(relative, role = CPE.URL)
570                self.pagesWatcher.addPath(url)
571                newPageItem.setData(dataList[2], role = CPE.KEYWORDS)
572                newPageItem.setData(dataList[3], role = CPE.LASTEDIT)
573                newPageItem.setData(dataList[4], role = CPE.EDITOR)
574                newPageItem.setToolTip(relative)
575                page.close()
576                self.pagesModel.appendRow(newPageItem)
577
578    """
579    Remove the selected page from the list of pages. This does not remove it from disk(far too dangerous).
580    """
581
582    def slot_remove_selected_page(self):
583        index = self.comicPageList.currentIndex()
584        self.pagesModel.removeRow(index.row())
585
586    """
587    This function adds a new page from the default template. If there's no default template, or the file does not exist, it will
588    show the create/import template dialog. It will remember the selected item as the default template.
589    """
590
591    def slot_add_new_page_single(self):
592        templateUrl = "templatepage"
593        templateExists = False
594
595        if "singlePageTemplate" in self.setupDictionary.keys():
596            templateUrl = self.setupDictionary["singlePageTemplate"]
597        if os.path.exists(os.path.join(self.projecturl, templateUrl)):
598            templateExists = True
599
600        if templateExists is False:
601            if "templateLocation" not in self.setupDictionary.keys():
602                self.setupDictionary["templateLocation"] = os.path.relpath(QFileDialog.getExistingDirectory(caption=i18n("Where are the templates located?"), options=QFileDialog.ShowDirsOnly), self.projecturl)
603
604            templateDir = os.path.join(self.projecturl, self.setupDictionary["templateLocation"])
605            template = comics_template_dialog.comics_template_dialog(templateDir)
606
607            if template.exec_() == QDialog.Accepted:
608                templateUrl = os.path.relpath(template.url(), self.projecturl)
609                self.setupDictionary["singlePageTemplate"] = templateUrl
610        if os.path.exists(os.path.join(self.projecturl, templateUrl)):
611            self.add_new_page(templateUrl)
612
613    """
614    This function always asks for a template showing the new template window. This allows users to have multiple different
615    templates created for back covers, spreads, other and have them accessible, while still having the convenience of a singular
616    "add page" that adds a default.
617    """
618
619    def slot_add_new_page_from_template(self):
620        if "templateLocation" not in self.setupDictionary.keys():
621            self.setupDictionary["templateLocation"] = os.path.relpath(QFileDialog.getExistingDirectory(caption=i18n("Where are the templates located?"), options=QFileDialog.ShowDirsOnly), self.projecturl)
622
623        templateDir = os.path.join(self.projecturl, self.setupDictionary["templateLocation"])
624        template = comics_template_dialog.comics_template_dialog(templateDir)
625
626        if template.exec_() == QDialog.Accepted:
627            templateUrl = os.path.relpath(template.url(), self.projecturl)
628            self.add_new_page(templateUrl)
629
630    """
631    This is the actual function that adds the template using the template url.
632    It will attempt to name the new page projectName+number.
633    """
634
635    def add_new_page(self, templateUrl):
636
637        # check for page list and or location.
638        pagesList = []
639        if "pages" in self.setupDictionary.keys():
640            pagesList = self.setupDictionary["pages"]
641        if not "pageNumber" in self.setupDictionary.keys():
642            self.setupDictionary['pageNumber'] = 0
643
644        if (str(self.setupDictionary["pagesLocation"]).isspace()):
645            self.setupDictionary["pagesLocation"] = os.path.relpath(QFileDialog.getExistingDirectory(caption=i18n("Where should the pages go?"), options=QFileDialog.ShowDirsOnly), self.projecturl)
646
647        # Search for the possible name.
648        extraUnderscore = str()
649        if str(self.setupDictionary["projectName"])[-1].isdigit():
650            extraUnderscore = "_"
651        self.setupDictionary['pageNumber'] += 1
652        pageName = str(self.setupDictionary["projectName"]).replace(" ", "_") + extraUnderscore + str(format(self.setupDictionary['pageNumber'], "03d"))
653        url = os.path.join(str(self.setupDictionary["pagesLocation"]), pageName + ".kra")
654
655        # open the page by opening the template and resaving it, or just opening it.
656        absoluteUrl = os.path.join(self.projecturl, url)
657        if (os.path.exists(absoluteUrl)):
658            newPage = Application.openDocument(absoluteUrl)
659        else:
660            booltemplateExists = os.path.exists(os.path.join(self.projecturl, templateUrl))
661            if booltemplateExists is False:
662                templateUrl = os.path.relpath(QFileDialog.getOpenFileName(caption=i18n("Which image should be the basis the new page?"), directory=self.projecturl, filter=str(i18n("Krita files") + "(*.kra)"))[0], self.projecturl)
663            newPage = Application.openDocument(os.path.join(self.projecturl, templateUrl))
664            newPage.waitForDone()
665            newPage.setFileName(absoluteUrl)
666            newPage.setName(pageName.replace("_", " "))
667            newPage.save()
668            newPage.waitForDone()
669
670        # Get out the extra data for the standard item.
671        newPageItem = QStandardItem()
672        newPageItem.setIcon(QIcon(QPixmap.fromImage(newPage.thumbnail(256, 256))))
673        newPageItem.setDragEnabled(True)
674        newPageItem.setDropEnabled(False)
675        newPageItem.setEditable(False)
676        newPageItem.setText(pageName.replace("_", " "))
677        newPageItem.setData("", role = CPE.DESCRIPTION)
678        newPageItem.setData(url, role = CPE.URL)
679        newPageItem.setData("", role = CPE.KEYWORDS)
680        newPageItem.setData("", role = CPE.LASTEDIT)
681        newPageItem.setData("", role = CPE.EDITOR)
682        newPageItem.setToolTip(url)
683
684        # close page document.
685        while os.path.exists(absoluteUrl) is False:
686            qApp.processEvents()
687
688        self.pagesWatcher.addPath(absoluteUrl)
689        newPage.close()
690
691        # add item to page.
692        self.pagesModel.appendRow(newPageItem)
693
694    """
695    Write to the json configuration file.
696    This also checks the current state of the pages list.
697    """
698
699    def slot_write_config(self):
700
701        # Don't load when the pages are still being loaded, otherwise we'll be overwriting our own pages list.
702        if (self.loadingPages is False):
703            print("CPMT: writing comic configuration...")
704
705            # Generate a pages list from the pagesmodel.
706            pagesList = []
707            for i in range(self.pagesModel.rowCount()):
708                index = self.pagesModel.index(i, 0)
709                url = str(self.pagesModel.data(index, role=CPE.URL))
710                if url not in pagesList:
711                    pagesList.append(url)
712            self.setupDictionary["pages"] = pagesList
713
714            # Save to our json file.
715            configFile = open(self.path_to_config, "w", newline="", encoding="utf-16")
716            json.dump(self.setupDictionary, configFile, indent=4, sort_keys=True, ensure_ascii=False)
717            configFile.close()
718            print("CPMT: done")
719
720    """
721    Open a page in the pagesmodel in Krita.
722    """
723
724    def slot_open_page(self, index):
725        if index.column() == 0:
726            # Get the absolute url from the relative one in the pages model.
727            absoluteUrl = os.path.join(self.projecturl, str(self.pagesModel.data(index, role=CPE.URL)))
728
729            # Make sure the page exists.
730            if os.path.exists(absoluteUrl):
731                page = Application.openDocument(absoluteUrl)
732
733                # Set the title to the filename if it was empty. It looks a bit neater.
734                if page.name().isspace or len(page.name()) < 1:
735                    page.setName(str(self.pagesModel.data(index, role=Qt.DisplayRole)).replace("_", " "))
736
737                # Add views for the document so the user can use it.
738                Application.activeWindow().addView(page)
739                Application.setActiveDocument(page)
740            else:
741                print("CPMT: The page cannot be opened because the file doesn't exist:", absoluteUrl)
742
743    """
744    Call up the metadata editor dialog. Only when the dialog is "Accepted" will the metadata be saved.
745    """
746
747    def slot_edit_meta_data(self):
748        dialog = comics_metadata_dialog.comic_meta_data_editor()
749
750        dialog.setConfig(self.setupDictionary)
751        if (dialog.exec_() == QDialog.Accepted):
752            self.setupDictionary = dialog.getConfig(self.setupDictionary)
753            self.slot_write_config()
754
755    """
756    An attempt at making the description editable from the comic pages list.
757    It is currently not working because ZipFile has no overwrite mechanism,
758    and I don't have the energy to write one yet.
759    """
760
761    def slot_write_description(self, index):
762
763        for row in range(self.pagesModel.rowCount()):
764            index = self.pagesModel.index(row, 1)
765            indexUrl = self.pagesModel.index(row, 0)
766            absoluteUrl = os.path.join(self.projecturl, str(self.pagesModel.data(indexUrl, role=CPE.URL)))
767            page = zipfile.ZipFile(absoluteUrl, "a")
768            xmlDoc = ET.ElementTree()
769            ET.register_namespace("", "http://www.calligra.org/DTD/document-info")
770            location = os.path.join(self.projecturl, "documentinfo.xml")
771            xmlDoc.parse(location)
772            xmlroot = ET.fromstring(page.read("documentinfo.xml"))
773            calligra = "{http://www.calligra.org/DTD/document-info}"
774            aboutelem = xmlroot.find(calligra + 'about')
775            if ET.iselement(aboutelem.find(calligra + 'subject')):
776                desc = aboutelem.find(calligra + 'subject')
777                desc.text = self.pagesModel.data(index, role=Qt.EditRole)
778                xmlstring = ET.tostring(xmlroot, encoding='unicode', method='xml', short_empty_elements=False)
779                page.writestr(zinfo_or_arcname="documentinfo.xml", data=xmlstring)
780                for document in Application.documents():
781                    if str(document.fileName()) == str(absoluteUrl):
782                        document.setDocumentInfo(xmlstring)
783            page.close()
784
785    """
786    Calls up the export settings dialog. Only when accepted will the configuration be written.
787    """
788
789    def slot_edit_export_settings(self):
790        dialog = comics_export_dialog.comic_export_setting_dialog()
791        dialog.setConfig(self.setupDictionary)
792
793        if (dialog.exec_() == QDialog.Accepted):
794            self.setupDictionary = dialog.getConfig(self.setupDictionary)
795            self.slot_write_config()
796
797    """
798    Export the comic. Won't work without export settings set.
799    """
800
801    def slot_export(self):
802
803        #ensure there is a unique identifier
804        if "uuid" not in self.setupDictionary.keys():
805            uuid = str()
806            if "acbfID" in self.setupDictionary.keys():
807                uuid = str(self.setupDictionary["acbfID"])
808            else:
809                uuid = QUuid.createUuid().toString()
810            self.setupDictionary["uuid"] = uuid
811
812        exporter = comics_exporter.comicsExporter()
813        exporter.set_config(self.setupDictionary, self.projecturl)
814        exportSuccess = exporter.export()
815        if exportSuccess:
816            print("CPMT: Export success! The files have been written to the export folder!")
817            QMessageBox.information(self, i18n("Export success"), i18n("The files have been written to the export folder."), QMessageBox.Ok)
818
819    """
820    Calls up the comics project setup wizard so users can create a new json file with the basic information.
821    """
822
823    def slot_new_project(self):
824        setup = comics_project_setup_wizard.ComicsProjectSetupWizard()
825        setup.showDialog()
826        self.path_to_config = os.path.join(setup.projectDirectory, "comicConfig.json")
827        if os.path.exists(self.path_to_config) is True:
828            configFile = open(self.path_to_config, "r", newline="", encoding="utf-16")
829            self.setupDictionary = json.load(configFile)
830            self.projecturl = os.path.dirname(str(self.path_to_config))
831            configFile.close()
832            self.load_config()
833    """
834    This is triggered by any document save.
835    It checks if the given url in in the pages list, and if so,
836    updates the appropriate page thumbnail.
837    This helps with the management of the pages, because the user
838    will be able to see the thumbnails as a todo for the whole comic,
839    giving a good overview over whether they still need to ink, color or
840    the like for a given page, and it thus also rewards the user whenever
841    they save.
842    """
843
844    def slot_start_delayed_check_page_update(self, url):
845        # It can happen that there are multiple signals from QFileSystemWatcher at once.
846        # Since QTimer cannot take any arguments, we need to keep a list of files to update.
847        # Otherwise only the last file would be updated and all subsequent calls
848        #   of `slot_check_for_page_update` would not know which files to update now.
849        # https://bugs.kde.org/show_bug.cgi?id=426701
850        self.updateurls.append(url)
851        QTimer.singleShot(200, Qt.PreciseTimer, self.slot_check_for_page_update)
852
853
854    def slot_check_for_page_update(self):
855        url = self.updateurls.pop(0)
856        if url:
857            if "pages" in self.setupDictionary.keys():
858                relUrl = os.path.relpath(url, self.projecturl)
859                if relUrl in self.setupDictionary["pages"]:
860                    index = self.pagesModel.index(self.setupDictionary["pages"].index(relUrl), 0)
861                    if index.isValid():
862                        if os.path.exists(url) is False:
863                            # we cannot check from here whether the file in question has been renamed or deleted.
864                            self.pagesModel.removeRow(index.row())
865                            return
866                        else:
867                            # Krita will trigger the filesystemwatcher when doing backupfiles,
868                            # so ensure the file is still watched if it exists.
869                            self.pagesWatcher.addPath(url)
870                        pageItem = self.pagesModel.itemFromIndex(index)
871                        page = zipfile.ZipFile(url, "r")
872                        dataList = self.get_description_and_title(page.read("documentinfo.xml"))
873                        if (dataList[0].isspace() or len(dataList[0]) < 1):
874                            dataList[0] = os.path.basename(url)
875                        thumbnail = QImage.fromData(page.read("preview.png"))
876                        pageItem.setIcon(QIcon(QPixmap.fromImage(thumbnail)))
877                        pageItem.setText(dataList[0])
878                        pageItem.setData(dataList[1], role = CPE.DESCRIPTION)
879                        pageItem.setData(relUrl, role = CPE.URL)
880                        pageItem.setData(dataList[2], role = CPE.KEYWORDS)
881                        pageItem.setData(dataList[3], role = CPE.LASTEDIT)
882                        pageItem.setData(dataList[4], role = CPE.EDITOR)
883                        self.pagesModel.setItem(index.row(), index.column(), pageItem)
884
885    """
886    Resize all the pages in the pages list.
887    It will show a dialog with the options for resizing.
888    Then, it will try to pop up a progress dialog while resizing.
889    The progress dialog shows the remaining time and pages.
890    """
891
892    def slot_batch_resize(self):
893        dialog = QDialog()
894        dialog.setWindowTitle(i18n("Resize all Pages"))
895        buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
896        buttons.accepted.connect(dialog.accept)
897        buttons.rejected.connect(dialog.reject)
898        sizesBox = comics_export_dialog.comic_export_resize_widget("Scale", batch=True, fileType=False)
899        exporterSizes = comics_exporter.sizesCalculator()
900        dialog.setLayout(QVBoxLayout())
901        dialog.layout().addWidget(sizesBox)
902        dialog.layout().addWidget(buttons)
903
904        if dialog.exec_() == QDialog.Accepted:
905            progress = QProgressDialog(i18n("Resizing pages..."), str(), 0, len(self.setupDictionary["pages"]))
906            progress.setWindowTitle(i18n("Resizing Pages"))
907            progress.setCancelButton(None)
908            timer = QElapsedTimer()
909            timer.start()
910            config = {}
911            config = sizesBox.get_config(config)
912            for p in range(len(self.setupDictionary["pages"])):
913                absoluteUrl = os.path.join(self.projecturl, self.setupDictionary["pages"][p])
914                progress.setValue(p)
915                timePassed = timer.elapsed()
916                if (p > 0):
917                    timeEstimated = (len(self.setupDictionary["pages"]) - p) * (timePassed / p)
918                    passedString = str(int(timePassed / 60000)) + ":" + format(int(timePassed / 1000), "02d") + ":" + format(timePassed % 1000, "03d")
919                    estimatedString = str(int(timeEstimated / 60000)) + ":" + format(int(timeEstimated / 1000), "02d") + ":" + format(int(timeEstimated % 1000), "03d")
920                    progress.setLabelText(str(i18n("{pages} of {pagesTotal} done. \nTime passed: {passedString}:\n Estimated:{estimated}")).format(pages=p, pagesTotal=len(self.setupDictionary["pages"]), passedString=passedString, estimated=estimatedString))
921                    qApp.processEvents()
922                if os.path.exists(absoluteUrl):
923                    doc = Application.openDocument(absoluteUrl)
924                    listScales = exporterSizes.get_scale_from_resize_config(config["Scale"], [doc.width(), doc.height(), doc.resolution(), doc.resolution()])
925                    doc.scaleImage(listScales[0], listScales[1], listScales[2], listScales[3], "bicubic")
926                    doc.waitForDone()
927                    doc.save()
928                    doc.waitForDone()
929                    doc.close()
930
931    def slot_show_page_viewer(self):
932        index = int(self.comicPageList.currentIndex().row())
933        self.page_viewer_dialog.load_comic(self.path_to_config)
934        self.page_viewer_dialog.go_to_page_index(index)
935        self.page_viewer_dialog.show()
936
937    """
938    Function to copy the current project location into the clipboard.
939    This is useful for users because they'll be able to use that url to quickly
940    move to the project location in outside applications.
941    """
942
943    def slot_copy_project_url(self):
944        if self.projecturl is not None:
945            clipboard = qApp.clipboard()
946            clipboard.setText(str(self.projecturl))
947
948    """
949    Scrape text files with the textlayer keys for text, and put those in a POT
950    file. This makes it possible to handle translations.
951    """
952
953    def slot_scrape_translations(self):
954        translationFolder = self.setupDictionary.get("translationLocation", "translations")
955        fullTranslationPath = os.path.join(self.projecturl, translationFolder)
956        os.makedirs(fullTranslationPath, exist_ok=True)
957        textLayersToSearch = self.setupDictionary.get("textLayerNames", ["text"])
958
959        scraper = comics_project_translation_scraper.translation_scraper(self.projecturl, translationFolder, textLayersToSearch, self.setupDictionary["projectName"])
960        # Run text scraper.
961        language = self.setupDictionary.get("language", "en")
962        metadata = {}
963        metadata["title"] = self.setupDictionary.get("title", "")
964        metadata["summary"] = self.setupDictionary.get("summary", "")
965        metadata["keywords"] = ", ".join(self.setupDictionary.get("otherKeywords", [""]))
966        metadata["transnotes"] = self.setupDictionary.get("translatorHeader", "Translator's Notes")
967        scraper.start(self.setupDictionary["pages"], language, metadata)
968        QMessageBox.information(self, i18n("Scraping success"), str(i18n("POT file has been written to: {file}")).format(file=fullTranslationPath), QMessageBox.Ok)
969    """
970    This is required by the dockwidget class, otherwise unused.
971    """
972
973    def canvasChanged(self, canvas):
974        pass
975
976
977"""
978Add docker to program
979"""
980Application.addDockWidgetFactory(DockWidgetFactory("comics_project_manager_docker", DockWidgetFactoryBase.DockRight, comics_project_manager_docker))
981