瀏覽代碼

Reformat all the server code with prettier

dev_h
lovasoa 4 年之前
父節點
當前提交
53c61ec16e
沒有連結到貢獻者的電子郵件帳戶。
共有 11 個檔案被更改,包括 1245 行新增1105 行删除
  1. 159
    151
      server/boardData.js
  2. 55
    45
      server/check_output_directory.js
  3. 5
    5
      server/client_configuration.js
  4. 26
    24
      server/configuration.js
  5. 199
    122
      server/createSVG.js
  6. 8
    8
      server/fs_promises.js
  7. 6
    6
      server/log.js
  8. 173
    147
      server/server.js
  9. 150
    139
      server/sockets.js
  10. 49
    43
      server/templating.js
  11. 415
    415
      server/translations.json

+ 159
- 151
server/boardData.js 查看文件

@@ -1,7 +1,7 @@
1 1
 /**
2 2
  *                  WHITEBOPHIR SERVER
3 3
  *********************************************************
4
- * @licstart  The following is the entire license notice for the 
4
+ * @licstart  The following is the entire license notice for the
5 5
  *  JavaScript code in this page.
6 6
  *
7 7
  * Copyright (C) 2013-2014  Ophir LOJKINE
@@ -25,10 +25,10 @@
25 25
  * @module boardData
26 26
  */
27 27
 
28
-var fs = require('./fs_promises.js')
29
-	, log = require("./log.js").log
30
-	, path = require("path")
31
-	, config = require("./configuration.js");
28
+var fs = require("./fs_promises.js"),
29
+  log = require("./log.js").log,
30
+  path = require("path"),
31
+  config = require("./configuration.js");
32 32
 
33 33
 /**
34 34
  * Represents a board.
@@ -37,21 +37,24 @@ var fs = require('./fs_promises.js')
37 37
  * @param {string} name
38 38
  */
39 39
 var BoardData = function (name) {
40
-	this.name = name;
41
-	/** @type {{[name: string]: {[object_id:string]: any}}} */
42
-	this.board = {};
43
-	this.file = path.join(config.HISTORY_DIR, "board-" + encodeURIComponent(name) + ".json");
44
-	this.lastSaveDate = Date.now();
45
-	this.users = new Set();
40
+  this.name = name;
41
+  /** @type {{[name: string]: {[object_id:string]: any}}} */
42
+  this.board = {};
43
+  this.file = path.join(
44
+    config.HISTORY_DIR,
45
+    "board-" + encodeURIComponent(name) + ".json"
46
+  );
47
+  this.lastSaveDate = Date.now();
48
+  this.users = new Set();
46 49
 };
47 50
 
48 51
 /** Adds data to the board */
49 52
 BoardData.prototype.set = function (id, data) {
50
-	//KISS
51
-	data.time = Date.now();
52
-	this.validate(data);
53
-	this.board[id] = data;
54
-	this.delaySave();
53
+  //KISS
54
+  data.time = Date.now();
55
+  this.validate(data);
56
+  this.board[id] = data;
57
+  this.delaySave();
55 58
 };
56 59
 
57 60
 /** Adds a child to an element that is already in the board
@@ -59,45 +62,45 @@ BoardData.prototype.set = function (id, data) {
59 62
  * @param {object} child - Object containing the the values to update.
60 63
  * @param {boolean} [create=true] - Whether to create an empty parent if it doesn't exist
61 64
  * @returns {boolean} - True if the child was added, else false
62
-*/
65
+ */
63 66
 BoardData.prototype.addChild = function (parentId, child) {
64
-	var obj = this.board[parentId];
65
-	if (typeof obj !== "object") return false;
66
-	if (Array.isArray(obj._children)) obj._children.push(child);
67
-	else obj._children = [child];
67
+  var obj = this.board[parentId];
68
+  if (typeof obj !== "object") return false;
69
+  if (Array.isArray(obj._children)) obj._children.push(child);
70
+  else obj._children = [child];
68 71
 
69
-	this.validate(obj);
70
-	this.delaySave();
71
-	return true;
72
+  this.validate(obj);
73
+  this.delaySave();
74
+  return true;
72 75
 };
73 76
 
74 77
 /** Update the data in the board
75 78
  * @param {string} id - Identifier of the data to update.
76 79
  * @param {object} data - Object containing the values to update.
77 80
  * @param {boolean} create - True if the object should be created if it's not currently in the DB.
78
-*/
81
+ */
79 82
 BoardData.prototype.update = function (id, data, create) {
80
-	delete data.type;
81
-	delete data.tool;
83
+  delete data.type;
84
+  delete data.tool;
82 85
 
83
-	var obj = this.board[id];
84
-	if (typeof obj === "object") {
85
-		for (var i in data) {
86
-			obj[i] = data[i];
87
-		}
88
-	} else if (create || obj !== undefined) {
89
-		this.board[id] = data;
90
-	}
91
-	this.delaySave();
86
+  var obj = this.board[id];
87
+  if (typeof obj === "object") {
88
+    for (var i in data) {
89
+      obj[i] = data[i];
90
+    }
91
+  } else if (create || obj !== undefined) {
92
+    this.board[id] = data;
93
+  }
94
+  this.delaySave();
92 95
 };
93 96
 
94 97
 /** Removes data from the board
95 98
  * @param {string} id - Identifier of the data to delete.
96 99
  */
97 100
 BoardData.prototype.delete = function (id) {
98
-	//KISS
99
-	delete this.board[id];
100
-	this.delaySave();
101
+  //KISS
102
+  delete this.board[id];
103
+  this.delaySave();
101 104
 };
102 105
 
103 106
 /** Reads data from the board
@@ -105,7 +108,7 @@ BoardData.prototype.delete = function (id) {
105 108
  * @returns {object} The element with the given id, or undefined if no element has this id
106 109
  */
107 110
 BoardData.prototype.get = function (id, children) {
108
-	return this.board[id];
111
+  return this.board[id];
109 112
 };
110 113
 
111 114
 /** Reads data from the board
@@ -113,13 +116,13 @@ BoardData.prototype.get = function (id, children) {
113 116
  * @param {BoardData~processData} callback - Function to be called with each piece of data read
114 117
  */
115 118
 BoardData.prototype.getAll = function (id) {
116
-	var results = [];
117
-	for (var i in this.board) {
118
-		if (!id || i > id) {
119
-			results.push(this.board[i]);
120
-		}
121
-	}
122
-	return results;
119
+  var results = [];
120
+  for (var i in this.board) {
121
+    if (!id || i > id) {
122
+      results.push(this.board[i]);
123
+    }
124
+  }
125
+  return results;
123 126
 };
124 127
 
125 128
 /**
@@ -129,134 +132,139 @@ BoardData.prototype.getAll = function (id) {
129 132
  * @param {object} data
130 133
  */
131 134
 
132
-
133 135
 /** Delays the triggering of auto-save by SAVE_INTERVAL seconds
134
-*/
136
+ */
135 137
 BoardData.prototype.delaySave = function (file) {
136
-	if (this.saveTimeoutId !== undefined) clearTimeout(this.saveTimeoutId);
137
-	this.saveTimeoutId = setTimeout(this.save.bind(this), config.SAVE_INTERVAL);
138
-	if (Date.now() - this.lastSaveDate > config.MAX_SAVE_DELAY) setTimeout(this.save.bind(this), 0);
138
+  if (this.saveTimeoutId !== undefined) clearTimeout(this.saveTimeoutId);
139
+  this.saveTimeoutId = setTimeout(this.save.bind(this), config.SAVE_INTERVAL);
140
+  if (Date.now() - this.lastSaveDate > config.MAX_SAVE_DELAY)
141
+    setTimeout(this.save.bind(this), 0);
139 142
 };
140 143
 
141 144
 /** Saves the data in the board to a file.
142 145
  * @param {string} [file=this.file] - Path to the file where the board data will be saved.
143
-*/
146
+ */
144 147
 BoardData.prototype.save = async function (file) {
145
-	this.lastSaveDate = Date.now();
146
-	this.clean();
147
-	if (!file) file = this.file;
148
-	var tmp_file = backupFileName(file);
149
-	var board_txt = JSON.stringify(this.board);
150
-	if (board_txt === "{}") { // empty board
151
-		try {
152
-			await fs.promises.unlink(file);
153
-			log("removed empty board", { 'name': this.name });
154
-		} catch (err) {
155
-			if (err.code !== "ENOENT") {
156
-				// If the file already wasn't saved, this is not an error
157
-				log("board deletion error", { "err": err.toString() })
158
-			}
159
-		}
160
-	} else {
161
-		try {
162
-			await fs.promises.writeFile(tmp_file, board_txt);
163
-			await fs.promises.rename(tmp_file, file);
164
-			log("saved board", {
165
-				'name': this.name,
166
-				'size': board_txt.length,
167
-				'delay_ms': (Date.now() - this.lastSaveDate),
168
-			});
169
-		} catch (err) {
170
-			log("board saving error", {
171
-				'err': err.toString(),
172
-				'tmp_file': tmp_file,
173
-			});
174
-			return;
175
-		}
176
-	}
148
+  this.lastSaveDate = Date.now();
149
+  this.clean();
150
+  if (!file) file = this.file;
151
+  var tmp_file = backupFileName(file);
152
+  var board_txt = JSON.stringify(this.board);
153
+  if (board_txt === "{}") {
154
+    // empty board
155
+    try {
156
+      await fs.promises.unlink(file);
157
+      log("removed empty board", { name: this.name });
158
+    } catch (err) {
159
+      if (err.code !== "ENOENT") {
160
+        // If the file already wasn't saved, this is not an error
161
+        log("board deletion error", { err: err.toString() });
162
+      }
163
+    }
164
+  } else {
165
+    try {
166
+      await fs.promises.writeFile(tmp_file, board_txt);
167
+      await fs.promises.rename(tmp_file, file);
168
+      log("saved board", {
169
+        name: this.name,
170
+        size: board_txt.length,
171
+        delay_ms: Date.now() - this.lastSaveDate,
172
+      });
173
+    } catch (err) {
174
+      log("board saving error", {
175
+        err: err.toString(),
176
+        tmp_file: tmp_file,
177
+      });
178
+      return;
179
+    }
180
+  }
177 181
 };
178 182
 
179 183
 /** Remove old elements from the board */
180 184
 BoardData.prototype.clean = function cleanBoard() {
181
-	var board = this.board;
182
-	var ids = Object.keys(board);
183
-	if (ids.length > config.MAX_ITEM_COUNT) {
184
-		var toDestroy = ids.sort(function (x, y) {
185
-			return (board[x].time | 0) - (board[y].time | 0);
186
-		}).slice(0, -config.MAX_ITEM_COUNT);
187
-		for (var i = 0; i < toDestroy.length; i++) delete board[toDestroy[i]];
188
-		log("cleaned board", { 'removed': toDestroy.length, "board": this.name });
189
-	}
190
-}
185
+  var board = this.board;
186
+  var ids = Object.keys(board);
187
+  if (ids.length > config.MAX_ITEM_COUNT) {
188
+    var toDestroy = ids
189
+      .sort(function (x, y) {
190
+        return (board[x].time | 0) - (board[y].time | 0);
191
+      })
192
+      .slice(0, -config.MAX_ITEM_COUNT);
193
+    for (var i = 0; i < toDestroy.length; i++) delete board[toDestroy[i]];
194
+    log("cleaned board", { removed: toDestroy.length, board: this.name });
195
+  }
196
+};
191 197
 
192
-/** Reformats an item if necessary in order to make it follow the boards' policy 
198
+/** Reformats an item if necessary in order to make it follow the boards' policy
193 199
  * @param {object} item The object to edit
194 200
  * @param {object} parent The parent of the object to edit
195
-*/
201
+ */
196 202
 BoardData.prototype.validate = function validate(item, parent) {
197
-	if (item.hasOwnProperty("size")) {
198
-		item.size = parseInt(item.size) || 1;
199
-		item.size = Math.min(Math.max(item.size, 1), 50);
200
-	}
201
-	if (item.hasOwnProperty("x") || item.hasOwnProperty("y")) {
202
-		item.x = parseFloat(item.x) || 0;
203
-		item.x = Math.min(Math.max(item.x, 0), config.MAX_BOARD_SIZE);
204
-		item.x = Math.round(10 * item.x) / 10;
205
-		item.y = parseFloat(item.y) || 0;
206
-		item.y = Math.min(Math.max(item.y, 0), config.MAX_BOARD_SIZE);
207
-		item.y = Math.round(10 * item.y) / 10;
208
-	}
209
-	if (item.hasOwnProperty("opacity")) {
210
-		item.opacity = Math.min(Math.max(item.opacity, 0.1), 1) || 1;
211
-		if (item.opacity === 1) delete item.opacity;
212
-	}
213
-	if (item.hasOwnProperty("_children")) {
214
-		if (!Array.isArray(item._children)) item._children = [];
215
-		if (item._children.length > config.MAX_CHILDREN) item._children.length = config.MAX_CHILDREN;
216
-		for (var i = 0; i < item._children.length; i++) {
217
-			this.validate(item._children[i]);
218
-		}
219
-	}
220
-}
203
+  if (item.hasOwnProperty("size")) {
204
+    item.size = parseInt(item.size) || 1;
205
+    item.size = Math.min(Math.max(item.size, 1), 50);
206
+  }
207
+  if (item.hasOwnProperty("x") || item.hasOwnProperty("y")) {
208
+    item.x = parseFloat(item.x) || 0;
209
+    item.x = Math.min(Math.max(item.x, 0), config.MAX_BOARD_SIZE);
210
+    item.x = Math.round(10 * item.x) / 10;
211
+    item.y = parseFloat(item.y) || 0;
212
+    item.y = Math.min(Math.max(item.y, 0), config.MAX_BOARD_SIZE);
213
+    item.y = Math.round(10 * item.y) / 10;
214
+  }
215
+  if (item.hasOwnProperty("opacity")) {
216
+    item.opacity = Math.min(Math.max(item.opacity, 0.1), 1) || 1;
217
+    if (item.opacity === 1) delete item.opacity;
218
+  }
219
+  if (item.hasOwnProperty("_children")) {
220
+    if (!Array.isArray(item._children)) item._children = [];
221
+    if (item._children.length > config.MAX_CHILDREN)
222
+      item._children.length = config.MAX_CHILDREN;
223
+    for (var i = 0; i < item._children.length; i++) {
224
+      this.validate(item._children[i]);
225
+    }
226
+  }
227
+};
221 228
 
222 229
 /** Load the data in the board from a file.
223 230
  * @param {string} name - name of the board
224
-*/
231
+ */
225 232
 BoardData.load = async function loadBoard(name) {
226
-	var boardData = new BoardData(name), data;
227
-	try {
228
-		data = await fs.promises.readFile(boardData.file);
229
-		boardData.board = JSON.parse(data);
230
-		for (id in boardData.board) boardData.validate(boardData.board[id]);
231
-		log('disk load', { 'board': boardData.name });
232
-	} catch (e) {
233
-		log('empty board creation', {
234
-			'board': boardData.name,
235
-			// If the file doesn't exist, this is not an error
236
-			"error": e.code !== "ENOENT" && e.toString(),
237
-		});
238
-		boardData.board = {}
239
-		if (data) {
240
-			// There was an error loading the board, but some data was still read
241
-			var backup = backupFileName(boardData.file);
242
-			log("Writing the corrupted file to " + backup);
243
-			try {
244
-				await fs.promises.writeFile(backup, data);
245
-			} catch (err) {
246
-				log("Error writing " + backup + ": " + err);
247
-			}
248
-		}
249
-	}
250
-	return boardData;
233
+  var boardData = new BoardData(name),
234
+    data;
235
+  try {
236
+    data = await fs.promises.readFile(boardData.file);
237
+    boardData.board = JSON.parse(data);
238
+    for (id in boardData.board) boardData.validate(boardData.board[id]);
239
+    log("disk load", { board: boardData.name });
240
+  } catch (e) {
241
+    log("empty board creation", {
242
+      board: boardData.name,
243
+      // If the file doesn't exist, this is not an error
244
+      error: e.code !== "ENOENT" && e.toString(),
245
+    });
246
+    boardData.board = {};
247
+    if (data) {
248
+      // There was an error loading the board, but some data was still read
249
+      var backup = backupFileName(boardData.file);
250
+      log("Writing the corrupted file to " + backup);
251
+      try {
252
+        await fs.promises.writeFile(backup, data);
253
+      } catch (err) {
254
+        log("Error writing " + backup + ": " + err);
255
+      }
256
+    }
257
+  }
258
+  return boardData;
251 259
 };
252 260
 
253 261
 /**
254
- * Given a board file name, return a name to use for temporary data saving. 
255
- * @param {string} baseName 
262
+ * Given a board file name, return a name to use for temporary data saving.
263
+ * @param {string} baseName
256 264
  */
257 265
 function backupFileName(baseName) {
258
-	var date = new Date().toISOString().replace(/:/g, '');
259
-	return baseName + '.' + date + '.bak';
266
+  var date = new Date().toISOString().replace(/:/g, "");
267
+  return baseName + "." + date + ".bak";
260 268
 }
261 269
 
262 270
 module.exports.BoardData = BoardData;

+ 55
- 45
server/check_output_directory.js 查看文件

@@ -1,6 +1,6 @@
1 1
 const fs = require("./fs_promises");
2
-const path = require('path');
3
-let os = require('os');
2
+const path = require("path");
3
+let os = require("os");
4 4
 
5 5
 const { R_OK, W_OK } = fs.constants;
6 6
 
@@ -10,56 +10,66 @@ const { R_OK, W_OK } = fs.constants;
10 10
  * @returns {string?}
11 11
  */
12 12
 async function get_error(directory) {
13
-    if (!fs.existsSync(directory)) {
14
-        return "does not exist";
15
-    }
16
-    if (!fs.statSync(directory).isDirectory()) {
17
-        error = "exists, but is not a directory";
18
-    }
19
-    const { uid, gid } = os.userInfo();
20
-    const tmpfile = path.join(directory, Math.random() + ".json");
21
-    try {
22
-        fs.writeFileSync(tmpfile, "{}");
23
-        fs.unlinkSync(tmpfile);
24
-    } catch (e) {
25
-        return "does not allow file creation and deletion. " +
26
-            "Check the permissions of the directory, and if needed change them so that " +
27
-            `user with UID ${uid} has access to them. This can be achieved by running the command: chown ${uid}:${gid} on the directory`;
28
-    }
29
-    const fileChecks = [];
30
-    const files = await fs.promises.readdir(directory, {withFileTypes: true});
31
-    for (const elem of files) {
32
-        if (/^board-(.*)\.json$/.test(elem.name)) {
33
-            const elemPath = path.join(directory, elem.name);
34
-            if (!elem.isFile()) return `contains a board file named "${elemPath}" which is not a normal file`
35
-            fileChecks.push(fs.promises.access(elemPath, R_OK | W_OK)
36
-                .catch(function () { return elemPath }))
37
-        }
38
-    }
39
-    const errs = (await Promise.all(fileChecks)).filter(function (x) { return x });
40
-    if (errs.length > 0) {
41
-        return `contains the following board files that are not readable and writable by the current user: "` +
42
-            errs.join('", "') +
43
-            `". Please make all board files accessible with chown 1000:1000`
13
+  if (!fs.existsSync(directory)) {
14
+    return "does not exist";
15
+  }
16
+  if (!fs.statSync(directory).isDirectory()) {
17
+    error = "exists, but is not a directory";
18
+  }
19
+  const { uid, gid } = os.userInfo();
20
+  const tmpfile = path.join(directory, Math.random() + ".json");
21
+  try {
22
+    fs.writeFileSync(tmpfile, "{}");
23
+    fs.unlinkSync(tmpfile);
24
+  } catch (e) {
25
+    return (
26
+      "does not allow file creation and deletion. " +
27
+      "Check the permissions of the directory, and if needed change them so that " +
28
+      `user with UID ${uid} has access to them. This can be achieved by running the command: chown ${uid}:${gid} on the directory`
29
+    );
30
+  }
31
+  const fileChecks = [];
32
+  const files = await fs.promises.readdir(directory, { withFileTypes: true });
33
+  for (const elem of files) {
34
+    if (/^board-(.*)\.json$/.test(elem.name)) {
35
+      const elemPath = path.join(directory, elem.name);
36
+      if (!elem.isFile())
37
+        return `contains a board file named "${elemPath}" which is not a normal file`;
38
+      fileChecks.push(
39
+        fs.promises.access(elemPath, R_OK | W_OK).catch(function () {
40
+          return elemPath;
41
+        })
42
+      );
44 43
     }
44
+  }
45
+  const errs = (await Promise.all(fileChecks)).filter(function (x) {
46
+    return x;
47
+  });
48
+  if (errs.length > 0) {
49
+    return (
50
+      `contains the following board files that are not readable and writable by the current user: "` +
51
+      errs.join('", "') +
52
+      `". Please make all board files accessible with chown 1000:1000`
53
+    );
54
+  }
45 55
 }
46 56
 
47 57
 /**
48
- * Checks that the output directory is writeable, 
58
+ * Checks that the output directory is writeable,
49 59
  * ans exits the current process with an error otherwise.
50 60
  * @param {string} directory
51 61
  */
52 62
 function check_output_directory(directory) {
53
-    get_error(directory).then(function (error) {
54
-        if (error) {
55
-            console.error(
56
-                `The configured history directory in which boards are stored ${error}.` +
57
-                `\nThe history directory can be configured with the environment variable HISTORY_DIR. ` +
58
-                `It is currently set to "${directory}".`
59
-            );
60
-            process.exit(1);
61
-        }
62
-    })
63
+  get_error(directory).then(function (error) {
64
+    if (error) {
65
+      console.error(
66
+        `The configured history directory in which boards are stored ${error}.` +
67
+          `\nThe history directory can be configured with the environment variable HISTORY_DIR. ` +
68
+          `It is currently set to "${directory}".`
69
+      );
70
+      process.exit(1);
71
+    }
72
+  });
63 73
 }
64 74
 
65
-module.exports = check_output_directory
75
+module.exports = check_output_directory;

+ 5
- 5
server/client_configuration.js 查看文件

@@ -2,8 +2,8 @@ const config = require("./configuration");
2 2
 
3 3
 /** Settings that should be handed through to the clients  */
4 4
 module.exports = {
5
-    "MAX_BOARD_SIZE": config.MAX_BOARD_SIZE,
6
-    "MAX_EMIT_COUNT": config.MAX_EMIT_COUNT,
7
-    "MAX_EMIT_COUNT_PERIOD": config.MAX_EMIT_COUNT_PERIOD,
8
-    "BLOCKED_TOOLS": config.BLOCKED_TOOLS,
9
-};
5
+  MAX_BOARD_SIZE: config.MAX_BOARD_SIZE,
6
+  MAX_EMIT_COUNT: config.MAX_EMIT_COUNT,
7
+  MAX_EMIT_COUNT_PERIOD: config.MAX_EMIT_COUNT_PERIOD,
8
+  BLOCKED_TOOLS: config.BLOCKED_TOOLS,
9
+};

+ 26
- 24
server/configuration.js 查看文件

@@ -2,41 +2,43 @@ const path = require("path");
2 2
 const app_root = path.dirname(__dirname); // Parent of the directory where this file is
3 3
 
4 4
 module.exports = {
5
-    /** Port on which the application will listen */
6
-    PORT: parseInt(process.env['PORT']) || 8080,
5
+  /** Port on which the application will listen */
6
+  PORT: parseInt(process.env["PORT"]) || 8080,
7 7
 
8
-    /** Host on which the application will listen (defaults to undefined,
8
+  /** Host on which the application will listen (defaults to undefined,
9 9
         hence listen on all interfaces on all IP addresses, but could also be
10 10
         '127.0.0.1' **/
11
-    HOST: process.env['HOST'] || undefined,
11
+  HOST: process.env["HOST"] || undefined,
12 12
 
13
-    /** Path to the directory where boards will be saved by default */
14
-    HISTORY_DIR: process.env['WBO_HISTORY_DIR'] || path.join(app_root, "server-data"),
13
+  /** Path to the directory where boards will be saved by default */
14
+  HISTORY_DIR:
15
+    process.env["WBO_HISTORY_DIR"] || path.join(app_root, "server-data"),
15 16
 
16
-    /** Folder from which static files will be served */
17
-    WEBROOT: process.env['WBO_WEBROOT'] || path.join(app_root, "client-data"),
17
+  /** Folder from which static files will be served */
18
+  WEBROOT: process.env["WBO_WEBROOT"] || path.join(app_root, "client-data"),
18 19
 
19
-    /** Number of milliseconds of inactivity after which the board should be saved to a file */
20
-    SAVE_INTERVAL: parseInt(process.env['WBO_SAVE_INTERVAL']) || 1000 * 2, // Save after 2 seconds of inactivity
20
+  /** Number of milliseconds of inactivity after which the board should be saved to a file */
21
+  SAVE_INTERVAL: parseInt(process.env["WBO_SAVE_INTERVAL"]) || 1000 * 2, // Save after 2 seconds of inactivity
21 22
 
22
-    /** Periodicity at which the board should be saved when it is being actively used (milliseconds)  */
23
-    MAX_SAVE_DELAY: parseInt(process.env['WBO_MAX_SAVE_DELAY']) || 1000 * 60, // Save after 60 seconds even if there is still activity
23
+  /** Periodicity at which the board should be saved when it is being actively used (milliseconds)  */
24
+  MAX_SAVE_DELAY: parseInt(process.env["WBO_MAX_SAVE_DELAY"]) || 1000 * 60, // Save after 60 seconds even if there is still activity
24 25
 
25
-    /** Maximal number of items to keep in the board. When there are more items, the oldest ones are deleted */
26
-    MAX_ITEM_COUNT: parseInt(process.env['WBO_MAX_ITEM_COUNT']) || 32768,
26
+  /** Maximal number of items to keep in the board. When there are more items, the oldest ones are deleted */
27
+  MAX_ITEM_COUNT: parseInt(process.env["WBO_MAX_ITEM_COUNT"]) || 32768,
27 28
 
28
-    /** Max number of sub-items in an item. This prevents flooding */
29
-    MAX_CHILDREN: parseInt(process.env['WBO_MAX_CHILDREN']) || 192,
29
+  /** Max number of sub-items in an item. This prevents flooding */
30
+  MAX_CHILDREN: parseInt(process.env["WBO_MAX_CHILDREN"]) || 192,
30 31
 
31
-    /** Maximum value for any x or y on the board */
32
-    MAX_BOARD_SIZE: parseInt(process.env['WBO_MAX_BOARD_SIZE']) || 65536,
32
+  /** Maximum value for any x or y on the board */
33
+  MAX_BOARD_SIZE: parseInt(process.env["WBO_MAX_BOARD_SIZE"]) || 65536,
33 34
 
34
-    /** Maximum messages per user over the given time period before banning them  */
35
-    MAX_EMIT_COUNT: parseInt(process.env['WBO_MAX_EMIT_COUNT']) || 192,
35
+  /** Maximum messages per user over the given time period before banning them  */
36
+  MAX_EMIT_COUNT: parseInt(process.env["WBO_MAX_EMIT_COUNT"]) || 192,
36 37
 
37
-    /** Duration after which the emit count is reset in miliseconds */
38
-    MAX_EMIT_COUNT_PERIOD: parseInt(process.env['WBO_MAX_EMIT_COUNT_PERIOD']) || 4096,
38
+  /** Duration after which the emit count is reset in miliseconds */
39
+  MAX_EMIT_COUNT_PERIOD:
40
+    parseInt(process.env["WBO_MAX_EMIT_COUNT_PERIOD"]) || 4096,
39 41
 
40
-    /** Blocked Tools. A comma-separated list of tools that should not appear on boards. */
41
-    BLOCKED_TOOLS: (process.env['WBO_BLOCKED_TOOLS'] || "").split(','),
42
+  /** Blocked Tools. A comma-separated list of tools that should not appear on boards. */
43
+  BLOCKED_TOOLS: (process.env["WBO_BLOCKED_TOOLS"] || "").split(","),
42 44
 };

+ 199
- 122
server/createSVG.js 查看文件

@@ -1,147 +1,224 @@
1 1
 const fs = require("./fs_promises.js"),
2
-	path = require("path"),
3
-	wboPencilPoint = require("../client-data/tools/pencil/wbo_pencil_point.js").wboPencilPoint;
2
+  path = require("path"),
3
+  wboPencilPoint = require("../client-data/tools/pencil/wbo_pencil_point.js")
4
+    .wboPencilPoint;
4 5
 
5 6
 function htmlspecialchars(str) {
6
-	if (typeof str !== "string") return "";
7
+  if (typeof str !== "string") return "";
7 8
 
8
-	return str.replace(/[<>&"']/g, function (c) {
9
-		switch (c) {
10
-			case '<': return '&lt;';
11
-			case '>': return '&gt;';
12
-			case '&': return '&amp;';
13
-			case '"': return '&quot;';
14
-			case "'": return '&#39;';
15
-		}
16
-	});
9
+  return str.replace(/[<>&"']/g, function (c) {
10
+    switch (c) {
11
+      case "<":
12
+        return "&lt;";
13
+      case ">":
14
+        return "&gt;";
15
+      case "&":
16
+        return "&amp;";
17
+      case '"':
18
+        return "&quot;";
19
+      case "'":
20
+        return "&#39;";
21
+    }
22
+  });
17 23
 }
18 24
 
19 25
 function renderPath(el, pathstring) {
20
-	return '<path ' +
21
-		(el.id ?
22
-			('id="' + htmlspecialchars(el.id) + '" ') : '') +
23
-		'stroke-width="' + (el.size | 0) + '" ' +
24
-		(el.opacity ?
25
-			('opacity="' + parseFloat(el.opacity) + '" ') : '') +
26
-		'stroke="' + htmlspecialchars(el.color) + '" ' +
27
-		'd="' + pathstring + '" ' +
28
-		(el.deltax || el.deltay ?
29
-			('transform="translate(' + (+el.deltax) + ',' + (+el.deltay) + ')"') : '') +
30
-		'/>';
26
+  return (
27
+    "<path " +
28
+    (el.id ? 'id="' + htmlspecialchars(el.id) + '" ' : "") +
29
+    'stroke-width="' +
30
+    (el.size | 0) +
31
+    '" ' +
32
+    (el.opacity ? 'opacity="' + parseFloat(el.opacity) + '" ' : "") +
33
+    'stroke="' +
34
+    htmlspecialchars(el.color) +
35
+    '" ' +
36
+    'd="' +
37
+    pathstring +
38
+    '" ' +
39
+    (el.deltax || el.deltay
40
+      ? 'transform="translate(' + +el.deltax + "," + +el.deltay + ')"'
41
+      : "") +
42
+    "/>"
43
+  );
31 44
 }
32 45
 
33 46
 const Tools = {
34
-	/**
35
-	 * @return {string}
36
-	 */
37
-	"Text": function (el) {
38
-		return '<text ' +
39
-			'id="' + htmlspecialchars(el.id || "t") + '" ' +
40
-			'x="' + (el.x | 0) + '" ' +
41
-			'y="' + (el.y | 0) + '" ' +
42
-			'font-size="' + (el.size | 0) + '" ' +
43
-			'fill="' + htmlspecialchars(el.color || "#000") + '" ' +
44
-			(el.deltax || el.deltay ? ('transform="translate(' + (el.deltax || 0) + ',' + (el.deltay || 0) + ')"') : '') +
45
-			'>' + htmlspecialchars(el.txt || "") + '</text>';
46
-	},
47
-	/**
48
-	 * @return {string}
49
-	 */
50
-	"Pencil": function (el) {
51
-		if (!el._children) return "";
52
-		let pts = el._children.reduce(function (pts, point) {
53
-			return wboPencilPoint(pts, point.x, point.y);
54
-		}, []);
55
-		const pathstring = pts.map(function (op) {
56
-			return op.type + ' ' + op.values.join(' ')
57
-		}).join(' ');
58
-		return renderPath(el, pathstring);
59
-	},
60
-	/**
61
-	 * @return {string}
62
-	 */
63
-	"Rectangle": function (el) {
64
-		return '<rect ' +
65
-			(el.id ?
66
-				('id="' + htmlspecialchars(el.id) + '" ') : '') +
67
-			'x="' + (el.x || 0) + '" ' +
68
-			'y="' + (el.y || 0) + '" ' +
69
-			'width="' + (el.x2 - el.x) + '" ' +
70
-			'height="' + (el.y2 - el.y) + '" ' +
71
-			'stroke="' + htmlspecialchars(el.color) + '" ' +
72
-			'stroke-width="' + (el.size | 0) + '" ' +
73
-			(el.deltax || el.deltay ? ('transform="translate(' + (el.deltax || 0) + ',' + (el.deltay || 0) + ')"') : '') +
74
-			'/>';
75
-	},
76
-	/**
77
-	 * @return {string}
78
-	 */
79
-	"Ellipse": function (el) {
80
-		const cx = Math.round((el.x2 + el.x) / 2);
81
-		const cy = Math.round((el.y2 + el.y) / 2);
82
-		const rx = Math.abs(el.x2 - el.x) / 2;
83
-		const ry = Math.abs(el.y2 - el.y) / 2;
84
-		const pathstring =
85
-			"M" + (cx - rx) + " " + cy +
86
-			"a" + rx + "," + ry + " 0 1,0 " + (rx * 2) + ",0" +
87
-			"a" + rx + "," + ry + " 0 1,0 " + (rx * -2) + ",0";
88
-		return renderPath(el, pathstring);
89
-	},
90
-	/**
91
-	 * @return {string}
92
-	 */
93
-	"Straight line": function (el) {
94
-		const pathstring = "M" + el.x + " " + el.y + "L" + el.x2 + " " + el.y2;
95
-		return renderPath(el, pathstring);
96
-	}
47
+  /**
48
+   * @return {string}
49
+   */
50
+  Text: function (el) {
51
+    return (
52
+      "<text " +
53
+      'id="' +
54
+      htmlspecialchars(el.id || "t") +
55
+      '" ' +
56
+      'x="' +
57
+      (el.x | 0) +
58
+      '" ' +
59
+      'y="' +
60
+      (el.y | 0) +
61
+      '" ' +
62
+      'font-size="' +
63
+      (el.size | 0) +
64
+      '" ' +
65
+      'fill="' +
66
+      htmlspecialchars(el.color || "#000") +
67
+      '" ' +
68
+      (el.deltax || el.deltay
69
+        ? 'transform="translate(' +
70
+          (el.deltax || 0) +
71
+          "," +
72
+          (el.deltay || 0) +
73
+          ')"'
74
+        : "") +
75
+      ">" +
76
+      htmlspecialchars(el.txt || "") +
77
+      "</text>"
78
+    );
79
+  },
80
+  /**
81
+   * @return {string}
82
+   */
83
+  Pencil: function (el) {
84
+    if (!el._children) return "";
85
+    let pts = el._children.reduce(function (pts, point) {
86
+      return wboPencilPoint(pts, point.x, point.y);
87
+    }, []);
88
+    const pathstring = pts
89
+      .map(function (op) {
90
+        return op.type + " " + op.values.join(" ");
91
+      })
92
+      .join(" ");
93
+    return renderPath(el, pathstring);
94
+  },
95
+  /**
96
+   * @return {string}
97
+   */
98
+  Rectangle: function (el) {
99
+    return (
100
+      "<rect " +
101
+      (el.id ? 'id="' + htmlspecialchars(el.id) + '" ' : "") +
102
+      'x="' +
103
+      (el.x || 0) +
104
+      '" ' +
105
+      'y="' +
106
+      (el.y || 0) +
107
+      '" ' +
108
+      'width="' +
109
+      (el.x2 - el.x) +
110
+      '" ' +
111
+      'height="' +
112
+      (el.y2 - el.y) +
113
+      '" ' +
114
+      'stroke="' +
115
+      htmlspecialchars(el.color) +
116
+      '" ' +
117
+      'stroke-width="' +
118
+      (el.size | 0) +
119
+      '" ' +
120
+      (el.deltax || el.deltay
121
+        ? 'transform="translate(' +
122
+          (el.deltax || 0) +
123
+          "," +
124
+          (el.deltay || 0) +
125
+          ')"'
126
+        : "") +
127
+      "/>"
128
+    );
129
+  },
130
+  /**
131
+   * @return {string}
132
+   */
133
+  Ellipse: function (el) {
134
+    const cx = Math.round((el.x2 + el.x) / 2);
135
+    const cy = Math.round((el.y2 + el.y) / 2);
136
+    const rx = Math.abs(el.x2 - el.x) / 2;
137
+    const ry = Math.abs(el.y2 - el.y) / 2;
138
+    const pathstring =
139
+      "M" +
140
+      (cx - rx) +
141
+      " " +
142
+      cy +
143
+      "a" +
144
+      rx +
145
+      "," +
146
+      ry +
147
+      " 0 1,0 " +
148
+      rx * 2 +
149
+      ",0" +
150
+      "a" +
151
+      rx +
152
+      "," +
153
+      ry +
154
+      " 0 1,0 " +
155
+      rx * -2 +
156
+      ",0";
157
+    return renderPath(el, pathstring);
158
+  },
159
+  /**
160
+   * @return {string}
161
+   */
162
+  "Straight line": function (el) {
163
+    const pathstring = "M" + el.x + " " + el.y + "L" + el.x2 + " " + el.y2;
164
+    return renderPath(el, pathstring);
165
+  },
97 166
 };
98 167
 
99
-
100 168
 /**
101 169
  * Writes the given board as an svg to the given writeable stream
102
- * @param {Object[string, BoardElem]} obj 
103
- * @param {WritableStream} writeable 
170
+ * @param {Object[string, BoardElem]} obj
171
+ * @param {WritableStream} writeable
104 172
  */
105 173
 async function toSVG(obj, writeable) {
106
-	const margin = 400;
107
-	const elems = Object.values(obj);
108
-	const dim = elems.reduce(function (dim, elem) {
109
-		if (elem._children) elem = elem._children[0];
110
-		return [
111
-			Math.max(elem.x + margin + (elem.deltax | 0) | 0, dim[0]),
112
-			Math.max(elem.y + margin + (elem.deltay | 0) | 0, dim[1]),
113
-		]
114
-	}, [margin, margin]);
115
-	writeable.write(
116
-		'<svg xmlns="http://www.w3.org/2000/svg" version="1.1" ' +
117
-		'width="' + dim[0] + '" height="' + dim[1] + '">' +
118
-		'<defs><style type="text/css"><![CDATA[' +
119
-		'text {font-family:"Arial"}' +
120
-		'path {fill:none;stroke-linecap:round;stroke-linejoin:round;}' +
121
-		'rect {fill:none}' +
122
-		']]></style></defs>'
123
-	);
124
-	await Promise.all(elems.map(async function (elem) {
125
-		await Promise.resolve(); // Do not block the event loop
126
-		const renderFun = Tools[elem.tool];
127
-		if (renderFun) writeable.write(renderFun(elem));
128
-		else console.warn("Missing render function for tool", elem.tool);
129
-	}));
130
-	writeable.write('</svg>');
174
+  const margin = 400;
175
+  const elems = Object.values(obj);
176
+  const dim = elems.reduce(
177
+    function (dim, elem) {
178
+      if (elem._children) elem = elem._children[0];
179
+      return [
180
+        Math.max((elem.x + margin + (elem.deltax | 0)) | 0, dim[0]),
181
+        Math.max((elem.y + margin + (elem.deltay | 0)) | 0, dim[1]),
182
+      ];
183
+    },
184
+    [margin, margin]
185
+  );
186
+  writeable.write(
187
+    '<svg xmlns="http://www.w3.org/2000/svg" version="1.1" ' +
188
+      'width="' +
189
+      dim[0] +
190
+      '" height="' +
191
+      dim[1] +
192
+      '">' +
193
+      '<defs><style type="text/css"><![CDATA[' +
194
+      'text {font-family:"Arial"}' +
195
+      "path {fill:none;stroke-linecap:round;stroke-linejoin:round;}" +
196
+      "rect {fill:none}" +
197
+      "]]></style></defs>"
198
+  );
199
+  await Promise.all(
200
+    elems.map(async function (elem) {
201
+      await Promise.resolve(); // Do not block the event loop
202
+      const renderFun = Tools[elem.tool];
203
+      if (renderFun) writeable.write(renderFun(elem));
204
+      else console.warn("Missing render function for tool", elem.tool);
205
+    })
206
+  );
207
+  writeable.write("</svg>");
131 208
 }
132 209
 
133 210
 async function renderBoard(file, stream) {
134
-	const data = await fs.promises.readFile(file);
135
-	var board = JSON.parse(data);
136
-	return toSVG(board, stream);
211
+  const data = await fs.promises.readFile(file);
212
+  var board = JSON.parse(data);
213
+  return toSVG(board, stream);
137 214
 }
138 215
 
139 216
 if (require.main === module) {
140
-	const config = require("./configuration.js");
141
-	const HISTORY_FILE = process.argv[2] || path.join(config.HISTORY_DIR, "board-anonymous.json");
217
+  const config = require("./configuration.js");
218
+  const HISTORY_FILE =
219
+    process.argv[2] || path.join(config.HISTORY_DIR, "board-anonymous.json");
142 220
 
143
-	renderBoard(HISTORY_FILE, process.stdout)
144
-		.catch(console.error.bind(console));
221
+  renderBoard(HISTORY_FILE, process.stdout).catch(console.error.bind(console));
145 222
 } else {
146
-	module.exports = { 'renderBoard': renderBoard };
223
+  module.exports = { renderBoard: renderBoard };
147 224
 }

+ 8
- 8
server/fs_promises.js 查看文件

@@ -1,13 +1,13 @@
1
-const fs = require('fs');
1
+const fs = require("fs");
2 2
 
3 3
 if (typeof fs.promises === "undefined") {
4
-    console.warn("Using an old node version without fs.promises");
4
+  console.warn("Using an old node version without fs.promises");
5 5
 
6
-    const util = require("util");
7
-    fs.promises = {};
8
-    Object.entries(fs)
9
-        .filter(([_, v]) => typeof v === 'function')
10
-        .forEach(([k, v]) => fs.promises[k] = util.promisify(v))
6
+  const util = require("util");
7
+  fs.promises = {};
8
+  Object.entries(fs)
9
+    .filter(([_, v]) => typeof v === "function")
10
+    .forEach(([k, v]) => (fs.promises[k] = util.promisify(v)));
11 11
 }
12 12
 
13
-module.exports = fs;
13
+module.exports = fs;

+ 6
- 6
server/log.js 查看文件

@@ -1,12 +1,12 @@
1 1
 /**
2 2
  * Add a message to the logs
3
- * @param {string} type 
4
- * @param {any} infos 
3
+ * @param {string} type
4
+ * @param {any} infos
5 5
  */
6 6
 function log(type, infos) {
7
-    var msg = new Date().toISOString() + '\t' + type;
8
-    if (infos) msg += '\t' + JSON.stringify(infos);
9
-    console.log(msg);
7
+  var msg = new Date().toISOString() + "\t" + type;
8
+  if (infos) msg += "\t" + JSON.stringify(infos);
9
+  console.log(msg);
10 10
 }
11 11
 
12
-module.exports.log = log;
12
+module.exports.log = log;

+ 173
- 147
server/server.js 查看文件

@@ -1,24 +1,27 @@
1
-var app = require('http').createServer(handler)
2
-	, sockets = require('./sockets.js')
3
-	, log = require("./log.js").log
4
-	, path = require('path')
5
-	, url = require('url')
6
-	, fs = require("fs")
7
-	, crypto = require("crypto")
8
-	, serveStatic = require("serve-static")
9
-	, createSVG = require("./createSVG.js")
10
-	, templating = require("./templating.js")
11
-	, config = require("./configuration.js")
12
-	, polyfillLibrary = require('polyfill-library')
13
-	, check_output_directory = require("./check_output_directory.js");
14
-
1
+var app = require("http").createServer(handler),
2
+  sockets = require("./sockets.js"),
3
+  log = require("./log.js").log,
4
+  path = require("path"),
5
+  url = require("url"),
6
+  fs = require("fs"),
7
+  crypto = require("crypto"),
8
+  serveStatic = require("serve-static"),
9
+  createSVG = require("./createSVG.js"),
10
+  templating = require("./templating.js"),
11
+  config = require("./configuration.js"),
12
+  polyfillLibrary = require("polyfill-library"),
13
+  check_output_directory = require("./check_output_directory.js");
15 14
 
16 15
 var MIN_NODE_VERSION = 10.0;
17 16
 
18 17
 if (parseFloat(process.versions.node) < MIN_NODE_VERSION) {
19
-	console.warn(
20
-		"!!! You are using node " + process.version +
21
-		", wbo requires at least " + MIN_NODE_VERSION + " !!!");
18
+  console.warn(
19
+    "!!! You are using node " +
20
+      process.version +
21
+      ", wbo requires at least " +
22
+      MIN_NODE_VERSION +
23
+      " !!!"
24
+  );
22 25
 }
23 26
 
24 27
 check_output_directory(config.HISTORY_DIR);
@@ -28,168 +31,191 @@ sockets.start(app);
28 31
 app.listen(config.PORT, config.HOST);
29 32
 log("server started", { port: config.PORT });
30 33
 
31
-var CSP = "default-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:";
34
+var CSP =
35
+  "default-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' ws: wss:";
32 36
 
33 37
 var fileserver = serveStatic(config.WEBROOT, {
34
-	maxAge: 2 * 3600 * 1000,
35
-	setHeaders: function (res) {
36
-		res.setHeader("X-UA-Compatible", "IE=Edge");
37
-		res.setHeader("Content-Security-Policy", CSP);
38
-	}
38
+  maxAge: 2 * 3600 * 1000,
39
+  setHeaders: function (res) {
40
+    res.setHeader("X-UA-Compatible", "IE=Edge");
41
+    res.setHeader("Content-Security-Policy", CSP);
42
+  },
39 43
 });
40 44
 
41 45
 var errorPage = fs.readFileSync(path.join(config.WEBROOT, "error.html"));
42 46
 function serveError(request, response) {
43
-	return function (err) {
44
-		log("error", { "error": err && err.toString(), "url": request.url });
45
-		response.writeHead(err ? 500 : 404, { "Content-Length": errorPage.length });
46
-		response.end(errorPage);
47
-	}
47
+  return function (err) {
48
+    log("error", { error: err && err.toString(), url: request.url });
49
+    response.writeHead(err ? 500 : 404, { "Content-Length": errorPage.length });
50
+    response.end(errorPage);
51
+  };
48 52
 }
49 53
 
50 54
 /**
51 55
  * Write a request to the logs
52
- * @param {import("http").IncomingMessage} request 
56
+ * @param {import("http").IncomingMessage} request
53 57
  */
54 58
 function logRequest(request) {
55
-	log('connection', {
56
-		ip: request.connection.remoteAddress,
57
-		original_ip: request.headers['x-forwarded-for'] || request.headers['forwarded'],
58
-		user_agent: request.headers['user-agent'],
59
-		referer: request.headers['referer'],
60
-		language: request.headers['accept-language'],
61
-		url: request.url,
62
-	});
59
+  log("connection", {
60
+    ip: request.connection.remoteAddress,
61
+    original_ip:
62
+      request.headers["x-forwarded-for"] || request.headers["forwarded"],
63
+    user_agent: request.headers["user-agent"],
64
+    referer: request.headers["referer"],
65
+    language: request.headers["accept-language"],
66
+    url: request.url,
67
+  });
63 68
 }
64 69
 
65 70
 /**
66 71
  * @type {import('http').RequestListener}
67 72
  */
68 73
 function handler(request, response) {
69
-	try {
70
-		handleRequest(request, response);
71
-	} catch (err) {
72
-		console.trace(err);
73
-		response.writeHead(500, { 'Content-Type': 'text/plain' });
74
-		response.end(err.toString());
75
-	}
74
+  try {
75
+    handleRequest(request, response);
76
+  } catch (err) {
77
+    console.trace(err);
78
+    response.writeHead(500, { "Content-Type": "text/plain" });
79
+    response.end(err.toString());
80
+  }
76 81
 }
77 82
 
78
-const boardTemplate = new templating.BoardTemplate(path.join(config.WEBROOT, 'board.html'));
79
-const indexTemplate = new templating.Template(path.join(config.WEBROOT, 'index.html'));
83
+const boardTemplate = new templating.BoardTemplate(
84
+  path.join(config.WEBROOT, "board.html")
85
+);
86
+const indexTemplate = new templating.Template(
87
+  path.join(config.WEBROOT, "index.html")
88
+);
80 89
 
81 90
 /**
82 91
  * Throws an error if the given board name is not allowed
83
- * @param {string} boardName 
92
+ * @param {string} boardName
84 93
  * @throws {Error}
85 94
  */
86 95
 function validateBoardName(boardName) {
87
-	if (/^[\w%\-_~()]*$/.test(boardName)) return boardName;
88
-	throw new Error("Illegal board name: " + boardName);
96
+  if (/^[\w%\-_~()]*$/.test(boardName)) return boardName;
97
+  throw new Error("Illegal board name: " + boardName);
89 98
 }
90 99
 
91 100
 /**
92 101
  * @type {import('http').RequestListener}
93 102
  */
94 103
 function handleRequest(request, response) {
95
-	var parsedUrl = url.parse(request.url, true);
96
-	var parts = parsedUrl.pathname.split('/');
97
-	if (parts[0] === '') parts.shift();
98
-
99
-	switch (parts[0]) {
100
-		case "boards":
101
-			// "boards" refers to the root directory
102
-			if (parts.length === 1) {
103
-				// '/boards?board=...' This allows html forms to point to boards
104
-				var boardName = parsedUrl.query.board || "anonymous";
105
-				var headers = { Location: 'boards/' + encodeURIComponent(boardName) };
106
-				response.writeHead(301, headers);
107
-				response.end();
108
-			} else if (parts.length === 2 && request.url.indexOf('.') === -1) {
109
-				validateBoardName(parts[1]);
110
-				// If there is no dot and no directory, parts[1] is the board name
111
-				boardTemplate.serve(request, response);
112
-			} else { // Else, it's a resource
113
-				request.url = "/" + parts.slice(1).join('/');
114
-				fileserver(request, response, serveError(request, response));
115
-			}
116
-			break;
117
-
118
-		case "download":
119
-			var boardName = validateBoardName(parts[1]),
120
-				history_file = path.join(config.HISTORY_DIR, "board-" + boardName + ".json");
121
-			if (parts.length > 2 && /^[0-9A-Za-z.\-]+$/.test(parts[2])) {
122
-				history_file += '.' + parts[2] + '.bak';
123
-			}
124
-			log("download", { "file": history_file });
125
-			fs.readFile(history_file, function (err, data) {
126
-				if (err) return serveError(request, response)(err);
127
-				response.writeHead(200, {
128
-					"Content-Type": "application/json",
129
-					"Content-Disposition": 'attachment; filename="' + boardName + '.wbo"',
130
-					"Content-Length": data.length,
131
-				});
132
-				response.end(data);
133
-			});
134
-			break;
135
-
136
-		case "export":
137
-		case "preview":
138
-			var boardName = validateBoardName(parts[1]),
139
-				history_file = path.join(config.HISTORY_DIR, "board-" + boardName + ".json");
140
-			response.writeHead(200, {
141
-				"Content-Type": "image/svg+xml",
142
-				"Content-Security-Policy": CSP,
143
-				"Cache-Control": "public, max-age=30",
144
-			});
145
-			var t = Date.now();
146
-			createSVG.renderBoard(history_file, response).then(function () {
147
-				log("preview", { "board": boardName, "time": Date.now() - t });
148
-				response.end();
149
-			}).catch(function (err) {
150
-				log("error", { "error": err.toString(), "stack": err.stack });
151
-				response.end('<text>Sorry, an error occured</text>');
152
-			});
153
-			break;
154
-
155
-		case "random":
156
-			var name = crypto.randomBytes(32).toString('base64').replace(/[^\w]/g, '-');
157
-			response.writeHead(307, { 'Location': 'boards/' + name });
158
-			response.end(name);
159
-			break;
160
-
161
-		case "polyfill.js": // serve tailored polyfills
162
-		case "polyfill.min.js":
163
-			polyfillLibrary.getPolyfillString({
164
-				uaString: request.headers['user-agent'],
165
-				minify: request.url.endsWith(".min.js"),
166
-				features: {
167
-					'default': { flags: ['gated'] },
168
-					'es5': { flags: ['gated'] },
169
-					'es6': { flags: ['gated'] },
170
-					'es7': { flags: ['gated'] },
171
-					'es2017': { flags: ['gated'] },
172
-					'es2018': { flags: ['gated'] },
173
-					'es2019': { flags: ['gated'] },
174
-					'performance.now': { flags: ['gated'] },
175
-				}
176
-			}).then(function (bundleString) {
177
-				response.setHeader('Cache-Control', 'private, max-age=172800, stale-while-revalidate=1728000');
178
-				response.setHeader('Vary', 'User-Agent');
179
-				response.setHeader('Content-Type', 'application/javascript');
180
-				response.end(bundleString);
181
-			});
182
-			break;
183
-
184
-		case "": // Index page
185
-			logRequest(request);
186
-			indexTemplate.serve(request, response);
187
-			break;
188
-
189
-		default:
190
-			fileserver(request, response, serveError(request, response));
191
-	}
104
+  var parsedUrl = url.parse(request.url, true);
105
+  var parts = parsedUrl.pathname.split("/");
106
+  if (parts[0] === "") parts.shift();
107
+
108
+  switch (parts[0]) {
109
+    case "boards":
110
+      // "boards" refers to the root directory
111
+      if (parts.length === 1) {
112
+        // '/boards?board=...' This allows html forms to point to boards
113
+        var boardName = parsedUrl.query.board || "anonymous";
114
+        var headers = { Location: "boards/" + encodeURIComponent(boardName) };
115
+        response.writeHead(301, headers);
116
+        response.end();
117
+      } else if (parts.length === 2 && request.url.indexOf(".") === -1) {
118
+        validateBoardName(parts[1]);
119
+        // If there is no dot and no directory, parts[1] is the board name
120
+        boardTemplate.serve(request, response);
121
+      } else {
122
+        // Else, it's a resource
123
+        request.url = "/" + parts.slice(1).join("/");
124
+        fileserver(request, response, serveError(request, response));
125
+      }
126
+      break;
127
+
128
+    case "download":
129
+      var boardName = validateBoardName(parts[1]),
130
+        history_file = path.join(
131
+          config.HISTORY_DIR,
132
+          "board-" + boardName + ".json"
133
+        );
134
+      if (parts.length > 2 && /^[0-9A-Za-z.\-]+$/.test(parts[2])) {
135
+        history_file += "." + parts[2] + ".bak";
136
+      }
137
+      log("download", { file: history_file });
138
+      fs.readFile(history_file, function (err, data) {
139
+        if (err) return serveError(request, response)(err);
140
+        response.writeHead(200, {
141
+          "Content-Type": "application/json",
142
+          "Content-Disposition": 'attachment; filename="' + boardName + '.wbo"',
143
+          "Content-Length": data.length,
144
+        });
145
+        response.end(data);
146
+      });
147
+      break;
148
+
149
+    case "export":
150
+    case "preview":
151
+      var boardName = validateBoardName(parts[1]),
152
+        history_file = path.join(
153
+          config.HISTORY_DIR,
154
+          "board-" + boardName + ".json"
155
+        );
156
+      response.writeHead(200, {
157
+        "Content-Type": "image/svg+xml",
158
+        "Content-Security-Policy": CSP,
159
+        "Cache-Control": "public, max-age=30",
160
+      });
161
+      var t = Date.now();
162
+      createSVG
163
+        .renderBoard(history_file, response)
164
+        .then(function () {
165
+          log("preview", { board: boardName, time: Date.now() - t });
166
+          response.end();
167
+        })
168
+        .catch(function (err) {
169
+          log("error", { error: err.toString(), stack: err.stack });
170
+          response.end("<text>Sorry, an error occured</text>");
171
+        });
172
+      break;
173
+
174
+    case "random":
175
+      var name = crypto
176
+        .randomBytes(32)
177
+        .toString("base64")
178
+        .replace(/[^\w]/g, "-");
179
+      response.writeHead(307, { Location: "boards/" + name });
180
+      response.end(name);
181
+      break;
182
+
183
+    case "polyfill.js": // serve tailored polyfills
184
+    case "polyfill.min.js":
185
+      polyfillLibrary
186
+        .getPolyfillString({
187
+          uaString: request.headers["user-agent"],
188
+          minify: request.url.endsWith(".min.js"),
189
+          features: {
190
+            default: { flags: ["gated"] },
191
+            es5: { flags: ["gated"] },
192
+            es6: { flags: ["gated"] },
193
+            es7: { flags: ["gated"] },
194
+            es2017: { flags: ["gated"] },
195
+            es2018: { flags: ["gated"] },
196
+            es2019: { flags: ["gated"] },
197
+            "performance.now": { flags: ["gated"] },
198
+          },
199
+        })
200
+        .then(function (bundleString) {
201
+          response.setHeader(
202
+            "Cache-Control",
203
+            "private, max-age=172800, stale-while-revalidate=1728000"
204
+          );
205
+          response.setHeader("Vary", "User-Agent");
206
+          response.setHeader("Content-Type", "application/javascript");
207
+          response.end(bundleString);
208
+        });
209
+      break;
210
+
211
+    case "": // Index page
212
+      logRequest(request);
213
+      indexTemplate.serve(request, response);
214
+      break;
215
+
216
+    default:
217
+      fileserver(request, response, serveError(request, response));
218
+  }
192 219
 }
193 220
 
194
-
195 221
 module.exports = app;

+ 150
- 139
server/sockets.js 查看文件

@@ -1,7 +1,7 @@
1
-var iolib = require('socket.io')
2
-	, log = require("./log.js").log
3
-	, BoardData = require("./boardData.js").BoardData
4
-	, config = require("./configuration");
1
+var iolib = require("socket.io"),
2
+  log = require("./log.js").log,
3
+  BoardData = require("./boardData.js").BoardData,
4
+  config = require("./configuration");
5 5
 
6 6
 /** Map from name to *promises* of BoardData
7 7
 	@type {Object<string, Promise<BoardData>>}
@@ -13,168 +13,179 @@ var boards = {};
13 13
  * If the inner function throws, the outer function just returns undefined
14 14
  * and logs the error.
15 15
  * @template A
16
- * @param {A} fn 
16
+ * @param {A} fn
17 17
  * @returns {A}
18 18
  */
19 19
 function noFail(fn) {
20
-	return function noFailWrapped(arg) {
21
-		try {
22
-			return fn(arg);
23
-		} catch (e) {
24
-			console.trace(e);
25
-		}
26
-	}
20
+  return function noFailWrapped(arg) {
21
+    try {
22
+      return fn(arg);
23
+    } catch (e) {
24
+      console.trace(e);
25
+    }
26
+  };
27 27
 }
28 28
 
29 29
 function startIO(app) {
30
-	io = iolib(app);
31
-	io.on('connection', noFail(socketConnection));
32
-	return io;
30
+  io = iolib(app);
31
+  io.on("connection", noFail(socketConnection));
32
+  return io;
33 33
 }
34 34
 
35 35
 /** Returns a promise to a BoardData with the given name
36 36
  * @returns {Promise<BoardData>}
37
-*/
37
+ */
38 38
 function getBoard(name) {
39
-	if (boards.hasOwnProperty(name)) {
40
-		return boards[name];
41
-	} else {
42
-		var board = BoardData.load(name);
43
-		boards[name] = board;
44
-		return board;
45
-	}
39
+  if (boards.hasOwnProperty(name)) {
40
+    return boards[name];
41
+  } else {
42
+    var board = BoardData.load(name);
43
+    boards[name] = board;
44
+    return board;
45
+  }
46 46
 }
47 47
 
48 48
 /**
49 49
  * Executes on every new connection
50
- * @param {iolib.Socket} socket 
50
+ * @param {iolib.Socket} socket
51 51
  */
52 52
 function socketConnection(socket) {
53
-
54
-	/**
55
-	 * Function to call when an user joins a board
56
-	 * @param {string} name 
57
-	 */
58
-	async function joinBoard(name) {
59
-		// Default to the public board
60
-		if (!name) name = "anonymous";
61
-
62
-		// Join the board
63
-		socket.join(name);
64
-
65
-		var board = await getBoard(name);
66
-		board.users.add(socket.id);
67
-		log('board joined', { 'board': board.name, 'users': board.users.size });
68
-		return board;
69
-	}
70
-
71
-	socket.on("error", noFail(function onError(error) {
72
-		log("ERROR", error);
73
-	}));
74
-
75
-	socket.on("getboard", async function onGetBoard(name) {
76
-		var board = await joinBoard(name);
77
-		//Send all the board's data as soon as it's loaded
78
-		socket.emit("broadcast", { _children: board.getAll() });
79
-	});
80
-
81
-	socket.on("joinboard", noFail(joinBoard));
82
-
83
-	var lastEmitSecond = Date.now() / config.MAX_EMIT_COUNT_PERIOD | 0;
84
-	var emitCount = 0;
85
-	socket.on('broadcast', noFail(function onBroadcast(message) {
86
-		var currentSecond = Date.now() / config.MAX_EMIT_COUNT_PERIOD | 0;
87
-		if (currentSecond === lastEmitSecond) {
88
-			emitCount++;
89
-			if (emitCount > config.MAX_EMIT_COUNT) {
90
-				var request = socket.client.request;
91
-				if (emitCount % 100 === 0) {
92
-					log('BANNED', {
93
-						user_agent: request.headers['user-agent'],
94
-						original_ip: request.headers['x-forwarded-for'] || request.headers['forwarded'],
95
-						emit_count: emitCount
96
-					});
97
-				}
98
-				return;
99
-			}
100
-		} else {
101
-			emitCount = 0;
102
-			lastEmitSecond = currentSecond;
103
-		}
104
-
105
-		var boardName = message.board || "anonymous";
106
-		var data = message.data;
107
-
108
-		if (!socket.rooms.has(boardName)) socket.join(boardName);
109
-
110
-		if (!data) {
111
-			console.warn("Received invalid message: %s.", JSON.stringify(message));
112
-			return;
113
-		}
114
-
115
-		if (!message.data.tool || config.BLOCKED_TOOLS.includes(message.data.tool)) {
116
-			log('BLOCKED MESSAGE', message.data);
117
-			return;
118
-		}
119
-
120
-		// Save the message in the board
121
-		handleMessage(boardName, data, socket);
122
-
123
-		//Send data to all other users connected on the same board
124
-		socket.broadcast.to(boardName).emit('broadcast', data);
125
-	}));
126
-
127
-	socket.on('disconnecting', function onDisconnecting(reason) {
128
-		socket.rooms.forEach(async function disconnectFrom(room) {
129
-			if (boards.hasOwnProperty(room)) {
130
-				var board = await boards[room];
131
-				board.users.delete(socket.id);
132
-				var userCount = board.users.size;
133
-				log('disconnection', { 'board': board.name, 'users': board.users.size });
134
-				if (userCount === 0) {
135
-					board.save();
136
-					delete boards[room];
137
-				}
138
-			}
139
-		});
140
-	});
53
+  /**
54
+   * Function to call when an user joins a board
55
+   * @param {string} name
56
+   */
57
+  async function joinBoard(name) {
58
+    // Default to the public board
59
+    if (!name) name = "anonymous";
60
+
61
+    // Join the board
62
+    socket.join(name);
63
+
64
+    var board = await getBoard(name);
65
+    board.users.add(socket.id);
66
+    log("board joined", { board: board.name, users: board.users.size });
67
+    return board;
68
+  }
69
+
70
+  socket.on(
71
+    "error",
72
+    noFail(function onError(error) {
73
+      log("ERROR", error);
74
+    })
75
+  );
76
+
77
+  socket.on("getboard", async function onGetBoard(name) {
78
+    var board = await joinBoard(name);
79
+    //Send all the board's data as soon as it's loaded
80
+    socket.emit("broadcast", { _children: board.getAll() });
81
+  });
82
+
83
+  socket.on("joinboard", noFail(joinBoard));
84
+
85
+  var lastEmitSecond = (Date.now() / config.MAX_EMIT_COUNT_PERIOD) | 0;
86
+  var emitCount = 0;
87
+  socket.on(
88
+    "broadcast",
89
+    noFail(function onBroadcast(message) {
90
+      var currentSecond = (Date.now() / config.MAX_EMIT_COUNT_PERIOD) | 0;
91
+      if (currentSecond === lastEmitSecond) {
92
+        emitCount++;
93
+        if (emitCount > config.MAX_EMIT_COUNT) {
94
+          var request = socket.client.request;
95
+          if (emitCount % 100 === 0) {
96
+            log("BANNED", {
97
+              user_agent: request.headers["user-agent"],
98
+              original_ip:
99
+                request.headers["x-forwarded-for"] ||
100
+                request.headers["forwarded"],
101
+              emit_count: emitCount,
102
+            });
103
+          }
104
+          return;
105
+        }
106
+      } else {
107
+        emitCount = 0;
108
+        lastEmitSecond = currentSecond;
109
+      }
110
+
111
+      var boardName = message.board || "anonymous";
112
+      var data = message.data;
113
+
114
+      if (!socket.rooms.has(boardName)) socket.join(boardName);
115
+
116
+      if (!data) {
117
+        console.warn("Received invalid message: %s.", JSON.stringify(message));
118
+        return;
119
+      }
120
+
121
+      if (
122
+        !message.data.tool ||
123
+        config.BLOCKED_TOOLS.includes(message.data.tool)
124
+      ) {
125
+        log("BLOCKED MESSAGE", message.data);
126
+        return;
127
+      }
128
+
129
+      // Save the message in the board
130
+      handleMessage(boardName, data, socket);
131
+
132
+      //Send data to all other users connected on the same board
133
+      socket.broadcast.to(boardName).emit("broadcast", data);
134
+    })
135
+  );
136
+
137
+  socket.on("disconnecting", function onDisconnecting(reason) {
138
+    socket.rooms.forEach(async function disconnectFrom(room) {
139
+      if (boards.hasOwnProperty(room)) {
140
+        var board = await boards[room];
141
+        board.users.delete(socket.id);
142
+        var userCount = board.users.size;
143
+        log("disconnection", { board: board.name, users: board.users.size });
144
+        if (userCount === 0) {
145
+          board.save();
146
+          delete boards[room];
147
+        }
148
+      }
149
+    });
150
+  });
141 151
 }
142 152
 
143 153
 function handleMessage(boardName, message, socket) {
144
-	if (message.tool === "Cursor") {
145
-		message.socket = socket.id;
146
-	} else {
147
-		saveHistory(boardName, message);
148
-	}
154
+  if (message.tool === "Cursor") {
155
+    message.socket = socket.id;
156
+  } else {
157
+    saveHistory(boardName, message);
158
+  }
149 159
 }
150 160
 
151 161
 async function saveHistory(boardName, message) {
152
-	var id = message.id;
153
-	var board = await getBoard(boardName);
154
-	switch (message.type) {
155
-		case "delete":
156
-			if (id) board.delete(id);
157
-			break;
158
-		case "update":
159
-			if (id) board.update(id, message);
160
-			break;
161
-		case "child":
162
-			board.addChild(message.parent, message);
163
-			break;
164
-		default: //Add data
165
-			if (!id) throw new Error("Invalid message: ", message);
166
-			board.set(id, message);
167
-	}
162
+  var id = message.id;
163
+  var board = await getBoard(boardName);
164
+  switch (message.type) {
165
+    case "delete":
166
+      if (id) board.delete(id);
167
+      break;
168
+    case "update":
169
+      if (id) board.update(id, message);
170
+      break;
171
+    case "child":
172
+      board.addChild(message.parent, message);
173
+      break;
174
+    default:
175
+      //Add data
176
+      if (!id) throw new Error("Invalid message: ", message);
177
+      board.set(id, message);
178
+  }
168 179
 }
169 180
 
170 181
 function generateUID(prefix, suffix) {
171
-	var uid = Date.now().toString(36); //Create the uids in chronological order
172
-	uid += (Math.round(Math.random() * 36)).toString(36); //Add a random character at the end
173
-	if (prefix) uid = prefix + uid;
174
-	if (suffix) uid = uid + suffix;
175
-	return uid;
182
+  var uid = Date.now().toString(36); //Create the uids in chronological order
183
+  uid += Math.round(Math.random() * 36).toString(36); //Add a random character at the end
184
+  if (prefix) uid = prefix + uid;
185
+  if (suffix) uid = uid + suffix;
186
+  return uid;
176 187
 }
177 188
 
178 189
 if (exports) {
179
-	exports.start = startIO;
190
+  exports.start = startIO;
180 191
 }

+ 49
- 43
server/templating.js 查看文件

@@ -2,7 +2,7 @@ const handlebars = require("handlebars");
2 2
 const fs = require("fs");
3 3
 const path = require("path");
4 4
 const url = require("url");
5
-const accept_language_parser = require('accept-language-parser');
5
+const accept_language_parser = require("accept-language-parser");
6 6
 const client_config = require("./client_configuration");
7 7
 
8 8
 /**
@@ -10,60 +10,66 @@ const client_config = require("./client_configuration");
10 10
  * @const
11 11
  * @type {object}
12 12
  */
13
-const TRANSLATIONS = JSON.parse(fs.readFileSync(path.join(__dirname, "translations.json")));
13
+const TRANSLATIONS = JSON.parse(
14
+  fs.readFileSync(path.join(__dirname, "translations.json"))
15
+);
14 16
 const languages = Object.keys(TRANSLATIONS);
15 17
 
16 18
 handlebars.registerHelper({
17
-    json: JSON.stringify.bind(JSON)
19
+  json: JSON.stringify.bind(JSON),
18 20
 });
19 21
 
20 22
 function findBaseUrl(req) {
21
-    var proto = req.headers['X-Forwarded-Proto'] || (req.connection.encrypted ? 'https' : 'http');
22
-    var host = req.headers['X-Forwarded-Host'] || req.headers.host;
23
-    return proto + '://' + host;
23
+  var proto =
24
+    req.headers["X-Forwarded-Proto"] ||
25
+    (req.connection.encrypted ? "https" : "http");
26
+  var host = req.headers["X-Forwarded-Host"] || req.headers.host;
27
+  return proto + "://" + host;
24 28
 }
25 29
 
26 30
 class Template {
27
-    constructor(path) {
28
-        const contents = fs.readFileSync(path, { encoding: 'utf8' });
29
-        this.template = handlebars.compile(contents);
30
-    }
31
-    parameters(parsedUrl, request) {
32
-        const accept_languages = parsedUrl.query.lang || request.headers['accept-language'];
33
-        const opts = { loose: true };
34
-        const language = accept_language_parser.pick(languages, accept_languages, opts) || 'en';
35
-        const translations = TRANSLATIONS[language] || {};
36
-        const configuration = client_config || {};
37
-        const prefix = request.url.split("/boards/")[0].substr(1);
38
-        const baseUrl = findBaseUrl(request) + (prefix ? prefix + "/" : "");
39
-        return { baseUrl, languages, language, translations, configuration };
40
-    }
41
-    serve(request, response) {
42
-        const parsedUrl = url.parse(request.url, true);
43
-        const parameters = this.parameters(parsedUrl, request);
44
-        var body = this.template(parameters);
45
-        var headers = {
46
-            'Content-Length': Buffer.byteLength(body),
47
-            'Content-Type': 'text/html',
48
-            'Cache-Control': 'public, max-age=3600',
49
-        };
50
-        if (!parsedUrl.query.lang) {
51
-            headers["Vary"] = 'Accept-Language';
52
-        }
53
-        response.writeHead(200, headers);
54
-        response.end(body);
31
+  constructor(path) {
32
+    const contents = fs.readFileSync(path, { encoding: "utf8" });
33
+    this.template = handlebars.compile(contents);
34
+  }
35
+  parameters(parsedUrl, request) {
36
+    const accept_languages =
37
+      parsedUrl.query.lang || request.headers["accept-language"];
38
+    const opts = { loose: true };
39
+    const language =
40
+      accept_language_parser.pick(languages, accept_languages, opts) || "en";
41
+    const translations = TRANSLATIONS[language] || {};
42
+    const configuration = client_config || {};
43
+    const prefix = request.url.split("/boards/")[0].substr(1);
44
+    const baseUrl = findBaseUrl(request) + (prefix ? prefix + "/" : "");
45
+    return { baseUrl, languages, language, translations, configuration };
46
+  }
47
+  serve(request, response) {
48
+    const parsedUrl = url.parse(request.url, true);
49
+    const parameters = this.parameters(parsedUrl, request);
50
+    var body = this.template(parameters);
51
+    var headers = {
52
+      "Content-Length": Buffer.byteLength(body),
53
+      "Content-Type": "text/html",
54
+      "Cache-Control": "public, max-age=3600",
55
+    };
56
+    if (!parsedUrl.query.lang) {
57
+      headers["Vary"] = "Accept-Language";
55 58
     }
59
+    response.writeHead(200, headers);
60
+    response.end(body);
61
+  }
56 62
 }
57 63
 
58 64
 class BoardTemplate extends Template {
59
-    parameters(parsedUrl, request) {
60
-        const params = super.parameters(parsedUrl, request);
61
-        const parts = parsedUrl.pathname.split('boards/', 2);
62
-        const boardUriComponent = parts[1];
63
-        params['boardUriComponent'] = boardUriComponent;
64
-        params['board'] = decodeURIComponent(boardUriComponent);
65
-        return params;
66
-    }
65
+  parameters(parsedUrl, request) {
66
+    const params = super.parameters(parsedUrl, request);
67
+    const parts = parsedUrl.pathname.split("boards/", 2);
68
+    const boardUriComponent = parts[1];
69
+    params["boardUriComponent"] = boardUriComponent;
70
+    params["board"] = decodeURIComponent(boardUriComponent);
71
+    return params;
72
+  }
67 73
 }
68 74
 
69
-module.exports = { Template, BoardTemplate };
75
+module.exports = { Template, BoardTemplate };

+ 415
- 415
server/translations.json 查看文件

@@ -1,417 +1,417 @@
1 1
 {
2
-    "en": {
3
-        "hand": "Hand",
4
-        "loading": "Loading",
5
-        "tagline": "A free and open-source online collaborative drawing tool. Sketch new ideas together on WBO!",
6
-        "configuration": "Configuration",
7
-        "collaborative_whiteboard": "Collaborative whiteboard",
8
-        "size": "Size",
9
-        "zoom": "Zoom",
10
-        "tools": "Tools",
11
-        "rectangle": "Rectangle",
12
-        "square": "Square",
13
-        "circle": "Circle",
14
-        "ellipse": "Ellipse",
15
-        "click_to_toggle": "click to toggle",
16
-        "menu": "Menu",
17
-        "text": "Text",
18
-        "mover": "Mover",
19
-        "straight_line": "Straight line",
20
-        "pencil": "Pencil",
21
-        "grid": "Grid",
22
-        "click_to_zoom": "Click to zoom in\nPress shift and click to zoom out",
23
-        "keyboard_shortcut": "keyboard shortcut",
24
-        "mousewheel": "mouse wheel",
25
-        "opacity": "Opacity",
26
-        "color": "Color",
27
-        "eraser": "Eraser",
28
-        "White-out": "White-out",
29
-        "index_title": "Welcome to the free online whiteboard WBO!",
30
-        "introduction_paragraph": "WBO is a <a href=\"https://github.com/lovasoa/whitebophir\" title=\"Free as in free speech, not free beer. This software is released under the AGPL license\">free and open-source</a> online collaborative whiteboard that allows many users to draw simultaneously on a large virtual board. The board is updated in real time for all connected users, and its state is always persisted. It can be used for many different purposes, including art, entertainment, design and teaching.",
31
-        "share_instructions": "To collaborate on a drawing in real time with someone, just send them its URL.",
32
-        "public_board_description": "The <b>public board</b> is accessible to everyone. It is a happily disorganized mess where you can meet with anonymous strangers and draw together. Everything there is ephemeral.",
33
-        "open_public_board": "Go to the public board",
34
-        "private_board_description": "You can create a <b>private board</b> with a random name, that will be accessible only by its link. Use this if you want to share private information.",
35
-        "create_private_board": "Create a private board",
36
-        "named_private_board_description": "You can also create a <strong>named private board</strong>, with a custom URL, that will be accessible to all those who know its name.",
37
-        "board_name_placeholder": "Name of the board…",
38
-        "view_source": "Source code on GitHub"
39
-    },
40
-    "de": {
41
-        "hand": "Hand",
42
-        "mover": "Verschiebung",
43
-        "loading": "Lädt",
44
-        "tagline": "Ein freies quelloffenes kollaboratives Zeichentool. Zeichnet eure Ideen zusammen auf WBO!",
45
-        "configuration": "Konfiguration",
46
-        "collaborative_whiteboard": "Kollaboratives Whiteboard",
47
-        "size": "Größe",
48
-        "zoom": "Zoom",
49
-        "tools": "Werkzeuge",
50
-        "rectangle": "Rechteck",
51
-        "square": "Quadrat",
52
-        "circle": "Kreis",
53
-        "ellipse": "Ellipse",
54
-        "click_to_toggle": "Klicken Sie zum Umschalten",
55
-        "menu": "Menü",
56
-        "text": "Text",
57
-        "straight_line": "Gerade Linie",
58
-        "pencil": "Stift",
59
-        "click_to_zoom": "Klicke zum reinzoomen\nHalte die Umschalttaste und klicke zum herauszoomen",
60
-        "keyboard_shortcut": "Tastenkombination",
61
-        "mousewheel": "Mausrad",
62
-        "opacity": "Deckkraft",
63
-        "color": "Farbe",
64
-        "eraser": "Radierer",
65
-        "white-out": "Korrekturflüssigkeit",
66
-        "grid": "Gitter",
67
-        "index_title": "Wilkommen bei WBO!",
68
-        "introduction_paragraph": "WBO ist ein <a href=\"https://github.com/lovasoa/whitebophir\" title=\"Frei im Sinne von Redefreiheit, nicht Freibier. Diese Software wird unter der AGPL Lizenz veröffentlicht.\">freies und quelloffenes</a> kollaboratives Online-Whiteboard das vielen Nutzern erlaubt gleichzeitig auf einem großen virtuellen Whiteboard zu zeichnen. Das Whiteboard wird in Echtzeit für alle Nutzer aktualisiert und sein Inhalt wird gespeichert. Es kann für verschiedenste Anwendungen genutzt werden, z.B. Kunst, Unterhaltung, Design, Unterricht und Lehre.",
69
-        "share_instructions": "Um mit jemanden zusammen an einem Whiteboard zu arbeiten teile einfach die jeweilige URL.",
70
-        "public_board_description": " Das <b>öffentliche Whiteboard</b> kann von jedem geöffnet werden. Es ein fröhliches unorganisiertes Chaos wo du zusammen mit anonymen Fremden malen kannst. Alles dort ist vergänglich.",
71
-        "open_public_board": "Gehe zum öffentlichen Whiteboard",
72
-        "private_board_description": "Du kannst ein <b>privates Whiteboard</b> mit einem zufälligen Namen erstellen, welches man nur mit seinem Link öffnen kann. Benutze dies wenn du private Informationen teilen möchtest.",
73
-        "create_private_board": "Erstelle ein privates Whiteboard",
74
-        "named_private_board_description": "Du kannst auch ein <strong>privates Whiteboard mit Namen</strong> mit einer benutzerdefinierten URL erstellen. Alle die den Namen kennen, können darauf zugreifen.",
75
-        "board_name_placeholder": "Name des Whiteboards…",
76
-        "view_source": "Quellcode auf GitHub"
77
-    },
78
-    "es": {
79
-        "hand": "Mano",
80
-        "mover": "Desplazamiento",
81
-        "loading": "Cargando",
82
-        "tagline": "Una herramienta de dibujo colaborativa en línea gratuita y de código abierto. Esboce nuevas ideas en la pizarra colaborativa WBO !",
83
-        "configuration": "Configuration",
84
-        "collaborative_whiteboard": "Pizarra colaborativa",
85
-        "size": "Tamaño",
86
-        "zoom": "Zoom",
87
-        "tools": "Herramientas",
88
-        "rectangle": "Rectángulo",
89
-        "square": "Cuadrado",
90
-        "circle": "Círculo",
91
-        "ellipse": "Elipse",
92
-        "click_to_toggle": "haga clic para alternar",
93
-        "menu": "Menú",
94
-        "text": "Texto",
95
-        "straight_line": "Línea recta",
96
-        "pencil": "Lápiz",
97
-        "click_to_zoom": "Haga clic para acercar, Pulse [Mayús] y haga clic para alejar",
98
-        "keyboard_shortcut": "atajo de teclado",
99
-        "mousewheel": "Rueda del Ratón",
100
-        "opacity": "Opacidad",
101
-        "color": "Color",
102
-        "grid": "Cuadrícula",
103
-        "eraser": "Borrador",
104
-        "white-out": "Blanqueado",
105
-        "index_title": "¡Bienvenido a WBO!",
106
-        "introduction_paragraph": "WBO es una pizarra colaborativa en línea, <a href=\"https://github.com/lovasoa/whitebophir\" title=\"libre como la libertad de expresión, no libre como una cerveza gratis. Este software se lanza bajo la licencia AGPL\">libre y de Código abierto</a>, que permite a muchos usuarios dibujar simultáneamente en una gran pizarra virtual. La pizarra se actualiza en tiempo real para todos los usuarios conectados y su estado siempre es persistente. Se puede utilizar para muchos propósitos diferentes, incluyendo arte, entretenimiento, diseño y enseñanza.",
107
-        "share_instructions": "Para colaborar en un dibujo en tiempo real con alguien, simplemente envíele la <abbr title=\"un enlace tipo: https://wbo.ophir.dev/boards/el-codigo-de-tu-pizarra\">URL</abbr> de la pizarra que ya creaste.",
108
-        "public_board_description": "La <b>pizarra pública</b> es accesible para todos. Es un desastre felizmente desorganizado donde puedes reunirte con extraños anónimos. Todo lo que hay es efímero.",
109
-        "open_public_board": "Ir a la pizarra pública",
110
-        "private_board_description": "Puede crear una <b>pizarra privada</b> con un nombre aleatorio, al que solo se podrá acceder mediante su enlace. Úselo si desea compartir información privada.",
111
-        "create_private_board": "Crea una pizarra privada",
112
-        "named_private_board_description": "También puede crear una <strong>pizarra privada dándole un nombre aleatorio o un nombre especifico</strong>, una <abbr title=\"tipo: https://wbo.ophir.dev/boards/el-código-que-te-parezca\">URL personalizada</abbr>, que será accesible para todos aquellos que conozcan su nombre.",
113
-        "board_name_placeholder": "Nombre de la pizarra …",
114
-        "view_source": "Código fuente en GitHub"
115
-    },
116
-    "fr": {
117
-        "collaborative_whiteboard": "Tableau blanc collaboratif",
118
-        "loading": "Chargement",
119
-        "menu": "Menu",
120
-        "tools": "Outils",
121
-        "size": "Taille",
122
-        "color": "Couleur",
123
-        "opacity": "Opacité",
124
-        "pencil": "Crayon",
125
-        "text": "Texte",
126
-        "rectangle": "Rectangle",
127
-        "square": "Carré",
128
-        "circle": "Cercle",
129
-        "ellipse": "Ellipse",
130
-        "click_to_toggle": "cliquer pour changer",
131
-        "eraser": "Gomme",
132
-        "white-out": "Blanco",
133
-        "hand": "Main",
134
-        "mover": "Déplacer un élément",
135
-        "straight_line": "Ligne droite",
136
-        "grid": "Grille",
137
-        "keyboard_shortcut": "raccourci clavier",
138
-        "mousewheel": "molette de la souris",
139
-        "click_to_zoom": "Cliquez pour zoomer\nCliquez en maintenant la touche majuscule enfoncée pour dézoomer",
140
-        "tagline": "Logiciel libre pour collaborer en ligne sur un tableau blanc. Venez dessiner vos idées ensemble sur WBO !",
141
-        "index_title": "Bienvenue sur le tableau blanc collaboratif WBO !",
142
-        "introduction_paragraph": "WBO est un logiciel <a href=\"https://github.com/lovasoa/whitebophir\" title=\"voir le code sous license AGPL\">libre et gratuit</a> de dessin collaboratif en ligne qui permet à plusieurs utilisateurs de collaborer simultanément sur un tableau blanc. Le tableau est mis à jour en temps réel pour tous les utilisateurs connectés, et reste disponible après votre déconnexion. Il peut être utilisé notamment pour l'enseignement, l'art, le design ou juste pour s'amuser.",
143
-        "share_instructions": "Pour collaborer sur un tableau avec quelqu'un, envoyez-lui simplement son URL.",
144
-        "public_board_description": "Le <b>tableau anonyme</b> est accessible publiquement. C'est un joyeux bazar où vous pourrez rencontrer des étrangers anonymes, et dessiner avec eux. Tout ce que vous y inscrivez est éphémère.",
145
-        "open_public_board": "Ouvrir le tableau anonyme",
146
-        "private_board_description": "Vous pouvez créer un <b>tableau privé</b> dont le nom sera aléatoire. Il sera accessible uniquement à ceux avec qui vous partagerez son adresse. À utiliser lorsque vous voulez partager des informations confidentielles.",
147
-        "create_private_board": "Créer un tableau privé",
148
-        "named_private_board_description": "Vous pouvez aussi créer un <strong>tableau privé nommé</strong>, avec une adresse personnalisée, accessible à tous ceux qui en connaissent le nom.",
149
-        "board_name_placeholder": "Nom du tableau…",
150
-        "view_source": "Code source sur GitHub"
151
-    },
152
-    "hu": {
153
-        "hand": "Kéz",
154
-        "loading": "Betöltés folyamatban",
155
-        "tagline": "Ingyenes és nyílt forráskódú online együttműködési rajzoló eszköz. Vázoljon fel új ötleteket a WBO-n!",
156
-        "configuration": "Beállítások",
157
-        "collaborative_whiteboard": "Együttműködési tábla",
158
-        "size": "Méret",
159
-        "zoom": "Nagyítás/kicsinyítés",
160
-        "tools": "Eszközök",
161
-        "rectangle": "Téglalap",
162
-        "square": "Négyzet",
163
-        "circle": "Kör",
164
-        "ellipse": "Ellipszis",
165
-        "click_to_toggle": "kattintson ide a be- és kikapcsolásához",
166
-        "menu": "Menü",
167
-        "text": "Szöveg",
168
-        "mover": "Mozgató",
169
-        "straight_line": "Egyenes vonal",
170
-        "pencil": "Ceruza",
171
-        "grid": "Rács",
172
-        "click_to_zoom": "Kattintson ide a nagyításhoz.\nShift + kattintás a kicsinyítéshez",
173
-        "keyboard_shortcut": "billentyűparancs",
174
-        "mousewheel": "egérkerék",
175
-        "opacity": "Átlátszatlanság",
176
-        "color": "Szín",
177
-        "eraser": "Radír",
178
-        "White-out": "Lefedő",
179
-        "index_title": "Isten hozta a WBO ingyenes online tábláján!",
180
-        "introduction_paragraph": "A WBO egy <a href=\"https://github.com/lovasoa/whitebophir\" title=\"Ingyenes, mint a szabad beszédben, nem ingyenes sör. Ez a szoftver a AGPL licenc alapján kerül kiadásra.\">ingyenes és nyílt forráskódú</a> online együttműködési tábla, amely lehetővé teszi sok felhasználó számára, hogy egyidejűleg rajzoljon egy nagy virtuális táblán. Az alaplap valós időben frissül az összes csatlakoztatott felhasználó számára és állapota állandó. Különböző célokra felhasználható, beleértve a művészetet, a szórakoztatást, a tervezést és a tanítást.",
181
-        "share_instructions": "Ha valakivel valós időben szeretne együttműködni egy rajzon, küldje el neki az URL-jét.",
182
-        "public_board_description": "A <b>nyilvános tábla</b> mindenki számára elérhető. Ez egy boldog szervezetlen rendetlenség, ahol találkozhat a névtelen ismeretlenek és dolgozhat együtt. Minden ott rövid távú.",
183
-        "open_public_board": "Nyilvános tábla megnyitása",
184
-        "private_board_description": "Készíthet egy <b>saját táblát</b> véletlenszerű névvel, amely csak a linkjével lesz elérhető. Használja ezt, ha személyes adatokat szeretne megosztani.",
185
-        "create_private_board": "Saját tábla létrehozása",
186
-        "named_private_board_description": "Készíthet egy <strong>saját nevű táblát</strong> is, egyéni URL-címmel, amely mindenki számára elérhető, aki ismeri a nevét.",
187
-        "board_name_placeholder": "Tábla neve…",
188
-        "view_source": "Forráskód a GitHub-on"
189
-    },
190
-    "it": {
191
-        "hand": "Mano",
192
-        "mover": "Spostamento",
193
-        "loading": "Caricamento in corso",
194
-        "tagline": "Uno strumento collaborativo per disegnare online, gratuito e open source. Disegniamo insieme nuove idee su WBO!",
195
-        "configuration": "Configurazione",
196
-        "collaborative_whiteboard": "Lavagna collaborativa",
197
-        "size": "Dimensione",
198
-        "zoom": "Zoom",
199
-        "tools": "Strumenti",
200
-        "rectangle": "Rettangolo",
201
-        "square": "Quadrato",
202
-        "circle": "Cerchio",
203
-        "ellipse": "Ellisse",
204
-        "click_to_toggle": "Fai Clic per attivare",
205
-        "menu": "Menu",
206
-        "text": "Testo",
207
-        "straight_line": "Linea retta",
208
-        "pencil": "Matita",
209
-        "click_to_zoom": "Fai clic per ingrandire \nPremi [MAIUSC] e fai clic per ridurre",
210
-        "keyboard_shortcut": "scorciatoia da tastiera",
211
-        "mousewheel": "rotella del mouse",
212
-        "opacity": "Opacità",
213
-        "color": "Colore",
214
-        "eraser": "Gomma",
215
-        "grid": "Griglia",
216
-        "white-out": "Bianchetto",
217
-        "index_title": "Benvenuti a WBO!",
218
-        "introduction_paragraph": "WBO è una lavagna collaborativa online <a href=\"https://github.com/lovasoa/whitebophir\" title=\"gratuita come é gratuita la libertà di espressione, no come un boccale di birra gratis. Questo software è rilasciato sotto licenza AGPL\">gratuita e open source</a> che consente a molti utenti di disegnare contemporaneamente su una grande lavagna virtuale. La lavagna viene aggiornata in tempo reale per tutti gli utenti connessi e lo stato è sempre persistente. Può essere utilizzato per molti scopi diversi, tra cui arte, intrattenimento, design e insegnamento.",
219
-        "share_instructions": "Per collaborare a un disegno in tempo reale con qualcuno, basta condividere l'<abbr title=\"un link tipo https://wbo.ophir.dev/boards/il-codice-della-tua-lavagna\">URL della lavagna</abbr>.",
220
-        "public_board_description": "La <b>lavagna pubblica</b> è accessibile a tutti. È un disastro felicemente disorganizzato dove puoi incontrare sconosciuti anonimi e disegnare insieme. Tutto in questo spazio è effimero.",
221
-        "open_public_board": "Vai alla lavagna pubblica",
222
-        "private_board_description": "Puoi creare una <b>lavagna privata</b> con un nome casuale, che sarà accessibile solo dal suo URL. Usalo se vuoi condividere informazioni private.",
223
-        "create_private_board": "Crea una lavagna privata",
224
-        "named_private_board_description": "Puoi anche creare una <strong>lavagna privata con un nome creato da te</strong>, con un URL personalizzato, che sarà accessibile a tutti coloro che ne conoscono il nome.",
225
-        "board_name_placeholder": "Nome della lavagna…",
226
-        "view_source": "Codice sorgente su GitHub"
227
-    },
228
-    "ja": {
229
-        "hand": "手のひらツール",
230
-        "mover": "変位",
231
-        "loading": "読み込み中",
232
-        "tagline": "無料でオープンソースの協同作業できるオンラインホワイトボード。WBOでアイディアを共有しましょう!",
233
-        "configuration": "設定",
234
-        "collaborative_whiteboard": "協同作業できるオンラインホワイトボード",
235
-        "size": "サイズ",
236
-        "zoom": "拡大・縮小",
237
-        "tools": "ツール",
238
-        "rectangle": "矩形",
239
-        "square": "正方形",
240
-        "menu": "メニュー",
241
-        "text": "テキスト",
242
-        "straight_line": "直線",
243
-        "pencil": "ペン",
244
-        "circle": "サークル",
245
-        "ellipse": "楕円",
246
-        "click_to_toggle": "クリックして切り替えます",
247
-        "click_to_zoom": "クリックで拡大\nシフトを押しながらクリックで縮小",
248
-        "keyboard_shortcut": "キーボードショートカット",
249
-        "mousewheel": "ねずみ車",
250
-        "opacity": "透明度",
251
-        "color": "色",
252
-        "eraser": "消去",
253
-        "grid": "グリッド",
254
-        "white-out": "修正液",
255
-        "index_title": "WBOへようこそ!",
256
-        "introduction_paragraph": "WBOは<a href=\"https://github.com/lovasoa/whitebophir\" title=\"ビール飲み放題ではなく言論の自由。このソフトウェアはAGPLライセンスで公開しています。\">無料かつオープンソース</a>の協同作業できるオンラインホワイトボードです。多くのユーザーが大きな仮想ホワイトボードに図などを書くことができ、接続しているすべてのユーザーの更新をリアルタイムに反映され、その状態を常に保存します。これはアート、エンタテインメント、デザインや教育など、様々な用途で使用できます。",
257
-        "share_instructions": "URLを送るだけで、リアルタイムな共同作業ができます。",
258
-        "public_board_description": "<b>公開ボード</b>は、WBOにアクセスできる人であれば誰でも参加できますが、これは一時的な用途に向いています。",
259
-        "open_public_board": "公開ボードを作成する",
260
-        "private_board_description": "プライベートな情報を共有したいときは、ランダムな名前を持つ、<b>プライベートボード</b>を作成できます。このボードはリンクを知っている人がアクセスできます。",
261
-        "create_private_board": "プライベートボードを作成する",
262
-        "named_private_board_description": "<strong>名前つきプライベートボード</strong>を作ることもできます。このボードは名前かURLを知っている人だけがアクセスできます。",
263
-        "board_name_placeholder": "ボードの名前",
264
-        "view_source": "GitHubでソースコード見る"
265
-    },
266
-    "ru": {
267
-        "collaborative_whiteboard": "Онлайн доска для совместного рисования",
268
-        "loading": "Загрузка",
269
-        "menu": "Панель",
270
-        "tools": "Инструменты",
271
-        "size": "Размер",
272
-        "color": "Цвет",
273
-        "opacity": "Непрозрачность",
274
-        "pencil": "Карандаш",
275
-        "text": "Текст",
276
-        "eraser": "Ластик",
277
-        "white-out": "Корректор",
278
-        "hand": "Рука",
279
-        "straight_line": "Прямая линия",
280
-        "rectangle": "Прямоугольник",
281
-        "square": "Квадрат",
282
-        "circle": "Круг",
283
-        "ellipse": "Эллипс",
284
-        "click_to_toggle": "нажмите, чтобы переключиться",
285
-        "zoom": "Лупа",
286
-        "mover": "Сдвинуть объект",
287
-        "grid": "Сетка",
288
-        "configuration": "Настройки",
289
-        "keyboard_shortcut": "горячая клавиша",
290
-        "mousewheel": "колёсико мыши ",
291
-        "tagline": "Бесплатная и открытая доска для совместной работы в интернете. Рисуете свои идеи вместе в WBO !",
292
-        "index_title": "Добро пожаловать на WBO !",
293
-        "introduction_paragraph": "WBO это бесплатная и <a href=\"https://github.com/lovasoa/whitebophir\" title=\"открытый исходный код\">открытая</a> виртуальная онлайн доска, позволяющая рисовать одновременно сразу нескольким пользователям. С WBO вы сможете рисовать, работать с коллегами над будущими проектами, проводить онлайн встречи, подкреплять ваши обучающие материалы и даже пробовать себя в дизайне. WBO доступен без регистрации.",
294
-        "share_instructions": "Использовать платформу для совместного творчества очень просто. Достаточно поделиться ссылкой URL с теми, кто хочет с вами порисовать. Как только они получат URL они смогут к вам присоединиться.",
295
-        "public_board_description": "<b>Анонимная доска</b> позволяет рисовать вместе онлайн. Используйте этот формат для творчества и кучи разных идей. Вдохновляйтесь уже существующими рисунками, дополняйте их и создавайте совместные работы с другими посетителями. Любой пользователь может удалять уже существующие элементы и рисунки.",
296
-        "open_public_board": "Открыть анонимную доску",
297
-        "private_board_description": "<b>Приватная доска</b> обладает тем же функционалом, что и анонимная доска. Разница в том, что приватную доску могут видеть только те пользователи, у которых на нее есть ссылка. Используйте приватную онлайн доску в рабочих целях, проводите онлайн уроки, рисуйте с детьми или друзьями. Другие пользователи не смогут удалять или менять ваши работы без вашего разрешения.",
298
-        "create_private_board": "Создать приватную доску",
299
-        "named_private_board_description": "Также можно создать <b>именную приватную доску</b> которая будет доступна всем тем, кому вы отправили название вашей доски.",
300
-        "board_name_placeholder": "Название доски",
301
-        "view_source": "Исходный код на GitHub"
302
-    },
303
-    "uk": {
304
-        "hand": "Рука",
305
-        "loading": "Завантаження",
306
-        "tagline": "Безкоштовний онлайн засіб для спільного малювання з відкритим кодом. Разом накресліть нові ідеї у WBO!",
307
-        "configuration": "Налаштування",
308
-        "collaborative_whiteboard": "Онлайн дошка для спільної роботи",
309
-        "size": "Розмір",
310
-        "zoom": "Лупа",
311
-        "tools": "Засоби",
312
-        "rectangle": "Прямокутник",
313
-        "square": "Квадрат",
314
-        "circle": "Коло",
315
-        "ellipse": "Еліпс",
316
-        "click_to_toggle": "клацніть, щоб перемкнути",
317
-        "menu": "Меню",
318
-        "text": "Текст",
319
-        "mover": "Пересунути",
320
-        "straight_line": "Пряма лінія",
321
-        "pencil": "Олівець",
322
-        "grid": "Сітка",
323
-        "click_to_zoom": "Клацніть для збільшення\nНатисніть shift та клацініть для зменшення",
324
-        "keyboard_shortcut": "швидкі клавіші",
325
-        "mousewheel": "коліщатко миші",
326
-        "opacity": "Прозорість",
327
-        "color": "Колір",
328
-        "eraser": "Ґумка",
329
-        "White-out": "Коректор",
330
-        "index_title": "Вітаємо у відкритій онлайн дошці WBO!",
331
-        "introduction_paragraph": "WBO це <a href=\"https://github.com/lovasoa/whitebophir\" title=\"Free as in free speech, not free beer. This software is released under the AGPL license\">безкоштовна та відкрита</a> онлайн дошка для спільної роботи, яка дозволяє багатьом користувачам одночасно писати на великій віртуальній дошці. Дошка оновлюється в реальному часі для всіх приєднаних користувачів, та її стан постійно зберігається. Вона може застосовуватись з різною метою, включаючи мистецтво, розваги, дизайн та навчання.",
332
-        "share_instructions": "Для спільної роботи на дошці досить повідомити іншій особі адресу URL.",
333
-        "public_board_description": "<b>Публічна дошка</b> доступна для всіх. Там панує повний безлад, де Ви можете зустріти анонімних незнайомців та малювати разом. Тут все ефемерне.",
334
-        "open_public_board": "Перейти до публічної дошки",
335
-        "private_board_description": "Ви можете створити <b>особисту дошку</b> з випадковою назвою, яка буде доступна лише за відповідним посиланням. Користуйтесь нею, якшо Вам потрібно ділитись особистою інформацією.",
336
-        "create_private_board": "Створити особисту дошку",
337
-        "named_private_board_description": "Ви також можете створити <strong>особисту дошку з назвою</strong>, з власним URL, яка буде доступна всім, хто знає її назву.",
338
-        "board_name_placeholder": "Назва дошки…",
339
-        "view_source": "Вихідний код на GitHub"
340
-    },
341
-    "zh": {
342
-        "collaborative_whiteboard": "在线协作式白板",
343
-        "loading": "载入中",
344
-        "menu": "目录",
345
-        "tools": "工具",
346
-        "size": "尺寸",
347
-        "color": "颜色",
348
-        "opacity": "不透明度",
349
-        "pencil": "铅笔",
350
-        "rectangle": "矩形",
351
-        "square": "正方形",
352
-        "circle": "圈",
353
-        "ellipse": "椭圆",
354
-        "click_to_toggle": "单击以切换",
355
-        "zoom": "放大",
356
-        "text": "文本",
357
-        "eraser": "橡皮",
358
-        "white-out": "修正液",
359
-        "hand": "移动",
360
-        "mover": "平移",
361
-        "straight_line": "直线",
362
-        "configuration": "刷设置",
363
-        "keyboard_shortcut": "键盘快捷键",
364
-        "mousewheel": "鼠标轮",
365
-        "grid": "格",
366
-        "click_to_zoom": "点击放大。\n保持班次并单击缩小。",
367
-        "tagline": "打开即用的免费在线白板工具",
368
-        "index_title": "欢迎来到 WBO!",
369
-        "introduction_paragraph": " WBO是一<a href=\"https://github.com/lovasoa/whitebophir\">个免费的</a>、开源的在线协作白板,它允许许多用户同时在一个大型虚拟板上画图。该板对所有连接的用户实时更新,并且始终可用。它可以用于许多不同的目的,包括艺术、娱乐、设计和教学。",
370
-        "share_instructions": "要与某人实时协作绘制图形,只需向他们发送白板的URL。",
371
-        "public_board_description": "每个人都可以使用公共白板。这是一个令人愉快的混乱的地方,你可以会见匿名陌生人,并在一起。那里的一切都是短暂的。",
372
-        "open_public_board": "进入公共白板",
373
-        "private_board_description": "您可以创建一个带有随机名称的私有白板,该白板只能通过其链接访问。如果要共享私人信息,请使用此选项。",
374
-        "create_private_board": "创建私人白板",
375
-        "named_private_board_description": "您还可以创建一个命名的私有白板,它有一个自定义的URL,所有知道它名字的人都可以访问它。",
376
-        "board_name_placeholder": "白板名称",
377
-        "view_source": "GitHub上的源代码"
378
-    },
379
- "vn": {
380
-        "hand": "Tay",
381
-        "loading": "Đang tải",
382
-        "tagline": "Một công cụ vẽ cộng tác trực tuyến miễn phí và mã nguồn mở. Cùng nhau phác thảo những ý tưởng mới trên WBO!",
383
-        "configuration": "Cấu hình",
384
-        "collaborative_whiteboard": "Bảng trắng cộng tác",
385
-        "size": "Size",
386
-        "zoom": "Zoom",
387
-        "tools": "Công cụ",
388
-        "rectangle": "Chữ nhật",
389
-        "square": "Vuông",
390
-        "circle": "Tròn",
391
-        "ellipse": "Hình elip",
392
-        "click_to_toggle": "Bật/tắt",
393
-        "menu": "Menu",
394
-        "text": "Text",
395
-        "mover": "Di chuyển",
396
-        "straight_line": "Đường thẳng",
397
-        "pencil": "Gạch ngang",
398
-        "grid": "Grid",
399
-        "click_to_zoom": "Nhấp để phóng to \n Nhấn shift và nhấp để thu nhỏ",
400
-        "keyboard_shortcut": "Phím tắt",
401
-        "mousewheel": "Lăn chuột",
402
-        "opacity": "Độ Mờ",
403
-        "color": "Màu",
404
-        "eraser": "Tẩy",
405
-        "White-out": "Trắng",
406
-        "index_title": "Chào mừng bạn đến với WBO bảng trắng trực tuyến miễn phí!",
407
-        "introduction_paragraph": "WBO là một <a href=\"https://github.com/lovasoa/whitebophir\" title=\"Miễn phí như trong tự do ngôn luận, không phải bia miễn phí. Phần mềm này được phát hành theo giấy phép AGPL\">Miễn phí và open-source</a> cho phép nhiều người dùng vẽ đồng thời trên một bảng ảo lớn. Bảng này được cập nhật theo thời gian thực cho tất cả người dùng được kết nối và trạng thái luôn tồn tại. Nó có thể được sử dụng cho nhiều mục đích khác nhau, bao gồm nghệ thuật, giải trí, thiết kế và giảng dạy.",
408
-        "share_instructions": "Để cộng tác trên một bản vẽ trong thời gian thực với ai đó, chỉ cần gửi cho họ URL của bản vẽ đó.",
409
-        "public_board_description": "Tất cả mọi người đều có thể truy cập <b> bảng công khai </b>. Đó là một mớ hỗn độn vô tổ chức vui vẻ, nơi bạn có thể gặp gỡ những người lạ vô danh và cùng nhau vẽ. Mọi thứ ở đó là phù du.",
410
-        "open_public_board": "Đi tới bảng công khai",
411
-        "private_board_description": "Bạn có thể tạo một <b> bảng riêng </b> với một tên ngẫu nhiên, chỉ có thể truy cập được bằng liên kết của nó. Sử dụng cái này nếu bạn muốn chia sẻ thông tin cá nhân.",
412
-        "create_private_board": "Tạo một bảng riêng",
413
-        "named_private_board_description": "Bạn cũng có thể tạo <strong> bảng riêng được đặt tên </strong>, với URL tùy chỉnh, tất cả những người biết tên của nó đều có thể truy cập được.",
414
-        "board_name_placeholder": "Tên bản …",
415
-        "view_source": "Source code on GitHub"
416
-    }
2
+  "en": {
3
+    "hand": "Hand",
4
+    "loading": "Loading",
5
+    "tagline": "A free and open-source online collaborative drawing tool. Sketch new ideas together on WBO!",
6
+    "configuration": "Configuration",
7
+    "collaborative_whiteboard": "Collaborative whiteboard",
8
+    "size": "Size",
9
+    "zoom": "Zoom",
10
+    "tools": "Tools",
11
+    "rectangle": "Rectangle",
12
+    "square": "Square",
13
+    "circle": "Circle",
14
+    "ellipse": "Ellipse",
15
+    "click_to_toggle": "click to toggle",
16
+    "menu": "Menu",
17
+    "text": "Text",
18
+    "mover": "Mover",
19
+    "straight_line": "Straight line",
20
+    "pencil": "Pencil",
21
+    "grid": "Grid",
22
+    "click_to_zoom": "Click to zoom in\nPress shift and click to zoom out",
23
+    "keyboard_shortcut": "keyboard shortcut",
24
+    "mousewheel": "mouse wheel",
25
+    "opacity": "Opacity",
26
+    "color": "Color",
27
+    "eraser": "Eraser",
28
+    "White-out": "White-out",
29
+    "index_title": "Welcome to the free online whiteboard WBO!",
30
+    "introduction_paragraph": "WBO is a <a href=\"https://github.com/lovasoa/whitebophir\" title=\"Free as in free speech, not free beer. This software is released under the AGPL license\">free and open-source</a> online collaborative whiteboard that allows many users to draw simultaneously on a large virtual board. The board is updated in real time for all connected users, and its state is always persisted. It can be used for many different purposes, including art, entertainment, design and teaching.",
31
+    "share_instructions": "To collaborate on a drawing in real time with someone, just send them its URL.",
32
+    "public_board_description": "The <b>public board</b> is accessible to everyone. It is a happily disorganized mess where you can meet with anonymous strangers and draw together. Everything there is ephemeral.",
33
+    "open_public_board": "Go to the public board",
34
+    "private_board_description": "You can create a <b>private board</b> with a random name, that will be accessible only by its link. Use this if you want to share private information.",
35
+    "create_private_board": "Create a private board",
36
+    "named_private_board_description": "You can also create a <strong>named private board</strong>, with a custom URL, that will be accessible to all those who know its name.",
37
+    "board_name_placeholder": "Name of the board…",
38
+    "view_source": "Source code on GitHub"
39
+  },
40
+  "de": {
41
+    "hand": "Hand",
42
+    "mover": "Verschiebung",
43
+    "loading": "Lädt",
44
+    "tagline": "Ein freies quelloffenes kollaboratives Zeichentool. Zeichnet eure Ideen zusammen auf WBO!",
45
+    "configuration": "Konfiguration",
46
+    "collaborative_whiteboard": "Kollaboratives Whiteboard",
47
+    "size": "Größe",
48
+    "zoom": "Zoom",
49
+    "tools": "Werkzeuge",
50
+    "rectangle": "Rechteck",
51
+    "square": "Quadrat",
52
+    "circle": "Kreis",
53
+    "ellipse": "Ellipse",
54
+    "click_to_toggle": "Klicken Sie zum Umschalten",
55
+    "menu": "Menü",
56
+    "text": "Text",
57
+    "straight_line": "Gerade Linie",
58
+    "pencil": "Stift",
59
+    "click_to_zoom": "Klicke zum reinzoomen\nHalte die Umschalttaste und klicke zum herauszoomen",
60
+    "keyboard_shortcut": "Tastenkombination",
61
+    "mousewheel": "Mausrad",
62
+    "opacity": "Deckkraft",
63
+    "color": "Farbe",
64
+    "eraser": "Radierer",
65
+    "white-out": "Korrekturflüssigkeit",
66
+    "grid": "Gitter",
67
+    "index_title": "Wilkommen bei WBO!",
68
+    "introduction_paragraph": "WBO ist ein <a href=\"https://github.com/lovasoa/whitebophir\" title=\"Frei im Sinne von Redefreiheit, nicht Freibier. Diese Software wird unter der AGPL Lizenz veröffentlicht.\">freies und quelloffenes</a> kollaboratives Online-Whiteboard das vielen Nutzern erlaubt gleichzeitig auf einem großen virtuellen Whiteboard zu zeichnen. Das Whiteboard wird in Echtzeit für alle Nutzer aktualisiert und sein Inhalt wird gespeichert. Es kann für verschiedenste Anwendungen genutzt werden, z.B. Kunst, Unterhaltung, Design, Unterricht und Lehre.",
69
+    "share_instructions": "Um mit jemanden zusammen an einem Whiteboard zu arbeiten teile einfach die jeweilige URL.",
70
+    "public_board_description": " Das <b>öffentliche Whiteboard</b> kann von jedem geöffnet werden. Es ein fröhliches unorganisiertes Chaos wo du zusammen mit anonymen Fremden malen kannst. Alles dort ist vergänglich.",
71
+    "open_public_board": "Gehe zum öffentlichen Whiteboard",
72
+    "private_board_description": "Du kannst ein <b>privates Whiteboard</b> mit einem zufälligen Namen erstellen, welches man nur mit seinem Link öffnen kann. Benutze dies wenn du private Informationen teilen möchtest.",
73
+    "create_private_board": "Erstelle ein privates Whiteboard",
74
+    "named_private_board_description": "Du kannst auch ein <strong>privates Whiteboard mit Namen</strong> mit einer benutzerdefinierten URL erstellen. Alle die den Namen kennen, können darauf zugreifen.",
75
+    "board_name_placeholder": "Name des Whiteboards…",
76
+    "view_source": "Quellcode auf GitHub"
77
+  },
78
+  "es": {
79
+    "hand": "Mano",
80
+    "mover": "Desplazamiento",
81
+    "loading": "Cargando",
82
+    "tagline": "Una herramienta de dibujo colaborativa en línea gratuita y de código abierto. Esboce nuevas ideas en la pizarra colaborativa WBO !",
83
+    "configuration": "Configuration",
84
+    "collaborative_whiteboard": "Pizarra colaborativa",
85
+    "size": "Tamaño",
86
+    "zoom": "Zoom",
87
+    "tools": "Herramientas",
88
+    "rectangle": "Rectángulo",
89
+    "square": "Cuadrado",
90
+    "circle": "Círculo",
91
+    "ellipse": "Elipse",
92
+    "click_to_toggle": "haga clic para alternar",
93
+    "menu": "Menú",
94
+    "text": "Texto",
95
+    "straight_line": "Línea recta",
96
+    "pencil": "Lápiz",
97
+    "click_to_zoom": "Haga clic para acercar, Pulse [Mayús] y haga clic para alejar",
98
+    "keyboard_shortcut": "atajo de teclado",
99
+    "mousewheel": "Rueda del Ratón",
100
+    "opacity": "Opacidad",
101
+    "color": "Color",
102
+    "grid": "Cuadrícula",
103
+    "eraser": "Borrador",
104
+    "white-out": "Blanqueado",
105
+    "index_title": "¡Bienvenido a WBO!",
106
+    "introduction_paragraph": "WBO es una pizarra colaborativa en línea, <a href=\"https://github.com/lovasoa/whitebophir\" title=\"libre como la libertad de expresión, no libre como una cerveza gratis. Este software se lanza bajo la licencia AGPL\">libre y de Código abierto</a>, que permite a muchos usuarios dibujar simultáneamente en una gran pizarra virtual. La pizarra se actualiza en tiempo real para todos los usuarios conectados y su estado siempre es persistente. Se puede utilizar para muchos propósitos diferentes, incluyendo arte, entretenimiento, diseño y enseñanza.",
107
+    "share_instructions": "Para colaborar en un dibujo en tiempo real con alguien, simplemente envíele la <abbr title=\"un enlace tipo: https://wbo.ophir.dev/boards/el-codigo-de-tu-pizarra\">URL</abbr> de la pizarra que ya creaste.",
108
+    "public_board_description": "La <b>pizarra pública</b> es accesible para todos. Es un desastre felizmente desorganizado donde puedes reunirte con extraños anónimos. Todo lo que hay es efímero.",
109
+    "open_public_board": "Ir a la pizarra pública",
110
+    "private_board_description": "Puede crear una <b>pizarra privada</b> con un nombre aleatorio, al que solo se podrá acceder mediante su enlace. Úselo si desea compartir información privada.",
111
+    "create_private_board": "Crea una pizarra privada",
112
+    "named_private_board_description": "También puede crear una <strong>pizarra privada dándole un nombre aleatorio o un nombre especifico</strong>, una <abbr title=\"tipo: https://wbo.ophir.dev/boards/el-código-que-te-parezca\">URL personalizada</abbr>, que será accesible para todos aquellos que conozcan su nombre.",
113
+    "board_name_placeholder": "Nombre de la pizarra …",
114
+    "view_source": "Código fuente en GitHub"
115
+  },
116
+  "fr": {
117
+    "collaborative_whiteboard": "Tableau blanc collaboratif",
118
+    "loading": "Chargement",
119
+    "menu": "Menu",
120
+    "tools": "Outils",
121
+    "size": "Taille",
122
+    "color": "Couleur",
123
+    "opacity": "Opacité",
124
+    "pencil": "Crayon",
125
+    "text": "Texte",
126
+    "rectangle": "Rectangle",
127
+    "square": "Carré",
128
+    "circle": "Cercle",
129
+    "ellipse": "Ellipse",
130
+    "click_to_toggle": "cliquer pour changer",
131
+    "eraser": "Gomme",
132
+    "white-out": "Blanco",
133
+    "hand": "Main",
134
+    "mover": "Déplacer un élément",
135
+    "straight_line": "Ligne droite",
136
+    "grid": "Grille",
137
+    "keyboard_shortcut": "raccourci clavier",
138
+    "mousewheel": "molette de la souris",
139
+    "click_to_zoom": "Cliquez pour zoomer\nCliquez en maintenant la touche majuscule enfoncée pour dézoomer",
140
+    "tagline": "Logiciel libre pour collaborer en ligne sur un tableau blanc. Venez dessiner vos idées ensemble sur WBO !",
141
+    "index_title": "Bienvenue sur le tableau blanc collaboratif WBO !",
142
+    "introduction_paragraph": "WBO est un logiciel <a href=\"https://github.com/lovasoa/whitebophir\" title=\"voir le code sous license AGPL\">libre et gratuit</a> de dessin collaboratif en ligne qui permet à plusieurs utilisateurs de collaborer simultanément sur un tableau blanc. Le tableau est mis à jour en temps réel pour tous les utilisateurs connectés, et reste disponible après votre déconnexion. Il peut être utilisé notamment pour l'enseignement, l'art, le design ou juste pour s'amuser.",
143
+    "share_instructions": "Pour collaborer sur un tableau avec quelqu'un, envoyez-lui simplement son URL.",
144
+    "public_board_description": "Le <b>tableau anonyme</b> est accessible publiquement. C'est un joyeux bazar où vous pourrez rencontrer des étrangers anonymes, et dessiner avec eux. Tout ce que vous y inscrivez est éphémère.",
145
+    "open_public_board": "Ouvrir le tableau anonyme",
146
+    "private_board_description": "Vous pouvez créer un <b>tableau privé</b> dont le nom sera aléatoire. Il sera accessible uniquement à ceux avec qui vous partagerez son adresse. À utiliser lorsque vous voulez partager des informations confidentielles.",
147
+    "create_private_board": "Créer un tableau privé",
148
+    "named_private_board_description": "Vous pouvez aussi créer un <strong>tableau privé nommé</strong>, avec une adresse personnalisée, accessible à tous ceux qui en connaissent le nom.",
149
+    "board_name_placeholder": "Nom du tableau…",
150
+    "view_source": "Code source sur GitHub"
151
+  },
152
+  "hu": {
153
+    "hand": "Kéz",
154
+    "loading": "Betöltés folyamatban",
155
+    "tagline": "Ingyenes és nyílt forráskódú online együttműködési rajzoló eszköz. Vázoljon fel új ötleteket a WBO-n!",
156
+    "configuration": "Beállítások",
157
+    "collaborative_whiteboard": "Együttműködési tábla",
158
+    "size": "Méret",
159
+    "zoom": "Nagyítás/kicsinyítés",
160
+    "tools": "Eszközök",
161
+    "rectangle": "Téglalap",
162
+    "square": "Négyzet",
163
+    "circle": "Kör",
164
+    "ellipse": "Ellipszis",
165
+    "click_to_toggle": "kattintson ide a be- és kikapcsolásához",
166
+    "menu": "Menü",
167
+    "text": "Szöveg",
168
+    "mover": "Mozgató",
169
+    "straight_line": "Egyenes vonal",
170
+    "pencil": "Ceruza",
171
+    "grid": "Rács",
172
+    "click_to_zoom": "Kattintson ide a nagyításhoz.\nShift + kattintás a kicsinyítéshez",
173
+    "keyboard_shortcut": "billentyűparancs",
174
+    "mousewheel": "egérkerék",
175
+    "opacity": "Átlátszatlanság",
176
+    "color": "Szín",
177
+    "eraser": "Radír",
178
+    "White-out": "Lefedő",
179
+    "index_title": "Isten hozta a WBO ingyenes online tábláján!",
180
+    "introduction_paragraph": "A WBO egy <a href=\"https://github.com/lovasoa/whitebophir\" title=\"Ingyenes, mint a szabad beszédben, nem ingyenes sör. Ez a szoftver a AGPL licenc alapján kerül kiadásra.\">ingyenes és nyílt forráskódú</a> online együttműködési tábla, amely lehetővé teszi sok felhasználó számára, hogy egyidejűleg rajzoljon egy nagy virtuális táblán. Az alaplap valós időben frissül az összes csatlakoztatott felhasználó számára és állapota állandó. Különböző célokra felhasználható, beleértve a művészetet, a szórakoztatást, a tervezést és a tanítást.",
181
+    "share_instructions": "Ha valakivel valós időben szeretne együttműködni egy rajzon, küldje el neki az URL-jét.",
182
+    "public_board_description": "A <b>nyilvános tábla</b> mindenki számára elérhető. Ez egy boldog szervezetlen rendetlenség, ahol találkozhat a névtelen ismeretlenek és dolgozhat együtt. Minden ott rövid távú.",
183
+    "open_public_board": "Nyilvános tábla megnyitása",
184
+    "private_board_description": "Készíthet egy <b>saját táblát</b> véletlenszerű névvel, amely csak a linkjével lesz elérhető. Használja ezt, ha személyes adatokat szeretne megosztani.",
185
+    "create_private_board": "Saját tábla létrehozása",
186
+    "named_private_board_description": "Készíthet egy <strong>saját nevű táblát</strong> is, egyéni URL-címmel, amely mindenki számára elérhető, aki ismeri a nevét.",
187
+    "board_name_placeholder": "Tábla neve…",
188
+    "view_source": "Forráskód a GitHub-on"
189
+  },
190
+  "it": {
191
+    "hand": "Mano",
192
+    "mover": "Spostamento",
193
+    "loading": "Caricamento in corso",
194
+    "tagline": "Uno strumento collaborativo per disegnare online, gratuito e open source. Disegniamo insieme nuove idee su WBO!",
195
+    "configuration": "Configurazione",
196
+    "collaborative_whiteboard": "Lavagna collaborativa",
197
+    "size": "Dimensione",
198
+    "zoom": "Zoom",
199
+    "tools": "Strumenti",
200
+    "rectangle": "Rettangolo",
201
+    "square": "Quadrato",
202
+    "circle": "Cerchio",
203
+    "ellipse": "Ellisse",
204
+    "click_to_toggle": "Fai Clic per attivare",
205
+    "menu": "Menu",
206
+    "text": "Testo",
207
+    "straight_line": "Linea retta",
208
+    "pencil": "Matita",
209
+    "click_to_zoom": "Fai clic per ingrandire \nPremi [MAIUSC] e fai clic per ridurre",
210
+    "keyboard_shortcut": "scorciatoia da tastiera",
211
+    "mousewheel": "rotella del mouse",
212
+    "opacity": "Opacità",
213
+    "color": "Colore",
214
+    "eraser": "Gomma",
215
+    "grid": "Griglia",
216
+    "white-out": "Bianchetto",
217
+    "index_title": "Benvenuti a WBO!",
218
+    "introduction_paragraph": "WBO è una lavagna collaborativa online <a href=\"https://github.com/lovasoa/whitebophir\" title=\"gratuita come é gratuita la libertà di espressione, no come un boccale di birra gratis. Questo software è rilasciato sotto licenza AGPL\">gratuita e open source</a> che consente a molti utenti di disegnare contemporaneamente su una grande lavagna virtuale. La lavagna viene aggiornata in tempo reale per tutti gli utenti connessi e lo stato è sempre persistente. Può essere utilizzato per molti scopi diversi, tra cui arte, intrattenimento, design e insegnamento.",
219
+    "share_instructions": "Per collaborare a un disegno in tempo reale con qualcuno, basta condividere l'<abbr title=\"un link tipo https://wbo.ophir.dev/boards/il-codice-della-tua-lavagna\">URL della lavagna</abbr>.",
220
+    "public_board_description": "La <b>lavagna pubblica</b> è accessibile a tutti. È un disastro felicemente disorganizzato dove puoi incontrare sconosciuti anonimi e disegnare insieme. Tutto in questo spazio è effimero.",
221
+    "open_public_board": "Vai alla lavagna pubblica",
222
+    "private_board_description": "Puoi creare una <b>lavagna privata</b> con un nome casuale, che sarà accessibile solo dal suo URL. Usalo se vuoi condividere informazioni private.",
223
+    "create_private_board": "Crea una lavagna privata",
224
+    "named_private_board_description": "Puoi anche creare una <strong>lavagna privata con un nome creato da te</strong>, con un URL personalizzato, che sarà accessibile a tutti coloro che ne conoscono il nome.",
225
+    "board_name_placeholder": "Nome della lavagna…",
226
+    "view_source": "Codice sorgente su GitHub"
227
+  },
228
+  "ja": {
229
+    "hand": "手のひらツール",
230
+    "mover": "変位",
231
+    "loading": "読み込み中",
232
+    "tagline": "無料でオープンソースの協同作業できるオンラインホワイトボード。WBOでアイディアを共有しましょう!",
233
+    "configuration": "設定",
234
+    "collaborative_whiteboard": "協同作業できるオンラインホワイトボード",
235
+    "size": "サイズ",
236
+    "zoom": "拡大・縮小",
237
+    "tools": "ツール",
238
+    "rectangle": "矩形",
239
+    "square": "正方形",
240
+    "menu": "メニュー",
241
+    "text": "テキスト",
242
+    "straight_line": "直線",
243
+    "pencil": "ペン",
244
+    "circle": "サークル",
245
+    "ellipse": "楕円",
246
+    "click_to_toggle": "クリックして切り替えます",
247
+    "click_to_zoom": "クリックで拡大\nシフトを押しながらクリックで縮小",
248
+    "keyboard_shortcut": "キーボードショートカット",
249
+    "mousewheel": "ねずみ車",
250
+    "opacity": "透明度",
251
+    "color": "色",
252
+    "eraser": "消去",
253
+    "grid": "グリッド",
254
+    "white-out": "修正液",
255
+    "index_title": "WBOへようこそ!",
256
+    "introduction_paragraph": "WBOは<a href=\"https://github.com/lovasoa/whitebophir\" title=\"ビール飲み放題ではなく言論の自由。このソフトウェアはAGPLライセンスで公開しています。\">無料かつオープンソース</a>の協同作業できるオンラインホワイトボードです。多くのユーザーが大きな仮想ホワイトボードに図などを書くことができ、接続しているすべてのユーザーの更新をリアルタイムに反映され、その状態を常に保存します。これはアート、エンタテインメント、デザインや教育など、様々な用途で使用できます。",
257
+    "share_instructions": "URLを送るだけで、リアルタイムな共同作業ができます。",
258
+    "public_board_description": "<b>公開ボード</b>は、WBOにアクセスできる人であれば誰でも参加できますが、これは一時的な用途に向いています。",
259
+    "open_public_board": "公開ボードを作成する",
260
+    "private_board_description": "プライベートな情報を共有したいときは、ランダムな名前を持つ、<b>プライベートボード</b>を作成できます。このボードはリンクを知っている人がアクセスできます。",
261
+    "create_private_board": "プライベートボードを作成する",
262
+    "named_private_board_description": "<strong>名前つきプライベートボード</strong>を作ることもできます。このボードは名前かURLを知っている人だけがアクセスできます。",
263
+    "board_name_placeholder": "ボードの名前",
264
+    "view_source": "GitHubでソースコード見る"
265
+  },
266
+  "ru": {
267
+    "collaborative_whiteboard": "Онлайн доска для совместного рисования",
268
+    "loading": "Загрузка",
269
+    "menu": "Панель",
270
+    "tools": "Инструменты",
271
+    "size": "Размер",
272
+    "color": "Цвет",
273
+    "opacity": "Непрозрачность",
274
+    "pencil": "Карандаш",
275
+    "text": "Текст",
276
+    "eraser": "Ластик",
277
+    "white-out": "Корректор",
278
+    "hand": "Рука",
279
+    "straight_line": "Прямая линия",
280
+    "rectangle": "Прямоугольник",
281
+    "square": "Квадрат",
282
+    "circle": "Круг",
283
+    "ellipse": "Эллипс",
284
+    "click_to_toggle": "нажмите, чтобы переключиться",
285
+    "zoom": "Лупа",
286
+    "mover": "Сдвинуть объект",
287
+    "grid": "Сетка",
288
+    "configuration": "Настройки",
289
+    "keyboard_shortcut": "горячая клавиша",
290
+    "mousewheel": "колёсико мыши ",
291
+    "tagline": "Бесплатная и открытая доска для совместной работы в интернете. Рисуете свои идеи вместе в WBO !",
292
+    "index_title": "Добро пожаловать на WBO !",
293
+    "introduction_paragraph": "WBO это бесплатная и <a href=\"https://github.com/lovasoa/whitebophir\" title=\"открытый исходный код\">открытая</a> виртуальная онлайн доска, позволяющая рисовать одновременно сразу нескольким пользователям. С WBO вы сможете рисовать, работать с коллегами над будущими проектами, проводить онлайн встречи, подкреплять ваши обучающие материалы и даже пробовать себя в дизайне. WBO доступен без регистрации.",
294
+    "share_instructions": "Использовать платформу для совместного творчества очень просто. Достаточно поделиться ссылкой URL с теми, кто хочет с вами порисовать. Как только они получат URL они смогут к вам присоединиться.",
295
+    "public_board_description": "<b>Анонимная доска</b> позволяет рисовать вместе онлайн. Используйте этот формат для творчества и кучи разных идей. Вдохновляйтесь уже существующими рисунками, дополняйте их и создавайте совместные работы с другими посетителями. Любой пользователь может удалять уже существующие элементы и рисунки.",
296
+    "open_public_board": "Открыть анонимную доску",
297
+    "private_board_description": "<b>Приватная доска</b> обладает тем же функционалом, что и анонимная доска. Разница в том, что приватную доску могут видеть только те пользователи, у которых на нее есть ссылка. Используйте приватную онлайн доску в рабочих целях, проводите онлайн уроки, рисуйте с детьми или друзьями. Другие пользователи не смогут удалять или менять ваши работы без вашего разрешения.",
298
+    "create_private_board": "Создать приватную доску",
299
+    "named_private_board_description": "Также можно создать <b>именную приватную доску</b> которая будет доступна всем тем, кому вы отправили название вашей доски.",
300
+    "board_name_placeholder": "Название доски",
301
+    "view_source": "Исходный код на GitHub"
302
+  },
303
+  "uk": {
304
+    "hand": "Рука",
305
+    "loading": "Завантаження",
306
+    "tagline": "Безкоштовний онлайн засіб для спільного малювання з відкритим кодом. Разом накресліть нові ідеї у WBO!",
307
+    "configuration": "Налаштування",
308
+    "collaborative_whiteboard": "Онлайн дошка для спільної роботи",
309
+    "size": "Розмір",
310
+    "zoom": "Лупа",
311
+    "tools": "Засоби",
312
+    "rectangle": "Прямокутник",
313
+    "square": "Квадрат",
314
+    "circle": "Коло",
315
+    "ellipse": "Еліпс",
316
+    "click_to_toggle": "клацніть, щоб перемкнути",
317
+    "menu": "Меню",
318
+    "text": "Текст",
319
+    "mover": "Пересунути",
320
+    "straight_line": "Пряма лінія",
321
+    "pencil": "Олівець",
322
+    "grid": "Сітка",
323
+    "click_to_zoom": "Клацніть для збільшення\nНатисніть shift та клацініть для зменшення",
324
+    "keyboard_shortcut": "швидкі клавіші",
325
+    "mousewheel": "коліщатко миші",
326
+    "opacity": "Прозорість",
327
+    "color": "Колір",
328
+    "eraser": "Ґумка",
329
+    "White-out": "Коректор",
330
+    "index_title": "Вітаємо у відкритій онлайн дошці WBO!",
331
+    "introduction_paragraph": "WBO це <a href=\"https://github.com/lovasoa/whitebophir\" title=\"Free as in free speech, not free beer. This software is released under the AGPL license\">безкоштовна та відкрита</a> онлайн дошка для спільної роботи, яка дозволяє багатьом користувачам одночасно писати на великій віртуальній дошці. Дошка оновлюється в реальному часі для всіх приєднаних користувачів, та її стан постійно зберігається. Вона може застосовуватись з різною метою, включаючи мистецтво, розваги, дизайн та навчання.",
332
+    "share_instructions": "Для спільної роботи на дошці досить повідомити іншій особі адресу URL.",
333
+    "public_board_description": "<b>Публічна дошка</b> доступна для всіх. Там панує повний безлад, де Ви можете зустріти анонімних незнайомців та малювати разом. Тут все ефемерне.",
334
+    "open_public_board": "Перейти до публічної дошки",
335
+    "private_board_description": "Ви можете створити <b>особисту дошку</b> з випадковою назвою, яка буде доступна лише за відповідним посиланням. Користуйтесь нею, якшо Вам потрібно ділитись особистою інформацією.",
336
+    "create_private_board": "Створити особисту дошку",
337
+    "named_private_board_description": "Ви також можете створити <strong>особисту дошку з назвою</strong>, з власним URL, яка буде доступна всім, хто знає її назву.",
338
+    "board_name_placeholder": "Назва дошки…",
339
+    "view_source": "Вихідний код на GitHub"
340
+  },
341
+  "zh": {
342
+    "collaborative_whiteboard": "在线协作式白板",
343
+    "loading": "载入中",
344
+    "menu": "目录",
345
+    "tools": "工具",
346
+    "size": "尺寸",
347
+    "color": "颜色",
348
+    "opacity": "不透明度",
349
+    "pencil": "铅笔",
350
+    "rectangle": "矩形",
351
+    "square": "正方形",
352
+    "circle": "圈",
353
+    "ellipse": "椭圆",
354
+    "click_to_toggle": "单击以切换",
355
+    "zoom": "放大",
356
+    "text": "文本",
357
+    "eraser": "橡皮",
358
+    "white-out": "修正液",
359
+    "hand": "移动",
360
+    "mover": "平移",
361
+    "straight_line": "直线",
362
+    "configuration": "刷设置",
363
+    "keyboard_shortcut": "键盘快捷键",
364
+    "mousewheel": "鼠标轮",
365
+    "grid": "格",
366
+    "click_to_zoom": "点击放大。\n保持班次并单击缩小。",
367
+    "tagline": "打开即用的免费在线白板工具",
368
+    "index_title": "欢迎来到 WBO!",
369
+    "introduction_paragraph": " WBO是一<a href=\"https://github.com/lovasoa/whitebophir\">个免费的</a>、开源的在线协作白板,它允许许多用户同时在一个大型虚拟板上画图。该板对所有连接的用户实时更新,并且始终可用。它可以用于许多不同的目的,包括艺术、娱乐、设计和教学。",
370
+    "share_instructions": "要与某人实时协作绘制图形,只需向他们发送白板的URL。",
371
+    "public_board_description": "每个人都可以使用公共白板。这是一个令人愉快的混乱的地方,你可以会见匿名陌生人,并在一起。那里的一切都是短暂的。",
372
+    "open_public_board": "进入公共白板",
373
+    "private_board_description": "您可以创建一个带有随机名称的私有白板,该白板只能通过其链接访问。如果要共享私人信息,请使用此选项。",
374
+    "create_private_board": "创建私人白板",
375
+    "named_private_board_description": "您还可以创建一个命名的私有白板,它有一个自定义的URL,所有知道它名字的人都可以访问它。",
376
+    "board_name_placeholder": "白板名称",
377
+    "view_source": "GitHub上的源代码"
378
+  },
379
+  "vn": {
380
+    "hand": "Tay",
381
+    "loading": "Đang tải",
382
+    "tagline": "Một công cụ vẽ cộng tác trực tuyến miễn phí và mã nguồn mở. Cùng nhau phác thảo những ý tưởng mới trên WBO!",
383
+    "configuration": "Cấu hình",
384
+    "collaborative_whiteboard": "Bảng trắng cộng tác",
385
+    "size": "Size",
386
+    "zoom": "Zoom",
387
+    "tools": "Công cụ",
388
+    "rectangle": "Chữ nhật",
389
+    "square": "Vuông",
390
+    "circle": "Tròn",
391
+    "ellipse": "Hình elip",
392
+    "click_to_toggle": "Bật/tắt",
393
+    "menu": "Menu",
394
+    "text": "Text",
395
+    "mover": "Di chuyển",
396
+    "straight_line": "Đường thẳng",
397
+    "pencil": "Gạch ngang",
398
+    "grid": "Grid",
399
+    "click_to_zoom": "Nhấp để phóng to \n Nhấn shift và nhấp để thu nhỏ",
400
+    "keyboard_shortcut": "Phím tắt",
401
+    "mousewheel": "Lăn chuột",
402
+    "opacity": "Độ Mờ",
403
+    "color": "Màu",
404
+    "eraser": "Tẩy",
405
+    "White-out": "Trắng",
406
+    "index_title": "Chào mừng bạn đến với WBO bảng trắng trực tuyến miễn phí!",
407
+    "introduction_paragraph": "WBO là một <a href=\"https://github.com/lovasoa/whitebophir\" title=\"Miễn phí như trong tự do ngôn luận, không phải bia miễn phí. Phần mềm này được phát hành theo giấy phép AGPL\">Miễn phí và open-source</a> cho phép nhiều người dùng vẽ đồng thời trên một bảng ảo lớn. Bảng này được cập nhật theo thời gian thực cho tất cả người dùng được kết nối và trạng thái luôn tồn tại. Nó có thể được sử dụng cho nhiều mục đích khác nhau, bao gồm nghệ thuật, giải trí, thiết kế và giảng dạy.",
408
+    "share_instructions": "Để cộng tác trên một bản vẽ trong thời gian thực với ai đó, chỉ cần gửi cho họ URL của bản vẽ đó.",
409
+    "public_board_description": "Tất cả mọi người đều có thể truy cập <b> bảng công khai </b>. Đó là một mớ hỗn độn vô tổ chức vui vẻ, nơi bạn có thể gặp gỡ những người lạ vô danh và cùng nhau vẽ. Mọi thứ ở đó là phù du.",
410
+    "open_public_board": "Đi tới bảng công khai",
411
+    "private_board_description": "Bạn có thể tạo một <b> bảng riêng </b> với một tên ngẫu nhiên, chỉ có thể truy cập được bằng liên kết của nó. Sử dụng cái này nếu bạn muốn chia sẻ thông tin cá nhân.",
412
+    "create_private_board": "Tạo một bảng riêng",
413
+    "named_private_board_description": "Bạn cũng có thể tạo <strong> bảng riêng được đặt tên </strong>, với URL tùy chỉnh, tất cả những người biết tên của nó đều có thể truy cập được.",
414
+    "board_name_placeholder": "Tên bản …",
415
+    "view_source": "Source code on GitHub"
416
+  }
417 417
 }

Loading…
取消
儲存