diff options
Diffstat (limited to 'trunk/etherpad/src/etherpad/pad/model.js')
-rw-r--r-- | trunk/etherpad/src/etherpad/pad/model.js | 651 |
1 files changed, 651 insertions, 0 deletions
diff --git a/trunk/etherpad/src/etherpad/pad/model.js b/trunk/etherpad/src/etherpad/pad/model.js new file mode 100644 index 0000000..9424f10 --- /dev/null +++ b/trunk/etherpad/src/etherpad/pad/model.js @@ -0,0 +1,651 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import("fastJSON"); +import("sqlbase.sqlbase"); +import("sqlbase.sqlcommon"); +import("sqlbase.sqlobj"); +import("timer"); +import("sync"); + +import("etherpad.collab.ace.easysync2.{Changeset,AttribPool}"); +import("etherpad.log"); +import("etherpad.pad.padevents"); +import("etherpad.pad.padutils"); +import("etherpad.pad.dbwriter"); +import("etherpad.pad.pad_migrations"); +import("etherpad.pad.pad_security"); +import("etherpad.collab.collab_server"); +import("cache_utils.syncedWithCache"); +jimport("net.appjet.common.util.LimitedSizeMapping"); + +jimport("java.lang.System.out.println"); + +jimport("java.util.concurrent.ConcurrentHashMap"); +jimport("net.appjet.oui.GlobalSynchronizer"); +jimport("net.appjet.oui.exceptionlog"); + +function onStartup() { + appjet.cache.pads = {}; + appjet.cache.pads.meta = new ConcurrentHashMap(); + appjet.cache.pads.temp = new ConcurrentHashMap(); + appjet.cache.pads.revs = new ConcurrentHashMap(); + appjet.cache.pads.revs10 = new ConcurrentHashMap(); + appjet.cache.pads.revs100 = new ConcurrentHashMap(); + appjet.cache.pads.revs1000 = new ConcurrentHashMap(); + appjet.cache.pads.chat = new ConcurrentHashMap(); + appjet.cache.pads.revmeta = new ConcurrentHashMap(); + appjet.cache.pads.authors = new ConcurrentHashMap(); + appjet.cache.pads.apool = new ConcurrentHashMap(); +} + +var _JSON_CACHE_SIZE = 10000; + +// to clear: appjet.cache.padmodel.modelcache.map.clear() +function _getModelCache() { + return syncedWithCache('padmodel.modelcache', function(cache) { + if (! cache.map) { + cache.map = new LimitedSizeMapping(_JSON_CACHE_SIZE); + } + return cache.map; + }); +} + +function cleanText(txt) { + return txt.replace(/\r\n/g,'\n').replace(/\r/g,'\n').replace(/\t/g, ' ').replace(/\xa0/g, ' '); +} + +/** + * Access a pad object, which is passed as an argument to + * the given padFunc, which is executed inside an exclusive lock, + * and return the result. If the pad doesn't exist, a wrapper + * object is still created and passed to padFunc, and it can + * be used to check whether the pad exists and create it. + * + * Note: padId is a GLOBAL id. + */ +function accessPadGlobal(padId, padFunc, rwMode) { + // this may make a nested call to accessPadGlobal, so do it first + pad_security.checkAccessControl(padId, rwMode); + + // pad is never loaded into memory (made "active") unless it has been migrated. + // Migrations do not use accessPad, but instead access the database directly. + pad_migrations.ensureMigrated(padId); + + var mode = (rwMode || "rw").toLowerCase(); + + if (! appjet.requestCache.padsAccessing) { + appjet.requestCache.padsAccessing = {}; + } + if (appjet.requestCache.padsAccessing[padId]) { + // nested access to same pad + var p = appjet.requestCache.padsAccessing[padId]; + var m = p._meta; + if (m && mode != "r") { + m.status.lastAccess = +new Date(); + m.status.dirty = true; + } + return padFunc(p); + } + + return doWithPadLock(padId, function() { + return sqlcommon.inTransaction(function() { + var meta = _getPadMetaData(padId); // null if pad doesn't exist yet + + if (meta && ! meta.status) { + meta.status = { validated: false }; + } + + if (meta && mode != "r") { + meta.status.lastAccess = +new Date(); + } + + function getCurrentAText() { + var tempObj = pad.tempObj(); + if (! tempObj.atext) { + tempObj.atext = pad.getInternalRevisionAText(meta.head); + } + return tempObj.atext; + } + function addRevision(theChangeset, author, optDatestamp) { + var atext = getCurrentAText(); + var newAText = Changeset.applyToAText(theChangeset, atext, pad.pool()); + Changeset.copyAText(newAText, atext); // updates pad.tempObj().atext! + + var newRev = ++meta.head; + + var revs = _getPadStringArray(padId, "revs"); + revs.setEntry(newRev, theChangeset); + + var revmeta = _getPadStringArray(padId, "revmeta"); + var thisRevMeta = {t: (optDatestamp || (+new Date())), + a: getNumForAuthor(author)}; + if ((newRev % meta.keyRevInterval) == 0) { + thisRevMeta.atext = atext; + } + revmeta.setJSONEntry(newRev, thisRevMeta); + + updateCoarseChangesets(true); + } + function getNumForAuthor(author, dontAddIfAbsent) { + return pad.pool().putAttrib(['author',author||''], dontAddIfAbsent); + } + function getAuthorForNum(n) { + // must return null if n is an attrib number that isn't an author + var pair = pad.pool().getAttrib(n); + if (pair && pair[0] == 'author') { + return pair[1]; + } + return null; + } + + function updateCoarseChangesets(onlyIfPresent) { + // this is fast to run if the coarse changesets + // are up-to-date or almost up-to-date; + // if there's no coarse changeset data, + // it may take a while. + + if (! meta.coarseHeads) { + if (onlyIfPresent) { + return; + } + else { + meta.coarseHeads = {10:-1, 100:-1, 1000:-1}; + } + } + var head = meta.head; + // once we reach head==9, coarseHeads[10] moves + // from -1 up to 0; at head==19 it moves up to 1 + var desiredCoarseHeads = { + 10: Math.floor((head-9)/10), + 100: Math.floor((head-99)/100), + 1000: Math.floor((head-999)/1000) + }; + var revs = _getPadStringArray(padId, "revs"); + var revs10 = _getPadStringArray(padId, "revs10"); + var revs100 = _getPadStringArray(padId, "revs100"); + var revs1000 = _getPadStringArray(padId, "revs1000"); + var fineArrays = [revs, revs10, revs100]; + var coarseArrays = [revs10, revs100, revs1000]; + var levels = [10, 100, 1000]; + var dirty = false; + for(var z=0;z<3;z++) { + var level = levels[z]; + var coarseArray = coarseArrays[z]; + var fineArray = fineArrays[z]; + while (meta.coarseHeads[level] < desiredCoarseHeads[level]) { + dirty = true; + // for example, if the current coarse head is -1, + // compose 0-9 inclusive of the finer level and call it 0 + var x = meta.coarseHeads[level] + 1; + var cs = fineArray.getEntry(10 * x); + for(var i=1;i<=9;i++) { + cs = Changeset.compose(cs, fineArray.getEntry(10*x + i), + pad.pool()); + } + coarseArray.setEntry(x, cs); + meta.coarseHeads[level] = x; + } + } + if (dirty) { + meta.status.dirty = true; + } + } + + /////////////////// "Public" API starts here (functions used by collab_server or other modules) + var pad = { + // Operations that write to the data structure should + // set meta.dirty = true. Any pad access that isn't + // done in "read" mode also sets dirty = true. + getId: function() { return padId; }, + exists: function() { return !!meta; }, + create: function(optText) { + meta = {}; + meta.head = -1; // incremented below by addRevision + pad.tempObj().atext = Changeset.makeAText("\n"); + meta.padId = padId, + meta.keyRevInterval = 100; + meta.numChatMessages = 0; + var t = +new Date(); + meta.status = { validated: true }; + meta.status.lastAccess = t; + meta.status.dirty = true; + meta.supportsTimeSlider = true; + + var firstChangeset = Changeset.makeSplice("\n", 0, 0, + cleanText(optText || '')); + addRevision(firstChangeset, ''); + + _insertPadMetaData(padId, meta); + + sqlobj.insert("PAD_SQLMETA", { + id: padId, version: 2, creationTime: new Date(t), lastWriteTime: new Date(), + headRev: meta.head }); // headRev is not authoritative, just for info + + padevents.onNewPad(pad); + }, + destroy: function() { // you may want to collab_server.bootAllUsers first + padevents.onDestroyPad(pad); + + _destroyPadStringArray(padId, "revs"); + _destroyPadStringArray(padId, "revs10"); + _destroyPadStringArray(padId, "revs100"); + _destroyPadStringArray(padId, "revs1000"); + _destroyPadStringArray(padId, "revmeta"); + _destroyPadStringArray(padId, "chat"); + _destroyPadStringArray(padId, "authors"); + _removePadMetaData(padId); + _removePadAPool(padId); + sqlobj.deleteRows("PAD_SQLMETA", { id: padId }); + meta = null; + }, + writeToDB: function() { + var meta2 = {}; + for(var k in meta) meta2[k] = meta[k]; + delete meta2.status; + sqlbase.putJSON("PAD_META", padId, meta2); + + _getPadStringArray(padId, "revs").writeToDB(); + _getPadStringArray(padId, "revs10").writeToDB(); + _getPadStringArray(padId, "revs100").writeToDB(); + _getPadStringArray(padId, "revs1000").writeToDB(); + _getPadStringArray(padId, "revmeta").writeToDB(); + _getPadStringArray(padId, "chat").writeToDB(); + _getPadStringArray(padId, "authors").writeToDB(); + sqlbase.putJSON("PAD_APOOL", padId, pad.pool().toJsonable()); + + var props = { headRev: meta.head, lastWriteTime: new Date() }; + _writePadSqlMeta(padId, props); + }, + pool: function() { + return _getPadAPool(padId); + }, + getHeadRevisionNumber: function() { return meta.head; }, + getRevisionAuthor: function(r) { + var n = _getPadStringArray(padId, "revmeta").getJSONEntry(r).a; + return getAuthorForNum(Number(n)); + }, + getRevisionChangeset: function(r) { + return _getPadStringArray(padId, "revs").getEntry(r); + }, + tempObj: function() { return _getPadTemp(padId); }, + getKeyRevisionNumber: function(r) { + return Math.floor(r / meta.keyRevInterval) * meta.keyRevInterval; + }, + getInternalRevisionAText: function(r) { + var cacheKey = "atext/C/"+r+"/"+padId; + var modelCache = _getModelCache(); + var cachedValue = modelCache.get(cacheKey); + if (cachedValue) { + modelCache.touch(cacheKey); + //java.lang.System.out.println("HIT! "+cacheKey); + return Changeset.cloneAText(cachedValue); + } + //java.lang.System.out.println("MISS! "+cacheKey); + + var revs = _getPadStringArray(padId, "revs"); + var keyRev = pad.getKeyRevisionNumber(r); + var revmeta = _getPadStringArray(padId, "revmeta"); + var atext = revmeta.getJSONEntry(keyRev).atext; + var curRev = keyRev; + var targetRev = r; + var apool = pad.pool(); + while (curRev < targetRev) { + curRev++; + var cs = pad.getRevisionChangeset(curRev); + atext = Changeset.applyToAText(cs, atext, apool); + } + modelCache.put(cacheKey, Changeset.cloneAText(atext)); + return atext; + }, + getInternalRevisionText: function(r, optInfoObj) { + var atext = pad.getInternalRevisionAText(r); + var text = atext.text; + if (optInfoObj) { + if (text.slice(-1) != "\n") { + optInfoObj.badLastChar = text.slice(-1); + } + } + return text; + }, + getRevisionText: function(r, optInfoObj) { + var internalText = pad.getInternalRevisionText(r, optInfoObj); + return internalText.slice(0, -1); + }, + atext: function() { return Changeset.cloneAText(getCurrentAText()); }, + text: function() { return pad.atext().text; }, + getRevisionDate: function(r) { + var revmeta = _getPadStringArray(padId, "revmeta"); + return new Date(revmeta.getJSONEntry(r).t); + }, + // note: calls like appendRevision will NOT notify clients of the change! + // you must go through collab_server. + // Also, be sure to run cleanText() on any text to strip out carriage returns + // and other stuff. + appendRevision: function(theChangeset, author, optDatestamp) { + addRevision(theChangeset, author || '', optDatestamp); + }, + appendChatMessage: function(obj) { + var index = meta.numChatMessages; + meta.numChatMessages++; + var chat = _getPadStringArray(padId, "chat"); + chat.setJSONEntry(index, obj); + }, + getNumChatMessages: function() { + return meta.numChatMessages; + }, + getChatMessage: function(i) { + var chat = _getPadStringArray(padId, "chat"); + return chat.getJSONEntry(i); + }, + getPadOptionsObj: function() { + var data = pad.getDataRoot(); + if (! data.padOptions) { + data.padOptions = {}; + } + if ((! data.padOptions.guestPolicy) || + (data.padOptions.guestPolicy == 'ask')) { + data.padOptions.guestPolicy = 'deny'; + } + return data.padOptions; + }, + getGuestPolicy: function() { + // allow/ask/deny + return pad.getPadOptionsObj().guestPolicy; + }, + setGuestPolicy: function(policy) { + pad.getPadOptionsObj().guestPolicy = policy; + }, + getDataRoot: function() { + var dataRoot = meta.dataRoot; + if (! dataRoot) { + dataRoot = {}; + meta.dataRoot = dataRoot; + } + return dataRoot; + }, + // returns an object, changes to which are not reflected + // in the DB; use setAuthorData for mutation + getAuthorData: function(author) { + var authors = _getPadStringArray(padId, "authors"); + var n = getNumForAuthor(author, true); + if (n < 0) { + return null; + } + else { + return authors.getJSONEntry(n); + } + }, + setAuthorData: function(author, data) { + var authors = _getPadStringArray(padId, "authors"); + var n = getNumForAuthor(author); + authors.setJSONEntry(n, data); + }, + adoptChangesetAttribs: function(cs, oldPool) { + return Changeset.moveOpsToNewPool(cs, oldPool, pad.pool()); + }, + eachATextAuthor: function(atext, func) { + var seenNums = {}; + Changeset.eachAttribNumber(atext.attribs, function(n) { + if (! seenNums[n]) { + seenNums[n] = true; + var author = getAuthorForNum(n); + if (author) { + func(author, n); + } + } + }); + }, + getCoarseChangeset: function(start, numChangesets) { + updateCoarseChangesets(); + + if (!(numChangesets == 10 || numChangesets == 100 || + numChangesets == 1000)) { + return null; + } + var level = numChangesets; + var x = Math.floor(start / level); + if (!(x >= 0 && x*level == start)) { + return null; + } + + var cs = _getPadStringArray(padId, "revs"+level).getEntry(x); + + if (! cs) { + return null; + } + + return cs; + }, + getSupportsTimeSlider: function() { + if (! ('supportsTimeSlider' in meta)) { + if (padutils.isProPadId(padId)) { + return true; + } + else { + return false; + } + } + else { + return !! meta.supportsTimeSlider; + } + }, + setSupportsTimeSlider: function(v) { + meta.supportsTimeSlider = v; + }, + get _meta() { return meta; } + }; + + try { + padutils.setCurrentPad(padId); + appjet.requestCache.padsAccessing[padId] = pad; + return padFunc(pad); + } + finally { + padutils.clearCurrentPad(); + delete appjet.requestCache.padsAccessing[padId]; + if (meta) { + if (mode != "r") { + meta.status.dirty = true; + } + if (meta.status.dirty) { + dbwriter.notifyPadDirty(padId); + } + } + } + }); + }); +} + +/** + * Call an arbitrary function with no arguments inside an exclusive + * lock on a padId, and return the result. + */ +function doWithPadLock(padId, func) { + var lockName = "document/"+padId; + return sync.doWithStringLock(lockName, func); +} + +function isPadLockHeld(padId) { + var lockName = "document/"+padId; + return GlobalSynchronizer.isHeld(lockName); +} + +/** + * Get pad meta-data object, which is stored in SQL as JSON + * but cached in appjet.cache. Returns null if pad doesn't + * exist at all (does NOT create it). Requires pad lock. + */ +function _getPadMetaData(padId) { + var padMeta = appjet.cache.pads.meta.get(padId); + if (! padMeta) { + // not in cache + padMeta = sqlbase.getJSON("PAD_META", padId); + if (! padMeta) { + // not in SQL + padMeta = null; + } + else { + appjet.cache.pads.meta.put(padId, padMeta); + } + } + return padMeta; +} + +/** + * Sets a pad's meta-data object, such as when creating + * a pad for the first time. Requires pad lock. + */ +function _insertPadMetaData(padId, obj) { + appjet.cache.pads.meta.put(padId, obj); +} + +/** + * Removes a pad's meta data, writing through to the database. + * Used for the rare case of deleting a pad. + */ +function _removePadMetaData(padId) { + appjet.cache.pads.meta.remove(padId); + sqlbase.deleteJSON("PAD_META", padId); +} + +function _getPadAPool(padId) { + var padAPool = appjet.cache.pads.apool.get(padId); + if (! padAPool) { + // not in cache + padAPool = new AttribPool(); + padAPoolJson = sqlbase.getJSON("PAD_APOOL", padId); + if (padAPoolJson) { + // in SQL + padAPool.fromJsonable(padAPoolJson); + } + appjet.cache.pads.apool.put(padId, padAPool); + } + return padAPool; +} + +/** + * Removes a pad's apool data, writing through to the database. + * Used for the rare case of deleting a pad. + */ +function _removePadAPool(padId) { + appjet.cache.pads.apool.remove(padId); + sqlbase.deleteJSON("PAD_APOOL", padId); +} + +/** + * Get an object for a pad that's not persisted in storage, + * e.g. for tracking open connections. Creates object + * if necessary. Requires pad lock. + */ +function _getPadTemp(padId) { + var padTemp = appjet.cache.pads.temp.get(padId); + if (! padTemp) { + padTemp = {}; + appjet.cache.pads.temp.put(padId, padTemp); + } + return padTemp; +} + +/** + * Returns an object with methods for manipulating a string array, where name + * is something like "revs" or "chat". The object must be acquired and used + * all within a pad lock. + */ +function _getPadStringArray(padId, name) { + var padFoo = appjet.cache.pads[name].get(padId); + if (! padFoo) { + padFoo = {}; + // writes go into writeCache, which is authoritative for reads; + // reads cause pages to be read into readCache + padFoo.readCache = {}; + padFoo.writeCache = {}; + appjet.cache.pads[name].put(padId, padFoo); + } + var tableName = "PAD_"+name.toUpperCase(); + var self = { + getEntry: function(idx) { + var n = Number(idx); + if (padFoo.writeCache[n]) return padFoo.writeCache[n]; + if (padFoo.readCache[n]) return padFoo.readCache[n]; + sqlbase.getPageStringArrayElements(tableName, padId, n, padFoo.readCache); + return padFoo.readCache[n]; // null if not present in SQL + }, + setEntry: function(idx, value) { + var n = Number(idx); + var v = String(value); + padFoo.writeCache[n] = v; + }, + getJSONEntry: function(idx) { + var result = self.getEntry(idx); + if (! result) return result; + return fastJSON.parse(String(result)); + }, + setJSONEntry: function(idx, valueObj) { + self.setEntry(idx, fastJSON.stringify(valueObj)); + }, + writeToDB: function() { + sqlbase.putDictStringArrayElements(tableName, padId, padFoo.writeCache); + // copy key-vals of writeCache into readCache + var readCache = padFoo.readCache; + var writeCache = padFoo.writeCache; + for(var p in writeCache) { + readCache[p] = writeCache[p]; + } + padFoo.writeCache = {}; + } + }; + return self; +} + +/** + * Destroy a string array; writes through to the database. Must be + * called within a pad lock. + */ +function _destroyPadStringArray(padId, name) { + appjet.cache.pads[name].remove(padId); + var tableName = "PAD_"+name.toUpperCase(); + sqlbase.clearStringArray(tableName, padId); +} + +/** + * SELECT the row of PAD_SQLMETA for the given pad. Requires pad lock. + */ +function _getPadSqlMeta(padId) { + return sqlobj.selectSingle("PAD_SQLMETA", { id: padId }); +} + +function _writePadSqlMeta(padId, updates) { + sqlobj.update("PAD_SQLMETA", { id: padId }, updates); +} + + +// called from dbwriter +function removeFromMemory(pad) { + // safe to call if all data is written to SQL, otherwise will lose data; + var padId = pad.getId(); + appjet.cache.pads.meta.remove(padId); + appjet.cache.pads.revs.remove(padId); + appjet.cache.pads.revs10.remove(padId); + appjet.cache.pads.revs100.remove(padId); + appjet.cache.pads.revs1000.remove(padId); + appjet.cache.pads.chat.remove(padId); + appjet.cache.pads.revmeta.remove(padId); + appjet.cache.pads.apool.remove(padId); + collab_server.removeFromMemory(pad); +} + + |