Browse Source

v1.0.3: comments storage, gogs auth, websockets

CrazyDoctor 6 months ago
parent
commit
dbccab387b

+ 2 - 1
.gitignore

@@ -1,4 +1,5 @@
 node_modules
 node_modules
 dist
 dist
 .env
 .env
-.vscode
+.vscode
+./config.json

+ 16 - 4
node_scripts/uglifyStatics.mjs

@@ -6,6 +6,7 @@ class Minifier {
 
 
 	static MinifyList = [
 	static MinifyList = [
 		'CDClientLib',
 		'CDClientLib',
+    'websocket',
 		'modules',
 		'modules',
 		'page',
 		'page',
 		'App.js'
 		'App.js'
@@ -48,7 +49,7 @@ class Minifier {
 		
 		
 		const mergedContent = mergeFilesWithComments(allJSFiles);
 		const mergedContent = mergeFilesWithComments(allJSFiles);
 
 
-		fs.writeFileSync('./dist/static/merged_statics.js', mergedContent);
+		fs.writeFileSync('./dist/static/merged_statics.js', mergedContent, { encoding: 'utf8' });
 	}
 	}
 
 
 	static uglify() {
 	static uglify() {
@@ -81,6 +82,8 @@ class Minifier {
 					// >>> Server data
 					// >>> Server data
 					'Class',
 					'Class',
 					'isAdmin',
 					'isAdmin',
+          'isEditor',
+          'Login',
 					'ClassList',
 					'ClassList',
 					'ClassSource',
 					'ClassSource',
 					'RepoNames',
 					'RepoNames',
@@ -90,19 +93,28 @@ class Minifier {
 					'comment',
 					'comment',
 					'timestamp',
 					'timestamp',
 					'nearestParent',
 					'nearestParent',
+          'nearestParentRoot',
 					'type',
 					'type',
 					'value',
 					'value',
 					'key',
 					'key',
 					'inherited',
 					'inherited',
 					'overridden',
 					'overridden',
-					'dynamic'
+					'dynamic',
 					// <<< Server data
 					// <<< Server data
+
+          // >>> WebSocket
+          'onopen',
+          'onclose',
+          'onerror',
+          'onmessage',
+          'send'
+          // <<< WebSocket
 				],
 				],
 				keep_quoted: true
 				keep_quoted: true
 			},
 			},
 			toplevel: true
 			toplevel: true
 		}
 		}
-		const uglified = minify_sync(fs.readFileSync('./dist/static/merged_statics.js', "utf8").toString(), { mangle: mangleProperties });
+		const uglified = minify_sync(fs.readFileSync('./dist/static/merged_statics.js', 'utf8').toString(), { mangle: mangleProperties });
 		const uglifiedCode = uglified.code.replace(/\/\*\!\s>>>\s(.+)\s\*\/([^\s])/g, '/*! >>> $1 */\n$2');
 		const uglifiedCode = uglified.code.replace(/\/\*\!\s>>>\s(.+)\s\*\/([^\s])/g, '/*! >>> $1 */\n$2');
 		const uglifiedCodeStrings = uglifiedCode.split('\n');
 		const uglifiedCodeStrings = uglifiedCode.split('\n');
 
 
@@ -119,7 +131,7 @@ class Minifier {
 		}
 		}
 
 
 		for(const file of Object.keys(fContents)) {
 		for(const file of Object.keys(fContents)) {
-			fs.writeFileSync(file, fContents[file]);
+			fs.writeFileSync(file, fContents[file], { encoding: 'utf8' });
 		}
 		}
 	}
 	}
 
 

File diff suppressed because it is too large
+ 193 - 482
package-lock.json


+ 12 - 10
package.json

@@ -1,31 +1,33 @@
 {
 {
   "name": "doczilla_js_docs",
   "name": "doczilla_js_docs",
-  "version": "1.0.1",
+  "version": "1.0.3",
   "dependencies": {
   "dependencies": {
-    "@types/cookie-parser": "^1.4.7",
-    "@types/cron": "^2.4.0",
-    "@types/jsdom": "^21.1.6",
-    "@types/pug": "^2.0.10",
-    "@types/sqlite3": "^3.1.11",
+    "@types/ws": "^8.5.10",
     "cookie-parser": "^1.4.6",
     "cookie-parser": "^1.4.6",
     "cron": "^3.1.6",
     "cron": "^3.1.6",
     "jsdom": "^24.0.0",
     "jsdom": "^24.0.0",
     "org.crazydoctor.expressts": "git+https://git.crazydoctor.org/expressts/org.crazydoctor.expressts",
     "org.crazydoctor.expressts": "git+https://git.crazydoctor.org/expressts/org.crazydoctor.expressts",
     "pug": "^3.0.2",
     "pug": "^3.0.2",
-    "sqlite3": "^5.1.7"
+    "ws": "^8.16.0"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@types/cookie-parser": "^1.4.7",
+    "@types/cron": "^2.4.0",
     "@types/express-session": "^1.18.0",
     "@types/express-session": "^1.18.0",
+    "@types/jsdom": "^21.1.6",
+    "@types/pug": "^2.0.10",
     "@typescript-eslint/eslint-plugin": "^7.1.0",
     "@typescript-eslint/eslint-plugin": "^7.1.0",
     "@typescript-eslint/parser": "^7.1.0",
     "@typescript-eslint/parser": "^7.1.0",
     "eslint": "^8.57.0",
     "eslint": "^8.57.0",
-		"ncp": "^2.0.0",
-		"terser": "^5.29.2"
+    "ncp": "^2.0.0",
+    "terser": "^5.29.2"
   },
   },
   "scripts": {
   "scripts": {
     "lint": "eslint .",
     "lint": "eslint .",
     "build": "tsc && ncp ./config.json ./dist/config.json && ncp ./src/views/ ./dist/views && node ./node_scripts/copyStatics.mjs",
     "build": "tsc && ncp ./config.json ./dist/config.json && ncp ./src/views/ ./dist/views && node ./node_scripts/copyStatics.mjs",
     "buildProd": "npm run build && node ./node_scripts/uglifyStatics.mjs",
     "buildProd": "npm run build && node ./node_scripts/uglifyStatics.mjs",
-		"start": "node ./dist/index.js"
+    "start": "node ./dist/index.js",
+    "buildAndStart": "npm run build && npm run start",
+    "buildAndStartProd": "npm run buildProd && npm run start"
   }
   }
 }
 }

+ 243 - 0
src/comments/CommentsManager.ts

@@ -0,0 +1,243 @@
+import { execSync, exec } from 'child_process';
+import fs from 'fs';
+import IComment from './IComment';
+import { promisify } from 'util';
+import ServerApp from '..';
+
+const execAsync = promisify(exec);
+
+interface IClassListResult {
+	success: boolean;
+	comments: IComment[] | null;
+};
+
+enum CommentAction {
+	Create = 'create',
+	Update = 'update',
+	Remove = 'remove'
+};
+
+interface IQueueComment extends IComment {
+	action: CommentAction;
+};
+
+enum CommentUpdateStatus {
+	Error = 0,
+	Created = 1,
+	Updated = 2,
+	Deleted = 3,
+	Enqueued = 4
+};
+
+class CommentsManager {
+	public static Saving: boolean = false;
+	public static Queue: { comment: IQueueComment, commentPath: string }[] = [];
+	public static CommentsRepoUrl: string = '';
+	public static CommentsFSRoot: string = '';
+
+	// called from index.ts
+	public static init(rootDir: string, repoUrl: string): void {
+		CommentsManager.CommentsFSRoot = rootDir;
+		CommentsManager.CommentsRepoUrl = repoUrl;
+
+		if(!fs.existsSync(rootDir)) {
+			fs.mkdirSync(rootDir);
+			execSync('git init', { encoding: 'utf8', cwd: rootDir, stdio: [] });
+			execSync(`git remote add origin ${repoUrl}`, { encoding: 'utf8', cwd: rootDir, stdio: [] });
+			execSync('git fetch origin', { encoding: 'utf8', cwd: rootDir, stdio: [] });
+			execSync('git checkout -b master origin/master', { encoding: 'utf8', cwd: rootDir, stdio: [] });
+		}
+
+		execSync('git pull', { encoding: 'utf8', cwd: rootDir, stdio: [] });
+	}
+
+	private static beginSave(): void {
+		CommentsManager.Saving = true;
+	}
+
+	private static endSave(): void {
+		CommentsManager.Saving = false;
+		CommentsManager.applyQueue();
+	}
+
+	private static isSaving(): boolean {
+		return CommentsManager.Saving;
+	}
+
+	private static enqueueComment(comment: IComment, action: CommentAction): void {
+		const commentPath = `${CommentsManager.getClassDirPath(comment.root, comment.className)}/${comment.propertyName}`;
+		if(fs.existsSync(commentPath))
+			action = CommentAction.Update;
+
+		const queueComment: IQueueComment = Object.assign(comment, { action }) as IQueueComment;
+
+		CommentsManager.Queue.push({ comment: queueComment, commentPath: commentPath });
+	}
+
+	private static createClassDir(root: string, className: string): string {
+		const classDirPath = CommentsManager.getClassDirPath(root, className);
+		let currentPath = '';
+		classDirPath.split('/').forEach(dir => {
+			currentPath += dir + '/';
+			if(!fs.existsSync(currentPath)) {
+				fs.mkdirSync(currentPath);
+			}
+		});
+
+		return classDirPath;
+	}
+
+	private static getClassDirPath(root: string, className: string): string {
+		return `${CommentsManager.CommentsFSRoot}/${root}/${className.replaceAll('.', '/')}`;
+	}
+
+	private static async createOrUpdateCommentFile(comment: IComment): Promise<{ commentPath: string, status: CommentUpdateStatus }> {
+		const commentContent = `${comment.author}:${comment.timestamp}:${comment.text}`;
+		const classDirPath = CommentsManager.createClassDir(comment.root, comment.className);
+		const commentPath = `${classDirPath}/${comment.propertyName}`;
+
+		let status = CommentUpdateStatus.Created;
+
+		if (fs.existsSync(commentPath))
+			status = CommentUpdateStatus.Updated;
+
+		await fs.promises.writeFile(commentPath, commentContent, { encoding: 'utf8' });
+
+		return { commentPath, status };
+	}
+
+	public static async create(comment: IComment): Promise<CommentUpdateStatus> {
+		if(CommentsManager.isSaving()) {
+			CommentsManager.enqueueComment(comment, CommentAction.Create);
+			return CommentUpdateStatus.Enqueued;
+		}
+		
+		CommentsManager.beginSave();
+		try {
+			const { commentPath, status } = await CommentsManager.createOrUpdateCommentFile(comment);
+			const queueComment: IQueueComment = Object.assign(comment, { action: (status === CommentUpdateStatus.Created ? CommentAction.Create : CommentAction.Update) }) 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]);
+			CommentsManager.endSave();
+			return status;
+		} catch(e) {
+			CommentsManager.endSave();
+			return CommentUpdateStatus.Error;
+		}
+	}
+
+	public static async getCommentsByClass(root: string, className: string, propertiesList?: string[]): Promise<IClassListResult> {
+		try {
+			const classDir = CommentsManager.getClassDirPath(root, className);
+
+			if(!fs.existsSync(classDir)) {
+				return { success: true, comments: [] };
+			}
+
+			const commentFiles = fs.readdirSync(classDir).filter(file => file !== '.' && file !== '..');
+			const comments: IComment[] = [];
+
+			for(const propertyName of commentFiles) {
+				if(propertiesList && !propertiesList.includes(propertyName))
+					continue;
+				const filePath = `${classDir}/${propertyName}`;
+				const [author, timestamp, ...rest] = fs.readFileSync(filePath, 'utf-8').split(':');
+				const text = rest.join(':');
+
+				comments.push({ root: root, className: className, propertyName: propertyName, text: text, author: author, timestamp: Number.parseInt(timestamp) });
+			}
+
+			return { success: true, comments: comments };
+		} catch(e) {
+			return { success: false, comments: null };
+		}
+	}
+
+	private static async commit(comments: IQueueComment[], commentsPaths: string[]): Promise<void> {
+		const commitMessages: string[] = [];
+
+		for(let i = 0; i < comments.length; i++) {
+			const comment = comments[i];
+			const commentPath = commentsPaths[i];
+			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 commit -m ${commitMessages.join(' -m ')}`, { encoding: 'utf8', cwd: CommentsManager.CommentsFSRoot });
+		await execAsync('git push', { encoding: 'utf8', cwd: CommentsManager.CommentsFSRoot });
+		await this.notifySockets(comments); // final action of any update
+	}
+
+	private static async applyQueue(): Promise<void> {
+		if(CommentsManager.Queue.length === 0 || CommentsManager.isSaving())
+			return;
+
+		const queue = [...CommentsManager.Queue];
+		CommentsManager.Queue = [];
+
+		CommentsManager.beginSave();
+		try {
+			const comments = [];
+			const commentsPaths = [];
+			for(const queueItem of queue) {
+				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;
+				}
+
+				comments.push(comment);
+				commentsPaths.push(commentPath);
+			}
+			await CommentsManager.commit(comments, commentsPaths);
+			CommentsManager.endSave();
+		} catch(e) {
+			CommentsManager.endSave();
+			return;
+		}
+	}
+
+	// called after each commit to notify client-side socket about changes
+	private static async notifySockets(comments: IQueueComment[]): Promise<void> {
+		await ServerApp.notifyAllSockets(JSON.stringify(comments));
+	}
+}
+
+export { CommentsManager, CommentUpdateStatus };

+ 10 - 0
src/comments/IComment.ts

@@ -0,0 +1,10 @@
+interface IComment {
+  root: string;
+  className: string;
+  propertyName: string;
+  author: string;
+  timestamp: number;
+  text: string;
+};
+
+export default IComment;

+ 27 - 68
src/index.ts

@@ -2,82 +2,40 @@ import { Guid, Server } from 'org.crazydoctor.expressts';
 import * as path from 'path';
 import * as path from 'path';
 import os from 'os';
 import os from 'os';
 import fs from 'fs';
 import fs from 'fs';
-import { spawn } from 'child_process';
 import { Sources } from './sources/Sources';
 import { Sources } from './sources/Sources';
-import { CronJob } from 'cron';
 import SHA256 from './util/SHA256';
 import SHA256 from './util/SHA256';
-import { Database } from 'sqlite3';
+import CronSourcesUpdateTask from './util/CronSourcesUpdateTask';
+import { CommentsManager } from './comments/CommentsManager';
+import { WebSocket as WS } from 'ws';
+import WebSocketHandler from './websocket/WebSocket';
 
 
 class ServerApp {
 class ServerApp {
 	public static SourcesUpdating = false;
 	public static SourcesUpdating = false;
-	private static Server: Server | null = null;
-	private static db: Database | null = null;
+	public static Server: Server | null = null;
+	public static WebSocketUrl: string = '/ws';
 
 
-	private static sourcesUpdateTask(): void {
-		if(ServerApp.SourcesUpdating)
-			return;
-		try {
-			ServerApp.Server?.logInfo('[Cron] Sources update started...');
-			ServerApp.SourcesUpdating = true;
-			const update = spawn('node', [path.resolve(__dirname, 'util/UpdateSources.js')]);
-	
-			update.stdout.on('data', (data) => {
-				console.log(data.toString());
-			});
-
-			update.stderr.on('data', (data) => {
-				console.error(data.toString());
-			});
-
-			update.on('close', (code) => {
-				ServerApp.SourcesUpdating = false;
-				if (code === 0) {
-					Sources.get();
-					ServerApp.Server?.logInfo('[Cron] Sources update finished.');
-				} else {
-					ServerApp.Server?.logError('[Cron] Sources update failed.');
-				}
-			});
-
-			update.on('error', (e) => {
-				ServerApp.SourcesUpdating = false;
-				ServerApp.Server?.logError('[Cron] Sources update failed.');
-				console.error(e);
-			});
-		} catch (e) {
-			ServerApp.SourcesUpdating = false;
-			ServerApp.Server?.logError('[Cron] Sources update failed.');
-			console.error(e);
-		}
+	public static getWebSocketConnections(): Set<WS> | null {
+		return ServerApp.Server?.getWsConnections(ServerApp.WebSocketUrl) || null;
 	}
 	}
 
 
-	private static startCronTask(): void {
-		const timeZone = 'Europe/Moscow';
-		const cronSchedule = '0 3 * * *';
-		const job = new CronJob(cronSchedule, ServerApp.sourcesUpdateTask, null, true, timeZone);
-		job.start();
-	}
-
-	private static initDb(assetsDir: string): void {
-		const dbDir = path.resolve(`${os.homedir()}/${assetsDir}/db`);
-		if(!fs.existsSync(dbDir))
-			fs.mkdirSync(dbDir);
-		
-		ServerApp.db = new Database(`${dbDir}/db.db`);
-
-		ServerApp.db.run(`
-			CREATE TABLE IF NOT EXISTS comments (
-				id INTEGER PRIMARY KEY AUTOINCREMENT,
-				class TEXT NOT NULL,
-				property TEXT NOT NULL,
-				comment TEXT,
-				timestamp BIGINT
-			)
-		`);
+	public static async notifySocket(ws: WS, message: string): Promise<void> {
+		return new Promise((resolve, reject) => {
+			if(ws.readyState !== WS.OPEN)
+				reject();
+			ws.send(message, (err) => {
+				if(err)
+					return reject();
+				return resolve();
+			});
+		});
 	}
 	}
 
 
-	public static getDb(): Database {
-		return ServerApp.db!;
+	public static async notifyAllSockets(message: string): Promise<void[]> {
+		const promises: Promise<void>[] = [];
+		ServerApp.getWebSocketConnections()?.forEach((connection) => {
+			promises.push(ServerApp.notifySocket(connection, message));
+		});
+		return Promise.all(promises);
 	}
 	}
 
 
 	public static start(): void {
 	public static start(): void {
@@ -86,7 +44,7 @@ class ServerApp {
 		const ServerConfig = Config.server;
 		const ServerConfig = Config.server;
 		const SourcesConfig = Config.sources;
 		const SourcesConfig = Config.sources;
 
 
-		ServerApp.initDb(SourcesConfig.assetsDir);
+		CommentsManager.init(path.resolve(`${os.homedir()}/${SourcesConfig.assetsDir}/comments`), ServerConfig.commentsRepoUrl);
 
 
 		new Server({
 		new Server({
 			port: 3000,
 			port: 3000,
@@ -94,6 +52,7 @@ class ServerApp {
 			middlewaresPath: path.resolve(__dirname, './middlewares'),
 			middlewaresPath: path.resolve(__dirname, './middlewares'),
 			viewsPath: path.resolve(__dirname, './views'),
 			viewsPath: path.resolve(__dirname, './views'),
 			viewEngine: 'pug',
 			viewEngine: 'pug',
+			wsHandlers: {[ServerApp.WebSocketUrl]: new WebSocketHandler()},
 			options: {
 			options: {
 				static: path.resolve(__dirname, './static'),
 				static: path.resolve(__dirname, './static'),
 				sessionSecret: Guid.new(),
 				sessionSecret: Guid.new(),
@@ -104,7 +63,7 @@ class ServerApp {
 				ServerApp.SourcesUpdating = true;
 				ServerApp.SourcesUpdating = true;
 				Sources.get(() => {
 				Sources.get(() => {
 					ServerApp.SourcesUpdating = false;
 					ServerApp.SourcesUpdating = false;
-					ServerApp.startCronTask();
+					CronSourcesUpdateTask.start();
 				});
 				});
 			});
 			});
 			ServerApp.Server = server;
 			ServerApp.Server = server;

+ 8 - 9
src/routes/GetClass.ts

@@ -4,6 +4,7 @@ import { ClassMapEntity, Sources } from '../sources/Sources';
 import fs from 'fs';
 import fs from 'fs';
 import ServerApp from '..';
 import ServerApp from '..';
 import { ISession } from '../session/ISession';
 import { ISession } from '../session/ISession';
+import { CommentsManager } from '../comments/CommentsManager';
 
 
 class GetClass extends Route {
 class GetClass extends Route {
 	private processClass(cls: ClassMapEntity): { Class: ClassMapEntity, ClassSource: string } {
 	private processClass(cls: ClassMapEntity): { Class: ClassMapEntity, ClassSource: string } {
@@ -23,21 +24,19 @@ class GetClass extends Route {
 		const className = req.params.className;
 		const className = req.params.className;
 		const cls: ClassMapEntity | null = Sources.findClass(className);
 		const cls: ClassMapEntity | null = Sources.findClass(className);
 		if(!cls) {
 		if(!cls) {
-			res.status(StatusCodes.NOT_FOUND).render('class', { isAdmin: session.isAdmin || false, Class: className, ClassSource: '', template: 'class', title: 'Class not found', ClassList: Sources.getShortenedClassMap(), RepoNames: Sources.getRepoNames(), Z8Locales: null, Comments: null });
+			res.status(StatusCodes.NOT_FOUND).render('class', { isAdmin: session.isAdmin || false, isEditor: session.isEditor || false, Login: session.login || null, Class: className, ClassSource: '', template: 'class', title: 'Class not found', ClassList: Sources.getShortenedClassMap(), RepoNames: Sources.getRepoNames(), Z8Locales: null, Comments: null });
 		} else {
 		} else {
-			ServerApp.getDb().all('SELECT * FROM comments WHERE class = ?', [cls.name], (err, rows) => {
-				if(err) {
+			CommentsManager.getCommentsByClass(cls.root, cls.name).then((result) => {
+				if(!result.success) {
 					res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
 					res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
 					return;
 					return;
 				}
 				}
-				const classComments = rows || [];
-				const classCommentsObj: {[key: string] : any} = {};
 
 
-				classComments.forEach((comment: any) => {
-					classCommentsObj[`${comment.property}`] = comment;
+				const commentsObj: {[key: string] : any} = {};
+				result.comments?.forEach((comment) => {
+					commentsObj[comment.propertyName] = comment;
 				});
 				});
-
-				res.status(StatusCodes.OK).render('class', Object.assign(this.processClass(cls), { isAdmin: session.isAdmin || false, template: 'class', title: `Class: ${className}`, ClassList: Sources.getShortenedClassMap(), RepoNames: Sources.getRepoNames(), Z8Locales: Sources.Z8Locales, Comments: classCommentsObj }));
+				res.status(StatusCodes.OK).render('class', Object.assign(this.processClass(cls), { isAdmin: session.isAdmin || false, isEditor: session.isEditor || false, Login: session.login || null, template: 'class', title: `Class: ${className}`, ClassList: Sources.getShortenedClassMap(), RepoNames: Sources.getRepoNames(), Z8Locales: Sources.Z8Locales, Comments: commentsObj }));
 			});
 			});
 		}
 		}
 	};
 	};

+ 1 - 5
src/routes/GetIndex.ts

@@ -13,11 +13,7 @@ class GetIndex extends Route {
 			return;
 			return;
 		}
 		}
 
 
-		res.status(StatusCodes.OK).render('index', { isAdmin: session.isAdmin || false, template: 'index', title: 'Doczilla JS Docs', lastUpdateTime: Sources.time().getTime(), ClassList: Sources.get(), RepoNames: Sources.getRepoNames() }, (err, html) => {
-			if(err)
-				console.log(err);
-			res.send(html);
-		});
+		res.status(StatusCodes.OK).render('index', { isAdmin: session.isAdmin || false, isEditor: session.isEditor || false, Login: session.login || null, template: 'index', title: 'Doczilla JS Docs', lastUpdateTime: Sources.time().getTime(), ClassList: Sources.get(), RepoNames: Sources.getRepoNames() });
 	};
 	};
 
 
 	protected method = HttpMethod.GET;
 	protected method = HttpMethod.GET;

+ 1 - 1
src/routes/GetLogin.ts

@@ -12,7 +12,7 @@ class GetLogin extends Route {
 			return;
 			return;
 		}
 		}
 
 
-		if(session.isAdmin) {
+		if(session.isEditor) {
 			res.redirect('/');
 			res.redirect('/');
 			return;
 			return;
 		}
 		}

+ 39 - 4
src/routes/PostAuthorize.ts

@@ -1,24 +1,59 @@
 import { HttpMethod, Route, StatusCodes } from 'org.crazydoctor.expressts';
 import { HttpMethod, Route, StatusCodes } from 'org.crazydoctor.expressts';
 import { Request, Response } from 'express';
 import { Request, Response } from 'express';
 import { ISession } from '../session/ISession';
 import { ISession } from '../session/ISession';
+import SHA256 from '../util/SHA256';
 
 
 class PostAuthorize extends Route {
 class PostAuthorize extends Route {
 	private AdminLogin = 'Admin';
 	private AdminLogin = 'Admin';
 
 
+	private async tryGogsAuth(login: string, password: string): Promise<boolean> {
+		try {
+			const response = await fetch(`https://git.doczilla.pro/api/v1/users/${login}/tokens`, {
+				method: 'GET',
+				headers: {
+					'Content-Type': 'application/json',
+					'Authorization': 'Basic ' + Buffer.from(`${login}:${password}`).toString('base64')
+				}
+			});
+
+			if (!response.ok)
+				return false;
+
+			const tokens = await response.json();
+			
+			if(tokens instanceof Array)
+				return true;
+
+			return false;
+		} catch (error) {
+			return false;
+		}
+	}
+
 	protected action = (req: Request, res: Response): any => {
 	protected action = (req: Request, res: Response): any => {
 		const session = req.session as ISession;
 		const session = req.session as ISession;
 		const params = req.body;
 		const params = req.body;
 
 
 		const login = params.login.trim();
 		const login = params.login.trim();
-		const password = params.password.trim(); // SHA256 hashed
+		const password = params.password.trim();
+		const hashedPassword = SHA256.hash(password);
 
 
-		if(login === this.AdminLogin && this.context.options.adminPassword === password) {
-			session.isAdmin = true;
+		if(login === this.AdminLogin && this.context.options.adminPassword === hashedPassword) {
+			session.login = 'Admin';
+			session.isAdmin = session.isEditor = true;
 			res.status(StatusCodes.OK).send('OK');
 			res.status(StatusCodes.OK).send('OK');
 			return;
 			return;
 		}
 		}
 
 
-		res.status(StatusCodes.FORBIDDEN).send('Authentication failed');
+		this.tryGogsAuth(login, password).then((result) => {
+			if(result) {
+				session.isEditor = true;
+				session.login = login;
+				res.status(StatusCodes.OK).send('OK');
+			} else {
+				res.status(StatusCodes.FORBIDDEN).send('Authentication failed');
+			}
+		});
 	};
 	};
 
 
 	protected method = HttpMethod.POST;
 	protected method = HttpMethod.POST;

+ 12 - 5
src/routes/PostInheritedComments.ts

@@ -1,6 +1,7 @@
 import { HttpMethod, Route, StatusCodes } from 'org.crazydoctor.expressts';
 import { HttpMethod, Route, StatusCodes } from 'org.crazydoctor.expressts';
 import { Request, Response } from 'express';
 import { Request, Response } from 'express';
 import ServerApp from '..';
 import ServerApp from '..';
+import { CommentsManager } from '../comments/CommentsManager';
 
 
 class PostInheritedComments extends Route {
 class PostInheritedComments extends Route {
 	protected action = (req: Request, res: Response): any => {
 	protected action = (req: Request, res: Response): any => {
@@ -17,21 +18,27 @@ class PostInheritedComments extends Route {
 		let index = 0;
 		let index = 0;
 
 
 		for(const className of Object.keys(query)) {
 		for(const className of Object.keys(query)) {
-			const props = query[className];
-			ServerApp.getDb().all(`SELECT * FROM comments WHERE class = ? AND property IN (${props.map(() => '?').join(', ')})`, [className, ...props], (err, rows) => {
-				if(err) {
+			const obj = query[className];
+			const root = obj.root;
+			const properties = obj.properties;
+			
+			CommentsManager.getCommentsByClass(root, className, properties).then((result) => {
+				if(!result.success) {
 					res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
 					res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
 					return;
 					return;
 				}
 				}
 
 
 				const commentsObj: any = {};
 				const commentsObj: any = {};
 
 
-				rows.forEach((comment: any) => {
-					commentsObj[comment.property] = { comment: comment.comment, timestamp: comment.timestamp };
+				result.comments?.forEach((comment) => {
+					result.comments?.forEach((comment) => {
+						commentsObj[comment.propertyName] = comment;
+					});
 				});
 				});
 
 
 				inheritedComments[className] = commentsObj;
 				inheritedComments[className] = commentsObj;
 				index++;
 				index++;
+
 				if(index === querySize)
 				if(index === querySize)
 					res.status(StatusCodes.OK).send(JSON.stringify(inheritedComments));
 					res.status(StatusCodes.OK).send(JSON.stringify(inheritedComments));
 			});
 			});

+ 2 - 0
src/routes/PostLogout.ts

@@ -7,6 +7,8 @@ class PostLogout extends Route {
 		const session = req.session as ISession;
 		const session = req.session as ISession;
 		
 		
 		session.isAdmin = false;
 		session.isAdmin = false;
+		session.isEditor = false;
+		session.login = null;
 		res.status(StatusCodes.OK).send('OK');
 		res.status(StatusCodes.OK).send('OK');
 	};
 	};
 
 

+ 20 - 37
src/routes/PostUpdateComment.ts

@@ -1,60 +1,43 @@
 import { HttpMethod, Route, StatusCodes } from 'org.crazydoctor.expressts';
 import { HttpMethod, Route, StatusCodes } from 'org.crazydoctor.expressts';
 import { Request, Response } from 'express';
 import { Request, Response } from 'express';
 import { ISession } from '../session/ISession';
 import { ISession } from '../session/ISession';
+import { CommentUpdateStatus, CommentsManager } from '../comments/CommentsManager';
 import ServerApp from '..';
 import ServerApp from '..';
 
 
 class PostUpdateComment extends Route {
 class PostUpdateComment extends Route {
-	private AdminLogin = 'Admin';
-
 	protected action = (req: Request, res: Response): any => {
 	protected action = (req: Request, res: Response): any => {
 		const session = req.session as ISession;
 		const session = req.session as ISession;
 		const params = req.body;
 		const params = req.body;
 
 
+		const root = params.root.trim();
 		const className = params.class.trim();
 		const className = params.class.trim();
 		const propertyName = params.property.trim();
 		const propertyName = params.property.trim();
 		const comment = params.comment;
 		const comment = params.comment;
 
 
-		if(!session.isAdmin) {
+		if(!session.isEditor) {
 			res.status(StatusCodes.FORBIDDEN).send('Access denied');
 			res.status(StatusCodes.FORBIDDEN).send('Access denied');
 			return;
 			return;
 		}
 		}
 
 
-		const db = ServerApp.getDb();
+		if(ServerApp.SourcesUpdating) {
+			res.status(StatusCodes.FORBIDDEN).send('Sources are being updated');
+			return;
+		}
 
 
-		db.get('SELECT * FROM comments WHERE class = ? AND property = ?', [className, propertyName], (err: any, row: any) => {
-			if(err) {
-				res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
-				return;
-			}
+		if(comment.length === 0) {
+			CommentsManager.delete(root, className, propertyName, session.login!);
+		} else {
+			CommentsManager.create({
+				root: root,
+				className: className,
+				propertyName: propertyName,
+				text: comment,
+				timestamp: new Date().getTime(),
+				author: session.login!
+			});			
+		}
 
 
-			if(row) {
-				if(comment.length > 0) {
-					db.run('UPDATE comments SET comment = ?, timestamp = ? WHERE class = ? AND property = ?', [comment, new Date().getTime(), className, propertyName], function(err) {
-						if(err) {
-							res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
-							return;
-						}
-						res.status(StatusCodes.OK).send('UPDATED');
-					});
-				} else {
-					db.run('DELETE FROM comments WHERE class = ? AND property = ?', [className, propertyName], function(err) {
-						if(err) {
-							res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
-							return;
-						}
-						res.status(StatusCodes.OK).send('DELETED');
-					});
-				}
-			} else {
-				db.run('INSERT INTO comments (class, property, comment, timestamp) VALUES (?, ?, ?, ?)', [className, propertyName, comment, new Date().getTime()], function(err) {
-					if(err) {
-						res.status(StatusCodes.INTERNAL_SERVER_ERROR).send('Internal server error');
-						return;
-					}
-					res.status(StatusCodes.CREATED).send('CREATED');
-				});
-			}
-		});
+		res.status(StatusCodes.ACCEPTED).send('ENQUEUED');
 	};
 	};
 
 
 	protected method = HttpMethod.POST;
 	protected method = HttpMethod.POST;

+ 2 - 0
src/session/ISession.ts

@@ -2,4 +2,6 @@ import { Session } from 'express-session';
 
 
 export interface ISession extends Session {
 export interface ISession extends Session {
   isAdmin: boolean;
   isAdmin: boolean;
+  isEditor: boolean;
+  login: string | null;
 }
 }

+ 2 - 0
src/sources/Analyzer.ts

@@ -101,6 +101,7 @@ class Analyzer {
 
 
 					parentPropertyCopy.inherited = true;
 					parentPropertyCopy.inherited = true;
 					parentPropertyCopy.nearestParent = parentName;
 					parentPropertyCopy.nearestParent = parentName;
+					parentPropertyCopy.nearestParentRoot = parent.root;
 					parentPropertyCopy.overridden = false;
 					parentPropertyCopy.overridden = false;
 
 
 					if(propIndex === -1) {
 					if(propIndex === -1) {
@@ -123,6 +124,7 @@ class Analyzer {
 					parentPropertyCopy.dynamic = false;
 					parentPropertyCopy.dynamic = false;
 					parentPropertyCopy.overridden = false;
 					parentPropertyCopy.overridden = false;
 					parentPropertyCopy.nearestParent = parentName;
 					parentPropertyCopy.nearestParent = parentName;
+					parentPropertyCopy.nearestParentRoot = parent.root;
 					
 					
 					if(propIndex === -1) {
 					if(propIndex === -1) {
 						properties.push(parentPropertyCopy);
 						properties.push(parentPropertyCopy);

+ 4 - 2
src/sources/Sources.ts

@@ -13,11 +13,13 @@ type Z8ClassProperties = {
 	inherited: boolean,
 	inherited: boolean,
 	dynamic: boolean,
 	dynamic: boolean,
 	overridden: boolean,
 	overridden: boolean,
-	nearestParent?: string
+	nearestParent?: string,
+  nearestParentRoot?: string
 };
 };
 
 
 type ClassMapEntity = {
 type ClassMapEntity = {
 	name: string,
 	name: string,
+  root: string,
 	filePath: string,
 	filePath: string,
 	shortName?: string | null,
 	shortName?: string | null,
 	extends: string | null,
 	extends: string | null,
@@ -220,7 +222,7 @@ class Sources {
 					};
 					};
 				}).filter(item1 => !dynamicProperties.some(item2 => item2.key === item1.key)));
 				}).filter(item1 => !dynamicProperties.some(item2 => item2.key === item1.key)));
 
 
-				Sources.ClassMap[define] = { name: define, filePath: `${pathPrefix}/${filePath}`, children: [], extends: null, parentsBranch: [], mixins: [], mixedIn: [], properties: dynamicProperties, dynamicProperties: dynamicConfigProperties, statics: [] };
+				Sources.ClassMap[define] = { name: define, root: pathPrefix, filePath: `${pathPrefix}/${filePath}`, children: [], extends: null, parentsBranch: [], mixins: [], mixedIn: [], properties: dynamicProperties, dynamicProperties: dynamicConfigProperties, statics: [] };
 			}
 			}
 			fs.writeFileSync(Sources.dzAppPath, content + '\n', { flag: 'a+', encoding: 'utf8' });
 			fs.writeFileSync(Sources.dzAppPath, content + '\n', { flag: 'a+', encoding: 'utf8' });
 			progress && progress.next();
 			progress && progress.next();

+ 54 - 0
src/util/CronSourcesUpdateTask.ts

@@ -0,0 +1,54 @@
+import { CronJob } from 'cron';
+import ServerApp from '..';
+import { Sources } from '../sources/Sources';
+import { spawn } from 'child_process';
+import * as path from 'path';
+
+class CronSourcesUpdateTask {
+	private static sourcesUpdateTask(): void {
+		if(ServerApp.SourcesUpdating)
+			return;
+		try {
+			ServerApp.Server?.logInfo('[Cron] Sources update started...');
+			ServerApp.SourcesUpdating = true;
+			const update = spawn('node', [path.resolve(__dirname, './UpdateSources.js')]);
+	
+			update.stdout.on('data', (data) => {
+				console.log(data.toString());
+			});
+
+			update.stderr.on('data', (data) => {
+				console.error(data.toString());
+			});
+
+			update.on('close', (code) => {
+				ServerApp.SourcesUpdating = false;
+				if (code === 0) {
+					Sources.get();
+					ServerApp.Server?.logInfo('[Cron] Sources update finished.');
+				} else {
+					ServerApp.Server?.logError('[Cron] Sources update failed.');
+				}
+			});
+
+			update.on('error', (e) => {
+				ServerApp.SourcesUpdating = false;
+				ServerApp.Server?.logError('[Cron] Sources update failed.');
+				console.error(e);
+			});
+		} catch (e) {
+			ServerApp.SourcesUpdating = false;
+			ServerApp.Server?.logError('[Cron] Sources update failed.');
+			console.error(e);
+		}
+	}
+
+	public static start(): void {
+		const timeZone = 'Europe/Moscow';
+		const cronSchedule = '0 3 * * *';
+		const job = new CronJob(cronSchedule, CronSourcesUpdateTask.sourcesUpdateTask, null, true, timeZone);
+		job.start();
+	}
+};
+
+export default CronSourcesUpdateTask;

+ 4 - 1
src/views/class.pug

@@ -10,11 +10,14 @@ html
 			const Z8Locales = !{JSON.stringify(Z8Locales)};
 			const Z8Locales = !{JSON.stringify(Z8Locales)};
 			const Comments = !{JSON.stringify(Comments)};
 			const Comments = !{JSON.stringify(Comments)};
 			const isAdmin = !{JSON.stringify(isAdmin)};
 			const isAdmin = !{JSON.stringify(isAdmin)};
+			const isEditor = !{JSON.stringify(isEditor)};
+			const Login = !{JSON.stringify(Login)};
 		include imports/cdclientlib.import.pug
 		include imports/cdclientlib.import.pug
 		include imports/codemirror.import.pug
 		include imports/codemirror.import.pug
 		include imports/codemirror.javascript.import.pug
 		include imports/codemirror.javascript.import.pug
 		include imports/jquery.import.pug
 		include imports/jquery.import.pug
-		include imports/global.import.pug 
+		include imports/global.import.pug
+		include imports/socket.import.pug
 		include imports/page.import.pug
 		include imports/page.import.pug
 	body
 	body
 		div.main
 		div.main

+ 4 - 0
src/views/faq.pug

@@ -10,4 +10,8 @@ html
 			div.main-title
 			div.main-title
 				div.logo
 				div.logo
 				div.title-text= 'Doczilla JS Docs: FAQ'
 				div.title-text= 'Doczilla JS Docs: FAQ'
+			div.lang
+				a(href="/faq#en")= 'EN'
+				span= '|'
+				a(href="/faq#ru")= 'RU'
 			div.faq-content
 			div.faq-content

+ 1 - 0
src/views/imports/socket.import.pug

@@ -0,0 +1 @@
+script(src="/websocket/Socket.js")

+ 2 - 0
src/views/index.pug

@@ -8,6 +8,8 @@ html
 			const LastUpdateTime = !{JSON.stringify(lastUpdateTime)};
 			const LastUpdateTime = !{JSON.stringify(lastUpdateTime)};
 			const RepoNames = !{JSON.stringify(RepoNames)};
 			const RepoNames = !{JSON.stringify(RepoNames)};
 			const isAdmin = !{JSON.stringify(isAdmin)};
 			const isAdmin = !{JSON.stringify(isAdmin)};
+			const isEditor = !{JSON.stringify(isEditor)};
+			const Login = !{JSON.stringify(Login)};
 		include imports/cdclientlib.import.pug
 		include imports/cdclientlib.import.pug
 		include imports/codemirror.import.pug
 		include imports/codemirror.import.pug
 		include imports/jquery.import.pug
 		include imports/jquery.import.pug

+ 1 - 1
src/views/parts/left-header.part.pug

@@ -3,7 +3,7 @@ div.left-header
 		div.logo
 		div.logo
 		div.main-title-text= 'Doczilla JS Docs'
 		div.main-title-text= 'Doczilla JS Docs'
 	div.left-header-toolbar
 	div.left-header-toolbar
-		if(isAdmin)
+		if(isEditor)
 			div.auth-button.logout-button
 			div.auth-button.logout-button
 		else
 		else
 			div.auth-button.login-button
 			div.auth-button.login-button

+ 11 - 0
src/websocket/WebSocket.ts

@@ -0,0 +1,11 @@
+import { WebSocketHandler as WSH } from 'org.crazydoctor.expressts';
+import { WebSocket } from 'ws';
+
+class WebSocketHandler extends WSH {
+	public onConnect = (ws: WebSocket): any => {};
+	public onMessage = (message: string): any => {};
+	public onError = (): any => {};
+	public onClose = (code?: number, reason?: string ): any => {};
+}
+
+export default WebSocketHandler;

+ 66 - 24
static/CDClientLib/CDClientLib.js

@@ -16,8 +16,8 @@ class CDElement {
 	}
 	}
 
 
 	static get(el) {
 	static get(el) {
-    if(el == null)
-      return null;
+		if(el == null)
+			return null;
 		if(el instanceof CDElement)
 		if(el instanceof CDElement)
 			return el;
 			return el;
 		if(el.cdelement)
 		if(el.cdelement)
@@ -142,9 +142,9 @@ class CDElement {
 		return id;
 		return id;
 	}
 	}
 
 
-  previousSibling() {
-    return CDElement.get(this.get().previousElementSibling);
-  }
+	previousSibling() {
+		return CDElement.get(this.get().previousElementSibling);
+	}
 
 
 	nextSibling() {
 	nextSibling() {
 		return CDElement.get(this.get().nextElementSibling);
 		return CDElement.get(this.get().nextElementSibling);
@@ -258,11 +258,16 @@ class CDElement {
 		return this;
 		return this;
 	}
 	}
 
 
-  click() {
-    this.get().click();
+  blur() {
+    this.get().blur();
     return this;
     return this;
   }
   }
 
 
+	click() {
+		this.get().click();
+		return this;
+	}
+
 	on(event, callback) {
 	on(event, callback) {
 		if (CDUtils.isEmpty(event) || CDUtils.isEmpty(callback))
 		if (CDUtils.isEmpty(event) || CDUtils.isEmpty(callback))
 			return this;
 			return this;
@@ -292,9 +297,9 @@ class Url {
 		return new Url().setHash(hash);
 		return new Url().setHash(hash);
 	}
 	}
 
 
-  static getOrigin() {
-    return new Url().getOrigin();
-  }
+	static getOrigin() {
+		return new Url().getOrigin();
+	}
 
 
 	static goTo(url, blank) {
 	static goTo(url, blank) {
 		window.open(url, blank ? '_blank' : '_self');
 		window.open(url, blank ? '_blank' : '_self');
@@ -319,8 +324,45 @@ class Url {
 		return this;
 		return this;
 	}
 	}
 
 
-  getOrigin() {
-    return this.url.origin;
+  setSearchParams(params) {
+    const paramsArr = [];
+
+    for(const key of Object.keys(params)) {
+      paramsArr.push(`${key}=${params[key]}`);
+    }
+
+    this.url.search = paramsArr.join('&');
+  }
+
+  getSearchParams() {
+    const params = {};
+    Array.from(this.url.searchParams).forEach((pair) => {
+      params[pair[0]] = pair[1];
+    });
+
+    return params;
+  }
+
+  setPath(path) {
+    this.url.pathname = path;
+    return this;
+  }
+
+  getPath() {
+    return this.url.pathname;
+  }
+
+	getOrigin() {
+		return this.url.origin;
+	}
+
+  getProtocol() {
+    return this.url.protocol;
+  }
+
+  setProtocol(protocol) {
+    this.url.protocol = protocol;
+    return this;
   }
   }
 
 
 	toString() {
 	toString() {
@@ -381,7 +423,7 @@ class DOM {
 		HashChange: 'hashchange',
 		HashChange: 'hashchange',
 		MouseDown: 'mousedown',
 		MouseDown: 'mousedown',
 		ContextMenu: 'contextmenu',
 		ContextMenu: 'contextmenu',
-    Blur: 'blur'
+		Blur: 'blur'
 	};
 	};
 
 
 	static Tags = {
 	static Tags = {
@@ -392,7 +434,7 @@ class DOM {
 		H3: 'h3',
 		H3: 'h3',
 		P: 'p',
 		P: 'p',
 		Textarea: 'textarea',
 		Textarea: 'textarea',
-    Input: 'input'
+		Input: 'input'
 	};
 	};
 
 
 	static Keys = {
 	static Keys = {
@@ -519,18 +561,18 @@ class DOM {
 		document.addEventListener(event, callback);
 		document.addEventListener(event, callback);
 	}
 	}
 
 
-  static copyToClipboard(str) {
-    const body = DOM.get('body');
-    const input = DOM.create({ tag: DOM.Tags.Input, style: 'display: none;' }, body);
+	static copyToClipboard(str) {
+		const body = DOM.get('body');
+		const input = DOM.create({ tag: DOM.Tags.Input, style: 'display: none;' }, body);
 
 
-    input.setValue(str);
-    input.get().select();
-    input.get().setSelectionRange(0, 99999);
+		input.setValue(str);
+		input.get().select();
+		input.get().setSelectionRange(0, 99999);
 
 
-    navigator.clipboard.writeText(input.get().value).then(() => {
-      input.remove();
-    });
-  }
+		navigator.clipboard.writeText(input.get().value).then(() => {
+			input.remove();
+		});
+	}
 }
 }
 
 
 class CDUtils {
 class CDUtils {

+ 75 - 0
static/img/loading.svg

@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<svg fill="#d1d1d1" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" 
+	 width="50px" height="50px" viewBox="0 0 95.348 95.347"
+	 xml:space="preserve">
+<g>
+	<g>
+		<path d="M56.527,60.122l-4.662-1.033c-0.047-3.138-0.719-6.168-1.996-9.007l3.606-2.92c0.858-0.695,0.99-1.954,0.296-2.813
+			l-4.521-5.584c-0.334-0.413-0.818-0.675-1.346-0.731c-0.525-0.057-1.056,0.102-1.468,0.435l-3.511,2.846l0,0
+			c-2.486-1.907-5.277-3.259-8.297-4.019v-4.458c0-1.104-0.896-2-2-2h-7.183c-1.104,0-2,0.896-2,2v4.461
+			c-3.08,0.777-5.922,2.171-8.447,4.144l-3.545-2.82c-0.415-0.33-0.94-0.479-1.472-0.422c-0.527,0.06-1.009,0.327-1.339,0.743
+			l-4.472,5.623c-0.688,0.864-0.544,2.123,0.32,2.81l3.642,2.896l0,0c-1.25,2.848-1.895,5.879-1.916,9.01l-4.666,1.078
+			c-1.076,0.25-1.747,1.322-1.499,2.398l1.616,7.001c0.249,1.077,1.325,1.747,2.399,1.499l4.813-1.111l0,0
+			c1.429,2.682,3.344,5.018,5.691,6.943l-2.17,4.55c-0.476,0.997-0.054,2.19,0.943,2.666l6.484,3.095
+			c0.271,0.129,0.566,0.194,0.861,0.194c0.226,0,0.451-0.038,0.668-0.114c0.5-0.178,0.909-0.545,1.138-1.024l2.198-4.611
+			c2.923,0.563,5.966,0.554,8.879-0.032l2.236,4.584c0.484,0.994,1.685,1.403,2.675,0.922l6.456-3.148
+			c0.992-0.484,1.405-1.682,0.921-2.674l-2.206-4.524c2.335-1.946,4.231-4.301,5.639-6.999l4.812,1.067
+			c1.076,0.237,2.146-0.441,2.385-1.521l1.557-7.014c0.114-0.518,0.02-1.061-0.267-1.508C57.495,60.552,57.045,60.236,56.527,60.122
+			z M37.856,59.435c0,4.859-3.953,8.812-8.813,8.812c-4.858,0-8.811-3.953-8.811-8.812s3.952-8.812,8.811-8.812
+			C33.903,50.624,37.856,54.576,37.856,59.435z">
+				<animateTransform
+				  attributeName="transform"
+				  begin="0s"
+				  dur="5s"
+				  type="rotate"
+				  from="0 29 59"
+				  to="360 29 59"
+				  repeatCount="indefinite" />
+			</path>
+		<path d="M61.943,42.999c0.746,0,1.463-0.42,1.807-1.139l1.054-2.208c1.826,0.353,3.735,0.345,5.551-0.021l1.07,2.195
+			c0.483,0.992,1.682,1.405,2.675,0.921l2.691-1.313c0.477-0.233,0.842-0.646,1.015-1.147c0.172-0.501,0.139-1.051-0.095-1.528
+			l-1.053-2.155c1.458-1.214,2.646-2.686,3.527-4.377l2.278,0.504c1.074,0.238,2.146-0.442,2.386-1.52l0.646-2.923
+			c0.238-1.078-0.441-2.146-1.521-2.385l-2.184-0.484c-0.028-1.962-0.449-3.857-1.248-5.632l1.673-1.355
+			c0.412-0.334,0.675-0.818,0.729-1.345c0.056-0.527-0.102-1.056-0.436-1.468l-1.884-2.327c-0.697-0.859-1.957-0.99-2.813-0.295
+			l-1.614,1.307c-1.554-1.193-3.299-2.038-5.188-2.513V9.751c0-1.104-0.896-2-2-2h-2.994c-1.104,0-2,0.896-2,2v2.04
+			c-1.927,0.486-3.703,1.358-5.28,2.592l-1.634-1.298c-0.862-0.687-2.12-0.543-2.81,0.32L52.43,15.75
+			c-0.33,0.416-0.481,0.945-0.422,1.472c0.061,0.527,0.327,1.009,0.743,1.339l1.689,1.345c-0.78,1.779-1.184,3.676-1.197,5.636
+			l-2.188,0.505c-0.518,0.119-0.965,0.439-1.246,0.889c-0.281,0.45-0.372,0.993-0.252,1.51l0.675,2.918
+			c0.249,1.076,1.323,1.747,2.398,1.498l2.279-0.527c0.893,1.676,2.09,3.137,3.56,4.343l-1.035,2.17
+			c-0.229,0.479-0.257,1.028-0.08,1.528c0.178,0.5,0.546,0.91,1.024,1.138l2.702,1.289C61.361,42.935,61.654,42.999,61.943,42.999z
+			 M62.01,25.635c0-3.039,2.473-5.51,5.512-5.51c3.038,0,5.51,2.472,5.51,5.51c0,3.039-2.472,5.511-5.51,5.511
+			C64.482,31.146,62.01,28.674,62.01,25.635z">
+				<animateTransform
+				  attributeName="transform"
+				  begin="0s"
+				  dur="5s"
+				  type="rotate"
+				  from="360 67 25"
+				  to="0 67 25"
+				  repeatCount="indefinite" />
+			</path>
+		<path d="M93.782,64.115l-2.182-0.483c-0.028-1.961-0.449-3.856-1.25-5.632l1.675-1.355c0.412-0.334,0.675-0.818,0.73-1.346
+			s-0.103-1.057-0.437-1.468l-1.885-2.327c-0.695-0.859-1.956-0.99-2.813-0.295l-1.613,1.307c-1.556-1.193-3.3-2.038-5.188-2.513
+			v-2.039c0-1.104-0.896-2-2-2h-2.994c-1.104,0-2,0.896-2,2v2.041c-1.929,0.485-3.706,1.358-5.281,2.592h-0.001l-1.632-1.298
+			c-0.415-0.331-0.938-0.482-1.472-0.422c-0.527,0.061-1.009,0.326-1.339,0.742l-1.863,2.343c-0.688,0.864-0.544,2.123,0.32,2.812
+			l1.691,1.344c-0.782,1.785-1.187,3.681-1.199,5.637l-2.188,0.505c-0.517,0.12-0.965,0.438-1.246,0.89
+			c-0.28,0.449-0.372,0.992-0.252,1.51l0.675,2.918c0.249,1.076,1.327,1.744,2.397,1.498l2.281-0.526
+			c0.893,1.677,2.09,3.138,3.558,4.343h0.001l-1.035,2.168c-0.229,0.479-0.258,1.029-0.081,1.529c0.179,0.5,0.546,0.909,1.024,1.138
+			l2.702,1.289c0.277,0.132,0.571,0.195,0.859,0.195c0.746,0,1.464-0.42,1.807-1.14l1.054-2.207
+			c1.828,0.353,3.739,0.347,5.552-0.021l1.071,2.193c0.484,0.992,1.682,1.406,2.675,0.922l2.69-1.312
+			c0.477-0.232,0.842-0.645,1.014-1.146c0.173-0.501,0.141-1.051-0.093-1.528l-1.052-2.155c1.459-1.215,2.645-2.688,3.524-4.377
+			l2.278,0.506c0.52,0.115,1.061,0.02,1.508-0.267c0.447-0.285,0.763-0.735,0.878-1.254l0.647-2.923
+			C95.542,65.422,94.861,64.355,93.782,64.115z M82.838,63.848c0,3.039-2.472,5.511-5.509,5.511c-3.038,0-5.512-2.472-5.512-5.511
+			s2.474-5.511,5.512-5.511C80.366,58.338,82.838,60.81,82.838,63.848z">
+				<animateTransform
+				  attributeName="transform"
+				  begin="0s"
+				  dur="5s"
+				  type="rotate"
+				  from="0 77 63"
+				  to="360 77 63"
+				  repeatCount="indefinite" />
+			</path>
+	</g>
+</g>
+</svg>

+ 75 - 0
static/img/loading_pink.svg

@@ -0,0 +1,75 @@
+<?xml version="1.0" encoding="iso-8859-1"?>
+<svg fill="#ff3366" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" 
+	 width="50px" height="50px" viewBox="0 0 95.348 95.347"
+	 xml:space="preserve">
+<g>
+	<g>
+		<path d="M56.527,60.122l-4.662-1.033c-0.047-3.138-0.719-6.168-1.996-9.007l3.606-2.92c0.858-0.695,0.99-1.954,0.296-2.813
+			l-4.521-5.584c-0.334-0.413-0.818-0.675-1.346-0.731c-0.525-0.057-1.056,0.102-1.468,0.435l-3.511,2.846l0,0
+			c-2.486-1.907-5.277-3.259-8.297-4.019v-4.458c0-1.104-0.896-2-2-2h-7.183c-1.104,0-2,0.896-2,2v4.461
+			c-3.08,0.777-5.922,2.171-8.447,4.144l-3.545-2.82c-0.415-0.33-0.94-0.479-1.472-0.422c-0.527,0.06-1.009,0.327-1.339,0.743
+			l-4.472,5.623c-0.688,0.864-0.544,2.123,0.32,2.81l3.642,2.896l0,0c-1.25,2.848-1.895,5.879-1.916,9.01l-4.666,1.078
+			c-1.076,0.25-1.747,1.322-1.499,2.398l1.616,7.001c0.249,1.077,1.325,1.747,2.399,1.499l4.813-1.111l0,0
+			c1.429,2.682,3.344,5.018,5.691,6.943l-2.17,4.55c-0.476,0.997-0.054,2.19,0.943,2.666l6.484,3.095
+			c0.271,0.129,0.566,0.194,0.861,0.194c0.226,0,0.451-0.038,0.668-0.114c0.5-0.178,0.909-0.545,1.138-1.024l2.198-4.611
+			c2.923,0.563,5.966,0.554,8.879-0.032l2.236,4.584c0.484,0.994,1.685,1.403,2.675,0.922l6.456-3.148
+			c0.992-0.484,1.405-1.682,0.921-2.674l-2.206-4.524c2.335-1.946,4.231-4.301,5.639-6.999l4.812,1.067
+			c1.076,0.237,2.146-0.441,2.385-1.521l1.557-7.014c0.114-0.518,0.02-1.061-0.267-1.508C57.495,60.552,57.045,60.236,56.527,60.122
+			z M37.856,59.435c0,4.859-3.953,8.812-8.813,8.812c-4.858,0-8.811-3.953-8.811-8.812s3.952-8.812,8.811-8.812
+			C33.903,50.624,37.856,54.576,37.856,59.435z">
+				<animateTransform
+				  attributeName="transform"
+				  begin="0s"
+				  dur="5s"
+				  type="rotate"
+				  from="0 29 59"
+				  to="360 29 59"
+				  repeatCount="indefinite" />
+			</path>
+		<path d="M61.943,42.999c0.746,0,1.463-0.42,1.807-1.139l1.054-2.208c1.826,0.353,3.735,0.345,5.551-0.021l1.07,2.195
+			c0.483,0.992,1.682,1.405,2.675,0.921l2.691-1.313c0.477-0.233,0.842-0.646,1.015-1.147c0.172-0.501,0.139-1.051-0.095-1.528
+			l-1.053-2.155c1.458-1.214,2.646-2.686,3.527-4.377l2.278,0.504c1.074,0.238,2.146-0.442,2.386-1.52l0.646-2.923
+			c0.238-1.078-0.441-2.146-1.521-2.385l-2.184-0.484c-0.028-1.962-0.449-3.857-1.248-5.632l1.673-1.355
+			c0.412-0.334,0.675-0.818,0.729-1.345c0.056-0.527-0.102-1.056-0.436-1.468l-1.884-2.327c-0.697-0.859-1.957-0.99-2.813-0.295
+			l-1.614,1.307c-1.554-1.193-3.299-2.038-5.188-2.513V9.751c0-1.104-0.896-2-2-2h-2.994c-1.104,0-2,0.896-2,2v2.04
+			c-1.927,0.486-3.703,1.358-5.28,2.592l-1.634-1.298c-0.862-0.687-2.12-0.543-2.81,0.32L52.43,15.75
+			c-0.33,0.416-0.481,0.945-0.422,1.472c0.061,0.527,0.327,1.009,0.743,1.339l1.689,1.345c-0.78,1.779-1.184,3.676-1.197,5.636
+			l-2.188,0.505c-0.518,0.119-0.965,0.439-1.246,0.889c-0.281,0.45-0.372,0.993-0.252,1.51l0.675,2.918
+			c0.249,1.076,1.323,1.747,2.398,1.498l2.279-0.527c0.893,1.676,2.09,3.137,3.56,4.343l-1.035,2.17
+			c-0.229,0.479-0.257,1.028-0.08,1.528c0.178,0.5,0.546,0.91,1.024,1.138l2.702,1.289C61.361,42.935,61.654,42.999,61.943,42.999z
+			 M62.01,25.635c0-3.039,2.473-5.51,5.512-5.51c3.038,0,5.51,2.472,5.51,5.51c0,3.039-2.472,5.511-5.51,5.511
+			C64.482,31.146,62.01,28.674,62.01,25.635z">
+				<animateTransform
+				  attributeName="transform"
+				  begin="0s"
+				  dur="5s"
+				  type="rotate"
+				  from="360 67 25"
+				  to="0 67 25"
+				  repeatCount="indefinite" />
+			</path>
+		<path d="M93.782,64.115l-2.182-0.483c-0.028-1.961-0.449-3.856-1.25-5.632l1.675-1.355c0.412-0.334,0.675-0.818,0.73-1.346
+			s-0.103-1.057-0.437-1.468l-1.885-2.327c-0.695-0.859-1.956-0.99-2.813-0.295l-1.613,1.307c-1.556-1.193-3.3-2.038-5.188-2.513
+			v-2.039c0-1.104-0.896-2-2-2h-2.994c-1.104,0-2,0.896-2,2v2.041c-1.929,0.485-3.706,1.358-5.281,2.592h-0.001l-1.632-1.298
+			c-0.415-0.331-0.938-0.482-1.472-0.422c-0.527,0.061-1.009,0.326-1.339,0.742l-1.863,2.343c-0.688,0.864-0.544,2.123,0.32,2.812
+			l1.691,1.344c-0.782,1.785-1.187,3.681-1.199,5.637l-2.188,0.505c-0.517,0.12-0.965,0.438-1.246,0.89
+			c-0.28,0.449-0.372,0.992-0.252,1.51l0.675,2.918c0.249,1.076,1.327,1.744,2.397,1.498l2.281-0.526
+			c0.893,1.677,2.09,3.138,3.558,4.343h0.001l-1.035,2.168c-0.229,0.479-0.258,1.029-0.081,1.529c0.179,0.5,0.546,0.909,1.024,1.138
+			l2.702,1.289c0.277,0.132,0.571,0.195,0.859,0.195c0.746,0,1.464-0.42,1.807-1.14l1.054-2.207
+			c1.828,0.353,3.739,0.347,5.552-0.021l1.071,2.193c0.484,0.992,1.682,1.406,2.675,0.922l2.69-1.312
+			c0.477-0.232,0.842-0.645,1.014-1.146c0.173-0.501,0.141-1.051-0.093-1.528l-1.052-2.155c1.459-1.215,2.645-2.688,3.524-4.377
+			l2.278,0.506c0.52,0.115,1.061,0.02,1.508-0.267c0.447-0.285,0.763-0.735,0.878-1.254l0.647-2.923
+			C95.542,65.422,94.861,64.355,93.782,64.115z M82.838,63.848c0,3.039-2.472,5.511-5.509,5.511c-3.038,0-5.512-2.472-5.512-5.511
+			s2.474-5.511,5.512-5.511C80.366,58.338,82.838,60.81,82.838,63.848z">
+				<animateTransform
+				  attributeName="transform"
+				  begin="0s"
+				  dur="5s"
+				  type="rotate"
+				  from="0 77 63"
+				  to="360 77 63"
+				  repeatCount="indefinite" />
+			</path>
+	</g>
+</g>
+</svg>

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

@@ -98,7 +98,8 @@ class ClassPage {
 		MixedIn: 'mixedIn',
 		MixedIn: 'mixedIn',
 		ParentsBranch: 'parentsBranch',
 		ParentsBranch: 'parentsBranch',
 		Statics: 'statics',
 		Statics: 'statics',
-		DynamicProperties: 'dynamicProperties'
+		DynamicProperties: 'dynamicProperties',
+		Root: 'root'
 	};
 	};
 
 
 	static ContextMenuType = {
 	static ContextMenuType = {
@@ -134,6 +135,7 @@ class ClassPage {
 		this.documentable = 0;
 		this.documentable = 0;
 		this.inheritedCommentsQuery = {};
 		this.inheritedCommentsQuery = {};
 		this.inheritedCommentsFields = {};
 		this.inheritedCommentsFields = {};
+		this.propertyItemElements = {};
 
 
 		this.documentedPercentage = DOM.get('.class-documented-percentage');
 		this.documentedPercentage = DOM.get('.class-documented-percentage');
 
 
@@ -165,6 +167,8 @@ class ClassPage {
 
 
 		this.applyHash();
 		this.applyHash();
 
 
+		this.openSocket();
+
 		return this;
 		return this;
 	}
 	}
 
 
@@ -305,6 +309,7 @@ class ClassPage {
 			return element.hasClass('property-item-nearest-parent-span')
 			return element.hasClass('property-item-nearest-parent-span')
 					|| element.hasClass('property-item-comment-input')
 					|| element.hasClass('property-item-comment-input')
 					|| element.hasClass('property-item-comment-button')
 					|| element.hasClass('property-item-comment-button')
+					|| element.hasClass('property-item-saving-filler')
 					|| (el.hasClass('property-item-comment-static') && el.hasClass(ClassPage.StyleClasses.Clickable));
 					|| (el.hasClass('property-item-comment-static') && el.hasClass(ClassPage.StyleClasses.Clickable));
 		};
 		};
 
 
@@ -314,9 +319,9 @@ class ClassPage {
 			const inheritedCommentsQuery = this.inheritedCommentsQuery;
 			const inheritedCommentsQuery = this.inheritedCommentsQuery;
 			properties.forEach((prop) => {
 			properties.forEach((prop) => {
 				if(inheritedCommentsQuery[prop.nearestParent]) {
 				if(inheritedCommentsQuery[prop.nearestParent]) {
-					inheritedCommentsQuery[prop.nearestParent].push(prop.key);
+					inheritedCommentsQuery[prop.nearestParent].properties.push(prop.key);
 				} else {
 				} else {
-					inheritedCommentsQuery[prop.nearestParent] = [prop.key];
+					inheritedCommentsQuery[prop.nearestParent] = { root: prop.nearestParentRoot, className: prop.nearestParent, properties: [prop.key] };
 				}
 				}
 			});
 			});
 		}
 		}
@@ -333,15 +338,17 @@ class ClassPage {
 
 
 		for(const property of properties) {
 		for(const property of properties) {
 			const propertyItem = DOM.create({ tag: DOM.Tags.Div, cls: 'property-item', attr: { [ClassPage.Attributes.DataPropertyName]: property.key, [ClassPage.Attributes.DataPropertyType]: property.type }}, propertiesList).on(DOM.Events.MouseDown, (e) => {
 			const propertyItem = DOM.create({ tag: DOM.Tags.Div, cls: 'property-item', attr: { [ClassPage.Attributes.DataPropertyName]: property.key, [ClassPage.Attributes.DataPropertyType]: property.type }}, propertiesList).on(DOM.Events.MouseDown, (e) => {
+				const element = CDElement.get(e.target);
+
 				if(e.buttons === DOM.MouseButtons.Right) {
 				if(e.buttons === DOM.MouseButtons.Right) {
+					if(element.hasClass('property-item-saving-filler'))
+						return;
 					setTimeout(() => {
 					setTimeout(() => {
-						this.showContextMenu(ClassPage.ContextMenuType.PropertyItem, CDElement.get(e.target), { x: e.pageX, y: e.pageY });
+						this.showContextMenu(ClassPage.ContextMenuType.PropertyItem, element, { x: e.pageX, y: e.pageY });
 					}, 10);
 					}, 10);
-				} else if (e.buttons === DOM.MouseButtons.Left) {
-					const element = CDElement.get(e.target);
+				} else if (e.buttons === DOM.MouseButtons.Left) {					
 					if(propertyItemClickable(element))
 					if(propertyItemClickable(element))
 						return;
 						return;
-
 					if(type === ClassPage.PropertyType.Inherited) {
 					if(type === ClassPage.PropertyType.Inherited) {
 						Url.goTo(`/class/${property.nearestParent}#${isMethods ? 'Methods' : 'Properties'}:${property.key}`);
 						Url.goTo(`/class/${property.nearestParent}#${isMethods ? 'Methods' : 'Properties'}:${property.key}`);
 					} else {
 					} else {
@@ -382,8 +389,8 @@ class ClassPage {
 			const itemCommentText = DOM.create({ tag: DOM.Tags.Div, innerHTML: 'Comment:' });
 			const itemCommentText = DOM.create({ tag: DOM.Tags.Div, innerHTML: 'Comment:' });
 			const itemCommentCn = [itemCommentText];
 			const itemCommentCn = [itemCommentText];
 
 
-			const loadedComment = Comments[type === ClassPage.PropertyType.Statics ? `static:${property.key}` : property.key];
-			const loadedCommentText = loadedComment && typeof loadedComment === 'object' ? loadedComment.comment : '';
+			const loadedComment = Comments[type === ClassPage.PropertyType.Statics ? `__static__${property.key}` : property.key];
+			const loadedCommentText = loadedComment && typeof loadedComment === 'object' ? loadedComment.text : '';
 
 
 			const hasComment = loadedComment && typeof loadedComment === 'object' && loadedCommentText.length > 0;
 			const hasComment = loadedComment && typeof loadedComment === 'object' && loadedCommentText.length > 0;
 			const itemCommentStatic = DOM.create({ tag: DOM.Tags.Div, cls: `property-item-comment-static${!hasComment ? ' empty' : ''}`, innerHTML: hasComment ? CDUtils.nl2br(loadedCommentText) : 'Not commented yet...' });
 			const itemCommentStatic = DOM.create({ tag: DOM.Tags.Div, cls: `property-item-comment-static${!hasComment ? ' empty' : ''}`, innerHTML: hasComment ? CDUtils.nl2br(loadedCommentText) : 'Not commented yet...' });
@@ -393,8 +400,10 @@ class ClassPage {
 				this.inheritedCommentsFields[`${property.nearestParent}:${property.key}`] = itemCommentStatic;
 				this.inheritedCommentsFields[`${property.nearestParent}:${property.key}`] = itemCommentStatic;
 				propertyItem.setAttribute(ClassPage.Attributes.DataPropertyInherited, 'true');
 				propertyItem.setAttribute(ClassPage.Attributes.DataPropertyInherited, 'true');
 			}
 			}
+
+			this.propertyItemElements[type === ClassPage.PropertyType.Statics ? `__static__${property.key}` : property.key] = propertyItem;
 			
 			
-			if(isAdmin) {
+			if(isEditor) {
 				const itemCommentInput = DOM.create({ tag: DOM.Tags.Textarea, cls: 'property-item-comment-input hidden', attr: { 'placeholder': 'Not commented yet...'} }).setValue(CDUtils.br2nl(loadedCommentText));
 				const itemCommentInput = DOM.create({ tag: DOM.Tags.Textarea, cls: 'property-item-comment-input hidden', attr: { 'placeholder': 'Not commented yet...'} }).setValue(CDUtils.br2nl(loadedCommentText));
 				itemCommentCn.push(itemCommentInput);
 				itemCommentCn.push(itemCommentInput);
 				
 				
@@ -411,42 +420,32 @@ class ClassPage {
 
 
 					const onCommentSave = (e) => {
 					const onCommentSave = (e) => {
 						const commentContent = itemCommentInput.getValue();
 						const commentContent = itemCommentInput.getValue();
-						const propertyName = `${type === ClassPage.PropertyType.Statics ? 'static:' : ''}${property.key}`;
+						const propertyName = `${type === ClassPage.PropertyType.Statics ? '__static__' : ''}${property.key}`;
 						const className = Class[ClassPage.ClassProperties.Name];
 						const className = Class[ClassPage.ClassProperties.Name];
+						const classRoot = Class[ClassPage.ClassProperties.Root];
+
+						propertyItem.addClass('saving');
+						itemCommentInput.blur();
+
 						fetch('/updateComment', {
 						fetch('/updateComment', {
 							method: 'POST',
 							method: 'POST',
 							headers: {
 							headers: {
 								'Content-Type': 'application/x-www-form-urlencoded'
 								'Content-Type': 'application/x-www-form-urlencoded'
 							},
 							},
 							body: new URLSearchParams({
 							body: new URLSearchParams({
+								'root': classRoot,
 								'class': className,
 								'class': className,
 								'property': propertyName,
 								'property': propertyName,
 								'comment': commentContent
 								'comment': commentContent
 							})
 							})
 						}).then((res) => {
 						}).then((res) => {
-							if(res.status === 200 || res.status === 201) {
-								if(res.status === 201) {
-									this.documented++;
-									this.renderDocumentedPercentage();
-									propertyItem.append(this.createCommentDateElement(new Date()));
-								} else {
-									if(commentContent.length === 0) {
-										this.documented--;
-										this.renderDocumentedPercentage();
-										propertyItem.getFirstChild('.property-item-comment-date').remove();
-									} else {
-										propertyItem.getFirstChild('.property-item-comment-date > .property-item-comment-date-date').setInnerHTML(CDUtils.dateFormatUTC(new Date(), 3, 'D.M.Y, H:I:S'));
-									}
-								}
-
-								itemCommentStatic.setInnerHTML(commentContent.length > 0 ? CDUtils.nl2br(commentContent) : 'Not commented yet...');
-								itemCommentStatic.switchClass(ClassPage.StyleClasses.Empty, commentContent.length === 0);
-							} else {
-								console.error('Comment update failed.');
+							if(res.status !== 202) {
+								propertyItem.removeClass('saving');
+								console.error(`Comment update failed (${res.status})`);
 							}
 							}
-							itemCommentInput.switchClass(ClassPage.StyleClasses.Hidden);
-							itemCommentOkButton.switchClass(ClassPage.StyleClasses.Hidden);
-							itemCommentStatic.switchClass(ClassPage.StyleClasses.Hidden);
+						}).catch((e) => {
+								propertyItem.removeClass('saving');
+								console.error(`Comment update failed`);
 						});
 						});
 					};
 					};
 
 
@@ -472,6 +471,8 @@ class ClassPage {
 						itemCommentStatic.switchClass(ClassPage.StyleClasses.Hidden);
 						itemCommentStatic.switchClass(ClassPage.StyleClasses.Hidden);
 					});
 					});
 				}
 				}
+
+				DOM.create({ tag: DOM.Tags.Div, cls: 'property-item-saving-filler' }, propertyItem);
 			}
 			}
 
 
 			if(hasComment)
 			if(hasComment)
@@ -510,7 +511,7 @@ class ClassPage {
 				for(const prop of Object.keys(props)) {
 				for(const prop of Object.keys(props)) {
 					const element = this.inheritedCommentsFields[`${cls}:${prop}`];
 					const element = this.inheritedCommentsFields[`${cls}:${prop}`];
 					if(element) {
 					if(element) {
-						element.setInnerHTML(props[prop].comment);
+						element.setInnerHTML(props[prop].text);
 						element.removeClass(ClassPage.StyleClasses.Empty);
 						element.removeClass(ClassPage.StyleClasses.Empty);
 						element.getParent().append(this.createCommentDateElement(props[prop].timestamp));
 						element.getParent().append(this.createCommentDateElement(props[prop].timestamp));
 					}
 					}
@@ -947,6 +948,62 @@ class ClassPage {
 		this.scrollToProperty(hashTab, hashProp);
 		this.scrollToProperty(hashTab, hashProp);
 	}
 	}
 
 
+	openSocket() {
+		this.socket = new Socket('/ws').onMessage(this.onSocketMessage.bind(this));
+	}
+
+	onSocketMessage(e) {
+		const changes = JSON.parse(e.data) || [];
+
+		changes.forEach((changedComment) => {
+			this.processChange(changedComment);
+		});
+	}
+
+	processChange(changedComment) {
+		if(changedComment.root !== Class[ClassPage.ClassProperties.Root] || changedComment.className !== Class[ClassPage.ClassProperties.Name])
+			return;
+
+		const propertyItem = this.propertyItemElements[changedComment.propertyName];
+
+		propertyItem.removeClass('saving');
+
+		switch(changedComment.action) {
+		case 'create':
+			this.documented++;
+			this.renderDocumentedPercentage();
+			propertyItem.append(this.createCommentDateElement(changedComment.timestamp));
+			break;
+		case 'update':
+			const dateElement = propertyItem.getFirstChild('.property-item-comment-date > .property-item-comment-date-date');
+			if(dateElement) {
+				dateElement.setInnerHTML(CDUtils.dateFormatUTC(changedComment.timestamp, 3, 'D.M.Y, H:I:S'));
+			} else {
+				propertyItem.append(this.createCommentDateElement(changedComment.timestamp));
+			}
+			break;
+		case 'remove':
+			this.documented--;
+			this.renderDocumentedPercentage();
+			propertyItem.getFirstChild('.property-item-comment-date').remove();
+			break;
+		}
+
+		const commentContent = changedComment.text;
+
+		const itemCommentStatic = propertyItem.getFirstChild('.property-item-comment-static');
+		const itemCommentInput = propertyItem.getFirstChild('.property-item-comment-input');
+		const itemCommentOkButton = propertyItem.getFirstChild('.property-item-comment-button');
+
+		itemCommentInput.setValue(commentContent);
+		itemCommentStatic.setInnerHTML(commentContent.length > 0 ? CDUtils.nl2br(commentContent) : 'Not commented yet...');
+		itemCommentStatic.switchClass(ClassPage.StyleClasses.Empty, commentContent.length === 0);
+
+		itemCommentInput.addClass(ClassPage.StyleClasses.Hidden);
+		itemCommentOkButton.addClass(ClassPage.StyleClasses.Hidden);
+		itemCommentStatic.removeClass(ClassPage.StyleClasses.Hidden);
+	}
+
 	/*	>>> Context menu | TODO: move to a completely independent module? */
 	/*	>>> Context menu | TODO: move to a completely independent module? */
 	showContextMenu(contextMenuType, target, pos) {
 	showContextMenu(contextMenuType, target, pos) {
 		while(!target.hasClass(contextMenuType))
 		while(!target.hasClass(contextMenuType))
@@ -970,7 +1027,7 @@ class ClassPage {
 					Url.goTo(`/class/${propertyItemParent}#${propertyItemType === 'method' ? 'Methods' : 'Properties'}:${propertyItemName}`);
 					Url.goTo(`/class/${propertyItemParent}#${propertyItemType === 'method' ? 'Methods' : 'Properties'}:${propertyItemName}`);
 				});
 				});
 			}
 			}
-			if(isAdmin && !target.getFirstChild('.property-item-comment-static').hasClass(ClassPage.StyleClasses.Hidden)) {
+			if(isEditor && !target.getFirstChild('.property-item-comment-static').hasClass(ClassPage.StyleClasses.Hidden)) {
 				this.createContextMenuDelimiter();
 				this.createContextMenuDelimiter();
 				this.createContextMenuItem('EditComment', 'Edit comment', () => {
 				this.createContextMenuItem('EditComment', 'Edit comment', () => {
 					target.getFirstChild('.property-item-comment-static').click();
 					target.getFirstChild('.property-item-comment-static').click();

+ 24 - 0
static/page/class/style.css

@@ -264,6 +264,10 @@
 	cursor: default;
 	cursor: default;
 }
 }
 
 
+.right > .content > .content-tab > .properties-list > .property-item > .property-item-default-value > .property-item-default-value-span {
+	word-wrap: break-word;
+}
+
 .right > .content > .content-tab > .properties-list > .property-item > .property-item-nearest-parent > .property-item-nearest-parent-span {
 .right > .content > .content-tab > .properties-list > .property-item > .property-item-nearest-parent > .property-item-nearest-parent-span {
 	text-decoration: underline;
 	text-decoration: underline;
 	cursor: pointer;
 	cursor: pointer;
@@ -273,6 +277,26 @@
 	background-color: #00000090;
 	background-color: #00000090;
 }
 }
 
 
+.right > .content > .content-tab > .properties-list > .property-item > .property-item-saving-filler {
+	display: none;
+	position: absolute;
+	top: 0;
+	bottom: 0;
+	left: 0;
+	right: 0;
+	margin: auto !important;
+	background-color: #ffffff40;
+	background-image: url(/img/loading.svg);
+	background-size: 50px;
+	background-repeat: no-repeat;
+	background-position: center;
+	z-index: 2;
+}
+
+.right > .content > .content-tab > .properties-list > .property-item.saving > .property-item-saving-filler {
+	display: block;
+}
+
 .right > .content > .content-tab > .properties-list > .property-item > .property-item-comment > .property-item-comment-input,
 .right > .content > .content-tab > .properties-list > .property-item > .property-item-comment > .property-item-comment-input,
 .right > .content > .content-tab > .properties-list > .property-item > .property-item-comment > .property-item-comment-static {
 .right > .content > .content-tab > .properties-list > .property-item > .property-item-comment > .property-item-comment-static {
 	width: 100%;
 	width: 100%;

+ 48 - 7
static/page/faq/script.js

@@ -1,6 +1,12 @@
 class FaqPage {
 class FaqPage {
+
+  static Lang = {
+    Ru: 'ru',
+    En: 'en'
+  };
+
   static FaqNotes = {
   static FaqNotes = {
-    'ru': {
+    [FaqPage.Lang.Ru]: {
       'Что это?':
       'Что это?':
         'Doczilla JS Docs - это инструмент для документирования клиентского кода Doczilla.',
         'Doczilla JS Docs - это инструмент для документирования клиентского кода Doczilla.',
       'Зачем?':
       'Зачем?':
@@ -55,7 +61,7 @@ class FaqPage {
         'После завершения написания комментария, необходимо нажать на кнопку "OK".'
         'После завершения написания комментария, необходимо нажать на кнопку "OK".'
     },
     },
 
 
-    'en': {
+    [FaqPage.Lang.En]: {
       'What is it?':
       'What is it?':
         'Doczilla JS Docs - is a tool for documenting client-side code of Doczilla.',
         'Doczilla JS Docs - is a tool for documenting client-side code of Doczilla.',
       'Why?':
       'Why?':
@@ -112,25 +118,60 @@ class FaqPage {
   };
   };
   
   
   start() {
   start() {
-    const faqContent = DOM.get('.faq-content');
-    const lang = Url.getHash() || 'en';
-    const faq = FaqPage.FaqNotes[lang] || FaqPage.FaqNotes['en'];
+    this.faqContent = DOM.get('.faq-content');
+    this.renderContent();
+    return this;
+  }
+
+  renderContent() {
+    const faqContent = this.faqContent;
+    const lang = Url.getHash() || FaqPage.Lang.En;
+    const faq = FaqPage.FaqNotes[lang] || FaqPage.FaqNotes[FaqPage.Lang.En];
     let index = 1;
     let index = 1;
 
 
+    this.themeElements = [];
+
     for(const theme of Object.keys(faq)) {
     for(const theme of Object.keys(faq)) {
       const noteContent = DOM.create({ tag: DOM.Tags.Div, cls: 'faq-note-content hidden', innerHTML: CDUtils.nl2br(faq[theme]) });
       const noteContent = DOM.create({ tag: DOM.Tags.Div, cls: 'faq-note-content hidden', innerHTML: CDUtils.nl2br(faq[theme]) });
       const noteThemeText = DOM.create({ tag: DOM.Tags.Span, innerHTML: `${index}. ${theme}` });
       const noteThemeText = DOM.create({ tag: DOM.Tags.Span, innerHTML: `${index}. ${theme}` });
       const noteThemeCollapsedIcon = DOM.create({ tag: DOM.Tags.Div, cls: 'collapsed-icon' });
       const noteThemeCollapsedIcon = DOM.create({ tag: DOM.Tags.Div, cls: 'collapsed-icon' });
-      const noteTheme = DOM.create({ tag: DOM.Tags.Div, cls: 'faq-note-theme collapsed', cn: [noteThemeText, noteThemeCollapsedIcon] }, faqContent).on('click', (e) => {
+      const onClick = (e) => {
         noteContent.switchClass('hidden');
         noteContent.switchClass('hidden');
         noteTheme.switchClass('collapsed');
         noteTheme.switchClass('collapsed');
-      });
+      };
+      const noteTheme = DOM.create({ tag: DOM.Tags.Div, cls: 'faq-note-theme collapsed', cn: [noteThemeText, noteThemeCollapsedIcon] }, faqContent).on(DOM.Events.Click, onClick);
+      this.themeElements.push({ element: noteTheme, handler: onClick });
       faqContent.append(noteContent);
       faqContent.append(noteContent);
       index++;
       index++;
     }
     }
   }
   }
+
+  clearThemes() {
+    for(const theme of this.themeElements) {
+      theme.element.un(DOM.Events.Click, theme.handler);
+      theme.element.remove();
+    }
+    this.themeElements = [];
+  }
+
+  clear() {
+    this.clearThemes();
+    this.faqContent.getChildren().forEach((child) => {
+      child.remove();
+    });
+  }
+
+  reload() {
+    this.clear();
+    this.renderContent();
+  }
 }
 }
 
 
 window_.on(DOM.Events.Load, (e) => {
 window_.on(DOM.Events.Load, (e) => {
   window.page = new FaqPage().start();
   window.page = new FaqPage().start();
+});
+
+window_.on(DOM.Events.HashChange, (e) => {
+	if(window.page)
+		window.page.reload();
 });
 });

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

@@ -7,6 +7,13 @@
 	width: 440px;
 	width: 440px;
 }
 }
 
 
+.lang {
+	display: flex;
+	justify-content: space-between;
+	width: 55px;
+	color: #d1d1d1;
+}
+
 .faq-content {
 .faq-content {
 	width: 700px;
 	width: 700px;
 	color: #d1d1d1;
 	color: #d1d1d1;

+ 2 - 1
static/page/index/script.js

@@ -88,11 +88,12 @@ window_.on('load', (e) => {
 	scheduledUpdateDate.setUTCDate(scheduledUpdateDate.getUTCDate() + 1);
 	scheduledUpdateDate.setUTCDate(scheduledUpdateDate.getUTCDate() + 1);
 
 
 	const separator = '\n\t\t\t\t';
 	const separator = '\n\t\t\t\t';
+  const loggedInAs = Login ? `${separator}Logged in as:\t\t\t\t${Login}` : '';
 	const lastUpdateText = `Last sources update:\t\t${CDUtils.dateFormatUTC(LastUpdateTime, 3, 'Y-M-D H:I:S')}`;
 	const lastUpdateText = `Last sources update:\t\t${CDUtils.dateFormatUTC(LastUpdateTime, 3, 'Y-M-D H:I:S')}`;
 	const nextScheduledUpdateText = `Next scheduled update:\t\t${CDUtils.dateFormatUTC(scheduledUpdateDate, 3, 'Y-M-D H:I:S')}`;
 	const nextScheduledUpdateText = `Next scheduled update:\t\t${CDUtils.dateFormatUTC(scheduledUpdateDate, 3, 'Y-M-D H:I:S')}`;
 	const sourcesAuthorsText = `Sources authors:\t\t\tDoczilla Team`;
 	const sourcesAuthorsText = `Sources authors:\t\t\tDoczilla Team`;
 	const dzJsDocsAuthor = `'Doczilla JS Docs' author:\tOleg Karataev (CrazyDoctor)`;
 	const dzJsDocsAuthor = `'Doczilla JS Docs' author:\tOleg Karataev (CrazyDoctor)`;
-	const infoMessage = `\n${separator}${lastUpdateText}${separator}${nextScheduledUpdateText}${separator}${sourcesAuthorsText}${separator}${dzJsDocsAuthor}`;
+	const infoMessage = `\n${loggedInAs}${separator}${lastUpdateText}${separator}${nextScheduledUpdateText}${separator}${sourcesAuthorsText}${separator}${dzJsDocsAuthor}`;
 	
 	
 	const page = window.page = new IndexPage().start();
 	const page = window.page = new IndexPage().start();
 	page.drawMatrix(page.editorHeaderLogo, 3, 0);
 	page.drawMatrix(page.editorHeaderLogo, 3, 0);

+ 18 - 20
static/page/login/script.js

@@ -21,26 +21,24 @@ class LoginPage {
 	authorize(e) {
 	authorize(e) {
 		const login = this.loginInput.getValue();
 		const login = this.loginInput.getValue();
 		const password = this.passwordInput.getValue();
 		const password = this.passwordInput.getValue();
-
-		CDUtils.SHA256(password).then((hashedPassword) => {
-			fetch('/authorize', {
-				method: 'POST',
-				headers:{
-					'Content-Type': 'application/x-www-form-urlencoded'
-				},
-				body: new URLSearchParams({
-					'login': login,
-					'password': hashedPassword
-				})
-			}).then((res) => {
-				if(res.status === 200)
-					Url.goTo('/');
-				else if(res.status === 403)
-					this.failureText.setInnerHTML('Wrong login or password');
-        else
-          this.failureText.setInnerHTML('Internal server error');
-			})
-		});
+		
+    fetch('/authorize', {
+      method: 'POST',
+      headers:{
+        'Content-Type': 'application/x-www-form-urlencoded'
+      },
+      body: new URLSearchParams({
+        'login': login,
+        'password': password
+      })
+    }).then((res) => {
+      if(res.status === 200)
+        Url.goTo('/');
+      else if(res.status === 403)
+        this.failureText.setInnerHTML('Wrong login or password');
+      else
+        this.failureText.setInnerHTML('Internal server error');
+    });
 	}
 	}
 }
 }
 
 

+ 5 - 1
static/style/style.css

@@ -76,6 +76,10 @@ body {
 	height: 50px;
 	height: 50px;
 }
 }
 
 
+.sources-updating .main-title > .logo {
+	background: url(/img/loading_pink.svg);
+}
+
 /* >>> 404 page */
 /* >>> 404 page */
 body.page-not-found {
 body.page-not-found {
 	display: flex;
 	display: flex;
@@ -112,7 +116,7 @@ body.page-not-found > .message-container > .page-not-found-image {
 }
 }
 
 
 .right {
 .right {
-	width: 80%;
+	width: 100%;
 	min-width: 800px;
 	min-width: 800px;
 	height: 100vh;
 	height: 100vh;
 	min-height: 1000px;
 	min-height: 1000px;

+ 53 - 0
static/websocket/Socket.js

@@ -0,0 +1,53 @@
+class Socket {
+	constructor(path) {
+		const url = this.url = new Url();
+		
+		if(url.getProtocol() === 'https:')
+			url.setProtocol('wss');
+		else
+			url.setProtocol('ws');
+
+		url.setPath(path).setHash();
+
+		try {
+			this.connection = new WebSocket(url.toString());
+			
+			this.keepAlive = setInterval(() => {
+				this.connection.send('keep-alive');
+			}, 20000);
+	
+			this.connection.onclose = (e) => {
+				clearInterval(this.keepAlive);
+			};
+		} catch(e) {
+			this.connection = null;
+		}
+	}
+
+	onOpen(onopen) {
+		if(this.connection)
+			this.connection.onopen = onopen;
+		return this;
+	}
+
+	onMessage(onmessage) {
+		if(this.connection)
+			this.connection.onmessage = onmessage;
+		return this;
+	}
+
+	onError(onerror) {
+		if(this.connection)
+			this.connection.onerror = onerror;
+		return this;
+	}
+
+	onClose(onclose) {
+		if(this.connection)
+			this.connection.onclose = (e) => {
+				clearInterval(this.keepAlive);
+				onclose(e);
+			};
+		return this;
+	}
+}

Some files were not shown because too many files changed in this diff