diff --git a/app/Env.scala b/app/Env.scala index 13eff32ad9b0..855e76b94a91 100644 --- a/app/Env.scala +++ b/app/Env.scala @@ -130,4 +130,5 @@ object Env { def blog = lila.blog.Env.current def pool = lila.pool.Env.current def donation = lila.donation.Env.current + def qa = lila.qa.Env.current } diff --git a/app/views/message/thread.scala.html b/app/views/message/thread.scala.html index 89485edf194f..5e1da363fe19 100644 --- a/app/views/message/thread.scala.html +++ b/app/views/message/thread.scala.html @@ -21,7 +21,7 @@

@thread.nonEmptyName

} -@if(!Set("lichess", "lichess-blog").contains(thread.creatorId)) { +@if(!Set("lichess", "lichess-blog", "lichess-qa").contains(thread.creatorId)) {
@if(blocks) {

This user blocks you. You cannot answer.

diff --git a/modules/common/src/main/paginator/Adapter.scala b/modules/common/src/main/paginator/Adapter.scala index 4fdc85b08d70..cdbd6ef058eb 100644 --- a/modules/common/src/main/paginator/Adapter.scala +++ b/modules/common/src/main/paginator/Adapter.scala @@ -36,4 +36,12 @@ trait AdapterLike[A] { results.map(f).sequenceFu } } + + def mapFutureList[B](f: Seq[A] => Fu[Seq[B]]): AdapterLike[B] = new AdapterLike[B] { + + def nbResults = AdapterLike.this.nbResults + + def slice(offset: Int, length: Int) = + AdapterLike.this.slice(offset, length) flatMap f + } } diff --git a/modules/db/src/main/PaginatorAdapter.scala b/modules/db/src/main/PaginatorAdapter.scala index b62218256a33..9ab79226f28d 100644 --- a/modules/db/src/main/PaginatorAdapter.scala +++ b/modules/db/src/main/PaginatorAdapter.scala @@ -4,7 +4,11 @@ package paginator import api._ import Implicits._ import play.api.libs.json._ +import reactivemongo.api.collections.default.BSONCollection import reactivemongo.api.SortOrder +import reactivemongo.api.{ QueryOpts, SortOrder } +import reactivemongo.bson._ +import reactivemongo.core.commands.Count import lila.common.paginator.AdapterLike @@ -15,8 +19,8 @@ final class Adapter[A: TubeInColl]( def nbResults: Fu[Int] = $count(selector) def slice(offset: Int, length: Int): Fu[Seq[A]] = $find( - pimpQB($query(selector)).sort(sort: _*) skip offset, - length + pimpQB($query(selector)).sort(sort: _*) skip offset, + length ) } @@ -27,3 +31,19 @@ final class CachedAdapter[A]( def slice(offset: Int, length: Int): Fu[Seq[A]] = adapter.slice(offset, length) } + +final class BSONAdapter[A: BSONDocumentReader]( + collection: BSONCollection, + selector: BSONDocument, + sort: BSONDocument) extends AdapterLike[A] { + + def nbResults: Fu[Int] = + collection.db command Count(collection.name, Some(selector)) + + def slice(offset: Int, length: Int): Fu[Seq[A]] = + collection.find(selector) + .sort(sort) + .copy(options = QueryOpts(skipN = offset)) + .cursor[A] + .collect[List](length) +} diff --git a/modules/db/src/main/Util.scala b/modules/db/src/main/Util.scala new file mode 100644 index 000000000000..f1d8f2a3ad84 --- /dev/null +++ b/modules/db/src/main/Util.scala @@ -0,0 +1,15 @@ +package lila.db + +import reactivemongo.bson._ + +import Types.Coll + +object Util { + + def findNextId(coll: Coll): Fu[Int] = + coll.find(BSONDocument(), BSONDocument("_id" -> true)) + .sort(BSONDocument("_id" -> -1)) + .one[BSONDocument] map { + _ flatMap { doc => doc.getAs[Int]("_id") map (1+) } getOrElse 1 + } +} diff --git a/modules/puzzle/src/main/PuzzleApi.scala b/modules/puzzle/src/main/PuzzleApi.scala index 358f9ae198fa..cacb64a7193f 100644 --- a/modules/puzzle/src/main/PuzzleApi.scala +++ b/modules/puzzle/src/main/PuzzleApi.scala @@ -46,7 +46,7 @@ private[puzzle] final class PuzzleApi( case Failure(err) :: rest => insertPuzzles(rest) map { ps => (Failure(err): Try[PuzzleId]) :: ps } - case Success(puzzle) :: rest => findNextId flatMap { id => + case Success(puzzle) :: rest => lila.db.Util findNextId puzzleColl flatMap { id => val p = puzzle(id) val fenStart = p.fen.split(' ').take(2).mkString(" ") puzzleColl.db command Count(puzzleColl.name, BSONDocument( @@ -59,13 +59,6 @@ private[puzzle] final class PuzzleApi( } } } - - private def findNextId: Fu[PuzzleId] = - puzzleColl.find(BSONDocument(), BSONDocument("_id" -> true)) - .sort(BSONDocument("_id" -> -1)) - .one[BSONDocument] map { - _ flatMap { doc => doc.getAs[Int]("_id") map (1+) } getOrElse 1 - } } object attempt { diff --git a/modules/qa/src/main/DataForm.scala b/modules/qa/src/main/DataForm.scala new file mode 100644 index 000000000000..c6dac5f86b5a --- /dev/null +++ b/modules/qa/src/main/DataForm.scala @@ -0,0 +1,49 @@ +package lila.qa + +import play.api.data._ +import play.api.data.Forms._ + +object Forms { + + lazy val question = Form( + mapping( + "title" -> nonEmptyText(minLength = 10, maxLength = 150), + "body" -> nonEmptyText(minLength = 10, maxLength = 10000), + "hidden-tags" -> text + )(QuestionData.apply)(QuestionData.unapply) + ) + + def editQuestion(q: Question) = question fill QuestionData( + title = q.title, + body = q.body, + `hidden-tags` = q.tags mkString ",") + + case class QuestionData(title: String, body: String, `hidden-tags`: String) { + + def tags = `hidden-tags`.split(',').toList.map(_.trim).filter(_.nonEmpty) + } + + lazy val answer = Form( + mapping( + "body" -> nonEmptyText(minLength = 30) + )(AnswerData.apply)(AnswerData.unapply) + ) + + case class AnswerData(body: String) + + lazy val comment = Form( + mapping( + "body" -> nonEmptyText(minLength = 20) + )(CommentData.apply)(CommentData.unapply) + ) + + case class CommentData(body: String) + + val vote = Form(single( + "vote" -> number + )) + + val favorite = Form(single( + "favorite" -> number + )) +} diff --git a/modules/qa/src/main/Env.scala b/modules/qa/src/main/Env.scala new file mode 100644 index 000000000000..c5ccb88d89f5 --- /dev/null +++ b/modules/qa/src/main/Env.scala @@ -0,0 +1,22 @@ +package lila.qa + +import com.typesafe.config.Config +import lila.common.PimpedConfig._ + +final class Env( + config: Config, + db: lila.db.Env) { + + private val CollectionQuestion = config getString "collection.question" + + // def forms = DataForm + + // lazy val api = new qaApi(db(Collectionqa), MonthlyGoal) +} + +object Env { + + lazy val current = "[boot] donation" describes new Env( + config = lila.common.PlayApp loadConfig "donation", + db = lila.db.Env.current) +} diff --git a/modules/qa/src/main/Mailer.scala b/modules/qa/src/main/Mailer.scala new file mode 100644 index 000000000000..108130b21cc5 --- /dev/null +++ b/modules/qa/src/main/Mailer.scala @@ -0,0 +1,68 @@ +package lila.qa + +import lila.common.String._ +import lila.user.User + +private[qa] final class Mailer(sender: String) { + + private[qa] def createAnswer(q: Question, a: Answer, u: User, favoriters: List[User]): Funit = ??? + // send( + // to = (rudyEmail :: user.email :: favoriters.map(_.email)) filterNot (u.email.==), + // subject = s"""${u.displaynameOrFullname} answered your question""", + // content = s"""New answer on prismic.io Q&A: ${questionUrl(q)}#answer-${a.id} + + +// By ${u.displaynameOrFullname} +// On question ${q.title} + +// ${a.body} + +// URL: ${questionUrl(q)}#answer-${a.id}""") + + private[qa] def createQuestionComment(q: Question, c: Comment, u: User): Funit = ??? + // send( + // to = List(rudyEmail, questionAuthor.email) filterNot (u.email.==), + // subject = s"""${u.displaynameOrFullname} commented your question""", + // content = s"""New comment on prismic.io Q&A: ${questionUrl(question)}#comment-${c.id} + + +// By ${u.displaynameOrFullname} +// On question ${question.title} + +// ${c.body} + +// URL: ${questionUrl(question)}#comment-${c.id}""") + // case _ => Future successful () + + private[qa] def createAnswerComment(q: Question, a: Answer, c: Comment, u: User): Funit = + ??? + // QaApi.answer.withUser(a) flatMap { + // case Some(AnswerWithUser(answer, answerAuthor)) => send( + // to = List(rudyEmail, answerAuthor.email) filterNot (u.email.==), + // subject = s"""${u.displaynameOrFullname} commented your answer""", + // content = s"""New comment on prismic.io Q&A: ${questionUrl(q)}#comment-${c.id} + + +// By ${u.displaynameOrFullname} +// On question ${q.title} + +// ${c.body} + +// URL: ${questionUrl(q)}#comment-${c.id}""") + + private def questionUrl(q: Question) = + s"http://lichess.org/qa/${q.id}/${q.slug}" + + private def send(to: List[String], subject: String, content: String) = { + to foreach { recipient => + // common.utils.Mailer.send( + // to = recipient, + // from = Some(sender), + // fromname = Some(common.Wroom.domain), + // subject = s"[Q&A] $subject", + // content = Html(nl2br(content))) + } + fuccess(()) + } +} + diff --git a/modules/qa/src/main/QaApi.scala b/modules/qa/src/main/QaApi.scala new file mode 100644 index 000000000000..3d5a55667a09 --- /dev/null +++ b/modules/qa/src/main/QaApi.scala @@ -0,0 +1,394 @@ +package lila.qa + +import scala.concurrent.duration._ + +import reactivemongo.bson._ +import reactivemongo.core.commands.Count + +import org.joda.time.DateTime +import spray.caching.{ LruCache, Cache } + +import lila.common.paginator._ +import lila.db.BSON.BSONJodaDateTimeHandler +import lila.db.paginator._ +import lila.db.Types.Coll +import lila.user.{ User, UserRepo } + +final class QaApi(questionColl: Coll, answerColl: Coll, mailer: Mailer) { + + object question { + + private implicit val commentBSONHandler = Macros.handler[Comment] + private implicit val voteBSONHandler = Macros.handler[Vote] + private[qa] implicit val questionBSONHandler = Macros.handler[Question] + + def create(data: Forms.QuestionData, user: User): Fu[Question] = + lila.db.Util findNextId questionColl flatMap { id => + val q = Question( + _id = id, + userId = user.id, + title = data.title, + body = data.body, + tags = data.tags, + vote = Vote(Set.empty, Set.empty, 0), + favoriters = Set.empty, + comments = Nil, + views = 0, + answers = 0, + createdAt = DateTime.now, + updatedAt = DateTime.now, + acceptedAt = None, + editedAt = None) + + (questionColl insert q) >> + tag.clearCache >> + relation.clearCache inject q + } + + def edit(data: Forms.QuestionData, id: QuestionId): Fu[Option[Question]] = findById(id) flatMap { + case None => fuccess(none) + case Some(q) => + val q2 = q.copy(title = data.title, body = data.body, tags = data.tags).editNow + questionColl.update(BSONDocument("_id" -> q2.id), q2) >> + tag.clearCache >> + relation.clearCache inject Some(q2) + } + + def findById(id: QuestionId): Fu[Option[Question]] = + questionColl.find(BSONDocument("_id" -> id)).one[Question] + + def findByIds(ids: List[QuestionId]): Fu[List[Question]] = + questionColl.find(BSONDocument("_id" -> BSONDocument("$in" -> ids.distinct))).cursor[Question].collect[List]() + + def accept(q: Question) = questionColl.update( + BSONDocument("_id" -> q.id), + BSONDocument("$set" -> BSONDocument("acceptedAt" -> DateTime.now)) + ) >> profile.clearCache + + def count: Fu[Int] = questionColl.db command Count(questionColl.name, None) + + def paginatorWithUsers(page: Int, perPage: Int): Fu[Paginator[QuestionWithUser]] = + Paginator( + adapter = new BSONAdapter[Question]( + collection = questionColl, + selector = BSONDocument(), + sort = BSONDocument("createdAt" -> -1) + ) mapFutureList { + (qs: Seq[Question]) => zipWithUsers(qs.toList) + }, + currentPage = page, + maxPerPage = perPage) + + def recent(max: Int): Fu[List[Question]] = + questionColl.find(BSONDocument()) + .sort(BSONDocument("createdAt" -> -1)) + .cursor[Question].collect[List](max) + + def recentByUser(u: User, max: Int): Fu[List[Question]] = + questionColl.find(BSONDocument("userId" -> u.id)) + .sort(BSONDocument("createdAt" -> -1)) + .cursor[Question].collect[List](max) + + def favoriteByUser(u: User, max: Int): Fu[List[Question]] = + questionColl.find(BSONDocument("favoriters" -> u.id)) + .sort(BSONDocument("createdAt" -> -1)) + .cursor[Question].collect[List](max) + + def favoriters(q: Question): Fu[List[User]] = UserRepo byIds q.favoriters.toList + + def popular(max: Int): Fu[List[Question]] = + questionColl.find(BSONDocument()) + .sort(BSONDocument("vote.score" -> -1)) + .cursor[Question].collect[List](max) + + def byTag(tag: String, max: Int): Fu[List[Question]] = + questionColl.find(BSONDocument("tags" -> tag)) + .sort(BSONDocument("createdAt" -> -1)) + .cursor[Question].collect[List](max) + + def byTags(tags: List[String], max: Int): Fu[List[Question]] = + questionColl.find(BSONDocument("tags" -> BSONDocument("$in" -> tags))).cursor[Question].collect[List](max) + + def zipWithUsers(questions: List[Question]): Fu[List[QuestionWithUser]] = + UserRepo byIds questions.map(_.userId) map { users => + questions flatMap { question => + users find (_.id == question.userId) map question.withUser + } + } + + def withUsers(question: Question): Fu[Option[QuestionWithUsers]] = { + val userIds = question.userId :: question.comments.map(_.userId) + UserRepo byIds userIds map { users => + users find (_.id == question.userId) map { questionUser => + question.withUsers(questionUser, question.comments flatMap { comment => + users find (_.id == comment.userId) map comment.withUser + }) + } + } + } + + def withUser(question: Question): Fu[Option[QuestionWithUser]] = + UserRepo byId question.userId map { + _ map { QuestionWithUser(question, _) } + } + + def addComment(c: Comment)(q: Question) = questionColl.update( + BSONDocument("_id" -> q.id), + BSONDocument("$push" -> BSONDocument("comments" -> c))) + + def vote(id: QuestionId, user: User, v: Boolean): Fu[Option[Vote]] = + question findById id flatMap { + case Some(q) => + val newVote = q.vote.add(user.id, v) + questionColl.update( + BSONDocument("_id" -> q.id), + BSONDocument("$set" -> BSONDocument("vote" -> newVote, "updatedAt" -> DateTime.now)) + ) >> profile.clearCache inject Some(newVote) + case None => fuccess(none) + } + + def favorite(id: QuestionId, user: User, v: Boolean): Fu[Option[Question]] = + question findById id flatMap { + case Some(q) => + val newFavs = q.setFavorite(user.id, v) + questionColl.update( + BSONDocument("_id" -> q.id), + BSONDocument("$set" -> BSONDocument("favoriters" -> newFavs.favoriters, "updatedAt" -> DateTime.now)) + ) >> profile.clearCache inject Some(newFavs) + case None => fuccess(none) + } + + def incViews(q: Question) = questionColl.update( + BSONDocument("_id" -> q.id), + BSONDocument("$inc" -> BSONDocument("views" -> BSONInteger(1)))) + + def recountAnswers(id: QuestionId) = answer.countByQuestionId(id) flatMap { + setAnswers(id, _) + } + + def setAnswers(id: QuestionId, nb: Int) = questionColl.update( + BSONDocument("_id" -> id), + BSONDocument( + "$set" -> BSONDocument( + "answers" -> BSONInteger(nb), + "updatedAt" -> DateTime.now + ) + )).void + + def remove(id: QuestionId) = + questionColl.remove(BSONDocument("_id" -> id)) >> + (answer removeByQuestion id) >> + profile.clearCache >> + tag.clearCache >> + relation.clearCache + + def removeComment(id: QuestionId, c: CommentId) = questionColl.update( + BSONDocument("_id" -> id), + BSONDocument("$pull" -> BSONDocument("comments" -> BSONDocument("id" -> c))) + ) + } + + object answer { + + private implicit val commentBSONHandler = Macros.handler[Comment] + private implicit val voteBSONHandler = Macros.handler[Vote] + private implicit val answerBSONHandler = Macros.handler[Answer] + + def create(data: Forms.AnswerData, q: Question, user: User): Fu[Answer] = + lila.db.Util findNextId answerColl flatMap { id => + val a = Answer( + _id = id, + questionId = q.id, + userId = user.id, + body = data.body, + vote = Vote(Set.empty, Set.empty, 0), + comments = Nil, + acceptedAt = None, + createdAt = DateTime.now, + editedAt = None) + + (answerColl insert a) >> + (question recountAnswers q.id) >> { + question favoriters q flatMap { + mailer.createAnswer(q, a, user, _) + } + } inject a + } + + def edit(data: Forms.AnswerData, id: AnswerId): Fu[Option[Answer]] = findById(id) flatMap { + case None => fuccess(none) + case Some(a) => + val a2 = a.copy(body = data.body).editNow + answerColl.update(BSONDocument("_id" -> a2.id), a2) inject Some(a2) + } + + def findById(id: AnswerId): Fu[Option[Answer]] = + answerColl.find(BSONDocument("_id" -> id)).one[Answer] + + def accept(q: Question, a: Answer) = (question accept q) >> answerColl.update( + BSONDocument("questionId" -> q.id), + BSONDocument("$unset" -> BSONDocument("acceptedAt" -> true)), + multi = true + ) >> answerColl.update( + BSONDocument("_id" -> a.id), + BSONDocument("$set" -> BSONDocument("acceptedAt" -> DateTime.now)) + ) >> profile.clearCache + + def recentByUser(u: User, max: Int): Fu[List[Answer]] = + answerColl.find(BSONDocument("userId" -> u.id)) + .sort(BSONDocument("createdAt" -> -1)) + .cursor[Answer].collect[List](max) + + def popular(questionId: QuestionId): Fu[List[Answer]] = + answerColl.find(BSONDocument("questionId" -> questionId)) + .sort(BSONDocument("vote.score" -> -1)) + .cursor[Answer].collect[List]() + + def zipWithQuestions(answers: List[Answer]): Fu[List[AnswerWithQuestion]] = + question.findByIds(answers.map(_.questionId)) flatMap question.zipWithUsers map { qs => + answers flatMap { a => + qs find (_.question.id == a.questionId) map { AnswerWithQuestion(a, _) } + } + } + + def zipWithUsers(answers: List[Answer]): Fu[List[AnswerWithUserAndComments]] = { + val userIds = (answers.map(_.userId) ::: answers.flatMap(_.comments.map(_.userId))) + UserRepo byIds userIds.distinct map { users => + answers flatMap { answer => + users find (_.id == answer.userId) map { answerUser => + val commentsWithUsers = answer.comments flatMap { comment => + users find (_.id == comment.userId) map comment.withUser + } + answer.withUserAndComments(answerUser, commentsWithUsers) + } + } + } + } + + def withUser(answer: Answer): Fu[Option[AnswerWithUser]] = + UserRepo byId answer.userId map { + _ map { AnswerWithUser(answer, _) } + } + + def addComment(c: Comment)(a: Answer) = answerColl.update( + BSONDocument("_id" -> a.id), + BSONDocument("$push" -> BSONDocument("comments" -> c))) + + def vote(id: QuestionId, user: User, v: Boolean): Fu[Option[Vote]] = + answer findById id flatMap { + case Some(a) => + val newVote = a.vote.add(user.id, v) + answerColl.update( + BSONDocument("_id" -> a.id), + BSONDocument("$set" -> BSONDocument("vote" -> newVote, "updatedAt" -> DateTime.now)) + ) >> profile.clearCache inject Some(newVote) + case None => fuccess(none) + } + + def remove(a: Answer): Fu[Unit] = + answerColl.remove(BSONDocument("_id" -> a.id)) >> + profile.clearCache >> + (question recountAnswers a.questionId).void + + def remove(id: AnswerId): Fu[Unit] = findById(id) flatMap { + case None => funit + case Some(a) => remove(a) + } + + def removeByQuestion(id: QuestionId) = + answerColl.remove(BSONDocument("questionId" -> id)) >> profile.clearCache + + def removeComment(id: QuestionId, c: CommentId) = answerColl.update( + BSONDocument("questionId" -> id), + BSONDocument("$pull" -> BSONDocument("comments" -> BSONDocument("id" -> c))), + multi = true) + + def countByQuestionId(id: QuestionId) = + answerColl.db command Count(answerColl.name, Some(BSONDocument("questionId" -> id))) + } + + object comment { + + def create(data: Forms.CommentData, subject: Either[Question, Answer], user: User): Fu[Comment] = { + val c = Comment( + id = ornicar.scalalib.Random nextStringUppercase 8, + userId = user.id, + body = data.body, + createdAt = DateTime.now) + subject.fold(question addComment c, answer addComment c) >> { + subject match { + case Left(q) => funit + case Right(a) => question findById a.questionId flatMap { + case None => funit + case Some(q) => mailer.createAnswerComment(q, a, c, user) + } + } + } inject c + } + + def remove(questionId: QuestionId, commentId: CommentId) = + question.removeComment(questionId, commentId) >> + answer.removeComment(questionId, commentId) + + private implicit val commentBSONHandler = Macros.handler[Comment] + } + + object tag { + + private val cache: Cache[List[Tag]] = LruCache(timeToLive = 1.day) + + def clearCache = fuccess(cache.clear) + + // list all tags found in questions collection + def all: Fu[List[Tag]] = cache(true) { + import reactivemongo.core.commands._ + val command = Aggregate(questionColl.name, Seq( + Project("tags" -> BSONBoolean(true)), + Unwind("tags"), + Group(BSONBoolean(true))("tags" -> AddToSet("$tags")) + )) + questionColl.db.command(command) map { + _.headOption flatMap { + _.getAs[List[String]]("tags") + } getOrElse Nil + } + } + } + + object profile { + + private val cache: Cache[Profile] = LruCache(timeToLive = 1.day) + + def clearCache = fuccess(cache.clear) + + def apply(u: User): Fu[Profile] = cache(u.id) { + question.recentByUser(u, 300) zip answer.recentByUser(u, 500) map { + case (qs, as) => Profile( + reputation = math.max(0, qs.map { q => + q.vote.score + q.favoriters.size + }.sum + as.map { a => + a.vote.score + (if (a.accepted && !qs.exists(_.userId == a.userId)) 5 else 0) + }.sum), + questions = qs.size, + answers = as.size) + } + } + } + + object relation { + + private val questionsCache: Cache[List[Question]] = LruCache(timeToLive = 3.hours) + + def questions(q: Question, max: Int): Fu[List[Question]] = questionsCache(q.id -> max) { + question.byTags(q.tags, 2000) map { qs => + qs.filter(_.id != q.id) sortBy { q2 => + -q.tags.union(q2.tags).size + } take max + } + } + + def clearCache = fuccess(questionsCache.clear) + + // def tags(tag: Tag, max: Int): Fu[List[Tag]] = ??? + } +} diff --git a/modules/qa/src/main/SearchEngine.scala b/modules/qa/src/main/SearchEngine.scala new file mode 100644 index 000000000000..4fccc80962a7 --- /dev/null +++ b/modules/qa/src/main/SearchEngine.scala @@ -0,0 +1,46 @@ +package lila.qa + +import reactivemongo.bson._ +import reactivemongo.core.commands._ + +import lila.db.BSON.BSONJodaDateTimeHandler +import lila.db.Types.Coll + +final class SearchEngine(collection: Coll) { + + private implicit val commentBSONHandler = Macros.handler[Comment] + private implicit val voteBSONHandler = Macros.handler[Vote] + private[qa] implicit val questionBSONHandler = Macros.handler[Question] + + private type Result = List[BSONDocument] + + private case class Search( + collectionName: String, + search: String, + filter: Option[BSONDocument] = None) extends Command[Result] { + + override def makeDocuments = BSONDocument( + "text" -> collectionName, + "search" -> search, + "filter" -> filter) + + val ResultMaker = new BSONCommandResultMaker[Result] { + /** + * Deserializes the given response into an instance of Result. + */ + def apply(document: BSONDocument): Either[CommandError, Result] = + CommandError.checkOk(document, Some("search")) toLeft { + document.getAs[List[BSONDocument]]("results") getOrElse Nil + } + } + } + + def apply(q: String): Fu[List[Question]] = { + collection.db command Search(collection.name, q) map { + _.map { d => + d.getAs[BSONDocument]("obj") flatMap (_.asOpt[Question]) + }.flatten + } + } +} + diff --git a/modules/qa/src/main/model.scala b/modules/qa/src/main/model.scala new file mode 100644 index 000000000000..0af3029f8e26 --- /dev/null +++ b/modules/qa/src/main/model.scala @@ -0,0 +1,109 @@ +package lila.qa + +import org.joda.time._ + +import lila.user.User + +case class Question( + _id: QuestionId, // autoincrement integer + userId: String, + title: String, + body: String, // markdown + tags: List[String], + vote: Vote, + favoriters: Set[String], + comments: List[Comment], + views: Int, + answers: Int, + createdAt: DateTime, + updatedAt: DateTime, + acceptedAt: Option[DateTime], + editedAt: Option[DateTime]) { + + def id = _id + + def slug = { + val s = lila.common.String slugify title + if (s.isEmpty) "-" else s + } + + def withUser(user: User) = QuestionWithUser(this, user) + def withUsers(user: User, commentsWithUsers: List[CommentWithUser]) = + QuestionWithUsers(this, user, commentsWithUsers) + + def ownBy(user: User) = userId == user.id + + def withVote(f: Vote => Vote) = copy(vote = f(vote)) + + def updateNow = copy(updatedAt = DateTime.now) + def editNow = copy(editedAt = Some(DateTime.now)).updateNow + + def accepted = acceptedAt.isDefined + + def favorite(user: User): Boolean = favoriters(user.id) + + def setFavorite(userId: String, v: Boolean) = copy( + favoriters = if (v) favoriters + userId else favoriters - userId + ) +} + +case class QuestionWithUser(question: Question, user: User) +case class QuestionWithUsers(question: Question, user: User, comments: List[CommentWithUser]) + +case class Answer( + _id: AnswerId, + questionId: QuestionId, + userId: String, + body: String, + vote: Vote, + comments: List[Comment], + acceptedAt: Option[DateTime], + createdAt: DateTime, + editedAt: Option[DateTime]) { + + def id = _id + + def accepted = acceptedAt.isDefined + + def withVote(f: Vote => Vote) = copy(vote = f(vote)) + + def withUserAndComments(user: User, commentsWithUsers: List[CommentWithUser]) = + AnswerWithUserAndComments(this, user, commentsWithUsers) + + def ownBy(user: User) = userId == user.id + + def editNow = copy(editedAt = Some(DateTime.now)) +} + +case class AnswerWithUser(answer: Answer, user: User) +case class AnswerWithUserAndComments(answer: Answer, user: User, comments: List[CommentWithUser]) + +case class AnswerWithQuestion(answer: Answer, question: QuestionWithUser) + +case class Vote(up: Set[String], down: Set[String], score: Int) { + + def add(user: String, v: Boolean) = (if (v) addUp _ else addDown _)(user) + def addUp(user: String) = copy(up = up + user, down = down - user).computeScore + def addDown(user: String) = copy(up = up - user, down = down + user).computeScore + + def of(userId: String): Option[Boolean] = + if (up(userId)) Some(true) + else if (down(userId)) Some(false) + else None + + private def computeScore = copy(score = up.size - down.size) +} + +case class Comment( + id: CommentId, // random string + userId: String, + body: String, + createdAt: DateTime) { + + def withUser(user: User) = CommentWithUser(this, user) +} + +case class CommentWithUser(comment: Comment, user: User) + +case class Profile(reputation: Int, questions: Int, answers: Int) + diff --git a/modules/qa/src/main/package.scala b/modules/qa/src/main/package.scala new file mode 100644 index 000000000000..7576092d0cb1 --- /dev/null +++ b/modules/qa/src/main/package.scala @@ -0,0 +1,10 @@ +package lila + +package object qa extends PackageObject with WithPlay { + + type Tag = String + type QuestionId = Int + type AnswerId = Int + type RelId = String + type CommentId = String +} diff --git a/project/Build.scala b/project/Build.scala index c5cbc5287af9..f8014d816223 100644 --- a/project/Build.scala +++ b/project/Build.scala @@ -40,7 +40,7 @@ object ApplicationBuild extends Build { gameSearch, timeline, forum, forumSearch, team, teamSearch, ai, analyse, mod, monitor, site, round, lobby, setup, importer, tournament, pool, relation, report, pref, // simulation, - evaluation, chat, puzzle, tv, coordinate, blog, donation) + evaluation, chat, puzzle, tv, coordinate, blog, donation, qa) lazy val moduleRefs = modules map projectToRef lazy val moduleCPDeps = moduleRefs map { new sbt.ClasspathDependency(_, None) } @@ -61,6 +61,10 @@ object ApplicationBuild extends Build { libraryDependencies ++= provided(play.api, RM, PRM) ) + lazy val qa = project("qa", Seq(common, db, memo, user)).settings( + libraryDependencies ++= provided(play.api, RM, PRM) + ) + lazy val blog = project("blog", Seq(common, memo, user, message)).settings( libraryDependencies ++= provided(play.api, RM, PRM, prismic) )