Skip to content

Commit 0ec89d6

Browse files
committed
Implementing plagiarism file comparator and related page for visualization.
1 parent af82ed0 commit 0ec89d6

File tree

24 files changed

+1228
-7
lines changed

24 files changed

+1228
-7
lines changed
Lines changed: 387 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,387 @@
1+
import React, { Component } from 'react';
2+
import PropTypes from 'prop-types';
3+
import { FormattedMessage, FormattedNumber } from 'react-intl';
4+
import { OverlayTrigger, Tooltip, Badge } from 'react-bootstrap';
5+
import { Link } from 'react-router-dom';
6+
7+
import CodeFragmentSelector from '../../helpers/CodeFragmentSelector';
8+
import Box from '../../widgets/Box';
9+
import Callout from '../../widgets/Callout';
10+
import Button, { TheButtonGroup } from '../../widgets/TheButton';
11+
import DateTime from '../../widgets/DateTime';
12+
import ResourceRenderer from '../../helpers/ResourceRenderer';
13+
import Icon, {
14+
CodeCompareIcon,
15+
DownloadIcon,
16+
GroupIcon,
17+
LoadingIcon,
18+
SolutionResultsIcon,
19+
WarningIcon,
20+
} from '../../icons';
21+
import withLinks from '../../../helpers/withLinks';
22+
import GroupsNameContainer from '../../../containers/GroupsNameContainer';
23+
24+
import styles from './PlagiarismCodeBox.less';
25+
import cfsStyles from '../../helpers/CodeFragmentSelector/CodeFragmentSelector.less';
26+
27+
const linesCount = content => (content.match(/\n/g) || '').length + 1;
28+
29+
class PlagiarismCodeBox extends Component {
30+
// Generate content for <pre> element that holds line numbers (based on size of the two compared contents).
31+
content1Ref = null;
32+
content2Ref = null;
33+
generatedLineNumbersCache = null;
34+
35+
generateLineNumbers = (content1, content2) => {
36+
if (this.content1Ref !== content1 || this.content2Ref !== content2) {
37+
const count = Math.max(linesCount(content1), linesCount(content2));
38+
this.generatedLineNumbersCache = [...Array(count).keys()] // [0, ..., count-1]
39+
.map(key => (key + 1).toString().padStart(5, ' '))
40+
.join('\n');
41+
this.content1Ref = content1;
42+
this.content2Ref = content2;
43+
}
44+
return this.generatedLineNumbersCache;
45+
};
46+
47+
// Get fragments from the plagiarism record and split them to 2 lists (left half and right half)
48+
fragmentsRef = null;
49+
splitFragmentsCache = null;
50+
51+
splitFragments = fragments => {
52+
if (this.fragmentsRef !== fragments) {
53+
this.splitFragmentsCache = [[], []];
54+
fragments.forEach(([f0, f1]) => {
55+
this.splitFragmentsCache[0].push(f0);
56+
this.splitFragmentsCache[1].push(f1);
57+
});
58+
this.fragmentsRef = fragments;
59+
}
60+
return this.splitFragmentsCache;
61+
};
62+
63+
/*
64+
* State and state-related functions.
65+
*/
66+
67+
state = {
68+
selectedFragment: null,
69+
fullWidth: false,
70+
};
71+
72+
componentDidUpdate(prevProps) {
73+
if (prevProps.selectedPlagiarismFile !== this.props.selectedPlagiarismFile) {
74+
this.setState({ selectedFragment: null });
75+
}
76+
}
77+
78+
selectFragment = selectedFragment => this.setState({ selectedFragment });
79+
80+
_selectRelFragment = (ev, rel) => {
81+
const count = this.props.selectedPlagiarismFile.fragments.length;
82+
if (count > 0) {
83+
const next = this.state.selectedFragment !== null ? this.state.selectedFragment + rel : 0;
84+
this.selectFragment(next >= 0 && next < count ? next : null);
85+
}
86+
87+
if (window) {
88+
window.setTimeout(() => {
89+
const span = window.document.querySelector('span.' + cfsStyles.selected);
90+
if (span) {
91+
window.scroll({
92+
top: Math.max(0, span.getBoundingClientRect().top + document.documentElement.scrollTop - 64), // 64 is a hack (to avoid srolling under top panel)
93+
behavior: 'smooth',
94+
});
95+
}
96+
}, 100);
97+
}
98+
};
99+
100+
selectPrevFragment = ev => this._selectRelFragment(ev, -1);
101+
102+
selectNextFragment = ev => this._selectRelFragment(ev, 1);
103+
104+
keyDownHandler = ev => {
105+
if (ev.code === 'ArrowLeft') {
106+
this.selectPrevFragment(ev);
107+
} else if (ev.code === 'ArrowRight') {
108+
this.selectNextFragment(ev);
109+
}
110+
};
111+
112+
toggleFullWidth = ev => {
113+
this.setState({ fullWidth: !this.state.fullWidth });
114+
ev.stopPropagation();
115+
};
116+
117+
render() {
118+
const {
119+
id,
120+
parentId = id,
121+
solutionId,
122+
name,
123+
entryName = null,
124+
download = null,
125+
fileContentsSelector,
126+
selectedPlagiarismFile,
127+
similarity = null,
128+
selectPlagiarismFile = null,
129+
links: { SOLUTION_DETAIL_URI_FACTORY, GROUP_STUDENTS_URI_FACTORY },
130+
} = this.props;
131+
132+
return (
133+
<ResourceRenderer
134+
key={id}
135+
resource={[
136+
fileContentsSelector(parentId, entryName),
137+
fileContentsSelector(selectedPlagiarismFile.solutionFile.id, selectedPlagiarismFile.fileEntry),
138+
]}
139+
loading={
140+
<Box
141+
key={`${id}-loading`}
142+
title={
143+
<>
144+
<LoadingIcon gapRight />
145+
<code>{name}</code>
146+
</>
147+
}
148+
noPadding
149+
/>
150+
}>
151+
{(content, secondContent) => (
152+
<div onKeyDown={this.keyDownHandler}>
153+
<nav className={styles.fragmentSelectButtons}>
154+
<TheButtonGroup>
155+
<Button size="xs" variant="primary" onClick={this.selectPrevFragment}>
156+
<Icon icon="angles-left" />
157+
</Button>
158+
<Button size="xs" variant="primary" onClick={this.selectNextFragment}>
159+
<Icon icon="angles-right" />
160+
</Button>
161+
</TheButtonGroup>
162+
</nav>
163+
164+
<Box
165+
key={id}
166+
title={
167+
<>
168+
{similarity && (
169+
<Badge variant={similarity > 0.8 ? 'danger' : 'warning'} className="mr-3">
170+
{<FormattedNumber value={similarity * 100} maximumFractionDigits={1} />} %
171+
</Badge>
172+
)}
173+
{content.malformedCharacters && (
174+
<OverlayTrigger
175+
placement="bottom"
176+
overlay={
177+
<Tooltip id={`${id}-malformed`}>
178+
<FormattedMessage
179+
id="app.solutionSourceCodes.malformedTooltip"
180+
defaultMessage="The file is not a valid UTF-8 text file so it cannot be properly displayed as a source code."
181+
/>
182+
</Tooltip>
183+
}>
184+
<WarningIcon className="text-danger" gapRight />
185+
</OverlayTrigger>
186+
)}
187+
188+
{content.tooLarge && (
189+
<OverlayTrigger
190+
placement="bottom"
191+
overlay={
192+
<Tooltip id={`${id}-tooLarge`}>
193+
<FormattedMessage
194+
id="app.solutionSourceCodes.tooLargeTooltip"
195+
defaultMessage="The file is too large for code preview and it was cropped."
196+
/>
197+
</Tooltip>
198+
}>
199+
<Icon icon="scissors" className="text-warning" gapRight />
200+
</OverlayTrigger>
201+
)}
202+
203+
<code>{name}</code>
204+
205+
{download && (
206+
<DownloadIcon
207+
gapLeft
208+
timid
209+
className="text-primary"
210+
onClick={ev => {
211+
ev.stopPropagation();
212+
download(parentId, entryName);
213+
}}
214+
/>
215+
)}
216+
217+
{selectPlagiarismFile ? (
218+
<OverlayTrigger
219+
placement="bottom"
220+
overlay={
221+
<Tooltip id={`${id}-mappingExplain`}>
222+
<FormattedMessage
223+
id="app.solutionSourceCodes.adjustMappingTooltip"
224+
defaultMessage="Adjust file mappings by selecting which file from the second solution will be compared to this file."
225+
/>
226+
</Tooltip>
227+
}>
228+
<CodeCompareIcon className="text-primary ml-4 mr-3" onClick={selectPlagiarismFile} />
229+
</OverlayTrigger>
230+
) : (
231+
<CodeCompareIcon className="text-muted ml-4 mr-3" />
232+
)}
233+
234+
{secondContent.malformedCharacters && (
235+
<OverlayTrigger
236+
placement="bottom"
237+
overlay={
238+
<Tooltip id={`${id}-malformed2`}>
239+
<FormattedMessage
240+
id="app.solutionSourceCodes.malformedTooltip"
241+
defaultMessage="The file is not a valid UTF-8 text file so it cannot be properly displayed as a source code."
242+
/>
243+
</Tooltip>
244+
}>
245+
<WarningIcon className="text-danger" gapRight />
246+
</OverlayTrigger>
247+
)}
248+
249+
{secondContent.tooLarge && (
250+
<OverlayTrigger
251+
placement="bottom"
252+
overlay={
253+
<Tooltip id={`${id}-tooLarge2`}>
254+
<FormattedMessage
255+
id="app.solutionSourceCodes.tooLargeTooltip"
256+
defaultMessage="The file is too large for code preview and it was cropped."
257+
/>
258+
</Tooltip>
259+
}>
260+
<Icon icon="scissors" className="text-warning" gapRight />
261+
</OverlayTrigger>
262+
)}
263+
264+
<code>
265+
{selectedPlagiarismFile.solutionFile.name}
266+
{selectedPlagiarismFile.fileEntry ? `/${selectedPlagiarismFile.fileEntry}` : ''}
267+
</code>
268+
269+
{download && (
270+
<DownloadIcon
271+
gapLeft
272+
timid
273+
className="text-primary"
274+
onClick={ev => {
275+
ev.stopPropagation();
276+
download(
277+
selectedPlagiarismFile.solutionFile.id,
278+
selectedPlagiarismFile.fileEntry || null,
279+
solutionId
280+
);
281+
}}
282+
/>
283+
)}
284+
285+
<OverlayTrigger
286+
placement="bottom"
287+
overlay={
288+
<Tooltip id={`${id}-solutionIcon`}>
289+
#{selectedPlagiarismFile.solution.attemptIndex} (
290+
<DateTime unixts={selectedPlagiarismFile.solution.createdAt} />)
291+
</Tooltip>
292+
}>
293+
{selectedPlagiarismFile.solution.canViewDetail ? (
294+
<Link
295+
to={SOLUTION_DETAIL_URI_FACTORY(
296+
selectedPlagiarismFile.assignment.id,
297+
selectedPlagiarismFile.solution.id
298+
)}>
299+
<SolutionResultsIcon gapLeft className="text-primary" timid />
300+
</Link>
301+
) : (
302+
<SolutionResultsIcon gapLeft className="text-muted" timid />
303+
)}
304+
</OverlayTrigger>
305+
306+
<OverlayTrigger
307+
placement="bottom"
308+
overlay={
309+
<Tooltip id={`${id}-groupIcon`}>
310+
<GroupsNameContainer groupId={selectedPlagiarismFile.groupId} fullName admins />
311+
</Tooltip>
312+
}>
313+
{selectedPlagiarismFile.assignment.canViewDetail ? (
314+
<Link to={GROUP_STUDENTS_URI_FACTORY(selectedPlagiarismFile.groupId)}>
315+
<GroupIcon gapLeft className="text-primary" timid />
316+
</Link>
317+
) : (
318+
<GroupIcon gapLeft className="text-muted" timid />
319+
)}
320+
</OverlayTrigger>
321+
322+
<span className="ml-5 text-primary">
323+
{this.state.fullWidth ? (
324+
<Icon icon="table-columns" timid onClick={this.toggleFullWidth} />
325+
) : (
326+
<Icon icon={['far', 'window-maximize']} timid onClick={this.toggleFullWidth} />
327+
)}
328+
</span>
329+
</>
330+
}
331+
noPadding
332+
unlimitedHeight
333+
collapsable
334+
isOpen={!content.malformedCharacters && !secondContent.malformedCharacters}>
335+
{!content.malformedCharacters && !secondContent.malformedCharacters ? (
336+
<div className={styles.container}>
337+
<div className={styles.lines}>
338+
<pre>{this.generateLineNumbers(content.content, secondContent.content)}</pre>
339+
</div>
340+
<div className={this.state.fullWidth ? styles.fullWidth : ''}>
341+
<CodeFragmentSelector
342+
content={content.content}
343+
fragments={this.splitFragments(selectedPlagiarismFile.fragments)[0]}
344+
selected={this.state.selectedFragment}
345+
setSelected={this.selectFragment}
346+
/>
347+
</div>
348+
<div className={this.state.fullWidth ? styles.fullWidth : ''}>
349+
<CodeFragmentSelector
350+
content={secondContent.content}
351+
fragments={this.splitFragments(selectedPlagiarismFile.fragments)[1]}
352+
selected={this.state.selectedFragment}
353+
setSelected={this.selectFragment}
354+
/>
355+
</div>
356+
</div>
357+
) : (
358+
<Callout variant="danger">
359+
<FormattedMessage
360+
id="app.solutionPlagiarisms.unableCompareMalformed"
361+
defaultMessage="Malformed files cannot be visualized in comparison mode."
362+
/>
363+
</Callout>
364+
)}
365+
</Box>
366+
</div>
367+
)}
368+
</ResourceRenderer>
369+
);
370+
}
371+
}
372+
373+
PlagiarismCodeBox.propTypes = {
374+
id: PropTypes.string.isRequired,
375+
parentId: PropTypes.string,
376+
solutionId: PropTypes.string.isRequired,
377+
name: PropTypes.string.isRequired,
378+
entryName: PropTypes.string,
379+
download: PropTypes.func,
380+
fileContentsSelector: PropTypes.func,
381+
selectedPlagiarismFile: PropTypes.object.isRequired,
382+
similarity: PropTypes.number,
383+
selectPlagiarismFile: PropTypes.func,
384+
links: PropTypes.object,
385+
};
386+
387+
export default withLinks(PlagiarismCodeBox);

0 commit comments

Comments
 (0)