Sorting for CS category views

This commit is contained in:
Kovid Goyal 2010-10-13 18:47:46 -06:00
parent a5f2c1fbb1
commit 4f031e05dd
5 changed files with 173 additions and 135 deletions

View File

@ -12,17 +12,22 @@
<link rel="stylesheet" type="text/css" href="/static/browse/browse.css" /> <link rel="stylesheet" type="text/css" href="/static/browse/browse.css" />
<link type="text/css" href="/static/jquery_ui/css/pepper-grinder/jquery-ui-1.8.5.custom.css" rel="stylesheet" /> <link type="text/css" href="/static/jquery_ui/css/pepper-grinder/jquery-ui-1.8.5.custom.css" rel="stylesheet" />
<link rel="stylesheet" type="text/css" href="/static/jquery.multiselect.css" />
<script type="text/javascript" src="/static/jquery.js"></script> <script type="text/javascript" src="/static/jquery.js"></script>
<script type="text/javascript" src="/static/jquery.corner.js"></script> <script type="text/javascript" src="/static/jquery.corner.js"></script>
<script type="text/javascript" <script type="text/javascript"
src="/static/jquery_ui/js/jquery-ui-1.8.5.custom.min.js"></script> src="/static/jquery_ui/js/jquery-ui-1.8.5.custom.min.js"></script>
<script type="text/javascript"
src="/static/jquery.multiselect.min.js"></script>
<script type="text/javascript" src="/static/browse/browse.js"></script> <script type="text/javascript" src="/static/browse/browse.js"></script>
<script type="text/javascript"> <script type="text/javascript">
var sort_cookie_name = "{sort_cookie_name}";
var sort_select_label = "{sort_select_label}";
$(document).ready(function() {{ $(document).ready(function() {{
init(); init();
{script} {script}
@ -67,7 +72,7 @@
<div id="content"> <div id="content">
<div class="sort_select"> <div class="sort_select">
<label>{sort_select_label}&nbsp;</label> <label>{sort_select_label}</label>
<select id="sort_combobox"> <select id="sort_combobox">
{sort_select_options} {sort_select_options}
</select> </select>

View File

@ -1,106 +1,64 @@
// Widgets {{{ // Cookies {{{
// Combobox {{{ function cookie(name, value, options) {
if (typeof value != 'undefined') { // name and value given, set cookie
(function( $ ) { options = options || {};
$.widget( "ui.combobox", { if (value === null) {
_create: function() { value = '';
var self = this, options.expires = -1;
select = this.element.hide(),
selected = select.children( ":selected" ),
value = selected.val() ? selected.text() : "";
var input = $( "<input>" )
.insertAfter( select )
.val( value )
.autocomplete({
delay: 0,
minLength: 0,
source: function( request, response ) {
var matcher = new RegExp( $.ui.autocomplete.escapeRegex(request.term), "i" );
response( select.children( "option" ).map(function() {
var text = $( this ).text();
if ( this.value && ( !request.term || matcher.test(text) ) )
return {
label: text.replace(
new RegExp(
"(?![^&;]+;)(?!<[^<>]*)(" +
$.ui.autocomplete.escapeRegex(request.term) +
")(?![^<>]*>)(?![^&;]+;)", "gi"
), "<strong>$1</strong>" ),
value: text,
option: this
};
}) );
},
select: function( event, ui ) {
ui.item.option.selected = true;
self._trigger( "selected", event, {
item: ui.item.option
});
},
change: function( event, ui ) {
if ( !ui.item ) {
var matcher = new RegExp( "^" + $.ui.autocomplete.escapeRegex( $(this).val() ) + "$", "i" ),
valid = false;
select.children( "option" ).each(function() {
if ( this.value.match( matcher ) ) {
this.selected = valid = true;
return false;
}
});
if ( !valid ) {
// remove invalid value, as it didn't match anything
$( this ).val( "" );
select.val( "" );
return false;
}
}
}
})
.addClass( "ui-widget ui-widget-content ui-corner-left" );
input.data( "autocomplete" )._renderItem = function( ul, item ) {
return $( "<li></li>" )
.data( "item.autocomplete", item )
.append( "<a>" + item.label + "</a>" )
.appendTo( ul );
};
$( "<button>&nbsp;</button>" )
.attr( "tabIndex", -1 )
.attr( "title", "Show All Items" )
.insertAfter( input )
.button({
icons: {
primary: "ui-icon-triangle-1-s"
},
text: false
})
.removeClass( "ui-corner-all" )
.addClass( "ui-corner-right ui-button-icon" )
.click(function() {
// close if already visible
if ( input.autocomplete( "widget" ).is( ":visible" ) ) {
input.autocomplete( "close" );
return;
}
// pass empty string as value to search for, displaying all results
input.autocomplete( "search", "" );
input.focus();
});
} }
}); var expires = '';
})( jQuery ); if (options.expires && (typeof options.expires == 'number' || options.expires.toUTCString)) {
// }}} var date;
if (typeof options.expires == 'number') {
date = new Date();
date.setTime(date.getTime() + (options.expires * 24 * 60 * 60 * 1000));
} else {
date = options.expires;
}
expires = '; expires=' + date.toUTCString(); // use expires attribute, max-age is not supported by IE
}
// CAUTION: Needed to parenthesize options.path and options.domain
// in the following expressions, otherwise they evaluate to undefined
// in the packed version for some reason...
var path = options.path ? '; path=' + (options.path) : '';
var domain = options.domain ? '; domain=' + (options.domain) : '';
var secure = options.secure ? '; secure' : '';
document.cookie = [name, '=', encodeURIComponent(value), expires, path, domain, secure].join('');
} else { // only name given, get cookie
var cookieValue = null;
if (document.cookie && document.cookie != '') {
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var cookie = jQuery.trim(cookies[i]);
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) == (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}
};
// }}} // }}}
// Sort {{{ // Sort {{{
function init_sort_combobox() { function init_sort_combobox() {
$("#sort_combobox").combobox(); $("#sort_combobox").multiselect({
multiple: false,
header: sort_select_label,
noneSelectedText: sort_select_label,
selectedList: 1,
click: function(event, ui){
$(this).multiselect("close");
cookie(sort_cookie_name, ui.value, {expires: 365});
window.location.reload();
}
});
} }
// }}} // }}}

View File

@ -0,0 +1,21 @@
.ui-multiselect { padding:2px 0 2px 4px; text-align:left }
.ui-multiselect span.ui-icon { float:right }
.ui-multiselect-header { margin-bottom:3px; padding:3px 0 3px 4px }
.ui-multiselect-header ul { font-size:0.9em }
.ui-multiselect-header ul li { float:left; padding:0 10px 0 0 }
.ui-multiselect-header a { text-decoration:none }
.ui-multiselect-header a:hover { text-decoration:underline }
.ui-multiselect-header span.ui-icon { float:left }
.ui-multiselect-header li.ui-multiselect-close { float:right; text-align:right; padding-right:0 }
.ui-multiselect-menu { display:none; padding:3px; position:absolute; z-index:10000 }
.ui-multiselect-checkboxes { position:relative /* fixes bug in IE6/7 */; overflow-y:scroll }
.ui-multiselect-checkboxes label { cursor:default; display:block; border:1px solid transparent; padding:3px 1px }
.ui-multiselect-checkboxes label input { position:relative; top:1px }
.ui-multiselect-checkboxes li { clear:both; font-size:0.9em; padding-right:3px }
.ui-multiselect-checkboxes li.ui-multiselect-optgroup-label { text-align:center; font-weight:bold; border-bottom:1px solid }
.ui-multiselect-checkboxes li.ui-multiselect-optgroup-label a { display:block; padding:3px; margin:1px 0; text-decoration:none }
/* remove label borders in IE6 because IE6 does not support transparency */
* html .ui-multiselect-checkboxes label { border:none }

File diff suppressed because one or more lines are too long

View File

@ -5,7 +5,7 @@ __license__ = 'GPL v3'
__copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>' __copyright__ = '2010, Kovid Goyal <kovid@kovidgoyal.net>'
__docformat__ = 'restructuredtext en' __docformat__ = 'restructuredtext en'
import operator, os, sys import operator, os
from urllib import quote from urllib import quote
from binascii import hexlify from binascii import hexlify
@ -13,9 +13,8 @@ import cherrypy
from calibre.constants import filesystem_encoding from calibre.constants import filesystem_encoding
from calibre import isbytestring, force_unicode, prepare_string_for_xml as xml from calibre import isbytestring, force_unicode, prepare_string_for_xml as xml
from calibre.library.server.utils import Offsets
def paginate(offsets, content, base_url, up_url=None): def paginate(offsets, content, base_url, up_url=None): # {{{
'Create markup for pagination' 'Create markup for pagination'
if '?' not in base_url: if '?' not in base_url:
@ -72,13 +71,15 @@ def paginate(offsets, content, base_url, up_url=None):
</div> </div>
''' '''
return templ.format(navbar=navbar, content=content) return templ.format(navbar=navbar, content=content)
# }}}
def utf8(x): def utf8(x): # {{{
if isinstance(x, unicode): if isinstance(x, unicode):
x = x.encode('utf-8') x = x.encode('utf-8')
return x return x
# }}}
def render_rating(rating, container='span'): def render_rating(rating, container='span'): # {{{
if rating < 0.1: if rating < 0.1:
return '', '' return '', ''
added = 0 added = 0
@ -99,7 +100,9 @@ def render_rating(rating, container='span'):
ans.append('</%s>'%container) ans.append('</%s>'%container)
return u''.join(ans), rstring return u''.join(ans), rstring
def get_category_items(category, items, offsets, db): # }}}
def get_category_items(category, items, db): # {{{
def item(i): def item(i):
templ = (u'<li title="{4}" class="category-item">' templ = (u'<li title="{4}" class="category-item">'
@ -118,9 +121,41 @@ def get_category_items(category, items, offsets, db):
return templ.format(xml(name), rating, return templ.format(xml(name), rating,
xml(desc), xml(quote(href)), rstring) xml(desc), xml(quote(href)), rstring)
items = list(map(item, items[offsets.offset:offsets.slice_upper_bound])) items = list(map(item, items))
return '\n'.join(['<ul>'] + items + ['</ul>']) return '\n'.join(['<ul>'] + items + ['</ul>'])
# }}}
class Endpoint(object): # {{{
'Manage encoding, mime-type, last modified, cookies, etc.'
def __init__(self, mimetype='text/html', sort_type='category'):
self.mimetype = mimetype
self.sort_type = sort_type
self.sort_kwarg = sort_type + '_sort'
self.sort_cookie_name = 'calibre_browse_server_sort_'+self.sort_type
def __call__(eself, func):
def do(self, *args, **kwargs):
sort_val = None
cookie = cherrypy.request.cookie
if cookie.has_key(eself.sort_cookie_name):
sort_val = cookie[eself.sort_cookie_name].value
kwargs[eself.sort_kwarg] = sort_val
ans = func(self, *args, **kwargs)
cherrypy.response.headers['Content-Type'] = eself.mimetype
updated = self.db.last_modified()
cherrypy.response.headers['Last-Modified'] = \
self.last_modified(max(updated, self.build_time))
ans = utf8(ans)
return ans
do.__name__ = func.__name__
return do
# }}}
class BrowseServer(object): class BrowseServer(object):
@ -134,26 +169,34 @@ class BrowseServer(object):
self.browse_search) self.browse_search)
connect('browse_book', base_href+'/book/{uuid}', self.browse_book) connect('browse_book', base_href+'/book/{uuid}', self.browse_book)
def browse_template(self, category=True): def browse_template(self, sort, category=True):
def generate(): def generate():
scn = 'calibre_browse_server_sort_'
if category: if category:
sort_opts = [('rating', _('Average rating')), ('name', sort_opts = [('rating', _('Average rating')), ('name',
_('Name')), ('popularity', _('Popularity'))] _('Name')), ('popularity', _('Popularity'))]
scn += 'category'
else: else:
scn += 'list'
fm = self.db.field_metadata fm = self.db.field_metadata
sort_opts = [(x, fm[x]['name']) for x in fm.sortable_field_keys() sort_opts, added = [], set([])
if fm[x]['name']] for x in fm.sortable_field_keys():
prefix = 'category' if category else 'book' n = fm[x]['name']
if n not in added:
added.add(n)
sort_opts.append((x, n))
ans = P('content_server/browse/browse.html', ans = P('content_server/browse/browse.html',
data=True).decode('utf-8') data=True).decode('utf-8')
ans = ans.replace('{sort_select_label}', xml(_('Sort by')+':')) ans = ans.replace('{sort_select_label}', xml(_('Sort by')+':'))
opts = ['<option value="%s_%s">%s</option>' % (prefix, xml(k), ans = ans.replace('{sort_cookie_name}', scn)
xml(n)) for k, n in opts = ['<option %svalue="%s">%s</option>' % (
'selected="selected" ' if k==sort else '',
xml(k), xml(n), ) for k, n in
sorted(sort_opts, key=operator.itemgetter(1))] sorted(sort_opts, key=operator.itemgetter(1))]
opts = ['<option value="_default">'+xml(_('Select one')) + ans = ans.replace('{sort_select_options}', ('\n'+' '*20).join(opts))
'&hellip;</option>'] + opts
ans = ans.replace('{sort_select_options}', '\n\t\t\t'.join(opts))
lp = self.db.library_path lp = self.db.library_path
if isbytestring(lp): if isbytestring(lp):
lp = force_unicode(lp, filesystem_encoding) lp = force_unicode(lp, filesystem_encoding)
@ -197,10 +240,12 @@ class BrowseServer(object):
main = '<div class="toplevel"><h3>{0}</h3><ul>{1}</ul></div>'\ main = '<div class="toplevel"><h3>{0}</h3><ul>{1}</ul></div>'\
.format(_('Choose a category to browse by:'), '\n\n'.join(cats)) .format(_('Choose a category to browse by:'), '\n\n'.join(cats))
return self.browse_template().format(title='', return self.browse_template('name').format(title='',
script='toplevel();', main=main) script='toplevel();', main=main)
def browse_category(self, category, offset, sort, subcategory=None): def browse_category(self, category, sort):
if sort not in ('rating', 'name', 'popularity'):
sort = 'name'
categories = self.categories_cache() categories = self.categories_cache()
category_meta = self.db.field_metadata category_meta = self.db.field_metadata
category_name = category_meta[category]['name'] category_name = category_meta[category]['name']
@ -210,18 +255,20 @@ class BrowseServer(object):
items = categories[category] items = categories[category]
base_url='/browse/category/'+category+'?' name_keyg = lambda x: getattr(x, 'sort', x.name).lower()
if subcategory is not None: items.sort(key=name_keyg)
base_url += 'subcategory='+quote(subcategory) if sort == 'popularity':
items.sort(key=operator.attrgetter('count'), reverse=True)
elif sort == 'rating':
items.sort(key=operator.attrgetter('avg_rating'), reverse=True)
base_url='/browse/category/'+category
if sort is not None: if sort is not None:
base_url += 'sort='+quote(sort) base_url += '?sort='+quote(sort)
script = 'category();' script = 'category();'
max_items = sys.maxint items = get_category_items(category, items, self.db)
offsets = Offsets(offset, max_items, len(items))
items = list(items)[offsets.offset:offsets.offset+max_items]
items = get_category_items(category, items, offsets, self.db)
main = u''' main = u'''
<div class="category"> <div class="category">
<h3>{0}</h3> <h3>{0}</h3>
@ -234,29 +281,20 @@ class BrowseServer(object):
xml(_('Browsing by')+': ' + category_name), items, xml(_('Browsing by')+': ' + category_name), items,
xml(_('Up'), True)) xml(_('Up'), True))
return self.browse_template().format(title=category_name, return self.browse_template(sort).format(title=category_name,
script=script, main=main) script=script, main=main)
def browse_catalog(self, category=None, offset=0, sort=None, @Endpoint()
subcategory=None): def browse_catalog(self, category=None, category_sort=None):
'Entry point for top-level, categories and sub-categories' 'Entry point for top-level, categories and sub-categories'
try:
offset = int(offset)
except:
raise cherrypy.HTTPError(404, 'Not found')
if category == None: if category == None:
ans = self.browse_toplevel() ans = self.browse_toplevel()
else: else:
ans = self.browse_category(category, offset, sort) ans = self.browse_category(category, category_sort)
cherrypy.response.headers['Content-Type'] = 'text/html' return ans
updated = self.db.last_modified()
cherrypy.response.headers['Last-Modified'] = \
self.last_modified(max(updated, self.build_time))
return utf8(ans)
# }}} # }}}