Contact
CoCalc Logo Icon
StoreFeaturesDocsShareSupport News AboutSign UpSign In
| Download
Views: 39538
1
###############################################################################
2
#
3
# CoCalc: Collaborative Calculation in the Cloud
4
#
5
# Copyright (C) 2016, Sagemath Inc.
6
#
7
# This program is free software: you can redistribute it and/or modify
8
# it under the terms of the GNU General Public License as published by
9
# the Free Software Foundation, either version 3 of the License, or
10
# (at your option) any later version.
11
#
12
# This program is distributed in the hope that it will be useful,
13
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15
# GNU General Public License for more details.
16
#
17
# You should have received a copy of the GNU General Public License
18
# along with this program. If not, see <http://www.gnu.org/licenses/>.
19
#
20
###############################################################################
21
22
###
23
Database-backed time-log database-based synchronized editing
24
###
25
26
# How big of files we allow users to open using syncstrings.
27
MAX_FILE_SIZE_MB = 2
28
29
# Client -- when it has this syncstring open and connected -- will touch the
30
# syncstring every so often so that it stays opened in the local hub,
31
# when the local hub is running.
32
TOUCH_INTERVAL_M = 10
33
34
# How often the local hub will autosave this file to disk if it has it open and
35
# there are unsaved changes. This is very important since it ensures that a user that
36
# edits a file but doesn't click "Save" and closes their browser (right after their edits
37
# have gone to the databse), still has their file saved to disk soon. This is important,
38
# e.g., for homework getting collected and not missing the last few changes. It turns out
39
# this is what people expect!
40
# Set to 0 to disable. (But don't do that.)
41
LOCAL_HUB_AUTOSAVE_S = 120
42
#LOCAL_HUB_AUTOSAVE_S = 5
43
44
# If the client becomes disconnected from the backend for more than this long
45
# the---on reconnect---do extra work to ensure that all snapshots are up to
46
# date (in case snapshots were made when we were offline), and mark the sent
47
# field of patches that weren't saved.
48
OFFLINE_THRESH_S = 5*60
49
50
{EventEmitter} = require('events')
51
immutable = require('immutable')
52
underscore = require('underscore')
53
54
node_uuid = require('uuid')
55
async = require('async')
56
57
misc = require('./misc')
58
{sagews} = require('./sagews')
59
60
schema = require('./schema')
61
62
{Evaluator} = require('./syncstring_evaluator')
63
64
{diff_match_patch} = require('./dmp')
65
dmp = new diff_match_patch()
66
dmp.Diff_Timeout = 0.2 # computing a diff won't block longer than about 0.2s
67
exports.dmp = dmp
68
69
{defaults, required} = misc
70
71
# Here's what a diff-match-patch patch looks like
72
#
73
# [{"diffs":[[1,"{\"x\":5,\"y\":3}"]],"start1":0,"start2":0,"length1":0,"length2":13},...]
74
#
75
compress_patch = (patch) ->
76
([p.diffs, p.start1, p.start2, p.length1, p.length2] for p in patch)
77
78
decompress_patch = (patch) ->
79
({diffs:p[0], start1:p[1], start2:p[2], length1:p[3], length2:p[4]} for p in patch)
80
81
# patch that transforms s0 into s1
82
exports.make_patch = make_patch = (s0, s1) ->
83
p = compress_patch(dmp.patch_make(s0, s1))
84
#console.log("make_patch: #{misc.to_json(p)}")
85
return p
86
87
exports.apply_patch = apply_patch = (patch, s) ->
88
try
89
x = dmp.patch_apply(decompress_patch(patch), s)
90
#console.log('patch_apply ', misc.to_json(decompress_patch(patch)), x)
91
catch err
92
# If a patch is so corrupted it can't be parsed -- e.g., due to a bug in SMC -- we at least
93
# want to make application the identity map, so the document isn't completely unreadable!
94
console.warn("apply_patch -- #{err}")
95
return [s, false]
96
clean = true
97
for a in x[1]
98
if not a
99
clean = false
100
break
101
return [x[0], clean]
102
103
patch_cmp = (a, b) ->
104
return misc.cmp_array([a.time - 0, a.user_id], [b.time - 0, b.user_id])
105
106
time_cmp = (a,b) ->
107
return a - b # sorting Date objects doesn't work perfectly!
108
109
# Do a 3-way **string** merge by computing patch that transforms
110
# base to remote, then applying that patch to local.
111
exports.three_way_merge = (opts) ->
112
opts = defaults opts,
113
base : required
114
local : required
115
remote : required
116
if opts.base == opts.remote # trivial special case...
117
return opts.local
118
return dmp.patch_apply(dmp.patch_make(opts.base, opts.remote), opts.local)[0]
119
120
121
###
122
The PatchValueCache is used to cache values returned
123
by SortedPatchList.value. Caching is critical, since otherwise
124
the client may have to apply hundreds of patches after ever
125
few keystrokes, which would make SMC unusable. Also, the
126
history browser is very painful to use without caching.
127
###
128
MAX_PATCHLIST_CACHE_SIZE = 20
129
class PatchValueCache
130
constructor: () ->
131
@cache = {}
132
133
# Remove everything from the value cache that has timestamp >= time.
134
# If time not defined, removes everything, thus emptying the cache.
135
invalidate: (time) =>
136
if not time?
137
@cache = {}
138
return
139
time0 = time - 0
140
for tm, _ of @cache
141
if tm >= time0
142
delete @cache[tm]
143
return
144
145
# Ensure the value cache doesn't have too many entries in it by
146
# removing all but n of the ones that have not been accessed recently.
147
prune: (n) =>
148
v = []
149
for time, x of @cache
150
v.push({time:time, last_used:x.last_used})
151
if v.length <= n
152
# nothing to do
153
return
154
v.sort((a,b) -> misc.cmp_Date(a.last_used, b.last_used))
155
for x in v.slice(0, v.length - n)
156
delete @cache[x.time]
157
return
158
159
# Include the given value at the given point in time, which should be
160
# the output of @value(time), and should involve applying all patches
161
# up to @_patches[start-1].
162
include: (time, value, start) =>
163
@cache[time - 0] = {time:time, value:value, start:start, last_used:new Date()}
164
return
165
166
# Return the newest value x with x.time <= time in the cache as an object
167
# x={time:time, value:value, start:start},
168
# where @value(time) is the given value, and it was obtained
169
# by applying the elements of @_patches up to @_patches[start-1]
170
# Return undefined if there are no cached values.
171
# If time is undefined, returns the newest value in the cache.
172
# If strict is true, returns newest value at time strictly older than time
173
newest_value_at_most: (time, strict=false) =>
174
v = misc.keys(@cache)
175
if v.length == 0
176
return
177
v.sort(misc.cmp)
178
v.reverse()
179
if not time?
180
return @get(v[0])
181
time0 = time - 0
182
for t in v
183
if (not strict and t <= time0) or (strict and t < time0)
184
return @get(t)
185
return
186
187
# Return cached value corresponding to the given point in time.
188
# Here time must be either a new Date() object, or a number (ms since epoch).
189
# If there is nothing in the cache for the given time, returns undefined.
190
# Do NOT mutate the returned value.
191
get: (time) =>
192
if typeof(time) != 'number'
193
# also allow dates
194
time = time - 0
195
x = @cache[time]
196
if not x?
197
return
198
x.last_used = new Date() # this is only for the client cache, so fine to use browser's clock
199
return x
200
201
oldest_time: () =>
202
v = misc.keys(@cache)
203
if v.length == 0
204
return
205
v.sort(misc.cmp)
206
return new Date(parseInt(v[0]))
207
208
# Number of cached values
209
size: () =>
210
return misc.len(@cache)
211
212
# Sorted list of patches applied to a string
213
class SortedPatchList extends EventEmitter
214
constructor: (@_from_str) ->
215
@_patches = []
216
@_times = {}
217
@_cache = new PatchValueCache()
218
@_snapshot_times = {}
219
220
close: () =>
221
@removeAllListeners()
222
delete @_patches
223
delete @_times
224
delete @_cache
225
delete @_snapshot_times
226
227
# Choose the next available time in ms that is congruent to m modulo n.
228
# The congruence condition is so that any time collision will have to be
229
# with a single person editing a document with themselves -- two different
230
# users are guaranteed to not collide. Note: even if there is a collision,
231
# it will automatically fix itself very quickly.
232
next_available_time: (time, m=0, n=1) =>
233
if misc.is_date(time)
234
t = time - 0
235
else
236
t = time
237
238
if n <= 0
239
n = 1
240
a = m - (t%n)
241
if a < 0
242
a += n
243
t += a # now t = m (mod n)
244
while @_times[t]?
245
t += n
246
return new Date(t)
247
248
add: (patches) =>
249
if patches.length == 0
250
# nothing to do
251
return
252
#console.log("SortedPatchList.add: #{misc.to_json(patches)}")
253
v = []
254
oldest = undefined
255
for x in patches
256
if x?
257
if not misc.is_date(x.time)
258
# ensure that time is not a string representation of a time
259
try
260
x.time = misc.ISO_to_Date(x.time)
261
if isNaN(x.time) # ignore bad times
262
continue
263
catch err
264
# ignore invalid times
265
continue
266
t = x.time - 0
267
cur = @_times[t]
268
if cur?
269
# Note: cur.prev and x.prev are Date objects, so must put + before them to convert to numbers and compare.
270
if underscore.isEqual(cur.patch, x.patch) and cur.user_id == x.user_id and cur.snapshot == x.snapshot and +cur.prev == +x.prev
271
# re-inserting exactly the same thing; nothing at all to do
272
continue
273
else
274
# adding snapshot or timestamp collision -- remove duplicate
275
#console.log "overwriting patch #{misc.to_json(t)}"
276
# remove patch with same timestamp from the sorted list of patches
277
@_patches = (y for y in @_patches when y.time - 0 != t)
278
@emit('overwrite', t)
279
v.push(x)
280
@_times[t] = x
281
if not oldest? or oldest > x.time
282
oldest = x.time
283
if x.snapshot?
284
@_snapshot_times[t] = true
285
if oldest?
286
@_cache.invalidate(oldest)
287
288
# this is O(n*log(n)) where n is the length of @_patches and patches;
289
# better would be an insertion sort which would be O(m*log(n)) where m=patches.length...
290
if v.length > 0
291
delete @_versions_cache
292
@_patches = @_patches.concat(v)
293
@_patches.sort(patch_cmp)
294
295
newest_snapshot_time: () =>
296
t0 = 0
297
for t of @_snapshot_times
298
t = parseInt(t)
299
if t > t0
300
t0 = t
301
return new Date(t0)
302
303
###
304
value: Return the value of the string at the given (optional)
305
point in time. If the optional time is given, only include patches up
306
to (and including) the given time; otherwise, return current value.
307
308
If force is true, doesn't use snapshot at given input time, even if
309
there is one; this is used to update snapshots in case of offline changes
310
getting inserted into the changelog.
311
312
If without is defined, it must be an array of Date objects; in that case
313
the current value of the string is computed, but with all the patches
314
at the given times in "without" ignored. This is used elsewhere as a building
315
block to implement undo.
316
###
317
value: (time, force=false, without_times=undefined) =>
318
#start_time = new Date()
319
# If the time is specified, verify that it is valid; otherwise, convert it to a valid time.
320
if time? and not misc.is_date(time)
321
time = misc.ISO_to_Date(time)
322
if without_times?
323
if not misc.is_array(without_times)
324
throw Error("without_times must be an array")
325
if without_times.length > 0
326
v = {}
327
without = undefined
328
for x in without_times
329
if not misc.is_date(x)
330
throw Error("each without_times entry must be a date")
331
v[+x] = true # convert to number
332
if not without? or x < without
333
without = x
334
if time? and +time < without
335
# requesting value at time before any without, so without is not relevant, so ignore.
336
without = undefined
337
without_times = undefined
338
else
339
without_times = v # change to map from time in ms to true.
340
341
prev_cutoff = @newest_snapshot_time()
342
# Determine oldest cached value
343
oldest_cached_time = @_cache.oldest_time() # undefined if nothing cached
344
# If the oldest cached value exists and is at least as old as the requested
345
# point in time, use it as a base.
346
if oldest_cached_time? and (not time? or +time >= +oldest_cached_time) and (not without? or +without > +oldest_cached_time)
347
# There is something in the cache, and it is at least as far back in time
348
# as the value we want to compute now.
349
if without?
350
cache = @_cache.newest_value_at_most(without, true) # true makes "at most" strict, so <.
351
else
352
cache = @_cache.newest_value_at_most(time)
353
value = cache.value
354
start = cache.start
355
cache_time = cache.time
356
for x in @_patches.slice(cache.start, @_patches.length) # all patches starting with the cached one
357
if time? and x.time > time
358
# Done -- no more patches need to be applied
359
break
360
if not x.prev? or @_times[x.prev - 0] or +x.prev >= +prev_cutoff
361
if not without? or (without? and not without_times[+x.time])
362
# apply patch x to update value to be closer to what we want
363
value = value.apply_patch(x.patch)
364
cache_time = x.time # also record the time of the last patch we applied.
365
start += 1
366
if not without? and (not time? or start - cache.start >= 10)
367
# Newest -- or at least 10 patches needed to be applied -- so cache result
368
@_cache.include(cache_time, value, start)
369
@_cache.prune(Math.max(3, Math.min(Math.ceil(30000000/value.length), MAX_PATCHLIST_CACHE_SIZE)))
370
else
371
# Cache is empty or doesn't have anything sufficiently old to be useful.
372
# Find the newest snapshot at a time that is <= time.
373
value = @_from_str('') # default in case no snapshots
374
start = 0
375
if @_patches.length > 0 # otherwise the [..] notation below has surprising behavior
376
for i in [@_patches.length-1 .. 0]
377
if (not time? or +@_patches[i].time <= +time) and @_patches[i].snapshot?
378
if force and +@_patches[i].time == +time
379
# If force is true we do NOT want to use the existing snapshot, since
380
# the whole point is to force recomputation of it, as it is wrong.
381
# Instead, we'll use the previous snapshot.
382
continue
383
# Found a patch with known snapshot that is as old as the time.
384
# This is the base on which we will apply other patches to move forward
385
# to the requested time.
386
value = @_from_str(@_patches[i].snapshot)
387
start = i + 1
388
break
389
# Apply each of the patches we need to get from
390
# value (the last snapshot) to time.
391
cache_time = 0
392
cache_start = start
393
for x in @_patches.slice(start, @_patches.length)
394
if time? and x.time > time
395
# Done -- no more patches need to be applied
396
break
397
# Apply a patch to move us forward.
398
#console.log("applying patch #{i}")
399
if not x.prev? or @_times[x.prev - 0] or +x.prev >= +prev_cutoff
400
if not without? or (without? and not without_times[+x.time])
401
value = value.apply_patch(x.patch)
402
cache_time = x.time
403
cache_start += 1
404
if not without? and (not time? or cache_time and cache_start - start >= 10)
405
# Newest -- or at least 10 patches needed to be applied -- so
406
# update the cache with our new known value
407
@_cache.include(cache_time, value, cache_start)
408
@_cache.prune(Math.max(3, Math.min(Math.ceil(30000000/value.length), MAX_PATCHLIST_CACHE_SIZE)))
409
410
#console.log("value: time=#{new Date() - start_time}")
411
# Use the following only for testing/debugging, since it will make everything VERY slow.
412
#if @_value_no_cache(time) != value
413
# console.warn("value for time #{time-0} is wrong!")
414
return value
415
416
# VERY Slow -- only for consistency checking purposes and debugging.
417
# If force=true, don't use snapshots.
418
_value_no_cache: (time, snapshots=true) =>
419
value = @_from_str('') # default in case no snapshots
420
start = 0
421
if snapshots and @_patches.length > 0 # otherwise the [..] notation below has surprising behavior
422
for i in [@_patches.length-1 .. 0]
423
if (not time? or +@_patches[i].time <= +time) and @_patches[i].snapshot?
424
# Found a patch with known snapshot that is as old as the time.
425
# This is the base on which we will apply other patches to move forward
426
# to the requested time.
427
value = @_from_str(@_patches[i].snapshot)
428
start = i + 1
429
break
430
# Apply each of the patches we need to get from
431
# value (the last snapshot) to time.
432
for x in @_patches.slice(start, @_patches.length)
433
if time? and x.time > time
434
# Done -- no more patches need to be applied
435
break
436
value = value.apply_patch(x.patch)
437
return value
438
439
# For testing/debugging. Go through the complete patch history and
440
# verify that all snapshots are correct (or not -- in which case say so).
441
_validate_snapshots: =>
442
if @_patches.length == 0
443
return
444
i = 0
445
if @_patches[0].snapshot?
446
i += 1
447
value = @_from_str(@_patches[0].snapshot)
448
else
449
value = @_from_str('')
450
for x in @_patches.slice(i)
451
value = value.apply_patch(x.patch)
452
if x.snapshot?
453
snapshot_value = @_from_str(x.snapshot)
454
if not value.is_equal(snapshot_value)
455
console.log("FAIL (#{x.time}): at #{i}")
456
console.log("diff(snapshot, correct)=")
457
console.log(JSON.stringify(value.make_patch(snapshot_value)))
458
else
459
console.log("GOOD (#{x.time}): snapshot at #{i} by #{x.user_id}")
460
i += 1
461
return
462
463
# integer index of user who made the edit at given point in time (or undefined)
464
user_id: (time) =>
465
return @patch(time)?.user_id
466
467
time_sent: (time) =>
468
return @patch(time)?.sent
469
470
# patch at a given point in time
471
# TODO: optimization -- this shouldn't be a linear search!!
472
patch: (time) =>
473
for x in @_patches
474
if +x.time == +time
475
return x
476
477
versions: () =>
478
# Compute and cache result,then return it; result gets cleared when new patches added.
479
return @_versions_cache ?= (x.time for x in @_patches)
480
481
# Show the history of this document; used mainly for debugging purposes.
482
show_history: (opts={}) =>
483
opts = defaults opts,
484
milliseconds : false
485
trunc : 80
486
log : console.log
487
s = undefined
488
i = 0
489
prev_cutoff = @newest_snapshot_time()
490
for x in @_patches
491
tm = x.time
492
tm = if opts.milliseconds then tm - 0 else tm.toLocaleString()
493
opts.log("-----------------------------------------------------\n", i, x.user_id, tm, misc.trunc_middle(JSON.stringify(x.patch), opts.trunc))
494
if not s?
495
s = @_from_str(x.snapshot ? '')
496
if not x.prev? or @_times[x.prev - 0] or +x.prev >= +prev_cutoff
497
t = s.apply_patch(x.patch)
498
else
499
opts.log("prev=#{x.prev} missing, so not applying")
500
s = t
501
opts.log((if x.snapshot then "(SNAPSHOT) " else " "), if s? then JSON.stringify(misc.trunc_middle(s.to_str(), opts.trunc).trim()))
502
i += 1
503
return
504
505
# If the number of patches since the most recent snapshot is >= 2*interval,
506
# make a snapshot at the patch that is interval steps forward from
507
# the most recent snapshot. This function returns the time at which we
508
# must make a snapshot.
509
time_of_unmade_periodic_snapshot: (interval) =>
510
n = @_patches.length - 1
511
if n < 2*interval
512
# definitely no need to make a snapshot
513
return
514
for i in [n .. n - 2*interval]
515
if @_patches[i].snapshot?
516
if i + interval + interval <= n
517
return @_patches[i + interval].time
518
else
519
# found too-recent snapshot so don't need to make another one
520
return
521
# No snapshot found at all -- maybe old ones were deleted.
522
# We return the time at which we should have the *newest* snapshot.
523
# This is the largest multiple i of interval that is <= n - interval
524
i = Math.floor((n - interval) / interval) * interval
525
return @_patches[i]?.time
526
527
# Times of all snapshots in memory on this client; these are the only ones
528
# we need to worry about for offline patches...
529
snapshot_times: =>
530
return (x.time for x in @_patches when x.snapshot?)
531
532
newest_patch_time: =>
533
return @_patches[@_patches.length-1]?.time
534
535
count: =>
536
return @_patches.length
537
538
539
# For testing purposes
540
exports.SortedPatchList = SortedPatchList
541
542
###
543
The SyncDoc class enables synchronized editing of a document that can be represented by a string.
544
545
EVENTS:
546
547
- 'change' event whenever the document is changed *remotely* (NOT locally), and also once
548
when document is initialized.
549
550
- 'user_change' when the string is definitely changed locally (so a new patch is recorded)
551
552
STATES:
553
554
555
###
556
557
class SyncDoc extends EventEmitter
558
constructor: (opts) ->
559
@_opts = opts = defaults opts,
560
save_interval : 1500
561
cursor_interval : 2000
562
patch_interval : 1000 # debouncing of incoming upstream patches
563
file_use_interval : 'default' # throttles: default is 60s for everything except .sage-chat files, where it is 10s.
564
string_id : undefined
565
project_id : required # project_id that contains the doc
566
path : required # path of the file corresponding to the doc
567
client : required
568
cursors : false # if true, also provide cursor tracking functionality
569
from_str : required # creates a doc from a string.
570
doctype : undefined # optional object describing document constructor (used by project to open file)
571
from_patch_str : JSON.parse
572
if not opts.string_id?
573
opts.string_id = schema.client_db.sha1(opts.project_id, opts.path)
574
575
@_closed = true
576
@_string_id = opts.string_id
577
@_project_id = opts.project_id
578
@_path = opts.path
579
@_client = opts.client
580
@_from_str = opts.from_str
581
@_from_patch_str = opts.from_patch_str
582
@_doctype = opts.doctype
583
@_patch_format = opts.doctype.patch_format
584
@_save_interval = opts.save_interval
585
@_patch_interval = opts.patch_interval
586
587
@_my_patches = {} # patches that this client made during this editing session.
588
589
# For debugging -- this is a (slight) security risk in production.
590
###
591
if window?
592
window.syncstrings ?= {}
593
window.syncstrings[@_path] = @
594
###
595
596
# window.s = @
597
598
#dbg = @dbg("constructor(path='#{@_path}')")
599
#dbg('connecting...')
600
@connect (err) =>
601
#dbg('connected')
602
if err
603
console.warn("error creating SyncDoc: '#{err}'")
604
@emit('error', err)
605
else
606
if @_client.is_project()
607
# CRITICAL: do not start autosaving this until syncstring is initialized!
608
@init_project_autosave()
609
else
610
# Ensure file is undeleted when explicitly open.
611
@_undelete()
612
613
if opts.file_use_interval and @_client.is_user()
614
is_chat = misc.filename_extension(@_path) == 'sage-chat'
615
if is_chat
616
action = 'chat'
617
else
618
action = 'edit'
619
file_use = () =>
620
@_client.mark_file(project_id:@_project_id, path:@_path, action:action, ttl:opts.file_use_interval)
621
622
@on('user_change', underscore.throttle(file_use, opts.file_use_interval, true))
623
624
if opts.cursors
625
# Initialize throttled cursors functions
626
set_cursor_locs = (locs) =>
627
x =
628
string_id : @_string_id
629
user_id : @_user_id
630
locs : locs
631
time : @_client.server_time()
632
@_cursors?.set(x, 'none')
633
@_throttled_set_cursor_locs = underscore.throttle(set_cursor_locs, @_opts.cursor_interval)
634
635
set_doc: (value) =>
636
if not value?.apply_patch?
637
# Do a sanity check -- see https://github.com/sagemathinc/cocalc/issues/1831
638
throw Error("value must be a document object with apply_patch, etc., methods")
639
@_doc = value
640
return
641
642
# Return underlying document, or undefined if document hasn't been set yet.
643
get_doc: =>
644
return @_doc
645
646
# Set this doc from its string representation.
647
from_str: (value) =>
648
@_doc = @_from_str(value)
649
return
650
651
# Return string representation of this doc, or undefined if the doc hasn't been set yet.
652
to_str: =>
653
return @_doc?.to_str?()
654
655
# Used for internal debug logging
656
dbg: (f) ->
657
return @_client.dbg("SyncString.#{f}:")
658
659
# Version of the document at a given point in time; if no
660
# time specified, gives the version right now.
661
version: (time) =>
662
return @_patch_list?.value(time)
663
664
# Compute version of document if the patches at the given times were simply not included.
665
# This is a building block that is used for implementing undo functionality for client editors.
666
version_without: (times) =>
667
return @_patch_list.value(undefined, undefined, times)
668
669
revert: (version) =>
670
@set_doc(@version(version))
671
return
672
673
# Undo/redo public api.
674
# Calling @undo and @redo returns the version of the document after
675
# the undo or redo operation, but does NOT otherwise change anything!
676
# The caller can then what they please with that output (e.g., update the UI).
677
# The one state change is that the first time calling @undo or @redo switches
678
# into undo/redo state in which additional calls to undo/redo
679
# move up and down the stack of changes made by this user during this session.
680
# Call @exit_undo_mode() to exit undo/redo mode.
681
# Undo and redo *only* impact changes made by this user during this session.
682
# Other users edits are unaffected, and work by this same user working from another
683
# browser tab or session is also unaffected.
684
#
685
# Finally, undo of a past patch by definition means "the state of the document"
686
# if that patch was not applied. The impact of undo is NOT that the patch is
687
# removed from the patch history; instead it just returns a document here that
688
# the client can do something with, which may result in future patches. Thus
689
# clients could implement a number of different undo strategies without impacting
690
# other clients code at all.
691
undo: () =>
692
state = @_undo_state
693
if not state?
694
# not in undo mode
695
state = @_undo_state = @_init_undo_state()
696
if state.pointer == state.my_times.length
697
# pointing at live state (e.g., happens on entering undo mode)
698
value = @version() # last saved version
699
live = @_doc
700
if not live.is_equal(value)
701
# User had unsaved changes, so last undo is to revert to version without those.
702
state.final = value.make_patch(live) # live redo if needed
703
state.pointer -= 1 # most recent timestamp
704
return value
705
else
706
# User had no unsaved changes, so last undo is version without last saved change.
707
tm = state.my_times[state.pointer - 1]
708
state.pointer -= 2
709
if tm?
710
state.without.push(tm)
711
return @version_without(state.without)
712
else
713
# no undo information during this session
714
return value
715
else
716
# pointing at particular timestamp in the past
717
if state.pointer >= 0
718
# there is still more to undo
719
state.without.push(state.my_times[state.pointer])
720
state.pointer -= 1
721
return @version_without(state.without)
722
723
redo: () =>
724
state = @_undo_state
725
if not state?
726
# nothing to do but return latest live version
727
return @get_doc()
728
if state.pointer == state.my_times.length
729
# pointing at live state -- nothing to do
730
return @get_doc()
731
else if state.pointer == state.my_times.length - 1
732
# one back from live state, so apply unsaved patch to live version
733
state.pointer += 1
734
return @version().apply_patch(state.final)
735
else
736
# at least two back from live state
737
state.without.pop()
738
state.pointer += 1
739
if not state.final? and state.pointer == state.my_times.length - 1
740
# special case when there wasn't any live change
741
state.pointer += 1
742
return @version_without(state.without)
743
744
in_undo_mode: () =>
745
return @_undo_state?
746
747
exit_undo_mode: () =>
748
delete @_undo_state
749
750
_init_undo_state: () =>
751
if @_undo_state?
752
@_undo_state
753
state = @_undo_state = {}
754
state.my_times = (new Date(parseInt(x)) for x in misc.keys(@_my_patches))
755
state.my_times.sort(misc.cmp_Date)
756
state.pointer = state.my_times.length
757
state.without = []
758
return state
759
760
# Make it so the local hub project will automatically save the file to disk periodically.
761
init_project_autosave: () =>
762
if not LOCAL_HUB_AUTOSAVE_S or not @_client.is_project() or @_project_autosave?
763
return
764
#dbg = @dbg("autosave")
765
#dbg("initializing")
766
f = () =>
767
#dbg('checking')
768
if @hash_of_saved_version()? and @has_unsaved_changes()
769
#dbg("doing")
770
@_save_to_disk()
771
@_project_autosave = setInterval(f, LOCAL_HUB_AUTOSAVE_S*1000)
772
773
# account_id of the user who made the edit at
774
# the given point in time.
775
account_id: (time) =>
776
return @_users[@user_id(time)]
777
778
# Approximate time when patch with given timestamp was
779
# actually sent to the server; returns undefined if time
780
# sent is approximately the timestamp time. Only not undefined
781
# when there is a significant difference.
782
time_sent: (time) =>
783
@_patch_list.time_sent(time)
784
785
# integer index of user who made the edit at given
786
# point in time.
787
user_id: (time) =>
788
return @_patch_list.user_id(time)
789
790
# Indicate active interest in syncstring; only updates time
791
# if last_active is at least min_age_m=5 minutes old (so this can be safely
792
# called frequently without too much load). We do *NOT* use
793
# "@_syncstring_table.set(...)" below because it is critical to
794
# to be able to do the touch before @_syncstring_table gets initialized,
795
# since otherwise the initial open a file will be very slow.
796
touch: (min_age_m=5) =>
797
if @_client.is_project()
798
return
799
if min_age_m > 0
800
# if min_age_m is 0 always do it immediately; if > 0 check what it was:
801
last_active = @_syncstring_table?.get_one().get('last_active')
802
# if not defined or not set recently, do it.
803
if not (not last_active? or +last_active <= +misc.server_minutes_ago(min_age_m))
804
return
805
# Now actually do the set.
806
@_client.query
807
query :
808
syncstrings :
809
string_id : @_string_id
810
project_id : @_project_id
811
path : @_path
812
deleted : @_deleted
813
last_active : misc.server_time()
814
doctype : misc.to_json(@_doctype) # important to set here, since this is when syncstring is often first created
815
816
# The project calls this once it has checked for the file on disk; this
817
# way the frontend knows that the syncstring has been initialized in
818
# the database, and also if there was an error doing the check.
819
_set_initialized: (error, cb) =>
820
init = {time: misc.server_time()}
821
if error
822
init.error = "error - #{JSON.stringify(error)}" # must be a string!
823
else
824
init.error = ''
825
@_client.query
826
query :
827
syncstrings :
828
string_id : @_string_id
829
project_id : @_project_id
830
path : @_path
831
init : init
832
cb : cb
833
834
# List of timestamps of the versions of this string in the sync
835
# table that we opened to start editing (so starts with what was
836
# the most recent snapshot when we started). The list of timestamps
837
# is sorted from oldest to newest.
838
versions: () =>
839
v = []
840
@_patches_table.get().map (x, id) =>
841
v.push(x.get('time'))
842
v.sort(time_cmp)
843
return v
844
845
# List of all known timestamps of versions of this string, including
846
# possibly much older versions than returned by @versions(), in
847
# case the full history has been loaded. The list of timestamps
848
# is sorted from oldest to newest.
849
all_versions: () =>
850
return @_patch_list?.versions()
851
852
last_changed: () =>
853
v = @versions()
854
if v.length > 0
855
return v[v.length-1]
856
else
857
return new Date(0)
858
859
# Close synchronized editing of this string; this stops listening
860
# for changes and stops broadcasting changes.
861
close: =>
862
if @_closed
863
return
864
@emit('close')
865
@removeAllListeners() # must be after @emit('close') above.
866
@_closed = true
867
if @_periodically_touch?
868
clearInterval(@_periodically_touch)
869
delete @_periodically_touch
870
if @_project_autosave?
871
clearInterval(@_project_autosave)
872
delete @_project_autosave
873
delete @_cursor_throttled
874
delete @_cursor_map
875
delete @_users
876
@_syncstring_table?.close()
877
delete @_syncstring_table
878
@_patches_table?.close()
879
delete @_patches_table
880
@_patch_list?.close()
881
delete @_patch_list
882
@_cursors?.close()
883
delete @_cursors
884
if @_client.is_project()
885
@_update_watch_path() # no input = closes it
886
@_evaluator?.close()
887
delete @_evaluator
888
889
reconnect: (cb) =>
890
@close()
891
@connect(cb)
892
893
connect: (cb) =>
894
if not @_closed
895
cb("already connected")
896
return
897
@touch(0) # critical to do a quick initial touch so file gets opened on the backend
898
query =
899
syncstrings :
900
string_id : @_string_id
901
project_id : @_project_id
902
path : @_path
903
deleted : null
904
users : null
905
last_snapshot : null
906
snapshot_interval : null
907
save : null
908
last_active : null
909
init : null
910
read_only : null
911
last_file_change : null
912
doctype : null
913
914
@_syncstring_table = @_client.sync_table(query)
915
916
@_syncstring_table.once 'connected', =>
917
@_handle_syncstring_update()
918
@_syncstring_table.on('change', @_handle_syncstring_update)
919
async.series([
920
(cb) =>
921
async.parallel([@_init_patch_list, @_init_cursors, @_init_evaluator], cb)
922
(cb) =>
923
@_closed = false
924
if @_client.is_user() and not @_periodically_touch?
925
@touch(1)
926
# touch every few minutes while syncstring is open, so that backend local_hub
927
# (if open) keeps its side open
928
@_periodically_touch = setInterval((=>@touch(TOUCH_INTERVAL_M/2)), 1000*60*TOUCH_INTERVAL_M)
929
if @_client.is_project()
930
@_load_from_disk_if_newer(cb)
931
else
932
cb()
933
], (err) =>
934
if @_closed
935
# disconnected while connecting...
936
cb()
937
return
938
@_syncstring_table.wait
939
until : (t) => t.get_one()?.get('init')
940
cb : (err, init) =>
941
@emit('init', err ? init.toJS().error)
942
if err
943
cb(err)
944
else
945
@emit('change')
946
@emit('connected')
947
cb()
948
)
949
950
# Delete the synchronized string and **all** patches from the database -- basically
951
# delete the complete history of editing this file.
952
# WARNINGS:
953
# (1) If a project has this string open, then things may be messed up, unless that project is restarted.
954
# (2) Only available for the admin user right now.
955
# To use: from a javascript console in the browser as admin, you can do:
956
#
957
# smc.client.sync_string({project_id:'9f2e5869-54b8-4890-8828-9aeba9a64af4', path:'a.txt'}).delete_from_database(console.log)
958
#
959
# Then make sure project and clients refresh.
960
#
961
delete_from_database: (cb) =>
962
async.parallel([
963
(cb) =>
964
@_client.query
965
query :
966
patches_delete :
967
id : [@_string_id]
968
dummy : null # required to force a get query.
969
cb : cb
970
(cb) =>
971
@_client.query
972
query :
973
syncstrings_delete :
974
project_id : @_project_id
975
path : @_path
976
cb : cb
977
], (err)=>cb?(err))
978
979
_update_if_file_is_read_only: (cb) =>
980
@_client.path_access
981
path : @_path
982
mode : 'w'
983
cb : (err) =>
984
@_set_read_only(!!err)
985
cb?()
986
987
_load_from_disk_if_newer: (cb) =>
988
tm = @last_changed()
989
dbg = @_client.dbg("syncstring._load_from_disk_if_newer('#{@_path}')")
990
exists = undefined
991
async.series([
992
(cb) =>
993
dbg("check if path exists")
994
@_client.path_exists
995
path : @_path
996
cb : (err, _exists) =>
997
if err
998
cb(err)
999
else
1000
exists = _exists
1001
cb()
1002
(cb) =>
1003
if not exists
1004
dbg("file does NOT exist")
1005
@_set_read_only(false)
1006
cb()
1007
return
1008
if tm?
1009
dbg("edited before, so stat file")
1010
@_client.path_stat
1011
path : @_path
1012
cb : (err, stats) =>
1013
if err
1014
cb(err)
1015
else if stats.ctime > tm
1016
dbg("disk file changed more recently than edits, so loading")
1017
@_load_from_disk(cb)
1018
else
1019
dbg("stick with database version")
1020
cb()
1021
else
1022
dbg("never edited before")
1023
if exists
1024
dbg("path exists, so load from disk")
1025
@_load_from_disk(cb)
1026
else
1027
cb()
1028
(cb) =>
1029
if exists
1030
@_update_if_file_is_read_only(cb)
1031
else
1032
cb()
1033
], (err) =>
1034
@_set_initialized(err, cb)
1035
)
1036
1037
_patch_table_query: (cutoff) =>
1038
query =
1039
string_id: @_string_id
1040
time : if cutoff then {'>=':cutoff} else null
1041
patch : null # compressed format patch as a JSON *string*
1042
user_id : null # integer id of user (maps to syncstring table)
1043
snapshot : null # (optional) a snapshot at this point in time
1044
sent : null # (optional) when patch actually sent, which may be later than when made
1045
prev : null # (optional) timestamp of previous patch sent from this session
1046
if @_patch_format?
1047
query.format = @_patch_format
1048
return query
1049
1050
_init_patch_list: (cb) =>
1051
# CRITICAL: note that _handle_syncstring_update checks whether
1052
# init_patch_list is done by testing whether @_patch_list is defined!
1053
# That is why we first define "patch_list" below, then set @_patch_list
1054
# to it only after we're done.
1055
delete @_patch_list
1056
1057
patch_list = new SortedPatchList(@_from_str)
1058
1059
@_patches_table = @_client.sync_table({patches : @_patch_table_query(@_last_snapshot)}, \
1060
undefined, @_patch_interval, @_patch_interval)
1061
1062
@_patches_table.once 'connected', =>
1063
patch_list.add(@_get_patches())
1064
doc = patch_list.value()
1065
@_last = @_doc = doc
1066
@_patches_table.on('change', @_handle_patch_update)
1067
@_patches_table.on('before-change', => @emit('before-change'))
1068
@_patch_list = patch_list
1069
cb()
1070
1071
###
1072
TODO/CRITICAL: We are temporarily disabling same-user collision detection, since this seems to be leading to
1073
serious issues involving a feedback loop, which may be way worse than the 1 in a million issue
1074
that this addresses. This only address the *same* account being used simultaneously on the same file
1075
by multiple people which isn't something users should ever do (but they do in big demos).
1076
1077
@_patch_list.on 'overwrite', (t) =>
1078
# ensure that any outstanding save is done
1079
@_patches_table.save () =>
1080
@_check_for_timestamp_collision(t)
1081
###
1082
1083
@_patches_table.on 'saved', (data) =>
1084
@_handle_offline(data)
1085
1086
###
1087
_check_for_timestamp_collision: (t) =>
1088
obj = @_my_patches[t]
1089
if not obj?
1090
return
1091
key = @_patches_table.key(obj)
1092
if obj.patch != @_patches_table.get(key)?.get('patch')
1093
#console.log("COLLISION! #{t}, #{obj.patch}, #{@_patches_table.get(key).get('patch')}")
1094
# We fix the collision by finding the nearest time after time that
1095
# is available, and reinserting our patch at that new time.
1096
@_my_patches[t] = 'killed'
1097
new_time = @_patch_list.next_available_time(new Date(t), @_user_id, @_users.length)
1098
@_save_patch(new_time, JSON.parse(obj.patch))
1099
###
1100
1101
_init_evaluator: (cb) =>
1102
if misc.filename_extension(@_path) == 'sagews'
1103
@_evaluator = new Evaluator(@, cb)
1104
else
1105
cb()
1106
1107
_init_cursors: (cb) =>
1108
if not @_client.is_user()
1109
# only the users care about cursors.
1110
cb()
1111
else
1112
if not @_opts.cursors
1113
cb()
1114
return
1115
query =
1116
cursors :
1117
string_id : @_string_id
1118
user_id : null
1119
locs : null
1120
time : null
1121
@_cursors = @_client.sync_table(query)
1122
@_cursors.once 'connected', =>
1123
# cursors now initialized; first initialize the local @_cursor_map,
1124
# which tracks positions of cursors by account_id:
1125
@_cursor_map = immutable.Map()
1126
@_cursors.get().map (locs, k) =>
1127
@_cursor_map = @_cursor_map.set(@_users[JSON.parse(k)?[1]], locs)
1128
cb()
1129
1130
# @_other_cursors is an immutable.js map from account_id's
1131
# to list of cursor positions of *other* users (starts undefined).
1132
@_cursor_map = undefined
1133
@_cursor_throttled = {} # throttled event emitters for each account_id
1134
emit_cursor_throttled = (account_id) =>
1135
t = @_cursor_throttled[account_id]
1136
if not t?
1137
f = () =>
1138
@emit('cursor_activity', account_id)
1139
t = @_cursor_throttled[account_id] = underscore.throttle(f, @_opts.cursor_interval)
1140
t()
1141
1142
@_cursors.on 'change', (keys) =>
1143
if @_closed
1144
return
1145
for k in keys
1146
account_id = @_users[JSON.parse(k)?[1]]
1147
@_cursor_map = @_cursor_map.set(account_id, @_cursors.get(k))
1148
emit_cursor_throttled(account_id)
1149
1150
# Set this users cursors to the given locs. This function is
1151
# throttled, so calling it many times is safe, and all but
1152
# the last call is discarded.
1153
# NOTE: no-op if only one user or cursors not enabled for this doc
1154
set_cursor_locs: (locs) =>
1155
if @_closed
1156
return
1157
if @_users.length <= 2
1158
# Don't bother in special case when only one user (plus the project -- for 2 above!)
1159
# since we never display the user's
1160
# own cursors - just other user's cursors. This simple optimization will save tons
1161
# of bandwidth, since many files are never opened by more than one user.
1162
return
1163
@_throttled_set_cursor_locs?(locs)
1164
return
1165
1166
# returns immutable.js map from account_id to list of cursor positions, if cursors are enabled.
1167
get_cursors: =>
1168
return @_cursor_map
1169
1170
save_asap: (cb) =>
1171
@_save(cb)
1172
1173
# save any changes we have as a new patch
1174
_save: (cb) =>
1175
#dbg = @dbg('_save'); dbg('saving changes to db')
1176
if @_closed
1177
#dbg("string closed -- can't save")
1178
cb?("string closed")
1179
return
1180
1181
if not @_last?
1182
#dbg("string not initialized -- can't save")
1183
cb?("string not initialized")
1184
return
1185
1186
if @_last.is_equal(@_doc)
1187
#dbg("nothing changed so nothing to save")
1188
cb?()
1189
return
1190
1191
if @_saving # this makes it at least safe to call @_save() directly...
1192
cb?("saving")
1193
return
1194
1195
@_saving = true
1196
1197
# compute transformation from _last to live -- exactly what we did
1198
patch = @_last.make_patch(@_doc)
1199
if not patch?
1200
# document not initialized (or closed) so nothing to save
1201
@_saving = false
1202
cb?()
1203
return
1204
@_last = @_doc
1205
1206
# now save the resulting patch
1207
time = @_client.server_time()
1208
1209
min_time = @_patch_list.newest_patch_time()
1210
if min_time? and min_time >= time
1211
# Ensure that time is newer than *all* already known times.
1212
# This is critical to ensure that patches are saved in order,
1213
# and that the new patch we are making is *on top* of all
1214
# known patches (otherwise it won't apply cleanly, etc.).
1215
time = new Date((min_time - 0) + 1)
1216
1217
time = @_patch_list.next_available_time(time, @_user_id, @_users.length)
1218
1219
# FOR *nasty* worst case DEBUGGING/TESTING ONLY!
1220
##window?.s = @
1221
##time = new Date(Math.floor((time - 0)/10000)*10000) # fake timestamps for testing to cause collisions
1222
1223
@_save_patch(time, patch, cb)
1224
1225
@snapshot_if_necessary()
1226
# Emit event since this syncstring was definitely changed locally.
1227
@emit('user_change')
1228
@_saving = false
1229
1230
_undelete: () =>
1231
if @_closed
1232
return
1233
#@dbg("_undelete")()
1234
@_syncstring_table.set(@_syncstring_table.get_one().set('deleted', false))
1235
1236
_save_patch: (time, patch, cb) =>
1237
if @_closed
1238
cb?('closed')
1239
return
1240
obj = # version for database
1241
string_id : @_string_id
1242
time : time
1243
patch : JSON.stringify(patch)
1244
user_id : @_user_id
1245
if @_patch_format?
1246
obj.format = @_patch_format
1247
if @_deleted
1248
# file was deleted but now change is being made, so undelete it.
1249
@_undelete()
1250
if @_save_patch_prev?
1251
# timestamp of last saved patch during this session
1252
obj.prev = @_save_patch_prev
1253
@_save_patch_prev = time
1254
#console.log("_save_patch: #{misc.to_json(obj)}")
1255
@_my_patches[time - 0] = obj
1256
1257
# If in undo mode put the just-created patch in our without timestamp list, so it won't be included when doing undo/redo.
1258
@_undo_state?.without.unshift(time)
1259
1260
x = @_patches_table.set(obj, 'none', cb)
1261
@_patch_list.add([@_process_patch(x, undefined, undefined, patch)])
1262
1263
1264
# Save current live string to backend. It's safe to call this frequently,
1265
# since it will debounce itself.
1266
save: (cb) =>
1267
@_save_debounce ?= {}
1268
misc.async_debounce
1269
f : @_save
1270
interval : @_save_interval
1271
state : @_save_debounce
1272
cb : cb
1273
return
1274
1275
# Create and store in the database a snapshot of the state
1276
# of the string at the given point in time. This should
1277
# be the time of an existing patch.
1278
snapshot: (time, force=false) =>
1279
if not misc.is_date(time)
1280
throw Error("time must be a date")
1281
x = @_patch_list.patch(time)
1282
if not x?
1283
console.warn("no patch at time #{time}") # should never happen...
1284
return
1285
if x.snapshot? and not force
1286
# there is already a snapshot at this point in time, so nothing further to do.
1287
return
1288
# save the snapshot itself in the patches table.
1289
obj =
1290
string_id : @_string_id
1291
time : time
1292
patch : JSON.stringify(x.patch)
1293
snapshot : @_patch_list.value(time, force).to_str()
1294
user_id : x.user_id
1295
if force
1296
# CRITICAL: We are sending the patch/snapshot later, but it was valid.
1297
# It's important to make this clear or _handle_offline will
1298
# recompute this snapshot and try to update sent on it again,
1299
# which leads to serious problems!
1300
obj.sent = time
1301
x.snapshot = obj.snapshot # also set snapshot in the @_patch_list, which helps with optimization
1302
@_patches_table.set obj, 'none' , (err) =>
1303
if not err
1304
# CRITICAL: Only save the snapshot time in the database after the set in the patches table was confirmed as a
1305
# success -- otherwise if the user refreshes their browser (or visits later) they lose all their early work!
1306
@_syncstring_table.set(string_id:@_string_id, project_id:@_project_id, path:@_path, last_snapshot:time)
1307
@_last_snapshot = time
1308
else
1309
console.warn("failed to save snapshot -- #{err}")
1310
return time
1311
1312
# Have a snapshot every @_snapshot_interval patches, except
1313
# for the very last interval.
1314
snapshot_if_necessary: () =>
1315
time = @_patch_list.time_of_unmade_periodic_snapshot(@_snapshot_interval)
1316
if time?
1317
return @snapshot(time)
1318
1319
# x - patch object
1320
# time0, time1: optional range of times; return undefined if patch not in this range
1321
# patch -- if given will be used as an actual patch instead of x.patch, which is a JSON string.
1322
_process_patch: (x, time0, time1, patch) =>
1323
if not x? # we allow for x itself to not be defined since that simplifies other code
1324
return
1325
time = x.get('time')
1326
if not misc.is_date(time)
1327
try
1328
time = misc.ISO_to_Date(time)
1329
if isNaN(time) # ignore patches with bad times
1330
return
1331
catch err
1332
# ignore patches with invalid times
1333
return
1334
user_id = x.get('user_id')
1335
sent = x.get('sent')
1336
prev = x.get('prev')
1337
if time0? and time < time0
1338
return
1339
if time1? and time > time1
1340
return
1341
if not patch?
1342
# Do **NOT** use misc.from_json, since we definitely do not want to
1343
# unpack ISO timestamps as Date, since patch just contains the raw
1344
# patches from user editing. This was done for a while, which
1345
# led to horrific bugs in some edge cases...
1346
# See https://github.com/sagemathinc/cocalc/issues/1771
1347
patch = JSON.parse(x.get('patch') ? '[]')
1348
snapshot = x.get('snapshot')
1349
obj =
1350
time : time
1351
user_id : user_id
1352
patch : patch
1353
if sent?
1354
obj.sent = sent
1355
if prev?
1356
obj.prev = prev
1357
if snapshot?
1358
obj.snapshot = snapshot
1359
return obj
1360
1361
# return all patches with time such that time0 <= time <= time1;
1362
# if time0 undefined then sets equal to time of last_snapshot; if time1 undefined treated as +oo
1363
_get_patches: (time0, time1) =>
1364
time0 ?= @_last_snapshot
1365
m = @_patches_table.get() # immutable.js map with keys the string that is the JSON version of the primary key [string_id, timestamp, user_number].
1366
v = []
1367
m.map (x, id) =>
1368
p = @_process_patch(x, time0, time1)
1369
if p?
1370
v.push(p)
1371
v.sort(patch_cmp)
1372
return v
1373
1374
has_full_history: () =>
1375
return not @_last_snapshot or @_load_full_history_done
1376
1377
load_full_history: (cb) =>
1378
dbg = @dbg("load_full_history")
1379
dbg()
1380
if @has_full_history()
1381
#dbg("nothing to do, since complete history definitely already loaded")
1382
cb?()
1383
return
1384
query = @_patch_table_query()
1385
@_client.query
1386
query : {patches:[query]}
1387
cb : (err, result) =>
1388
if err
1389
cb?(err)
1390
else
1391
v = []
1392
# _process_patch assumes immutable.js objects
1393
immutable.fromJS(result.query.patches).forEach (x) =>
1394
p = @_process_patch(x, 0, @_last_snapshot)
1395
if p?
1396
v.push(p)
1397
@_patch_list.add(v)
1398
@_load_full_history_done = true
1399
cb?()
1400
1401
show_history: (opts) =>
1402
@_patch_list.show_history(opts)
1403
1404
get_path: =>
1405
return @_path
1406
1407
get_project_id: =>
1408
return @_project_id
1409
1410
set_snapshot_interval: (n) =>
1411
@_syncstring_table.set(@_syncstring_table.get_one().set('snapshot_interval', n))
1412
return
1413
1414
# Check if any patches that just got confirmed as saved are relatively old; if so,
1415
# we mark them as such and also possibly recompute snapshots.
1416
_handle_offline: (data) =>
1417
#dbg = @dbg("_handle_offline")
1418
#dbg("data='#{misc.to_json(data)}'")
1419
if @_closed
1420
return
1421
now = misc.server_time()
1422
oldest = undefined
1423
for obj in data
1424
if obj.sent
1425
# CRITICAL: ignore anything already processed! (otherwise, infinite loop)
1426
continue
1427
if now - obj.time >= 1000*OFFLINE_THRESH_S
1428
# patch is "old" -- mark it as likely being sent as a result of being
1429
# offline, so clients could potentially discard it.
1430
obj.sent = now
1431
@_patches_table.set(obj)
1432
if not oldest? or obj.time < oldest
1433
oldest = obj.time
1434
if oldest
1435
#dbg("oldest=#{oldest}, so check whether any snapshots need to be recomputed")
1436
for snapshot_time in @_patch_list.snapshot_times()
1437
if snapshot_time - oldest >= 0
1438
#console.log("recomputing snapshot #{snapshot_time}")
1439
@snapshot(snapshot_time, true)
1440
1441
_handle_syncstring_update: () =>
1442
#dbg = @dbg("_handle_syncstring_update")
1443
#dbg()
1444
if not @_syncstring_table? # not initialized; nothing to do
1445
#dbg("nothing to do")
1446
return
1447
x = @_syncstring_table.get_one()?.toJS()
1448
#dbg(JSON.stringify(x))
1449
# TODO: potential races, but it will (or should!?) get instantly fixed when we get an update in case of a race (?)
1450
client_id = @_client.client_id()
1451
# Below " not x.snapshot? or not x.users?" is because the initial touch sets
1452
# only string_id and last_active, and nothing else.
1453
if not x? or not x.users?
1454
# Brand new document
1455
@_last_snapshot = undefined
1456
@_snapshot_interval = schema.SCHEMA.syncstrings.user_query.get.fields.snapshot_interval
1457
# brand new syncstring
1458
@_user_id = 0
1459
@_users = [client_id]
1460
obj =
1461
string_id : @_string_id
1462
project_id : @_project_id
1463
path : @_path
1464
last_snapshot : @_last_snapshot
1465
users : @_users
1466
deleted : @_deleted
1467
doctype : misc.to_json(@_doctype)
1468
@_syncstring_table.set(obj)
1469
@emit('metadata-change')
1470
else
1471
# TODO: handle doctype change here (?)
1472
@_last_snapshot = x.last_snapshot
1473
@_snapshot_interval = x.snapshot_interval
1474
@_users = x.users
1475
@_project_id = x.project_id
1476
@_path = x.path
1477
if @_deleted? and x.deleted and not @_deleted # change to deleted
1478
@emit("deleted")
1479
@_deleted = x.deleted
1480
1481
# Ensure that this client is in the list of clients
1482
@_user_id = @_users?.indexOf(client_id)
1483
if @_user_id == -1
1484
@_user_id = @_users.length
1485
@_users.push(client_id)
1486
@_syncstring_table.set({string_id:@_string_id, project_id:@_project_id, path:@_path, users:@_users})
1487
1488
if not @_client.is_project()
1489
@emit('metadata-change')
1490
return
1491
1492
#dbg = @dbg("_handle_syncstring_update('#{@_path}')")
1493
#dbg("project only handling")
1494
# Only done for project:
1495
async.series([
1496
(cb) =>
1497
if @_patch_list?
1498
#dbg("patch list already loaded")
1499
cb()
1500
else
1501
#dbg("wait for patch list to load...")
1502
@once 'connected', =>
1503
#dbg("patch list loaded")
1504
cb()
1505
(cb) =>
1506
# NOTE: very important to completely do @_update_watch_path
1507
# before @_save_to_disk below.
1508
# If client is a project and path isn't being properly watched, make it so.
1509
if x.project_id? and @_watch_path != x.path
1510
#dbg("watch path")
1511
@_update_watch_path(x.path, cb)
1512
else
1513
cb()
1514
(cb) =>
1515
if x.save?.state == 'requested'
1516
#dbg("save to disk")
1517
@_save_to_disk(cb)
1518
else
1519
cb()
1520
], (err) =>
1521
if err
1522
@dbg("_handle_syncstring_update")("POSSIBLY UNHANDLED ERROR -- #{err}")
1523
@emit('metadata-change')
1524
)
1525
1526
1527
_update_watch_path: (path, cb) =>
1528
dbg = @_client.dbg("_update_watch_path('#{path}')")
1529
if @_file_watcher?
1530
# clean up
1531
dbg("close")
1532
@_file_watcher.close()
1533
delete @_file_watcher
1534
delete @_watch_path
1535
if not path?
1536
dbg("not opening another watcher")
1537
cb?()
1538
return
1539
if @_watch_path?
1540
dbg("watch_path already defined")
1541
cb?()
1542
return
1543
dbg("opening watcher")
1544
@_watch_path = path
1545
async.series([
1546
(cb) =>
1547
@_client.path_exists
1548
path : path
1549
cb : (err, exists) =>
1550
if err
1551
cb(err)
1552
else if not exists
1553
dbg("write '#{path}' to disk from syncstring in-memory database version")
1554
data = @to_str() ? '' # maybe in case of no patches yet (?).
1555
@_client.write_file
1556
path : path
1557
data : data
1558
cb : (err) =>
1559
dbg("wrote '#{path}' to disk -- now calling cb")
1560
cb(err)
1561
else
1562
cb()
1563
(cb) =>
1564
dbg("now requesting to watch file")
1565
@_file_watcher = @_client.watch_file(path:path)
1566
@_file_watcher.on 'change', (ctime) =>
1567
ctime ?= new Date()
1568
ctime = ctime - 0
1569
dbg("file_watcher: change, ctime=#{ctime}, @_save_to_disk_start_ctime=#{@_save_to_disk_start_ctime}, @_save_to_disk_end_ctime=#{@_save_to_disk_end_ctime}")
1570
if @_closed
1571
@_file_watcher.close()
1572
return
1573
if ctime - (@_save_to_disk_start_ctime ? 0) >= 15*1000
1574
# last attempt to save was at least 15s ago, so definitely
1575
# this change event was not caused by it.
1576
dbg("_load_from_disk: no recent save")
1577
@_load_from_disk()
1578
return
1579
if not @_save_to_disk_end_ctime?
1580
# save event started less than 15s and isn't done.
1581
# ignore this load.
1582
dbg("_load_from_disk: unfinished @_save_to_disk just happened, so ignoring file change")
1583
return
1584
if @_save_to_disk_start_ctime <= ctime and ctime <= @_save_to_disk_end_ctime
1585
# changed triggered during the save
1586
dbg("_load_from_disk: change happened during @_save_to_disk , so ignoring file change")
1587
return
1588
# Changed happened near to when there was a save, but not in window, so
1589
# we do it..
1590
dbg("_load_from_disk: despite a recent save")
1591
@_load_from_disk()
1592
return
1593
1594
@_file_watcher.on 'delete', =>
1595
dbg("event delete")
1596
if @_closed
1597
@_file_watcher.close()
1598
return
1599
dbg("delete: setting deleted=true and closing")
1600
@from_str('')
1601
@save () =>
1602
# NOTE: setting deleted=true must be done **after** setting document to blank above,
1603
# since otherwise the set would set deleted=false.
1604
@_syncstring_table.set(@_syncstring_table.get_one().set('deleted', true))
1605
@_syncstring_table.save () => # make sure deleted:true is saved.
1606
@close()
1607
return
1608
cb()
1609
], (err) => cb?(err))
1610
1611
_load_from_disk: (cb) =>
1612
path = @get_path()
1613
dbg = @_client.dbg("syncstring._load_from_disk('#{path}')")
1614
dbg()
1615
if @_load_from_disk_lock
1616
cb?('lock')
1617
return
1618
@_load_from_disk_lock = true
1619
exists = undefined
1620
async.series([
1621
(cb) =>
1622
@_client.path_exists
1623
path : path
1624
cb : (err, x) =>
1625
exists = x
1626
if not exists
1627
dbg("file no longer exists")
1628
@from_str('')
1629
cb(err)
1630
(cb) =>
1631
if exists
1632
@_update_if_file_is_read_only(cb)
1633
else
1634
cb()
1635
(cb) =>
1636
if not exists
1637
cb()
1638
return
1639
@_client.path_read
1640
path : path
1641
maxsize_MB : MAX_FILE_SIZE_MB
1642
cb : (err, data) =>
1643
if err
1644
dbg("failed -- #{err}")
1645
cb(err)
1646
else
1647
dbg("got it -- length=#{data?.length}")
1648
@from_str(data)
1649
# we also know that this is the version on disk, so we update the hash
1650
@_set_save(state:'done', error:false, hash:misc.hash_string(data))
1651
cb()
1652
(cb) =>
1653
# save back to database
1654
@_save(cb)
1655
], (err) =>
1656
@_load_from_disk_lock = false
1657
cb?(err)
1658
)
1659
1660
_set_save: (x) =>
1661
if @_closed # nothing to do
1662
return
1663
@_syncstring_table?.set?(@_syncstring_table.get_one()?.set('save', immutable.fromJS(x)))
1664
return
1665
1666
_set_read_only: (read_only) =>
1667
if @_closed # nothing to do
1668
return
1669
@_syncstring_table?.set?(@_syncstring_table.get_one()?.set('read_only', read_only))
1670
return
1671
1672
get_read_only: () =>
1673
if @_closed # nothing to do
1674
return
1675
return @_syncstring_table?.get_one()?.get('read_only')
1676
1677
wait_until_read_only_known: (cb) =>
1678
if not @_syncstring_table?
1679
cb("@_syncstring_table must be defined")
1680
return
1681
@_syncstring_table.wait
1682
until : (t) => t.get_one()?.get('read_only')?
1683
cb : cb
1684
1685
# Returns true if the current live version of this document has a different hash
1686
# than the version mostly recently saved to disk. I.e., if there are changes
1687
# that have not yet been **saved to disk**. See the other function
1688
# has_uncommitted_changes below for determining whether there are changes
1689
# that haven't been commited to the database yet.
1690
has_unsaved_changes: () =>
1691
return @hash_of_live_version() != @hash_of_saved_version()
1692
1693
# Returns hash of last version saved to disk (as far as we know).
1694
hash_of_saved_version: =>
1695
return @_syncstring_table?.get_one()?.getIn(['save', 'hash'])
1696
1697
# Return hash of the live version of the document, or undefined if the document
1698
# isn't loaded yet. (TODO: faster version of this for syncdb, which avoids
1699
# converting to a string, which is a waste of time.)
1700
hash_of_live_version: =>
1701
s = @_doc?.to_str?()
1702
if s?
1703
return misc.hash_string(s)
1704
1705
# Initiates a save of file to disk, then if cb is set, waits for the state to
1706
# change to done before calling cb.
1707
save_to_disk: (cb) =>
1708
#dbg = @dbg("save_to_disk(cb)")
1709
#dbg("initiating the save")
1710
if not @has_unsaved_changes()
1711
# no unsaved changes, so don't save -- CRITICAL: this optimization is assumed by autosave, etc.
1712
cb?()
1713
return
1714
1715
@_save_to_disk()
1716
if not @_syncstring_table?
1717
cb("@_syncstring_table must be defined")
1718
return
1719
if cb?
1720
#dbg("waiting for save.state to change from '#{@_syncstring_table.get_one().getIn(['save','state'])}' to 'done'")
1721
f = (cb) =>
1722
if not @_syncstring_table?
1723
cb(true)
1724
return
1725
@_syncstring_table.wait
1726
until : (table) -> table.get_one()?.getIn(['save','state']) == 'done'
1727
timeout : 10
1728
cb : (err) =>
1729
#dbg("done waiting -- now save.state is '#{@_syncstring_table.get_one().getIn(['save','state'])}'")
1730
if err
1731
#dbg("got err waiting: #{err}")
1732
else
1733
err = @_syncstring_table.get_one().getIn(['save', 'error'])
1734
#if err
1735
# dbg("got result but there was an error: #{err}")
1736
if err
1737
@touch(0) # touch immediately to ensure backend pays attention.
1738
cb(err)
1739
misc.retry_until_success
1740
f : f
1741
max_tries : 5
1742
cb : cb
1743
1744
# Save this file to disk, if it is associated with a project and has a filename.
1745
# A user (web browsers) sets the save state to requested.
1746
# The project sets the state to saving, does the save to disk, then sets
1747
# the state to done.
1748
_save_to_disk: (cb) =>
1749
if @_client.is_user()
1750
@__save_to_disk_user()
1751
cb?()
1752
return
1753
1754
if @_saving_to_disk_cbs?
1755
@_saving_to_disk_cbs.push(cb)
1756
return
1757
else
1758
@_saving_to_disk_cbs = [cb]
1759
1760
@__do_save_to_disk_project (err) =>
1761
v = @_saving_to_disk_cbs
1762
delete @_saving_to_disk_cbs
1763
for cb in v
1764
cb?(err)
1765
@emit("save_to_disk_project", err)
1766
1767
__save_to_disk_user: =>
1768
if @_closed # nothing to do
1769
return
1770
if not @has_unsaved_changes()
1771
# Browser client that has no unsaved changes, so don't need to save --
1772
# CRITICAL: this optimization is assumed by autosave, etc.
1773
return
1774
# CRITICAL: First, we broadcast interest in the syncstring -- this will cause the relevant project
1775
# (if it is running) to open the syncstring (if closed), and hence be aware that the client
1776
# is requesting a save. This is important if the client and database have changes not
1777
# saved to disk, and the project stopped listening for activity on this syncstring due
1778
# to it not being touched (due to active editing). Not having this leads to a lot of "can't save"
1779
# errors.
1780
@touch()
1781
@_set_save(state:'requested', error:false)
1782
1783
__do_save_to_disk_project: (cb) =>
1784
# check if on-disk version is same as in memory, in which case no save is needed.
1785
data = @to_str() # string version of this doc
1786
hash = misc.hash_string(data)
1787
if hash == @hash_of_saved_version()
1788
# No actual save to disk needed; still we better record this fact in table in case it
1789
# isn't already recorded
1790
@_set_save(state:'done', error:false, hash:hash)
1791
cb()
1792
return
1793
1794
path = @get_path()
1795
#dbg = @dbg("__do_save_to_disk_project('#{path}')")
1796
if not path?
1797
cb("not yet initialized")
1798
return
1799
if not path
1800
@_set_save(state:'done', error:'cannot save without path')
1801
cb("cannot save without path")
1802
return
1803
1804
#dbg("project - write to disk file")
1805
# set window to slightly earlier to account for clock imprecision.
1806
# Over an sshfs mount, all stats info is **rounded down to the nearest second**,
1807
# which this also takes care of.
1808
@_save_to_disk_start_ctime = new Date() - 1500
1809
@_save_to_disk_end_ctime = undefined
1810
async.series([
1811
(cb) =>
1812
@_client.write_file
1813
path : path
1814
data : data
1815
cb : cb
1816
(cb) =>
1817
@_client.path_stat
1818
path : path
1819
cb : (err, stat) =>
1820
@_save_to_disk_end_ctime = stat?.ctime - 0
1821
cb(err)
1822
], (err) =>
1823
#dbg("returned from write_file: #{err}")
1824
if err
1825
@_set_save(state:'done', error:err)
1826
else
1827
@_set_save(state:'done', error:false, hash:misc.hash_string(data))
1828
cb(err)
1829
)
1830
1831
###
1832
# When the underlying synctable that defines the state of the document changes
1833
# due to new remote patches, this function is called.
1834
# It handles update of the remote version, updating our live version as a result.
1835
###
1836
_handle_patch_update: (changed_keys) =>
1837
if @_closed
1838
return
1839
#console.log("_handle_patch_update #{misc.to_json(changed_keys)}")
1840
if not changed_keys?
1841
# this happens right now when we do a save.
1842
return
1843
if not @_patch_list?
1844
# nothing to do
1845
return
1846
#dbg = @dbg("_handle_patch_update")
1847
#dbg(new Date(), changed_keys)
1848
1849
# note: other code handles that @_patches_table.get(key) may not be defined, e.g., when changed means "deleted"
1850
@_patch_list.add( (@_process_patch(@_patches_table.get(key)) for key in changed_keys) )
1851
1852
# Save any unsaved changes we might have made locally.
1853
# This is critical to do, since otherwise the remote
1854
# changes would overwrite the local ones.
1855
@_save()
1856
1857
# compute result of applying all patches in order to snapshot
1858
new_remote = @_patch_list.value()
1859
1860
# temporary hotfix for https://github.com/sagemathinc/cocalc/issues/1873
1861
try
1862
changed = not @_doc?.is_equal(new_remote)
1863
catch
1864
changed = true
1865
# if any possibility that document changed, set to new version
1866
if changed
1867
@_last = @_doc = new_remote
1868
@emit('change')
1869
1870
# Return true if there are changes to this syncstring that have not been
1871
# committed to the database (with the commit acknowledged). This does not
1872
# mean the file has been written to disk; however, it does mean that it
1873
# safe for the user to close their browser.
1874
has_uncommitted_changes: () =>
1875
return @_patches_table?.has_uncommitted_changes()
1876
1877
exports.SyncDoc = SyncDoc
1878
1879
# Immutable string document that satisfies our spec.
1880
class StringDocument
1881
constructor: (@_value='') ->
1882
1883
to_str: =>
1884
return @_value
1885
1886
is_equal: (other) =>
1887
return @_value == other?._value
1888
1889
apply_patch: (patch) =>
1890
return new StringDocument(apply_patch(patch, @_value)[0])
1891
1892
make_patch: (other) =>
1893
if not @_value? or not other?._value?
1894
# document not inialized or other not meaningful
1895
return
1896
return make_patch(@_value, other._value)
1897
1898
exports._testStringDocument = StringDocument
1899
1900
class exports.SyncString extends SyncDoc
1901
constructor: (opts) ->
1902
opts = defaults opts,
1903
id : undefined
1904
client : required
1905
project_id : undefined
1906
path : undefined
1907
save_interval : undefined
1908
patch_interval : undefined
1909
file_use_interval : undefined
1910
cursors : false # if true, also provide cursor tracking ability
1911
1912
1913
from_str = (str) ->
1914
new StringDocument(str)
1915
1916
super
1917
string_id : opts.id
1918
client : opts.client
1919
project_id : opts.project_id
1920
path : opts.path
1921
save_interval : opts.save_interval
1922
patch_interval : opts.patch_interval
1923
file_use_interval : opts.file_use_interval
1924
cursors : opts.cursors
1925
from_str : from_str
1926
doctype : {type:'string'}
1927
1928
###
1929
Used for testing
1930
###
1931
synctable = require('./synctable')
1932
class exports.TestBrowserClient1 extends synctable.TestBrowserClient1
1933
constructor: (@_client_id, @_debounce_interval=0) ->
1934
1935
is_user: =>
1936
return true
1937
1938
mark_file: =>
1939
1940
server_time: =>
1941
return new Date()
1942
1943
sync_table: (query, options, debounce_interval=0) =>
1944
debounce_interval = @_debounce_interval # hard coded for testing
1945
return synctable.sync_table(query, options, @, debounce_interval, 0, false)
1946
1947
sync_string: (opts) =>
1948
opts = defaults opts,
1949
id : undefined
1950
project_id : undefined
1951
path : undefined
1952
file_use_interval : 'default'
1953
cursors : false
1954
save_interval : 0
1955
opts.client = @
1956
return new exports.SyncString(opts)
1957
1958
client_id: =>
1959
return @_client_id
1960
1961