mirror of
https://github.com/kovidgoyal/calibre.git
synced 2025-07-08 10:44:09 -04:00
First cut at DB preferences
This commit is contained in:
parent
a3d8ce4850
commit
77ddc1fedf
@ -25,8 +25,8 @@ class SavedSearchEditor(QDialog, Ui_SavedSearchEditor):
|
|||||||
self.current_search_name = None
|
self.current_search_name = None
|
||||||
self.searches = {}
|
self.searches = {}
|
||||||
self.searches_to_delete = []
|
self.searches_to_delete = []
|
||||||
for name in saved_searches.names():
|
for name in saved_searches().names():
|
||||||
self.searches[name] = saved_searches.lookup(name)
|
self.searches[name] = saved_searches().lookup(name)
|
||||||
|
|
||||||
self.populate_search_list()
|
self.populate_search_list()
|
||||||
if initial_search is not None and initial_search in self.searches:
|
if initial_search is not None and initial_search in self.searches:
|
||||||
@ -78,7 +78,7 @@ class SavedSearchEditor(QDialog, Ui_SavedSearchEditor):
|
|||||||
if self.current_search_name:
|
if self.current_search_name:
|
||||||
self.searches[self.current_search_name] = unicode(self.search_text.toPlainText())
|
self.searches[self.current_search_name] = unicode(self.search_text.toPlainText())
|
||||||
for name in self.searches_to_delete:
|
for name in self.searches_to_delete:
|
||||||
saved_searches.delete(name)
|
saved_searches().delete(name)
|
||||||
for name in self.searches:
|
for name in self.searches:
|
||||||
saved_searches.add(name, self.searches[name])
|
saved_searches().add(name, self.searches[name])
|
||||||
QDialog.accept(self)
|
QDialog.accept(self)
|
||||||
|
@ -7,7 +7,6 @@ from PyQt4.QtCore import SIGNAL, Qt
|
|||||||
from PyQt4.QtGui import QDialog, QIcon, QListWidgetItem
|
from PyQt4.QtGui import QDialog, QIcon, QListWidgetItem
|
||||||
|
|
||||||
from calibre.gui2.dialogs.tag_categories_ui import Ui_TagCategories
|
from calibre.gui2.dialogs.tag_categories_ui import Ui_TagCategories
|
||||||
from calibre.utils.config import prefs
|
|
||||||
from calibre.gui2.dialogs.confirm_delete import confirm
|
from calibre.gui2.dialogs.confirm_delete import confirm
|
||||||
from calibre.constants import islinux
|
from calibre.constants import islinux
|
||||||
|
|
||||||
@ -63,7 +62,7 @@ class TagCategories(QDialog, Ui_TagCategories):
|
|||||||
self.all_items.append(t)
|
self.all_items.append(t)
|
||||||
self.all_items_dict[label+':'+n] = t
|
self.all_items_dict[label+':'+n] = t
|
||||||
|
|
||||||
self.categories = dict.copy(prefs['user_categories'])
|
self.categories = dict.copy(db.prefs['user_categories'])
|
||||||
if self.categories is None:
|
if self.categories is None:
|
||||||
self.categories = {}
|
self.categories = {}
|
||||||
for cat in self.categories:
|
for cat in self.categories:
|
||||||
@ -182,7 +181,7 @@ class TagCategories(QDialog, Ui_TagCategories):
|
|||||||
|
|
||||||
def accept(self):
|
def accept(self):
|
||||||
self.save_category()
|
self.save_category()
|
||||||
prefs['user_categories'] = self.categories
|
self.db.prefs['user_categories'] = self.categories
|
||||||
QDialog.accept(self)
|
QDialog.accept(self)
|
||||||
|
|
||||||
def save_category(self):
|
def save_category(self):
|
||||||
|
@ -259,8 +259,7 @@ class SavedSearchBox(QComboBox):
|
|||||||
self.setMinimumContentsLength(10)
|
self.setMinimumContentsLength(10)
|
||||||
self.tool_tip_text = self.toolTip()
|
self.tool_tip_text = self.toolTip()
|
||||||
|
|
||||||
def initialize(self, _saved_searches, _search_box, colorize=False, help_text=_('Search')):
|
def initialize(self, _search_box, colorize=False, help_text=_('Search')):
|
||||||
self.saved_searches = _saved_searches
|
|
||||||
self.search_box = _search_box
|
self.search_box = _search_box
|
||||||
self.help_text = help_text
|
self.help_text = help_text
|
||||||
self.colorize = colorize
|
self.colorize = colorize
|
||||||
@ -302,11 +301,11 @@ class SavedSearchBox(QComboBox):
|
|||||||
self.normalize_state()
|
self.normalize_state()
|
||||||
self.search_box.set_search_string(u'search:"%s"' % qname)
|
self.search_box.set_search_string(u'search:"%s"' % qname)
|
||||||
self.setEditText(qname)
|
self.setEditText(qname)
|
||||||
self.setToolTip(self.saved_searches.lookup(qname))
|
self.setToolTip(saved_searches().lookup(qname))
|
||||||
|
|
||||||
def initialize_saved_search_names(self):
|
def initialize_saved_search_names(self):
|
||||||
self.clear()
|
self.clear()
|
||||||
qnames = self.saved_searches.names()
|
qnames = saved_searches().names()
|
||||||
self.addItems(qnames)
|
self.addItems(qnames)
|
||||||
self.setCurrentIndex(-1)
|
self.setCurrentIndex(-1)
|
||||||
|
|
||||||
@ -319,10 +318,10 @@ class SavedSearchBox(QComboBox):
|
|||||||
idx = self.currentIndex
|
idx = self.currentIndex
|
||||||
if idx < 0:
|
if idx < 0:
|
||||||
return
|
return
|
||||||
ss = self.saved_searches.lookup(unicode(self.currentText()))
|
ss = saved_searches().lookup(unicode(self.currentText()))
|
||||||
if ss is None:
|
if ss is None:
|
||||||
return
|
return
|
||||||
self.saved_searches.delete(unicode(self.currentText()))
|
saved_searches().delete(unicode(self.currentText()))
|
||||||
self.clear_to_help()
|
self.clear_to_help()
|
||||||
self.search_box.clear_to_help()
|
self.search_box.clear_to_help()
|
||||||
self.emit(SIGNAL('changed()'))
|
self.emit(SIGNAL('changed()'))
|
||||||
@ -332,8 +331,8 @@ class SavedSearchBox(QComboBox):
|
|||||||
name = unicode(self.currentText())
|
name = unicode(self.currentText())
|
||||||
if self.help_state or not name.strip():
|
if self.help_state or not name.strip():
|
||||||
name = unicode(self.search_box.text()).replace('"', '')
|
name = unicode(self.search_box.text()).replace('"', '')
|
||||||
self.saved_searches.delete(name)
|
saved_searches().delete(name)
|
||||||
self.saved_searches.add(name, unicode(self.search_box.text()))
|
saved_searches().add(name, unicode(self.search_box.text()))
|
||||||
# now go through an initialization cycle to ensure that the combobox has
|
# now go through an initialization cycle to ensure that the combobox has
|
||||||
# the new search in it, that it is selected, and that the search box
|
# the new search in it, that it is selected, and that the search box
|
||||||
# references the new search instead of the text in the search.
|
# references the new search instead of the text in the search.
|
||||||
@ -348,7 +347,7 @@ class SavedSearchBox(QComboBox):
|
|||||||
idx = self.currentIndex();
|
idx = self.currentIndex();
|
||||||
if idx < 0:
|
if idx < 0:
|
||||||
return
|
return
|
||||||
self.search_box.set_search_string(self.saved_searches.lookup(unicode(self.currentText())))
|
self.search_box.set_search_string(saved_searches().lookup(unicode(self.currentText())))
|
||||||
|
|
||||||
class SearchBoxMixin(object):
|
class SearchBoxMixin(object):
|
||||||
|
|
||||||
@ -390,11 +389,12 @@ class SearchBoxMixin(object):
|
|||||||
|
|
||||||
class SavedSearchBoxMixin(object):
|
class SavedSearchBoxMixin(object):
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, db):
|
||||||
|
self.db = db
|
||||||
self.connect(self.saved_search, SIGNAL('changed()'), self.saved_searches_changed)
|
self.connect(self.saved_search, SIGNAL('changed()'), self.saved_searches_changed)
|
||||||
self.saved_searches_changed()
|
self.saved_searches_changed()
|
||||||
self.connect(self.clear_button, SIGNAL('clicked()'), self.saved_search.clear_to_help)
|
self.connect(self.clear_button, SIGNAL('clicked()'), self.saved_search.clear_to_help)
|
||||||
self.saved_search.initialize(saved_searches, self.search, colorize=True,
|
self.saved_search.initialize(self.search, colorize=True,
|
||||||
help_text=_('Saved Searches'))
|
help_text=_('Saved Searches'))
|
||||||
self.connect(self.save_search_button, SIGNAL('clicked()'),
|
self.connect(self.save_search_button, SIGNAL('clicked()'),
|
||||||
self.saved_search.save_search_button_clicked)
|
self.saved_search.save_search_button_clicked)
|
||||||
@ -409,9 +409,12 @@ class SavedSearchBoxMixin(object):
|
|||||||
b = getattr(self, x+'_search_button')
|
b = getattr(self, x+'_search_button')
|
||||||
b.setStatusTip(b.toolTip())
|
b.setStatusTip(b.toolTip())
|
||||||
|
|
||||||
|
def set_database(self, db):
|
||||||
|
self.db = db
|
||||||
|
self.saved_searches_changed()
|
||||||
|
|
||||||
def saved_searches_changed(self):
|
def saved_searches_changed(self):
|
||||||
p = prefs['saved_searches'].keys()
|
p = saved_searches().names()
|
||||||
p.sort()
|
p.sort()
|
||||||
t = unicode(self.search_restriction.currentText())
|
t = unicode(self.search_restriction.currentText())
|
||||||
self.search_restriction.clear() # rebuild the restrictions combobox using current saved searches
|
self.search_restriction.clear() # rebuild the restrictions combobox using current saved searches
|
||||||
|
@ -224,7 +224,7 @@ class TagsView(QTreeView): # {{{
|
|||||||
|
|
||||||
# Always show the user categories editor
|
# Always show the user categories editor
|
||||||
self.context_menu.addSeparator()
|
self.context_menu.addSeparator()
|
||||||
if category in prefs['user_categories'].keys():
|
if category in self.db.prefs['user_categories'].keys():
|
||||||
self.context_menu.addAction(_('Manage User Categories'),
|
self.context_menu.addAction(_('Manage User Categories'),
|
||||||
partial(self.context_menu_handler, action='manage_categories',
|
partial(self.context_menu_handler, action='manage_categories',
|
||||||
category=category))
|
category=category))
|
||||||
@ -426,10 +426,10 @@ class TagsModel(QAbstractItemModel): # {{{
|
|||||||
for k in tb_cats.keys():
|
for k in tb_cats.keys():
|
||||||
if tb_cats[k]['kind'] in ['user', 'search']:
|
if tb_cats[k]['kind'] in ['user', 'search']:
|
||||||
del tb_cats[k]
|
del tb_cats[k]
|
||||||
for user_cat in sorted(prefs['user_categories'].keys()):
|
for user_cat in sorted(self.db.prefs['user_categories'].keys()):
|
||||||
cat_name = user_cat+':' # add the ':' to avoid name collision
|
cat_name = user_cat+':' # add the ':' to avoid name collision
|
||||||
tb_cats.add_user_category(label=cat_name, name=user_cat)
|
tb_cats.add_user_category(label=cat_name, name=user_cat)
|
||||||
if len(saved_searches.names()):
|
if len(saved_searches().names()):
|
||||||
tb_cats.add_search_category(label='search', name=_('Searches'))
|
tb_cats.add_search_category(label='search', name=_('Searches'))
|
||||||
|
|
||||||
# Now get the categories
|
# Now get the categories
|
||||||
@ -507,11 +507,11 @@ class TagsModel(QAbstractItemModel): # {{{
|
|||||||
if key not in self.db.field_metadata:
|
if key not in self.db.field_metadata:
|
||||||
return
|
return
|
||||||
if key == 'search':
|
if key == 'search':
|
||||||
if val in saved_searches.names():
|
if val in saved_searches().names():
|
||||||
error_dialog(self.tags_view, _('Duplicate search name'),
|
error_dialog(self.tags_view, _('Duplicate search name'),
|
||||||
_('The saved search name %s is already used.')%val).exec_()
|
_('The saved search name %s is already used.')%val).exec_()
|
||||||
return False
|
return False
|
||||||
saved_searches.rename(unicode(item.data(role).toString()), val)
|
saved_searches().rename(unicode(item.data(role).toString()), val)
|
||||||
self.tags_view.search_item_renamed.emit()
|
self.tags_view.search_item_renamed.emit()
|
||||||
else:
|
else:
|
||||||
if key == 'series':
|
if key == 'series':
|
||||||
|
@ -199,7 +199,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, # {{{
|
|||||||
UpdateMixin.__init__(self, opts)
|
UpdateMixin.__init__(self, opts)
|
||||||
|
|
||||||
####################### Search boxes ########################
|
####################### Search boxes ########################
|
||||||
SavedSearchBoxMixin.__init__(self)
|
SavedSearchBoxMixin.__init__(self, db)
|
||||||
SearchBoxMixin.__init__(self)
|
SearchBoxMixin.__init__(self)
|
||||||
|
|
||||||
####################### Library view ########################
|
####################### Library view ########################
|
||||||
@ -392,6 +392,7 @@ class Main(MainWindow, MainWindowMixin, DeviceMixin, # {{{
|
|||||||
self.library_view.model().set_book_on_device_func(self.book_on_device)
|
self.library_view.model().set_book_on_device_func(self.book_on_device)
|
||||||
self.status_bar.clear_message()
|
self.status_bar.clear_message()
|
||||||
self.search.clear_to_help()
|
self.search.clear_to_help()
|
||||||
|
self.saved_search.clear_to_help()
|
||||||
self.book_details.reset_info()
|
self.book_details.reset_info()
|
||||||
self.library_view.model().count_changed()
|
self.library_view.model().count_changed()
|
||||||
self.scheduler.database_changed(db)
|
self.scheduler.database_changed(db)
|
||||||
|
@ -30,7 +30,7 @@ from calibre.customize.ui import run_plugins_on_import
|
|||||||
from calibre.utils.filenames import ascii_filename
|
from calibre.utils.filenames import ascii_filename
|
||||||
from calibre.utils.date import utcnow, now as nowf, utcfromtimestamp
|
from calibre.utils.date import utcnow, now as nowf, utcfromtimestamp
|
||||||
from calibre.utils.config import prefs, tweaks
|
from calibre.utils.config import prefs, tweaks
|
||||||
from calibre.utils.search_query_parser import saved_searches
|
from calibre.utils.search_query_parser import saved_searches, set_saved_searches
|
||||||
from calibre.ebooks import BOOK_EXTENSIONS, check_ebook_format
|
from calibre.ebooks import BOOK_EXTENSIONS, check_ebook_format
|
||||||
from calibre.utils.magick_draw import save_cover_data_to
|
from calibre.utils.magick_draw import save_cover_data_to
|
||||||
|
|
||||||
@ -142,6 +142,24 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
|
|||||||
|
|
||||||
def initialize_dynamic(self):
|
def initialize_dynamic(self):
|
||||||
self.prefs = DBPrefs(self)
|
self.prefs = DBPrefs(self)
|
||||||
|
|
||||||
|
# Migrate saved search and user categories to db preference scheme
|
||||||
|
def migrate_preference(name):
|
||||||
|
ans = self.prefs.get(name, None)
|
||||||
|
if ans is None:
|
||||||
|
ans = prefs[name]
|
||||||
|
try:
|
||||||
|
del prefs[name]
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
if ans is not None:
|
||||||
|
self.prefs[name] = ans
|
||||||
|
|
||||||
|
migrate_preference('user_categories')
|
||||||
|
migrate_preference('saved_searches')
|
||||||
|
|
||||||
|
set_saved_searches(self, 'saved_searches')
|
||||||
|
|
||||||
self.conn.executescript('''
|
self.conn.executescript('''
|
||||||
DROP TRIGGER IF EXISTS author_insert_trg;
|
DROP TRIGGER IF EXISTS author_insert_trg;
|
||||||
CREATE TEMP TRIGGER author_insert_trg
|
CREATE TEMP TRIGGER author_insert_trg
|
||||||
@ -270,10 +288,10 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
|
|||||||
for k in tb_cats.keys():
|
for k in tb_cats.keys():
|
||||||
if tb_cats[k]['kind'] in ['user', 'search']:
|
if tb_cats[k]['kind'] in ['user', 'search']:
|
||||||
del tb_cats[k]
|
del tb_cats[k]
|
||||||
for user_cat in sorted(prefs['user_categories'].keys()):
|
for user_cat in sorted(self.prefs['user_categories'].keys()):
|
||||||
cat_name = user_cat+':' # add the ':' to avoid name collision
|
cat_name = user_cat+':' # add the ':' to avoid name collision
|
||||||
tb_cats.add_user_category(label=cat_name, name=user_cat)
|
tb_cats.add_user_category(label=cat_name, name=user_cat)
|
||||||
if len(saved_searches.names()):
|
if len(saved_searches().names()):
|
||||||
tb_cats.add_search_category(label='search', name=_('Searches'))
|
tb_cats.add_search_category(label='search', name=_('Searches'))
|
||||||
|
|
||||||
self.book_on_device_func = None
|
self.book_on_device_func = None
|
||||||
@ -845,7 +863,7 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
|
|||||||
categories['formats'].sort(key = lambda x:x.name)
|
categories['formats'].sort(key = lambda x:x.name)
|
||||||
|
|
||||||
#### Now do the user-defined categories. ####
|
#### Now do the user-defined categories. ####
|
||||||
user_categories = prefs['user_categories']
|
user_categories = self.prefs['user_categories']
|
||||||
|
|
||||||
# We want to use same node in the user category as in the source
|
# We want to use same node in the user category as in the source
|
||||||
# category. To do that, we need to find the original Tag node. There is
|
# category. To do that, we need to find the original Tag node. There is
|
||||||
@ -882,8 +900,8 @@ class LibraryDatabase2(LibraryDatabase, SchemaUpgrade, CustomColumns):
|
|||||||
icon = None
|
icon = None
|
||||||
if icon_map and 'search' in icon_map:
|
if icon_map and 'search' in icon_map:
|
||||||
icon = icon_map['search']
|
icon = icon_map['search']
|
||||||
for srch in saved_searches.names():
|
for srch in saved_searches().names():
|
||||||
items.append(Tag(srch, tooltip=saved_searches.lookup(srch), icon=icon))
|
items.append(Tag(srch, tooltip=saved_searches().lookup(srch), icon=icon))
|
||||||
if len(items):
|
if len(items):
|
||||||
if icon_map is not None:
|
if icon_map is not None:
|
||||||
icon_map['search'] = icon_map['search']
|
icon_map['search'] = icon_map['search']
|
||||||
|
@ -397,4 +397,3 @@ class SchemaUpgrade(object):
|
|||||||
UNIQUE(key));
|
UNIQUE(key));
|
||||||
'''
|
'''
|
||||||
self.conn.executescript(script)
|
self.conn.executescript(script)
|
||||||
|
|
||||||
|
@ -21,7 +21,6 @@ import sys, string, operator
|
|||||||
from calibre.utils.pyparsing import Keyword, Group, Forward, CharsNotIn, Suppress, \
|
from calibre.utils.pyparsing import Keyword, Group, Forward, CharsNotIn, Suppress, \
|
||||||
OneOrMore, oneOf, CaselessLiteral, Optional, NoMatch, ParseException
|
OneOrMore, oneOf, CaselessLiteral, Optional, NoMatch, ParseException
|
||||||
from calibre.constants import preferred_encoding
|
from calibre.constants import preferred_encoding
|
||||||
from calibre.utils.config import prefs
|
|
||||||
|
|
||||||
'''
|
'''
|
||||||
This class manages access to the preference holding the saved search queries.
|
This class manages access to the preference holding the saved search queries.
|
||||||
@ -32,9 +31,13 @@ class SavedSearchQueries(object):
|
|||||||
queries = {}
|
queries = {}
|
||||||
opt_name = ''
|
opt_name = ''
|
||||||
|
|
||||||
def __init__(self, _opt_name):
|
def __init__(self, db, _opt_name):
|
||||||
self.opt_name = _opt_name;
|
self.opt_name = _opt_name;
|
||||||
self.queries = prefs[self.opt_name]
|
self.db = db
|
||||||
|
if db is not None:
|
||||||
|
self.queries = db.prefs[self.opt_name]
|
||||||
|
else:
|
||||||
|
self.queries = {}
|
||||||
|
|
||||||
def force_unicode(self, x):
|
def force_unicode(self, x):
|
||||||
if not isinstance(x, unicode):
|
if not isinstance(x, unicode):
|
||||||
@ -43,20 +46,20 @@ class SavedSearchQueries(object):
|
|||||||
|
|
||||||
def add(self, name, value):
|
def add(self, name, value):
|
||||||
self.queries[self.force_unicode(name)] = self.force_unicode(value).strip()
|
self.queries[self.force_unicode(name)] = self.force_unicode(value).strip()
|
||||||
prefs[self.opt_name] = self.queries
|
self.db.prefs[self.opt_name] = self.queries
|
||||||
|
|
||||||
def lookup(self, name):
|
def lookup(self, name):
|
||||||
return self.queries.get(self.force_unicode(name), None)
|
return self.queries.get(self.force_unicode(name), None)
|
||||||
|
|
||||||
def delete(self, name):
|
def delete(self, name):
|
||||||
self.queries.pop(self.force_unicode(name), False)
|
self.queries.pop(self.force_unicode(name), False)
|
||||||
prefs[self.opt_name] = self.queries
|
self.db.prefs[self.opt_name] = self.queries
|
||||||
|
|
||||||
def rename(self, old_name, new_name):
|
def rename(self, old_name, new_name):
|
||||||
self.queries[self.force_unicode(new_name)] = \
|
self.queries[self.force_unicode(new_name)] = \
|
||||||
self.queries.get(self.force_unicode(old_name), None)
|
self.queries.get(self.force_unicode(old_name), None)
|
||||||
self.queries.pop(self.force_unicode(old_name), False)
|
self.queries.pop(self.force_unicode(old_name), False)
|
||||||
prefs[self.opt_name] = self.queries
|
self.db.prefs[self.opt_name] = self.queries
|
||||||
|
|
||||||
def names(self):
|
def names(self):
|
||||||
return sorted(self.queries.keys(),
|
return sorted(self.queries.keys(),
|
||||||
@ -66,8 +69,15 @@ class SavedSearchQueries(object):
|
|||||||
Create a global instance of the saved searches. It is global so that the searches
|
Create a global instance of the saved searches. It is global so that the searches
|
||||||
are common across all instances of the parser (devices, library, etc).
|
are common across all instances of the parser (devices, library, etc).
|
||||||
'''
|
'''
|
||||||
saved_searches = SavedSearchQueries('saved_searches')
|
ss = SavedSearchQueries(None, None)
|
||||||
|
|
||||||
|
def set_saved_searches(db, opt_name):
|
||||||
|
global ss
|
||||||
|
ss = SavedSearchQueries(db, opt_name)
|
||||||
|
|
||||||
|
def saved_searches():
|
||||||
|
global ss
|
||||||
|
return ss
|
||||||
|
|
||||||
class SearchQueryParser(object):
|
class SearchQueryParser(object):
|
||||||
'''
|
'''
|
||||||
@ -209,7 +219,7 @@ class SearchQueryParser(object):
|
|||||||
raise ParseException(query, len(query), 'undefined saved search', self)
|
raise ParseException(query, len(query), 'undefined saved search', self)
|
||||||
if self.recurse_level > 5:
|
if self.recurse_level > 5:
|
||||||
self.searches_seen.add(query)
|
self.searches_seen.add(query)
|
||||||
return self._parse(saved_searches.lookup(query))
|
return self._parse(saved_searches().lookup(query))
|
||||||
except: # convert all exceptions (e.g., missing key) to a parse error
|
except: # convert all exceptions (e.g., missing key) to a parse error
|
||||||
raise ParseException(query, len(query), 'undefined saved search', self)
|
raise ParseException(query, len(query), 'undefined saved search', self)
|
||||||
return self.get_matches(location, query)
|
return self.get_matches(location, query)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user