isomorphic-jj 1.0.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.
- package/LICENSE +21 -0
- package/README.md +971 -0
- package/dist/index.cjs +12609 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.mjs +12600 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +90 -0
- package/src/api/repository.js +6885 -0
- package/src/backend/isomorphic-git-backend.js +687 -0
- package/src/backend/lazy-git-backend.js +180 -0
- package/src/browser/helpers.js +189 -0
- package/src/browser/index.d.ts +174 -0
- package/src/browser/index.js +12 -0
- package/src/core/background-ops.js +272 -0
- package/src/core/bookmark-store.js +232 -0
- package/src/core/change-graph.js +285 -0
- package/src/core/conflict-model.js +505 -0
- package/src/core/jj-checkout.js +78 -0
- package/src/core/jj-operation-store.js +120 -0
- package/src/core/jj-tree-state.js +94 -0
- package/src/core/jj-view-store.js +105 -0
- package/src/core/merge-driver-registry.js +432 -0
- package/src/core/operation-log.js +272 -0
- package/src/core/revset-engine.js +1081 -0
- package/src/core/storage-manager.js +207 -0
- package/src/core/tag-store.js +167 -0
- package/src/core/user-config.js +142 -0
- package/src/core/working-copy.js +314 -0
- package/src/core/workspace-manager.js +283 -0
- package/src/drivers/built-in-drivers.js +296 -0
- package/src/index.js +15 -0
- package/src/protos/local_working_copy.proto +76 -0
- package/src/protos/simple_op_store.proto +147 -0
- package/src/types.d.ts +1129 -0
- package/src/utils/errors.js +21 -0
- package/src/utils/id-generation.js +54 -0
- package/src/utils/validation.js +124 -0
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BackgroundOps - Background operations manager
|
|
3
|
+
*
|
|
4
|
+
* Handles file watching, automatic snapshots, and async operations.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { JJError } from '../utils/errors.js';
|
|
8
|
+
|
|
9
|
+
export class BackgroundOps {
|
|
10
|
+
/**
|
|
11
|
+
* @param {Object} jj - JJ instance
|
|
12
|
+
* @param {Object} fs - Filesystem implementation
|
|
13
|
+
* @param {string} dir - Repository directory
|
|
14
|
+
*/
|
|
15
|
+
constructor(jj, fs, dir) {
|
|
16
|
+
this.jj = jj;
|
|
17
|
+
this.fs = fs;
|
|
18
|
+
this.dir = dir;
|
|
19
|
+
this.watchers = new Map();
|
|
20
|
+
this.operations = new Map(); // operationId -> operation
|
|
21
|
+
this.timers = new Map(); // watcherId -> timer
|
|
22
|
+
this.running = false;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Start background operations
|
|
27
|
+
*/
|
|
28
|
+
async start() {
|
|
29
|
+
if (this.running) {
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
this.running = true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Stop background operations
|
|
38
|
+
*/
|
|
39
|
+
async stop() {
|
|
40
|
+
if (!this.running) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Clear all timers
|
|
45
|
+
for (const timer of this.timers.values()) {
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
}
|
|
48
|
+
this.timers.clear();
|
|
49
|
+
|
|
50
|
+
// Stop all watchers
|
|
51
|
+
for (const watcher of this.watchers.values()) {
|
|
52
|
+
if (watcher.close) {
|
|
53
|
+
watcher.close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
this.watchers.clear();
|
|
58
|
+
this.running = false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Watch a path for changes
|
|
63
|
+
*
|
|
64
|
+
* @param {string} path - Path to watch
|
|
65
|
+
* @param {Function} callback - Callback function(event, filename)
|
|
66
|
+
* @returns {string} Watcher ID
|
|
67
|
+
*/
|
|
68
|
+
async watch(path, callback) {
|
|
69
|
+
if (!this.running) {
|
|
70
|
+
throw new JJError('BACKGROUND_OPS_NOT_RUNNING', 'Background operations not started', {
|
|
71
|
+
suggestion: 'Call start() first',
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Check if fs.watch is available (Node.js)
|
|
76
|
+
if (this.fs.watch) {
|
|
77
|
+
const watcherId = `watcher-${this.watchers.size}`;
|
|
78
|
+
const watcher = this.fs.watch(path, { recursive: true }, callback);
|
|
79
|
+
this.watchers.set(watcherId, watcher);
|
|
80
|
+
return watcherId;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Browser: use FileSystem Access API observeDirectory if available
|
|
84
|
+
if (typeof FileSystemObserver !== 'undefined') {
|
|
85
|
+
const watcherId = `watcher-${this.watchers.size}`;
|
|
86
|
+
const observer = new FileSystemObserver(async (records) => {
|
|
87
|
+
for (const record of records) {
|
|
88
|
+
callback(record.type, record.relativePathComponents.join('/'));
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// Note: This API is experimental and may not be available
|
|
93
|
+
this.watchers.set(watcherId, observer);
|
|
94
|
+
return watcherId;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
throw new JJError('WATCH_NOT_SUPPORTED', 'File watching not supported in this environment');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Unwatch a path
|
|
102
|
+
*
|
|
103
|
+
* @param {string} watcherId - Watcher ID
|
|
104
|
+
*/
|
|
105
|
+
async unwatch(watcherId) {
|
|
106
|
+
const watcher = this.watchers.get(watcherId);
|
|
107
|
+
if (!watcher) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Clear any pending timer for this watcher
|
|
112
|
+
const timer = this.timers.get(watcherId);
|
|
113
|
+
if (timer) {
|
|
114
|
+
clearTimeout(timer);
|
|
115
|
+
this.timers.delete(watcherId);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (watcher.close) {
|
|
119
|
+
watcher.close();
|
|
120
|
+
} else if (watcher.disconnect) {
|
|
121
|
+
watcher.disconnect();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
this.watchers.delete(watcherId);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Queue a background operation
|
|
129
|
+
*
|
|
130
|
+
* @param {Function} operation - Async operation to run
|
|
131
|
+
* @param {Object} opts - Options
|
|
132
|
+
* @param {string} [opts.description] - Operation description
|
|
133
|
+
* @returns {Promise<Object>} Operation result
|
|
134
|
+
*/
|
|
135
|
+
async queue(operation, opts = {}) {
|
|
136
|
+
const operationId = `op-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
137
|
+
|
|
138
|
+
const op = {
|
|
139
|
+
id: operationId,
|
|
140
|
+
description: opts.description || 'background operation',
|
|
141
|
+
status: 'pending',
|
|
142
|
+
created: new Date().toISOString(),
|
|
143
|
+
promise: null,
|
|
144
|
+
result: null,
|
|
145
|
+
error: null,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
this.operations.set(operationId, op);
|
|
149
|
+
|
|
150
|
+
// Run operation asynchronously
|
|
151
|
+
op.promise = (async () => {
|
|
152
|
+
try {
|
|
153
|
+
op.status = 'running';
|
|
154
|
+
op.result = await operation();
|
|
155
|
+
op.status = 'completed';
|
|
156
|
+
op.completed = new Date().toISOString();
|
|
157
|
+
return op.result;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
op.status = 'failed';
|
|
160
|
+
op.error = error;
|
|
161
|
+
op.completed = new Date().toISOString();
|
|
162
|
+
throw error;
|
|
163
|
+
}
|
|
164
|
+
})();
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
id: operationId,
|
|
168
|
+
promise: op.promise,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get operation status
|
|
174
|
+
*
|
|
175
|
+
* @param {string} operationId - Operation ID
|
|
176
|
+
* @returns {Object|null} Operation status
|
|
177
|
+
*/
|
|
178
|
+
getOperation(operationId) {
|
|
179
|
+
return this.operations.get(operationId) || null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* List all operations
|
|
184
|
+
*
|
|
185
|
+
* @param {Object} opts - List options
|
|
186
|
+
* @param {string} [opts.status] - Filter by status
|
|
187
|
+
* @returns {Array<Object>} Operations
|
|
188
|
+
*/
|
|
189
|
+
listOperations(opts = {}) {
|
|
190
|
+
let ops = Array.from(this.operations.values());
|
|
191
|
+
|
|
192
|
+
if (opts.status) {
|
|
193
|
+
ops = ops.filter(op => op.status === opts.status);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return ops.map(op => ({
|
|
197
|
+
id: op.id,
|
|
198
|
+
description: op.description,
|
|
199
|
+
status: op.status,
|
|
200
|
+
created: op.created,
|
|
201
|
+
completed: op.completed,
|
|
202
|
+
error: op.error ? op.error.message : null,
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Enable auto-snapshot on file changes
|
|
208
|
+
*
|
|
209
|
+
* @param {Object} opts - Options
|
|
210
|
+
* @param {number} [opts.debounceMs=1000] - Debounce delay in ms
|
|
211
|
+
*/
|
|
212
|
+
async enableAutoSnapshot(opts = {}) {
|
|
213
|
+
const debounceMs = opts.debounceMs || 1000;
|
|
214
|
+
|
|
215
|
+
const watcherId = await this.watch(this.dir, (event, filename) => {
|
|
216
|
+
if (filename && filename.startsWith('.jj/')) {
|
|
217
|
+
return; // Ignore .jj directory changes
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (filename && filename.startsWith('.git/')) {
|
|
221
|
+
return; // Ignore .git directory changes
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Debounce: wait for changes to settle
|
|
225
|
+
const existingTimer = this.timers.get(watcherId);
|
|
226
|
+
if (existingTimer) {
|
|
227
|
+
clearTimeout(existingTimer);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const timer = setTimeout(async () => {
|
|
231
|
+
try {
|
|
232
|
+
// Create automatic snapshot
|
|
233
|
+
await this.queue(async () => {
|
|
234
|
+
await this.jj.describe({ message: `Auto-snapshot: ${filename || 'multiple files'} changed` });
|
|
235
|
+
}, { description: 'auto-snapshot' });
|
|
236
|
+
} catch (error) {
|
|
237
|
+
console.error('Auto-snapshot failed:', error);
|
|
238
|
+
} finally {
|
|
239
|
+
// Clean up timer reference
|
|
240
|
+
this.timers.delete(watcherId);
|
|
241
|
+
}
|
|
242
|
+
}, debounceMs);
|
|
243
|
+
|
|
244
|
+
// Use unref() to allow process to exit if this is the only pending timer
|
|
245
|
+
if (timer.unref) {
|
|
246
|
+
timer.unref();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
this.timers.set(watcherId, timer);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
return watcherId;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Clean up completed operations
|
|
257
|
+
*
|
|
258
|
+
* @param {number} [maxAge] - Max age in ms (default: 1 hour)
|
|
259
|
+
*/
|
|
260
|
+
cleanupOperations(maxAge = 3600000) {
|
|
261
|
+
const cutoff = Date.now() - maxAge;
|
|
262
|
+
|
|
263
|
+
for (const [id, op] of this.operations.entries()) {
|
|
264
|
+
if (op.status === 'completed' || op.status === 'failed') {
|
|
265
|
+
const completedTime = new Date(op.completed).getTime();
|
|
266
|
+
if (completedTime < cutoff) {
|
|
267
|
+
this.operations.delete(id);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BookmarkStore - Manages local and remote bookmarks
|
|
3
|
+
*
|
|
4
|
+
* Bookmarks are named pointers to changes, similar to Git branches.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { JJError } from '../utils/errors.js';
|
|
8
|
+
import { validateChangeId, validateBookmarkName } from '../utils/validation.js';
|
|
9
|
+
|
|
10
|
+
export class BookmarkStore {
|
|
11
|
+
/**
|
|
12
|
+
* @param {Storage} storage - Storage manager instance
|
|
13
|
+
*/
|
|
14
|
+
constructor(storage) {
|
|
15
|
+
this.storage = storage;
|
|
16
|
+
this.local = new Map();
|
|
17
|
+
this.remote = new Map(); // remote → (bookmark → changeId)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Initialize empty bookmark store
|
|
22
|
+
*/
|
|
23
|
+
async init() {
|
|
24
|
+
this.local.clear();
|
|
25
|
+
this.remote.clear();
|
|
26
|
+
|
|
27
|
+
await this.storage.write('repo/store/bookmarks.json', {
|
|
28
|
+
version: 1,
|
|
29
|
+
local: {},
|
|
30
|
+
remote: {},
|
|
31
|
+
tracked: {},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Load bookmarks from storage
|
|
37
|
+
*/
|
|
38
|
+
async load() {
|
|
39
|
+
const data = await this.storage.read('repo/store/bookmarks.json');
|
|
40
|
+
|
|
41
|
+
if (!data) {
|
|
42
|
+
throw new JJError('STORAGE_CORRUPT', 'repo/store/bookmarks.json not found', {
|
|
43
|
+
suggestion: 'Initialize repository with init()',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (data.version !== 1) {
|
|
48
|
+
throw new JJError(
|
|
49
|
+
'STORAGE_VERSION_MISMATCH',
|
|
50
|
+
`Unsupported bookmarks version: ${data.version}`,
|
|
51
|
+
{
|
|
52
|
+
version: data.version,
|
|
53
|
+
suggestion: 'Upgrade isomorphic-jj or run migration tool',
|
|
54
|
+
}
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.local.clear();
|
|
59
|
+
this.remote.clear();
|
|
60
|
+
|
|
61
|
+
// Load local bookmarks
|
|
62
|
+
for (const [name, target] of Object.entries(data.local)) {
|
|
63
|
+
this.local.set(name, target);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Load remote bookmarks
|
|
67
|
+
for (const [remoteName, bookmarks] of Object.entries(data.remote)) {
|
|
68
|
+
const remoteBookmarks = new Map();
|
|
69
|
+
for (const [name, target] of Object.entries(bookmarks)) {
|
|
70
|
+
remoteBookmarks.set(name, target);
|
|
71
|
+
}
|
|
72
|
+
this.remote.set(remoteName, remoteBookmarks);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Save bookmarks to storage
|
|
78
|
+
*/
|
|
79
|
+
async save() {
|
|
80
|
+
const local = {};
|
|
81
|
+
for (const [name, target] of this.local.entries()) {
|
|
82
|
+
local[name] = target;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const remote = {};
|
|
86
|
+
for (const [remoteName, bookmarks] of this.remote.entries()) {
|
|
87
|
+
remote[remoteName] = {};
|
|
88
|
+
for (const [name, target] of bookmarks.entries()) {
|
|
89
|
+
remote[remoteName][name] = target;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await this.storage.write('repo/store/bookmarks.json', {
|
|
94
|
+
version: 1,
|
|
95
|
+
local,
|
|
96
|
+
remote,
|
|
97
|
+
tracked: {},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Set a local bookmark
|
|
103
|
+
*
|
|
104
|
+
* @param {string} name - Bookmark name
|
|
105
|
+
* @param {string} target - Change ID to point to
|
|
106
|
+
*/
|
|
107
|
+
async set(name, target) {
|
|
108
|
+
validateBookmarkName(name);
|
|
109
|
+
validateChangeId(target);
|
|
110
|
+
|
|
111
|
+
if (this.local.has(name)) {
|
|
112
|
+
throw new JJError('BOOKMARK_EXISTS', `Bookmark ${name} already exists`, {
|
|
113
|
+
name,
|
|
114
|
+
suggestion: 'Use move() to update existing bookmark',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
this.local.set(name, target);
|
|
119
|
+
await this.save();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Move an existing local bookmark
|
|
124
|
+
*
|
|
125
|
+
* @param {string} name - Bookmark name
|
|
126
|
+
* @param {string} target - New change ID to point to
|
|
127
|
+
*/
|
|
128
|
+
async move(name, target) {
|
|
129
|
+
validateBookmarkName(name);
|
|
130
|
+
validateChangeId(target);
|
|
131
|
+
|
|
132
|
+
if (!this.local.has(name)) {
|
|
133
|
+
throw new JJError('BOOKMARK_NOT_FOUND', `Bookmark ${name} not found`, {
|
|
134
|
+
name,
|
|
135
|
+
suggestion: 'Use set() to create a new bookmark',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.local.set(name, target);
|
|
140
|
+
await this.save();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Delete a local bookmark
|
|
145
|
+
*
|
|
146
|
+
* @param {string} name - Bookmark name
|
|
147
|
+
*/
|
|
148
|
+
async delete(name) {
|
|
149
|
+
validateBookmarkName(name);
|
|
150
|
+
|
|
151
|
+
if (!this.local.has(name)) {
|
|
152
|
+
throw new JJError('BOOKMARK_NOT_FOUND', `Bookmark ${name} not found`, {
|
|
153
|
+
name,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.local.delete(name);
|
|
158
|
+
await this.save();
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Get bookmark target
|
|
163
|
+
*
|
|
164
|
+
* @param {string} name - Bookmark name
|
|
165
|
+
* @returns {Promise<string|null>} Change ID or null if not found
|
|
166
|
+
*/
|
|
167
|
+
async get(name) {
|
|
168
|
+
return this.local.get(name) || null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Set a remote bookmark
|
|
173
|
+
*
|
|
174
|
+
* @param {string} remoteName - Remote name
|
|
175
|
+
* @param {string} name - Bookmark name
|
|
176
|
+
* @param {string} target - Change ID to point to
|
|
177
|
+
*/
|
|
178
|
+
async setRemote(remoteName, name, target) {
|
|
179
|
+
validateBookmarkName(name);
|
|
180
|
+
validateChangeId(target);
|
|
181
|
+
|
|
182
|
+
if (!this.remote.has(remoteName)) {
|
|
183
|
+
this.remote.set(remoteName, new Map());
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
this.remote.get(remoteName).set(name, target);
|
|
187
|
+
await this.save();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Get remote bookmark target
|
|
192
|
+
*
|
|
193
|
+
* @param {string} remoteName - Remote name
|
|
194
|
+
* @param {string} name - Bookmark name
|
|
195
|
+
* @returns {Promise<string|null>} Change ID or null if not found
|
|
196
|
+
*/
|
|
197
|
+
async getRemote(remoteName, name) {
|
|
198
|
+
const remoteBookmarks = this.remote.get(remoteName);
|
|
199
|
+
return remoteBookmarks ? remoteBookmarks.get(name) || null : null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* List all bookmarks (local and remote)
|
|
204
|
+
*
|
|
205
|
+
* @returns {Promise<Array>} Array of bookmark objects
|
|
206
|
+
*/
|
|
207
|
+
async list() {
|
|
208
|
+
const result = [];
|
|
209
|
+
|
|
210
|
+
// Add local bookmarks
|
|
211
|
+
for (const [name, target] of this.local.entries()) {
|
|
212
|
+
result.push({
|
|
213
|
+
name,
|
|
214
|
+
changeId: target,
|
|
215
|
+
remote: null,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Add remote bookmarks
|
|
220
|
+
for (const [remoteName, bookmarks] of this.remote.entries()) {
|
|
221
|
+
for (const [name, target] of bookmarks.entries()) {
|
|
222
|
+
result.push({
|
|
223
|
+
name: `${remoteName}/${name}`,
|
|
224
|
+
changeId: target,
|
|
225
|
+
remote: remoteName,
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
}
|