diff --git a/app/controllers/category/index.js b/app/controllers/category/index.js
index 52aed1e844c..079a61cecca 100644
--- a/app/controllers/category/index.js
+++ b/app/controllers/category/index.js
@@ -7,7 +7,7 @@ export default Controller.extend(PaginationMixin, {
queryParams: ['page', 'per_page', 'sort'],
page: '1',
per_page: 10,
- sort: 'downloads',
+ sort: 'recent-downloads',
totalItems: computed.readOnly('model.meta.total'),
@@ -15,6 +15,12 @@ export default Controller.extend(PaginationMixin, {
category: computed.alias('categoryController.model'),
currentSortBy: computed('sort', function() {
- return (this.get('sort') === 'downloads') ? 'Downloads' : 'Alphabetical';
+ if (this.get('sort') === 'downloads') {
+ return 'All-Time Downloads';
+ } else if (this.get('sort') === 'alpha') {
+ return 'Alphabetical';
+ } else {
+ return 'Recent Downloads';
+ }
}),
});
diff --git a/app/controllers/crates.js b/app/controllers/crates.js
index 185d93ca697..274e25ee739 100644
--- a/app/controllers/crates.js
+++ b/app/controllers/crates.js
@@ -14,6 +14,12 @@ export default Controller.extend(PaginationMixin, {
totalItems: computed.readOnly('model.meta.total'),
currentSortBy: computed('sort', function() {
- return (this.get('sort') === 'downloads') ? 'Downloads' : 'Alphabetical';
+ if (this.get('sort') === 'downloads') {
+ return 'All-Time Downloads';
+ } else if (this.get('sort') === 'recent-downloads') {
+ return 'Recent Downloads';
+ } else {
+ return 'Alphabetical';
+ }
}),
});
diff --git a/app/controllers/keyword/index.js b/app/controllers/keyword/index.js
index c0650b00664..76deffc376d 100644
--- a/app/controllers/keyword/index.js
+++ b/app/controllers/keyword/index.js
@@ -7,11 +7,17 @@ export default Controller.extend(PaginationMixin, {
queryParams: ['page', 'per_page', 'sort'],
page: '1',
per_page: 10,
- sort: 'alpha',
+ sort: 'recent-downloads',
totalItems: computed.readOnly('model.meta.total'),
currentSortBy: computed('sort', function() {
- return (this.get('sort') === 'downloads') ? 'Downloads' : 'Alphabetical';
+ if (this.get('sort') === 'downloads') {
+ return 'All-Time Downloads';
+ } else if (this.get('sort') === 'alpha') {
+ return 'Alphabetical';
+ } else {
+ return 'Recent Downloads';
+ }
}),
});
diff --git a/app/controllers/search.js b/app/controllers/search.js
index a590e1348a9..50914fd2fdc 100644
--- a/app/controllers/search.js
+++ b/app/controllers/search.js
@@ -13,7 +13,13 @@ export default Controller.extend(PaginationMixin, {
totalItems: computed.readOnly('model.meta.total'),
currentSortBy: computed('sort', function() {
- return (this.get('sort') === 'downloads') ? 'Downloads' : 'Relevance';
+ if (this.get('sort') === 'downloads') {
+ return 'All-Time Downloads';
+ } else if (this.get('sort') === 'recent-downloads') {
+ return 'Recent Downloads';
+ } else {
+ return 'Relevance';
+ }
}),
hasItems: computed.bool('totalItems'),
diff --git a/app/controllers/team.js b/app/controllers/team.js
index 53830bf9153..5b559694086 100644
--- a/app/controllers/team.js
+++ b/app/controllers/team.js
@@ -12,6 +12,12 @@ export default Controller.extend(PaginationMixin, {
totalItems: computed.readOnly('model.crates.meta.total'),
currentSortBy: computed('sort', function() {
- return (this.get('sort') === 'downloads') ? 'Downloads' : 'Alphabetical';
+ if (this.get('sort') === 'downloads') {
+ return 'All-Time Downloads';
+ } else if (this.get('sort') === 'recent-downloads') {
+ return 'Recent Downloads';
+ } else {
+ return 'Alphabetical';
+ }
}),
});
diff --git a/app/controllers/user.js b/app/controllers/user.js
index c2c6a9b0893..ab3088fd4e9 100644
--- a/app/controllers/user.js
+++ b/app/controllers/user.js
@@ -13,6 +13,12 @@ export default Controller.extend(PaginationMixin, {
totalItems: computed.readOnly('model.crates.meta.total'),
currentSortBy: computed('sort', function() {
- return (this.get('sort') === 'downloads') ? 'Downloads' : 'Alphabetical';
+ if (this.get('sort') === 'downloads') {
+ return 'All-Time Downloads';
+ } else if (this.get('sort') === 'recent-downloads') {
+ return 'Recent Downloads';
+ } else {
+ return 'Alphabetical';
+ }
}),
});
diff --git a/app/models/crate.js b/app/models/crate.js
index 67ed18cf4c3..44cf9bb710e 100644
--- a/app/models/crate.js
+++ b/app/models/crate.js
@@ -4,6 +4,7 @@ import DS from 'ember-data';
export default DS.Model.extend({
name: DS.attr('string'),
downloads: DS.attr('number'),
+ recent_downloads: DS.attr('number'),
created_at: DS.attr('date'),
updated_at: DS.attr('date'),
max_version: DS.attr('string'),
diff --git a/app/routes/team.js b/app/routes/team.js
index 6a7c642964b..f87e63c9400 100644
--- a/app/routes/team.js
+++ b/app/routes/team.js
@@ -1,13 +1,13 @@
-import RSVP from 'rsvp';
-import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
+import { inject as service } from '@ember/service';
+import RSVP from 'rsvp';
export default Route.extend({
flashMessages: service(),
queryParams: {
- page: { refreshedModel: true },
- sort: { refreshedModel: true },
+ page: { refreshModel: true },
+ sort: { refreshModel: true },
},
data: {},
diff --git a/app/styles/crate.scss b/app/styles/crate.scss
index ddf3f99ada5..9a39f5c60ee 100644
--- a/app/styles/crate.scss
+++ b/app/styles/crate.scss
@@ -119,7 +119,7 @@
padding-top: 5px;
@include display-flex;
@include flex-direction(column);
- width: 85%;
+ width: 75%;
}
.info a {
@@ -133,10 +133,18 @@
}
.stats {
- width: 15%;
+ width: 25%;
color: $main-color-light;
}
- .downloads { @include display-flex; @include align-items(center); }
+ .downloads {
+ @include display-flex;
+ @include align-items(center);
+ padding-bottom: 5px;
+ }
+ .recent-downloads {
+ @include display-flex;
+ @include align-items(center);
+ }
.rev-dep-downloads {padding-left: 7px}
}
diff --git a/app/templates/category/index.hbs b/app/templates/category/index.hbs
index da7ceab66b8..d77f15916ce 100644
--- a/app/templates/category/index.hbs
+++ b/app/templates/category/index.hbs
@@ -61,7 +61,12 @@
{{#link-to (query-params sort="downloads")}}
- Downloads
+ All-Time Downloads
+ {{/link-to}}
+
+
+ {{#link-to (query-params sort="recent-downloads")}}
+ Recent Downloads
{{/link-to}}
{{/rl-dropdown}}
diff --git a/app/templates/components/crate-row.hbs b/app/templates/components/crate-row.hbs
index daf61b07799..f9318133dbe 100644
--- a/app/templates/components/crate-row.hbs
+++ b/app/templates/components/crate-row.hbs
@@ -20,7 +20,11 @@
{{svg-jar "download"}}
- {{ format-num crate.downloads }}
+ All-Time: {{ format-num crate.downloads }}
+
+
+ {{svg-jar "download"}}
+ Recent: {{ format-num crate.recent_downloads }}
diff --git a/app/templates/crates.hbs b/app/templates/crates.hbs
index cc8a760b228..692ebd8a6a4 100644
--- a/app/templates/crates.hbs
+++ b/app/templates/crates.hbs
@@ -52,7 +52,12 @@
{{#link-to (query-params page=1 sort="downloads")}}
- Downloads
+ All-Time Downloads
+ {{/link-to}}
+
+
+ {{#link-to (query-params page=1 sort="recent-downloads")}}
+ Recent Downloads
{{/link-to}}
{{/rl-dropdown}}
diff --git a/app/templates/keyword/index.hbs b/app/templates/keyword/index.hbs
index 352cf8d1459..5aac1585c99 100644
--- a/app/templates/keyword/index.hbs
+++ b/app/templates/keyword/index.hbs
@@ -32,7 +32,12 @@
{{#link-to (query-params sort="downloads")}}
- Downloads
+ All-Time Downloads
+ {{/link-to}}
+
+
+ {{#link-to (query-params sort="recent-downloads")}}
+ Recent Downloads
{{/link-to}}
{{/rl-dropdown}}
diff --git a/app/templates/search.hbs b/app/templates/search.hbs
index e256327fb60..f221c60dce1 100644
--- a/app/templates/search.hbs
+++ b/app/templates/search.hbs
@@ -38,7 +38,12 @@
{{#link-to (query-params page=1 sort="downloads")}}
- Downloads
+ All-Time Downloads
+ {{/link-to}}
+
+
+ {{#link-to (query-params page=1 sort="recent-downloads")}}
+ Recent Downloads
{{/link-to}}
{{/rl-dropdown}}
diff --git a/app/templates/team.hbs b/app/templates/team.hbs
index cde38b755a0..aeca821014c 100644
--- a/app/templates/team.hbs
+++ b/app/templates/team.hbs
@@ -47,7 +47,12 @@
{{#link-to (query-params sort="downloads")}}
- Downloads
+ All-Time Downloads
+ {{/link-to}}
+
+
+ {{#link-to (query-params sort="recent-downloads")}}
+ Recent Downloads
{{/link-to}}
{{/rl-dropdown}}
diff --git a/app/templates/user.hbs b/app/templates/user.hbs
index 98b1d54b833..3602d54277f 100644
--- a/app/templates/user.hbs
+++ b/app/templates/user.hbs
@@ -38,7 +38,12 @@
{{#link-to (query-params sort="downloads")}}
- Downloads
+ All-Time Downloads
+ {{/link-to}}
+
+
+ {{#link-to (query-params sort="recent-downloads")}}
+ Recent Downloads
{{/link-to}}
{{/rl-dropdown}}
diff --git a/mirage/config.js b/mirage/config.js
index 49671f2fb53..a75eae275ed 100644
--- a/mirage/config.js
+++ b/mirage/config.js
@@ -170,6 +170,12 @@ export default function() {
return withMeta(this.serialize(categories), { total });
});
+ this.get('/categories/:category_id', function(schema, request) {
+ let catId = request.params.category_id;
+ let category = schema.categories.find(catId);
+ return category ? category : notFound();
+ });
+
this.get('/keywords', function(schema, request) {
let { start, end } = pageParams(request);
diff --git a/mirage/fixtures/crates.js b/mirage/fixtures/crates.js
index 2bb1d4ea037..0b55068e662 100644
--- a/mirage/fixtures/crates.js
+++ b/mirage/fixtures/crates.js
@@ -6,6 +6,7 @@ export default [{
"description": "A high-level, Rust idiomatic wrapper around nanomsg.",
"documentation": "https://github.com/thehydroimpulse/nanomsg.rs",
"downloads": 3888,
+ "recent_downloads": 800,
"homepage": "https://github.com/thehydroimpulse/nanomsg.rs",
"id": "nanomsg",
"keywords": [
@@ -47,6 +48,7 @@ export default [{
"description": "Yo dawg, use Rust to generate Rust, right in your Rust. (See\n`external_mixin` to use scripting languages.)\n",
"documentation": "https://github.com/huonw/external_mixin#rust_mixin",
"downloads": 477,
+ "recent_downloads": 100,
"exact_match": true,
"homepage": "https://github.com/huonw/external_mixin",
"id": "rust_mixin",
@@ -109,6 +111,7 @@ export default [{
"description": "Use your favourite interpreted language to generate your Rust, right\nin your Rust. Supports Python, Ruby and shell (`sh`) out of the box,\nwith an extensible macro to support any others. (See `rust_mixin` to\nbe able to use your all-time favourite language to generate your Rust.)\n",
"documentation": "https://github.com/huonw/external_mixin#external_mixin",
"downloads": 497,
+ "recent_downloads": 497,
"homepage": "https://github.com/huonw/external_mixin",
"id": "external_mixin",
"keywords": ["python", "ruby", "shell", "plugin", "code-generation"],
@@ -122,6 +125,7 @@ export default [{
"description": "Backing library for `rust_mixin` and `external_mixin` to keep them\nDRY.\n",
"documentation": "https://github.com/huonw/external_mixin#external_mixin_base",
"downloads": 989,
+ "recent_downloads": 0,
"homepage": "https://github.com/huonw/external_mixin",
"id": "external_mixin_umbrella",
"keywords": ["plugin", "code-generation"],
@@ -135,6 +139,7 @@ export default [{
"description": "Adds String based inflections for Rust. Snake, kebab, camel, sentence, class, title, upper, and lower cases as well as ordinalize, deordinalize, demodulize, and foreign key are supported as both traits and pure functions acting on String types.\n",
"documentation": "http://whatisinternet.github.io/inflector/doc/inflector/",
"downloads": 57,
+ "recent_downloads": 1,
"homepage": "https://github.com/whatisinternet/inflector",
"id": "Inflector",
"keywords": ["string", "case", "camel", "snake", "inflection"],
@@ -148,6 +153,7 @@ export default [{
"description": "Client for the ElasticSearch REST API",
"documentation": "http://benashford.github.io/rs-es/rs_es/index.html",
"downloads": 321,
+ "recent_downloads": 21,
"homepage": null,
"id": "rs-es",
"keywords": ["elasticsearch", "elastic"],
@@ -161,6 +167,7 @@ export default [{
"description": "A (mostly) pure-Rust implementation of various common cryptographic algorithms.",
"documentation": null,
"downloads": 21573,
+ "recent_downloads": 2000,
"homepage": "https://github.com/DaGenix/rust-crypto/",
"id": "rust-crypto",
"keywords": ["Crypto", "MD5", "Sha1", "Sha2", "AES"],
@@ -174,6 +181,7 @@ export default [{
"description": "This library provides HTSlib bindings and a high level Rust API for reading and writing BAM files.",
"documentation": null,
"downloads": 485,
+ "recent_downloads": 85,
"homepage": null,
"id": "rust-htslib",
"keywords": ["htslib", "bam", "bioinformatics", "pileup", "sequencing"],
@@ -187,6 +195,7 @@ export default [{
"description": "A Kinetic protocol library written in Rust",
"documentation": "https://icorderi.github.io/kinetic-rust/doc/kinetic/",
"downloads": 225,
+ "recent_downloads": 125,
"homepage": "https://icorderi.github.io/icorderi/kinetic-rust",
"id": "kinetic-rust",
"keywords": ["Protocol", "Kinetic", "Storage"],
@@ -200,6 +209,7 @@ export default [{
"description": "Rustless is a REST-like API micro-framework for Rust.",
"documentation": null,
"downloads": 554,
+ "recent_downloads": 500,
"homepage": "https://github.com/rustless/rustless",
"id": "rustless",
"keywords": ["api", "web", "hyper", "iron", "rest"],
@@ -213,6 +223,7 @@ export default [{
"description": "A generic serialization/deserialization framework",
"documentation": "https://serde-rs.github.io/serde/serde/serde/index.html",
"downloads": 50854,
+ "recent_downloads": 854,
"homepage": null,
"id": "serde",
"keywords": ["serde", "serialization"],
@@ -226,6 +237,7 @@ export default [{
"description": "Send cypher queries to a neo4j database",
"documentation": "http://livioribeiro.github.io/rusted_cypher/rusted_cypher/",
"downloads": 156,
+ "recent_downloads": 54,
"homepage": "https://github.com/livioribeiro/rusted-cypher",
"id": "rusted_cypher",
"keywords": ["neo4j", "database", "query", "cypher", "graph"],
@@ -239,6 +251,7 @@ export default [{
"description": "An (incomplete) port of zlib to Rust. The decompressor works, but the compressor has not yet been ported.",
"documentation": null,
"downloads": 223,
+ "recent_downloads": 23,
"homepage": null,
"id": "zlib",
"keywords": [],
@@ -252,6 +265,7 @@ export default [{
"description": "A light HTTP framework, with some REST-like features and the ambition of being simple, modular and non-intrusive.",
"documentation": "http://ogeon.github.io/docs/rustful/master/rustful/index.html",
"downloads": 576,
+ "recent_downloads": 76,
"homepage": null,
"id": "rustful",
"keywords": ["web", "rest", "framework", "http", "routing"],
@@ -265,6 +279,7 @@ export default [{
"description": "A native PostgreSQL driver",
"documentation": "https://sfackler.github.io/rust-postgres/doc/v0.10.1/postgres",
"downloads": 13449,
+ "recent_downloads": 13,
"homepage": null,
"id": "postgres",
"keywords": ["database", "sql"],
@@ -278,6 +293,7 @@ export default [{
"description": "Automatic property based testing with shrinking.",
"documentation": "http://burntsushi.net/rustdoc/quickcheck/",
"downloads": 19271,
+ "recent_downloads": 143,
"homepage": "https://github.com/BurntSushi/quickcheck",
"id": "quickcheck",
"keywords": ["testing", "quickcheck", "property", "shrinking", "fuzz"],
@@ -291,6 +307,7 @@ export default [{
"description": "A macro attribute for quickcheck.",
"documentation": "http://burntsushi.net/rustdoc/quickcheck/",
"downloads": 3796,
+ "recent_downloads": 768,
"homepage": "https://github.com/BurntSushi/quickcheck",
"id": "quickcheck_macros",
"keywords": ["testing", "quickcheck", "property", "shrinking", "fuzz"],
@@ -304,6 +321,7 @@ export default [{
"description": "Lexical analysers generator for Rust, written in Rust (crate dedicated to rumblebars, divergences written by Nicolas Cherel)",
"documentation": null,
"downloads": 109,
+ "recent_downloads": 0,
"homepage": "https://github.com/nicolas-cherel/rustlex",
"id": "nc_rustlex",
"keywords": ["lexer", "lexical", "analyser", "generator"],
@@ -317,6 +335,7 @@ export default [{
"description": "A byte-oriented, zero-copy, parser combinators library",
"documentation": "http://rust.unhandledexpression.com/nom/",
"downloads": 5169,
+ "recent_downloads": 69,
"homepage": null,
"id": "nom",
"keywords": ["parser", "parser-combinators", "parsing", "streaming", "bit"],
diff --git a/mirage/serializers/crate.js b/mirage/serializers/crate.js
index fca074d67af..87ec6db827c 100644
--- a/mirage/serializers/crate.js
+++ b/mirage/serializers/crate.js
@@ -8,6 +8,7 @@ export default BaseSerializer.extend({
'description',
'documentation',
'downloads',
+ 'recent_downloads',
'homepage',
'id',
'keywords',
diff --git a/src/krate.rs b/src/krate.rs
index 1a6435ce9ce..7f6554d7b37 100644
--- a/src/krate.rs
+++ b/src/krate.rs
@@ -19,6 +19,7 @@ use serde_json;
use semver;
use time::Timespec;
use url::Url;
+use chrono::NaiveDate;
use app::{App, RequestApp};
use badge::EncodableBadge;
@@ -39,7 +40,17 @@ use util::{RequestUtils, CargoResult, internal, ChainError, human};
use version::{EncodableVersion, NewVersion};
use {Model, User, Keyword, Version, Category, Badge, Replica};
-#[derive(Debug, Clone, Queryable, Identifiable, AsChangeset)]
+#[derive(Debug, Insertable, Queryable, Identifiable, Associations, AsChangeset)]
+#[belongs_to(Crate)]
+#[primary_key(crate_id, date)]
+#[table_name = "crate_downloads"]
+pub struct CrateDownload {
+ pub crate_id: i32,
+ pub downloads: i32,
+ pub date: NaiveDate,
+}
+
+#[derive(Debug, Clone, Queryable, Identifiable, Associations, AsChangeset)]
pub struct Crate {
pub id: i32,
pub name: String,
@@ -100,6 +111,7 @@ pub struct EncodableCrate {
pub badges: Option>,
pub created_at: String,
pub downloads: i32,
+ pub recent_downloads: Option,
pub max_version: String,
pub description: Option,
pub homepage: Option,
@@ -484,10 +496,20 @@ impl Crate {
max_version: semver::Version,
badges: Option>,
exact_match: bool,
+ recent_downloads: Option,
) -> EncodableCrate {
- self.encodable(max_version, None, None, None, badges, exact_match)
+ self.encodable(
+ max_version,
+ None,
+ None,
+ None,
+ badges,
+ exact_match,
+ recent_downloads,
+ )
}
+ #[cfg_attr(feature = "clippy", allow(too_many_arguments))]
pub fn encodable(
self,
max_version: semver::Version,
@@ -496,6 +518,7 @@ impl Crate {
categories: Option<&[Category]>,
badges: Option>,
exact_match: bool,
+ recent_downloads: Option,
) -> EncodableCrate {
let Crate {
name,
@@ -521,6 +544,7 @@ impl Crate {
updated_at: ::encode_time(updated_at),
created_at: ::encode_time(created_at),
downloads: downloads,
+ recent_downloads: recent_downloads,
versions: versions,
keywords: keyword_ids,
categories: category_ids,
@@ -728,25 +752,47 @@ impl Model for Crate {
/// Handles the `GET /crates` route.
pub fn index(req: &mut Request) -> CargoResult {
- use diesel::expression::AsExpression;
- use diesel::types::Bool;
+ use diesel::expression::{AsExpression, DayAndMonthIntervalDsl};
+ use diesel::types::{Bool, BigInt, Nullable};
+ use diesel::expression::functions::date_and_time::{now, date};
+ use diesel::expression::sql_literal::sql;
+ use diesel::query_source::joins::LeftOuter;
let conn = req.db_conn()?;
let (offset, limit) = req.pagination(10, 100)?;
let params = req.query();
- let sort = params.get("sort").map(|s| &**s).unwrap_or("alpha");
+ let sort = params.get("sort").map(|s| &**s).unwrap_or(
+ "recent-downloads",
+ );
+
+ let recent_downloads = sql::>("SUM(crate_downloads.downloads)");
let mut query = crates::table
- .select((ALL_COLUMNS, AsExpression::::as_expression(false)))
+ .join(
+ crate_downloads::table,
+ LeftOuter,
+ crates::id.eq(crate_downloads::crate_id).and(
+ crate_downloads::date.gt(date(now - 90.days())),
+ ),
+ )
+ .group_by(crates::id)
+ .select((
+ ALL_COLUMNS,
+ AsExpression::::as_expression(false),
+ recent_downloads.clone(),
+ ))
.into_boxed();
if sort == "downloads" {
query = query.order(crates::downloads.desc())
+ } else if sort == "recent-downloads" {
+ query = query.order(recent_downloads.clone().desc().nulls_last())
} else {
query = query.order(crates::name.asc())
}
if let Some(q_string) = params.get("q") {
+ let sort = params.get("sort").map(|s| &**s).unwrap_or("relevance");
let q = plainto_tsquery(q_string);
query = query.filter(q.matches(crates::textsearchable_index_col).or(
crates::name.eq(
@@ -754,10 +800,19 @@ pub fn index(req: &mut Request) -> CargoResult {
),
));
- query = query.select((ALL_COLUMNS, crates::name.eq(q_string)));
+ query = query.select((
+ ALL_COLUMNS,
+ crates::name.eq(q_string),
+ recent_downloads.clone(),
+ ));
let perfect_match = crates::name.eq(q_string).desc();
if sort == "downloads" {
query = query.order((perfect_match, crates::downloads.desc()));
+ } else if sort == "recent-downloads" {
+ query = query.order((
+ perfect_match,
+ recent_downloads.clone().desc().nulls_last(),
+ ));
} else {
let rank = ts_rank_cd(crates::textsearchable_index_col, q);
query = query.order((perfect_match, rank.desc()))
@@ -824,14 +879,21 @@ pub fn index(req: &mut Request) -> CargoResult {
));
}
- let data = query.paginate(limit, offset).load::<((Crate, bool), i64)>(
- &*conn,
- )?;
+ let data = query
+ .paginate(limit, offset)
+ .load::<((Crate, bool, Option), i64)>(&*conn)?;
let total = data.first().map(|&(_, t)| t).unwrap_or(0);
let crates = data.iter()
- .map(|&((ref c, _), _)| c.clone())
+ .map(|&((ref c, _, _), _)| c.clone())
+ .collect::>();
+ let perfect_matches = data.clone()
+ .into_iter()
+ .map(|((_, b, _), _)| b)
+ .collect::>();
+ let recent_downloads = data.clone()
+ .into_iter()
+ .map(|((_, _, s), _)| s.unwrap_or(0))
.collect::>();
- let perfect_matches = data.into_iter().map(|((_, b), _)| b).collect::>();
let versions = Version::belonging_to(&crates)
.load::(&*conn)?
@@ -842,7 +904,9 @@ pub fn index(req: &mut Request) -> CargoResult {
let crates = versions
.zip(crates)
.zip(perfect_matches)
- .map(|((max_version, krate), perfect_match)| {
+ .zip(recent_downloads)
+ .map(|(((max_version, krate), perfect_match),
+ recent_downloads)| {
// FIXME: If we add crate_id to the Badge enum we can eliminate
// this N+1
let badges = badges::table
@@ -852,6 +916,7 @@ pub fn index(req: &mut Request) -> CargoResult {
max_version,
Some(badges),
perfect_match,
+ Some(recent_downloads),
))
})
.collect::>()?;
@@ -891,7 +956,7 @@ pub fn summary(req: &mut Request) -> CargoResult {
.map(|versions| Version::max(versions.into_iter().map(|v| v.num)))
.zip(krates)
.map(|(max_version, krate)| {
- Ok(krate.minimal_encodable(max_version, None, false))
+ Ok(krate.minimal_encodable(max_version, None, false, Some(0)))
})
.collect()
};
@@ -988,6 +1053,7 @@ pub fn show(req: &mut Request) -> CargoResult {
Some(&cats),
Some(badges),
false,
+ Some(0),
),
versions: versions
.into_iter()
@@ -1130,7 +1196,7 @@ pub fn new(req: &mut Request) -> CargoResult {
warnings: Warnings<'a>,
}
Ok(req.json(&R {
- krate: krate.minimal_encodable(max_version, None, false),
+ krate: krate.minimal_encodable(max_version, None, false, Some(0)),
warnings: warnings,
}))
})
diff --git a/src/tests/all.rs b/src/tests/all.rs
index f4dec1e5b8f..d53a1379e16 100644
--- a/src/tests/all.rs
+++ b/src/tests/all.rs
@@ -7,6 +7,7 @@ extern crate diesel;
extern crate diesel_codegen;
extern crate bufstream;
extern crate cargo_registry;
+extern crate chrono;
extern crate conduit;
extern crate conduit_middleware;
extern crate conduit_test;
@@ -32,7 +33,7 @@ use cargo_registry::category::NewCategory;
use cargo_registry::db::{self, RequestTransaction};
use cargo_registry::dependency::NewDependency;
use cargo_registry::keyword::Keyword;
-use cargo_registry::krate::NewCrate;
+use cargo_registry::krate::{NewCrate, CrateDownload};
use cargo_registry::schema::dependencies;
use cargo_registry::upload as u;
use cargo_registry::user::NewUser;
@@ -308,6 +309,7 @@ struct CrateBuilder<'a> {
owner_id: i32,
krate: NewCrate<'a>,
downloads: Option,
+ recent_downloads: Option,
versions: Vec>,
keywords: Vec<&'a str>,
}
@@ -321,6 +323,7 @@ impl<'a> CrateBuilder<'a> {
..NewCrate::default()
},
downloads: None,
+ recent_downloads: None,
versions: Vec::new(),
keywords: Vec::new(),
}
@@ -356,6 +359,11 @@ impl<'a> CrateBuilder<'a> {
self
}
+ fn recent_downloads(mut self, recent_downloads: i32) -> Self {
+ self.recent_downloads = Some(recent_downloads);
+ self
+ }
+
fn version>>(mut self, version: T) -> Self {
self.versions.push(version.into());
self
@@ -367,17 +375,43 @@ impl<'a> CrateBuilder<'a> {
}
fn build(mut self, connection: &PgConnection) -> CargoResult {
- use diesel::update;
+ use diesel::{insert, update};
let mut krate = self.krate.create_or_update(connection, None, self.owner_id)?;
// Since we are using `NewCrate`, we can't set all the
// crate properties in a single DB call.
+
+ let old_downloads = self.downloads.unwrap_or(0) - self.recent_downloads.unwrap_or(0);
+ let now = chrono::Utc::now();
+ let old_date = now.naive_utc().date() - chrono::Duration::days(91);
+
if let Some(downloads) = self.downloads {
+ let crate_download = CrateDownload {
+ crate_id: krate.id,
+ downloads: old_downloads,
+ date: old_date,
+ };
+
+ insert(&crate_download)
+ .into(crate_downloads::table)
+ .execute(connection)?;
krate.downloads = downloads;
update(&krate).set(&krate).execute(connection)?;
}
+ if self.recent_downloads.is_some() {
+ let crate_download = CrateDownload {
+ crate_id: krate.id,
+ downloads: self.recent_downloads.unwrap(),
+ date: now.naive_utc().date(),
+ };
+
+ insert(&crate_download)
+ .into(crate_downloads::table)
+ .execute(connection)?;
+ }
+
if self.versions.is_empty() {
self.versions.push(VersionBuilder::new("0.99.0"));
}
diff --git a/src/tests/krate.rs b/src/tests/krate.rs
index 6df9a921e79..0eaad61eff0 100644
--- a/src/tests/krate.rs
+++ b/src/tests/krate.rs
@@ -311,11 +311,13 @@ fn exact_match_on_queries_with_sort() {
::CrateBuilder::new("foo_sort", user.id)
.description("bar_sort baz_sort const")
.downloads(50)
+ .recent_downloads(50)
.expect_build(&conn);
::CrateBuilder::new("bar_sort", user.id)
.description("foo_sort baz_sort foo_sort baz_sort const")
.downloads(3333)
+ .recent_downloads(0)
.expect_build(&conn);
::CrateBuilder::new("baz_sort", user.id)
@@ -323,6 +325,7 @@ fn exact_match_on_queries_with_sort() {
"foo_sort bar_sort foo_sort bar_sort foo_sort bar_sort const",
)
.downloads(100000)
+ .recent_downloads(10)
.expect_build(&conn);
::CrateBuilder::new("other_sort", user.id)
@@ -331,6 +334,7 @@ fn exact_match_on_queries_with_sort() {
.expect_build(&conn);
}
+ // Sort by downloads
let mut req = ::req(app, Method::Get, "/api/v1/crates");
let mut response = ok_resp!(middle.call(req.with_query("q=foo_sort&sort=downloads")));
let json: CrateList = ::json(&mut response);
@@ -360,6 +364,27 @@ fn exact_match_on_queries_with_sort() {
assert_eq!(json.crates[1].name, "baz_sort");
assert_eq!(json.crates[2].name, "bar_sort");
assert_eq!(json.crates[3].name, "foo_sort");
+
+ // Sort by recent-downloads
+ let mut response = ok_resp!(middle.call(
+ req.with_query("q=bar_sort&sort=recent-downloads"),
+ ));
+ let json: CrateList = ::json(&mut response);
+ assert_eq!(json.meta.total, 3);
+ assert_eq!(json.crates[0].name, "bar_sort");
+ assert_eq!(json.crates[1].name, "foo_sort");
+ assert_eq!(json.crates[2].name, "baz_sort");
+
+ // Test for bug with showing null results first when sorting
+ // by descending
+ // This has nothing to do with querying for exact match I'm sorry
+ let mut response = ok_resp!(middle.call(req.with_query("sort=recent-downloads")));
+ let json: CrateList = ::json(&mut response);
+ assert_eq!(json.meta.total, 4);
+ assert_eq!(json.crates[0].name, "foo_sort");
+ assert_eq!(json.crates[1].name, "baz_sort");
+ assert_eq!(json.crates[2].name, "bar_sort");
+ assert_eq!(json.crates[3].name, "other_sort");
}
#[test]
@@ -1920,3 +1945,152 @@ fn check_ownership_one_crate() {
assert_eq!(json.users[0].kind, "user");
assert_eq!(json.users[0].name, user.name);
}
+
+/* Given two crates, one with downloads less than 90 days ago, the
+ other with all downloads greater than 90 days ago, check that
+ the order returned is by recent downloads, descending. Check
+ also that recent download counts are returned in recent_downloads,
+ and total downloads counts are returned in downloads, and that
+ these numbers do not overlap.
+*/
+#[test]
+fn test_recent_download_count() {
+ let (_b, app, middle) = ::app();
+
+ {
+ let conn = app.diesel_database.get().unwrap();
+ let user = ::new_user("Oskar").create_or_update(&conn).unwrap();
+
+ // More than 90 days ago
+ ::CrateBuilder::new("green_ball", user.id)
+ .description("For fetching")
+ .downloads(10)
+ .recent_downloads(0)
+ .expect_build(&conn);
+
+ ::CrateBuilder::new("sweet_potato_snack", user.id)
+ .description("For when better than usual")
+ .downloads(5)
+ .recent_downloads(2)
+ .expect_build(&conn);
+ }
+
+ let mut req = ::req(app, Method::Get, "/api/v1/crates");
+ let mut response = ok_resp!(middle.call(req.with_query("sort=recent-downloads")));
+ let json: CrateList = ::json(&mut response);
+
+ assert_eq!(json.meta.total, 2);
+
+ assert_eq!(json.crates[0].name, "sweet_potato_snack");
+ assert_eq!(json.crates[1].name, "green_ball");
+
+ assert_eq!(json.crates[0].recent_downloads, Some(2));
+ assert_eq!(json.crates[0].downloads, 5);
+
+ assert_eq!(json.crates[1].recent_downloads, Some(0));
+ assert_eq!(json.crates[1].downloads, 10);
+}
+
+/* Given one crate with zero downloads, check that the crate
+ still shows up in index results, but that it displays 0
+ for both recent downloads and downloads.
+ */
+#[test]
+fn test_zero_downloads() {
+ let (_b, app, middle) = ::app();
+
+ {
+ let conn = app.diesel_database.get().unwrap();
+ let user = ::new_user("Oskar").create_or_update(&conn).unwrap();
+
+ // More than 90 days ago
+ ::CrateBuilder::new("green_ball", user.id)
+ .description("For fetching")
+ .downloads(0)
+ .recent_downloads(0)
+ .expect_build(&conn);
+ }
+
+ let mut req = ::req(app, Method::Get, "/api/v1/crates");
+ let mut response = ok_resp!(middle.call(req.with_query("sort=recent-downloads")));
+ let json: CrateList = ::json(&mut response);
+
+ assert_eq!(json.meta.total, 1);
+
+ assert_eq!(json.crates[0].name, "green_ball");
+ assert_eq!(json.crates[0].recent_downloads, Some(0));
+ assert_eq!(json.crates[0].downloads, 0);
+}
+
+/* Given two crates, one with more all-time downloads, the other with
+ more downloads in the past 90 days, check that the index page for
+ categories and keywords is sorted by recent downlaods by default.
+*/
+#[test]
+fn test_default_sort_recent() {
+ let (_b, app, middle) = ::app();
+
+ let (green_crate, potato_crate) = {
+ let conn = app.diesel_database.get().unwrap();
+ let user = ::new_user("Oskar").create_or_update(&conn).unwrap();
+
+ // More than 90 days ago
+ let green_crate = ::CrateBuilder::new("green_ball", user.id)
+ .description("For fetching")
+ .keyword("dog")
+ .downloads(10)
+ .recent_downloads(10)
+ .expect_build(&conn);
+
+ let potato_crate = ::CrateBuilder::new("sweet_potato_snack", user.id)
+ .description("For when better than usual")
+ .keyword("dog")
+ .downloads(20)
+ .recent_downloads(0)
+ .expect_build(&conn);
+
+ (green_crate, potato_crate)
+ };
+
+ // test that index for keywords is sorted by recent_downloads
+ // by default
+ let mut req = ::req(app.clone(), Method::Get, "/api/v1/crates");
+ let mut response = ok_resp!(middle.call(req.with_query("keyword=dog")));
+ let json: CrateList = ::json(&mut response);
+
+ assert_eq!(json.meta.total, 2);
+
+ assert_eq!(json.crates[0].name, "green_ball");
+ assert_eq!(json.crates[1].name, "sweet_potato_snack");
+
+ assert_eq!(json.crates[0].recent_downloads, Some(10));
+ assert_eq!(json.crates[0].downloads, 10);
+
+ assert_eq!(json.crates[1].recent_downloads, Some(0));
+ assert_eq!(json.crates[1].downloads, 20);
+
+ {
+ let conn = app.diesel_database.get().unwrap();
+ ::new_category("Animal", "animal")
+ .create_or_update(&conn)
+ .unwrap();
+ Category::update_crate(&conn, &green_crate, &["animal"]).unwrap();
+ Category::update_crate(&conn, &potato_crate, &["animal"]).unwrap();
+ }
+
+ // test that index for categories is sorted by recent_downloads
+ // by default
+ let mut response = ok_resp!(middle.call(req.with_query("category=animal")));
+ let json: CrateList = ::json(&mut response);
+
+ assert_eq!(json.meta.total, 2);
+
+ assert_eq!(json.crates[0].name, "green_ball");
+ assert_eq!(json.crates[1].name, "sweet_potato_snack");
+
+ assert_eq!(json.crates[0].recent_downloads, Some(10));
+ assert_eq!(json.crates[0].downloads, 10);
+
+ assert_eq!(json.crates[1].recent_downloads, Some(0));
+ assert_eq!(json.crates[1].downloads, 20);
+}
diff --git a/tests/acceptance/categories-test.js b/tests/acceptance/categories-test.js
index dc23ac70593..32e57755f6c 100644
--- a/tests/acceptance/categories-test.js
+++ b/tests/acceptance/categories-test.js
@@ -15,3 +15,12 @@ test('listing categories', async function(assert) {
hasText(assert, '.row:eq(1) .desc .info span', '1 crate');
hasText(assert, '.row:eq(2) .desc .info span', '3,910 crates');
});
+
+test('category/:category_id index default sort is recent-downloads', async function(assert) {
+ server.create('category', { category: 'Algorithms', crates_cnt: 1 });
+
+ await visit('/categories/algorithms');
+
+ const $sort = findWithAssert('div.sort div.dropdown-container a.dropdown');
+ hasText(assert, $sort, 'Recent Downloads');
+});
diff --git a/tests/acceptance/crates-test.js b/tests/acceptance/crates-test.js
index df57654b806..594f30cfa68 100644
--- a/tests/acceptance/crates-test.js
+++ b/tests/acceptance/crates-test.js
@@ -43,3 +43,28 @@ test('navigating to next page of crates', async function(assert) {
hasText(assert, '.amt.small .cur', '11-19');
hasText(assert, '.amt.small .total', '19');
});
+
+test('crates default sort is alphabetical', async function(assert) {
+ server.loadFixtures();
+
+ await visit('/crates');
+
+ const $sort = findWithAssert('div.sort div.dropdown-container a.dropdown');
+ hasText(assert, $sort, 'Alphabetical');
+});
+
+test('downloads appears for each crate on crate list', async function(assert) {
+ server.loadFixtures();
+
+ await visit('/crates');
+ const $recentDownloads = findWithAssert('div.downloads:first span.num');
+ hasText(assert, $recentDownloads, 'All-Time: 497');
+});
+
+test('recent downloads appears for each crate on crate list', async function(assert) {
+ server.loadFixtures();
+
+ await visit('/crates');
+ const $recentDownloads = findWithAssert('div.recent-downloads:first span.num');
+ hasText(assert, $recentDownloads, 'Recent: 497');
+});
diff --git a/tests/acceptance/keyword-test.js b/tests/acceptance/keyword-test.js
new file mode 100644
index 00000000000..b89a2b94060
--- /dev/null
+++ b/tests/acceptance/keyword-test.js
@@ -0,0 +1,14 @@
+import { test } from 'qunit';
+import moduleForAcceptance from 'cargo/tests/helpers/module-for-acceptance';
+import hasText from 'cargo/tests/helpers/has-text';
+
+moduleForAcceptance('Acceptance | keywords');
+
+test('keyword/:keyword_id index default sort is recent-downloads', async function(assert) {
+ server.create('keyword', { id: 'network', keyword: 'network', crates_cnt: 38 });
+
+ await visit('/keywords/network');
+
+ const $sort = findWithAssert('div.sort div.dropdown-container a.dropdown');
+ hasText(assert, $sort, 'Recent Downloads');
+});
diff --git a/tests/acceptance/search-test.js b/tests/acceptance/search-test.js
index 76defefa1bd..10066be8a04 100644
--- a/tests/acceptance/search-test.js
+++ b/tests/acceptance/search-test.js
@@ -18,13 +18,13 @@ test('searching for "rust"', async function(assert) {
assert.equal(document.title, 'Search Results for \'rust\' - Cargo: packages for Rust');
hasText(assert, '#crates-heading', 'Search Results for \'rust\'');
- hasText(assert, '#results', 'Displaying 1-8 of 8 total results Sort by Relevance Relevance Downloads');
+ hasText(assert, '#results', 'Displaying 1-8 of 8 total results Sort by Relevance Relevance All-Time Downloads Recent Downloads');
hasText(assert, '#crates .row:first .desc .info', 'kinetic-rust');
findWithAssert('#crates .row:first .desc .info .vers img[alt="0.0.16"]');
hasText(assert, '#crates .row:first .desc .summary', 'A Kinetic protocol library written in Rust');
- hasText(assert, '#crates .row:first .downloads', '225');
+ hasText(assert, '#crates .row:first .downloads', 'All-Time: 225');
});
test('pressing S key to focus the search bar', async function(assert) {
@@ -55,3 +55,17 @@ test('pressing S key to focus the search bar', async function(assert) {
await keyEvent(document, 'keydown', KEYCODE_S);
assertSearchBarIsFocused();
});
+
+test('check search results are by default displayed by relevance', async function(assert) {
+ server.loadFixtures();
+
+ await visit('/');
+ await fillIn('input.search', 'rust');
+
+ findWithAssert('form.search').submit();
+
+ await wait();
+
+ const $sort = findWithAssert('div.sort div.dropdown-container a.dropdown');
+ hasText(assert, $sort, 'Relevance');
+});