Browse Source

v1.0.5

comments.xml

Statistics module

css fixes
CrazyDoctor 6 months ago
parent
commit
9c947f7664

+ 1 - 1
.git-credentials.tmpl

@@ -1 +1 @@
-http://${gitUsername}:${gitPassword}@${gitHost}/${commentsRepoUrl}
+${outerProtocol}://${gitUsername}:${gitPassword}@${gitHost}/${commentsRepoUrl}

+ 1 - 1
.gitconfig.tmpl

@@ -1,6 +1,6 @@
 [credential]
 	helper = store
-[credential "http://${gitHost}/${commentsRepoUrl}"]
+[credential "${outerProtocol}://${gitHost}/${commentsRepoUrl}"]
 	provider = generic
 [user]
 	email = ${gitEmail}

+ 3 - 2
build.gradle

@@ -30,6 +30,7 @@ ext.npmCmd = windows ? 'npm.cmd' : 'npm'
 ext.nodeCmd = windows ? 'node.exe' : 'node'
 
 def gitDefaults = [
+	outerProtocol: 'http',
 	adminPassword: '',
 	gitHost: '', // excluding protocol
 	commentsRepoUrl: '', // part after gitHost
@@ -60,7 +61,7 @@ task copySources {
 		copy {
 			from file("${projectDir}/config.json")
 			into file(buildDir)
-			expand(commentsRepoUrl: commentsRepoUrl, adminPassword: adminPassword, gitHost: gitHost)
+			expand(outerProtocol: outerProtocol, commentsRepoUrl: commentsRepoUrl, adminPassword: adminPassword, gitHost: gitHost)
 		}
 
 		copy {
@@ -68,7 +69,7 @@ task copySources {
 			include "*.tmpl"
 			into file(buildDir)
 			rename { fileName -> fileName.replace('.tmpl', '') }
-			expand(commentsRepoUrl: commentsRepoUrl, gitUsername: gitUsername, gitEmail: gitEmail, gitPassword: gitPassword, gitHost: gitHost)
+			expand(outerProtocol: outerProtocol, commentsRepoUrl: commentsRepoUrl, gitUsername: gitUsername, gitEmail: gitEmail, gitPassword: gitPassword, gitHost: gitHost)
 		}
 
 		copy {

+ 5 - 5
config.json

@@ -1,27 +1,27 @@
 {
 	"server": {
 		"adminPassword": "${adminPassword}",
-		"gitHost": "http://${gitHost}",
-		"commentsRepoUrl": "http://${gitHost}/${commentsRepoUrl}"
+		"gitHost": "${outerProtocol}://${gitHost}",
+		"commentsRepoUrl": "${outerProtocol}://${gitHost}/${commentsRepoUrl}"
 	},
 	"sources": {
 		"assetsDir": ".doczilla_js_docs",
 		"repos": [
 			{
 				"name": "org.zenframework.z8",
-				"url": "http://${gitHost}/z8/org.zenframework.z8",
+				"url": "${outerProtocol}://${gitHost}/z8/org.zenframework.z8",
 				"sparseCheckout": "org.zenframework.z8.js",
 				"collectFrom": "org.zenframework.z8.js/src/js"
 			},
 			{
 				"name": "ru.morpher.js",
-				"url": "http://${gitHost}/doczilla/ru.morpher.js",
+				"url": "${outerProtocol}://${gitHost}/doczilla/ru.morpher.js",
 				"sparseCheckout": "src/js",
 				"collectFrom": "src/js"
 			},
 			{
 				"name": "pro.doczilla.base.js",
-				"url": "http://${gitHost}/doczilla/pro.doczilla.base.js",
+				"url": "${outerProtocol}://${gitHost}/doczilla/pro.doczilla.base.js",
 				"sparseCheckout": "src/js",
 				"collectFrom": "src/js"
 			}

+ 12 - 2
docker.gradle

@@ -61,24 +61,34 @@ task copyGitCredentials(type: Copy) {
 	into "${buildDir}/install/${project.name}"
 }
 
+task copyPackageJson(type: Copy) {
+	from("${buildDir}")
+	include "package.json"
+	into "${buildDir}/install/${project.name}"
+}
+
 task prepareDockerFiles(type: Copy) {
 	dependsOn clearDockerFiles
 	dependsOn copyGitCredentials
+	dependsOn copyPackageJson
 
 	from("${buildDir}/install/${project.name}")
+	exclude "**/node_modules/**"
 	into "${dockerBuildDir}/files"
 }
 
 task createDockerfile(type: Dockerfile) {
 	destFile = project.file("${dockerBuildDir}/Dockerfile")
 	from dockerBaseImage
-	runCommand "apk update" // for Alpine distro
-	runCommand "apk add git" // for Alpine distro
+	runCommand "apk update"
+	runCommand "apk add git"
+	runCommand "apk add --no-cache sqlite-libs"
 	runCommand "mkdir -p /root/.doczilla_js_docs"
 	copyFile './files/.gitconfig', '/root/'
 	copyFile './files/.git-credentials', '/root/'
 	copyFile "./files", '/opt/org.crazydoctor.dzjsdocs/'
 	workingDir '/opt/org.crazydoctor.dzjsdocs/'
+	runCommand "npm install"
 	
 	defaultCommand 'node', 'index.js'
 	exposePort 9080

+ 1 - 0
node_scripts/uglifyStatics.mjs

@@ -90,6 +90,7 @@ class Minifier {
 					'Z8Locales',
 					'Comments',
 					'LastUpdateTime',
+					'StatisticsUser',
 					'comment',
 					'timestamp',
 					'nearestParent',

+ 3 - 1
package.json

@@ -1,13 +1,15 @@
 {
 	"name": "doczilla_js_docs",
-	"version": "1.0.4",
+	"version": "1.0.5",
 	"dependencies": {
+		"@types/sqlite3": "^3.1.11",
 		"@types/ws": "^8.5.10",
 		"cookie-parser": "^1.4.6",
 		"cron": "^3.1.6",
 		"jsdom": "^24.0.0",
 		"org.crazydoctor.expressts": "git+https://git.crazydoctor.org/expressts/org.crazydoctor.expressts",
 		"pug": "^3.0.2",
+		"sqlite3": "^5.1.7",
 		"ws": "^8.16.0"
 	},
 	"devDependencies": {

+ 17 - 42
src/comments/CommentsManager.ts

@@ -3,6 +3,7 @@ import fs from 'fs';
 import IComment from './IComment';
 import { promisify } from 'util';
 import ServerApp from '..';
+import DBEvents from '../db/DBEvents';
 
 const execAsync = promisify(exec);
 
@@ -103,8 +104,12 @@ class CommentsManager {
 
 		let status = CommentUpdateStatus.Created;
 		const exists = fs.existsSync(commentPath);
-		if(exists)
-			status = CommentUpdateStatus.Updated;
+		if(exists) {
+			const content = fs.readFileSync(commentPath, 'utf-8');
+			const [_1, _2, ...rest] = content.split(':');
+			const commentContent = rest.join(':');
+			status = commentContent.length > 0 ? CommentUpdateStatus.Updated : CommentUpdateStatus.Created;
+		}
 		if(exists && comment.text.length === 0)
 			status = CommentUpdateStatus.Deleted;
 
@@ -113,6 +118,14 @@ class CommentsManager {
 		return { commentPath, status };
 	}
 
+	private static async createEvent(comments: IQueueComment[]): Promise<void[]> {
+		const promises: Promise<void>[] = [];
+		for(const comment of comments) {
+			promises.push(DBEvents.createEvent(comment.author, comment.className, comment.propertyName, comment.action, comment.timestamp.toString()));
+		}
+		return Promise.all(promises);
+	}
+
 	public static async update(comment: IComment): Promise<CommentUpdateStatus> {
 		if(CommentsManager.isSaving()) {
 			CommentsManager.enqueueComment(comment, CommentAction.Create);
@@ -138,35 +151,7 @@ class CommentsManager {
 
 			const queueComment: IQueueComment = Object.assign(comment, { action: action }) as IQueueComment;
 			await CommentsManager.commit([queueComment], [commentPath]);
-			CommentsManager.endSave();
-			return status;
-		} catch(e) {
-			CommentsManager.endSave();
-			return CommentUpdateStatus.Error;
-		}
-	}
-
-	private static async deleteCommentFile(root: string, className: string, propertyName: string): Promise<{ commentPath: string, status: CommentUpdateStatus }> {
-		const classDirPath = CommentsManager.getClassDirPath(root, className);
-		const commentPath = `${classDirPath}/${propertyName}`;
-
-		if (!fs.existsSync(commentPath))
-			return { commentPath, status: CommentUpdateStatus.Error };
-
-		await fs.promises.rm(commentPath);
-		return { commentPath, status: CommentUpdateStatus.Deleted };
-	}
-
-	public static async delete(root: string, className: string, propertyName: string, initiator: string): Promise<CommentUpdateStatus> {
-		if(CommentsManager.isSaving()) {
-			CommentsManager.enqueueComment({ root, className, propertyName, text: '', author: initiator, timestamp: 0 }, CommentAction.Remove);
-			return CommentUpdateStatus.Enqueued;
-		}
-		
-		CommentsManager.beginSave();
-		try {
-			const { commentPath, status } = await CommentsManager.deleteCommentFile(root, className, propertyName);
-			await CommentsManager.commit([{ root, className, propertyName, text: '', author: initiator, timestamp: 0, action: CommentAction.Remove }], [commentPath]);
+			await CommentsManager.createEvent([queueComment]);
 			CommentsManager.endSave();
 			return status;
 		} catch(e) {
@@ -211,7 +196,6 @@ class CommentsManager {
 			const message = `"${comment.author} ${comment.action === CommentAction.Create ? 'created' : (comment.action === CommentAction.Update ? 'updated' : 'deleted')} a comment for ${comment.root}/${comment.className}:${comment.propertyName}"`;
 			commitMessages.push(message);
 
-			//await execAsync(`git ${comment.action === CommentAction.Create || comment.action === CommentAction.Update ? 'add' : 'rm'} ${commentPath}`, { encoding: 'utf8', cwd: CommentsManager.CommentsFSRoot });
 			await execAsync(`git add ${commentPath}`, { encoding: 'utf8', cwd: CommentsManager.CommentsFSRoot });
 		}
 
@@ -235,22 +219,13 @@ class CommentsManager {
 				const comment = queueItem.comment;
 				const commentPath = queueItem.commentPath;
 
-				/*switch(comment.action) {
-				case CommentAction.Create:
-				case CommentAction.Update:
-					await CommentsManager.createOrUpdateCommentFile(comment);
-					break;
-				case CommentAction.Remove:
-					await CommentsManager.deleteCommentFile(comment.root, comment.className, comment.propertyName);
-					break;
-				}*/
-
 				await CommentsManager.createOrUpdateCommentFile(comment);
 
 				comments.push(comment);
 				commentsPaths.push(commentPath);
 			}
 			await CommentsManager.commit(comments, commentsPaths);
+			await CommentsManager.createEvent(comments);
 			CommentsManager.endSave();
 		} catch(e) {
 			CommentsManager.endSave();

+ 167 - 0
src/db/DBEvents.ts

@@ -0,0 +1,167 @@
+import * as sqlite3 from 'sqlite3';
+
+type UserEventModel = {
+	user: string;
+	class: string;
+	property: string;
+	action: string;
+	timestamp: string;
+};
+
+type UserStatisticsModel = {
+	user: string;
+	created: number;
+	updated: number;
+	removed: number;
+	total: number;
+};
+
+class DBEvents {
+	private static db: sqlite3.Database;
+
+	public static init(path: string): void {
+		const db = DBEvents.db = new sqlite3.Database(path);
+
+		db.run(`CREATE TABLE IF NOT EXISTS events (
+				id INTEGER PRIMARY KEY AUTOINCREMENT,
+				user TEXT,
+				class TEXT,
+				property TEXT,
+				action TEXT,
+				timestamp TEXT
+		)`, (err) => {
+			if(err)
+				console.error(err);
+		});
+	}
+
+	public static async createEvent(user: string, cls: string, property: string, action: string, timestamp: string): Promise<void> {
+		const sql = 'INSERT INTO events (user, class, property, action, timestamp) VALUES (?, ?, ?, ?, ?)';
+		const params = [user, cls, property, action, timestamp];
+
+		return new Promise<void>(
+			(resolve, reject) => {
+				DBEvents.db.run(sql, params, function(err) {
+					if (err) {
+						reject(err);
+					} else {
+						resolve();
+					}
+				});
+			}
+		);
+	}
+
+	public static async getStatisticsCount(): Promise<number> {
+		const sql = 'SELECT COUNT(*) AS count FROM (SELECT DISTINCT user FROM events);';
+
+		return new Promise<number>((resolve, reject) => {
+			DBEvents.db.get<{ count: number }>(sql, [], (err, row) => {
+				if (err) {
+					reject(err);
+				} else {
+					resolve(row.count);
+				}
+			});
+		});
+	}
+
+	public static async getStatistics(page: number, pageSize: number): Promise<UserStatisticsModel[]> {
+		const offset = (page - 1) * pageSize;
+		const sql = `
+			SELECT
+					user,
+					SUM(CASE WHEN action = 'create' THEN 1 ELSE 0 END) AS created,
+					SUM(CASE WHEN action = 'update' THEN 1 ELSE 0 END) AS updated,
+					SUM(CASE WHEN action = 'remove' THEN 1 ELSE 0 END) AS removed,
+					(SUM(CASE WHEN action = 'create' THEN 1 ELSE 0 END) + SUM(CASE WHEN action = 'update' THEN 1 ELSE 0 END) + SUM(CASE WHEN action = 'remove' THEN 1 ELSE 0 END)) AS total
+			FROM
+					events
+			GROUP BY
+					user
+			ORDER BY
+					total DESC
+			LIMIT ? OFFSET ?;
+		`;
+
+		return new Promise<UserStatisticsModel[]>((resolve, reject) => {
+			DBEvents.db.all<UserStatisticsModel>(sql, [pageSize, offset], (err, rows) => {
+				if(err) {
+					reject(err);
+				} else {
+					resolve(rows);
+				}
+			});
+		});
+	}
+
+	public static async getUserEventsCount(user: string): Promise<number> {
+		const sql = 'SELECT COUNT(*) AS count FROM events WHERE user = ?;';
+
+		return new Promise<number>((resolve, reject) => {
+			DBEvents.db.get<{ count: number }>(sql, [user], (err, row) => {
+				if (err) {
+					reject(err);
+				} else {
+					resolve(row.count);
+				}
+			});
+		});
+	}
+
+	public static async getUserEvents(user: string, page: number, pageSize: number): Promise<UserEventModel[]> {
+		const offset = (page - 1) * pageSize;
+		const sql = `
+			SELECT user, class, property, action, timestamp
+			FROM events
+			WHERE user = ?
+			ORDER BY timestamp DESC
+			LIMIT ? OFFSET ?;
+		`;
+		return new Promise<UserEventModel[]>((resolve, reject) => {
+			DBEvents.db.all<UserEventModel>(sql, [user, pageSize, offset], (err, rows) => {
+				if(err) {
+					reject(err);
+				} else {
+					resolve(rows);
+				}
+			});
+		});
+	}
+
+	public static async getClassEventsCount(cls: string): Promise<number> {
+		const sql = 'SELECT COUNT(*) AS count FROM events WHERE class = ?;';
+
+		return new Promise<number>((resolve, reject) => {
+			DBEvents.db.get<{ count: number }>(sql, [cls], (err, row) => {
+				if (err) {
+					reject(err);
+				} else {
+					resolve(row.count);
+				}
+			});
+		});
+	}
+
+	public static async getClassEvents(cls: string, page: number, pageSize: number): Promise<UserEventModel[]> {
+		const offset = (page - 1) * pageSize;
+		const sql = `
+			SELECT user, class, property, action, timestamp
+			FROM events
+			WHERE class = ?
+			ORDER BY timestamp DESC
+			LIMIT ? OFFSET ?;
+		`;
+		return new Promise<UserEventModel[]>((resolve, reject) => {
+			DBEvents.db.all<UserEventModel>(sql, [cls, pageSize, offset], (err, rows) => {
+				if(err) {
+					reject(err);
+				} else {
+					resolve(rows);
+				}
+			});
+		});
+	}
+}
+
+export default DBEvents;

+ 3 - 0
src/index.ts

@@ -8,6 +8,7 @@ import CronSourcesUpdateTask from './util/CronSourcesUpdateTask';
 import { CommentsManager } from './comments/CommentsManager';
 import { WebSocket as WS } from 'ws';
 import WebSocketHandler from './websocket/WebSocket';
+import DBEvents from './db/DBEvents';
 
 class ServerApp {
 	public static SourcesUpdating = false;
@@ -53,6 +54,8 @@ class ServerApp {
 
 		CommentsManager.init(path.resolve(`${assetsDir}/comments`), ServerConfig.commentsRepoUrl);
 
+		DBEvents.init(path.resolve(`${assetsDir}/events.db`));
+
 		new Server({
 			port: 9080,
 			routesPath: path.resolve(__dirname, './routes'),

+ 80 - 0
src/routes/GetCommentsXml.ts

@@ -0,0 +1,80 @@
+import {HttpMethod, Route, StatusCodes} from 'org.crazydoctor.expressts';
+import {Request, Response} from 'express';
+import XmlWriter from '../util/XmlWriter';
+import fs from 'fs';
+import * as path from 'path';
+import { CommentsManager } from '../comments/CommentsManager';
+
+class GetCommentsXml extends Route {
+
+	private getCommentsXmlData(): string {
+		const xmlWriter = new XmlWriter();
+		const sourcesPath = CommentsManager.CommentsFSRoot;
+		const files = fs.readdirSync(sourcesPath);
+
+		const readRepoDir = (repoPath: string, className: string[]): void => {
+			const files = fs.readdirSync(repoPath);
+			let newClass = false;
+			files.forEach(file => {
+				const filePath = path.join(repoPath, file);
+				if (fs.statSync(filePath).isDirectory()) {
+					className.push(file);
+					readRepoDir(filePath, className);
+				} else {
+					if(!newClass) {
+						newClass = true;
+						xmlWriter.startTag('class', { 'name': className.join('.') }); // <class>
+					}
+					const [editor, timestamp, ...rest] = fs.readFileSync(filePath, 'utf-8').split(':');
+					const content = rest.join(':'); 
+					xmlWriter.writeTag('comment', { 'property': file, 'editor': editor, 'timestamp': timestamp }, content);
+				}
+			});
+			if (newClass) {
+				className.pop();
+				xmlWriter.endTag(); // </class>
+			}
+		};
+
+		xmlWriter.startTag('root', { 'timestamp': new Date().getTime().toString() }); // <root>
+
+		files.forEach(file => {
+			const filePath = path.join(sourcesPath, file);
+			if (fs.statSync(filePath).isDirectory() && file !== '.' && file !== '..' && file !== '.git') {
+				const repo = file;
+				
+				xmlWriter.startTag('repo', { 'name': repo }); // <repo>
+				readRepoDir(filePath, []);
+				xmlWriter.endTag(); // </repo>
+			}
+		});
+
+		xmlWriter.endTag(); // </root>
+
+		return xmlWriter.toString();
+	}
+
+	protected action = (req: Request, res: Response): any => {
+		let xmlData = '';
+
+		try {
+			xmlData = this.getCommentsXmlData();
+		} catch(e) {
+			this.context.logError(e);
+			res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
+			return;
+		}
+		
+		res.set({
+			'Content-Type': 'application/xml',
+			'Content-Disposition': 'attachment; filename="comments.xml"'
+		});
+		res.status(StatusCodes.OK).send(xmlData);
+	};
+
+	protected method = HttpMethod.GET;
+	protected order = 7;
+	protected route = '/comments.xml';
+}
+
+export default GetCommentsXml;

+ 38 - 0
src/routes/GetDataStatistics.ts

@@ -0,0 +1,38 @@
+import {HttpMethod, Route, StatusCodes} from 'org.crazydoctor.expressts';
+import {Request, Response} from 'express';
+import ServerApp from '..';
+import { ISession } from '../session/ISession';
+import DBEvents from '../db/DBEvents';
+
+class GetDataStatistics extends Route {
+	protected action = (req: Request, res: Response): any => {
+		const session = req.session as ISession;
+		
+		if(!session.isEditor) {
+			res.status(StatusCodes.FORBIDDEN).send('Access denied');
+			return;
+		}
+
+		if(ServerApp.SourcesUpdating) {
+			res.status(StatusCodes.ACCEPTED).send('Sources are being updated');
+			return;
+		}
+
+		const { page, pageSize } = req.query;
+
+		DBEvents.getStatisticsCount().then((count) => {
+			DBEvents.getStatistics(parseInt(page as string), parseInt(pageSize as string)).then((statistics) => {
+				res.set({
+					'Content-Type': 'application/json'
+				});
+				res.status(StatusCodes.OK).send({ count: count, records: statistics });
+			});
+		});
+	};
+
+	protected method = HttpMethod.GET;
+	protected order = 8;
+	protected route = '/data/statistics';
+}
+
+export default GetDataStatistics;

+ 38 - 0
src/routes/GetDataStatisticsClass.ts

@@ -0,0 +1,38 @@
+import {HttpMethod, Route, StatusCodes} from 'org.crazydoctor.expressts';
+import {Request, Response} from 'express';
+import ServerApp from '..';
+import { ISession } from '../session/ISession';
+import DBEvents from '../db/DBEvents';
+
+class GetDataStatisticsClass extends Route {
+	protected action = (req: Request, res: Response): any => {
+		const session = req.session as ISession;
+		
+		if(!session.isEditor) {
+			res.status(StatusCodes.FORBIDDEN).send('Access denied');
+			return;
+		}
+
+		if(ServerApp.SourcesUpdating) {
+			res.status(StatusCodes.ACCEPTED).send('Sources are being updated');
+			return;
+		}
+
+		const cls = req.params.class;
+		const { page, pageSize } = req.query;
+		DBEvents.getClassEventsCount(cls).then((count) => {
+			DBEvents.getClassEvents(cls, parseInt(page as string), parseInt(pageSize as string)).then((classEvents) => {
+				res.set({
+					'Content-Type': 'application/json'
+				});
+				res.status(StatusCodes.OK).send({ count: count, records: JSON.parse(JSON.stringify(classEvents).replaceAll('__static__', '')) });
+			});
+		});
+	};
+
+	protected method = HttpMethod.GET;
+	protected order = 10;
+	protected route = '/data/statistics/class/:class';
+}
+
+export default GetDataStatisticsClass;

+ 38 - 0
src/routes/GetDataStatisticsUser.ts

@@ -0,0 +1,38 @@
+import {HttpMethod, Route, StatusCodes} from 'org.crazydoctor.expressts';
+import {Request, Response} from 'express';
+import ServerApp from '..';
+import { ISession } from '../session/ISession';
+import DBEvents from '../db/DBEvents';
+
+class GetDataStatisticsUser extends Route {
+	protected action = (req: Request, res: Response): any => {
+		const session = req.session as ISession;
+		
+		if(!session.isEditor) {
+			res.status(StatusCodes.FORBIDDEN).send('Access denied');
+			return;
+		}
+
+		if(ServerApp.SourcesUpdating) {
+			res.status(StatusCodes.ACCEPTED).send('Sources are being updated');
+			return;
+		}
+
+		const user = req.params.user;
+		const { page, pageSize } = req.query;
+		DBEvents.getUserEventsCount(user).then((count) => {
+			DBEvents.getUserEvents(user, parseInt(page as string), parseInt(pageSize as string)).then((userEvents) => {
+				res.set({
+					'Content-Type': 'application/json'
+				});
+				res.status(StatusCodes.OK).send({ count: count, records: JSON.parse(JSON.stringify(userEvents).replaceAll('__static__', '')) });
+			});
+		});
+	};
+
+	protected method = HttpMethod.GET;
+	protected order = 9;
+	protected route = '/data/statistics/user/:user';
+}
+
+export default GetDataStatisticsUser;

+ 30 - 0
src/routes/GetStatistics.ts

@@ -0,0 +1,30 @@
+import {HttpMethod, Route, StatusCodes} from 'org.crazydoctor.expressts';
+import {Request, Response} from 'express';
+import ServerApp from '..';
+import { ISession } from '../session/ISession';
+import { Sources } from '../sources/Sources';
+
+class GetStatistics extends Route {
+	protected action = (req: Request, res: Response): any => {
+		const session = req.session as ISession;
+		
+		if(!session.isEditor) {
+			session.returnTo = '/statistics';
+			res.redirect('/login');
+			return;
+		}
+
+		if(ServerApp.SourcesUpdating) {
+			res.status(StatusCodes.ACCEPTED).send('Sources are being updated');
+			return;
+		}
+
+		res.status(StatusCodes.OK).render('statistics', { isAdmin: session.isAdmin || false, isEditor: session.isEditor || false, Login: session.login || null, template: 'statistics', StatisticsUser: null, title: 'Doczilla JS Docs - Statistics', ClassList: Sources.get(), RepoNames: Sources.getRepoNames() });
+	};
+
+	protected method = HttpMethod.GET;
+	protected order = 11;
+	protected route = '/statistics';
+}
+
+export default GetStatistics;

+ 30 - 0
src/routes/GetStatisticsUser.ts

@@ -0,0 +1,30 @@
+import {HttpMethod, Route, StatusCodes} from 'org.crazydoctor.expressts';
+import {Request, Response} from 'express';
+import ServerApp from '..';
+import { ISession } from '../session/ISession';
+import { Sources } from '../sources/Sources';
+
+class GetStatisticsUser extends Route {
+	protected action = (req: Request, res: Response): any => {
+		const session = req.session as ISession;
+		
+		if(!session.isEditor) {
+			session.returnTo = `/statistics/${req.params.user}`;
+			res.redirect('/login');
+			return;
+		}
+
+		if(ServerApp.SourcesUpdating) {
+			res.status(StatusCodes.ACCEPTED).send('Sources are being updated');
+			return;
+		}
+
+		res.status(StatusCodes.OK).render('statistics', { isAdmin: session.isAdmin || false, isEditor: session.isEditor || false, Login: session.login || null, template: 'statistics', StatisticsUser: req.params.user, title: `Doczilla JS Docs - Statistics (${req.params.user})`, ClassList: Sources.get(), RepoNames: Sources.getRepoNames() });
+	};
+
+	protected method = HttpMethod.GET;
+	protected order = 12;
+	protected route = '/statistics/:user';
+}
+
+export default GetStatisticsUser;

+ 11 - 4
src/routes/PostAuthorize.ts

@@ -39,10 +39,17 @@ class PostAuthorize extends Route {
 		const password = params.password.trim();
 		const hashedPassword = SHA256.hash(password);
 
+		const returnTo = session.returnTo;
+		delete session.returnTo;
+
+		res.set({
+			'Content-Type': 'application/json'
+		});
+
 		if(login === this.AdminLogin && this.context.options.adminPassword === hashedPassword) {
 			session.login = 'Admin';
-			session.isAdmin = session.isEditor = true;
-			res.status(StatusCodes.OK).send('OK');
+			session.isAdmin = session.isEditor = true;		
+			res.status(StatusCodes.OK).send({ success: true, returnTo: returnTo });
 			return;
 		}
 
@@ -50,9 +57,9 @@ class PostAuthorize extends Route {
 			if(result) {
 				session.isEditor = true;
 				session.login = login;
-				res.status(StatusCodes.OK).send('OK');
+				res.status(StatusCodes.OK).send({ success: true, returnTo: returnTo });
 			} else {
-				res.status(StatusCodes.FORBIDDEN).send('Authentication failed');
+				res.status(StatusCodes.FORBIDDEN).send({ success: false, message: 'Authentication failed' });
 			}
 		});
 	};

+ 1 - 0
src/session/ISession.ts

@@ -4,4 +4,5 @@ export interface ISession extends Session {
 	isAdmin: boolean;
 	isEditor: boolean;
 	login: string | null;
+	returnTo?: string;
 }

+ 1 - 1
src/sources/Sources.ts

@@ -44,8 +44,8 @@ class Sources {
 	private static repoSourceCopyPaths: string[]			= [];
 	private static repoSparseCheckoutPaths: string[]	= [];
 	private static repoCollectFromPaths: string[]			= [];
-	private static sourcesPath: string								= `${os.homedir()}/.doczilla_js_docs`;
 
+	public static sourcesPath: string									= `${os.homedir()}/.doczilla_js_docs`;
 	public static sourcesCopyPath											= `${Sources.sourcesPath}/JsSources`;
 	public static dzAppPath														= `${Sources.sourcesCopyPath}/DZApp.js`;
 	public static classMapPath				    						= `${Sources.sourcesCopyPath}/ClassMap.json`;

+ 69 - 0
src/util/XmlWriter.ts

@@ -0,0 +1,69 @@
+type XmlTagAttributes = {
+	[key: string]: string;
+};
+
+/**
+ * Simple XML writer tool.
+ * TODO: tag names, attribute names/values validation
+ */
+class XmlWriter {
+	private content: string;
+	private tagsStack: string[];
+
+	public constructor() {
+		this.content = '<?xml version="1.0" encoding="UTF-8"?>\n';
+		this.tagsStack = [];
+	}
+
+	private attributesToString(attributes: XmlTagAttributes): string {
+		const attr: string[] = [];
+
+		for(const k of Object.keys(attributes)) {
+			attr.push(`${k}="${attributes[k]}"`);
+		}
+
+		return attr.length > 0 ? ' ' + attr.join(' ') : '';
+	}
+
+	private tabs(add?: number): string {
+		const tabsCount = this.tagsStack.length + (add ? add : 0) - 1;
+		let tabs = '';
+		for(let i = 0; i < tabsCount; i++)
+			tabs += '\t';
+		return tabs;
+	}
+
+	public startTag(tagName: string, attributes: XmlTagAttributes): XmlWriter {
+		this.tagsStack.push(tagName);
+		this.content += `${this.tabs()}<${tagName}${this.attributesToString(attributes)}>\n`;
+		return this;
+	}
+
+	public endTag(): XmlWriter {
+		if(this.tagsStack.length === 0)
+			throw 'XmlWriter.endTag(): there is no opened tag';
+		const tagName = this.tagsStack.pop();
+		this.content += `${this.tabs(1)}</${tagName}>\n`;
+		return this;
+	}
+
+	public writeText(text: string): XmlWriter {
+		this.content += `${this.tabs(1)}<![CDATA[${text}]]>\n`;
+		return this;
+	}
+
+	public writeTag(tagName: string, attributes: XmlTagAttributes, content: string): XmlWriter {
+		this.startTag(tagName, attributes);
+		this.writeText(content);
+		this.endTag();
+		return this;
+	}
+
+	public toString(): string {
+		if(this.tagsStack.length > 0)
+			throw 'XmlWriter.toString(): invalid xml (some tags are not closed properly)';
+		return this.content;
+	}
+}
+
+export default XmlWriter;

+ 6 - 0
src/views/class.pug

@@ -17,6 +17,7 @@ html
 		include imports/codemirror.javascript.import.pug
 		include imports/jquery.import.pug
 		include imports/global.import.pug
+		include modules/statistics.module.pug 
 		include imports/socket.import.pug
 		include imports/page.import.pug
 	body
@@ -44,6 +45,8 @@ html
 							div.tab.mixins(data-tab='Mixins')= 'Mixins'
 							div.tab.children(data-tab='Children')= 'Children'
 							div.tab.mixedin(data-tab='MixedIn')= 'Mixed in'
+							if(isEditor)
+								div.tab.contribution(data-tab='Contribution')= 'Contribution history'
 					div.content
 						div.content-tab#parents
 							h3.content-tab-title= 'Parents'
@@ -59,6 +62,9 @@ html
 							h3.content-tab-title= 'Children'
 						div.content-tab#editor
 							h3.content-tab-title= 'Editor'
+						if(isEditor)
+							div.content-tab#contribution
+								h3.content-tab-title= 'Contribution history'
 				else
 					div.right-header.class-not-found
 						div.class-not-found-image

+ 2 - 0
src/views/modules/statistics.module.pug

@@ -0,0 +1,2 @@
+script(src="/modules/statistics/statistics.js")
+link(rel="stylesheet", href="/modules/statistics/statistics.css")

+ 8 - 6
src/views/parts/left-header.part.pug

@@ -4,14 +4,16 @@ div.left-header
 		div.main-title-text= 'Doczilla JS Docs'
 	div.left-header-toolbar
 		if(isEditor)
-			div.auth-button.logout-button
+			div.button.auth-button.logout-button
 		else
-			div.auth-button.login-button
+			div.button.auth-button.login-button
 		div.class-list-mode-text= 'List display mode:'
-		div.class-list-mode-button.structurized(data-mode='structurized')
-		div.class-list-mode-button.unstructurized(data-mode='unstructurized')
+		div.button.class-list-mode-button.structurized(data-mode='structurized')
+		div.button.class-list-mode-button.unstructurized(data-mode='unstructurized')
 		if(isAdmin)
-			div.refresh-button
-		div.faq-button
+			div.button.refresh-button
+		div.button.faq-button
+		if(isEditor)
+			div.button.statistics-button
 	include ../modules/search.module.pug
 include ../modules/class-list.module.pug

+ 28 - 0
src/views/statistics.pug

@@ -0,0 +1,28 @@
+html
+	head
+		include imports/meta.import.pug
+		title= title
+		script.
+			const ClassList = !{JSON.stringify(ClassList)};
+			const Class = null;
+			const RepoNames = !{JSON.stringify(RepoNames)};
+			const StatisticsUser = !{JSON.stringify(StatisticsUser)};
+			const isAdmin = !{JSON.stringify(isAdmin)};
+			const isEditor = !{JSON.stringify(isEditor)};
+			const Login = !{JSON.stringify(Login)};
+		include imports/cdclientlib.import.pug
+		include imports/jquery.import.pug
+		include imports/global.import.pug
+		include modules/statistics.module.pug 
+		include imports/socket.import.pug
+		include imports/page.import.pug
+	body
+		div.main
+			div.left
+				include parts/left-header.part.pug
+			div.right
+				div#statistics
+					div.title
+						div.statistics-icon
+						span= StatisticsUser ? 'Statistics for user: ' + StatisticsUser : 'Statistics'
+					div.content

+ 11 - 2
static/CDClientLib/CDClientLib.js

@@ -427,6 +427,7 @@ class DOM {
 	};
 
 	static Tags = {
+		A: 'a',
 		Div: 'div',
 		Span: 'span',
 		H1: 'h1',
@@ -434,7 +435,14 @@ class DOM {
 		H3: 'h3',
 		P: 'p',
 		Textarea: 'textarea',
-		Input: 'input'
+		Input: 'input',
+		Table: 'table',
+		Tr: 'tr',
+		Th: 'th',
+		Tbody: 'tbody',
+		Td: 'td',
+		Select: 'select',
+		Option: 'option'
 	};
 
 	static Keys = {
@@ -476,7 +484,8 @@ class DOM {
 
 		if (!CDUtils.isEmpty(config.attr)) {
 			Object.keys(config.attr).forEach((name) => {
-				element.setAttribute(name, config.attr[name]);
+				if(config.attr[name] !== undefined)
+					element.setAttribute(name, config.attr[name]);
 			});
 		}
 

+ 12 - 0
static/img/statistics.svg

@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg height="30px" width="30px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" 
+	 viewBox="0 0 50 50" enable-background="new 0 0 50 50" xml:space="preserve">
+<path fill="#d1d1d1" d="M9.037,40.763h4.286c0.552,0,1-0.447,1-1v-7.314c0-0.553-0.448-1-1-1H9.037c-0.552,0-1,0.447-1,1v7.314
+	C8.037,40.315,8.485,40.763,9.037,40.763z M10.037,33.448h2.286v5.314h-2.286V33.448z"/>
+<path fill="#d1d1d1" d="M21.894,40.763c0.552,0,1-0.447,1-1v-20.64c0-0.553-0.448-1-1-1h-4.286c-0.552,0-1,0.447-1,1v20.64
+	c0,0.553,0.448,1,1,1H21.894z M18.608,20.123h2.286v18.64h-2.286V20.123z"/>
+<path fill="#d1d1d1" d="M30.465,40.763c0.552,0,1-0.447,1-1V25.96c0-0.553-0.448-1-1-1H26.18c-0.552,0-1,0.447-1,1v13.803
+	c0,0.553,0.448,1,1,1H30.465z M27.18,26.96h2.286v11.803H27.18V26.96z"/>
+<path fill="#d1d1d1" d="M33.751,9.763v30c0,0.553,0.448,1,1,1h4.286c0.552,0,1-0.447,1-1v-30c0-0.553-0.448-1-1-1h-4.286
+	C34.199,8.763,33.751,9.21,33.751,9.763z M35.751,10.763h2.286v28h-2.286V10.763z"/>
+</svg>

+ 7 - 1
static/modules/class-list/class-list.js

@@ -226,7 +226,6 @@ class ClassListModule {
 		DOM.get('.faq-button').on(DOM.Events.Click, (e) => { Url.goTo('/faq'); });
 
 		const refreshButton = DOM.get('.refresh-button');
-
 		if(refreshButton) {
 			refreshButton.on(DOM.Events.Click, (e) => {
 				if(confirm('Are you sure you want to update sources? It will take approximately 3-5 minutes. The system will not be available until the process finished.')) {
@@ -236,6 +235,13 @@ class ClassListModule {
 			});
 		}
 
+		const statisticsButton = DOM.get('.statistics-button');
+		if(statisticsButton) {
+			statisticsButton.on(DOM.Events.Click, (e) => {
+				Url.goTo('/statistics');
+			});
+		}
+
 		const modeCookieValue = DOM.getCookieProperty(App.CookieName, ClassListModule.ModeCookieName);
 		
 		ClassListModule._Mode = modeCookieValue || ClassListModule.Mode.Structurized;

+ 39 - 0
static/modules/statistics/statistics.css

@@ -0,0 +1,39 @@
+.statistics-module {
+	display: flex;
+	flex-direction: column;
+	align-items: flex-end;
+}
+
+.statistics-module > .statistics-table {
+	width: 100%;
+	color: #d1d1d1;
+	border-collapse: collapse;
+	text-align: center;
+}
+
+.statistics-module > .statistics-table,
+.statistics-module > .statistics-table tr,
+.statistics-module > .statistics-table td,
+.statistics-module > .statistics-table th {
+	border: 1px solid #d1d1d1;
+}
+
+.statistics-module > .statistics-pager {
+	margin: 5px 0;
+}
+
+.statistics-module > .statistics-pager > span,
+.statistics-module > .statistics-pager > .pager-page-size-selector {
+	margin-left: 5px;
+}
+
+.statistics-module > .statistics-pager > .pager-page {
+	text-decoration: underline;
+	cursor: pointer;
+}
+
+.statistics-module > .statistics-pager > .pager-page.current {
+	font-weight: 900;
+	text-decoration: none;
+	cursor: default;
+}

+ 226 - 0
static/modules/statistics/statistics.js

@@ -0,0 +1,226 @@
+class Statistics {
+	static Modes = {
+		ALL: 'all',
+		USER: 'user',
+		CLASS: 'class'
+	};
+
+	static Response = {
+		COUNT:		'count',
+		RECORDS:	'records'
+	};
+
+	static RowFields = {
+		USER:				'user',
+		CLASS:			'class',
+		PROPERTY:		'property',
+		ACTION:			'action',
+		TIMESTAMP:	'timestamp',
+		CREATED:		'created',
+		UPDATED:		'updated',
+		REMOVED:		'removed',
+		TOTAL:			'total'
+	};
+
+	static TableHeaders = {
+		USER:			'User',
+		CLASS:		'Class',
+		PROPERTY:	'Property',
+		ACTION:		'Action',
+		TIME:			'Time',
+		CREATED:	'Created',
+		UPDATED:	'Updated',
+		REMOVED:	'Removed',
+		TOTAL:		'Total'
+	};
+
+	static DefaultPage = 1;
+	static DefaultPageSize = 50;
+
+	static init(container, mode, page, pageSize) {
+		return new Statistics(container, mode, page, pageSize).load();
+	}
+
+	constructor(container, mode, page, pageSize) {
+		this.mode = mode || Statistics.Modes.ALL;
+		this.page = page || Statistics.DefaultPage;
+		this.pageSize = pageSize || Statistics.DefaultPageSize;
+		this.container = DOM.create({ tag: DOM.Tags.Div, cls: 'statistics-module' }, container);
+		this.statistics = [];
+		this.pagers = [];
+		this.pagerPages = [];
+		this.pagerPageSizeSelectors = [];
+	}
+
+	renderStatistics() {
+		if(this.statisticsTable)
+			this.statisticsTable.remove();
+
+		const table = this.statisticsTable = DOM.create({ tag: DOM.Tags.Table, cls: 'statistics-table' });
+		const headers = this.statisticsTableHeaders = DOM.create({ tag: DOM.Tags.Tr }, table);
+		const tbody = this.statisticsTableTbody = DOM.create({ tag: DOM.Tags.Tbody }, table);
+		
+		if(!this.isAllMode()) {
+			if(this.isUserMode())
+				DOM.create({ tag: DOM.Tags.Th, innerHTML: Statistics.TableHeaders.CLASS }, headers);
+			else
+				DOM.create({ tag: DOM.Tags.Th, innerHTML: Statistics.TableHeaders.USER }, headers);
+			DOM.create({ tag: DOM.Tags.Th, innerHTML: Statistics.TableHeaders.PROPERTY }, headers);
+			DOM.create({ tag: DOM.Tags.Th, innerHTML: Statistics.TableHeaders.ACTION }, headers);
+			DOM.create({ tag: DOM.Tags.Th, innerHTML: Statistics.TableHeaders.TIME }, headers);
+		
+			for(const statRow of this.statistics) {
+				const row = DOM.create({ tag: DOM.Tags.Tr }, tbody);
+				if(this.isUserMode()) {
+					const classLink = DOM.create({ tag: DOM.Tags.A, innerHTML: statRow[Statistics.RowFields.CLASS], attr: { 'href': `/class/${statRow[Statistics.RowFields.CLASS]}` } });
+					DOM.create({ tag: DOM.Tags.Td, cn: [classLink] }, row);
+				} else {
+					const userLink = DOM.create({ tag: DOM.Tags.A, innerHTML: statRow[Statistics.RowFields.USER], attr: { 'href': `/statistics/${statRow[Statistics.RowFields.USER]}` } });
+					DOM.create({ tag: DOM.Tags.Td, cn: [userLink] }, row);
+				}
+				DOM.create({ tag: DOM.Tags.Td, innerHTML: statRow[Statistics.RowFields.PROPERTY] }, row);
+				DOM.create({ tag: DOM.Tags.Td, innerHTML: statRow[Statistics.RowFields.ACTION] }, row);
+				DOM.create({ tag: DOM.Tags.Td, innerHTML: CDUtils.dateFormatUTC(parseInt(statRow[Statistics.RowFields.TIMESTAMP]), 3, 'D.M.Y, H:I:S') }, row);
+			}
+		} else {
+			DOM.create({ tag: DOM.Tags.Th, innerHTML: Statistics.TableHeaders.USER }, headers);
+			DOM.create({ tag: DOM.Tags.Th, innerHTML: Statistics.TableHeaders.CREATED }, headers);
+			DOM.create({ tag: DOM.Tags.Th, innerHTML: Statistics.TableHeaders.UPDATED }, headers);
+			DOM.create({ tag: DOM.Tags.Th, innerHTML: Statistics.TableHeaders.REMOVED }, headers);
+			DOM.create({ tag: DOM.Tags.Th, innerHTML: Statistics.TableHeaders.TOTAL }, headers);
+
+			for(const statRow of this.statistics) {
+				const row = DOM.create({ tag: DOM.Tags.Tr }, tbody);
+				const userLink = DOM.create({ tag: DOM.Tags.A, innerHTML: statRow[Statistics.RowFields.USER], attr: { 'href': `/statistics/${statRow[Statistics.RowFields.USER]}` } });
+				DOM.create({ tag: DOM.Tags.Td, cn: [userLink] }, row);
+				DOM.create({ tag: DOM.Tags.Td, innerHTML: statRow[Statistics.RowFields.CREATED] }, row);
+				DOM.create({ tag: DOM.Tags.Td, innerHTML: statRow[Statistics.RowFields.UPDATED] }, row);
+				DOM.create({ tag: DOM.Tags.Td, innerHTML: statRow[Statistics.RowFields.REMOVED] }, row);
+				DOM.create({ tag: DOM.Tags.Td, innerHTML: statRow[Statistics.RowFields.TOTAL] }, row);
+			}
+		}
+
+		this.container.append(this.statisticsTable);
+
+		return this;
+	}
+
+	renderPager(pagerIndex) {
+		if(this.pagers[pagerIndex]) {
+			
+			this.pagerPages[pagerIndex].forEach((page) => {
+				page.un(DOM.Events.Click, this.onPageClick.bind(this));
+				page.remove();
+			});
+
+			const selector = this.pagerPageSizeSelectors[pagerIndex];
+			selector.un(DOM.Events.Change, this.onPageSizeSelectChange.bind(this));
+			selector.remove();
+
+			this.pagers[pagerIndex].remove();
+		}
+
+		const page = this.page;
+		const pageCount = this.pageCount;
+
+		const pager = this.pagers[pagerIndex] = DOM.create({ tag: DOM.Tags.Div, cls: 'statistics-pager' });
+
+		const pagerPages = this.pagerPages[pagerIndex] = [];
+
+		pager.append(DOM.create({ tag: DOM.Tags.Span, innerHTML: 'Page:', cls: 'pager-page-label' }));
+
+		const startPage = Math.max(1, page - 2);
+		const endPage = Math.min(pageCount > 0 ? pageCount : 1, page + 2);
+
+		if(startPage > 1) {
+			const firstPage = DOM.create({ tag: DOM.Tags.Span, innerHTML: 1, cls: 'pager-page' }).on(DOM.Events.Click, this.onPageClick.bind(this));
+			pagerPages.push(firstPage);
+			pager.append(firstPage);
+			pager.append(DOM.create({ tag: DOM.Tags.Span, innerHTML: '..' }));
+		}
+
+		for (let i = startPage; i <= endPage; i++) {
+			const pageElement =  DOM.create({ tag: DOM.Tags.Span, innerHTML: i, cls: 'pager-page' });
+
+			if(page === i) {
+				pageElement.addClass('current');
+			} else {
+				pageElement.on(DOM.Events.Click, this.onPageClick.bind(this));
+				pagerPages.push(pageElement);
+			}
+
+			pager.append(pageElement);
+		}
+
+		if(endPage < pageCount) {
+			pager.append(DOM.create({ tag: DOM.Tags.Span, innerHTML: '..' }));
+
+			const lastPage = DOM.create({ tag: DOM.Tags.Span, innerHTML: pageCount, cls: 'pager-page' }).on(DOM.Events.Click, this.onPageClick.bind(this));
+			pager.append(lastPage);
+			pagerPages.push(lastPage);
+		}
+
+		const pageSizeSelector = DOM.create({ tag: DOM.Tags.Select, cls: 'pager-page-size-selector', cn: [
+			DOM.create({ tag: DOM.Tags.Option, innerHTML: '1', attr: { 'selected': (this.pageSize === 1 ? 'selected' : undefined) } }),
+			DOM.create({ tag: DOM.Tags.Option, innerHTML: '5', attr: { 'selected': (this.pageSize === 5 ? 'selected' : undefined) } }),
+			DOM.create({ tag: DOM.Tags.Option, innerHTML: '25', attr: { 'selected': (this.pageSize === 25 ? 'selected' : undefined) } }),
+			DOM.create({ tag: DOM.Tags.Option, innerHTML: '50', attr: { 'selected': (this.pageSize === 50 ? 'selected' : undefined) } }),
+			DOM.create({ tag: DOM.Tags.Option, innerHTML: '100', attr: { 'selected': (this.pageSize === 100 ? 'selected' : undefined) } }),
+			DOM.create({ tag: DOM.Tags.Option, innerHTML: '500', attr: { 'selected': (this.pageSize === 500 ? 'selected' : undefined) } })
+		]});
+
+		pageSizeSelector.on(DOM.Events.Change, this.onPageSizeSelectChange.bind(this));
+
+		pager.append(DOM.create({ tag: DOM.Tags.Span, innerHTML: '|' }));
+		pager.append(DOM.create({ tag: DOM.Tags.Span, innerHTML: 'Page size:', cls: 'pager-page-size-label' }));
+
+		pager.append(pageSizeSelector);
+
+		this.pagerPageSizeSelectors[pagerIndex] = pageSizeSelector;
+
+		this.container.append(pager);
+	}
+
+	onPageSizeSelectChange(e) {
+		this.page = Statistics.DefaultPage;
+		this.pageSize = parseInt(CDElement.get(e.target).getValue());
+		this.load();
+	}
+
+	onPageClick(e) {
+		this.page = parseInt(CDElement.get(e.target).getValue());
+		this.load();
+	}
+
+	isAllMode() {
+		return this.mode === Statistics.Modes.ALL;
+	}
+
+	isUserMode() {
+		return this.mode === Statistics.Modes.USER;
+	}
+
+	isClassMode() {
+		return this.mode === Statistics.Modes.CLASS;
+	}
+
+	load() {
+		let url = '/data/statistics';
+		if(this.isUserMode())
+			url = `/data/statistics/user/${StatisticsUser}`;
+		if(this.isClassMode())
+			url = `/data/statistics/class/${Class.name}`;
+		
+		fetch(`${url}?page=${this.page}&pageSize=${this.pageSize}`, {
+			method: 'GET'
+		}).then(res => res.json()).then(res => {
+			this.statistics = res[Statistics.Response.RECORDS];
+			this.pageCount = Math.ceil(res[Statistics.Response.COUNT] / this.pageSize);
+			this.renderPager(0); // top
+			this.renderStatistics();
+			this.renderPager(1); // bottom
+		});
+
+		return this;
+	}
+}

+ 35 - 17
static/page/class/script.js

@@ -62,7 +62,8 @@ class ClassPage {
 		Properties: 'Properties',
 		Mixins: 'Mixins',
 		Children: 'Children',
-		MixedIn: 'MixedIn'
+		MixedIn: 'MixedIn',
+		Contribution: 'Contribution'
 	};
 
 	static PropertyType = {
@@ -99,7 +100,8 @@ class ClassPage {
 		ParentsBranch: 'parentsBranch',
 		Statics: 'statics',
 		DynamicProperties: 'dynamicProperties',
-		Root: 'root'
+		Root: 'root',
+		ShortName: 'shortName'
 	};
 
 	static ContextMenuType = {
@@ -112,25 +114,30 @@ class ClassPage {
 		}
 
 		this.tabElements = {
-			[ClassPage.TabNames.Editor]:     DOM.get('.tab.editor'),
-			[ClassPage.TabNames.Methods]:    DOM.get('.tab.methods'),
-			[ClassPage.TabNames.Parents]:    DOM.get('.tab.parents'),
-			[ClassPage.TabNames.Properties]: DOM.get('.tab.properties'),
-			[ClassPage.TabNames.Mixins]:     DOM.get('.tab.mixins'),
-			[ClassPage.TabNames.Children]:   DOM.get('.tab.children'),
-			[ClassPage.TabNames.MixedIn]:    DOM.get('.tab.mixedin')
+			[ClassPage.TabNames.Editor]:				DOM.get('.tab.editor'),
+			[ClassPage.TabNames.Methods]:				DOM.get('.tab.methods'),
+			[ClassPage.TabNames.Parents]:				DOM.get('.tab.parents'),
+			[ClassPage.TabNames.Properties]:		DOM.get('.tab.properties'),
+			[ClassPage.TabNames.Mixins]:				DOM.get('.tab.mixins'),
+			[ClassPage.TabNames.Children]:			DOM.get('.tab.children'),
+			[ClassPage.TabNames.MixedIn]:				DOM.get('.tab.mixedin')
 		};
 		
 		this.contentElements = {
-			[ClassPage.TabNames.Editor]:     DOM.get('.content-tab#editor'),
-			[ClassPage.TabNames.Methods]:    DOM.get('.content-tab#methods'),
-			[ClassPage.TabNames.Parents]:    DOM.get('.content-tab#parents'),
-			[ClassPage.TabNames.Properties]: DOM.get('.content-tab#properties'),
-			[ClassPage.TabNames.Mixins]:     DOM.get('.content-tab#mixins'),
-			[ClassPage.TabNames.Children]:   DOM.get('.content-tab#children'),
-			[ClassPage.TabNames.MixedIn]:    DOM.get('.content-tab#mixedin')
+			[ClassPage.TabNames.Editor]:				DOM.get('.content-tab#editor'),
+			[ClassPage.TabNames.Methods]:				DOM.get('.content-tab#methods'),
+			[ClassPage.TabNames.Parents]:				DOM.get('.content-tab#parents'),
+			[ClassPage.TabNames.Properties]:		DOM.get('.content-tab#properties'),
+			[ClassPage.TabNames.Mixins]:				DOM.get('.content-tab#mixins'),
+			[ClassPage.TabNames.Children]:			DOM.get('.content-tab#children'),
+			[ClassPage.TabNames.MixedIn]:				DOM.get('.content-tab#mixedin')
 		};
 
+		if(isEditor) {
+			this.tabElements[ClassPage.TabNames.Contribution] = DOM.get('.tab.contribution');
+			this.contentElements[ClassPage.TabNames.Contribution] = DOM.get('.content-tab#contribution');
+		}
+
 		this.documented = Object.keys(Comments).filter((key) => { return Comments[key].text.length > 0; }).length;
 		this.documentable = 0;
 		this.inheritedCommentsQuery = {};
@@ -247,6 +254,9 @@ class ClassPage {
 		this.renderMethods();
 		this.renderDocumentedPercentage();
 		this.loadInheritedComments();
+
+		if(isEditor)
+			this.renderContribution();
 	}
 
 	renderDocumentedPercentage() {
@@ -809,7 +819,7 @@ class ClassPage {
 	}
 
 	shortNameExists(shortName) {
-		return Object.keys(ClassList).map((key) => ClassList[key]).filter((item) => item.shortName === shortName).length > 0;
+		return Object.keys(ClassList).map((key) => ClassList[key]).filter((item) => item[ClassPage.ClassProperties.ShortName] === shortName).length > 0;
 	}
 
 	findClassProperty(propertyName) {
@@ -969,6 +979,9 @@ class ClassPage {
 		changes.forEach((changedComment) => {
 			this.processChange(changedComment);
 		});
+
+		if(this.statistics)
+			this.statistics.load();
 	}
 
 	processChange(changedComment) {
@@ -1030,6 +1043,11 @@ class ClassPage {
 		itemCommentStatic.removeClass(ClassPage.StyleClasses.Hidden);
 	}
 
+	renderContribution() {
+		const contributionElement = this.contentElements[ClassPage.TabNames.Contribution];
+		this.statistics = Statistics.init(contributionElement, Statistics.Modes.CLASS);
+	}
+
 	/*	>>> Context menu | TODO: move to a completely independent module? */
 	showContextMenu(contextMenuType, target, pos) {
 		while(!target.hasClass(contextMenuType))

+ 1 - 9
static/page/class/style.css

@@ -27,7 +27,6 @@
 
 .right-header {
 	padding: 8px 10px 0 10px;
-	color: #d1d1d1;
 }
 
 .right-header.class-not-found {
@@ -152,7 +151,6 @@
 .right > .content > .content-tab {
 	display: none;
 	position: relative;
-	color: #d1d1d1;
 }
 
 .right.mode-list > .content > .content-tab,
@@ -305,12 +303,12 @@
 	border: 1px solid #d1d1d1;
 	background-color: #0000;
 	resize: none;
-	color: #d1d1d1;
 	font-style: italic;
 	border-radius: 4px;
 	outline: none;
 	font-family: 'Play';
 	font-size: 16px;
+	color: #d1d1d1;
 }
 
 .right > .content > .content-tab > .properties-list > .property-item > .property-item-comment > .property-item-comment-input {
@@ -378,15 +376,10 @@
 	opacity: 0.95;
 }
 
-.right > .content > .content-tab#editor > .full-source-prompt > .full-source-prompt-text {
-	color: #d1d1d1;
-}
-
 .right > .content > .content-tab#editor > .full-source-prompt > .full-source-prompt-button {
 	padding: 5px 10px;
 	border: 1px solid #d1d1d1;
 	cursor: pointer;
-	color: #d1d1d1;
 	width: fit-content;
 	border-radius: 5px;
 }
@@ -406,7 +399,6 @@
 .right > .content > .content-tab .class-item.indent:not(:first-child) > .class-icon:before {
 	content: '↳';
 	position: absolute;
-	color: #d1d1d1;
 	left: -10px;
 }
 

+ 0 - 2
static/page/faq/style.css

@@ -11,12 +11,10 @@
 	display: flex;
 	justify-content: space-between;
 	width: 55px;
-	color: #d1d1d1;
 }
 
 .faq-content {
 	width: 700px;
-	color: #d1d1d1;
 	margin-top: 25px;
 }
 

+ 12 - 6
static/page/login/script.js

@@ -1,4 +1,10 @@
 class LoginPage {
+
+	static AuthorizeResponse = {
+		SUCCESS: 'success',
+		RETURN_TO: 'returnTo'
+	};
+
 	start() {
 		this.loginInput = DOM.get('.login-form>.login-form-login>.login-form-login-input').focus();
 		this.passwordInput = DOM.get('.login-form>.login-form-password>.login-form-password-input');
@@ -31,13 +37,13 @@ class LoginPage {
 				'login': login,
 				'password': password
 			})
-		}).then((res) => {
-			if(res.status === 200)
-				Url.goTo('/');
-			else if(res.status === 403)
+		}).then((res) => res.json()).then((res) => {
+			if(res[LoginPage.AuthorizeResponse.SUCCESS]) {
+				const returnTo = res[LoginPage.AuthorizeResponse.RETURN_TO];
+				Url.goTo(returnTo ? returnTo : '/');
+			} else {
 				this.failureText.setInnerHTML('Wrong login or password');
-			else
-				this.failureText.setInnerHTML('Internal server error');
+			}
 		});
 	}
 }

+ 1 - 0
static/page/login/style.css

@@ -27,6 +27,7 @@
 	background-color: #e7e7e7;
 	cursor: pointer;
 	font-size: 20px;
+	color: #000000;
 }
 
 .login-form > .login-form-button:hover {

+ 23 - 0
static/page/statistics/script.js

@@ -0,0 +1,23 @@
+class StatisticsPage {
+	constructor() {
+		this.userMode = StatisticsUser != null;
+	}
+
+	openSocket() {
+		this.socket = new Socket('/ws').onMessage(this.onSocketMessage.bind(this));
+	}
+
+	onSocketMessage(e) {
+		this.statistics.load();
+	}
+
+	start() {
+		this.openSocket();
+		this.statistics = Statistics.init(DOM.get('#statistics>.content'), this.userMode ? Statistics.Modes.USER : Statistics.Modes.ALL);
+		return this;
+	}
+}
+
+window_.on('load', (e) => {
+	window.page = new StatisticsPage().start();
+});

+ 17 - 0
static/page/statistics/style.css

@@ -0,0 +1,17 @@
+#statistics {
+	padding: 14px 10px 8px 10px;
+}
+
+#statistics > .title {
+	font-size: 24px;
+	display: flex;
+	align-items: center;
+	padding-bottom: 7px;
+}
+
+#statistics > .title > .statistics-icon {
+	background-image: url(/img/statistics.svg);
+	width: 30px;
+	height: 30px;
+	background-size: cover;
+}

+ 20 - 17
static/style/style.css

@@ -65,7 +65,6 @@ body {
 }
 
 .sources-updating > .message-container > .message {
-	color: #d1d1d1;
 	font-size: 24px;
 }
 
@@ -91,7 +90,6 @@ body.page-not-found > .message-container {
 	flex-direction: column;
 	align-items: center;
 	justify-content: center;
-	color: #d1d1d1;
 }
 
 body.page-not-found > .message-container > .page-not-found-text {
@@ -113,6 +111,7 @@ body.page-not-found > .message-container > .page-not-found-image {
 	display: flex;
 	flex-direction: row;
 	height: 100%;
+	color: #d1d1d1;
 }
 
 .right {
@@ -136,13 +135,10 @@ body.page-not-found > .message-container > .page-not-found-image {
 }
 
 .left > .left-header > .left-header-toolbar > .class-list-mode-text {
-	color: #d1d1d1;
 	margin-right: 15px;
 }
 
-.left > .left-header > .left-header-toolbar > .class-list-mode-button,
-.left > .left-header > .left-header-toolbar > .faq-button,
-.left > .left-header > .left-header-toolbar > .refresh-button {
+.left > .left-header > .left-header-toolbar > .button {
 	width: 24px;
 	height: 24px;
 	border: 1px solid #d1d1d1;
@@ -153,10 +149,8 @@ body.page-not-found > .message-container > .page-not-found-image {
 	cursor: pointer;
 }
 
-.left > .left-header > .left-header-toolbar > .class-list-mode-button.selected,
-.left > .left-header > .left-header-toolbar > .class-list-mode-button:hover,
-.left > .left-header > .left-header-toolbar > .faq-button:hover,
-.left > .left-header > .left-header-toolbar > .refresh-button:hover {
+.left > .left-header > .left-header-toolbar > .button:hover,
+.left > .left-header > .left-header-toolbar > .button.selected {
 	background-color: #ffffff40;
 }
 
@@ -179,12 +173,22 @@ body.page-not-found > .message-container > .page-not-found-image {
 	margin-left: 10px;
 }
 
+.left > .left-header > .left-header-toolbar > .statistics-button {
+	background-image: url(/img/statistics.svg);
+	margin-left: 5px;
+}
+
 .left > .left-header > .left-header-toolbar > .auth-button {
 	width: 24px;
 	height: 24px;
 	background-size: cover;
-	margin-right: 40px;
+	margin-right: 20px;
 	cursor: pointer;
+	border: 0;
+}
+
+.left > .left-header > .left-header-toolbar > .auth-button:hover {
+	background-color: transparent;
 }
 
 .left > .left-header > .left-header-toolbar > .auth-button.login-button {
@@ -197,6 +201,10 @@ body.page-not-found > .message-container > .page-not-found-image {
 
 /* Class/Dir items >>> */
 
+.dir-item {
+	min-width: 100%;
+}
+
 .class-item, .dir-item>.dir-name {
 	align-items: center;
 	cursor: pointer;
@@ -222,11 +230,6 @@ body.page-not-found > .message-container > .page-not-found-image {
 
 .class-item > .class-name {
 	display: inline-block;
-	color: #d1d1d1;
-}
-
-.dir-item {
-	color: #d1d1d1;
 }
 
 .dir-item > .dir-name > .dir-collapsed-icon {
@@ -262,6 +265,7 @@ body.page-not-found > .message-container > .page-not-found-image {
 
 .dir-item.collapsed > .dir-content {
 	height: 0 !important;
+	display: none;
 }
 
 a, a:visited {
@@ -275,7 +279,6 @@ a, a:visited {
 	display: flex;
 	flex-direction: column;
 	z-index: 10000;
-	color: #d1d1d1;
 	width: fit-content;
 	background-color: #312d2a;
 	border: 1px solid #d1d1d1;