###############################################################################1#2# CoCalc: Collaborative Calculation in the Cloud3#4# Copyright (C) 2016, Sagemath Inc.5#6# This program is free software: you can redistribute it and/or modify7# it under the terms of the GNU General Public License as published by8# the Free Software Foundation, either version 3 of the License, or9# (at your option) any later version.10#11# This program is distributed in the hope that it will be useful,12# but WITHOUT ANY WARRANTY; without even the implied warranty of13# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the14# GNU General Public License for more details.15#16# You should have received a copy of the GNU General Public License17# along with this program. If not, see <http://www.gnu.org/licenses/>.18#19###############################################################################2021###22Database-backed time-log database-based synchronized editing23###2425# How big of files we allow users to open using syncstrings.26MAX_FILE_SIZE_MB = 22728# Client -- when it has this syncstring open and connected -- will touch the29# syncstring every so often so that it stays opened in the local hub,30# when the local hub is running.31TOUCH_INTERVAL_M = 103233# How often the local hub will autosave this file to disk if it has it open and34# there are unsaved changes. This is very important since it ensures that a user that35# edits a file but doesn't click "Save" and closes their browser (right after their edits36# have gone to the databse), still has their file saved to disk soon. This is important,37# e.g., for homework getting collected and not missing the last few changes. It turns out38# this is what people expect!39# Set to 0 to disable. (But don't do that.)40LOCAL_HUB_AUTOSAVE_S = 12041#LOCAL_HUB_AUTOSAVE_S = 54243# If the client becomes disconnected from the backend for more than this long44# the---on reconnect---do extra work to ensure that all snapshots are up to45# date (in case snapshots were made when we were offline), and mark the sent46# field of patches that weren't saved.47OFFLINE_THRESH_S = 5*604849{EventEmitter} = require('events')50immutable = require('immutable')51underscore = require('underscore')5253node_uuid = require('uuid')54async = require('async')5556misc = require('./misc')57{sagews} = require('./sagews')5859schema = require('./schema')6061{Evaluator} = require('./syncstring_evaluator')6263{diff_match_patch} = require('./dmp')64dmp = new diff_match_patch()65dmp.Diff_Timeout = 0.2 # computing a diff won't block longer than about 0.2s66exports.dmp = dmp6768{defaults, required} = misc6970# Here's what a diff-match-patch patch looks like71#72# [{"diffs":[[1,"{\"x\":5,\"y\":3}"]],"start1":0,"start2":0,"length1":0,"length2":13},...]73#74compress_patch = (patch) ->75([p.diffs, p.start1, p.start2, p.length1, p.length2] for p in patch)7677decompress_patch = (patch) ->78({diffs:p[0], start1:p[1], start2:p[2], length1:p[3], length2:p[4]} for p in patch)7980# patch that transforms s0 into s181exports.make_patch = make_patch = (s0, s1) ->82p = compress_patch(dmp.patch_make(s0, s1))83#console.log("make_patch: #{misc.to_json(p)}")84return p8586exports.apply_patch = apply_patch = (patch, s) ->87try88x = dmp.patch_apply(decompress_patch(patch), s)89#console.log('patch_apply ', misc.to_json(decompress_patch(patch)), x)90catch err91# If a patch is so corrupted it can't be parsed -- e.g., due to a bug in SMC -- we at least92# want to make application the identity map, so the document isn't completely unreadable!93console.warn("apply_patch -- #{err}")94return [s, false]95clean = true96for a in x[1]97if not a98clean = false99break100return [x[0], clean]101102patch_cmp = (a, b) ->103return misc.cmp_array([a.time - 0, a.user_id], [b.time - 0, b.user_id])104105time_cmp = (a,b) ->106return a - b # sorting Date objects doesn't work perfectly!107108# Do a 3-way **string** merge by computing patch that transforms109# base to remote, then applying that patch to local.110exports.three_way_merge = (opts) ->111opts = defaults opts,112base : required113local : required114remote : required115if opts.base == opts.remote # trivial special case...116return opts.local117return dmp.patch_apply(dmp.patch_make(opts.base, opts.remote), opts.local)[0]118119120###121The PatchValueCache is used to cache values returned122by SortedPatchList.value. Caching is critical, since otherwise123the client may have to apply hundreds of patches after ever124few keystrokes, which would make SMC unusable. Also, the125history browser is very painful to use without caching.126###127MAX_PATCHLIST_CACHE_SIZE = 20128class PatchValueCache129constructor: () ->130@cache = {}131132# Remove everything from the value cache that has timestamp >= time.133# If time not defined, removes everything, thus emptying the cache.134invalidate: (time) =>135if not time?136@cache = {}137return138time0 = time - 0139for tm, _ of @cache140if tm >= time0141delete @cache[tm]142return143144# Ensure the value cache doesn't have too many entries in it by145# removing all but n of the ones that have not been accessed recently.146prune: (n) =>147v = []148for time, x of @cache149v.push({time:time, last_used:x.last_used})150if v.length <= n151# nothing to do152return153v.sort((a,b) -> misc.cmp_Date(a.last_used, b.last_used))154for x in v.slice(0, v.length - n)155delete @cache[x.time]156return157158# Include the given value at the given point in time, which should be159# the output of @value(time), and should involve applying all patches160# up to @_patches[start-1].161include: (time, value, start) =>162@cache[time - 0] = {time:time, value:value, start:start, last_used:new Date()}163return164165# Return the newest value x with x.time <= time in the cache as an object166# x={time:time, value:value, start:start},167# where @value(time) is the given value, and it was obtained168# by applying the elements of @_patches up to @_patches[start-1]169# Return undefined if there are no cached values.170# If time is undefined, returns the newest value in the cache.171# If strict is true, returns newest value at time strictly older than time172newest_value_at_most: (time, strict=false) =>173v = misc.keys(@cache)174if v.length == 0175return176v.sort(misc.cmp)177v.reverse()178if not time?179return @get(v[0])180time0 = time - 0181for t in v182if (not strict and t <= time0) or (strict and t < time0)183return @get(t)184return185186# Return cached value corresponding to the given point in time.187# Here time must be either a new Date() object, or a number (ms since epoch).188# If there is nothing in the cache for the given time, returns undefined.189# Do NOT mutate the returned value.190get: (time) =>191if typeof(time) != 'number'192# also allow dates193time = time - 0194x = @cache[time]195if not x?196return197x.last_used = new Date() # this is only for the client cache, so fine to use browser's clock198return x199200oldest_time: () =>201v = misc.keys(@cache)202if v.length == 0203return204v.sort(misc.cmp)205return new Date(parseInt(v[0]))206207# Number of cached values208size: () =>209return misc.len(@cache)210211# Sorted list of patches applied to a string212class SortedPatchList extends EventEmitter213constructor: (@_from_str) ->214@_patches = []215@_times = {}216@_cache = new PatchValueCache()217@_snapshot_times = {}218219close: () =>220@removeAllListeners()221delete @_patches222delete @_times223delete @_cache224delete @_snapshot_times225226# Choose the next available time in ms that is congruent to m modulo n.227# The congruence condition is so that any time collision will have to be228# with a single person editing a document with themselves -- two different229# users are guaranteed to not collide. Note: even if there is a collision,230# it will automatically fix itself very quickly.231next_available_time: (time, m=0, n=1) =>232if misc.is_date(time)233t = time - 0234else235t = time236237if n <= 0238n = 1239a = m - (t%n)240if a < 0241a += n242t += a # now t = m (mod n)243while @_times[t]?244t += n245return new Date(t)246247add: (patches) =>248if patches.length == 0249# nothing to do250return251#console.log("SortedPatchList.add: #{misc.to_json(patches)}")252v = []253oldest = undefined254for x in patches255if x?256if not misc.is_date(x.time)257# ensure that time is not a string representation of a time258try259x.time = misc.ISO_to_Date(x.time)260if isNaN(x.time) # ignore bad times261continue262catch err263# ignore invalid times264continue265t = x.time - 0266cur = @_times[t]267if cur?268# Note: cur.prev and x.prev are Date objects, so must put + before them to convert to numbers and compare.269if underscore.isEqual(cur.patch, x.patch) and cur.user_id == x.user_id and cur.snapshot == x.snapshot and +cur.prev == +x.prev270# re-inserting exactly the same thing; nothing at all to do271continue272else273# adding snapshot or timestamp collision -- remove duplicate274#console.log "overwriting patch #{misc.to_json(t)}"275# remove patch with same timestamp from the sorted list of patches276@_patches = (y for y in @_patches when y.time - 0 != t)277@emit('overwrite', t)278v.push(x)279@_times[t] = x280if not oldest? or oldest > x.time281oldest = x.time282if x.snapshot?283@_snapshot_times[t] = true284if oldest?285@_cache.invalidate(oldest)286287# this is O(n*log(n)) where n is the length of @_patches and patches;288# better would be an insertion sort which would be O(m*log(n)) where m=patches.length...289if v.length > 0290delete @_versions_cache291@_patches = @_patches.concat(v)292@_patches.sort(patch_cmp)293294newest_snapshot_time: () =>295t0 = 0296for t of @_snapshot_times297t = parseInt(t)298if t > t0299t0 = t300return new Date(t0)301302###303value: Return the value of the string at the given (optional)304point in time. If the optional time is given, only include patches up305to (and including) the given time; otherwise, return current value.306307If force is true, doesn't use snapshot at given input time, even if308there is one; this is used to update snapshots in case of offline changes309getting inserted into the changelog.310311If without is defined, it must be an array of Date objects; in that case312the current value of the string is computed, but with all the patches313at the given times in "without" ignored. This is used elsewhere as a building314block to implement undo.315###316value: (time, force=false, without_times=undefined) =>317#start_time = new Date()318# If the time is specified, verify that it is valid; otherwise, convert it to a valid time.319if time? and not misc.is_date(time)320time = misc.ISO_to_Date(time)321if without_times?322if not misc.is_array(without_times)323throw Error("without_times must be an array")324if without_times.length > 0325v = {}326without = undefined327for x in without_times328if not misc.is_date(x)329throw Error("each without_times entry must be a date")330v[+x] = true # convert to number331if not without? or x < without332without = x333if time? and +time < without334# requesting value at time before any without, so without is not relevant, so ignore.335without = undefined336without_times = undefined337else338without_times = v # change to map from time in ms to true.339340prev_cutoff = @newest_snapshot_time()341# Determine oldest cached value342oldest_cached_time = @_cache.oldest_time() # undefined if nothing cached343# If the oldest cached value exists and is at least as old as the requested344# point in time, use it as a base.345if oldest_cached_time? and (not time? or +time >= +oldest_cached_time) and (not without? or +without > +oldest_cached_time)346# There is something in the cache, and it is at least as far back in time347# as the value we want to compute now.348if without?349cache = @_cache.newest_value_at_most(without, true) # true makes "at most" strict, so <.350else351cache = @_cache.newest_value_at_most(time)352value = cache.value353start = cache.start354cache_time = cache.time355for x in @_patches.slice(cache.start, @_patches.length) # all patches starting with the cached one356if time? and x.time > time357# Done -- no more patches need to be applied358break359if not x.prev? or @_times[x.prev - 0] or +x.prev >= +prev_cutoff360if not without? or (without? and not without_times[+x.time])361# apply patch x to update value to be closer to what we want362value = value.apply_patch(x.patch)363cache_time = x.time # also record the time of the last patch we applied.364start += 1365if not without? and (not time? or start - cache.start >= 10)366# Newest -- or at least 10 patches needed to be applied -- so cache result367@_cache.include(cache_time, value, start)368@_cache.prune(Math.max(3, Math.min(Math.ceil(30000000/value.length), MAX_PATCHLIST_CACHE_SIZE)))369else370# Cache is empty or doesn't have anything sufficiently old to be useful.371# Find the newest snapshot at a time that is <= time.372value = @_from_str('') # default in case no snapshots373start = 0374if @_patches.length > 0 # otherwise the [..] notation below has surprising behavior375for i in [@_patches.length-1 .. 0]376if (not time? or +@_patches[i].time <= +time) and @_patches[i].snapshot?377if force and +@_patches[i].time == +time378# If force is true we do NOT want to use the existing snapshot, since379# the whole point is to force recomputation of it, as it is wrong.380# Instead, we'll use the previous snapshot.381continue382# Found a patch with known snapshot that is as old as the time.383# This is the base on which we will apply other patches to move forward384# to the requested time.385value = @_from_str(@_patches[i].snapshot)386start = i + 1387break388# Apply each of the patches we need to get from389# value (the last snapshot) to time.390cache_time = 0391cache_start = start392for x in @_patches.slice(start, @_patches.length)393if time? and x.time > time394# Done -- no more patches need to be applied395break396# Apply a patch to move us forward.397#console.log("applying patch #{i}")398if not x.prev? or @_times[x.prev - 0] or +x.prev >= +prev_cutoff399if not without? or (without? and not without_times[+x.time])400value = value.apply_patch(x.patch)401cache_time = x.time402cache_start += 1403if not without? and (not time? or cache_time and cache_start - start >= 10)404# Newest -- or at least 10 patches needed to be applied -- so405# update the cache with our new known value406@_cache.include(cache_time, value, cache_start)407@_cache.prune(Math.max(3, Math.min(Math.ceil(30000000/value.length), MAX_PATCHLIST_CACHE_SIZE)))408409#console.log("value: time=#{new Date() - start_time}")410# Use the following only for testing/debugging, since it will make everything VERY slow.411#if @_value_no_cache(time) != value412# console.warn("value for time #{time-0} is wrong!")413return value414415# VERY Slow -- only for consistency checking purposes and debugging.416# If force=true, don't use snapshots.417_value_no_cache: (time, snapshots=true) =>418value = @_from_str('') # default in case no snapshots419start = 0420if snapshots and @_patches.length > 0 # otherwise the [..] notation below has surprising behavior421for i in [@_patches.length-1 .. 0]422if (not time? or +@_patches[i].time <= +time) and @_patches[i].snapshot?423# Found a patch with known snapshot that is as old as the time.424# This is the base on which we will apply other patches to move forward425# to the requested time.426value = @_from_str(@_patches[i].snapshot)427start = i + 1428break429# Apply each of the patches we need to get from430# value (the last snapshot) to time.431for x in @_patches.slice(start, @_patches.length)432if time? and x.time > time433# Done -- no more patches need to be applied434break435value = value.apply_patch(x.patch)436return value437438# For testing/debugging. Go through the complete patch history and439# verify that all snapshots are correct (or not -- in which case say so).440_validate_snapshots: =>441if @_patches.length == 0442return443i = 0444if @_patches[0].snapshot?445i += 1446value = @_from_str(@_patches[0].snapshot)447else448value = @_from_str('')449for x in @_patches.slice(i)450value = value.apply_patch(x.patch)451if x.snapshot?452snapshot_value = @_from_str(x.snapshot)453if not value.is_equal(snapshot_value)454console.log("FAIL (#{x.time}): at #{i}")455console.log("diff(snapshot, correct)=")456console.log(JSON.stringify(value.make_patch(snapshot_value)))457else458console.log("GOOD (#{x.time}): snapshot at #{i} by #{x.user_id}")459i += 1460return461462# integer index of user who made the edit at given point in time (or undefined)463user_id: (time) =>464return @patch(time)?.user_id465466time_sent: (time) =>467return @patch(time)?.sent468469# patch at a given point in time470# TODO: optimization -- this shouldn't be a linear search!!471patch: (time) =>472for x in @_patches473if +x.time == +time474return x475476versions: () =>477# Compute and cache result,then return it; result gets cleared when new patches added.478return @_versions_cache ?= (x.time for x in @_patches)479480# Show the history of this document; used mainly for debugging purposes.481show_history: (opts={}) =>482opts = defaults opts,483milliseconds : false484trunc : 80485log : console.log486s = undefined487i = 0488prev_cutoff = @newest_snapshot_time()489for x in @_patches490tm = x.time491tm = if opts.milliseconds then tm - 0 else tm.toLocaleString()492opts.log("-----------------------------------------------------\n", i, x.user_id, tm, misc.trunc_middle(JSON.stringify(x.patch), opts.trunc))493if not s?494s = @_from_str(x.snapshot ? '')495if not x.prev? or @_times[x.prev - 0] or +x.prev >= +prev_cutoff496t = s.apply_patch(x.patch)497else498opts.log("prev=#{x.prev} missing, so not applying")499s = t500opts.log((if x.snapshot then "(SNAPSHOT) " else " "), if s? then JSON.stringify(misc.trunc_middle(s.to_str(), opts.trunc).trim()))501i += 1502return503504# If the number of patches since the most recent snapshot is >= 2*interval,505# make a snapshot at the patch that is interval steps forward from506# the most recent snapshot. This function returns the time at which we507# must make a snapshot.508time_of_unmade_periodic_snapshot: (interval) =>509n = @_patches.length - 1510if n < 2*interval511# definitely no need to make a snapshot512return513for i in [n .. n - 2*interval]514if @_patches[i].snapshot?515if i + interval + interval <= n516return @_patches[i + interval].time517else518# found too-recent snapshot so don't need to make another one519return520# No snapshot found at all -- maybe old ones were deleted.521# We return the time at which we should have the *newest* snapshot.522# This is the largest multiple i of interval that is <= n - interval523i = Math.floor((n - interval) / interval) * interval524return @_patches[i]?.time525526# Times of all snapshots in memory on this client; these are the only ones527# we need to worry about for offline patches...528snapshot_times: =>529return (x.time for x in @_patches when x.snapshot?)530531newest_patch_time: =>532return @_patches[@_patches.length-1]?.time533534count: =>535return @_patches.length536537538# For testing purposes539exports.SortedPatchList = SortedPatchList540541###542The SyncDoc class enables synchronized editing of a document that can be represented by a string.543544EVENTS:545546- 'change' event whenever the document is changed *remotely* (NOT locally), and also once547when document is initialized.548549- 'user_change' when the string is definitely changed locally (so a new patch is recorded)550551STATES:552553554###555556class SyncDoc extends EventEmitter557constructor: (opts) ->558@_opts = opts = defaults opts,559save_interval : 1500560cursor_interval : 2000561patch_interval : 1000 # debouncing of incoming upstream patches562file_use_interval : 'default' # throttles: default is 60s for everything except .sage-chat files, where it is 10s.563string_id : undefined564project_id : required # project_id that contains the doc565path : required # path of the file corresponding to the doc566client : required567cursors : false # if true, also provide cursor tracking functionality568from_str : required # creates a doc from a string.569doctype : undefined # optional object describing document constructor (used by project to open file)570from_patch_str : JSON.parse571if not opts.string_id?572opts.string_id = schema.client_db.sha1(opts.project_id, opts.path)573574@_closed = true575@_string_id = opts.string_id576@_project_id = opts.project_id577@_path = opts.path578@_client = opts.client579@_from_str = opts.from_str580@_from_patch_str = opts.from_patch_str581@_doctype = opts.doctype582@_patch_format = opts.doctype.patch_format583@_save_interval = opts.save_interval584@_patch_interval = opts.patch_interval585586@_my_patches = {} # patches that this client made during this editing session.587588# For debugging -- this is a (slight) security risk in production.589###590if window?591window.syncstrings ?= {}592window.syncstrings[@_path] = @593###594595# window.s = @596597#dbg = @dbg("constructor(path='#{@_path}')")598#dbg('connecting...')599@connect (err) =>600#dbg('connected')601if err602console.warn("error creating SyncDoc: '#{err}'")603@emit('error', err)604else605if @_client.is_project()606# CRITICAL: do not start autosaving this until syncstring is initialized!607@init_project_autosave()608else609# Ensure file is undeleted when explicitly open.610@_undelete()611612if opts.file_use_interval and @_client.is_user()613is_chat = misc.filename_extension(@_path) == 'sage-chat'614if is_chat615action = 'chat'616else617action = 'edit'618file_use = () =>619@_client.mark_file(project_id:@_project_id, path:@_path, action:action, ttl:opts.file_use_interval)620621@on('user_change', underscore.throttle(file_use, opts.file_use_interval, true))622623if opts.cursors624# Initialize throttled cursors functions625set_cursor_locs = (locs) =>626x =627string_id : @_string_id628user_id : @_user_id629locs : locs630time : @_client.server_time()631@_cursors?.set(x, 'none')632@_throttled_set_cursor_locs = underscore.throttle(set_cursor_locs, @_opts.cursor_interval)633634set_doc: (value) =>635if not value?.apply_patch?636# Do a sanity check -- see https://github.com/sagemathinc/cocalc/issues/1831637throw Error("value must be a document object with apply_patch, etc., methods")638@_doc = value639return640641# Return underlying document, or undefined if document hasn't been set yet.642get_doc: =>643return @_doc644645# Set this doc from its string representation.646from_str: (value) =>647@_doc = @_from_str(value)648return649650# Return string representation of this doc, or undefined if the doc hasn't been set yet.651to_str: =>652return @_doc?.to_str?()653654# Used for internal debug logging655dbg: (f) ->656return @_client.dbg("SyncString.#{f}:")657658# Version of the document at a given point in time; if no659# time specified, gives the version right now.660version: (time) =>661return @_patch_list?.value(time)662663# Compute version of document if the patches at the given times were simply not included.664# This is a building block that is used for implementing undo functionality for client editors.665version_without: (times) =>666return @_patch_list.value(undefined, undefined, times)667668revert: (version) =>669@set_doc(@version(version))670return671672# Undo/redo public api.673# Calling @undo and @redo returns the version of the document after674# the undo or redo operation, but does NOT otherwise change anything!675# The caller can then what they please with that output (e.g., update the UI).676# The one state change is that the first time calling @undo or @redo switches677# into undo/redo state in which additional calls to undo/redo678# move up and down the stack of changes made by this user during this session.679# Call @exit_undo_mode() to exit undo/redo mode.680# Undo and redo *only* impact changes made by this user during this session.681# Other users edits are unaffected, and work by this same user working from another682# browser tab or session is also unaffected.683#684# Finally, undo of a past patch by definition means "the state of the document"685# if that patch was not applied. The impact of undo is NOT that the patch is686# removed from the patch history; instead it just returns a document here that687# the client can do something with, which may result in future patches. Thus688# clients could implement a number of different undo strategies without impacting689# other clients code at all.690undo: () =>691state = @_undo_state692if not state?693# not in undo mode694state = @_undo_state = @_init_undo_state()695if state.pointer == state.my_times.length696# pointing at live state (e.g., happens on entering undo mode)697value = @version() # last saved version698live = @_doc699if not live.is_equal(value)700# User had unsaved changes, so last undo is to revert to version without those.701state.final = value.make_patch(live) # live redo if needed702state.pointer -= 1 # most recent timestamp703return value704else705# User had no unsaved changes, so last undo is version without last saved change.706tm = state.my_times[state.pointer - 1]707state.pointer -= 2708if tm?709state.without.push(tm)710return @version_without(state.without)711else712# no undo information during this session713return value714else715# pointing at particular timestamp in the past716if state.pointer >= 0717# there is still more to undo718state.without.push(state.my_times[state.pointer])719state.pointer -= 1720return @version_without(state.without)721722redo: () =>723state = @_undo_state724if not state?725# nothing to do but return latest live version726return @get_doc()727if state.pointer == state.my_times.length728# pointing at live state -- nothing to do729return @get_doc()730else if state.pointer == state.my_times.length - 1731# one back from live state, so apply unsaved patch to live version732state.pointer += 1733return @version().apply_patch(state.final)734else735# at least two back from live state736state.without.pop()737state.pointer += 1738if not state.final? and state.pointer == state.my_times.length - 1739# special case when there wasn't any live change740state.pointer += 1741return @version_without(state.without)742743in_undo_mode: () =>744return @_undo_state?745746exit_undo_mode: () =>747delete @_undo_state748749_init_undo_state: () =>750if @_undo_state?751@_undo_state752state = @_undo_state = {}753state.my_times = (new Date(parseInt(x)) for x in misc.keys(@_my_patches))754state.my_times.sort(misc.cmp_Date)755state.pointer = state.my_times.length756state.without = []757return state758759# Make it so the local hub project will automatically save the file to disk periodically.760init_project_autosave: () =>761if not LOCAL_HUB_AUTOSAVE_S or not @_client.is_project() or @_project_autosave?762return763#dbg = @dbg("autosave")764#dbg("initializing")765f = () =>766#dbg('checking')767if @hash_of_saved_version()? and @has_unsaved_changes()768#dbg("doing")769@_save_to_disk()770@_project_autosave = setInterval(f, LOCAL_HUB_AUTOSAVE_S*1000)771772# account_id of the user who made the edit at773# the given point in time.774account_id: (time) =>775return @_users[@user_id(time)]776777# Approximate time when patch with given timestamp was778# actually sent to the server; returns undefined if time779# sent is approximately the timestamp time. Only not undefined780# when there is a significant difference.781time_sent: (time) =>782@_patch_list.time_sent(time)783784# integer index of user who made the edit at given785# point in time.786user_id: (time) =>787return @_patch_list.user_id(time)788789# Indicate active interest in syncstring; only updates time790# if last_active is at least min_age_m=5 minutes old (so this can be safely791# called frequently without too much load). We do *NOT* use792# "@_syncstring_table.set(...)" below because it is critical to793# to be able to do the touch before @_syncstring_table gets initialized,794# since otherwise the initial open a file will be very slow.795touch: (min_age_m=5) =>796if @_client.is_project()797return798if min_age_m > 0799# if min_age_m is 0 always do it immediately; if > 0 check what it was:800last_active = @_syncstring_table?.get_one().get('last_active')801# if not defined or not set recently, do it.802if not (not last_active? or +last_active <= +misc.server_minutes_ago(min_age_m))803return804# Now actually do the set.805@_client.query806query :807syncstrings :808string_id : @_string_id809project_id : @_project_id810path : @_path811deleted : @_deleted812last_active : misc.server_time()813doctype : misc.to_json(@_doctype) # important to set here, since this is when syncstring is often first created814815# The project calls this once it has checked for the file on disk; this816# way the frontend knows that the syncstring has been initialized in817# the database, and also if there was an error doing the check.818_set_initialized: (error, cb) =>819init = {time: misc.server_time()}820if error821init.error = "error - #{JSON.stringify(error)}" # must be a string!822else823init.error = ''824@_client.query825query :826syncstrings :827string_id : @_string_id828project_id : @_project_id829path : @_path830init : init831cb : cb832833# List of timestamps of the versions of this string in the sync834# table that we opened to start editing (so starts with what was835# the most recent snapshot when we started). The list of timestamps836# is sorted from oldest to newest.837versions: () =>838v = []839@_patches_table.get().map (x, id) =>840v.push(x.get('time'))841v.sort(time_cmp)842return v843844# List of all known timestamps of versions of this string, including845# possibly much older versions than returned by @versions(), in846# case the full history has been loaded. The list of timestamps847# is sorted from oldest to newest.848all_versions: () =>849return @_patch_list?.versions()850851last_changed: () =>852v = @versions()853if v.length > 0854return v[v.length-1]855else856return new Date(0)857858# Close synchronized editing of this string; this stops listening859# for changes and stops broadcasting changes.860close: =>861if @_closed862return863@emit('close')864@removeAllListeners() # must be after @emit('close') above.865@_closed = true866if @_periodically_touch?867clearInterval(@_periodically_touch)868delete @_periodically_touch869if @_project_autosave?870clearInterval(@_project_autosave)871delete @_project_autosave872delete @_cursor_throttled873delete @_cursor_map874delete @_users875@_syncstring_table?.close()876delete @_syncstring_table877@_patches_table?.close()878delete @_patches_table879@_patch_list?.close()880delete @_patch_list881@_cursors?.close()882delete @_cursors883if @_client.is_project()884@_update_watch_path() # no input = closes it885@_evaluator?.close()886delete @_evaluator887888reconnect: (cb) =>889@close()890@connect(cb)891892connect: (cb) =>893if not @_closed894cb("already connected")895return896@touch(0) # critical to do a quick initial touch so file gets opened on the backend897query =898syncstrings :899string_id : @_string_id900project_id : @_project_id901path : @_path902deleted : null903users : null904last_snapshot : null905snapshot_interval : null906save : null907last_active : null908init : null909read_only : null910last_file_change : null911doctype : null912913@_syncstring_table = @_client.sync_table(query)914915@_syncstring_table.once 'connected', =>916@_handle_syncstring_update()917@_syncstring_table.on('change', @_handle_syncstring_update)918async.series([919(cb) =>920async.parallel([@_init_patch_list, @_init_cursors, @_init_evaluator], cb)921(cb) =>922@_closed = false923if @_client.is_user() and not @_periodically_touch?924@touch(1)925# touch every few minutes while syncstring is open, so that backend local_hub926# (if open) keeps its side open927@_periodically_touch = setInterval((=>@touch(TOUCH_INTERVAL_M/2)), 1000*60*TOUCH_INTERVAL_M)928if @_client.is_project()929@_load_from_disk_if_newer(cb)930else931cb()932], (err) =>933if @_closed934# disconnected while connecting...935cb()936return937@_syncstring_table.wait938until : (t) => t.get_one()?.get('init')939cb : (err, init) =>940@emit('init', err ? init.toJS().error)941if err942cb(err)943else944@emit('change')945@emit('connected')946cb()947)948949# Delete the synchronized string and **all** patches from the database -- basically950# delete the complete history of editing this file.951# WARNINGS:952# (1) If a project has this string open, then things may be messed up, unless that project is restarted.953# (2) Only available for the admin user right now.954# To use: from a javascript console in the browser as admin, you can do:955#956# smc.client.sync_string({project_id:'9f2e5869-54b8-4890-8828-9aeba9a64af4', path:'a.txt'}).delete_from_database(console.log)957#958# Then make sure project and clients refresh.959#960delete_from_database: (cb) =>961async.parallel([962(cb) =>963@_client.query964query :965patches_delete :966id : [@_string_id]967dummy : null # required to force a get query.968cb : cb969(cb) =>970@_client.query971query :972syncstrings_delete :973project_id : @_project_id974path : @_path975cb : cb976], (err)=>cb?(err))977978_update_if_file_is_read_only: (cb) =>979@_client.path_access980path : @_path981mode : 'w'982cb : (err) =>983@_set_read_only(!!err)984cb?()985986_load_from_disk_if_newer: (cb) =>987tm = @last_changed()988dbg = @_client.dbg("syncstring._load_from_disk_if_newer('#{@_path}')")989exists = undefined990async.series([991(cb) =>992dbg("check if path exists")993@_client.path_exists994path : @_path995cb : (err, _exists) =>996if err997cb(err)998else999exists = _exists1000cb()1001(cb) =>1002if not exists1003dbg("file does NOT exist")1004@_set_read_only(false)1005cb()1006return1007if tm?1008dbg("edited before, so stat file")1009@_client.path_stat1010path : @_path1011cb : (err, stats) =>1012if err1013cb(err)1014else if stats.ctime > tm1015dbg("disk file changed more recently than edits, so loading")1016@_load_from_disk(cb)1017else1018dbg("stick with database version")1019cb()1020else1021dbg("never edited before")1022if exists1023dbg("path exists, so load from disk")1024@_load_from_disk(cb)1025else1026cb()1027(cb) =>1028if exists1029@_update_if_file_is_read_only(cb)1030else1031cb()1032], (err) =>1033@_set_initialized(err, cb)1034)10351036_patch_table_query: (cutoff) =>1037query =1038string_id: @_string_id1039time : if cutoff then {'>=':cutoff} else null1040patch : null # compressed format patch as a JSON *string*1041user_id : null # integer id of user (maps to syncstring table)1042snapshot : null # (optional) a snapshot at this point in time1043sent : null # (optional) when patch actually sent, which may be later than when made1044prev : null # (optional) timestamp of previous patch sent from this session1045if @_patch_format?1046query.format = @_patch_format1047return query10481049_init_patch_list: (cb) =>1050# CRITICAL: note that _handle_syncstring_update checks whether1051# init_patch_list is done by testing whether @_patch_list is defined!1052# That is why we first define "patch_list" below, then set @_patch_list1053# to it only after we're done.1054delete @_patch_list10551056patch_list = new SortedPatchList(@_from_str)10571058@_patches_table = @_client.sync_table({patches : @_patch_table_query(@_last_snapshot)}, \1059undefined, @_patch_interval, @_patch_interval)10601061@_patches_table.once 'connected', =>1062patch_list.add(@_get_patches())1063doc = patch_list.value()1064@_last = @_doc = doc1065@_patches_table.on('change', @_handle_patch_update)1066@_patches_table.on('before-change', => @emit('before-change'))1067@_patch_list = patch_list1068cb()10691070###1071TODO/CRITICAL: We are temporarily disabling same-user collision detection, since this seems to be leading to1072serious issues involving a feedback loop, which may be way worse than the 1 in a million issue1073that this addresses. This only address the *same* account being used simultaneously on the same file1074by multiple people which isn't something users should ever do (but they do in big demos).10751076@_patch_list.on 'overwrite', (t) =>1077# ensure that any outstanding save is done1078@_patches_table.save () =>1079@_check_for_timestamp_collision(t)1080###10811082@_patches_table.on 'saved', (data) =>1083@_handle_offline(data)10841085###1086_check_for_timestamp_collision: (t) =>1087obj = @_my_patches[t]1088if not obj?1089return1090key = @_patches_table.key(obj)1091if obj.patch != @_patches_table.get(key)?.get('patch')1092#console.log("COLLISION! #{t}, #{obj.patch}, #{@_patches_table.get(key).get('patch')}")1093# We fix the collision by finding the nearest time after time that1094# is available, and reinserting our patch at that new time.1095@_my_patches[t] = 'killed'1096new_time = @_patch_list.next_available_time(new Date(t), @_user_id, @_users.length)1097@_save_patch(new_time, JSON.parse(obj.patch))1098###10991100_init_evaluator: (cb) =>1101if misc.filename_extension(@_path) == 'sagews'1102@_evaluator = new Evaluator(@, cb)1103else1104cb()11051106_init_cursors: (cb) =>1107if not @_client.is_user()1108# only the users care about cursors.1109cb()1110else1111if not @_opts.cursors1112cb()1113return1114query =1115cursors :1116string_id : @_string_id1117user_id : null1118locs : null1119time : null1120@_cursors = @_client.sync_table(query)1121@_cursors.once 'connected', =>1122# cursors now initialized; first initialize the local @_cursor_map,1123# which tracks positions of cursors by account_id:1124@_cursor_map = immutable.Map()1125@_cursors.get().map (locs, k) =>1126@_cursor_map = @_cursor_map.set(@_users[JSON.parse(k)?[1]], locs)1127cb()11281129# @_other_cursors is an immutable.js map from account_id's1130# to list of cursor positions of *other* users (starts undefined).1131@_cursor_map = undefined1132@_cursor_throttled = {} # throttled event emitters for each account_id1133emit_cursor_throttled = (account_id) =>1134t = @_cursor_throttled[account_id]1135if not t?1136f = () =>1137@emit('cursor_activity', account_id)1138t = @_cursor_throttled[account_id] = underscore.throttle(f, @_opts.cursor_interval)1139t()11401141@_cursors.on 'change', (keys) =>1142if @_closed1143return1144for k in keys1145account_id = @_users[JSON.parse(k)?[1]]1146@_cursor_map = @_cursor_map.set(account_id, @_cursors.get(k))1147emit_cursor_throttled(account_id)11481149# Set this users cursors to the given locs. This function is1150# throttled, so calling it many times is safe, and all but1151# the last call is discarded.1152# NOTE: no-op if only one user or cursors not enabled for this doc1153set_cursor_locs: (locs) =>1154if @_closed1155return1156if @_users.length <= 21157# Don't bother in special case when only one user (plus the project -- for 2 above!)1158# since we never display the user's1159# own cursors - just other user's cursors. This simple optimization will save tons1160# of bandwidth, since many files are never opened by more than one user.1161return1162@_throttled_set_cursor_locs?(locs)1163return11641165# returns immutable.js map from account_id to list of cursor positions, if cursors are enabled.1166get_cursors: =>1167return @_cursor_map11681169save_asap: (cb) =>1170@_save(cb)11711172# save any changes we have as a new patch1173_save: (cb) =>1174#dbg = @dbg('_save'); dbg('saving changes to db')1175if @_closed1176#dbg("string closed -- can't save")1177cb?("string closed")1178return11791180if not @_last?1181#dbg("string not initialized -- can't save")1182cb?("string not initialized")1183return11841185if @_last.is_equal(@_doc)1186#dbg("nothing changed so nothing to save")1187cb?()1188return11891190if @_saving # this makes it at least safe to call @_save() directly...1191cb?("saving")1192return11931194@_saving = true11951196# compute transformation from _last to live -- exactly what we did1197patch = @_last.make_patch(@_doc)1198if not patch?1199# document not initialized (or closed) so nothing to save1200@_saving = false1201cb?()1202return1203@_last = @_doc12041205# now save the resulting patch1206time = @_client.server_time()12071208min_time = @_patch_list.newest_patch_time()1209if min_time? and min_time >= time1210# Ensure that time is newer than *all* already known times.1211# This is critical to ensure that patches are saved in order,1212# and that the new patch we are making is *on top* of all1213# known patches (otherwise it won't apply cleanly, etc.).1214time = new Date((min_time - 0) + 1)12151216time = @_patch_list.next_available_time(time, @_user_id, @_users.length)12171218# FOR *nasty* worst case DEBUGGING/TESTING ONLY!1219##window?.s = @1220##time = new Date(Math.floor((time - 0)/10000)*10000) # fake timestamps for testing to cause collisions12211222@_save_patch(time, patch, cb)12231224@snapshot_if_necessary()1225# Emit event since this syncstring was definitely changed locally.1226@emit('user_change')1227@_saving = false12281229_undelete: () =>1230if @_closed1231return1232#@dbg("_undelete")()1233@_syncstring_table.set(@_syncstring_table.get_one().set('deleted', false))12341235_save_patch: (time, patch, cb) =>1236if @_closed1237cb?('closed')1238return1239obj = # version for database1240string_id : @_string_id1241time : time1242patch : JSON.stringify(patch)1243user_id : @_user_id1244if @_patch_format?1245obj.format = @_patch_format1246if @_deleted1247# file was deleted but now change is being made, so undelete it.1248@_undelete()1249if @_save_patch_prev?1250# timestamp of last saved patch during this session1251obj.prev = @_save_patch_prev1252@_save_patch_prev = time1253#console.log("_save_patch: #{misc.to_json(obj)}")1254@_my_patches[time - 0] = obj12551256# If in undo mode put the just-created patch in our without timestamp list, so it won't be included when doing undo/redo.1257@_undo_state?.without.unshift(time)12581259x = @_patches_table.set(obj, 'none', cb)1260@_patch_list.add([@_process_patch(x, undefined, undefined, patch)])126112621263# Save current live string to backend. It's safe to call this frequently,1264# since it will debounce itself.1265save: (cb) =>1266@_save_debounce ?= {}1267misc.async_debounce1268f : @_save1269interval : @_save_interval1270state : @_save_debounce1271cb : cb1272return12731274# Create and store in the database a snapshot of the state1275# of the string at the given point in time. This should1276# be the time of an existing patch.1277snapshot: (time, force=false) =>1278if not misc.is_date(time)1279throw Error("time must be a date")1280x = @_patch_list.patch(time)1281if not x?1282console.warn("no patch at time #{time}") # should never happen...1283return1284if x.snapshot? and not force1285# there is already a snapshot at this point in time, so nothing further to do.1286return1287# save the snapshot itself in the patches table.1288obj =1289string_id : @_string_id1290time : time1291patch : JSON.stringify(x.patch)1292snapshot : @_patch_list.value(time, force).to_str()1293user_id : x.user_id1294if force1295# CRITICAL: We are sending the patch/snapshot later, but it was valid.1296# It's important to make this clear or _handle_offline will1297# recompute this snapshot and try to update sent on it again,1298# which leads to serious problems!1299obj.sent = time1300x.snapshot = obj.snapshot # also set snapshot in the @_patch_list, which helps with optimization1301@_patches_table.set obj, 'none' , (err) =>1302if not err1303# CRITICAL: Only save the snapshot time in the database after the set in the patches table was confirmed as a1304# success -- otherwise if the user refreshes their browser (or visits later) they lose all their early work!1305@_syncstring_table.set(string_id:@_string_id, project_id:@_project_id, path:@_path, last_snapshot:time)1306@_last_snapshot = time1307else1308console.warn("failed to save snapshot -- #{err}")1309return time13101311# Have a snapshot every @_snapshot_interval patches, except1312# for the very last interval.1313snapshot_if_necessary: () =>1314time = @_patch_list.time_of_unmade_periodic_snapshot(@_snapshot_interval)1315if time?1316return @snapshot(time)13171318# x - patch object1319# time0, time1: optional range of times; return undefined if patch not in this range1320# patch -- if given will be used as an actual patch instead of x.patch, which is a JSON string.1321_process_patch: (x, time0, time1, patch) =>1322if not x? # we allow for x itself to not be defined since that simplifies other code1323return1324time = x.get('time')1325if not misc.is_date(time)1326try1327time = misc.ISO_to_Date(time)1328if isNaN(time) # ignore patches with bad times1329return1330catch err1331# ignore patches with invalid times1332return1333user_id = x.get('user_id')1334sent = x.get('sent')1335prev = x.get('prev')1336if time0? and time < time01337return1338if time1? and time > time11339return1340if not patch?1341# Do **NOT** use misc.from_json, since we definitely do not want to1342# unpack ISO timestamps as Date, since patch just contains the raw1343# patches from user editing. This was done for a while, which1344# led to horrific bugs in some edge cases...1345# See https://github.com/sagemathinc/cocalc/issues/17711346patch = JSON.parse(x.get('patch') ? '[]')1347snapshot = x.get('snapshot')1348obj =1349time : time1350user_id : user_id1351patch : patch1352if sent?1353obj.sent = sent1354if prev?1355obj.prev = prev1356if snapshot?1357obj.snapshot = snapshot1358return obj13591360# return all patches with time such that time0 <= time <= time1;1361# if time0 undefined then sets equal to time of last_snapshot; if time1 undefined treated as +oo1362_get_patches: (time0, time1) =>1363time0 ?= @_last_snapshot1364m = @_patches_table.get() # immutable.js map with keys the string that is the JSON version of the primary key [string_id, timestamp, user_number].1365v = []1366m.map (x, id) =>1367p = @_process_patch(x, time0, time1)1368if p?1369v.push(p)1370v.sort(patch_cmp)1371return v13721373has_full_history: () =>1374return not @_last_snapshot or @_load_full_history_done13751376load_full_history: (cb) =>1377dbg = @dbg("load_full_history")1378dbg()1379if @has_full_history()1380#dbg("nothing to do, since complete history definitely already loaded")1381cb?()1382return1383query = @_patch_table_query()1384@_client.query1385query : {patches:[query]}1386cb : (err, result) =>1387if err1388cb?(err)1389else1390v = []1391# _process_patch assumes immutable.js objects1392immutable.fromJS(result.query.patches).forEach (x) =>1393p = @_process_patch(x, 0, @_last_snapshot)1394if p?1395v.push(p)1396@_patch_list.add(v)1397@_load_full_history_done = true1398cb?()13991400show_history: (opts) =>1401@_patch_list.show_history(opts)14021403get_path: =>1404return @_path14051406get_project_id: =>1407return @_project_id14081409set_snapshot_interval: (n) =>1410@_syncstring_table.set(@_syncstring_table.get_one().set('snapshot_interval', n))1411return14121413# Check if any patches that just got confirmed as saved are relatively old; if so,1414# we mark them as such and also possibly recompute snapshots.1415_handle_offline: (data) =>1416#dbg = @dbg("_handle_offline")1417#dbg("data='#{misc.to_json(data)}'")1418if @_closed1419return1420now = misc.server_time()1421oldest = undefined1422for obj in data1423if obj.sent1424# CRITICAL: ignore anything already processed! (otherwise, infinite loop)1425continue1426if now - obj.time >= 1000*OFFLINE_THRESH_S1427# patch is "old" -- mark it as likely being sent as a result of being1428# offline, so clients could potentially discard it.1429obj.sent = now1430@_patches_table.set(obj)1431if not oldest? or obj.time < oldest1432oldest = obj.time1433if oldest1434#dbg("oldest=#{oldest}, so check whether any snapshots need to be recomputed")1435for snapshot_time in @_patch_list.snapshot_times()1436if snapshot_time - oldest >= 01437#console.log("recomputing snapshot #{snapshot_time}")1438@snapshot(snapshot_time, true)14391440_handle_syncstring_update: () =>1441#dbg = @dbg("_handle_syncstring_update")1442#dbg()1443if not @_syncstring_table? # not initialized; nothing to do1444#dbg("nothing to do")1445return1446x = @_syncstring_table.get_one()?.toJS()1447#dbg(JSON.stringify(x))1448# TODO: potential races, but it will (or should!?) get instantly fixed when we get an update in case of a race (?)1449client_id = @_client.client_id()1450# Below " not x.snapshot? or not x.users?" is because the initial touch sets1451# only string_id and last_active, and nothing else.1452if not x? or not x.users?1453# Brand new document1454@_last_snapshot = undefined1455@_snapshot_interval = schema.SCHEMA.syncstrings.user_query.get.fields.snapshot_interval1456# brand new syncstring1457@_user_id = 01458@_users = [client_id]1459obj =1460string_id : @_string_id1461project_id : @_project_id1462path : @_path1463last_snapshot : @_last_snapshot1464users : @_users1465deleted : @_deleted1466doctype : misc.to_json(@_doctype)1467@_syncstring_table.set(obj)1468@emit('metadata-change')1469else1470# TODO: handle doctype change here (?)1471@_last_snapshot = x.last_snapshot1472@_snapshot_interval = x.snapshot_interval1473@_users = x.users1474@_project_id = x.project_id1475@_path = x.path1476if @_deleted? and x.deleted and not @_deleted # change to deleted1477@emit("deleted")1478@_deleted = x.deleted14791480# Ensure that this client is in the list of clients1481@_user_id = @_users?.indexOf(client_id)1482if @_user_id == -11483@_user_id = @_users.length1484@_users.push(client_id)1485@_syncstring_table.set({string_id:@_string_id, project_id:@_project_id, path:@_path, users:@_users})14861487if not @_client.is_project()1488@emit('metadata-change')1489return14901491#dbg = @dbg("_handle_syncstring_update('#{@_path}')")1492#dbg("project only handling")1493# Only done for project:1494async.series([1495(cb) =>1496if @_patch_list?1497#dbg("patch list already loaded")1498cb()1499else1500#dbg("wait for patch list to load...")1501@once 'connected', =>1502#dbg("patch list loaded")1503cb()1504(cb) =>1505# NOTE: very important to completely do @_update_watch_path1506# before @_save_to_disk below.1507# If client is a project and path isn't being properly watched, make it so.1508if x.project_id? and @_watch_path != x.path1509#dbg("watch path")1510@_update_watch_path(x.path, cb)1511else1512cb()1513(cb) =>1514if x.save?.state == 'requested'1515#dbg("save to disk")1516@_save_to_disk(cb)1517else1518cb()1519], (err) =>1520if err1521@dbg("_handle_syncstring_update")("POSSIBLY UNHANDLED ERROR -- #{err}")1522@emit('metadata-change')1523)152415251526_update_watch_path: (path, cb) =>1527dbg = @_client.dbg("_update_watch_path('#{path}')")1528if @_file_watcher?1529# clean up1530dbg("close")1531@_file_watcher.close()1532delete @_file_watcher1533delete @_watch_path1534if not path?1535dbg("not opening another watcher")1536cb?()1537return1538if @_watch_path?1539dbg("watch_path already defined")1540cb?()1541return1542dbg("opening watcher")1543@_watch_path = path1544async.series([1545(cb) =>1546@_client.path_exists1547path : path1548cb : (err, exists) =>1549if err1550cb(err)1551else if not exists1552dbg("write '#{path}' to disk from syncstring in-memory database version")1553data = @to_str() ? '' # maybe in case of no patches yet (?).1554@_client.write_file1555path : path1556data : data1557cb : (err) =>1558dbg("wrote '#{path}' to disk -- now calling cb")1559cb(err)1560else1561cb()1562(cb) =>1563dbg("now requesting to watch file")1564@_file_watcher = @_client.watch_file(path:path)1565@_file_watcher.on 'change', (ctime) =>1566ctime ?= new Date()1567ctime = ctime - 01568dbg("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}")1569if @_closed1570@_file_watcher.close()1571return1572if ctime - (@_save_to_disk_start_ctime ? 0) >= 15*10001573# last attempt to save was at least 15s ago, so definitely1574# this change event was not caused by it.1575dbg("_load_from_disk: no recent save")1576@_load_from_disk()1577return1578if not @_save_to_disk_end_ctime?1579# save event started less than 15s and isn't done.1580# ignore this load.1581dbg("_load_from_disk: unfinished @_save_to_disk just happened, so ignoring file change")1582return1583if @_save_to_disk_start_ctime <= ctime and ctime <= @_save_to_disk_end_ctime1584# changed triggered during the save1585dbg("_load_from_disk: change happened during @_save_to_disk , so ignoring file change")1586return1587# Changed happened near to when there was a save, but not in window, so1588# we do it..1589dbg("_load_from_disk: despite a recent save")1590@_load_from_disk()1591return15921593@_file_watcher.on 'delete', =>1594dbg("event delete")1595if @_closed1596@_file_watcher.close()1597return1598dbg("delete: setting deleted=true and closing")1599@from_str('')1600@save () =>1601# NOTE: setting deleted=true must be done **after** setting document to blank above,1602# since otherwise the set would set deleted=false.1603@_syncstring_table.set(@_syncstring_table.get_one().set('deleted', true))1604@_syncstring_table.save () => # make sure deleted:true is saved.1605@close()1606return1607cb()1608], (err) => cb?(err))16091610_load_from_disk: (cb) =>1611path = @get_path()1612dbg = @_client.dbg("syncstring._load_from_disk('#{path}')")1613dbg()1614if @_load_from_disk_lock1615cb?('lock')1616return1617@_load_from_disk_lock = true1618exists = undefined1619async.series([1620(cb) =>1621@_client.path_exists1622path : path1623cb : (err, x) =>1624exists = x1625if not exists1626dbg("file no longer exists")1627@from_str('')1628cb(err)1629(cb) =>1630if exists1631@_update_if_file_is_read_only(cb)1632else1633cb()1634(cb) =>1635if not exists1636cb()1637return1638@_client.path_read1639path : path1640maxsize_MB : MAX_FILE_SIZE_MB1641cb : (err, data) =>1642if err1643dbg("failed -- #{err}")1644cb(err)1645else1646dbg("got it -- length=#{data?.length}")1647@from_str(data)1648# we also know that this is the version on disk, so we update the hash1649@_set_save(state:'done', error:false, hash:misc.hash_string(data))1650cb()1651(cb) =>1652# save back to database1653@_save(cb)1654], (err) =>1655@_load_from_disk_lock = false1656cb?(err)1657)16581659_set_save: (x) =>1660if @_closed # nothing to do1661return1662@_syncstring_table?.set?(@_syncstring_table.get_one()?.set('save', immutable.fromJS(x)))1663return16641665_set_read_only: (read_only) =>1666if @_closed # nothing to do1667return1668@_syncstring_table?.set?(@_syncstring_table.get_one()?.set('read_only', read_only))1669return16701671get_read_only: () =>1672if @_closed # nothing to do1673return1674return @_syncstring_table?.get_one()?.get('read_only')16751676wait_until_read_only_known: (cb) =>1677if not @_syncstring_table?1678cb("@_syncstring_table must be defined")1679return1680@_syncstring_table.wait1681until : (t) => t.get_one()?.get('read_only')?1682cb : cb16831684# Returns true if the current live version of this document has a different hash1685# than the version mostly recently saved to disk. I.e., if there are changes1686# that have not yet been **saved to disk**. See the other function1687# has_uncommitted_changes below for determining whether there are changes1688# that haven't been commited to the database yet.1689has_unsaved_changes: () =>1690return @hash_of_live_version() != @hash_of_saved_version()16911692# Returns hash of last version saved to disk (as far as we know).1693hash_of_saved_version: =>1694return @_syncstring_table?.get_one()?.getIn(['save', 'hash'])16951696# Return hash of the live version of the document, or undefined if the document1697# isn't loaded yet. (TODO: faster version of this for syncdb, which avoids1698# converting to a string, which is a waste of time.)1699hash_of_live_version: =>1700s = @_doc?.to_str?()1701if s?1702return misc.hash_string(s)17031704# Initiates a save of file to disk, then if cb is set, waits for the state to1705# change to done before calling cb.1706save_to_disk: (cb) =>1707#dbg = @dbg("save_to_disk(cb)")1708#dbg("initiating the save")1709if not @has_unsaved_changes()1710# no unsaved changes, so don't save -- CRITICAL: this optimization is assumed by autosave, etc.1711cb?()1712return17131714@_save_to_disk()1715if not @_syncstring_table?1716cb("@_syncstring_table must be defined")1717return1718if cb?1719#dbg("waiting for save.state to change from '#{@_syncstring_table.get_one().getIn(['save','state'])}' to 'done'")1720f = (cb) =>1721if not @_syncstring_table?1722cb(true)1723return1724@_syncstring_table.wait1725until : (table) -> table.get_one()?.getIn(['save','state']) == 'done'1726timeout : 101727cb : (err) =>1728#dbg("done waiting -- now save.state is '#{@_syncstring_table.get_one().getIn(['save','state'])}'")1729if err1730#dbg("got err waiting: #{err}")1731else1732err = @_syncstring_table.get_one().getIn(['save', 'error'])1733#if err1734# dbg("got result but there was an error: #{err}")1735if err1736@touch(0) # touch immediately to ensure backend pays attention.1737cb(err)1738misc.retry_until_success1739f : f1740max_tries : 51741cb : cb17421743# Save this file to disk, if it is associated with a project and has a filename.1744# A user (web browsers) sets the save state to requested.1745# The project sets the state to saving, does the save to disk, then sets1746# the state to done.1747_save_to_disk: (cb) =>1748if @_client.is_user()1749@__save_to_disk_user()1750cb?()1751return17521753if @_saving_to_disk_cbs?1754@_saving_to_disk_cbs.push(cb)1755return1756else1757@_saving_to_disk_cbs = [cb]17581759@__do_save_to_disk_project (err) =>1760v = @_saving_to_disk_cbs1761delete @_saving_to_disk_cbs1762for cb in v1763cb?(err)1764@emit("save_to_disk_project", err)17651766__save_to_disk_user: =>1767if @_closed # nothing to do1768return1769if not @has_unsaved_changes()1770# Browser client that has no unsaved changes, so don't need to save --1771# CRITICAL: this optimization is assumed by autosave, etc.1772return1773# CRITICAL: First, we broadcast interest in the syncstring -- this will cause the relevant project1774# (if it is running) to open the syncstring (if closed), and hence be aware that the client1775# is requesting a save. This is important if the client and database have changes not1776# saved to disk, and the project stopped listening for activity on this syncstring due1777# to it not being touched (due to active editing). Not having this leads to a lot of "can't save"1778# errors.1779@touch()1780@_set_save(state:'requested', error:false)17811782__do_save_to_disk_project: (cb) =>1783# check if on-disk version is same as in memory, in which case no save is needed.1784data = @to_str() # string version of this doc1785hash = misc.hash_string(data)1786if hash == @hash_of_saved_version()1787# No actual save to disk needed; still we better record this fact in table in case it1788# isn't already recorded1789@_set_save(state:'done', error:false, hash:hash)1790cb()1791return17921793path = @get_path()1794#dbg = @dbg("__do_save_to_disk_project('#{path}')")1795if not path?1796cb("not yet initialized")1797return1798if not path1799@_set_save(state:'done', error:'cannot save without path')1800cb("cannot save without path")1801return18021803#dbg("project - write to disk file")1804# set window to slightly earlier to account for clock imprecision.1805# Over an sshfs mount, all stats info is **rounded down to the nearest second**,1806# which this also takes care of.1807@_save_to_disk_start_ctime = new Date() - 15001808@_save_to_disk_end_ctime = undefined1809async.series([1810(cb) =>1811@_client.write_file1812path : path1813data : data1814cb : cb1815(cb) =>1816@_client.path_stat1817path : path1818cb : (err, stat) =>1819@_save_to_disk_end_ctime = stat?.ctime - 01820cb(err)1821], (err) =>1822#dbg("returned from write_file: #{err}")1823if err1824@_set_save(state:'done', error:err)1825else1826@_set_save(state:'done', error:false, hash:misc.hash_string(data))1827cb(err)1828)18291830###1831# When the underlying synctable that defines the state of the document changes1832# due to new remote patches, this function is called.1833# It handles update of the remote version, updating our live version as a result.1834###1835_handle_patch_update: (changed_keys) =>1836if @_closed1837return1838#console.log("_handle_patch_update #{misc.to_json(changed_keys)}")1839if not changed_keys?1840# this happens right now when we do a save.1841return1842if not @_patch_list?1843# nothing to do1844return1845#dbg = @dbg("_handle_patch_update")1846#dbg(new Date(), changed_keys)18471848# note: other code handles that @_patches_table.get(key) may not be defined, e.g., when changed means "deleted"1849@_patch_list.add( (@_process_patch(@_patches_table.get(key)) for key in changed_keys) )18501851# Save any unsaved changes we might have made locally.1852# This is critical to do, since otherwise the remote1853# changes would overwrite the local ones.1854@_save()18551856# compute result of applying all patches in order to snapshot1857new_remote = @_patch_list.value()18581859# temporary hotfix for https://github.com/sagemathinc/cocalc/issues/18731860try1861changed = not @_doc?.is_equal(new_remote)1862catch1863changed = true1864# if any possibility that document changed, set to new version1865if changed1866@_last = @_doc = new_remote1867@emit('change')18681869# Return true if there are changes to this syncstring that have not been1870# committed to the database (with the commit acknowledged). This does not1871# mean the file has been written to disk; however, it does mean that it1872# safe for the user to close their browser.1873has_uncommitted_changes: () =>1874return @_patches_table?.has_uncommitted_changes()18751876exports.SyncDoc = SyncDoc18771878# Immutable string document that satisfies our spec.1879class StringDocument1880constructor: (@_value='') ->18811882to_str: =>1883return @_value18841885is_equal: (other) =>1886return @_value == other?._value18871888apply_patch: (patch) =>1889return new StringDocument(apply_patch(patch, @_value)[0])18901891make_patch: (other) =>1892if not @_value? or not other?._value?1893# document not inialized or other not meaningful1894return1895return make_patch(@_value, other._value)18961897exports._testStringDocument = StringDocument18981899class exports.SyncString extends SyncDoc1900constructor: (opts) ->1901opts = defaults opts,1902id : undefined1903client : required1904project_id : undefined1905path : undefined1906save_interval : undefined1907patch_interval : undefined1908file_use_interval : undefined1909cursors : false # if true, also provide cursor tracking ability191019111912from_str = (str) ->1913new StringDocument(str)19141915super1916string_id : opts.id1917client : opts.client1918project_id : opts.project_id1919path : opts.path1920save_interval : opts.save_interval1921patch_interval : opts.patch_interval1922file_use_interval : opts.file_use_interval1923cursors : opts.cursors1924from_str : from_str1925doctype : {type:'string'}19261927###1928Used for testing1929###1930synctable = require('./synctable')1931class exports.TestBrowserClient1 extends synctable.TestBrowserClient11932constructor: (@_client_id, @_debounce_interval=0) ->19331934is_user: =>1935return true19361937mark_file: =>19381939server_time: =>1940return new Date()19411942sync_table: (query, options, debounce_interval=0) =>1943debounce_interval = @_debounce_interval # hard coded for testing1944return synctable.sync_table(query, options, @, debounce_interval, 0, false)19451946sync_string: (opts) =>1947opts = defaults opts,1948id : undefined1949project_id : undefined1950path : undefined1951file_use_interval : 'default'1952cursors : false1953save_interval : 01954opts.client = @1955return new exports.SyncString(opts)19561957client_id: =>1958return @_client_id195919601961