mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-09 03:04:10 -04:00
Wire up Open With
This commit is contained in:
parent
d96c632028
commit
1451573bc8
@ -6,6 +6,7 @@ __copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
|
|||||||
__docformat__ = 'restructuredtext en'
|
__docformat__ = 'restructuredtext en'
|
||||||
|
|
||||||
from binascii import unhexlify
|
from binascii import unhexlify
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
from PyQt5.Qt import (QPixmap, QSize, QWidget, Qt, pyqtSignal, QUrl, QIcon,
|
from PyQt5.Qt import (QPixmap, QSize, QWidget, Qt, pyqtSignal, QUrl, QIcon,
|
||||||
QPropertyAnimation, QEasingCurve, QApplication, QFontInfo, QAction,
|
QPropertyAnimation, QEasingCurve, QApplication, QFontInfo, QAction,
|
||||||
@ -107,6 +108,7 @@ class CoverView(QWidget): # {{{
|
|||||||
|
|
||||||
cover_changed = pyqtSignal(object, object)
|
cover_changed = pyqtSignal(object, object)
|
||||||
cover_removed = pyqtSignal(object)
|
cover_removed = pyqtSignal(object)
|
||||||
|
open_cover_with = pyqtSignal(object, object)
|
||||||
|
|
||||||
def __init__(self, vertical, parent=None):
|
def __init__(self, vertical, parent=None):
|
||||||
QWidget.__init__(self, parent)
|
QWidget.__init__(self, parent)
|
||||||
@ -203,6 +205,7 @@ class CoverView(QWidget): # {{{
|
|||||||
)
|
)
|
||||||
|
|
||||||
def contextMenuEvent(self, ev):
|
def contextMenuEvent(self, ev):
|
||||||
|
from calibre.gui2.open_with import populate_menu
|
||||||
cm = QMenu(self)
|
cm = QMenu(self)
|
||||||
paste = cm.addAction(_('Paste Cover'))
|
paste = cm.addAction(_('Paste Cover'))
|
||||||
copy = cm.addAction(_('Copy Cover'))
|
copy = cm.addAction(_('Copy Cover'))
|
||||||
@ -214,8 +217,28 @@ class CoverView(QWidget): # {{{
|
|||||||
paste.triggered.connect(self.paste_from_clipboard)
|
paste.triggered.connect(self.paste_from_clipboard)
|
||||||
remove.triggered.connect(self.remove_cover)
|
remove.triggered.connect(self.remove_cover)
|
||||||
gc.triggered.connect(self.generate_cover)
|
gc.triggered.connect(self.generate_cover)
|
||||||
|
|
||||||
|
m = QMenu(_('Open with...'))
|
||||||
|
populate_menu(m, self.open_with, 'jpeg')
|
||||||
|
if len(m.actions()) == 0:
|
||||||
|
cm.addAction(_('Open with...'), self.choose_open_with)
|
||||||
|
else:
|
||||||
|
m.addSeparator()
|
||||||
|
m.addAction(_('Choose other program...'), self.choose_open_with)
|
||||||
|
cm.addMenu(m)
|
||||||
cm.exec_(ev.globalPos())
|
cm.exec_(ev.globalPos())
|
||||||
|
|
||||||
|
def open_with(self, entry):
|
||||||
|
id_ = self.data.get('id', None)
|
||||||
|
if id_ is not None:
|
||||||
|
self.open_cover_with.emit(id_, entry)
|
||||||
|
|
||||||
|
def choose_open_with(self):
|
||||||
|
from calibre.gui2.open_with import choose_program
|
||||||
|
entry = choose_program('jpeg', self)
|
||||||
|
if entry is not None:
|
||||||
|
self.open_with(entry)
|
||||||
|
|
||||||
def copy_to_clipboard(self):
|
def copy_to_clipboard(self):
|
||||||
QApplication.instance().clipboard().setPixmap(self.pixmap)
|
QApplication.instance().clipboard().setPixmap(self.pixmap)
|
||||||
|
|
||||||
@ -286,6 +309,7 @@ class BookInfo(QWebView):
|
|||||||
compare_format = pyqtSignal(int, object)
|
compare_format = pyqtSignal(int, object)
|
||||||
copy_link = pyqtSignal(object)
|
copy_link = pyqtSignal(object)
|
||||||
manage_author = pyqtSignal(object)
|
manage_author = pyqtSignal(object)
|
||||||
|
open_fmt_with = pyqtSignal(int, object, object)
|
||||||
|
|
||||||
def __init__(self, vertical, parent=None):
|
def __init__(self, vertical, parent=None):
|
||||||
QWebView.__init__(self, parent)
|
QWebView.__init__(self, parent)
|
||||||
@ -405,7 +429,16 @@ class BookInfo(QWebView):
|
|||||||
ac.current_fmt = (book_id, fmt)
|
ac.current_fmt = (book_id, fmt)
|
||||||
ac.setText(t)
|
ac.setText(t)
|
||||||
menu.addAction(ac)
|
menu.addAction(ac)
|
||||||
|
if not fmt.upper().startswith('ORIGINAL_'):
|
||||||
|
from calibre.gui2.open_with import populate_menu
|
||||||
|
m = QMenu(_('Open with...'))
|
||||||
|
populate_menu(m, partial(self.open_with, book_id, fmt), fmt)
|
||||||
|
if len(m.actions()) == 0:
|
||||||
|
menu.addAction(_('Open with...'), partial(self.choose_open_with, book_id, fmt))
|
||||||
|
else:
|
||||||
|
m.addSeparator()
|
||||||
|
m.addAction(_('Choose other program...'), partial(self.choose_open_with, book_id, fmt))
|
||||||
|
menu.addMenu(m)
|
||||||
else:
|
else:
|
||||||
el = r.linkElement()
|
el = r.linkElement()
|
||||||
author = el.toPlainText() if unicode(el.attribute('calibre-data')) == u'authors' else None
|
author = el.toPlainText() if unicode(el.attribute('calibre-data')) == u'authors' else None
|
||||||
@ -425,6 +458,15 @@ class BookInfo(QWebView):
|
|||||||
if len(menu.actions()) > 0:
|
if len(menu.actions()) > 0:
|
||||||
menu.exec_(ev.globalPos())
|
menu.exec_(ev.globalPos())
|
||||||
|
|
||||||
|
def open_with(self, book_id, fmt, entry):
|
||||||
|
self.open_fmt_with.emit(book_id, fmt, entry)
|
||||||
|
|
||||||
|
def choose_open_with(self, book_id, fmt):
|
||||||
|
from calibre.gui2.open_with import choose_program
|
||||||
|
entry = choose_program(fmt, self)
|
||||||
|
if entry is not None:
|
||||||
|
self.open_with(book_id, fmt, entry)
|
||||||
|
|
||||||
|
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
@ -531,9 +573,11 @@ class BookDetails(QWidget): # {{{
|
|||||||
remote_file_dropped = pyqtSignal(object, object)
|
remote_file_dropped = pyqtSignal(object, object)
|
||||||
files_dropped = pyqtSignal(object, object)
|
files_dropped = pyqtSignal(object, object)
|
||||||
cover_changed = pyqtSignal(object, object)
|
cover_changed = pyqtSignal(object, object)
|
||||||
|
open_cover_with = pyqtSignal(object, object)
|
||||||
cover_removed = pyqtSignal(object)
|
cover_removed = pyqtSignal(object)
|
||||||
view_device_book = pyqtSignal(object)
|
view_device_book = pyqtSignal(object)
|
||||||
manage_author = pyqtSignal(object)
|
manage_author = pyqtSignal(object)
|
||||||
|
open_fmt_with = pyqtSignal(int, object, object)
|
||||||
|
|
||||||
# Drag 'n drop {{{
|
# Drag 'n drop {{{
|
||||||
DROPABBLE_EXTENSIONS = IMAGE_EXTENSIONS+BOOK_EXTENSIONS
|
DROPABBLE_EXTENSIONS = IMAGE_EXTENSIONS+BOOK_EXTENSIONS
|
||||||
@ -590,12 +634,14 @@ class BookDetails(QWidget): # {{{
|
|||||||
|
|
||||||
self.cover_view = CoverView(vertical, self)
|
self.cover_view = CoverView(vertical, self)
|
||||||
self.cover_view.cover_changed.connect(self.cover_changed.emit)
|
self.cover_view.cover_changed.connect(self.cover_changed.emit)
|
||||||
|
self.cover_view.open_cover_with.connect(self.open_cover_with.emit)
|
||||||
self.cover_view.cover_removed.connect(self.cover_removed.emit)
|
self.cover_view.cover_removed.connect(self.cover_removed.emit)
|
||||||
self._layout.addWidget(self.cover_view)
|
self._layout.addWidget(self.cover_view)
|
||||||
self.book_info = BookInfo(vertical, self)
|
self.book_info = BookInfo(vertical, self)
|
||||||
self._layout.addWidget(self.book_info)
|
self._layout.addWidget(self.book_info)
|
||||||
self.book_info.link_clicked.connect(self.handle_click)
|
self.book_info.link_clicked.connect(self.handle_click)
|
||||||
self.book_info.remove_format.connect(self.remove_specific_format)
|
self.book_info.remove_format.connect(self.remove_specific_format)
|
||||||
|
self.book_info.open_fmt_with.connect(self.open_fmt_with)
|
||||||
self.book_info.save_format.connect(self.save_specific_format)
|
self.book_info.save_format.connect(self.save_specific_format)
|
||||||
self.book_info.restore_format.connect(self.restore_specific_format)
|
self.book_info.restore_format.connect(self.restore_specific_format)
|
||||||
self.book_info.compare_format.connect(self.compare_specific_format)
|
self.book_info.compare_format.connect(self.compare_specific_format)
|
||||||
@ -640,4 +686,3 @@ class BookDetails(QWidget): # {{{
|
|||||||
self.show_data(Metadata(_('Unknown')))
|
self.show_data(Metadata(_('Unknown')))
|
||||||
|
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
|
@ -100,7 +100,7 @@ class LibraryViewMixin(object): # {{{
|
|||||||
|
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
class QuickviewSplitter(QSplitter): # {{{
|
class QuickviewSplitter(QSplitter): # {{{
|
||||||
|
|
||||||
def __init__(self, parent=None, orientation=Qt.Vertical, qv_widget=None):
|
def __init__(self, parent=None, orientation=Qt.Vertical, qv_widget=None):
|
||||||
QSplitter.__init__(self, parent=parent, orientation=orientation)
|
QSplitter.__init__(self, parent=parent, orientation=orientation)
|
||||||
@ -489,6 +489,10 @@ class LayoutMixin(object): # {{{
|
|||||||
self.book_details.files_dropped.connect(self.iactions['Add Books'].files_dropped_on_book)
|
self.book_details.files_dropped.connect(self.iactions['Add Books'].files_dropped_on_book)
|
||||||
self.book_details.cover_changed.connect(self.bd_cover_changed,
|
self.book_details.cover_changed.connect(self.bd_cover_changed,
|
||||||
type=Qt.QueuedConnection)
|
type=Qt.QueuedConnection)
|
||||||
|
self.book_details.open_cover_with.connect(self.bd_open_cover_with,
|
||||||
|
type=Qt.QueuedConnection)
|
||||||
|
self.book_details.open_fmt_with.connect(self.bd_open_fmt_with,
|
||||||
|
type=Qt.QueuedConnection)
|
||||||
self.book_details.cover_removed.connect(self.bd_cover_removed,
|
self.book_details.cover_removed.connect(self.bd_cover_removed,
|
||||||
type=Qt.QueuedConnection)
|
type=Qt.QueuedConnection)
|
||||||
self.book_details.remote_file_dropped.connect(
|
self.book_details.remote_file_dropped.connect(
|
||||||
@ -525,6 +529,18 @@ class LayoutMixin(object): # {{{
|
|||||||
if self.cover_flow:
|
if self.cover_flow:
|
||||||
self.cover_flow.dataChanged()
|
self.cover_flow.dataChanged()
|
||||||
|
|
||||||
|
def bd_open_cover_with(self, book_id, entry):
|
||||||
|
cpath = self.current_db.new_api.format_abspath(book_id, '__COVER_INTERNAL__')
|
||||||
|
if cpath:
|
||||||
|
from calibre.gui2.open_with import run_program
|
||||||
|
run_program(entry, cpath, self)
|
||||||
|
|
||||||
|
def bd_open_fmt_with(self, book_id, fmt, entry):
|
||||||
|
path = self.current_db.new_api.format_abspath(book_id, fmt)
|
||||||
|
if path:
|
||||||
|
from calibre.gui2.open_with import run_program
|
||||||
|
run_program(entry, path, self)
|
||||||
|
|
||||||
def bd_cover_removed(self, id_):
|
def bd_cover_removed(self, id_):
|
||||||
self.library_view.model().db.remove_cover(id_, commit=True,
|
self.library_view.model().db.remove_cover(id_, commit=True,
|
||||||
notify=False)
|
notify=False)
|
||||||
@ -570,5 +586,3 @@ class LayoutMixin(object): # {{{
|
|||||||
self.status_bar.update_state(library_total, total, current, selected)
|
self.status_bar.update_state(library_total, total, current, selected)
|
||||||
|
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
|
|
||||||
|
@ -8,18 +8,59 @@ __copyright__ = '2015, Kovid Goyal <kovid at kovidgoyal.net>'
|
|||||||
|
|
||||||
import os, re, shlex, cPickle
|
import os, re, shlex, cPickle
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
|
from threading import Thread
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
from calibre import force_unicode, walk, guess_type, prints
|
from PyQt5.Qt import (
|
||||||
|
QApplication, QStackedLayout, QVBoxLayout, QWidget, QLabel, Qt,
|
||||||
|
QListWidget, QSize, pyqtSignal, QListWidgetItem, QIcon, QByteArray,
|
||||||
|
QBuffer, QPixmap)
|
||||||
|
|
||||||
|
from calibre import force_unicode, walk, guess_type, prints, as_unicode
|
||||||
from calibre.constants import iswindows, isosx, filesystem_encoding, cache_dir
|
from calibre.constants import iswindows, isosx, filesystem_encoding, cache_dir
|
||||||
|
from calibre.gui2 import error_dialog, choose_files
|
||||||
|
from calibre.gui2.widgets2 import Dialog
|
||||||
|
from calibre.gui2.progress_indicator import ProgressIndicator
|
||||||
|
from calibre.utils.config import JSONConfig
|
||||||
from calibre.utils.icu import numeric_sort_key as sort_key
|
from calibre.utils.icu import numeric_sort_key as sort_key
|
||||||
from calibre.utils.localization import canonicalize_lang, get_lang
|
from calibre.utils.localization import canonicalize_lang, get_lang
|
||||||
|
|
||||||
|
DESC_ROLE = Qt.UserRole
|
||||||
|
ENTRY_ROLE = DESC_ROLE + 1
|
||||||
|
|
||||||
|
def pixmap_to_data(pixmap):
|
||||||
|
ba = QByteArray()
|
||||||
|
buf = QBuffer(ba)
|
||||||
|
buf.open(QBuffer.WriteOnly)
|
||||||
|
pixmap.save(buf, 'PNG')
|
||||||
|
return bytearray(ba.data())
|
||||||
|
|
||||||
|
def run_program(entry, path, parent):
|
||||||
|
import subprocess
|
||||||
|
cmdline = entry_to_cmdline(entry, path)
|
||||||
|
print('Running Open With commandline:', repr(cmdline))
|
||||||
|
try:
|
||||||
|
process = subprocess.Popen(cmdline)
|
||||||
|
except Exception as err:
|
||||||
|
return error_dialog(
|
||||||
|
parent, _('Failed to run'), _(
|
||||||
|
'Failed to run program, click "Show Details" for more information'),
|
||||||
|
det_msg='Command line: %r\n%s' %(cmdline, as_unicode(err)))
|
||||||
|
t = Thread(name='WaitProgram', target=process.wait)
|
||||||
|
t.daemon = True
|
||||||
|
t.start()
|
||||||
|
|
||||||
if iswindows:
|
if iswindows:
|
||||||
pass
|
oprefs = JSONConfig('windows_open_with')
|
||||||
|
run_program
|
||||||
|
def run_program(entry, path, parent):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
elif isosx:
|
elif isosx:
|
||||||
pass
|
oprefs = JSONConfig('osx_open_with')
|
||||||
else:
|
else:
|
||||||
# Linux find_programs {{{
|
oprefs = JSONConfig('xdg_open_with')
|
||||||
|
# XDG find_programs {{{
|
||||||
def parse_localized_key(key):
|
def parse_localized_key(key):
|
||||||
name, rest = key.partition('[')[0::2]
|
name, rest = key.partition('[')[0::2]
|
||||||
if not rest:
|
if not rest:
|
||||||
@ -42,6 +83,7 @@ else:
|
|||||||
return
|
return
|
||||||
group = None
|
group = None
|
||||||
ans = {}
|
ans = {}
|
||||||
|
ans['desktop_file_path'] = path
|
||||||
for line in raw.splitlines():
|
for line in raw.splitlines():
|
||||||
m = gpat.match(line)
|
m = gpat.match(line)
|
||||||
if m is not None:
|
if m is not None:
|
||||||
@ -207,25 +249,61 @@ else:
|
|||||||
if comment:
|
if comment:
|
||||||
comment += '\n'
|
comment += '\n'
|
||||||
ans.setToolTip(comment + _('Command line:') + '\n' + (' '.join(entry['Exec'])))
|
ans.setToolTip(comment + _('Command line:') + '\n' + (' '.join(entry['Exec'])))
|
||||||
|
|
||||||
|
def choose_manually(filetype, parent):
|
||||||
|
ans = choose_files(parent, 'choose-open-with-program-manually', _('Choose a program to open %s files') % filetype.upper(), select_only_single_file=True)
|
||||||
|
if ans:
|
||||||
|
ans = ans[0]
|
||||||
|
if not os.access(ans, os.X_OK):
|
||||||
|
return error_dialog(parent, _('Cannot execute'), _(
|
||||||
|
'The program %s is not an executable file') % ans, show=True)
|
||||||
|
return {'Exec':[ans, '%f'], 'Name':os.path.basename(ans)}
|
||||||
|
|
||||||
|
def finalize_entry(entry):
|
||||||
|
icon_path = entry.get('Icon')
|
||||||
|
if icon_path:
|
||||||
|
ic = QIcon(icon_path)
|
||||||
|
if not ic.isNull():
|
||||||
|
pmap = ic.pixmap(48, 48)
|
||||||
|
if not pmap.isNull():
|
||||||
|
entry['icon_data'] = pixmap_to_data(pmap)
|
||||||
|
entry['MimeType'] = tuple(entry['MimeType'])
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def entry_sort_key(entry):
|
||||||
|
return sort_key(entry['Name'])
|
||||||
|
|
||||||
|
def entry_to_icon_text(entry):
|
||||||
|
data = entry.get('icon_data')
|
||||||
|
if data is None:
|
||||||
|
icon = QIcon(I('blank.png'))
|
||||||
|
else:
|
||||||
|
pmap = QPixmap()
|
||||||
|
pmap.loadFromData(bytes(data))
|
||||||
|
icon = QIcon(pmap)
|
||||||
|
return icon, entry['Name']
|
||||||
|
|
||||||
|
def entry_to_cmdline(entry, path):
|
||||||
|
path = os.path.abspath(path)
|
||||||
|
rmap = {
|
||||||
|
'f':path, 'F':path, 'u':'file://'+path, 'U':'file://'+path, '%':'%',
|
||||||
|
'i':entry.get('Icon', '%i'), 'c':entry.get('Name', ''), 'k':entry.get('desktop_file_path', ''),
|
||||||
|
}
|
||||||
|
def replace(match):
|
||||||
|
char = match.group()[-1]
|
||||||
|
repl = rmap.get(char)
|
||||||
|
return match.group() if repl is None else repl
|
||||||
|
sub = re.compile(r'%[fFuUdDnNickvm%]').sub
|
||||||
|
cmd = entry['Exec']
|
||||||
|
return cmd[:1] + [sub(replace, x) for x in cmd[1:]]
|
||||||
|
|
||||||
# }}}
|
# }}}
|
||||||
|
|
||||||
from threading import Thread
|
class ChooseProgram(Dialog): # {{{
|
||||||
|
|
||||||
from PyQt5.Qt import (
|
|
||||||
QApplication, QStackedLayout, QVBoxLayout, QWidget, QLabel, Qt,
|
|
||||||
QListWidget, QSize, pyqtSignal, QListWidgetItem, QIcon)
|
|
||||||
from calibre.gui2 import gprefs, error_dialog
|
|
||||||
from calibre.gui2.widgets2 import Dialog
|
|
||||||
from calibre.gui2.progress_indicator import ProgressIndicator
|
|
||||||
|
|
||||||
DESC_ROLE = Qt.UserRole
|
|
||||||
ENTRY_ROLE = DESC_ROLE + 1
|
|
||||||
|
|
||||||
class ChooseProgram(Dialog):
|
|
||||||
|
|
||||||
found = pyqtSignal()
|
found = pyqtSignal()
|
||||||
|
|
||||||
def __init__(self, file_type='jpeg', parent=None, prefs=gprefs):
|
def __init__(self, file_type='jpeg', parent=None, prefs=oprefs):
|
||||||
self.file_type = file_type
|
self.file_type = file_type
|
||||||
self.programs = self.find_error = self.selected_entry = None
|
self.programs = self.find_error = self.selected_entry = None
|
||||||
self.select_manually = False
|
self.select_manually = False
|
||||||
@ -258,6 +336,8 @@ class ChooseProgram(Dialog):
|
|||||||
l.addWidget(la), l.addWidget(pl)
|
l.addWidget(la), l.addWidget(pl)
|
||||||
la.setBuddy(pl)
|
la.setBuddy(pl)
|
||||||
|
|
||||||
|
b = self.bb.addButton(_('&Browse computer for program'), self.bb.ActionRole)
|
||||||
|
b.clicked.connect(self.manual)
|
||||||
l.addWidget(self.bb)
|
l.addWidget(self.bb)
|
||||||
|
|
||||||
def sizeHint(self):
|
def sizeHint(self):
|
||||||
@ -291,10 +371,36 @@ class ChooseProgram(Dialog):
|
|||||||
self.selected_entry = ci.data(ENTRY_ROLE)
|
self.selected_entry = ci.data(ENTRY_ROLE)
|
||||||
return Dialog.accept(self)
|
return Dialog.accept(self)
|
||||||
|
|
||||||
|
def manual(self):
|
||||||
|
self.select_manually = True
|
||||||
|
self.reject()
|
||||||
|
|
||||||
|
oprefs.defaults['entries'] = {}
|
||||||
|
|
||||||
|
def choose_program(file_type='jpeg', parent=None, prefs=oprefs):
|
||||||
|
d = ChooseProgram(file_type, parent, prefs)
|
||||||
|
d.exec_()
|
||||||
|
entry = choose_manually(file_type, parent) if d.select_manually else d.selected_entry
|
||||||
|
if entry is not None:
|
||||||
|
entry = finalize_entry(entry)
|
||||||
|
entries = oprefs['entries']
|
||||||
|
if file_type not in entries:
|
||||||
|
entries[file_type] = []
|
||||||
|
entries[file_type].append(entry)
|
||||||
|
entries[file_type].sort(key=entry_sort_key)
|
||||||
|
oprefs['entries'] = entries
|
||||||
|
return entry
|
||||||
|
|
||||||
|
def populate_menu(menu, receiver, file_type):
|
||||||
|
for entry in oprefs['entries'].get(file_type, ()):
|
||||||
|
ac = menu.addAction(*entry_to_icon_text(entry))
|
||||||
|
ac.triggered.connect(partial(receiver, entry))
|
||||||
|
return menu
|
||||||
|
|
||||||
|
# }}}
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
from pprint import pprint
|
from pprint import pprint
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
d = ChooseProgram()
|
pprint(choose_program('pdf'))
|
||||||
d.exec_()
|
|
||||||
pprint(d.selected_entry)
|
|
||||||
del app
|
del app
|
||||||
|
Loading…
x
Reference in New Issue
Block a user