mbeditor 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +131 -0
  3. data/README.md +153 -3
  4. data/app/assets/javascripts/mbeditor/application.js +5 -0
  5. data/app/assets/javascripts/mbeditor/application_iife_tail.js +6 -0
  6. data/app/assets/javascripts/mbeditor/collaboration_identity.js +234 -0
  7. data/app/assets/javascripts/mbeditor/collaboration_service.js +690 -0
  8. data/app/assets/javascripts/mbeditor/components/EditorPanel.js +120 -19
  9. data/app/assets/javascripts/mbeditor/components/FileTree.js +127 -8
  10. data/app/assets/javascripts/mbeditor/components/GitPanel.js +12 -3
  11. data/app/assets/javascripts/mbeditor/components/ImportConflictModal.js +127 -0
  12. data/app/assets/javascripts/mbeditor/components/MbeditorApp.js +911 -72
  13. data/app/assets/javascripts/mbeditor/components/ModelGraph.js +565 -0
  14. data/app/assets/javascripts/mbeditor/components/ProblemsPanel.js +130 -10
  15. data/app/assets/javascripts/mbeditor/components/ShortcutHelp.js +1 -0
  16. data/app/assets/javascripts/mbeditor/components/TabBar.js +4 -2
  17. data/app/assets/javascripts/mbeditor/editor_plugins.js +517 -111
  18. data/app/assets/javascripts/mbeditor/file_import.js +146 -0
  19. data/app/assets/javascripts/mbeditor/file_service.js +52 -3
  20. data/app/assets/javascripts/mbeditor/tab_manager.js +50 -1
  21. data/app/assets/javascripts/mbeditor/websocket_service.js +89 -0
  22. data/app/assets/stylesheets/mbeditor/editor.css +273 -10
  23. data/app/channels/mbeditor/channel_authentication.rb +94 -0
  24. data/app/channels/mbeditor/collaboration_channel.rb +84 -0
  25. data/app/channels/mbeditor/editor_channel.rb +40 -1
  26. data/app/controllers/mbeditor/application_controller.rb +5 -1
  27. data/app/controllers/mbeditor/editors_controller.rb +465 -19
  28. data/app/controllers/mbeditor/git_controller.rb +9 -2
  29. data/app/services/mbeditor/availability_probe.rb +76 -17
  30. data/app/services/mbeditor/code_search_service.rb +23 -3
  31. data/app/services/mbeditor/collaboration_doc_store.rb +116 -0
  32. data/app/services/mbeditor/file_import_service.rb +103 -0
  33. data/app/services/mbeditor/git_combined_diff_service.rb +36 -5
  34. data/app/services/mbeditor/git_info_service.rb +6 -0
  35. data/app/services/mbeditor/git_service.rb +22 -6
  36. data/app/services/mbeditor/lsp_diagnostics_translator.rb +99 -5
  37. data/app/services/mbeditor/model_graph_service.rb +232 -0
  38. data/app/services/mbeditor/presence_registry.rb +83 -0
  39. data/app/services/mbeditor/ri_definition_service.rb +39 -5
  40. data/app/services/mbeditor/search_replace_service.rb +24 -4
  41. data/app/views/layouts/mbeditor/application.html.erb +2 -0
  42. data/lib/mbeditor/configuration.rb +33 -3
  43. data/lib/mbeditor/engine.rb +34 -0
  44. data/lib/mbeditor/exception_log.rb +84 -0
  45. data/lib/mbeditor/route_map.rb +5 -0
  46. data/lib/mbeditor/ruby_lsp_client.rb +28 -1
  47. data/lib/mbeditor/version.rb +1 -1
  48. data/lib/mbeditor.rb +1 -0
  49. data/vendor/assets/javascripts/yjs-collab.js +12 -0
  50. metadata +15 -2
@@ -0,0 +1,146 @@
1
+ 'use strict';
2
+
3
+ // Turns a drop's DataTransfer into a flat list of { file, relativePath }
4
+ // entries and packs them into the multipart body the /import endpoint wants.
5
+ //
6
+ // Directory drops are walked with webkitGetAsEntry — non-standard, but the
7
+ // only way any browser exposes a dropped folder. Where it is missing we fall
8
+ // back to dataTransfer.files, which is flat: files import, folders vanish.
9
+ var FileImport = (function () {
10
+ // Mirrors EditorsController::IMPORT_MAX_FILES. Trimming client-side means a
11
+ // stray node_modules drop reports a clear message instead of a 422 — and
12
+ // keeps the batch under Rack's 128-file-part multipart limit, which would
13
+ // otherwise reject the request as a 500 before the server guard can run.
14
+ var MAX_ENTRIES = 100;
15
+
16
+ function hasExternalFiles(dataTransfer) {
17
+ if (!dataTransfer || !dataTransfer.types) return false;
18
+ return Array.prototype.indexOf.call(dataTransfer.types, 'Files') !== -1;
19
+ }
20
+
21
+ // MUST be called synchronously from the drop handler: DataTransferItem
22
+ // objects are neutered the moment the handler returns, so every entry is
23
+ // pulled off the item list up front and only then walked asynchronously.
24
+ function collectEntries(dataTransfer) {
25
+ var items = dataTransfer.items;
26
+ var roots = [];
27
+ var supportsEntries = typeof DataTransferItem !== 'undefined' &&
28
+ DataTransferItem.prototype.webkitGetAsEntry;
29
+
30
+ if (items && items.length && supportsEntries) {
31
+ for (var i = 0; i < items.length; i++) {
32
+ if (items[i].kind !== 'file') continue;
33
+ var entry = items[i].webkitGetAsEntry();
34
+ if (entry) roots.push(entry);
35
+ }
36
+ }
37
+
38
+ if (roots.length === 0) {
39
+ var flat = [];
40
+ var files = dataTransfer.files || [];
41
+ for (var j = 0; j < files.length; j++) {
42
+ flat.push({ file: files[j], relativePath: files[j].name });
43
+ }
44
+ return Promise.resolve({
45
+ entries: flat.slice(0, MAX_ENTRIES),
46
+ truncated: flat.length > MAX_ENTRIES,
47
+ foldersSkipped: !!(items && items.length > flat.length)
48
+ });
49
+ }
50
+
51
+ var collected = [];
52
+ return walkAll(roots, collected).then(function () {
53
+ return {
54
+ entries: collected.slice(0, MAX_ENTRIES),
55
+ truncated: collected.length > MAX_ENTRIES,
56
+ foldersSkipped: false
57
+ };
58
+ });
59
+ }
60
+
61
+ function walkAll(entries, out) {
62
+ return entries.reduce(function (chain, entry) {
63
+ return chain.then(function () { return walk(entry, out); });
64
+ }, Promise.resolve());
65
+ }
66
+
67
+ function walk(entry, out) {
68
+ if (entry.isFile) {
69
+ return new Promise(function (resolve) {
70
+ entry.file(function (file) {
71
+ out.push({ file: file, relativePath: stripLeadingSlash(entry.fullPath) });
72
+ resolve();
73
+ }, function () { resolve(); });
74
+ });
75
+ }
76
+ if (!entry.isDirectory) return Promise.resolve();
77
+
78
+ var reader = entry.createReader();
79
+ // readEntries hands back at most ~100 children per call and signals the
80
+ // end of the directory with an empty batch, so it has to be called until
81
+ // it runs dry rather than once.
82
+ var readBatch = function () {
83
+ return new Promise(function (resolve) {
84
+ reader.readEntries(function (batch) { resolve(batch); }, function () { resolve([]); });
85
+ }).then(function (batch) {
86
+ if (!batch || batch.length === 0) return null;
87
+ return walkAll(batch, out).then(readBatch);
88
+ });
89
+ };
90
+ return readBatch();
91
+ }
92
+
93
+ function stripLeadingSlash(p) {
94
+ return String(p || '').replace(/^\/+/, '');
95
+ }
96
+
97
+ // '' as the folder means the workspace root.
98
+ function joinPath(folder, relativePath) {
99
+ var rel = stripLeadingSlash(relativePath);
100
+ if (!folder) return rel;
101
+ return folder.replace(/\/+$/, '') + '/' + rel;
102
+ }
103
+
104
+ function buildFormData(entries, targetFolderPath, onConflict) {
105
+ var fd = new FormData();
106
+ fd.append('on_conflict', onConflict);
107
+ entries.forEach(function (e) {
108
+ fd.append('files[]', e.file, e.file.name);
109
+ fd.append('paths[]', joinPath(targetFolderPath, e.relativePath));
110
+ });
111
+ return fd;
112
+ }
113
+
114
+ // Match conflict occurrences from the end of the original entry list. If
115
+ // two dropped files map to one previously-free path, pass one imports the
116
+ // first and reports only the later occurrence as conflicting; retrying every
117
+ // entry with that path would duplicate the already-imported file.
118
+ function conflictedEntries(entries, targetFolderPath, conflicts) {
119
+ var remaining = {};
120
+ (conflicts || []).forEach(function(c) {
121
+ var key = '$' + c.path;
122
+ remaining[key] = (remaining[key] || 0) + 1;
123
+ });
124
+
125
+ var retry = [];
126
+ for (var i = entries.length - 1; i >= 0; i--) {
127
+ var targetKey = '$' + joinPath(targetFolderPath, entries[i].relativePath);
128
+ if (!remaining[targetKey]) continue;
129
+ retry.unshift(entries[i]);
130
+ remaining[targetKey] -= 1;
131
+ }
132
+ return retry;
133
+ }
134
+
135
+ return {
136
+ MAX_ENTRIES: MAX_ENTRIES,
137
+ hasExternalFiles: hasExternalFiles,
138
+ collectEntries: collectEntries,
139
+ joinPath: joinPath,
140
+ buildFormData: buildFormData,
141
+ conflictedEntries: conflictedEntries
142
+ };
143
+ })();
144
+
145
+ // Expose globally for sprockets require
146
+ window.FileImport = FileImport;
@@ -70,6 +70,15 @@ var FileService = (function () {
70
70
  return axios.post(window.mbeditorBasePath() + '/create_file', { path: path, code: code || '' }).then(function(res) { return res.data; });
71
71
  }
72
72
 
73
+ // Multipart import of files dragged in from outside the browser. The
74
+ // default 30 s axios timeout is too tight for a large drop on a slow disk,
75
+ // so this one call gets a longer leash.
76
+ function importFiles(formData) {
77
+ return axios.post(window.mbeditorBasePath() + '/import', formData, {
78
+ timeout: 120000
79
+ }).then(function (res) { return res.data; });
80
+ }
81
+
73
82
  function createDir(path) {
74
83
  return axios.post(window.mbeditorBasePath() + '/create_dir', { path: path }).then(function(res) { return res.data; });
75
84
  }
@@ -224,15 +233,50 @@ var FileService = (function () {
224
233
  // | 'completion'; line/character are Monaco 1-based. The server answers in
225
234
  // provider-ready shapes, or { fallback: true } when the LSP can't answer in
226
235
  // time (caller then uses the legacy grep/Ripper path).
227
- function rubyLspRequest(lspMethod, path, content, line, character, extraOptions) {
236
+ // extraBody carries per-method request params (e.g. formatting's tab_size);
237
+ // extraOptions is axios config (e.g. a longer timeout).
238
+ function rubyLspRequest(lspMethod, path, content, line, character, extraOptions, extraBody) {
228
239
  var config = Object.assign({ timeout: 6000 }, extraOptions || {});
229
- return axios.post(window.mbeditorBasePath() + '/ruby_lsp', {
240
+ var body = Object.assign({
230
241
  lsp_method: lspMethod,
231
242
  path: path,
232
243
  content: content,
233
244
  line: line,
234
245
  character: character
235
- }, config).then(function(res) { return res.data; });
246
+ }, extraBody || {});
247
+ return axios.post(window.mbeditorBasePath() + '/ruby_lsp', body, config)
248
+ .then(function(res) { return res.data; });
249
+ }
250
+
251
+ // ActiveRecord models and their associations. Generating this eager-loads the
252
+ // host app, so the timeout is generous and it is only requested on demand.
253
+ function getModelGraph(force) {
254
+ return axios.get(window.mbeditorBasePath() + '/model_graph', {
255
+ params: force ? { refresh: 1 } : {},
256
+ timeout: 60000
257
+ }).then(function (res) { return res.data; });
258
+ }
259
+
260
+ // Exceptions raised by the host app, newest first. The cable push is the
261
+ // live path; this seeds the panel and covers hosts without ActionCable.
262
+ function getExceptions() {
263
+ return axios.get(window.mbeditorBasePath() + '/exceptions')
264
+ .then(function (res) { return res.data; });
265
+ }
266
+
267
+ function clearExceptions() {
268
+ return axios["delete"](window.mbeditorBasePath() + '/exceptions')
269
+ .then(function (res) { return res.data; });
270
+ }
271
+
272
+ // Rename a Ruby constant across the workspace. openPaths lists every file
273
+ // with a live editor model: the server returns their edits for Monaco to
274
+ // apply (undoable, marks the tab dirty) and writes the rest to disk itself.
275
+ function rubyRename(path, content, line, character, newName, openPaths) {
276
+ return axios.post(window.mbeditorBasePath() + '/ruby_rename', {
277
+ path: path, content: content, line: line, character: character,
278
+ new_name: newName, open_paths: openPaths || []
279
+ }, { timeout: 35000 }).then(function (res) { return res.data; });
236
280
  }
237
281
 
238
282
  // Whole-document Ruby diagnostics from ruby-lsp (RuboCop offenses + Prism
@@ -283,6 +327,7 @@ var FileService = (function () {
283
327
  getFileChunk: getFileChunk,
284
328
  saveFile: saveFile,
285
329
  createFile: createFile,
330
+ importFiles: importFiles,
286
331
  createDir: createDir,
287
332
  renamePath: renamePath,
288
333
  deletePath: deletePath,
@@ -310,6 +355,10 @@ var FileService = (function () {
310
355
  getJsProgramFile: getJsProgramFile,
311
356
  rubyLspRequest: rubyLspRequest,
312
357
  lspDiagnostics: lspDiagnostics,
358
+ rubyRename: rubyRename,
359
+ getModelGraph: getModelGraph,
360
+ getExceptions: getExceptions,
361
+ clearExceptions: clearExceptions,
313
362
  getRelatedFiles: getRelatedFiles,
314
363
  getModelSchema: getModelSchema,
315
364
  getChangelog: getChangelog
@@ -61,6 +61,10 @@ var TabManager = (function () {
61
61
  entry.model.dispose();
62
62
  }
63
63
  delete window.__mbeditorModels[candidate];
64
+ // The evicted file is not open in any pane — tear down its collaboration room.
65
+ if (typeof CollaborationService !== 'undefined') {
66
+ CollaborationService.leaveRoom(candidate);
67
+ }
64
68
  }
65
69
  }
66
70
 
@@ -141,7 +145,32 @@ var TabManager = (function () {
141
145
  });
142
146
  }
143
147
 
148
+ // Single slot holding "where the cursor was before the last jump".
149
+ // PageUp/PageDown swap the cursor between this slot and its current spot.
150
+ // ponytail: two-position cycle, upgrade to a real back/forward stack if asked
151
+ var _jumpOrigin = null;
152
+
153
+ function _snapshotPosition() {
154
+ var editor = window.__mbeditorActiveEditor;
155
+ var pos = editor && editor.getPosition ? editor.getPosition() : null;
156
+ if (!pos) return null;
157
+ var state = EditorStore.getState();
158
+ var pane = state.panes.find(function(p) { return p.id === state.focusedPaneId; });
159
+ var tab = pane && pane.tabs.find(function(t) { return t.id === pane.activeTabId; });
160
+ if (!tab || !tab.path) return null;
161
+ return { path: tab.path, name: tab.name, line: pos.lineNumber, col: pos.column };
162
+ }
163
+
164
+ function toggleJumpOrigin() {
165
+ var target = _jumpOrigin;
166
+ if (!target) return;
167
+ // openTab re-snapshots the current position into _jumpOrigin below,
168
+ // which is exactly the swap we want.
169
+ openTab(target.path, target.name, target.line, null, false, target.col);
170
+ }
171
+
144
172
  function openTab(path, name, line, forcePaneId, isSoftOpen, col) {
173
+ if (line) _jumpOrigin = _snapshotPosition() || _jumpOrigin;
145
174
  var state = EditorStore.getState();
146
175
  var paneId = forcePaneId || state.focusedPaneId;
147
176
  var pane = state.panes.find(function(p) { return p.id === paneId; });
@@ -360,8 +389,23 @@ var TabManager = (function () {
360
389
  axios.get(window.mbeditorBasePath() + '/git/combined_diff', { params: { scope: scope || 'local' } })
361
390
  .then(function(res) {
362
391
  var data = res.data;
392
+ var text = typeof data === 'string' ? data : (data && data.diff) || '';
393
+ // The server reports what it compared against, and says so when it
394
+ // could not work out a base at all — otherwise an empty diff looks
395
+ // identical to "no changes" and hides the real problem.
396
+ var headers = res.headers || {};
397
+ var baseRef = headers['x-mbeditor-diff-base'];
398
+ var diffError = headers['x-mbeditor-diff-error'];
399
+
400
+ if (diffError) {
401
+ text = '# ' + diffError + '\n';
402
+ } else if (baseRef && text.trim() === '') {
403
+ text = '# No changes compared to ' + baseRef + '.\n';
404
+ }
405
+
363
406
  _updateTab(paneId, tabId, {
364
- combinedDiffText: typeof data === 'string' ? data : (data && data.diff) || '',
407
+ combinedDiffText: text,
408
+ combinedDiffBase: baseRef || null,
365
409
  combinedDiffLoaded: true
366
410
  });
367
411
  })
@@ -434,6 +478,10 @@ var TabManager = (function () {
434
478
  _entry.model.dispose();
435
479
  }
436
480
  delete window.__mbeditorModels[path];
481
+ // File is no longer open in any pane — tear down its collaboration room.
482
+ if (typeof CollaborationService !== 'undefined') {
483
+ CollaborationService.leaveRoom(path);
484
+ }
437
485
  }
438
486
  }
439
487
  }
@@ -617,6 +665,7 @@ var TabManager = (function () {
617
665
  reorderTabInPane: reorderTabInPane,
618
666
  moveTabToPane: moveTabToPane,
619
667
  clearGotoLine: clearGotoLine,
668
+ toggleJumpOrigin: toggleJumpOrigin,
620
669
  closeAllTabsInPane: closeAllTabsInPane,
621
670
  closeOtherTabsInPane: closeOtherTabsInPane,
622
671
  closeSavedTabsInPane: closeSavedTabsInPane,
@@ -11,6 +11,8 @@ var WebSocketService = (function () {
11
11
  var _subscription = null;
12
12
  var _connected = false;
13
13
  var _filesChangedCallbacks = [];
14
+ var _fileSavedCallbacks = [];
15
+ var _presenceCallbacks = [];
14
16
  var _logLinesCallbacks = [];
15
17
  var _serverSupportsWs = false;
16
18
  var _reconnectTimer = null;
@@ -117,8 +119,14 @@ var WebSocketService = (function () {
117
119
  if (!data) return;
118
120
  if (data.type === 'files_changed') {
119
121
  _emitFilesChanged(data);
122
+ } else if (data.type === 'file_saved') {
123
+ _emitFileSaved(data);
124
+ } else if (data.type === 'presence') {
125
+ _emitPresence(data);
120
126
  } else if (data.type === 'log') {
121
127
  _emitLogLines(data);
128
+ } else if (data.type === 'exception') {
129
+ _emitException(data);
122
130
  }
123
131
  }
124
132
  }
@@ -136,12 +144,33 @@ var WebSocketService = (function () {
136
144
  });
137
145
  }
138
146
 
147
+ function _emitFileSaved(data) {
148
+ _fileSavedCallbacks.forEach(function (fn) {
149
+ try { fn(data); } catch (e) { /* ignore */ }
150
+ });
151
+ }
152
+
153
+ function _emitPresence(data) {
154
+ _presenceCallbacks.forEach(function (fn) {
155
+ try { fn(data); } catch (e) { /* ignore */ }
156
+ });
157
+ }
158
+
139
159
  function _emitLogLines(data) {
140
160
  _logLinesCallbacks.forEach(function (fn) {
141
161
  try { fn(data); } catch (e) { /* ignore */ }
142
162
  });
143
163
  }
144
164
 
165
+ // Host-app exceptions go out as a DOM event rather than a callback list:
166
+ // the Problems panel mounts and unmounts, so it subscribes and unsubscribes
167
+ // with its own lifecycle rather than registering here at boot.
168
+ function _emitException(data) {
169
+ try {
170
+ window.dispatchEvent(new CustomEvent('mbeditor:exception', { detail: data }));
171
+ } catch (e) { /* CustomEvent unavailable; the panel still polls on open */ }
172
+ }
173
+
145
174
  // ---------------------------------------------------------------------------
146
175
  // Public API
147
176
  // ---------------------------------------------------------------------------
@@ -172,6 +201,38 @@ var WebSocketService = (function () {
172
201
  return _connected;
173
202
  }
174
203
 
204
+ // Returns true when ActionCable is loaded and the server advertised cable
205
+ // support. Collaboration uses this as its up-front "is the feature available?"
206
+ // gate, independent of whether the EditorChannel handshake has completed yet.
207
+ function isCableAvailable() {
208
+ return _serverSupportsWs && _isActionCableAvailable();
209
+ }
210
+
211
+ // Open a CollaborationChannel subscription for one file on the SHARED consumer
212
+ // (the same connection the EditorChannel uses). Returns the subscription, or
213
+ // null when cable is unavailable so callers can stay in single-user mode.
214
+ // handlers: { connected, received, rejected, disconnected } — all optional.
215
+ function subscribeCollaboration(path, handlers) {
216
+ if (!isCableAvailable()) return null;
217
+ handlers = handlers || {};
218
+ try {
219
+ if (!_consumer) {
220
+ _consumer = _getConsumer();
221
+ }
222
+ return _consumer.subscriptions.create(
223
+ { channel: 'Mbeditor::CollaborationChannel', path: path },
224
+ {
225
+ connected: function () { if (handlers.connected) handlers.connected(); },
226
+ disconnected: function () { if (handlers.disconnected) handlers.disconnected(); },
227
+ rejected: function () { if (handlers.rejected) handlers.rejected(); },
228
+ received: function (data) { if (handlers.received) handlers.received(data); }
229
+ }
230
+ );
231
+ } catch (e) {
232
+ return null;
233
+ }
234
+ }
235
+
175
236
  // Register a callback to be invoked when the server broadcasts files_changed.
176
237
  function onFilesChanged(fn) {
177
238
  _filesChangedCallbacks.push(fn);
@@ -182,6 +243,28 @@ var WebSocketService = (function () {
182
243
  _filesChangedCallbacks = _filesChangedCallbacks.filter(function (f) { return f !== fn; });
183
244
  }
184
245
 
246
+ // Register a callback to be invoked when a peer saves a file (file_saved).
247
+ // The callback receives the broadcast data, including the relative `path`.
248
+ function onFileSaved(fn) {
249
+ _fileSavedCallbacks.push(fn);
250
+ }
251
+
252
+ // Remove a previously registered file_saved callback.
253
+ function offFileSaved(fn) {
254
+ _fileSavedCallbacks = _fileSavedCallbacks.filter(function (f) { return f !== fn; });
255
+ }
256
+
257
+ // Register a callback for presence heartbeats/leaves relayed on the global
258
+ // stream. The callback receives {type:'presence', status, client_id, ...}.
259
+ function onPresence(fn) {
260
+ _presenceCallbacks.push(fn);
261
+ }
262
+
263
+ // Remove a previously registered presence callback.
264
+ function offPresence(fn) {
265
+ _presenceCallbacks = _presenceCallbacks.filter(function (f) { return f !== fn; });
266
+ }
267
+
185
268
  // Register a callback invoked when the server transmits a { type: 'log' } message.
186
269
  function onLogLines(fn) {
187
270
  _logLinesCallbacks.push(fn);
@@ -207,9 +290,15 @@ var WebSocketService = (function () {
207
290
  connect: connect,
208
291
  disconnect: disconnect,
209
292
  isConnected: isConnected,
293
+ isCableAvailable: isCableAvailable,
294
+ subscribeCollaboration: subscribeCollaboration,
210
295
  perform: perform,
211
296
  onFilesChanged: onFilesChanged,
212
297
  offFilesChanged: offFilesChanged,
298
+ onFileSaved: onFileSaved,
299
+ offFileSaved: offFileSaved,
300
+ onPresence: onPresence,
301
+ offPresence: offPresence,
213
302
  onLogLines: onLogLines,
214
303
  offLogLines: offLogLines
215
304
  };