From 5b376ce5c9972f4876f4e79da64a02d78b02884e Mon Sep 17 00:00:00 2001 From: Menci Date: Sat, 15 Apr 2017 00:25:51 +0800 Subject: [PATCH] Add problem tag support --- app.js | 18 ++++++-- config-example.json | 3 +- models/common.js | 43 ++++++++++++------ models/problem.js | 18 ++++++++ models/problem_tag.js | 54 ++++++++++++++++++++++ models/problem_tag_map.js | 62 +++++++++++++++++++++++++ modules/api_v2.js | 20 ++++++++- modules/problem.js | 83 +++++++++++++++++++++++++++++++++- modules/problem_tag.js | 78 ++++++++++++++++++++++++++++++++ utility.js | 5 +++ views/contest_edit.ejs | 4 +- views/problem.ejs | 25 +++++++++++ views/problem_edit.ejs | 41 ++++++++++++----- views/problems.ejs | 95 ++++++++++++++++++++++++++++++++++++--- 14 files changed, 511 insertions(+), 38 deletions(-) create mode 100644 models/problem_tag.js create mode 100644 models/problem_tag_map.js create mode 100644 modules/problem_tag.js diff --git a/app.js b/app.js index 8abe36f..33dc2b3 100644 --- a/app.js +++ b/app.js @@ -74,7 +74,8 @@ global.syzoj = { }); global.Promise = Sequelize.Promise; this.db.countQuery = async (sql, options) => (await this.db.query(`SELECT COUNT(*) FROM (${sql}) AS \`__tmp_table\``, options))[0][0]['COUNT(*)']; - this.db.sync(); + + this.loadModels(); }, loadModules() { fs.readdir('./modules/', (err, files) => { @@ -86,9 +87,20 @@ global.syzoj = { .forEach((file) => this.modules.push(require(`./modules/${file}`))); }); }, + loadModels() { + fs.readdir('./models/', (err, files) => { + if (err) { + this.log(err); + return; + } + files.filter((file) => file.endsWith('.js')) + .forEach((file) => require(`./models/${file}`)); + + this.db.sync(); + }); + }, model(name) { - if (this.models[name] !== undefined) return this.models[name]; - else return this.models[name] = require(`./models/${name}`); + return require(`./models/${name}`); }, loadHooks() { let Session = require('express-session'); diff --git a/config-example.json b/config-example.json index f5b89af..61a0929 100644 --- a/config-example.json +++ b/config-example.json @@ -28,7 +28,8 @@ "discussion": 10, "article_comment": 10, "contest": 10, - "edit_contest_problem_list": 10 + "edit_contest_problem_list": 10, + "edit_problem_tag_list": 10 }, "languages": { "cpp": { diff --git a/models/common.js b/models/common.js index 5bf70bc..5dfc3b2 100644 --- a/models/common.js +++ b/models/common.js @@ -84,24 +84,41 @@ class Model { } static async count(where) { + // count(sql) + if (typeof where === 'string') { + let sql = where; + return syzoj.db.countQuery(sql); + } + + // count(where) return this.model.count({ where: where }); } static async query(paginate, where, order) { - if (paginate && !Array.isArray(paginate) && !paginate.pageCnt) return []; - - let options = { - where: where, - order: order - }; - if (Array.isArray(paginate)) { - options.offset = paginate[0] - 1; - options.limit = paginate[1] - paginate[0] + 1; - } else if (paginate) { - options.offset = (paginate.currPage - 1) * paginate.perPage; - options.limit = paginate.perPage; + let records = []; + + if (typeof paginate === 'string') { + // query(sql) + let sql = paginate; + records = await syzoj.db.query(sql, { model: this.model }); + } else { + if (paginate && !Array.isArray(paginate) && !paginate.pageCnt) return []; + + let options = { + where: where, + order: order + }; + if (Array.isArray(paginate)) { + options.offset = paginate[0] - 1; + options.limit = paginate[1] - paginate[0] + 1; + } else if (paginate) { + options.offset = (paginate.currPage - 1) * paginate.perPage; + options.limit = paginate.perPage; + } + + records = await this.model.findAll(options); } - let records = await this.model.findAll(options); + return records.mapAsync(record => (this.fromRecord(record))); } } diff --git a/models/problem.js b/models/problem.js index 1f15fa0..61d9d34 100644 --- a/models/problem.js +++ b/models/problem.js @@ -353,6 +353,24 @@ class Problem extends Model { return statistics; } + async getTags() { + let ProblemTagMap = syzoj.model('problem_tag_map'); + let maps = await ProblemTagMap.query(null, { + problem_id: this.id + }); + + let ProblemTag = syzoj.model('problem_tag'); + let res = await maps.mapAsync(async map => { + return ProblemTag.fromID(map.tag_id); + }); + + res.sort((a, b) => { + return a.name > b.name ? 1 : -1; + }); + + return res; + } + getModel() { return model; } } diff --git a/models/problem_tag.js b/models/problem_tag.js new file mode 100644 index 0000000..09f7077 --- /dev/null +++ b/models/problem_tag.js @@ -0,0 +1,54 @@ +/* + * This file is part of SYZOJ. + * + * Copyright (c) 2016 Menci + * + * SYZOJ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * SYZOJ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with SYZOJ. If not, see . + */ + +'use strict'; + +let Sequelize = require('sequelize'); +let db = syzoj.db; + +let model = db.define('problem_tag', { + id: { type: Sequelize.INTEGER, primaryKey: true, autoIncrement: true }, + name: { type: Sequelize.STRING }, + color: { type: Sequelize.STRING }, +}, { + timestamps: false, + tableName: 'problem_tag', + indexes: [ + { + unique: true, + fields: ['name'], + } + ] +}); + +let Model = require('./common'); +class ProblemTag extends Model { + static async create(val) { + return ProblemTag.fromRecord(ProblemTag.model.build(Object.assign({ + name: '', + color: '' + }, val))); + } + + getModel() { return model; } +} + +ProblemTag.model = model; + +module.exports = ProblemTag; diff --git a/models/problem_tag_map.js b/models/problem_tag_map.js new file mode 100644 index 0000000..61c33bd --- /dev/null +++ b/models/problem_tag_map.js @@ -0,0 +1,62 @@ +/* + * This file is part of SYZOJ. + * + * Copyright (c) 2016 Menci + * + * SYZOJ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * SYZOJ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with SYZOJ. If not, see . + */ + +'use strict'; + +let Sequelize = require('sequelize'); +let db = syzoj.db; + +let model = db.define('problem_tag_map', { + problem_id: { type: Sequelize.INTEGER, primaryKey: true }, + tag_id: { + type: Sequelize.INTEGER, + primaryKey: true, + references: { + model: 'problem_tag', + key: 'id' + } + } +}, { + timestamps: false, + tableName: 'problem_tag_map', + indexes: [ + { + fields: ['problem_id'] + }, + { + fields: ['tag_id'] + } + ] +}); + +let Model = require('./common'); +class ProblemTagMap extends Model { + static async create(val) { + return ProblemTagMap.fromRecord(ProblemTagMap.model.build(Object.assign({ + problem_id: 0, + tag_id: 0 + }, val))); + } + + getModel() { return model; } +} + +ProblemTagMap.model = model; + +module.exports = ProblemTagMap; diff --git a/modules/api_v2.js b/modules/api_v2.js index 9109328..2f54e32 100644 --- a/modules/api_v2.js +++ b/modules/api_v2.js @@ -20,8 +20,9 @@ 'use strict'; let Problem = syzoj.model('problem'); +let ProblemTag = syzoj.model('problem_tag'); -app.get('/api/v2/search/problem/:keyword*?', async (req, res) => { +app.get('/api/v2/search/problems/:keyword*?', async (req, res) => { try { let keyword = req.params.keyword || ''; let problems = await Problem.query(null, { @@ -51,6 +52,23 @@ app.get('/api/v2/search/problem/:keyword*?', async (req, res) => { } }); +app.get('/api/v2/search/tags/:keyword*?', async (req, res) => { + try { + let keyword = req.params.keyword || ''; + let tags = await ProblemTag.query(null, { + name: { like: `%${req.params.keyword}%` } + }, [['name', 'asc']]); + + let result = tags.slice(0, syzoj.config.page.edit_problem_tag_list); + + result = result.map(x => ({ name: x.name, value: x.id })); + res.send({ success: true, results: result }); + } catch (e) { + syzoj.log(e); + res.send({ success: false }); + } +}); + app.get('/api/v2/hitokoto', async (req, res) => { try { res.send(await syzoj.utils.hitokoto()); diff --git a/modules/problem.js b/modules/problem.js index 6766fb6..c989ebb 100644 --- a/modules/problem.js +++ b/modules/problem.js @@ -23,6 +23,8 @@ let Problem = syzoj.model('problem'); let JudgeState = syzoj.model('judge_state'); let WaitingJudge = syzoj.model('waiting_judge'); let Contest = syzoj.model('contest'); +let ProblemTag = syzoj.model('problem_tag'); +let ProblemTagMap = syzoj.model('problem_tag_map'); app.get('/problems', async (req, res) => { try { @@ -32,6 +34,7 @@ app.get('/problems', async (req, res) => { await problems.forEachAsync(async problem => { problem.allowedEdit = await problem.isAllowedEditBy(res.locals.user); problem.judge_state = await problem.getJudgeState(res.locals.user, true); + problem.tags = await problem.getTags(); }); res.render('problems', { @@ -46,6 +49,49 @@ app.get('/problems', async (req, res) => { } }); +app.get('/problems/tag/:tagIDs', async (req, res) => { + try { + let tagIDs = Array.from(new Set(req.params.tagIDs.split(',').map(x => parseInt(x)))); + let tags = await tagIDs.mapAsync(async tagID => ProblemTag.fromID(tagID)); + + // Validate the tagIDs + for (let tag of tags) { + if (!tag) { + return res.redirect(syzoj.utils.makeUrl(['problems'])); + } + } + + let sql = 'SELECT * FROM `problem` WHERE\n'; + for (let tagID of tagIDs) { + if (tagID !== tagIDs[0]) { + sql += 'AND\n'; + } + + sql += '`problem`.`id` IN (SELECT `problem_id` FROM `problem_tag_map` WHERE `tag_id` = ' + tagID + ')'; + } + + let paginate = syzoj.utils.paginate(await Problem.count(sql), req.query.page, syzoj.config.page.problem); + let problems = await Problem.query(sql); + + await problems.forEachAsync(async problem => { + problem.allowedEdit = await problem.isAllowedEditBy(res.locals.user); + problem.judge_state = await problem.getJudgeState(res.locals.user, true); + problem.tags = await problem.getTags(); + }); + + res.render('problems', { + problems: problems, + tags: tags, + paginate: paginate + }); + } catch (e) { + syzoj.log(e); + res.render('error', { + err: e + }); + } +}); + app.get('/problem/:id', async (req, res) => { try { let id = parseInt(req.params.id); @@ -66,6 +112,8 @@ app.get('/problem/:id', async (req, res) => { let state = await problem.getJudgeState(res.locals.user, false); + problem.tags = await problem.getTags(); + res.render('problem', { problem: problem, state: state @@ -80,7 +128,7 @@ app.get('/problem/:id', async (req, res) => { app.get('/problem/:id/edit', async (req, res) => { try { - let id = parseInt(req.params.id); + let id = parseInt(req.params.id) || 0; let problem = await Problem.fromID(id); if (!problem) { @@ -88,9 +136,11 @@ app.get('/problem/:id/edit', async (req, res) => { problem = await Problem.create(); problem.id = id; problem.allowedEdit = true; + problem.tags = []; } else { if (!await problem.isAllowedUseBy(res.locals.user)) throw 'Permission denied.'; problem.allowedEdit = await problem.isAllowedEditBy(res.locals.user); + problem.tags = await problem.getTags(); } res.render('problem_edit', { @@ -124,8 +174,39 @@ app.post('/problem/:id/edit', async (req, res) => { problem.example = req.body.example; problem.limit_and_hint = req.body.limit_and_hint; + // Save the problem first, to have the `id` allocated await problem.save(); + if (!req.body.tags) { + req.body.tags = []; + } else if (!Array.isArray(req.body.tags)) { + req.body.tags = [req.body.tags]; + } + + let oldTagIDs = (await problem.getTags()).map(x => x.id); + let newTagIDs = await req.body.tags.map(x => parseInt(x)).filterAsync(async x => ProblemTag.fromID(x)); + + let delTagIDs = oldTagIDs.filter(x => !newTagIDs.includes(x)); + let addTagIDs = newTagIDs.filter(x => !oldTagIDs.includes(x)); + + for (let tagID of delTagIDs) { + let map = await ProblemTagMap.findOne({ where: { + problem_id: id, + tag_id: tagID + } }); + + await map.destroy(); + } + + for (let tagID of addTagIDs) { + let map = await ProblemTagMap.create({ + problem_id: id, + tag_id: tagID + }); + + await map.save(); + } + res.redirect(syzoj.utils.makeUrl(['problem', problem.id])); } catch (e) { syzoj.log(e); diff --git a/modules/problem_tag.js b/modules/problem_tag.js new file mode 100644 index 0000000..75340dd --- /dev/null +++ b/modules/problem_tag.js @@ -0,0 +1,78 @@ +/* + * This file is part of SYZOJ. + * + * Copyright (c) 2016 Menci + * + * SYZOJ is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * SYZOJ is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with SYZOJ. If not, see . + */ + +'use strict'; + +let ProblemTag = syzoj.model('problem_tag'); + +app.get('/problems/tag/:id/edit', async (req, res) => { + try { + if (!res.locals.user && !res.locals.user.is_admin) throw 'Permission denied.'; + + let id = parseInt(req.params.id) || 0; + let tag = await ProblemTag.fromID(id); + + if (!tag) { + tag = await ProblemTag.create(); + tag.id = id; + } + + res.render('problem_tag_edit', { + tag: tag + }); + } catch (e) { + syzoj.log(e); + res.render('error', { + err: e + }); + } +}); + +app.post('/problems/tag/:id/edit', async (req, res) => { + try { + if (!res.locals.user || !res.locals.user.is_admin) throw 'Permission denied.'; + + let id = parseInt(req.params.id) || 0; + let tag = await ProblemTag.fromID(id); + + if (!tag) { + tag = await ProblemTag.create(); + tag.id = id; + } + + req.body.name = req.body.name.trim(); + if (tag.name !== req.body.name) { + if (await ProblemTag.findOne({ where: { name: req.body.name } })) { + throw 'There is already a tag with that name.'; + } + } + + tag.name = req.body.name; + tag.color = req.body.color; + + await tag.save(); + + res.redirect(syzoj.utils.makeUrl(['problems', 'tag', tag.id])); + } catch (e) { + syzoj.log(e); + res.render('error', { + err: e + }); + } +}); diff --git a/utility.js b/utility.js index be77803..b4dc8fd 100644 --- a/utility.js +++ b/utility.js @@ -23,6 +23,11 @@ Array.prototype.forEachAsync = Array.prototype.mapAsync = async function (fn) { return Promise.all(this.map(fn)); }; +Array.prototype.filterAsync = async function (fn) { + let a = await this.mapAsync(fn); + return this.filter((x, i) => a[i]); +}; + let path = require('path'); let util = require('util'); let renderer = require('moemark-renderer'); diff --git a/views/contest_edit.ejs b/views/contest_edit.ejs index 3f2e9cf..6f9f6e3 100644 --- a/views/contest_edit.ejs +++ b/views/contest_edit.ejs @@ -34,9 +34,9 @@ $(function () { .dropdown({ debug: true, apiSettings: { - url: '/api/v2/search/problem/{query}', + url: '/api/v2/search/problems/{query}', onResponse: function (response) { - var a = $('#search_problems').val().map(x => parseInt(x)); + var a = $('#search_problems').val().map(function (x) { return parseInt(x) }); if (response.results) { response.results = response.results.filter(x => !a.includes(parseInt(x.value))); } diff --git a/views/problem.ejs b/views/problem.ejs index 7a31548..4ba8c26 100644 --- a/views/problem.ejs +++ b/views/problem.ejs @@ -116,6 +116,31 @@ if (contest) {
<%- problem.limit_and_hint %>
+ <% } %> + <% if (problem.tags && problem.tags.length) { %> +
+
+

显示分类标签

+ +
+
+ <% } %> <% let noSubmit = false; %> <% diff --git a/views/problem_edit.ejs b/views/problem_edit.ejs index 3a8ccfd..8180ea4 100644 --- a/views/problem_edit.ejs +++ b/views/problem_edit.ejs @@ -1,8 +1,4 @@ -<% if (problem.id) { - this.title = '修改题目'; -} else { - this.title = '添加题目'; -} %> +<% this.title = '编辑题目'; %> <% include header %> + + + + + +
+ <% if (user && user.is_admin) { %> + <% if (typeof tags !== 'undefined' && tags.length === 1) { %> + 编辑标签 + <% } %> + 添加标签 + <% } %> + <% if (user) { %> + 添加题目 + <% } %>
- <% } %> + + @@ -18,6 +75,7 @@ <% } %> + @@ -40,8 +98,31 @@ <% } %> +
编号 题目名称  通过 提交 通过率<%= problem.id %> <%= problem.title %> - <% if (!problem.is_public) { %>未公开<% } %> + <% if (!problem.is_public) { %>未公开<% } %> +
+ <% + if (problem.tags) { + for (let tag of problem.tags) { + let tagListToggledThisTag; + if (!tagIDs.includes(tag.id)) tagListToggledThisTag = tagIDs.concat([tag.id]); + else tagListToggledThisTag = tagIDs.filter(x => x != tag.id); + tagListToggledThisTag = tagListToggledThisTag.sort().join(','); + + let url = tagListToggledThisTag ? syzoj.utils.makeUrl(['problems', 'tag', tagListToggledThisTag]) : syzoj.utils.makeUrl(['problems']); + %> + + + <%= tag.name %> + + + <% + } + } + %> +
+
<%= problem.ac_num %> <%= problem.submit_num %> <%= ((problem.ac_num / problem.submit_num * 100) || 0).toFixed(2) %>%