Merge branch 'master' into master
2
.gitattributes
vendored
@ -1,4 +1,4 @@
|
||||
helper.py ident export-subst
|
||||
updater.py ident export-subst
|
||||
/test export-ignore
|
||||
cps/static/css/libs/* linguist-vendored
|
||||
cps/static/js/libs/* linguist-vendored
|
||||
|
3
MANIFEST.in
Normal file
@ -0,0 +1,3 @@
|
||||
include cps/static/*
|
||||
include cps/templates/*
|
||||
include cps/translations/*
|
@ -1,6 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2016-2019 lemmsh cervinko Kennyl matthazinski OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import logging
|
||||
import uploader
|
||||
import os
|
||||
@ -19,6 +35,7 @@ logger = logging.getLogger("book_formats")
|
||||
try:
|
||||
from wand.image import Image
|
||||
from wand import version as ImageVersion
|
||||
from wand.exceptions import PolicyError
|
||||
use_generic_pdf_cover = False
|
||||
except (ImportError, RuntimeError) as e:
|
||||
logger.warning('cannot import Image, generating pdf covers for pdf uploads will not work: %s', e)
|
||||
@ -45,6 +62,13 @@ except ImportError as e:
|
||||
logger.warning('cannot import fb2, extracting fb2 metadata will not work: %s', e)
|
||||
use_fb2_meta = False
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
from PIL import __version__ as PILversion
|
||||
use_PIL = True
|
||||
except ImportError:
|
||||
use_PIL = False
|
||||
|
||||
|
||||
def process(tmp_file_path, original_file_name, original_file_extension):
|
||||
meta = None
|
||||
@ -84,7 +108,7 @@ def default_meta(tmp_file_path, original_file_name, original_file_extension):
|
||||
def pdf_meta(tmp_file_path, original_file_name, original_file_extension):
|
||||
|
||||
if use_pdf_meta:
|
||||
pdf = PdfFileReader(open(tmp_file_path, 'rb'))
|
||||
pdf = PdfFileReader(open(tmp_file_path, 'rb'), strict=False)
|
||||
doc_info = pdf.getDocumentInfo()
|
||||
else:
|
||||
doc_info = None
|
||||
@ -114,12 +138,58 @@ def pdf_preview(tmp_file_path, tmp_dir):
|
||||
if use_generic_pdf_cover:
|
||||
return None
|
||||
else:
|
||||
if use_PIL:
|
||||
try:
|
||||
input1 = PdfFileReader(open(tmp_file_path, 'rb'), strict=False)
|
||||
page0 = input1.getPage(0)
|
||||
xObject = page0['/Resources']['/XObject'].getObject()
|
||||
|
||||
for obj in xObject:
|
||||
if xObject[obj]['/Subtype'] == '/Image':
|
||||
size = (xObject[obj]['/Width'], xObject[obj]['/Height'])
|
||||
data = xObject[obj]._data # xObject[obj].getData()
|
||||
if xObject[obj]['/ColorSpace'] == '/DeviceRGB':
|
||||
mode = "RGB"
|
||||
else:
|
||||
mode = "P"
|
||||
if '/Filter' in xObject[obj]:
|
||||
if xObject[obj]['/Filter'] == '/FlateDecode':
|
||||
img = Image.frombytes(mode, size, data)
|
||||
cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.png"
|
||||
img.save(filename=os.path.join(tmp_dir, cover_file_name))
|
||||
return cover_file_name
|
||||
# img.save(obj[1:] + ".png")
|
||||
elif xObject[obj]['/Filter'] == '/DCTDecode':
|
||||
cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.jpg"
|
||||
img = open(cover_file_name, "wb")
|
||||
img.write(data)
|
||||
img.close()
|
||||
return cover_file_name
|
||||
elif xObject[obj]['/Filter'] == '/JPXDecode':
|
||||
cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.jp2"
|
||||
img = open(cover_file_name, "wb")
|
||||
img.write(data)
|
||||
img.close()
|
||||
return cover_file_name
|
||||
else:
|
||||
img = Image.frombytes(mode, size, data)
|
||||
cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.png"
|
||||
img.save(filename=os.path.join(tmp_dir, cover_file_name))
|
||||
return cover_file_name
|
||||
except Exception as ex:
|
||||
print(ex)
|
||||
try:
|
||||
cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.jpg"
|
||||
with Image(filename=tmp_file_path + "[0]", resolution=150) as img:
|
||||
img.compression_quality = 88
|
||||
img.save(filename=os.path.join(tmp_dir, cover_file_name))
|
||||
return cover_file_name
|
||||
|
||||
except PolicyError as ex:
|
||||
logger.warning('Pdf extraction forbidden by Imagemagick policy: %s', ex)
|
||||
return None
|
||||
except Exception as ex:
|
||||
logger.warning('Cannot extract cover image, using default: %s', ex)
|
||||
return None
|
||||
|
||||
def get_versions():
|
||||
if not use_generic_pdf_cover:
|
||||
@ -136,4 +206,12 @@ def get_versions():
|
||||
XVersion = 'v'+'.'.join(map(str, lxmlversion))
|
||||
else:
|
||||
XVersion = _(u'not installed')
|
||||
return {'Image Magick': IVersion, 'PyPdf': PVersion, 'lxml':XVersion, 'Wand Version': WVersion}
|
||||
if use_PIL:
|
||||
PILVersion = 'v' + PILversion
|
||||
else:
|
||||
PILVersion = _(u'not installed')
|
||||
return {'Image Magick': IVersion,
|
||||
'PyPdf': PVersion,
|
||||
'lxml':XVersion,
|
||||
'Wand': WVersion,
|
||||
'Pillow': PILVersion}
|
||||
|
@ -1,3 +1,19 @@
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2016-2019 jkrehm andy29485 OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
# Inspired by https://github.com/ChrisTM/Flask-CacheBust
|
||||
# Uses query strings so CSS font files are found without having to resort to absolute URLs
|
||||
|
||||
|
17
cps/cli.py
@ -1,6 +1,23 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2018 OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
101
cps/comic.py
Normal file → Executable file
@ -1,41 +1,120 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import zipfile
|
||||
import tarfile
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2018 OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os
|
||||
import uploader
|
||||
import logging
|
||||
from iso639 import languages as isoLanguages
|
||||
|
||||
|
||||
logger = logging.getLogger("book_formats")
|
||||
|
||||
try:
|
||||
from comicapi.comicarchive import ComicArchive, MetaDataStyle
|
||||
use_comic_meta = True
|
||||
except ImportError as e:
|
||||
logger.warning('cannot import comicapi, extracting comic metadata will not work: %s', e)
|
||||
import zipfile
|
||||
import tarfile
|
||||
use_comic_meta = False
|
||||
|
||||
|
||||
def extractCover(tmp_file_name, original_file_extension):
|
||||
if use_comic_meta:
|
||||
archive = ComicArchive(tmp_file_name)
|
||||
cover_data = None
|
||||
ext = os.path.splitext(archive.getPageName(0))
|
||||
if len(ext) > 1:
|
||||
extension = ext[1].lower()
|
||||
if extension == '.jpg' or extension == '.jpeg':
|
||||
cover_data = archive.getPage(0)
|
||||
else:
|
||||
if original_file_extension.upper() == '.CBZ':
|
||||
cf = zipfile.ZipFile(tmp_file_name)
|
||||
compressed_name = cf.namelist()[0]
|
||||
cover_data = cf.read(compressed_name)
|
||||
for name in cf.namelist():
|
||||
ext = os.path.splitext(name)
|
||||
if len(ext) > 1:
|
||||
extension = ext[1].lower()
|
||||
if extension == '.jpg':
|
||||
cover_data = cf.read(name)
|
||||
break
|
||||
elif original_file_extension.upper() == '.CBT':
|
||||
cf = tarfile.TarFile(tmp_file_name)
|
||||
compressed_name = cf.getnames()[0]
|
||||
cover_data = cf.extractfile(compressed_name).read()
|
||||
|
||||
for name in cf.getnames():
|
||||
ext = os.path.splitext(name)
|
||||
if len(ext) > 1:
|
||||
extension = ext[1].lower()
|
||||
if extension == '.jpg':
|
||||
cover_data = cf.extractfile(name).read()
|
||||
break
|
||||
prefix = os.path.dirname(tmp_file_name)
|
||||
|
||||
tmp_cover_name = prefix + '/cover' + os.path.splitext(compressed_name)[1]
|
||||
if cover_data:
|
||||
tmp_cover_name = prefix + '/cover' + extension
|
||||
image = open(tmp_cover_name, 'wb')
|
||||
image.write(cover_data)
|
||||
image.close()
|
||||
else:
|
||||
tmp_cover_name = None
|
||||
return tmp_cover_name
|
||||
|
||||
|
||||
def get_comic_info(tmp_file_path, original_file_name, original_file_extension):
|
||||
if use_comic_meta:
|
||||
archive = ComicArchive(tmp_file_path)
|
||||
if archive.seemsToBeAComicArchive():
|
||||
if archive.hasMetadata(MetaDataStyle.CIX):
|
||||
style = MetaDataStyle.CIX
|
||||
elif archive.hasMetadata(MetaDataStyle.CBI):
|
||||
style = MetaDataStyle.CBI
|
||||
else:
|
||||
style = None
|
||||
|
||||
coverfile = extractCover(tmp_file_path, original_file_extension)
|
||||
if style is not None:
|
||||
loadedMetadata = archive.readMetadata(style)
|
||||
|
||||
lang = loadedMetadata.language
|
||||
if len(lang) == 2:
|
||||
loadedMetadata.language = isoLanguages.get(part1=lang).name
|
||||
elif len(lang) == 3:
|
||||
loadedMetadata.language = isoLanguages.get(part3=lang).name
|
||||
else:
|
||||
loadedMetadata.language = ""
|
||||
|
||||
return uploader.BookMeta(
|
||||
file_path=tmp_file_path,
|
||||
extension=original_file_extension,
|
||||
title=loadedMetadata.title or original_file_name,
|
||||
author=" & ".join([credit["person"] for credit in loadedMetadata.credits if credit["role"] == "Writer"]) or u"Unknown",
|
||||
cover=extractCover(tmp_file_path, original_file_extension),
|
||||
description=loadedMetadata.comments or "",
|
||||
tags="",
|
||||
series=loadedMetadata.series or "",
|
||||
series_id=loadedMetadata.issue or "",
|
||||
languages=loadedMetadata.language)
|
||||
else:
|
||||
|
||||
return uploader.BookMeta(
|
||||
file_path=tmp_file_path,
|
||||
extension=original_file_extension,
|
||||
title=original_file_name,
|
||||
author=u"Unknown",
|
||||
cover=coverfile,
|
||||
cover=extractCover(tmp_file_path, original_file_extension),
|
||||
description="",
|
||||
tags="",
|
||||
series="",
|
||||
|
@ -1,5 +1,23 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2016-2019 Ben Bennett, OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import ub
|
||||
|
31
cps/db.py
@ -1,6 +1,23 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2012-2019 mutschler, cervinko, ok11, jkrehm, nanu-c, Wineliva,
|
||||
# pjeby, elelay, idalin, Ozzieisaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from sqlalchemy import *
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import *
|
||||
@ -9,6 +26,8 @@ import re
|
||||
import ast
|
||||
from ub import config
|
||||
import ub
|
||||
import sys
|
||||
import unidecode
|
||||
|
||||
session = None
|
||||
cc_exceptions = ['datetime', 'comments', 'float', 'composite', 'series']
|
||||
@ -28,7 +47,7 @@ def title_sort(title):
|
||||
|
||||
|
||||
def lcase(s):
|
||||
return s.lower()
|
||||
return unidecode.unidecode(s.lower())
|
||||
|
||||
|
||||
def ucase(s):
|
||||
@ -94,6 +113,8 @@ class Identifiers(Base):
|
||||
return u"Google Books"
|
||||
elif self.type == "kobo":
|
||||
return u"Kobo"
|
||||
if self.type == "lubimyczytac":
|
||||
return u"Lubimyczytac"
|
||||
else:
|
||||
return self.type
|
||||
|
||||
@ -112,6 +133,8 @@ class Identifiers(Base):
|
||||
return u"https://books.google.com/books?id={0}".format(self.val)
|
||||
elif self.type == "kobo":
|
||||
return u"https://www.kobo.com/ebook/{0}".format(self.val)
|
||||
elif self.type == "lubimyczytac":
|
||||
return u" http://lubimyczytac.pl/ksiazka/{0}".format(self.val)
|
||||
elif self.type == "url":
|
||||
return u"{0}".format(self.val)
|
||||
else:
|
||||
@ -301,6 +324,8 @@ class Custom_Columns(Base):
|
||||
|
||||
def get_display_dict(self):
|
||||
display_dict = ast.literal_eval(self.display)
|
||||
if sys.version_info < (3, 0):
|
||||
display_dict['enum_values'] = [x.decode('unicode_escape') for x in display_dict['enum_values']]
|
||||
return display_dict
|
||||
|
||||
|
||||
@ -335,8 +360,8 @@ def setup_db():
|
||||
ub.session.commit()
|
||||
config.loadSettings()
|
||||
conn.connection.create_function('title_sort', 1, title_sort)
|
||||
conn.connection.create_function('lower', 1, lcase)
|
||||
conn.connection.create_function('upper', 1, ucase)
|
||||
# conn.connection.create_function('lower', 1, lcase)
|
||||
# conn.connection.create_function('upper', 1, ucase)
|
||||
|
||||
if not cc_classes:
|
||||
cc = conn.execute("SELECT id, datatype FROM custom_columns")
|
||||
|
18
cps/epub.py
@ -1,11 +1,27 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2018 lemmsh, Kennyl, Kyosfonica, matthazinski
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import zipfile
|
||||
from lxml import etree
|
||||
import os
|
||||
import uploader
|
||||
from iso639 import languages as isoLanguages
|
||||
import isoLanguages
|
||||
|
||||
|
||||
def extractCover(zipFile, coverFile, coverpath, tmp_file_name):
|
||||
|
16
cps/fb2.py
@ -1,6 +1,22 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2018 lemmsh, cervinko, OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from lxml import etree
|
||||
import uploader
|
||||
|
||||
|
@ -1,7 +1,26 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2018 idalin, OzzieIsaacs
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
try:
|
||||
from pydrive.auth import GoogleAuth
|
||||
from pydrive.drive import GoogleDrive
|
||||
from pydrive.auth import RefreshError
|
||||
from pydrive.auth import RefreshError, InvalidConfigError
|
||||
from apiclient import errors
|
||||
gdrive_support = True
|
||||
except ImportError:
|
||||
@ -12,12 +31,9 @@ from ub import config
|
||||
import cli
|
||||
import shutil
|
||||
from flask import Response, stream_with_context
|
||||
|
||||
from sqlalchemy import *
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import *
|
||||
|
||||
|
||||
import web
|
||||
|
||||
class Singleton:
|
||||
@ -147,7 +163,10 @@ def getDrive(drive=None, gauth=None):
|
||||
# Save the current credentials to a file
|
||||
return GoogleDrive(gauth)
|
||||
if drive.auth.access_token_expired:
|
||||
try:
|
||||
drive.auth.Refresh()
|
||||
except RefreshError as e:
|
||||
web.app.logger.error("Google Drive error: " + e.message)
|
||||
return drive
|
||||
|
||||
def listRootFolders():
|
||||
@ -165,7 +184,7 @@ def getFolderInFolder(parentId, folderName, drive):
|
||||
# drive = getDrive(drive)
|
||||
query=""
|
||||
if folderName:
|
||||
query = "title = '%s' and " % folderName.replace("'", "\\'")
|
||||
query = "title = '%s' and " % folderName.replace("'", r"\'")
|
||||
folder = query + "'%s' in parents and mimeType = 'application/vnd.google-apps.folder'" \
|
||||
" and trashed = false" % parentId
|
||||
fileList = drive.ListFile({'q': folder}).GetList()
|
||||
@ -192,7 +211,7 @@ def getEbooksFolderId(drive=None):
|
||||
|
||||
|
||||
def getFile(pathId, fileName, drive):
|
||||
metaDataFile = "'%s' in parents and trashed = false and title = '%s'" % (pathId, fileName.replace("'", "\\'"))
|
||||
metaDataFile = "'%s' in parents and trashed = false and title = '%s'" % (pathId, fileName.replace("'", r"\'"))
|
||||
fileList = drive.ListFile({'q': metaDataFile}).GetList()
|
||||
if fileList.__len__() == 0:
|
||||
return None
|
||||
@ -227,7 +246,7 @@ def getFolderId(path, drive):
|
||||
dbChange = True
|
||||
currentFolderId = currentFolder['id']
|
||||
else:
|
||||
currentFolderId= None
|
||||
currentFolderId = None
|
||||
break
|
||||
if dbChange:
|
||||
session.commit()
|
||||
@ -249,16 +268,9 @@ def getFileFromEbooksFolder(path, fileName):
|
||||
return None
|
||||
|
||||
|
||||
'''def copyDriveFileRemote(drive, origin_file_id, copy_title):
|
||||
drive = getDrive(drive)
|
||||
copied_file = {'title': copy_title}
|
||||
try:
|
||||
file_data = drive.auth.service.files().copy(
|
||||
fileId = origin_file_id, body=copied_file).execute()
|
||||
return drive.CreateFile({'id': file_data['id']})
|
||||
except errors.HttpError as error:
|
||||
print ('An error occurred: %s' % error)
|
||||
return None'''
|
||||
def moveGdriveFileRemote(origin_file_id, new_title):
|
||||
origin_file_id['title']= new_title
|
||||
origin_file_id.Upload()
|
||||
|
||||
|
||||
# Download metadata.db from gdrive
|
||||
@ -270,9 +282,10 @@ def downloadFile(path, filename, output):
|
||||
def moveGdriveFolderRemote(origin_file, target_folder):
|
||||
drive = getDrive(Gdrive.Instance().drive)
|
||||
previous_parents = ",".join([parent["id"] for parent in origin_file.get('parents')])
|
||||
children = drive.auth.service.children().list(folderId=previous_parents).execute()
|
||||
gFileTargetDir = getFileFromEbooksFolder(None, target_folder)
|
||||
if not gFileTargetDir:
|
||||
# Folder is not exisiting, create, and move folder
|
||||
# Folder is not existing, create, and move folder
|
||||
gFileTargetDir = drive.CreateFile(
|
||||
{'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}],
|
||||
"mimeType": "application/vnd.google-apps.folder"})
|
||||
@ -282,13 +295,10 @@ def moveGdriveFolderRemote(origin_file, target_folder):
|
||||
addParents=gFileTargetDir['id'],
|
||||
removeParents=previous_parents,
|
||||
fields='id, parents').execute()
|
||||
# if previous_parents has no childs anymore, delete originfileparent
|
||||
# is not working correctly, because of slow update on gdrive -> could cause trouble in gdrive.db
|
||||
# (nonexisting folder has id)
|
||||
# children = drive.auth.service.children().list(folderId=previous_parents).execute()
|
||||
# if not len(children['items']):
|
||||
# drive.auth.service.files().delete(fileId=previous_parents).execute()
|
||||
|
||||
# if previous_parents has no childs anymore, delete original fileparent
|
||||
if len(children['items']) == 1:
|
||||
deleteDatabaseEntry(previous_parents)
|
||||
drive.auth.service.files().delete(fileId=previous_parents).execute()
|
||||
|
||||
|
||||
def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
|
||||
@ -301,7 +311,7 @@ def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
|
||||
parent = getEbooksFolder(drive)
|
||||
if os.path.isdir(os.path.join(prevDir,uploadFile)):
|
||||
existingFolder = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
||||
(os.path.basename(uploadFile), parent['id'])}).GetList()
|
||||
(os.path.basename(uploadFile).replace("'", r"\'"), parent['id'])}).GetList()
|
||||
if len(existingFolder) == 0 and (not isInitial or createRoot):
|
||||
parent = drive.CreateFile({'title': os.path.basename(uploadFile),
|
||||
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],
|
||||
@ -316,11 +326,11 @@ def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
|
||||
else:
|
||||
if os.path.basename(uploadFile) not in ignoreFiles:
|
||||
existingFiles = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
||||
(os.path.basename(uploadFile), parent['id'])}).GetList()
|
||||
(os.path.basename(uploadFile).replace("'", r"\'"), parent['id'])}).GetList()
|
||||
if len(existingFiles) > 0:
|
||||
driveFile = existingFiles[0]
|
||||
else:
|
||||
driveFile = drive.CreateFile({'title': os.path.basename(uploadFile),
|
||||
driveFile = drive.CreateFile({'title': os.path.basename(uploadFile).replace("'", r"\'"),
|
||||
'parents': [{"kind":"drive#fileLink", 'id': parent['id']}], })
|
||||
driveFile.SetContentFile(os.path.join(prevDir, uploadFile))
|
||||
driveFile.Upload()
|
||||
@ -333,7 +343,7 @@ def uploadFileToEbooksFolder(destFile, f):
|
||||
for i, x in enumerate(splitDir):
|
||||
if i == len(splitDir)-1:
|
||||
existingFiles = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
||||
(x, parent['id'])}).GetList()
|
||||
(x.replace("'", r"\'"), parent['id'])}).GetList()
|
||||
if len(existingFiles) > 0:
|
||||
driveFile = existingFiles[0]
|
||||
else:
|
||||
@ -342,7 +352,7 @@ def uploadFileToEbooksFolder(destFile, f):
|
||||
driveFile.Upload()
|
||||
else:
|
||||
existingFolder = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" %
|
||||
(x, parent['id'])}).GetList()
|
||||
(x.replace("'", r"\'"), parent['id'])}).GetList()
|
||||
if len(existingFolder) == 0:
|
||||
parent = drive.CreateFile({'title': x, 'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],
|
||||
"mimeType": "application/vnd.google-apps.folder"})
|
||||
@ -435,6 +445,10 @@ def getChangeById (drive, change_id):
|
||||
except (errors.HttpError) as error:
|
||||
web.app.logger.info(error.message)
|
||||
return None
|
||||
except Exception as e:
|
||||
web.app.logger.info(e)
|
||||
return None
|
||||
|
||||
|
||||
# Deletes the local hashes database to force search for new folder names
|
||||
def deleteDatabaseOnChange():
|
||||
@ -449,9 +463,10 @@ def updateGdriveCalibreFromLocal():
|
||||
|
||||
# update gdrive.db on edit of books title
|
||||
def updateDatabaseOnEdit(ID,newPath):
|
||||
sqlCheckPath = newPath if newPath[-1] == '/' else newPath + u'/'
|
||||
storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first()
|
||||
if storedPathName:
|
||||
storedPathName.path = newPath
|
||||
storedPathName.path = sqlCheckPath
|
||||
session.commit()
|
||||
|
||||
|
||||
|
354
cps/helper.py
@ -1,16 +1,33 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2012-2019 cervinko, idalin, SiphonSquirrel, ouzklcn, akushsky,
|
||||
# OzzieIsaacs, bodybybuddha, jkrehm, matthazinski, janeczku
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
import db
|
||||
import ub
|
||||
from flask import current_app as app
|
||||
import logging
|
||||
from tempfile import gettempdir
|
||||
import sys
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import unicodedata
|
||||
from io import BytesIO
|
||||
import worker
|
||||
import time
|
||||
from flask import send_from_directory, make_response, redirect, abort
|
||||
@ -18,16 +35,13 @@ from flask_babel import gettext as _
|
||||
from flask_login import current_user
|
||||
from babel.dates import format_datetime
|
||||
from datetime import datetime
|
||||
import threading
|
||||
import shutil
|
||||
import requests
|
||||
import zipfile
|
||||
try:
|
||||
import gdriveutils as gd
|
||||
except ImportError:
|
||||
pass
|
||||
import web
|
||||
import server
|
||||
import random
|
||||
import subprocess
|
||||
|
||||
@ -37,8 +51,14 @@ try:
|
||||
except ImportError:
|
||||
use_unidecode = False
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
use_PIL = True
|
||||
except ImportError:
|
||||
use_PIL = False
|
||||
|
||||
# Global variables
|
||||
updater_thread = None
|
||||
# updater_thread = None
|
||||
global_WorkerThread = worker.WorkerThread()
|
||||
global_WorkerThread.start()
|
||||
|
||||
@ -110,7 +130,7 @@ def send_registration_mail(e_mail, user_name, default_password, resend=False):
|
||||
text += "Sincerely\r\n\r\n"
|
||||
text += "Your Calibre-Web team"
|
||||
global_WorkerThread.add_email(_(u'Get Started with Calibre-Web'),None, None, ub.get_mail_settings(),
|
||||
e_mail, user_name, _(u"Registration e-mail for user: %(name)s", name=user_name), text)
|
||||
e_mail, None, _(u"Registration e-mail for user: %(name)s", name=user_name), text)
|
||||
return
|
||||
|
||||
def check_send_to_kindle(entry):
|
||||
@ -128,8 +148,8 @@ def check_send_to_kindle(entry):
|
||||
bookformats.append({'format':'Pdf','convert':0,'text':_('Send %(format)s to Kindle',format='Pdf')})
|
||||
if 'AZW' in ele.format:
|
||||
bookformats.append({'format':'Azw','convert':0,'text':_('Send %(format)s to Kindle',format='Azw')})
|
||||
if 'AZW3' in ele.format:
|
||||
bookformats.append({'format':'Azw3','convert':0,'text':_('Send %(format)s to Kindle',format='Azw3')})
|
||||
'''if 'AZW3' in ele.format:
|
||||
bookformats.append({'format':'Azw3','convert':0,'text':_('Send %(format)s to Kindle',format='Azw3')})'''
|
||||
else:
|
||||
formats = list()
|
||||
for ele in iter(entry.data):
|
||||
@ -138,18 +158,16 @@ def check_send_to_kindle(entry):
|
||||
bookformats.append({'format': 'Mobi','convert':0,'text':_('Send %(format)s to Kindle',format='Mobi')})
|
||||
if 'AZW' in formats:
|
||||
bookformats.append({'format': 'Azw','convert':0,'text':_('Send %(format)s to Kindle',format='Azw')})
|
||||
if 'AZW3' in formats:
|
||||
bookformats.append({'format': 'Azw3','convert':0,'text':_('Send %(format)s to Kindle',format='Azw3')})
|
||||
if 'PDF' in formats:
|
||||
bookformats.append({'format': 'Pdf','convert':0,'text':_('Send %(format)s to Kindle',format='Pdf')})
|
||||
if ub.config.config_ebookconverter >= 1:
|
||||
if 'EPUB' in formats and not 'MOBI' in formats:
|
||||
bookformats.append({'format': 'Mobi','convert':1,
|
||||
'text':_('Convert %(orig)s to %(format)s and send to Kindle',orig='Epub',format='Mobi')})
|
||||
if ub.config.config_ebookconverter == 2:
|
||||
'''if ub.config.config_ebookconverter == 2:
|
||||
if 'EPUB' in formats and not 'AZW3' in formats:
|
||||
bookformats.append({'format': 'Azw3','convert':1,
|
||||
'text':_('Convert %(orig)s to %(format)s and send to Kindle',orig='Epub',format='Azw3')})
|
||||
'text':_('Convert %(orig)s to %(format)s and send to Kindle',orig='Epub',format='Azw3')})'''
|
||||
return bookformats
|
||||
else:
|
||||
app.logger.error(u'Cannot find book entry %d', entry.id)
|
||||
@ -159,7 +177,7 @@ def check_send_to_kindle(entry):
|
||||
# Check if a reader is existing for any of the book formats, if not, return empty list, otherwise return
|
||||
# list with supported formats
|
||||
def check_read_formats(entry):
|
||||
EXTENSIONS_READER = {'TXT', 'PDF', 'EPUB', 'ZIP', 'CBZ', 'TAR', 'CBT', 'RAR', 'CBR'}
|
||||
EXTENSIONS_READER = {'TXT', 'PDF', 'EPUB', 'CBZ', 'CBT', 'CBR'}
|
||||
bookformats = list()
|
||||
if len(entry.data):
|
||||
for ele in iter(entry.data):
|
||||
@ -217,7 +235,10 @@ def get_valid_filename(value, replace_whitespace=True):
|
||||
value = value[:128]
|
||||
if not value:
|
||||
raise ValueError("Filename cannot be empty")
|
||||
if sys.version_info.major == 3:
|
||||
return value
|
||||
else:
|
||||
return value.decode('utf-8')
|
||||
|
||||
|
||||
def get_sorted_author(value):
|
||||
@ -306,12 +327,12 @@ def update_dir_structure_file(book_id, calibrepath, first_author):
|
||||
# Rename all files from old names to new names
|
||||
if authordir != new_authordir or titledir != new_titledir:
|
||||
try:
|
||||
for format in localbook.data:
|
||||
path_name = os.path.join(calibrepath, new_authordir, os.path.basename(path))
|
||||
new_name = get_valid_filename(localbook.title) + ' - ' + get_valid_filename(new_authordir)
|
||||
os.renames(os.path.join(path_name, format.name + '.' + format.format.lower()),
|
||||
os.path.join(path_name,new_name + '.' + format.format.lower()))
|
||||
format.name = new_name
|
||||
path_name = os.path.join(calibrepath, new_authordir, os.path.basename(path))
|
||||
for file_format in localbook.data:
|
||||
os.renames(os.path.join(path_name, file_format.name + '.' + file_format.format.lower()),
|
||||
os.path.join(path_name, new_name + '.' + file_format.format.lower()))
|
||||
file_format.name = new_name
|
||||
except OSError as ex:
|
||||
web.app.logger.error("Rename file in path " + path + " to " + new_name + ": " + str(ex))
|
||||
web.app.logger.debug(ex, exc_info=True)
|
||||
@ -323,6 +344,7 @@ def update_dir_structure_file(book_id, calibrepath, first_author):
|
||||
def update_dir_structure_gdrive(book_id, first_author):
|
||||
error = False
|
||||
book = db.session.query(db.Books).filter(db.Books.id == book_id).first()
|
||||
path = book.path
|
||||
|
||||
authordir = book.path.split('/')[0]
|
||||
if first_author:
|
||||
@ -330,40 +352,39 @@ def update_dir_structure_gdrive(book_id, first_author):
|
||||
else:
|
||||
new_authordir = get_valid_filename(book.authors[0].name)
|
||||
titledir = book.path.split('/')[1]
|
||||
new_titledir = get_valid_filename(book.title) + " (" + str(book_id) + ")"
|
||||
new_titledir = get_valid_filename(book.title) + u" (" + str(book_id) + u")"
|
||||
|
||||
if titledir != new_titledir:
|
||||
gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path), titledir)
|
||||
if gFile:
|
||||
gFile['title'] = new_titledir
|
||||
|
||||
gFile.Upload()
|
||||
book.path = book.path.split('/')[0] + '/' + new_titledir
|
||||
book.path = book.path.split('/')[0] + u'/' + new_titledir
|
||||
path = book.path
|
||||
gd.updateDatabaseOnEdit(gFile['id'], book.path) # only child folder affected
|
||||
else:
|
||||
error = _(u'File %(file)s not found on Google Drive', file=book.path) # file not found
|
||||
|
||||
if authordir != new_authordir:
|
||||
gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path), titledir)
|
||||
gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path), new_titledir)
|
||||
if gFile:
|
||||
gd.moveGdriveFolderRemote(gFile,new_authordir)
|
||||
book.path = new_authordir + '/' + book.path.split('/')[1]
|
||||
gd.moveGdriveFolderRemote(gFile, new_authordir)
|
||||
book.path = new_authordir + u'/' + book.path.split('/')[1]
|
||||
path = book.path
|
||||
gd.updateDatabaseOnEdit(gFile['id'], book.path)
|
||||
else:
|
||||
error = _(u'File %(file)s not found on Google Drive', file=authordir) # file not found
|
||||
# Rename all files from old names to new names
|
||||
# ToDo: Rename also all bookfiles with new author name and new title name
|
||||
'''
|
||||
|
||||
if authordir != new_authordir or titledir != new_titledir:
|
||||
for format in book.data:
|
||||
# path_name = os.path.join(calibrepath, new_authordir, os.path.basename(path))
|
||||
new_name = get_valid_filename(book.title) + ' - ' + get_valid_filename(book)
|
||||
format.name = new_name
|
||||
if gFile:
|
||||
pass
|
||||
else:
|
||||
error = _(u'File %(file)s not found on Google Drive', file=format.name) # file not found
|
||||
break'''
|
||||
new_name = get_valid_filename(book.title) + u' - ' + get_valid_filename(new_authordir)
|
||||
for file_format in book.data:
|
||||
gFile = gd.getFileFromEbooksFolder(path, file_format.name + u'.' + file_format.format.lower())
|
||||
if not gFile:
|
||||
error = _(u'File %(file)s not found on Google Drive', file=file_format.name) # file not found
|
||||
break
|
||||
gd.moveGdriveFileRemote(gFile, new_name + u'.' + file_format.format.lower())
|
||||
file_format.name = new_name
|
||||
return error
|
||||
|
||||
|
||||
@ -409,6 +430,8 @@ def delete_book(book, calibrepath, book_format):
|
||||
def get_book_cover(cover_path):
|
||||
if ub.config.config_use_google_drive:
|
||||
try:
|
||||
if not web.is_gdrive_ready():
|
||||
return send_from_directory(os.path.join(os.path.dirname(__file__), "static"), "generic_cover.jpg")
|
||||
path=gd.get_cover_via_gdrive(cover_path)
|
||||
if path:
|
||||
return redirect(path)
|
||||
@ -416,7 +439,7 @@ def get_book_cover(cover_path):
|
||||
web.app.logger.error(cover_path + '/cover.jpg not found on Google Drive')
|
||||
return send_from_directory(os.path.join(os.path.dirname(__file__), "static"), "generic_cover.jpg")
|
||||
except Exception as e:
|
||||
web.app.logger.error("Error Message: "+e.message)
|
||||
web.app.logger.error("Error Message: " + e.message)
|
||||
web.app.logger.exception(e)
|
||||
# traceback.print_exc()
|
||||
return send_from_directory(os.path.join(os.path.dirname(__file__), "static"),"generic_cover.jpg")
|
||||
@ -424,27 +447,71 @@ def get_book_cover(cover_path):
|
||||
return send_from_directory(os.path.join(ub.config.config_calibre_dir, cover_path), "cover.jpg")
|
||||
|
||||
|
||||
# saves book cover to gdrive or locally
|
||||
def save_cover(url, book_path):
|
||||
# saves book cover from url
|
||||
def save_cover_from_url(url, book_path):
|
||||
img = requests.get(url)
|
||||
if img.headers.get('content-type') != 'image/jpeg':
|
||||
web.app.logger.error("Cover is no jpg file, can't save")
|
||||
return save_cover(img, book_path)
|
||||
|
||||
|
||||
def save_cover_from_filestorage(filepath, saved_filename, img):
|
||||
if hasattr(img,'_content'):
|
||||
f = open(os.path.join(filepath, saved_filename), "wb")
|
||||
f.write(img._content)
|
||||
f.close()
|
||||
else:
|
||||
# check if file path exists, otherwise create it, copy file to calibre path and delete temp file
|
||||
if not os.path.exists(filepath):
|
||||
try:
|
||||
os.makedirs(filepath)
|
||||
except OSError:
|
||||
web.app.logger.error(u"Failed to create path for cover")
|
||||
return False
|
||||
try:
|
||||
img.save(os.path.join(filepath, saved_filename))
|
||||
except OSError:
|
||||
web.app.logger.error(u"Failed to store cover-file")
|
||||
return False
|
||||
except IOError:
|
||||
web.app.logger.error(u"Cover-file is not a valid image file")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# saves book cover to gdrive or locally
|
||||
def save_cover(img, book_path):
|
||||
content_type = img.headers.get('content-type')
|
||||
|
||||
if use_PIL:
|
||||
if content_type not in ('image/jpeg', 'image/png', 'image/webp'):
|
||||
web.app.logger.error("Only jpg/jpeg/png/webp files are supported as coverfile")
|
||||
return False
|
||||
# convert to jpg because calibre only supports jpg
|
||||
if content_type in ('image/png', 'image/webp'):
|
||||
if hasattr(img,'stream'):
|
||||
imgc = Image.open(img.stream)
|
||||
else:
|
||||
imgc = Image.open(io.BytesIO(img.content))
|
||||
im = imgc.convert('RGB')
|
||||
tmp_bytesio = io.BytesIO()
|
||||
im.save(tmp_bytesio, format='JPEG')
|
||||
img._content = tmp_bytesio.getvalue()
|
||||
else:
|
||||
if content_type not in ('image/jpeg'):
|
||||
web.app.logger.error("Only jpg/jpeg files are supported as coverfile")
|
||||
return False
|
||||
|
||||
if ub.config.config_use_google_drive:
|
||||
tmpDir = gettempdir()
|
||||
f = open(os.path.join(tmpDir, "uploaded_cover.jpg"), "wb")
|
||||
f.write(img.content)
|
||||
f.close()
|
||||
gd.uploadFileToEbooksFolder(os.path.join(book_path, 'cover.jpg'), os.path.join(tmpDir, f.name))
|
||||
if save_cover_from_filestorage(tmpDir, "uploaded_cover.jpg", img) is True:
|
||||
gd.uploadFileToEbooksFolder(os.path.join(book_path, 'cover.jpg'),
|
||||
os.path.join(tmpDir, "uploaded_cover.jpg"))
|
||||
web.app.logger.info("Cover is saved on Google Drive")
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
else:
|
||||
return save_cover_from_filestorage(os.path.join(ub.config.config_calibre_dir, book_path), "cover.jpg", img)
|
||||
|
||||
f = open(os.path.join(ub.config.config_calibre_dir, book_path, "cover.jpg"), "wb")
|
||||
f.write(img.content)
|
||||
f.close()
|
||||
web.app.logger.info("Cover is saved")
|
||||
return True
|
||||
|
||||
|
||||
def do_download_file(book, book_format, data, headers):
|
||||
@ -468,167 +535,6 @@ def do_download_file(book, book_format, data, headers):
|
||||
##################################
|
||||
|
||||
|
||||
class Updater(threading.Thread):
|
||||
|
||||
def __init__(self):
|
||||
threading.Thread.__init__(self)
|
||||
self.status = 0
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
self.status = 1
|
||||
r = requests.get('https://api.github.com/repos/janeczku/calibre-web/zipball/master', stream=True)
|
||||
r.raise_for_status()
|
||||
|
||||
fname = re.findall("filename=(.+)", r.headers['content-disposition'])[0]
|
||||
self.status = 2
|
||||
z = zipfile.ZipFile(BytesIO(r.content))
|
||||
self.status = 3
|
||||
tmp_dir = gettempdir()
|
||||
z.extractall(tmp_dir)
|
||||
self.status = 4
|
||||
self.update_source(os.path.join(tmp_dir, os.path.splitext(fname)[0]), ub.config.get_main_dir)
|
||||
self.status = 6
|
||||
time.sleep(2)
|
||||
server.Server.setRestartTyp(True)
|
||||
server.Server.stopServer()
|
||||
self.status = 7
|
||||
time.sleep(2)
|
||||
except requests.exceptions.HTTPError as ex:
|
||||
logging.getLogger('cps.web').info( u'HTTP Error' + ' ' + str(ex))
|
||||
self.status = 8
|
||||
except requests.exceptions.ConnectionError:
|
||||
logging.getLogger('cps.web').info(u'Connection error')
|
||||
self.status = 9
|
||||
except requests.exceptions.Timeout:
|
||||
logging.getLogger('cps.web').info(u'Timeout while establishing connection')
|
||||
self.status = 10
|
||||
except requests.exceptions.RequestException:
|
||||
self.status = 11
|
||||
logging.getLogger('cps.web').info(u'General error')
|
||||
|
||||
def get_update_status(self):
|
||||
return self.status
|
||||
|
||||
@classmethod
|
||||
def file_to_list(self, filelist):
|
||||
return [x.strip() for x in open(filelist, 'r') if not x.startswith('#EXT')]
|
||||
|
||||
@classmethod
|
||||
def one_minus_two(self, one, two):
|
||||
return [x for x in one if x not in set(two)]
|
||||
|
||||
@classmethod
|
||||
def reduce_dirs(self, delete_files, new_list):
|
||||
new_delete = []
|
||||
for filename in delete_files:
|
||||
parts = filename.split(os.sep)
|
||||
sub = ''
|
||||
for part in parts:
|
||||
sub = os.path.join(sub, part)
|
||||
if sub == '':
|
||||
sub = os.sep
|
||||
count = 0
|
||||
for song in new_list:
|
||||
if song.startswith(sub):
|
||||
count += 1
|
||||
break
|
||||
if count == 0:
|
||||
if sub != '\\':
|
||||
new_delete.append(sub)
|
||||
break
|
||||
return list(set(new_delete))
|
||||
|
||||
@classmethod
|
||||
def reduce_files(self, remove_items, exclude_items):
|
||||
rf = []
|
||||
for item in remove_items:
|
||||
if not item.startswith(exclude_items):
|
||||
rf.append(item)
|
||||
return rf
|
||||
|
||||
@classmethod
|
||||
def moveallfiles(self, root_src_dir, root_dst_dir):
|
||||
change_permissions = True
|
||||
if sys.platform == "win32" or sys.platform == "darwin":
|
||||
change_permissions = False
|
||||
else:
|
||||
logging.getLogger('cps.web').debug('Update on OS-System : ' + sys.platform)
|
||||
new_permissions = os.stat(root_dst_dir)
|
||||
# print new_permissions
|
||||
for src_dir, __, files in os.walk(root_src_dir):
|
||||
dst_dir = src_dir.replace(root_src_dir, root_dst_dir, 1)
|
||||
if not os.path.exists(dst_dir):
|
||||
os.makedirs(dst_dir)
|
||||
logging.getLogger('cps.web').debug('Create-Dir: '+dst_dir)
|
||||
if change_permissions:
|
||||
# print('Permissions: User '+str(new_permissions.st_uid)+' Group '+str(new_permissions.st_uid))
|
||||
os.chown(dst_dir, new_permissions.st_uid, new_permissions.st_gid)
|
||||
for file_ in files:
|
||||
src_file = os.path.join(src_dir, file_)
|
||||
dst_file = os.path.join(dst_dir, file_)
|
||||
if os.path.exists(dst_file):
|
||||
if change_permissions:
|
||||
permission = os.stat(dst_file)
|
||||
logging.getLogger('cps.web').debug('Remove file before copy: '+dst_file)
|
||||
os.remove(dst_file)
|
||||
else:
|
||||
if change_permissions:
|
||||
permission = new_permissions
|
||||
shutil.move(src_file, dst_dir)
|
||||
logging.getLogger('cps.web').debug('Move File '+src_file+' to '+dst_dir)
|
||||
if change_permissions:
|
||||
try:
|
||||
os.chown(dst_file, permission.st_uid, permission.st_gid)
|
||||
except (Exception) as e:
|
||||
# ex = sys.exc_info()
|
||||
old_permissions = os.stat(dst_file)
|
||||
logging.getLogger('cps.web').debug('Fail change permissions of ' + str(dst_file) + '. Before: '
|
||||
+ str(old_permissions.st_uid) + ':' + str(old_permissions.st_gid) + ' After: '
|
||||
+ str(permission.st_uid) + ':' + str(permission.st_gid) + ' error: '+str(e))
|
||||
return
|
||||
|
||||
def update_source(self, source, destination):
|
||||
# destination files
|
||||
old_list = list()
|
||||
exclude = (
|
||||
'vendor' + os.sep + 'kindlegen.exe', 'vendor' + os.sep + 'kindlegen', os.sep + 'app.db',
|
||||
os.sep + 'vendor', os.sep + 'calibre-web.log')
|
||||
for root, dirs, files in os.walk(destination, topdown=True):
|
||||
for name in files:
|
||||
old_list.append(os.path.join(root, name).replace(destination, ''))
|
||||
for name in dirs:
|
||||
old_list.append(os.path.join(root, name).replace(destination, ''))
|
||||
# source files
|
||||
new_list = list()
|
||||
for root, dirs, files in os.walk(source, topdown=True):
|
||||
for name in files:
|
||||
new_list.append(os.path.join(root, name).replace(source, ''))
|
||||
for name in dirs:
|
||||
new_list.append(os.path.join(root, name).replace(source, ''))
|
||||
|
||||
delete_files = self.one_minus_two(old_list, new_list)
|
||||
|
||||
rf = self.reduce_files(delete_files, exclude)
|
||||
|
||||
remove_items = self.reduce_dirs(rf, new_list)
|
||||
|
||||
self.moveallfiles(source, destination)
|
||||
|
||||
for item in remove_items:
|
||||
item_path = os.path.join(destination, item[1:])
|
||||
if os.path.isdir(item_path):
|
||||
logging.getLogger('cps.web').debug("Delete dir " + item_path)
|
||||
shutil.rmtree(item_path)
|
||||
else:
|
||||
try:
|
||||
logging.getLogger('cps.web').debug("Delete file " + item_path)
|
||||
# log_from_thread("Delete file " + item_path)
|
||||
os.remove(item_path)
|
||||
except Exception:
|
||||
logging.getLogger('cps.web').debug("Could not remove:" + item_path)
|
||||
shutil.rmtree(source, ignore_errors=True)
|
||||
|
||||
|
||||
def check_unrar(unrarLocation):
|
||||
error = False
|
||||
@ -654,26 +560,6 @@ def check_unrar(unrarLocation):
|
||||
return (error, version)
|
||||
|
||||
|
||||
def is_sha1(sha1):
|
||||
if len(sha1) != 40:
|
||||
return False
|
||||
try:
|
||||
int(sha1, 16)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def get_current_version_info():
|
||||
content = {}
|
||||
content[0] = '$Format:%H$'
|
||||
content[1] = '$Format:%cI$'
|
||||
# content[0] = 'bb7d2c6273ae4560e83950d36d64533343623a57'
|
||||
# content[1] = '2018-09-09T10:13:08+02:00'
|
||||
if is_sha1(content[0]) and len(content[1]) > 0:
|
||||
return {'hash': content[0], 'datetime': content[1]}
|
||||
return False
|
||||
|
||||
|
||||
def json_serial(obj):
|
||||
"""JSON serializer for objects not serializable by default json code"""
|
||||
@ -682,17 +568,13 @@ def json_serial(obj):
|
||||
return obj.isoformat()
|
||||
raise TypeError ("Type %s not serializable" % type(obj))
|
||||
|
||||
|
||||
# helper function to apply localize status information in tasklist entries
|
||||
def render_task_status(tasklist):
|
||||
#helper function to apply localize status information in tasklist entries
|
||||
renderedtasklist=list()
|
||||
# task2 = task
|
||||
for task in tasklist:
|
||||
if task['user'] == current_user.nickname or current_user.role_admin():
|
||||
# task2 = copy.deepcopy(task) # = task
|
||||
if task['formStarttime']:
|
||||
task['starttime'] = format_datetime(task['formStarttime'], format='short', locale=web.get_locale())
|
||||
# task2['formStarttime'] = ""
|
||||
else:
|
||||
if 'starttime' not in task:
|
||||
task['starttime'] = ""
|
||||
|
43
cps/isoLanguages.py
Normal file
@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2019 pwr
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
try:
|
||||
from iso639 import languages, __version__
|
||||
get = languages.get
|
||||
except ImportError:
|
||||
from pycountry import languages as pyc_languages
|
||||
try:
|
||||
import pkg_resources
|
||||
__version__ = pkg_resources.get_distribution('pycountry').version + ' (PyCountry)'
|
||||
del pkg_resources
|
||||
except (ImportError, Exception):
|
||||
__version__ = "? (PyCountry)"
|
||||
|
||||
def _copy_fields(l):
|
||||
l.part1 = l.alpha_2
|
||||
l.part3 = l.alpha_3
|
||||
return l
|
||||
|
||||
def get(name=None, part1=None, part3=None):
|
||||
if (part3 is not None):
|
||||
return _copy_fields(pyc_languages.get(alpha_3=part3))
|
||||
if (part1 is not None):
|
||||
return _copy_fields(pyc_languages.get(alpha_2=part1))
|
||||
if (name is not None):
|
||||
return _copy_fields(pyc_languages.get(name=name))
|
@ -1,3 +1,31 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Flask License
|
||||
#
|
||||
# Copyright © 2010 by the Pallets team.
|
||||
#
|
||||
# Some rights reserved.
|
||||
|
||||
# Redistribution and use in source and binary forms of the software as well as
|
||||
# documentation, with or without modification, are permitted provided that the
|
||||
# following conditions are met:
|
||||
#
|
||||
# Redistributions of source code must retain the above copyright notice, this list of conditions
|
||||
# and the following disclaimer.
|
||||
# Redistributions in binary form must reproduce the above copyright notice, this list of conditions
|
||||
# and the following disclaimer in the documentation and/or other materials provided with the distribution.
|
||||
# Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products
|
||||
# derived from this software without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR
|
||||
# IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
||||
# PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
||||
# INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||
# THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING
|
||||
# IN ANY WAY OUT OF THE USE OF THIS SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
# http://flask.pocoo.org/snippets/62/
|
||||
|
||||
try:
|
||||
|
@ -1,6 +1,42 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Flask License
|
||||
#
|
||||
# Copyright © 2010 by the Pallets team, cervinko, janeczku, OzzieIsaacs
|
||||
#
|
||||
# Some rights reserved.
|
||||
#
|
||||
# Redistribution and use in source and binary forms of the software as
|
||||
# well as documentation, with or without modification, are permitted
|
||||
# provided that the following conditions are met:
|
||||
#
|
||||
# * Redistributions of source code must retain the above copyright notice,
|
||||
# this list of conditions and the following disclaimer.
|
||||
#
|
||||
# * Redistributions in binary form must reproduce the above copyright
|
||||
# notice, this list of conditions and the following disclaimer in the
|
||||
# documentation and/or other materials provided with the distribution.
|
||||
#
|
||||
# * Neither the name of the copyright holder nor the names of its
|
||||
# contributors may be used to endorse or promote products derived from
|
||||
# this software without specific prior written permission.
|
||||
#
|
||||
# THIS SOFTWARE AND DOCUMENTATION IS PROVIDED BY THE COPYRIGHT HOLDERS AND
|
||||
# CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING,
|
||||
# BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND
|
||||
# FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
||||
# COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
|
||||
# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
|
||||
# NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF
|
||||
# USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||
# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
|
||||
# THIS SOFTWARE AND DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF
|
||||
# SUCH DAMAGE.
|
||||
#
|
||||
# Inspired by http://flask.pocoo.org/snippets/35/
|
||||
|
||||
class ReverseProxied(object):
|
||||
"""Wrap the application in this middleware and configure the
|
||||
front-end server to add these headers, to let you quietly bind
|
||||
@ -33,7 +69,7 @@ class ReverseProxied(object):
|
||||
scheme = environ.get('HTTP_X_SCHEME', '')
|
||||
if scheme:
|
||||
environ['wsgi.url_scheme'] = scheme
|
||||
servr = environ.get('HTTP_X_FORWARDED_SERVER', '')
|
||||
servr = environ.get('HTTP_X_FORWARDED_HOST', '')
|
||||
if servr:
|
||||
environ['HTTP_HOST'] = servr
|
||||
return self.app(environ, start_response)
|
||||
|
@ -1,6 +1,23 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
|
||||
# Copyright (C) 2012-2019 janeczku, OzzieIsaacs, andrerfcsantos, idalin
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
from socket import error as SocketError
|
||||
import sys
|
||||
import os
|
||||
@ -20,6 +37,7 @@ except ImportError:
|
||||
gevent_present = False
|
||||
|
||||
|
||||
|
||||
class server:
|
||||
|
||||
wsgiserver = None
|
||||
@ -44,11 +62,14 @@ class server:
|
||||
self.wsgiserver= WSGIServer(('0.0.0.0', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args)
|
||||
else:
|
||||
self.wsgiserver = WSGIServer(('', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args)
|
||||
web.py3_gevent_link = self.wsgiserver
|
||||
self.wsgiserver.serve_forever()
|
||||
|
||||
except SocketError:
|
||||
try:
|
||||
web.app.logger.info('Unable to listen on \'\', trying on IPv4 only...')
|
||||
self.wsgiserver = WSGIServer(('0.0.0.0', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args)
|
||||
web.py3_gevent_link = self.wsgiserver
|
||||
self.wsgiserver.serve_forever()
|
||||
except (OSError, SocketError) as e:
|
||||
web.app.logger.info("Error starting server: %s" % e.strerror)
|
||||
@ -82,6 +103,7 @@ class server:
|
||||
ssl_options=ssl)
|
||||
http_server.listen(web.ub.config.config_port)
|
||||
self.wsgiserver=IOLoop.instance()
|
||||
web.py3_gevent_link = self.wsgiserver
|
||||
self.wsgiserver.start()
|
||||
# wait for stop signal
|
||||
self.wsgiserver.close(True)
|
||||
@ -91,6 +113,9 @@ class server:
|
||||
web.helper.global_WorkerThread.stop()
|
||||
sys.exit(1)
|
||||
|
||||
# ToDo: Somehow caused by circular import under python3 refactor
|
||||
if sys.version_info > (3, 0):
|
||||
self.restart = web.py3_restart_Typ
|
||||
if self.restart == True:
|
||||
web.app.logger.info("Performing restart of Calibre-Web")
|
||||
web.helper.global_WorkerThread.stop()
|
||||
@ -107,12 +132,21 @@ class server:
|
||||
sys.exit(0)
|
||||
|
||||
def setRestartTyp(self,starttyp):
|
||||
self.restart=starttyp
|
||||
self.restart = starttyp
|
||||
# ToDo: Somehow caused by circular import under python3 refactor
|
||||
web.py3_restart_Typ = starttyp
|
||||
|
||||
def killServer(self, signum, frame):
|
||||
self.stopServer()
|
||||
|
||||
def stopServer(self):
|
||||
# ToDo: Somehow caused by circular import under python3 refactor
|
||||
if sys.version_info > (3, 0):
|
||||
if not self.wsgiserver:
|
||||
# if gevent_present:
|
||||
self.wsgiserver = web.py3_gevent_link
|
||||
#else:
|
||||
# self.wsgiserver = IOLoop.instance()
|
||||
if self.wsgiserver:
|
||||
if gevent_present:
|
||||
self.wsgiserver.close()
|
||||
|
2
cps/static/css/caliBlur.min.css
vendored
@ -152,11 +152,11 @@ body {
|
||||
max-width: 70%;
|
||||
}
|
||||
|
||||
#prev {
|
||||
#left {
|
||||
left: 40px;
|
||||
}
|
||||
|
||||
#next {
|
||||
#right {
|
||||
right: 40px;
|
||||
}
|
||||
|
||||
|
6
cps/static/css/libs/bootstrap-theme.min.css
vendored
6
cps/static/css/libs/bootstrap.min.css
vendored
2
cps/static/css/libs/bootstrap.min.css.map
vendored
Before Width: | Height: | Size: 415 B After Width: | Height: | Size: 415 B |
Before Width: | Height: | Size: 883 B After Width: | Height: | Size: 883 B |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
Before Width: | Height: | Size: 408 B After Width: | Height: | Size: 408 B |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 426 B After Width: | Height: | Size: 426 B |
Before Width: | Height: | Size: 158 B After Width: | Height: | Size: 158 B |
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 1.0 KiB |
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 199 B After Width: | Height: | Size: 199 B |
Before Width: | Height: | Size: 304 B After Width: | Height: | Size: 304 B |
Before Width: | Height: | Size: 193 B After Width: | Height: | Size: 193 B |
Before Width: | Height: | Size: 296 B After Width: | Height: | Size: 296 B |
Before Width: | Height: | Size: 193 B After Width: | Height: | Size: 193 B |
Before Width: | Height: | Size: 296 B After Width: | Height: | Size: 296 B |
Before Width: | Height: | Size: 199 B After Width: | Height: | Size: 199 B |
Before Width: | Height: | Size: 304 B After Width: | Height: | Size: 304 B |
Before Width: | Height: | Size: 326 B After Width: | Height: | Size: 326 B |
Before Width: | Height: | Size: 326 B After Width: | Height: | Size: 326 B |
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.2 KiB |
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 403 B After Width: | Height: | Size: 403 B |
Before Width: | Height: | Size: 933 B After Width: | Height: | Size: 933 B |
Before Width: | Height: | Size: 179 B After Width: | Height: | Size: 179 B |
Before Width: | Height: | Size: 266 B After Width: | Height: | Size: 266 B |
Before Width: | Height: | Size: 301 B After Width: | Height: | Size: 301 B |
Before Width: | Height: | Size: 583 B After Width: | Height: | Size: 583 B |
Before Width: | Height: | Size: 175 B After Width: | Height: | Size: 175 B |
Before Width: | Height: | Size: 276 B After Width: | Height: | Size: 276 B |
Before Width: | Height: | Size: 360 B After Width: | Height: | Size: 360 B |
Before Width: | Height: | Size: 731 B After Width: | Height: | Size: 731 B |
Before Width: | Height: | Size: 359 B After Width: | Height: | Size: 359 B |
Before Width: | Height: | Size: 714 B After Width: | Height: | Size: 714 B |
After Width: | Height: | Size: 218 B |
After Width: | Height: | Size: 332 B |
After Width: | Height: | Size: 228 B |
After Width: | Height: | Size: 349 B |
After Width: | Height: | Size: 297 B |
After Width: | Height: | Size: 490 B |
BIN
cps/static/css/libs/images/secondaryToolbarButton-selectTool.png
Normal file
After Width: | Height: | Size: 461 B |
After Width: | Height: | Size: 1.0 KiB |
BIN
cps/static/css/libs/images/secondaryToolbarButton-spreadEven.png
Normal file
After Width: | Height: | Size: 347 B |
After Width: | Height: | Size: 694 B |
BIN
cps/static/css/libs/images/secondaryToolbarButton-spreadNone.png
Normal file
After Width: | Height: | Size: 179 B |
After Width: | Height: | Size: 261 B |
BIN
cps/static/css/libs/images/secondaryToolbarButton-spreadOdd.png
Normal file
After Width: | Height: | Size: 344 B |
After Width: | Height: | Size: 621 B |
Before Width: | Height: | Size: 290 B After Width: | Height: | Size: 290 B |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
Before Width: | Height: | Size: 174 B After Width: | Height: | Size: 174 B |
Before Width: | Height: | Size: 260 B After Width: | Height: | Size: 260 B |
Before Width: | Height: | Size: 259 B After Width: | Height: | Size: 259 B |
Before Width: | Height: | Size: 425 B After Width: | Height: | Size: 425 B |
Before Width: | Height: | Size: 107 B After Width: | Height: | Size: 107 B |
Before Width: | Height: | Size: 152 B After Width: | Height: | Size: 152 B |
Before Width: | Height: | Size: 295 B After Width: | Height: | Size: 295 B |
Before Width: | Height: | Size: 550 B After Width: | Height: | Size: 550 B |
Before Width: | Height: | Size: 242 B After Width: | Height: | Size: 242 B |
Before Width: | Height: | Size: 398 B After Width: | Height: | Size: 398 B |
Before Width: | Height: | Size: 238 B After Width: | Height: | Size: 238 B |
Before Width: | Height: | Size: 396 B After Width: | Height: | Size: 396 B |
Before Width: | Height: | Size: 245 B After Width: | Height: | Size: 245 B |
Before Width: | Height: | Size: 405 B After Width: | Height: | Size: 405 B |
Before Width: | Height: | Size: 246 B After Width: | Height: | Size: 246 B |
Before Width: | Height: | Size: 403 B After Width: | Height: | Size: 403 B |
Before Width: | Height: | Size: 321 B After Width: | Height: | Size: 321 B |
Before Width: | Height: | Size: 586 B After Width: | Height: | Size: 586 B |
Before Width: | Height: | Size: 257 B After Width: | Height: | Size: 257 B |
Before Width: | Height: | Size: 464 B After Width: | Height: | Size: 464 B |
Before Width: | Height: | Size: 309 B After Width: | Height: | Size: 309 B |
Before Width: | Height: | Size: 653 B After Width: | Height: | Size: 653 B |
Before Width: | Height: | Size: 246 B After Width: | Height: | Size: 246 B |
Before Width: | Height: | Size: 456 B After Width: | Height: | Size: 456 B |
Before Width: | Height: | Size: 243 B After Width: | Height: | Size: 243 B |
Before Width: | Height: | Size: 458 B After Width: | Height: | Size: 458 B |
Before Width: | Height: | Size: 225 B After Width: | Height: | Size: 225 B |