diff --git a/packages/hydrooj/package.json b/packages/hydrooj/package.json index f7001d2de..0434a6073 100644 --- a/packages/hydrooj/package.json +++ b/packages/hydrooj/package.json @@ -1,6 +1,6 @@ { "name": "hydrooj", - "version": "4.2.12", + "version": "4.2.13", "bin": "bin/hydrooj.js", "main": "src/plugin-api", "module": "src/plugin-api", diff --git a/packages/hydrooj/src/handler/discussion.ts b/packages/hydrooj/src/handler/discussion.ts index 42f842196..7445d4609 100644 --- a/packages/hydrooj/src/handler/discussion.ts +++ b/packages/hydrooj/src/handler/discussion.ts @@ -272,7 +272,7 @@ class DiscussionDetailHandler extends DiscussionHandler { this.checkPerm(PERM.PERM_EDIT_DISCUSSION_REPLY_SELF); if (!this.user.own(this.drdoc)) throw new PermissionError(PERM.PERM_EDIT_DISCUSSION_REPLY_SELF); await Promise.all([ - discussion.editReply(domainId, drid, content), + discussion.editReply(domainId, drid, content, this.user._id, this.request.ip), oplog.log(this, 'discussion.reply.edit', this.drdoc), ]); this.back(); @@ -312,7 +312,7 @@ class DiscussionDetailHandler extends DiscussionHandler { this.checkPerm(PERM.PERM_EDIT_DISCUSSION_REPLY_SELF); if (!this.user.own(this.drrdoc)) throw new PermissionError(PERM.PERM_EDIT_DISCUSSION_REPLY_SELF); await Promise.all([ - discussion.editTailReply(domainId, drid, drrid, content), + discussion.editTailReply(domainId, drid, drrid, content, this.user._id, this.request.ip), oplog.log(this, 'discussion.tailReply.edit', this.drrdoc), ]); this.back(); @@ -354,11 +354,20 @@ class DiscussionDetailHandler extends DiscussionHandler { } class DiscussionRawHandler extends DiscussionHandler { + @param('did', Types.ObjectID, true) @param('drid', Types.ObjectID, true) @param('drrid', Types.ObjectID, true) - async get(domainId: string, drid: ObjectID, drrid: ObjectID) { - this.response.type = 'text/markdown'; - this.response.body = drrid ? this.drrdoc.content : drid ? this.drdoc.content : this.ddoc.content; + @param('time', Types.UnsignedInt, true) + @param('all', Types.Boolean, true) + async get(domainId: string, did: ObjectID, drid: ObjectID, drrid: ObjectID, ts: number, all = false) { + if (all) { + this.response.body.history = await discussion.getHistory(domainId, drrid || drid || did); + } else { + const [doc] = await discussion.getHistory(domainId, drrid || drid || did, ts ? { time: new Date(ts) } : {}); + if (!doc && ts) throw new DiscussionNotFoundError(drrid || drid || did); + this.response.type = 'text/markdown'; + this.response.body = doc ? doc.content : drrid ? this.drrdoc.content : drid ? this.drdoc.content : this.ddoc.content; + } } } @@ -390,7 +399,7 @@ class DiscussionEditHandler extends DiscussionHandler { if (!this.user.hasPerm(PERM.PERM_PIN_DISCUSSION)) pin = this.ddoc.pin; await Promise.all([ discussion.edit(domainId, did, { - title, content, highlight, pin, + title, highlight, pin, content, editor: this.user._id, edited: true, }), oplog.log(this, 'discussion.edit', this.ddoc), ]); @@ -427,7 +436,7 @@ export async function apply(ctx) { ctx.Route('discussion_main', '/discuss', DiscussionMainHandler); ctx.Route('discussion_detail', '/discuss/:did', DiscussionDetailHandler); ctx.Route('discussion_edit', '/discuss/:did/edit', DiscussionEditHandler); - ctx.Route('discussion_detail', '/discuss/:did/raw', DiscussionRawHandler); + ctx.Route('discussion_raw', '/discuss/:did/raw', DiscussionRawHandler); ctx.Route('discussion_reply_raw', '/discuss/:did/:drid/raw', DiscussionRawHandler); ctx.Route('discussion_tail_reply_raw', '/discuss/:did/:drid/:drrid/raw', DiscussionRawHandler); ctx.Route('discussion_node', '/discuss/:type/:name', DiscussionNodeHandler); diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index 2ec3cef2b..dff13fd1c 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -405,11 +405,6 @@ export interface BlacklistDoc { expireAt: Date; } -export interface HistoryDoc { - content: string; - time: Date; -} - // Discussion export type { DiscussionDoc } from './model/discussion'; declare module './model/discussion' { @@ -426,7 +421,8 @@ declare module './model/discussion' { updateAt: Date; nReply: number; views: number; - history: HistoryDoc[]; + edited?: boolean; + editor?: number; react: Record; sort: number; lastRCount: number; @@ -442,16 +438,18 @@ export interface DiscussionReplyDoc extends Document { ip: string; content: string; reply: DiscussionTailReplyDoc[]; - history: HistoryDoc[]; + edited?: boolean; + editor?: number; react: Record; } export interface DiscussionTailReplyDoc { - _id: ObjectID, - owner: number, - content: string, - ip: string, - history: HistoryDoc[], + _id: ObjectID; + owner: number; + content: string; + ip: string; + edited?: boolean; + editor?:number; } export interface BlogDoc { @@ -602,6 +600,17 @@ export interface OauthMap { uid: number; } +export interface DiscussionHistoryDoc { + title?: string; + content: string; + domainId: string; + docId: ObjectID; + /** Create time */ + time: Date; + uid: number; + ip: string; +} + declare module './service/db' { interface Collections { 'blacklist': BlacklistDoc; @@ -610,6 +619,7 @@ declare module './service/db' { 'record': RecordDoc; 'document': any; 'document.status': StatusDocBase; + 'discussion.history': DiscussionHistoryDoc; 'user': Udoc; 'user.preference': UserPreferenceDoc; 'vuser': VUdoc; diff --git a/packages/hydrooj/src/model/discussion.ts b/packages/hydrooj/src/model/discussion.ts index e182f035f..769bcd856 100644 --- a/packages/hydrooj/src/model/discussion.ts +++ b/packages/hydrooj/src/model/discussion.ts @@ -3,8 +3,11 @@ import moment from 'moment'; import { FilterQuery, ObjectID } from 'mongodb'; import { Context } from '../context'; import { DiscussionNodeNotFoundError, DocumentNotFoundError } from '../error'; -import { DiscussionReplyDoc, DiscussionTailReplyDoc, Document } from '../interface'; +import { + DiscussionHistoryDoc, DiscussionReplyDoc, DiscussionTailReplyDoc, Document, +} from '../interface'; import * as bus from '../service/bus'; +import db from '../service/db'; import { NumberKeys } from '../typeutils'; import { buildProjection } from '../utils'; import * as contest from './contest'; @@ -22,9 +25,12 @@ export const PROJECTION_LIST: Field[] = [ 'parentId', 'parentType', 'title', ]; export const PROJECTION_PUBLIC: Field[] = [ - ...PROJECTION_LIST, 'content', 'history', 'react', 'maintainer', + ...PROJECTION_LIST, 'content', 'edited', 'react', 'maintainer', 'lock', ]; +export const HISTORY_PROJECTION_PUBLIC: (keyof DiscussionHistoryDoc)[] = [ + 'title', 'content', 'docId', 'uid', 'time', +]; export const typeDisplay = { [document.TYPE_PROBLEM]: 'problem', @@ -34,15 +40,19 @@ export const typeDisplay = { [document.TYPE_HOMEWORK]: 'homework', }; +export const coll = db.collection('discussion.history'); + export async function add( domainId: string, parentType: number, parentId: ObjectID | number | string, owner: number, title: string, content: string, ip: string | null = null, highlight: boolean, pin: boolean, ): Promise { + const time = new Date(); const payload: Partial = { domainId, content, owner, + editor: owner, parentType, parentId, title, @@ -50,7 +60,7 @@ export async function add( nReply: 0, highlight, pin, - updateAt: new Date(), + updateAt: time, views: 0, sort: 100, }; @@ -70,7 +80,10 @@ export async function get( return await document.get(domainId, document.TYPE_DISCUSSION, did, projection); } -export function edit(domainId: string, did: ObjectID, $set: Partial) { +export async function edit(domainId: string, did: ObjectID, $set: Partial) { + await coll.insertOne({ + domainId, docId: did, content: $set.content, uid: $set.editor, ip: $set.ip, time: new Date(), + }); return document.set(domainId, document.TYPE_DISCUSSION, did, $set); } @@ -80,13 +93,20 @@ export function inc( return document.inc(domainId, document.TYPE_DISCUSSION, did, key, value); } -export function del(domainId: string, did: ObjectID): Promise { - return Promise.all([ +export async function del(domainId: string, did: ObjectID): Promise { + const [ddoc, drdocs] = await Promise.all([ + document.get(domainId, document.TYPE_DISCUSSION, did), + document.getMulti(domainId, document.TYPE_DISCUSSION_REPLY, { + parentType: document.TYPE_DISCUSSION, parentId: did, + }).project({ _id: 1, 'reply._id': 1 }).toArray(), + ]) as any; + await Promise.all([ document.deleteOne(domainId, document.TYPE_DISCUSSION, did), document.deleteMulti(domainId, document.TYPE_DISCUSSION_REPLY, { parentType: document.TYPE_DISCUSSION, parentId: did, }), document.deleteMultiStatus(domainId, document.TYPE_DISCUSSION, { docId: did }), + coll.deleteMany({ domainId, docId: { $in: [ddoc._id, ...(drdocs.reply?.map((i) => i._id) || [])] } }), ]) as any; } @@ -104,13 +124,17 @@ export async function addReply( domainId: string, did: ObjectID, owner: number, content: string, ip: string, ): Promise { + const time = new Date(); const [drid] = await Promise.all([ document.add( domainId, content, owner, document.TYPE_DISCUSSION_REPLY, - null, document.TYPE_DISCUSSION, did, { ip }, + null, document.TYPE_DISCUSSION, did, { ip, editor: owner }, ), - document.incAndSet(domainId, document.TYPE_DISCUSSION, did, 'nReply', 1, { updateAt: new Date() }), + document.incAndSet(domainId, document.TYPE_DISCUSSION, did, 'nReply', 1, { updateAt: time }), ]); + await coll.insertOne({ + domainId, docId: drid, content, uid: owner, ip, time, + }); return drid; } @@ -119,9 +143,12 @@ export function getReply(domainId: string, drid: ObjectID): Promise { - return document.set(domainId, document.TYPE_DISCUSSION_REPLY, drid, { content }); + await coll.insertOne({ + domainId, docId: drid, content, uid, ip, time: new Date(), + }); + return document.set(domainId, document.TYPE_DISCUSSION_REPLY, drid, { content, edited: true, editor: uid }); } export async function delReply(domainId: string, drid: ObjectID) { @@ -130,6 +157,7 @@ export async function delReply(domainId: string, drid: ObjectID) { return await Promise.all([ document.deleteOne(domainId, document.TYPE_DISCUSSION_REPLY, drid), document.inc(domainId, document.TYPE_DISCUSSION, drdoc.parentId, 'nReply', -1), + coll.deleteMany({ domainId, docId: { $in: [drid, ...(drdoc.reply?.map((i) => i._id) || [])] } }), ]); } @@ -161,14 +189,20 @@ export async function addTailReply( domainId: string, drid: ObjectID, owner: number, content: string, ip: string, ): Promise<[DiscussionReplyDoc, ObjectID]> { + const time = new Date(); const [drdoc, subId] = await document.push( domainId, document.TYPE_DISCUSSION_REPLY, drid, - 'reply', content, owner, { ip }, - ); - await document.set( - domainId, document.TYPE_DISCUSSION, drdoc.parentId, - { updateAt: new Date() }, + 'reply', content, owner, { ip, editor: owner }, ); + await Promise.all([ + coll.insertOne({ + domainId, docId: subId, content, uid: owner, ip, time: new Date(), + }), + document.set( + domainId, document.TYPE_DISCUSSION, drdoc.parentId, + { updateAt: time }, + ), + ]); return [drdoc, subId]; } @@ -178,14 +212,33 @@ export function getTailReply( return document.getSub(domainId, document.TYPE_DISCUSSION_REPLY, drid, 'reply', drrid); } -export function editTailReply( - domainId: string, drid: ObjectID, drrid: ObjectID, content: string, +export async function editTailReply( + domainId: string, drid: ObjectID, drrid: ObjectID, content: string, uid: number, ip: string, ): Promise { - return document.setSub(domainId, document.TYPE_DISCUSSION_REPLY, drid, 'reply', drrid, { content }); + const [, drrdoc] = await Promise.all([ + coll.insertOne({ + domainId, docId: drrid, content, uid, time: new Date(), ip, + }), + document.setSub(domainId, document.TYPE_DISCUSSION_REPLY, drid, + 'reply', drrid, { content, edited: true, editor: uid }), + ]); + return drrdoc; } export async function delTailReply(domainId: string, drid: ObjectID, drrid: ObjectID) { - return document.deleteSub(domainId, document.TYPE_DISCUSSION_REPLY, drid, 'reply', drrid); + return Promise.all([ + document.deleteSub(domainId, document.TYPE_DISCUSSION_REPLY, drid, 'reply', drrid), + coll.deleteMany({ domainId, docId: drrid }), + ]); +} + +export function getHistory( + domainId: string, docId: ObjectID, query: FilterQuery = {}, + projection = HISTORY_PROJECTION_PUBLIC, +) { + return coll.find({ domainId, docId, ...query }) + .sort({ time: -1 }).project(buildProjection(projection)) + .toArray(); } export function setStar(domainId: string, did: ObjectID, uid: number, star: boolean) { @@ -306,9 +359,11 @@ export function apply(ctx: Context) { } global.Hydro.model.discussion = { + coll, typeDisplay, PROJECTION_LIST, PROJECTION_PUBLIC, + HISTORY_PROJECTION_PUBLIC, apply, add, @@ -330,6 +385,7 @@ global.Hydro.model.discussion = { delTailReply, react, getReaction, + getHistory, setStar, getStatus, setStatus, diff --git a/packages/hydrooj/src/model/document.ts b/packages/hydrooj/src/model/document.ts index 23ca953dd..2a764a033 100644 --- a/packages/hydrooj/src/model/document.ts +++ b/packages/hydrooj/src/model/document.ts @@ -1,7 +1,7 @@ /* eslint-disable object-curly-newline */ import assert from 'assert'; import { - Cursor, FilterQuery, ObjectID, OnlyFieldsOfType, UpdateQuery, + Cursor, FilterQuery, ObjectID, OnlyFieldsOfType, PushOperator, UpdateQuery, } from 'mongodb'; import { Context } from '../context'; import { @@ -15,6 +15,7 @@ import { ArrayKeys, MaybeArray, NumberKeys, Projection } from '../typeutils'; import { buildProjection } from '../utils'; type DocID = ObjectID | string | number; +type NormalArrayKeys = Exclude, Symbol>; export const coll = db.collection('document'); export const collStatus = db.collection('document.status'); @@ -95,12 +96,18 @@ export async function get(domainId: string, docType: K, } export async function set( - domainId: string, docType: K, docId: DocType[K]['docId'], $set?: Partial, $unset?: OnlyFieldsOfType, + domainId: string, + docType: K, + docId: DocType[K]['docId'], + $set?: Partial, + $unset?: OnlyFieldsOfType, + $push?: PushOperator, ): Promise { await bus.parallel('document/set', domainId, docType, docId, $set, $unset); const update: UpdateQuery = {}; if ($set) update.$set = $set; if ($unset) update.$unset = $unset; + if ($push) update.$push = $push; const res = await coll.findOneAndUpdate( { domainId, docType, docId }, update, @@ -234,7 +241,7 @@ export async function getSub>( +export async function setSub>( domainId: string, docType: T, docId: DocType[T]['docId'], key: K, subId: DocType[T][K][0]['_id'], args: Partial, ): Promise { @@ -368,7 +375,7 @@ export async function incStatus( export async function revPushStatus( domainId: string, docType: T, docId: DocStatusType[T]['docId'], uid: number, - key: string & ArrayKeys, value: any, id = '_id', + key: NormalArrayKeys, value: any, id = '_id', ): Promise { let res = await collStatus.findOneAndUpdate( { domainId, docType, docId, uid, [`${key}.${id}`]: value[id] }, diff --git a/packages/migrate/scripts/vijos.ts b/packages/migrate/scripts/vijos.ts index 6f4c75acc..4fdbc1396 100644 --- a/packages/migrate/scripts/vijos.ts +++ b/packages/migrate/scripts/vijos.ts @@ -63,7 +63,6 @@ const tasks = { content: r.content, owner: r.owner_uid, ip: r.ip, - history: [], }; res.push(drrdoc); } diff --git a/packages/ui-default/components/discussion/comments.page.styl b/packages/ui-default/components/discussion/comments.page.styl index cbabc90ab..4247742e9 100644 --- a/packages/ui-default/components/discussion/comments.page.styl +++ b/packages/ui-default/components/discussion/comments.page.styl @@ -48,6 +48,10 @@ $downvote-color = red float: left margin-bottom: rem(5px) + .history + float: right + margin-left: rem(5px) + .dczcomments__operations font-size: rem($font-size) float: right diff --git a/packages/ui-default/components/discussion/history.page.tsx b/packages/ui-default/components/discussion/history.page.tsx new file mode 100644 index 000000000..8cb56e55b --- /dev/null +++ b/packages/ui-default/components/discussion/history.page.tsx @@ -0,0 +1,69 @@ +import { Popover } from '@blueprintjs/core'; +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { QueryClient, QueryClientProvider, useQuery } from 'react-query'; +import TimeAgo from 'timeago-react'; +import { InfoDialog } from 'vj/components/dialog'; +import { AutoloadPage } from 'vj/misc/Page'; +import i18n from 'vj/utils/i18n'; +import request from 'vj/utils/request'; +import tpl from 'vj/utils/tpl'; + +async function historyDialog(payload, time, uid) { + const ts = new Date(time).getTime(); + const rawHtml = await fetch(`${payload}?time=${ts}&render=1`).then((res) => res.text()); + new InfoDialog({ + $body: tpl` +
+

${uid}
${i18n('Edited at')} ${time}

+ ${{ templateRaw: true, html: rawHtml }} +
`, + }).open(); +} + +const queryClient = new QueryClient(); + +function History({ payload }) { + const [load, setLoad] = React.useState(false); + const { isLoading, isError, data } = useQuery(['history', payload], async () => { + const { history } = await request.get(`${payload}?all=1`); + return history; + }, { enabled: !!load }); + return ( + setLoad(true)}> + {i18n('Edited')} +
    + {(isLoading || isError) && ( +
  1. + + {isLoading ? i18n('Loading...') : i18n('Loading failed.')} + +
  2. + )} + {data?.map((item) => ( +
  3. + historyDialog(payload, item.time, item.uid)}> + {i18n('Edited at')} + {' '} + + +
  4. + ))} +
+
+ ); +} + +const page = new AutoloadPage('discussionHistoryPage', () => { + $('[data-discussion-history]').each((i, e) => { + const url = $(e).data('raw-url'); + if (!url) return; + ReactDOM.createRoot(e).render( + + + , + ); + }); +}); + +export default page; diff --git a/packages/ui-default/index.ts b/packages/ui-default/index.ts index f1b498845..5f77f59b3 100644 --- a/packages/ui-default/index.ts +++ b/packages/ui-default/index.ts @@ -254,4 +254,10 @@ export function apply(ctx: Context) { ctx.on('system/setting', () => triggerHotUpdate()); ctx.on('app/watch/change', triggerHotUpdate); ctx.on('app/watch/unlink', triggerHotUpdate); + ctx.on('handler/after/DiscussionRaw', async (that) => { + if (that.args.render && that.response.type === 'text/markdown') { + that.response.type = 'text/html'; + that.response.body = await markdown.render(that.response.body); + } + }); } diff --git a/packages/ui-default/locales/zh.yaml b/packages/ui-default/locales/zh.yaml index d3f6acb9a..8dac71002 100644 --- a/packages/ui-default/locales/zh.yaml +++ b/packages/ui-default/locales/zh.yaml @@ -285,6 +285,8 @@ Download: 下载 Downvote: 差评 Duration (hours): 持续时间 (小时) Duration: 持续时间 +Edited: 已修改 +Edited at: 修改于 Edit any contests: 修改任意的比赛 Edit any homework: 修改任意的作业 Edit Contest: 编辑比赛 diff --git a/packages/ui-default/package.json b/packages/ui-default/package.json index e7b3eba05..0033c5a42 100644 --- a/packages/ui-default/package.json +++ b/packages/ui-default/package.json @@ -1,6 +1,6 @@ { "name": "@hydrooj/ui-default", - "version": "4.42.9", + "version": "4.42.10", "author": "undefined ", "license": "AGPL-3.0", "main": "index.ts", @@ -87,6 +87,7 @@ "react-dnd": "^16.0.1", "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.2.0", + "react-query": "^3.39.2", "react-redux": "^8.0.4", "react-split-pane": "^0.1.92", "reconnecting-websocket": "^4.4.0", diff --git a/packages/ui-default/templates/components/comments_discussion.html b/packages/ui-default/templates/components/comments_discussion.html index 5957e6448..996ed7d9f 100644 --- a/packages/ui-default/templates/components/comments_discussion.html +++ b/packages/ui-default/templates/components/comments_discussion.html @@ -94,6 +94,9 @@
{{ user.render_inline(udoc, avatar=false) }} @ {{ datetimeSpan(doc['_id'])|safe }} + {% if doc.edited %} + + {% endif %}
{% if handler.user.hasPerm(reply_post_perm) %} @@ -132,6 +135,9 @@
{{ user.render_inline(udoc, avatar=false) }} @ {{ datetimeSpan(rdoc._id)|safe }} + {% if doc.edited %} + + {% endif %}
{% if handler.user.hasPerm(reply_post_perm) %} diff --git a/packages/ui-default/templates/discussion_detail.html b/packages/ui-default/templates/discussion_detail.html index ec3fc31e1..9fc93ebac 100644 --- a/packages/ui-default/templates/discussion_detail.html +++ b/packages/ui-default/templates/discussion_detail.html @@ -34,6 +34,9 @@
  • {{ _('Edit') }}
  • + {% endif %} + {% if ddoc.edited %} +
  • {{ _('Edited') }}
  • {% endif %}
  • {{ _('{0} views').format(ddoc.views) }}
  • {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} diff --git a/packages/ui-default/templates/partials/nav.html b/packages/ui-default/templates/partials/nav.html index 884a71773..0a544522c 100644 --- a/packages/ui-default/templates/partials/nav.html +++ b/packages/ui-default/templates/partials/nav.html @@ -84,6 +84,16 @@ {% endif %} + {% if ui.getNodes('UserDropdown').length %} + + {%- for item in ui.getNodes('UserDropdown') -%} + + {%- endfor -%} + {% endif %} {% if handler.user.hasPriv(PRIV.PRIV_EDIT_SYSTEM) %}