mirror of
https://github.com/sphinx-doc/sphinx.git
synced 2025-02-25 18:55:22 -06:00
Improved search system. The search index is now a regular javascript file which should speed things up because browsers can cache it. Removed unused code from doctools.js
This commit is contained in:
parent
b4ec549f04
commit
3debdc2c2a
@ -34,6 +34,7 @@ from sphinx.latexwriter import LaTeXWriter
|
|||||||
from sphinx.environment import BuildEnvironment, NoUri
|
from sphinx.environment import BuildEnvironment, NoUri
|
||||||
from sphinx.highlighting import PygmentsBridge
|
from sphinx.highlighting import PygmentsBridge
|
||||||
from sphinx.util.console import bold, purple, darkgreen
|
from sphinx.util.console import bold, purple, darkgreen
|
||||||
|
from sphinx.search import js_index
|
||||||
|
|
||||||
# side effect: registers roles and directives
|
# side effect: registers roles and directives
|
||||||
from sphinx import roles
|
from sphinx import roles
|
||||||
@ -338,10 +339,10 @@ class StandaloneHTMLBuilder(Builder):
|
|||||||
name = 'html'
|
name = 'html'
|
||||||
copysource = True
|
copysource = True
|
||||||
out_suffix = '.html'
|
out_suffix = '.html'
|
||||||
indexer_format = json
|
indexer_format = js_index
|
||||||
supported_image_types = ['image/svg+xml', 'image/png', 'image/gif',
|
supported_image_types = ['image/svg+xml', 'image/png', 'image/gif',
|
||||||
'image/jpeg']
|
'image/jpeg']
|
||||||
searchindex_filename = 'searchindex.json'
|
searchindex_filename = 'searchindex.js'
|
||||||
add_header_links = True
|
add_header_links = True
|
||||||
add_definition_links = True
|
add_definition_links = True
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@
|
|||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
import cPickle as pickle
|
import cPickle as pickle
|
||||||
|
from cStringIO import StringIO
|
||||||
|
|
||||||
from docutils.nodes import Text, NodeVisitor
|
from docutils.nodes import Text, NodeVisitor
|
||||||
|
|
||||||
@ -20,6 +21,37 @@ from sphinx.util import json
|
|||||||
word_re = re.compile(r'\w+(?u)')
|
word_re = re.compile(r'\w+(?u)')
|
||||||
|
|
||||||
|
|
||||||
|
class _JavaScriptIndex(object):
|
||||||
|
"""
|
||||||
|
The search index as javascript file that calls a function
|
||||||
|
on the documentation search object to register the index.
|
||||||
|
This serializing system does not support chaining because
|
||||||
|
simplejson (which it depends on) doesn't support it either.
|
||||||
|
"""
|
||||||
|
|
||||||
|
PREFIX = 'Search.setIndex('
|
||||||
|
SUFFIX = ')'
|
||||||
|
|
||||||
|
def dumps(self, data):
|
||||||
|
return self.PREFIX + json.dumps(data) + self.SUFFIX
|
||||||
|
|
||||||
|
def loads(self, s):
|
||||||
|
data = s[len(self.PREFIX):-len(self.SUFFIX)]
|
||||||
|
if not data or not s.startswith(self.PREFIX) or not \
|
||||||
|
s.endswith(self.SUFFIX):
|
||||||
|
raise ValueError('invalid data')
|
||||||
|
return json.loads(data)
|
||||||
|
|
||||||
|
def dump(self, data, f):
|
||||||
|
f.write(self.dumps(data))
|
||||||
|
|
||||||
|
def load(self, f):
|
||||||
|
return self.loads(f.read())
|
||||||
|
|
||||||
|
|
||||||
|
js_index = _JavaScriptIndex()
|
||||||
|
|
||||||
|
|
||||||
class Stemmer(PorterStemmer):
|
class Stemmer(PorterStemmer):
|
||||||
"""
|
"""
|
||||||
All those porter stemmer implementations look hideous.
|
All those porter stemmer implementations look hideous.
|
||||||
|
@ -94,11 +94,9 @@ jQuery.fn.highlightText = function(text, className) {
|
|||||||
var Documentation = {
|
var Documentation = {
|
||||||
|
|
||||||
init : function() {
|
init : function() {
|
||||||
/* this.addContextElements(); -- now done statically */
|
|
||||||
this.fixFirefoxAnchorBug();
|
this.fixFirefoxAnchorBug();
|
||||||
this.highlightSearchWords();
|
this.highlightSearchWords();
|
||||||
this.initModIndex();
|
this.initModIndex();
|
||||||
this.initComments();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -108,6 +106,8 @@ var Documentation = {
|
|||||||
PLURAL_EXPR : function(n) { return n == 1 ? 0 : 1; },
|
PLURAL_EXPR : function(n) { return n == 1 ? 0 : 1; },
|
||||||
LOCALE : 'unknown',
|
LOCALE : 'unknown',
|
||||||
|
|
||||||
|
// gettext and ngettext don't access this so that the functions
|
||||||
|
// can savely bound to a different name (_ = Documentation.gettext)
|
||||||
gettext : function(string) {
|
gettext : function(string) {
|
||||||
var translated = Documentation.TRANSLATIONS[string];
|
var translated = Documentation.TRANSLATIONS[string];
|
||||||
if (typeof translated == 'undefined')
|
if (typeof translated == 'undefined')
|
||||||
@ -133,14 +133,12 @@ var Documentation = {
|
|||||||
* add context elements like header anchor links
|
* add context elements like header anchor links
|
||||||
*/
|
*/
|
||||||
addContextElements : function() {
|
addContextElements : function() {
|
||||||
for (var i = 1; i <= 6; i++) {
|
$('div[@id] > :header:first').each(function() {
|
||||||
$('h' + i + '[@id]').each(function() {
|
|
||||||
$('<a class="headerlink">\u00B6</a>').
|
$('<a class="headerlink">\u00B6</a>').
|
||||||
attr('href', '#' + this.id).
|
attr('href', '#' + this.id).
|
||||||
attr('title', _('Permalink to this headline')).
|
attr('title', _('Permalink to this headline')).
|
||||||
appendTo(this);
|
appendTo(this);
|
||||||
});
|
});
|
||||||
}
|
|
||||||
$('dt[@id]').each(function() {
|
$('dt[@id]').each(function() {
|
||||||
$('<a class="headerlink">\u00B6</a>').
|
$('<a class="headerlink">\u00B6</a>').
|
||||||
attr('href', '#' + this.id).
|
attr('href', '#' + this.id).
|
||||||
@ -196,37 +194,6 @@ var Documentation = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* init the inline comments
|
|
||||||
*/
|
|
||||||
initComments : function() {
|
|
||||||
$('.inlinecomments div.actions').each(function() {
|
|
||||||
this.innerHTML += ' | ';
|
|
||||||
$(this).append($('<a href="#">hide comments</a>').click(function() {
|
|
||||||
$(this).parent().parent().toggle();
|
|
||||||
return false;
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
$('.inlinecomments .comments').hide();
|
|
||||||
$('.inlinecomments a.bubble').each(function() {
|
|
||||||
$(this).click($(this).is('.emptybubble') ? function() {
|
|
||||||
var params = $.getQueryParameters(this.href);
|
|
||||||
Documentation.newComment(params.target[0]);
|
|
||||||
return false;
|
|
||||||
} : function() {
|
|
||||||
$('.comments', $(this).parent().parent()[0]).toggle();
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
$('#comments div.actions a.newcomment').click(function() {
|
|
||||||
Documentation.newComment();
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
if (document.location.hash.match(/^#comment-/))
|
|
||||||
$('.inlinecomments .comments ' + document.location.hash)
|
|
||||||
.parent().toggle();
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* helper function to hide the search marks again
|
* helper function to hide the search marks again
|
||||||
*/
|
*/
|
||||||
@ -235,22 +202,6 @@ var Documentation = {
|
|||||||
$('span.highlight').removeClass('highlight');
|
$('span.highlight').removeClass('highlight');
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* show the comment window for a certain id or the whole page.
|
|
||||||
*/
|
|
||||||
newComment : function(id) {
|
|
||||||
Documentation.CommentWindow.openFor(id || '');
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* write a new comment from within a comment view box
|
|
||||||
*/
|
|
||||||
newCommentFromBox : function(link) {
|
|
||||||
var params = $.getQueryParameters(link.href);
|
|
||||||
$(link).parent().parent().fadeOut('slow');
|
|
||||||
this.newComment(params.target);
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* make the url absolute
|
* make the url absolute
|
||||||
*/
|
*/
|
||||||
@ -270,108 +221,7 @@ var Documentation = {
|
|||||||
});
|
});
|
||||||
var url = parts.join('/');
|
var url = parts.join('/');
|
||||||
return path.substring(url.lastIndexOf('/') + 1, path.length - 1);
|
return path.substring(url.lastIndexOf('/') + 1, path.length - 1);
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* class that represents the comment window
|
|
||||||
*/
|
|
||||||
CommentWindow : (function() {
|
|
||||||
var openWindows = {};
|
|
||||||
|
|
||||||
var Window = function(sectionID) {
|
|
||||||
this.url = Documentation.makeURL('@comments/' + Documentation.getCurrentURL()
|
|
||||||
+ '/?target=' + $.urlencode(sectionID) + '&mode=ajax');
|
|
||||||
this.sectionID = sectionID;
|
|
||||||
|
|
||||||
this.root = $('<div class="commentwindow"></div>');
|
|
||||||
this.root.appendTo($('body'));
|
|
||||||
this.title = $('<h3>New Comment</h3>').appendTo(this.root);
|
|
||||||
this.body = $('<div class="form">please wait...</div>').appendTo(this.root);
|
|
||||||
this.resizeHandle = $('<div class="resizehandle"></div>').appendTo(this.root);
|
|
||||||
|
|
||||||
this.root.Draggable({
|
|
||||||
handle: this.title[0]
|
|
||||||
});
|
|
||||||
|
|
||||||
this.root.css({
|
|
||||||
left: window.innerWidth / 2 - $(this.root).width() / 2,
|
|
||||||
top: window.scrollY + (window.innerHeight / 2 - 150)
|
|
||||||
});
|
|
||||||
this.root.fadeIn('slow');
|
|
||||||
this.updateView();
|
|
||||||
};
|
|
||||||
|
|
||||||
Window.prototype.updateView = function(data) {
|
|
||||||
var self = this;
|
|
||||||
function update(data) {
|
|
||||||
if (data.posted) {
|
|
||||||
document.location.hash = '#comment-' + data.commentID;
|
|
||||||
document.location.reload();
|
|
||||||
}
|
}
|
||||||
else {
|
|
||||||
self.body.html(data.body);
|
|
||||||
$('div.actions', self.body).append($('<input>')
|
|
||||||
.attr('type', 'button')
|
|
||||||
.attr('value', 'Close')
|
|
||||||
.click(function() { self.close(); })
|
|
||||||
);
|
|
||||||
$('div.actions input[@name="preview"]')
|
|
||||||
.attr('type', 'button')
|
|
||||||
.click(function() { self.submitForm($('form', self.body)[0], true); });
|
|
||||||
$('form', self.body).bind("submit", function() {
|
|
||||||
self.submitForm(this);
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.error) {
|
|
||||||
self.root.Highlight(1000, '#aadee1');
|
|
||||||
$('div.error', self.root).slideDown(500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof data == 'undefined')
|
|
||||||
$.getJSON(this.url, function(json) { update(json); });
|
|
||||||
else
|
|
||||||
$.ajax({
|
|
||||||
url: this.url,
|
|
||||||
type: 'POST',
|
|
||||||
dataType: 'json',
|
|
||||||
data: data,
|
|
||||||
success: function(json) { update(json); }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Window.prototype.getFormValue = function(name) {
|
|
||||||
return $('*[@name="' + name + '"]', this.body)[0].value;
|
|
||||||
}
|
|
||||||
|
|
||||||
Window.prototype.submitForm = function(form, previewMode) {
|
|
||||||
this.updateView({
|
|
||||||
author: form.author.value,
|
|
||||||
author_mail: form.author_mail.value,
|
|
||||||
title: form.title.value,
|
|
||||||
comment_body: form.comment_body.value,
|
|
||||||
preview: previewMode ? 'yes' : ''
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Window.prototype.close = function() {
|
|
||||||
var self = this;
|
|
||||||
delete openWindows[this.sectionID];
|
|
||||||
this.root.fadeOut('slow', function() {
|
|
||||||
self.root.remove();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Window.openFor = function(sectionID) {
|
|
||||||
if (sectionID in openWindows)
|
|
||||||
return openWindows[sectionID];
|
|
||||||
return new Window(sectionID);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Window;
|
|
||||||
})()
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// quick alias for translations
|
// quick alias for translations
|
||||||
|
@ -224,6 +224,10 @@ var PorterStemmer = function() {
|
|||||||
*/
|
*/
|
||||||
var Search = {
|
var Search = {
|
||||||
|
|
||||||
|
_index : null,
|
||||||
|
_queued_query : null,
|
||||||
|
_pulse_status : -1,
|
||||||
|
|
||||||
init : function() {
|
init : function() {
|
||||||
var params = $.getQueryParameters();
|
var params = $.getQueryParameters();
|
||||||
if (params.q) {
|
if (params.q) {
|
||||||
@ -234,33 +238,68 @@ var Search = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* perform a search for something
|
* Sets the index
|
||||||
*/
|
*/
|
||||||
performSearch : function(query) {
|
setIndex : function(index) {
|
||||||
// create the required interface elements
|
var q;
|
||||||
var out = $('#search-results');
|
this._index = index;
|
||||||
var title = $('<h2>' + _('Searching') + '</h2>').appendTo(out);
|
if ((q = this._queued_query) !== null) {
|
||||||
var dots = $('<span></span>').appendTo(title);
|
this._queued_query = null;
|
||||||
var status = $('<p style="display: none"></p>').appendTo(out);
|
Search.query(q);
|
||||||
var output = $('<ul class="search"/>').appendTo(out);
|
}
|
||||||
$('#search-progress').text(_('Getting search index...'));
|
},
|
||||||
|
|
||||||
// spawn a background runner for updating the dots
|
hasIndex : function() {
|
||||||
// until the search has finished
|
return self._index !== null;
|
||||||
var pulseStatus = 0;
|
},
|
||||||
|
|
||||||
|
deferQuery : function(query) {
|
||||||
|
this._queued_query = query;
|
||||||
|
},
|
||||||
|
|
||||||
|
stopPulse : function() {
|
||||||
|
this._pulse_status = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
startPulse : function() {
|
||||||
|
if (this._pulse_status >= 0)
|
||||||
|
return;
|
||||||
function pulse() {
|
function pulse() {
|
||||||
pulseStatus = (pulseStatus + 1) % 4;
|
Search._pulse_status = (Search._pulse_status + 1) % 4;
|
||||||
var dotString = '';
|
var dotString = '';
|
||||||
for (var i = 0; i < pulseStatus; i++) {
|
for (var i = 0; i < Search._pulse_status; i++) {
|
||||||
dotString += '.';
|
dotString += '.';
|
||||||
}
|
}
|
||||||
dots.text(dotString);
|
Search.dots.text(dotString);
|
||||||
if (pulseStatus > -1) {
|
if (Search._pulse_status > -1) {
|
||||||
window.setTimeout(pulse, 500);
|
window.setTimeout(pulse, 500);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
pulse();
|
pulse();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* perform a search for something
|
||||||
|
*/
|
||||||
|
performSearch : function(query) {
|
||||||
|
// create the required interface elements
|
||||||
|
this.out = $('#search-results');
|
||||||
|
this.title = $('<h2>' + _('Searching') + '</h2>').appendTo(this.out);
|
||||||
|
this.dots = $('<span></span>').appendTo(this.title);
|
||||||
|
this.status = $('<p style="display: none"></p>').appendTo(this.out);
|
||||||
|
this.output = $('<ul class="search"/>').appendTo(this.out);
|
||||||
|
|
||||||
|
$('#search-progress').text(_('Preparing search...'));
|
||||||
|
this.startPulse();
|
||||||
|
|
||||||
|
// index already loaded, the browser was quick!
|
||||||
|
if (this.hasIndex())
|
||||||
|
this.query(query);
|
||||||
|
else
|
||||||
|
this.setQuery(query);
|
||||||
|
},
|
||||||
|
|
||||||
|
query : function(query) {
|
||||||
// stem the searchwords and add them to the
|
// stem the searchwords and add them to the
|
||||||
// correct list
|
// correct list
|
||||||
var stemmer = new PorterStemmer();
|
var stemmer = new PorterStemmer();
|
||||||
@ -291,36 +330,29 @@ var Search = {
|
|||||||
console.info('required: ', searchwords);
|
console.info('required: ', searchwords);
|
||||||
console.info('excluded: ', excluded);
|
console.info('excluded: ', excluded);
|
||||||
|
|
||||||
// fetch searchindex and perform search
|
|
||||||
$.getJSON('searchindex.json', function(data) {
|
|
||||||
|
|
||||||
// prepare search
|
// prepare search
|
||||||
var filenames = data[0];
|
var filenames = this._index[0];
|
||||||
var titles = data[1]
|
var titles = this._index[1];
|
||||||
var words = data[2];
|
var words = this._index[2];
|
||||||
var fileMap = {};
|
var fileMap = {};
|
||||||
var files = null;
|
var files = null;
|
||||||
|
$('#search-progress').empty();
|
||||||
$('#search-progress').empty()
|
|
||||||
|
|
||||||
// perform the search on the required words
|
// perform the search on the required words
|
||||||
for (var i = 0; i < searchwords.length; i++) {
|
for (var i = 0; i < searchwords.length; i++) {
|
||||||
var word = searchwords[i];
|
var word = searchwords[i];
|
||||||
// no match but word was a required one
|
// no match but word was a required one
|
||||||
if ((files = words[word]) == null) {
|
if ((files = words[word]) == null)
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
// create the mapping
|
// create the mapping
|
||||||
for (var j = 0; j < files.length; j++) {
|
for (var j = 0; j < files.length; j++) {
|
||||||
var file = files[j];
|
var file = files[j];
|
||||||
if (file in fileMap) {
|
if (file in fileMap)
|
||||||
fileMap[file].push(word);
|
fileMap[file].push(word);
|
||||||
}
|
else
|
||||||
else {
|
|
||||||
fileMap[file] = [word];
|
fileMap[file] = [word];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// now check if the files are in the correct
|
// now check if the files are in the correct
|
||||||
// areas and if the don't contain excluded words
|
// areas and if the don't contain excluded words
|
||||||
@ -329,9 +361,9 @@ var Search = {
|
|||||||
var valid = true;
|
var valid = true;
|
||||||
|
|
||||||
// check if all requirements are matched
|
// check if all requirements are matched
|
||||||
if (fileMap[file].length != searchwords.length) {
|
if (fileMap[file].length != searchwords.length)
|
||||||
continue;
|
continue;
|
||||||
}
|
|
||||||
// ensure that none of the excluded words is in the
|
// ensure that none of the excluded words is in the
|
||||||
// search result.
|
// search result.
|
||||||
for (var i = 0; i < excluded.length; i++) {
|
for (var i = 0; i < excluded.length; i++) {
|
||||||
@ -343,14 +375,13 @@ var Search = {
|
|||||||
|
|
||||||
// if we have still a valid result we can add it
|
// if we have still a valid result we can add it
|
||||||
// to the result list
|
// to the result list
|
||||||
if (valid) {
|
if (valid)
|
||||||
results.push([filenames[file], titles[file]]);
|
results.push([filenames[file], titles[file]]);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// delete unused variables in order to not waste
|
// delete unused variables in order to not waste
|
||||||
// memory until list is retrieved completely
|
// memory until list is retrieved completely
|
||||||
delete filenames, titles, words, data;
|
delete filenames, titles, words;
|
||||||
|
|
||||||
// now sort the results by title
|
// now sort the results by title
|
||||||
results.sort(function(a, b) {
|
results.sort(function(a, b) {
|
||||||
@ -372,7 +403,7 @@ var Search = {
|
|||||||
highlightstring).html(item[1]));
|
highlightstring).html(item[1]));
|
||||||
$.get('_sources/' + item[0] + '.txt', function(data) {
|
$.get('_sources/' + item[0] + '.txt', function(data) {
|
||||||
listItem.append($.makeSearchSummary(data, searchwords, hlwords));
|
listItem.append($.makeSearchSummary(data, searchwords, hlwords));
|
||||||
output.append(listItem);
|
Search.output.append(listItem);
|
||||||
listItem.slideDown(10, function() {
|
listItem.slideDown(10, function() {
|
||||||
displayNextItem();
|
displayNextItem();
|
||||||
});
|
});
|
||||||
@ -380,21 +411,19 @@ var Search = {
|
|||||||
}
|
}
|
||||||
// search finished, update title and status message
|
// search finished, update title and status message
|
||||||
else {
|
else {
|
||||||
pulseStatus = -1;
|
Search.stopPulse();
|
||||||
title.text(_('Search Results'));
|
Search.title.text(_('Search Results'));
|
||||||
if (!resultCount) {
|
if (!resultCount) {
|
||||||
status.text(_('Your search did not match any documents. Please make sure that all words are spelled correctly and that you\'ve selected enough categories.'));
|
Search.status.text(_('Your search did not match any documents. Please make sure that all words are spelled correctly and that you\'ve selected enough categories.'));
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
status.text(_('Search finished, found %s page(s) matching the search query.').replace('%s', resultCount));
|
Search.status.text(_('Search finished, found %s page(s) matching the search query.').replace('%s', resultCount));
|
||||||
}
|
}
|
||||||
status.fadeIn(500);
|
Search.status.fadeIn(500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
displayNextItem();
|
displayNextItem();
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{% extends "layout.html" %}
|
{% extends "layout.html" %}
|
||||||
{% set title = _('Search') %}
|
{% set title = _('Search') %}
|
||||||
{% set script_files = script_files + ['_static/searchtools.js'] %}
|
{% set script_files = script_files + ['_static/searchtools.js', 'searchindex.js'] %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h1 id="search-documentation">{{ _('Search') }}</h1>
|
<h1 id="search-documentation">{{ _('Search') }}</h1>
|
||||||
<p>
|
<p>
|
||||||
|
Loading…
Reference in New Issue
Block a user