Merge branch 'master' into master

This commit is contained in:
Krakinou 2019-06-08 01:41:43 +02:00 committed by GitHub
commit 9a5ab97d78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
343 changed files with 128798 additions and 74577 deletions

2
.gitattributes vendored
View File

@ -1,4 +1,4 @@
helper.py ident export-subst updater.py ident export-subst
/test export-ignore /test export-ignore
cps/static/css/libs/* linguist-vendored cps/static/css/libs/* linguist-vendored
cps/static/js/libs/* linguist-vendored cps/static/js/libs/* linguist-vendored

3
MANIFEST.in Normal file
View File

@ -0,0 +1,3 @@
include cps/static/*
include cps/templates/*
include cps/translations/*

View File

@ -1,6 +1,22 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- 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 logging
import uploader import uploader
import os import os
@ -19,6 +35,7 @@ logger = logging.getLogger("book_formats")
try: try:
from wand.image import Image from wand.image import Image
from wand import version as ImageVersion from wand import version as ImageVersion
from wand.exceptions import PolicyError
use_generic_pdf_cover = False use_generic_pdf_cover = False
except (ImportError, RuntimeError) as e: except (ImportError, RuntimeError) as e:
logger.warning('cannot import Image, generating pdf covers for pdf uploads will not work: %s', 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) logger.warning('cannot import fb2, extracting fb2 metadata will not work: %s', e)
use_fb2_meta = False 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): def process(tmp_file_path, original_file_name, original_file_extension):
meta = None 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): def pdf_meta(tmp_file_path, original_file_name, original_file_extension):
if use_pdf_meta: if use_pdf_meta:
pdf = PdfFileReader(open(tmp_file_path, 'rb')) pdf = PdfFileReader(open(tmp_file_path, 'rb'), strict=False)
doc_info = pdf.getDocumentInfo() doc_info = pdf.getDocumentInfo()
else: else:
doc_info = None doc_info = None
@ -114,12 +138,58 @@ def pdf_preview(tmp_file_path, tmp_dir):
if use_generic_pdf_cover: if use_generic_pdf_cover:
return None return None
else: else:
cover_file_name = os.path.splitext(tmp_file_path)[0] + ".cover.jpg" if use_PIL:
with Image(filename=tmp_file_path + "[0]", resolution=150) as img: try:
img.compression_quality = 88 input1 = PdfFileReader(open(tmp_file_path, 'rb'), strict=False)
img.save(filename=os.path.join(tmp_dir, cover_file_name)) page0 = input1.getPage(0)
return cover_file_name 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(): def get_versions():
if not use_generic_pdf_cover: if not use_generic_pdf_cover:
@ -136,4 +206,12 @@ def get_versions():
XVersion = 'v'+'.'.join(map(str, lxmlversion)) XVersion = 'v'+'.'.join(map(str, lxmlversion))
else: else:
XVersion = _(u'not installed') 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}

View File

@ -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 # Inspired by https://github.com/ChrisTM/Flask-CacheBust
# Uses query strings so CSS font files are found without having to resort to absolute URLs # Uses query strings so CSS font files are found without having to resort to absolute URLs

View File

@ -1,6 +1,23 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- 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 argparse
import os import os
import sys import sys

117
cps/comic.py Normal file → Executable file
View File

@ -1,41 +1,120 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import zipfile # This file is part of the Calibre-Web (https://github.com/janeczku/calibre-web)
import tarfile # 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 os
import uploader 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): def extractCover(tmp_file_name, original_file_extension):
if original_file_extension.upper() == '.CBZ': if use_comic_meta:
cf = zipfile.ZipFile(tmp_file_name) archive = ComicArchive(tmp_file_name)
compressed_name = cf.namelist()[0] cover_data = None
cover_data = cf.read(compressed_name) ext = os.path.splitext(archive.getPageName(0))
elif original_file_extension.upper() == '.CBT': if len(ext) > 1:
cf = tarfile.TarFile(tmp_file_name) extension = ext[1].lower()
compressed_name = cf.getnames()[0] if extension == '.jpg' or extension == '.jpeg':
cover_data = cf.extractfile(compressed_name).read() cover_data = archive.getPage(0)
else:
if original_file_extension.upper() == '.CBZ':
cf = zipfile.ZipFile(tmp_file_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)
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) prefix = os.path.dirname(tmp_file_name)
if cover_data:
tmp_cover_name = prefix + '/cover' + os.path.splitext(compressed_name)[1] tmp_cover_name = prefix + '/cover' + extension
image = open(tmp_cover_name, 'wb') image = open(tmp_cover_name, 'wb')
image.write(cover_data) image.write(cover_data)
image.close() image.close()
else:
tmp_cover_name = None
return tmp_cover_name return tmp_cover_name
def get_comic_info(tmp_file_path, original_file_name, original_file_extension): 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)
return uploader.BookMeta( 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, file_path=tmp_file_path,
extension=original_file_extension, extension=original_file_extension,
title=original_file_name, title=original_file_name,
author=u"Unknown", author=u"Unknown",
cover=coverfile, cover=extractCover(tmp_file_path, original_file_extension),
description="", description="",
tags="", tags="",
series="", series="",

View File

@ -1,5 +1,23 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- 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 os
import subprocess import subprocess
import ub import ub

View File

@ -1,6 +1,23 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- 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 import *
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import * from sqlalchemy.orm import *
@ -9,6 +26,8 @@ import re
import ast import ast
from ub import config from ub import config
import ub import ub
import sys
import unidecode
session = None session = None
cc_exceptions = ['datetime', 'comments', 'float', 'composite', 'series'] cc_exceptions = ['datetime', 'comments', 'float', 'composite', 'series']
@ -28,7 +47,7 @@ def title_sort(title):
def lcase(s): def lcase(s):
return s.lower() return unidecode.unidecode(s.lower())
def ucase(s): def ucase(s):
@ -94,6 +113,8 @@ class Identifiers(Base):
return u"Google Books" return u"Google Books"
elif self.type == "kobo": elif self.type == "kobo":
return u"Kobo" return u"Kobo"
if self.type == "lubimyczytac":
return u"Lubimyczytac"
else: else:
return self.type return self.type
@ -112,6 +133,8 @@ class Identifiers(Base):
return u"https://books.google.com/books?id={0}".format(self.val) return u"https://books.google.com/books?id={0}".format(self.val)
elif self.type == "kobo": elif self.type == "kobo":
return u"https://www.kobo.com/ebook/{0}".format(self.val) 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": elif self.type == "url":
return u"{0}".format(self.val) return u"{0}".format(self.val)
else: else:
@ -301,6 +324,8 @@ class Custom_Columns(Base):
def get_display_dict(self): def get_display_dict(self):
display_dict = ast.literal_eval(self.display) 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 return display_dict
@ -335,8 +360,8 @@ def setup_db():
ub.session.commit() ub.session.commit()
config.loadSettings() config.loadSettings()
conn.connection.create_function('title_sort', 1, title_sort) conn.connection.create_function('title_sort', 1, title_sort)
conn.connection.create_function('lower', 1, lcase) # conn.connection.create_function('lower', 1, lcase)
conn.connection.create_function('upper', 1, ucase) # conn.connection.create_function('upper', 1, ucase)
if not cc_classes: if not cc_classes:
cc = conn.execute("SELECT id, datatype FROM custom_columns") cc = conn.execute("SELECT id, datatype FROM custom_columns")

View File

@ -1,11 +1,27 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- 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 import zipfile
from lxml import etree from lxml import etree
import os import os
import uploader import uploader
from iso639 import languages as isoLanguages import isoLanguages
def extractCover(zipFile, coverFile, coverpath, tmp_file_name): def extractCover(zipFile, coverFile, coverpath, tmp_file_name):

View File

@ -1,6 +1,22 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- 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 from lxml import etree
import uploader import uploader

View File

@ -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: try:
from pydrive.auth import GoogleAuth from pydrive.auth import GoogleAuth
from pydrive.drive import GoogleDrive from pydrive.drive import GoogleDrive
from pydrive.auth import RefreshError from pydrive.auth import RefreshError, InvalidConfigError
from apiclient import errors from apiclient import errors
gdrive_support = True gdrive_support = True
except ImportError: except ImportError:
@ -12,12 +31,9 @@ from ub import config
import cli import cli
import shutil import shutil
from flask import Response, stream_with_context from flask import Response, stream_with_context
from sqlalchemy import * from sqlalchemy import *
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import * from sqlalchemy.orm import *
import web import web
class Singleton: class Singleton:
@ -147,7 +163,10 @@ def getDrive(drive=None, gauth=None):
# Save the current credentials to a file # Save the current credentials to a file
return GoogleDrive(gauth) return GoogleDrive(gauth)
if drive.auth.access_token_expired: if drive.auth.access_token_expired:
drive.auth.Refresh() try:
drive.auth.Refresh()
except RefreshError as e:
web.app.logger.error("Google Drive error: " + e.message)
return drive return drive
def listRootFolders(): def listRootFolders():
@ -165,7 +184,7 @@ def getFolderInFolder(parentId, folderName, drive):
# drive = getDrive(drive) # drive = getDrive(drive)
query="" query=""
if folderName: 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'" \ folder = query + "'%s' in parents and mimeType = 'application/vnd.google-apps.folder'" \
" and trashed = false" % parentId " and trashed = false" % parentId
fileList = drive.ListFile({'q': folder}).GetList() fileList = drive.ListFile({'q': folder}).GetList()
@ -192,7 +211,7 @@ def getEbooksFolderId(drive=None):
def getFile(pathId, fileName, drive): 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() fileList = drive.ListFile({'q': metaDataFile}).GetList()
if fileList.__len__() == 0: if fileList.__len__() == 0:
return None return None
@ -227,7 +246,7 @@ def getFolderId(path, drive):
dbChange = True dbChange = True
currentFolderId = currentFolder['id'] currentFolderId = currentFolder['id']
else: else:
currentFolderId= None currentFolderId = None
break break
if dbChange: if dbChange:
session.commit() session.commit()
@ -249,16 +268,9 @@ def getFileFromEbooksFolder(path, fileName):
return None return None
'''def copyDriveFileRemote(drive, origin_file_id, copy_title): def moveGdriveFileRemote(origin_file_id, new_title):
drive = getDrive(drive) origin_file_id['title']= new_title
copied_file = {'title': copy_title} origin_file_id.Upload()
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'''
# Download metadata.db from gdrive # Download metadata.db from gdrive
@ -270,9 +282,10 @@ def downloadFile(path, filename, output):
def moveGdriveFolderRemote(origin_file, target_folder): def moveGdriveFolderRemote(origin_file, target_folder):
drive = getDrive(Gdrive.Instance().drive) drive = getDrive(Gdrive.Instance().drive)
previous_parents = ",".join([parent["id"] for parent in origin_file.get('parents')]) 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) gFileTargetDir = getFileFromEbooksFolder(None, target_folder)
if not gFileTargetDir: if not gFileTargetDir:
# Folder is not exisiting, create, and move folder # Folder is not existing, create, and move folder
gFileTargetDir = drive.CreateFile( gFileTargetDir = drive.CreateFile(
{'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}], {'title': target_folder, 'parents': [{"kind": "drive#fileLink", 'id': getEbooksFolderId()}],
"mimeType": "application/vnd.google-apps.folder"}) "mimeType": "application/vnd.google-apps.folder"})
@ -282,13 +295,10 @@ def moveGdriveFolderRemote(origin_file, target_folder):
addParents=gFileTargetDir['id'], addParents=gFileTargetDir['id'],
removeParents=previous_parents, removeParents=previous_parents,
fields='id, parents').execute() fields='id, parents').execute()
# if previous_parents has no childs anymore, delete originfileparent # if previous_parents has no childs anymore, delete original fileparent
# is not working correctly, because of slow update on gdrive -> could cause trouble in gdrive.db if len(children['items']) == 1:
# (nonexisting folder has id) deleteDatabaseEntry(previous_parents)
# children = drive.auth.service.children().list(folderId=previous_parents).execute() drive.auth.service.files().delete(fileId=previous_parents).execute()
# if not len(children['items']):
# drive.auth.service.files().delete(fileId=previous_parents).execute()
def copyToDrive(drive, uploadFile, createRoot, replaceFiles, def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
@ -301,7 +311,7 @@ def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
parent = getEbooksFolder(drive) parent = getEbooksFolder(drive)
if os.path.isdir(os.path.join(prevDir,uploadFile)): if os.path.isdir(os.path.join(prevDir,uploadFile)):
existingFolder = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" % 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): if len(existingFolder) == 0 and (not isInitial or createRoot):
parent = drive.CreateFile({'title': os.path.basename(uploadFile), parent = drive.CreateFile({'title': os.path.basename(uploadFile),
'parents': [{"kind": "drive#fileLink", 'id': parent['id']}], 'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],
@ -316,11 +326,11 @@ def copyToDrive(drive, uploadFile, createRoot, replaceFiles,
else: else:
if os.path.basename(uploadFile) not in ignoreFiles: if os.path.basename(uploadFile) not in ignoreFiles:
existingFiles = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" % 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: if len(existingFiles) > 0:
driveFile = existingFiles[0] driveFile = existingFiles[0]
else: 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']}], }) 'parents': [{"kind":"drive#fileLink", 'id': parent['id']}], })
driveFile.SetContentFile(os.path.join(prevDir, uploadFile)) driveFile.SetContentFile(os.path.join(prevDir, uploadFile))
driveFile.Upload() driveFile.Upload()
@ -333,7 +343,7 @@ def uploadFileToEbooksFolder(destFile, f):
for i, x in enumerate(splitDir): for i, x in enumerate(splitDir):
if i == len(splitDir)-1: if i == len(splitDir)-1:
existingFiles = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" % 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: if len(existingFiles) > 0:
driveFile = existingFiles[0] driveFile = existingFiles[0]
else: else:
@ -342,7 +352,7 @@ def uploadFileToEbooksFolder(destFile, f):
driveFile.Upload() driveFile.Upload()
else: else:
existingFolder = drive.ListFile({'q': "title = '%s' and '%s' in parents and trashed = false" % 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: if len(existingFolder) == 0:
parent = drive.CreateFile({'title': x, 'parents': [{"kind": "drive#fileLink", 'id': parent['id']}], parent = drive.CreateFile({'title': x, 'parents': [{"kind": "drive#fileLink", 'id': parent['id']}],
"mimeType": "application/vnd.google-apps.folder"}) "mimeType": "application/vnd.google-apps.folder"})
@ -435,6 +445,10 @@ def getChangeById (drive, change_id):
except (errors.HttpError) as error: except (errors.HttpError) as error:
web.app.logger.info(error.message) web.app.logger.info(error.message)
return None 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 # Deletes the local hashes database to force search for new folder names
def deleteDatabaseOnChange(): def deleteDatabaseOnChange():
@ -449,9 +463,10 @@ def updateGdriveCalibreFromLocal():
# update gdrive.db on edit of books title # update gdrive.db on edit of books title
def updateDatabaseOnEdit(ID,newPath): def updateDatabaseOnEdit(ID,newPath):
sqlCheckPath = newPath if newPath[-1] == '/' else newPath + u'/'
storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first() storedPathName = session.query(GdriveId).filter(GdriveId.gdrive_id == ID).first()
if storedPathName: if storedPathName:
storedPathName.path = newPath storedPathName.path = sqlCheckPath
session.commit() session.commit()

View File

@ -1,16 +1,33 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- 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 db
import ub import ub
from flask import current_app as app from flask import current_app as app
import logging
from tempfile import gettempdir from tempfile import gettempdir
import sys import sys
import io
import os import os
import re import re
import unicodedata import unicodedata
from io import BytesIO
import worker import worker
import time import time
from flask import send_from_directory, make_response, redirect, abort 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 flask_login import current_user
from babel.dates import format_datetime from babel.dates import format_datetime
from datetime import datetime from datetime import datetime
import threading
import shutil import shutil
import requests import requests
import zipfile
try: try:
import gdriveutils as gd import gdriveutils as gd
except ImportError: except ImportError:
pass pass
import web import web
import server
import random import random
import subprocess import subprocess
@ -37,8 +51,14 @@ try:
except ImportError: except ImportError:
use_unidecode = False use_unidecode = False
try:
from PIL import Image
use_PIL = True
except ImportError:
use_PIL = False
# Global variables # Global variables
updater_thread = None # updater_thread = None
global_WorkerThread = worker.WorkerThread() global_WorkerThread = worker.WorkerThread()
global_WorkerThread.start() 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 += "Sincerely\r\n\r\n"
text += "Your Calibre-Web team" text += "Your Calibre-Web team"
global_WorkerThread.add_email(_(u'Get Started with Calibre-Web'),None, None, ub.get_mail_settings(), 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 return
def check_send_to_kindle(entry): 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')}) bookformats.append({'format':'Pdf','convert':0,'text':_('Send %(format)s to Kindle',format='Pdf')})
if 'AZW' in ele.format: if 'AZW' in ele.format:
bookformats.append({'format':'Azw','convert':0,'text':_('Send %(format)s to Kindle',format='Azw')}) bookformats.append({'format':'Azw','convert':0,'text':_('Send %(format)s to Kindle',format='Azw')})
if 'AZW3' in ele.format: '''if 'AZW3' in ele.format:
bookformats.append({'format':'Azw3','convert':0,'text':_('Send %(format)s to Kindle',format='Azw3')}) bookformats.append({'format':'Azw3','convert':0,'text':_('Send %(format)s to Kindle',format='Azw3')})'''
else: else:
formats = list() formats = list()
for ele in iter(entry.data): 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')}) bookformats.append({'format': 'Mobi','convert':0,'text':_('Send %(format)s to Kindle',format='Mobi')})
if 'AZW' in formats: if 'AZW' in formats:
bookformats.append({'format': 'Azw','convert':0,'text':_('Send %(format)s to Kindle',format='Azw')}) 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: if 'PDF' in formats:
bookformats.append({'format': 'Pdf','convert':0,'text':_('Send %(format)s to Kindle',format='Pdf')}) bookformats.append({'format': 'Pdf','convert':0,'text':_('Send %(format)s to Kindle',format='Pdf')})
if ub.config.config_ebookconverter >= 1: if ub.config.config_ebookconverter >= 1:
if 'EPUB' in formats and not 'MOBI' in formats: if 'EPUB' in formats and not 'MOBI' in formats:
bookformats.append({'format': 'Mobi','convert':1, bookformats.append({'format': 'Mobi','convert':1,
'text':_('Convert %(orig)s to %(format)s and send to Kindle',orig='Epub',format='Mobi')}) '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: if 'EPUB' in formats and not 'AZW3' in formats:
bookformats.append({'format': 'Azw3','convert':1, 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 return bookformats
else: else:
app.logger.error(u'Cannot find book entry %d', entry.id) 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 # Check if a reader is existing for any of the book formats, if not, return empty list, otherwise return
# list with supported formats # list with supported formats
def check_read_formats(entry): 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() bookformats = list()
if len(entry.data): if len(entry.data):
for ele in iter(entry.data): for ele in iter(entry.data):
@ -217,7 +235,10 @@ def get_valid_filename(value, replace_whitespace=True):
value = value[:128] value = value[:128]
if not value: if not value:
raise ValueError("Filename cannot be empty") raise ValueError("Filename cannot be empty")
return value if sys.version_info.major == 3:
return value
else:
return value.decode('utf-8')
def get_sorted_author(value): 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 # Rename all files from old names to new names
if authordir != new_authordir or titledir != new_titledir: if authordir != new_authordir or titledir != new_titledir:
try: try:
for format in localbook.data: new_name = get_valid_filename(localbook.title) + ' - ' + get_valid_filename(new_authordir)
path_name = os.path.join(calibrepath, new_authordir, os.path.basename(path)) path_name = os.path.join(calibrepath, new_authordir, os.path.basename(path))
new_name = get_valid_filename(localbook.title) + ' - ' + get_valid_filename(new_authordir) for file_format in localbook.data:
os.renames(os.path.join(path_name, format.name + '.' + format.format.lower()), os.renames(os.path.join(path_name, file_format.name + '.' + file_format.format.lower()),
os.path.join(path_name,new_name + '.' + format.format.lower())) os.path.join(path_name, new_name + '.' + file_format.format.lower()))
format.name = new_name file_format.name = new_name
except OSError as ex: except OSError as ex:
web.app.logger.error("Rename file in path " + path + " to " + new_name + ": " + str(ex)) web.app.logger.error("Rename file in path " + path + " to " + new_name + ": " + str(ex))
web.app.logger.debug(ex, exc_info=True) 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): def update_dir_structure_gdrive(book_id, first_author):
error = False error = False
book = db.session.query(db.Books).filter(db.Books.id == book_id).first() book = db.session.query(db.Books).filter(db.Books.id == book_id).first()
path = book.path
authordir = book.path.split('/')[0] authordir = book.path.split('/')[0]
if first_author: if first_author:
@ -330,40 +352,39 @@ def update_dir_structure_gdrive(book_id, first_author):
else: else:
new_authordir = get_valid_filename(book.authors[0].name) new_authordir = get_valid_filename(book.authors[0].name)
titledir = book.path.split('/')[1] 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: if titledir != new_titledir:
gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path), titledir) gFile = gd.getFileFromEbooksFolder(os.path.dirname(book.path), titledir)
if gFile: if gFile:
gFile['title'] = new_titledir gFile['title'] = new_titledir
gFile.Upload() 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 gd.updateDatabaseOnEdit(gFile['id'], book.path) # only child folder affected
else: else:
error = _(u'File %(file)s not found on Google Drive', file=book.path) # file not found error = _(u'File %(file)s not found on Google Drive', file=book.path) # file not found
if authordir != new_authordir: 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: if gFile:
gd.moveGdriveFolderRemote(gFile,new_authordir) gd.moveGdriveFolderRemote(gFile, new_authordir)
book.path = new_authordir + '/' + book.path.split('/')[1] book.path = new_authordir + u'/' + book.path.split('/')[1]
path = book.path
gd.updateDatabaseOnEdit(gFile['id'], book.path) gd.updateDatabaseOnEdit(gFile['id'], book.path)
else: else:
error = _(u'File %(file)s not found on Google Drive', file=authordir) # file not found error = _(u'File %(file)s not found on Google Drive', file=authordir) # file not found
# Rename all files from old names to new names # 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: if authordir != new_authordir or titledir != new_titledir:
for format in book.data: new_name = get_valid_filename(book.title) + u' - ' + get_valid_filename(new_authordir)
# path_name = os.path.join(calibrepath, new_authordir, os.path.basename(path)) for file_format in book.data:
new_name = get_valid_filename(book.title) + ' - ' + get_valid_filename(book) gFile = gd.getFileFromEbooksFolder(path, file_format.name + u'.' + file_format.format.lower())
format.name = new_name if not gFile:
if gFile: error = _(u'File %(file)s not found on Google Drive', file=file_format.name) # file not found
pass break
else: gd.moveGdriveFileRemote(gFile, new_name + u'.' + file_format.format.lower())
error = _(u'File %(file)s not found on Google Drive', file=format.name) # file not found file_format.name = new_name
break'''
return error return error
@ -409,6 +430,8 @@ def delete_book(book, calibrepath, book_format):
def get_book_cover(cover_path): def get_book_cover(cover_path):
if ub.config.config_use_google_drive: if ub.config.config_use_google_drive:
try: 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) path=gd.get_cover_via_gdrive(cover_path)
if path: if path:
return redirect(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') 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") return send_from_directory(os.path.join(os.path.dirname(__file__), "static"), "generic_cover.jpg")
except Exception as e: 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) web.app.logger.exception(e)
# traceback.print_exc() # traceback.print_exc()
return send_from_directory(os.path.join(os.path.dirname(__file__), "static"),"generic_cover.jpg") 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") return send_from_directory(os.path.join(ub.config.config_calibre_dir, cover_path), "cover.jpg")
# saves book cover to gdrive or locally # saves book cover from url
def save_cover(url, book_path): def save_cover_from_url(url, book_path):
img = requests.get(url) img = requests.get(url)
if img.headers.get('content-type') != 'image/jpeg': return save_cover(img, book_path)
web.app.logger.error("Cover is no jpg file, can't save")
return False
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: if ub.config.config_use_google_drive:
tmpDir = gettempdir() tmpDir = gettempdir()
f = open(os.path.join(tmpDir, "uploaded_cover.jpg"), "wb") if save_cover_from_filestorage(tmpDir, "uploaded_cover.jpg", img) is True:
f.write(img.content) gd.uploadFileToEbooksFolder(os.path.join(book_path, 'cover.jpg'),
f.close() os.path.join(tmpDir, "uploaded_cover.jpg"))
gd.uploadFileToEbooksFolder(os.path.join(book_path, 'cover.jpg'), os.path.join(tmpDir, f.name)) web.app.logger.info("Cover is saved on Google Drive")
web.app.logger.info("Cover is saved on Google Drive") return True
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): 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): def check_unrar(unrarLocation):
error = False error = False
@ -654,26 +560,6 @@ def check_unrar(unrarLocation):
return (error, version) 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): def json_serial(obj):
"""JSON serializer for objects not serializable by default json code""" """JSON serializer for objects not serializable by default json code"""
@ -682,17 +568,13 @@ def json_serial(obj):
return obj.isoformat() return obj.isoformat()
raise TypeError ("Type %s not serializable" % type(obj)) raise TypeError ("Type %s not serializable" % type(obj))
# helper function to apply localize status information in tasklist entries
def render_task_status(tasklist): def render_task_status(tasklist):
#helper function to apply localize status information in tasklist entries
renderedtasklist=list() renderedtasklist=list()
# task2 = task
for task in tasklist: for task in tasklist:
if task['user'] == current_user.nickname or current_user.role_admin(): if task['user'] == current_user.nickname or current_user.role_admin():
# task2 = copy.deepcopy(task) # = task
if task['formStarttime']: if task['formStarttime']:
task['starttime'] = format_datetime(task['formStarttime'], format='short', locale=web.get_locale()) task['starttime'] = format_datetime(task['formStarttime'], format='short', locale=web.get_locale())
# task2['formStarttime'] = ""
else: else:
if 'starttime' not in task: if 'starttime' not in task:
task['starttime'] = "" task['starttime'] = ""

43
cps/isoLanguages.py Normal file
View 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))

View File

@ -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/ # http://flask.pocoo.org/snippets/62/
try: try:

View File

@ -1,6 +1,42 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- 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): class ReverseProxied(object):
"""Wrap the application in this middleware and configure the """Wrap the application in this middleware and configure the
front-end server to add these headers, to let you quietly bind 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', '') scheme = environ.get('HTTP_X_SCHEME', '')
if scheme: if scheme:
environ['wsgi.url_scheme'] = scheme environ['wsgi.url_scheme'] = scheme
servr = environ.get('HTTP_X_FORWARDED_SERVER', '') servr = environ.get('HTTP_X_FORWARDED_HOST', '')
if servr: if servr:
environ['HTTP_HOST'] = servr environ['HTTP_HOST'] = servr
return self.app(environ, start_response) return self.app(environ, start_response)

View File

@ -1,6 +1,23 @@
#!/usr/bin/env python #!/usr/bin/env python
# -*- coding: utf-8 -*- # -*- 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 from socket import error as SocketError
import sys import sys
import os import os
@ -20,6 +37,7 @@ except ImportError:
gevent_present = False gevent_present = False
class server: class server:
wsgiserver = None 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) self.wsgiserver= WSGIServer(('0.0.0.0', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args)
else: else:
self.wsgiserver = WSGIServer(('', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args) self.wsgiserver = WSGIServer(('', web.ub.config.config_port), web.app, spawn=Pool(), **ssl_args)
web.py3_gevent_link = self.wsgiserver
self.wsgiserver.serve_forever() self.wsgiserver.serve_forever()
except SocketError: except SocketError:
try: try:
web.app.logger.info('Unable to listen on \'\', trying on IPv4 only...') 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) 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() self.wsgiserver.serve_forever()
except (OSError, SocketError) as e: except (OSError, SocketError) as e:
web.app.logger.info("Error starting server: %s" % e.strerror) web.app.logger.info("Error starting server: %s" % e.strerror)
@ -82,6 +103,7 @@ class server:
ssl_options=ssl) ssl_options=ssl)
http_server.listen(web.ub.config.config_port) http_server.listen(web.ub.config.config_port)
self.wsgiserver=IOLoop.instance() self.wsgiserver=IOLoop.instance()
web.py3_gevent_link = self.wsgiserver
self.wsgiserver.start() self.wsgiserver.start()
# wait for stop signal # wait for stop signal
self.wsgiserver.close(True) self.wsgiserver.close(True)
@ -91,6 +113,9 @@ class server:
web.helper.global_WorkerThread.stop() web.helper.global_WorkerThread.stop()
sys.exit(1) 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: if self.restart == True:
web.app.logger.info("Performing restart of Calibre-Web") web.app.logger.info("Performing restart of Calibre-Web")
web.helper.global_WorkerThread.stop() web.helper.global_WorkerThread.stop()
@ -107,12 +132,21 @@ class server:
sys.exit(0) sys.exit(0)
def setRestartTyp(self,starttyp): 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): def killServer(self, signum, frame):
self.stopServer() self.stopServer()
def stopServer(self): 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 self.wsgiserver:
if gevent_present: if gevent_present:
self.wsgiserver.close() self.wsgiserver.close()

File diff suppressed because one or more lines are too long

View File

@ -152,11 +152,11 @@ body {
max-width: 70%; max-width: 70%;
} }
#prev { #left {
left: 40px; left: 40px;
} }
#next { #right {
right: 40px; right: 40px;
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

Before

Width:  |  Height:  |  Size: 415 B

After

Width:  |  Height:  |  Size: 415 B

View File

Before

Width:  |  Height:  |  Size: 883 B

After

Width:  |  Height:  |  Size: 883 B

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 408 B

After

Width:  |  Height:  |  Size: 408 B

View File

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

Before

Width:  |  Height:  |  Size: 426 B

After

Width:  |  Height:  |  Size: 426 B

View File

Before

Width:  |  Height:  |  Size: 158 B

After

Width:  |  Height:  |  Size: 158 B

View File

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 199 B

After

Width:  |  Height:  |  Size: 199 B

View File

Before

Width:  |  Height:  |  Size: 304 B

After

Width:  |  Height:  |  Size: 304 B

View File

Before

Width:  |  Height:  |  Size: 193 B

After

Width:  |  Height:  |  Size: 193 B

View File

Before

Width:  |  Height:  |  Size: 296 B

After

Width:  |  Height:  |  Size: 296 B

View File

Before

Width:  |  Height:  |  Size: 193 B

After

Width:  |  Height:  |  Size: 193 B

View File

Before

Width:  |  Height:  |  Size: 296 B

After

Width:  |  Height:  |  Size: 296 B

View File

Before

Width:  |  Height:  |  Size: 199 B

After

Width:  |  Height:  |  Size: 199 B

View File

Before

Width:  |  Height:  |  Size: 304 B

After

Width:  |  Height:  |  Size: 304 B

View File

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 326 B

View File

Before

Width:  |  Height:  |  Size: 326 B

After

Width:  |  Height:  |  Size: 326 B

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

Before

Width:  |  Height:  |  Size: 179 B

After

Width:  |  Height:  |  Size: 179 B

View File

Before

Width:  |  Height:  |  Size: 266 B

After

Width:  |  Height:  |  Size: 266 B

View File

Before

Width:  |  Height:  |  Size: 301 B

After

Width:  |  Height:  |  Size: 301 B

View File

Before

Width:  |  Height:  |  Size: 583 B

After

Width:  |  Height:  |  Size: 583 B

View File

Before

Width:  |  Height:  |  Size: 175 B

After

Width:  |  Height:  |  Size: 175 B

View File

Before

Width:  |  Height:  |  Size: 276 B

After

Width:  |  Height:  |  Size: 276 B

View File

Before

Width:  |  Height:  |  Size: 360 B

After

Width:  |  Height:  |  Size: 360 B

View File

Before

Width:  |  Height:  |  Size: 731 B

After

Width:  |  Height:  |  Size: 731 B

View File

Before

Width:  |  Height:  |  Size: 359 B

After

Width:  |  Height:  |  Size: 359 B

View File

Before

Width:  |  Height:  |  Size: 714 B

After

Width:  |  Height:  |  Size: 714 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 461 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 344 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 B

View File

Before

Width:  |  Height:  |  Size: 290 B

After

Width:  |  Height:  |  Size: 290 B

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 174 B

After

Width:  |  Height:  |  Size: 174 B

View File

Before

Width:  |  Height:  |  Size: 260 B

After

Width:  |  Height:  |  Size: 260 B

View File

Before

Width:  |  Height:  |  Size: 259 B

After

Width:  |  Height:  |  Size: 259 B

View File

Before

Width:  |  Height:  |  Size: 425 B

After

Width:  |  Height:  |  Size: 425 B

View File

Before

Width:  |  Height:  |  Size: 107 B

After

Width:  |  Height:  |  Size: 107 B

View File

Before

Width:  |  Height:  |  Size: 152 B

After

Width:  |  Height:  |  Size: 152 B

View File

Before

Width:  |  Height:  |  Size: 295 B

After

Width:  |  Height:  |  Size: 295 B

View File

Before

Width:  |  Height:  |  Size: 550 B

After

Width:  |  Height:  |  Size: 550 B

View File

Before

Width:  |  Height:  |  Size: 242 B

After

Width:  |  Height:  |  Size: 242 B

View File

Before

Width:  |  Height:  |  Size: 398 B

After

Width:  |  Height:  |  Size: 398 B

View File

Before

Width:  |  Height:  |  Size: 238 B

After

Width:  |  Height:  |  Size: 238 B

View File

Before

Width:  |  Height:  |  Size: 396 B

After

Width:  |  Height:  |  Size: 396 B

View File

Before

Width:  |  Height:  |  Size: 245 B

After

Width:  |  Height:  |  Size: 245 B

View File

Before

Width:  |  Height:  |  Size: 405 B

After

Width:  |  Height:  |  Size: 405 B

View File

Before

Width:  |  Height:  |  Size: 246 B

After

Width:  |  Height:  |  Size: 246 B

View File

Before

Width:  |  Height:  |  Size: 403 B

After

Width:  |  Height:  |  Size: 403 B

View File

Before

Width:  |  Height:  |  Size: 321 B

After

Width:  |  Height:  |  Size: 321 B

View File

Before

Width:  |  Height:  |  Size: 586 B

After

Width:  |  Height:  |  Size: 586 B

View File

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 257 B

View File

Before

Width:  |  Height:  |  Size: 464 B

After

Width:  |  Height:  |  Size: 464 B

View File

Before

Width:  |  Height:  |  Size: 309 B

After

Width:  |  Height:  |  Size: 309 B

View File

Before

Width:  |  Height:  |  Size: 653 B

After

Width:  |  Height:  |  Size: 653 B

View File

Before

Width:  |  Height:  |  Size: 243 B

After

Width:  |  Height:  |  Size: 243 B

View File

Before

Width:  |  Height:  |  Size: 225 B

After

Width:  |  Height:  |  Size: 225 B

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