summaryrefslogtreecommitdiffstats
path: root/trunk/etherpad/src/etherpad
diff options
context:
space:
mode:
Diffstat (limited to 'trunk/etherpad/src/etherpad')
-rw-r--r--trunk/etherpad/src/etherpad/admin/shell.js127
-rw-r--r--trunk/etherpad/src/etherpad/billing/billing.js800
-rw-r--r--trunk/etherpad/src/etherpad/billing/fields.js219
-rw-r--r--trunk/etherpad/src/etherpad/billing/team_billing.js422
-rw-r--r--trunk/etherpad/src/etherpad/collab/ace/contentcollector.js527
-rw-r--r--trunk/etherpad/src/etherpad/collab/ace/domline.js210
-rw-r--r--trunk/etherpad/src/etherpad/collab/ace/easysync1.js923
-rw-r--r--trunk/etherpad/src/etherpad/collab/ace/easysync2.js1968
-rw-r--r--trunk/etherpad/src/etherpad/collab/ace/easysync2_tests.js877
-rw-r--r--trunk/etherpad/src/etherpad/collab/ace/linestylefilter.js253
-rw-r--r--trunk/etherpad/src/etherpad/collab/collab_server.js778
-rw-r--r--trunk/etherpad/src/etherpad/collab/collabroom_server.js359
-rw-r--r--trunk/etherpad/src/etherpad/collab/genimg.js55
-rw-r--r--trunk/etherpad/src/etherpad/collab/json_sans_eval.js178
-rw-r--r--trunk/etherpad/src/etherpad/collab/readonly_server.js174
-rw-r--r--trunk/etherpad/src/etherpad/collab/server_utils.js204
-rw-r--r--trunk/etherpad/src/etherpad/control/aboutcontrol.js263
-rw-r--r--trunk/etherpad/src/etherpad/control/admincontrol.js1471
-rw-r--r--trunk/etherpad/src/etherpad/control/blogcontrol.js199
-rw-r--r--trunk/etherpad/src/etherpad/control/connection_diagnostics_control.js87
-rw-r--r--trunk/etherpad/src/etherpad/control/global_pro_account_control.js143
-rw-r--r--trunk/etherpad/src/etherpad/control/historycontrol.js226
-rw-r--r--trunk/etherpad/src/etherpad/control/loadtestcontrol.js93
-rw-r--r--trunk/etherpad/src/etherpad/control/maincontrol.js54
-rw-r--r--trunk/etherpad/src/etherpad/control/pad/pad_changeset_control.js280
-rw-r--r--trunk/etherpad/src/etherpad/control/pad/pad_control.js780
-rw-r--r--trunk/etherpad/src/etherpad/control/pad/pad_importexport_control.js319
-rw-r--r--trunk/etherpad/src/etherpad/control/pad/pad_view_control.js287
-rw-r--r--trunk/etherpad/src/etherpad/control/pne_manual_control.js75
-rw-r--r--trunk/etherpad/src/etherpad/control/pne_tracker_control.js48
-rw-r--r--trunk/etherpad/src/etherpad/control/pro/account_control.js369
-rw-r--r--trunk/etherpad/src/etherpad/control/pro/admin/account_manager_control.js260
-rw-r--r--trunk/etherpad/src/etherpad/control/pro/admin/license_manager_control.js128
-rw-r--r--trunk/etherpad/src/etherpad/control/pro/admin/pro_admin_control.js283
-rw-r--r--trunk/etherpad/src/etherpad/control/pro/admin/pro_config_control.js54
-rw-r--r--trunk/etherpad/src/etherpad/control/pro/admin/team_billing_control.js447
-rw-r--r--trunk/etherpad/src/etherpad/control/pro/pro_main_control.js150
-rw-r--r--trunk/etherpad/src/etherpad/control/pro/pro_padlist_control.js200
-rw-r--r--trunk/etherpad/src/etherpad/control/pro_beta_control.js136
-rw-r--r--trunk/etherpad/src/etherpad/control/pro_signup_control.js173
-rw-r--r--trunk/etherpad/src/etherpad/control/scriptcontrol.js75
-rw-r--r--trunk/etherpad/src/etherpad/control/static_control.js65
-rw-r--r--trunk/etherpad/src/etherpad/control/statscontrol.js1214
-rw-r--r--trunk/etherpad/src/etherpad/control/store/eepnet_checkout_control.js757
-rw-r--r--trunk/etherpad/src/etherpad/control/store/storecontrol.js201
-rw-r--r--trunk/etherpad/src/etherpad/control/testcontrol.js74
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0000_test.js23
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0001_eepnet_signups_init.js38
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0002_eepnet_signups_2.js47
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0003_create_tests_table_v2.js29
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0004_convert_all_tables_to_innodb.js38
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0005_create_billing_tables.js73
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0006_eepnet_signups_3.js29
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0007_create_pro_tables_v4.js67
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0008_persistent_vars.js31
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0009_pad_tables.js31
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0010_pad_sqlmeta.js71
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0011_pro_users_temppass.js33
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0012_pro_users_auto_signin.js30
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0013_pne_padv2_upgrade.js54
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0014_pne_globalpadids.js102
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0015_padmeta_passwords.js25
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0016_pne_tracking_data.js35
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0017_pne_tracking_data_v2.js30
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0018_eepnet_checkout_tables.js82
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0019_padmeta_deleted.js24
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0020_padmeta_archived.js25
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0021_pro_padmeta_json.js57
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0022_create_userids_table.js30
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0023_create_usagestats_table.js32
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0024_statistics_table.js42
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0025_rename_pro_users_table.js26
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0026_create_guests_table.js37
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0027_pro_config.js27
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0028_ondemand_beta_emails.js29
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0029_lowercase_subdomains.js31
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0030_fix_statistics_values.js26
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0031_deleted_pro_users.js24
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0032_reduce_topvalues_counts.js39
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0033_pro_account_usage.js30
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0034_create_recurring_billing_table.js42
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0035_add_email_to_paymentinfo.js28
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0036_create_missing_subscription_records.js45
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0037_create_pro_referral_table.js32
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/m0038_pad_coarse_revs.js26
-rw-r--r--trunk/etherpad/src/etherpad/db_migrations/migration_runner.js147
-rw-r--r--trunk/etherpad/src/etherpad/debug.js26
-rw-r--r--trunk/etherpad/src/etherpad/globals.js41
-rw-r--r--trunk/etherpad/src/etherpad/helpers.js276
-rw-r--r--trunk/etherpad/src/etherpad/importexport/importexport.js241
-rw-r--r--trunk/etherpad/src/etherpad/legacy_urls.js37
-rw-r--r--trunk/etherpad/src/etherpad/licensing.js163
-rw-r--r--trunk/etherpad/src/etherpad/log.js255
-rw-r--r--trunk/etherpad/src/etherpad/metrics/metrics.js438
-rw-r--r--trunk/etherpad/src/etherpad/pad/activepads.js52
-rw-r--r--trunk/etherpad/src/etherpad/pad/chatarchive.js67
-rw-r--r--trunk/etherpad/src/etherpad/pad/dbwriter.js338
-rw-r--r--trunk/etherpad/src/etherpad/pad/easysync2migration.js675
-rw-r--r--trunk/etherpad/src/etherpad/pad/exporthtml.js383
-rw-r--r--trunk/etherpad/src/etherpad/pad/importhtml.js230
-rw-r--r--trunk/etherpad/src/etherpad/pad/model.js651
-rw-r--r--trunk/etherpad/src/etherpad/pad/noprowatcher.js110
-rw-r--r--trunk/etherpad/src/etherpad/pad/pad_migrations.js206
-rw-r--r--trunk/etherpad/src/etherpad/pad/pad_security.js237
-rw-r--r--trunk/etherpad/src/etherpad/pad/padevents.js170
-rw-r--r--trunk/etherpad/src/etherpad/pad/padusers.js397
-rw-r--r--trunk/etherpad/src/etherpad/pad/padutils.js154
-rw-r--r--trunk/etherpad/src/etherpad/pad/revisions.js103
-rw-r--r--trunk/etherpad/src/etherpad/pne/pne_utils.js187
-rw-r--r--trunk/etherpad/src/etherpad/pro/domains.js141
-rw-r--r--trunk/etherpad/src/etherpad/pro/pro_account_auto_signin.js101
-rw-r--r--trunk/etherpad/src/etherpad/pro/pro_accounts.js496
-rw-r--r--trunk/etherpad/src/etherpad/pro/pro_config.js92
-rw-r--r--trunk/etherpad/src/etherpad/pro/pro_pad_db.js232
-rw-r--r--trunk/etherpad/src/etherpad/pro/pro_pad_editors.js104
-rw-r--r--trunk/etherpad/src/etherpad/pro/pro_padlist.js289
-rw-r--r--trunk/etherpad/src/etherpad/pro/pro_padmeta.js111
-rw-r--r--trunk/etherpad/src/etherpad/pro/pro_quotas.js141
-rw-r--r--trunk/etherpad/src/etherpad/pro/pro_utils.js165
-rw-r--r--trunk/etherpad/src/etherpad/quotas.js50
-rw-r--r--trunk/etherpad/src/etherpad/sessions.js203
-rw-r--r--trunk/etherpad/src/etherpad/statistics/exceptions.js231
-rw-r--r--trunk/etherpad/src/etherpad/statistics/statistics.js1248
-rw-r--r--trunk/etherpad/src/etherpad/store/checkout.js300
-rw-r--r--trunk/etherpad/src/etherpad/store/eepnet_checkout.js101
-rw-r--r--trunk/etherpad/src/etherpad/store/eepnet_trial.js241
-rw-r--r--trunk/etherpad/src/etherpad/testing/testutils.js23
-rw-r--r--trunk/etherpad/src/etherpad/testing/unit_tests/t0000_test.js22
-rw-r--r--trunk/etherpad/src/etherpad/testing/unit_tests/t0001_sqlbase_transaction_rollback.js48
-rw-r--r--trunk/etherpad/src/etherpad/testing/unit_tests/t0002_license_generation.js89
-rw-r--r--trunk/etherpad/src/etherpad/testing/unit_tests/t0003_persistent_vars.js42
-rw-r--r--trunk/etherpad/src/etherpad/testing/unit_tests/t0004_sqlobj.js214
-rw-r--r--trunk/etherpad/src/etherpad/testing/unit_tests/t0005_easysync.js22
-rw-r--r--trunk/etherpad/src/etherpad/usage_stats/usage_stats.js162
-rw-r--r--trunk/etherpad/src/etherpad/utils.js396
135 files changed, 29353 insertions, 0 deletions
diff --git a/trunk/etherpad/src/etherpad/admin/shell.js b/trunk/etherpad/src/etherpad/admin/shell.js
new file mode 100644
index 0000000..391d524
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/admin/shell.js
@@ -0,0 +1,127 @@
+/**
+ * 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("funhtml.*");
+import("jsutils.cmp");
+import("jsutils.eachProperty");
+import("exceptionutils");
+import("execution");
+import("stringutils.trim");
+
+import("etherpad.sessions.getSession");
+import("etherpad.utils.*");
+
+function _splitCommand(cmd) {
+ var parts = [[], []];
+ var importing = true;
+ cmd.split("\n").forEach(function(l) {
+ if ((trim(l).length > 0) &&
+ (trim(l).indexOf("import") != 0)) {
+ importing = false;
+ }
+
+ if (importing) {
+ parts[0].push(l);
+ } else {
+ parts[1].push(l);
+ }
+ });
+
+ parts[0] = parts[0].join("\n");
+ parts[1] = parts[1].join("\n");
+ return parts;
+}
+
+function getResult(cmd) {
+ var resultString = (function() {
+ try {
+ var parts = _splitCommand(cmd);
+ result = execution.fancyAssEval(parts[0], parts[1]);
+ } catch (e) {
+ // if (e instanceof JavaException) {
+ // e = new net.appjet.bodylock.JSRuntimeException(e.getMessage(), e.javaException);
+ // }
+ if (appjet.config.devMode) {
+ (e.javaException || e.rhinoException || e).printStackTrace();
+ }
+ result = exceptionutils.getStackTracePlain(e);
+ }
+ var resultString;
+ try {
+ resultString = ((result && result.toString) ? result.toString() : String(result));
+ } catch (ex) {
+ resultString = "Error converting result to string: "+ex.toString();
+ }
+ return resultString;
+ })();
+ return resultString;
+}
+
+function _renderCommandShell() {
+ // run command if necessary
+ if (request.params.cmd) {
+ var cmd = request.params.cmd;
+ var resultString = getResult(cmd);
+
+ getSession().shellCommand = cmd;
+ getSession().shellResult = resultString;
+ response.redirect(request.path+(request.query?'?'+request.query:''));
+ }
+
+ var div = DIV({style: "padding: 4px; margin: 4px; background: #eee; "
+ + "border: 1px solid #338"});
+ // command div
+ var oldCmd = getSession().shellCommand || "";
+ var commandDiv = DIV({style: "width: 100%; margin: 4px 0;"});
+ commandDiv.push(FORM({style: "width: 100%;",
+ method: "POST", action: request.path + (request.query?'?'+request.query:'')},
+ TEXTAREA({name: "cmd",
+ style: "border: 1px solid #555;"
+ + "width: 100%; height: 160px; font-family: monospace;"},
+ html(oldCmd)),
+ INPUT({type: "submit"})));
+
+ // result div
+ var resultDiv = DIV({style: ""});
+ var isResult = getSession().shellResult != null;
+ if (isResult) {
+ resultDiv.push(DIV(
+ PRE({style: 'border: 1px solid #555; font-family: monospace; margin: 4px 0; padding: 4px;'},
+ getSession().shellResult)));
+ delete getSession().shellResult;
+ resultDiv.push(DIV({style: "text-align: right;"},
+ A({href: qpath({})}, "clear")));
+ } else {
+ resultDiv.push(P("result will go here"));
+ }
+
+ var t = TABLE({border: 0, cellspacing: 0, cellpadding: 0, width: "100%",
+ style: "width: 100%;"});
+ t.push(TR(TH({width: "49%", align: "left"}, " Command:"),
+ TH({width: "49%", align: "left"}, " "+(isResult ? "Result:" : ""))),
+ TR(TD({valign: "top", style: 'padding: 4px;'}, commandDiv),
+ TD({valign: "top", style: 'padding: 4px;'}, resultDiv)));
+ div.push(t);
+ return div;
+}
+
+function handleRequest() {
+ var body = BODY();
+ body.push(A({href: '/ep/admin/'}, html("« Admin")));
+ body.push(BR(), BR());
+ body.push(_renderCommandShell());
+ response.write(HTML(body));
+}
diff --git a/trunk/etherpad/src/etherpad/billing/billing.js b/trunk/etherpad/src/etherpad/billing/billing.js
new file mode 100644
index 0000000..444c233
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/billing/billing.js
@@ -0,0 +1,800 @@
+/**
+ * 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("dateutils.*");
+import("fastJSON");
+import("jsutils.eachProperty");
+import("netutils.urlPost");
+import("sqlbase.sqlbase");
+import("sqlbase.sqlcommon");
+import("sqlbase.sqlobj");
+import("stringutils.{md5,repeat}");
+
+import("etherpad.log.{custom=>eplog}");
+
+
+jimport("java.lang.System.out.println");
+
+function clearKeys(obj, keys) {
+ var newObj = {};
+ eachProperty(obj, function(k, v) {
+ var isCopied = false;
+ keys.forEach(function(key) {
+ if (k == key.name &&
+ key.valueTest(v)) {
+ newObj[k] = key.valueReplace(v);
+ isCopied = true;
+ }
+ });
+ if (! isCopied) {
+ if (typeof(obj[k]) == 'object') {
+ newObj[k] = clearKeys(v, keys);
+ } else {
+ newObj[k] = v;
+ }
+ }
+ });
+ return newObj;
+}
+
+function replaceWithX(s) {
+ return repeat("X", s.length);
+}
+
+function log(obj) {
+ eplog('billing', clearKeys(obj, [
+ {name: "ACCT",
+ valueTest: function(s) { return /^\d{15,16}$/.test(s) },
+ valueReplace: replaceWithX},
+ {name: "CVV2",
+ valueTest: function(s) { return /^\d{3,4}$/.test(s) },
+ valueReplace: replaceWithX}]));
+}
+
+var _USER = function() { return appjet.config['etherpad.paypal.user'] || "zamfir_1239051855_biz_api1.gmail.com"; }
+var _PWD = function() { return appjet.config['etherpad.paypal.pwd'] || "1239051867"; }
+var _SIGNATURE = function() { return appjet.config['etherpad.paypal.signature'] || "AQU0e5vuZCvSg-XJploSa.sGUDlpAwAy5fz.FhtfOQ25Qa9sFLDt7Bmp"; }
+var _RECEIVER = function() { return appjet.config['etherpad.paypal.receiver'] || "zamfir_1239051855_biz@gmail.com"; }
+var _paypalApiUrl = function() { return appjet.config['etherpad.paypal.apiUrl'] || "https://api-3t.sandbox.paypal.com/nvp"; }
+var _paypalWebUrl = function() { return appjet.config['etherpad.paypal.webUrl'] || "https://www.sandbox.paypal.com/cgi-bin/webscr"; }
+function paypalPurchaseUrl(token) {
+ return (appjet.config['etherpad.paypal.purchaseUrl'] || "https://www.sandbox.paypal.com/cgi-bin/webscr?cmd=_express-checkout&token=")+token;
+}
+
+function getPurchase(id) {
+ return sqlobj.selectSingle('billing_purchase', {id: id});
+}
+
+function getPurchaseForCustomer(customerId) {
+ return sqlobj.selectSingle('billing_purchase', {customer: customerId});
+}
+
+function updatePurchase(id, fields) {
+ sqlobj.updateSingle('billing_purchase', {id: id}, fields);
+}
+
+function getInvoicesForPurchase(purchaseId) {
+ return sqlobj.selectMulti('billing_invoice', {purchase: purchaseId});
+}
+
+function getInvoice(id) {
+ return sqlobj.selectSingle('billing_invoice', {id: id});
+}
+
+function createInvoice() {
+ return _newInvoice();
+}
+
+function updateInvoice(id, fields) {
+ sqlobj.updateSingle('billing_invoice', {id: id}, fields)
+}
+
+function getTransaction(id) {
+ return sqlobj.selectSingle('billing_transaction', {id: id});
+}
+function getTransactionByExternalId(txnId) {
+ return sqlobj.selectSingle('billing_transaction', {txnId: txnId});
+}
+
+function getTransactionsForCustomer(customerId) {
+ return sqlobj.selectMulti('billing_transaction', {customer: customerId});
+}
+
+function getPendingTransactionsForCustomer(customerId) {
+ return sqlobj.selectMulti('billing_transaction', {customer: customerId, status: 'pending'});
+}
+
+function _updateTransaction(id, fields) {
+ return sqlobj.updateSingle('billing_transaction', {id: id}, fields);
+}
+
+function getAdjustments(invoiceId) {
+ return sqlobj.selectMulti('billing_adjustment', {invoice: invoiceId});
+}
+
+function createSubscription(customer, product, dollars, couponCode) {
+ var purchaseId = _newPurchase(customer, product, dollarsToCents(dollars), couponCode);
+ _purchaseActive(purchaseId);
+ updatePurchase(purchaseId, {type: 'subscription', paidThrough: nextMonth(noon(new Date))});
+ return purchaseId;
+}
+
+function _newPurchase(customer, product, cents, couponCode) {
+ var purchaseId = sqlobj.insert('billing_purchase', {
+ customer: customer,
+ product: product,
+ cost: cents,
+ coupon: couponCode,
+ status: 'inactive'
+ });
+ return purchaseId;
+}
+
+function _newInvoice() {
+ var invoiceId = sqlobj.insert('billing_invoice', {
+ time: new Date(),
+ purchase: -1,
+ amt: 0,
+ status: 'pending'
+ });
+ return invoiceId;
+}
+
+function _newTransaction(customer, cents) {
+ var transactionId = sqlobj.insert('billing_transaction', {
+ customer: customer,
+ time: new Date(),
+ amt: cents,
+ status: 'new'
+ });
+ return transactionId;
+}
+
+function _newAdjustment(transaction, invoice, cents) {
+ sqlobj.insert('billing_adjustment', {
+ transaction: transaction,
+ invoice: invoice,
+ time: new Date(),
+ amt: cents
+ });
+}
+
+function _transactionSuccess(transaction, txnId, payInfo) {
+ _updateTransaction(transaction, {
+ status: 'success', txnId: txnId, time: new Date(), payInfo: payInfo
+ });
+}
+
+function _transactionFailure(transaction, txnId) {
+ _updateTransaction(transaction, {
+ status: 'failure', txnId: txnId, time: new Date()
+ });
+}
+
+function _transactionPending(transaction, txnId) {
+ _updateTransaction(transaction, {
+ status: 'pending', txnId: txnId, time: new Date()
+ });
+}
+
+function _invoicePaid(invoice) {
+ updateInvoice(invoice, {status: 'paid'});
+}
+
+function _purchaseActive(purchase) {
+ updatePurchase(purchase, {status: 'active'});
+}
+
+function _purchaseExtend(purchase, monthCount) {
+ var expiration = getPurchase(purchase).paidThrough;
+ for (var i = monthCount; i > 0; i--) {
+ expiration = nextMonth(expiration);
+ }
+ // paying your invoice always makes you current.
+ if (expiration < new Date) {
+ expiration = nextMonth(new Date);
+ }
+ updatePurchase(purchase, {paidThrough: expiration});
+}
+
+function _doPost(url, body) {
+ try {
+ var ret = urlPost(url, body);
+ } catch (e) {
+ if (e.javaException) {
+ net.appjet.oui.exceptionlog.apply(e.javaException);
+ }
+ return { error: e };
+ }
+ return { value: ret };
+}
+
+function _doPaypalNvpPost(properties0) {
+ return {
+ status: 'failure',
+ errorMessage: "Billing has been discontinued. No new services may be purchased."
+ }
+ // var properties = {
+ // USER: _USER(),
+ // PWD: _PWD(),
+ // SIGNATURE: _SIGNATURE(),
+ // VERSION: "56.0"
+ // }
+ // eachProperty(properties0, function(k, v) {
+ // if (v !== undefined) {
+ // properties[k] = v;
+ // }
+ // })
+ // log({'type': 'api call', 'value': properties});
+ // var ret = _doPost(_paypalApiUrl(), properties);
+ // if (ret.error) {
+ // return {
+ // status: 'failure',
+ // exception: ret.error.javaException || ret.error,
+ // errorMessage: ret.error.message
+ // }
+ // }
+ // ret = ret.value;
+ // var paypalResponse = {};
+ // ret.content.split("&").forEach(function(x) {
+ // var parts = x.split("=");
+ // paypalResponse[decodeURIComponent(parts[0])] =
+ // decodeURIComponent(parts[1]);
+ // })
+ //
+ // var res = paypalResponse;
+ // log(res)
+ // if (res.ACK == "Success" || res.ACK == "SuccessWithWarning") {
+ // return {
+ // status: 'success',
+ // response: res
+ // }
+ // } else {
+ // errors = [];
+ // for (var i = 0; res['L_LONGMESSAGE'+i]; ++i) {
+ // errors.push(res['L_LONGMESSAGE'+i]);
+ // }
+ // return {
+ // status: 'failure',
+ // errorMessage: errors.join(", "),
+ // errorMessages: errors,
+ // response: res
+ // }
+ // }
+}
+
+// status -> 'completion', 'bad', 'redundant', 'possible_fraud'
+function handlePaypalNotification() {
+ var content = (typeof(request.content) == 'string' ? request.content : undefined);
+ if (! content) {
+ return new BillingResult('bad', "no content");
+ }
+ log({'type': 'paypal-notification', 'content': content});
+ var params = {};
+ content.split("&").forEach(function(x) {
+ var parts = x.split("=");
+ params[decodeURIComponent(parts[0])] = decodeURIComponent(parts[1]);
+ });
+ var txnId = params.txn_id;
+ var properties = [];
+ for(var i in params) {
+ properties.push(i+" -> "+params[i]);
+ }
+ var debugString = properties.join(", ");
+ log({'type': 'parsed-paypal-notification', 'value': debugString});
+ var transaction = getTransactionByExternalId(txnId);
+ log({'type': 'notification-transaction', 'value': (transaction || {})});
+ if (_RECEIVER() != params.receiver_email) {
+ return new BillingResult('possible_fraud', debugString);
+ }
+ if (params.payment_status == "Completed" && transaction &&
+ (transaction.status == 'pending' || transaction.status == 'new')) {
+ var ret = _doPost(_paypalWebUrl(), "cmd=_notify-validate&"+content);
+ if (ret.error || ret.value.content != "VERIFIED") {
+ return new BillingResult('possible_fraud', debugString);
+ }
+ var invoice = getInvoice(params.invoice);
+ if (invoice.amt != dollarsToCents(params.mc_gross)) {
+ return new BillingResult('possible_fraud', debugString);
+ }
+
+ sqlcommon.inTransaction(function () {
+ _transactionSuccess(transaction.id, txnId, "via eCheck");
+ _invoicePaid(invoice.id);
+ _purchaseActive(invoice.purchase);
+ });
+ var purchase = getPurchase(invoice.purchase);
+ return new BillingResult('completion', debugString, null,
+ new PurchaseInfo(params.custom,
+ invoice.id,
+ transaction.id,
+ params.txn_id,
+ purchase.id,
+ centsToDollars(invoice.amt),
+ purchase.couponCode,
+ purchase.time,
+ undefined));
+ } else {
+ return new BillingResult('redundant', debugString);
+ }
+}
+
+function _expressCheckoutCustom(invoiceId, transactionId) {
+ return md5("zimki_sucks"+invoiceId+transactionId);
+}
+
+function PurchaseInfo(custom, invoiceId, transactionId, paypalId, purchaseId, dollars, couponCode, time, token, description) {
+ this.__defineGetter__("custom", function() { return custom });
+ this.__defineGetter__("invoiceId", function() { return invoiceId });
+ this.__defineGetter__("transactionId", function() { return transactionId });
+ this.__defineGetter__("paypalId", function() { return paypalId });
+ this.__defineGetter__("purchaseId", function() { return purchaseId });
+ this.__defineGetter__("cost", function() { return dollars });
+ this.__defineGetter__("couponCode", function() { return couponCode });
+ this.__defineGetter__("time", function() { return time });
+ this.__defineGetter__("token", function() { return token });
+ this.__defineGetter__("description", function() { return description });
+}
+
+function PayerInfo(paypalResult) {
+ this.__defineGetter__("payerId", function() { return paypalResult.response.PAYERID });
+ this.__defineGetter__("email", function() { return paypalResult.response.EMAIL });
+ this.__defineGetter__("businessName", function() { return paypalResult.response.BUSINESS });
+ this.__defineGetter__("nameSalutation", function() { return paypalResult.response.SALUTATION });
+ this.__defineGetter__("nameFirst", function() { return paypalResult.response.FIRSTNAME });
+ this.__defineGetter__("nameMiddle", function() { return paypalResult.response.MIDDLENAME });
+ this.__defineGetter__("nameLast", function() { return paypalResult.response.LASTNAME });
+}
+
+function BillingResult(status, debug, errorField, purchaseInfo, payerInfo) {
+ this.__defineGetter__("status", function() { return status });
+ this.__defineGetter__("debug", function() { return debug });
+ this.__defineGetter__("errorField", function() { return errorField });
+ this.__defineGetter__("purchaseInfo", function() { return purchaseInfo });
+ this.__defineGetter__("payerInfo", function() { return payerInfo });
+}
+
+function dollarsToCents(dollars) {
+ return Math.round(Number(dollars)*100);
+}
+
+function centsToDollars(cents) {
+ return Math.round(Number(cents)) / 100;
+}
+
+function verifyDollars(dollars) {
+ return Math.round(Number(dollars)*100)/100;
+}
+
+function beginExpressPurchase(invoiceId, customerId, productId, dollars, couponCode, successUrl, failureUrl, notifyUrl, authorizeOnly) {
+ var cents = dollarsToCents(dollars);
+ var time = new Date();
+ var purchaseId;
+ var transactionid;
+ if (! authorizeOnly) {
+ try {
+ sqlcommon.inTransaction(function() {
+ purchaseId = _newPurchase(customerId, productId, cents, couponCode);
+ updateInvoice(invoiceId, {purchase: purchaseId, amt: cents});
+ transactionId = _newTransaction(customerId, cents);
+ _newAdjustment(transactionId, invoiceId, cents);
+ });
+ } catch (e) {
+ if (e instanceof BillingResult) { return e; }
+ throw e;
+ }
+ }
+
+ var paypalResult =
+ _setExpressCheckout(invoiceId, transactionId, cents,
+ successUrl, failureUrl, notifyUrl, authorizeOnly);
+
+ if (paypalResult.status == 'success') {
+ var token = paypalResult.response.TOKEN;
+ return new BillingResult('success', paypalResult, null, new PurchaseInfo(
+ _expressCheckoutCustom(invoiceId, transactionId),
+ invoiceId,
+ transactionId,
+ undefined,
+ purchaseId,
+ verifyDollars(dollars),
+ couponCode,
+ time,
+ token));
+ } else {
+ return new BillingResult('failure', paypalResult);
+ }
+}
+
+function _setExpressCheckout(invoiceId, transactionId, cents, successUrl, failureUrl, notifyUrl, authorizeOnly) {
+ var properties = {
+ INVNUM: invoiceId,
+
+ METHOD: 'SetExpressCheckout',
+ CUSTOM:
+ _expressCheckoutCustom(invoiceId, transactionId),
+ MAXAMT: centsToDollars(cents),
+ RETURNURL: successUrl,
+ CANCELURL: failureUrl,
+ NOTIFYURL: notifyUrl,
+ NOSHIPPING: 1,
+ PAYMENTACTION: (authorizeOnly ? 'Authorization' : 'Sale'),
+
+ AMT: centsToDollars(cents)
+ }
+
+ return _doPaypalNvpPost(properties);
+}
+
+function continueExpressPurchase(purchaseInfo, authorizeOnly) {
+ var paypalResult = _getExpressCheckoutDetails(purchaseInfo.token, authorizeOnly)
+ if (paypalResult.status == 'success') {
+ if (! authorizeOnly) {
+ if (paypalResult.response.INVNUM != purchaseInfo.invoiceId) {
+ return new BillingResult('failure', "invoice id mismatch");
+ }
+ }
+ if (paypalResult.response.CUSTOM !=
+ _expressCheckoutCustom(purchaseInfo.invoiceId, purchaseInfo.transactionId)) {
+ return new BillingResult('failure', "custom mismatch");
+ }
+ return new BillingResult('success', paypalResult, null, null, new PayerInfo(paypalResult));
+ } else {
+ return new BillingResult('failure', paypalResult);
+ }
+}
+
+function _getExpressCheckoutDetails(token, authorizeOnly) {
+ var properties = {
+ METHOD: 'GetExpresscheckoutDetails',
+ TOKEN: token,
+ }
+
+ return _doPaypalNvpPost(properties);
+}
+
+function completeExpressPurchase(purchaseInfo, payerInfo, notifyUrl, authorizeOnly) {
+ var paypalResult = _doExpressCheckoutPayment(purchaseInfo, payerInfo, notifyUrl, authorizeOnly);
+
+ if (paypalResult.status == 'success') {
+ if (paypalResult.response.PAYMENTSTATUS == 'Completed') {
+ if (! authorizeOnly) {
+ sqlcommon.inTransaction(function() {
+ _transactionSuccess(purchaseInfo.transactionId,
+ paypalResult.response.TRANSACTIONID, "via PayPal");
+ _invoicePaid(purchaseInfo.invoiceId);
+ _purchaseActive(purchaseInfo.purchaseId);
+ });
+ }
+ return new BillingResult('success', paypalResult);
+ } else if (paypalResult.response.PAYMENTSTATUS == 'Pending') {
+ if (! authorizeOnly) {
+ sqlcommon.inTransaction(function() {
+ _transactionPending(purchaseInfo.transactionId,
+ paypalResult.response.TRANSACTIONID);
+ });
+ }
+ return new BillingResult('pending', paypalResult);
+ }
+ } else {
+ if (! authorizeOnly) {
+ sqlcommon.inTransaction(function() {
+ _transactionFailure(purchaseInfo.transactionId,
+ (paypalResult.response ?
+ paypalResult.response.TRANSACTIONID || "" :
+ ""));
+ });
+ }
+ return new BillingResult('failure', paypalResult);
+ }
+}
+
+function _doExpressCheckoutPayment(purchaseInfo, payerInfo, notifyUrl, authorizeOnly) {
+ var properties = {
+ METHOD: 'DoExpressCheckoutPayment',
+ TOKEN: purchaseInfo.token,
+ PAYMENTACTION: (authorizeOnly ? 'Authorization' : 'Sale'),
+
+ NOTIFYURL: notifyUrl,
+
+ PAYERID: payerInfo.payerId,
+
+ AMT: verifyDollars(purchaseInfo.cost), // dollars
+ INVNUM: purchaseInfo.invoiceId,
+ CUSTOM:
+ _expressCheckoutCustom(purchaseInfo.invoiceId, purchaseInfo.transactionId)
+ }
+
+ return _doPaypalNvpPost(properties);
+}
+
+// which field has error? and, is it not user-correctable?
+var _directErrorCodes = {
+ '10502': ['cardExpiration'],
+ '10504': ['cardCvv'],
+ '10505': ['addressStreet', true],
+ '10508': ['cardExpiration'],
+ '10510': ['cardType'],
+ '10512': ['nameFirst'],
+ '10513': ['nameLast'],
+ '10519': ['cardNumber'],
+ '10521': ['cardNumber'],
+ '10527': ['cardNumber'],
+ '10534': ['cardNumber', true],
+ '10535': ['cardNumber'],
+ '10536': ['invoiceId', true],
+ '10537': ['addressCountry', true],
+ '10540': ['addressStreet', true],
+ '10541': ['cardNumber', true],
+ '10554': ['address', true],
+ '10555': ['address', true],
+ '10556': ['address', true],
+ '10561': ['address'],
+ '10562': ['cardExpiration'],
+ '10563': ['cardExpiration'],
+ '10565': ['addressCountry'],
+ '10566': ['cardType'],
+ '10571': ['cardCvv'],
+ '10701': ['address'],
+ '10702': ['addressStreet'],
+ '10703': ['addressStreet2'],
+ '10704': ['addressCity'],
+ '10705': ['addressState'],
+ '10706': ['addressZip'],
+ '10707': ['addressCountry'],
+ '10708': ['address'],
+ '10709': ['addressStreet'],
+ '10710': ['addressCity'],
+ '10711': ['addressState'],
+ '10712': ['addressZip'],
+ '10713': ['addressCountry'],
+ '10714': ['address'],
+ '10715': ['addressState'],
+ '10716': ['addressZip'],
+ '10717': ['addressZip'],
+ '10718': ['addressCity,addressState'],
+ '10748': ['cardCvv'],
+ '10752': ['card'],
+ '10756': ['address,card'],
+ '10759': ['cardNumber'],
+ '10762': ['cardCvv'],
+ '11611': function(response) {
+ var avsCode = response.AVSCODE;
+ var cvv2Match = response.CVV2MATCH;
+ var errorFields = [];
+ switch (avsCode) {
+ case 'N': case 'C': case 'A': case 'B':
+ case 'R': case 'S': case 'U': case 'G':
+ case 'I': case 'E':
+ errorFields.push('address');
+ }
+ switch (cvv2Match) {
+ case 'N':
+ errorFields.push('cardCvv');
+ }
+ return [errorFields.join(",")];
+ },
+ '15004': ['cardCvv'],
+ '15005': ['cardNumber'],
+ '15006': ['cardNumber'],
+ '15007': ['cardNumber']
+}
+
+function authorizePurchase(payinfo, notifyUrl) {
+ return directPurchase(undefined, undefined, undefined, 1, undefined, payinfo, notifyUrl, true);
+}
+
+function directPurchase(invoiceId, customerId, productId, dollars, couponCode, payinfo, notifyUrl, authorizeOnly) {
+ var time = new Date();
+ var cents = dollarsToCents(dollars);
+
+ var purchaseId, transactionId;
+
+ if (! authorizeOnly) {
+ try {
+ sqlcommon.inTransaction(function() {
+ purchaseId = _newPurchase(customerId, productId, cents, couponCode);
+ updateInvoice(invoiceId, {purchase: purchaseId, amt: cents});
+ transactionId = _newTransaction(customerId, cents);
+ _newAdjustment(transactionId, invoiceId, cents);
+ });
+ } catch (e) {
+ if (e instanceof BillingResult) { return e; }
+ if (e.javaException || e.rhinoException) {
+ throw e.javaException || e.rhinoException;
+ }
+ throw e;
+ }
+ }
+
+ var paypalResult = _doDirectPurchase(invoiceId, cents, payinfo, notifyUrl, authorizeOnly);
+
+ if (paypalResult.status == 'success') {
+ if (! authorizeOnly) {
+ sqlcommon.inTransaction(function() {
+ _transactionSuccess(transactionId,
+ paypalResult.response.TRANSACTIONID,
+ payinfo.cardType+" ending in "+payinfo.cardNumber.substr(-4));
+ _invoicePaid(invoiceId);
+ _purchaseActive(purchaseId);
+ });
+ }
+ return new BillingResult('success', paypalResult, null, new PurchaseInfo(
+ undefined,
+ invoiceId,
+ transactionId,
+ paypalResult.response.TRANSACTIONID,
+ purchaseId,
+ verifyDollars(dollars),
+ couponCode,
+ time,
+ undefined));
+ } else {
+ if (! authorizeOnly) {
+ sqlcommon.inTransaction(function() {
+ _transactionFailure(transactionId,
+ (paypalResult.response ?
+ paypalResult.response.TRANSACTIONID || "":
+ ""));
+ });
+ }
+ return new BillingResult('failure', paypalResult, _getErrorCodes(paypalResult.response));
+ }
+}
+
+function _getErrorCodes(paypalResponse) {
+ var errorCodes = {userErrors: [], permanentErrors: []};
+ if (! paypalResponse) {
+ return undefined;
+ }
+ for (var i = 0; paypalResponse['L_ERRORCODE'+i]; ++i) {
+ var code = paypalResponse['L_ERRORCODE'+i];
+ var errorField = _directErrorCodes[code];
+ if (typeof(errorField) == 'function') {
+ errorField = errorField(paypalResponse);
+ }
+ if (errorField && errorField[1]) {
+ Array.prototype.push.apply(errorCodes.permanentErrors, errorField[0].split(","));
+ } else if (errorField) {
+ Array.prototype.push.apply(errorCodes.userErrors, errorField[0].split(","));
+ }
+ }
+ return errorCodes;
+}
+
+function _doDirectPurchase(invoiceId, cents, payinfo, notifyUrl, authorizeOnly) {
+ var properties = {
+ INVNUM: invoiceId,
+
+ METHOD: 'DoDirectPayment',
+ PAYMENTACTION: (authorizeOnly ? 'Authorization' : 'Sale'),
+ IPADDRESS: request.clientAddr,
+ NOTIFYURL: notifyUrl,
+
+ CREDITCARDTYPE: payinfo.cardType,
+ ACCT: payinfo.cardNumber,
+ EXPDATE: payinfo.cardExpiration,
+ CVV2: payinfo.cardCvv,
+
+ SALUTATION: payinfo.nameSalutation,
+ FIRSTNAME: payinfo.nameFirst,
+ MIDDLENAME: payinfo.nameMiddle,
+ LASTNAME: payinfo.nameLast,
+ SUFFIX: payinfo.nameSuffix,
+
+ STREET: payinfo.addressStreet,
+ STREET2: payinfo.addressStreet2,
+ CITY: payinfo.addressCity,
+ STATE: payinfo.addressState,
+ COUNTRYCODE: payinfo.addressCountry,
+ ZIP: payinfo.addressZip,
+
+ AMT: centsToDollars(cents)
+ }
+
+ return _doPaypalNvpPost(properties);
+}
+
+// function directAuthorization(payInfo, dollars, notifyUrl) {
+// var paypalResult = _doDirectPurchase(undefined, dollarsToCents(dollars), payInfo, notifyUrl, true);
+// if (paypalResult.status == 'success') {
+// return new BillingResult('success', paypalResult, null, new PurchaseInfo(
+// undefined,
+// undefined,
+// paypalResult.response.TRANSACTIONID,
+// undefined,
+// verifyDollars(dollars),
+// undefined,
+// undefined,
+// undefined));
+// } else {
+// return new BillingResult('failure', paypalResult, _getErrorCodes(paypalResult.response));
+// }
+// }
+
+function asyncRecurringPurchase(invoiceId, purchaseId, oldTransactionId, paymentInfo, dollars, monthCount, notifyUrl) {
+ var time = new Date();
+ var cents = dollarsToCents(dollars);
+
+ var purchase, transactionId;
+
+ try {
+ sqlcommon.inTransaction(function() {
+ // purchaseId = _newPurchase(customerId, productId, cents, couponCode);
+ purchase = getPurchase(purchaseId);
+ updateInvoice(invoiceId, {purchase: purchaseId, amt: cents});
+ transactionId = _newTransaction(purchase.customer, cents);
+ _newAdjustment(transactionId, invoiceId, cents);
+ });
+ } catch (e) {
+ if (e instanceof BillingResult) { return e; }
+ if (e.rhinoException) {
+ throw e.rhinoException;
+ }
+ throw e;
+ }
+
+ // do transaction using previous transaction as template
+ var paypalResult;
+ if (cents == 0) {
+ // can't actually charge nothing, so fake it.
+ paypalResult = { status: 'success', response: { TRANSACTIONID: null }}
+ } else {
+ paypalResult = _doReferenceTransaction(invoiceId, cents, oldTransactionId, notifyUrl);
+ }
+
+ if (paypalResult.status == 'success') {
+ sqlcommon.inTransaction(function() {
+ _transactionSuccess(transactionId,
+ paypalResult.response.TRANSACTIONID,
+ paymentInfo);
+ _invoicePaid(invoiceId);
+ _purchaseActive(purchaseId);
+ _purchaseExtend(purchaseId, monthCount);
+ });
+ return new BillingResult('success', paypalResult, null, new PurchaseInfo(
+ undefined,
+ invoiceId,
+ transactionId,
+ paypalResult.response.TRANSACTIONID,
+ purchaseId,
+ verifyDollars(dollars),
+ undefined,
+ time,
+ undefined));
+ } else {
+ sqlcommon.inTransaction(function() {
+ _transactionFailure(transactionId,
+ (paypalResult.response ?
+ paypalResult.response.TRANSACTIONID || "":
+ ""));
+ });
+ return new BillingResult('failure', paypalResult, _getErrorCodes(paypalResult.response));
+ }
+}
+
+function _doReferenceTransaction(invoiceId, cents, transactionId, notifyUrl) {
+ var properties = {
+ METHOD: 'DoReferenceTransaction',
+ PAYMENTACTION: 'Sale',
+
+ REFERENCEID: transactionId,
+ AMT: centsToDollars(cents),
+ INVNUM: invoiceId
+ }
+
+ return _doPaypalNvpPost(properties);
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/billing/fields.js b/trunk/etherpad/src/etherpad/billing/fields.js
new file mode 100644
index 0000000..4a307ac
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/billing/fields.js
@@ -0,0 +1,219 @@
+/**
+ * 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.
+ */
+
+
+// Taken from paypal's form
+var countryList = [
+ ["US", "United States"],
+ ["AL", "Albania"],
+ ["DZ", "Algeria"],
+ ["AD", "Andorra"],
+ ["AO", "Angola"],
+ ["AI", "Anguilla"],
+ ["AG", "Antigua and Barbuda"],
+ ["AR", "Argentina"],
+ ["AM", "Armenia"],
+ ["AW", "Aruba"],
+ ["AU", "Australia"],
+ ["AT", "Austria"],
+ ["AZ", "Azerbaijan Republic"],
+ ["BS", "Bahamas"],
+ ["BH", "Bahrain"],
+ ["BB", "Barbados"],
+ ["BE", "Belgium"],
+ ["BZ", "Belize"],
+ ["BJ", "Benin"],
+ ["BM", "Bermuda"],
+ ["BT", "Bhutan"],
+ ["BO", "Bolivia"],
+ ["BA", "Bosnia and Herzegovina"],
+ ["BW", "Botswana"],
+ ["BR", "Brazil"],
+ ["VG", "British Virgin Islands"],
+ ["BN", "Brunei"],
+ ["BG", "Bulgaria"],
+ ["BF", "Burkina Faso"],
+ ["BI", "Burundi"],
+ ["KH", "Cambodia"],
+ ["CA", "Canada"],
+ ["CV", "Cape Verde"],
+ ["KY", "Cayman Islands"],
+ ["TD", "Chad"],
+ ["CL", "Chile"],
+ ["C2", "China"],
+ ["CO", "Colombia"],
+ ["KM", "Comoros"],
+ ["CK", "Cook Islands"],
+ ["CR", "Costa Rica"],
+ ["HR", "Croatia"],
+ ["CY", "Cyprus"],
+ ["CZ", "Czech Republic"],
+ ["CD", "Democratic Republic of the Congo"],
+ ["DK", "Denmark"],
+ ["DJ", "Djibouti"],
+ ["DM", "Dominica"],
+ ["DO", "Dominican Republic"],
+ ["EC", "Ecuador"],
+ ["SV", "El Salvador"],
+ ["ER", "Eritrea"],
+ ["EE", "Estonia"],
+ ["ET", "Ethiopia"],
+ ["FK", "Falkland Islands"],
+ ["FO", "Faroe Islands"],
+ ["FM", "Federated States of Micronesia"],
+ ["FJ", "Fiji"],
+ ["FI", "Finland"],
+ ["FR", "France"],
+ ["GF", "French Guiana"],
+ ["PF", "French Polynesia"],
+ ["GA", "Gabon Republic"],
+ ["GM", "Gambia"],
+ ["DE", "Germany"],
+ ["GI", "Gibraltar"],
+ ["GR", "Greece"],
+ ["GL", "Greenland"],
+ ["GD", "Grenada"],
+ ["GP", "Guadeloupe"],
+ ["GT", "Guatemala"],
+ ["GN", "Guinea"],
+ ["GW", "Guinea Bissau"],
+ ["GY", "Guyana"],
+ ["HN", "Honduras"],
+ ["HK", "Hong Kong"],
+ ["HU", "Hungary"],
+ ["IS", "Iceland"],
+ ["IN", "India"],
+ ["ID", "Indonesia"],
+ ["IE", "Ireland"],
+ ["IL", "Israel"],
+ ["IT", "Italy"],
+ ["JM", "Jamaica"],
+ ["JP", "Japan"],
+ ["JO", "Jordan"],
+ ["KZ", "Kazakhstan"],
+ ["KE", "Kenya"],
+ ["KI", "Kiribati"],
+ ["KW", "Kuwait"],
+ ["KG", "Kyrgyzstan"],
+ ["LA", "Laos"],
+ ["LV", "Latvia"],
+ ["LS", "Lesotho"],
+ ["LI", "Liechtenstein"],
+ ["LT", "Lithuania"],
+ ["LU", "Luxembourg"],
+ ["MG", "Madagascar"],
+ ["MW", "Malawi"],
+ ["MY", "Malaysia"],
+ ["MV", "Maldives"],
+ ["ML", "Mali"],
+ ["MT", "Malta"],
+ ["MH", "Marshall Islands"],
+ ["MQ", "Martinique"],
+ ["MR", "Mauritania"],
+ ["MU", "Mauritius"],
+ ["YT", "Mayotte"],
+ ["MX", "Mexico"],
+ ["MN", "Mongolia"],
+ ["MS", "Montserrat"],
+ ["MA", "Morocco"],
+ ["MZ", "Mozambique"],
+ ["NA", "Namibia"],
+ ["NR", "Nauru"],
+ ["NP", "Nepal"],
+ ["NL", "Netherlands"],
+ ["AN", "Netherlands Antilles"],
+ ["NC", "New Caledonia"],
+ ["NZ", "New Zealand"],
+ ["NI", "Nicaragua"],
+ ["NE", "Niger"],
+ ["NU", "Niue"],
+ ["NF", "Norfolk Island"],
+ ["NO", "Norway"],
+ ["OM", "Oman"],
+ ["PW", "Palau"],
+ ["PA", "Panama"],
+ ["PG", "Papua New Guinea"],
+ ["PE", "Peru"],
+ ["PN", "Pitcairn Islands"],
+ ["PL", "Poland"],
+ ["PT", "Portugal"],
+ ["QA", "Qatar"],
+ ["CG", "Republic of the Congo"],
+ ["RE", "Reunion"],
+ ["RO", "Romania"],
+ ["RU", "Russia"],
+ ["VC", "Saint Vincent and the Grenadines"],
+ ["WS", "Samoa"],
+ ["SM", "San Marino"],
+ ["ST", "São Tomé and Príncipe"],
+ ["SA", "Saudi Arabia"],
+ ["SN", "Senegal"],
+ ["SC", "Seychelles"],
+ ["SL", "Sierra Leone"],
+ ["SG", "Singapore"],
+ ["SK", "Slovakia"],
+ ["SI", "Slovenia"],
+ ["SB", "Solomon Islands"],
+ ["SO", "Somalia"],
+ ["ZA", "South Africa"],
+ ["KR", "South Korea"],
+ ["ES", "Spain"],
+ ["LK", "Sri Lanka"],
+ ["SH", "St. Helena"],
+ ["KN", "St. Kitts and Nevis"],
+ ["LC", "St. Lucia"],
+ ["PM", "St. Pierre and Miquelon"],
+ ["SR", "Suriname"],
+ ["SJ", "Svalbard and Jan Mayen Islands"],
+ ["SZ", "Swaziland"],
+ ["SE", "Sweden"],
+ ["CH", "Switzerland"],
+ ["TW", "Taiwan"],
+ ["TJ", "Tajikistan"],
+ ["TZ", "Tanzania"],
+ ["TH", "Thailand"],
+ ["TG", "Togo"],
+ ["TO", "Tonga"],
+ ["TT", "Trinidad and Tobago"],
+ ["TN", "Tunisia"],
+ ["TR", "Turkey"],
+ ["TM", "Turkmenistan"],
+ ["TC", "Turks and Caicos Islands"],
+ ["TV", "Tuvalu"],
+ ["UG", "Uganda"],
+ ["UA", "Ukraine"],
+ ["AE", "United Arab Emirates"],
+ ["GB", "United Kingdom"],
+ ["UY", "Uruguay"],
+ ["VU", "Vanuatu"],
+ ["VA", "Vatican City State"],
+ ["VE", "Venezuela"],
+ ["VN", "Vietnam"],
+ ["WF", "Wallis and Futuna Islands"],
+ ["YE", "Yemen"],
+ ["ZM", "Zambia"],
+];
+
+var usaStateList = [
+ "", "AK", "AL", "AR", "AZ", "CA", "CO", "CT", "DC", "DE",
+ "FL", "GA", "HI", "IA", "ID", "IL", "IN", "KS", "KY", "LA",
+ "MA", "MD", "ME", "MI", "MN", "MO", "MS", "MT", "NC", "ND",
+ "NE", "NH", "NJ", "NM", "NV", "NY", "OH", "OK", "OR", "PA",
+ "RI", "SC", "SD", "TN", "TX", "UT", "VA", "VT", "WA", "WI",
+ "WV", "WY", "AA", "AE", "AP", "AS", "FM", "GU", "MH", "MP",
+ "PR", "PW", "VI"
+];
+
diff --git a/trunk/etherpad/src/etherpad/billing/team_billing.js b/trunk/etherpad/src/etherpad/billing/team_billing.js
new file mode 100644
index 0000000..ae8ae8a
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/billing/team_billing.js
@@ -0,0 +1,422 @@
+/**
+ * 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("execution");
+import("exceptionutils");
+import("sqlbase.sqlobj");
+import("sqlbase.sqlcommon.inTransaction");
+
+import("etherpad.billing.billing");
+import("etherpad.globals");
+import("etherpad.log");
+import("etherpad.pro.domains");
+import("etherpad.pro.pro_quotas");
+import("etherpad.store.checkout");
+import("etherpad.utils.renderTemplateAsString");
+
+jimport("java.lang.System.out.println");
+
+function recurringBillingNotifyUrl() {
+ return "";
+}
+
+function _billing() {
+ if (! appjet.cache.billing) {
+ appjet.cache.billing = {};
+ }
+ return appjet.cache.billing;
+}
+
+function _lpad(str, width, padDigit) {
+ str = String(str);
+ padDigit = (padDigit === undefined ? ' ' : padDigit);
+ var count = width - str.length;
+ var prepend = []
+ for (var i = 0; i < count; ++i) {
+ prepend.push(padDigit);
+ }
+ return prepend.join("")+str;
+}
+
+// utility functions
+
+function _dayToDateTime(date) {
+ return [date.getFullYear(), _lpad(date.getMonth()+1, 2, '0'), _lpad(date.getDate(), 2, '0')].join("-");
+}
+
+function _createInvoice(subscription) {
+ var maxUsers = getMaxUsers(subscription.customer);
+ var invoice = inTransaction(function() {
+ var invoiceId = billing.createInvoice();
+ billing.updateInvoice(
+ invoiceId,
+ {purchase: subscription.id,
+ amt: billing.dollarsToCents(calculateSubscriptionCost(maxUsers, subscription.coupon)),
+ users: maxUsers});
+ return billing.getInvoice(invoiceId);
+ });
+ if (invoice) {
+ resetMaxUsers(subscription.customer)
+ }
+ return invoice;
+}
+
+function getExpiredSubscriptions(date) {
+ return sqlobj.selectMulti('billing_purchase',
+ {type: 'subscription',
+ status: 'active',
+ paidThrough: ['<', _dayToDateTime(date)]});
+}
+
+function getAllSubscriptions() {
+ return sqlobj.selectMulti('billing_purchase', {type: 'subscription', status: 'active'});
+}
+
+function getSubscriptionForCustomer(customerId) {
+ return sqlobj.selectSingle('billing_purchase',
+ {type: 'subscription',
+ customer: customerId});
+}
+
+function getOrCreateInvoice(subscription) {
+ return inTransaction(function() {
+ var existingInvoice =
+ sqlobj.selectSingle('billing_invoice',
+ {purchase: subscription.id, status: 'pending'});
+ if (existingInvoice) {
+ return existingInvoice;
+ } else {
+ return _createInvoice(subscription);
+ }
+ });
+}
+
+function getLatestPendingInvoice(subscriptionId) {
+ return sqlobj.selectMulti('billing_invoice',
+ {purchase: subscriptionId, status: 'pending'},
+ {orderBy: '-time', limit: 1})[0];
+}
+
+function getLatestPaidInvoice(subscriptionId) {
+ return sqlobj.selectMulti('billing_invoice',
+ {purchase: subscriptionId, status: 'paid'},
+ {orderBy: '-time', limit: 1})[0];
+}
+
+function pendingTransactions(customer) {
+ return billing.getPendingTransactionsForCustomer(customer);
+}
+
+function checkPendingTransactions(transactions) {
+ // XXX: do nothing for now.
+ return transactions.length > 0;
+}
+
+function getRecurringBillingTransactionId(customerId) {
+ return sqlobj.selectSingle('billing_payment_info', {customer: customerId}).transaction;
+}
+
+function getRecurringBillingInfo(customerId) {
+ return sqlobj.selectSingle('billing_payment_info', {customer: customerId});
+}
+
+function clearRecurringBillingInfo(customerId) {
+ return sqlobj.deleteRows('billing_payment_info', {customer: customerId});
+}
+
+function setRecurringBillingInfo(customerId, fullName, email, paymentSummary, expiration, transactionId) {
+ var info = {
+ fullname: fullName,
+ email: email,
+ paymentsummary: paymentSummary,
+ expiration: expiration,
+ transaction: transactionId
+ }
+ inTransaction(function() {
+ if (sqlobj.selectSingle('billing_payment_info', {customer: customerId})) {
+ sqlobj.update('billing_payment_info', {customer: customerId}, info);
+ } else {
+ info.customer = customerId;
+ sqlobj.insert('billing_payment_info', info);
+ }
+ });
+}
+
+function createSubscription(customerId, couponCode) {
+ domainCacheClear(customerId);
+ return inTransaction(function() {
+ return billing.createSubscription(customerId, 'ONDEMAND', 0, couponCode);
+ });
+}
+
+function updateSubscriptionCouponCode(subscriptionId, couponCode) {
+ billing.updatePurchase(subscriptionId, {coupon: couponCode || ""});
+}
+
+function subscriptionChargeFailure(subscription, invoice, failureMessage) {
+ billing.updatePurchase(subscription.id,
+ {error: failureMessage, status: 'inactive'});
+ sendFailureEmail(subscription, invoice);
+}
+
+function subscriptionChargeSuccess(subscription, invoice) {
+ sendReceiptEmail(subscription, invoice);
+}
+
+function errorFieldsToMessage(errorCodes) {
+ var prefix = "Your payment information was rejected. Please verify your ";
+ var errorList = (errorCodes.permanentErrors ? errorCodes.permanentErrors : errorCodes.userErrors);
+
+ return prefix +
+ errorList.map(function(field) {
+ return checkout.billingCartFieldMap[field].d;
+ }).join(", ")+
+ "."
+}
+
+function getAllInvoices(customer) {
+ var purchase = getSubscriptionForCustomer(customer);
+ if (! purchase) {
+ return [];
+ }
+ return billing.getInvoicesForPurchase(purchase.id);
+}
+
+// scheduled charges
+
+function attemptCharge(invoice, subscription) {
+ var billingInfo = getRecurringBillingInfo(subscription.customer);
+ if (! billingInfo) {
+ subscriptionChargeFailure(subscription, invoice, "No billing information on file.");
+ return false;
+ }
+
+ var result =
+ billing.asyncRecurringPurchase(
+ invoice.id,
+ subscription.id,
+ billingInfo.transaction,
+ billingInfo.paymentsummary,
+ billing.centsToDollars(invoice.amt),
+ 1, // 1 month only for now
+ recurringBillingNotifyUrl);
+ if (result.status == 'success') {
+ subscriptionChargeSuccess(subscription, invoice);
+ return true;
+ } else {
+ subscriptionChargeFailure(subscription, invoice, errorFieldsToMessage(result.errorField));
+ return false;
+ }
+}
+
+function processSubscription(subscription) {
+ try {
+ var hasPendingTransactions = inTransaction(function() {
+ var transactions = pendingTransactions(subscription.customer);
+ if (checkPendingTransactions(transactions)) {
+ billing.log({type: 'pending-transactions-delay', subscription: subscription, transactions: transactions});
+ // there are actual pending transactions. wait until tomorrow.
+ return true;
+ } else {
+ return false;
+ }
+ });
+ if (hasPendingTransactions) {
+ return;
+ }
+ var invoice = getOrCreateInvoice(subscription);
+
+ return attemptCharge(invoice, subscription);
+ } catch (e) {
+ log.logException(e);
+ billing.log({message: "Thrown error",
+ exception: exceptionutils.getStackTracePlain(e),
+ subscription: subscription});
+ subscriptionChargeFailure(subscription, "Permanent failure. Please confirm your billing information.");
+ } finally {
+ domainCacheClear(subscription.customer);
+ }
+}
+
+function processAllSubscriptions() {
+ var subs = getExpiredSubscriptions(new Date);
+ println("processing "+subs.length+" subscriptions.");
+ subs.forEach(processSubscription);
+}
+
+function _scheduleNextDailyUpdate() {
+ // Run at 2:22am every day
+ var now = +(new Date);
+ var tomorrow = new Date(now + 1000*60*60*24);
+ tomorrow.setHours(2);
+ tomorrow.setMinutes(22);
+ tomorrow.setMilliseconds(222);
+ log.info("Scheduling next daily billing update for: "+tomorrow.toString());
+ var delay = +tomorrow - (+(new Date));
+ execution.scheduleTask('billing', "billingDailyUpdate", delay, []);
+}
+
+serverhandlers.tasks.billingDailyUpdate = function() {
+ return; // do nothing, there's no more billing.
+ // if (! globals.isProduction()) { return; }
+ // try {
+ // processAllSubscriptions();
+ // } finally {
+ // _scheduleNextDailyUpdate();
+ // }
+}
+
+function onStartup() {
+ execution.initTaskThreadPool("billing", 1);
+ _scheduleNextDailyUpdate();
+}
+
+// pricing
+
+function getMaxUsers(customer) {
+ return pro_quotas.getAccountUsageCount(customer);
+}
+
+function resetMaxUsers(customer) {
+ pro_quotas.resetAccountUsageCount(customer);
+}
+
+var COST_PER_USER = 8;
+
+function getCouponValue(couponCode) {
+ if (couponCode && couponCode.length == 8) {
+ return sqlobj.selectSingle('checkout_pro_referral', {id: couponCode});
+ }
+}
+
+function calculateSubscriptionCost(users, couponId) {
+ if (users <= globals.PRO_FREE_ACCOUNTS) {
+ return 0;
+ }
+ var coupon = getCouponValue(couponId);
+ var pctDiscount = (coupon ? coupon.pctDiscount : 0);
+ var freeUsers = (coupon ? coupon.freeUsers : 0);
+
+ var cost = (users - freeUsers) * COST_PER_USER;
+ cost = cost * (100-pctDiscount)/100;
+
+ return Math.max(0, cost);
+}
+
+// currentDomainsCache
+
+function _cache() {
+ if (! appjet.cache.currentDomainsCache) {
+ appjet.cache.currentDomainsCache = {};
+ }
+ return appjet.cache.currentDomainsCache;
+}
+
+function domainCacheClear(domain) {
+ delete _cache()[domain];
+}
+
+function _domainCacheGetOrUpdate(domain, f) {
+ if (domain in _cache()) {
+ return _cache()[domain];
+ }
+
+ _cache()[domain] = f();
+ return _cache()[domain];
+}
+
+// external API helpers
+
+function _getPaidThroughDate(domainId) {
+ return _domainCacheGetOrUpdate(domainId, function() {
+ var subscription = getSubscriptionForCustomer(domainId);
+ if (! subscription) {
+ return null;
+ } else {
+ return subscription.paidThrough;
+ }
+ });
+}
+
+// external API
+
+var GRACE_PERIOD_DAYS = 10;
+
+var CURRENT = 0;
+var PAST_DUE = 1;
+var SUSPENDED = 2;
+var NO_BILLING_INFO = 3;
+
+function getDomainStatus(domainId) {
+ var paidThrough = _getPaidThroughDate(domainId);
+
+ if (paidThrough == null) {
+ return NO_BILLING_INFO;
+ }
+ if (paidThrough.getTime() > new Date(Date.now()-86400*1000)) {
+ return CURRENT;
+ }
+ // less than GRACE_PERIOD_DAYS have passed since paidThrough date
+ if (paidThrough.getTime() > Date.now() - GRACE_PERIOD_DAYS*86400*1000) {
+ return PAST_DUE;
+ }
+ return SUSPENDED;
+}
+
+function getDomainDueDate(domainId) {
+ return _getPaidThroughDate(domainId);
+}
+
+function getDomainSuspensionDate(domainId) {
+ return new Date(_getPaidThroughDate(domainId).getTime() + GRACE_PERIOD_DAYS*86400*1000);
+}
+
+// emails
+
+function sendReceiptEmail(subscription, invoice) {
+ var paymentInfo = getRecurringBillingInfo(subscription.customer);
+ var coupon = getCouponValue(subscription.coupon);
+ var emailText = renderTemplateAsString('email/pro_payment_receipt.ejs', {
+ fullName: paymentInfo.fullname,
+ paymentSummary: paymentInfo.paymentsummary,
+ expiration: checkout.formatExpiration(paymentInfo.expiration),
+ invoiceNumber: invoice.id,
+ numUsers: invoice.users,
+ cost: billing.centsToDollars(invoice.amt),
+ dollars: checkout.dollars,
+ coupon: coupon,
+ globals: globals
+ });
+ var address = paymentInfo.email;
+ checkout.salesEmail(address, "sales@pad.spline.inf.fu-berlin.de", "EtherPad: Receipt for "+paymentInfo.fullname,
+ {}, emailText);
+}
+
+function sendFailureEmail(subscription, invoice, failureMessage) {
+ var domain = subscription.customer;
+ var subDomain = domains.getDomainRecord(domain).subDomain;
+ var paymentInfo = getRecurringBillingInfo(subscription.customer);
+ var emailText = renderTemplateAsString('email/pro_payment_failure.ejs', {
+ fullName: paymentInfo.fullname,
+ billingError: failureMessage,
+ balance: "US $"+checkout.dollars(billing.centsToDollars(invoice.amt)),
+ suspensionDate: checkout.formatDate(new Date(subscription.paidThrough.getTime()+GRACE_PERIOD_DAYS*86400*1000)),
+ billingAdminLink: "https://"+subDomain+".pad.spline.inf.fu-berlin.de/ep/admin/billing/"
+ });
+ var address = paymentInfo.email;
+ checkout.salesEmail(address, "sales@pad.spline.inf.fu-berlin.de", "EtherPad: Payment Failure for "+paymentInfo.fullname,
+ {}, emailText);
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/collab/ace/contentcollector.js b/trunk/etherpad/src/etherpad/collab/ace/contentcollector.js
new file mode 100644
index 0000000..5dd4f9c
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/collab/ace/contentcollector.js
@@ -0,0 +1,527 @@
+// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/contentcollector.js
+import("etherpad.collab.ace.easysync2.Changeset")
+
+/**
+ * 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.
+ */
+
+var _MAX_LIST_LEVEL = 8;
+
+function sanitizeUnicode(s) {
+ return s.replace(/[\uffff\ufffe\ufeff\ufdd0-\ufdef\ud800-\udfff]/g, '?');
+}
+
+function makeContentCollector(collectStyles, browser, apool, domInterface,
+ className2Author) {
+ browser = browser || {};
+
+ var dom = domInterface || {
+ isNodeText: function(n) {
+ return (n.nodeType == 3);
+ },
+ nodeTagName: function(n) {
+ return n.tagName;
+ },
+ nodeValue: function(n) {
+ return n.nodeValue;
+ },
+ nodeNumChildren: function(n) {
+ return n.childNodes.length;
+ },
+ nodeChild: function(n, i) {
+ return n.childNodes.item(i);
+ },
+ nodeProp: function(n, p) {
+ return n[p];
+ },
+ nodeAttr: function(n, a) {
+ return n.getAttribute(a);
+ },
+ optNodeInnerHTML: function(n) {
+ return n.innerHTML;
+ }
+ };
+
+ var _blockElems = { "div":1, "p":1, "pre":1, "li":1 };
+ function isBlockElement(n) {
+ return !!_blockElems[(dom.nodeTagName(n) || "").toLowerCase()];
+ }
+ function textify(str) {
+ return sanitizeUnicode(
+ str.replace(/[\n\r ]/g, ' ').replace(/\xa0/g, ' ').replace(/\t/g, ' '));
+ }
+ function getAssoc(node, name) {
+ return dom.nodeProp(node, "_magicdom_"+name);
+ }
+
+ var lines = (function() {
+ var textArray = [];
+ var attribsArray = [];
+ var attribsBuilder = null;
+ var op = Changeset.newOp('+');
+ var self = {
+ length: function() { return textArray.length; },
+ atColumnZero: function() {
+ return textArray[textArray.length-1] === "";
+ },
+ startNew: function() {
+ textArray.push("");
+ self.flush(true);
+ attribsBuilder = Changeset.smartOpAssembler();
+ },
+ textOfLine: function(i) { return textArray[i]; },
+ appendText: function(txt, attrString) {
+ textArray[textArray.length-1] += txt;
+ //dmesg(txt+" / "+attrString);
+ op.attribs = attrString;
+ op.chars = txt.length;
+ attribsBuilder.append(op);
+ },
+ textLines: function() { return textArray.slice(); },
+ attribLines: function() { return attribsArray; },
+ // call flush only when you're done
+ flush: function(withNewline) {
+ if (attribsBuilder) {
+ attribsArray.push(attribsBuilder.toString());
+ attribsBuilder = null;
+ }
+ }
+ };
+ self.startNew();
+ return self;
+ }());
+ var cc = {};
+ function _ensureColumnZero(state) {
+ if (! lines.atColumnZero()) {
+ _startNewLine(state);
+ }
+ }
+ var selection, startPoint, endPoint;
+ var selStart = [-1,-1], selEnd = [-1,-1];
+ var blockElems = { "div":1, "p":1, "pre":1 };
+ function _isEmpty(node, state) {
+ // consider clean blank lines pasted in IE to be empty
+ if (dom.nodeNumChildren(node) == 0) return true;
+ if (dom.nodeNumChildren(node) == 1 &&
+ getAssoc(node, "shouldBeEmpty") && dom.optNodeInnerHTML(node) == "&nbsp;"
+ && ! getAssoc(node, "unpasted")) {
+ if (state) {
+ var child = dom.nodeChild(node, 0);
+ _reachPoint(child, 0, state);
+ _reachPoint(child, 1, state);
+ }
+ return true;
+ }
+ return false;
+ }
+ function _pointHere(charsAfter, state) {
+ var ln = lines.length()-1;
+ var chr = lines.textOfLine(ln).length;
+ if (chr == 0 && state.listType && state.listType != 'none') {
+ chr += 1; // listMarker
+ }
+ chr += charsAfter;
+ return [ln, chr];
+ }
+ function _reachBlockPoint(nd, idx, state) {
+ if (! dom.isNodeText(nd)) _reachPoint(nd, idx, state);
+ }
+ function _reachPoint(nd, idx, state) {
+ if (startPoint && nd == startPoint.node && startPoint.index == idx) {
+ selStart = _pointHere(0, state);
+ }
+ if (endPoint && nd == endPoint.node && endPoint.index == idx) {
+ selEnd = _pointHere(0, state);
+ }
+ }
+ function _incrementFlag(state, flagName) {
+ state.flags[flagName] = (state.flags[flagName] || 0)+1;
+ }
+ function _decrementFlag(state, flagName) {
+ state.flags[flagName]--;
+ }
+ function _incrementAttrib(state, attribName) {
+ if (! state.attribs[attribName]) {
+ state.attribs[attribName] = 1;
+ }
+ else {
+ state.attribs[attribName]++;
+ }
+ _recalcAttribString(state);
+ }
+ function _decrementAttrib(state, attribName) {
+ state.attribs[attribName]--;
+ _recalcAttribString(state);
+ }
+ function _enterList(state, listType) {
+ var oldListType = state.listType;
+ state.listLevel = (state.listLevel || 0)+1;
+ if (listType != 'none') {
+ state.listNesting = (state.listNesting || 0)+1;
+ }
+ state.listType = listType;
+ _recalcAttribString(state);
+ return oldListType;
+ }
+ function _exitList(state, oldListType) {
+ state.listLevel--;
+ if (state.listType != 'none') {
+ state.listNesting--;
+ }
+ state.listType = oldListType;
+ _recalcAttribString(state);
+ }
+ function _enterAuthor(state, author) {
+ var oldAuthor = state.author;
+ state.authorLevel = (state.authorLevel || 0)+1;
+ state.author = author;
+ _recalcAttribString(state);
+ return oldAuthor;
+ }
+ function _exitAuthor(state, oldAuthor) {
+ state.authorLevel--;
+ state.author = oldAuthor;
+ _recalcAttribString(state);
+ }
+ function _recalcAttribString(state) {
+ var lst = [];
+ for(var a in state.attribs) {
+ if (state.attribs[a]) {
+ lst.push([a,'true']);
+ }
+ }
+ if (state.authorLevel > 0) {
+ var authorAttrib = ['author', state.author];
+ if (apool.putAttrib(authorAttrib, true) >= 0) {
+ // require that author already be in pool
+ // (don't add authors from other documents, etc.)
+ lst.push(authorAttrib);
+ }
+ }
+ state.attribString = Changeset.makeAttribsString('+', lst, apool);
+ }
+ function _produceListMarker(state) {
+ lines.appendText('*', Changeset.makeAttribsString(
+ '+', [['list', state.listType],
+ ['insertorder', 'first']],
+ apool));
+ }
+ function _startNewLine(state) {
+ if (state) {
+ var atBeginningOfLine = lines.textOfLine(lines.length()-1).length == 0;
+ if (atBeginningOfLine && state.listType && state.listType != 'none') {
+ _produceListMarker(state);
+ }
+ }
+ lines.startNew();
+ }
+ cc.notifySelection = function (sel) {
+ if (sel) {
+ selection = sel;
+ startPoint = selection.startPoint;
+ endPoint = selection.endPoint;
+ }
+ };
+ cc.collectContent = function (node, state) {
+ if (! state) {
+ state = {flags: {/*name -> nesting counter*/},
+ attribs: {/*name -> nesting counter*/},
+ attribString: ''};
+ }
+ var isBlock = isBlockElement(node);
+ var isEmpty = _isEmpty(node, state);
+ if (isBlock) _ensureColumnZero(state);
+ var startLine = lines.length()-1;
+ _reachBlockPoint(node, 0, state);
+ if (dom.isNodeText(node)) {
+ var txt = dom.nodeValue(node);
+ var rest = '';
+ var x = 0; // offset into original text
+ if (txt.length == 0) {
+ if (startPoint && node == startPoint.node) {
+ selStart = _pointHere(0, state);
+ }
+ if (endPoint && node == endPoint.node) {
+ selEnd = _pointHere(0, state);
+ }
+ }
+ while (txt.length > 0) {
+ var consumed = 0;
+ if (state.flags.preMode) {
+ var firstLine = txt.split('\n',1)[0];
+ consumed = firstLine.length+1;
+ rest = txt.substring(consumed);
+ txt = firstLine;
+ }
+ else { /* will only run this loop body once */ }
+ if (startPoint && node == startPoint.node &&
+ startPoint.index-x <= txt.length) {
+ selStart = _pointHere(startPoint.index-x, state);
+ }
+ if (endPoint && node == endPoint.node &&
+ endPoint.index-x <= txt.length) {
+ selEnd = _pointHere(endPoint.index-x, state);
+ }
+ var txt2 = txt;
+ if ((! state.flags.preMode) && /^[\r\n]*$/.exec(txt)) {
+ // prevents textnodes containing just "\n" from being significant
+ // in safari when pasting text, now that we convert them to
+ // spaces instead of removing them, because in other cases
+ // removing "\n" from pasted HTML will collapse words together.
+ txt2 = "";
+ }
+ var atBeginningOfLine = lines.textOfLine(lines.length()-1).length == 0;
+ if (atBeginningOfLine) {
+ // newlines in the source mustn't become spaces at beginning of line box
+ txt2 = txt2.replace(/^\n*/, '');
+ }
+ if (atBeginningOfLine && state.listType && state.listType != 'none') {
+ _produceListMarker(state);
+ }
+ lines.appendText(textify(txt2), state.attribString);
+ x += consumed;
+ txt = rest;
+ if (txt.length > 0) {
+ _startNewLine(state);
+ }
+ }
+ }
+ else {
+ var tname = (dom.nodeTagName(node) || "").toLowerCase();
+ if (tname == "br") {
+ _startNewLine(state);
+ }
+ else if (tname == "script" || tname == "style") {
+ // ignore
+ }
+ else if (! isEmpty) {
+ var styl = dom.nodeAttr(node, "style");
+ var cls = dom.nodeProp(node, "className");
+
+ var isPre = (tname == "pre");
+ if ((! isPre) && browser.safari) {
+ isPre = (styl && /\bwhite-space:\s*pre\b/i.exec(styl));
+ }
+ if (isPre) _incrementFlag(state, 'preMode');
+ var attribs = null;
+ var oldListTypeOrNull = null;
+ var oldAuthorOrNull = null;
+ if (collectStyles) {
+ function doAttrib(na) {
+ attribs = (attribs || []);
+ attribs.push(na);
+ _incrementAttrib(state, na);
+ }
+ if (tname == "b" || (styl && /\bfont-weight:\s*bold\b/i.exec(styl)) ||
+ tname == "strong") {
+ doAttrib("bold");
+ }
+ if (tname == "i" || (styl && /\bfont-style:\s*italic\b/i.exec(styl)) ||
+ tname == "em") {
+ doAttrib("italic");
+ }
+ if (tname == "u" || (styl && /\btext-decoration:\s*underline\b/i.exec(styl)) ||
+ tname == "ins") {
+ doAttrib("underline");
+ }
+ if (tname == "s" || (styl && /\btext-decoration:\s*line-through\b/i.exec(styl)) ||
+ tname == "del") {
+ doAttrib("strikethrough");
+ }
+ if (tname == "h1") {
+ doAttrib("h1");
+ }
+ if (tname == "h2") {
+ doAttrib("h2");
+ }
+ if (tname == "h3") {
+ doAttrib("h3");
+ }
+ if (tname == "h4") {
+ doAttrib("h4");
+ }
+ if (tname == "h5") {
+ doAttrib("h5");
+ }
+ if (tname == "h6") {
+ doAttrib("h6");
+ }
+ if (tname == "ul") {
+ var type;
+ var rr = cls && /(?:^| )list-(bullet[12345678])\b/.exec(cls);
+ type = rr && rr[1] || "bullet"+
+ String(Math.min(_MAX_LIST_LEVEL, (state.listNesting||0)+1));
+ oldListTypeOrNull = (_enterList(state, type) || 'none');
+ }
+ else if ((tname == "div" || tname == "p") && cls &&
+ cls.match(/(?:^| )ace-line\b/)) {
+ oldListTypeOrNull = (_enterList(state, type) || 'none');
+ }
+ if (className2Author && cls) {
+ var classes = cls.match(/\S+/g);
+ if (classes && classes.length > 0) {
+ for(var i=0;i<classes.length;i++) {
+ var c = classes[i];
+ var a = className2Author(c);
+ if (a) {
+ oldAuthorOrNull = (_enterAuthor(state, a) || 'none');
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ var nc = dom.nodeNumChildren(node);
+ for(var i=0;i<nc;i++) {
+ var c = dom.nodeChild(node, i);
+ cc.collectContent(c, state);
+ }
+
+ if (isPre) _decrementFlag(state, 'preMode');
+ if (attribs) {
+ for(var i=0;i<attribs.length;i++) {
+ _decrementAttrib(state, attribs[i]);
+ }
+ }
+ if (oldListTypeOrNull) {
+ _exitList(state, oldListTypeOrNull);
+ }
+ if (oldAuthorOrNull) {
+ _exitAuthor(state, oldAuthorOrNull);
+ }
+ }
+ }
+ if (! browser.msie) {
+ _reachBlockPoint(node, 1, state);
+ }
+ if (isBlock) {
+ if (lines.length()-1 == startLine) {
+ _startNewLine(state);
+ }
+ else {
+ _ensureColumnZero(state);
+ }
+ }
+
+ if (browser.msie) {
+ // in IE, a point immediately after a DIV appears on the next line
+ _reachBlockPoint(node, 1, state);
+ }
+ };
+ // can pass a falsy value for end of doc
+ cc.notifyNextNode = function (node) {
+ // an "empty block" won't end a line; this addresses an issue in IE with
+ // typing into a blank line at the end of the document. typed text
+ // goes into the body, and the empty line div still looks clean.
+ // it is incorporated as dirty by the rule that a dirty region has
+ // to end a line.
+ if ((!node) || (isBlockElement(node) && !_isEmpty(node))) {
+ _ensureColumnZero(null);
+ }
+ };
+ // each returns [line, char] or [-1,-1]
+ var getSelectionStart = function() { return selStart; };
+ var getSelectionEnd = function() { return selEnd; };
+
+ // returns array of strings for lines found, last entry will be "" if
+ // last line is complete (i.e. if a following span should be on a new line).
+ // can be called at any point
+ cc.getLines = function() { return lines.textLines(); };
+
+ //cc.applyHints = function(hints) {
+ //if (hints.pastedLines) {
+ //
+ //}
+ //}
+
+ cc.finish = function() {
+ lines.flush();
+ var lineAttribs = lines.attribLines();
+ var lineStrings = cc.getLines();
+
+ lineStrings.length--;
+ lineAttribs.length--;
+
+ var ss = getSelectionStart();
+ var se = getSelectionEnd();
+
+ function fixLongLines() {
+ // design mode does not deal with with really long lines!
+ var lineLimit = 2000; // chars
+ var buffer = 10; // chars allowed over before wrapping
+ var linesWrapped = 0;
+ var numLinesAfter = 0;
+ for(var i=lineStrings.length-1; i>=0; i--) {
+ var oldString = lineStrings[i];
+ var oldAttribString = lineAttribs[i];
+ if (oldString.length > lineLimit+buffer) {
+ var newStrings = [];
+ var newAttribStrings = [];
+ while (oldString.length > lineLimit) {
+ //var semiloc = oldString.lastIndexOf(';', lineLimit-1);
+ //var lengthToTake = (semiloc >= 0 ? (semiloc+1) : lineLimit);
+ lengthToTake = lineLimit;
+ newStrings.push(oldString.substring(0, lengthToTake));
+ oldString = oldString.substring(lengthToTake);
+ newAttribStrings.push(Changeset.subattribution(oldAttribString,
+ 0, lengthToTake));
+ oldAttribString = Changeset.subattribution(oldAttribString,
+ lengthToTake);
+ }
+ if (oldString.length > 0) {
+ newStrings.push(oldString);
+ newAttribStrings.push(oldAttribString);
+ }
+ function fixLineNumber(lineChar) {
+ if (lineChar[0] < 0) return;
+ var n = lineChar[0];
+ var c = lineChar[1];
+ if (n > i) {
+ n += (newStrings.length-1);
+ }
+ else if (n == i) {
+ var a = 0;
+ while (c > newStrings[a].length) {
+ c -= newStrings[a].length;
+ a++;
+ }
+ n += a;
+ }
+ lineChar[0] = n;
+ lineChar[1] = c;
+ }
+ fixLineNumber(ss);
+ fixLineNumber(se);
+ linesWrapped++;
+ numLinesAfter += newStrings.length;
+
+ newStrings.unshift(i, 1);
+ lineStrings.splice.apply(lineStrings, newStrings);
+ newAttribStrings.unshift(i, 1);
+ lineAttribs.splice.apply(lineAttribs, newAttribStrings);
+ }
+ }
+ return {linesWrapped:linesWrapped, numLinesAfter:numLinesAfter};
+ }
+ var wrapData = fixLongLines();
+
+ return { selStart: ss, selEnd: se, linesWrapped: wrapData.linesWrapped,
+ numLinesAfter: wrapData.numLinesAfter,
+ lines: lineStrings, lineAttribs: lineAttribs };
+ }
+
+ return cc;
+}
diff --git a/trunk/etherpad/src/etherpad/collab/ace/domline.js b/trunk/etherpad/src/etherpad/collab/ace/domline.js
new file mode 100644
index 0000000..de2e7d3
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/collab/ace/domline.js
@@ -0,0 +1,210 @@
+// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/domline.js
+
+/**
+ * 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.
+ */
+
+var domline = {};
+domline.noop = function() {};
+domline.identity = function(x) { return x; };
+
+domline.addToLineClass = function(lineClass, cls) {
+ // an "empty span" at any point can be used to add classes to
+ // the line, using line:className. otherwise, we ignore
+ // the span.
+ cls.replace(/\S+/g, function (c) {
+ if (c.indexOf("line:") == 0) {
+ // add class to line
+ lineClass = (lineClass ? lineClass+' ' : '')+c.substring(5);
+ }
+ });
+ return lineClass;
+}
+
+// if "document" is falsy we don't create a DOM node, just
+// an object with innerHTML and className
+domline.createDomLine = function(nonEmpty, doesWrap, optBrowser, optDocument) {
+ var result = { node: null,
+ appendSpan: domline.noop,
+ prepareForAdd: domline.noop,
+ notifyAdded: domline.noop,
+ clearSpans: domline.noop,
+ finishUpdate: domline.noop,
+ lineMarker: 0 };
+
+ var browser = (optBrowser || {});
+ var document = optDocument;
+
+ if (document) {
+ result.node = document.createElement("div");
+ }
+ else {
+ result.node = {innerHTML: '', className: ''};
+ }
+
+ var html = [];
+ var preHtml, postHtml;
+ var curHTML = null;
+ function processSpaces(s) {
+ return domline.processSpaces(s, doesWrap);
+ }
+ var identity = domline.identity;
+ var perTextNodeProcess = (doesWrap ? identity : processSpaces);
+ var perHtmlLineProcess = (doesWrap ? processSpaces : identity);
+ var lineClass = 'ace-line';
+ result.appendSpan = function(txt, cls) {
+ if (cls.indexOf('list') >= 0) {
+ var listType = /(?:^| )list:(\S+)/.exec(cls);
+ if (listType) {
+ listType = listType[1];
+ if (listType) {
+ preHtml = '<ul class="list-'+listType+'"><li>';
+ postHtml = '</li></ul>';
+ }
+ result.lineMarker += txt.length;
+ return; // don't append any text
+ }
+ }
+ var href = null;
+ var simpleTags = null;
+ if (cls.indexOf('url') >= 0) {
+ cls = cls.replace(/(^| )url:(\S+)/g, function(x0, space, url) {
+ href = url;
+ return space+"url";
+ });
+ }
+ if (cls.indexOf('tag') >= 0) {
+ cls = cls.replace(/(^| )tag:(\S+)/g, function(x0, space, tag) {
+ if (! simpleTags) simpleTags = [];
+ simpleTags.push(tag.toLowerCase());
+ return space+tag;
+ });
+ }
+ if ((! txt) && cls) {
+ lineClass = domline.addToLineClass(lineClass, cls);
+ }
+ else if (txt) {
+ var extraOpenTags = "";
+ var extraCloseTags = "";
+ if (href) {
+ extraOpenTags = extraOpenTags+'<a href="'+
+ href.replace(/\"/g, '&quot;')+'">';
+ extraCloseTags = '</a>'+extraCloseTags;
+ }
+ if (simpleTags) {
+ simpleTags.sort();
+ extraOpenTags = extraOpenTags+'<'+simpleTags.join('><')+'>';
+ simpleTags.reverse();
+ extraCloseTags = '</'+simpleTags.join('></')+'>'+extraCloseTags;
+ }
+ html.push('<span class="',cls||'','">',extraOpenTags,
+ perTextNodeProcess(domline.escapeHTML(txt)),
+ extraCloseTags,'</span>');
+ }
+ };
+ result.clearSpans = function() {
+ html = [];
+ lineClass = ''; // non-null to cause update
+ result.lineMarker = 0;
+ };
+ function writeHTML() {
+ var newHTML = perHtmlLineProcess(html.join(''));
+ if (! newHTML) {
+ if ((! document) || (! optBrowser)) {
+ newHTML += '&nbsp;';
+ }
+ else if (! browser.msie) {
+ newHTML += '<br/>';
+ }
+ }
+ if (nonEmpty) {
+ newHTML = (preHtml||'')+newHTML+(postHtml||'');
+ }
+ html = preHtml = postHtml = null; // free memory
+ if (newHTML !== curHTML) {
+ curHTML = newHTML;
+ result.node.innerHTML = curHTML;
+ }
+ if (lineClass !== null) result.node.className = lineClass;
+ }
+ result.prepareForAdd = writeHTML;
+ result.finishUpdate = writeHTML;
+ result.getInnerHTML = function() { return curHTML || ''; };
+
+ return result;
+};
+
+domline.escapeHTML = function(s) {
+ var re = /[&<>'"]/g; /']/; // stupid indentation thing
+ if (! re.MAP) {
+ // persisted across function calls!
+ re.MAP = {
+ '&': '&amp;',
+ '<': '&lt;',
+ '>': '&gt;',
+ '"': '&#34;',
+ "'": '&#39;'
+ };
+ }
+ return s.replace(re, function(c) { return re.MAP[c]; });
+};
+
+domline.processSpaces = function(s, doesWrap) {
+ if (s.indexOf("<") < 0 && ! doesWrap) {
+ // short-cut
+ return s.replace(/ /g, '&nbsp;');
+ }
+ var parts = [];
+ s.replace(/<[^>]*>?| |[^ <]+/g, function(m) { parts.push(m); });
+ if (doesWrap) {
+ var endOfLine = true;
+ var beforeSpace = false;
+ // last space in a run is normal, others are nbsp,
+ // end of line is nbsp
+ for(var i=parts.length-1;i>=0;i--) {
+ var p = parts[i];
+ if (p == " ") {
+ if (endOfLine || beforeSpace)
+ parts[i] = '&nbsp;';
+ endOfLine = false;
+ beforeSpace = true;
+ }
+ else if (p.charAt(0) != "<") {
+ endOfLine = false;
+ beforeSpace = false;
+ }
+ }
+ // beginning of line is nbsp
+ for(var i=0;i<parts.length;i++) {
+ var p = parts[i];
+ if (p == " ") {
+ parts[i] = '&nbsp;';
+ break;
+ }
+ else if (p.charAt(0) != "<") {
+ break;
+ }
+ }
+ }
+ else {
+ for(var i=0;i<parts.length;i++) {
+ var p = parts[i];
+ if (p == " ") {
+ parts[i] = '&nbsp;';
+ }
+ }
+ }
+ return parts.join('');
+};
diff --git a/trunk/etherpad/src/etherpad/collab/ace/easysync1.js b/trunk/etherpad/src/etherpad/collab/ace/easysync1.js
new file mode 100644
index 0000000..4f40aa0
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/collab/ace/easysync1.js
@@ -0,0 +1,923 @@
+// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/easy_sync.js
+
+/**
+ * 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.
+ */
+
+function Changeset(arg) {
+
+ var array;
+ if ((typeof arg) == "string") {
+ // constant
+ array = [Changeset.MAGIC, 0, arg.length, 0, 0, arg];
+ }
+ else if ((typeof arg) == "number") {
+ var n = Math.round(arg);
+ // delete-all on n-length text (useful for making a "builder")
+ array = [Changeset.MAGIC, n, 0, 0, 0, ""];
+ }
+ else if (! arg) {
+ // identity on 0-length text
+ array = [Changeset.MAGIC, 0, 0, 0, 0, ""];
+ }
+ else if (arg.isChangeset) {
+ return arg;
+ }
+ else array = arg;
+
+ array.isChangeset = true;
+
+ // OOP style: attach generic methods to array object, hold no state in environment
+
+ //function error(msg) { top.console.error(msg); top.console.trace(); }
+ function error(msg) { var e = new Error(msg); e.easysync = true; throw e; }
+ function assert(b, msg) { if (! b) error("Changeset: "+String(msg)); }
+ function min(x, y) { return (x < y) ? x : y; }
+ Changeset._assert = assert;
+
+ array.isIdentity = function() {
+ return this.length == 6 && this[1] == this[2] && this[3] == 0 &&
+ this[4] == this[1] && this[5] == "";
+ }
+
+ array.eachStrip = function(func, thisObj) {
+ // inside "func", the method receiver will be "this" by default,
+ // or you can pass an object.
+ for(var i=0;i<this.numStrips();i++) {
+ var ptr = 3 + i*3;
+ if (func.call(thisObj || this, this[ptr], this[ptr+1], this[ptr+2], i))
+ return true;
+ }
+ return false;
+ }
+
+ array.numStrips = function() { return (this.length-3)/3; };
+ array.oldLen = function() { return this[1]; };
+ array.newLen = function() { return this[2]; };
+
+ array.checkRep = function() {
+ assert(this[0] == Changeset.MAGIC, "bad magic");
+ assert(this[1] >= 0, "bad old text length");
+ assert(this[2] >= 0, "bad new text length");
+ assert((this.length % 3) == 0, "bad array length");
+ assert(this.length >= 6, "must be at least one strip");
+ var numStrips = this.numStrips();
+ var oldLen = this[1];
+ var newLen = this[2];
+ // iterate over the "text strips"
+ var actualNewLen = 0;
+ this.eachStrip(function(startIndex, numTaken, newText, i) {
+ var s = startIndex, t = numTaken, n = newText;
+ var isFirst = (i == 0);
+ var isLast = (i == numStrips-1);
+ assert(t >= 0, "can't take negative number of chars");
+ assert(isFirst || t > 0, "all strips but first must take");
+ assert((t > 0) || (s == 0), "if first strip doesn't take, must have 0 startIndex");
+ assert(s >= 0 && s + t <= oldLen, "bad index: "+this.toString());
+ assert(t > 0 || n.length > 0 || (isFirst && isLast), "empty strip must be first and only");
+ if (! isLast) {
+ var s2 = this[3 + i*3 + 3]; // startIndex of following strip
+ var gap = s2 - (s + t);
+ assert(gap >= 0, "overlapping or out-of-order strips: "+this.toString());
+ assert(gap > 0 || n.length > 0, "touching strips with no added text");
+ }
+ actualNewLen += t + n.length;
+ });
+ assert(newLen == actualNewLen, "calculated new text length doesn't match");
+ }
+
+ array.applyToText = function(text) {
+ assert(text.length == this.oldLen(), "mismatched apply: "+text.length+" / "+this.oldLen());
+ var buf = [];
+ this.eachStrip(function (s, t, n) {
+ buf.push(text.substr(s, t), n);
+ });
+ return buf.join('');
+ }
+
+ function _makeBuilder(oldLen, supportAuthors) {
+ var C = Changeset(oldLen);
+ if (supportAuthors) {
+ _ensureAuthors(C);
+ }
+ return C.builder();
+ }
+
+ function _getNumInserted(C) {
+ var numChars = 0;
+ C.eachStrip(function(s,t,n) {
+ numChars += n.length;
+ });
+ return numChars;
+ }
+
+ function _ensureAuthors(C) {
+ if (! C.authors) {
+ C.setAuthor();
+ }
+ return C;
+ }
+
+ array.setAuthor = function(author) {
+ var C = this;
+ // authors array has even length >= 2;
+ // alternates [numChars1, author1, numChars2, author2];
+ // all numChars > 0 unless there is exactly one, in which
+ // case it can be == 0.
+ C.authors = [_getNumInserted(C), author || ''];
+ return C;
+ }
+
+ array.builder = function() {
+ // normal pattern is Changeset(oldLength).builder().appendOldText(...). ...
+ // builder methods mutate this!
+ var C = this;
+ // OOP style: state in environment
+ var self;
+ return self = {
+ appendNewText: function(str, author) {
+ C[C.length-1] += str;
+ C[2] += str.length;
+
+ if (C.authors) {
+ var a = (author || '');
+ var lastAuthorPtr = C.authors.length-1;
+ var lastAuthorLengthPtr = C.authors.length-2;
+ if ((!a) || a == C.authors[lastAuthorPtr]) {
+ C.authors[lastAuthorLengthPtr] += str.length;
+ }
+ else if (0 == C.authors[lastAuthorLengthPtr]) {
+ C.authors[lastAuthorLengthPtr] = str.length;
+ C.authors[lastAuthorPtr] = (a || C.authors[lastAuthorPtr]);
+ }
+ else {
+ C.authors.push(str.length, a);
+ }
+ }
+
+ return self;
+ },
+ appendOldText: function(startIndex, numTaken) {
+ if (numTaken == 0) return self;
+ // properties of last strip...
+ var s = C[C.length-3], t = C[C.length-2], n = C[C.length-1];
+ if (t == 0 && n == "") {
+ // must be empty changeset, one strip that doesn't take old chars or add new ones
+ C[C.length-3] = startIndex;
+ C[C.length-2] = numTaken;
+ }
+ else if (n == "" && (s+t == startIndex)) {
+ C[C.length-2] += numTaken; // take more
+ }
+ else C.push(startIndex, numTaken, ""); // add a strip
+ C[2] += numTaken;
+ C.checkRep();
+ return self;
+ },
+ toChangeset: function() { return C; }
+ };
+ }
+
+ array.authorSlicer = function(outputBuilder) {
+ return _makeAuthorSlicer(this, outputBuilder);
+ }
+
+ function _makeAuthorSlicer(changesetOrAuthorsIn, builderOut) {
+ // "builderOut" only needs to support appendNewText
+ var authors; // considered immutable
+ if (changesetOrAuthorsIn.isChangeset) {
+ authors = changesetOrAuthorsIn.authors;
+ }
+ else {
+ authors = changesetOrAuthorsIn;
+ }
+
+ // OOP style: state in environment
+ var authorPtr = 0;
+ var charIndex = 0;
+ var charWithinAuthor = 0; // 0 <= charWithinAuthor <= authors[authorPtr]; max value iff atEnd
+ var atEnd = false;
+ function curAuthor() { return authors[authorPtr+1]; }
+ function curAuthorWidth() { return authors[authorPtr]; }
+ function assertNotAtEnd() { assert(! atEnd, "_authorSlicer: can't move past end"); }
+ function forwardInAuthor(numChars) {
+ charWithinAuthor += numChars;
+ charIndex += numChars;
+ }
+ function nextAuthor() {
+ assertNotAtEnd();
+ assert(charWithinAuthor == curAuthorWidth(), "_authorSlicer: not at author end");
+ charWithinAuthor = 0;
+ authorPtr += 2;
+ if (authorPtr == authors.length) {
+ atEnd = true;
+ }
+ }
+
+ var self;
+ return self = {
+ skipChars: function(n) {
+ assert(n >= 0, "_authorSlicer: can't skip negative n");
+ if (n == 0) return;
+ assertNotAtEnd();
+
+ var leftToSkip = n;
+ while (leftToSkip > 0) {
+ var leftInAuthor = curAuthorWidth() - charWithinAuthor;
+ if (leftToSkip >= leftInAuthor) {
+ forwardInAuthor(leftInAuthor);
+ leftToSkip -= leftInAuthor;
+ nextAuthor();
+ }
+ else {
+ forwardInAuthor(leftToSkip);
+ leftToSkip = 0;
+ }
+ }
+ },
+ takeChars: function(n, text) {
+ assert(n >= 0, "_authorSlicer: can't take negative n");
+ if (n == 0) return;
+ assertNotAtEnd();
+ assert(n == text.length, "_authorSlicer: bad text length");
+
+ var textLeft = text;
+ var leftToTake = n;
+ while (leftToTake > 0) {
+ if (curAuthorWidth() > 0 && charWithinAuthor < curAuthorWidth()) {
+ // at least one char to take from current author
+ var leftInAuthor = (curAuthorWidth() - charWithinAuthor);
+ assert(leftInAuthor > 0, "_authorSlicer: should have leftInAuthor > 0");
+ var toTake = min(leftInAuthor, leftToTake);
+ assert(toTake > 0, "_authorSlicer: should have toTake > 0");
+ builderOut.appendNewText(textLeft.substring(0, toTake), curAuthor());
+ forwardInAuthor(toTake);
+ leftToTake -= toTake;
+ textLeft = textLeft.substring(toTake);
+ }
+ assert(charWithinAuthor <= curAuthorWidth(), "_authorSlicer: past end of author");
+ if (charWithinAuthor == curAuthorWidth()) {
+ nextAuthor();
+ }
+ }
+ },
+ setBuilder: function(builder) {
+ builderOut = builder;
+ }
+ };
+ }
+
+ function _makeSlicer(C, output) {
+ // C: Changeset, output: builder from _makeBuilder
+ // C is considered immutable, won't change or be changed
+
+ // OOP style: state in environment
+ var charIndex = 0; // 0 <= charIndex <= C.newLen(); maximum value iff atEnd
+ var stripIndex = 0; // 0 <= stripIndex <= C.numStrips(); maximum value iff atEnd
+ var charWithinStrip = 0; // 0 <= charWithinStrip < curStripWidth()
+ var atEnd = false;
+
+ var authorSlicer;
+ if (C.authors) {
+ authorSlicer = _makeAuthorSlicer(C.authors, output);
+ }
+
+ var ptr = 3;
+ function curStartIndex() { return C[ptr]; }
+ function curNumTaken() { return C[ptr+1]; }
+ function curNewText() { return C[ptr+2]; }
+ function curStripWidth() { return curNumTaken() + curNewText().length; }
+ function assertNotAtEnd() { assert(! atEnd, "_slicer: can't move past changeset end"); }
+ function forwardInStrip(numChars) {
+ charWithinStrip += numChars;
+ charIndex += numChars;
+ }
+ function nextStrip() {
+ assertNotAtEnd();
+ assert(charWithinStrip == curStripWidth(), "_slicer: not at strip end");
+ charWithinStrip = 0;
+ stripIndex++;
+ ptr += 3;
+ if (stripIndex == C.numStrips()) {
+ atEnd = true;
+ }
+ }
+ function curNumNewCharsInRange(start, end) {
+ // takes two indices into the current strip's combined "taken" and "new"
+ // chars, and returns how many "new" chars are included in the range
+ assert(start <= end, "_slicer: curNumNewCharsInRange given out-of-order indices");
+ var nt = curNumTaken();
+ var nn = curNewText().length;
+ var s = nt;
+ var e = nt+nn;
+ if (s < start) s = start;
+ if (e > end) e = end;
+ if (e < s) return 0;
+ return e-s;
+ }
+
+ var self;
+ return self = {
+ skipChars: function (n) {
+ assert(n >= 0, "_slicer: can't skip negative n");
+ if (n == 0) return;
+ assertNotAtEnd();
+
+ var leftToSkip = n;
+ while (leftToSkip > 0) {
+ var leftInStrip = curStripWidth() - charWithinStrip;
+ if (leftToSkip >= leftInStrip) {
+ forwardInStrip(leftInStrip);
+
+ if (authorSlicer)
+ authorSlicer.skipChars(curNumNewCharsInRange(charWithinStrip,
+ charWithinStrip + leftInStrip));
+
+ leftToSkip -= leftInStrip;
+ nextStrip();
+ }
+ else {
+ if (authorSlicer)
+ authorSlicer.skipChars(curNumNewCharsInRange(charWithinStrip,
+ charWithinStrip + leftToSkip));
+
+ forwardInStrip(leftToSkip);
+ leftToSkip = 0;
+ }
+ }
+ },
+ takeChars: function (n) {
+ assert(n >= 0, "_slicer: can't take negative n");
+ if (n == 0) return;
+ assertNotAtEnd();
+
+ var leftToTake = n;
+ while (leftToTake > 0) {
+ if (curNumTaken() > 0 && charWithinStrip < curNumTaken()) {
+ // at least one char to take from current strip's numTaken
+ var leftInTaken = (curNumTaken() - charWithinStrip);
+ assert(leftInTaken > 0, "_slicer: should have leftInTaken > 0");
+ var toTake = min(leftInTaken, leftToTake);
+ assert(toTake > 0, "_slicer: should have toTake > 0");
+ output.appendOldText(curStartIndex() + charWithinStrip, toTake);
+ forwardInStrip(toTake);
+ leftToTake -= toTake;
+ }
+ if (leftToTake > 0 && curNewText().length > 0 && charWithinStrip >= curNumTaken() &&
+ charWithinStrip < curStripWidth()) {
+ // at least one char to take from current strip's newText
+ var leftInNewText = (curStripWidth() - charWithinStrip);
+ assert(leftInNewText > 0, "_slicer: should have leftInNewText > 0");
+ var toTake = min(leftInNewText, leftToTake);
+ assert(toTake > 0, "_slicer: should have toTake > 0");
+ var newText = curNewText().substr(charWithinStrip - curNumTaken(), toTake);
+ if (authorSlicer) {
+ authorSlicer.takeChars(newText.length, newText);
+ }
+ else {
+ output.appendNewText(newText);
+ }
+ forwardInStrip(toTake);
+ leftToTake -= toTake;
+ }
+ assert(charWithinStrip <= curStripWidth(), "_slicer: past end of strip");
+ if (charWithinStrip == curStripWidth()) {
+ nextStrip();
+ }
+ }
+ },
+ skipTo: function(n) {
+ self.skipChars(n - charIndex);
+ }
+ };
+ }
+
+ array.slicer = function(outputBuilder) {
+ return _makeSlicer(this, outputBuilder);
+ }
+
+ array.compose = function(next) {
+ assert(next.oldLen() == this.newLen(), "mismatched composition");
+
+ var builder = _makeBuilder(this.oldLen(), !!(this.authors || next.authors));
+ var slicer = _makeSlicer(this, builder);
+
+ var authorSlicer;
+ if (next.authors) {
+ authorSlicer = _makeAuthorSlicer(next.authors, builder);
+ }
+
+ next.eachStrip(function(s, t, n) {
+ slicer.skipTo(s);
+ slicer.takeChars(t);
+ if (authorSlicer) {
+ authorSlicer.takeChars(n.length, n);
+ }
+ else {
+ builder.appendNewText(n);
+ }
+ }, this);
+
+ return builder.toChangeset();
+ };
+
+ array.traverser = function() {
+ return _makeTraverser(this);
+ }
+
+ function _makeTraverser(C) {
+ var s = C[3], t = C[4], n = C[5];
+ var nextIndex = 6;
+ var indexIntoNewText = 0;
+
+ var authorSlicer;
+ if (C.authors) {
+ authorSlicer = _makeAuthorSlicer(C.authors, null);
+ }
+
+ function advanceIfPossible() {
+ if (t == 0 && n == "" && nextIndex < C.length) {
+ s = C[nextIndex];
+ t = C[nextIndex+1];
+ n = C[nextIndex+2];
+ nextIndex += 3;
+ }
+ }
+
+ var self;
+ return self = {
+ numTakenChars: function() {
+ // if starts with taken characters, then how many, else 0
+ return (t > 0) ? t : 0;
+ },
+ numNewChars: function() {
+ // if starts with new characters, then how many, else 0
+ return (t == 0 && n.length > 0) ? n.length : 0;
+ },
+ takenCharsStart: function() {
+ return (self.numTakenChars() > 0) ? s : 0;
+ },
+ hasMore: function() {
+ return self.numTakenChars() > 0 || self.numNewChars() > 0;
+ },
+ curIndex: function() {
+ return indexIntoNewText;
+ },
+ consumeTakenChars: function (x) {
+ assert(self.numTakenChars() > 0, "_traverser: no taken chars");
+ assert(x >= 0 && x <= self.numTakenChars(), "_traverser: bad number of taken chars");
+ if (x == 0) return;
+ if (t == x) { s = 0; t = 0; }
+ else { s += x; t -= x; }
+ indexIntoNewText += x;
+ advanceIfPossible();
+ },
+ consumeNewChars: function(x) {
+ return self.appendNewChars(x, null);
+ },
+ appendNewChars: function(x, builder) {
+ assert(self.numNewChars() > 0, "_traverser: no new chars");
+ assert(x >= 0 && x <= self.numNewChars(), "_traverser: bad number of new chars");
+ if (x == 0) return "";
+ var str = n.substring(0, x);
+ n = n.substring(x);
+ indexIntoNewText += x;
+ advanceIfPossible();
+
+ if (builder) {
+ if (authorSlicer) {
+ authorSlicer.setBuilder(builder);
+ authorSlicer.takeChars(x, str);
+ }
+ else {
+ builder.appendNewText(str);
+ }
+ }
+ else {
+ if (authorSlicer) authorSlicer.skipChars(x);
+ return str;
+ }
+ },
+ consumeAvailableTakenChars: function() {
+ return self.consumeTakenChars(self.numTakenChars());
+ },
+ consumeAvailableNewChars: function() {
+ return self.consumeNewChars(self.numNewChars());
+ },
+ appendAvailableNewChars: function(builder) {
+ return self.appendNewChars(self.numNewChars(), builder);
+ }
+ };
+ }
+
+ array.follow = function(prev, reverseInsertOrder) {
+ // prev: Changeset, reverseInsertOrder: boolean
+
+ // A.compose(B.follow(A)) is the merging of Changesets A and B, which operate on the same old text.
+ // It is always the same as B.compose(A.follow(B, true)).
+
+ assert(prev.oldLen() == this.oldLen(), "mismatched follow: "+prev.oldLen()+"/"+this.oldLen());
+ var builder = _makeBuilder(prev.newLen(), !! this.authors);
+ var a = _makeTraverser(prev);
+ var b = _makeTraverser(this);
+ while (a.hasMore() || b.hasMore()) {
+ if (a.numNewChars() > 0 && ! reverseInsertOrder) {
+ builder.appendOldText(a.curIndex(), a.numNewChars());
+ a.consumeAvailableNewChars();
+ }
+ else if (b.numNewChars() > 0) {
+ b.appendAvailableNewChars(builder);
+ }
+ else if (a.numNewChars() > 0 && reverseInsertOrder) {
+ builder.appendOldText(a.curIndex(), a.numNewChars());
+ a.consumeAvailableNewChars();
+ }
+ else if (! b.hasMore()) a.consumeAvailableTakenChars();
+ else if (! a.hasMore()) b.consumeAvailableTakenChars();
+ else {
+ var x = a.takenCharsStart();
+ var y = b.takenCharsStart();
+ if (x < y) a.consumeTakenChars(min(a.numTakenChars(), y-x));
+ else if (y < x) b.consumeTakenChars(min(b.numTakenChars(), x-y));
+ else {
+ var takenByBoth = min(a.numTakenChars(), b.numTakenChars());
+ builder.appendOldText(a.curIndex(), takenByBoth);
+ a.consumeTakenChars(takenByBoth);
+ b.consumeTakenChars(takenByBoth);
+ }
+ }
+ }
+ return builder.toChangeset();
+ }
+
+ array.encodeToString = function(asBinary) {
+ var stringDataArray = [];
+ var numsArray = [];
+ if (! asBinary) numsArray.push(this[0]);
+ numsArray.push(this[1], this[2]);
+ this.eachStrip(function(s, t, n) {
+ numsArray.push(s, t, n.length);
+ stringDataArray.push(n);
+ }, this);
+ if (! asBinary) {
+ return numsArray.join(',')+'|'+stringDataArray.join('');
+ }
+ else {
+ return "A" + Changeset.numberArrayToString(numsArray)
+ +escapeCrazyUnicode(stringDataArray.join(''));
+ }
+ }
+
+ function escapeCrazyUnicode(str) {
+ return str.replace(/\\/g, '\\\\').replace(/[\ud800-\udfff]/g, function (c) {
+ return "\\u"+("0000"+c.charCodeAt(0).toString(16)).slice(-4);
+ });
+ }
+
+ array.applyToAttributedText = Changeset.applyToAttributedText;
+
+ function splicesFromChanges(c) {
+ var splices = [];
+ // get a list of splices, [startChar, endChar, newText]
+ var traverser = c.traverser();
+ var oldTextLength = c.oldLen();
+ var indexIntoOldText = 0;
+ while (traverser.hasMore() || indexIntoOldText < oldTextLength) {
+ var newText = "";
+ var startChar = indexIntoOldText;
+ var endChar = indexIntoOldText;
+ if (traverser.numNewChars() > 0) {
+ newText = traverser.consumeAvailableNewChars();
+ }
+ if (traverser.hasMore()) {
+ endChar = traverser.takenCharsStart();
+ indexIntoOldText = endChar + traverser.numTakenChars();
+ traverser.consumeAvailableTakenChars();
+ }
+ else {
+ endChar = oldTextLength;
+ indexIntoOldText = endChar;
+ }
+ if (endChar != startChar || newText.length > 0) {
+ splices.push([startChar, endChar, newText]);
+ }
+ }
+ return splices;
+ }
+
+ array.toSplices = function() {
+ return splicesFromChanges(this);
+ }
+
+ array.characterRangeFollowThis = function(selStartChar, selEndChar, insertionsAfter) {
+ var changeset = this;
+ // represent the selection as a changeset that replaces the selection with some finite string.
+ // Because insertions indicate intention, it doesn't matter what this string is, and even
+ // if the selectionChangeset is made to "follow" other changes it will still be the only
+ // insertion.
+ var selectionChangeset =
+ Changeset(changeset.oldLen()).builder().appendOldText(0, selStartChar).appendNewText(
+ "X").appendOldText(selEndChar, changeset.oldLen() - selEndChar).toChangeset();
+ var newSelectionChangeset = selectionChangeset.follow(changeset, insertionsAfter);
+ var selectionSplices = newSelectionChangeset.toSplices();
+ function includeChar(i) {
+ if (! includeChar.calledYet) {
+ selStartChar = i;
+ selEndChar = i;
+ includeChar.calledYet = true;
+ }
+ else {
+ if (i < selStartChar) selStartChar = i;
+ if (i > selEndChar) selEndChar = i;
+ }
+ }
+ for(var i=0; i<selectionSplices.length; i++) {
+ var s = selectionSplices[i];
+ includeChar(s[0]);
+ includeChar(s[1]);
+ }
+ return [selStartChar, selEndChar];
+ }
+
+ return array;
+}
+
+Changeset.MAGIC = "Changeset";
+Changeset.makeSplice = function(oldLength, spliceStart, numRemoved, stringInserted) {
+ oldLength = (oldLength || 0);
+ spliceStart = (spliceStart || 0);
+ numRemoved = (numRemoved || 0);
+ stringInserted = String(stringInserted || "");
+
+ var builder = Changeset(oldLength).builder();
+ builder.appendOldText(0, spliceStart);
+ builder.appendNewText(stringInserted);
+ builder.appendOldText(spliceStart + numRemoved, oldLength - numRemoved - spliceStart);
+ return builder.toChangeset();
+};
+Changeset.identity = function(len) {
+ return Changeset(len).builder().appendOldText(0, len).toChangeset();
+};
+Changeset.decodeFromString = function(str) {
+ function error(msg) { var e = new Error(msg); e.easysync = true; throw e; }
+ function toHex(str) {
+ var a = [];
+ a.push("length["+str.length+"]:");
+ var TRUNC=20;
+ for(var i=0;i<str.substring(0,TRUNC).length;i++) {
+ a.push(("000"+str.charCodeAt(i).toString(16)).slice(-4));
+ }
+ if (str.length > TRUNC) a.push("...");
+ return a.join(' ');
+ }
+ function unescapeCrazyUnicode(str) {
+ return str.replace(/\\(u....|\\)/g, function(seq) {
+ if (seq == "\\\\") return "\\";
+ return String.fromCharCode(Number("0x"+seq.substring(2)));
+ });
+ }
+
+ var numData, stringData;
+ var binary = false;
+ var typ = str.charAt(0);
+ if (typ == "B" || typ == "A") {
+ var result = Changeset.numberArrayFromString(str, 1);
+ numData = result[0];
+ stringData = result[1];
+ if (typ == "A") {
+ stringData = unescapeCrazyUnicode(stringData);
+ }
+ binary = true;
+ }
+ else if (typ == "C") {
+ var barPosition = str.indexOf('|');
+ numData = str.substring(0, barPosition).split(',');
+ stringData = str.substring(barPosition+1);
+ }
+ else {
+ error("Not a changeset: "+toHex(str));
+ }
+ var stringDataOffset = 0;
+ var array = [];
+ var ptr;
+ if (binary) {
+ array.push("Changeset", numData[0], numData[1]);
+ var ptr = 2;
+ }
+ else {
+ array.push(numData[0], Number(numData[1]), Number(numData[2]));
+ var ptr = 3;
+ }
+ while (ptr < numData.length) {
+ array.push(Number(numData[ptr++]), Number(numData[ptr++]));
+ var newTextLength = Number(numData[ptr++]);
+ array.push(stringData.substr(stringDataOffset, newTextLength));
+ stringDataOffset += newTextLength;
+ }
+ if (stringDataOffset != stringData.length) {
+ error("Extra character data beyond end of encoded string ("+toHex(str)+")");
+ }
+ return Changeset(array);
+};
+
+Changeset.numberArrayToString = function(nums) {
+ var array = [];
+ function writeNum(n) {
+ // does not support negative numbers
+ var twentyEightBit = (n & 0xfffffff);
+ if (twentyEightBit <= 0x7fff) {
+ array.push(String.fromCharCode(twentyEightBit));
+ }
+ else {
+ array.push(String.fromCharCode(0xa000 | (twentyEightBit >> 15),
+ twentyEightBit & 0x7fff));
+ }
+ }
+ writeNum(nums.length);
+ var len = nums.length;
+ for(var i=0;i<len;i++) {
+ writeNum(nums[i]);
+ }
+ return array.join('');
+};
+
+Changeset.numberArrayFromString = function(str, startIndex) {
+ // returns [numberArray, remainingString]
+ var nums = [];
+ var strIndex = (startIndex || 0);
+ function readNum() {
+ var n = str.charCodeAt(strIndex++);
+ if (n > 0x7fff) {
+ if (n >= 0xa000) {
+ n = (((n & 0x1fff) << 15) | str.charCodeAt(strIndex++));
+ }
+ else {
+ // legacy format
+ n = (((n & 0x1fff) << 16) | str.charCodeAt(strIndex++));
+ }
+ }
+ return n;
+ }
+ var len = readNum();
+ for(var i=0;i<len;i++) {
+ nums.push(readNum());
+ }
+ return [nums, str.substring(strIndex)];
+};
+
+(function() {
+ function repeatString(str, times) {
+ if (times <= 0) return "";
+ var s = repeatString(str, times >> 1);
+ s += s;
+ if (times & 1) s += str;
+ return s;
+ }
+ function chr(n) { return String.fromCharCode(n+48); }
+ function ord(c) { return c.charCodeAt(0)-48; }
+ function runMatcher(c) {
+ // Takes "A" and returns /\u0041+/g .
+ // Avoid creating new objects unnecessarily by caching matchers
+ // as properties of this function.
+ var re = runMatcher[c];
+ if (re) return re;
+ re = runMatcher[c] = new RegExp("\\u"+("0000"+c.charCodeAt(0).toString(16)).slice(-4)+"+", 'g');
+ return re;
+ }
+ function runLength(str, idx, c) {
+ var re = runMatcher(c);
+ re.lastIndex = idx;
+ var result = re.exec(str);
+ if (result && result[0]) {
+ return result[0].length;
+ }
+ return 0;
+ }
+
+ // emptyObj may be a StorableObject
+ Changeset.initAttributedText = function(emptyObj, initialString, initialAuthor) {
+ var obj = emptyObj;
+ obj.authorMap = { 1: (initialAuthor || '') };
+ obj.text = (initialString || '');
+ obj.attribs = repeatString(chr(1), obj.text.length);
+ return obj;
+ };
+ Changeset.gcAttributedText = function(atObj) {
+ // "garbage collect" the list of authors
+ var removedAuthors = [];
+ for(var a in atObj.authorMap) {
+ if (atObj.attribs.indexOf(chr(Number(a))) < 0) {
+ removedAuthors.push(atObj.authorMap[a]);
+ delete atObj.authorMap[a];
+ }
+ }
+ return removedAuthors;
+ };
+ Changeset.cloneAttributedText = function(emptyObj, atObj) {
+ var obj = emptyObj;
+ obj.text = atObj.text; // string
+ if (atObj.attribs) obj.attribs = atObj.attribs; // string
+ if (atObj.attribs_c) obj.attribs_c = atObj.attribs_c; // string
+ obj.authorMap = {};
+ for(var a in atObj.authorMap) {
+ obj.authorMap[a] = atObj.authorMap[a];
+ }
+ return obj;
+ };
+ Changeset.applyToAttributedText = function(atObj, C) {
+ C = (C || this);
+ var oldText = atObj.text;
+ var oldAttribs = atObj.attribs;
+ Changeset._assert(C.isChangeset, "applyToAttributedText: 'this' is not a changeset");
+ Changeset._assert(oldText.length == C.oldLen(),
+ "applyToAttributedText: mismatch "+oldText.length+" / "+C.oldLen());
+ var textBuf = [];
+ var attribsBuf = [];
+ var authorMap = atObj.authorMap;
+ function authorId(author) {
+ for(var a in authorMap) {
+ if (authorMap[Number(a)] === author) {
+ return Number(a);
+ }
+ }
+ for(var i=1;i<=60000;i++) {
+ // don't use "in" because it's currently broken on StorableObjects
+ if (authorMap[i] === undefined) {
+ authorMap[i] = author;
+ return i;
+ }
+ }
+ }
+ var myBuilder = { appendNewText: function(txt, author) {
+ // object that acts as a "builder" in that it receives requests from
+ // authorSlicer to append text attributed to different authors
+ attribsBuf.push(repeatString(chr(authorId(author)), txt.length));
+ } };
+ var authorSlicer;
+ if (C.authors) {
+ authorSlicer = C.authorSlicer(myBuilder);
+ }
+ C.eachStrip(function (s, t, n) {
+ textBuf.push(oldText.substr(s, t), n);
+ attribsBuf.push(oldAttribs.substr(s, t));
+ if (authorSlicer) {
+ authorSlicer.takeChars(n.length, n);
+ }
+ else {
+ myBuilder.appendNewText(n, '');
+ }
+ });
+ atObj.text = textBuf.join('');
+ atObj.attribs = attribsBuf.join('');
+ return atObj;
+ };
+ Changeset.getAttributedTextCharAuthor = function(atObj, idx) {
+ return atObj.authorMap[ord(atObj.attribs.charAt(idx))];
+ };
+ Changeset.getAttributedTextCharRunLength = function(atObj, idx) {
+ var c = atObj.attribs.charAt(idx);
+ return runLength(atObj.attribs, idx, c);
+ };
+ Changeset.eachAuthorInAttributedText = function(atObj, func) {
+ // call func(author, authorNum)
+ for(var a in atObj.authorMap) {
+ if (func(atObj.authorMap[a], Number(a))) break;
+ }
+ };
+ Changeset.getAttributedTextAuthorByNum = function(atObj, n) {
+ return atObj.authorMap[n];
+ };
+ // Compressed attributed text can be cloned, but nothing else until uncompressed!!
+ Changeset.compressAttributedText = function(atObj) {
+ // idempotent, mutates the object, returns it
+ if (atObj.attribs) {
+ atObj.attribs_c = atObj.attribs.replace(/([\s\S])\1{0,63}/g, function(run) {
+ return run.charAt(0)+chr(run.length);;
+ });
+ delete atObj.attribs;
+ }
+ return atObj;
+ };
+ Changeset.decompressAttributedText = function(atObj) {
+ // idempotent, mutates the object, returns it
+ if (atObj.attribs_c) {
+ atObj.attribs = atObj.attribs_c.replace(/[\s\S][\s\S]/g, function(run) {
+ return repeatString(run.charAt(0), ord(run.charAt(1)));
+ });
+ delete atObj.attribs_c;
+ }
+ return atObj;
+ };
+})();
diff --git a/trunk/etherpad/src/etherpad/collab/ace/easysync2.js b/trunk/etherpad/src/etherpad/collab/ace/easysync2.js
new file mode 100644
index 0000000..0fa1ec4
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/collab/ace/easysync2.js
@@ -0,0 +1,1968 @@
+// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/easysync2.js
+jimport("com.etherpad.Easysync2Support");
+
+/**
+ * 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.
+ */
+
+//var _opt = (this.Easysync2Support || null);
+var _opt = null; // disable optimization for now
+
+function AttribPool() {
+ var p = {};
+ p.numToAttrib = {}; // e.g. {0: ['foo','bar']}
+ p.attribToNum = {}; // e.g. {'foo,bar': 0}
+ p.nextNum = 0;
+
+ p.putAttrib = function(attrib, dontAddIfAbsent) {
+ var str = String(attrib);
+ if (str in p.attribToNum) {
+ return p.attribToNum[str];
+ }
+ if (dontAddIfAbsent) {
+ return -1;
+ }
+ var num = p.nextNum++;
+ p.attribToNum[str] = num;
+ p.numToAttrib[num] = [String(attrib[0]||''),
+ String(attrib[1]||'')];
+ return num;
+ };
+
+ p.getAttrib = function(num) {
+ var pair = p.numToAttrib[num];
+ if (! pair) return pair;
+ return [pair[0], pair[1]]; // return a mutable copy
+ };
+
+ p.getAttribKey = function(num) {
+ var pair = p.numToAttrib[num];
+ if (! pair) return '';
+ return pair[0];
+ };
+
+ p.getAttribValue = function(num) {
+ var pair = p.numToAttrib[num];
+ if (! pair) return '';
+ return pair[1];
+ };
+
+ p.eachAttrib = function(func) {
+ for(var n in p.numToAttrib) {
+ var pair = p.numToAttrib[n];
+ func(pair[0], pair[1]);
+ }
+ };
+
+ p.toJsonable = function() {
+ return {numToAttrib: p.numToAttrib, nextNum: p.nextNum};
+ };
+
+ p.fromJsonable = function(obj) {
+ p.numToAttrib = obj.numToAttrib;
+ p.nextNum = obj.nextNum;
+ p.attribToNum = {};
+ for(var n in p.numToAttrib) {
+ p.attribToNum[String(p.numToAttrib[n])] = Number(n);
+ }
+ return p;
+ };
+
+ return p;
+}
+
+var Changeset = {};
+
+Changeset.error = function error(msg) { var e = new Error(msg); e.easysync = true; throw e; };
+Changeset.assert = function assert(b, msgParts) {
+ if (! b) {
+ var msg = Array.prototype.slice.call(arguments, 1).join('');
+ Changeset.error("Changeset: "+msg);
+ }
+};
+
+Changeset.parseNum = function(str) { return parseInt(str, 36); };
+Changeset.numToString = function(num) { return num.toString(36).toLowerCase(); };
+Changeset.toBaseTen = function(cs) {
+ var dollarIndex = cs.indexOf('$');
+ var beforeDollar = cs.substring(0, dollarIndex);
+ var fromDollar = cs.substring(dollarIndex);
+ return beforeDollar.replace(/[0-9a-z]+/g, function(s) {
+ return String(Changeset.parseNum(s)); }) + fromDollar;
+};
+
+Changeset.oldLen = function(cs) {
+ return Changeset.unpack(cs).oldLen;
+};
+Changeset.newLen = function(cs) {
+ return Changeset.unpack(cs).newLen;
+};
+
+Changeset.opIterator = function(opsStr, optStartIndex) {
+ //print(opsStr);
+ var regex = /((?:\*[0-9a-z]+)*)(?:\|([0-9a-z]+))?([-+=])([0-9a-z]+)|\?|/g;
+ var startIndex = (optStartIndex || 0);
+ var curIndex = startIndex;
+ var prevIndex = curIndex;
+ function nextRegexMatch() {
+ prevIndex = curIndex;
+ var result;
+ if (_opt) {
+ result = _opt.nextOpInString(opsStr, curIndex);
+ if (result) {
+ if (result.opcode() == '?') {
+ Changeset.error("Hit error opcode in op stream");
+ }
+ curIndex = result.lastIndex();
+ }
+ }
+ else {
+ regex.lastIndex = curIndex;
+ result = regex.exec(opsStr);
+ curIndex = regex.lastIndex;
+ if (result[0] == '?') {
+ Changeset.error("Hit error opcode in op stream");
+ }
+ }
+ return result;
+ }
+ var regexResult = nextRegexMatch();
+ var obj = Changeset.newOp();
+ function next(optObj) {
+ var op = (optObj || obj);
+ if (_opt && regexResult) {
+ op.attribs = regexResult.attribs();
+ op.lines = regexResult.lines();
+ op.chars = regexResult.chars();
+ op.opcode = regexResult.opcode();
+ regexResult = nextRegexMatch();
+ }
+ else if ((! _opt) && regexResult[0]) {
+ op.attribs = regexResult[1];
+ op.lines = Changeset.parseNum(regexResult[2] || 0);
+ op.opcode = regexResult[3];
+ op.chars = Changeset.parseNum(regexResult[4]);
+ regexResult = nextRegexMatch();
+ }
+ else {
+ Changeset.clearOp(op);
+ }
+ return op;
+ }
+ function hasNext() { return !! (_opt ? regexResult : regexResult[0]); }
+ function lastIndex() { return prevIndex; }
+ return {next: next, hasNext: hasNext, lastIndex: lastIndex};
+};
+
+Changeset.clearOp = function(op) {
+ op.opcode = '';
+ op.chars = 0;
+ op.lines = 0;
+ op.attribs = '';
+};
+Changeset.newOp = function(optOpcode) {
+ return {opcode:(optOpcode || ''), chars:0, lines:0, attribs:''};
+};
+Changeset.cloneOp = function(op) {
+ return {opcode: op.opcode, chars: op.chars, lines: op.lines, attribs: op.attribs};
+};
+Changeset.copyOp = function(op1, op2) {
+ op2.opcode = op1.opcode;
+ op2.chars = op1.chars;
+ op2.lines = op1.lines;
+ op2.attribs = op1.attribs;
+};
+Changeset.opString = function(op) {
+ // just for debugging
+ if (! op.opcode) return 'null';
+ var assem = Changeset.opAssembler();
+ assem.append(op);
+ return assem.toString();
+};
+Changeset.stringOp = function(str) {
+ // just for debugging
+ return Changeset.opIterator(str).next();
+};
+
+Changeset.checkRep = function(cs) {
+ // doesn't check things that require access to attrib pool (e.g. attribute order)
+ // or original string (e.g. newline positions)
+ var unpacked = Changeset.unpack(cs);
+ var oldLen = unpacked.oldLen;
+ var newLen = unpacked.newLen;
+ var ops = unpacked.ops;
+ var charBank = unpacked.charBank;
+
+ var assem = Changeset.smartOpAssembler();
+ var oldPos = 0;
+ var calcNewLen = 0;
+ var numInserted = 0;
+ var iter = Changeset.opIterator(ops);
+ while (iter.hasNext()) {
+ var o = iter.next();
+ switch (o.opcode) {
+ case '=': oldPos += o.chars; calcNewLen += o.chars; break;
+ case '-': oldPos += o.chars; Changeset.assert(oldPos < oldLen, oldPos," >= ",oldLen," in ",cs); break;
+ case '+': {
+ calcNewLen += o.chars; numInserted += o.chars;
+ Changeset.assert(calcNewLen < newLen, calcNewLen," >= ",newLen," in ",cs);
+ break;
+ }
+ }
+ assem.append(o);
+ }
+
+ calcNewLen += oldLen - oldPos;
+ charBank = charBank.substring(0, numInserted);
+ while (charBank.length < numInserted) {
+ charBank += "?";
+ }
+
+ assem.endDocument();
+ var normalized = Changeset.pack(oldLen, calcNewLen, assem.toString(), charBank);
+ Changeset.assert(normalized == cs, normalized,' != ',cs);
+
+ return cs;
+}
+
+Changeset.smartOpAssembler = function() {
+ // Like opAssembler but able to produce conforming changesets
+ // from slightly looser input, at the cost of speed.
+ // Specifically:
+ // - merges consecutive operations that can be merged
+ // - strips final "="
+ // - ignores 0-length changes
+ // - reorders consecutive + and - (which margingOpAssembler doesn't do)
+
+ var minusAssem = Changeset.mergingOpAssembler();
+ var plusAssem = Changeset.mergingOpAssembler();
+ var keepAssem = Changeset.mergingOpAssembler();
+ var assem = Changeset.stringAssembler();
+ var lastOpcode = '';
+ var lengthChange = 0;
+
+ function flushKeeps() {
+ assem.append(keepAssem.toString());
+ keepAssem.clear();
+ }
+
+ function flushPlusMinus() {
+ assem.append(minusAssem.toString());
+ minusAssem.clear();
+ assem.append(plusAssem.toString());
+ plusAssem.clear();
+ }
+
+ function append(op) {
+ if (! op.opcode) return;
+ if (! op.chars) return;
+
+ if (op.opcode == '-') {
+ if (lastOpcode == '=') {
+ flushKeeps();
+ }
+ minusAssem.append(op);
+ lengthChange -= op.chars;
+ }
+ else if (op.opcode == '+') {
+ if (lastOpcode == '=') {
+ flushKeeps();
+ }
+ plusAssem.append(op);
+ lengthChange += op.chars;
+ }
+ else if (op.opcode == '=') {
+ if (lastOpcode != '=') {
+ flushPlusMinus();
+ }
+ keepAssem.append(op);
+ }
+ lastOpcode = op.opcode;
+ }
+
+ function appendOpWithText(opcode, text, attribs, pool) {
+ var op = Changeset.newOp(opcode);
+ op.attribs = Changeset.makeAttribsString(opcode, attribs, pool);
+ var lastNewlinePos = text.lastIndexOf('\n');
+ if (lastNewlinePos < 0) {
+ op.chars = text.length;
+ op.lines = 0;
+ append(op);
+ }
+ else {
+ op.chars = lastNewlinePos+1;
+ op.lines = text.match(/\n/g).length;
+ append(op);
+ op.chars = text.length - (lastNewlinePos+1);
+ op.lines = 0;
+ append(op);
+ }
+ }
+
+ function toString() {
+ flushPlusMinus();
+ flushKeeps();
+ return assem.toString();
+ }
+
+ function clear() {
+ minusAssem.clear();
+ plusAssem.clear();
+ keepAssem.clear();
+ assem.clear();
+ lengthChange = 0;
+ }
+
+ function endDocument() {
+ keepAssem.endDocument();
+ }
+
+ function getLengthChange() {
+ return lengthChange;
+ }
+
+ return {append: append, toString: toString, clear: clear, endDocument: endDocument,
+ appendOpWithText: appendOpWithText, getLengthChange: getLengthChange };
+};
+
+if (_opt) {
+ Changeset.mergingOpAssembler = function() {
+ var assem = _opt.mergingOpAssembler();
+
+ function append(op) {
+ assem.append(op.opcode, op.chars, op.lines, op.attribs);
+ }
+ function toString() {
+ return assem.toString();
+ }
+ function clear() {
+ assem.clear();
+ }
+ function endDocument() {
+ assem.endDocument();
+ }
+
+ return {append: append, toString: toString, clear: clear, endDocument: endDocument};
+ };
+}
+else {
+ Changeset.mergingOpAssembler = function() {
+ // This assembler can be used in production; it efficiently
+ // merges consecutive operations that are mergeable, ignores
+ // no-ops, and drops final pure "keeps". It does not re-order
+ // operations.
+ var assem = Changeset.opAssembler();
+ var bufOp = Changeset.newOp();
+
+ // If we get, for example, insertions [xxx\n,yyy], those don't merge,
+ // but if we get [xxx\n,yyy,zzz\n], that merges to [xxx\nyyyzzz\n].
+ // This variable stores the length of yyy and any other newline-less
+ // ops immediately after it.
+ var bufOpAdditionalCharsAfterNewline = 0;
+
+ function flush(isEndDocument) {
+ if (bufOp.opcode) {
+ if (isEndDocument && bufOp.opcode == '=' && ! bufOp.attribs) {
+ // final merged keep, leave it implicit
+ }
+ else {
+ assem.append(bufOp);
+ if (bufOpAdditionalCharsAfterNewline) {
+ bufOp.chars = bufOpAdditionalCharsAfterNewline;
+ bufOp.lines = 0;
+ assem.append(bufOp);
+ bufOpAdditionalCharsAfterNewline = 0;
+ }
+ }
+ bufOp.opcode = '';
+ }
+ }
+ function append(op) {
+ if (op.chars > 0) {
+ if (bufOp.opcode == op.opcode && bufOp.attribs == op.attribs) {
+ if (op.lines > 0) {
+ // bufOp and additional chars are all mergeable into a multi-line op
+ bufOp.chars += bufOpAdditionalCharsAfterNewline + op.chars;
+ bufOp.lines += op.lines;
+ bufOpAdditionalCharsAfterNewline = 0;
+ }
+ else if (bufOp.lines == 0) {
+ // both bufOp and op are in-line
+ bufOp.chars += op.chars;
+ }
+ else {
+ // append in-line text to multi-line bufOp
+ bufOpAdditionalCharsAfterNewline += op.chars;
+ }
+ }
+ else {
+ flush();
+ Changeset.copyOp(op, bufOp);
+ }
+ }
+ }
+ function endDocument() {
+ flush(true);
+ }
+ function toString() {
+ flush();
+ return assem.toString();
+ }
+ function clear() {
+ assem.clear();
+ Changeset.clearOp(bufOp);
+ }
+ return {append: append, toString: toString, clear: clear, endDocument: endDocument};
+ };
+}
+
+if (_opt) {
+ Changeset.opAssembler = function() {
+ var assem = _opt.opAssembler();
+ // this function allows op to be mutated later (doesn't keep a ref)
+ function append(op) {
+ assem.append(op.opcode, op.chars, op.lines, op.attribs);
+ }
+ function toString() {
+ return assem.toString();
+ }
+ function clear() {
+ assem.clear();
+ }
+ return {append: append, toString: toString, clear: clear};
+ };
+}
+else {
+ Changeset.opAssembler = function() {
+ var pieces = [];
+ // this function allows op to be mutated later (doesn't keep a ref)
+ function append(op) {
+ pieces.push(op.attribs);
+ if (op.lines) {
+ pieces.push('|', Changeset.numToString(op.lines));
+ }
+ pieces.push(op.opcode);
+ pieces.push(Changeset.numToString(op.chars));
+ }
+ function toString() {
+ return pieces.join('');
+ }
+ function clear() {
+ pieces.length = 0;
+ }
+ return {append: append, toString: toString, clear: clear};
+ };
+}
+
+Changeset.stringIterator = function(str) {
+ var curIndex = 0;
+ function assertRemaining(n) {
+ Changeset.assert(n <= remaining(), "!(",n," <= ",remaining(),")");
+ }
+ function take(n) {
+ assertRemaining(n);
+ var s = str.substr(curIndex, n);
+ curIndex += n;
+ return s;
+ }
+ function peek(n) {
+ assertRemaining(n);
+ var s = str.substr(curIndex, n);
+ return s;
+ }
+ function skip(n) {
+ assertRemaining(n);
+ curIndex += n;
+ }
+ function remaining() {
+ return str.length - curIndex;
+ }
+ return {take:take, skip:skip, remaining:remaining, peek:peek};
+};
+
+Changeset.stringAssembler = function() {
+ var pieces = [];
+ function append(x) {
+ pieces.push(String(x));
+ }
+ function toString() {
+ return pieces.join('');
+ }
+ return {append: append, toString: toString};
+};
+
+// "lines" need not be an array as long as it supports certain calls (lines_foo inside).
+Changeset.textLinesMutator = function(lines) {
+ // Mutates lines, an array of strings, in place.
+ // Mutation operations have the same constraints as changeset operations
+ // with respect to newlines, but not the other additional constraints
+ // (i.e. ins/del ordering, forbidden no-ops, non-mergeability, final newline).
+ // Can be used to mutate lists of strings where the last char of each string
+ // is not actually a newline, but for the purposes of N and L values,
+ // the caller should pretend it is, and for things to work right in that case, the input
+ // to insert() should be a single line with no newlines.
+
+ var curSplice = [0,0];
+ var inSplice = false;
+ // position in document after curSplice is applied:
+ var curLine = 0, curCol = 0;
+ // invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) &&
+ // curLine >= curSplice[0]
+ // invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then
+ // curCol == 0
+
+ function lines_applySplice(s) {
+ lines.splice.apply(lines, s);
+ }
+ function lines_toSource() {
+ return lines.toSource();
+ }
+ function lines_get(idx) {
+ if (lines.get) {
+ return lines.get(idx);
+ }
+ else {
+ return lines[idx];
+ }
+ }
+ // can be unimplemented if removeLines's return value not needed
+ function lines_slice(start, end) {
+ if (lines.slice) {
+ return lines.slice(start, end);
+ }
+ else {
+ return [];
+ }
+ }
+ function lines_length() {
+ if ((typeof lines.length) == "number") {
+ return lines.length;
+ }
+ else {
+ return lines.length();
+ }
+ }
+
+ function enterSplice() {
+ curSplice[0] = curLine;
+ curSplice[1] = 0;
+ if (curCol > 0) {
+ putCurLineInSplice();
+ }
+ inSplice = true;
+ }
+ function leaveSplice() {
+ lines_applySplice(curSplice);
+ curSplice.length = 2;
+ curSplice[0] = curSplice[1] = 0;
+ inSplice = false;
+ }
+ function isCurLineInSplice() {
+ return (curLine - curSplice[0] < (curSplice.length - 2));
+ }
+ function debugPrint(typ) {
+ print(typ+": "+curSplice.toSource()+" / "+curLine+","+curCol+" / "+lines_toSource());
+ }
+ function putCurLineInSplice() {
+ if (! isCurLineInSplice()) {
+ curSplice.push(lines_get(curSplice[0] + curSplice[1]));
+ curSplice[1]++;
+ }
+ return 2 + curLine - curSplice[0];
+ }
+
+ function skipLines(L, includeInSplice) {
+ if (L) {
+ if (includeInSplice) {
+ if (! inSplice) {
+ enterSplice();
+ }
+ for(var i=0;i<L;i++) {
+ curCol = 0;
+ putCurLineInSplice();
+ curLine++;
+ }
+ }
+ else {
+ if (inSplice) {
+ if (L > 1) {
+ leaveSplice();
+ }
+ else {
+ putCurLineInSplice();
+ }
+ }
+ curLine += L;
+ curCol = 0;
+ }
+ //print(inSplice+" / "+isCurLineInSplice()+" / "+curSplice[0]+" / "+curSplice[1]+" / "+lines.length);
+ /*if (inSplice && (! isCurLineInSplice()) && (curSplice[0] + curSplice[1] < lines.length)) {
+ print("BLAH");
+ putCurLineInSplice();
+ }*/ // tests case foo in remove(), which isn't otherwise covered in current impl
+ }
+ //debugPrint("skip");
+ }
+
+ function skip(N, L, includeInSplice) {
+ if (N) {
+ if (L) {
+ skipLines(L, includeInSplice);
+ }
+ else {
+ if (includeInSplice && ! inSplice) {
+ enterSplice();
+ }
+ if (inSplice) {
+ putCurLineInSplice();
+ }
+ curCol += N;
+ //debugPrint("skip");
+ }
+ }
+ }
+
+ function removeLines(L) {
+ var removed = '';
+ if (L) {
+ if (! inSplice) {
+ enterSplice();
+ }
+ function nextKLinesText(k) {
+ var m = curSplice[0] + curSplice[1];
+ return lines_slice(m, m+k).join('');
+ }
+ if (isCurLineInSplice()) {
+ //print(curCol);
+ if (curCol == 0) {
+ removed = curSplice[curSplice.length-1];
+ // print("FOO"); // case foo
+ curSplice.length--;
+ removed += nextKLinesText(L-1);
+ curSplice[1] += L-1;
+ }
+ else {
+ removed = nextKLinesText(L-1);
+ curSplice[1] += L-1;
+ var sline = curSplice.length - 1;
+ removed = curSplice[sline].substring(curCol) + removed;
+ curSplice[sline] = curSplice[sline].substring(0, curCol) +
+ lines_get(curSplice[0] + curSplice[1]);
+ curSplice[1] += 1;
+ }
+ }
+ else {
+ removed = nextKLinesText(L);
+ curSplice[1] += L;
+ }
+ //debugPrint("remove");
+ }
+ return removed;
+ }
+
+ function remove(N, L) {
+ var removed = '';
+ if (N) {
+ if (L) {
+ return removeLines(L);
+ }
+ else {
+ if (! inSplice) {
+ enterSplice();
+ }
+ var sline = putCurLineInSplice();
+ removed = curSplice[sline].substring(curCol, curCol+N);
+ curSplice[sline] = curSplice[sline].substring(0, curCol) +
+ curSplice[sline].substring(curCol+N);
+ //debugPrint("remove");
+ }
+ }
+ return removed;
+ }
+
+ function insert(text, L) {
+ if (text) {
+ if (! inSplice) {
+ enterSplice();
+ }
+ if (L) {
+ var newLines = Changeset.splitTextLines(text);
+ if (isCurLineInSplice()) {
+ //if (curCol == 0) {
+ //curSplice.length--;
+ //curSplice[1]--;
+ //Array.prototype.push.apply(curSplice, newLines);
+ //curLine += newLines.length;
+ //}
+ //else {
+ var sline = curSplice.length - 1;
+ var theLine = curSplice[sline];
+ var lineCol = curCol;
+ curSplice[sline] = theLine.substring(0, lineCol) + newLines[0];
+ curLine++;
+ newLines.splice(0, 1);
+ Array.prototype.push.apply(curSplice, newLines);
+ curLine += newLines.length;
+ curSplice.push(theLine.substring(lineCol));
+ curCol = 0;
+ //}
+ }
+ else {
+ Array.prototype.push.apply(curSplice, newLines);
+ curLine += newLines.length;
+ }
+ }
+ else {
+ var sline = putCurLineInSplice();
+ curSplice[sline] = curSplice[sline].substring(0, curCol) +
+ text + curSplice[sline].substring(curCol);
+ curCol += text.length;
+ }
+ //debugPrint("insert");
+ }
+ }
+
+ function hasMore() {
+ //print(lines.length+" / "+inSplice+" / "+(curSplice.length - 2)+" / "+curSplice[1]);
+ var docLines = lines_length();
+ if (inSplice) {
+ docLines += curSplice.length - 2 - curSplice[1];
+ }
+ return curLine < docLines;
+ }
+
+ function close() {
+ if (inSplice) {
+ leaveSplice();
+ }
+ //debugPrint("close");
+ }
+
+ var self = {skip:skip, remove:remove, insert:insert, close:close, hasMore:hasMore,
+ removeLines:removeLines, skipLines: skipLines};
+ return self;
+};
+
+Changeset.applyZip = function(in1, idx1, in2, idx2, func) {
+ var iter1 = Changeset.opIterator(in1, idx1);
+ var iter2 = Changeset.opIterator(in2, idx2);
+ var assem = Changeset.smartOpAssembler();
+ var op1 = Changeset.newOp();
+ var op2 = Changeset.newOp();
+ var opOut = Changeset.newOp();
+ while (op1.opcode || iter1.hasNext() || op2.opcode || iter2.hasNext()) {
+ if ((! op1.opcode) && iter1.hasNext()) iter1.next(op1);
+ if ((! op2.opcode) && iter2.hasNext()) iter2.next(op2);
+ func(op1, op2, opOut);
+ if (opOut.opcode) {
+ //print(opOut.toSource());
+ assem.append(opOut);
+ opOut.opcode = '';
+ }
+ }
+ assem.endDocument();
+ return assem.toString();
+};
+
+Changeset.unpack = function(cs) {
+ var headerRegex = /Z:([0-9a-z]+)([><])([0-9a-z]+)|/;
+ var headerMatch = headerRegex.exec(cs);
+ if ((! headerMatch) || (! headerMatch[0])) {
+ Changeset.error("Not a changeset: "+cs);
+ }
+ var oldLen = Changeset.parseNum(headerMatch[1]);
+ var changeSign = (headerMatch[2] == '>') ? 1 : -1;
+ var changeMag = Changeset.parseNum(headerMatch[3]);
+ var newLen = oldLen + changeSign*changeMag;
+ var opsStart = headerMatch[0].length;
+ var opsEnd = cs.indexOf("$");
+ if (opsEnd < 0) opsEnd = cs.length;
+ return {oldLen: oldLen, newLen: newLen, ops: cs.substring(opsStart, opsEnd),
+ charBank: cs.substring(opsEnd+1)};
+};
+
+Changeset.pack = function(oldLen, newLen, opsStr, bank) {
+ var lenDiff = newLen - oldLen;
+ var lenDiffStr = (lenDiff >= 0 ?
+ '>'+Changeset.numToString(lenDiff) :
+ '<'+Changeset.numToString(-lenDiff));
+ var a = [];
+ a.push('Z:', Changeset.numToString(oldLen), lenDiffStr, opsStr, '$', bank);
+ return a.join('');
+};
+
+Changeset.applyToText = function(cs, str) {
+ var unpacked = Changeset.unpack(cs);
+ Changeset.assert(str.length == unpacked.oldLen,
+ "mismatched apply: ",str.length," / ",unpacked.oldLen);
+ var csIter = Changeset.opIterator(unpacked.ops);
+ var bankIter = Changeset.stringIterator(unpacked.charBank);
+ var strIter = Changeset.stringIterator(str);
+ var assem = Changeset.stringAssembler();
+ while (csIter.hasNext()) {
+ var op = csIter.next();
+ switch(op.opcode) {
+ case '+': assem.append(bankIter.take(op.chars)); break;
+ case '-': strIter.skip(op.chars); break;
+ case '=': assem.append(strIter.take(op.chars)); break;
+ }
+ }
+ assem.append(strIter.take(strIter.remaining()));
+ return assem.toString();
+};
+
+Changeset.mutateTextLines = function(cs, lines) {
+ var unpacked = Changeset.unpack(cs);
+ var csIter = Changeset.opIterator(unpacked.ops);
+ var bankIter = Changeset.stringIterator(unpacked.charBank);
+ var mut = Changeset.textLinesMutator(lines);
+ while (csIter.hasNext()) {
+ var op = csIter.next();
+ switch(op.opcode) {
+ case '+': mut.insert(bankIter.take(op.chars), op.lines); break;
+ case '-': mut.remove(op.chars, op.lines); break;
+ case '=': mut.skip(op.chars, op.lines, (!! op.attribs)); break;
+ }
+ }
+ mut.close();
+};
+
+Changeset.composeAttributes = function(att1, att2, resultIsMutation, pool) {
+ // att1 and att2 are strings like "*3*f*1c", asMutation is a boolean.
+
+ // Sometimes attribute (key,value) pairs are treated as attribute presence
+ // information, while other times they are treated as operations that
+ // mutate a set of attributes, and this affects whether an empty value
+ // is a deletion or a change.
+ // Examples, of the form (att1Items, att2Items, resultIsMutation) -> result
+ // ([], [(bold, )], true) -> [(bold, )]
+ // ([], [(bold, )], false) -> []
+ // ([], [(bold, true)], true) -> [(bold, true)]
+ // ([], [(bold, true)], false) -> [(bold, true)]
+ // ([(bold, true)], [(bold, )], true) -> [(bold, )]
+ // ([(bold, true)], [(bold, )], false) -> []
+
+ // pool can be null if att2 has no attributes.
+
+ if ((! att1) && resultIsMutation) {
+ // In the case of a mutation (i.e. composing two changesets),
+ // an att2 composed with an empy att1 is just att2. If att1
+ // is part of an attribution string, then att2 may remove
+ // attributes that are already gone, so don't do this optimization.
+ return att2;
+ }
+ if (! att2) return att1;
+ var atts = [];
+ att1.replace(/\*([0-9a-z]+)/g, function(_, a) {
+ atts.push(pool.getAttrib(Changeset.parseNum(a)));
+ return '';
+ });
+ att2.replace(/\*([0-9a-z]+)/g, function(_, a) {
+ var pair = pool.getAttrib(Changeset.parseNum(a));
+ var found = false;
+ for(var i=0;i<atts.length;i++) {
+ var oldPair = atts[i];
+ if (oldPair[0] == pair[0]) {
+ if (pair[1] || resultIsMutation) {
+ oldPair[1] = pair[1];
+ }
+ else {
+ atts.splice(i, 1);
+ }
+ found = true;
+ break;
+ }
+ }
+ if ((! found) && (pair[1] || resultIsMutation)) {
+ atts.push(pair);
+ }
+ return '';
+ });
+ atts.sort();
+ var buf = Changeset.stringAssembler();
+ for(var i=0;i<atts.length;i++) {
+ buf.append('*');
+ buf.append(Changeset.numToString(pool.putAttrib(atts[i])));
+ }
+ //print(att1+" / "+att2+" / "+buf.toString());
+ return buf.toString();
+};
+
+Changeset._slicerZipperFunc = function(attOp, csOp, opOut, pool) {
+ // attOp is the op from the sequence that is being operated on, either an
+ // attribution string or the earlier of two changesets being composed.
+ // pool can be null if definitely not needed.
+
+ //print(csOp.toSource()+" "+attOp.toSource()+" "+opOut.toSource());
+ if (attOp.opcode == '-') {
+ Changeset.copyOp(attOp, opOut);
+ attOp.opcode = '';
+ }
+ else if (! attOp.opcode) {
+ Changeset.copyOp(csOp, opOut);
+ csOp.opcode = '';
+ }
+ else {
+ switch (csOp.opcode) {
+ case '-': {
+ if (csOp.chars <= attOp.chars) {
+ // delete or delete part
+ if (attOp.opcode == '=') {
+ opOut.opcode = '-';
+ opOut.chars = csOp.chars;
+ opOut.lines = csOp.lines;
+ opOut.attribs = '';
+ }
+ attOp.chars -= csOp.chars;
+ attOp.lines -= csOp.lines;
+ csOp.opcode = '';
+ if (! attOp.chars) {
+ attOp.opcode = '';
+ }
+ }
+ else {
+ // delete and keep going
+ if (attOp.opcode == '=') {
+ opOut.opcode = '-';
+ opOut.chars = attOp.chars;
+ opOut.lines = attOp.lines;
+ opOut.attribs = '';
+ }
+ csOp.chars -= attOp.chars;
+ csOp.lines -= attOp.lines;
+ attOp.opcode = '';
+ }
+ break;
+ }
+ case '+': {
+ // insert
+ Changeset.copyOp(csOp, opOut);
+ csOp.opcode = '';
+ break;
+ }
+ case '=': {
+ if (csOp.chars <= attOp.chars) {
+ // keep or keep part
+ opOut.opcode = attOp.opcode;
+ opOut.chars = csOp.chars;
+ opOut.lines = csOp.lines;
+ opOut.attribs = Changeset.composeAttributes(attOp.attribs, csOp.attribs,
+ attOp.opcode == '=', pool);
+ csOp.opcode = '';
+ attOp.chars -= csOp.chars;
+ attOp.lines -= csOp.lines;
+ if (! attOp.chars) {
+ attOp.opcode = '';
+ }
+ }
+ else {
+ // keep and keep going
+ opOut.opcode = attOp.opcode;
+ opOut.chars = attOp.chars;
+ opOut.lines = attOp.lines;
+ opOut.attribs = Changeset.composeAttributes(attOp.attribs, csOp.attribs,
+ attOp.opcode == '=', pool);
+ attOp.opcode = '';
+ csOp.chars -= attOp.chars;
+ csOp.lines -= attOp.lines;
+ }
+ break;
+ }
+ case '': {
+ Changeset.copyOp(attOp, opOut);
+ attOp.opcode = '';
+ break;
+ }
+ }
+ }
+};
+
+Changeset.applyToAttribution = function(cs, astr, pool) {
+ var unpacked = Changeset.unpack(cs);
+
+ return Changeset.applyZip(astr, 0, unpacked.ops, 0, function(op1, op2, opOut) {
+ return Changeset._slicerZipperFunc(op1, op2, opOut, pool);
+ });
+};
+
+/*Changeset.oneInsertedLineAtATimeOpIterator = function(opsStr, optStartIndex, charBank) {
+ var iter = Changeset.opIterator(opsStr, optStartIndex);
+ var bankIndex = 0;
+
+};*/
+
+Changeset.mutateAttributionLines = function(cs, lines, pool) {
+ //dmesg(cs);
+ //dmesg(lines.toSource()+" ->");
+
+ var unpacked = Changeset.unpack(cs);
+ var csIter = Changeset.opIterator(unpacked.ops);
+ var csBank = unpacked.charBank;
+ var csBankIndex = 0;
+ // treat the attribution lines as text lines, mutating a line at a time
+ var mut = Changeset.textLinesMutator(lines);
+
+ var lineIter = null;
+ function isNextMutOp() {
+ return (lineIter && lineIter.hasNext()) || mut.hasMore();
+ }
+ function nextMutOp(destOp) {
+ if ((!(lineIter && lineIter.hasNext())) && mut.hasMore()) {
+ var line = mut.removeLines(1);
+ lineIter = Changeset.opIterator(line);
+ }
+ if (lineIter && lineIter.hasNext()) {
+ lineIter.next(destOp);
+ }
+ else {
+ destOp.opcode = '';
+ }
+ }
+ var lineAssem = null;
+ function outputMutOp(op) {
+ //print("outputMutOp: "+op.toSource());
+ if (! lineAssem) {
+ lineAssem = Changeset.mergingOpAssembler();
+ }
+ lineAssem.append(op);
+ if (op.lines > 0) {
+ Changeset.assert(op.lines == 1, "Can't have op.lines of ",op.lines," in attribution lines");
+ // ship it to the mut
+ mut.insert(lineAssem.toString(), 1);
+ lineAssem = null;
+ }
+ }
+
+ var csOp = Changeset.newOp();
+ var attOp = Changeset.newOp();
+ var opOut = Changeset.newOp();
+ while (csOp.opcode || csIter.hasNext() || attOp.opcode || isNextMutOp()) {
+ if ((! csOp.opcode) && csIter.hasNext()) {
+ csIter.next(csOp);
+ }
+ //print(csOp.toSource()+" "+attOp.toSource()+" "+opOut.toSource());
+ //print(csOp.opcode+"/"+csOp.lines+"/"+csOp.attribs+"/"+lineAssem+"/"+lineIter+"/"+(lineIter?lineIter.hasNext():null));
+ //print("csOp: "+csOp.toSource());
+ if ((! csOp.opcode) && (! attOp.opcode) &&
+ (! lineAssem) && (! (lineIter && lineIter.hasNext()))) {
+ break; // done
+ }
+ else if (csOp.opcode == '=' && csOp.lines > 0 && (! csOp.attribs) && (! attOp.opcode) &&
+ (! lineAssem) && (! (lineIter && lineIter.hasNext()))) {
+ // skip multiple lines; this is what makes small changes not order of the document size
+ mut.skipLines(csOp.lines);
+ //print("skipped: "+csOp.lines);
+ csOp.opcode = '';
+ }
+ else if (csOp.opcode == '+') {
+ if (csOp.lines > 1) {
+ var firstLineLen = csBank.indexOf('\n', csBankIndex) + 1 - csBankIndex;
+ Changeset.copyOp(csOp, opOut);
+ csOp.chars -= firstLineLen;
+ csOp.lines--;
+ opOut.lines = 1;
+ opOut.chars = firstLineLen;
+ }
+ else {
+ Changeset.copyOp(csOp, opOut);
+ csOp.opcode = '';
+ }
+ outputMutOp(opOut);
+ csBankIndex += opOut.chars;
+ opOut.opcode = '';
+ }
+ else {
+ if ((! attOp.opcode) && isNextMutOp()) {
+ nextMutOp(attOp);
+ }
+ //print("attOp: "+attOp.toSource());
+ Changeset._slicerZipperFunc(attOp, csOp, opOut, pool);
+ if (opOut.opcode) {
+ outputMutOp(opOut);
+ opOut.opcode = '';
+ }
+ }
+ }
+
+ Changeset.assert(! lineAssem, "line assembler not finished");
+ mut.close();
+
+ //dmesg("-> "+lines.toSource());
+};
+
+Changeset.joinAttributionLines = function(theAlines) {
+ var assem = Changeset.mergingOpAssembler();
+ for(var i=0;i<theAlines.length;i++) {
+ var aline = theAlines[i];
+ var iter = Changeset.opIterator(aline);
+ while (iter.hasNext()) {
+ assem.append(iter.next());
+ }
+ }
+ return assem.toString();
+};
+
+Changeset.splitAttributionLines = function(attrOps, text) {
+ var iter = Changeset.opIterator(attrOps);
+ var assem = Changeset.mergingOpAssembler();
+ var lines = [];
+ var pos = 0;
+
+ function appendOp(op) {
+ assem.append(op);
+ if (op.lines > 0) {
+ lines.push(assem.toString());
+ assem.clear();
+ }
+ pos += op.chars;
+ }
+
+ while (iter.hasNext()) {
+ var op = iter.next();
+ var numChars = op.chars;
+ var numLines = op.lines;
+ while (numLines > 1) {
+ var newlineEnd = text.indexOf('\n', pos)+1;
+ Changeset.assert(newlineEnd > 0, "newlineEnd <= 0 in splitAttributionLines");
+ op.chars = newlineEnd - pos;
+ op.lines = 1;
+ appendOp(op);
+ numChars -= op.chars;
+ numLines -= op.lines;
+ }
+ if (numLines == 1) {
+ op.chars = numChars;
+ op.lines = 1;
+ }
+ appendOp(op);
+ }
+
+ return lines;
+};
+
+Changeset.splitTextLines = function(text) {
+ return text.match(/[^\n]*(?:\n|[^\n]$)/g);
+};
+
+Changeset.compose = function(cs1, cs2, pool) {
+ var unpacked1 = Changeset.unpack(cs1);
+ var unpacked2 = Changeset.unpack(cs2);
+ var len1 = unpacked1.oldLen;
+ var len2 = unpacked1.newLen;
+ Changeset.assert(len2 == unpacked2.oldLen, "mismatched composition");
+ var len3 = unpacked2.newLen;
+ var bankIter1 = Changeset.stringIterator(unpacked1.charBank);
+ var bankIter2 = Changeset.stringIterator(unpacked2.charBank);
+ var bankAssem = Changeset.stringAssembler();
+
+ var newOps = Changeset.applyZip(unpacked1.ops, 0, unpacked2.ops, 0, function(op1, op2, opOut) {
+ //var debugBuilder = Changeset.stringAssembler();
+ //debugBuilder.append(Changeset.opString(op1));
+ //debugBuilder.append(',');
+ //debugBuilder.append(Changeset.opString(op2));
+ //debugBuilder.append(' / ');
+
+ var op1code = op1.opcode;
+ var op2code = op2.opcode;
+ if (op1code == '+' && op2code == '-') {
+ bankIter1.skip(Math.min(op1.chars, op2.chars));
+ }
+ Changeset._slicerZipperFunc(op1, op2, opOut, pool);
+ if (opOut.opcode == '+') {
+ if (op2code == '+') {
+ bankAssem.append(bankIter2.take(opOut.chars));
+ }
+ else {
+ bankAssem.append(bankIter1.take(opOut.chars));
+ }
+ }
+
+ //debugBuilder.append(Changeset.opString(op1));
+ //debugBuilder.append(',');
+ //debugBuilder.append(Changeset.opString(op2));
+ //debugBuilder.append(' -> ');
+ //debugBuilder.append(Changeset.opString(opOut));
+ //print(debugBuilder.toString());
+ });
+
+ return Changeset.pack(len1, len3, newOps, bankAssem.toString());
+};
+
+Changeset.attributeTester = function(attribPair, pool) {
+ // returns a function that tests if a string of attributes
+ // (e.g. *3*4) contains a given attribute key,value that
+ // is already present in the pool.
+ if (! pool) {
+ return never;
+ }
+ var attribNum = pool.putAttrib(attribPair, true);
+ if (attribNum < 0) {
+ return never;
+ }
+ else {
+ var re = new RegExp('\\*'+Changeset.numToString(attribNum)+
+ '(?!\\w)');
+ return function(attribs) {
+ return re.test(attribs);
+ };
+ }
+ function never(attribs) { return false; }
+};
+
+Changeset.identity = function(N) {
+ return Changeset.pack(N, N, "", "");
+};
+
+Changeset.makeSplice = function(oldFullText, spliceStart, numRemoved, newText, optNewTextAPairs, pool) {
+ var oldLen = oldFullText.length;
+
+ if (spliceStart >= oldLen) {
+ spliceStart = oldLen - 1;
+ }
+ if (numRemoved > oldFullText.length - spliceStart - 1) {
+ numRemoved = oldFullText.length - spliceStart - 1;
+ }
+ var oldText = oldFullText.substring(spliceStart, spliceStart+numRemoved);
+ var newLen = oldLen + newText.length - oldText.length;
+
+ var assem = Changeset.smartOpAssembler();
+ assem.appendOpWithText('=', oldFullText.substring(0, spliceStart));
+ assem.appendOpWithText('-', oldText);
+ assem.appendOpWithText('+', newText, optNewTextAPairs, pool);
+ assem.endDocument();
+ return Changeset.pack(oldLen, newLen, assem.toString(), newText);
+};
+
+Changeset.toSplices = function(cs) {
+ // get a list of splices, [startChar, endChar, newText]
+
+ var unpacked = Changeset.unpack(cs);
+ var splices = [];
+
+ var oldPos = 0;
+ var iter = Changeset.opIterator(unpacked.ops);
+ var charIter = Changeset.stringIterator(unpacked.charBank);
+ var inSplice = false;
+ while (iter.hasNext()) {
+ var op = iter.next();
+ if (op.opcode == '=') {
+ oldPos += op.chars;
+ inSplice = false;
+ }
+ else {
+ if (! inSplice) {
+ splices.push([oldPos, oldPos, ""]);
+ inSplice = true;
+ }
+ if (op.opcode == '-') {
+ oldPos += op.chars;
+ splices[splices.length-1][1] += op.chars;
+ }
+ else if (op.opcode == '+') {
+ splices[splices.length-1][2] += charIter.take(op.chars);
+ }
+ }
+ }
+
+ return splices;
+};
+
+Changeset.characterRangeFollow = function(cs, startChar, endChar, insertionsAfter) {
+ var newStartChar = startChar;
+ var newEndChar = endChar;
+ var splices = Changeset.toSplices(cs);
+ var lengthChangeSoFar = 0;
+ for(var i=0;i<splices.length;i++) {
+ var splice = splices[i];
+ var spliceStart = splice[0] + lengthChangeSoFar;
+ var spliceEnd = splice[1] + lengthChangeSoFar;
+ var newTextLength = splice[2].length;
+ var thisLengthChange = newTextLength - (spliceEnd - spliceStart);
+
+ if (spliceStart <= newStartChar && spliceEnd >= newEndChar) {
+ // splice fully replaces/deletes range
+ // (also case that handles insertion at a collapsed selection)
+ if (insertionsAfter) {
+ newStartChar = newEndChar = spliceStart;
+ }
+ else {
+ newStartChar = newEndChar = spliceStart + newTextLength;
+ }
+ }
+ else if (spliceEnd <= newStartChar) {
+ // splice is before range
+ newStartChar += thisLengthChange;
+ newEndChar += thisLengthChange;
+ }
+ else if (spliceStart >= newEndChar) {
+ // splice is after range
+ }
+ else if (spliceStart >= newStartChar && spliceEnd <= newEndChar) {
+ // splice is inside range
+ newEndChar += thisLengthChange;
+ }
+ else if (spliceEnd < newEndChar) {
+ // splice overlaps beginning of range
+ newStartChar = spliceStart + newTextLength;
+ newEndChar += thisLengthChange;
+ }
+ else {
+ // splice overlaps end of range
+ newEndChar = spliceStart;
+ }
+
+ lengthChangeSoFar += thisLengthChange;
+ }
+
+ return [newStartChar, newEndChar];
+};
+
+Changeset.moveOpsToNewPool = function(cs, oldPool, newPool) {
+ // works on changeset or attribution string
+ var dollarPos = cs.indexOf('$');
+ if (dollarPos < 0) {
+ dollarPos = cs.length;
+ }
+ var upToDollar = cs.substring(0, dollarPos);
+ var fromDollar = cs.substring(dollarPos);
+ // order of attribs stays the same
+ return upToDollar.replace(/\*([0-9a-z]+)/g, function(_, a) {
+ var oldNum = Changeset.parseNum(a);
+ var pair = oldPool.getAttrib(oldNum);
+ var newNum = newPool.putAttrib(pair);
+ return '*'+Changeset.numToString(newNum);
+ }) + fromDollar;
+};
+
+Changeset.makeAttribution = function(text) {
+ var assem = Changeset.smartOpAssembler();
+ assem.appendOpWithText('+', text);
+ return assem.toString();
+};
+
+// callable on a changeset, attribution string, or attribs property of an op
+Changeset.eachAttribNumber = function(cs, func) {
+ var dollarPos = cs.indexOf('$');
+ if (dollarPos < 0) {
+ dollarPos = cs.length;
+ }
+ var upToDollar = cs.substring(0, dollarPos);
+
+ upToDollar.replace(/\*([0-9a-z]+)/g, function(_, a) {
+ func(Changeset.parseNum(a));
+ return '';
+ });
+};
+
+// callable on a changeset, attribution string, or attribs property of an op,
+// though it may easily create adjacent ops that can be merged.
+Changeset.filterAttribNumbers = function(cs, filter) {
+ return Changeset.mapAttribNumbers(cs, filter);
+};
+
+Changeset.mapAttribNumbers = function(cs, func) {
+ var dollarPos = cs.indexOf('$');
+ if (dollarPos < 0) {
+ dollarPos = cs.length;
+ }
+ var upToDollar = cs.substring(0, dollarPos);
+
+ var newUpToDollar = upToDollar.replace(/\*([0-9a-z]+)/g, function(s, a) {
+ var n = func(Changeset.parseNum(a));
+ if (n === true) {
+ return s;
+ }
+ else if ((typeof n) === "number") {
+ return '*'+Changeset.numToString(n);
+ }
+ else {
+ return '';
+ }
+ });
+
+ return newUpToDollar + cs.substring(dollarPos);
+};
+
+Changeset.makeAText = function(text, attribs) {
+ return { text: text, attribs: (attribs || Changeset.makeAttribution(text)) };
+};
+
+Changeset.applyToAText = function(cs, atext, pool) {
+ return { text: Changeset.applyToText(cs, atext.text),
+ attribs: Changeset.applyToAttribution(cs, atext.attribs, pool) };
+};
+
+Changeset.cloneAText = function(atext) {
+ return { text: atext.text, attribs: atext.attribs };
+};
+
+Changeset.copyAText = function(atext1, atext2) {
+ atext2.text = atext1.text;
+ atext2.attribs = atext1.attribs;
+};
+
+Changeset.appendATextToAssembler = function(atext, assem) {
+ // intentionally skips last newline char of atext
+ var iter = Changeset.opIterator(atext.attribs);
+ var op = Changeset.newOp();
+ while (iter.hasNext()) {
+ iter.next(op);
+ if (! iter.hasNext()) {
+ // last op, exclude final newline
+ if (op.lines <= 1) {
+ op.lines = 0;
+ op.chars--;
+ if (op.chars) {
+ assem.append(op);
+ }
+ }
+ else {
+ var nextToLastNewlineEnd =
+ atext.text.lastIndexOf('\n', atext.text.length-2) + 1;
+ var lastLineLength = atext.text.length - nextToLastNewlineEnd - 1;
+ op.lines--;
+ op.chars -= (lastLineLength + 1);
+ assem.append(op);
+ op.lines = 0;
+ op.chars = lastLineLength;
+ if (op.chars) {
+ assem.append(op);
+ }
+ }
+ }
+ else {
+ assem.append(op);
+ }
+ }
+};
+
+Changeset.prepareForWire = function(cs, pool) {
+ var newPool = new AttribPool();
+ var newCs = Changeset.moveOpsToNewPool(cs, pool, newPool);
+ return {translated: newCs, pool: newPool};
+};
+
+Changeset.isIdentity = function(cs) {
+ var unpacked = Changeset.unpack(cs);
+ return unpacked.ops == "" && unpacked.oldLen == unpacked.newLen;
+};
+
+Changeset.opAttributeValue = function(op, key, pool) {
+ return Changeset.attribsAttributeValue(op.attribs, key, pool);
+};
+
+Changeset.attribsAttributeValue = function(attribs, key, pool) {
+ var value = '';
+ if (attribs) {
+ Changeset.eachAttribNumber(attribs, function(n) {
+ if (pool.getAttribKey(n) == key) {
+ value = pool.getAttribValue(n);
+ }
+ });
+ }
+ return value;
+};
+
+Changeset.builder = function(oldLen) {
+ var assem = Changeset.smartOpAssembler();
+ var o = Changeset.newOp();
+ var charBank = Changeset.stringAssembler();
+
+ var self = {
+ // attribs are [[key1,value1],[key2,value2],...] or '*0*1...' (no pool needed in latter case)
+ keep: function(N, L, attribs, pool) {
+ o.opcode = '=';
+ o.attribs = (attribs &&
+ Changeset.makeAttribsString('=', attribs, pool)) || '';
+ o.chars = N;
+ o.lines = (L || 0);
+ assem.append(o);
+ return self;
+ },
+ keepText: function(text, attribs, pool) {
+ assem.appendOpWithText('=', text, attribs, pool);
+ return self;
+ },
+ insert: function(text, attribs, pool) {
+ assem.appendOpWithText('+', text, attribs, pool);
+ charBank.append(text);
+ return self;
+ },
+ remove: function(N, L) {
+ o.opcode = '-';
+ o.attribs = '';
+ o.chars = N;
+ o.lines = (L || 0);
+ assem.append(o);
+ return self;
+ },
+ toString: function() {
+ assem.endDocument();
+ var newLen = oldLen + assem.getLengthChange();
+ return Changeset.pack(oldLen, newLen, assem.toString(),
+ charBank.toString());
+ }
+ };
+
+ return self;
+};
+
+Changeset.makeAttribsString = function(opcode, attribs, pool) {
+ // makeAttribsString(opcode, '*3') or makeAttribsString(opcode, [['foo','bar']], myPool) work
+ if (! attribs) {
+ return '';
+ }
+ else if ((typeof attribs) == "string") {
+ return attribs;
+ }
+ else if (pool && attribs && attribs.length) {
+ if (attribs.length > 1) {
+ attribs = attribs.slice();
+ attribs.sort();
+ }
+ var result = [];
+ for(var i=0;i<attribs.length;i++) {
+ var pair = attribs[i];
+ if (opcode == '=' || (opcode == '+' && pair[1])) {
+ result.push('*'+Changeset.numToString(pool.putAttrib(pair)));
+ }
+ }
+ return result.join('');
+ }
+};
+
+// like "substring" but on a single-line attribution string
+Changeset.subattribution = function(astr, start, optEnd) {
+ var iter = Changeset.opIterator(astr, 0);
+ var assem = Changeset.smartOpAssembler();
+ var attOp = Changeset.newOp();
+ var csOp = Changeset.newOp();
+ var opOut = Changeset.newOp();
+
+ function doCsOp() {
+ if (csOp.chars) {
+ while (csOp.opcode && (attOp.opcode || iter.hasNext())) {
+ if (! attOp.opcode) iter.next(attOp);
+
+ if (csOp.opcode && attOp.opcode && csOp.chars >= attOp.chars &&
+ attOp.lines > 0 && csOp.lines <= 0) {
+ csOp.lines++;
+ }
+
+ Changeset._slicerZipperFunc(attOp, csOp, opOut, null);
+ if (opOut.opcode) {
+ assem.append(opOut);
+ opOut.opcode = '';
+ }
+ }
+ }
+ }
+
+ csOp.opcode = '-';
+ csOp.chars = start;
+
+ doCsOp();
+
+ if (optEnd === undefined) {
+ if (attOp.opcode) {
+ assem.append(attOp);
+ }
+ while (iter.hasNext()) {
+ iter.next(attOp);
+ assem.append(attOp);
+ }
+ }
+ else {
+ csOp.opcode = '=';
+ csOp.chars = optEnd - start;
+ doCsOp();
+ }
+
+ return assem.toString();
+};
+
+Changeset.inverse = function(cs, lines, alines, pool) {
+ // lines and alines are what the changeset is meant to apply to.
+ // They may be arrays or objects with .get(i) and .length methods.
+ // They include final newlines on lines.
+ function lines_get(idx) {
+ if (lines.get) {
+ return lines.get(idx);
+ }
+ else {
+ return lines[idx];
+ }
+ }
+ function lines_length() {
+ if ((typeof lines.length) == "number") {
+ return lines.length;
+ }
+ else {
+ return lines.length();
+ }
+ }
+ function alines_get(idx) {
+ if (alines.get) {
+ return alines.get(idx);
+ }
+ else {
+ return alines[idx];
+ }
+ }
+ function alines_length() {
+ if ((typeof alines.length) == "number") {
+ return alines.length;
+ }
+ else {
+ return alines.length();
+ }
+ }
+
+ var curLine = 0;
+ var curChar = 0;
+ var curLineOpIter = null;
+ var curLineOpIterLine;
+ var curLineNextOp = Changeset.newOp('+');
+
+ var unpacked = Changeset.unpack(cs);
+ var csIter = Changeset.opIterator(unpacked.ops);
+ var builder = Changeset.builder(unpacked.newLen);
+
+ function consumeAttribRuns(numChars, func/*(len, attribs, endsLine)*/) {
+
+ if ((! curLineOpIter) || (curLineOpIterLine != curLine)) {
+ // create curLineOpIter and advance it to curChar
+ curLineOpIter = Changeset.opIterator(alines_get(curLine));
+ curLineOpIterLine = curLine;
+ var indexIntoLine = 0;
+ var done = false;
+ while (! done) {
+ curLineOpIter.next(curLineNextOp);
+ if (indexIntoLine + curLineNextOp.chars >= curChar) {
+ curLineNextOp.chars -= (curChar - indexIntoLine);
+ done = true;
+ }
+ else {
+ indexIntoLine += curLineNextOp.chars;
+ }
+ }
+ }
+
+ while (numChars > 0) {
+ if ((! curLineNextOp.chars) && (! curLineOpIter.hasNext())) {
+ curLine++;
+ curChar = 0;
+ curLineOpIterLine = curLine;
+ curLineNextOp.chars = 0;
+ curLineOpIter = Changeset.opIterator(alines_get(curLine));
+ }
+ if (! curLineNextOp.chars) {
+ curLineOpIter.next(curLineNextOp);
+ }
+ var charsToUse = Math.min(numChars, curLineNextOp.chars);
+ func(charsToUse, curLineNextOp.attribs,
+ charsToUse == curLineNextOp.chars && curLineNextOp.lines > 0);
+ numChars -= charsToUse;
+ curLineNextOp.chars -= charsToUse;
+ curChar += charsToUse;
+ }
+
+ if ((! curLineNextOp.chars) && (! curLineOpIter.hasNext())) {
+ curLine++;
+ curChar = 0;
+ }
+ }
+
+ function skip(N, L) {
+ if (L) {
+ curLine += L;
+ curChar = 0;
+ }
+ else {
+ if (curLineOpIter && curLineOpIterLine == curLine) {
+ consumeAttribRuns(N, function() {});
+ }
+ else {
+ curChar += N;
+ }
+ }
+ }
+
+ function nextText(numChars) {
+ var len = 0;
+ var assem = Changeset.stringAssembler();
+ var firstString = lines_get(curLine).substring(curChar);
+ len += firstString.length;
+ assem.append(firstString);
+
+ var lineNum = curLine+1;
+ while (len < numChars) {
+ var nextString = lines_get(lineNum);
+ len += nextString.length;
+ assem.append(nextString);
+ lineNum++;
+ }
+
+ return assem.toString().substring(0, numChars);
+ }
+
+ function cachedStrFunc(func) {
+ var cache = {};
+ return function(s) {
+ if (! cache[s]) {
+ cache[s] = func(s);
+ }
+ return cache[s];
+ };
+ }
+
+ var attribKeys = [];
+ var attribValues = [];
+ while (csIter.hasNext()) {
+ var csOp = csIter.next();
+ if (csOp.opcode == '=') {
+ if (csOp.attribs) {
+ attribKeys.length = 0;
+ attribValues.length = 0;
+ Changeset.eachAttribNumber(csOp.attribs, function(n) {
+ attribKeys.push(pool.getAttribKey(n));
+ attribValues.push(pool.getAttribValue(n));
+ });
+ var undoBackToAttribs = cachedStrFunc(function(attribs) {
+ var backAttribs = [];
+ for(var i=0;i<attribKeys.length;i++) {
+ var appliedKey = attribKeys[i];
+ var appliedValue = attribValues[i];
+ var oldValue = Changeset.attribsAttributeValue(attribs, appliedKey, pool);
+ if (appliedValue != oldValue) {
+ backAttribs.push([appliedKey, oldValue]);
+ }
+ }
+ return Changeset.makeAttribsString('=', backAttribs, pool);
+ });
+ consumeAttribRuns(csOp.chars, function(len, attribs, endsLine) {
+ builder.keep(len, endsLine ? 1 : 0, undoBackToAttribs(attribs));
+ });
+ }
+ else {
+ skip(csOp.chars, csOp.lines);
+ builder.keep(csOp.chars, csOp.lines);
+ }
+ }
+ else if (csOp.opcode == '+') {
+ builder.remove(csOp.chars, csOp.lines);
+ }
+ else if (csOp.opcode == '-') {
+ var textBank = nextText(csOp.chars);
+ var textBankIndex = 0;
+ consumeAttribRuns(csOp.chars, function(len, attribs, endsLine) {
+ builder.insert(textBank.substr(textBankIndex, len), attribs);
+ textBankIndex += len;
+ });
+ }
+ }
+
+ return Changeset.checkRep(builder.toString());
+};
+
+// %CLIENT FILE ENDS HERE%
+
+Changeset.follow = function(cs1, cs2, reverseInsertOrder, pool) {
+ var unpacked1 = Changeset.unpack(cs1);
+ var unpacked2 = Changeset.unpack(cs2);
+ var len1 = unpacked1.oldLen;
+ var len2 = unpacked2.oldLen;
+ Changeset.assert(len1 == len2, "mismatched follow");
+ var chars1 = Changeset.stringIterator(unpacked1.charBank);
+ var chars2 = Changeset.stringIterator(unpacked2.charBank);
+
+ var oldLen = unpacked1.newLen;
+ var oldPos = 0;
+ var newLen = 0;
+
+ var hasInsertFirst = Changeset.attributeTester(['insertorder','first'],
+ pool);
+
+ var newOps = Changeset.applyZip(unpacked1.ops, 0, unpacked2.ops, 0, function(op1, op2, opOut) {
+ if (op1.opcode == '+' || op2.opcode == '+') {
+ var whichToDo;
+ if (op2.opcode != '+') {
+ whichToDo = 1;
+ }
+ else if (op1.opcode != '+') {
+ whichToDo = 2;
+ }
+ else {
+ // both +
+ var firstChar1 = chars1.peek(1);
+ var firstChar2 = chars2.peek(1);
+ var insertFirst1 = hasInsertFirst(op1.attribs);
+ var insertFirst2 = hasInsertFirst(op2.attribs);
+ if (insertFirst1 && ! insertFirst2) {
+ whichToDo = 1;
+ }
+ else if (insertFirst2 && ! insertFirst1) {
+ whichToDo = 2;
+ }
+ // insert string that doesn't start with a newline first so as not to break up lines
+ else if (firstChar1 == '\n' && firstChar2 != '\n') {
+ whichToDo = 2;
+ }
+ else if (firstChar1 != '\n' && firstChar2 == '\n') {
+ whichToDo = 1;
+ }
+ // break symmetry:
+ else if (reverseInsertOrder) {
+ whichToDo = 2;
+ }
+ else {
+ whichToDo = 1;
+ }
+ }
+ if (whichToDo == 1) {
+ chars1.skip(op1.chars);
+ opOut.opcode = '=';
+ opOut.lines = op1.lines;
+ opOut.chars = op1.chars;
+ opOut.attribs = '';
+ op1.opcode = '';
+ }
+ else {
+ // whichToDo == 2
+ chars2.skip(op2.chars);
+ Changeset.copyOp(op2, opOut);
+ op2.opcode = '';
+ }
+ }
+ else if (op1.opcode == '-') {
+ if (! op2.opcode) {
+ op1.opcode = '';
+ }
+ else {
+ if (op1.chars <= op2.chars) {
+ op2.chars -= op1.chars;
+ op2.lines -= op1.lines;
+ op1.opcode = '';
+ if (! op2.chars) {
+ op2.opcode = '';
+ }
+ }
+ else {
+ op1.chars -= op2.chars;
+ op1.lines -= op2.lines;
+ op2.opcode = '';
+ }
+ }
+ }
+ else if (op2.opcode == '-') {
+ Changeset.copyOp(op2, opOut);
+ if (! op1.opcode) {
+ op2.opcode = '';
+ }
+ else if (op2.chars <= op1.chars) {
+ // delete part or all of a keep
+ op1.chars -= op2.chars;
+ op1.lines -= op2.lines;
+ op2.opcode = '';
+ if (! op1.chars) {
+ op1.opcode = '';
+ }
+ }
+ else {
+ // delete all of a keep, and keep going
+ opOut.lines = op1.lines;
+ opOut.chars = op1.chars;
+ op2.lines -= op1.lines;
+ op2.chars -= op1.chars;
+ op1.opcode = '';
+ }
+ }
+ else if (! op1.opcode) {
+ Changeset.copyOp(op2, opOut);
+ op2.opcode = '';
+ }
+ else if (! op2.opcode) {
+ Changeset.copyOp(op1, opOut);
+ op1.opcode = '';
+ }
+ else {
+ // both keeps
+ opOut.opcode = '=';
+ opOut.attribs = Changeset.followAttributes(op1.attribs, op2.attribs, pool);
+ if (op1.chars <= op2.chars) {
+ opOut.chars = op1.chars;
+ opOut.lines = op1.lines;
+ op2.chars -= op1.chars;
+ op2.lines -= op1.lines;
+ op1.opcode = '';
+ if (! op2.chars) {
+ op2.opcode = '';
+ }
+ }
+ else {
+ opOut.chars = op2.chars;
+ opOut.lines = op2.lines;
+ op1.chars -= op2.chars;
+ op1.lines -= op2.lines;
+ op2.opcode = '';
+ }
+ }
+ switch (opOut.opcode) {
+ case '=': oldPos += opOut.chars; newLen += opOut.chars; break;
+ case '-': oldPos += opOut.chars; break;
+ case '+': newLen += opOut.chars; break;
+ }
+ });
+ newLen += oldLen - oldPos;
+
+ return Changeset.pack(oldLen, newLen, newOps, unpacked2.charBank);
+};
+
+Changeset.followAttributes = function(att1, att2, pool) {
+ // The merge of two sets of attribute changes to the same text
+ // takes the lexically-earlier value if there are two values
+ // for the same key. Otherwise, all key/value changes from
+ // both attribute sets are taken. This operation is the "follow",
+ // so a set of changes is produced that can be applied to att1
+ // to produce the merged set.
+ if ((! att2) || (! pool)) return '';
+ if (! att1) return att2;
+ var atts = [];
+ att2.replace(/\*([0-9a-z]+)/g, function(_, a) {
+ atts.push(pool.getAttrib(Changeset.parseNum(a)));
+ return '';
+ });
+ att1.replace(/\*([0-9a-z]+)/g, function(_, a) {
+ var pair1 = pool.getAttrib(Changeset.parseNum(a));
+ for(var i=0;i<atts.length;i++) {
+ var pair2 = atts[i];
+ if (pair1[0] == pair2[0]) {
+ if (pair1[1] <= pair2[1]) {
+ // winner of merge is pair1, delete this attribute
+ atts.splice(i, 1);
+ }
+ break;
+ }
+ }
+ return '';
+ });
+ // we've only removed attributes, so they're already sorted
+ var buf = Changeset.stringAssembler();
+ for(var i=0;i<atts.length;i++) {
+ buf.append('*');
+ buf.append(Changeset.numToString(pool.putAttrib(atts[i])));
+ }
+ return buf.toString();
+};
diff --git a/trunk/etherpad/src/etherpad/collab/ace/easysync2_tests.js b/trunk/etherpad/src/etherpad/collab/ace/easysync2_tests.js
new file mode 100644
index 0000000..7a23dc0
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/collab/ace/easysync2_tests.js
@@ -0,0 +1,877 @@
+// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/easysync2_tests.js
+import("etherpad.collab.ace.easysync2.*")
+
+/**
+ * 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.
+ */
+
+function runTests() {
+
+ function print(str) {
+ java.lang.System.out.println(str);
+ }
+
+ function assert(code, optMsg) {
+ if (! eval(code)) throw new Error("FALSE: "+(optMsg || code));
+ }
+ function literal(v) {
+ if ((typeof v) == "string") {
+ return '"'+v.replace(/[\\\"]/g, '\\$1').replace(/\n/g, '\\n')+'"';
+ }
+ else return v.toSource();
+ }
+ function assertEqualArrays(a, b) {
+ assert(literal(a)+".toSource() == "+literal(b)+".toSource()");
+ }
+ function assertEqualStrings(a, b) {
+ assert(literal(a)+" == "+literal(b));
+ }
+
+ function throughIterator(opsStr) {
+ var iter = Changeset.opIterator(opsStr);
+ var assem = Changeset.opAssembler();
+ while (iter.hasNext()) {
+ assem.append(iter.next());
+ }
+ return assem.toString();
+ }
+
+ function throughSmartAssembler(opsStr) {
+ var iter = Changeset.opIterator(opsStr);
+ var assem = Changeset.smartOpAssembler();
+ while (iter.hasNext()) {
+ assem.append(iter.next());
+ }
+ assem.endDocument();
+ return assem.toString();
+ }
+
+ (function() {
+ print("> throughIterator");
+ var x = '-c*3*4+6|3=az*asdf0*1*2*3+1=1-1+1*0+1=1-1+1|c=c-1';
+ assert("throughIterator("+literal(x)+") == "+literal(x));
+ })();
+
+ (function() {
+ print("> throughSmartAssembler");
+ var x = '-c*3*4+6|3=az*asdf0*1*2*3+1=1-1+1*0+1=1-1+1|c=c-1';
+ assert("throughSmartAssembler("+literal(x)+") == "+literal(x));
+ })();
+
+ function applyMutations(mu, arrayOfArrays) {
+ arrayOfArrays.forEach(function (a) {
+ var result = mu[a[0]].apply(mu, a.slice(1));
+ if (a[0] == 'remove' && a[3]) {
+ assertEqualStrings(a[3], result);
+ }
+ });
+ }
+
+ function mutationsToChangeset(oldLen, arrayOfArrays) {
+ var assem = Changeset.smartOpAssembler();
+ var op = Changeset.newOp();
+ var bank = Changeset.stringAssembler();
+ var oldPos = 0;
+ var newLen = 0;
+ arrayOfArrays.forEach(function (a) {
+ if (a[0] == 'skip') {
+ op.opcode = '=';
+ op.chars = a[1];
+ op.lines = (a[2] || 0);
+ assem.append(op);
+ oldPos += op.chars;
+ newLen += op.chars;
+ }
+ else if (a[0] == 'remove') {
+ op.opcode = '-';
+ op.chars = a[1];
+ op.lines = (a[2] || 0);
+ assem.append(op);
+ oldPos += op.chars;
+ }
+ else if (a[0] == 'insert') {
+ op.opcode = '+';
+ bank.append(a[1]);
+ op.chars = a[1].length;
+ op.lines = (a[2] || 0);
+ assem.append(op);
+ newLen += op.chars;
+ }
+ });
+ newLen += oldLen - oldPos;
+ assem.endDocument();
+ return Changeset.pack(oldLen, newLen, assem.toString(),
+ bank.toString());
+ }
+
+ function runMutationTest(testId, origLines, muts, correct) {
+ print("> runMutationTest#"+testId);
+ var lines = origLines.slice();
+ var mu = Changeset.textLinesMutator(lines);
+ applyMutations(mu, muts);
+ mu.close();
+ assertEqualArrays(correct, lines);
+
+ var inText = origLines.join('');
+ var cs = mutationsToChangeset(inText.length, muts);
+ lines = origLines.slice();
+ Changeset.mutateTextLines(cs, lines);
+ assertEqualArrays(correct, lines);
+
+ var correctText = correct.join('');
+ //print(literal(cs));
+ var outText = Changeset.applyToText(cs, inText);
+ assertEqualStrings(correctText, outText);
+ }
+
+ runMutationTest(1, ["apple\n", "banana\n", "cabbage\n", "duffle\n", "eggplant\n"],
+ [['remove',1,0,"a"],['insert',"tu"],['remove',1,0,"p"],['skip',4,1],['skip',7,1],
+ ['insert',"cream\npie\n",2],['skip',2],['insert',"bot"],['insert',"\n",1],
+ ['insert',"bu"],['skip',3],['remove',3,1,"ge\n"],['remove',6,0,"duffle"]],
+ ["tuple\n","banana\n","cream\n","pie\n", "cabot\n","bubba\n","eggplant\n"]);
+
+ runMutationTest(2, ["apple\n", "banana\n", "cabbage\n", "duffle\n", "eggplant\n"],
+ [['remove',1,0,"a"],['remove',1,0,"p"],['insert',"tu"],['skip',11,2],
+ ['insert',"cream\npie\n",2],['skip',2],['insert',"bot"],['insert',"\n",1],
+ ['insert',"bu"],['skip',3],['remove',3,1,"ge\n"],['remove',6,0,"duffle"]],
+ ["tuple\n","banana\n","cream\n","pie\n", "cabot\n","bubba\n","eggplant\n"]);
+
+ runMutationTest(3, ["apple\n", "banana\n", "cabbage\n", "duffle\n", "eggplant\n"],
+ [['remove',6,1,"apple\n"],['skip',15,2],['skip',6],['remove',1,1,"\n"],
+ ['remove',8,0,"eggplant"],['skip',1,1]],
+ ["banana\n","cabbage\n","duffle\n"]);
+
+ runMutationTest(4, ["15\n"],
+ [['skip',1],['insert',"\n2\n3\n4\n",4],['skip',2,1]],
+ ["1\n","2\n","3\n","4\n","5\n"]);
+
+ runMutationTest(5, ["1\n","2\n","3\n","4\n","5\n"],
+ [['skip',1],['remove',7,4,"\n2\n3\n4\n"],['skip',2,1]],
+ ["15\n"]);
+
+ runMutationTest(6, ["123\n","abc\n","def\n","ghi\n","xyz\n"],
+ [['insert',"0"],['skip',4,1],['skip',4,1],['remove',8,2,"def\nghi\n"],['skip',4,1]],
+ ["0123\n", "abc\n", "xyz\n"]);
+
+ runMutationTest(7, ["apple\n", "banana\n", "cabbage\n", "duffle\n", "eggplant\n"],
+ [['remove',6,1,"apple\n"],['skip',15,2,true],['skip',6,0,true],['remove',1,1,"\n"],
+ ['remove',8,0,"eggplant"],['skip',1,1,true]],
+ ["banana\n","cabbage\n","duffle\n"]);
+
+ function poolOrArray(attribs) {
+ if (attribs.getAttrib) {
+ return attribs; // it's already an attrib pool
+ }
+ else {
+ // assume it's an array of attrib strings to be split and added
+ var p = new AttribPool();
+ attribs.forEach(function (kv) { p.putAttrib(kv.split(',')); });
+ return p;
+ }
+ }
+
+ function runApplyToAttributionTest(testId, attribs, cs, inAttr, outCorrect) {
+ print("> applyToAttribution#"+testId);
+ var p = poolOrArray(attribs);
+ var result = Changeset.applyToAttribution(
+ Changeset.checkRep(cs), inAttr, p);
+ assertEqualStrings(outCorrect, result);
+ }
+
+ // turn c<b>a</b>ctus\n into a<b>c</b>tusabcd\n
+ runApplyToAttributionTest(1, ['bold,', 'bold,true'],
+ "Z:7>3-1*0=1*1=1=3+4$abcd",
+ "+1*1+1|1+5", "+1*1+1|1+8");
+
+ // turn "david\ngreenspan\n" into "<b>david\ngreen</b>\n"
+ runApplyToAttributionTest(2, ['bold,', 'bold,true'],
+ "Z:g<4*1|1=6*1=5-4$",
+ "|2+g", "*1|1+6*1+5|1+1");
+
+ (function() {
+ print("> mutatorHasMore");
+ var lines = ["1\n", "2\n", "3\n", "4\n"];
+ var mu;
+
+ mu = Changeset.textLinesMutator(lines);
+ assert(mu.hasMore()+' == true');
+ mu.skip(8,4);
+ assert(mu.hasMore()+' == false');
+ mu.close();
+ assert(mu.hasMore()+' == false');
+
+ // still 1,2,3,4
+ mu = Changeset.textLinesMutator(lines);
+ assert(mu.hasMore()+' == true');
+ mu.remove(2,1);
+ assert(mu.hasMore()+' == true');
+ mu.skip(2,1);
+ assert(mu.hasMore()+' == true');
+ mu.skip(2,1);
+ assert(mu.hasMore()+' == true');
+ mu.skip(2,1);
+ assert(mu.hasMore()+' == false');
+ mu.insert("5\n", 1);
+ assert(mu.hasMore()+' == false');
+ mu.close();
+ assert(mu.hasMore()+' == false');
+
+ // 2,3,4,5 now
+ mu = Changeset.textLinesMutator(lines);
+ assert(mu.hasMore()+' == true');
+ mu.remove(6,3);
+ assert(mu.hasMore()+' == true');
+ mu.remove(2,1);
+ assert(mu.hasMore()+' == false');
+ mu.insert("hello\n", 1);
+ assert(mu.hasMore()+' == false');
+ mu.close();
+ assert(mu.hasMore()+' == false');
+
+ })();
+
+ function runMutateAttributionTest(testId, attribs, cs, alines, outCorrect) {
+ print("> runMutateAttributionTest#"+testId);
+ var p = poolOrArray(attribs);
+ var alines2 = Array.prototype.slice.call(alines);
+ var result = Changeset.mutateAttributionLines(
+ Changeset.checkRep(cs), alines2, p);
+ assertEqualArrays(outCorrect, alines2);
+
+ print("> runMutateAttributionTest#"+testId+".applyToAttribution");
+ function removeQuestionMarks(a) { return a.replace(/\?/g, ''); }
+ var inMerged = Changeset.joinAttributionLines(alines.map(removeQuestionMarks));
+ var correctMerged = Changeset.joinAttributionLines(outCorrect.map(removeQuestionMarks));
+ var mergedResult = Changeset.applyToAttribution(cs, inMerged, p);
+ assertEqualStrings(correctMerged, mergedResult);
+ }
+
+ // turn 123\n 456\n 789\n into 123\n 4<b>5</b>6\n 789\n
+ runMutateAttributionTest(1, ["bold,true"], "Z:c>0|1=4=1*0=1$", ["|1+4", "|1+4", "|1+4"],
+ ["|1+4", "+1*0+1|1+2", "|1+4"]);
+
+ // make a document bold
+ runMutateAttributionTest(2, ["bold,true"], "Z:c>0*0|3=c$", ["|1+4", "|1+4", "|1+4"],
+ ["*0|1+4", "*0|1+4", "*0|1+4"]);
+
+ // clear bold on document
+ runMutateAttributionTest(3, ["bold,","bold,true"], "Z:c>0*0|3=c$",
+ ["*1+1+1*1+1|1+1", "+1*1+1|1+2", "*1+1+1*1+1|1+1"],
+ ["|1+4", "|1+4", "|1+4"]);
+
+ // add a character on line 3 of a document with 5 blank lines, and make sure
+ // the optimization that skips purely-kept lines is working; if any attribution string
+ // with a '?' is parsed it will cause an error.
+ runMutateAttributionTest(4, ['foo,bar','line,1','line,2','line,3','line,4','line,5'],
+ "Z:5>1|2=2+1$x",
+ ["?*1|1+1", "?*2|1+1", "*3|1+1", "?*4|1+1", "?*5|1+1"],
+ ["?*1|1+1", "?*2|1+1", "+1*3|1+1", "?*4|1+1", "?*5|1+1"]);
+
+ var testPoolWithChars = (function() {
+ var p = new AttribPool();
+ p.putAttrib(['char','newline']);
+ for(var i=1;i<36;i++) {
+ p.putAttrib(['char',Changeset.numToString(i)]);
+ }
+ p.putAttrib(['char','']);
+ return p;
+ })();
+
+ // based on runMutationTest#1
+ runMutateAttributionTest(5, testPoolWithChars,
+ "Z:11>7-2*t+1*u+1|2=b|2+a=2*b+1*o+1*t+1*0|1+1*b+1*u+1=3|1-3-6$"+
+ "tucream\npie\nbot\nbu",
+ ["*a+1*p+2*l+1*e+1*0|1+1",
+ "*b+1*a+1*n+1*a+1*n+1*a+1*0|1+1",
+ "*c+1*a+1*b+2*a+1*g+1*e+1*0|1+1",
+ "*d+1*u+1*f+2*l+1*e+1*0|1+1",
+ "*e+1*g+2*p+1*l+1*a+1*n+1*t+1*0|1+1"],
+ ["*t+1*u+1*p+1*l+1*e+1*0|1+1",
+ "*b+1*a+1*n+1*a+1*n+1*a+1*0|1+1",
+ "|1+6",
+ "|1+4",
+ "*c+1*a+1*b+1*o+1*t+1*0|1+1",
+ "*b+1*u+1*b+2*a+1*0|1+1",
+ "*e+1*g+2*p+1*l+1*a+1*n+1*t+1*0|1+1"]);
+
+ // based on runMutationTest#3
+ runMutateAttributionTest(6, testPoolWithChars,
+ "Z:11<f|1-6|2=f=6|1-1-8$",
+ ["*a|1+6", "*b|1+7", "*c|1+8", "*d|1+7", "*e|1+9"],
+ ["*b|1+7", "*c|1+8", "*d+6*e|1+1"]);
+
+ // based on runMutationTest#4
+ runMutateAttributionTest(7, testPoolWithChars,
+ "Z:3>7=1|4+7$\n2\n3\n4\n",
+ ["*1+1*5|1+2"],
+ ["*1+1|1+1","|1+2","|1+2","|1+2","*5|1+2"]);
+
+ // based on runMutationTest#5
+ runMutateAttributionTest(8, testPoolWithChars,
+ "Z:a<7=1|4-7$",
+ ["*1|1+2","*2|1+2","*3|1+2","*4|1+2","*5|1+2"],
+ ["*1+1*5|1+2"]);
+
+ // based on runMutationTest#6
+ runMutateAttributionTest(9, testPoolWithChars,
+ "Z:k<7*0+1*10|2=8|2-8$0",
+ ["*1+1*2+1*3+1|1+1","*a+1*b+1*c+1|1+1",
+ "*d+1*e+1*f+1|1+1","*g+1*h+1*i+1|1+1","?*x+1*y+1*z+1|1+1"],
+ ["*0+1|1+4", "|1+4", "?*x+1*y+1*z+1|1+1"]);
+
+ runMutateAttributionTest(10, testPoolWithChars,
+ "Z:6>4=1+1=1+1|1=1+1=1*0+1$abcd",
+ ["|1+3", "|1+3"],
+ ["|1+5", "+2*0+1|1+2"]);
+
+
+ runMutateAttributionTest(11, testPoolWithChars,
+ "Z:s>1|1=4=6|1+1$\n",
+ ["*0|1+4", "*0|1+8", "*0+5|1+1", "*0|1+1", "*0|1+5", "*0|1+1", "*0|1+1", "*0|1+1", "|1+1"],
+ ["*0|1+4", "*0+6|1+1", "*0|1+2", "*0+5|1+1", "*0|1+1", "*0|1+5", "*0|1+1", "*0|1+1", "*0|1+1", "|1+1"]);
+
+ function randomInlineString(len, rand) {
+ var assem = Changeset.stringAssembler();
+ for(var i=0;i<len;i++) {
+ assem.append(String.fromCharCode(rand.nextInt(26) + 97));
+ }
+ return assem.toString();
+ }
+
+ function randomMultiline(approxMaxLines, approxMaxCols, rand) {
+ var numParts = rand.nextInt(approxMaxLines*2)+1;
+ var txt = Changeset.stringAssembler();
+ txt.append(rand.nextInt(2) ? '\n' : '');
+ for(var i=0;i<numParts;i++) {
+ if ((i % 2) == 0) {
+ if (rand.nextInt(10)) {
+ txt.append(randomInlineString(rand.nextInt(approxMaxCols)+1, rand));
+ }
+ else {
+ txt.append('\n');
+ }
+ }
+ else {
+ txt.append('\n');
+ }
+ }
+ return txt.toString();
+ }
+
+ function randomStringOperation(numCharsLeft, rand) {
+ var result;
+ switch(rand.nextInt(9)) {
+ case 0: {
+ // insert char
+ result = {insert: randomInlineString(1, rand)};
+ break;
+ }
+ case 1: {
+ // delete char
+ result = {remove: 1};
+ break;
+ }
+ case 2: {
+ // skip char
+ result = {skip: 1};
+ break;
+ }
+ case 3: {
+ // insert small
+ result = {insert: randomInlineString(rand.nextInt(4)+1, rand)};
+ break;
+ }
+ case 4: {
+ // delete small
+ result = {remove: rand.nextInt(4)+1};
+ break;
+ }
+ case 5: {
+ // skip small
+ result = {skip: rand.nextInt(4)+1};
+ break;
+ }
+ case 6: {
+ // insert multiline;
+ result = {insert: randomMultiline(5, 20, rand)};
+ break;
+ }
+ case 7: {
+ // delete multiline
+ result = {remove: Math.round(numCharsLeft * rand.nextDouble() * rand.nextDouble()) };
+ break;
+ }
+ case 8: {
+ // skip multiline
+ result = {skip: Math.round(numCharsLeft * rand.nextDouble() * rand.nextDouble()) };
+ break;
+ }
+ case 9: {
+ // delete to end
+ result = {remove: numCharsLeft};
+ break;
+ }
+ case 10: {
+ // skip to end
+ result = {skip: numCharsLeft};
+ break;
+ }
+ }
+ var maxOrig = numCharsLeft - 1;
+ if ('remove' in result) {
+ result.remove = Math.min(result.remove, maxOrig);
+ }
+ else if ('skip' in result) {
+ result.skip = Math.min(result.skip, maxOrig);
+ }
+ return result;
+ }
+
+ function randomTwoPropAttribs(opcode, rand) {
+ // assumes attrib pool like ['apple,','apple,true','banana,','banana,true']
+ if (opcode == '-' || rand.nextInt(3)) {
+ return '';
+ }
+ else if (rand.nextInt(3)) {
+ if (opcode == '+' || rand.nextInt(2)) {
+ return '*'+Changeset.numToString(rand.nextInt(2)*2+1);
+ }
+ else {
+ return '*'+Changeset.numToString(rand.nextInt(2)*2);
+ }
+ }
+ else {
+ if (opcode == '+' || rand.nextInt(4) == 0) {
+ return '*1*3';
+ }
+ else {
+ return ['*0*2', '*0*3', '*1*2'][rand.nextInt(3)];
+ }
+ }
+ }
+
+ function randomTestChangeset(origText, rand, withAttribs) {
+ var charBank = Changeset.stringAssembler();
+ var textLeft = origText; // always keep final newline
+ var outTextAssem = Changeset.stringAssembler();
+ var opAssem = Changeset.smartOpAssembler();
+ var oldLen = origText.length;
+
+ var nextOp = Changeset.newOp();
+ function appendMultilineOp(opcode, txt) {
+ nextOp.opcode = opcode;
+ if (withAttribs) {
+ nextOp.attribs = randomTwoPropAttribs(opcode, rand);
+ }
+ txt.replace(/\n|[^\n]+/g, function (t) {
+ if (t == '\n') {
+ nextOp.chars = 1;
+ nextOp.lines = 1;
+ opAssem.append(nextOp);
+ }
+ else {
+ nextOp.chars = t.length;
+ nextOp.lines = 0;
+ opAssem.append(nextOp);
+ }
+ return '';
+ });
+ }
+
+ function doOp() {
+ var o = randomStringOperation(textLeft.length, rand);
+ if (o.insert) {
+ var txt = o.insert;
+ charBank.append(txt);
+ outTextAssem.append(txt);
+ appendMultilineOp('+', txt);
+ }
+ else if (o.skip) {
+ var txt = textLeft.substring(0, o.skip);
+ textLeft = textLeft.substring(o.skip);
+ outTextAssem.append(txt);
+ appendMultilineOp('=', txt);
+ }
+ else if (o.remove) {
+ var txt = textLeft.substring(0, o.remove);
+ textLeft = textLeft.substring(o.remove);
+ appendMultilineOp('-', txt);
+ }
+ }
+
+ while (textLeft.length > 1) doOp();
+ for(var i=0;i<5;i++) doOp(); // do some more (only insertions will happen)
+
+ var outText = outTextAssem.toString()+'\n';
+ opAssem.endDocument();
+ var cs = Changeset.pack(oldLen, outText.length, opAssem.toString(), charBank.toString());
+ Changeset.checkRep(cs);
+ return [cs, outText];
+ }
+
+ function testCompose(randomSeed) {
+ var rand = new java.util.Random(randomSeed);
+ print("> testCompose#"+randomSeed);
+
+ var p = new AttribPool();
+
+ var startText = randomMultiline(10, 20, rand)+'\n';
+
+ var x1 = randomTestChangeset(startText, rand);
+ var change1 = x1[0];
+ var text1 = x1[1];
+
+ var x2 = randomTestChangeset(text1, rand);
+ var change2 = x2[0];
+ var text2 = x2[1];
+
+ var x3 = randomTestChangeset(text2, rand);
+ var change3 = x3[0];
+ var text3 = x3[1];
+
+ //print(literal(Changeset.toBaseTen(startText)));
+ //print(literal(Changeset.toBaseTen(change1)));
+ //print(literal(Changeset.toBaseTen(change2)));
+ var change12 = Changeset.checkRep(Changeset.compose(change1, change2, p));
+ var change23 = Changeset.checkRep(Changeset.compose(change2, change3, p));
+ var change123 = Changeset.checkRep(Changeset.compose(change12, change3, p));
+ var change123a = Changeset.checkRep(Changeset.compose(change1, change23, p));
+ assertEqualStrings(change123, change123a);
+
+ assertEqualStrings(text2, Changeset.applyToText(change12, startText));
+ assertEqualStrings(text3, Changeset.applyToText(change23, text1));
+ assertEqualStrings(text3, Changeset.applyToText(change123, startText));
+ }
+
+ for(var i=0;i<30;i++) testCompose(i);
+
+ (function simpleComposeAttributesTest() {
+ print("> simpleComposeAttributesTest");
+ var p = new AttribPool();
+ p.putAttrib(['bold','']);
+ p.putAttrib(['bold','true']);
+ var cs1 = Changeset.checkRep("Z:2>1*1+1*1=1$x");
+ var cs2 = Changeset.checkRep("Z:3>0*0|1=3$");
+ var cs12 = Changeset.checkRep(Changeset.compose(cs1, cs2, p));
+ assertEqualStrings("Z:2>1+1*0|1=2$x", cs12);
+ })();
+
+ (function followAttributesTest() {
+ var p = new AttribPool();
+ p.putAttrib(['x','']);
+ p.putAttrib(['x','abc']);
+ p.putAttrib(['x','def']);
+ p.putAttrib(['y','']);
+ p.putAttrib(['y','abc']);
+ p.putAttrib(['y','def']);
+
+ function testFollow(a, b, afb, bfa, merge) {
+ assertEqualStrings(afb, Changeset.followAttributes(a, b, p));
+ assertEqualStrings(bfa, Changeset.followAttributes(b, a, p));
+ assertEqualStrings(merge, Changeset.composeAttributes(a, afb, true, p));
+ assertEqualStrings(merge, Changeset.composeAttributes(b, bfa, true, p));
+ }
+
+ testFollow('', '', '', '', '');
+ testFollow('*0', '', '', '*0', '*0');
+ testFollow('*0', '*0', '', '', '*0');
+ testFollow('*0', '*1', '', '*0', '*0');
+ testFollow('*1', '*2', '', '*1', '*1');
+ testFollow('*0*1', '', '', '*0*1', '*0*1');
+ testFollow('*0*4', '*2*3', '*3', '*0', '*0*3');
+ testFollow('*0*4', '*2', '', '*0*4', '*0*4');
+ })();
+
+ function testFollow(randomSeed) {
+ var rand = new java.util.Random(randomSeed + 1000);
+ print("> testFollow#"+randomSeed);
+
+ var p = new AttribPool();
+
+ var startText = randomMultiline(10, 20, rand)+'\n';
+
+ var cs1 = randomTestChangeset(startText, rand)[0];
+ var cs2 = randomTestChangeset(startText, rand)[0];
+
+ var afb = Changeset.checkRep(Changeset.follow(cs1, cs2, false, p));
+ var bfa = Changeset.checkRep(Changeset.follow(cs2, cs1, true, p));
+
+ var merge1 = Changeset.checkRep(Changeset.compose(cs1, afb));
+ var merge2 = Changeset.checkRep(Changeset.compose(cs2, bfa));
+
+ assertEqualStrings(merge1, merge2);
+ }
+
+ for(var i=0;i<30;i++) testFollow(i);
+
+ function testSplitJoinAttributionLines(randomSeed) {
+ var rand = new java.util.Random(randomSeed + 2000);
+ print("> testSplitJoinAttributionLines#"+randomSeed);
+
+ var doc = randomMultiline(10, 20, rand)+'\n';
+
+ function stringToOps(str) {
+ var assem = Changeset.mergingOpAssembler();
+ var o = Changeset.newOp('+');
+ o.chars = 1;
+ for(var i=0;i<str.length;i++) {
+ var c = str.charAt(i);
+ o.lines = (c == '\n' ? 1 : 0);
+ o.attribs = (c == 'a' || c == 'b' ? '*'+c : '');
+ assem.append(o);
+ }
+ return assem.toString();
+ }
+
+ var theJoined = stringToOps(doc);
+ var theSplit = doc.match(/[^\n]*\n/g).map(stringToOps);
+
+ assertEqualArrays(theSplit, Changeset.splitAttributionLines(theJoined, doc));
+ assertEqualStrings(theJoined, Changeset.joinAttributionLines(theSplit));
+ }
+
+ for(var i=0;i<10;i++) testSplitJoinAttributionLines(i);
+
+ (function testMoveOpsToNewPool() {
+ print("> testMoveOpsToNewPool");
+
+ var pool1 = new AttribPool();
+ var pool2 = new AttribPool();
+
+ pool1.putAttrib(['baz','qux']);
+ pool1.putAttrib(['foo','bar']);
+
+ pool2.putAttrib(['foo','bar']);
+
+ assertEqualStrings(Changeset.moveOpsToNewPool('Z:1>2*1+1*0+1$ab', pool1, pool2), 'Z:1>2*0+1*1+1$ab');
+ assertEqualStrings(Changeset.moveOpsToNewPool('*1+1*0+1', pool1, pool2), '*0+1*1+1');
+ })();
+
+
+ (function testMakeSplice() {
+ print("> testMakeSplice");
+
+ var t = "a\nb\nc\n";
+ var t2 = Changeset.applyToText(Changeset.makeSplice(t, 5, 0, "def"), t);
+ assertEqualStrings("a\nb\ncdef\n", t2);
+
+ })();
+
+ (function testToSplices() {
+ print("> testToSplices");
+
+ var cs = Changeset.checkRep('Z:z>9*0=1=4-3+9=1|1-4-4+1*0+a$123456789abcdefghijk');
+ var correctSplices = [[5, 8, "123456789"], [9, 17, "abcdefghijk"]];
+ assertEqualArrays(correctSplices, Changeset.toSplices(cs));
+ })();
+
+ function testCharacterRangeFollow(testId, cs, oldRange, insertionsAfter, correctNewRange) {
+ print("> testCharacterRangeFollow#"+testId);
+
+ var cs = Changeset.checkRep(cs);
+ assertEqualArrays(correctNewRange, Changeset.characterRangeFollow(cs, oldRange[0], oldRange[1],
+ insertionsAfter));
+
+ }
+
+ testCharacterRangeFollow(1, 'Z:z>9*0=1=4-3+9=1|1-4-4+1*0+a$123456789abcdefghijk',
+ [7, 10], false, [14, 15]);
+ testCharacterRangeFollow(2, "Z:bc<6|x=b4|2-6$", [400, 407], false, [400, 401]);
+ testCharacterRangeFollow(3, "Z:4>0-3+3$abc", [0,3], false, [3,3]);
+ testCharacterRangeFollow(4, "Z:4>0-3+3$abc", [0,3], true, [0,0]);
+ testCharacterRangeFollow(5, "Z:5>1+1=1-3+3$abcd", [1,4], false, [5,5]);
+ testCharacterRangeFollow(6, "Z:5>1+1=1-3+3$abcd", [1,4], true, [2,2]);
+ testCharacterRangeFollow(7, "Z:5>1+1=1-3+3$abcd", [0,6], false, [1,7]);
+ testCharacterRangeFollow(8, "Z:5>1+1=1-3+3$abcd", [0,3], false, [1,2]);
+ testCharacterRangeFollow(9, "Z:5>1+1=1-3+3$abcd", [2,5], false, [5,6]);
+ testCharacterRangeFollow(10, "Z:2>1+1$a", [0,0], false, [1,1]);
+ testCharacterRangeFollow(11, "Z:2>1+1$a", [0,0], true, [0,0]);
+
+ (function testOpAttributeValue() {
+ print("> testOpAttributeValue");
+
+ var p = new AttribPool();
+ p.putAttrib(['name','david']);
+ p.putAttrib(['color','green']);
+
+ assertEqualStrings("david", Changeset.opAttributeValue(Changeset.stringOp('*0*1+1'), 'name', p));
+ assertEqualStrings("david", Changeset.opAttributeValue(Changeset.stringOp('*0+1'), 'name', p));
+ assertEqualStrings("", Changeset.opAttributeValue(Changeset.stringOp('*1+1'), 'name', p));
+ assertEqualStrings("", Changeset.opAttributeValue(Changeset.stringOp('+1'), 'name', p));
+ assertEqualStrings("green", Changeset.opAttributeValue(Changeset.stringOp('*0*1+1'), 'color', p));
+ assertEqualStrings("green", Changeset.opAttributeValue(Changeset.stringOp('*1+1'), 'color', p));
+ assertEqualStrings("", Changeset.opAttributeValue(Changeset.stringOp('*0+1'), 'color', p));
+ assertEqualStrings("", Changeset.opAttributeValue(Changeset.stringOp('+1'), 'color', p));
+ })();
+
+ function testAppendATextToAssembler(testId, atext, correctOps) {
+ print("> testAppendATextToAssembler#"+testId);
+
+ var assem = Changeset.smartOpAssembler();
+ Changeset.appendATextToAssembler(atext, assem);
+ assertEqualStrings(correctOps, assem.toString());
+ }
+
+ testAppendATextToAssembler(1, {text:"\n", attribs:"|1+1"}, "");
+ testAppendATextToAssembler(2, {text:"\n\n", attribs:"|2+2"}, "|1+1");
+ testAppendATextToAssembler(3, {text:"\n\n", attribs:"*x|2+2"}, "*x|1+1");
+ testAppendATextToAssembler(4, {text:"\n\n", attribs:"*x|1+1|1+1"}, "*x|1+1");
+ testAppendATextToAssembler(5, {text:"foo\n", attribs:"|1+4"}, "+3");
+ testAppendATextToAssembler(6, {text:"\nfoo\n", attribs:"|2+5"}, "|1+1+3");
+ testAppendATextToAssembler(7, {text:"\nfoo\n", attribs:"*x|2+5"}, "*x|1+1*x+3");
+ testAppendATextToAssembler(8, {text:"\n\n\nfoo\n", attribs:"|2+2*x|2+5"}, "|2+2*x|1+1*x+3");
+
+ function testMakeAttribsString(testId, pool, opcode, attribs, correctString) {
+ print("> testMakeAttribsString#"+testId);
+
+ var p = poolOrArray(pool);
+ var str = Changeset.makeAttribsString(opcode, attribs, p);
+ assertEqualStrings(correctString, str);
+ }
+
+ testMakeAttribsString(1, ['bold,'], '+', [['bold','']], '');
+ testMakeAttribsString(2, ['abc,def','bold,'], '=', [['bold','']], '*1');
+ testMakeAttribsString(3, ['abc,def','bold,true'], '+', [['abc','def'],['bold','true']], '*0*1');
+ testMakeAttribsString(4, ['abc,def','bold,true'], '+', [['bold','true'],['abc','def']], '*0*1');
+
+ function testSubattribution(testId, astr, start, end, correctOutput) {
+ print("> testSubattribution#"+testId);
+
+ var str = Changeset.subattribution(astr, start, end);
+ assertEqualStrings(correctOutput, str);
+ }
+
+ testSubattribution(1, "+1", 0, 0, "");
+ testSubattribution(2, "+1", 0, 1, "+1");
+ testSubattribution(3, "+1", 0, undefined, "+1");
+ testSubattribution(4, "|1+1", 0, 0, "");
+ testSubattribution(5, "|1+1", 0, 1, "|1+1");
+ testSubattribution(6, "|1+1", 0, undefined, "|1+1");
+ testSubattribution(7, "*0+1", 0, 0, "");
+ testSubattribution(8, "*0+1", 0, 1, "*0+1");
+ testSubattribution(9, "*0+1", 0, undefined, "*0+1");
+ testSubattribution(10, "*0|1+1", 0, 0, "");
+ testSubattribution(11, "*0|1+1", 0, 1, "*0|1+1");
+ testSubattribution(12, "*0|1+1", 0, undefined, "*0|1+1");
+ testSubattribution(13, "*0+2+1*1+3", 0, 1, "*0+1");
+ testSubattribution(14, "*0+2+1*1+3", 0, 2, "*0+2");
+ testSubattribution(15, "*0+2+1*1+3", 0, 3, "*0+2+1");
+ testSubattribution(16, "*0+2+1*1+3", 0, 4, "*0+2+1*1+1");
+ testSubattribution(17, "*0+2+1*1+3", 0, 5, "*0+2+1*1+2");
+ testSubattribution(18, "*0+2+1*1+3", 0, 6, "*0+2+1*1+3");
+ testSubattribution(19, "*0+2+1*1+3", 0, 7, "*0+2+1*1+3");
+ testSubattribution(20, "*0+2+1*1+3", 0, undefined, "*0+2+1*1+3");
+ testSubattribution(21, "*0+2+1*1+3", 1, undefined, "*0+1+1*1+3");
+ testSubattribution(22, "*0+2+1*1+3", 2, undefined, "+1*1+3");
+ testSubattribution(23, "*0+2+1*1+3", 3, undefined, "*1+3");
+ testSubattribution(24, "*0+2+1*1+3", 4, undefined, "*1+2");
+ testSubattribution(25, "*0+2+1*1+3", 5, undefined, "*1+1");
+ testSubattribution(26, "*0+2+1*1+3", 6, undefined, "");
+ testSubattribution(27, "*0+2+1*1|1+3", 0, 1, "*0+1");
+ testSubattribution(28, "*0+2+1*1|1+3", 0, 2, "*0+2");
+ testSubattribution(29, "*0+2+1*1|1+3", 0, 3, "*0+2+1");
+ testSubattribution(30, "*0+2+1*1|1+3", 0, 4, "*0+2+1*1+1");
+ testSubattribution(31, "*0+2+1*1|1+3", 0, 5, "*0+2+1*1+2");
+ testSubattribution(32, "*0+2+1*1|1+3", 0, 6, "*0+2+1*1|1+3");
+ testSubattribution(33, "*0+2+1*1|1+3", 0, 7, "*0+2+1*1|1+3");
+ testSubattribution(34, "*0+2+1*1|1+3", 0, undefined, "*0+2+1*1|1+3");
+ testSubattribution(35, "*0+2+1*1|1+3", 1, undefined, "*0+1+1*1|1+3");
+ testSubattribution(36, "*0+2+1*1|1+3", 2, undefined, "+1*1|1+3");
+ testSubattribution(37, "*0+2+1*1|1+3", 3, undefined, "*1|1+3");
+ testSubattribution(38, "*0+2+1*1|1+3", 4, undefined, "*1|1+2");
+ testSubattribution(39, "*0+2+1*1|1+3", 5, undefined, "*1|1+1");
+ testSubattribution(40, "*0+2+1*1|1+3", 1, 5, "*0+1+1*1+2");
+ testSubattribution(41, "*0+2+1*1|1+3", 2, 6, "+1*1|1+3");
+ testSubattribution(42, "*0+2+1*1+3", 2, 6, "+1*1+3");
+
+ function testFilterAttribNumbers(testId, cs, filter, correctOutput) {
+ print("> testFilterAttribNumbers#"+testId);
+
+ var str = Changeset.filterAttribNumbers(cs, filter);
+ assertEqualStrings(correctOutput, str);
+ }
+
+ testFilterAttribNumbers(1, "*0*1+1+2+3*1+4*2+5*0*2*1*b*c+6",
+ function(n) { return (n%2) == 0; },
+ "*0+1+2+3+4*2+5*0*2*c+6");
+ testFilterAttribNumbers(2, "*0*1+1+2+3*1+4*2+5*0*2*1*b*c+6",
+ function(n) { return (n%2) == 1; },
+ "*1+1+2+3*1+4+5*1*b+6");
+
+ function testInverse(testId, cs, lines, alines, pool, correctOutput) {
+ print("> testInverse#"+testId);
+
+ pool = poolOrArray(pool);
+ var str = Changeset.inverse(Changeset.checkRep(cs), lines, alines, pool);
+ assertEqualStrings(correctOutput, str);
+ }
+
+ // take "FFFFTTTTT" and apply "-FT--FFTT", the inverse of which is "--F--TT--"
+ testInverse(1, "Z:9>0=1*0=1*1=1=2*0=2*1|1=2$", null, ["+4*1+5"], ['bold,','bold,true'],
+ "Z:9>0=2*0=1=2*1=2$");
+
+ function testMutateTextLines(testId, cs, lines, correctLines) {
+ print("> testMutateTextLines#"+testId);
+
+ var a = lines.slice();
+ Changeset.mutateTextLines(cs, a);
+ assertEqualArrays(correctLines, a);
+ }
+
+ testMutateTextLines(1, "Z:4<1|1-2-1|1+1+1$\nc", ["a\n", "b\n"], ["\n", "c\n"]);
+ testMutateTextLines(2, "Z:4>0|1-2-1|2+3$\nc\n", ["a\n", "b\n"], ["\n", "c\n", "\n"]);
+
+ function testInverseRandom(randomSeed) {
+ var rand = new java.util.Random(randomSeed + 3000);
+ print("> testInverseRandom#"+randomSeed);
+
+ var p = poolOrArray(['apple,','apple,true','banana,','banana,true']);
+
+ var startText = randomMultiline(10, 20, rand)+'\n';
+ var alines = Changeset.splitAttributionLines(Changeset.makeAttribution(startText), startText);
+ var lines = startText.slice(0,-1).split('\n').map(function(s) { return s+'\n'; });
+
+ var stylifier = randomTestChangeset(startText, rand, true)[0];
+
+ //print(alines.join('\n'));
+ Changeset.mutateAttributionLines(stylifier, alines, p);
+ //print(stylifier);
+ //print(alines.join('\n'));
+ Changeset.mutateTextLines(stylifier, lines);
+
+ var changeset = randomTestChangeset(lines.join(''), rand, true)[0];
+ var inverseChangeset = Changeset.inverse(changeset, lines, alines, p);
+
+ var origLines = lines.slice();
+ var origALines = alines.slice();
+
+ Changeset.mutateTextLines(changeset, lines);
+ Changeset.mutateAttributionLines(changeset, alines, p);
+ //print(origALines.join('\n'));
+ //print(changeset);
+ //print(inverseChangeset);
+ //print(origLines.map(function(s) { return '1: '+s.slice(0,-1); }).join('\n'));
+ //print(lines.map(function(s) { return '2: '+s.slice(0,-1); }).join('\n'));
+ //print(alines.join('\n'));
+ Changeset.mutateTextLines(inverseChangeset, lines);
+ Changeset.mutateAttributionLines(inverseChangeset, alines, p);
+ //print(lines.map(function(s) { return '3: '+s.slice(0,-1); }).join('\n'));
+
+ assertEqualArrays(origLines, lines);
+ assertEqualArrays(origALines, alines);
+ }
+
+ for(var i=0;i<30;i++) testInverseRandom(i);
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/collab/ace/linestylefilter.js b/trunk/etherpad/src/etherpad/collab/ace/linestylefilter.js
new file mode 100644
index 0000000..c7f79a5
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/collab/ace/linestylefilter.js
@@ -0,0 +1,253 @@
+// DO NOT EDIT THIS FILE, edit infrastructure/ace/www/linestylefilter.js
+import("etherpad.collab.ace.easysync2.Changeset");
+
+/**
+ * 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.
+ */
+
+// requires: easysync2.Changeset
+
+var linestylefilter = {};
+
+linestylefilter.ATTRIB_CLASSES = {
+ 'bold':'tag:b',
+ 'italic':'tag:i',
+ 'underline':'tag:u',
+ 'strikethrough':'tag:s',
+ 'h1':'tag:h1',
+ 'h2':'tag:h2',
+ 'h3':'tag:h3',
+ 'h4':'tag:h4',
+ 'h5':'tag:h5',
+ 'h6':'tag:h6'
+};
+
+linestylefilter.getAuthorClassName = function(author) {
+ return "author-"+author.replace(/[^a-y0-9]/g, function(c) {
+ if (c == ".") return "-";
+ return 'z'+c.charCodeAt(0)+'z';
+ });
+};
+
+// lineLength is without newline; aline includes newline,
+// but may be falsy if lineLength == 0
+linestylefilter.getLineStyleFilter = function(lineLength, aline,
+ textAndClassFunc, apool) {
+
+ if (lineLength == 0) return textAndClassFunc;
+
+ var nextAfterAuthorColors = textAndClassFunc;
+
+ var authorColorFunc = (function() {
+ var lineEnd = lineLength;
+ var curIndex = 0;
+ var extraClasses;
+ var leftInAuthor;
+
+ function attribsToClasses(attribs) {
+ var classes = '';
+ Changeset.eachAttribNumber(attribs, function(n) {
+ var key = apool.getAttribKey(n);
+ if (key) {
+ var value = apool.getAttribValue(n);
+ if (value) {
+ if (key == 'author') {
+ classes += ' '+linestylefilter.getAuthorClassName(value);
+ }
+ else if (key == 'list') {
+ classes += ' list:'+value;
+ }
+ else if (linestylefilter.ATTRIB_CLASSES[key]) {
+ classes += ' '+linestylefilter.ATTRIB_CLASSES[key];
+ }
+ }
+ }
+ });
+ return classes.substring(1);
+ }
+
+ var attributionIter = Changeset.opIterator(aline);
+ var nextOp, nextOpClasses;
+ function goNextOp() {
+ nextOp = attributionIter.next();
+ nextOpClasses = (nextOp.opcode && attribsToClasses(nextOp.attribs));
+ }
+ goNextOp();
+ function nextClasses() {
+ if (curIndex < lineEnd) {
+ extraClasses = nextOpClasses;
+ leftInAuthor = nextOp.chars;
+ goNextOp();
+ while (nextOp.opcode && nextOpClasses == extraClasses) {
+ leftInAuthor += nextOp.chars;
+ goNextOp();
+ }
+ }
+ }
+ nextClasses();
+
+ return function(txt, cls) {
+ while (txt.length > 0) {
+ if (leftInAuthor <= 0) {
+ // prevent infinite loop if something funny's going on
+ return nextAfterAuthorColors(txt, cls);
+ }
+ var spanSize = txt.length;
+ if (spanSize > leftInAuthor) {
+ spanSize = leftInAuthor;
+ }
+ var curTxt = txt.substring(0, spanSize);
+ txt = txt.substring(spanSize);
+ nextAfterAuthorColors(curTxt, (cls&&cls+" ")+extraClasses);
+ curIndex += spanSize;
+ leftInAuthor -= spanSize;
+ if (leftInAuthor == 0) {
+ nextClasses();
+ }
+ }
+ };
+ })();
+ return authorColorFunc;
+};
+
+linestylefilter.getAtSignSplitterFilter = function(lineText,
+ textAndClassFunc) {
+ var at = /@/g;
+ at.lastIndex = 0;
+ var splitPoints = null;
+ var execResult;
+ while ((execResult = at.exec(lineText))) {
+ if (! splitPoints) {
+ splitPoints = [];
+ }
+ splitPoints.push(execResult.index);
+ }
+
+ if (! splitPoints) return textAndClassFunc;
+
+ return linestylefilter.textAndClassFuncSplitter(textAndClassFunc,
+ splitPoints);
+};
+
+linestylefilter.REGEX_WORDCHAR = /[\u0030-\u0039\u0041-\u005A\u0061-\u007A\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF\u0100-\u1FFF\u3040-\u9FFF\uF900-\uFDFF\uFE70-\uFEFE\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFDC]/;
+linestylefilter.REGEX_URLCHAR = new RegExp('('+/[-:@a-zA-Z0-9_.,~%+\/\\?=&#;()$]/.source+'|'+linestylefilter.REGEX_WORDCHAR.source+')');
+linestylefilter.REGEX_URL = new RegExp(/(?:(?:https?|s?ftp|ftps|file|smb|afp|nfs|(x-)?man|gopher|txmt):\/\/|mailto:)/.source+linestylefilter.REGEX_URLCHAR.source+'*(?![:.,;])'+linestylefilter.REGEX_URLCHAR.source, 'g');
+
+linestylefilter.getURLFilter = function(lineText, textAndClassFunc) {
+ linestylefilter.REGEX_URL.lastIndex = 0;
+ var urls = null;
+ var splitPoints = null;
+ var execResult;
+ while ((execResult = linestylefilter.REGEX_URL.exec(lineText))) {
+ if (! urls) {
+ urls = [];
+ splitPoints = [];
+ }
+ var startIndex = execResult.index;
+ var url = execResult[0];
+ urls.push([startIndex, url]);
+ splitPoints.push(startIndex, startIndex + url.length);
+ }
+
+ if (! urls) return textAndClassFunc;
+
+ function urlForIndex(idx) {
+ for(var k=0; k<urls.length; k++) {
+ var u = urls[k];
+ if (idx >= u[0] && idx < u[0]+u[1].length) {
+ return u[1];
+ }
+ }
+ return false;
+ }
+
+ var handleUrlsAfterSplit = (function() {
+ var curIndex = 0;
+ return function(txt, cls) {
+ var txtlen = txt.length;
+ var newCls = cls;
+ var url = urlForIndex(curIndex);
+ if (url) {
+ newCls += " url:"+url;
+ }
+ textAndClassFunc(txt, newCls);
+ curIndex += txtlen;
+ };
+ })();
+
+ return linestylefilter.textAndClassFuncSplitter(handleUrlsAfterSplit,
+ splitPoints);
+};
+
+linestylefilter.textAndClassFuncSplitter = function(func, splitPointsOpt) {
+ var nextPointIndex = 0;
+ var idx = 0;
+
+ // don't split at 0
+ while (splitPointsOpt &&
+ nextPointIndex < splitPointsOpt.length &&
+ splitPointsOpt[nextPointIndex] == 0) {
+ nextPointIndex++;
+ }
+
+ function spanHandler(txt, cls) {
+ if ((! splitPointsOpt) || nextPointIndex >= splitPointsOpt.length) {
+ func(txt, cls);
+ idx += txt.length;
+ }
+ else {
+ var splitPoints = splitPointsOpt;
+ var pointLocInSpan = splitPoints[nextPointIndex] - idx;
+ var txtlen = txt.length;
+ if (pointLocInSpan >= txtlen) {
+ func(txt, cls);
+ idx += txt.length;
+ if (pointLocInSpan == txtlen) {
+ nextPointIndex++;
+ }
+ }
+ else {
+ if (pointLocInSpan > 0) {
+ func(txt.substring(0, pointLocInSpan), cls);
+ idx += pointLocInSpan;
+ }
+ nextPointIndex++;
+ // recurse
+ spanHandler(txt.substring(pointLocInSpan), cls);
+ }
+ }
+ }
+ return spanHandler;
+};
+
+// domLineObj is like that returned by domline.createDomLine
+linestylefilter.populateDomLine = function(textLine, aline, apool,
+ domLineObj) {
+ // remove final newline from text if any
+ var text = textLine;
+ if (text.slice(-1) == '\n') {
+ text = text.substring(0, text.length-1);
+ }
+
+ function textAndClassFunc(tokenText, tokenClass) {
+ domLineObj.appendSpan(tokenText, tokenClass);
+ }
+
+ var func = textAndClassFunc;
+ func = linestylefilter.getURLFilter(text, func);
+ func = linestylefilter.getLineStyleFilter(text.length, aline,
+ func, apool);
+ func(text, '');
+};
diff --git a/trunk/etherpad/src/etherpad/collab/collab_server.js b/trunk/etherpad/src/etherpad/collab/collab_server.js
new file mode 100644
index 0000000..78c9921
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/collab/collab_server.js
@@ -0,0 +1,778 @@
+/**
+ * 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("comet");
+import("ejs");
+import("etherpad.collab.ace.easysync2.{AttribPool,Changeset}");
+import("etherpad.log");
+import("etherpad.pad.activepads");
+import("etherpad.pad.model");
+import("etherpad.pad.padutils");
+import("etherpad.pad.padusers");
+import("etherpad.pad.padevents");
+import("etherpad.pad.pad_security");
+import("etherpad.pro.pro_padmeta");
+import("fastJSON");
+import("fileutils.readFile");
+import("jsutils.{eachProperty,keys}");
+import("etherpad.collab.collabroom_server.*");
+import("etherpad.collab.readonly_server");
+jimport("java.util.concurrent.ConcurrentHashMap");
+
+var PADPAGE_ROOMTYPE = "padpage";
+
+function onStartup() {
+
+}
+
+function _padIdToRoom(padId) {
+ return "padpage/"+padId;
+}
+
+function _roomToPadId(roomName) {
+ return roomName.substring(roomName.indexOf("/")+1);
+}
+
+function removeFromMemory(pad) {
+ // notification so we can free stuff
+ if (getNumConnections(pad) == 0) {
+ var tempObj = pad.tempObj();
+ tempObj.revisionSockets = {};
+ }
+}
+
+function _getPadConnections(pad) {
+ return getRoomConnections(_padIdToRoom(pad.getId()));
+}
+
+function guestKnock(globalPadId, guestId, displayName) {
+ var askedSomeone = false;
+
+ // requires that we somehow have permission on this pad
+ model.accessPadGlobal(globalPadId, function(pad) {
+ var connections = _getPadConnections(pad);
+ connections.forEach(function(connection) {
+ // only send to pro users
+ if (! padusers.isGuest(connection.data.userInfo.userId)) {
+ askedSomeone = true;
+ var msg = { type: "SERVER_MESSAGE",
+ payload: { type: 'GUEST_PROMPT',
+ userId: guestId,
+ displayName: displayName } };
+ sendMessage(connection.connectionId, msg);
+ }
+ });
+ });
+
+ if (! askedSomeone) {
+ pad_security.answerKnock(guestId, globalPadId, "denied");
+ }
+}
+
+function _verifyUserId(userId) {
+ var result;
+ if (padusers.isGuest(userId)) {
+ // allow cookie-verified guest even if user has signed in
+ result = (userId == padusers.getGuestUserId());
+ }
+ else {
+ result = (userId == padusers.getUserId());
+ }
+ return result;
+}
+
+function _checkChangesetAndPool(cs, pool) {
+ Changeset.checkRep(cs);
+ Changeset.eachAttribNumber(cs, function(n) {
+ if (! pool.getAttrib(n)) {
+ throw new Error("Attribute pool is missing attribute "+n+" for changeset "+cs);
+ }
+ });
+}
+
+function _doWarn(str) {
+ log.warn(appjet.executionId+": "+str);
+}
+
+function _doInfo(str) {
+ log.info(appjet.executionId+": "+str);
+}
+
+function _getPadRevisionSockets(pad) {
+ var revisionSockets = pad.tempObj().revisionSockets;
+ if (! revisionSockets) {
+ revisionSockets = {}; // rev# -> socket id
+ pad.tempObj().revisionSockets = revisionSockets;
+ }
+ return revisionSockets;
+}
+
+function applyUserChanges(pad, baseRev, changeset, optSocketId, optAuthor) {
+ // changeset must be already adapted to the server's apool
+
+ var apool = pad.pool();
+ var r = baseRev;
+ while (r < pad.getHeadRevisionNumber()) {
+ r++;
+ var c = pad.getRevisionChangeset(r);
+ changeset = Changeset.follow(c, changeset, false, apool);
+ }
+
+ var prevText = pad.text();
+ if (Changeset.oldLen(changeset) != prevText.length) {
+ _doWarn("Can't apply USER_CHANGES "+changeset+" to document of length "+
+ prevText.length);
+ return;
+ }
+
+ var thisAuthor = '';
+ if (optSocketId) {
+ var connectionId = getSocketConnectionId(optSocketId);
+ if (connectionId) {
+ var connection = getConnection(connectionId);
+ if (connection) {
+ thisAuthor = connection.data.userInfo.userId;
+ }
+ }
+ }
+ if (optAuthor) {
+ thisAuthor = optAuthor;
+ }
+
+ pad.appendRevision(changeset, thisAuthor);
+ var newRev = pad.getHeadRevisionNumber();
+ if (optSocketId) {
+ _getPadRevisionSockets(pad)[newRev] = optSocketId;
+ }
+
+ var correctionChangeset = _correctMarkersInPad(pad.atext(), pad.pool());
+ if (correctionChangeset) {
+ pad.appendRevision(correctionChangeset);
+ }
+
+ ///// make document end in blank line if it doesn't:
+ if (pad.text().lastIndexOf("\n\n") != pad.text().length-2) {
+ var nlChangeset = Changeset.makeSplice(
+ pad.text(), pad.text().length-1, 0, "\n");
+ pad.appendRevision(nlChangeset);
+ }
+
+ updatePadClients(pad);
+
+ activepads.touch(pad.getId());
+ padevents.onEditPad(pad, thisAuthor);
+}
+
+function updateClient(pad, connectionId) {
+ var conn = getConnection(connectionId);
+ if (! conn) {
+ return;
+ }
+ var lastRev = conn.data.lastRev;
+ var userId = conn.data.userInfo.userId;
+ var socketId = conn.socketId;
+ while (lastRev < pad.getHeadRevisionNumber()) {
+ var r = ++lastRev;
+ var author = pad.getRevisionAuthor(r);
+ var revisionSockets = _getPadRevisionSockets(pad);
+ if (revisionSockets[r] === socketId) {
+ sendMessage(connectionId, {type:"ACCEPT_COMMIT", newRev:r});
+ }
+ else {
+ var forWire = Changeset.prepareForWire(pad.getRevisionChangeset(r), pad.pool());
+ var msg = {type:"NEW_CHANGES", newRev:r,
+ changeset: forWire.translated,
+ apool: forWire.pool,
+ author: author};
+ sendMessage(connectionId, msg);
+ }
+ }
+ conn.data.lastRev = pad.getHeadRevisionNumber();
+ updateRoomConnectionData(connectionId, conn.data);
+}
+
+function updatePadClients(pad) {
+ _getPadConnections(pad).forEach(function(connection) {
+ updateClient(pad, connection.connectionId);
+ });
+
+ readonly_server.updatePadClients(pad);
+}
+
+function applyMissedChanges(pad, missedChanges) {
+ var userInfo = missedChanges.userInfo;
+ var baseRev = missedChanges.baseRev;
+ var committedChangeset = missedChanges.committedChangeset; // may be falsy
+ var furtherChangeset = missedChanges.furtherChangeset; // may be falsy
+ var apool = pad.pool();
+
+ if (! _verifyUserId(userInfo.userId)) {
+ return;
+ }
+
+ if (committedChangeset) {
+ var wireApool1 = (new AttribPool()).fromJsonable(missedChanges.committedChangesetAPool);
+ _checkChangesetAndPool(committedChangeset, wireApool1);
+ committedChangeset = pad.adoptChangesetAttribs(committedChangeset, wireApool1);
+ }
+ if (furtherChangeset) {
+ var wireApool2 = (new AttribPool()).fromJsonable(missedChanges.furtherChangesetAPool);
+ _checkChangesetAndPool(furtherChangeset, wireApool2);
+ furtherChangeset = pad.adoptChangesetAttribs(furtherChangeset, wireApool2);
+ }
+
+ var commitWasMissed = !! committedChangeset;
+ if (commitWasMissed) {
+ var commitSocketId = missedChanges.committedChangesetSocketId;
+ var revisionSockets = _getPadRevisionSockets(pad);
+ // was the commit really missed, or did the client just not hear back?
+ // look for later changeset by this socket
+ var r = baseRev;
+ while (r < pad.getHeadRevisionNumber()) {
+ r++;
+ var s = revisionSockets[r];
+ if (! s) {
+ // changes are too old, have to drop them.
+ return;
+ }
+ if (s == commitSocketId) {
+ commitWasMissed = false;
+ break;
+ }
+ }
+ }
+ if (! commitWasMissed) {
+ // commit already incorporated by the server
+ committedChangeset = null;
+ }
+
+ var changeset;
+ if (committedChangeset && furtherChangeset) {
+ changeset = Changeset.compose(committedChangeset, furtherChangeset, apool);
+ }
+ else {
+ changeset = (committedChangeset || furtherChangeset);
+ }
+
+ if (changeset) {
+ var author = userInfo.userId;
+
+ applyUserChanges(pad, baseRev, changeset, null, author);
+ }
+}
+
+function getAllPadsWithConnections() {
+ // returns array of global pad id strings
+ return getAllRoomsOfType(PADPAGE_ROOMTYPE).map(_roomToPadId);
+}
+
+function broadcastServerMessage(msgObj) {
+ var msg = {type: "SERVER_MESSAGE", payload: msgObj};
+ getAllRoomsOfType(PADPAGE_ROOMTYPE).forEach(function(roomName) {
+ getRoomConnections(roomName).forEach(function(connection) {
+ sendMessage(connection.connectionId, msg);
+ });
+ });
+}
+
+function appendPadText(pad, txt) {
+ txt = model.cleanText(txt);
+ var oldFullText = pad.text();
+ _applyChangesetToPad(pad, Changeset.makeSplice(oldFullText,
+ oldFullText.length-1, 0, txt));
+}
+
+function setPadText(pad, txt) {
+ txt = model.cleanText(txt);
+ var oldFullText = pad.text();
+ // replace text except for the existing final (virtual) newline
+ _applyChangesetToPad(pad, Changeset.makeSplice(oldFullText, 0,
+ oldFullText.length-1, txt));
+}
+
+function setPadAText(pad, atext) {
+ var oldFullText = pad.text();
+ var deletion = Changeset.makeSplice(oldFullText, 0, oldFullText.length-1, "");
+
+ var assem = Changeset.smartOpAssembler();
+ Changeset.appendATextToAssembler(atext, assem);
+ var charBank = atext.text.slice(0, -1);
+ var insertion = Changeset.checkRep(Changeset.pack(1, atext.text.length,
+ assem.toString(), charBank));
+
+ var cs = Changeset.compose(deletion, insertion, pad.pool());
+ Changeset.checkRep(cs);
+
+ _applyChangesetToPad(pad, cs);
+}
+
+function applyChangesetToPad(pad, changeset) {
+ Changeset.checkRep(changeset);
+
+ _applyChangesetToPad(pad, changeset);
+}
+
+function _applyChangesetToPad(pad, changeset) {
+ pad.appendRevision(changeset);
+ updatePadClients(pad);
+}
+
+function getHistoricalAuthorData(pad, author) {
+ var authorData = pad.getAuthorData(author);
+ if (authorData) {
+ var data = {};
+ if ((typeof authorData.colorId) == "number") {
+ data.colorId = authorData.colorId;
+ }
+ if (authorData.name) {
+ data.name = authorData.name;
+ }
+ else {
+ var uname = padusers.getNameForUserId(author);
+ if (uname) {
+ data.name = uname;
+ }
+ }
+ return data;
+ }
+ return null;
+}
+
+function buildHistoricalAuthorDataMapFromAText(pad, atext) {
+ var map = {};
+ pad.eachATextAuthor(atext, function(author, authorNum) {
+ var data = getHistoricalAuthorData(pad, author);
+ if (data) {
+ map[author] = data;
+ }
+ });
+ return map;
+}
+
+function buildHistoricalAuthorDataMapForPadHistory(pad) {
+ var map = {};
+ pad.pool().eachAttrib(function(key, value) {
+ if (key == 'author') {
+ var author = value;
+ var data = getHistoricalAuthorData(pad, author);
+ if (data) {
+ map[author] = data;
+ }
+ }
+ });
+ return map;
+}
+
+function getATextForWire(pad, optRev) {
+ var atext;
+ if ((optRev && ! isNaN(Number(optRev))) || (typeof optRev) == "number") {
+ atext = pad.getInternalRevisionAText(Number(optRev));
+ }
+ else {
+ atext = pad.atext();
+ }
+
+ var historicalAuthorData = buildHistoricalAuthorDataMapFromAText(pad, atext);
+
+ var attribsForWire = Changeset.prepareForWire(atext.attribs, pad.pool());
+ var apool = attribsForWire.pool;
+ // mutate atext (translate attribs for wire):
+ atext.attribs = attribsForWire.translated;
+
+ return {atext:atext, apool:apool.toJsonable(),
+ historicalAuthorData:historicalAuthorData };
+}
+
+function getCollabClientVars(pad) {
+ // construct object that is made available on the client
+ // as collab_client_vars
+
+ var forWire = getATextForWire(pad);
+
+ return {
+ initialAttributedText: forWire.atext,
+ rev: pad.getHeadRevisionNumber(),
+ padId: pad.getLocalId(),
+ globalPadId: pad.getId(),
+ historicalAuthorData: forWire.historicalAuthorData,
+ apool: forWire.apool,
+ clientIp: request.clientAddr,
+ clientAgent: request.headers["User-Agent"]
+ };
+}
+
+function getNumConnections(pad) {
+ return _getPadConnections(pad).length;
+}
+
+function getConnectedUsers(pad) {
+ var users = [];
+ _getPadConnections(pad).forEach(function(connection) {
+ users.push(connection.data.userInfo);
+ });
+ return users;
+}
+
+
+function bootAllUsersFromPad(pad, reason) {
+ return bootUsersFromPad(pad, reason);
+}
+
+function bootUsersFromPad(pad, reason, userInfoFilter) {
+ var connections = _getPadConnections(pad);
+ var bootedUserInfos = [];
+ connections.forEach(function(connection) {
+ if ((! userInfoFilter) || userInfoFilter(connection.data.userInfo)) {
+ bootedUserInfos.push(connection.data.userInfo);
+ bootConnection(connection.connectionId);
+ }
+ });
+ return bootedUserInfos;
+}
+
+function dumpStorageToString(pad) {
+ var lines = [];
+ var errors = [];
+ var head = pad.getHeadRevisionNumber();
+ try {
+ for(var i=0;i<=head;i++) {
+ lines.push("changeset "+i+" "+Changeset.toBaseTen(pad.getRevisionChangeset(i)));
+ }
+ }
+ catch (e) {
+ errors.push("!!!!! Error in changeset "+i+": "+e.message);
+ }
+ for(var i=0;i<=head;i++) {
+ lines.push("author "+i+" "+pad.getRevisionAuthor(i));
+ }
+ for(var i=0;i<=head;i++) {
+ lines.push("time "+i+" "+pad.getRevisionDate(i));
+ }
+ var revisionSockets = _getPadRevisionSockets(pad);
+ for(var k in revisionSockets) lines.push("socket "+k+" "+revisionSockets[k]);
+ return errors.concat(lines).join('\n');
+}
+
+function _getPadIdForSocket(socketId) {
+ var connectionId = getSocketConnectionId(socketId);
+ if (connectionId) {
+ var connection = getConnection(connectionId);
+ if (connection) {
+ return _roomToPadId(connection.roomName);
+ }
+ }
+ return null;
+}
+
+function _getUserIdForSocket(socketId) {
+ var connectionId = getSocketConnectionId(socketId);
+ if (connectionId) {
+ var connection = getConnection(connectionId);
+ if (connection) {
+ return connection.data.userInfo.userId;
+ }
+ }
+ return null;
+}
+
+function _serverDebug(msg) { /* nothing */ }
+
+function _accessSocketPad(socketId, accessType, padFunc, dontRequirePad) {
+ return _accessCollabPad(_getPadIdForSocket(socketId), accessType,
+ padFunc, dontRequirePad);
+}
+
+function _accessConnectionPad(connection, accessType, padFunc, dontRequirePad) {
+ return _accessCollabPad(_roomToPadId(connection.roomName), accessType,
+ padFunc, dontRequirePad);
+}
+
+function _accessCollabPad(padId, accessType, padFunc, dontRequirePad) {
+ if (! padId) {
+ if (! dontRequirePad) {
+ _doWarn("Collab operation \""+accessType+"\" aborted because socket "+socketId+" has no pad.");
+ }
+ return;
+ }
+ else {
+ return _accessExistingPad(padId, accessType, function(pad) {
+ return padFunc(pad);
+ }, dontRequirePad);
+ }
+}
+
+function _accessExistingPad(padId, accessType, padFunc, dontRequireExist) {
+ return model.accessPadGlobal(padId, function(pad) {
+ if (! pad.exists()) {
+ if (! dontRequireExist) {
+ _doWarn("Collab operation \""+accessType+"\" aborted because pad "+padId+" doesn't exist.");
+ }
+ return;
+ }
+ else {
+ return padFunc(pad);
+ }
+ });
+}
+
+function _handlePadUserInfo(pad, userInfo) {
+ var author = userInfo.userId;
+ var colorId = Number(userInfo.colorId);
+ var name = userInfo.name;
+
+ if (! author) return;
+
+ // update map from author to that author's last known color and name
+ var data = {colorId: colorId};
+ if (name) data.name = name;
+ pad.setAuthorData(author, data);
+ padusers.notifyUserData(data);
+}
+
+function _sendUserInfoMessage(connectionId, type, userInfo) {
+ if (translateSpecialKey(userInfo.specialKey) != 'invisible') {
+ sendMessage(connectionId, {type: type, userInfo: userInfo });
+ }
+}
+
+
+function getRoomCallbacks(roomName) {
+ var callbacks = {};
+ callbacks.introduceUsers =
+ function (joiningConnection, existingConnection) {
+ // notify users of each other
+ _sendUserInfoMessage(existingConnection.connectionId,
+ "USER_NEWINFO",
+ joiningConnection.data.userInfo);
+ _sendUserInfoMessage(joiningConnection.connectionId,
+ "USER_NEWINFO",
+ existingConnection.data.userInfo);
+ };
+ callbacks.extroduceUsers =
+ function (leavingConnection, existingConnection) {
+ _sendUserInfoMessage(existingConnection.connectionId, "USER_LEAVE",
+ leavingConnection.data.userInfo);
+ };
+ callbacks.onAddConnection =
+ function (data) {
+ model.accessPadGlobal(_roomToPadId(roomName), function(pad) {
+ _handlePadUserInfo(pad, data.userInfo);
+ padevents.onUserJoin(pad, data.userInfo);
+ readonly_server.updateUserInfo(pad, data.userInfo);
+ });
+ };
+ callbacks.onRemoveConnection =
+ function (data) {
+ model.accessPadGlobal(_roomToPadId(roomName), function(pad) {
+ padevents.onUserLeave(pad, data.userInfo);
+ });
+ };
+ callbacks.handleConnect =
+ function (data) {
+ if (roomName.indexOf("padpage/") != 0) {
+ return null;
+ }
+ if (! (data.userInfo && data.userInfo.userId &&
+ _verifyUserId(data.userInfo.userId))) {
+ return null;
+ }
+ return data.userInfo;
+ };
+ callbacks.clientReady =
+ function(newConnection, data) {
+ var padId = _roomToPadId(newConnection.roomName);
+
+ if (data.stats) {
+ log.custom("padclientstats", {padId:padId, stats:data.stats});
+ }
+
+ var lastRev = data.lastRev;
+ var isReconnectOf = data.isReconnectOf;
+ var isCommitPending = !! data.isCommitPending;
+ var connectionId = newConnection.connectionId;
+
+ newConnection.data.lastRev = lastRev;
+ updateRoomConnectionData(connectionId, newConnection.data);
+
+ if (padutils.isProPadId(padId)) {
+ pro_padmeta.accessProPad(padId, function(propad) {
+ // tell client about pad title
+ sendMessage(connectionId, {type: "CLIENT_MESSAGE", payload: {
+ type: "padtitle", title: propad.getDisplayTitle() } });
+ sendMessage(connectionId, {type: "CLIENT_MESSAGE", payload: {
+ type: "padpassword", password: propad.getPassword() } });
+ });
+ }
+
+ _accessExistingPad(padId, "CLIENT_READY", function(pad) {
+ sendMessage(connectionId, {type: "CLIENT_MESSAGE", payload: {
+ type: "padoptions", options: pad.getPadOptionsObj() } });
+
+ updateClient(pad, connectionId);
+
+ });
+
+ if (isCommitPending) {
+ // tell client that if it hasn't received an ACCEPT_COMMIT by now, it isn't coming.
+ sendMessage(connectionId, {type:"NO_COMMIT_PENDING"});
+ }
+ };
+ callbacks.handleMessage = function(connection, msg) {
+ _handleCometMessage(connection, msg);
+ };
+ return callbacks;
+}
+
+var _specialKeys = [['x375b', 'invisible']];
+
+function translateSpecialKey(specialKey) {
+ // code -> name
+ for(var i=0;i<_specialKeys.length;i++) {
+ if (_specialKeys[i][0] == specialKey) {
+ return _specialKeys[i][1];
+ }
+ }
+ return null;
+}
+
+function getSpecialKey(name) {
+ // name -> code
+ for(var i=0;i<_specialKeys.length;i++) {
+ if (_specialKeys[i][1] == name) {
+ return _specialKeys[i][0];
+ }
+ }
+ return null;
+}
+
+function _updateDocumentConnectionUserInfo(pad, socketId, userInfo) {
+ var connectionId = getSocketConnectionId(socketId);
+ if (connectionId) {
+ var updatingConnection = getConnection(connectionId);
+ updatingConnection.data.userInfo = userInfo;
+ updateRoomConnectionData(connectionId, updatingConnection.data);
+ _getPadConnections(pad).forEach(function(connection) {
+ if (connection.socketId != updatingConnection.socketId) {
+ _sendUserInfoMessage(connection.connectionId,
+ "USER_NEWINFO", userInfo);
+ }
+ });
+
+ _handlePadUserInfo(pad, userInfo);
+ padevents.onUserInfoChange(pad, userInfo);
+ readonly_server.updateUserInfo(pad, userInfo);
+ }
+}
+
+function _handleCometMessage(connection, msg) {
+
+ var socketUserId = connection.data.userInfo.userId;
+ if (! (socketUserId && _verifyUserId(socketUserId))) {
+ // user has signed out or cleared cookies, no longer auth'ed
+ bootConnection(connection.connectionId, "unauth");
+ }
+
+ if (msg.type == "USER_CHANGES") {
+ try {
+ _accessConnectionPad(connection, "USER_CHANGES", function(pad) {
+ var baseRev = msg.baseRev;
+ var wireApool = (new AttribPool()).fromJsonable(msg.apool);
+ var changeset = msg.changeset;
+ if (changeset) {
+ _checkChangesetAndPool(changeset, wireApool);
+ changeset = pad.adoptChangesetAttribs(changeset, wireApool);
+ applyUserChanges(pad, baseRev, changeset, connection.socketId);
+ }
+ });
+ }
+ catch (e if e.easysync) {
+ _doWarn("Changeset error handling USER_CHANGES: "+e);
+ }
+ }
+ else if (msg.type == "USERINFO_UPDATE") {
+ _accessConnectionPad(connection, "USERINFO_UPDATE", function(pad) {
+ var userInfo = msg.userInfo;
+ // security check
+ if (userInfo.userId == connection.data.userInfo.userId) {
+ _updateDocumentConnectionUserInfo(pad,
+ connection.socketId, userInfo);
+ }
+ else {
+ // drop on the floor
+ }
+ });
+ }
+ else if (msg.type == "CLIENT_MESSAGE") {
+ _accessConnectionPad(connection, "CLIENT_MESSAGE", function(pad) {
+ var payload = msg.payload;
+ if (payload.authId &&
+ payload.authId != connection.data.userInfo.userId) {
+ // authId, if present, must actually be the sender's userId;
+ // here it wasn't
+ }
+ else {
+ getRoomConnections(connection.roomName).forEach(
+ function(conn) {
+ if (conn.socketId != connection.socketId) {
+ sendMessage(conn.connectionId,
+ {type: "CLIENT_MESSAGE", payload: payload});
+ }
+ });
+ padevents.onClientMessage(pad, connection.data.userInfo,
+ payload);
+ }
+ });
+ }
+}
+
+function _correctMarkersInPad(atext, apool) {
+ var text = atext.text;
+
+ // collect char positions of line markers (e.g. bullets) in new atext
+ // that aren't at the start of a line
+ var badMarkers = [];
+ var iter = Changeset.opIterator(atext.attribs);
+ var offset = 0;
+ while (iter.hasNext()) {
+ var op = iter.next();
+ var listValue = Changeset.opAttributeValue(op, 'list', apool);
+ if (listValue) {
+ for(var i=0;i<op.chars;i++) {
+ if (offset > 0 && text.charAt(offset-1) != '\n') {
+ badMarkers.push(offset);
+ }
+ offset++;
+ }
+ }
+ else {
+ offset += op.chars;
+ }
+ }
+
+ if (badMarkers.length == 0) {
+ return null;
+ }
+
+ // create changeset that removes these bad markers
+ offset = 0;
+ var builder = Changeset.builder(text.length);
+ badMarkers.forEach(function(pos) {
+ builder.keepText(text.substring(offset, pos));
+ builder.remove(1);
+ offset = pos+1;
+ });
+ return builder.toString();
+}
diff --git a/trunk/etherpad/src/etherpad/collab/collabroom_server.js b/trunk/etherpad/src/etherpad/collab/collabroom_server.js
new file mode 100644
index 0000000..ab1f844
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/collab/collabroom_server.js
@@ -0,0 +1,359 @@
+/**
+ * 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("execution");
+import("comet");
+import("fastJSON");
+import("cache_utils.syncedWithCache");
+import("etherpad.collab.collab_server");
+import("etherpad.collab.readonly_server");
+import("etherpad.log");
+jimport("java.util.concurrent.ConcurrentSkipListMap");
+jimport("java.util.concurrent.CopyOnWriteArraySet");
+
+function onStartup() {
+ execution.initTaskThreadPool("collabroom_async", 1);
+}
+
+function _doWarn(str) {
+ log.warn(appjet.executionId+": "+str);
+}
+
+// deep-copies (recursively clones) an object (or value)
+function _deepCopy(obj) {
+ if ((typeof obj) != 'object' || !obj) {
+ return obj;
+ }
+ var o = {};
+ for(var k in obj) {
+ if (obj.hasOwnProperty(k)) {
+ var v = obj[k];
+ if ((typeof v) == 'object' && v) {
+ o[k] = _deepCopy(v);
+ }
+ else {
+ o[k] = v;
+ }
+ }
+ }
+ return o;
+}
+
+// calls func inside a global lock on the cache
+function _withCache(func) {
+ return syncedWithCache("collabroom_server", function(cache) {
+ if (! cache.rooms) {
+ // roomName -> { connections: CopyOnWriteArraySet<connectionId>,
+ // type: <immutable type string> }
+ cache.rooms = new ConcurrentSkipListMap();
+ }
+ if (! cache.allConnections) {
+ // connectionId -> connection object
+ cache.allConnections = new ConcurrentSkipListMap();
+ }
+ return func(cache);
+ });
+}
+
+// accesses cache without lock
+function _getCache() {
+ return _withCache(function(cache) { return cache; });
+}
+
+// if roomType is null, will only update an existing connection
+// (otherwise will insert or update as appropriate)
+function _putConnection(connection, roomType) {
+ var roomName = connection.roomName;
+ var connectionId = connection.connectionId;
+ var socketId = connection.socketId;
+ var data = connection.data;
+
+ _withCache(function(cache) {
+ var rooms = cache.rooms;
+ if (! rooms.containsKey(roomName)) {
+ // connection refers to room that doesn't exist / is empty
+ if (roomType) {
+ rooms.put(roomName, {connections: new CopyOnWriteArraySet(),
+ type: roomType});
+ }
+ else {
+ return;
+ }
+ }
+ if (roomType) {
+ rooms.get(roomName).connections.add(connectionId);
+ cache.allConnections.put(connectionId, connection);
+ }
+ else {
+ cache.allConnections.replace(connectionId, connection);
+ }
+ });
+}
+
+function _removeConnection(connection) {
+ _withCache(function(cache) {
+ var rooms = cache.rooms;
+ var thisRoom = connection.roomName;
+ var thisConnectionId = connection.connectionId;
+ if (rooms.containsKey(thisRoom)) {
+ var roomConnections = rooms.get(thisRoom).connections;
+ roomConnections.remove(thisConnectionId);
+ if (roomConnections.isEmpty()) {
+ rooms.remove(thisRoom);
+ }
+ }
+ cache.allConnections.remove(thisConnectionId);
+ });
+}
+
+function _getConnection(connectionId) {
+ // return a copy of the connection object
+ return _deepCopy(_getCache().allConnections.get(connectionId) || null);
+}
+
+function _getConnections(roomName) {
+ var array = [];
+
+ var roomObj = _getCache().rooms.get(roomName);
+ if (roomObj) {
+ var roomConnections = roomObj.connections;
+ var iter = roomConnections.iterator();
+ while (iter.hasNext()) {
+ var cid = iter.next();
+ var conn = _getConnection(cid);
+ if (conn) {
+ array.push(conn);
+ }
+ }
+ }
+ return array;
+}
+
+function sendMessage(connectionId, msg) {
+ var connection = _getConnection(connectionId);
+ if (connection) {
+ _sendMessageToSocket(connection.socketId, msg);
+ if (! comet.isConnected(connection.socketId)) {
+ // defunct socket, disconnect (later)
+ execution.scheduleTask("collabroom_async",
+ "collabRoomDisconnectSocket",
+ 0, [connection.connectionId,
+ connection.socketId]);
+ }
+ }
+}
+
+function _sendMessageToSocket(socketId, msg) {
+ var msgString = fastJSON.stringify({type: "COLLABROOM", data: msg});
+ comet.sendMessage(socketId, msgString);
+}
+
+function disconnectDefunctSocket(connectionId, socketId) {
+ var connection = _getConnection(connectionId);
+ if (connection && connection.socketId == socketId) {
+ removeRoomConnection(connectionId);
+ }
+}
+
+function _bootSocket(socketId, reason) {
+ if (reason) {
+ _sendMessageToSocket(socketId,
+ {type: "DISCONNECT_REASON", reason: reason});
+ }
+ comet.disconnect(socketId);
+}
+
+function bootConnection(connectionId, reason) {
+ var connection = _getConnection(connectionId);
+ if (connection) {
+ _bootSocket(connection.socketId, reason);
+ removeRoomConnection(connectionId);
+ }
+}
+
+function getCallbacksForRoom(roomName, roomType) {
+ if (! roomType) {
+ var room = _getCache().rooms.get(roomName);
+ if (room) {
+ roomType = room.type;
+ }
+ }
+
+ var emptyCallbacks = {};
+ emptyCallbacks.introduceUsers =
+ function (joiningConnection, existingConnection) {};
+ emptyCallbacks.extroduceUsers =
+ function extroduceUsers(leavingConnection, existingConnection) {};
+ emptyCallbacks.onAddConnection = function (joiningData) {};
+ emptyCallbacks.onRemoveConnection = function (leavingData) {};
+ emptyCallbacks.handleConnect =
+ function(data) { return /*userInfo or */null; };
+ emptyCallbacks.clientReady = function(newConnection, data) {};
+ emptyCallbacks.handleMessage = function(connection, msg) {};
+
+ if (roomType == collab_server.PADPAGE_ROOMTYPE) {
+ return collab_server.getRoomCallbacks(roomName, emptyCallbacks);
+ }
+ else if (roomType == readonly_server.PADVIEW_ROOMTYPE) {
+ return readonly_server.getRoomCallbacks(roomName, emptyCallbacks);
+ }
+ else {
+ //java.lang.System.out.println("UNKNOWN ROOMTYPE: "+roomType);
+ return emptyCallbacks;
+ }
+}
+
+// roomName must be globally unique, just within roomType;
+// data must have a userInfo.userId
+function addRoomConnection(roomName, roomType,
+ connectionId, socketId, data) {
+ var callbacks = getCallbacksForRoom(roomName, roomType);
+
+ comet.setAttribute(socketId, "connectionId", connectionId);
+
+ bootConnection(connectionId, "userdup");
+ var joiningConnection = {roomName:roomName,
+ connectionId:connectionId, socketId:socketId,
+ data:data};
+ _putConnection(joiningConnection, roomType);
+ var connections = _getConnections(roomName);
+ var joiningUser = data.userInfo.userId;
+
+ connections.forEach(function(connection) {
+ if (connection.socketId != socketId) {
+ var user = connection.data.userInfo.userId;
+ if (user == joiningUser) {
+ bootConnection(connection.connectionId, "userdup");
+ }
+ else {
+ callbacks.introduceUsers(joiningConnection, connection);
+ }
+ }
+ });
+
+ callbacks.onAddConnection(data);
+
+ return joiningConnection;
+}
+
+function removeRoomConnection(connectionId) {
+ var leavingConnection = _getConnection(connectionId);
+ if (leavingConnection) {
+ var roomName = leavingConnection.roomName;
+ var callbacks = getCallbacksForRoom(roomName);
+
+ _removeConnection(leavingConnection);
+
+ _getConnections(roomName).forEach(function (connection) {
+ callbacks.extroduceUsers(leavingConnection, connection);
+ });
+
+ callbacks.onRemoveConnection(leavingConnection.data);
+ }
+}
+
+function getConnection(connectionId) {
+ return _getConnection(connectionId);
+}
+
+function updateRoomConnectionData(connectionId, data) {
+ var connection = _getConnection(connectionId);
+ if (connection) {
+ connection.data = data;
+ _putConnection(connection);
+ }
+}
+
+function getRoomConnections(roomName) {
+ return _getConnections(roomName);
+}
+
+function getAllRoomsOfType(roomType) {
+ var rooms = _getCache().rooms;
+ var roomsIter = rooms.entrySet().iterator();
+ var array = [];
+ while (roomsIter.hasNext()) {
+ var entry = roomsIter.next();
+ var roomName = entry.getKey();
+ var roomStruct = entry.getValue();
+ if (roomStruct.type == roomType) {
+ array.push(roomName);
+ }
+ }
+ return array;
+}
+
+function getSocketConnectionId(socketId) {
+ var result = comet.getAttribute(socketId, "connectionId");
+ return result && String(result);
+}
+
+function handleComet(cometOp, cometId, msg) {
+ var cometEvent = cometOp;
+
+ function requireTruthy(x, id) {
+ if (!x) {
+ _doWarn("Collab operation rejected due to missing value, case "+id);
+ if (messageSocketId) {
+ comet.disconnect(messageSocketId);
+ }
+ response.stop();
+ }
+ return x;
+ }
+
+ if (cometEvent != "disconnect" && cometEvent != "message") {
+ response.stop();
+ }
+
+ var messageSocketId = requireTruthy(cometId, 2);
+ var messageConnectionId = getSocketConnectionId(messageSocketId);
+
+ if (cometEvent == "disconnect") {
+ if (messageConnectionId) {
+ removeRoomConnection(messageConnectionId);
+ }
+ }
+ else if (cometEvent == "message") {
+ if (msg.type == "CLIENT_READY") {
+ var roomType = requireTruthy(msg.roomType, 4);
+ var roomName = requireTruthy(msg.roomName, 11);
+
+ var socketId = messageSocketId;
+ var connectionId = messageSocketId;
+ var clientReadyData = requireTruthy(msg.data, 12);
+
+ var callbacks = getCallbacksForRoom(roomName, roomType);
+ var userInfo =
+ requireTruthy(callbacks.handleConnect(clientReadyData), 13);
+
+ var newConnection = addRoomConnection(roomName, roomType,
+ connectionId, socketId,
+ {userInfo: userInfo});
+
+ callbacks.clientReady(newConnection, clientReadyData);
+ }
+ else {
+ if (messageConnectionId) {
+ var connection = getConnection(messageConnectionId);
+ if (connection) {
+ var callbacks = getCallbacksForRoom(connection.roomName);
+ callbacks.handleMessage(connection, msg);
+ }
+ }
+ }
+ }
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/collab/genimg.js b/trunk/etherpad/src/etherpad/collab/genimg.js
new file mode 100644
index 0000000..04d1b3b
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/collab/genimg.js
@@ -0,0 +1,55 @@
+/**
+ * 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("sync");
+import("image");
+import("blob");
+
+//jimport("java.lang.System.out.println");
+
+function _cache() {
+ sync.callsyncIfTrue(appjet.cache,
+ function() { return ! appjet.cache["etherpad-genimg"]; },
+ function() { appjet.cache["etherpad-genimg"] = { paths: {}}; });
+ return appjet.cache["etherpad-genimg"];
+}
+
+function renderPath(path) {
+ if (_cache().paths[path]) {
+ //println("CACHE HIT");
+ }
+ else {
+ //println("CACHE MISS");
+ var regexResult = null;
+ var img = null;
+ if ((regexResult =
+ /solid\/([0-9]+)x([0-9]+)\/([0-9a-fA-F]{6})\.gif/.exec(path))) {
+ var width = Number(regexResult[1]);
+ var height = Number(regexResult[2]);
+ var color = regexResult[3];
+ img = image.solidColorImageBlob(width, height, color);
+ }
+ else {
+ // our "broken image" image, red and partly transparent
+ img = image.pixelsToImageBlob(2, 2, [0x00000000, 0xffff0000,
+ 0xffff0000, 0x00000000], true, "gif");
+ }
+ _cache().paths[path] = img;
+ }
+
+ blob.serveBlob(_cache().paths[path]);
+ return true;
+}
diff --git a/trunk/etherpad/src/etherpad/collab/json_sans_eval.js b/trunk/etherpad/src/etherpad/collab/json_sans_eval.js
new file mode 100644
index 0000000..6cbd497
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/collab/json_sans_eval.js
@@ -0,0 +1,178 @@
+// Copyright (C) 2008 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.
+
+/**
+ * Parses a string of well-formed JSON text.
+ *
+ * If the input is not well-formed, then behavior is undefined, but it is
+ * deterministic and is guaranteed not to modify any object other than its
+ * return value.
+ *
+ * This does not use `eval` so is less likely to have obscure security bugs than
+ * json2.js.
+ * It is optimized for speed, so is much faster than json_parse.js.
+ *
+ * This library should be used whenever security is a concern (when JSON may
+ * come from an untrusted source), speed is a concern, and erroring on malformed
+ * JSON is *not* a concern.
+ *
+ * Pros Cons
+ * +-----------------------+-----------------------+
+ * json_sans_eval.js | Fast, secure | Not validating |
+ * +-----------------------+-----------------------+
+ * json_parse.js | Validating, secure | Slow |
+ * +-----------------------+-----------------------+
+ * json2.js | Fast, some validation | Potentially insecure |
+ * +-----------------------+-----------------------+
+ *
+ * json2.js is very fast, but potentially insecure since it calls `eval` to
+ * parse JSON data, so an attacker might be able to supply strange JS that
+ * looks like JSON, but that executes arbitrary javascript.
+ * If you do have to use json2.js with untrusted data, make sure you keep
+ * your version of json2.js up to date so that you get patches as they're
+ * released.
+ *
+ * @param {string} json per RFC 4627
+ * @return {Object|Array}
+ * @author Mike Samuel <mikesamuel@gmail.com>
+ */
+var jsonParse = (function () {
+ var number
+ = '(?:-?\\b(?:0|[1-9][0-9]*)(?:\\.[0-9]+)?(?:[eE][+-]?[0-9]+)?\\b)';
+ var oneChar = '(?:[^\\0-\\x08\\x0a-\\x1f\"\\\\]'
+ + '|\\\\(?:[\"/\\\\bfnrt]|u[0-9A-Fa-f]{4}|x7c))';
+ var string = '(?:\"' + oneChar + '*\")';
+
+ // Will match a value in a well-formed JSON file.
+ // If the input is not well-formed, may match strangely, but not in an unsafe
+ // way.
+ // Since this only matches value tokens, it does not match whitespace, colons,
+ // or commas.
+ var jsonToken = new RegExp(
+ '(?:false|true|null|[\\{\\}\\[\\]]'
+ + '|' + number
+ + '|' + string
+ + ')', 'g');
+
+ // Matches escape sequences in a string literal
+ var escapeSequence = new RegExp('\\\\(?:([^ux]|x7c)|u(.{4}))', 'g');
+
+ // Decodes escape sequences in object literals
+ var escapes = {
+ '"': '"',
+ '/': '/',
+ '\\': '\\',
+ 'b': '\b',
+ 'f': '\f',
+ 'n': '\n',
+ 'r': '\r',
+ 't': '\t',
+ 'x7c': '|'
+ };
+ function unescapeOne(_, ch, hex) {
+ return ch ? escapes[ch] : String.fromCharCode(parseInt(hex, 16));
+ }
+
+ // A non-falsy value that coerces to the empty string when used as a key.
+ var EMPTY_STRING = new String('');
+ var SLASH = '\\';
+
+ // Constructor to use based on an open token.
+ var firstTokenCtors = { '{': Object, '[': Array };
+
+ return function (json) {
+ // Split into tokens
+ var toks = json.match(jsonToken);
+ // Construct the object to return
+ var result;
+ var tok = toks[0];
+ if ('{' === tok) {
+ result = {};
+ } else if ('[' === tok) {
+ result = [];
+ } else {
+ throw new Error(tok);
+ }
+
+ // If undefined, the key in an object key/value record to use for the next
+ // value parsed.
+ var key;
+ // Loop over remaining tokens maintaining a stack of uncompleted objects and
+ // arrays.
+ var stack = [result];
+ for (var i = 1, n = toks.length; i < n; ++i) {
+ tok = toks[i];
+
+ var cont;
+ switch (tok.charCodeAt(0)) {
+ default: // sign or digit
+ cont = stack[0];
+ cont[key || cont.length] = +(tok);
+ key = void 0;
+ break;
+ case 0x22: // '"'
+ tok = tok.substring(1, tok.length - 1);
+ if (tok.indexOf(SLASH) !== -1) {
+ tok = tok.replace(escapeSequence, unescapeOne);
+ }
+ cont = stack[0];
+ if (!key) {
+ if (cont instanceof Array) {
+ key = cont.length;
+ } else {
+ key = tok || EMPTY_STRING; // Use as key for next value seen.
+ break;
+ }
+ }
+ cont[key] = tok;
+ key = void 0;
+ break;
+ case 0x5b: // '['
+ cont = stack[0];
+ stack.unshift(cont[key || cont.length] = []);
+ key = void 0;
+ break;
+ case 0x5d: // ']'
+ stack.shift();
+ break;
+ case 0x66: // 'f'
+ cont = stack[0];
+ cont[key || cont.length] = false;
+ key = void 0;
+ break;
+ case 0x6e: // 'n'
+ cont = stack[0];
+ cont[key || cont.length] = null;
+ key = void 0;
+ break;
+ case 0x74: // 't'
+ cont = stack[0];
+ cont[key || cont.length] = true;
+ key = void 0;
+ break;
+ case 0x7b: // '{'
+ cont = stack[0];
+ stack.unshift(cont[key || cont.length] = {});
+ key = void 0;
+ break;
+ case 0x7d: // '}'
+ stack.shift();
+ break;
+ }
+ }
+ // Fail if we've got an uncompleted object.
+ if (stack.length) { throw new Error(); }
+ return result;
+ };
+})();
diff --git a/trunk/etherpad/src/etherpad/collab/readonly_server.js b/trunk/etherpad/src/etherpad/collab/readonly_server.js
new file mode 100644
index 0000000..e367f04
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/collab/readonly_server.js
@@ -0,0 +1,174 @@
+/**
+ * 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("comet");
+import("ejs");
+import("etherpad.collab.ace.easysync2.{AttribPool,Changeset}");
+import("etherpad.log");
+import("etherpad.pad.activepads");
+import("etherpad.pad.model");
+import("etherpad.pad.padutils");
+import("etherpad.pad.padevents");
+import("etherpad.pro.pro_padmeta");
+import("fastJSON");
+import("fileutils.readFile");
+import("jsutils.eachProperty");
+import("etherpad.collab.server_utils.*");
+import("etherpad.collab.collabroom_server");
+
+jimport("java.util.concurrent.ConcurrentHashMap");
+
+jimport("java.lang.System.out.println");
+
+var PADVIEW_ROOMTYPE = 'padview';
+
+var _serverDebug = println;//function(x) {};
+
+// "view id" is either a padId or an ro.id
+function _viewIdToRoom(padId) {
+ return "padview/"+padId;
+}
+
+function _roomToViewId(roomName) {
+ return roomName.substring(roomName.indexOf("/")+1);
+}
+
+function getRoomCallbacks(roomName, emptyCallbacks) {
+ var callbacks = emptyCallbacks;
+
+ var viewId = _roomToViewId(roomName);
+
+ callbacks.handleConnect = function(data) {
+ if (data.userInfo && data.userInfo.userId) {
+ return data.userInfo;
+ }
+ return null;
+ };
+ callbacks.clientReady =
+ function(newConnection, data) {
+ newConnection.data.lastRev = data.lastRev;
+ collabroom_server.updateRoomConnectionData(newConnection.connectionId,
+ newConnection.data);
+ };
+
+ return callbacks;
+}
+
+function updatePadClients(pad) {
+ var padId = pad.getId();
+ var roId = padIdToReadonly(padId);
+
+ function update(connection) {
+ updateClient(pad, connection.connectionId);
+ }
+
+ collabroom_server.getRoomConnections(_viewIdToRoom(padId)).forEach(update);
+ collabroom_server.getRoomConnections(_viewIdToRoom(roId)).forEach(update);
+}
+
+// Get arrays of text lines and attribute lines for a revision
+// of a pad.
+function _getPadLines(pad, revNum) {
+ var atext;
+ if (revNum >= 0) {
+ atext = pad.getInternalRevisionAText(revNum);
+ } else {
+ atext = Changeset.makeAText("\n");
+ }
+
+ var result = {};
+ result.textlines = Changeset.splitTextLines(atext.text);
+ result.alines = Changeset.splitAttributionLines(atext.attribs,
+ atext.text);
+ return result;
+}
+
+function updateClient(pad, connectionId) {
+ var conn = collabroom_server.getConnection(connectionId);
+ if (! conn) {
+ return;
+ }
+ var lastRev = conn.data.lastRev;
+ while (lastRev < pad.getHeadRevisionNumber()) {
+ var r = ++lastRev;
+ var author = pad.getRevisionAuthor(r);
+ var lines = _getPadLines(pad, r-1);
+ var wirePool = new AttribPool();
+ var forwards = pad.getRevisionChangeset(r);
+ var backwards = Changeset.inverse(forwards, lines.textlines,
+ lines.alines, pad.pool());
+ var forwards2 = Changeset.moveOpsToNewPool(forwards, pad.pool(),
+ wirePool);
+ var backwards2 = Changeset.moveOpsToNewPool(backwards, pad.pool(),
+ wirePool);
+
+ function revTime(r) {
+ var date = pad.getRevisionDate(r);
+ var s = Math.floor((+date)/1000);
+ //java.lang.System.out.println("time "+r+": "+s);
+ return s;
+ }
+
+ var msg = {type:"NEW_CHANGES", newRev:r,
+ changeset: forwards2,
+ changesetBack: backwards2,
+ apool: wirePool.toJsonable(),
+ author: author,
+ timeDelta: revTime(r) - revTime(r-1) };
+ collabroom_server.sendMessage(connectionId, msg);
+ }
+ conn.data.lastRev = pad.getHeadRevisionNumber();
+ collabroom_server.updateRoomConnectionData(connectionId, conn.data);
+}
+
+function sendMessageToPadConnections(pad, msg) {
+ var padId = pad.getId();
+ var roId = padIdToReadonly(padId);
+
+ function update(connection) {
+ collabroom_server.sendMessage(connection.connectionId, msg);
+ }
+
+ collabroom_server.getRoomConnections(_viewIdToRoom(padId)).forEach(update);
+ collabroom_server.getRoomConnections(_viewIdToRoom(roId)).forEach(update);
+}
+
+function updateUserInfo(pad, userInfo) {
+ var msg = { type:"NEW_AUTHORDATA",
+ author: userInfo.userId,
+ data: {} };
+ var hasData = false;
+ if ((typeof (userInfo.colorId)) == "number") {
+ msg.data.colorId = userInfo.colorId;
+ hasData = true;
+ }
+ if (userInfo.name) {
+ msg.data.name = userInfo.name;
+ hasData = true;
+ }
+ if (hasData) {
+ sendMessageToPadConnections(pad, msg);
+ }
+}
+
+function broadcastNewRevision(pad, revObj) {
+ var msg = { type:"NEW_SAVEDREV",
+ savedRev: revObj };
+
+ delete revObj.ip; // we try not to share info like IP addresses on slider
+
+ sendMessageToPadConnections(pad, msg);
+}
diff --git a/trunk/etherpad/src/etherpad/collab/server_utils.js b/trunk/etherpad/src/etherpad/collab/server_utils.js
new file mode 100644
index 0000000..ece3aea
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/collab/server_utils.js
@@ -0,0 +1,204 @@
+/**
+ * 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("comet");
+import("ejs");
+import("etherpad.collab.ace.easysync2.{AttribPool,Changeset}");
+import("etherpad.log");
+import("etherpad.pad.activepads");
+import("etherpad.pad.model");
+import("etherpad.pad.padutils");
+import("etherpad.pad.padevents");
+import("etherpad.pro.pro_padmeta");
+import("fastJSON");
+import("fileutils.readFile");
+import("jsutils.eachProperty");
+
+jimport("java.util.Random");
+jimport("java.lang.System");
+
+import("etherpad.collab.collab_server");
+// importClass(java.util.Random);
+// importClass(java.lang.System);
+
+var _serverDebug = function() {};
+var _dmesg = function() { System.out.println(arguments[0]+""); };
+
+/// Begin readonly/padId conversion code
+/// TODO: refactor into new file?
+var _baseRandomNumber = 0x123123; // keep this number seekrit
+
+function _map(array, func) {
+ for(var i=0; i<array.length; i++) {
+ array[i] = func(array[i]);
+ }
+ return array;
+}
+
+function parseUrlId(readOnlyIdOrLocalPadId) {
+ var localPadId;
+ var viewId;
+ var isReadOnly;
+ var roPadId;
+ var globalPadId;
+ if(isReadOnlyId(readOnlyIdOrLocalPadId)) {
+ isReadOnly = true;
+ globalPadId = readonlyToPadId(readOnlyIdOrLocalPadId);
+ localPadId = padutils.globalToLocalId(globalPadId);
+ var globalPadIdCheck = padutils.getGlobalPadId(localPadId);
+ if (globalPadId != globalPadIdCheck) {
+ // domain doesn't match
+ response.forbid();
+ }
+ roPadId = readOnlyIdOrLocalPadId;
+ viewId = roPadId;
+ }
+ else {
+ isReadOnly = false;
+ localPadId = readOnlyIdOrLocalPadId;
+ globalPadId = padutils.getGlobalPadId(localPadId);
+ viewId = globalPadId;
+ roPadId = padIdToReadonly(globalPadId);
+ }
+
+ return {localPadId:localPadId, viewId:viewId, isReadOnly:isReadOnly,
+ roPadId:roPadId, globalPadId:globalPadId};
+}
+
+function isReadOnlyId(str) {
+ return str.indexOf("ro.") == 0;
+}
+
+/*
+ for now, we just make it 'hard to guess'
+ TODO: make it impossible to find read/write page through hash
+*/
+function readonlyToPadId (readOnlyHash) {
+
+ // readOnly hashes must start with 'ro-'
+ if(!isReadOnlyId(readOnlyHash)) return null;
+ else {
+ readOnlyHash = readOnlyHash.substring(3, readOnlyHash.length);
+ }
+
+ // convert string to series of numbers between 1 and 64
+ var result = _strToArray(readOnlyHash);
+
+ var sum = result.pop();
+ // using a secret seed to util.random, transform each number using + and %
+ var seed = _baseRandomNumber + sum;
+ var rand = new Random(seed);
+
+ _map(result, function(elem) {
+ return ((64 + elem - rand.nextInt(64)) % 64);
+ });
+
+ // convert array of numbers back to a string
+ return _arrayToStr(result);
+}
+
+/*
+ Temporary code. see comment at readonlyToPadId.
+*/
+function padIdToReadonly (padid) {
+ var result = _strToArray(padid);
+ var sum = 0;
+
+ if(padid.length > 1) {
+ for(var i=0; i<result.length; i++) {
+ sum = (sum + result[i] + 1) % 64;
+ }
+ } else {
+ sum = 64;
+ }
+
+ var seed = _baseRandomNumber + sum;
+ var rand = new Random(seed);
+
+ _map(result, function(elem) {
+ var randnum = rand.nextInt(64);
+ return ((elem + randnum) % 64);
+ });
+
+ result.push(sum);
+ return "ro." + _arrayToStr(result);
+}
+
+// little reversable string encoding function
+// 0-9 are the numbers 0-9
+// 10-35 are the uppercase letters A-Z
+// 36-61 are the lowercase letters a-z
+// 62 are all other characters
+function _strToArray(str) {
+ var result = new Array(str.length);
+ for(var i=0; i<str.length; i++) {
+ result[i] = str.charCodeAt(i);
+
+ if (_between(result[i], '0'.charCodeAt(0), '9'.charCodeAt(0))) {
+ result[i] -= '0'.charCodeAt(0);
+ }
+ else if(_between(result[i], 'A'.charCodeAt(0), 'Z'.charCodeAt(0))) {
+ result[i] -= 'A'.charCodeAt(0); // A becomes 0
+ result[i] += 10; // A becomes 10
+ }
+ else if(_between(result[i], 'a'.charCodeAt(0), 'z'.charCodeAt(0))) {
+ result[i] -= 'a'.charCodeAt(0); // a becomes 0
+ result[i] += 36; // a becomes 36
+ } else if(result[i] == '$'.charCodeAt(0)) {
+ result[i] = 62;
+ } else {
+ result[i] = 63; // if not alphanumeric or $, we default to 63
+ }
+ }
+ return result;
+}
+
+function _arrayToStr(array) {
+ var result = "";
+ for(var i=0; i<array.length; i++) {
+ if(_between(array[i], 0, 9)) {
+ result += String.fromCharCode(array[i] + '0'.charCodeAt(0));
+ }
+ else if(_between(array[i], 10, 35)) {
+ result += String.fromCharCode(array[i] - 10 + 'A'.charCodeAt(0));
+ }
+ else if(_between(array[i], 36, 61)) {
+ result += String.fromCharCode(array[i] - 36 + 'a'.charCodeAt(0));
+ }
+ else if(array[i] == 62) {
+ result += "$";
+ } else {
+ result += "-";
+ }
+ }
+ return result;
+}
+
+function _between(charcode, start, end) {
+ return charcode >= start && charcode <= end;
+}
+
+/* a short little testing function, converts back and forth */
+// function _testEncrypt(str) {
+// var encrypted = padIdToReadonly(str);
+// var decrypted = readonlyToPadId(encrypted);
+// _dmesg(str + " " + encrypted + " " + decrypted);
+// if(decrypted != str) {
+// _dmesg("ERROR: " + str + " and " + decrypted + " do not match");
+// }
+// }
+
+// _testEncrypt("testing$");
diff --git a/trunk/etherpad/src/etherpad/control/aboutcontrol.js b/trunk/etherpad/src/etherpad/control/aboutcontrol.js
new file mode 100644
index 0000000..9d77142
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/aboutcontrol.js
@@ -0,0 +1,263 @@
+/**
+ * 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("email.sendEmail");
+import("funhtml.*", "stringutils.*");
+import("netutils");
+import("execution");
+
+import("etherpad.utils.*");
+import("etherpad.log");
+import("etherpad.globals.*");
+import("etherpad.quotas");
+import("etherpad.sessions.getSession");
+import("etherpad.store.eepnet_trial");
+import("etherpad.store.checkout");
+import("etherpad.store.eepnet_checkout");
+
+jimport("java.lang.System.out.println");
+
+//----------------------------------------------------------------
+
+function render_product() {
+ if (request.params.from) { response.redirect(request.path); }
+ renderFramed("about/product_body.ejs");
+}
+
+function render_faq() {
+ renderFramed("about/faq_body.ejs", {
+ LI: LI,
+ H2: H2,
+ A: A,
+ html: html
+ });
+}
+
+function render_pne_faq() {
+ renderFramed("about/pne-faq.ejs");
+}
+
+function render_company() {
+ renderFramed("about/company_body.ejs");
+}
+
+function render_contact() {
+ renderFramed("about/contact_body.ejs");
+}
+
+function render_privacy() {
+ renderFramed("about/privacy_body.ejs");
+}
+
+function render_tos() {
+ renderFramed("about/tos_body.ejs");
+}
+
+function render_testimonials() {
+ renderFramed("about/testimonials.ejs");
+}
+
+function render_appjet() {
+ response.redirect("/ep/blog/posts/etherpad-and-appjet");
+// renderFramed("about/appjet_body.ejs");
+}
+
+function render_screencast() {
+ if (request.params.from) { response.redirect(request.path); }
+ var screencastUrl;
+// if (isProduction()) {
+ screencastUrl = encodeURIComponent("http://etherpad.s3.amazonaws.com/epscreencast800x600.flv");
+// } else {
+// screencastUrl = encodeURIComponent("/static/flv/epscreencast800x600.flv");
+// }
+ renderFramed("about/screencast_body.ejs", {screencastUrl: screencastUrl});
+}
+
+function render_forums() {
+ renderFramed("about/forums_body.ejs");
+}
+
+function render_blog() {
+ renderFramed("about/blog_body.ejs");
+}
+
+function render_really_real_time() {
+ renderFramed("about/simultaneously.ejs");
+}
+
+function render_simultaneously() {
+ renderFramed("about/simultaneously.ejs");
+}
+
+//----------------------------------------------------------------
+// pricing
+//----------------------------------------------------------------
+
+function render_pricing() {
+ renderFramed("about/pricing.ejs", {
+ trialDays: eepnet_trial.getTrialDays(),
+ costPerUser: checkout.dollars(eepnet_checkout.COST_PER_USER)
+ });
+}
+
+function render_pricing_free() {
+ renderFramed("about/pricing_free.ejs", {
+ maxUsersPerPad: quotas.getMaxSimultaneousPadEditors()
+ });
+}
+
+function render_pricing_eepnet() {
+ renderFramed("about/pricing_eepnet.ejs", {
+ trialDays: eepnet_trial.getTrialDays(),
+ costPerUser: checkout.dollars(eepnet_checkout.COST_PER_USER)
+ });
+}
+
+function render_pricing_pro() {
+ renderFramed("about/pricing_pro.ejs", {});
+}
+
+function render_eepnet_pricing_contact_post() {
+ response.setContentType("text/plain; charset=utf-8");
+ var data = {};
+ var fields = ['firstName', 'lastName', 'email', 'orgName',
+ 'jobTitle', 'phone', 'estUsers', 'industry'];
+
+ if (!getSession().pricingContactData) {
+ getSession().pricingContactData = {};
+ }
+
+ function err(m) {
+ response.write(m);
+ response.stop();
+ }
+
+ fields.forEach(function(f) {
+ getSession().pricingContactData[f] = request.params[f];
+ });
+
+ fields.forEach(function(f) {
+ data[f] = request.params[f];
+ if (!(data[f] && (data[f].length > 0))) {
+ err("All fields are required.");
+ }
+ });
+
+ if (!isValidEmail(data.email)) {
+ err("Error: Invalid Email");
+ }
+
+ // log this data to a file
+ fields.ip = request.clientAddr;
+ fields.sessionReferer = getSession().initialReferer;
+ log.custom("eepnet_pricing_inquiry", fields);
+
+ // submit web2lead
+ var ref = getSession().initialReferer;
+ var googleQuery = extractGoogleQuery(ref);
+ var wlparams = {
+ oid: "00D80000000b7ey",
+ first_name: data.firstName,
+ last_name: data.lastName,
+ email: data.email,
+ company: data.orgName,
+ title: data.jobTitle,
+ phone: data.phone,
+ '00N80000003FYtG': data.estUsers,
+ '00N80000003FYto': ref,
+ '00N80000003FYuI': googleQuery,
+ lead_source: 'EEPNET Pricing Inquiry',
+ industry: data.industry,
+ retURL: 'http://'+request.host+'/ep/store/salesforce-web2lead-ok'
+ };
+
+ var result = netutils.urlPost(
+ "http://www.salesforce.com/servlet/servlet.WebToLead?encoding=UTF-8",
+ wlparams, {});
+
+ // now send an email sales notification
+ var hostname = ipToHostname(request.clientAddr) || "unknown";
+ var subject = 'EEPNET Pricing Inquiry: '+data.email+' / '+hostname;
+ var body = [
+ "", "This is an automated email.", "",
+ data.firstName+" "+data.lastName+" ("+data.orgName+") has inquired about EEPNET pricing.",
+ "",
+ "This record has automatically been added to SalesForce. See the salesforce lead page for more details.",
+ "", "Session Referer: "+ref, ""
+ ].join("\n");
+ var toAddr = 'sales@pad.spline.inf.fu-berlin.de';
+ if (isTestEmail(data.email)) {
+ toAddr = 'blackhole@appjet.com';
+ }
+ sendEmail(toAddr, 'sales@pad.spline.inf.fu-berlin.de', subject, {}, body);
+
+ // all done!
+ response.write("OK");
+}
+
+function render_pricing_interest_signup() {
+ response.setContentType('text/plain; charset=utf-8');
+
+ var email = request.params.email;
+ var interestedNet = request.params.interested_net;
+ var interestedHosted = request.params.interested_hosted;
+
+ if (!isValidEmail(email)) {
+ response.write("Error: Invalid Email");
+ response.stop();
+ }
+
+ log.custom("pricing_interest",
+ {email: email,
+ net: interestedNet,
+ hosted: interestedHosted});
+
+ response.write('OK');
+}
+
+function render_pricing_eepnet_users() {
+ renderFramed('about/pricing_eepnet_users.ejs', {});
+}
+
+function render_pricing_eepnet_support() {
+ renderFramed('about/pricing_eepnet_support.ejs', {});
+}
+
+
+//------------------------------------------------------------
+// survey
+
+function render_survey() {
+ var id = request.params.id;
+ log.custom("pro-user-survey", { surveyProAccountId: (id || "unknown") });
+ response.redirect("http://www.surveymonkey.com/s.aspx?sm=yT3ALP0pb_2fP_2bHtcfzvpkXQ_3d_3d");
+}
+
+
+//------------------------------------------------------------
+
+import("etherpad.billing.billing");
+
+function render_testbillingnotify() {
+ var ret = billing.handlePaypalNotification();
+ if (ret.status == 'completion') {
+ // do something with purchase ret.purchaseInfo
+ } else if (ret.status != 'redundant') {
+ java.lang.System.out.println("Whoa error: "+ret.toSource());
+ }
+ response.write("ok");
+}
+
diff --git a/trunk/etherpad/src/etherpad/control/admincontrol.js b/trunk/etherpad/src/etherpad/control/admincontrol.js
new file mode 100644
index 0000000..02f6428
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/admincontrol.js
@@ -0,0 +1,1471 @@
+/**
+ * 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("netutils");
+import("funhtml.*");
+import("stringutils.{html,sprintf,startsWith,md5}");
+import("jsutils.*");
+import("sqlbase.sqlbase");
+import("sqlbase.sqlcommon");
+import("sqlbase.sqlobj");
+import("varz");
+import("comet");
+import("dispatch.{Dispatcher,PrefixMatcher,DirMatcher,forward}");
+
+import("etherpad.billing.team_billing");
+import("etherpad.globals.*");
+import("etherpad.utils.*");
+import("etherpad.licensing");
+import("etherpad.sessions.getSession");
+import("etherpad.sessions");
+import("etherpad.statistics.statistics");
+import("etherpad.log");
+import("etherpad.admin.shell");
+import("etherpad.usage_stats.usage_stats");
+import("etherpad.control.blogcontrol");
+import("etherpad.control.pro_beta_control");
+import("etherpad.control.statscontrol");
+import("etherpad.statistics.exceptions");
+import("etherpad.store.checkout");
+
+import("etherpad.pad.activepads");
+import("etherpad.pad.model");
+import("etherpad.pad.padutils");
+import("etherpad.pad.dbwriter");
+import("etherpad.collab.collab_server");
+
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.pro_utils");
+import("etherpad.pro.domains");
+
+jimport("java.lang.System.out.println");
+
+jimport("net.appjet.oui.cometlatencies");
+jimport("net.appjet.oui.appstats");
+
+
+//----------------------------------------------------------------
+
+function _isAuthorizedAdmin() {
+ if (!isProduction()) {
+ return true;
+ }
+ return (getSession().adminAuth === true);
+}
+
+var _mainLinks = [
+ ['exceptions', 'Exceptions Monitor'],
+ ['usagestats/', 'Usage Stats'],
+ ['padinspector', 'Pad Inspector'],
+ ['dashboard', 'Dashboard'],
+ ['eepnet-licenses', 'EEPNET Licenses'],
+ ['config', 'appjet.config'],
+ ['shell', 'Shell'],
+ ['timings', 'timing data'],
+ ['broadcast-message', 'Pad Broadcast'],
+// ['analytics', 'Google Analytics'],
+ ['varz', 'varz'],
+ ['genlicense', 'Manually generate a license key'],
+ ['flows', 'Flows (warning: slow)'],
+ ['diagnostics', 'Pad Connection Diagnostics'],
+ ['cachebrowser', 'Cache Browser'],
+ ['pne-tracker', 'PNE Tracking Stats'],
+ ['reload-blog-db', 'Reload blog DB'],
+ ['pro-domain-accounts', 'Pro Domain Accounts'],
+ ['beta-valve', 'Beta Valve'],
+ ['reset-subscription', "Reset Subscription"]
+];
+
+function onRequest(name) {
+ if (name == "auth") {
+ return;
+ }
+ if (!_isAuthorizedAdmin()) {
+ getSession().cont = request.path;
+ response.redirect('/ep/admin/auth');
+ }
+
+ var disp = new Dispatcher();
+ disp.addLocations([
+ [PrefixMatcher('/ep/admin/usagestats/'), forward(statscontrol)]
+ ]);
+
+ return disp.dispatch();
+}
+
+function _commonHead() {
+ return HEAD(STYLE(
+ "html {font-family:Verdana,Helvetica,sans-serif;}",
+ "body {padding: 2em;}"
+ ));
+}
+
+//----------------------------------------------------------------
+
+function render_auth() {
+ var cont = getSession().cont;
+ if (getSession().message) {
+ response.write(DIV(P(B(getSession().message))));
+ delete getSession().message;
+ }
+ if (request.method == "GET") {
+ response.write(FORM({method: "POST", action: request.path},
+ P("Are you an admin?"),
+ LABEL("Password:"),
+ INPUT({type: "password", name: "password", value: ""}),
+ INPUT({type: "submit", value: "submit"})
+ ));
+ }
+ if (request.method == "POST") {
+ var pass = request.params.password;
+ if (pass === appjet.config['etherpad.adminPass']) {
+ getSession().adminAuth = true;
+ if (cont) {
+ response.redirect(cont);
+ } else {
+ response.redirect("/ep/admin/main");
+ }
+ } else {
+ getSession().message = "Bad Password.";
+ response.redirect(request.path);
+ }
+ }
+}
+
+function render_main() {
+ var div = DIV();
+
+ div.push(A({href: "/"}, html("&laquo;"), " home"));
+ div.push(H1("Admin"));
+
+ _mainLinks.forEach(function(l) {
+ div.push(DIV(A({href: l[0]}, l[1])));
+ });
+ if (sessions.isAnEtherpadAdmin()) {
+ div.push(P(A({href: "/ep/admin/setadminmode?v=false"},
+ "Exit Admin Mode")));
+ }
+ else {
+ div.push(P(A({href: "/ep/admin/setadminmode?v=true"},
+ "Enter Admin Mode")));
+ }
+ response.write(HTML(_commonHead(), BODY(div)));
+}
+
+//----------------------------------------------------------------
+
+function render_config() {
+
+ vars = [];
+ eachProperty(appjet.config, function(k,v) {
+ vars.push(k);
+ });
+
+ vars.sort();
+
+ response.setContentType('text/plain; charset=utf-8');
+ vars.forEach(function(v) {
+ response.write("appjet.config."+v+" = "+appjet.config[v]+"\n");
+ });
+}
+
+//----------------------------------------------------------------
+
+function render_test() {
+ response.setContentType("text/plain");
+ response.write(Packages.net.appjet.common.util.ExpiringMapping + "\n");
+ var m = new Packages.net.appjet.common.util.ExpiringMapping(10 * 1000);
+ response.write(m.toString() + "\n");
+ m.get("test");
+ return;
+ response.write(m.toString());
+}
+
+function render_dashboard() {
+ var body = BODY();
+ body.push(A({href: '/ep/admin/'}, html("&laquo; Admin")));
+ body.push(H1({style: "border-bottom: 1px solid black;"}, "Dashboard"));
+
+ /*
+ body.push(H2({style: "color: #226; font-size: 1em;"}, "License"));
+ var license = licensing.getLicense();
+ body.push(P(TT(" Licensed To (name): "+license.personName)));
+ body.push(P(TT(" Licensed To (organization): "+license.organizationName)));
+ body.push(P(TT(" Software Edition: "+license.editionName)));
+ var quota = ((license.userQuota > 0) ? license.userQuota : 'unlimited');
+ body.push(P(TT(" User Quota: "+quota)));
+ var expires = (license.expiresDate ? (license.expiresDate.toString()) : 'never');
+ body.push(P(TT(" Expires: "+expires)));
+ */
+
+ /*
+ body.push(H2({style: "color: #226; font-size: 1em;"}, "Active User Quota"));
+
+ var activeUserCount = licensing.getActiveUserCount();
+ var activeUserQuota = licensing.getActiveUserQuota();
+ var activeUserWindowStart = licensing.getActiveUserWindowStart();
+
+ body.push(P(TT(" Since ", B(activeUserWindowStart.toString()), ", ",
+ "you have used ", B(activeUserCount), " of ", B(activeUserQuota),
+ " active users.")));
+*/
+ body.push(H2({style: "color: #226; font-size: 1em;"}, "Uptime"));
+ body.push(P({style: "margin-left: 25px;"}, "Server running for "+renderServerUptime()+"."))
+
+ body.push(H2({style: "color: #226; font-size: 1em;"}, "Response codes"));
+ body.push(renderResponseCodes());
+
+ body.push(H2({style: "color: #226; font-size: 1em;"}, "Comet Connections"));
+ body.push(renderPadConnections());
+
+ body.push(H2({style: "color: #226; font-size: 1em;"}, "Comet Stats"));
+ body.push(renderCometStats());
+
+ body.push(H2({style: "color: #226; font-size: 1em;"}, "Recurring revenue, monthly"));
+ body.push(renderRevenueStats());
+
+ response.write(HTML(_commonHead(), body));
+}
+
+// Note: This function is called by the PNE dashboard (pro_admin_control.js)! Be careful.
+function renderPadConnections() {
+ var d = DIV();
+ var lastCount = cometlatencies.lastCount();
+
+ if (lastCount.isDefined()) {
+ var countMap = {};
+ Array.prototype.map.call(lastCount.get().elements().collect().toArray().unbox(
+ java.lang.Class.forName("java.lang.Object")),
+ function(x) {
+ countMap[x._1()] = x._2();
+ });
+ var totalConnected = 0;
+ var ul = UL();
+ eachProperty(countMap, function(k,v) {
+ ul.push(LI(k+": "+v));
+ if (/^\d+$/.test(v)) {
+ totalConnected += Number(v);
+ }
+ });
+ ul.push(LI(B("Total: ", totalConnected)));
+ d.push(ul);
+ } else {
+ d.push("Still collecting data... check back in a minute.");
+ }
+ return d;
+}
+
+// Note: This function is called by the PNE dashboard (pro_admin_control.js)! Be careful.
+function renderCometStats() {
+ var d = DIV();
+ var lastStats = cometlatencies.lastStats();
+ var lastCount = cometlatencies.lastCount();
+
+
+ if (lastStats.isDefined()) {
+ d.push(P("Realtime transport latency percentiles (microseconds):"));
+ var ul = UL();
+ lastStats.map(scalaF1(function(s) {
+ ['50', '90', '95', '99', 'max'].forEach(function(id) {
+ var fn = id;
+ if (id != "max") {
+ fn = ("p"+fn);
+ id = id+"%";
+ }
+ ul.push(LI(id, ": <", s[fn](), html("&micro;"), "s"));
+ });
+ }));
+ d.push(ul);
+ } else {
+ d.push(P("Still collecting data... check back in a minutes."));
+ }
+
+ /* ["p50", "p90", "p95", "p99", "max"].forEach(function(id) {
+ ul.push(LI(B(
+
+ return DIV(P(sprintf("50%% %d\t90%% %d\t95%% %d\t99%% %d\tmax %d",
+ s.p50(), s.p90(), s.p95(), s.p99(), s.max())),
+ P(sprintf("%d total messages", s.count())));
+ }})).get();*/
+
+
+ return d;
+}
+
+// Note: This function is called by the PNE dashboard (pro_admin_control.js)! Be careful.
+function renderResponseCodes() {
+ var statusCodeFrequencyNames = ["minute", "hour", "day", "week"];
+ var data = { };
+ var statusCodes = appstats.stati();
+ for (var i = 0; i < statusCodes.length; ++i) {
+ var name = statusCodeFrequencyNames[i];
+ var map = statusCodes[i];
+ map.foreach(scalaF1(function(pair) {
+ if (! (pair._1() in data)) data[pair._1()] = {};
+ var scmap = data[pair._1()];
+ scmap[name] = pair._2().count();
+ }));
+ };
+ var stats = TABLE({id: "responsecodes-table", style: "margin-left: 25px;",
+ border: 1, cellspacing: 0, cellpadding: 4},
+ TR.apply(TR, statusCodeFrequencyNames.map(function(name) {
+ return TH({colspan: 2}, "Last", html("&nbsp;"), name);
+ })));
+ var sortedStati = [];
+ eachProperty(data, function(k) {
+ sortedStati.push(k);
+ });
+ sortedStati.sort();
+ sortedStati.forEach(function(k, i) { // k is status code.
+ var row = TR();
+ statusCodeFrequencyNames.forEach(function(name) {
+ row.push(TD({style: 'width: 2em;'}, data[k][name] ? k+":" : ""));
+ row.push(TD(data[k][name] ? data[k][name] : ""));
+ });
+ stats.push(row);
+ });
+ return stats;
+}
+
+// Note: This function is called by the PNE dashboard (pro_admin_control.js)! Be careful.
+function renderServerUptime() {
+ var labels = ["seconds", "minutes", "hours", "days"];
+ var ratios = [60, 60, 24];
+ var time = appjet.uptime / 1000;
+ var pos = 0;
+ while (pos < ratios.length && time / ratios[pos] > 1.1) {
+ time = time / ratios[pos];
+ pos++;
+ }
+ return sprintf("%.1f %s", time, labels[pos]);
+}
+
+function renderRevenueStats() {
+ var subs = team_billing.getAllSubscriptions();
+ var total = 0;
+ var totalUsers = 0;
+ subs.forEach(function(sub) {
+ var users = team_billing.getMaxUsers(sub.customer);
+ var cost = team_billing.calculateSubscriptionCost(users, sub.coupon);
+ if (cost > 0) {
+ totalUsers += users;
+ total += cost;
+ }
+ });
+ return "US $"+checkout.dollars(total)+", from "+subs.length+" domains and "+totalUsers+" users.";
+}
+
+//----------------------------------------------------------------
+// Broadcasting Messages
+//----------------------------------------------------------------
+
+function render_broadcast_message_get() {
+ var body = BODY(FORM({action: request.path, method: 'post'},
+ H3('Broadcast Message to All Active Pad Clients:'),
+ TEXTAREA({name: 'msgtext', style: 'width: 100%; height: 100px;'}),
+ H3('JavaScript code to be eval()ed on client (optional, be careful!): '),
+ TEXTAREA({name: 'jscode', style: 'width: 100%; height: 100px;'}),
+ INPUT({type: 'submit', value: 'Broadcast Now'})));
+ response.write(HTML(body));
+}
+
+function render_broadcast_message_post() {
+ var msgText = request.params.msgtext;
+ var jsCode = request.params.jscode;
+ if (!(msgText || jsCode)) {
+ response.write("No mesage text or jscode specified.");
+ response.stop();
+ return;
+ }
+ collab_server.broadcastServerMessage({
+ type: 'NOTICE',
+ text: msgText,
+ js: jsCode
+ });
+ response.write(HTML(BODY(P("OK"), P(A({href: request.path}, "back")))));
+}
+
+function render_shell() {
+ shell.handleRequest();
+}
+
+//----------------------------------------------------------------
+// pad inspector
+//----------------------------------------------------------------
+
+function _getPadUrl(globalPadId) {
+ var superdomain = pro_utils.getRequestSuperdomain();
+ var domain;
+ if (padutils.isProPadId(globalPadId)) {
+ var domainId = padutils.getDomainId(globalPadId);
+ domain = domains.getDomainRecord(domainId).subDomain +
+ '.' + superdomain;
+ }
+ else {
+ domain = superdomain;
+ }
+ var localId = padutils.globalToLocalId(globalPadId);
+ return "http://"+httpHost(domain)+"/"+localId;
+}
+
+function render_padinspector_get() {
+ var padId = request.params.padId;
+ if (!padId) {
+ response.write(FORM({action: request.path, method: 'get', style: 'border: 1px solid #ccc; background-color: #eee; padding: .2em 1em;'},
+ P("Pad Lookup: ",
+ INPUT({name: 'padId', value: '<enter pad id>'}),
+ INPUT({type: 'submit'}))));
+
+ // show recently active pads; the number of them may vary; lots of
+ // activity in a pad will push others off the list
+ response.write(H3("Recently Active Pads:"));
+ var recentlyActiveTable = TABLE({cellspacing: 0, cellpadding: 6, border: 1,
+ style: 'font-family: monospace;'});
+ var recentPads = activepads.getActivePads();
+ recentPads.forEach(function (info) {
+ var time = info.timestamp; // number
+ var pid = info.padId;
+ model.accessPadGlobal(pid, function(pad) {
+ if (pad.exists()) {
+ var numRevisions = pad.getHeadRevisionNumber();
+ var connected = collab_server.getNumConnections(pad);
+ recentlyActiveTable.push(
+ TR(TD(B(pid)),
+ TD({style: 'font-style: italic;'}, timeAgo(time)),
+ TD(connected+" connected"),
+ TD(numRevisions+" revisions"),
+ TD(A({href: qpath({padId: pid, revtext: "HEAD"})}, "HEAD")),
+ TD(A({href: qpath({padId: pid})}, "inspect")),
+ TD(A({href: qpath({padId: pid, snoop: 1})}, "snoop"))
+ ));
+ }
+ }, "r");
+ });
+ response.write(recentlyActiveTable);
+ response.stop();
+ }
+ if (startsWith(padId, '/')) {
+ padId = padId.substr(1);
+ }
+ if (request.params.snoop) {
+ sessions.setIsAnEtherpadAdmin(true);
+ response.redirect(_getPadUrl(padId));
+ }
+ if (request.params.setsupportstimeslider) {
+ var v = (String(request.params.setsupportstimeslider).toLowerCase() ==
+ 'true');
+ model.accessPadGlobal(padId, function(pad) {
+ pad.setSupportsTimeSlider(v);
+ });
+ response.write("on pad "+padId+": setSupportsTimeSlider("+v+")");
+ response.stop();
+ }
+ model.accessPadGlobal(padId, function(pad) {
+ if (! pad.exists()) {
+ response.write("Pad not found: /"+padId);
+ }
+ else {
+ var headRev = pad.getHeadRevisionNumber();
+ var div = DIV({style: 'font-family: monospace;'});
+
+ if (request.params.revtext) {
+ var i;
+ if (request.params.revtext == "HEAD") {
+ i = headRev;
+ } else {
+ i = Number(request.params.revtext);
+ }
+ var infoObj = {};
+ div.push(H2(A({href: request.path}, "PadInspector"),
+ ' > ', A({href: request.path+'?padId='+padId}, "/"+padId),
+ ' > ', "Revision ", i, "/", headRev,
+ SPAN({style: 'color: #949;'}, ' [ ', pad.getRevisionDate(i).toString(), ' ] ')));
+ div.push(H3("Browse Revisions: ",
+ ((i > 0) ? A({id: 'previous', href: qpath({revtext: (i-1)})}, '<< previous') : ''),
+ ' ',
+ ((i < pad.getHeadRevisionNumber()) ? A({id: 'next', href: qpath({revtext:(i+1)})}, 'next >>') : '')),
+ DIV({style: 'padding: 1em; border: 1px solid #ccc;'},
+ pad.getRevisionText(i, infoObj)));
+ if (infoObj.badLastChar) {
+ div.push(P("Bad last character of text (not newline): "+infoObj.badLastChar));
+ }
+ } else if (request.params.dumpstorage) {
+ div.push(P(collab_server.dumpStorageToString(pad)));
+ } else if (request.params.showlatest) {
+ div.push(P(pad.text()));
+ } else {
+ div.push(H2(A({href: request.path}, "PadInspector"), ' > ', "/"+padId));
+ // no action
+ div.push(P(A({href: qpath({revtext: 'HEAD'})}, 'HEAD='+headRev)));
+ div.push(P(A({href: qpath({dumpstorage: 1})}, 'dumpstorage')));
+ var supportsTimeSlider = pad.getSupportsTimeSlider();
+ if (supportsTimeSlider) {
+ div.push(P(A({href: qpath({setsupportstimeslider: 'false'})}, 'hide slider')));
+ }
+ else {
+ div.push(P(A({href: qpath({setsupportstimeslider: 'true'})}, 'show slider')));
+ }
+ }
+ }
+
+ var script = SCRIPT({type: 'text/javascript'}, html([
+ '$(document).keydown(function(e) {',
+ ' var h = undefined;',
+ ' if (e.keyCode == 37) { h = $("#previous").attr("href"); }',
+ ' if (e.keyCode == 39) { h = $("#next").attr("href"); }',
+ ' if (h) { window.location.href = h; }',
+ '});'
+ ].join('\n')));
+
+ response.write(HTML(
+ HEAD(SCRIPT({type: 'text/javascript', src: '/static/js/jquery-1.3.2.js?'+(+(new Date))})),
+ BODY(div, script)));
+ }, "r");
+}
+
+function render_analytics() {
+ response.redirect("https://www.google.com/analytics/reporting/?reset=1&id=12611622");
+}
+
+//----------------------------------------------------------------
+// eepnet license display
+//----------------------------------------------------------------
+
+function render_eepnet_licenses() {
+ var data = sqlobj.selectMulti('eepnet_signups', {}, {orderBy: 'date'});
+ var t = TABLE({border: 1, cellspacing: 0, cellpadding: 2});
+ var cols = ['date','email','orgName','firstName','lastName', 'jobTitle','phone','estUsers'];
+ data.forEach(function(x) {
+ var tr = TR();
+ cols.forEach(function(colname) {
+ tr.push(TD(x[colname]));
+ });
+ t.push(tr);
+ });
+ response.write(HTML(BODY({style: 'font-family: monospace;'}, t)));
+}
+
+//----------------------------------------------------------------
+// pad integrity
+//----------------------------------------------------------------
+
+/*function render_changesettest_get() {
+ var nums = [0, 1, 2, 3, 0xfffffff, 0x02345678, 4];
+ var str = Changeset.numberArrayToString(nums);
+ var result = Changeset.numberArrayFromString(str);
+ var resultArray = result[0];
+ var remainingString = result[1];
+ var bad = false;
+ if (remainingString) {
+ response.write(P("remaining string length is: "+remainingString.length));
+ bad = true;
+ }
+ if (nums.length != resultArray.length) {
+ response.write(P("length mismatch: "+nums.length+" / "+resultArray.length));
+ bad = true;
+ }
+ response.write(P(nums[2]));
+ for(var i=0;i<nums.length;i++) {
+ var a = nums[i];
+ var b = resultArray[i];
+ if (a !== b) {
+ response.write(P("mismatch at element "+i+": "+a+" / "+b));
+ bad = true;
+ }
+ }
+ if (! bad) {
+ response.write("SUCCESS");
+ }
+}*/
+
+/////////
+
+function render_appendtest() {
+ var padId = request.params.padId;
+ var mode = request.params.mode;
+ var text = request.params.text;
+
+ model.accessPadGlobal(padId, function(pad) {
+ if (mode == "append") {
+ collab_server.appendPadText(pad, text);
+ }
+ else if (mode == "replace") {
+ collab_server.setPadText(pad, text);
+ }
+ });
+}
+
+//function render_flushall() {
+// dbwriter.writeAllToDB(null, true);
+// response.write("OK");
+//}
+
+//function render_flushpad() {
+// var padId = request.params.padId;
+// model.accessPadGlobal(padId, function(pad) {
+// dbwriter.writePad(pad, true);
+// });
+// response.write("OK");
+//}
+
+/*function render_foo() {
+ locking.doWithPadLock("CAT", function() {
+ sqlbase.createJSONTable("STUFF");
+ sqlbase.putJSON("STUFF", "dogs", {very:"bad"});
+ response.write(sqlbase.getJSON("STUFF", "dogs")); // {very:"bad"}
+ response.write(',');
+ response.write(sqlbase.getJSON("STUFF", "cats")); // undefined
+ response.write("<br/>");
+
+ sqlbase.createStringArrayTable("SEQUENCES");
+ sqlbase.putStringArrayElement("SEQUENCES", "fibo", 0, "1");
+ sqlbase.putStringArrayElement("SEQUENCES", "fibo", 1, "1");
+ sqlbase.putStringArrayElement("SEQUENCES", "fibo", 2, "2");
+ sqlbase.putStringArrayElement("SEQUENCES", "fibo", 3, "3");
+ sqlbase.putStringArrayElement("SEQUENCES", "fibo", 4, "5");
+ sqlbase.putStringArrayElement("SEQUENCES", "fibo", 30, "number30");
+ sqlbase.putStringArrayElement("SEQUENCES", "fibo", 29, "number29");
+ sqlbase.deleteStringArrayElement("SEQUENCES", "fibo", 29);
+ sqlbase.putConsecutiveStringArrayElements("SEQUENCES", "fibo", 19, [19,20,21,22]);
+ var a = [];
+ for(var i=0;i<31;i++) {
+ a.push(sqlbase.getStringArrayElement("SEQUENCES", "fibo", i));
+ }
+ response.write(a.join(',')); // 1,1,2,3,5,,, ... 19,20,21,22, ... ,,,number30
+ });
+}*/
+
+function render_timings() {
+ var timer = Packages.net.appjet.ajstdlib.timer;
+ var opnames = timer.getOpNames();
+
+ response.write(P(A({href: '/ep/admin/timingsreset'}, "reset all")));
+
+ var t = TABLE({border: 1, cellspacing: 0, cellpadding: 3, style: 'font-family: monospace;'});
+ t.push(TR(TH("operation"),
+ TH("sample_count"),
+ TH("total_ms"),
+ TH("avg_ms")));
+
+ function r(x) {
+ return sprintf("%09.2f", x);
+ }
+ var rows = [];
+ for (var i = 0; i < opnames.length; i++) {
+ var stats = timer.getStats(opnames[i]);
+ rows.push([String(opnames[i]),
+ Math.floor(stats[0]),
+ stats[1],
+ stats[2]]);
+ }
+
+ var si = Number(request.params.sb || 0);
+
+ rows.sort(function(a,b) { return cmp(b[si],a[si]); });
+
+ rows.forEach(function(row) {
+ t.push(TR(TD(row[0]),
+ TD(row[1]),
+ TD(r(row[2])),
+ TD(r(row[3]))));
+ });
+
+ response.write(t);
+}
+
+function render_timingsreset() {
+ Packages.net.appjet.ajstdlib.timer.reset();
+ response.redirect('/ep/admin/timings');
+}
+
+// function render_jsontest() {
+// response.setContentType('text/plain; charset=utf-8');
+
+// var a = [];
+// a[0] = 5;
+// a[1] = 6;
+// a[9] = 8;
+// a['foo'] = "should appear";
+
+// jtest(a);
+
+// var obj1 = {
+// a: 1,
+// b: 2,
+// q: [true,true,,,,,,false,false,,,,{},{a:{a:{a:{a:{a:{a:[[{a:{a:false}}]]}}}}}}],
+// c: "foo",
+// d: {
+// nested: { obj: 'yo' },
+// bar: "baz"
+// },
+// e: 3.6,
+// 1: "numeric value",
+// 2: "anohter numeric value",
+// 2.46: "decimal numeric value",
+// foo: 3.212312310,
+// bar: 0.234242e-10,
+// baz: null,
+// ar: [{}, '1', [], [[[[]]]]],
+// n1: null,
+// n2: undefined,
+// n3: false,
+// n4: "null",
+// n5: "undefined"
+// };
+
+// jtest(obj1);
+
+// var obj2 = {
+// t1: 1232738532270
+// };
+
+// jtest(obj2);
+
+// // a javascript object plus numeric ids
+// var obj3 = {};
+// obj3["foo"] = "bar";
+// obj3[1] = "aaron";
+// obj3[2] = "iba";
+
+// jtest(obj3);
+
+// function jtest(x) {
+// response.write('----------------------------------------------------------------\n\n');
+
+// var str1 = JSON.stringify(x);
+// var str2 = fastJSON.stringify(x);
+
+// var str1_ = JSON.stringify(JSON.parse(str1));
+// var str2_ = fastJSON.stringify(fastJSON.parse(str2));
+
+// response.write([str1,str2].join('\n') + '\n\n');
+// response.write([str1_,str2_].join('\n') + '\n\n');
+// }
+// }
+
+function render_varz() {
+ var varzes = varz.getSnapshot();
+ response.setContentType('text/plain; charset=utf-8');
+ for (var k in varzes) {
+ response.write(k+': '+varzes[k]+'\n');
+ }
+}
+
+function render_extest() {
+ throw new Error("foo");
+}
+
+
+function _diagnosticRecordToHtml(obj) {
+ function valToHtml(o, noborder) {
+ if (typeof (o) != 'object') {
+ return String(o);
+ }
+ var t = TABLE((noborder ? {} : {style: "border-left: 1px solid black; border-top: 1px solid black;"}));
+ if (typeof (o.length) != 'number') {
+ eachProperty(o, function(k, v) {
+ var tr = TR();
+ tr.push(TD({valign: "top", align: "right"}, B(k)));
+ tr.push(TD(valToHtml(v)));
+ t.push(tr);
+ });
+ } else {
+ if (o.length == 0) return "(empty array)";
+ for (var i = 0; i < o.length; ++i) {
+ var tr = TR();
+ tr.push(TD({valign: "top", align: "right"}, B(i)));
+ tr.push(TD(valToHtml(o[i])));
+ t.push(tr);
+ }
+ }
+ return t;
+ }
+ return valToHtml(obj, true);
+}
+
+function render_diagnostics() {
+ var start = Number(request.params.start || 0);
+ var count = Number(request.params.count || 100);
+ var diagnostic_entries = sqlbase.getAllJSON("PAD_DIAGNOSTIC", start, count);
+ var expandArray = request.params.expand || [];
+
+ if (typeof (expandArray) == 'string') expandArray = [expandArray];
+ var expand = {};
+ for (var i = 0; i < expandArray.length; ++i) {
+ expand[expandArray[i]] = true;
+ }
+
+ function makeLink(text, expand, collapse, start0, count0) {
+ start0 = (typeof(start0) == "number" ? start0 : start);
+ count0 = count0 || count;
+ collapse = collapse || [];
+ expand = expand || [];
+
+ var collapseObj = {};
+ for (var i = 0; i < collapse.length; ++i) {
+ collapseObj[collapse[i]] = true;
+ }
+ var expandString =
+ expandArray.concat(expand).filter(function(x) { return ! collapseObj[x] }).map(function(x) { return "expand="+encodeURIComponent(x) }).join("&");
+
+ var url = request.path + "?start="+start0+"&count="+count0+"&"+expandString+(expand.length == 1 ? "#"+md5(expand[0]) : "");
+
+ return A({href: url}, text);
+ }
+
+ var t = TABLE({border: 1, cellpadding: 2, style: "font-family: monospace;"});
+ diagnostic_entries.forEach(function(ent) {
+ var tr = TR()
+ tr.push(TD({valign: "top", align: "right"}, (new Date(Number(ent.id.split("-")[0]))).toString()));
+ tr.push(TD({valign: "top", align: "right"}, ent.id));
+ if (expand[ent.id]) {
+ tr.push(TD(A({name: md5(ent.id)}, makeLink("(collapse)", false, [ent.id])), BR(),
+ _diagnosticRecordToHtml(ent.value)));
+ } else {
+ tr.push(TD(A({name: md5(ent.id)}, makeLink(_diagnosticRecordToHtml({padId: ent.value.padId, disconnectedMessage: ent.value.disconnectedMessage}), [ent.id]))));
+ }
+ t.push(tr);
+ });
+
+ var body = BODY();
+ body.push(P("Showing entries ", start, "-", start+diagnostic_entries.length, ". ",
+ (start > 0 ? makeLink("Show previous "+count+".", [], [], start-count) : ""),
+ (diagnostic_entries.length == count ? makeLink("Show next "+count+".", [], [], start+count) : "")));
+ body.push(t);
+
+ response.write(HTML(body));
+}
+
+//----------------------------------------------------------------
+import("etherpad.billing.billing");
+
+function render_testbillingdirect() {
+ var invoiceId = billing.createInvoice();
+ var ret = billing.directPurchase(invoiceId, 0, 'EEPNET', 500, 'DISCOUNT', {
+ cardType: "Visa",
+ cardNumber: "4501251685453214",
+ cardExpiration: "042019",
+ cardCvv: "123",
+ nameSalutation: "Dr.",
+ nameFirst: "John",
+ nameMiddle: "D",
+ nameLast: "Zamfirescu",
+ nameSuffix: "none",
+ addressStreet: "531 Main St. Apt. 1227",
+ addressStreet2: "",
+ addressCity: "New York",
+ addressState: "NY",
+ addressCountry: "US",
+ addressZip: "10044"
+ }, "https://"+request.host+"/ep/about/testbillingnotify");
+ if (ret.status == 'success') {
+ response.write(P("Success! Invoice id: "+ret.purchaseInfo.invoiceId+" for "+ret.purchaseInfo.cost));
+ } else {
+ response.write(P("Failure: "+ret.toSource()))
+ }
+}
+
+function render_testbillingrecurring() {
+ var invoiceId = billing.createInvoice();
+ var ret = billing.directPurchase(invoiceId, 0, 'EEPNET', 1, 'DISCOUNT', {
+ cardType: "Visa",
+ cardNumber: "4501251685453214",
+ cardExpiration: "042019",
+ cardCvv: "123",
+ nameSalutation: "Dr.",
+ nameFirst: "John",
+ nameMiddle: "D",
+ nameLast: "Zamfirescu",
+ nameSuffix: "none",
+ addressStreet: "531 Main St. Apt. 1227",
+ addressStreet2: "",
+ addressCity: "New York",
+ addressState: "NY",
+ addressCountry: "US",
+ addressZip: "10044"
+ }, "https://"+request.host+"/ep/about/testbillingnotify", true);
+ if (ret.status == 'success') {
+ var transactionId = billing.getTransaction(ret.purchaseInfo.transactionId).txnId;
+ var purchaseId = ret.purchaseInfo.purchaseId;
+ response.write(P("Direct billing successful. PayPal transaction id: ", transactionId));
+
+ invoiceId = billing.createInvoice();
+ ret = billing.asyncRecurringPurchase(
+ invoiceId, purchaseId, transactionId, 500,
+ "https://"+request.host+"/ep/about/testbillingnotify");
+ if (ret.status == 'success') {
+ response.write(P("Woot! Recurrent billing successful! ", ret.purchaseInfo.invoiceId, " for ", ret.purchaseInfo.cost));
+ } else {
+ response.write(P("Failure: "+ret.toSource()));
+ }
+ } else {
+ response.write("Direct billing failure: "+ret.toSource());
+ }
+}
+
+function render_testbillingexpress() {
+ var urlPrefix = "http://"+request.host+request.path;
+ var session = sessions.getSession();
+ var notifyUrl = "http://"+request.host+"/ep/about/testbillingnotify";
+
+ switch (request.params.step) {
+ case '0':
+ response.write(P("You'll be charged $400 for EEPNET. Click the link below to go to paypal."));
+ response.write(A({href: urlPrefix+"?step=1"}, "Link"));
+ break;
+ case '1':
+ var ret = billing.beginExpressPurchase(1, 'EEPNET', 400, 'DISCOUNT', urlPrefix+"?step=2", urlPrefix+"?step=0", notifyUrl);
+ if (ret.status != 'success') {
+ response.write("Error: "+ret.debug.toSource());
+ response.stop();
+ }
+ session.purchaseInfo = ret.purchaseInfo;
+ response.redirect(paypalPurchaseUrl(ret.purchaseInfo.token));
+ break;
+ case '2':
+ var ret = billing.continueExpressPurchase(session.purchaseInfo);
+ if (! ret.status == 'success') {
+ response.write("Error: "+ret.debug.toSource());
+ response.stop();
+ }
+ session.payerInfo = ret.payerInfo;
+
+ response.write(P("You approved the transaction. Click 'confirm' to confirm."));
+ response.write(A({href: urlPrefix+"?step=3"}, "Confirm"));
+ break;
+ case '3':
+ var ret = billing.completeExpressPurchase(session.purchaseInfo, session.payerInfo, notifyUrl);
+ if (ret.status == 'failure') {
+ response.write("Error: "+ret.debug.toSource());
+ response.stop();
+ }
+ if (ret.status == 'pending') {
+ response.write("Your charge is pending. You will be notified by email when your payment clears. Your invoice number is "+session.purchaseInfo.invoiceId);
+ response.stop();
+ }
+
+ response.write(P("Purchase completed: invoice # is "+session.purchaseInfo.invoiceId+" for "+session.purchaseInfo.cost));
+ break;
+ default:
+ response.redirect(request.path+"?step=0");
+ }
+}
+
+//----------------------------------------------------------------
+
+function render_genlicense_get() {
+
+ var t = TABLE({border: 1});
+ function ti(id, label) {
+ t.push(TR(TD({align: "right"}, LABEL({htmlFor: id}, label+":")),
+ TD(INPUT({id: id, name: id, type: 'text', size: 40}))));
+ }
+
+ ti("name", "Name of Licensee");
+ ti("org", "Name of Organization");
+ ti("userQuota", "User Quota");
+
+ t.push(TR(TD({align: "right"}, LABEL("Software Edtition:")),
+ TD( SELECT({name: "edition"},
+ OPTION({value: licensing.getEditionId('PRIVATE_NETWORK_EVALUATION')},
+ "Private Network EVALUATION"),
+ OPTION({value: licensing.getEditionId('PRIVATE_NETWORK')},
+ "Private Network")))));
+
+ ti("expdays", "Number of days until expiration\n(leave blank if never expires)");
+
+ t.push(TR(TD({colspan: 2}, INPUT({type: "submit"}))));
+
+ var f = FORM({action: request.path, method: "post"});
+ f.push(t);
+
+ response.write(HTML(BODY(f)));
+}
+
+function render_genlicense_post() {
+ var name = request.params.name;
+ var org = request.params.org;
+ var editionId = +request.params.edition;
+ var editionName = licensing.getEditionName(editionId);
+ var userQuota = +request.params.userQuota;
+
+ var expiresTime = null;
+ if (request.params.expdays) {
+ expiresTime = +(new Date) + 1000*60*60*24*(+request.params.expdays);
+ }
+
+ var licenseKey = licensing.generateNewKey(
+ name,
+ org,
+ expiresTime,
+ editionId,
+ userQuota
+ );
+
+ // verify
+ if (!licensing.isValidKey(licenseKey)) {
+ throw Error("License key I just created is not valid: "+licenseKey);
+ }
+
+ // TODO: write to database??
+ //
+
+ // display
+ var licenseInfo = licensing.decodeLicenseInfoFromKey(licenseKey);
+ var t = TABLE({border: 1});
+ function line(k, v) {
+ t.push(TR(TD({align: "right"}, k+":"),
+ TD(v)));
+ }
+
+ var key = licenseKey.split(":")[2];
+ if ((key.length % 2) != 0) {
+ key = key + "+";
+ }
+ var keyLine1 = key.substr(0, key.length/2);
+ var keyLine2 = key.substr(key.length/2, key.length);
+
+ line("Name", licenseInfo.personName);
+ line("Organization", licenseInfo.organizationName);
+ line("Key", P(keyLine1, BR(), keyLine2));
+ line("Software Edition", licenseInfo.editionName);
+ line("User Quota", licenseInfo.userQuota);
+ line("Expires", (+licenseInfo.expiresDate > 0) ? licenseInfo.expiresDate.toString() : "(never)");
+
+ response.write(HTML(BODY(t)));
+}
+
+//----------------------------------------------------------------
+
+import("etherpad.metrics.metrics");
+
+function render_flows() {
+ if (request.params.imgId && getSession()[request.params.imgId]) {
+ var arr = getSession()[request.params.imgId];
+ metrics[arr[0]](arr[1], Array.prototype.slice.call(arr[2]));
+ response.stop();
+ }
+
+ function drawHistogram(name, h) {
+ var imgKey = Math.round(Math.random()*1e12);
+ print(IMG({src: request.path+"?imgId="+imgKey}));
+ getSession()[imgKey] = ["respondWithPieChart", name, h];
+ }
+
+ var body = BODY();
+ function print() {
+ for (var i = 0; i < arguments.length; ++i) {
+ body.push(arguments[i]);
+ }
+ }
+
+ var [startDate, endDate] = [7, 1].map(function(a) { return new Date(Date.now() - 86400*1000*a); });
+
+ var allFlows = metrics.getFlows(startDate, endDate);
+
+/*
+ print(P("All flows:"));
+
+ eachProperty(allFlows, function(k, flows) {
+ print(P(k, html(" &raquo; ")));
+ flows.forEach(function(flow) {
+ print(P(flow.toString()));
+ });
+ });
+ response.write(HTML(body));
+ return;
+*/
+
+ print(P("Parsing logs from: "+startDate+" through "+endDate));
+
+ var fs =
+ [metrics.getFunnel(startDate, endDate, ['/ep/about/pricing', '/ep/about/pricing-eepnet', '/ep/store/eepnet-eval-signup'], true),
+ metrics.getFunnel(startDate, endDate, ['/ep/about/pricing', '/ep/about/pricing-free'], true),
+ metrics.getFunnel(startDate, endDate, ['/ep/about/pricing', '/ep/about/pricing-eepod'], true),
+ metrics.getFunnel(startDate, endDate, ['/ep/about/pricing', '/ep/store/eepnet-eval-signup'], true),
+ metrics.getFunnel(startDate, endDate, ['/', '(pad)']),
+ metrics.getFunnel(startDate, endDate, ['/', '/ep/pad/newpad'], true),
+ metrics.getFunnel(startDate, endDate, ['/ep/about/screencast', '(pad)'])];
+
+ function vcnt(i, i2) {
+ return fs[i].visitorCounts[i2];
+ }
+ function pct(f) {
+ return ""+Math.round(f*10000)/100+"%"
+ }
+ function cntAndPct(i, i2) {
+ if (i2 === undefined) { i2 = 1; }
+ return ""+vcnt(i, i2)+" ("+pct(vcnt(i, i2)/vcnt(i, i2-1))+")";
+ }
+ print(P("Of ", vcnt(0, 0), " visitors to the pricing page, ",
+ cntAndPct(0), " of them viewed eepnet, (", cntAndPct(0, 2), " of those downloaded), ",
+ cntAndPct(1), " of them viewed free, and ",
+ cntAndPct(2), " of them viewed eepod. ",
+ cntAndPct(3), " of them clicked on the eval signup link straight up."
+ ),
+ P("Of ", vcnt(4, 0), " visitors to the home page, ",
+ cntAndPct(4), " of them went to a pad page in the same flow; ",
+ cntAndPct(5), " of them clicked the new pad button immediately."),
+ P("Of ", vcnt(6, 0), " vistitors to the screencast page, ",
+ cntAndPct(6), " of them visisted a pad page in the same flow."));
+
+ var origins = metrics.getOrigins(startDate, endDate, true);
+ print(P("Flow first origins: "));
+ drawHistogram("first origins", origins.flowFirsts);
+
+ var firstHits = metrics.getOrigins(startDate, endDate, false, true);
+ var padFirstHits = 0;
+ var nonPadFirstHits = 0;
+ print(P("First paths hit: "));
+ drawHistogram("first paths", firstHits.flowFirsts);
+ firstHits.flowFirsts.filter(function(x) {
+ if (x.value != '/' && ! startsWith(x.value, "/ep/")) {
+ padFirstHits += x.count;
+ return false;
+ }
+ nonPadFirstHits += x.count;
+ return true;
+ });
+ print(P("Some pad page: "+padFirstHits),
+ P("Non-pad page: "+nonPadFirstHits));
+
+ var exitsFromHomepage = metrics.getExits(startDate, endDate, '/', true);
+ print(P("Exits from homepage: "));
+ drawHistogram("exits", exitsFromHomepage.histogram)
+
+ response.write(HTML(body));
+}
+
+//----------------------------------------------------------------
+
+import("etherpad.pad.pad_migrations");
+
+function render_padmigrations() {
+ var residue = (request.params.r || 0);
+ var modulus = (request.params.m || 1);
+ var name = (request.params.n || (residue+"%"+modulus));
+ pad_migrations.runBackgroundMigration(residue, modulus, name);
+ response.write("done");
+ return true;
+}
+
+// TODO: add ability to delete entries?
+// TODO: show sizes?
+function render_cachebrowser() {
+ var path = request.params.path;
+ if (path && path.charAt(0) == ',') {
+ path = path.substr(1);
+ }
+ var pathArg = (path || "");
+ var c = appjet.cache;
+ if (path) {
+ path.split(",").forEach(function(part) {
+ c = c[part];
+ });
+ }
+
+ var d = DIV({style: 'font-family: monospace; text-decoration: none;'});
+
+ d.push(H3("appjet.cache --> "+pathArg.split(",").join(" --> ")));
+
+ var t = TABLE({border: 1});
+ keys(c).sort().forEach(function(k) {
+ var v = c[k];
+ if (v && (typeof(v) == 'object') && (!v.getDate)) {
+ t.push(TR(TD(A({style: 'text-decoration: none;',
+ href: request.path+"?path="+pathArg+","+k}, k))));
+ } else {
+ t.push(TR(TD(k), TD(v)));
+ }
+ });
+
+ d.push(t);
+ response.write(d);
+}
+
+function render_pne_tracker_get() {
+ var data = sqlobj.selectMulti('pne_tracking_data', {}, {});
+ data.sort(function(x, y) { return cmp(y.date, x.date); });
+
+ var t = TABLE();
+
+ var headrow = TR();
+ ['date', 'remote host', 'keyHash', 'name', 'value'].forEach(function(x) {
+ headrow.push(TH({align: "left", style: "padding: 0 6px;"}, x));
+ });
+ t.push(headrow);
+
+ data.forEach(function(d) {
+ var tr = TR();
+
+ tr.push(TD(d.date.toString().split(' ').slice(0,5).join('-')));
+
+ if (d.remoteIp) {
+ tr.push(TD(netutils.getHostnameFromIp(d.remoteIp) || d.remoteIp));
+ } else {
+ tr.push(TD("-"));
+ }
+
+ if (d.keyHash) {
+ tr.push(TD(A({href: '/ep/admin/pne-tracker-lookup-keyhash?hash='+d.keyHash}, d.keyHash)));
+ } else {
+ tr.push(TD("-"));
+ }
+
+ tr.push(TD(d.name));
+ tr.push(TD(d.value));
+
+ t.push(tr);
+ });
+
+ response.write(HTML(HEAD(html("<style>td { border-bottom: 1px solid #ddd; border-right: 1px solid #ddd; padding: 0 6px; } \n tr:hover { background: #ffc; }</style>"),
+ BODY({style: "font-family: monospace; font-size: 12px;"}, t))));
+}
+
+function render_pne_tracker_lookup_keyhash_get() {
+ var hash = request.params.hash;
+ // brute force it
+ var allLicenses = sqlobj.selectMulti('eepnet_signups', {}, {});
+ var record = null;
+ var i = 0;
+ while (i < allLicenses.length && record == null) {
+ var d = allLicenses[i];
+ if (md5(d.licenseKey).substr(0, 16) == hash) {
+ record = d;
+ }
+ i++;
+ }
+ if (!record) {
+ response.write("Not found. Perhaps this was a test download from local development, or a paid customer whose licenses we don't currently look through on this page.");
+ } else {
+ var kl = keys(record).sort();
+ var t = TABLE();
+ kl.forEach(function(k) {
+ t.push(TR(TH({align: "right"}, k+":"),
+ TD({style: "padding-left: 1em;"}, record[k])));
+ });
+ response.write(HTML(BODY(DIV({style: "font-family: monospace;"},
+ DIV(H1("Trial Signup Record:")), t))));
+ }
+}
+
+function render_reload_blog_db_get() {
+ var d = DIV();
+ if (request.params.ok) {
+ d.push(DIV(P("OK")));
+ }
+ d.push(FORM({method: "post", action: request.path},
+ INPUT({type: "submit", value: "Reload Blog DB Now"})));
+ response.write(HTML(BODY(d)));
+}
+
+function render_reload_blog_db_post() {
+ blogcontrol.reloadBlogDb();
+ response.redirect(request.path+"?ok=1");
+}
+
+function render_pro_domain_accounts() {
+ var accounts = sqlobj.selectMulti('pro_accounts', {}, {});
+ var domains = sqlobj.selectMulti('pro_domains', {}, {});
+
+ // build domain map
+ var domainMap = {};
+ domains.forEach(function(d) { domainMap[d.id] = d; });
+ accounts.sort(function(a,b) { return cmp(b.lastLoginDate, a.lastLoginDate); });
+
+ var b = BODY({style: "font-family: monospace;"});
+ b.push(accounts.length + " pro accounts.");
+ var t = TABLE({border: 1});
+ t.push(TR(TH("email"),
+ TH("domain"),
+ TH("lastLogin")));
+ accounts.forEach(function(u) {
+ t.push(TR(TD(u.email),
+ TD(domainMap[u.domainId].subDomain+"."+request.domain),
+ TD(u.lastLoginDate)));
+ });
+
+ b.push(t);
+
+ response.write(HTML(b));
+}
+
+
+function render_beta_valve_get() {
+ var d = DIV(
+ P("Beta Valve Status: ",
+ (pro_beta_control.isValveOpen() ?
+ SPAN({style: "color: green;"}, B("OPEN")) :
+ SPAN({style: "color: red;"}, B("CLOSED")))),
+ P(FORM({action: '/ep/admin/beta-valve-toggle', method: "post"},
+ BUTTON({type: "submit"}, "Toggle"))));
+
+ var t = TABLE({border: 1, cellspacing: 0, cellpadding: 4, style: "font-family: monospace;"});
+ var signupList = sqlobj.selectMulti('pro_beta_signups', {}, {});
+ signupList.sort(function(a, b) {
+ return cmp(b.signupDate, a.signupDate);
+ });
+
+ d.push(HR());
+
+ if (getSession().betaAdminMessage) {
+ d.push(DIV({style: "border: 1px solid #ccc; padding: 1em; background: #eee;"},
+ getSession().betaAdminMessage));
+ delete getSession().betaAdminMessage;
+ }
+
+ d.push(P(signupList.length + " beta signups"));
+
+ d.push(FORM({action: '/ep/admin/beta-invite-multisend', method: 'post'},
+ P("Send ", INPUT({type: 'text', name: 'count', size: 3}), " invites."),
+ INPUT({type: "submit"})));
+
+ t.push(TR(TH("id"), TH("email"), TH("signupDate"),
+ TH("activationDate"), TH("activationCode"), TH(' ')));
+
+ signupList.forEach(function(s) {
+ var tr = TR();
+ tr.push(TD(s.id),
+ TD(s.email),
+ TD(s.signupDate),
+ TD(s.isActivated ? s.activationDate : "-"),
+ TD(s.activationCode));
+ if (!s.activationCode) {
+ tr.push(TD(FORM({action: '/ep/admin/beta-invite-send', method: 'post'},
+ INPUT({type: 'hidden', name: 'id', value: s.id}),
+ INPUT({type: 'submit', value: "Send Invite"}))));
+ } else {
+ tr.push(TD(' '));
+ }
+ t.push(tr);
+ });
+ d.push(t);
+ response.write(d);
+}
+
+function render_beta_valve_toggle_post() {
+ pro_beta_control.toggleValve();
+ response.redirect('/ep/admin/beta-valve');
+}
+
+function render_beta_invite_send_post() {
+ var id = request.params.id;
+ pro_beta_control.sendInvite(id);
+ response.redirect('/ep/admin/beta-valve');
+}
+
+function render_beta_invite_multisend_post() {
+ var count = request.params.count;
+ var signupList = sqlobj.selectMulti('pro_beta_signups', {}, {});
+ signupList.sort(function(a, b) {
+ return cmp(a.signupDate, b.signupDate);
+ });
+ var sent = 0;
+ for (var i = 0; ((i < signupList.length) && (sent < count)); i++) {
+ var record = signupList[i];
+ if (!record.activationCode) {
+ pro_beta_control.sendInvite(record.id);
+ sent++;
+ }
+ }
+ getSession().betaAdminMessage = (sent+" invites sent.");
+ response.redirect('/ep/admin/beta-valve');
+}
+
+function render_usagestats() {
+ response.redirect("/ep/admin/usagestats/");
+}
+
+function render_exceptions() {
+ exceptions.render();
+}
+
+function render_setadminmode() {
+ sessions.setIsAnEtherpadAdmin(
+ String(request.params.v).toLowerCase() == "true");
+ response.redirect("/ep/admin/");
+}
+
+// --------------------------------------------------------------
+// billing-related
+// --------------------------------------------------------------
+
+// some of these functions are only used from selenium tests, and so have no UI.
+
+function render_setdomainpaidthrough() {
+ var domainName = request.params.domain;
+ var when = new Date(Number(request.params.paidthrough));
+ if (! domainName || ! when) {
+ response.write("fail");
+ response.stop();
+ }
+ var domain = domains.getDomainRecordFromSubdomain(domainName);
+ var domainId = domain.id;
+
+ var subscription = team_billing.getSubscriptionForCustomer(domainId);
+ if (subscription) {
+ billing.updatePurchase(subscription.id, {paidThrough: when});
+ team_billing.domainCacheClear(domainId);
+ response.write("OK");
+ } else {
+ response.write("fail");
+ }
+}
+
+function render_runsubscriptions() {
+ team_billing.processAllSubscriptions();
+ response.write("OK");
+}
+
+function render_reset_subscription() {
+ var body = BODY();
+ if (request.isGet) {
+ body.push(FORM({method: "POST"},
+ "Subdomain: ", INPUT({type: "text", name: "subdomain"}), BUTTON({name: "clear"}, "Go")));
+ } else if (request.isPost) {
+ if (! request.params.confirm) {
+ var domain = domains.getDomainRecordFromSubdomain(request.params.subdomain);
+ var admins = pro_accounts.listAllDomainAdmins(domain.id);
+ body.push(P("Domain ", domain.subDomain, ".", request.domain, "; admins:"));
+ var p = UL();
+ admins.forEach(function(admin) {
+ p.push(LI(admin.fullName, " <", admin.email, ">"));
+ });
+ body.push(p);
+ var subscription = team_billing.getSubscriptionForCustomer(domain.id);
+ if (subscription) {
+ body.push(P("Subscription is currently ", subscription.status, ", and paid through: ", checkout.formatDate(subscription.paidThrough), "."))
+ body.push(FORM({method: "POST"},
+ INPUT({type: "hidden", name: "subdomain", value: request.params.subdomain}),
+ "Are you sure? ", BUTTON({name: "confirm", value: "yes"}, "YES")));
+ } else {
+ body.push(P("No current subscription"));
+ }
+ } else {
+ var domain = domains.getDomainRecordFromSubdomain(request.params.subdomain);
+ sqlcommon.inTransaction(function() {
+ team_billing.resetMaxUsers(domain.id);
+ sqlobj.deleteRows('billing_purchase', {customer: domain.id, type: 'subscription'});
+ team_billing.domainCacheClear(domain.id);
+ team_billing.clearRecurringBillingInfo(domain.id);
+ });
+ body.push("Done!")
+ }
+ }
+ body.push(A({href: request.path}, html("&laquo; back")));
+ response.write(HTML(body));
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/control/blogcontrol.js b/trunk/etherpad/src/etherpad/control/blogcontrol.js
new file mode 100644
index 0000000..9ec485d
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/blogcontrol.js
@@ -0,0 +1,199 @@
+/**
+ * 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.
+ */
+
+//blogcontrol
+
+import("jsutils.*");
+import("atomfeed");
+import("funhtml.*");
+
+import("etherpad.globals.*");
+import("etherpad.utils.*");
+import("etherpad.quotas");
+
+//----------------------------------------------------------------
+// bloghelpers
+//----------------------------------------------------------------
+bloghelpers = {};
+bloghelpers.disqusDeveloper = function() {
+ if (isProduction()) {
+ return '';
+ }
+ return [
+ '<script type="text/javascript">',
+ ' var disqus_developer = 1;',
+ '</script>'
+ ].join('\n');
+};
+
+bloghelpers.feedburnerUrl = function() {
+ var name = isProduction() ? "TheEtherPadBlog" : "TheEtherPadBlogDev";
+ return "http://feeds.feedburner.com/"+name;
+};
+
+bloghelpers.feedLink = function() {
+ return [
+ '<link rel="alternate"',
+ ' title="EtherPad Blog Feed"',
+ ' href="', bloghelpers.feedburnerUrl(), '"',
+ ' type="application/rss+xml" />'
+ ].join('');
+};
+
+bloghelpers.dfmt = function(d) {
+ return d.toString().split(' ').slice(0,3).join(' ');
+};
+
+bloghelpers.feedbuttonHtml = function() {
+ var aProps = {
+ href: bloghelpers.feedburnerUrl(),
+ rel: "alternate",
+ type: "application/rss+xml"
+ };
+
+ return SPAN(A(aProps,
+ IMG({src: "http://www.feedburner.com/fb/images/pub/feed-icon32x32.png",
+ alt: "EtherPad Blog Feed",
+ style: "vertical-align:middle; border:0;"}))).toHTML();
+};
+
+bloghelpers.getMaxUsersPerPad = function() {
+ return quotas.getMaxSimultaneousPadEditors()
+};
+
+//----------------------------------------------------------------
+// posts "database"
+//----------------------------------------------------------------
+
+function _wrapPost(p) {
+ var wp = {};
+ keys(p).forEach(function(k) { wp[k] = p[k]; });
+ wp.url = function() {
+ return "http://"+request.host+"/ep/blog/posts/"+p.id;
+ };
+ wp.renderContent = function() {
+ return renderTemplateAsString("blog/posts/"+p.id+".ejs",
+ {post: wp, bloghelpers: bloghelpers});
+ };
+ return wp;
+}
+
+function _addPost(id, title, author, published, updated) {
+ if (!appjet.cache.blogDB) {
+ appjet.cache.blogDB = {
+ posts: [],
+ postMap: {}
+ };
+ }
+ var p = {id: id, title: title, author: author, published: published, updated: updated};
+ appjet.cache.blogDB.posts.push(p);
+ appjet.cache.blogDB.postMap[p.id] = p;
+}
+
+function _getPostById(id) {
+ var p = appjet.cache.blogDB.postMap[id];
+ if (!p) { return null; }
+ return _wrapPost(p);
+}
+
+function _getAllPosts() {
+ return [];
+}
+
+function _sortBlogDB() {
+ appjet.cache.blogDB.posts.sort(function(a,b) { return cmp(b.published, a.published); });
+}
+
+//----------------------------------------------------------------
+// Posts
+//----------------------------------------------------------------
+
+function _initBlogDB() {
+ return;
+}
+
+function reloadBlogDb() {
+ delete appjet.cache.blogDB;
+ _initBlogDB();
+}
+
+function onStartup() {
+ reloadBlogDb();
+}
+
+//----------------------------------------------------------------
+// onRequest
+//----------------------------------------------------------------
+function onRequest(name) {
+ // nothing yet.
+}
+
+//----------------------------------------------------------------
+// main
+//----------------------------------------------------------------
+function render_main() {
+ renderFramed('blog/blog_main_body.ejs',
+ {posts: _getAllPosts(), bloghelpers: bloghelpers});
+}
+
+//----------------------------------------------------------------
+// render_feed
+//----------------------------------------------------------------
+function render_feed() {
+ var lastModified = new Date(); // TODO: most recent of all entries modified
+
+ var entries = [];
+ _getAllPosts().forEach(function(post) {
+ entries.push({
+ title: post.title,
+ author: post.author,
+ published: post.published,
+ updated: post.updated,
+ href: post.url(),
+ content: post.renderContent()
+ });
+ });
+
+ response.setContentType("application/atom+xml; charset=utf-8");
+
+ response.write(atomfeed.renderFeed(
+ "The EtherPad Blog", new Date(), entries,
+ "http://"+request.host+"/ep/blog/"));
+}
+
+//----------------------------------------------------------------
+// render_post
+//----------------------------------------------------------------
+function render_post(name) {
+ var p = _getPostById(name);
+ if (!p) {
+ return false;
+ }
+ renderFramed('blog/blog_post_body.ejs', {
+ post: p, bloghelpers: bloghelpers,
+ posts: _getAllPosts()
+ });
+ return true;
+}
+
+//----------------------------------------------------------------
+// render_new_from_etherpad()
+//----------------------------------------------------------------
+
+function render_new_from_etherpad() {
+ return "";
+}
+
diff --git a/trunk/etherpad/src/etherpad/control/connection_diagnostics_control.js b/trunk/etherpad/src/etherpad/control/connection_diagnostics_control.js
new file mode 100644
index 0000000..aaa1bb3
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/connection_diagnostics_control.js
@@ -0,0 +1,87 @@
+/**
+ * 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("etherpad.utils.*");
+import("etherpad.helpers.*");
+
+//----------------------------------------------------------------
+// Connection diagnostics
+//----------------------------------------------------------------
+
+/*
+function _getDiagnosticsCollection() {
+ var db = storage.getRoot("connection_diagnostics");
+ if (!db.diagnostics) {
+ db.diagnostics = new StorableCollection();
+ }
+ return db.diagnostics;
+}
+*/
+
+function render_main_get() {
+ /*
+ var diagnostics = _getDiagnosticsCollection();
+
+ var data = new StorableObject({
+ ip: request.clientAddr,
+ userAgent: request.headers['User-Agent']
+ });
+
+ diagnostics.add(data);
+
+ helpers.addClientVars({
+ diagnosticStorableId: data.id
+ });
+*/
+ renderFramed("main/connection_diagnostics_body.ejs");
+}
+
+function render_submitdata_post() {
+ response.setContentType('text/plain; charset=utf-8');
+ /*
+ var id = request.params.diagnosticStorableId;
+ var storedData = storage.getStorable(id);
+ if (!storedData) {
+ response.write("Error retreiving diagnostics record.");
+ response.stop();
+ }
+ var diagnosticData = JSON.parse(request.params.dataJson);
+ eachProperty(diagnosticData, function(k,v) {
+ storedData[k] = v;
+ });
+*/
+ response.write("OK");
+}
+
+function render_submitemail_post() {
+ response.setContentType('text/plain; charset=utf-8');
+ /*
+ var id = request.params.diagnosticStorableId;
+ var data = storage.getStorable(id);
+ if (!data) {
+ response.write("Error retreiving diagnostics record.");
+ response.stop();
+ }
+ var email = request.params.email;
+ if (!isValidEmail(email)) {
+ response.write("Invalid email address.");
+ response.stop();
+ }
+ data.email = email;
+*/
+ response.write("OK");
+}
+
diff --git a/trunk/etherpad/src/etherpad/control/global_pro_account_control.js b/trunk/etherpad/src/etherpad/control/global_pro_account_control.js
new file mode 100644
index 0000000..65d2124
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/global_pro_account_control.js
@@ -0,0 +1,143 @@
+/**
+ * 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("funhtml.*");
+import("stringutils");
+import("stringutils.*");
+import("email.sendEmail");
+import("cache_utils.syncedWithCache");
+
+import("etherpad.utils.*");
+import("etherpad.sessions.getSession");
+
+import("etherpad.pro.domains");
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.pro_utils");
+
+jimport("java.lang.System.out.println");
+
+function onRequest() {
+ if (!getSession().oldFormData) {
+ getSession().oldFormData = {};
+ }
+ return false; // not handled yet.
+}
+
+function _errorDiv() {
+ var m = getSession().proAccountControlError;
+ delete getSession().proAccountControlError;
+ if (m) {
+ return DIV({className: "error"}, m);
+ }
+ return "";
+}
+
+function _redirectError(m) {
+ getSession().proAccountControlError = m;
+ response.redirect(request.path);
+}
+
+
+function render_main_get() {
+ response.redirect('/ep/pro-account/sign-in');
+}
+
+function render_sign_in_get() {
+ renderFramed('pro-account/sign-in.ejs', {
+ oldData: getSession().oldFormData,
+ errorDiv: _errorDiv
+ });
+}
+
+
+function render_sign_in_post() {
+ var email = trim(request.params.email);
+ var password = request.params.password;
+ var subDomain = request.params.subDomain;
+
+ subDomain = subDomain.toLowerCase();
+
+ getSession().oldFormData.email = email;
+ getSession().oldFormData.subDomain = subDomain;
+
+ var domainRecord = domains.getDomainRecordFromSubdomain(subDomain);
+ if (!domainRecord) {
+ _redirectError("Site address not found: "+subDomain+"."+request.host);
+ }
+
+ var instantSigninKey = stringutils.randomString(20);
+ syncedWithCache('global_signin_passwords', function(c) {
+ c[instantSigninKey] = {
+ email: email,
+ password: password
+ };
+ });
+
+ response.redirect(
+ "https://"+subDomain+"."+httpsHost(request.host)+
+ "/ep/account/sign-in?instantSigninKey="+instantSigninKey);
+}
+
+function render_recover_get() {
+ renderFramed('pro-account/recover.ejs', {
+ oldData: getSession().oldFormData,
+ errorDiv: _errorDiv
+ });
+}
+
+function render_recover_post() {
+
+ function _recoverLink(accountRecord, domainRecord) {
+ var host = (domainRecord.subDomain + "." + httpsHost(request.host));
+ return (
+ "https://"+host+"/ep/account/forgot-password?instantSubmit=1&email="+
+ encodeURIComponent(accountRecord.email));
+ }
+
+ var email = trim(request.params.email);
+
+ // lookup all domains associated with this email
+ var accountList = pro_accounts.getAllAccountsWithEmail(email);
+ println("account records matching ["+email+"]: "+accountList.length);
+
+ var domainList = [];
+ for (var i = 0; i < accountList.length; i++) {
+ domainList[i] = domains.getDomainRecord(accountList[i].domainId);
+ }
+
+ if (accountList.length == 0) {
+ _redirectError("No accounts were found associated with the email address \""+email+"\".");
+ }
+ if (accountList.length == 1) {
+ response.redirect(_recoverLink(accountList[0], domainList[0]));
+ }
+ if (accountList.length > 1) {
+ var fromAddr = '"EtherPad" <noreply@pad.spline.inf.fu-berlin.de>';
+ var subj = "EtherPad: account information";
+ var body = renderTemplateAsString(
+ 'pro/account/global-multi-domain-recover-email.ejs', {
+ accountList: accountList,
+ domainList: domainList,
+ recoverLink: _recoverLink,
+ email: email
+ }
+ );
+ sendEmail(email, fromAddr, subj, {}, body);
+ pro_utils.renderFramedMessage("Instructions have been sent to "+email+".");
+ }
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/control/historycontrol.js b/trunk/etherpad/src/etherpad/control/historycontrol.js
new file mode 100644
index 0000000..a78cfad
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/historycontrol.js
@@ -0,0 +1,226 @@
+/**
+ * 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("etherpad.utils.render404");
+import("etherpad.pad.model");
+import("etherpad.collab.collab_server");
+import("etherpad.collab.ace.easysync2.*");
+import("jsutils.eachProperty");
+
+function _urlCache() {
+ if (!appjet.cache.historyUrlCache) {
+ appjet.cache.historyUrlCache = {};
+ }
+ return appjet.cache.historyUrlCache;
+}
+
+function _replyWithJSONAndCache(obj) {
+ obj.apiversion = _VERSION;
+ var output = fastJSON.stringify(obj);
+ _urlCache()[request.path] = output;
+ response.write(output);
+ response.stop();
+}
+
+function _replyWithJSON(obj) {
+ obj.apiversion = _VERSION;
+ response.write(fastJSON.stringify(obj));
+ response.stop();
+}
+
+function _error(msg, num) {
+ _replyWithJSON({error: String(msg), errornum: num});
+}
+
+var _VERSION = 1;
+
+var _ERROR_REVISION_NUMBER_TOO_LARGE = 14;
+
+function _do_text(padId, r) {
+ if (! padId) render404();
+ model.accessPadGlobal(padId, function(pad) {
+ if (! pad.exists()) {
+ render404();
+ }
+ if (r > pad.getHeadRevisionNumber()) {
+ _error("Revision number too large", _ERROR_REVISION_NUMBER_TOO_LARGE);
+ }
+ var text = pad.getInternalRevisionText(r);
+ text = _censorText(text);
+ _replyWithJSONAndCache({ text: text });
+ });
+}
+
+function _do_stat(padId) {
+ var obj = {};
+ if (! padId) {
+ obj.exists = false;
+ }
+ else {
+ model.accessPadGlobal(padId, function(pad) {
+ if (! pad.exists()) {
+ obj.exists = false;
+ }
+ else {
+ obj.exists = true;
+ obj.latestRev = pad.getHeadRevisionNumber();
+ }
+ });
+ }
+ _replyWithJSON(obj);
+}
+
+function _censorText(text) {
+ // may not change length of text
+ return text.replace(/(http:\/\/pad.spline.inf.fu-berlin.de\/)(\w+)/g, function(url, u1, u2) {
+ return u1 + u2.replace(/\w/g, '-');
+ });
+}
+
+function _do_changes(padId, first, last) {
+ if (! padId) render404();
+
+ var charPool = [];
+ var changeList = [];
+
+ function charPoolText(txt) {
+ charPool.push(txt);
+ return _encodeVarInt(txt.length);
+ }
+
+ model.accessPadGlobal(padId, function(pad) {
+
+ if (first > pad.getHeadRevisionNumber() || last > pad.getHeadRevisionNumber()) {
+ _error("Revision number too large", _ERROR_REVISION_NUMBER_TOO_LARGE);
+ }
+
+ var curAText = Changeset.makeAText("\n");
+ if (first > 0) {
+ curAText = pad.getInternalRevisionAText(first - 1);
+ }
+ curAText.text = _censorText(curAText.text);
+ var lastTimestamp = null;
+ for(var r=first;r<=last;r++) {
+ var binRev = [];
+ var timestamp = +pad.getRevisionDate(r);
+ binRev.push(_encodeTimeStamp(timestamp, lastTimestamp));
+ lastTimestamp = timestamp;
+ binRev.push(_encodeVarInt(1)); // fake author
+
+ var c = pad.getRevisionChangeset(r);
+ var splices = Changeset.toSplices(c);
+ splices.forEach(function (splice) {
+ var startChar = splice[0];
+ var endChar = splice[1];
+ var newText = splice[2];
+ oldText = curAText.text.substring(startChar, endChar);
+
+ if (oldText.length == 0) {
+ binRev.push('+');
+ binRev.push(_encodeVarInt(startChar));
+ binRev.push(charPoolText(newText));
+ }
+ else if (newText.length == 0) {
+ binRev.push('-');
+ binRev.push(_encodeVarInt(startChar));
+ binRev.push(charPoolText(oldText));
+ }
+ else {
+ binRev.push('*');
+ binRev.push(_encodeVarInt(startChar));
+ binRev.push(charPoolText(oldText));
+ binRev.push(charPoolText(newText));
+ }
+ });
+ changeList.push(binRev.join(''));
+
+ curAText = Changeset.applyToAText(c, curAText, pad.pool());
+ }
+
+ _replyWithJSONAndCache({charPool: charPool.join(''), changes: changeList.join(',')});
+
+ });
+}
+
+function render_history(padOpaqueRef, rest) {
+ if (_urlCache()[request.path]) {
+ response.write(_urlCache()[request.path]);
+ response.stop();
+ return true;
+ }
+ var padId;
+ if (padOpaqueRef == "CSi1xgbFXl" || padOpaqueRef == "13sentences") {
+ // made-up, hard-coded opaque ref, should be a table for these
+ padId = "jbg5HwzUX8";
+ }
+ else if (padOpaqueRef == "dO1j7Zf34z" || padOpaqueRef == "foundervisa") {
+ // made-up, hard-coded opaque ref, should be a table for these
+ padId = "3hS7kQyDXG";
+ }
+ else {
+ padId = null;
+ }
+ var regexResult;
+ if ((regexResult = /^stat$/.exec(rest))) {
+ _do_stat(padId);
+ }
+ else if ((regexResult = /^text\/(\d+)$/.exec(rest))) {
+ var r = Number(regexResult[1]);
+ _do_text(padId, r);
+ }
+ else if ((regexResult = /^changes\/(\d+)-(\d+)$/.exec(rest))) {
+ _do_changes(padId, Number(regexResult[1]), Number(regexResult[2]));
+ }
+ else {
+ return false;
+ }
+}
+
+function _encodeVarInt(num) {
+ var n = +num;
+ if (isNaN(n)) {
+ throw new Error("Can't encode non-number "+num);
+ }
+ var chars = [];
+ var done = false;
+ while (! done) {
+ if (n < 32) done = true;
+ var nd = (n % 32);
+ if (chars.length > 0) {
+ // non-first, will become non-last digit
+ nd = (nd | 32);
+ }
+ chars.push(_BASE64_DIGITS[nd]);
+ n = Math.floor(n / 32)
+ }
+ return chars.reverse().join('');
+}
+var _BASE64_DIGITS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._";
+
+function _encodeTimeStamp(tMillis, baseMillis) {
+ var t = Math.floor(tMillis/1000);
+ var base = Math.floor(baseMillis/1000);
+ var absolute = ["+", t];
+ var resultPair = absolute;
+ if (((typeof base) == "number") && base <= t) {
+ var relative = ["", t - base];
+ if (relative[1] < absolute[1]) {
+ resultPair = relative;
+ }
+ }
+ return resultPair[0] + _encodeVarInt(resultPair[1]);
+}
diff --git a/trunk/etherpad/src/etherpad/control/loadtestcontrol.js b/trunk/etherpad/src/etherpad/control/loadtestcontrol.js
new file mode 100644
index 0000000..2a4e3f7
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/loadtestcontrol.js
@@ -0,0 +1,93 @@
+/**
+ * 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("etherpad.pad.model");
+import("etherpad.pad.padutils");
+import("etherpad.pad.dbwriter");
+import("etherpad.pad.activepads");
+import("etherpad.control.pad.pad_control");
+import("etherpad.collab.collab_server");
+
+// NOTE: we need to talk before enabling this again, for potential security vulnerabilities.
+var LOADTEST_ENABLED = false;
+
+function onRequest() {
+ if (!LOADTEST_ENABLED) {
+ response.forbid();
+ }
+}
+
+function render_createpad() {
+ var padId = request.params.padId;
+
+ padutils.accessPadLocal(padId, function(pad) {
+ if (! pad.exists()) {
+ pad.create(pad_control.getDefaultPadText());
+ }
+ });
+
+ activepads.touch(padId);
+ response.write("OK");
+}
+
+function render_readpad() {
+ var padId = request.params.padId;
+
+ padutils.accessPadLocal(padId, function(pad) {
+ /* nothing */
+ });
+
+ activepads.touch(padId);
+ response.write("OK");
+}
+
+function render_appendtopad() {
+ var padId = request.params.padId;
+ var text = request.params.text;
+
+ padutils.accessPadLocal(padId, function(pad) {
+ collab_server.appendPadText(pad, text);
+ });
+
+ activepads.touch(padId);
+ response.write("OK");
+}
+
+function render_flushpad() {
+ var padId = request.params.padId;
+
+ padutils.accessPadLocal(padId, function(pad) {
+ dbwriter.writePadNow(pad, true);
+ });
+
+ activepads.touch(padId);
+ response.write("OK");
+}
+
+function render_setpadtext() {
+ var padId = request.params.padId;
+ var text = request.params.text;
+
+ padutils.accessPadLocal(padId, function(pad) {
+ collab_server.setPadText(pad, text);
+ });
+
+ activepads.touch(padId);
+ response.write("OK");
+}
+
+
+
diff --git a/trunk/etherpad/src/etherpad/control/maincontrol.js b/trunk/etherpad/src/etherpad/control/maincontrol.js
new file mode 100644
index 0000000..261ddaf
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/maincontrol.js
@@ -0,0 +1,54 @@
+/**
+ * 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("funhtml.*");
+import("stringutils.toHTML");
+
+import("etherpad.globals.*");
+import("etherpad.helpers.*");
+import("etherpad.licensing");
+import("etherpad.log");
+import("etherpad.utils.*");
+
+import("etherpad.control.blogcontrol");
+
+import("etherpad.pad.model");
+import("etherpad.collab.collab_server");
+
+jimport("java.lang.System.out.println");
+
+//----------------------------------------------------------------
+
+function render_main() {
+ if (request.path == '/ep/') {
+ response.redirect('/');
+ }
+ renderFramed('main/home.ejs', {
+ newFromEtherpad: blogcontrol.render_new_from_etherpad()
+ });
+ return true;
+}
+
+function render_support() {
+ renderFramed("main/support_body.ejs");
+}
+
+function render_changelog_get() {
+ renderFramed("main/changelog.ejs");
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/control/pad/pad_changeset_control.js b/trunk/etherpad/src/etherpad/control/pad/pad_changeset_control.js
new file mode 100644
index 0000000..5af7ed0
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pad/pad_changeset_control.js
@@ -0,0 +1,280 @@
+/**
+ * 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("etherpad.helpers");
+import("etherpad.pad.model");
+import("etherpad.pad.padutils");
+import("etherpad.utils.*");
+import("fastJSON");
+import("etherpad.collab.server_utils.*");
+import("etherpad.collab.ace.easysync2.{AttribPool,Changeset}");
+import("cache_utils.syncedWithCache");
+import("etherpad.log");
+jimport("net.appjet.common.util.LimitedSizeMapping");
+
+import("stringutils");
+import("stringutils.sprintf");
+
+var _JSON_CACHE_SIZE = 10000;
+
+// to clear: appjet.cache.pad_changeset_control.jsoncache.map.clear()
+function _getJSONCache() {
+ return syncedWithCache('pad_changeset_control.jsoncache', function(cache) {
+ if (! cache.map) {
+ cache.map = new LimitedSizeMapping(_JSON_CACHE_SIZE);
+ }
+ return cache.map;
+ });
+}
+
+var _profiler = {
+ t: 0,
+ laps: [],
+ active: false,
+ start: function() {
+ _profiler.t = +new Date;
+ _profiler.laps = [];
+ //_profiler.active = true;
+ },
+ lap: function(name) {
+ if (! _profiler.active) return;
+ var t2 = +new Date;
+ _profiler.laps.push([name, t2 - _profiler.t]);
+ },
+ dump: function(info) {
+ if (! _profiler.active) return;
+ function padright(s, len) {
+ s = String(s);
+ return s + new Array(Math.max(0,len-s.length+1)).join(' ');
+ }
+ var str = padright(info,20)+": ";
+ _profiler.laps.forEach(function(e) {
+ str += padright(e.join(':'), 8);
+ });
+ java.lang.System.out.println(str);
+ },
+ stop: function() {
+ _profiler.active = false;
+ }
+};
+
+function onRequest() {
+ _profiler.start();
+
+ var parts = request.path.split('/');
+ // TODO(kroo): create a mapping between padId and read-only id
+ var urlId = parts[4];
+ var padId = parseUrlId(urlId).localPadId;
+ // var revisionId = parts[5];
+
+ padutils.accessPadLocal(padId, function(pad) {
+ if (! pad.exists() && pad.getSupportsTimeSlider()) {
+ response.forbid();
+ }
+ }, 'r');
+
+ // use the query string to specify start and end revision numbers
+ var startRev = parseInt(request.params["s"]);
+ var endRev = startRev + 100 * parseInt(request.params["g"]);
+ var granularity = parseInt(request.params["g"]);
+
+ _profiler.lap('A');
+ var changesetsJson =
+ getCacheableChangesetInfoJSON(padId, startRev, endRev, granularity);
+ _profiler.lap('X');
+
+ //TODO(kroo): set content-type to javascript
+ response.write(changesetsJson);
+ _profiler.lap('J');
+ if (request.acceptsGzip) {
+ response.setGzip(true);
+ }
+
+ _profiler.lap('Z');
+ _profiler.dump(startRev+'/'+granularity+'/'+endRev);
+ _profiler.stop();
+
+ return true;
+}
+
+function getCacheableChangesetInfoJSON(padId, startNum, endNum, granularity) {
+ padutils.accessPadLocal(padId, function(pad) {
+ var lastRev = pad.getHeadRevisionNumber();
+ if (endNum > lastRev+1) {
+ endNum = lastRev+1;
+ }
+ endNum = Math.floor(endNum / granularity)*granularity;
+ }, 'r');
+
+ var cacheKey = "C/"+startNum+"/"+endNum+"/"+granularity+"/"+
+ padutils.getGlobalPadId(padId);
+
+ var cache = _getJSONCache();
+
+ var cachedJson = cache.get(cacheKey);
+ if (cachedJson) {
+ cache.touch(cacheKey);
+ //java.lang.System.out.println("HIT! "+cacheKey);
+ return cachedJson;
+ }
+ else {
+ var result = getChangesetInfo(padId, startNum, endNum, granularity);
+ var json = fastJSON.stringify(result);
+ cache.put(cacheKey, json);
+ //java.lang.System.out.println("MISS! "+cacheKey);
+ return json;
+ }
+}
+
+// uses changesets whose numbers are between startRev (inclusive)
+// and endRev (exclusive); 0 <= startNum < endNum
+function getChangesetInfo(padId, startNum, endNum, granularity) {
+ var forwardsChangesets = [];
+ var backwardsChangesets = [];
+ var timeDeltas = [];
+ var apool = new AttribPool();
+
+ var callId = stringutils.randomString(10);
+
+ log.custom("getchangesetinfo", {event: "start", callId:callId,
+ padId:padId, startNum:startNum,
+ endNum:endNum, granularity:granularity});
+
+ // This function may take a while and avoids holding a lock on the pad.
+ // Though the pad may change during execution of this function,
+ // after we retrieve the HEAD revision number, all other accesses
+ // are unaffected by new revisions being added to the pad.
+
+ var lastRev;
+ padutils.accessPadLocal(padId, function(pad) {
+ lastRev = pad.getHeadRevisionNumber();
+ }, 'r');
+
+ if (endNum > lastRev+1) {
+ endNum = lastRev+1;
+ }
+ endNum = Math.floor(endNum / granularity)*granularity;
+
+ var lines;
+ padutils.accessPadLocal(padId, function(pad) {
+ lines = _getPadLines(pad, startNum-1);
+ }, 'r');
+ _profiler.lap('L');
+
+ var compositeStart = startNum;
+ while (compositeStart < endNum) {
+ var whileBodyResult = padutils.accessPadLocal(padId, function(pad) {
+ _profiler.lap('c0');
+ if (compositeStart + granularity > endNum) {
+ return "break";
+ }
+ var compositeEnd = compositeStart + granularity;
+ var forwards = _composePadChangesets(pad, compositeStart, compositeEnd);
+ _profiler.lap('c1');
+ var backwards = Changeset.inverse(forwards, lines.textlines,
+ lines.alines, pad.pool());
+
+ _profiler.lap('c2');
+ Changeset.mutateAttributionLines(forwards, lines.alines, pad.pool());
+ _profiler.lap('c3');
+ Changeset.mutateTextLines(forwards, lines.textlines);
+ _profiler.lap('c4');
+
+ var forwards2 = Changeset.moveOpsToNewPool(forwards, pad.pool(), apool);
+ _profiler.lap('c5');
+ var backwards2 = Changeset.moveOpsToNewPool(backwards, pad.pool(), apool);
+ _profiler.lap('c6');
+ function revTime(r) {
+ var date = pad.getRevisionDate(r);
+ var s = Math.floor((+date)/1000);
+ //java.lang.System.out.println("time "+r+": "+s);
+ return s;
+ }
+
+ var t1, t2;
+ if (compositeStart == 0) {
+ t1 = revTime(0);
+ }
+ else {
+ t1 = revTime(compositeStart - 1);
+ }
+ t2 = revTime(compositeEnd - 1);
+ timeDeltas.push(t2 - t1);
+
+ _profiler.lap('c7');
+ forwardsChangesets.push(forwards2);
+ backwardsChangesets.push(backwards2);
+
+ compositeStart += granularity;
+ }, 'r');
+ if (whileBodyResult == "break") {
+ break;
+ }
+ }
+
+ log.custom("getchangesetinfo", {event: "finish", callId:callId,
+ padId:padId, startNum:startNum,
+ endNum:endNum, granularity:granularity});
+
+ return { forwardsChangesets:forwardsChangesets,
+ backwardsChangesets:backwardsChangesets,
+ apool: apool.toJsonable(),
+ actualEndNum: endNum,
+ timeDeltas: timeDeltas };
+}
+
+// Compose a series of consecutive changesets from a pad.
+// precond: startNum < endNum
+function _composePadChangesets(pad, startNum, endNum) {
+ if (endNum - startNum > 1) {
+ var csFromPad = pad.getCoarseChangeset(startNum, endNum - startNum);
+ if (csFromPad) {
+ //java.lang.System.out.println("HIT! "+startNum+"-"+endNum);
+ return csFromPad;
+ }
+ else {
+ //java.lang.System.out.println("MISS! "+startNum+"-"+endNum);
+ }
+ //java.lang.System.out.println("composePadChangesets: "+startNum+','+endNum);
+ }
+ var changeset = pad.getRevisionChangeset(startNum);
+ for(var r=startNum+1; r<endNum; r++) {
+ var cs = pad.getRevisionChangeset(r);
+ changeset = Changeset.compose(changeset, cs, pad.pool());
+ }
+ return changeset;
+}
+
+// Get arrays of text lines and attribute lines for a revision
+// of a pad.
+function _getPadLines(pad, revNum) {
+ var atext;
+ _profiler.lap('PL0');
+ if (revNum >= 0) {
+ atext = pad.getInternalRevisionAText(revNum);
+ }
+ else {
+ atext = Changeset.makeAText("\n");
+ }
+ _profiler.lap('PL1');
+ var result = {};
+ result.textlines = Changeset.splitTextLines(atext.text);
+ _profiler.lap('PL2');
+ result.alines = Changeset.splitAttributionLines(atext.attribs,
+ atext.text);
+ _profiler.lap('PL3');
+ return result;
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/control/pad/pad_control.js b/trunk/etherpad/src/etherpad/control/pad/pad_control.js
new file mode 100644
index 0000000..3c32202
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pad/pad_control.js
@@ -0,0 +1,780 @@
+/**
+ * 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("funhtml.*");
+import("comet");
+import("email.sendEmail");
+import("fastJSON");
+import("jsutils.eachProperty");
+import("sqlbase.sqlbase");
+import("stringutils.{toHTML,md5}");
+import("stringutils");
+
+import("etherpad.collab.collab_server");
+import("etherpad.debug.dmesg");
+import("etherpad.globals.*");
+import("etherpad.helpers");
+import("etherpad.licensing");
+import("etherpad.quotas");
+import("etherpad.log");
+import("etherpad.log.{logRequest,logException}");
+import("etherpad.sessions");
+import("etherpad.sessions.getSession");
+import("etherpad.utils.*");
+import("etherpad.pro.pro_padmeta");
+import("etherpad.pro.pro_pad_db");
+import("etherpad.pro.pro_utils");
+import("etherpad.pro.pro_accounts.getSessionProAccount");
+import("etherpad.pro.domains");
+import("etherpad.pro.pro_config");
+import("etherpad.pne.pne_utils");
+import("etherpad.pro.pro_quotas");
+
+import("etherpad.pad.revisions");
+import("etherpad.pad.chatarchive");
+import("etherpad.pad.model");
+import("etherpad.pad.padutils");
+import("etherpad.pad.padusers");
+import("etherpad.control.pad.pad_view_control");
+import("etherpad.control.pad.pad_changeset_control");
+import("etherpad.control.pad.pad_importexport_control");
+import("etherpad.collab.readonly_server");
+
+import("dispatch.{Dispatcher,PrefixMatcher,DirMatcher,forward}");
+
+jimport("java.lang.System.out.println");
+
+var DISABLE_PAD_CREATION = false;
+
+function onStartup() {
+ sqlbase.createJSONTable("PAD_DIAGNOSTIC");
+}
+
+function onRequest() {
+
+ // TODO: take a hard look at /ep/pad/FOO/BAR/ dispatching.
+ // Perhaps standardize on /ep/pad/<pad-id>/foo
+ if (request.path.indexOf('/ep/pad/auth/') == 0) {
+ if (request.isGet) {
+ return render_auth_get();
+ }
+ if (request.isPost) {
+ return render_auth_post();
+ }
+ }
+
+ if (pro_utils.isProDomainRequest()) {
+ pro_quotas.perRequestBillingCheck();
+ }
+
+ var disp = new Dispatcher();
+ disp.addLocations([
+ [PrefixMatcher('/ep/pad/view/'), forward(pad_view_control)],
+ [PrefixMatcher('/ep/pad/changes/'), forward(pad_changeset_control)],
+ [PrefixMatcher('/ep/pad/impexp/'), forward(pad_importexport_control)],
+ [PrefixMatcher('/ep/pad/export/'), pad_importexport_control.renderExport]
+ ]);
+ return disp.dispatch();
+}
+
+//----------------------------------------------------------------
+// utils
+//----------------------------------------------------------------
+
+function getDefaultPadText() {
+ if (pro_utils.isProDomainRequest()) {
+ return pro_config.getConfig().defaultPadText;
+ }
+ return renderTemplateAsString("misc/pad_default.ejs", {padUrl: request.url.split("?", 1)[0]});
+}
+
+function assignName(pad, userId) {
+ if (padusers.isGuest(userId)) {
+ // use pad-specific name if possible
+ var userData = pad.getAuthorData(userId);
+ var nm = (userData && userData.name) || padusers.getUserName() || null;
+
+ // don't let name guest typed in last once we've assigned a name
+ // for this pad, so the user can change it
+ delete getSession().guestDisplayName;
+
+ return nm;
+ }
+ else {
+ return padusers.getUserName();
+ }
+}
+
+function assignColorId(pad, userId) {
+ // use pad-specific color if possible
+ var userData = pad.getAuthorData(userId);
+ if (userData && ('colorId' in userData)) {
+ return userData.colorId;
+ }
+
+ // assign random unique color
+ function r(n) {
+ return Math.floor(Math.random() * n);
+ }
+ var colorsUsed = {};
+ var users = collab_server.getConnectedUsers(pad);
+ var availableColors = [];
+ users.forEach(function(u) {
+ colorsUsed[u.colorId] = true;
+ });
+ for (var i = 0; i < COLOR_PALETTE.length; i++) {
+ if (!colorsUsed[i]) {
+ availableColors.push(i);
+ }
+ }
+ if (availableColors.length > 0) {
+ return availableColors[r(availableColors.length)];
+ } else {
+ return r(COLOR_PALETTE.length);
+ }
+}
+
+function _getPrivs() {
+ return {
+ maxRevisions: quotas.getMaxSavedRevisionsPerPad()
+ };
+}
+
+//----------------------------------------------------------------
+// linkfile (a file that users can save that redirects them to
+// a particular pad; auto-download)
+//----------------------------------------------------------------
+function render_linkfile() {
+ var padId = request.params.padId;
+
+ renderHtml("pad/pad_download_link.ejs", {
+ padId: padId
+ });
+
+ response.setHeader("Content-Disposition", "attachment; filename=\""+padId+".html\"");
+}
+
+//----------------------------------------------------------------
+// newpad
+//----------------------------------------------------------------
+
+function render_newpad() {
+ var session = getSession();
+ var padId;
+
+ if (pro_utils.isProDomainRequest()) {
+ padId = pro_pad_db.getNextPadId();
+ } else {
+ padId = randomUniquePadId();
+ }
+
+ session.instantCreate = padId;
+ response.redirect("/"+padId);
+}
+
+// Tokbox
+function render_newpad_xml_post() {
+ var localPadId;
+ if (pro_utils.isProDomainRequest()) {
+ localPadId = pro_pad_db.getNextPadId();
+ } else {
+ localPadId = randomUniquePadId();
+ }
+ // <RAFTER>
+ if (DISABLE_PAD_CREATION) {
+ if (! pro_utils.isProDomainRequest()) {
+ utils.render500();
+ return;
+ }
+ }
+ // </RAFTER>
+
+ padutils.accessPadLocal(localPadId, function(pad) {
+ if (!pad.exists()) {
+ pad.create(getDefaultPadText());
+ }
+ });
+ response.setContentType('text/plain; charset=utf-8');
+ response.write([
+ '<newpad>',
+ '<url>http://'+request.host+'/'+localPadId+'</url>',
+ '</newpad>'
+ ].join('\n'));
+}
+
+//----------------------------------------------------------------
+// pad
+//----------------------------------------------------------------
+
+function _createIfNecessary(localPadId, pad) {
+ if (pad.exists()) {
+ delete getSession().instantCreate;
+ return;
+ }
+ // make sure localPadId is valid.
+ var validPadId = padutils.makeValidLocalPadId(localPadId);
+ if (localPadId != validPadId) {
+ response.redirect('/'+validPadId);
+ }
+ // <RAFTER>
+ if (DISABLE_PAD_CREATION) {
+ if (! pro_utils.isProDomainRequest()) {
+ response.redirect("/ep/pad/create?padId="+encodeURIComponent(localPadId));
+ return;
+ }
+ }
+ // </RAFTER>
+ // tokbox may use createImmediately
+ if (request.params.createImmediately || getSession().instantCreate == localPadId) {
+ pad.create(getDefaultPadText());
+ delete getSession().instantCreate;
+ return;
+ }
+ response.redirect("/ep/pad/create?padId="+encodeURIComponent(localPadId));
+}
+
+function _promptForMobileDevices(pad) {
+ // TODO: also work with blackbery and windows mobile and others
+ if (request.userAgent.isIPhone() && (!request.params.skipIphoneCheck)) {
+ renderHtml("pad/pad_iphone_body.ejs", {padId: pad.getLocalId()});
+ response.stop();
+ }
+}
+
+function _checkPadQuota(pad) {
+ var numConnectedUsers = collab_server.getNumConnections(pad);
+ var maxUsersPerPad = quotas.getMaxSimultaneousPadEditors(pad.getId());
+
+ if (numConnectedUsers >= maxUsersPerPad) {
+ log.info("rendered-padfull");
+ renderFramed('pad/padfull_body.ejs',
+ {maxUsersPerPad: maxUsersPerPad, padId: pad.getLocalId()});
+ response.stop();
+ }
+
+ if (pne_utils.isPNE()) {
+ if (!licensing.canSessionUserJoin()) {
+ renderFramed('pad/total_users_exceeded.ejs', {
+ userQuota: licensing.getActiveUserQuota(),
+ activeUserWindowHours: licensing.getActiveUserWindowHours()
+ });
+ response.stop();
+ }
+ }
+}
+
+function _checkIfDeleted(pad) {
+ // TODO: move to access control check on access?
+ if (pro_utils.isProDomainRequest()) {
+ pro_padmeta.accessProPad(pad.getId(), function(propad) {
+ if (propad.exists() && propad.isDeleted()) {
+ renderNoticeString("This pad has been deleted.");
+ response.stop();
+ }
+ });
+ }
+}
+
+function render_pad(localPadId) {
+ var proTitle = null, documentBarTitle, initialPassword = null;
+ var isPro = isProDomainRequest();
+ var userId = padusers.getUserId();
+
+ var opts = {};
+ var globalPadId;
+
+ if (isPro) {
+ pro_quotas.perRequestBillingCheck();
+ }
+
+ padutils.accessPadLocal(localPadId, function(pad) {
+ globalPadId = pad.getId();
+ request.cache.globalPadId = globalPadId;
+ _createIfNecessary(localPadId, pad);
+ _promptForMobileDevices(pad);
+ _checkPadQuota(pad);
+ _checkIfDeleted(pad);
+
+ if (request.params.inviteTo) {
+ getSession().nameGuess = request.params.inviteTo;
+ response.redirect('/'+localPadId);
+ }
+ var displayName;
+ if (request.params.displayName) { // tokbox
+ displayName = String(request.params.displayName);
+ }
+ else {
+ displayName = assignName(pad, userId);
+ }
+
+ if (isProDomainRequest()) {
+ pro_padmeta.accessProPadLocal(localPadId, function(propad) {
+ proTitle = propad.getDisplayTitle();
+ initialPassword = propad.getPassword();
+ });
+ }
+ documentBarTitle = (proTitle || "Public Pad");
+
+ var specialKey = request.params.specialKey ||
+ (sessions.isAnEtherpadAdmin() ? collab_server.getSpecialKey('invisible') :
+ null);
+ if (request.params.fullScreen) { // tokbox, embedding
+ opts.fullScreen = true;
+ }
+ if (request.params.tokbox) {
+ opts.tokbox = true;
+ }
+ if (request.params.sidebar) {
+ opts.sidebar = Boolean(Number(request.params.sidebar));
+ }
+
+ helpers.addClientVars({
+ padId: localPadId,
+ globalPadId: globalPadId,
+ userAgent: request.headers["User-Agent"],
+ collab_client_vars: collab_server.getCollabClientVars(pad),
+ debugEnabled: request.params.djs,
+ clientIp: request.clientAddr,
+ colorPalette: COLOR_PALETTE,
+ nameGuess: (getSession().nameGuess || null),
+ initialRevisionList: revisions.getRevisionList(pad),
+ serverTimestamp: +(new Date),
+ accountPrivs: _getPrivs(),
+ chatHistory: chatarchive.getRecentChatBlock(pad, 30),
+ numConnectedUsers: collab_server.getNumConnections(pad),
+ isProPad: isPro,
+ initialTitle: documentBarTitle,
+ initialPassword: initialPassword,
+ initialOptions: pad.getPadOptionsObj(),
+ userIsGuest: padusers.isGuest(userId),
+ userId: userId,
+ userName: displayName,
+ userColor: assignColorId(pad, userId),
+ specialKey: specialKey,
+ specialKeyTranslation: collab_server.translateSpecialKey(specialKey),
+ opts: opts
+ });
+ });
+
+ var isProUser = (isPro && ! padusers.isGuest(userId));
+
+ var isFullWidth = false;
+ var hideSidebar = false;
+ var cookiePrefs = padutils.getPrefsCookieData();
+ if (cookiePrefs) {
+ isFullWidth = !! cookiePrefs.fullWidth;
+ hideSidebar = !! cookiePrefs.hideSidebar;
+ }
+ if (opts.fullScreen) {
+ isFullWidth = true;
+ if (opts.tokbox) {
+ hideSidebar = true;
+ }
+ }
+ if ('sidebar' in opts) {
+ hideSidebar = ! opts.sidebar;
+ }
+ var bodyClass = (isFullWidth ? "fullwidth" : "limwidth")+
+ " "+(isPro ? "propad" : "nonpropad")+" "+
+ (isProUser ? "prouser" : "nonprouser");
+
+ var cookiePrefsToSet = {fullWidth:isFullWidth, hideSidebar:hideSidebar};
+ helpers.addClientVars({cookiePrefsToSet: cookiePrefsToSet});
+
+ renderHtml("pad/pad_body2.ejs",
+ {localPadId:localPadId,
+ pageTitle:toHTML(proTitle || localPadId),
+ initialTitle:toHTML(documentBarTitle),
+ bodyClass: bodyClass,
+ hasOffice: hasOffice(),
+ isPro: isPro,
+ isProAccountHolder: isProUser,
+ account: getSessionProAccount(), // may be falsy
+ toHTML: toHTML,
+ prefs: {isFullWidth:isFullWidth, hideSidebar:hideSidebar},
+ signinUrl: '/ep/account/sign-in?cont='+
+ encodeURIComponent(request.url),
+ fullSuperdomain: pro_utils.getFullSuperdomainHost()
+ });
+ return true;
+}
+
+function render_create_get() {
+ var padId = request.params.padId;
+ // <RAFTER>
+ var template = (DISABLE_PAD_CREATION && ! pro_utils.isProDomainRequest()) ?
+ "pad/create_body_rafter.ejs" :
+ "pad/create_body.ejs";
+ // </RAFTER>
+ renderFramed(template, {padId: padId,
+ fullSuperdomain: pro_utils.getFullSuperdomainHost()});
+}
+
+function render_create_post() {
+ var padId = request.params.padId;
+ getSession().instantCreate = padId;
+ response.redirect("/"+padId);
+}
+
+//----------------------------------------------------------------
+// saverevision
+//----------------------------------------------------------------
+
+function render_saverevision_post() {
+ var padId = request.params.padId;
+ var savedBy = request.params.savedBy;
+ var savedById = request.params.savedById;
+ var revNum = request.params.revNum;
+ var privs = _getPrivs();
+ padutils.accessPadLocal(padId, function(pad) {
+ if (! pad.exists()) { response.notFound(); }
+ var currentRevs = revisions.getRevisionList(pad);
+ if (currentRevs.length >= privs.maxRevisions) {
+ response.forbid();
+ }
+ var savedRev = revisions.saveNewRevision(pad, savedBy, savedById,
+ revNum);
+ readonly_server.broadcastNewRevision(pad, savedRev);
+ response.setContentType('text/x-json');
+ response.write(fastJSON.stringify(revisions.getRevisionList(pad)));
+ });
+}
+
+function render_saverevisionlabel_post() {
+ var userId = request.params.userId;
+ var padId = request.params.padId;
+ var revId = request.params.revId;
+ var newLabel = request.params.newLabel;
+ padutils.accessPadLocal(padId, function(pad) {
+ revisions.setLabel(pad, revId, userId, newLabel);
+ response.setContentType('text/x-json');
+ response.write(fastJSON.stringify(revisions.getRevisionList(pad)));
+ });
+}
+
+function render_getrevisionatext_get() {
+ var padId = request.params.padId;
+ var revId = request.params.revId;
+ var result = null;
+
+ var rev = padutils.accessPadLocal(padId, function(pad) {
+ var r = revisions.getStoredRevision(pad, revId);
+ var forWire = collab_server.getATextForWire(pad, r.revNum);
+ result = {atext:forWire.atext, apool:forWire.apool,
+ historicalAuthorData:forWire.historicalAuthorData};
+ return r;
+ }, "r");
+
+ response.setContentType('text/plain; charset=utf-8');
+ response.write(fastJSON.stringify(result));
+}
+
+//----------------------------------------------------------------
+// reconnect
+//----------------------------------------------------------------
+
+function _recordDiagnosticInfo(padId, diagnosticInfoJson) {
+
+ var diagnosticInfo = {};
+ try {
+ diagnosticInfo = fastJSON.parse(diagnosticInfoJson);
+ } catch (ex) {
+ log.warn("Error parsing diagnosticInfoJson: "+ex);
+ diagnosticInfo = {error: "error parsing JSON"};
+ }
+
+ // ignore userdups, unauth
+ if (diagnosticInfo.disconnectedMessage == "userdup" ||
+ diagnosticInfo.disconnectedMessage == "unauth") {
+ return;
+ }
+
+ var d = new Date();
+
+ diagnosticInfo.date = +d;
+ diagnosticInfo.strDate = String(d);
+ diagnosticInfo.clientAddr = request.clientAddr;
+ diagnosticInfo.padId = padId;
+ diagnosticInfo.headers = {};
+ eachProperty(request.headers, function(k,v) {
+ diagnosticInfo.headers[k] = v;
+ });
+
+ var uid = diagnosticInfo.uniqueId;
+
+ sqlbase.putJSON("PAD_DIAGNOSTIC", (diagnosticInfo.date)+"-"+uid, diagnosticInfo);
+
+}
+
+function recordMigratedDiagnosticInfo(objArray) {
+ objArray.forEach(function(obj) {
+ sqlbase.putJSON("PAD_DIAGNOSTIC", (obj.date)+"-"+obj.uniqueId, obj);
+ });
+}
+
+function render_reconnect() {
+ var localPadId = request.params.padId;
+ var globalPadId = padutils.getGlobalPadId(localPadId);
+ var userId = (padutils.getPrefsCookieUserId() || undefined);
+ var hasClientErrors = false;
+ var uniqueId;
+ try {
+ var obj = fastJSON.parse(request.params.diagnosticInfo);
+ uniqueId = obj.uniqueId;
+ errorMessage = obj.disconnectedMessage;
+ hasClientErrors = obj.collabDiagnosticInfo.errors.length > 0;
+ } catch (e) {
+ // guess it doesn't have errors.
+ }
+
+ log.custom("reconnect", {globalPadId: globalPadId, userId: userId,
+ uniqueId: uniqueId,
+ hasClientErrors: hasClientErrors,
+ errorMessage: errorMessage });
+
+ try {
+ _recordDiagnosticInfo(globalPadId, request.params.diagnosticInfo);
+ } catch (ex) {
+ log.warn("Error recording diagnostic info: "+ex+" / "+request.params.diagnosticInfo);
+ }
+
+ try {
+ _applyMissedChanges(localPadId, request.params.missedChanges);
+ } catch (ex) {
+ log.warn("Error applying missed changes: "+ex+" / "+request.params.missedChanges);
+ }
+
+ response.redirect('/'+localPadId);
+}
+
+/* posted asynchronously by the client as soon as reconnect dialogue appears. */
+function render_connection_diagnostic_info_post() {
+ var localPadId = request.params.padId;
+ var globalPadId = padutils.getGlobalPadId(localPadId);
+ var userId = (padutils.getPrefsCookieUserId() || undefined);
+ var hasClientErrors = false;
+ var uniqueId;
+ var errorMessage;
+ try {
+ var obj = fastJSON.parse(request.params.diagnosticInfo);
+ uniqueId = obj.uniqueId;
+ errorMessage = obj.disconnectedMessage;
+ hasClientErrors = obj.collabDiagnosticInfo.errors.length > 0;
+ } catch (e) {
+ // guess it doesn't have errors.
+ }
+ log.custom("disconnected_autopost", {globalPadId: globalPadId, userId: userId,
+ uniqueId: uniqueId,
+ hasClientErrors: hasClientErrors,
+ errorMessage: errorMessage});
+
+ try {
+ _recordDiagnosticInfo(globalPadId, request.params.diagnosticInfo);
+ } catch (ex) {
+ log.warn("Error recording diagnostic info: "+ex+" / "+request.params.diagnosticInfo);
+ }
+ response.setContentType('text/plain; charset=utf-8');
+ response.write("OK");
+}
+
+function _applyMissedChanges(localPadId, missedChangesJson) {
+ var missedChanges;
+ try {
+ missedChanges = fastJSON.parse(missedChangesJson);
+ } catch (ex) {
+ log.warn("Error parsing missedChangesJson: "+ex);
+ return;
+ }
+
+ padutils.accessPadLocal(localPadId, function(pad) {
+ if (pad.exists()) {
+ collab_server.applyMissedChanges(pad, missedChanges);
+ }
+ });
+}
+
+//----------------------------------------------------------------
+// feedback
+//----------------------------------------------------------------
+
+function render_feedback_post() {
+ var feedback = request.params.feedback;
+ var localPadId = request.params.padId;
+ var globalPadId = padutils.getGlobalPadId(localPadId);
+ var username = request.params.username;
+ var email = request.params.email;
+ var subject = 'EtherPad Feedback from '+request.clientAddr+' / '+globalPadId+' / '+username;
+
+ if (feedback.indexOf("@") > 0) {
+ subject = "@ "+subject;
+ }
+
+ feedback += "\n\n--\n";
+ feedback += ("User Agent: "+request.headers['User-Agent'] + "\n");
+ feedback += ("Session Referer: "+getSession().initialReferer + "\n");
+ feedback += ("Email: "+email+"\n");
+
+ // log feedback
+ var userId = padutils.getPrefsCookieUserId();
+ log.custom("feedback", {
+ globalPadId: globalPadId,
+ userId: userId,
+ email: email,
+ username: username,
+ feedback: request.params.feedback});
+
+ sendEmail(
+ 'feedback@pad.spline.inf.fu-berlin.de',
+ 'feedback@pad.spline.inf.fu-berlin.de',
+ subject,
+ {},
+ feedback
+ );
+ response.write("OK");
+}
+
+//----------------------------------------------------------------
+// emailinvite
+//----------------------------------------------------------------
+
+function render_emailinvite_post() {
+ var toEmails = String(request.params.toEmails).split(',');
+ var padId = String(request.params.padId);
+ var username = String(request.params.username);
+ var subject = String(request.params.subject);
+ var message = String(request.params.message);
+
+ log.custom("padinvite",
+ {toEmails: toEmails, padId: padId, username: username,
+ subject: subject, message: message});
+
+ var fromAddr = '"EtherPad" <noreply@pad.spline.inf.fu-berlin.de>';
+ // client enforces non-empty subject and message
+ var subj = '[EtherPad] '+subject;
+ var body = renderTemplateAsString('email/padinvite.ejs',
+ {body: message});
+ var headers = {};
+ var proAccount = getSessionProAccount();
+ if (proAccount) {
+ headers['Reply-To'] = proAccount.email;
+ }
+
+ response.setContentType('text/plain; charset=utf-8');
+ try {
+ sendEmail(toEmails, fromAddr, subj, headers, body);
+ response.write("OK");
+ } catch (e) {
+ logException(e);
+ response.setStatusCode(500);
+ response.write("Error");
+ }
+}
+
+//----------------------------------------------------------------
+// time-slider
+//----------------------------------------------------------------
+function render_slider() {
+ var parts = request.path.split('/');
+ var padOpaqueRef = parts[4];
+
+ helpers.addClientVars({padOpaqueRef:padOpaqueRef});
+
+ renderHtml("pad/padslider_body.ejs", {
+ // properties go here
+ });
+
+ return true;
+}
+
+//----------------------------------------------------------------
+// auth
+//----------------------------------------------------------------
+
+function render_auth_get() {
+ var parts = request.path.split('/');
+ var localPadId = parts[4];
+ var errDiv;
+ if (getSession().padPassErr) {
+ errDiv = DIV({style: "border: 1px solid #fcc; background: #ffeeee; padding: 1em; margin: 1em 0;"},
+ B(getSession().padPassErr));
+ delete getSession().padPassErr;
+ } else {
+ errDiv = DIV();
+ }
+ renderFramedHtml(function() {
+ return DIV({className: "fpcontent"},
+ DIV({style: "margin: 1em;"},
+ errDiv,
+ FORM({style: "border: 1px solid #ccc; padding: 1em; background: #fff6cc;",
+ action: request.path+'?'+request.query,
+ method: "post"},
+ LABEL(B("Please enter the password required to access this pad:")),
+ BR(), BR(),
+ INPUT({type: "text", name: "password"}), INPUT({type: "submit", value: "Submit"})
+ /*DIV(BR(), "Or ", A({href: '/ep/account/sign-in'}, "sign in"), ".")*/
+ )),
+ DIV({style: "padding: 0 1em;"},
+ P({style: "color: #444;"},
+ "If you have forgotten a pad's password, contact your site administrator.",
+ " Site administrators can recover lost pad text through the \"Admin\" tab.")
+ )
+ );
+ });
+ return true;
+}
+
+function render_auth_post() {
+ var parts = request.path.split('/');
+ var localPadId = parts[4];
+ var domainId = domains.getRequestDomainId();
+ if (!getSession().padPasswordAuth) {
+ getSession().padPasswordAuth = {};
+ }
+ var currentPassword = pro_padmeta.accessProPadLocal(localPadId, function(propad) {
+ return propad.getPassword();
+ });
+ if (request.params.password == currentPassword) {
+ var globalPadId = padutils.getGlobalPadId(localPadId);
+ getSession().padPasswordAuth[globalPadId] = true;
+ } else {
+ getSession().padPasswordAuth[globalPadId] = false;
+ getSession().padPassErr = "Incorrect password.";
+ }
+ var cont = request.params.cont;
+ if (!cont) {
+ cont = '/'+localPadId;
+ }
+ response.redirect(cont);
+}
+
+//----------------------------------------------------------------
+// chathistory
+//----------------------------------------------------------------
+
+function render_chathistory_get() {
+ var padId = request.params.padId;
+ var start = Number(request.params.start || 0);
+ var end = Number(request.params.end || 0);
+ var result = null;
+
+ var rev = padutils.accessPadLocal(padId, function(pad) {
+ result = chatarchive.getChatBlock(pad, start, end);
+ }, "r");
+
+ response.setContentType('text/plain; charset=utf-8');
+ response.write(fastJSON.stringify(result));
+}
+
diff --git a/trunk/etherpad/src/etherpad/control/pad/pad_importexport_control.js b/trunk/etherpad/src/etherpad/control/pad/pad_importexport_control.js
new file mode 100644
index 0000000..b7e5f4d
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pad/pad_importexport_control.js
@@ -0,0 +1,319 @@
+/**
+ * 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("jsutils.arrayToSet");
+import("stringutils.{toHTML,md5}");
+import("stringutils");
+import("sync");
+import("varz");
+
+import("etherpad.control.pad.pad_view_control.getRevisionInfo");
+import("etherpad.helpers");
+import("etherpad.importexport.importexport");
+import("etherpad.log");
+import("etherpad.pad.model");
+import("etherpad.pad.padutils");
+import("etherpad.pad.importhtml");
+import("etherpad.pad.exporthtml");
+import("etherpad.sessions");
+import("etherpad.sessions.getSession");
+import("etherpad.utils.{render404,renderFramedError}");
+import("etherpad.collab.server_utils");
+
+function _log(obj) {
+ log.custom("import-export", obj);
+}
+
+//---------------------------------------
+// utilities
+//---------------------------------------
+
+function _getPadTextBytes(padId, revNum) {
+ if (revNum === undefined) {
+ return null;
+ }
+ return padutils.accessPadLocal(padId, function(pad) {
+ if (pad.exists()) {
+ var txt = exporthtml.getPadPlainText(pad, revNum);
+ return (new java.lang.String(txt)).getBytes("UTF-8");
+ } else {
+ return null;
+ }
+ }, 'r');
+}
+
+function _getPadHtmlBytes(padId, revNum, noDocType) {
+ if (revNum === undefined) {
+ return null;
+ }
+ var html = padutils.accessPadLocal(padId, function(pad) {
+ if (pad.exists()) {
+ return exporthtml.getPadHTMLDocument(pad, revNum, noDocType);
+ }
+ });
+ if (html) {
+ return (new java.lang.String(html)).getBytes("UTF-8");
+ } else {
+ return null;
+ }
+}
+
+function _getFileExtension(fileName, def) {
+ if (fileName.lastIndexOf('.') > 0) {
+ return fileName.substr(fileName.lastIndexOf('.')+1);
+ } else {
+ return def;
+ }
+}
+
+function _guessFileType(contentType, fileName) {
+ function _f(str) { return function() { return str; }}
+ var unchangedExtensions =
+ arrayToSet(['txt', 'htm', 'html', 'doc', 'docx', 'rtf', 'pdf', 'odt']);
+ var textExtensions =
+ arrayToSet(['js', 'scala', 'java', 'c', 'cpp', 'log', 'h', 'htm', 'html', 'css', 'php', 'xhtml',
+ 'dhtml', 'jsp', 'asp', 'sh', 'bat', 'pl', 'py']);
+ var contentTypes = {
+ 'text/plain': 'txt',
+ 'text/html': 'html',
+ 'application/msword': 'doc',
+ 'application/vnd.oasis.opendocument.text': 'odt',
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'docx',
+ 'text/rtf': 'rtf',
+ 'application/pdf': 'pdf'
+ }
+
+ var ext = _getFileExtension(fileName);
+ if (ext) {
+ if (unchangedExtensions[ext]) {
+ return ext;
+ } else if (textExtensions[ext]) {
+ return 'txt';
+ }
+ }
+ if (contentType in contentTypes) {
+ return contentTypes[contentType]
+ }
+ // unknown type, nothing to return.
+ _log({type: "warning", error: "unknown-type", contentType: contentType, fileName: fileName});
+}
+
+function _noteExportFailure() {
+ varz.incrementInt("export-failed");
+}
+
+function _noteImportFailure() {
+ varz.incrementInt("import-failed");
+}
+
+//---------------------------------------
+// export
+//---------------------------------------
+
+// handles /ep/pad/export/*
+function renderExport() {
+ var parts = request.path.split('/');
+ var padId = server_utils.parseUrlId(parts[4]).localPadId;
+ var revisionId = parts[5];
+ var rev = null;
+ var format = request.params.format || 'txt';
+
+ if (! request.cache.skipAccess) {
+ _log({type: "request", direction: "export", format: format});
+ rev = getRevisionInfo(padId, revisionId);
+ if (! rev) {
+ render404();
+ }
+ request.cache.skipAccess = true;
+ }
+
+ var result = _exportToFormat(padId, revisionId, (rev || {}).revNum, format);
+ if (result === true) {
+ response.stop();
+ } else {
+ renderFramedError(result);
+ }
+ return true;
+}
+
+function _exportToFormat(padId, revisionId, revNum, format) {
+ var bytes = _doExportConversion(format,
+ function() { return _getPadTextBytes(padId, revNum); },
+ function(noDocType) { return _getPadHtmlBytes(padId, revNum, noDocType); });
+ if (! bytes) {
+ return "Unable to convert file for export... try a different format?"
+ } else if (typeof(bytes) == 'string') {
+ return bytes
+ } else {
+ response.setContentType(importexport.formats[format]);
+ response.setHeader("Content-Disposition", "attachment; filename=\""+padId+"-"+revisionId+"."+format+"\"");
+ response.writeBytes(bytes);
+ return true;
+ }
+}
+
+
+function _doExportConversion(format, getTextBytes, getHtmlBytes) {
+ if (! (format in importexport.formats)) {
+ return false;
+ }
+ var bytes;
+ var srcFormat;
+
+ if (format == 'txt') {
+ bytes = getTextBytes();
+ srcFormat = 'txt';
+ } else {
+ bytes = getHtmlBytes(format == 'doc' || format == 'odt');
+ srcFormat = 'html';
+ }
+ if (bytes == null) {
+ bytes = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, 0);
+ }
+
+ try {
+ var ret = importexport.convertFile(srcFormat, format, bytes);
+ if (typeof(ret) == 'string') {
+ _log({type: "error", error: "export-failed", format: format, message: ret});
+ _noteExportFailure();
+ return ret;
+ }
+ bytes = ret;
+ } catch (e) {
+ if (e.javaException instanceof org.mortbay.jetty.RetryRequest) {
+ throw e.javaException
+ }
+ if (e.javaException || e.rhinoException) {
+ net.appjet.oui.exceptionlog.apply(e.javaException || e.rhinoException);
+ }
+ bytes = null;
+ }
+ if (bytes == null || bytes.length == 0) {
+ _log({type: "error", error: "export-failed", format: format, message: ret});
+ _noteExportFailure();
+ return false;
+ }
+ return bytes;
+}
+
+//---------------------------------------
+// import
+//---------------------------------------
+
+function _getImportInfo(key) {
+ var session = getSession();
+ sync.callsyncIfTrue(session, function() { return ! ('importexport' in session) },
+ function() {
+ session.importexport = {};
+ });
+ var tokens = session.importexport;
+ sync.callsyncIfTrue(tokens, function() { return ! (key in tokens) },
+ function() {
+ tokens[key] = {};
+ });
+ return tokens[key];
+}
+
+function render_import() {
+ function _r(code) {
+ response.setContentType("text/html");
+ response.write("<html><body><script>try{parent.document.domain}catch(e){document.domain=document.domain}\n"+code+"</script></body></html>");
+ response.stop();
+ }
+
+ if (! request.isPost) {
+ response.stop();
+ }
+
+ var padId = decodeURIComponent(request.params.padId);
+ if (! padId) {
+ response.stop();
+ }
+
+ var file = request.files.file;
+ if (! file) {
+ _r('parent.pad.handleImportExportFrameCall("importFailed", "Please select a file to import.")');
+ }
+
+ var bytes = file.bytes;
+ var type = _guessFileType(file.contentType, file.filesystemName);
+
+ _log({type: "request", direction: "import", format: type});
+
+ if (! type) {
+ type = _getFileExtension(file.filesystemName, "no file extension found");
+ _r('parent.pad.handleImportExportFrameCall("importFailed", "'+importexport.errorUnsupported(type)+'")');
+ }
+
+ var token = md5(bytes);
+ var state = _getImportInfo(token);
+ state.bytes = bytes;
+ state.type = type;
+
+ _r("parent.pad.handleImportExportFrameCall('importSuccessful', '"+token+"')");
+}
+
+
+function render_import2() {
+ var token = request.params.token;
+
+ function _r(txt) {
+ response.write(txt);
+ response.stop();
+ }
+
+ if (! token) { _r("fail"); }
+
+ var state = _getImportInfo(token);
+ if (! state.type || ! state.bytes) { _r("fail"); }
+
+ var newBytes;
+ try {
+ newBytes = importexport.convertFile(state.type, "html", state.bytes);
+ } catch (e) {
+ if (e.javaException instanceof org.mortbay.jetty.RetryRequest) {
+ throw e.javaException;
+ }
+ net.appjet.oui.exceptionlog.apply(e);
+ throw e;
+ }
+
+ if (typeof(newBytes) == 'string') {
+ _log({type: "error", error: "import-failed", format: state.type, message: newBytes});
+ _noteImportFailure();
+ _r("msg:"+newBytes);
+ }
+
+ if (! newBytes || newBytes.length == 0) {
+ _r("fail");
+ }
+
+ var newHTML;
+ try {
+ newHTML = String(new java.lang.String(newBytes, "UTF-8"));
+ } catch (e) {
+ _r("fail");
+ }
+
+ if (! request.params.padId) { _r("fail"); }
+ padutils.accessPadLocal(request.params.padId, function(pad) {
+ if (! pad.exists()) {
+ _r("fail");
+ }
+ importhtml.setPadHTML(pad, newHTML);
+ });
+ _r("ok");
+}
diff --git a/trunk/etherpad/src/etherpad/control/pad/pad_view_control.js b/trunk/etherpad/src/etherpad/control/pad/pad_view_control.js
new file mode 100644
index 0000000..0606d2c
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pad/pad_view_control.js
@@ -0,0 +1,287 @@
+/**
+ * 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("etherpad.helpers");
+import("etherpad.pad.model");
+import("etherpad.pad.padusers");
+import("etherpad.pad.padutils");
+import("etherpad.pad.exporthtml");
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.pro_padmeta");
+import("etherpad.utils.*");
+import("etherpad.pad.revisions");
+import("stringutils.toHTML");
+import("etherpad.collab.server_utils.*");
+import("etherpad.collab.collab_server.buildHistoricalAuthorDataMapForPadHistory");
+import("etherpad.collab.collab_server.getATextForWire");
+import("etherpad.control.pad.pad_changeset_control.getChangesetInfo");
+import("etherpad.globals");
+import("fastJSON");
+import("etherpad.collab.ace.easysync2.Changeset");
+import("etherpad.collab.ace.linestylefilter.linestylefilter");
+import("etherpad.collab.ace.domline.domline");
+
+//----------------------------------------------------------------
+// view (viewing a static revision of a pad)
+//----------------------------------------------------------------
+
+function onRequest() {
+ var parts = request.path.split('/');
+ // TODO(kroo): create a mapping between padId and read-only id
+ var readOnlyIdOrLocalPadId = parts[4];
+ var parseResult = parseUrlId(readOnlyIdOrLocalPadId);
+ var isReadOnly = parseResult.isReadOnly;
+ var viewId = parseResult.viewId;
+ var localPadId = parseResult.localPadId;
+ var globalPadId = parseResult.globalPadId;
+ var roPadId = parseResult.roPadId;
+ var revisionId = parts[5];
+
+ var rev = getRevisionInfo(localPadId, revisionId);
+ if (! rev) {
+ return false;
+ }
+
+ if (request.params.pt == 1) {
+ var padText = padutils.accessPadLocal(localPadId, function(pad) {
+ return pad.getRevisionText(rev.revNum);
+ }, 'r');
+
+ response.setContentType('text/plain; charset=utf-8');
+ response.write(padText);
+ } else {
+ var padContents, totalRevs, atextForWire, savedRevisions;
+ var supportsSlider;
+ padutils.accessPadLocal(localPadId, function(pad) {
+ padContents = [_getPadHTML(pad, rev.revNum),
+ pad.getRevisionText(rev.revNum)];
+ totalRevs = pad.getHeadRevisionNumber();
+ atextForWire = getATextForWire(pad, rev.revNum);
+ savedRevisions = revisions.getRevisionList(pad);
+ supportsSlider = pad.getSupportsTimeSlider();
+ }, 'r');
+
+ var _add = function(dict, anotherdict) {
+ for(var key in anotherdict) {
+ dict[key] = anotherdict[key];
+ }
+ return dict;
+ }
+
+ var getAdaptiveChangesetsArray = function(array, start, granularity) {
+ array = array || [];
+ start = start || 0;
+ granularity = granularity || Math.pow(10, Math.floor(Math.log(totalRevs+1) / Math.log(10)));
+ var changeset = _add(getChangesetInfo(localPadId, start, totalRevs+1, granularity), {
+ start: start,
+ granularity: Math.floor(granularity)
+ });
+ array.push(changeset);
+ if(changeset.actualEndNum != totalRevs+1 && granularity > 1)
+ getAdaptiveChangesetsArray(array, changeset.actualEndNum, Math.floor(granularity / 10));
+ return array;
+ }
+ var initialChangesets = [];
+ if (supportsSlider) {
+ initialChangesets = getAdaptiveChangesetsArray(
+ [
+ _add(getChangesetInfo(localPadId, Math.floor(rev.revNum / 1000)*1000, Math.floor(rev.revNum / 1000)*1000+1000, 100), {
+ start: Math.floor(rev.revNum / 1000)*1000,
+ granularity: 100
+ }),
+ _add(getChangesetInfo(localPadId, Math.floor(rev.revNum / 100)*100, Math.floor(rev.revNum / 100)*100+100, 10), {
+ start: Math.floor(rev.revNum / 100)*100,
+ granularity: 10
+ }),
+ _add(getChangesetInfo(localPadId, Math.floor(rev.revNum / 10)*10, Math.floor(rev.revNum / 10)*10+10, 1), {
+ start: Math.floor(rev.revNum / 10)*10,
+ granularity: 1
+ })]
+ );
+ }
+
+ var zpad = function(str, length) {
+ str = str+"";
+ while(str.length < length)
+ str = '0'+str;
+ return str;
+ };
+ var dateFormat = function(savedWhen) {
+ var date = new Date(savedWhen);
+ var month = zpad(date.getMonth()+1,2);
+ var day = zpad(date.getDate(),2);
+ var year = (date.getFullYear());
+ var hours = zpad(date.getHours(),2);
+ var minutes = zpad(date.getMinutes(),2);
+ var seconds = zpad(date.getSeconds(),2);
+ return ([month,'/',day,'/',year,' ',hours,':',minutes,':',seconds].join(""));
+ };
+
+ var proTitle = null;
+ var initialPassword = null;
+ if (isProDomainRequest()) {
+ pro_padmeta.accessProPadLocal(localPadId, function(propad) {
+ proTitle = propad.getDisplayTitle();
+ initialPassword = propad.getPassword();
+ });
+ }
+ var documentBarTitle = (proTitle || "Public Pad");
+
+ var padHTML = padContents[0];
+ var padText = padContents[1];
+
+ var historicalAuthorData = padutils.accessPadLocal(localPadId, function(pad) {
+ return buildHistoricalAuthorDataMapForPadHistory(pad);
+ }, 'r');
+
+ helpers.addClientVars({
+ viewId: viewId,
+ initialPadContents: padText,
+ revNum: rev.revNum,
+ totalRevs: totalRevs,
+ initialChangesets: initialChangesets,
+ initialStyledContents: atextForWire,
+ savedRevisions: savedRevisions,
+ currentTime: rev.timestamp,
+ sliderEnabled: (!appjet.cache.killSlider) && request.params.slider != 0,
+ supportsSlider: supportsSlider,
+ historicalAuthorData: historicalAuthorData,
+ colorPalette: globals.COLOR_PALETTE,
+ padIdForUrl: readOnlyIdOrLocalPadId,
+ fullWidth: request.params.fullScreen == 1,
+ disableRightBar: request.params.sidebar == 0,
+ });
+
+ var userId = padusers.getUserId();
+ var isPro = isProDomainRequest();
+ var isProUser = (isPro && ! padusers.isGuest(userId));
+
+ var bodyClass = ["limwidth",
+ (isPro ? "propad" : "nonpropad"),
+ (isProUser ? "prouser" : "nonprouser")].join(" ");
+
+ renderHtml("pad/padview_body.ejs", {
+ bodyClass: bodyClass,
+ isPro: isPro,
+ isProAccountHolder: isProUser,
+ account: pro_accounts.getSessionProAccount(),
+ signinUrl: '/ep/account/sign-in?cont='+
+ encodeURIComponent(request.url),
+ padId: readOnlyIdOrLocalPadId,
+ padTitle: documentBarTitle,
+ rlabel: rev.label,
+ padHTML: padHTML,
+ padText: padText,
+ savedBy: rev.savedBy,
+ savedIp: rev.ip,
+ savedWhen: rev.timestamp,
+ toHTML: toHTML,
+ revisionId: revisionId,
+ dateFormat: dateFormat(rev.timestamp),
+ readOnly: isReadOnly,
+ roPadId: roPadId,
+ hasOffice: hasOffice()
+ });
+ }
+
+ return true;
+}
+
+function getRevisionInfo(localPadId, revisionId) {
+ var rev = padutils.accessPadLocal(localPadId, function(pad) {
+ if (!pad.exists()) {
+ return null;
+ }
+ var r;
+ if (revisionId == "latest") {
+ // a "fake" revision for HEAD
+ var headRevNum = pad.getHeadRevisionNumber();
+ r = {
+ revNum: headRevNum,
+ label: "Latest text of pad "+localPadId,
+ savedBy: null,
+ savedIp: null,
+ timestamp: +pad.getRevisionDate(headRevNum)
+ };
+ } else if (revisionId == "autorecover") {
+ var revNum = _findLastGoodRevisionInPad(pad);
+ r = {
+ revNum: revNum,
+ label: "Auto-recovered text of pad "+localPadId,
+ savedBy: null,
+ savedIp: null,
+ timestamp: +pad.getRevisionDate(revNum)
+ };
+ } else if(revisionId.indexOf("rev.") === 0) {
+ var revNum = parseInt(revisionId.split(".")[1]);
+ var latest = pad.getHeadRevisionNumber();
+ if(revNum > latest)
+ revNum = latest;
+ r = {
+ revNum: revNum,
+ label: "Version " + revNum,
+ savedBy: null,
+ savedIp: null,
+ timestamp: +pad.getRevisionDate(revNum)
+ }
+
+ } else {
+ r = revisions.getStoredRevision(pad, revisionId);
+ }
+ if (!r) {
+ return null;
+ }
+ return r;
+ }, "r");
+ return rev;
+}
+
+function _findLastGoodRevisionInPad(pad) {
+ var revNum = pad.getHeadRevisionNumber();
+ function valueOrNullOnError(f) {
+ try { return f(); } catch (e) { return null; }
+ }
+ function isAcceptable(strOrNull) {
+ return (strOrNull && strOrNull.length > 20);
+ }
+ while (revNum > 0 &&
+ ! isAcceptable(valueOrNullOnError(function() { return pad.getRevisionText(revNum); }))) {
+ revNum--;
+ }
+ return revNum;
+}
+
+function _getPadHTML(pad, revNum) {
+ var atext = pad.getInternalRevisionAText(revNum);
+ var textlines = Changeset.splitTextLines(atext.text);
+ var alines = Changeset.splitAttributionLines(atext.attribs,
+ atext.text);
+
+ var pieces = [];
+ var apool = pad.pool();
+ for(var i=0;i<textlines.length;i++) {
+ var line = textlines[i];
+ var aline = alines[i];
+ var emptyLine = (line == '\n');
+ var domInfo = domline.createDomLine(! emptyLine, true);
+ linestylefilter.populateDomLine(line, aline, apool, domInfo);
+ domInfo.prepareForAdd();
+ var node = domInfo.node;
+ pieces.push('<div class="', node.className, '">',
+ node.innerHTML, '</div>\n');
+ }
+ return pieces.join('');
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/control/pne_manual_control.js b/trunk/etherpad/src/etherpad/control/pne_manual_control.js
new file mode 100644
index 0000000..0dd65f8
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pne_manual_control.js
@@ -0,0 +1,75 @@
+/**
+ * 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("funhtml.*");
+
+import("etherpad.utils.*");
+import("etherpad.globals.*");
+
+function onRequest() {
+ var p = request.path.split('/')[3];
+ if (!p) {
+ p = "main";
+ }
+ if (_getTitle(p)) {
+ _renderManualPage(p);
+ return true;
+ } else {
+ return false;
+ }
+}
+
+function _getTitle(t) {
+ var titles = {
+ 'main': " ",
+ 'installation-guide': "Installation Guide",
+ 'upgrade-guide': "Upgrade Guide",
+ 'configuration-guide': "Configuration Guide",
+ 'troubleshooting': "Troubleshooting",
+ 'faq': "FAQ",
+ 'changelog': "ChangeLog"
+ };
+ return titles[t];
+}
+
+function _renderTopnav(p) {
+ var d = DIV({className: "pne-manual-topnav"});
+ if (p != "main") {
+ d.push(A({href: '/ep/pne-manual/'}, "PNE Manual"),
+ " > ",
+ _getTitle(p));
+ }
+ return d;
+}
+
+function _renderManualPage(p, data) {
+ data = (data || {});
+ data.pneVersion = PNE_RELEASE_VERSION;
+
+ function getContent() {
+ return renderTemplateAsString('pne-manual/'+p+'.ejs', data);
+ }
+ renderFramed('pne-manual/manual-template.ejs', {
+ getContent: getContent,
+ renderTopnav: function() { return _renderTopnav(p); },
+ title: _getTitle(p),
+ id: p,
+ });
+ return true;
+}
+
+
+
diff --git a/trunk/etherpad/src/etherpad/control/pne_tracker_control.js b/trunk/etherpad/src/etherpad/control/pne_tracker_control.js
new file mode 100644
index 0000000..ee36645
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pne_tracker_control.js
@@ -0,0 +1,48 @@
+/**
+ * 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("image");
+import("blob");
+import("sqlbase.sqlobj");
+import("jsutils.*");
+
+function render_t() {
+ var data = {
+ date: new Date(),
+ remoteIp: request.clientAddr
+ };
+ if (request.params.k) {
+ data.keyHash = request.params.k;
+ }
+ var found = false;
+ eachProperty(request.params, function(name, value) {
+ if (name != "k") {
+ data.name = name;
+ data.value = value;
+ found = true;
+ }
+ });
+ if (found) {
+ sqlobj.insert('pne_tracking_data', data);
+ }
+
+ // serve a 1x1 white image
+ if (!appjet.cache.pneTrackingImage) {
+ appjet.cache.pneTrackingImage = image.solidColorImageBlob(1, 1, "ffffff");
+ }
+ blob.serveBlob(appjet.cache.pneTrackingImage);
+}
+
diff --git a/trunk/etherpad/src/etherpad/control/pro/account_control.js b/trunk/etherpad/src/etherpad/control/pro/account_control.js
new file mode 100644
index 0000000..031dbe6
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pro/account_control.js
@@ -0,0 +1,369 @@
+/**
+ * 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("stringutils");
+import("stringutils.*");
+import("funhtml.*");
+import("email.sendEmail");
+import("cache_utils.syncedWithCache");
+
+import("etherpad.helpers");
+import("etherpad.utils.*");
+import("etherpad.sessions.getSession");
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.pro_accounts.getSessionProAccount");
+import("etherpad.pro.domains");
+import("etherpad.pro.pro_utils");
+import("etherpad.pro.pro_account_auto_signin");
+import("etherpad.pro.pro_config");
+import("etherpad.pad.pad_security");
+import("etherpad.pad.padutils");
+import("etherpad.pad.padusers");
+import("etherpad.collab.collab_server");
+
+function onRequest() {
+ if (!getSession().tempFormData) {
+ getSession().tempFormData = {};
+ }
+
+ return false; // path not handled here
+}
+
+//--------------------------------------------------------------------------------
+// helpers
+//--------------------------------------------------------------------------------
+
+function _redirOnError(m, clearQuery) {
+ if (m) {
+ getSession().accountFormError = m;
+
+ var dest = request.url;
+ if (clearQuery) {
+ dest = request.path;
+ }
+ response.redirect(dest);
+ }
+}
+
+function setSigninNotice(m) {
+ getSession().accountSigninNotice = m;
+}
+
+function setSessionError(m) {
+ getSession().accountFormError = m;
+}
+
+function _topDiv(id, name) {
+ var m = getSession()[name];
+ if (m) {
+ delete getSession()[name];
+ return DIV({id: id}, m);
+ } else {
+ return '';
+ }
+}
+
+function _messageDiv() { return _topDiv('account-message', 'accountMessage'); }
+function _errorDiv() { return _topDiv('account-error', 'accountFormError'); }
+function _signinNoticeDiv() { return _topDiv('signin-notice', 'accountSigninNotice'); }
+
+function _renderTemplate(name, data) {
+ data.messageDiv = _messageDiv;
+ data.errorDiv = _errorDiv;
+ data.signinNotice = _signinNoticeDiv;
+ data.tempFormData = getSession().tempFormData;
+ renderFramed('pro/account/'+name+'.ejs', data);
+}
+
+//----------------------------------------------------------------
+// /ep/account/
+//----------------------------------------------------------------
+
+function render_main_get() {
+ _renderTemplate('my-account', {
+ account: getSessionProAccount(),
+ changePass: getSession().changePass
+ });
+}
+
+function render_update_info_get() {
+ response.redirect('/ep/account/');
+}
+
+function render_update_info_post() {
+ var fullName = request.params.fullName;
+ var email = trim(request.params.email);
+
+ getSession().tempFormData.email = email;
+ getSession().tempFormData.fullName = fullName;
+
+ _redirOnError(pro_accounts.validateEmail(email));
+ _redirOnError(pro_accounts.validateFullName(fullName));
+
+ pro_accounts.setEmail(getSessionProAccount(), email);
+ pro_accounts.setFullName(getSessionProAccount(), fullName);
+
+ getSession().accountMessage = "Info updated.";
+ response.redirect('/ep/account/');
+}
+
+function render_update_password_get() {
+ response.redirect('/ep/account/');
+}
+
+function render_update_password_post() {
+ var password = request.params.password;
+ var passwordConfirm = request.params.passwordConfirm;
+
+ if (password != passwordConfirm) { _redirOnError('Passwords did not match.'); }
+
+ _redirOnError(pro_accounts.validatePassword(password));
+
+ pro_accounts.setPassword(getSessionProAccount(), password);
+
+ if (getSession().changePass) {
+ delete getSession().changePass;
+ response.redirect('/');
+ }
+
+ getSession().accountMessage = "Password updated.";
+ response.redirect('/ep/account/');
+}
+
+//--------------------------------------------------------------------------------
+// signin/signout
+//--------------------------------------------------------------------------------
+
+function render_sign_in_get() {
+ if (request.params.uid && request.params.tp) {
+ var m = pro_accounts.authenticateTempSignIn(Number(request.params.uid), request.params.tp);
+ if (m) {
+ getSession().accountFormError = m;
+ response.redirect('/ep/account/');
+ }
+ }
+ if (request.params.instantSigninKey) {
+ _attemptInstantSignin(request.params.instantSigninKey);
+ }
+ if (getSession().recentlySignedOut && getSession().accountFormError) {
+ delete getSession().accountFormError;
+ delete getSession().recentlySignedOut;
+ }
+ // Note: must check isAccountSignedIn before calling checkAutoSignin()!
+ if (pro_accounts.isAccountSignedIn()) {
+ _redirectToPostSigninDestination();
+ }
+ pro_account_auto_signin.checkAutoSignin();
+ var domainRecord = domains.getRequestDomainRecord();
+ var showGuestBox = false;
+ if (request.params.guest && request.params.padId) {
+ showGuestBox = true;
+ }
+ _renderTemplate('signin', {
+ domain: pro_utils.getFullProDomain(),
+ siteName: toHTML(pro_config.getConfig().siteName),
+ email: getSession().tempFormData.email || "",
+ password: getSession().tempFormData.password || "",
+ rememberMe: getSession().tempFormData.rememberMe || false,
+ showGuestBox: showGuestBox,
+ localPadId: request.params.padId
+ });
+}
+
+function _attemptInstantSignin(key) {
+ // See src/etherpad/control/global_pro_account_control.js
+ var email = null;
+ var password = null;
+ syncedWithCache('global_signin_passwords', function(c) {
+ if (c[key]) {
+ email = c[key].email;
+ password = c[key].password;
+ }
+ delete c[key];
+ });
+ getSession().tempFormData.email = email;
+ _redirOnError(pro_accounts.authenticateSignIn(email, password), true);
+}
+
+function render_sign_in_post() {
+ var email = trim(request.params.email);
+ var password = request.params.password;
+
+ getSession().tempFormData.email = email;
+ getSession().tempFormData.rememberMe = request.params.rememberMe;
+
+ _redirOnError(pro_accounts.authenticateSignIn(email, password));
+ pro_account_auto_signin.setAutoSigninCookie(request.params.rememberMe);
+ _redirectToPostSigninDestination();
+}
+
+function render_guest_sign_in_get() {
+ var localPadId = request.params.padId;
+ var domainId = domains.getRequestDomainId();
+ var globalPadId = padutils.makeGlobalId(domainId, localPadId);
+ var userId = padusers.getUserId();
+
+ pro_account_auto_signin.checkAutoSignin();
+ pad_security.clearKnockStatus(userId, globalPadId);
+
+ _renderTemplate('signin-guest', {
+ localPadId: localPadId,
+ errorMessage: getSession().guestAccessError,
+ siteName: toHTML(pro_config.getConfig().siteName),
+ guestName: padusers.getUserName() || ""
+ });
+}
+
+function render_guest_sign_in_post() {
+ function _err(m) {
+ if (m) {
+ getSession().guestAccessError = m;
+ response.redirect(request.url);
+ }
+ }
+ var displayName = request.params.guestDisplayName;
+ var localPadId = request.params.localPadId;
+ if (!(displayName && displayName.length > 0)) {
+ _err("Please enter a display name");
+ }
+ getSession().guestDisplayName = displayName;
+ response.redirect('/ep/account/guest-knock?padId='+encodeURIComponent(localPadId)+
+ "&guestDisplayName="+encodeURIComponent(displayName));
+}
+
+function render_guest_knock_get() {
+ var localPadId = request.params.padId;
+ helpers.addClientVars({
+ localPadId: localPadId,
+ guestDisplayName: request.params.guestDisplayName,
+ padUrl: "http://"+httpHost(request.host)+"/"+localPadId
+ });
+ _renderTemplate('guest-knock', {});
+}
+
+function render_guest_knock_post() {
+ var localPadId = request.params.padId;
+ var displayName = request.params.guestDisplayName;
+ var domainId = domains.getRequestDomainId();
+ var globalPadId = padutils.makeGlobalId(domainId, localPadId);
+ var userId = padusers.getUserId();
+
+ response.setContentType("text/plain; charset=utf-8");
+ // has the knock already been answsered?
+ var currentAnswer = pad_security.getKnockAnswer(userId, globalPadId);
+ if (currentAnswer) {
+ response.write(currentAnswer);
+ } else {
+ collab_server.guestKnock(globalPadId, userId, displayName);
+ response.write("wait");
+ }
+}
+
+function _redirectToPostSigninDestination() {
+ var cont = request.params.cont;
+ if (!cont) { cont = '/'; }
+ response.redirect(cont);
+}
+
+function render_sign_out() {
+ pro_account_auto_signin.setAutoSigninCookie(false);
+ pro_accounts.signOut();
+ delete getSession().padPasswordAuth;
+ getSession().recentlySignedOut = true;
+ response.redirect("/");
+}
+
+//--------------------------------------------------------------------------------
+// create-admin-account (eepnet only)
+//--------------------------------------------------------------------------------
+
+function render_create_admin_account_get() {
+ if (pro_accounts.doesAdminExist()) {
+ renderFramedError("An admin account already exists on this domain.");
+ response.stop();
+ }
+ _renderTemplate('create-admin-account', {});
+}
+
+function render_create_admin_account_post() {
+ var email = trim(request.params.email);
+ var password = request.params.password;
+ var passwordConfirm = request.params.passwordConfirm;
+ var fullName = request.params.fullName;
+
+ getSession().tempFormData.email = email;
+ getSession().tempFormData.fullName = fullName;
+
+ if (password != passwordConfirm) { _redirOnError('Passwords did not match.'); }
+
+ _redirOnError(pro_accounts.validateEmail(email));
+ _redirOnError(pro_accounts.validateFullName(fullName));
+ _redirOnError(pro_accounts.validatePassword(password));
+
+ pro_accounts.createNewAccount(null, fullName, email, password, true);
+
+ var u = pro_accounts.getAccountByEmail(email, null);
+
+ // TODO: should we send a welcome email here?
+ //pro_accounts.sendWelcomeEmail(u);
+
+ _redirOnError(pro_accounts.authenticateSignIn(email, password));
+
+ response.redirect("/");
+}
+
+
+//--------------------------------------------------------------------------------
+// forgot password
+//--------------------------------------------------------------------------------
+
+function render_forgot_password_get() {
+ if (request.params.instantSubmit && request.params.email) {
+ render_forgot_password_post();
+ } else {
+ _renderTemplate('forgot-password', {
+ email: getSession().tempFormData.email || ""
+ });
+ }
+}
+
+function render_forgot_password_post() {
+ var email = trim(request.params.email);
+
+ getSession().tempFormData.email = email;
+
+ var u = pro_accounts.getAccountByEmail(email, null);
+ if (!u) {
+ _redirOnError("Account not found: "+email);
+ }
+
+ var tempPass = stringutils.randomString(10);
+ pro_accounts.setTempPassword(u, tempPass);
+
+ var subj = "EtherPad: Request to reset your password on "+request.domain;
+ var body = renderTemplateAsString('pro/account/forgot-password-email.ejs', {
+ account: u,
+ recoverUrl: pro_accounts.getTempSigninUrl(u, tempPass)
+ });
+ var fromAddr = pro_utils.getEmailFromAddr();
+ sendEmail(u.email, fromAddr, subj, {}, body);
+
+ getSession().accountMessage = "An email has been sent to "+u.email+" with instructions to reset the password.";
+ response.redirect(request.path);
+}
+
+
+
diff --git a/trunk/etherpad/src/etherpad/control/pro/admin/account_manager_control.js b/trunk/etherpad/src/etherpad/control/pro/admin/account_manager_control.js
new file mode 100644
index 0000000..8f93b2e
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pro/admin/account_manager_control.js
@@ -0,0 +1,260 @@
+/**
+ * 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("funhtml.*");
+import("stringutils");
+import("stringutils.*");
+import("email.sendEmail");
+
+import("etherpad.globals.*");
+import("etherpad.utils.*");
+import("etherpad.sessions.getSession");
+
+import("etherpad.control.pro.admin.pro_admin_control");
+
+import("etherpad.pne.pne_utils");
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.pro_accounts.getSessionProAccount");
+import("etherpad.pro.pro_utils");
+import("etherpad.pro.pro_config");
+import("etherpad.pro.domains");
+import("etherpad.billing.team_billing");
+
+jimport("java.lang.System.out.println");
+
+function _err(m) {
+ if (m) {
+ getSession().accountManagerError = m;
+ response.redirect(request.path);
+ }
+}
+
+function _renderTopDiv(mid, htmlId) {
+ var m = getSession()[mid];
+ if (m) {
+ delete getSession()[mid];
+ return DIV({id: htmlId}, m);
+ } else {
+ return '';
+ }
+}
+
+function _errorDiv() { return _renderTopDiv('accountManagerError', 'error-message'); }
+function _messageDiv() { return _renderTopDiv('accountManagerMessage', 'message'); }
+function _warningDiv() { return _renderTopDiv('accountManagerWarning', 'warning'); }
+
+function onRequest() {
+ var parts = request.path.split('/');
+
+ function dispatchAccountAction(action, handlerGet, handlerPost) {
+ if ((parts[4] == action) && (isNumeric(parts[5]))) {
+ if (request.isGet) { handlerGet(+parts[5]); }
+ if (request.isPost) { handlerPost(+parts[5]); }
+ return true;
+ }
+ return false;
+ }
+
+ if (dispatchAccountAction('account', render_account_get, render_account_post)) {
+ return true;
+ }
+ if (dispatchAccountAction('delete-account', render_delete_account_get, render_delete_account_post)) {
+ return true;
+ };
+
+ return false;
+}
+
+function render_main() {
+ var accountList = pro_accounts.listAllDomainAccounts();
+ pro_admin_control.renderAdminPage('account-manager', {
+ accountList: accountList,
+ messageDiv: _messageDiv,
+ warningDiv: _warningDiv
+ });
+}
+
+function render_new_get() {
+ pro_admin_control.renderAdminPage('new-account', {
+ oldData: getSession().accountManagerFormData || {},
+ stringutils: stringutils,
+ errorDiv: _errorDiv
+ });
+}
+
+function _ensureBillingOK() {
+ var activeAccounts = pro_accounts.getCachedActiveCount(domains.getRequestDomainId());
+ if (activeAccounts < PRO_FREE_ACCOUNTS) {
+ return;
+ }
+
+ var status = team_billing.getDomainStatus(domains.getRequestDomainId());
+ if (!((status == team_billing.CURRENT)
+ || (status == team_billing.PAST_DUE))) {
+ _err(SPAN(
+ "A payment profile is required to create more than ", PRO_FREE_ACCOUNTS,
+ " accounts. ",
+ A({href: "/ep/admin/billing/", id: "billinglink"}, "Manage billing")));
+ }
+}
+
+function render_new_post() {
+ if (request.params.cancel) {
+ response.redirect('/ep/admin/account-manager/');
+ }
+
+ _ensureBillingOK();
+
+ var fullName = request.params.fullName;
+ var email = trim(request.params.email);
+ var tempPass = request.params.tempPass;
+ var makeAdmin = !!request.params.makeAdmin;
+
+ getSession().accountManagerFormData = {
+ fullName: fullName,
+ email: email,
+ tempPass: tempPass,
+ makeAdmin: makeAdmin
+ };
+
+ // validation
+ if (!tempPass) {
+ tempPass = stringutils.randomString(6);
+ }
+
+ _err(pro_accounts.validateEmail(email));
+ _err(pro_accounts.validateFullName(fullName));
+ _err(pro_accounts.validatePassword(tempPass));
+
+ var existingAccount = pro_accounts.getAccountByEmail(email, null);
+ if (existingAccount) {
+ _err("There is already a account with that email address.");
+ }
+
+ pro_accounts.createNewAccount(null, fullName, email, tempPass, makeAdmin);
+ var account = pro_accounts.getAccountByEmail(email, null);
+
+ pro_accounts.setTempPassword(account, tempPass);
+ sendWelcomeEmail(account, tempPass);
+
+ delete getSession().accountManagerFormData;
+ getSession().accountManagerMessage = "Account "+fullName+" ("+email+") created successfully.";
+ response.redirect('/ep/admin/account-manager/');
+}
+
+function sendWelcomeEmail(account, tempPass) {
+ var subj = "Welcome to EtherPad on "+pro_utils.getFullProDomain()+"!";
+ var toAddr = account.email;
+ var fromAddr = pro_utils.getEmailFromAddr();
+
+ var body = renderTemplateAsString('pro/account/account-welcome-email.ejs', {
+ account: account,
+ adminAccount: getSessionProAccount(),
+ signinLink: pro_accounts.getTempSigninUrl(account, tempPass),
+ toEmail: toAddr,
+ siteName: pro_config.getConfig().siteName
+ });
+ try {
+ sendEmail(toAddr, fromAddr, subj, {}, body);
+ } catch (ex) {
+ var d = DIV();
+ d.push(P("Warning: unable to send welcome email."));
+ if (pne_utils.isPNE()) {
+ d.push(P("Perhaps you have not ",
+ A({href: '/ep/admin/pne-config'}, "Configured SMTP on this server", "?")));
+ }
+ getSession().accountManagerWarning = d;
+ }
+}
+
+// Managing a single account.
+function render_account_get(accountId) {
+ var account = pro_accounts.getAccountById(accountId);
+ if (!account) {
+ response.write("Account not found.");
+ return true;
+ }
+ pro_admin_control.renderAdminPage('manage-account', {
+ account: account,
+ errorDiv: _errorDiv,
+ warningDiv: _warningDiv
+ });
+}
+
+function render_account_post(accountId) {
+ if (request.params.cancel) {
+ response.redirect('/ep/admin/account-manager/');
+ }
+ var newFullName = request.params.newFullName;
+ var newEmail = request.params.newEmail;
+ var newIsAdmin = !!request.params.newIsAdmin;
+
+ _err(pro_accounts.validateEmail(newEmail));
+ _err(pro_accounts.validateFullName(newFullName));
+
+ if ((!newIsAdmin) && (accountId == getSessionProAccount().id)) {
+ _err("You cannot remove your own administrator privileges.");
+ }
+
+ var account = pro_accounts.getAccountById(accountId);
+ if (!account) {
+ response.write("Account not found.");
+ return true;
+ }
+
+ pro_accounts.setEmail(account, newEmail);
+ pro_accounts.setFullName(account, newFullName);
+ pro_accounts.setIsAdmin(account, newIsAdmin);
+
+ getSession().accountManageMessage = "Info updated.";
+ response.redirect('/ep/admin/account-manager/');
+}
+
+function render_delete_account_get(accountId) {
+ var account = pro_accounts.getAccountById(accountId);
+ if (!account) {
+ response.write("Account not found.");
+ return true;
+ }
+ pro_admin_control.renderAdminPage('delete-account', {
+ account: account,
+ errorDiv: _errorDiv
+ });
+}
+
+function render_delete_account_post(accountId) {
+ if (request.params.cancel) {
+ response.redirect("/ep/admin/account-manager/account/"+accountId);
+ }
+
+ if (accountId == getSessionProAccount().id) {
+ getSession().accountManagerError = "You cannot delete your own account.";
+ response.redirect("/ep/admin/account-manager/account/"+accountId);
+ }
+
+ var account = pro_accounts.getAccountById(accountId);
+ if (!account) {
+ response.write("Account not found.");
+ return true;
+ }
+
+ pro_accounts.setDeleted(account);
+ getSession().accountManagerMessage = "The account "+account.fullName+" <"+account.email+"> has been deleted.";
+ response.redirect("/ep/admin/account-manager/");
+}
+
+
+
diff --git a/trunk/etherpad/src/etherpad/control/pro/admin/license_manager_control.js b/trunk/etherpad/src/etherpad/control/pro/admin/license_manager_control.js
new file mode 100644
index 0000000..ca6d6a6
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pro/admin/license_manager_control.js
@@ -0,0 +1,128 @@
+/**
+ * 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("fileutils.writeRealFile");
+import("stringutils");
+
+import("etherpad.licensing");
+import("etherpad.sessions.getSession");
+import("etherpad.utils.*");
+import("etherpad.pne.pne_utils");
+
+import("etherpad.control.pro.admin.pro_admin_control");
+
+jimport("java.lang.System.out.println");
+
+//----------------------------------------------------------------
+// license manager
+//----------------------------------------------------------------
+
+function getPath() {
+ return '/ep/admin/pne-license-manager/';
+}
+
+function _getTemplateData(data) {
+ var licenseInfo = licensing.getLicense();
+ data.licenseInfo = licenseInfo;
+ data.isUnlicensed = !licenseInfo;
+ data.isEvaluation = licensing.isEvaluation();
+ data.isExpired = licensing.isExpired();
+ data.isTooOld = licensing.isVersionTooOld();
+ data.errorMessage = (getSession().errorMessage || null);
+ data.runningVersionString = pne_utils.getVersionString();
+ data.licenseVersionString = licensing.getVersionString();
+ return data;
+}
+
+function render_main_get() {
+ licensing.reloadLicense();
+ var licenseInfo = licensing.getLicense();
+ if (!licenseInfo || licensing.isExpired()) {
+ response.redirect(getPath()+'edit');
+ }
+
+ pro_admin_control.renderAdminPage('pne-license-manager',
+ _getTemplateData({edit: false}));
+}
+
+function render_edit_get() {
+ licensing.reloadLicense();
+
+ if (request.params.btn) { response.redirect(request.path); }
+
+ var licenseInfo = licensing.getLicense();
+ var oldData = getSession().oldLicenseData;
+ if (!oldData) {
+ oldData = {};
+ if (licenseInfo) {
+ oldData.orgName = licenseInfo.organizationName;
+ oldData.personName = licenseInfo.personName;
+ }
+ }
+
+ pro_admin_control.renderAdminPage('pne-license-manager',
+ _getTemplateData({edit: true, oldData: oldData}));
+
+ delete getSession().errorMessage;
+}
+
+function render_edit_post() {
+ pne_utils.enableTrackingAgain();
+
+ function _trim(s) {
+ if (!s) { return ''; }
+ return stringutils.trim(s);
+ }
+ function _clean(s) {
+ s = s.replace(/\W/g, '');
+ s = s.replace(/\+/g, '');
+ return s;
+ }
+
+ if (request.params.cancel) {
+ delete getSession().oldLicenseData;
+ response.redirect(getPath());
+ }
+
+ var personName = _trim(request.params.personName);
+ var orgName = _trim(request.params.orgName);
+ var licenseString = _clean(request.params.licenseString);
+
+ getSession().oldLicenseData = {
+ personName: personName, orgName: orgName, licenseString: licenseString};
+
+ var key = [personName,orgName,licenseString].join(":");
+ println("validating key [ "+key+" ]");
+
+ if (!licensing.isValidKey(key)) {
+ getSession().errorMessage = "Invalid License Key";
+ response.redirect(request.path);
+ }
+
+ // valid key. write to disk.
+ var writeSuccess = false;
+ try {
+ println("writing key file: ./data/license.key");
+ writeRealFile("./data/license.key", key);
+ writeSuccess = true;
+ } catch (ex) {
+ println("exception: "+ex);
+ getSession().errorMessage = "Failed to write key to disk. (Do you have permission to write ./data/license.key ?).";
+ }
+ response.redirect(getPath());
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/control/pro/admin/pro_admin_control.js b/trunk/etherpad/src/etherpad/control/pro/admin/pro_admin_control.js
new file mode 100644
index 0000000..f9ce179
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pro/admin/pro_admin_control.js
@@ -0,0 +1,283 @@
+/**
+ * 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("stringutils");
+import("funhtml.*");
+import("dispatch.{Dispatcher,DirMatcher,forward}");
+
+import("etherpad.licensing");
+import("etherpad.control.admincontrol");
+import("etherpad.control.pro.admin.license_manager_control");
+import("etherpad.control.pro.admin.account_manager_control");
+import("etherpad.control.pro.admin.pro_config_control");
+import("etherpad.control.pro.admin.team_billing_control");
+
+import("etherpad.pad.padutils");
+
+import("etherpad.admin.shell");
+import("etherpad.sessions");
+import("etherpad.sessions.getSession");
+
+import("etherpad.pne.pne_utils");
+import("etherpad.pro.pro_accounts");
+import("etherpad.utils.*");
+
+//----------------------------------------------------------------
+
+var _pathPrefix = '/ep/admin/';
+
+var _PRO = 1;
+var _PNE_ONLY = 2;
+var _ONDEMAND_ONLY = 3;
+
+function _getLeftnavItems() {
+ var nav = [
+ _PRO, [
+ [_PRO, null, "Admin"],
+ [_PNE_ONLY, "pne-dashboard", "Server Dashboard"],
+ [_PNE_ONLY, "pne-license-manager/", "Manage License"],
+ [_PRO, "account-manager/", "Manage Accounts"],
+ [_PRO, "recover-padtext", "Recover Pad Text"],
+ [_PRO, null, "Configuration"],
+ [_PRO, [[_PNE_ONLY, "pne-config", "Private Server Configuration"],
+ [_PRO, "pro-config", "Application Configuration"]]],
+ [_PNE_ONLY, null, "Documentation"],
+ [_PNE_ONLY, "/ep/pne-manual/", "Administrator's Manual"],
+ ]
+ ];
+ return nav;
+}
+
+function renderAdminLeftNav() {
+ function _make(x) {
+ if ((x[0] == _PNE_ONLY) && !pne_utils.isPNE()) {
+ return null;
+ }
+ if ((x[0] == _ONDEMAND_ONLY) && pne_utils.isPNE()) {
+ return null;
+ }
+
+ if (x[1] instanceof Array) {
+ return _makelist(x[1]);
+ } else {
+ return _makeitem(x);
+ }
+ }
+ var selected;
+ function _makeitem(x) {
+ if (x[1]) {
+ var p = x[1];
+ if (x[1].charAt(0) != '/') {
+ p = _pathPrefix+p;
+ }
+ var li = LI(A({href: p}, x[2]));
+ if (stringutils.startsWith(request.path, p)) {
+ // select the longest prefix match.
+ if (! selected || p.length > selected.path.length) {
+ selected = {path: p, li: li};
+ }
+ }
+ return li;
+ } else {
+ return LI(DIV({className: 'leftnav-title'}, x[2]));
+ }
+ }
+ function _makelist(x) {
+ var ul = UL();
+ x.forEach(function(y) {
+ var t = _make(y);
+ if (t) { ul.push(t); }
+ });
+ return ul;
+ }
+ var d = DIV(_make(_getLeftnavItems()));
+ if (selected) {
+ selected.li.attribs.className = "selected";
+ }
+ // leftnav looks stupid when it's not very tall.
+ for (var i = 0; i < 10; i++) { d.push(BR()); }
+ return d;
+}
+
+function renderAdminPage(p, data) {
+ appjet.requestCache.proTopNavSelection = 'admin';
+ function getAdminContent() {
+ if (typeof(p) == 'function') {
+ return p();
+ } else {
+ return renderTemplateAsString('pro/admin/'+p+'.ejs', data);
+ }
+ }
+ renderFramed('pro/admin/admin-template.ejs', {
+ getAdminContent: getAdminContent,
+ renderAdminLeftNav: renderAdminLeftNav,
+ validLicense: pne_utils.isServerLicensed(),
+ });
+}
+
+//----------------------------------------------------------------
+
+function onRequest() {
+ var disp = new Dispatcher();
+ disp.addLocations([
+ [DirMatcher(license_manager_control.getPath()), forward(license_manager_control)],
+ [DirMatcher('/ep/admin/account-manager/'), forward(account_manager_control)],
+ [DirMatcher('/ep/admin/pro-config/'), forward(pro_config_control)],
+ [DirMatcher('/ep/admin/billing/'), forward(team_billing_control)],
+ ]);
+
+ if (disp.dispatch()) {
+ return true;
+ }
+
+ // request will be handled by this module.
+ pro_accounts.requireAdminAccount();
+}
+
+function render_main() {
+// renderAdminPage('admin');
+ response.redirect('/ep/admin/account-manager/')
+}
+
+function render_pne_dashboard() {
+ renderAdminPage('pne-dashboard', {
+ renderUptime: admincontrol.renderServerUptime,
+ renderResponseCodes: admincontrol.renderResponseCodes,
+ renderPadConnections: admincontrol.renderPadConnections,
+ renderTransportStats: admincontrol.renderCometStats,
+ todayActiveUsers: licensing.getActiveUserCount(),
+ userQuota: licensing.getActiveUserQuota()
+ });
+}
+
+var _documentedServerOptions = [
+ 'listen',
+ 'listenSecure',
+ 'transportUseWildcardSubdomains',
+ 'sslKeyStore',
+ 'sslKeyPassword',
+ 'etherpad.soffice',
+ 'etherpad.adminPass',
+ 'etherpad.SQL_JDBC_DRIVER',
+ 'etherpad.SQL_JDBC_URL',
+ 'etherpad.SQL_USERNAME',
+ 'etherpad.SQL_PASSWORD',
+ 'smtpServer',
+ 'smtpUser',
+ 'smtpPass',
+ 'configFile',
+ 'etherpad.licenseKey',
+ 'verbose'
+];
+
+function render_pne_config_get() {
+ renderAdminPage('pne-config', {
+ propKeys: _documentedServerOptions,
+ appjetConfig: appjet.config
+ });
+}
+
+function render_pne_advanced_get() {
+ response.redirect("/ep/admin/shell");
+}
+
+function render_shell_get() {
+ if (!(pne_utils.isPNE() || sessions.isAnEtherpadAdmin())) {
+ return false;
+ }
+ appjet.requestCache.proTopNavSelection = 'admin';
+ renderAdminPage('pne-shell', {
+ oldCmd: getSession().pneAdminShellCmd,
+ result: getSession().pneAdminShellResult,
+ elapsedMs: getSession().pneAdminShellElapsed
+ });
+ delete getSession().pneAdminShellResult;
+ delete getSession().pneAdminShellElapsed;
+}
+
+function render_shell_post() {
+ if (!(pne_utils.isPNE() || sessions.isAnEtherpadAdmin())) {
+ return false;
+ }
+ var cmd = request.params.cmd;
+ var start = +(new Date);
+ getSession().pneAdminShellCmd = cmd;
+ getSession().pneAdminShellResult = shell.getResult(cmd);
+ getSession().pneAdminShellElapsed = +(new Date) - start;
+ response.redirect(request.path);
+}
+
+function render_recover_padtext_get() {
+ function getNumRevisions(localPadId) {
+ return padutils.accessPadLocal(localPadId, function(pad) {
+ if (!pad.exists()) { return null; }
+ return 1+pad.getHeadRevisionNumber();
+ });
+ }
+ function getPadText(localPadId, revNum) {
+ return padutils.accessPadLocal(localPadId, function(pad) {
+ if (!pad.exists()) { return null; }
+ return pad.getRevisionText(revNum);
+ });
+ }
+
+ var localPadId = request.params.localPadId;
+ var revNum = request.params.revNum;
+
+ var d = DIV({style: "font-size: .8em;"});
+
+ d.push(FORM({action: request.path, method: "get"},
+ P({style: "margin-top: 0;"}, LABEL("Pad ID: "),
+ INPUT({type: "text", name: "localPadId", value: localPadId || ""}),
+ INPUT({type: "submit", value: "Submit"}))));
+
+ var showPadHelp = false;
+ var revisions = null;
+
+ if (!localPadId) {
+ showPadHelp = true;
+ } else {
+ revisions = getNumRevisions(localPadId);
+ if (!revisions) {
+ d.push(P("Pad not found: "+localPadId));
+ } else {
+ d.push(P(B(localPadId), " has ", revisions, " revisions."));
+ d.push(P("Enter a revision number (0-"+revisions+") to recover the pad text for that revision:"));
+ d.push(FORM({action: request.path, method: "get"},
+ P(LABEL("Revision number:"),
+ INPUT({type: "hidden", name: "localPadId", value: localPadId}),
+ INPUT({type: "text", name: "revNum", value: revNum || (revisions - 1)}),
+ INPUT({type: "submit", value: "Submit"}))));
+ }
+ }
+
+ if (showPadHelp) {
+ d.push(P({style: "font-size: 1em; color: #555;"},
+ 'The pad ID is the same as the URL to the pad, without the leading "/".',
+ ' For example, if the pad lives at http://pad.spline.inf.fu-berlin.de/foobar,',
+ ' then the pad ID is "foobar" (without the quotes).'))
+ }
+
+ if (revisions && revNum && (revNum < revisions)) {
+ var padText = getPadText(localPadId, revNum);
+ d.push(P(B("Pad text for ["+localPadId+"] revision #"+revNum)));
+ d.push(DIV({style: "font-family: monospace; border: 1px solid #ccc; background: #ffe; padding: 1em;"}, padText));
+ }
+
+ renderAdminPage(function() { return d; });
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/control/pro/admin/pro_config_control.js b/trunk/etherpad/src/etherpad/control/pro/admin/pro_config_control.js
new file mode 100644
index 0000000..b03da45
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pro/admin/pro_config_control.js
@@ -0,0 +1,54 @@
+/**
+ * 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("funhtml.*");
+
+import("etherpad.sessions.getSession");
+import("etherpad.control.pro.admin.pro_admin_control");
+import("etherpad.pro.pro_config");
+
+function _renderTopDiv(mid, htmlId) {
+ var m = getSession()[mid];
+ if (m) {
+ delete getSession()[mid];
+ return DIV({id: htmlId}, m);
+ } else {
+ return '';
+ }
+}
+
+function _messageDiv() {
+ return _renderTopDiv('proConfigMessage', 'pro-config-message');
+}
+
+function render_main_get() {
+ pro_config.reloadConfig();
+ var config = pro_config.getConfig();
+ pro_admin_control.renderAdminPage('pro-config', {
+ config: config,
+ messageDiv: _messageDiv
+ });
+}
+
+function render_main_post() {
+ pro_config.setConfigVal('siteName', request.params.siteName);
+ pro_config.setConfigVal('alwaysHttps', !!request.params.alwaysHttps);
+ pro_config.setConfigVal('defaultPadText', request.params.defaultPadText);
+ getSession().proConfigMessage = "New settings applied.";
+ response.redirect(request.path);
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/control/pro/admin/team_billing_control.js b/trunk/etherpad/src/etherpad/control/pro/admin/team_billing_control.js
new file mode 100644
index 0000000..5be6a0e
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pro/admin/team_billing_control.js
@@ -0,0 +1,447 @@
+/**
+ * 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("dateutils");
+import("email.sendEmail");
+import("fastJSON");
+import("funhtml.*");
+import("jsutils.*");
+import("sqlbase.sqlcommon.inTransaction");
+import("stringutils.*");
+
+import("etherpad.billing.billing");
+import("etherpad.billing.fields");
+import("etherpad.billing.team_billing");
+import("etherpad.control.pro.admin.pro_admin_control");
+import("etherpad.globals");
+import("etherpad.helpers");
+import("etherpad.pro.domains");
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.pro_utils");
+import("etherpad.sessions");
+import("etherpad.store.checkout");
+import("etherpad.utils.*");
+
+import("static.js.billing_shared.{billing=>billingJS}");
+
+var billingButtonName = "Confirm"
+
+function _cart() {
+ var s = sessions.getSession();
+ if (! s.proBillingCart) {
+ s.proBillingCart = {};
+ }
+ return s.proBillingCart;
+}
+
+function _billingForm() {
+ return renderTemplateAsString('store/eepnet-checkout/billing-info.ejs', {
+ cart: _cart(),
+ billingButtonName: billingButtonName,
+ billingFinalPhrase: "",
+ helpers: helpers,
+ errorIfInvalid: _errorIfInvalid,
+ billing: billingJS,
+ obfuscateCC: checkout.obfuscateCC,
+ dollars: checkout.dollars,
+ countryList: fields.countryList,
+ usaStateList: fields.usaStateList,
+ getFullSuperdomainHost: pro_utils.getFullSuperdomainHost,
+ showCouponCode: true,
+ });
+}
+
+function _plural(num) {
+ return (num == 1 ? "" : "s");
+}
+
+function _billingSummary(domainId, subscription) {
+ var paymentInfo = team_billing.getRecurringBillingInfo(domainId);
+ if (! paymentInfo) {
+ return;
+ }
+ var latestInvoice = team_billing.getLatestPaidInvoice(subscription.id);
+ var usersSoFar = team_billing.getMaxUsers(domainId);
+ var costSoFar = team_billing.calculateSubscriptionCost(usersSoFar, subscription.coupon);
+
+ var lastPaymentString =
+ (latestInvoice ?
+ "US $"+checkout.dollars(billing.centsToDollars(latestInvoice.amt))+
+ " ("+latestInvoice.users+" account"+_plural(latestInvoice.users)+")"+
+ ", on "+checkout.formatDate(latestInvoice.time) :
+ "None");
+
+ var coupon = false;
+ if (subscription.coupon) {
+ println("has a coupon: "+subscription.coupon);
+ var cval = team_billing.getCouponValue(subscription.coupon);
+ coupon = [];
+ if (cval.freeUsers) {
+ coupon.push(cval.freeUsers+" free user"+(cval.freeUsers == 1 ? "" : "s"));
+ }
+ if (cval.pctDiscount) {
+ coupon.push(cval.pctDiscount+"% savings");
+ }
+ coupon = coupon.join(", ");
+ }
+
+ return {
+ fullName: paymentInfo.fullname,
+ paymentSummary:
+ paymentInfo.paymentsummary +
+ (paymentInfo.expiration ?
+ ", expires "+checkout.formatExpiration(paymentInfo.expiration) :
+ ""),
+ lastPayment: lastPaymentString,
+ nextPayment: checkout.formatDate(subscription.paidThrough),
+ maxUsers: usersSoFar,
+ estimatedPayment: "US $"+checkout.dollars(costSoFar),
+ coupon: coupon
+ }
+}
+
+function _statusMessage() {
+ if (_cart().statusMessage) {
+ return toHTML(P({style: "color: green;"}, _cart().statusMessage));
+ } else {
+ return '';
+ }
+}
+
+function renderMainPage(doEdit) {
+ var cart = _cart();
+ var domainId = domains.getRequestDomainId();
+ var subscription = team_billing.getSubscriptionForCustomer(domainId);
+ var pendingInvoice = team_billing.getLatestPendingInvoice(domainId)
+ var usersSoFar = team_billing.getMaxUsers(domainId);
+ var costSoFar = team_billing.calculateSubscriptionCost(usersSoFar, subscription && subscription.coupon);
+
+ checkout.guessBillingNames(cart, pro_accounts.getSessionProAccount().fullName);
+ if (! cart.billingReferralCode) {
+ if (subscription && subscription.coupon) {
+ cart.billingReferralCode = subscription.coupon;
+ }
+ }
+
+ var summary = _billingSummary(domainId, subscription);
+ if (! summary) {
+ doEdit = true;
+ }
+
+ pro_admin_control.renderAdminPage('manage-billing', {
+ billingForm: _billingForm,
+ doEdit: doEdit,
+ paymentInfo: summary,
+ getFullSuperdomainHost: pro_utils.getFullSuperdomainHost,
+ firstCharge: checkout.formatDate(subscription ? subscription.paidThrough : dateutils.nextMonth(new Date)),
+ billingButtonName: billingButtonName,
+ errorDiv: _errorDiv,
+ showBackButton: (summary != undefined),
+ statusMessage: _statusMessage,
+ isBehind: (subscription ? subscription.paidThrough < Date.now() - 86400*1000 : false),
+ amountDue: "US $"+checkout.dollars(billing.centsToDollars(pendingInvoice ? pendingInvoice.amt : costSoFar*100)),
+ cart: _cart()
+ });
+
+ delete _cart().errorId;
+ delete _cart().errorMsg;
+ delete _cart().statusMessage;
+}
+
+function render_main() {
+ renderMainPage(false);
+}
+
+function render_edit() {
+ renderMainPage(true);
+}
+
+function _errorDiv() {
+ var m = _cart().errorMsg;
+ if (m) {
+ return DIV({className: 'errormsg', id: 'errormsg'}, m);
+ } else {
+ return '';
+ }
+}
+
+function _validationError(id, errorMessage) {
+ var cart = _cart();
+ cart.errorMsg = errorMessage;
+ cart.errorId = {};
+ if (id instanceof Array) {
+ id.forEach(function(k) {
+ cart.errorId[k] = true;
+ });
+ } else {
+ cart.errorId[id] = true;
+ }
+ response.redirect('/ep/admin/billing/edit');
+}
+
+function _errorIfInvalid(id) {
+ var cart = _cart();
+ if (cart.errorId && cart.errorId[id]) {
+ return 'error';
+ } else {
+ return '';
+ }
+}
+
+function paypalNotifyUrl() {
+ return request.scheme+"://"+pro_utils.getFullSuperdomainHost()+"/ep/store/paypalnotify";
+}
+
+function _paymentSummary(payInfo) {
+ return payInfo.cardType + " ending in " + payInfo.cardNumber.substr(-4);
+}
+
+function _expiration(payInfo) {
+ return payInfo.cardExpiration;
+}
+
+function _attemptAuthorization(success_f) {
+ var cart = _cart();
+ var domain = domains.getRequestDomainRecord();
+ var domainId = domain.id;
+ var domainName = domain.subDomain;
+ var payInfo = checkout.generatePayInfo(cart);
+ var proAccount = pro_accounts.getSessionProAccount();
+ var fullName = cart.billingFirstName+" "+cart.billingLastName;
+ var email = proAccount.email;
+
+ // PCI rules require that we not store the CVV longer than necessary to complete the transaction
+ var savedCvv = payInfo.cardCvv;
+ delete payInfo.cardCvv;
+ checkout.writeToEncryptedLog(fastJSON.stringify({date: String(new Date()), domain: domain, payInfo: payInfo}));
+ payInfo.cardCvv = savedCvv;
+
+ var result = billing.authorizePurchase(payInfo, paypalNotifyUrl());
+ if (result.status == 'success') {
+ billing.log({type: 'new-subscription',
+ name: fullName,
+ domainId: domainId,
+ domainName: domainName});
+ success_f(result);
+ } else if (result.status == 'pending') {
+ _validationError('', "Your authorization is pending. When it clears, your account will be activated. "+
+ "You may choose to pay by different means now, or wait until your authorization clears.");
+ } else if (result.status == 'failure') {
+ var paypalResult = result.debug;
+ billing.log({'type': 'FATAL', value: "Direct purchase failed on paypal.", cart: cart, paypal: paypalResult});
+ checkout.validateErrorFields(_validationError, "There seems to be an error in your billing information."+
+ " Please verify and correct your ",
+ result.errorField.userErrors);
+ checkout.validateErrorFields(_validationError, "The bank declined your billing information. Please try a different ",
+ result.errorField.permanentErrors);
+ _validationError('', "A temporary error has prevented processing of your payment. Please try again later.");
+ } else {
+ billing.log({'type': 'FATAL', value: "Unknown error: "+result.status+" - debug: "+result.debug});
+ sendEmail('support@pad.spline.inf.fu-berlin.de', 'urgent@pad.spline.inf.fu-berlin.de', 'UNKNOWN ERROR WARNING!', {},
+ "Hey,\n\nThis is a billing system error. Some unknown error occurred. "+
+ "This shouldn't ever happen. Probably good to let J.D. know. <grin>\n\n"+
+ fastJSON.stringify(cart));
+ _validationError('', "An unknown error occurred. We're looking into it!")
+ }
+}
+
+function _processNewSubscription() {
+ _attemptAuthorization(function(result) {
+ var domain = domains.getRequestDomainRecord();
+ var domainId = domain.id;
+ var domainName = domain.subDomain;
+
+ var cart = _cart();
+ var payInfo = checkout.generatePayInfo(cart);
+ var proAccount = pro_accounts.getSessionProAccount();
+ var fullName = cart.billingFirstName+" "+cart.billingLastName;
+ var email = proAccount.email;
+
+ inTransaction(function() {
+
+ var subscriptionId = team_billing.createSubscription(domainId, cart.billingReferralCode);
+
+ team_billing.setRecurringBillingInfo(
+ domainId,
+ fullName,
+ email,
+ _paymentSummary(payInfo),
+ _expiration(payInfo),
+ result.purchaseInfo.paypalId);
+ });
+
+ if (globals.isProduction()) {
+ sendEmail('sales@pad.spline.inf.fu-berlin.de', 'sales@pad.spline.inf.fu-berlin.de', "EtherPad: New paid pro account for "+fullName, {},
+ "This is an automatic notification.\n\n"+fullName+" ("+email+") successfully set up "+
+ "a billing profile for domain: "+domainName+".");
+ }
+ });
+}
+
+function _updateExistingSubscription(subscription) {
+ var cart = _cart();
+
+ _attemptAuthorization(function(result) {
+ inTransaction(function() {
+ var cart = _cart();
+ var domain = domains.getRequestDomainId();
+ var payInfo = checkout.generatePayInfo(cart);
+ var proAccount = pro_accounts.getSessionProAccount();
+ var fullName = cart.billingFirstName+" "+cart.billingLastName;
+ var email = proAccount.email;
+
+ var subscriptionId = subscription.id;
+
+ team_billing.setRecurringBillingInfo(
+ domain,
+ fullName,
+ email,
+ _paymentSummary(payInfo),
+ _expiration(payInfo),
+ result.purchaseInfo.paypalId);
+ });
+ });
+
+ if (subscription.paidThrough < new Date) {
+ // if they're behind, do the purchase!
+ if (team_billing.processSubscription(subscription)) {
+ cart.statusMessage = "Your payment was successful, and your account is now up to date! You will receive a receipt by email."
+ } else {
+ cart.statusMessage = "Your payment failed; you will receive further instructions by email.";
+ }
+ }
+}
+
+function _processBillingInfo() {
+ var cart = _cart();
+ var domain = domains.getRequestDomainId();
+
+ var subscription = team_billing.getSubscriptionForCustomer(domain);
+ if (! subscription) {
+ _processNewSubscription();
+ response.redirect('/ep/admin/billing/');
+ } else {
+ team_billing.updateSubscriptionCouponCode(subscription.id, cart.billingReferralCode);
+ if (cart.billingCCNumber.length > 0) {
+ _updateExistingSubscription(subscription);
+ }
+ response.redirect('/ep/admin/billing')
+ }
+}
+
+function _processPaypalPurchase() {
+ var domain = domains.getRequestDomainId();
+ billing.log({type: "paypal-attempt",
+ domain: domain,
+ message: "Someone tried to use paypal to pay for on-demand."+
+ " They got an error message. If this happens a lot, we should implement paypal."})
+ java.lang.Thread.sleep(5000);
+ _validationError('billingPurchaseType', "There was an error contacting PayPal. Please try another payment type.")
+}
+
+function _processInvoicePurchase() {
+ var output = [
+ "Name: "+cart.billingFirstName+" "+cart.billingLastName,
+ "\nAddress: ",
+ cart.billingAddressLine1+(cart.billingAddressLine2.length > 0 ? "\n"+cart.billingAddressLine2 : ""),
+ cart.billingCity + ", " + (cart.billingState.length > 0 ? cart.billingState : cart.billingProvince),
+ cart.billingZipCode.length > 0 ? cart.billingZipCode : cart.billingPostalCode,
+ cart.billingCountry,
+ "\nEmail: ",
+ pro_accounts.getSessionProAccount().email
+ ].join("\n");
+ var recipient = (globals.isProduction() ? 'sales@pad.spline.inf.fu-berlin.de' : 'jd@appjet.com');
+ sendEmail(
+ recipient,
+ 'sales@pad.spline.inf.fu-berlin.de',
+ 'Invoice payment request - '+pro_utils.getProRequestSubdomain(),
+ {},
+ "Hi there,\n\nA pro user tried to pay by invoice. Their information follows."+
+ "\n\nThanks!\n\n"+output);
+ _validationError('', "Your information has been sent to our sales department; a salesperson will contact you shortly regarding your invoice request.")
+}
+
+function render_apply() {
+ var cart = _cart();
+ eachProperty(request.params, function(k, v) {
+ if (startsWith(k, "billing")) {
+ if (k == "billingCCNumber" && v.charAt(0) == 'X') { return; }
+ cart[k] = toHTML(v);
+ }
+ });
+
+ if (! request.params.backbutton) {
+ var allPaymentFields = ["billingCCNumber", "billingExpirationMonth", "billingExpirationYear", "billingCSC", "billingAddressLine1", "billingAddressLine2", "billingCity", "billingState", "billingZipCode", "billingProvince", "billingPostalCode"];
+ var allBlank = true;
+ allPaymentFields.forEach(function(field) { if (cart[field].length > 0) { allBlank = false; }});
+ if (! allBlank) {
+ checkout.validateBillingCart(_validationError, cart);
+ }
+ } else {
+ response.redirect("/ep/admin/billing/");
+ }
+
+ var couponCode = cart.billingReferralCode;
+
+ if (couponCode.length != 0 && (couponCode.length != 8 || ! team_billing.getCouponValue(couponCode))) {
+ _validationError('billingReferralCode', 'Invalid referral code entered. Please verify your code and try again.');
+ }
+
+ if (cart.billingPurchaseType == 'paypal') {
+ _processPaypalPurchase();
+ } else if (cart.billingPurchaseType == 'invoice') {
+ _processInvoicePurchase();
+ }
+
+ _processBillingInfo();
+}
+
+function handlePaypalNotify() {
+ // XXX: handle delayed paypal authorization
+}
+
+function render_invoices() {
+ if (request.params.id) {
+ var purchaseId = team_billing.getSubscriptionForCustomer(domains.getRequestDomainId()).id;
+ var invoice = billing.getInvoice(request.params.id);
+ if (invoice.purchase != purchaseId) {
+ response.redirect(request.path);
+ }
+
+ var transaction;
+ var adjustments = billing.getAdjustments(invoice.id);
+ if (adjustments.length == 1) {
+ transaction = billing.getTransaction(adjustments[0].transaction);
+ }
+
+ pro_admin_control.renderAdminPage('single-invoice', {
+ formatDate: checkout.formatDate,
+ dollars: checkout.dollars,
+ centsToDollars: billing.centsToDollars,
+ invoice: invoice,
+ transaction: transaction
+ });
+ } else {
+ var invoices = team_billing.getAllInvoices(domains.getRequestDomainId());
+
+ pro_admin_control.renderAdminPage('billing-invoices', {
+ invoices: invoices,
+ formatDate: checkout.formatDate,
+ dollars: checkout.dollars,
+ centsToDollars: billing.centsToDollars
+ });
+ }
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/control/pro/pro_main_control.js b/trunk/etherpad/src/etherpad/control/pro/pro_main_control.js
new file mode 100644
index 0000000..b4e3bc4
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pro/pro_main_control.js
@@ -0,0 +1,150 @@
+/**
+ * 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("stringutils");
+import("dispatch.{Dispatcher,DirMatcher,forward}");
+import("funhtml.*");
+import("cache_utils.syncedWithCache");
+
+import("etherpad.helpers");
+import("etherpad.utils.*");
+import("etherpad.sessions.getSession");
+import("etherpad.licensing");
+import("etherpad.pne.pne_utils");
+import("etherpad.pro.pro_pad_db");
+import("etherpad.pro.domains");
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.pro_accounts.getSessionProAccount");
+import("etherpad.pro.pro_padlist");
+
+import("etherpad.control.pro.account_control");
+import("etherpad.control.pro.pro_padlist_control");
+import("etherpad.control.pro.admin.pro_admin_control");
+import("etherpad.control.pro.admin.account_manager_control");
+
+import("etherpad.pad.activepads");
+import("etherpad.pad.model");
+
+
+function onRequest() {
+ var disp = new Dispatcher();
+ disp.addLocations([
+ [DirMatcher('/ep/account/'), forward(account_control)],
+ [DirMatcher('/ep/admin/'), forward(pro_admin_control)],
+ [DirMatcher('/ep/padlist/'), forward(pro_padlist_control)],
+ ]);
+ return disp.dispatch();
+}
+
+function render_main() {
+ if (request.path == '/ep/') {
+ response.redirect('/');
+ }
+
+ // recent pad list
+ var livePads = pro_pad_db.listLiveDomainPads();
+ var recentPads = pro_pad_db.listAllDomainPads();
+
+ var renderLivePads = function() {
+ return pro_padlist.renderPadList(livePads, ['title', 'connectedUsers'], 10);
+ }
+
+ var renderRecentPads = function() {
+ return pro_padlist.renderPadList(recentPads, ['title'], 10);
+ };
+
+ var r = domains.getRequestDomainRecord();
+
+ renderFramed('pro/pro_home.ejs', {
+ isEvaluation: licensing.isEvaluation(),
+ account: getSessionProAccount(),
+ isPNE: pne_utils.isPNE(),
+ pneVersion: pne_utils.getVersionString(),
+ livePads: livePads,
+ recentPads: recentPads,
+ renderRecentPads: renderRecentPads,
+ renderLivePads: renderLivePads,
+ orgName: r.orgName
+ });
+ return true;
+}
+
+function render_finish_activation_get() {
+ if (!isActivationAllowed()) {
+ response.redirect('/');
+ }
+
+ var accountList = pro_accounts.listAllDomainAccounts();
+ if (accountList.length > 1) {
+ response.redirect('/');
+ }
+ if (accountList.length == 0) {
+ throw Error("accountList.length should never be 0.");
+ }
+
+ var acct = accountList[0];
+ var tempPass = stringutils.randomString(10);
+ pro_accounts.setTempPassword(acct, tempPass);
+ account_manager_control.sendWelcomeEmail(acct, tempPass);
+
+ var domainId = domains.getRequestDomainId();
+
+ syncedWithCache('pro-activations', function(c) {
+ delete c[domainId];
+ });
+
+ renderNoticeString(
+ DIV({style: "font-size: 16pt; border: 1px solid green; background: #eeffee; margin: 2em 4em; padding: 1em;"},
+ P("Success! You will receive an email shortly with instructions."),
+ DIV({style: "display: none;", id: "reference"}, acct.id, ":", tempPass)));
+}
+
+function isActivationAllowed() {
+ if (request.path != '/ep/finish-activation') {
+ return false;
+ }
+ var allowed = false;
+ var domainId = domains.getRequestDomainId();
+ return syncedWithCache('pro-activations', function(c) {
+ if (c[domainId]) {
+ return true;
+ }
+ return false;
+ });
+}
+
+function render_payment_required_get() {
+ // Users get to this page when there is a problem with billing:
+ // possibilities:
+ // * they try to create a new account but they have not entered
+ // payment information
+ //
+ // * their credit card lapses and any pro request fails.
+ //
+ // * others?
+
+ var message = getSession().billingProblem || "A payment is required to proceed.";
+ var adminList = pro_accounts.listAllDomainAdmins();
+
+ renderFramed("pro/pro-payment-required.ejs", {
+ message: message,
+ isAdmin: pro_accounts.isAdminSignedIn(),
+ adminList: adminList
+ });
+}
+
+
+
diff --git a/trunk/etherpad/src/etherpad/control/pro/pro_padlist_control.js b/trunk/etherpad/src/etherpad/control/pro/pro_padlist_control.js
new file mode 100644
index 0000000..9a90c67
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pro/pro_padlist_control.js
@@ -0,0 +1,200 @@
+/**
+ * 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("funhtml.*");
+import("jsutils.*");
+import("stringutils");
+
+import("etherpad.sessions.getSession");
+import("etherpad.utils.*");
+import("etherpad.helpers");
+import("etherpad.pad.exporthtml");
+import("etherpad.pad.padutils");
+import("etherpad.pro.pro_padmeta");
+import("etherpad.pro.pro_pad_db");
+import("etherpad.pro.domains");
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.pro_padlist");
+
+jimport("java.lang.System.out.println");
+
+function onRequest(name) {
+ if (name == "all_pads.zip") {
+ render_all_pads_zip_get();
+ return true;
+ } else {
+ return false;
+ }
+}
+
+function _getBaseUrl() { return "/ep/padlist/"; }
+
+function _renderPadNav() {
+ var d = DIV({id: "padlist-nav"});
+ var ul = UL();
+ var items = [
+ ['allpads', 'all-pads', "All Pads"],
+ ['mypads', 'my-pads', "My Pads"],
+ ['archivedpads', 'archived-pads', "Archived Pads"]
+ ];
+ for (var i = 0; i < items.length; i++) {
+ var item = items[i];
+ var cn = "";
+ if (request.path.split("/").slice(-1)[0] == item[1]) {
+ cn = "selected";
+ }
+ ul.push(LI(A({id: "nav-"+item[1], href: _getBaseUrl()+item[1], className: cn}, item[2])));
+ }
+ ul.push(html(helpers.clearFloats()));
+ d.push(ul);
+ d.push(FORM({id: "newpadform", method: "get", action: "/ep/pad/newpad"},
+ INPUT({type: "submit", value: "New Pad"})));
+ d.push(html(helpers.clearFloats()));
+ return d;
+}
+
+function _renderPage(name, data) {
+ getSession().latestPadlistView = request.path + "?" + request.query;
+ var r = domains.getRequestDomainRecord();
+ appjet.requestCache.proTopNavSelection = 'padlist';
+ data.renderPadNav = _renderPadNav;
+ data.orgName = r.orgName;
+ data.renderNotice = function() {
+ var m = getSession().padlistMessage;
+ if (m) {
+ delete getSession().padlistMessage;
+ return DIV({className: "padlist-notice"}, m);
+ } else {
+ return "";
+ }
+ };
+
+ renderFramed("pro/padlist/"+name+".ejs", data);
+}
+
+function _renderListPage(padList, showingDesc, columns) {
+ _renderPage("pro-padlist", {
+ padList: padList,
+ renderPadList: function() {
+ return pro_padlist.renderPadList(padList, columns);
+ },
+ renderShowingDesc: function(count) {
+ return DIV({id: "showing-desc"},
+ "Showing "+showingDesc+" ("+count+").");
+ },
+ isAdmin: pro_accounts.isAdminSignedIn()
+ });
+}
+
+function render_main() {
+ if (!getSession().latestPadlistView) {
+ getSession().latestPadlistView = "/ep/padlist/all-pads";
+ }
+ response.redirect(getSession().latestPadlistView);
+}
+
+function render_all_pads_get() {
+ _renderListPage(
+ pro_pad_db.listAllDomainPads(),
+ "all pads",
+ ['secure', 'title', 'lastEditedDate', 'editors', 'actions']);
+}
+
+function render_all_pads_zip_get() {
+ if (! pro_accounts.isAdminSignedIn()) {
+ response.redirect(_getBaseUrl()+"all-pads");
+ }
+ var bytes = new java.io.ByteArrayOutputStream();
+ var zos = new java.util.zip.ZipOutputStream(bytes);
+
+ var pads = pro_pad_db.listAllDomainPads();
+ pads.forEach(function(pad) {
+ var padHtml;
+ var title;
+ padutils.accessPadLocal(pad.localPadId, function(p) {
+ title = padutils.getProDisplayTitle(pad.localPadId, pad.title);
+ padHtml = exporthtml.getPadHTML(p);
+ }, "r");
+
+ title = title.replace(/[^\w\s]/g, "-") + ".html";
+ zos.putNextEntry(new java.util.zip.ZipEntry(title));
+ var padBytes = (new java.lang.String(renderTemplateAsString('pad/exporthtml.ejs', {
+ content: padHtml,
+ pre: false
+ }))).getBytes("UTF-8");
+
+ zos.write(padBytes, 0, padBytes.length);
+ zos.closeEntry();
+ });
+ zos.close();
+ response.setContentType("application/zip");
+ response.writeBytes(bytes.toByteArray());
+}
+
+function render_my_pads_get() {
+ _renderListPage(
+ pro_pad_db.listMyPads(),
+ "pads created by me",
+ ['secure', 'title', 'lastEditedDate', 'editors', 'actions']);
+}
+
+function render_archived_pads_get() {
+ helpers.addClientVars({
+ showingArchivedPads: true
+ });
+ _renderListPage(
+ pro_pad_db.listArchivedPads(),
+ "archived pads",
+ ['secure', 'title', 'lastEditedDate', 'actions']);
+}
+
+function render_edited_by_get() {
+ var editorId = request.params.editorId;
+ var editorName = pro_accounts.getFullNameById(editorId);
+ _renderListPage(
+ pro_pad_db.listPadsByEditor(editorId),
+ "pads edited by "+editorName,
+ ['secure', 'title', 'lastEditedDate', 'editors', 'actions']);
+}
+
+function render_delete_post() {
+ var localPadId = request.params.padIdToDelete;
+
+ pro_padmeta.accessProPadLocal(localPadId, function(propad) {
+ propad.markDeleted();
+ getSession().padlistMessage = 'Pad "'+propad.getDisplayTitle()+'" has been deleted.';
+ });
+
+ response.redirect(request.params.returnPath);
+}
+
+function render_toggle_archive_post() {
+ var localPadId = request.params.padIdToToggleArchive;
+
+ pro_padmeta.accessProPadLocal(localPadId, function(propad) {
+ if (propad.isArchived()) {
+ propad.unmarkArchived();
+ getSession().padlistMessage = 'Pad "'+propad.getDisplayTitle()+'" has been un-archived.';
+ } else {
+ propad.markArchived();
+ getSession().padlistMessage = 'Pad "'+propad.getDisplayTitle()+'" has been archived. You can view archived pads by clicking on the "Archived" tab at the top of the pad list.';
+ }
+ });
+
+ response.redirect(request.params.returnPath);
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/control/pro_beta_control.js b/trunk/etherpad/src/etherpad/control/pro_beta_control.js
new file mode 100644
index 0000000..ec99b43
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pro_beta_control.js
@@ -0,0 +1,136 @@
+/**
+ * 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("funhtml.*", "stringutils.*");
+import("sqlbase.sqlobj");
+import("sqlbase.sqlcommon");
+import("stringutils");
+import("email.sendEmail");
+
+import("etherpad.utils.*");
+import("etherpad.log");
+import("etherpad.sessions.getSession");
+
+jimport("java.lang.System.out.println");
+
+function render_main_get() {
+ if (isValveOpen()) {
+ response.redirect("/ep/pro-signup/");
+ }
+ renderFramed("beta/signup.ejs", {
+ errorMsg: getSession().betaSignupError
+ });
+ delete getSession().betaSignupError;
+}
+
+function render_signup_post() {
+ // record in sql: [id, email, activated=false, activationCode]
+ // log to disk
+
+ var email = request.params.email;
+ if (!isValidEmail(email)) {
+ getSession().betaSignupError = "Invalid email address.";
+ response.redirect('/ep/beta-account/');
+ }
+
+ // does email already exist?
+ if (sqlobj.selectSingle('pro_beta_signups', {email: email})) {
+ getSession().betaSignupError = "Email already signed up.";
+ response.redirect('/ep/beta-account/');
+ }
+
+ sqlobj.insert('pro_beta_signups', {
+ email: email,
+ isActivated: false,
+ signupDate: new Date()
+ });
+
+ response.redirect('/ep/beta-account/signup-ok');
+}
+
+function render_signup_ok() {
+ renderNoticeString(
+ DIV({style: "font-size: 16pt; border: 1px solid green; background: #eeffee; margin: 2em 4em; padding: 1em;"},
+ P("Great! We'll be in touch."),
+ P("In the meantime, you can ", A({href: '/ep/pad/newpad', style: 'text-decoration: underline;'},
+ "create a public pad"), " right now.")));
+}
+
+// return string if not valid, falsy otherwise.
+function isValidCode(code) {
+ if (isValveOpen()) {
+ return undefined;
+ }
+
+ function wr(m) {
+ return DIV(P(m), P("You can sign up for the beta ",
+ A({href: "/ep/beta-account/"}, "here")));
+ }
+
+ if (!code) {
+ return wr("Invalid activation code.");
+ }
+ var record = sqlobj.selectSingle('pro_beta_signups', { activationCode: code });
+ if (!record) {
+ return wr("Invalid activation code.");
+ }
+ if (record.isActivated) {
+ return wr("That activation code has already been used.");
+ }
+ return undefined;
+}
+
+function isValveOpen() {
+ if (appjet.cache.proBetaValveIsOpen === undefined) {
+ appjet.cache.proBetaValveIsOpen = true;
+ }
+ return appjet.cache.proBetaValveIsOpen;
+}
+
+function toggleValve() {
+ appjet.cache.proBetaValveIsOpen = !appjet.cache.proBetaValveIsOpen;
+}
+
+function sendInvite(recordId) {
+ var record = sqlobj.selectSingle('pro_beta_signups', {id: recordId});
+ if (record.activationCode) {
+ getSession().betaAdminMessage = "Already active";
+ return;
+ }
+
+ // create activation code
+ var code = stringutils.randomString(10);
+ sqlcommon.inTransaction(function() {
+ sqlobj.update('pro_beta_signups', {id: recordId}, {activationCode: code});
+ var body = renderTemplateAsString('email/pro_beta_invite.ejs', {
+ toAddr: record.email,
+ signupAgo: timeAgo(record.signupDate),
+ signupCode: code,
+ activationUrl: "http://"+httpHost(request.host)+"/ep/pro-signup/?sc="+code
+ });
+ sendEmail(record.email, "EtherPad <support@pad.spline.inf.fu-berlin.de>",
+ "Your EtherPad Professional Beta Account", {}, body);
+ });
+
+ getSession().betaAdminMessage = "Invite sent.";
+}
+
+function notifyActivated(code) {
+ println("updating: "+code);
+ sqlobj.update('pro_beta_signups', {activationCode: code},
+ {isActivated: true, activationDate: new Date()});
+}
+
diff --git a/trunk/etherpad/src/etherpad/control/pro_signup_control.js b/trunk/etherpad/src/etherpad/control/pro_signup_control.js
new file mode 100644
index 0000000..6bf7cc3
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/pro_signup_control.js
@@ -0,0 +1,173 @@
+/**
+ * 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("jsutils.*");
+import("cache_utils.syncedWithCache");
+import("funhtml.*");
+import("stringutils");
+import("stringutils.*");
+import("sqlbase.sqlcommon");
+
+import("etherpad.sessions.getSession");
+import("etherpad.utils.*");
+
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.domains");
+
+import("etherpad.control.pro_beta_control");
+import("etherpad.control.pro.admin.account_manager_control");
+
+import("etherpad.helpers");
+
+function onRequest() {
+ if (!getSession().ods) {
+ getSession().ods = {};
+ }
+ if (request.method == "POST") {
+ // add params to cart
+ eachProperty(request.params, function(k,v) {
+ getSession().ods[k] = stringutils.toHTML(v);
+ });
+ }
+}
+
+function _errorDiv() {
+ var m = getSession().errorMessage;
+ if (m) {
+ delete getSession().errorMessage;
+ return DIV({className: 'err'}, m);
+ }
+ return "";
+}
+
+function _input(id, type) {
+ return INPUT({type: type ? type : 'text', name: id, id: id,
+ value: getSession().ods[id] || ""});
+}
+
+function _inf(id, label, type) {
+ return DIV(
+ DIV({style: "width: 100px; text-align: right; float: left; padding-top: 3px;"}, label, ": "),
+ DIV({style: "text-align: left; float: left;"},
+ _input(id, type)),
+ DIV({style: "height: 6px; clear: both;"}, " "));
+}
+
+function render_main_get() {
+ // observe activation code
+ if (request.params.sc) {
+ getSession().betaActivationCode = request.params.sc;
+ response.redirect(request.path);
+ }
+
+ // validate activation code
+ var activationCode = getSession().betaActivationCode;
+ var err = pro_beta_control.isValidCode(activationCode);
+ if (err) {
+ renderNoticeString(DIV({style: "border: 1px solid red; background: #fdd; font-weight: bold; padding: 1em;"},
+ err));
+ response.stop();
+ }
+
+ // serve activation page
+ renderFramed('main/pro_signup_body.ejs', {
+ errorDiv: _errorDiv,
+ input: _input,
+ inf: _inf
+ });
+}
+
+function _err(m) {
+ if (m) {
+ getSession().errorMessage = m;
+ response.redirect(request.path);
+ }
+}
+
+function render_main_post() {
+ var subdomain = trim(String(request.params.subdomain).toLowerCase());
+ var fullName = request.params.fullName;
+ var email = trim(request.params.email);
+
+ // validate activation code
+ var activationCode = getSession().betaActivationCode;
+ var err = pro_beta_control.isValidCode(activationCode);
+ if (err) {
+ resonse.write(err);
+ }
+
+ /*
+ var password = request.params.password;
+ var passwordConfirm = request.params.passwordConfirm;
+ */
+ var orgName = subdomain;
+
+ //---- basic validation ----
+ if (!/^\w[\w\d\-]*$/.test(subdomain)) {
+ _err("Invalid domain: "+subdomain);
+ }
+ if (subdomain.length < 2) {
+ _err("Subdomain must be at least 2 characters.");
+ }
+ if (subdomain.length > 60) {
+ _err("Subdomain must be <= 60 characters.");
+ }
+
+/*
+ if (password != passwordConfirm) {
+ _err("Passwords do not match.");
+ }
+ */
+
+ _err(pro_accounts.validateFullName(fullName));
+ _err(pro_accounts.validateEmail(email));
+
+ if (!(email.match(/[Ff][Uu]-[Bb][Ee][Rr][Ll][Ii][Nn].[Dd][Ee]$/))) { _err("Please use your *.fu-berlin.de email address."); }
+// _err(pro_accounts.validatePassword(password));
+
+ //---- database validation ----
+
+ if (domains.doesSubdomainExist(subdomain)) {
+ _err("The domain "+subdomain+" is already in use.");
+ }
+
+ //---- looks good. create records! ----
+
+ // TODO: log a bunch of stuff, and request IP address, etc.
+
+ var ok = false;
+ sqlcommon.inTransaction(function() {
+ var tempPass = stringutils.randomString(10);
+ // TODO: move validation code into domains.createNewSubdomain...
+ var domainId = domains.createNewSubdomain(subdomain, orgName);
+ var accountId = pro_accounts.createNewAccount(domainId, fullName, email, tempPass, true);
+ // send welcome email
+ syncedWithCache('pro-activations', function(c) {
+ c[domainId] = true;
+ });
+ ok = true;
+ if (activationCode) {
+ pro_beta_control.notifyActivated(activationCode);
+ }
+ });
+
+ if (ok) {
+ response.redirect('http://'+subdomain+"."+request.host+'/ep/finish-activation');
+ } else {
+ response.write("There was an error processing your request.");
+ }
+}
+
diff --git a/trunk/etherpad/src/etherpad/control/scriptcontrol.js b/trunk/etherpad/src/etherpad/control/scriptcontrol.js
new file mode 100644
index 0000000..16efc60
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/scriptcontrol.js
@@ -0,0 +1,75 @@
+/**
+ * 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("etherpad.pad.dbwriter");
+import("etherpad.utils.*");
+import("etherpad.globals.*");
+
+function onRequest() {
+ if (!isProduction()) {
+ return;
+ }
+ if (request.params.auth != 'f83kg840d12jk') {
+ response.forbid();
+ }
+}
+
+function render_setdbwritable() {
+ var dbwritable = (String(request.params.value).toLowerCase() != 'false'); // default to true
+
+ dbwriter.setWritableState({constant: dbwritable});
+
+ response.write("OK, set to "+dbwritable);
+}
+
+function render_getdbwritable() {
+ var state = dbwriter.getWritableState();
+
+ response.write(String(dbwriter.getWritableStateDescription(state)));
+}
+
+function render_pausedbwriter() {
+ var seconds = request.params.seconds;
+ var seconds = Number(seconds || 0);
+ if (isNaN(seconds)) seconds = 0;
+
+ var finishTime = (+new Date())+(1000*seconds);
+ dbwriter.setWritableState({trueAfter: finishTime});
+
+ response.write("Paused dbwriter for "+seconds+" seconds.");
+}
+
+function render_fake_pne_on() {
+ if (isProduction()) {
+ response.write("has no effect in production.");
+ } else {
+ appjet.cache.fakePNE = true;
+ response.write("OK");
+ }
+}
+
+function render_fake_pne_off() {
+ if (isProduction()) {
+ response.write("has no effect in production.");
+ } else {
+ appjet.cache.fakePNE = false;
+ response.write("OK");
+ }
+}
+
+
+
+
diff --git a/trunk/etherpad/src/etherpad/control/static_control.js b/trunk/etherpad/src/etherpad/control/static_control.js
new file mode 100644
index 0000000..5c087b6
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/static_control.js
@@ -0,0 +1,65 @@
+/**
+ * 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("faststatic");
+import("dispatch.{Dispatcher,PrefixMatcher,forward}");
+
+import("etherpad.utils.*");
+import("etherpad.globals.*");
+
+function onRequest() {
+ var staticBase = '/static';
+
+ var opts = {cache: isProduction()};
+
+ var serveFavicon = faststatic.singleFileServer(staticBase + '/favicon.ico', opts);
+ var serveCrossDomain = faststatic.singleFileServer(staticBase + '/crossdomain.xml', opts);
+ var serveStaticDir = faststatic.directoryServer(staticBase, opts);
+ var serveCompressed = faststatic.compressedFileServer(opts);
+ var serveJs = faststatic.directoryServer(staticBase+'/js/', opts);
+ var serveCss = faststatic.directoryServer(staticBase+'/css/', opts);
+ var serveSwf = faststatic.directoryServer(staticBase+'/swf/', opts);
+ var serveHtml = faststatic.directoryServer(staticBase+'/html/', opts);
+ var serveZip = faststatic.directoryServer(staticBase+'/zip/', opts);
+
+ var disp = new Dispatcher();
+
+ disp.addLocations([
+ ['/favicon.ico', serveFavicon],
+ ['/robots.txt', serveRobotsTxt],
+ ['/crossdomain.xml', serveCrossDomain],
+ [PrefixMatcher('/static/html/'), serveHtml],
+ [PrefixMatcher('/static/js/'), serveJs],
+ [PrefixMatcher('/static/css/'), serveCss],
+ [PrefixMatcher('/static/swf/'), serveSwf],
+ [PrefixMatcher('/static/zip/'), serveZip],
+ [PrefixMatcher('/static/compressed/'), serveCompressed],
+ [PrefixMatcher('/static/'), serveStaticDir]
+ ]);
+
+ return disp.dispatch();
+}
+
+function serveRobotsTxt(name) {
+ response.neverCache();
+ response.setContentType('text/plain');
+ response.write('User-agent: *\n');
+ if (!isProduction()) {
+ response.write('Disallow: /\n');
+ }
+ response.stop();
+ return true;
+}
diff --git a/trunk/etherpad/src/etherpad/control/statscontrol.js b/trunk/etherpad/src/etherpad/control/statscontrol.js
new file mode 100644
index 0000000..3659107
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/statscontrol.js
@@ -0,0 +1,1214 @@
+/**
+ * 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("netutils");
+import("funhtml.*");
+import("stringutils.{html,sprintf,startsWith,md5}");
+import("jsutils.*");
+import("sqlbase.sqlbase");
+import("sqlbase.sqlcommon");
+import("sqlbase.sqlobj");
+import("dispatch.{Dispatcher,PrefixMatcher,DirMatcher,forward}");
+
+import("etherpad.globals.*");
+import("etherpad.utils.*");
+import("etherpad.sessions.getSession");
+import("etherpad.sessions");
+import("etherpad.statistics.statistics");
+import("etherpad.log");
+import("etherpad.usage_stats.usage_stats");
+import("etherpad.helpers");
+
+//----------------------------------------------------------------
+// Usagestats
+//----------------------------------------------------------------
+
+var _defaultPrefs = {
+ topNCount: 5,
+ granularity: 1440
+}
+
+function onRequest() {
+ keys(_defaultPrefs).forEach(function(prefName) {
+ if (request.params[prefName]) {
+ _prefs()[prefName] = request.params[prefName];
+ }
+ });
+ if (request.isPost) {
+ response.redirect(
+ request.path+
+ (request.query ? "?"+request.query : "")+
+ (request.params.fragment ? "#"+request.params.fragment : ""));
+ }
+}
+
+function _prefs() {
+ if (! sessions.getSession().statsPrefs) {
+ sessions.getSession().statsPrefs = {}
+ }
+ return sessions.getSession().statsPrefs;
+}
+
+function _pref(pname) {
+ return _prefs()[pname] || _defaultPrefs[pname];
+}
+
+function _topN() {
+ return _pref('topNCount');
+}
+function _showLiveStats() {
+ return _timescale() < 1440;
+ // return _pref('granularity') == 'live';
+}
+function _showHistStats() {
+ return _timescale() >= 1440
+ // return _pref('showLiveOrHistorical') == 'hist';
+}
+function _timescale() {
+ return Number(_pref('granularity')) || 1;
+}
+
+// types:
+// compare - compare one or more single-value stats
+// top - show top values over time
+// histogram - show histogram over time
+
+var statDisplays = {
+ users: [
+ { name: "visitors",
+ description: "User visits, total over a %t period",
+ type: "compare",
+ stats: [ {stat: "site_pageviews",
+ description: "Page views",
+ color: "FFA928" },
+ {stat: "site_unique_ips",
+ description: "Unique IPs",
+ color: "00FF00" } ] },
+
+ // free pad usage
+ { name: "free pad usage, 1 day",
+ description: "Free pad.spline.inf.fu-berlin.de users, total over a %t period",
+ type: "compare",
+ stats: [ {stat: "active_user_ids",
+ description: "All users",
+ color: "FFA928" },
+ {stat: "users_1day_returning_7days",
+ description: "Users returning after 7 days",
+ color: "00FF00"},
+ {stat: "users_1day_returning_30days",
+ description: "Users returning after 30 days",
+ color: "FF0000"} ] },
+ { name: "free pad usage, 7 day",
+ description: "Free pad.spline.inf.fu-berlin.de users over the last 7 days",
+ type: "compare",
+ options: { hideLive: true, latestUseHistorical: true},
+ stats: [ {stat: "active_user_ids_7days",
+ description: "All users",
+ color: "FFA928" },
+ {stat: "users_7day_returning_7days",
+ description: "Users returning after 7 days",
+ color: "00FF00"},
+ {stat: "users_7day_returning_30days",
+ description: "Users returning after 30 days",
+ color: "FF0000"} ] },
+ { name: "free pad usage, 30 day",
+ description: "Free pad.spline.inf.fu-berlin.de users over the last 30 days",
+ type: "compare",
+ options: { hideLive: true, latestUseHistorical: true},
+ stats: [ {stat: "active_user_ids_30days",
+ description: "All users",
+ color: "FFA928" },
+ {stat: "users_30day_returning_7days",
+ description: "Users returning after 7 days",
+ color: "00FF00"},
+ {stat: "users_30day_returning_30days",
+ description: "Users returning after 30 days",
+ color: "FF0000"} ] },
+
+ // pro pad usage
+ { name: "active pro accounts, 1 day",
+ description: "Active pro accounts, total over a %t period",
+ type: "compare",
+ stats: [ {stat: "active_pro_accounts",
+ description: "All accounts",
+ color: "FFA928" },
+ {stat: "pro_accounts_1day_returning_7days",
+ description: "Accounts older than 7 days",
+ color: "00FF00"},
+ {stat: "pro_accounts_1day_returning_30days",
+ description: "Accounts older than 30 days",
+ color: "FF0000"} ] },
+ { name: "active pro accounts, 7 day",
+ description: "Active pro accounts over the last 7 days",
+ type: "compare",
+ options: { hideLive: true, latestUseHistorical: true},
+ stats: [ {stat: "active_pro_accounts_7days",
+ description: "All accounts",
+ color: "FFA928" },
+ {stat: "pro_accounts_7day_returning_7days",
+ description: "Accounts older than 7 days",
+ color: "00FF00"},
+ {stat: "pro_accounts_7day_returning_30days",
+ description: "Accounts older than 30 days",
+ color: "FF0000"} ] },
+ { name: "active pro accounts, 30 day",
+ description: "Active pro accounts over the last 30 days",
+ type: "compare",
+ options: { hideLive: true, latestUseHistorical: true},
+ stats: [ {stat: "active_pro_accounts_30days",
+ description: "All accounts",
+ color: "FFA928" },
+ {stat: "pro_accounts_30day_returning_7days",
+ description: "Accounts older than 7 days",
+ color: "00FF00"},
+ {stat: "pro_accounts_30day_returning_30days",
+ description: "Accounts older than 30 days",
+ color: "FF0000"} ] },
+
+ // other stats
+ { name: "pad connections",
+ description: "Number of active comet connections, mean over a %t period",
+ type: "top",
+ options: {showOthers: false},
+ stats: ["streaming_connections"] },
+ { name: "referers",
+ description: "Referers, number of hits over a %t period",
+ type: "top",
+ options: {showOthers: false},
+ stats: ["top_referers"] },
+ ],
+ product: [
+ { name: "pads",
+ description: "Newly-created and active pads, total over a %t period",
+ type: "compare",
+ stats: [ {stat: "active_pads",
+ description: "Active pads",
+ color: "FFA928" },
+ {stat: "new_pads",
+ description: "New pads",
+ color: "FF0000" }] },
+ { name: "chats",
+ description: "Chat messages and active chatters, total over a %t period",
+ type: "compare",
+ stats: [ {stat: "chat_messages",
+ description: "Messages",
+ color: "FFA928" },
+ {stat: "active_chatters",
+ description: "Chatters",
+ color: "FF0000" }] },
+ { name: "import/export",
+ description: "Imports and Exports, total over a %t period",
+ type: "compare",
+ stats: [ {stat: {f: '+', args: ["imports_exports_counts:export", "imports_exports_counts:import"]},
+ description: "Total",
+ color: "FFA928" },
+ {stat: "imports_exports_counts:export",
+ description: "Exports",
+ color: "FF0000"},
+ {stat: "imports_exports_counts:import",
+ description: "Imports",
+ color: "00FF00"}] },
+ { name: "revenue",
+ description: "Revenue, total over a %t period",
+ type: "compare",
+ stats: [ {stat: "revenue",
+ description: "Revenue",
+ color: "FFA928"}] }
+ ],
+ performance: [
+ { name: "dynamic page latencies",
+ description: "Slowest dynamic pages: mean load time in milliseconds over a %t period",
+ type: "top",
+ options: {showOthers: false},
+ stats: ["execution_latencies"] },
+ { name: "pad startup latencies",
+ description: "Pad startup times: percent load time in milliseconds over a %t period",
+ type: "histogram",
+ stats: ["pad_startup_times"] },
+ { name: "stream post latencies",
+ description: "Comet post latencies, percentiles in milliseconds over a %t period",
+ type: "histogram",
+ stats: ["streaming_latencies"] },
+ ],
+ health: [
+ { name: "disconnect causes",
+ description: "Causes of disconnects, total over a %t period",
+ type: "top",
+ stats: ["disconnect_causes"] },
+ { name: "paths with 404s",
+ description: "'Not found' responses, by path, number served over a %t period",
+ type: "top",
+ stats: ["paths_404"] },
+ { name: "exceptions",
+ description: "Total number of server exceptions over a %t period",
+ type: "compare",
+ stats: [ {stat: "exceptions",
+ description: "Exceptions",
+ color: "FF1928" } ] },
+ { name: "paths with 500s",
+ type: "top",
+ description: "'500' responses, by path, number served over a %t period",
+ type: "top",
+ stats: ["paths_500"] },
+ { name: "paths with exceptions",
+ description: "responses with exceptions, by path, number served over a %t period",
+ type: "top",
+ stats: ["paths_exception"] },
+ { name: "disconnects with client-side errors",
+ description: "user disconnects with an error on the client side, number over a %t period",
+ type: "compare",
+ stats: [ { stat: "disconnects_with_clientside_errors",
+ description: "Disconnects with errors",
+ color: "FFA928" } ] },
+ { name: "unnecessary disconnects",
+ description: "disconnects that were avoidable, number over a %t period",
+ type: "compare",
+ stats: [ { stat: "streaming_disconnects:disconnected_userids",
+ description: "Number of unique users disconnected",
+ color: "FFA928" },
+ { stat: "streaming_disconnects:total_disconnects",
+ description: "Total number of disconnects",
+ color: "FF0000" } ] },
+ ]
+}
+
+function getUsedStats(statStructure) {
+ var stats = {};
+ function getStructureValues(statStructure) {
+ if (typeof(statStructure) == 'string') {
+ stats[statStructure] = true;
+ } else {
+ statStructure.args.forEach(getStructureValues);
+ }
+ }
+ getStructureValues(statStructure);
+ return keys(stats);
+}
+
+function getStatData(statStructure, values_f) {
+ function getStructureValues(statStructure) {
+ if (typeof(statStructure) == 'string') {
+ return values_f(statStructure);
+ } else if (typeof(statStructure) == 'number') {
+ return statStructure;
+ } else {
+ var args = statStructure.args.map(getStructureValues);
+ return {
+ f: statStructure.f,
+ args: args
+ }
+ }
+ }
+
+ var mappedStructure = getStructureValues(statStructure);
+
+ function evalStructure(statStructure) {
+ if ((typeof(statStructure) == 'number') || (statStructure instanceof Array)) {
+ return statStructure;
+ } else {
+ var merge_f = statStructure.f;
+ if (typeof(merge_f) == 'string') {
+ switch (merge_f) {
+ case '+':
+ merge_f = function() {
+ var sum = 0;
+ for (var i = 0; i < arguments.length; ++i) {
+ sum += arguments[i];
+ }
+ return sum;
+ }
+ break;
+ case '*':
+ merge_f = function() {
+ var product = 0;
+ for (var i = 0; i < arguments.length; ++i) {
+ product *= arguments[i];
+ }
+ return product;
+ }
+ break;
+ case '/':
+ merge_f = function(a, b) { return a / b; }
+ break;
+ case '-':
+ merge_f = function(a, b) { return a - b; }
+ break;
+ }
+ }
+ var evaluatedArguments = statStructure.args.map(evalStructure);
+ var length = -1;
+ evaluatedArguments.forEach(function(arg) {
+ if (typeof(arg) == 'object' && (arg instanceof Array)) {
+ length = arg.length;
+ }
+ });
+ evaluatedArguments = evaluatedArguments.map(function(arg) {
+ if (typeof(arg) == 'number') {
+ var newArg = new Array(length);
+ for (var i = 0; i < newArg.length; ++i) {
+ newArg[i] = arg;
+ }
+ return newArg
+ } else {
+ return arg;
+ }
+ });
+ return mergeArrays.apply(this, [merge_f].concat(evaluatedArguments));
+ }
+ }
+ return evalStructure(mappedStructure);
+}
+
+var googleChartSimpleEncoding = "ABCDEFGHIJLKMNOPQRSTUVQXYZabcdefghijklmnopqrstuvwxyz0123456789-.";
+function _enc(value) {
+ return googleChartSimpleEncoding[Math.floor(value/64)] + googleChartSimpleEncoding[value%64];
+}
+
+function drawSparkline(dataSets, labels, colors, minutes) {
+ var max = 1;
+ var maxLength = 0;
+ dataSets.forEach(function(dataSet, i) {
+ if (dataSet.length > maxLength) {
+ maxLength = dataSet.length;
+ }
+ dataSet.forEach(function(point) {
+ if (point > max) {
+ max = point;
+ }
+ });
+ });
+ var data = dataSets.map(function(dataSet) {
+ var chars = dataSet.map(function(x) {
+ if (x !== undefined) {
+ return _enc(Math.round(x/max*4095));
+ } else {
+ return "__";
+ }
+ }).join("");
+ while (chars.length < maxLength*2) {
+ chars = "__"+chars;
+ }
+ return chars;
+ }).join(",");
+ var timeLabels;
+ if (minutes < 60*24) {
+ timeLabels = [4,3,2,1,0].map(function(t) {
+ var minutesPerTick = minutes/4;
+ var d = new Date(Date.now() - minutesPerTick*60000*t);
+ return (d.getHours()%12 || 12)+":"+(d.getMinutes() < 10 ? "0" : "")+d.getMinutes()+(d.getHours() < 12 ? "am":"pm");
+ }).join("|");
+ } else {
+ timeLabels = [4,3,2,1,0].map(function(t) {
+ var daysPerTick = (minutes/(60*24))/4;
+ var d = new Date(Date.now() - t*daysPerTick*24*60*60*1000);
+ return (d.getMonth()+1)+"/"+d.getDate();
+ }).join("|");
+ }
+ var pointLabels = dataSets.map(function(dataSet, i) {
+ return ["t"+dataSet[dataSet.length-1],colors[i],i,maxLength-1,12,0].join(",");
+ }).join("|");
+ labels = labels.map(function(label) {
+ return encodeURIComponent((label.length > 73) ? label.slice(0, 70) + "..." : label);
+ });
+ var step = Math.round(max/10);
+ step = Math.round(step/Math.pow(10, String(step).length-1))*Math.pow(10, String(step).length-1);
+ var srcUrl =
+ "http://chart.apis.google.com/chart?chs=600x300&cht=lc&chd=e:"+data+
+ "&chxt=y,x&chco="+colors.join(",")+"&chxr=0,0,"+max+","+step+"&chxl=1:|"+timeLabels+
+ "&chdl="+labels.join("|")+"&chdlp=b&chm="+pointLabels;
+ return toHTML(IMG({src: srcUrl}));
+}
+
+var liveDataNumSamples = 20;
+
+function extractStatValuesFunction(nameToValues_f) {
+ return function(statName) {
+ var value;
+ if (statName.indexOf(":") >= 0) {
+ [statName, value] = statName.split(":");
+ }
+ var h = nameToValues_f(statName);
+ if (value) {
+ h = h.map(function(topValues) {
+ if (! topValues) { return; }
+ var tv = topValues.topValues;
+ for (var i = 0; i < tv.length; ++i) {
+ if (tv[i].value == value) {
+ return tv[i].count;
+ }
+ }
+ return 0;
+ });
+ }
+ return h;
+ }
+}
+
+function sparkline_compare(history_f, minutesPerSample, stat) {
+ var histories = stat.stats.map(function(stat) {
+ var samples = getStatData(stat.stat, extractStatValuesFunction(history_f));
+ return [samples, stat.description, stat.color];
+ });
+ return drawSparkline(histories.map(function(history) { return history[0] }),
+ histories.map(function(history) { return history[1] }),
+ histories.map(function(history) { return history[2] }),
+ minutesPerSample*histories[0][0].length);
+}
+
+function sparkline_top(history_f, minutesPerSample, stat) {
+ var showOthers = ! stat.options || stat.options.showOthers != false;
+ var history = stat.stats.map(history_f)[0];
+
+ if (history.length == 0) {
+ return "<b>no data</b>";
+ }
+ var topRecents = {};
+ var topRecents_arr = [];
+ history.forEach(function(tv) {
+ if (! tv) { return; }
+ if (tv.topValues.length > 0) {
+ topRecents_arr = tv.topValues.map(function(x) { return x.value; });
+ }
+ });
+
+ if (topRecents_arr.length == 0) {
+ return "<b>no data</b>";
+ }
+ topRecents_arr = topRecents_arr.slice(0, _topN());
+ topRecents_arr.forEach(function(value, i) {
+ topRecents[value] = i;
+ });
+
+ if (showOthers) {
+ topRecents_arr.push("Other");
+ }
+ var max = 1;
+ var values = topRecents_arr.map(function() { return history.map(function() { return 0 }); });
+
+ history.forEach(function(tv, i) {
+ if (! tv) { return; }
+ tv.topValues.forEach(function(entry) {
+ if (entry.count > max) {
+ max = entry.count;
+ }
+ if (entry.value in topRecents) {
+ values[topRecents[entry.value]][i] = entry.count;
+ } else if (showOthers) {
+ values[values.length-1][i] += entry.count;
+ }
+ });
+ });
+ return drawSparkline(
+ values,
+ topRecents_arr,
+ ["FF0000", "00FF00", "0000FF", "FF00FF", "00FFFF"].slice(0, topRecents_arr.length-1).concat("FFA928"),
+ minutesPerSample*history.length);
+}
+
+function sparkline_histogram(history_f, minutesPerSample, stat) {
+ var history = stat.stats.map(history_f)[0];
+
+ if (history.length == 0) {
+ return "<b>no data</b>";
+ }
+ var percentiles = [50, 90, 95, 99];
+ var data = percentiles.map(function() { return []; })
+ history.forEach(function(hist) {
+ percentiles.forEach(function(pct, i) {
+ data[i].push((hist ? hist[""+pct] : undefined));
+ });
+ });
+ return drawSparkline(
+ data,
+ percentiles.map(function(pct) { return ""+pct+"%"; }),
+ ["FF0000","FF00FF","FFA928","00FF00"].reverse(),
+ minutesPerSample*history.length);
+}
+
+function liveHistoryFunction(minutesPerSample) {
+ return function(statName) {
+ return statistics.liveSnapshot(statName).history(minutesPerSample, liveDataNumSamples);
+ }
+}
+
+function _listStats(statName, count) {
+ var options = { orderBy: '-timestamp,id' };
+ if (count !== undefined) {
+ options.limit = count;
+ }
+ return sqlobj.selectMulti('statistics', {name: statName}, options);
+}
+
+function ancientHistoryFunction(time) {
+ return function(statName) {
+ var seenDates = {};
+ var samples = _listStats(statName);
+
+ samples = samples.reverse().map(function(json) {
+ if (seenDates[""+json.timestamp]) { return; }
+ seenDates[""+json.timestamp] = true;
+ return {timestamp: json.timestamp, json: json.value};
+ }).filter(function(x) { return x !== undefined });
+
+ samples = samples.reverse().slice(0, Math.round(time/(24*60)));
+ var samplesWithEmptyValues = [];
+ for (var i = 0; i < samples.length-1; ++i) {
+ var current = samples[i];
+ var next = samples[i+1];
+ samplesWithEmptyValues.push(current.json);
+ for (var j = current.timestamp+86400*1000; j < next.timestamp; j += 86400*1000) {
+ samplesWithEmptyValues.push(undefined);
+ }
+ }
+ if (samples.length > 0) {
+ samplesWithEmptyValues.push(samples[samples.length-1].json);
+ }
+ samplesWithEmptyValues = samplesWithEmptyValues.map(function(json) {
+ if (! json) { return; }
+ var obj = fastJSON.parse(json);
+ if (keys(obj).length == 1 && 'value' in obj) {
+ obj = obj.value;
+ }
+ return obj;
+ });
+
+ return samplesWithEmptyValues.reverse();
+ }
+}
+
+function sparkline(history_f, minutesPerSample, stat) {
+ if (this["sparkline_"+stat.type]) {
+ return this["sparkline_"+stat.type](history_f, minutesPerSample, stat);
+ } else {
+ return "<b>No sparkline handler!</b>";
+ }
+}
+
+function liveLatestFunction(minutesPerSample) {
+ return function(statName) {
+ return [statistics.liveSnapshot(statName).latest(minutesPerSample)];
+ }
+}
+
+function liveTotal(statName) {
+ return [statistics.liveSnapshot(statName).total];
+}
+
+function historyLatest(statName) {
+ return _listStats(statName, 1).map(function(x) {
+ var value = fastJSON.parse(x.value);
+ if (keys(value).length == 1 && 'value' in value) {
+ value = value.value;
+ }
+ return value;
+ });
+}
+
+function latest_compare(latest_f, stat) {
+ return stat.stats.map(function(stat) {
+ var sample = getStatData(stat.stat, extractStatValuesFunction(latest_f))[0];
+ return { value: sample, description: stat.description };
+ });
+}
+
+function latest_top(latest_f, stat) {
+ var showOthers = ! stat.options || stat.options.showOthers != false;
+
+ var sample = stat.stats.map(latest_f)[0][0];
+ if (! sample) {
+ return [];
+ }
+ var total = sample.count;
+
+ var values = sample.topValues.slice(0, _topN()).map(function(v) {
+ total -= v.count;
+ return { value: v.count, description: v.value };
+ });
+ if (showOthers) {
+ values.push({value: total, description: "Other"});
+ }
+ return values;
+}
+
+function latest_histogram(latest_f, stat) {
+ var sample = stat.stats.map(latest_f)[0][0];
+
+ if (! sample) {
+ return "<b>no data</b>";
+ }
+
+ var percentiles = [0, 1, 5, 10, 25, 50, 75, 90, 95, 99, 100].filter(function(pct) { return ((""+pct) in sample) });
+
+ var xpos = percentiles.map(function(x, i) { return sample[x] });
+ var xMax = 0;
+ var xMin = 1e12;
+ xpos.forEach(function(x) { xMax = (x > xMax ? x : xMax); xMin = (x < xMin ? x : xMin); });
+ xposNormalized = xpos.map(function(x) { return Math.round((x-xMin)/(xMax-xMin || 1)*100); });
+
+ var ypos = percentiles.slice(1).map(function(y, i) { return (y-percentiles[i])/(xpos[i+1] || 1); });
+ var yMax = 0;
+ ypos.forEach(function(y) { yMax = (y > yMax ? y : yMax); });
+ yposNormalized = ypos.map(function(y) { return Math.round(y/yMax*100); });
+
+ // var proposedLabels = mergeArrays(function(x, y) { return {pos: x, label: y}; }, xposNormalized, xpos);
+ // var keepLabels = [{pos: 0, label: 0}];
+ // proposedLabels.forEach(function(label) {
+ // if (label.pos - keepLabels[keepLabels.length-1].pos > 10) {
+ // keepLabels.push(label);
+ // }
+ // });
+ //
+ // var labelPos = keepLabels.map(function(x) { return x.pos });
+ // var labels = keepLabels.map(function(x) { return x.label });
+
+ return toHTML(IMG({src:
+ "http://chart.apis.google.com/chart?chs=340x100&cht=lxy&chd=t:"+xposNormalized.join(",")+"|0,"+yposNormalized.join(",")+
+ "&chxt=x&chxr=0,"+xMin+","+xMax+","+Math.floor((xMax-xMin)/5) // "l=0:|"+labels.join("|")+"&chxp=0,"+labelPos.join(",")
+ }));
+}
+
+function latest(latest_f, stat) {
+ if (this["latest_"+stat.type]) {
+ return this["latest_"+stat.type](latest_f, stat);
+ } else {
+ return "<b>No latest handler!</b>";
+ }
+}
+
+function dropdown(name, options, selected) {
+ var select;
+ if (typeof(name) == 'string') {
+ select = SELECT({name: name});
+ } else {
+ select = SELECT(name);
+ }
+
+ function addOption(value, content) {
+ var opt = OPTION({value: value}, content || value);
+ if (value == selected) {
+ opt.attribs.selected = "selected";
+ }
+ select.push(opt);
+ }
+
+ if (options instanceof Array) {
+ options.forEach(f_limitArgs(this, addOption, 1));
+ } else {
+ eachProperty(options, addOption);
+ }
+ return select;
+}
+
+function render_main() {
+ var categoriesToStats = {};
+
+ eachProperty(statDisplays, function(catName, statArray) {
+ categoriesToStats[catName] = statArray.map(_renderableStat);
+ });
+
+ renderHtml('statistics/stat_page.ejs',
+ {eachProperty: eachProperty,
+ statCategoryNames: keys(categoriesToStats),
+ categoriesToStats: categoriesToStats,
+ optionsForm: _optionsForm() });
+}
+
+function _optionsForm() {
+ return FORM({id: "statprefs", method: "POST"}, "Show data with granularity: ",
+ // dropdown({name: 'showLiveOrHistorical', onchange: 'formChanged();'},
+ // {live: 'live', hist: 'historical'},
+ // _pref('showLiveOrHistorical')),
+ // (_showLiveStats() ?
+ // SPAN(" with granularity ",
+ dropdown({name: 'granularity', onchange: 'formChanged();'},
+ {"1": '1 minute', "5": '5 minutes', "60": '1 hour', "1440": '1 day'},
+ _pref('granularity')), // ),
+ // : ""),
+ " top N:",
+ INPUT({type: "text", name: "topNCount", value: _topN()}),
+ INPUT({type: "submit", name: "Set", value: "set N"}),
+ INPUT({type: "hidden", name: "fragment", id: "fragment", value: "health"}));
+}
+
+// function render_main() {
+// var body = BODY();
+//
+// var cat = request.params.cat;
+// if (!cat) {
+// cat = 'health';
+// }
+//
+// body.push(A({id: "backtoadmin", href: "/ep/admin/"}, html("&laquo;"), " back to admin"));
+// body.push(_renderTopnav(cat));
+//
+// body.push(form);
+//
+// if (request.params.stat) {
+// body.push(A({className: "viewall",
+// href: qpath({stat: null})}, html("&laquo;"), " view all"));
+// }
+//
+// var statNames = statDisplays[cat];
+// statNames.forEach(function(sn) {
+// if (!request.params.stat || (request.params.stat == sn)) {
+// body.push(_renderableStat(sn));
+// }
+// });
+//
+// helpers.includeCss('admin/admin-stats.css');
+// response.write(HTML(HEAD(html(helpers.cssIncludes())), body));
+// }
+
+function _getLatest(stat) {
+ var minutesPerSample = _timescale();
+
+ if (_showLiveStats()) {
+ return latest(liveLatestFunction(minutesPerSample), stat);
+ } else {
+ return latest(liveTotal, stat);
+ }
+}
+
+function _getGraph(stat) {
+ var minutesPerSample = _timescale();
+
+ if (_showLiveStats()) {
+ return html(sparkline(liveHistoryFunction(minutesPerSample), minutesPerSample, stat));
+ } else {
+ return html(sparkline(ancientHistoryFunction(60*24*60), 24*60, stat));
+ }
+}
+
+function _getDataLinks(stat) {
+ if (_showLiveStats()) {
+ return;
+ }
+
+ function listToLinks(list) {
+ var links = []; //SPAN({className: "datalink"}, "(data for ");
+ list.forEach(function(statName) {
+ links.push(toHTML(A({href: "/ep/admin/usagestats/data?statName="+statName}, statName)));
+ });
+// links.push(")");
+ return links;
+ }
+
+ switch (stat.type) {
+ case 'compare':
+ var stats = [];
+ stat.stats.map(function(stat) { return getUsedStats(stat.stat); }).forEach(function(list) {
+ stats = stats.concat(list);
+ });
+ return listToLinks(stats);
+ case 'top':
+ return listToLinks(stat.stats);
+ case 'histogram':
+ return listToLinks(stat.stats);
+ }
+}
+
+function _renderableStat(stat) {
+ var minutesPerSample = _timescale();
+
+ var period = (_showLiveStats() ? minutesPerSample : 24*60);
+
+ if (period < 24*60 && stat.hideLive) {
+ return;
+ }
+
+ if (period < 60) {
+ period = ""+period+"-minute";
+ } else if (period < 24*60) {
+ period = ""+period/(60)+"-hour";
+ } else if (period >= 24*60) {
+ period = ""+period/(24*60)+"-day";
+ }
+ var graph = _getGraph(stat);
+ var id = stat.name.replace(/[^a-zA-Z0-9]/g, "");
+
+ var displayName = stat.description.replace("%t", period);
+ var latest = _getLatest(stat);
+ var dataLinks = _getDataLinks(stat);
+
+ return {
+ id: id,
+ specialState: "",
+ displayName: displayName,
+ name: stat.name,
+ graph: graph,
+ latest: latest,
+ dataLinks: dataLinks
+ }
+}
+
+function render_data() {
+ var sn = request.params.statName;
+ var t = TABLE({border: 1, cellpadding: 2, style: "font-family: monospace;"});
+ _listStats(sn).forEach(function(s) {
+ var tr = TR();
+ tr.push(TD((s.id)));
+ tr.push(TD((new Date(s.timestamp * 1000)).toString()));
+ tr.push(TD(s.value));
+ t.push(tr);
+ });
+ response.write(HTML(BODY(t)));
+}
+
+
+// function renderStat(body, statName) {
+// var div = DIV({className: 'statbox'});
+// div.push(A({className: "stat-title", href: qpath({stat: statName})},
+// statName, descriptions[statName] || ""));
+// if (_showHistStats()) {
+// div.push(
+// DIV({className: "stat-graph"},
+// A({href: '/ep/admin/usagestats/graph?size=1080x420&statName='+statName},
+// IMG({src: '/ep/admin/usagestats/graph?size=400x200&statName='+statName,
+// style: 'border: 1px solid #ccc; margin: 10px 0 0 20px;'})),
+// BR(),
+// DIV({style: 'text-align: right;'},
+// A({style: 'text-decoration: none; font-size: .8em;',
+// href: '/ep/admin/usagestats/data?statName='+statName}, "(data)")))
+// );
+// }
+// if (_showLiveStats()) {
+// var data = statistics.getStatData(statName);
+// var displayData = statistics.liveSnapshot(data);
+// var t = TABLE({border: 0});
+// var tcount = 0;
+// ["minute", "hour", "fourHour", "day"].forEach(function(timescale) {
+// if (! _showTimescale(timescale)) { return; }
+// var tr = TR();
+// t.push(tr);
+// tr.push(TD({valign: "top"}, B("Last ", timescale)));
+// var td = TD();
+// var cell = SPAN();
+// tr.push(td);
+// td.push(cell);
+// switch (data.plotType) {
+// case 'line':
+// cell.push(B(displayData[timescale])); break;
+// case 'topValues':
+// var top = displayData[timescale].topValues;
+// if (tcount != 0) {
+// tr[0].attribs.style = cell.attribs.style = "border-top: 2px solid black;";
+// }
+// // println(statName+" / top length: "+top.length);
+// for (var i = 0; i < Math.min(_topN(), top.length); ++i) {
+// cell.push(B(top[i].count), ": ", top[i].value, BR());
+// }
+// break;
+// case 'histogram':
+// var percentiles = displayData[timescale];
+// var pcts = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
+// var max = percentiles["100"] || 1000;
+// cell.push(IMG({src: "http://chart.apis.google.com/chart?chs=340x100&cht=bvs&chd=t:"+
+// pcts.map(function(pct) { return Math.round(percentiles[""+pct]/max*100); }).join(",")+
+// "&chxt=x,y&chxl=0:|"+
+// pcts.map(function(pct) { return ""+pct+"%"; }).join("|")+
+// "&chxr=0,0,100|1,0,"+max+""}))
+// // td.push("50%: ", B(percentiles["50"]), " ",
+// // "90%: ", B(percentiles["90"]), " ",
+// // "max: ", B(percentiles["100"]));
+// break;
+// }
+// tcount++;
+// });
+// div.push(DIV({className: "stat-table"}, t));
+// div.push(html(helpers.clearFloats()));
+// }
+// body.push(div);
+// }
+// =======
+// >>>>>>> Stashed changes:etherpad/src/etherpad/control/statscontrol.js
+
+
+// old output.
+
+//
+// function getStatsForCategory(category) {
+// var statnames = statistics.getAllStatNames();
+//
+// var matchingStatNames = [];
+// statnames.forEach(function(sn) {
+// if (statistics.getStatData(sn).category == category) {
+// matchingStatNames.push(sn);
+// }
+// });
+//
+// return matchingStatNames;
+// }
+//
+// function renderCategoryList() {
+// var body = BODY();
+//
+// catNames = getCategoryNames();
+// body.push(P("Please select a statistics category:"));
+// catNames.sort().forEach(function(catname) {
+// body.push(P(A({href: "/ep/admin/usagestats/?cat="+catname}, catname)));
+// });
+// response.write(body);
+// }
+//
+// function getCategoryNames() {
+// var statnames = statistics.getAllStatNames();
+// var catNames = {};
+// statnames.forEach(function(sn) {
+// catNames[statistics.getStatData(sn).category] = true;
+// });
+// return keys(catNames);
+// }
+//
+// function dropdown(name, options, selected) {
+// var select;
+// if (typeof(name) == 'string') {
+// select = SELECT({name: name});
+// } else {
+// select = SELECT(name);
+// }
+//
+// function addOption(value, content) {
+// var opt = OPTION({value: value}, content || value);
+// if (value == selected) {
+// opt.attribs.selected = "selected";
+// }
+// select.push(opt);
+// }
+//
+// if (options instanceof Array) {
+// options.forEach(f_limitArgs(this, addOption, 1));
+// } else {
+// eachProperty(options, addOption);
+// }
+// return select;
+// }
+//
+// function getCategorizedStats() {
+// var statnames = statistics.getAllStatNames();
+// var categories = {}
+// statnames.forEach(function(sn) {
+// var category = statistics.getStatData(sn).category
+// if (! categories[category]) {
+// categories[category] = [];
+// }
+// categories[category].push(statistics.getStatData(sn));
+// });
+// return categories;
+// }
+//
+// function render_ajax() {
+// var categoriesToStats = getCategorizedStats();
+//
+// eachProperty(categoriesToStats, function(catName, statArray) {
+// categoriesToStats[catName] = statArray.map(function(statObject) {
+// return {
+// specialState: "",
+// displayName: statObject.name,
+// name: statObject.name,
+// data: liveStatDisplayHtml(statObject)
+// }
+// })
+// });
+//
+// renderHtml('statistics/stat_page.ejs',
+// {eachProperty: eachProperty,
+// statCategoryNames: keys(categoriesToStats),
+// categoriesToStats: categoriesToStats });
+// }
+
+// function render_main() {
+// var body = BODY();
+//
+// var statNames = statistics.getAllStatNames(); //getStatsForCategory(request.params.cat);
+// statNames.forEach(function(sn) {
+// renderStat(body, sn);
+// });
+// response.write(body);
+// }
+//
+// var descriptions = {
+// execution_latencies: ", mean response time in milliseconds",
+// static_file_latencies: ", mean response time in milliseconds",
+// pad_startup_times: ", max response time in milliseconds of fastest N% of requests"
+// };
+//
+// function liveStatDisplayHtml(statObject) {
+// var displayData = statistics.liveSnapshot(statObject);
+// switch (statObject.plotType) {
+// case 'line':
+// return displayData;
+// case 'topValues':
+// var data = {}
+// eachProperty(displayData, function(timescale, tsdata) {
+// data[timescale] = ""
+// var top = tsdata.topValues;
+// for (var i = 0; i < Math.min(_topN(), top.length); ++i) {
+// data[timescale] += [B(top[i].count), ": ", top[i].value, BR()].map(toHTML).join("");
+// }
+// if (data[timescale] == "") {
+// data[timescale] = "(no data)";
+// }
+// });
+// return data;
+// case 'histogram':
+// var imgs = {}
+// eachProperty(displayData, function(timescale, tsdata) {
+// var percentiles = tsdata;
+// var pcts = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
+// var max = percentiles["100"] || 1000;
+// imgs[timescale] =
+// toHTML(IMG({src: "http://chart.apis.google.com/chart?chs=400x100&cht=bvs&chd=t:"+
+// pcts.map(function(pct) { return Math.round(percentiles[""+pct]/max*100); }).join(",")+
+// "&chxt=x,y&chxl=0:|"+
+// pcts.map(function(pct) { return ""+pct+"%"; }).join("|")+
+// "&chxr=0,0,100|1,0,"+max+""}));
+// });
+// return imgs;
+// }
+// }
+//
+// function renderStat(body, statName) {
+// var div = DIV({style: 'float: left; text-align: center; margin: 3px; border: 1px solid black;'})
+// div.push(P(statName, descriptions[statName] || ""));
+// if (_showLiveStats()) {
+// var data = statistics.getStatData(statName);
+// var displayData = statistics.liveSnapshot(data);
+// var t = TABLE();
+// var tcount = 0;
+// ["minute", "hour", "fourHour", "day"].forEach(function(timescale) {
+// if (! _showTimescale(timescale)) { return; }
+// var tr = TR();
+// t.push(tr);
+// tr.push(TD("last ", timescale));
+// var td = TD();
+// tr.push(td);
+// switch (data.plotType) {
+// case 'line':
+// td.push(B(displayData[timescale])); break;
+// case 'topValues':
+// var top = displayData[timescale].topValues;
+// if (tcount != 0) {
+// tr[0].attribs.style = td.attribs.style = "border-top: 1px solid gray;";
+// }
+// // println(statName+" / top length: "+top.length);
+// for (var i = 0; i < Math.min(_topN(), top.length); ++i) {
+// td.push(B(top[i].count), ": ", top[i].value, BR());
+// }
+// break;
+// case 'histogram':
+// var percentiles = displayData[timescale];
+// var pcts = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
+// var max = percentiles["100"] || 1000;
+// td.push(IMG({src: "http://chart.apis.google.com/chart?chs=340x100&cht=bvs&chd=t:"+
+// pcts.map(function(pct) { return Math.round(percentiles[""+pct]/max*100); }).join(",")+
+// "&chxt=x,y&chxl=0:|"+
+// pcts.map(function(pct) { return ""+pct+"%"; }).join("|")+
+// "&chxr=0,0,100|1,0,"+max+""}))
+// // td.push("50%: ", B(percentiles["50"]), " ",
+// // "90%: ", B(percentiles["90"]), " ",
+// // "max: ", B(percentiles["100"]));
+// break;
+// }
+// tcount++;
+// });
+// div.push(t)
+// }
+// if (_showHistStats()) {
+// div.push(A({href: '/ep/admin/usagestats/graph?size=1080x420&statName='+statName},
+// IMG({src: '/ep/admin/usagestats/graph?size=400x200&statName='+statName,
+// style: 'border: 1px solid #ccc; margin: 10px 0 0 20px;'})),
+// BR(),
+// DIV({style: 'text-align: right;'},
+// A({style: 'text-decoration: none; font-size: .8em;',
+// href: '/ep/admin/usagestats/data?statName='+statName}, "(data)")));
+// }
+// body.push(div);
+// }
+//
+// function render_graph() {
+// var sn = request.params.statName;
+// if (!sn) {
+// render404();
+// }
+// usage_stats.respondWithGraph(sn);
+// }
+//
+//
+// function render_exceptions() {
+// var logNames = ["frontend/exception", "backend/exceptions"];
+// }
+
+// function render_updatehistory() {
+//
+// sqlcommon.withConnection(function(conn) {
+// var stmnt = "delete from statistics;";
+// var s = conn.createStatement();
+// sqlcommon.closing(s, function() {
+// s.execute(stmnt);
+// });
+// });
+//
+// var processed = {};
+//
+// function _domonth(y, m) {
+// for (var i = 0; i < 32; i++) {
+// _processStatsDay(y, m, i, processed);
+// }
+// }
+//
+// _domonth(2008, 10);
+// _domonth(2008, 11);
+// _domonth(2008, 12);
+// _domonth(2009, 1);
+// _domonth(2009, 2);
+// _domonth(2009, 3);
+// _domonth(2009, 4);
+// _domonth(2009, 5);
+// _domonth(2009, 6);
+// _domonth(2009, 7);
+//
+// response.redirect('/ep/admin/usagestats');
+// }
+
+// function _processStatsDay(year, month, date, processed) {
+// var now = new Date();
+// var day = new Date();
+//
+// for (var i = 0; i < 10; i++) {
+// day.setFullYear(year);
+// day.setDate(date);
+// day.setMonth(month-1);
+// }
+//
+// if ((+day < +now) &&
+// (!((day.getFullYear() == now.getFullYear()) &&
+// (day.getMonth() == now.getMonth()) &&
+// (day.getDate() == now.getDate())))) {
+//
+// var dayNoon = statistics.noon(day);
+//
+// if (processed[dayNoon]) {
+// return;
+// } else {
+// statistics.processLogDay(new Date(dayNoon));
+// processed[dayNoon] = true;
+// }
+// } else {
+// /* nothing */
+// }
+// }
+
diff --git a/trunk/etherpad/src/etherpad/control/store/eepnet_checkout_control.js b/trunk/etherpad/src/etherpad/control/store/eepnet_checkout_control.js
new file mode 100644
index 0000000..ddd4973
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/store/eepnet_checkout_control.js
@@ -0,0 +1,757 @@
+/**
+ * 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("email.sendEmail");
+import("fastJSON");
+import("funhtml.*");
+import("jsutils.*");
+import("sqlbase.sqlobj");
+import("stringutils");
+import("sync");
+
+import("etherpad.billing.billing");
+import("etherpad.billing.fields");
+import("etherpad.globals");
+import("etherpad.globals.*");
+import("etherpad.helpers");
+import("etherpad.licensing");
+import("etherpad.pro.pro_utils");
+import("etherpad.sessions.{getSession,getTrackingId,getSessionId}");
+import("etherpad.store.checkout");
+import("etherpad.store.eepnet_checkout");
+import("etherpad.utils.*");
+
+import("static.js.billing_shared.{billing=>billingJS}");
+
+jimport("java.lang.System.out.println");
+
+//----------------------------------------------------------------
+
+var STORE_URL = '/ep/store/eepnet-checkout/';
+
+var _pageSequence = [
+ ['purchase', "Number of Users", true],
+ ['support-contract', "Support Contract", true],
+ ['license-info', "License Information", true],
+ ['billing-info', "Billing Information", true],
+ ['confirmation', "Confirmation", false]
+];
+
+var _specialPages = {
+ 'receipt': ['receipt', "Receipt", false]
+}
+
+//----------------------------------------------------------------
+
+function _cart() {
+ return getSession().eepnetCart;
+}
+
+function _currentPageSegment() {
+ return request.path.split('/')[4];
+}
+
+function _currentPageId() {
+ return _applyToCurrentPageSequenceEntry(function(ps) { return ps[0]; });
+}
+
+function _applyToCurrentPageSequenceEntry(f) {
+ for (var i = 0; i < _pageSequence.length; i++) {
+ if (_pageSequence[i][0] == _currentPageSegment()) {
+ return f(_pageSequence[i], i, true);
+ }
+ }
+ if (_specialPages[_currentPageSegment()]) {
+ return f(_specialPages[_currentPageSegment()], -1, false);
+ }
+ return undefined;
+}
+
+function _currentPageIndex() {
+ return _applyToCurrentPageSequenceEntry(function(ps, i) { return i; });
+}
+
+function _currentPageTitle() {
+ return _applyToCurrentPageSequenceEntry(function(ps) { return ps[1]; });
+}
+
+function _currentPageShowCart() {
+ return _applyToCurrentPageSequenceEntry(function(ps) { return ps[2]; });
+}
+
+function _currentPageInFlow() {
+ return _applyToCurrentPageSequenceEntry(function(ps, i, isSpecial) { return isSpecial });
+}
+
+function _pageId(d) {
+ return _applyToCurrentPageSequenceEntry(function(ps, i) {
+ if (_pageSequence[i+d]) {
+ return _pageSequence[i+d][0];
+ }
+ });
+}
+
+function _nextPageId() { return _pageId(+1); }
+function _prevPageId() { return _pageId(-1); }
+
+function _advancePage() {
+ response.redirect(_pathTo(_nextPageId()));
+}
+
+function _pathTo(id) {
+ return STORE_URL+id;
+}
+
+// anything starting with 'billing' is also ok.
+function _isAutomaticallySetParam(p) {
+ var _automaticallySetParams = arrayToSet([
+ 'numUsers', 'couponCode', 'supportContract',
+ 'email', 'ownerName', 'orgName', 'licenseAgreement'
+ ]);
+
+ return _automaticallySetParams[p] || stringutils.startsWith(p, "billing");
+}
+
+function _lastSubmittedPage() {
+ var cart = _cart();
+ return isNaN(cart.lastSubmittedPage) ? -1 : Number(cart.lastSubmittedPage);
+}
+
+function _shallowSafeCopy(obj) {
+ return billing.clearKeys(obj, [
+ {name: 'billingCCNumber',
+ valueTest: function(s) { return /^\d{15,16}$/.test(s) },
+ valueReplace: billing.replaceWithX },
+ {name: 'billingCSC',
+ valueTest: function(s) { return /^\d{3,4}$/.test(s) },
+ valueReplace: billing.replaceWithX }]);
+}
+
+function onRequest() {
+ billing.log({
+ 'type': "billing-request",
+ 'date': +(new Date),
+ 'method': request.method,
+ 'path': request.path,
+ 'query': request.query,
+ 'host': request.host,
+ 'scheme': request.scheme,
+ 'params': _shallowSafeCopy(request.params),
+ 'cart': _shallowSafeCopy(_cart())
+ });
+ if (request.path == STORE_URL+"paypalnotify") {
+ _handlePaypalNotification();
+ }
+ if (request.path == STORE_URL+"paypalredirect") {
+ _handlePayPalRedirect();
+ }
+ var cart = _cart();
+ if (!cart || request.params.clearcart) {
+ getSession().eepnetCart = {
+ lastSubmittedPage: -1,
+ invoiceId: billing.createInvoice()
+ };
+ if (request.params.clearcart) {
+ response.redirect(request.path);
+ }
+ if (_currentPageId() != 'purchase') {
+ response.redirect(_pathTo('purchase'));
+ }
+ cart = _cart();
+ }
+ if (request.params.invoice) {
+ cart.billingPurchaseType = 'invoice';
+ }
+ if (cart.purchaseComplete && _currentPageId() != 'receipt') {
+ cart.showStartOverMessage = true;
+ response.redirect(_pathTo('receipt'));
+ }
+ // somehow user got too far?
+ if (_currentPageIndex() > _lastSubmittedPage() + 1) {
+ response.redirect(_pathTo(_pageSequence[_lastSubmittedPage()+1][0]));
+ }
+ if (request.isGet) {
+ // see if this is a standard cart-page get
+ if (_currentPageId()) {
+ _renderCartPage();
+ return true;
+ }
+ }
+ if (request.isPost) {
+ // add params to cart
+ eachProperty(request.params, function(k,v) {
+ if (! _isAutomaticallySetParam(k)) { return; }
+ if (k == "billingCCNumber" && v.charAt(0) == 'X') { return; }
+ cart[k] = stringutils.toHTML(v);
+ });
+ if (_currentPageId() == 'license-info' && ! request.params.licenseAgreement) {
+ delete cart.licenseAgreement;
+ }
+ if (_currentPageIndex() > cart.lastSubmittedPage) {
+ cart.lastSubmittedPage = _currentPageIndex();
+ }
+ }
+ if (request.params.backbutton) {
+ _updateCosts();
+ response.redirect(_pathTo(_prevPageId()));
+ }
+ return false; // commence auto-dispatch
+}
+
+function _getCoupon(code) {
+ return sqlobj.selectSingle('checkout_referral', {id: code});
+}
+
+function _supportCost() {
+ var cart = _cart();
+ return Math.max(eepnet_checkout.SUPPORT_MIN_COST, eepnet_checkout.SUPPORT_COST_PCT/100*cart.baseCost);
+}
+
+function _discountedSupportCost() {
+ var cart = _cart();
+ if ('couponSupportPctDiscount' in cart) {
+ return _supportCost() -
+ (cart.couponSupportPctDiscount ?
+ cart.couponSupportPctDiscount/100 * _supportCost() :
+ 0);
+ }
+}
+
+function _updateCosts() {
+ var cart = _cart();
+
+ if (cart.numUsers) {
+ cart.numUsers = Number(cart.numUsers);
+
+ cart.baseCost = cart.numUsers * eepnet_checkout.COST_PER_USER;
+
+ if (cart.supportContract == "true") {
+ cart.supportCost = _supportCost();
+ } else {
+ delete cart.supportCost;
+ }
+
+ var coupon = _getCoupon(cart.couponCode);
+ if (coupon) {
+ for (i in coupon) {
+ cart["coupon"+stringutils.makeTitle(i)] = coupon[i];
+ }
+ cart.coupon = coupon;
+ } else {
+ for (i in cart.coupon) {
+ delete cart["coupon"+stringutils.makeTitle(i)];
+ }
+ delete cart.coupon;
+ }
+
+ if (cart.couponProductPctDiscount) {
+ cart.productReferralDiscount =
+ cart.couponProductPctDiscount/100 * cart.baseCost;
+ } else {
+ delete cart.productReferralDiscount;
+ }
+ if (cart.couponSupportPctDiscount) {
+ cart.supportReferralDiscount =
+ cart.couponSupportPctDiscount/100 * (cart.supportCost || 0);
+ } else {
+ delete cart.supportReferralDiscount;
+ }
+ cart.subTotal =
+ cart.baseCost - (cart.productReferralDiscount || 0) +
+ (cart.supportCost || 0) - (cart.supportReferralDiscount || 0);
+
+ if (cart.couponTotalPctDiscount) {
+ cart.totalReferralDiscount =
+ cart.couponTotalPctDiscount/100 * cart.subTotal;
+ } else {
+ delete cart.totalReferralDiscount;
+ }
+
+ if (cart.couponFreeUsersCount || cart.couponFreeUsersPct) {
+ cart.freeUserCount =
+ Math.round(cart.couponFreeUsersCount +
+ cart.couponFreeUsersPct/100 * cart.numUsers);
+ } else {
+ delete cart.freeUserCount;
+ }
+ cart.userCount = Number(cart.numUsers) + Number(cart.freeUserCount || 0);
+
+ cart.total =
+ cart.subTotal - (cart.totalReferralDiscount || 0);
+ }
+}
+
+//----------------------------------------------------------------
+// template helper functions
+//----------------------------------------------------------------
+
+function _cartDebug() {
+ if (globals.isProduction()) {
+ return '';
+ }
+
+ var d = DIV({style: 'font-family: monospace; font-size: 1em; border: 1px solid #ccc; padding: 1em; margin: 1em;'});
+ d.push(H3({style: "font-size: 1.5em; font-weight: bold;"}, "Debug Info:"));
+ var t = TABLE({border: 1, cellspacing: 0, cellpadding: 4});
+ keys(_cart()).sort().forEach(function(k) {
+ var v = _cart()[k];
+ if (typeof(v) == 'object' && v != null) {
+ v = v.toSource();
+ }
+ t.push(TR(TD({style: 'padding: 2px 6px;', align: 'right'}, k),
+ TD({style: 'padding: 2px 6px;', align: 'left'}, v)));
+ });
+ d.push(t);
+ return d;
+}
+
+var billingButtonName = "Review Order";
+
+function _templateContext(extra) {
+ var cart = _cart();
+
+ var pageId = _currentPageId();
+
+ var ret = {
+ cart: cart,
+ costPerUser: eepnet_checkout.COST_PER_USER,
+ supportCostPct: eepnet_checkout.SUPPORT_COST_PCT,
+ supportMinCost: eepnet_checkout.SUPPORT_MIN_COST,
+ errorIfInvalid: _errorIfInvalid,
+ dollars: checkout.dollars,
+ countryList: fields.countryList,
+ usaStateList: fields.usaStateList,
+ obfuscateCC: checkout.obfuscateCC,
+ helpers: helpers,
+ inFlow: _currentPageInFlow(),
+ displayCart: _displayCart,
+ displaySummary: _displaySummary,
+ pathTo: _pathTo,
+ billing: billingJS,
+ handlePayPalRedirect: _handlePayPalRedirect,
+ supportCost: _supportCost,
+ discountedSupportCost: _discountedSupportCost,
+ billingButtonName: billingButtonName,
+ billingFinalPhrase: "<p>You will not be charged until you review"+
+ " and confirm your order on the next page.</p>",
+ getFullSuperdomainHost: pro_utils.getFullSuperdomainHost,
+ showCouponCode: false
+ };
+ eachProperty(extra, function(k, v) {
+ ret[k] = v;
+ });
+ return ret;
+}
+
+function _displayCart(cartid, editable) {
+ return renderTemplateAsString('store/eepnet-checkout/cart.ejs', _templateContext({
+ shoppingcartid: cartid || "shoppingcart",
+ editable: editable
+ }));
+}
+
+function _displaySummary(editable) {
+ return renderTemplateAsString('store/eepnet-checkout/summary.ejs', _templateContext({
+ editable: editable
+ }));
+}
+
+function _renderCartPage() {
+ var cart = _cart();
+
+ var pageId = _currentPageId();
+ var title = _currentPageTitle();
+
+ function _getContent() {
+ return renderTemplateAsString('store/eepnet-checkout/'+pageId+'.ejs', _templateContext());
+ }
+
+ renderFramed('store/eepnet-checkout/checkout-template.ejs', {
+ cartDebug: _cartDebug,
+ errorDiv: _errorDiv,
+ pageId: pageId,
+ getContent: _getContent,
+ title: title,
+ inFlow: _currentPageInFlow(),
+ displayCart: _displayCart,
+ showCart: _currentPageShowCart(),
+ cart: cart,
+ billingButtonName: billingButtonName
+ });
+
+ // clear errors
+ delete cart.errorMsg;
+ delete cart.errorId;
+}
+
+function _errorDiv() {
+ var m = _cart().errorMsg;
+ if (m) {
+ return DIV({className: 'errormsg', id: 'errormsg'}, m);
+ } else {
+ return '';
+ }
+}
+
+function _errorIfInvalid(id) {
+ var e = _cart().errorId
+ if (e && e[id]) {
+ return 'error';
+ } else {
+ return '';
+ }
+}
+
+function _validationError(id, msg, pageId) {
+ var cart = _cart();
+ cart.errorMsg = msg;
+ cart.errorId = {};
+ if (id instanceof Array) {
+ id.forEach(function(k) {
+ cart.errorId[k] = true;
+ });
+ } else {
+ cart.errorId[id] = true;
+ }
+ if (pageId) {
+ response.redirect(_pathTo(pageId));
+ }
+ response.redirect(request.path);
+}
+
+//--------------------------------------------------------------------------------
+// main
+//--------------------------------------------------------------------------------
+
+function render_main() {
+ response.redirect(STORE_URL+'purchase');
+}
+
+//--------------------------------------------------------------------------------
+// cart
+//--------------------------------------------------------------------------------
+
+function render_purchase_post() {
+ var cart = _cart();
+
+ // validate numUsers and couponCode
+ if (! checkout.isOnlyDigits(cart.numUsers)) {
+ _validationError("numUsers", "Please enter a valid number of users.");
+ }
+ if (Number(cart.numUsers) < 1) {
+ _validationError("numUsers", "Please specify at least one user.");
+ }
+
+ if (cart.couponCode && (cart.couponCode.length != 8 || ! _getCoupon(cart.couponCode))) {
+ _validationError("couponCode", "That coupon code does not appear to be valid.");
+ }
+
+ _updateCosts();
+ _advancePage();
+}
+
+//--------------------------------------------------------------------------------
+// support-contract
+//--------------------------------------------------------------------------------
+
+function render_support_contract_post() {
+ var cart = _cart();
+
+ if (cart.supportContract != "true" && cart.supportContract != "false") {
+ _validationError("supportContract", "Please select one of the options.");
+ }
+
+ _updateCosts();
+ _advancePage();
+}
+
+//--------------------------------------------------------------------------------
+// license-info
+//--------------------------------------------------------------------------------
+
+function render_license_info_post() {
+ var cart = _cart();
+
+ if (!isValidEmail(cart.email)) {
+ _validationError("email", "That email address does not look valid.");
+ }
+ if (!cart.ownerName) {
+ _validationError("ownerName", "Please enter a license owner name.");
+ }
+ if (!cart.orgName) {
+ _validationError("orgName", "Please enter an organization name.");
+ }
+ if (!cart.licenseAgreement) {
+ _validationError("licenseAgreement", "You must agree to the terms of the license to purchase EtherPad PNE.");
+ }
+
+ if ((! cart.billingFirstName) && ! (cart.billingLastName)) {
+ var nameParts = cart.ownerName.split(/\s+/);
+ if (nameParts.length == 1) {
+ cart.billingFirstName = nameParts[0];
+ } else {
+ cart.billingLastName = nameParts[nameParts.length-1];
+ cart.billingFirstName = nameParts.slice(0, nameParts.length-1).join(' ');
+ }
+ }
+
+ _updateCosts();
+ _advancePage();
+}
+
+//--------------------------------------------------------------------------------
+// billing-info
+//--------------------------------------------------------------------------------
+
+function render_billing_info_post() {
+ var cart = _cart();
+
+ checkout.validateBillingCart(_validationError, cart);
+ if (cart.billingPurchaseType == 'paypal') {
+ _beginPaypalPurchase();
+ }
+
+ _updateCosts();
+ _advancePage();
+}
+
+function _absoluteUrl(id) {
+ return request.scheme+"://"+request.host+_pathTo(id);
+}
+
+function _beginPaypalPurchase() {
+ _updateCosts();
+
+ var cart = _cart();
+
+ var purchase = _generatePurchaseRecord();
+ var result =
+ billing.beginExpressPurchase(cart.invoiceId, cart.customerId,
+ "EEPNET", cart.total || 0.01, cart.couponCode || "",
+ _absoluteUrl('paypalredirect?status=ok'),
+ _absoluteUrl('paypalredirect?status=fail'),
+ _absoluteUrl('paypalnotify'));
+ if (result.status != 'success') {
+ _validationError("billingPurchaseType",
+ "PayPal purchase not available at the moment. "+
+ "Please try again later, or try using a different payment option.");
+ }
+ cart.paypalPurchaseInfo = result.purchaseInfo;
+ response.redirect(billing.paypalPurchaseUrl(result.purchaseInfo.token));
+}
+
+//--------------------------------------------------------------------------------
+// confirmation
+//--------------------------------------------------------------------------------
+
+function _handlePaypalNotification() {
+ var ret = billing.handlePaypalNotification();
+ if (ret.status == 'completion') {
+ var purchaseInfo = ret.purchaseInfo;
+ var eepnetPurchase = eepnet_checkout.getPurchaseByInvoiceId(purchaseInfo.invoiceId);
+ var fakeCart = {
+ ownerName: eepnetPurchase.owner,
+ orgName: eepnetPurchase.organization,
+ email: eepnetPurchase.emails,
+ customerId: eepnetPurchase.id,
+ userCount: eepnetPurchase.numUsers,
+ receiptEmail: eepnetPurchase.receiptEmail,
+ }
+ eepnet_checkout.generateLicenseKey(fakeCart);
+ eepnet_checkout.sendReceiptEmail(fakeCart);
+ eepnet_checkout.sendLicenseEmail(fakeCart);
+ billing.log({type: 'purchase-complete', dollars: purchaseInfo.cost});
+ }
+}
+
+function _handlePayPalRedirect() {
+ var cart = _cart();
+
+ if (request.params.status == 'ok' && cart.paypalPurchaseInfo) {
+ var result = billing.continueExpressPurchase(cart.paypalPurchaseInfo);
+ if (result.status == 'success') {
+ cart.paypalPayerInfo = result.payerInfo;
+ response.redirect(_pathTo('confirmation'));
+ } else {
+ _validationError("billingPurchaseType",
+ "There was an error processing your payment through PayPal. "+
+ "Please try again later, or use a different payment option.",
+ 'billing-info');
+ }
+ } else {
+ _validationError("billingPurchaseType",
+ "PayPal payment didn't go through. "+
+ "Please try again later, or use a different payment option.",
+ 'billing-info');
+ }
+}
+
+function _recordPurchase(p) {
+ return sqlobj.insert("checkout_purchase", p);
+}
+
+function _generatePurchaseRecord() {
+ var cart = _cart();
+
+ if (! cart.invoiceId) {
+ throw Error("No invoice id!");
+ }
+
+ var purchase = {
+ invoiceId: cart.invoiceId,
+ email: cart.email,
+ firstName: cart.billingFirstName,
+ lastName: cart.billingLastName,
+ owner: cart.ownerName || "",
+ organization: cart.orgName || "",
+ addressLine1: cart.billingAddressLine1 || "",
+ addressLine2: cart.billingAddressLine2 || "",
+ city: cart.billingCity || "",
+ state: cart.billingState || "",
+ zip: cart.billingZipCode || "",
+ referral: cart.couponCode,
+ cents: cart.total*100, // cents here.
+ numUsers: cart.userCount,
+ purchaseType: cart.billingPurchaseType,
+ }
+ cart.customerId = _recordPurchase(purchase);
+ return purchase;
+}
+
+function _performCreditCardPurchase() {
+ var cart = _cart();
+ var purchase = _generatePurchaseRecord();
+ var payInfo = checkout.generatePayInfo(cart);
+
+ // log everything but the CVV, which we're not allowed to store
+ // any longer than it takes to process this transaction.
+ var savedCvv = payInfo.cardCvv;
+ delete payInfo.cardCvv;
+ checkout.writeToEncryptedLog(fastJSON.stringify({date: String(new Date()), purchase: purchase, customerId: cart.customerId, payInfo: payInfo}));
+ payInfo.cardCvv = savedCvv;
+
+ var result =
+ billing.directPurchase(cart.invoiceId, cart.customerId,
+ "EEPNET", cart.total || 0.01,
+ cart.couponCode || "",
+ payInfo, _absoluteUrl('paypalnotify'));
+
+ if (result.status == 'success') {
+ cart.status = 'success';
+ cart.purchaseComplete = true;
+ eepnet_checkout.generateLicenseKey(cart);
+ eepnet_checkout.sendReceiptEmail(cart);
+ eepnet_checkout.sendLicenseEmail(cart);
+ billing.log({type: 'purchase-complete', dollars: cart.total,
+ email: cart.email, user: cart.ownerName,
+ org: cart.organization});
+ // TODO: generate key and include in receipt page, and add to purchase table.
+ } else if (result.status == 'pending') {
+ cart.status = 'pending';
+ cart.purchaseComplete = true;
+ eepnet_checkout.sendReceiptEmail(cart);
+ // save the receipt email text to resend later.
+ eepnet_checkout.updatePurchaseWithReceipt(cart.customerId,
+ eepnet_checkout.receiptEmailText(cart));
+ } else if (result.status == 'failure') {
+ var paypalResult = result.debug;
+ billing.log({'type': 'FATAL', value: "Direct purchase failed on paypal.", cart: cart, paypal: paypalResult});
+ if (result.errorField.permanentErrors[0] == 'invoiceId') {
+ // repeat invoice id. damnit, this is bad.
+ sendEmail('support@pad.spline.inf.fu-berlin.de', 'urgent@pad.spline.inf.fu-berlin.de', 'DUPLICATE INVOICE WARNING!', {},
+ "Hey,\n\nThis is a billing system error. The EEPNET checkout tried to make a "+
+ "purchase with PayPal and got a duplicate invoice error on invoice ID "+cart.invoiceId+
+ ".\n\nUnless you're expecting this (or recently ran a selenium test, or have reason to "+
+ "believe this isn't an exceptional condition, please look into this "+
+ "and get back to the user ASAP!\n\n"+fastJSON.stringify(cart));
+ _validationError('', "Your payment was processed, but we cannot proceed. "+
+ "You will hear from us shortly via email. (If you don't hear from us "+
+ "within 24 hours, please email <a href='mailto:sales@pad.spline.inf.fu-berlin.de'>"+
+ "sales@pad.spline.inf.fu-berlin.de</a>.)");
+ }
+ checkout.validateErrorFields(function(x, y) { _validationError(x, y, 'billing-info') }, "There seems to be an error in your billing information."+
+ " Please verify and correct your ",
+ result.errorField.userErrors);
+ checkout.validateErrorFields(function(x, y) { _validationError(x, y, 'billing-info') }, "The bank declined your billing information. Please try a different ",
+ result.errorField.permanentErrors);
+ _validationError('', "A temporary error has prevented processing of your payment. Please try again later.");
+ } else {
+ billing.log({'type': 'FATAL', value: "Unknown error: "+result.status+" - debug: "+result.debug});
+ sendEmail('support@pad.spline.inf.fu-berlin.de', 'urgent@pad.spline.inf.fu-berlin.de', 'UNKNOWN ERROR WARNING!', {},
+ "Hey,\n\nThis is a billing system error. Some unknown error occurred. "+
+ "This shouldn't ever happen. Probably good to let J.D. know. <grin>\n\n"+
+ fastJSON.stringify(cart));
+ _validationError('', "An unknown error occurred. We're looking into it!")
+ }
+}
+
+function _completePaypalPurchase() {
+ var cart = _cart();
+ var purchaseInfo = cart.paypalPurchaseInfo;
+ var payerInfo = cart.paypalPayerInfo;
+
+ var result = billing.completeExpressPurchase(purchaseInfo, payerInfo, _absoluteUrl('paypalnotify'));
+ if (result.status == 'success') {
+ cart.status = 'success';
+ cart.purchaseComplete = true;
+ eepnet_checkout.generateLicenseKey(cart);
+ eepnet_checkout.sendReceiptEmail(cart);
+ eepnet_checkout.sendLicenseEmail(cart);
+ billing.log({type: 'purchase-complete', dollars: cart.total,
+ email: cart.email, user: cart.ownerName,
+ org: cart.organization});
+
+ } else if (result.status == 'pending') {
+ cart.status = 'pending';
+ cart.purchaseComplete = true;
+ eepnet_checkout.sendReceiptEmail(cart);
+ // save the receipt email text to resend later.
+ eepnet_checkout.updatePurchaseWithReceipt(cart.customerId,
+ eepnet_checkout.receiptEmailText(cart));
+ } else {
+ billing.log({'type': 'FATAL', value: "Paypal failed.", cart: cart, paypal: paypalResult});
+ _validationError("billingPurchaseType",
+ "There was an error processing your payment through PayPal. "+
+ "Please try again later, or use a different payment option.",
+ 'billing-info');
+ }
+}
+
+function _showReceipt() {
+ response.redirect(_pathTo('receipt'));
+}
+
+function render_confirmation_post() {
+ var cart = _cart();
+
+ _updateCosts(); // no fishy business, please.
+
+ if (cart.billingPurchaseType == 'creditcard') {
+ _performCreditCardPurchase();
+ _showReceipt();
+ } else if (cart.billingPurchaseType == 'paypal') {
+ _completePaypalPurchase();
+ _showReceipt();
+ }
+}
+
+//--------------------------------------------------------------------------------
+// receipt
+//--------------------------------------------------------------------------------
+
+function render_receipt_post() {
+ response.redirect(request.path);
+}
diff --git a/trunk/etherpad/src/etherpad/control/store/storecontrol.js b/trunk/etherpad/src/etherpad/control/store/storecontrol.js
new file mode 100644
index 0000000..43569e4
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/store/storecontrol.js
@@ -0,0 +1,201 @@
+/**
+ * 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("dispatch.{Dispatcher,DirMatcher,forward}");
+import("fastJSON");
+import("funhtml.*");
+
+import('etherpad.globals.*');
+import("etherpad.store.eepnet_trial");
+import("etherpad.store.eepnet_checkout");
+import("etherpad.sessions.getSession");
+import("etherpad.utils.*");
+
+import("etherpad.control.store.eepnet_checkout_control");
+import("etherpad.control.pro.admin.team_billing_control");
+
+jimport("java.lang.System.out.println");
+
+//----------------------------------------------------------------
+
+function onStartup() {}
+
+function onRequest() {
+ var disp = new Dispatcher();
+ disp.addLocations([
+ [DirMatcher('/ep/store/eepnet-checkout/'), forward(eepnet_checkout_control)],
+ ]);
+ return disp.dispatch();
+}
+
+//----------------------------------------------------------------
+
+function render_main() {
+ response.redirect("/ep/about/pricing");
+}
+
+//----------------------------------------------------------------
+// Flow goes through these 4 pages in order:
+//----------------------------------------------------------------
+
+function render_eepnet_eval_signup_get() {
+ renderFramed("store/eepnet_eval_signup.ejs", {
+ trialDays: eepnet_trial.getTrialDays(),
+ oldData: (getSession().pricingContactData || {}),
+ sfIndustryList: eepnet_trial.getSalesforceIndustryList()
+ });
+ delete getSession().errorMsg;
+}
+
+// function render_eepnet_eval_signup_post() {
+// response.setContentType("text/plain; charset=utf-8");
+// var data = {};
+// var fields = ['firstName', 'lastName', 'email', 'orgName',
+// 'jobTitle', 'phone', 'estUsers', 'industry'];
+//
+// if (!getSession().pricingContactData) {
+// getSession().pricingContactData = {};
+// }
+//
+// function _redirectErr(msg) {
+// response.write(fastJSON.stringify({error: msg}));
+// response.stop();
+// }
+//
+// fields.forEach(function(f) {
+// getSession().pricingContactData[f] = request.params[f];
+// });
+//
+// fields.forEach(function(f) {
+// data[f] = request.params[f];
+// if (!(data[f] && (data[f].length > 0))) {
+// _redirectErr("All fields are required.");
+// }
+// });
+//
+// // validate email
+// if (!isValidEmail(data.email)) {
+// _redirectErr("That email address doesn't look valid.");
+// }
+//
+// // check that email not already registered.
+// if (eepnet_trial.hasEmailAlreadyDownloaded(data.email)) {
+// _redirectErr("That email has already downloaded a free trial."+
+// ' <a href="/ep/store/eepnet-recover-license">Recover a lost license key here</a>.');
+// }
+//
+// // Looks good! Create and email license key...
+// eepnet_trial.createAndMailNewLicense(data);
+// getSession().message = "A license key has been sent to "+data.email;
+//
+// // Generate web2lead info and return it
+// var web2leadData = eepnet_trial.getWeb2LeadData(data, request.clientAddr, getSession().initialReferer);
+// response.write(fastJSON.stringify(web2leadData));
+// }
+//
+// function render_salesforce_web2lead_ok() {
+// renderFramedHtml([
+// '<script>',
+// 'top.location.href = "'+request.scheme+'://'+request.host+'/ep/store/eepnet-download";',
+// '</script>'
+// ].join('\n'));
+// }
+//
+// function render_eepnet_eval_download() {
+// // NOTE: keep this URL around for historical reasons?
+// response.redirect("/ep/store/eepnet-download");
+// }
+//
+// function render_eepnet_download() {
+// renderFramed("store/eepnet_download.ejs", {
+// message: (getSession().message || null),
+// versionString: (PNE_RELEASE_VERSION+"&nbsp;("+PNE_RELEASE_DATE +")")
+// });
+// delete getSession().message;
+// }
+//
+// function render_eepnet_download_zip() {
+// response.redirect("/static/zip/pne-release/etherpad-pne-"+PNE_RELEASE_VERSION+".zip");
+// }
+//
+// function render_eepnet_download_nextsteps() {
+// renderFramed("store/eepnet_eval_nextsteps.ejs");
+// }
+
+//----------------------------------------------------------------
+// recover a lost license
+//----------------------------------------------------------------
+function render_eepnet_recover_license_get() {
+ var d = DIV({className: "fpcontent"});
+
+ d.push(P("Recover your lost license key."));
+
+ if (getSession().message) {
+ d.push(DIV({id: "resultmsg",
+ style: "border: 1px solid #333; padding: 0 1em; background: #efe; margin: 1em 0;"}, getSession().message));
+ delete getSession().message;
+ }
+ if (getSession().error) {
+ d.push(DIV({id: "errormsg",
+ style: "border: 1px solid red; padding: 0 1em; background: #fee; margin: 1em 0;"}, getSession().error));
+ delete getSession().error;
+ }
+
+ d.push(FORM({style: "border: 1px solid #222; padding: 2em; background: #eee;",
+ action: request.path, method: "post"},
+ LABEL({htmlFor: "email"},
+ "Your email address:"),
+ INPUT({type: "text", name: "email", id: "email"}),
+ INPUT({type: "submit", id: "submit", value: "Submit"})));
+
+ renderFramedHtml(d);
+}
+
+function render_eepnet_recover_license_post() {
+ var email = request.params.email;
+ if (!eepnet_trial.hasEmailAlreadyDownloaded(email) && !eepnet_trialhasEmailAlreadyPurchased(email)) {
+ getSession().error = P("License not found for email: \"", email, "\".");
+ response.redirect(request.path);
+ }
+ if (eepnet_checkout.hasEmailAlreadyPurchased(email)) {
+ eepnet_checkout.mailLostLicense(email);
+ } else if (eepnet_trial.hasEmailAlreadyDownloaded(email)) {
+ eepnet_trial.mailLostLicense(email);
+ }
+ getSession().message = P("Your license information has been sent to ", email, ".");
+ response.redirect(request.path);
+}
+
+//----------------------------------------------------------------
+function render_eepnet_purchase_get() {
+ renderFramed("store/eepnet_purchase.ejs", {});
+}
+
+//--------------------------------------------------------------------------------
+// csc-help page
+//--------------------------------------------------------------------------------
+
+function render_csc_help_get() {
+ response.write(renderTemplateAsString("store/csc-help.ejs"));
+}
+
+//--------------------------------------------------------------------------------
+// paypal notifications for pro
+//--------------------------------------------------------------------------------
+
+function render_paypalnotify() {
+ team_billing_control.handlePaypalNotify();
+}
diff --git a/trunk/etherpad/src/etherpad/control/testcontrol.js b/trunk/etherpad/src/etherpad/control/testcontrol.js
new file mode 100644
index 0000000..ed13006
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/control/testcontrol.js
@@ -0,0 +1,74 @@
+/**
+ * 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("etherpad.globals.*");
+import("etherpad.utils.*");
+
+jimport("java.lang.System.out.println");
+
+//----------------------------------------------------------------
+var tests = [
+ "t0000_test",
+ "t0001_sqlbase_transaction_rollback",
+ "t0002_license_generation",
+ "t0003_persistent_vars",
+ "t0004_sqlobj",
+ "t0005_easysync"
+];
+
+var tscope = this;
+tests.forEach(function(t) {
+ import.call(tscope, 'etherpad.testing.unit_tests.'+t);
+});
+//----------------------------------------------------------------
+
+function _testName(x) {
+ x = x.replace(/^t\d+\_/, '');
+ return x;
+}
+
+function render_run() {
+ response.setContentType("text/plain; charset=utf-8");
+ if (isProduction() && (request.params.p != "waverunner")) {
+ response.write("access denied");
+ response.stop();
+ }
+
+ var singleTest = request.params.t;
+ var numRun = 0;
+
+ println("----------------------------------------------------------------");
+ println("running tests");
+ println("----------------------------------------------------------------");
+ tests.forEach(function(t) {
+ var testName = _testName(t);
+ if (singleTest && (singleTest != testName)) {
+ return;
+ }
+ println("running test: "+testName);
+ numRun++;
+ tscope[t].run();
+ println("|| pass ||");
+ });
+ println("----------------------------------------------------------------");
+
+ if (numRun == 0) {
+ response.write("Error: no tests found");
+ } else {
+ response.write("OK");
+ }
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0000_test.js b/trunk/etherpad/src/etherpad/db_migrations/m0000_test.js
new file mode 100644
index 0000000..7df9bfd
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0000_test.js
@@ -0,0 +1,23 @@
+/**
+ * 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.
+ */
+
+
+function run() {
+ // nothing
+}
+
+
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0001_eepnet_signups_init.js b/trunk/etherpad/src/etherpad/db_migrations/m0001_eepnet_signups_init.js
new file mode 100644
index 0000000..0e65779
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0001_eepnet_signups_init.js
@@ -0,0 +1,38 @@
+/**
+ * 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("sqlbase.sqlobj");
+import("sqlbase.sqlcommon");
+import("etherpad.utils.isPrivateNetworkEdition");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ sqlobj.createTable('eepnet_signups', {
+ id: 'INT NOT NULL '+sqlcommon.autoIncrementClause()+' PRIMARY KEY',
+ email: 'VARCHAR(128) NOT NULL UNIQUE',
+ date: 'TIMESTAMP',
+ signupIp: 'VARCHAR(16)',
+ fullName: 'VARCHAR(255) NOT NULL',
+ orgName: 'VARCHAR(255) NOT NULL',
+ jobTitle: 'VARCHAR(255) NOT NULL',
+ estUsers: 'VARCHAR(255) NOT NULL',
+ licenseKey: 'VARCHAR(1024) NOT NULL'
+ });
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0002_eepnet_signups_2.js b/trunk/etherpad/src/etherpad/db_migrations/m0002_eepnet_signups_2.js
new file mode 100644
index 0000000..786e4e9
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0002_eepnet_signups_2.js
@@ -0,0 +1,47 @@
+/**
+ * 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("sqlbase.sqlobj");
+import("etherpad.utils.isPrivateNetworkEdition");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ // add new columns.
+ sqlobj.addColumns('eepnet_signups', {
+ firstName: 'VARCHAR(128) NOT NULL DEFAULT \'\'',
+ lastName: 'VARCHAR(128) NOT NULL DEFAULT \'\'',
+ phone: 'VARCHAR(128) NOT NULL DEFAULT \'\''
+ });
+
+ // split name into first/last
+ var rows = sqlobj.selectMulti('eepnet_signups', {}, {});
+ rows.forEach(function(r) {
+ var name = r.fullName;
+ r.firstName = (r.fullName.split(' ')[0]) || "?";
+ r.lastName = (r.fullName.split(' ').slice(1).join(' ')) || "?";
+ r.phone = "?";
+ sqlobj.updateSingle('eepnet_signups', {id: r.id}, r);
+ });
+
+ // drop column fullName
+ sqlobj.dropColumn('eepnet_signups', 'fullName');
+}
+
+
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0003_create_tests_table_v2.js b/trunk/etherpad/src/etherpad/db_migrations/m0003_create_tests_table_v2.js
new file mode 100644
index 0000000..f121145
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0003_create_tests_table_v2.js
@@ -0,0 +1,29 @@
+/**
+ * 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("sqlbase.sqlobj");
+import("sqlbase.sqlcommon");
+import("etherpad.utils.isPrivateNetworkEdition");
+
+function run() {
+ if (sqlcommon.doesTableExist('just_a_test')) {
+ sqlobj.dropTable('just_a_test');
+ }
+ sqlobj.createTable('just_a_test', {
+ id: 'INT NOT NULL '+sqlcommon.autoIncrementClause()+' PRIMARY KEY',
+ x: 'VARCHAR(128)'
+ });
+}
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0004_convert_all_tables_to_innodb.js b/trunk/etherpad/src/etherpad/db_migrations/m0004_convert_all_tables_to_innodb.js
new file mode 100644
index 0000000..959865d
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0004_convert_all_tables_to_innodb.js
@@ -0,0 +1,38 @@
+/**
+ * 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("sqlbase.sqlcommon");
+import("sqlbase.sqlobj");
+
+import('etherpad.db_migrations.migration_runner.dmesg');
+
+function run() {
+ // This migration only applies to MySQL
+ if (!sqlcommon.isMysql()) {
+ return;
+ }
+
+ var tables = sqlobj.listTables();
+ tables.forEach(function(t) {
+ if (sqlobj.getTableEngine(t) != "InnoDB") {
+ dmesg("Converting table "+t+" to InnoDB...");
+ sqlobj.setTableEngine(t, "InnoDB");
+ }
+ });
+
+};
+
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0005_create_billing_tables.js b/trunk/etherpad/src/etherpad/db_migrations/m0005_create_billing_tables.js
new file mode 100644
index 0000000..0dfd37e
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0005_create_billing_tables.js
@@ -0,0 +1,73 @@
+/**
+ * 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("etherpad.utils.isPrivateNetworkEdition");
+import("sqlbase.sqlobj");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ var idColspec = "INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY";
+
+ sqlobj.createTable('billing_purchase', {
+ id: idColspec,
+ type: "ENUM('onetimepurchase', 'subscription')",
+ customer: "INT(11) NOT NULL",
+ product: "VARCHAR(128) NOT NULL",
+ cost: "INT(11) NOT NULL",
+ coupon: "VARCHAR(128) NOT NULL",
+ time: "DATETIME",
+ paidThrough: "DATETIME",
+ status: "ENUM('active', 'inactive')"
+ }, {
+ type: true,
+ customer: true,
+ product: true
+ });
+
+ sqlobj.createTable('billing_invoice', {
+ id: idColspec,
+ time: "DATETIME",
+ purchase: "INT(11) NOT NULL",
+ amt: "INT(11) NOT NULL",
+ status: "ENUM('pending', 'paid', 'void', 'refunded')"
+ }, {
+ status: true
+ });
+
+ sqlobj.createTable('billing_transaction', {
+ id: idColspec,
+ customer: "INT(11)",
+ time: "DATETIME",
+ amt: "INT(11)",
+ payInfo: "VARCHAR(128)",
+ txnId: "VARCHAR(128)", // depends on gateway used?
+ status: "ENUM('new', 'success', 'failure', 'pending')"
+ }, {
+ customer: true,
+ txnId: true
+ });
+
+ sqlobj.createTable('billing_adjustment', {
+ id: idColspec,
+ transaction: "INT(11)",
+ invoice: "INT(11)",
+ time: "DATETIME",
+ amt: "INT(11)"
+ });
+}
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0006_eepnet_signups_3.js b/trunk/etherpad/src/etherpad/db_migrations/m0006_eepnet_signups_3.js
new file mode 100644
index 0000000..349b27a
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0006_eepnet_signups_3.js
@@ -0,0 +1,29 @@
+/**
+ * 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("etherpad.utils.isPrivateNetworkEdition");
+import("sqlbase.sqlobj");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ // add new columns.
+ sqlobj.addColumns('eepnet_signups', {
+ industry: 'VARCHAR(128)',
+ });
+}
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0007_create_pro_tables_v4.js b/trunk/etherpad/src/etherpad/db_migrations/m0007_create_pro_tables_v4.js
new file mode 100644
index 0000000..bda5853
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0007_create_pro_tables_v4.js
@@ -0,0 +1,67 @@
+/**
+ * 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("sqlbase.sqlcommon");
+import("sqlbase.sqlobj");
+
+function run() {
+ ['pro_domains', 'pro_users', 'pro_padmeta'].forEach(function(t) {
+ if (sqlcommon.doesTableExist(t)) {
+ sqlobj.dropTable(t);
+ }
+ });
+
+ sqlobj.createTable('pro_domains', {
+ id: sqlobj.getIdColspec(),
+ subDomain: 'VARCHAR(128) UNIQUE NOT NULL',
+ extDomain: 'VARCHAR(128) DEFAULT NULL',
+ orgName: 'VARCHAR(128)'
+ });
+
+ sqlobj.createIndex('pro_domains', ['subDomain']);
+ sqlobj.createIndex('pro_domains', ['extDomain']);
+
+ sqlobj.createTable('pro_users', {
+ id: sqlobj.getIdColspec(),
+ domainId: 'INT NOT NULL',
+ fullName: 'VARCHAR(128) NOT NULL',
+ email: 'VARCHAR(128) NOT NULL', // not unique because same
+ // email can be on multiple domains.
+ passwordHash: 'VARCHAR(128) NOT NULL',
+ createdDate: sqlobj.getDateColspec("NOT NULL"),
+ lastLoginDate: sqlobj.getDateColspec("DEFAULT NULL"),
+ isAdmin: sqlobj.getBoolColspec("DEFAULT 0")
+ });
+
+ sqlobj.createTable('pro_padmeta', {
+ id: sqlobj.getIdColspec(),
+ domainId: 'INT NOT NULL',
+ localPadId: 'VARCHAR(128) NOT NULL',
+ title: 'VARCHAR(128)',
+ creatorId: 'INT DEFAULT NULL',
+ createdDate: sqlobj.getDateColspec("NOT NULL"),
+ lastEditorId: 'INT DEFAULT NULL',
+ lastEditedDate: sqlobj.getDateColspec("DEFAULT NULL")
+ });
+
+ sqlobj.createIndex('pro_padmeta', ['domainId', 'localPadId']);
+
+ var pneDomain = "<<private-network>>";
+ if (!sqlobj.selectSingle('pro_domains', {subDomain: pneDomain})) {
+ sqlobj.insert('pro_domains', {subDomain: pneDomain});
+ }
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0008_persistent_vars.js b/trunk/etherpad/src/etherpad/db_migrations/m0008_persistent_vars.js
new file mode 100644
index 0000000..30e379a
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0008_persistent_vars.js
@@ -0,0 +1,31 @@
+/**
+ * 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("sqlbase.sqlcommon");
+import("sqlbase.sqlobj");
+
+function run() {
+
+ var idColspec = 'INT NOT NULL '+sqlcommon.autoIncrementClause()+' PRIMARY KEY';
+
+ sqlobj.createTable('persistent_vars', {
+ id: idColspec,
+ name: 'VARCHAR(128) UNIQUE NOT NULL',
+ stringVal: 'VARCHAR(1024)'
+ });
+
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0009_pad_tables.js b/trunk/etherpad/src/etherpad/db_migrations/m0009_pad_tables.js
new file mode 100644
index 0000000..93f5a62
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0009_pad_tables.js
@@ -0,0 +1,31 @@
+/**
+ * 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("sqlbase.sqlbase");
+
+function run() {
+
+ // These table creations used to be in etherpad.pad.model.onStartup, but
+ // they make more sense here because later migrations access these tables.
+ sqlbase.createJSONTable("PAD_META");
+ sqlbase.createJSONTable("PAD_APOOL");
+ sqlbase.createStringArrayTable("PAD_REVS");
+ sqlbase.createStringArrayTable("PAD_CHAT");
+ sqlbase.createStringArrayTable("PAD_REVMETA");
+ sqlbase.createStringArrayTable("PAD_AUTHORS");
+
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0010_pad_sqlmeta.js b/trunk/etherpad/src/etherpad/db_migrations/m0010_pad_sqlmeta.js
new file mode 100644
index 0000000..36150b1
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0010_pad_sqlmeta.js
@@ -0,0 +1,71 @@
+/**
+ * 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("sqlbase.sqlbase");
+import("sqlbase.sqlobj");
+import("sqlbase.sqlcommon");
+import("etherpad.utils.startConsoleProgressBar");
+
+
+function run() {
+
+ sqlobj.dropAndCreateTable('PAD_SQLMETA', {
+ id: 'VARCHAR(128) PRIMARY KEY NOT NULL',
+ version: 'INT NOT NULL',
+ creationTime: sqlobj.getDateColspec('NOT NULL'),
+ lastWriteTime: sqlobj.getDateColspec('NOT NULL'),
+ headRev: 'INT NOT NULL'
+ });
+
+ sqlobj.createIndex('PAD_SQLMETA', ['version']);
+
+ var allPadIds = sqlbase.getAllJSONKeys("PAD_META");
+
+ // If this is a new database, there are no pads; else
+ // it is an old database with version 1 pads.
+ if (allPadIds.length == 0) {
+ return;
+ }
+
+ var numPadsTotal = allPadIds.length;
+ var numPadsSoFar = 0;
+ var progressBar = startConsoleProgressBar();
+
+ allPadIds.forEach(function(padId) {
+ var meta = sqlbase.getJSON("PAD_META", padId);
+
+ sqlobj.insert("PAD_SQLMETA", {
+ id: padId,
+ version: 1,
+ creationTime: new Date(meta.creationTime || 0),
+ lastWriteTime: new Date(),
+ headRev: meta.head
+ });
+
+ delete meta.creationTime; // now stored in SQLMETA
+ delete meta.version; // just in case (was used during development)
+ delete meta.dirty; // no longer stored in DB
+ delete meta.lastAccess; // no longer stored in DB
+
+ sqlbase.putJSON("PAD_META", padId, meta);
+
+ numPadsSoFar++;
+ progressBar.update(numPadsSoFar/numPadsTotal, numPadsSoFar+"/"+numPadsTotal+" pads");
+ });
+
+ progressBar.finish();
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0011_pro_users_temppass.js b/trunk/etherpad/src/etherpad/db_migrations/m0011_pro_users_temppass.js
new file mode 100644
index 0000000..5ac8b26
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0011_pro_users_temppass.js
@@ -0,0 +1,33 @@
+/**
+ * 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("sqlbase.sqlcommon");
+import("sqlbase.sqlobj");
+
+function run() {
+ // allow null values in passwordHash
+ if (sqlcommon.isDerby()) {
+ sqlobj.alterColumn('pro_users', 'passwordHash', 'NULL');
+ } else {
+ sqlobj.modifyColumn('pro_users', 'passwordHash', 'VARCHAR(128)');
+ }
+ sqlobj.addColumns('pro_users', {
+ tempPassHash: 'VARCHAR(128)'
+ });
+}
+
+
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0012_pro_users_auto_signin.js b/trunk/etherpad/src/etherpad/db_migrations/m0012_pro_users_auto_signin.js
new file mode 100644
index 0000000..ddd4cf6
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0012_pro_users_auto_signin.js
@@ -0,0 +1,30 @@
+/**
+ * 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("sqlbase.sqlcommon");
+import("sqlbase.sqlobj");
+
+function run() {
+ sqlobj.createTable('pro_users_auto_signin', {
+ id: sqlobj.getIdColspec(),
+ cookie: 'VARCHAR(128) UNIQUE NOT NULL',
+ userId: 'INT UNIQUE NOT NULL',
+ expires: sqlobj.getDateColspec('NOT NULL')
+ });
+ sqlobj.createIndex('pro_users_auto_signin', ['cookie']);
+ sqlobj.createIndex('pro_users_auto_signin', ['userId']);
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0013_pne_padv2_upgrade.js b/trunk/etherpad/src/etherpad/db_migrations/m0013_pne_padv2_upgrade.js
new file mode 100644
index 0000000..146923a
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0013_pne_padv2_upgrade.js
@@ -0,0 +1,54 @@
+/**
+ * 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("etherpad.utils.startConsoleProgressBar");
+import("etherpad.pad.easysync2migration");
+import("etherpad.pne.pne_utils");
+import("sqlbase.sqlobj");
+import("etherpad.log");
+
+function run() {
+
+ // this is a PNE-only migration
+ if (! pne_utils.isPNE()) {
+ return;
+ }
+
+ var migrationsNeeded = sqlobj.selectMulti("PAD_SQLMETA", {version: 1});
+
+ if (migrationsNeeded.length == 0) {
+ return;
+ }
+
+ var migrationsTotal = migrationsNeeded.length;
+ var migrationsSoFar = 0;
+ var progressBar = startConsoleProgressBar();
+
+ migrationsNeeded.forEach(function(obj) {
+ var padId = String(obj.id);
+
+ log.info("Migrating pad "+padId+" from version 1 to version 2...");
+ easysync2migration.migratePad(padId);
+ sqlobj.update("PAD_SQLMETA", {id: padId}, {version: 2});
+ log.info("Migrated pad "+padId+".");
+
+ migrationsSoFar++;
+ progressBar.update(migrationsSoFar/migrationsTotal, migrationsSoFar+"/"+migrationsTotal+" pads");
+ });
+
+ progressBar.finish();
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0014_pne_globalpadids.js b/trunk/etherpad/src/etherpad/db_migrations/m0014_pne_globalpadids.js
new file mode 100644
index 0000000..445b32d
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0014_pne_globalpadids.js
@@ -0,0 +1,102 @@
+/**
+ * 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("etherpad.utils.startConsoleProgressBar");
+import("etherpad.pne.pne_utils");
+import("sqlbase.sqlobj");
+import("sqlbase.sqlbase");
+import("etherpad.log");
+import("sqlbase.sqlcommon.*");
+import("etherpad.pad.padutils");
+
+function run() {
+
+ // this is a PNE-only migration
+ if (! pne_utils.isPNE()) {
+ return;
+ }
+
+ var renamesNeeded = sqlobj.selectMulti("PAD_SQLMETA", {});
+
+ if (renamesNeeded.length == 0) {
+ return;
+ }
+
+ var renamesTotal = renamesNeeded.length;
+ var renamesSoFar = 0;
+ var progressBar = startConsoleProgressBar();
+
+ renamesNeeded.forEach(function(obj) {
+ var oldPadId = String(obj.id);
+ var newPadId;
+ if (/^1\$[a-zA-Z0-9\-]+$/.test(oldPadId)) {
+ // not expecting a user pad beginning with "1$";
+ // this case is to avoid trashing dev databases
+ newPadId = oldPadId;
+ }
+ else {
+ var localPadId = padutils.makeValidLocalPadId(oldPadId);
+ newPadId = "1$"+localPadId;
+
+ // PAD_SQLMETA
+ obj.id = newPadId;
+ sqlobj.deleteRows("PAD_SQLMETA", {id:oldPadId});
+ sqlobj.insert("PAD_SQLMETA", obj);
+
+ // PAD_META
+ var meta = sqlbase.getJSON("PAD_META", oldPadId);
+ meta.padId = newPadId;
+ sqlbase.deleteJSON("PAD_META", oldPadId);
+ sqlbase.putJSON("PAD_META", newPadId, meta);
+
+ // PAD_APOOL
+ var apool = sqlbase.getJSON("PAD_APOOL", oldPadId);
+ sqlbase.deleteJSON("PAD_APOOL", oldPadId);
+ sqlbase.putJSON("PAD_APOOL", newPadId, apool);
+
+ function renamePadInStringArrayTable(arrayName) {
+ var stmnt = "UPDATE "+btquote("PAD_"+arrayName.toUpperCase()+"_META")+
+ " SET "+btquote("ID")+" = ? WHERE "+btquote("ID")+" = ?";
+ return withConnection(function(conn) {
+ var pstmnt = conn.prepareStatement(stmnt);
+ return closing(pstmnt, function() {
+ pstmnt.setString(1, newPadId);
+ pstmnt.setString(2, oldPadId);
+ pstmnt.executeUpdate();
+ });
+ });
+ }
+
+ renamePadInStringArrayTable("revs");
+ renamePadInStringArrayTable("chat");
+ renamePadInStringArrayTable("revmeta");
+ renamePadInStringArrayTable("authors");
+
+ sqlobj.insert('pro_padmeta', {
+ localPadId: localPadId,
+ title: localPadId,
+ createdDate: obj.creationTime,
+ domainId: 1 // PNE
+ });
+ }
+
+ renamesSoFar++;
+ progressBar.update(renamesSoFar/renamesTotal, renamesSoFar+"/"+renamesTotal+" pads");
+ });
+
+ progressBar.finish();
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0015_padmeta_passwords.js b/trunk/etherpad/src/etherpad/db_migrations/m0015_padmeta_passwords.js
new file mode 100644
index 0000000..8fa98bb
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0015_padmeta_passwords.js
@@ -0,0 +1,25 @@
+/**
+ * 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("sqlbase.sqlobj");
+
+function run() {
+ sqlobj.addColumns('pro_padmeta', {
+ password: 'VARCHAR(128) DEFAULT NULL'
+ });
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0016_pne_tracking_data.js b/trunk/etherpad/src/etherpad/db_migrations/m0016_pne_tracking_data.js
new file mode 100644
index 0000000..abcc93f
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0016_pne_tracking_data.js
@@ -0,0 +1,35 @@
+/**
+ * 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("sqlbase.sqlobj");
+import("etherpad.utils.isPrivateNetworkEdition");
+
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ sqlobj.createTable('pne_tracking_data', {
+ id: sqlobj.getIdColspec(),
+ date: sqlobj.getDateColspec("NOT NULL"),
+ keyHash: 'VARCHAR(128) DEFAULT NULL',
+ name: 'VARCHAR(128) NOT NULL',
+ value: 'VARCHAR(1024) NOT NULL'
+ });
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0017_pne_tracking_data_v2.js b/trunk/etherpad/src/etherpad/db_migrations/m0017_pne_tracking_data_v2.js
new file mode 100644
index 0000000..1067840
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0017_pne_tracking_data_v2.js
@@ -0,0 +1,30 @@
+/**
+ * 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("sqlbase.sqlobj");
+import("etherpad.utils.isPrivateNetworkEdition");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ sqlobj.addColumns('pne_tracking_data', {
+ remoteIp: 'VARCHAR(128) NOT NULL'
+ });
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0018_eepnet_checkout_tables.js b/trunk/etherpad/src/etherpad/db_migrations/m0018_eepnet_checkout_tables.js
new file mode 100644
index 0000000..6e10000
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0018_eepnet_checkout_tables.js
@@ -0,0 +1,82 @@
+/**
+ * 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("etherpad.utils.isPrivateNetworkEdition");
+import("sqlbase.sqlobj");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ var idColspec = "INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY";
+
+ sqlobj.createTable('checkout_purchase', {
+ id: idColspec,
+ invoiceId: "INT NOT NULL",
+ owner: "VARCHAR(128) NOT NULL",
+ email: "VARCHAR(128) NOT NULL",
+ organization: "VARCHAR(128) NOT NULL",
+ firstName: "VARCHAR(100) NOT NULL",
+ lastName: "VARCHAR(100) NOT NULL",
+ addressLine1: "VARCHAR(100) NOT NULL",
+ addressLine2: "VARCHAR(100) NOT NULL",
+ city: "VARCHAR(40) NOT NULL",
+ state: "VARCHAR(2) NOT NULL",
+ zip: "VARCHAR(10) NOT NULL",
+ numUsers: "INT NOT NULL",
+ date: "TIMESTAMP NOT NULL",
+ cents: "INT NOT NULL",
+ referral: "VARCHAR(8)",
+ receiptEmail: "TEXT",
+ purchaseType: "ENUM('creditcard', 'invoice', 'paypal') NOT NULL",
+ licenseKey: "VARCHAR(1024)"
+ }, {
+ email: true,
+ invoiceId: true
+ });
+
+ sqlobj.createTable('checkout_referral', {
+ id: "VARCHAR(8) NOT NULL PRIMARY KEY",
+ productPctDiscount: "INT",
+ supportPctDiscount: "INT",
+ totalPctDiscount: "INT",
+ freeUsersCount: "INT",
+ freeUsersPct: "INT"
+ });
+
+ // add a sample referral code.
+ sqlobj.insert('checkout_referral', {
+ id: 'EPCO6128',
+ productPctDiscount: 50,
+ supportPctDiscount: 25,
+ totalPctDiscount: 15,
+ freeUsersCount: 20,
+ freeUsersPct: 10
+ });
+
+ // add a "free" referral code.
+ sqlobj.insert('checkout_referral', {
+ id: 'EP99FREE',
+ totalPctDiscount: 99
+ });
+
+ sqlobj.insert('checkout_referral', {
+ id: 'EPFREE68',
+ totalPctDiscount: 100
+ });
+
+}
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0019_padmeta_deleted.js b/trunk/etherpad/src/etherpad/db_migrations/m0019_padmeta_deleted.js
new file mode 100644
index 0000000..1f9ecbb
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0019_padmeta_deleted.js
@@ -0,0 +1,24 @@
+/**
+ * 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("sqlbase.sqlobj");
+
+function run() {
+ sqlobj.addColumns('pro_padmeta', {
+ isDeleted: sqlobj.getBoolColspec("NOT NULL DEFAULT 0")
+ });
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0020_padmeta_archived.js b/trunk/etherpad/src/etherpad/db_migrations/m0020_padmeta_archived.js
new file mode 100644
index 0000000..a776622
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0020_padmeta_archived.js
@@ -0,0 +1,25 @@
+/**
+ * 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("sqlbase.sqlobj");
+
+function run() {
+ sqlobj.addColumns('pro_padmeta', {
+ isArchived: sqlobj.getBoolColspec("NOT NULL DEFAULT 0")
+ });
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0021_pro_padmeta_json.js b/trunk/etherpad/src/etherpad/db_migrations/m0021_pro_padmeta_json.js
new file mode 100644
index 0000000..9f357b7
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0021_pro_padmeta_json.js
@@ -0,0 +1,57 @@
+/**
+ * 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.sqlobj");
+import("sqlbase.sqlcommon");
+
+function run() {
+ sqlobj.addColumns('pro_padmeta', {
+ proAttrsJson: sqlobj.getLongtextColspec("")
+ });
+
+ // convert all existing columns into metaJSON
+
+ sqlcommon.inTransaction(function() {
+ var records = sqlobj.selectMulti('pro_padmeta', {}, {});
+ records.forEach(function(r) {
+ migrateRecord(r);
+ });
+ });
+}
+
+function migrateRecord(r) {
+ var editors = [];
+ if (r.creatorId) {
+ editors.push(r.creatorId);
+ }
+ if (r.lastEditorId) {
+ if (editors.indexOf(r.lastEditorId) < 0) {
+ editors.push(r.lastEditorId);
+ }
+ }
+ editors.sort();
+
+ var proAttrs = {
+ editors: editors,
+ };
+
+ var proAttrsJson = fastJSON.stringify(proAttrs);
+
+ sqlobj.update('pro_padmeta', {id: r.id}, {proAttrsJson: proAttrsJson});
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0022_create_userids_table.js b/trunk/etherpad/src/etherpad/db_migrations/m0022_create_userids_table.js
new file mode 100644
index 0000000..23ca8d3
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0022_create_userids_table.js
@@ -0,0 +1,30 @@
+/**
+ * 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("etherpad.utils.isPrivateNetworkEdition");
+import("sqlbase.sqlobj");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ sqlobj.createTable('pad_cookie_userids', {
+ id: "VARCHAR(40) NOT NULL PRIMARY KEY",
+ createdDate: sqlobj.getDateColspec("NOT NULL"),
+ lastActiveDate: sqlobj.getDateColspec("NOT NULL")
+ });
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0023_create_usagestats_table.js b/trunk/etherpad/src/etherpad/db_migrations/m0023_create_usagestats_table.js
new file mode 100644
index 0000000..927cdc9
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0023_create_usagestats_table.js
@@ -0,0 +1,32 @@
+/**
+ * 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("etherpad.utils.isPrivateNetworkEdition");
+import("sqlbase.sqlobj");
+import("sqlbase.sqlcommon");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ sqlobj.createTable('usage_stats', {
+ id: 'INT NOT NULL '+sqlcommon.autoIncrementClause()+' PRIMARY KEY',
+ name: 'VARCHAR(128) NOT NULL',
+ timestamp: 'INT NOT NULL',
+ value: 'INT NOT NULL'
+ });
+}
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0024_statistics_table.js b/trunk/etherpad/src/etherpad/db_migrations/m0024_statistics_table.js
new file mode 100644
index 0000000..9d6e58c
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0024_statistics_table.js
@@ -0,0 +1,42 @@
+/**
+ * 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("etherpad.utils.isPrivateNetworkEdition");
+import("sqlbase.sqlobj");
+import("sqlbase.sqlcommon");
+import("fastJSON");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ sqlobj.createTable('statistics', {
+ id: 'INT NOT NULL '+sqlcommon.autoIncrementClause()+' PRIMARY KEY',
+ name: 'VARCHAR(128) NOT NULL',
+ timestamp: 'INT NOT NULL',
+ value: 'TEXT NOT NULL'
+ });
+
+ var oldStats = sqlobj.selectMulti('usage_stats', {});
+ oldStats.forEach(function(stat) {
+ sqlobj.insert('statistics', {
+ timestamp: stat.timestamp,
+ name: stat.name,
+ value: fastJSON.stringify({value: stat.value})
+ });
+ });
+}
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0025_rename_pro_users_table.js b/trunk/etherpad/src/etherpad/db_migrations/m0025_rename_pro_users_table.js
new file mode 100644
index 0000000..a429f41
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0025_rename_pro_users_table.js
@@ -0,0 +1,26 @@
+/**
+ * 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("sqlbase.sqlobj");
+import("sqlbase.sqlcommon");
+
+function run() {
+ sqlobj.renameTable('pro_users', 'pro_accounts');
+ sqlobj.renameTable('pro_users_auto_signin', 'pro_accounts_auto_signin');
+ sqlobj.changeColumn('pro_accounts_auto_signin', 'userId', 'accountId INT UNIQUE NOT NULL');
+ sqlobj.createIndex('pro_accounts_auto_signin', ['accountId']);
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0026_create_guests_table.js b/trunk/etherpad/src/etherpad/db_migrations/m0026_create_guests_table.js
new file mode 100644
index 0000000..7c41309
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0026_create_guests_table.js
@@ -0,0 +1,37 @@
+/**
+ * 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("sqlbase.sqlcommon");
+import("sqlbase.sqlobj");
+
+function run() {
+ if (sqlcommon.doesTableExist("pad_guests")) {
+ sqlobj.dropTable("pad_guests");
+ }
+
+ sqlobj.createTable('pad_guests', {
+ id: sqlobj.getIdColspec(),
+ privateKey: 'VARCHAR(63) UNIQUE NOT NULL',
+ userId: 'VARCHAR(63) UNIQUE NOT NULL',
+ createdDate: sqlobj.getDateColspec("NOT NULL"),
+ lastActiveDate: sqlobj.getDateColspec("NOT NULL"),
+ data: sqlobj.getLongtextColspec("")
+ });
+
+ sqlobj.createIndex('pad_guests', ['privateKey']);
+ sqlobj.createIndex('pad_guests', ['userId']);
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0027_pro_config.js b/trunk/etherpad/src/etherpad/db_migrations/m0027_pro_config.js
new file mode 100644
index 0000000..9cbb629
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0027_pro_config.js
@@ -0,0 +1,27 @@
+/**
+ * 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("sqlbase.sqlobj");
+
+function run() {
+ sqlobj.createTable('pro_config', {
+ id: sqlobj.getIdColspec(),
+ domainId: 'INT',
+ name: 'VARCHAR(128)',
+ jsonVal: sqlobj.getLongtextColspec("")
+ });
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0028_ondemand_beta_emails.js b/trunk/etherpad/src/etherpad/db_migrations/m0028_ondemand_beta_emails.js
new file mode 100644
index 0000000..f708363
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0028_ondemand_beta_emails.js
@@ -0,0 +1,29 @@
+/**
+ * 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("sqlbase.sqlobj");
+
+function run() {
+ sqlobj.createTable('pro_beta_signups', {
+ id: sqlobj.getIdColspec(),
+ email: 'VARCHAR(256)',
+ activationCode: 'VARCHAR(128)',
+ isActivated: sqlobj.getBoolColspec(),
+ signupDate: sqlobj.getDateColspec(),
+ activationDate: sqlobj.getDateColspec()
+ });
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0029_lowercase_subdomains.js b/trunk/etherpad/src/etherpad/db_migrations/m0029_lowercase_subdomains.js
new file mode 100644
index 0000000..36b76ab
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0029_lowercase_subdomains.js
@@ -0,0 +1,31 @@
+/**
+ * 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("sqlbase.sqlobj");
+
+function run() {
+ var recordList = sqlobj.selectMulti('pro_domains', {});
+ recordList.forEach(function(r) {
+ var subDomain = r.subDomain;
+ if (subDomain != subDomain.toLowerCase()) {
+ // delete this domain record and all accounts associated with it.
+ sqlobj.deleteRows('pro_domains', {id: r.id});
+ sqlobj.deleteRows('pro_accounts', {domainId: r.id});
+ }
+ });
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0030_fix_statistics_values.js b/trunk/etherpad/src/etherpad/db_migrations/m0030_fix_statistics_values.js
new file mode 100644
index 0000000..aeaa40f
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0030_fix_statistics_values.js
@@ -0,0 +1,26 @@
+/**
+ * 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("sqlbase.sqlobj");
+import("etherpad.utils.isPrivateNetworkEdition");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ sqlobj.modifyColumn('statistics', 'value', 'MEDIUMTEXT NOT NULL');
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0031_deleted_pro_users.js b/trunk/etherpad/src/etherpad/db_migrations/m0031_deleted_pro_users.js
new file mode 100644
index 0000000..b9744a3
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0031_deleted_pro_users.js
@@ -0,0 +1,24 @@
+/**
+ * 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("sqlbase.sqlobj");
+
+function run() {
+ sqlobj.addColumns('pro_accounts', {
+ isDeleted: sqlobj.getBoolColspec("NOT NULL DEFAULT 0")
+ });
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0032_reduce_topvalues_counts.js b/trunk/etherpad/src/etherpad/db_migrations/m0032_reduce_topvalues_counts.js
new file mode 100644
index 0000000..5e748f5
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0032_reduce_topvalues_counts.js
@@ -0,0 +1,39 @@
+/**
+ * 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("sqlbase.sqlobj");
+import("etherpad.utils.isPrivateNetworkEdition");
+import("fastJSON");
+
+import("etherpad.statistics.statistics");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ statistics.getAllStatNames().forEach(function(statName) {
+ if (statistics.getStatData(statName).dataType == 'topValues') {
+ var entries = sqlobj.selectMulti('statistics', {name: statName});
+ entries.forEach(function(statEntry) {
+ var value = fastJSON.parse(statEntry.value);
+ value.topValues = value.topValues.slice(0, 50);
+ statEntry.value = fastJSON.stringify(value);
+ sqlobj.update('statistics', {id: statEntry.id}, statEntry);
+ });
+ }
+ });
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0033_pro_account_usage.js b/trunk/etherpad/src/etherpad/db_migrations/m0033_pro_account_usage.js
new file mode 100644
index 0000000..4b33f52
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0033_pro_account_usage.js
@@ -0,0 +1,30 @@
+/**
+ * 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("sqlbase.sqlobj");
+
+function run() {
+ sqlobj.createTable('pro_account_usage', {
+ id: sqlobj.getIdColspec(),
+ domainId: 'INT NOT NULL UNIQUE',
+ count: 'INT NOT NULL DEFAULT 0',
+ lastReset: sqlobj.getDateColspec(),
+ lastUpdated: sqlobj.getDateColspec()
+ });
+ sqlobj.createIndex('pro_account_usage', ['domainId']);
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0034_create_recurring_billing_table.js b/trunk/etherpad/src/etherpad/db_migrations/m0034_create_recurring_billing_table.js
new file mode 100644
index 0000000..491581b
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0034_create_recurring_billing_table.js
@@ -0,0 +1,42 @@
+/**
+ * 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("etherpad.utils.isPrivateNetworkEdition");
+import("sqlbase.sqlobj");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ var idColspec = "INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY";
+
+ sqlobj.createTable('billing_payment_info', {
+ customer: "INT(11) NOT NULL PRIMARY KEY",
+ fullname: "VARCHAR(128)",
+ paymentsummary: "VARCHAR(128)",
+ expiration: "VARCHAR(6)", // MMYYYY
+ transaction: "VARCHAR(128)"
+ });
+
+ sqlobj.addColumns('billing_purchase', {
+ error: "TEXT"
+ });
+
+ sqlobj.addColumns('billing_invoice', {
+ users: "INT(11)"
+ })
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0035_add_email_to_paymentinfo.js b/trunk/etherpad/src/etherpad/db_migrations/m0035_add_email_to_paymentinfo.js
new file mode 100644
index 0000000..a49e9f9
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0035_add_email_to_paymentinfo.js
@@ -0,0 +1,28 @@
+/**
+ * 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("etherpad.utils.isPrivateNetworkEdition");
+import("sqlbase.sqlobj");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ sqlobj.addColumns('billing_payment_info', {
+ email: "VARCHAR(255)"
+ });
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0036_create_missing_subscription_records.js b/trunk/etherpad/src/etherpad/db_migrations/m0036_create_missing_subscription_records.js
new file mode 100644
index 0000000..ce77734
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0036_create_missing_subscription_records.js
@@ -0,0 +1,45 @@
+/**
+ * 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("dateutils");
+import("etherpad.utils.isPrivateNetworkEdition");
+import("sqlbase.sqlobj");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ var allDomains = sqlobj.selectMulti('pro_domains', {}, {});
+
+ allDomains.forEach(function(domain) {
+ var domainId = domain.id;
+ var accounts = sqlobj.selectMulti('pro_accounts', {domainId: domainId}, {});
+ if (accounts.length > 3) {
+ if (! sqlobj.selectSingle('billing_purchase', {product: "ONDEMAND", customer: domainId}, {})) {
+ sqlobj.insert('billing_purchase', {
+ product: "ONDEMAND",
+ paidThrough: dateutils.noon(new Date(Date.now()-1000*86400)),
+ type: 'subscription',
+ customer: domainId,
+ status: 'inactive',
+ cost: 0,
+ coupon: ""
+ });
+ }
+ }
+ });
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0037_create_pro_referral_table.js b/trunk/etherpad/src/etherpad/db_migrations/m0037_create_pro_referral_table.js
new file mode 100644
index 0000000..7a9982c
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0037_create_pro_referral_table.js
@@ -0,0 +1,32 @@
+/**
+ * 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("etherpad.utils.isPrivateNetworkEdition");
+import("sqlbase.sqlobj");
+
+function run() {
+ if (isPrivateNetworkEdition()) {
+ return;
+ }
+
+ var idColspec = "INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY";
+
+ sqlobj.createTable('checkout_pro_referral', {
+ id: "VARCHAR(8) NOT NULL PRIMARY KEY",
+ pctDiscount: "INT",
+ freeUsers: "INT",
+ });
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/db_migrations/m0038_pad_coarse_revs.js b/trunk/etherpad/src/etherpad/db_migrations/m0038_pad_coarse_revs.js
new file mode 100644
index 0000000..1e9a53c
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/m0038_pad_coarse_revs.js
@@ -0,0 +1,26 @@
+/**
+ * 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("sqlbase.sqlbase");
+
+function run() {
+
+ sqlbase.createStringArrayTable("PAD_REVS10");
+ sqlbase.createStringArrayTable("PAD_REVS100");
+ sqlbase.createStringArrayTable("PAD_REVS1000");
+
+}
+
diff --git a/trunk/etherpad/src/etherpad/db_migrations/migration_runner.js b/trunk/etherpad/src/etherpad/db_migrations/migration_runner.js
new file mode 100644
index 0000000..ddf201d
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/db_migrations/migration_runner.js
@@ -0,0 +1,147 @@
+/**
+ * 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.
+ */
+
+// Database migrations.
+
+import("sqlbase.sqlcommon");
+import("sqlbase.sqlobj");
+
+import("etherpad.globals.*");
+import("etherpad.utils.*");
+import("etherpad.log");
+import("etherpad.pne.pne_utils");
+
+jimport("java.lang.System.out.println");
+
+//----------------------------------------------------------------
+// 1 migration per file
+//----------------------------------------------------------------
+
+var migrations = [
+ "m0000_test",
+ "m0001_eepnet_signups_init",
+ "m0002_eepnet_signups_2",
+ "m0003_create_tests_table_v2",
+ "m0004_convert_all_tables_to_innodb",
+ "m0005_create_billing_tables",
+ "m0006_eepnet_signups_3",
+ "m0007_create_pro_tables_v4",
+ "m0008_persistent_vars",
+ "m0009_pad_tables",
+ "m0010_pad_sqlmeta",
+ "m0011_pro_users_temppass",
+ "m0012_pro_users_auto_signin",
+ "m0013_pne_padv2_upgrade",
+ "m0014_pne_globalpadids",
+ "m0015_padmeta_passwords",
+ "m0016_pne_tracking_data",
+ "m0017_pne_tracking_data_v2",
+ "m0018_eepnet_checkout_tables",
+ "m0019_padmeta_deleted",
+ "m0020_padmeta_archived",
+ "m0021_pro_padmeta_json",
+ "m0022_create_userids_table",
+ "m0023_create_usagestats_table",
+ "m0024_statistics_table",
+ "m0025_rename_pro_users_table",
+ "m0026_create_guests_table",
+ "m0027_pro_config",
+ "m0028_ondemand_beta_emails",
+ "m0029_lowercase_subdomains",
+ "m0030_fix_statistics_values",
+ "m0031_deleted_pro_users",
+ "m0032_reduce_topvalues_counts",
+ "m0033_pro_account_usage",
+ "m0034_create_recurring_billing_table",
+ "m0035_add_email_to_paymentinfo",
+ "m0036_create_missing_subscription_records",
+ "m0037_create_pro_referral_table",
+ "m0038_pad_coarse_revs"
+];
+
+var mscope = this;
+migrations.forEach(function(m) {
+ import.call(mscope, "etherpad.db_migrations."+m);
+});
+
+//----------------------------------------------------------------
+
+function dmesg(m) {
+ if ((!isProduction()) || appjet.cache.db_migrations_print_debug) {
+ log.info(m);
+ println(m);
+ }
+}
+
+function onStartup() {
+ appjet.cache.db_migrations_print_debug = true;
+ if (!sqlcommon.doesTableExist("db_migrations")) {
+ appjet.cache.db_migrations_print_debug = false;
+ sqlobj.createTable('db_migrations', {
+ id: 'INT NOT NULL '+sqlcommon.autoIncrementClause()+' PRIMARY KEY',
+ name: 'VARCHAR(255) NOT NULL UNIQUE',
+ completed: 'TIMESTAMP'
+ });
+ }
+
+ if (pne_utils.isPNE()) { pne_utils.checkDbVersionUpgrade(); }
+ runMigrations();
+ if (pne_utils.isPNE()) { pne_utils.saveDbVersion(); }
+}
+
+function _migrationName(m) {
+ m = m.replace(/^m\d+\_/, '');
+ m = m.replace(/\_/g, '-');
+ return m;
+}
+
+function getCompletedMigrations() {
+ var completedMigrationsList = sqlobj.selectMulti('db_migrations', {}, {});
+ var completedMigrations = {};
+
+ completedMigrationsList.forEach(function(c) {
+ completedMigrations[c.name] = true;
+ });
+
+ return completedMigrations;
+}
+
+function runMigrations() {
+ var completedMigrations = getCompletedMigrations();
+
+ dmesg("Checking for database migrations...");
+ migrations.forEach(function(m) {
+ var name = _migrationName(m);
+ if (!completedMigrations[name]) {
+ sqlcommon.inTransaction(function() {
+ dmesg("performing database migration: ["+name+"]");
+ var startTime = +(new Date);
+
+ mscope[m].run();
+
+ var elapsedMs = +(new Date) - startTime;
+ dmesg("migration completed in "+elapsedMs+"ms");
+
+ sqlobj.insert('db_migrations', {
+ name: name,
+ completed: new Date()
+ });
+ });
+ }
+ });
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/debug.js b/trunk/etherpad/src/etherpad/debug.js
new file mode 100644
index 0000000..069ad14
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/debug.js
@@ -0,0 +1,26 @@
+/**
+ * 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("etherpad.globals.*");
+
+jimport("java.lang.System.out.println");
+
+function dmesg(m) {
+ if (!isProduction()) {
+ println(m);
+ }
+}
+
diff --git a/trunk/etherpad/src/etherpad/globals.js b/trunk/etherpad/src/etherpad/globals.js
new file mode 100644
index 0000000..2bae776
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/globals.js
@@ -0,0 +1,41 @@
+/**
+ * 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.
+ */
+
+//----------------------------------------------------------------
+// global variabls
+//----------------------------------------------------------------
+
+var COMETPATH = "/comet";
+
+var COLOR_PALETTE = ['#ffc6c6','#ffe2bf','#fffcbf','#cbffb3','#b3fff1','#c6e7ff','#dcccff','#ffd9fb'];
+
+function isProduction() {
+ return (appjet.config['etherpad.isProduction'] == "true");
+}
+
+var SUPERDOMAINS = {
+ 'localhost': true,
+ 'pad.spline.inf.fu-berlin.de': true,
+ 'pad.spline.de': true,
+ 'pad.spline.nomad': true
+};
+
+var PNE_RELEASE_VERSION = "1.1.3";
+var PNE_RELEASE_DATE = "June 15, 2009";
+
+var PRO_FREE_ACCOUNTS = 1e9;
+
+
diff --git a/trunk/etherpad/src/etherpad/helpers.js b/trunk/etherpad/src/etherpad/helpers.js
new file mode 100644
index 0000000..cafa201
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/helpers.js
@@ -0,0 +1,276 @@
+/**
+ * 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("jsutils.eachProperty");
+import("faststatic");
+import("comet");
+import("funhtml.META");
+
+import("etherpad.globals.*");
+import("etherpad.debug.dmesg");
+
+import("etherpad.pro.pro_utils");
+
+jimport("java.lang.System.out.println");
+
+//----------------------------------------------------------------
+// array that supports contains() in O(1)
+
+var _UniqueArray = function() {
+ this._a = [];
+ this._m = {};
+};
+_UniqueArray.prototype.add = function(x) {
+ if (!this._m[x]) {
+ this._a.push(x);
+ this._m[x] = true;
+ }
+};
+_UniqueArray.prototype.asArray = function() {
+ return this._a;
+};
+
+//----------------------------------------------------------------
+// EJS template helpers
+//----------------------------------------------------------------
+
+function _hd() {
+ if (!appjet.requestCache.helperData) {
+ appjet.requestCache.helperData = {
+ clientVars: {},
+ htmlTitle: "",
+ headExtra: "",
+ bodyId: "",
+ bodyClasses: new _UniqueArray(),
+ cssIncludes: new _UniqueArray(),
+ jsIncludes: new _UniqueArray(),
+ includeCometJs: false,
+ suppressGA: false,
+ showHeader: true,
+ robotsPolicy: null
+ };
+ }
+ return appjet.requestCache.helperData;
+}
+
+function addBodyClass(c) {
+ _hd().bodyClasses.add(c);
+}
+
+function addClientVars(vars) {
+ eachProperty(vars, function(k,v) {
+ _hd().clientVars[k] = v;
+ });
+}
+
+function addToHead(stuff) {
+ _hd().headExtra += stuff;
+}
+
+function setHtmlTitle(t) {
+ _hd().htmlTitle = t;
+}
+
+function setBodyId(id) {
+ _hd().bodyId = id;
+}
+
+function includeJs(relpath) {
+ _hd().jsIncludes.add(relpath);
+}
+
+function includeJQuery() {
+ includeJs("jquery-1.3.2.js");
+}
+
+function includeCss(relpath) {
+ _hd().cssIncludes.add(relpath);
+}
+
+function includeCometJs() {
+ _hd().includeCometJs = true;
+}
+
+function suppressGA() {
+ _hd().suppressGA = true;
+}
+
+function hideHeader() {
+ _hd().showHeader = false;
+}
+
+//----------------------------------------------------------------
+// for rendering HTML
+//----------------------------------------------------------------
+
+function bodyClasses() {
+ return _hd().bodyClasses.asArray().join(' ');
+}
+
+function clientVarsScript() {
+ var x = _hd().clientVars;
+ x = fastJSON.stringify(x);
+ if (x == '{}') {
+ return '<!-- no client vars -->';
+ }
+ x = x.replace(/</g, '\\x3c');
+ return [
+ '<script type="text/javascript">',
+ ' // <![CDATA[',
+ 'var clientVars = '+x+';',
+ ' // ]]>',
+ '</script>'
+ ].join('\n');
+}
+
+function htmlTitle() {
+ return _hd().htmlTitle;
+}
+
+function bodyId() {
+ return _hd().bodyId;
+}
+
+function baseHref() {
+ return request.scheme + "://"+ request.host + "/";
+}
+
+function headExtra() {
+ return _hd().headExtra;
+}
+
+function jsIncludes() {
+ if (isProduction()) {
+ var jsincludes = _hd().jsIncludes.asArray();
+ if (_hd().includeCometJs) {
+ jsincludes.splice(0, 0, {
+ getPath: function() { return 'comet-client.js'; },
+ getContents: function() { return comet.clientCode(); },
+ getMTime: function() { return comet.clientMTime(); }
+ });
+ }
+ if (jsincludes.length < 1) { return ''; }
+ var key = faststatic.getCompressedFilesKey('js', '/static/js', jsincludes);
+ return '<script type="text/javascript" src="/static/compressed/'+key+'"></script>';
+ } else {
+ var ts = +(new Date);
+ var r = [];
+ if (_hd().includeCometJs) {
+ r.push('<script type="text/javascript" src="'+COMETPATH+'/js/client.js?'+ts+'"></script>');
+ }
+ _hd().jsIncludes.asArray().forEach(function(relpath) {
+ r.push('<script type="text/javascript" src="/static/js/'+relpath+'?'+ts+'"></script>');
+ });
+ return r.join('\n');
+ }
+}
+
+function cssIncludes() {
+ if (isProduction()) {
+ var key = faststatic.getCompressedFilesKey('css', '/static/css', _hd().cssIncludes.asArray());
+ return '<link href="/static/compressed/'+key+'" rel="stylesheet" type="text/css" />';
+ } else {
+ var ts = +(new Date);
+ var r = [];
+ _hd().cssIncludes.asArray().forEach(function(relpath) {
+ r.push('<link href="/static/css/'+relpath+'?'+ts+'" rel="stylesheet" type="text/css" />');
+ });
+ return r.join('\n');
+ }
+}
+
+function oemail(username) {
+ return '&lt;<a class="obfuscemail" href="mailto:'+username+'@p*d.sp***e.inf.fu-berlin.de">'+
+ username+'@p*d.sp***e.inf.fu-berlin.de</a>&gt;';
+}
+
+function googleAnalytics() {
+ // GA disabled always now.
+ return '';
+
+ if (!isProduction()) { return ''; }
+ if (_hd().suppressGA) { return ''; }
+ return [
+ '<script type="text/javascript">',
+ ' var gaJsHost = (("https:" == document.location.protocol) ? "https://ssl." : "http://www.");',
+ ' document.write(unescape("%3Cscript src=\'" + gaJsHost + "google-analytics.com/ga.js\' type=\'text/javascript\'%3E%3C/script%3E"));',
+ '</script>',
+ '<script type="text/javascript">',
+ 'try {',
+ ' var pageTracker = _gat._getTracker("UA-6236278-1");',
+ ' pageTracker._trackPageview();',
+ '} catch(err) {}</script>'
+ ].join('\n');
+}
+
+function isHeaderVisible() {
+ return _hd().showHeader;
+}
+
+function setRobotsPolicy(policy) {
+ _hd().robotsPolicy = policy;
+}
+function robotsMeta() {
+ if (!_hd().robotsPolicy) { return ''; }
+ var content = "";
+ content += (_hd().robotsPolicy.index ? 'INDEX' : 'NOINDEX');
+ content += ", ";
+ content += (_hd().robotsPolicy.follow ? 'FOLLOW' : 'NOFOLLOW');
+ return META({name: "ROBOTS", content: content});
+}
+
+function thawteSiteSeal() {
+ return [
+ '<div>',
+ '<table width="10" border="0" cellspacing="0" align="center">',
+ '<tr>',
+ '<td>',
+ '<script src="https://siteseal.thawte.com/cgi/server/thawte_seal_generator.exe"></script>',
+ '</td>',
+ '</tr>',
+ '<tr>',
+ '<td height="0" align="center">',
+ '<a style="color:#AD0034" target="_new"',
+ 'href="http://www.thawte.com/digital-certificates/">',
+ '<span style="font-family:arial; font-size:8px; color:#AD0034">',
+ 'ABOUT SSL CERTIFICATES</span>',
+ '</a>',
+ '</td>',
+ '</tr>',
+ '</table>',
+ '</div>'
+ ].join('\n');
+}
+
+function clearFloats() {
+ return '<div style="clear: both;"><!-- --></div>';
+}
+
+function rafterBlogUrl() {
+ return '/ep/blog/posts/google-acquires-appjet';
+}
+
+function rafterNote() {
+ return """<div style='border: 1px solid #ccc; background: #fee; padding: 1em; margin: 1em 0;'>
+ <b>Note: </b>We are no longer accepting new accounts. <a href='"""+rafterBlogUrl()+"""'>Read more</a>.
+ </div>""";
+}
+
+function rafterTerminationDate() {
+ return "March 31, 2010";
+}
+
diff --git a/trunk/etherpad/src/etherpad/importexport/importexport.js b/trunk/etherpad/src/etherpad/importexport/importexport.js
new file mode 100644
index 0000000..304a1f4
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/importexport/importexport.js
@@ -0,0 +1,241 @@
+/**
+ * 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.
+ */
+
+jimport("java.io.File");
+jimport("java.io.FileOutputStream");
+jimport("java.lang.System.out.println");
+jimport("java.io.ByteArrayInputStream");
+jimport("java.io.ByteArrayOutputStream");
+jimport("java.io.DataInputStream");
+jimport("java.io.DataOutputStream");
+jimport("net.appjet.common.sars.SarsClient");
+jimport("com.etherpad.openofficeservice.OpenOfficeService");
+jimport("com.etherpad.openofficeservice.UnsupportedFormatException");
+jimport("com.etherpad.openofficeservice.TemporaryFailure");
+
+import("etherpad.log");
+import("etherpad.utils");
+import("sync");
+import("execution");
+import("varz");
+import("exceptionutils");
+
+function _log(obj) {
+ log.custom("import-export", obj);
+}
+
+function onStartup() {
+ execution.initTaskThreadPool("importexport", 1);
+}
+
+var formats = {
+ pdf: 'application/pdf',
+ doc: 'application/msword',
+ html: 'text/html; charset=utf-8',
+ odt: 'application/vnd.oasis.opendocument.text',
+ txt: 'text/plain; charset=utf-8'
+}
+
+function _createTempFile(bytes, type) {
+ var f = File.createTempFile("ooconvert-", (type === null ? null : (type == "" ? "" : "."+type)));
+ if (bytes) {
+ var fos = new FileOutputStream(f);
+ fos.write(bytes);
+ }
+ return f;
+}
+
+function _initConverterClient(convertServer) {
+ if (convertServer) {
+ var convertHost = convertServer.split(":")[0];
+ var convertPort = Number(convertServer.split(":")[1]);
+ if (! appjet.scopeCache.converter) {
+ var converter = new SarsClient("ooffice-password", convertHost, convertPort);
+ appjet.scopeCache.converter = converter;
+ converter.setConnectTimeout(5000);
+ converter.setReadTimeout(40000);
+ appjet.scopeCache.converter.connect();
+ }
+ return appjet.scopeCache.converter;
+ } else {
+ return null;
+ }
+}
+
+function _conversionSarsFailure() {
+ delete appjet.scopeCache.converter;
+}
+
+function errorUnsupported(from) {
+ return "Unsupported file type"+(from ? ": <strong>"+from+"</strong>." : ".")+" Etherpad can only import <strong>txt</strong>, <strong>html</strong>, <strong>rtf</strong>, <strong>doc</strong>, and <strong>docx</strong> files.";
+}
+var errorTemporary = "A temporary failure occurred; please try again later.";
+
+function doSlowFileConversion(from, to, bytes, continuation) {
+ var bytes = convertFileSlowly(from, to, bytes);
+ continuation.resume();
+ return bytes;
+}
+
+function _convertOverNetwork(convertServer, from, to, bytes) {
+ var c = _initConverterClient(convertServer);
+ var reqBytes = new ByteArrayOutputStream();
+ var req = new DataOutputStream(reqBytes);
+ req.writeUTF(from);
+ req.writeUTF(to);
+ req.writeInt(bytes.length);
+ req.write(bytes, 0, bytes.length);
+
+ var retBtyes;
+ try {
+ retBytes = c.message(reqBytes.toByteArray());
+ } catch (e) {
+ if (e.javaException) {
+ net.appjet.oui.exceptionlog.apply(e.javaException)
+ }
+ _conversionSarsFailure();
+ return "A communications failure occurred; please try again later.";
+ }
+
+ if (retBytes.length == 0) {
+ return "An unknown failure occurred; please try again later. (#5)";
+ }
+ var res = new DataInputStream(new ByteArrayInputStream(retBytes));
+ var status = res.readInt();
+ if (status == 0) { // success
+ var len = res.readInt();
+ var resBytes = java.lang.reflect.Array.newInstance(java.lang.Byte.TYPE, len);
+ res.readFully(resBytes);
+ return resBytes;
+ } else if (status == 1) {
+ return errorTemporary;
+ } else if (status == 2) {
+ var permFailureCode = res.readInt();
+ if (permFailureCode == 0) {
+ return "An unknown failure occurred. (#1)";
+ } else if (permFailureCode == 1) {
+ return errorUnsupported(from);
+ }
+ } else {
+ return "An unknown failure occurred. (#2)";
+ }
+}
+
+function convertFileSlowly(from, to, bytes) {
+ var convertServer = appjet.config["etherpad.sofficeConversionServer"];
+ if (convertServer) {
+ return _convertOverNetwork(convertServer, from, to, bytes);
+ }
+
+ if (! utils.hasOffice()) {
+ return "EtherPad is not configured to import or export formats other than <strong>txt</strong> and <strong>html</strong>. Please contact your system administrator for details.";
+ }
+ OpenOfficeService.setExecutable(appjet.config["etherpad.soffice"]);
+ try {
+ return OpenOfficeService.convertFile(from, to, bytes);
+ } catch (e) {
+ if (e.javaException instanceof TemporaryFailure) {
+ return errorTemporary;
+ } else if (e.javaException instanceof UnsupportedFormatException) {
+ return errorUnsupported(from);
+ } else {
+ return "An unknown failure occurred. (#3)";
+ }
+ }
+}
+
+function _noteConversionAttempt() {
+ varz.incrementInt("importexport-conversions-attempted");
+}
+
+function _noteConversionSuccess() {
+ varz.incrementInt("importexport-conversions-successful");
+}
+
+function _noteConversionFailure() {
+ varz.incrementInt("importexport-conversions-failed");
+}
+
+function _noteConversionTimeout() {
+ varz.incrementInt("importexport-conversions-timeout");
+}
+
+function _noteConversionImpossible() {
+ varz.incrementInt("importexport-conversions-impossible");
+}
+
+function precomputedConversionResult(from, to, bytes) {
+ try {
+ var retBytes = request.cache.conversionCallable.get(500, java.util.concurrent.TimeUnit.MILLISECONDS);
+ var delay = Date.now() - request.cache.startTime;
+ _log({type: "conversion-latency", from: from, to: to,
+ numBytes: request.cache.conversionByteLength,
+ delay: delay});
+ varz.addToInt("importexport-total-conversion-millis", delay);
+ if (typeof(retBytes) == 'string') {
+ _log({type: "error", error: "conversion-failed", from: from, to: to,
+ numBytes: request.cache.conversionByteLength,
+ delay: delay});
+ _noteConversionFailure();
+ } else {
+ _noteConversionSuccess();
+ }
+ return retBytes;
+ } catch (e) {
+ if (e.javaException instanceof java.util.concurrent.TimeoutException) {
+ _noteConversionTimeout();
+ request.cache.conversionCallable.cancel(false);
+ _log({type: "error", error: "conversion-failed", from: from, to: to,
+ numBytes: request.cache.conversionByteLength,
+ delay: -1});
+ return "Conversion timed out. Please try again later.";
+ }
+ _log({type: "error", error: "conversion-failed", from: from, to: to,
+ numBytes: request.cache.conversionByteLength,
+ trace: exceptionutils.getStackTracePlain(e)});
+ _noteConversionFailure();
+ return "An unknown failure occurred. (#4)";
+ }
+}
+
+function convertFile(from, to, bytes) {
+ if (request.cache.conversionCallable) {
+ return precomputedConversionResult(from, to, bytes);
+ }
+
+ _noteConversionAttempt();
+ if (from == to) {
+ _noteConversionSuccess();
+ return bytes;
+ }
+ if (from == "txt" && to == "html") {
+ _noteConversionSuccess();
+ return (new java.lang.String(utils.renderTemplateAsString('pad/exporthtml.ejs', {
+ content: String(new java.lang.String(bytes, "UTF-8")).replace(/&/g, "&amp;").replace(/</g, "&lt;"),
+ pre: true
+ }))).getBytes("UTF-8");
+ }
+
+ request.cache.conversionByteLength = bytes.length;
+ request.cache.conversionCallable =
+ execution.scheduleTask("importexport", "doSlowFileConversion", 0, [
+ from, to, bytes, request.continuation
+ ]);
+ request.cache.startTime = Date.now();
+ request.continuation.suspend(45000);
+ _noteConversionImpossible();
+ return "An unexpected error occurred."; // Shouldn't ever get here.
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/legacy_urls.js b/trunk/etherpad/src/etherpad/legacy_urls.js
new file mode 100644
index 0000000..80f6f77
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/legacy_urls.js
@@ -0,0 +1,37 @@
+/**
+ * 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.
+ */
+
+/* legacy URLs only apply to the public pad.spline.inf.fu-berlin.de site. (not Pro or PNE). */
+
+var _legacyURLs = {
+ '/ep/beta-signup': '/',
+ '/ep/talktostrangers': '/',
+ '/ep/about/pricing-eepod': '/ep/about/pricing-pro',
+ '/static/html/enterprise-etherpad-installguide.html': '/ep/pne-manual/',
+ '/static/html/eepnet/eepnet-changelog.html': '/ep/pne-manual/changelog',
+ '/static/html/eepnet/eepnet-installguide.html': '/ep/pne-manual/',
+ '/ep/blog/posts/back-online-until-open-sourced': '/ep/blog/posts/etherpad-back-online-until-open-sourced'
+};
+
+function checkPath() {
+ var p = request.path;
+ var match = _legacyURLs[p];
+
+ if (match) {
+ response.redirect(match);
+ }
+}
+
diff --git a/trunk/etherpad/src/etherpad/licensing.js b/trunk/etherpad/src/etherpad/licensing.js
new file mode 100644
index 0000000..2337456
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/licensing.js
@@ -0,0 +1,163 @@
+/**
+ * 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.
+ */
+
+
+/*
+ * This file used to control access restrictions for various sites like
+ * pad.spline.inf.fu-berlin.de or on-prem installations of etherpad, or evaluation
+ * editions. For the open-source effort, I have gutted out the
+ * restrictions. --aiba
+ */
+
+import("sync.callsync");
+import("stringutils");
+import("fileutils.readRealFile");
+import("jsutils.*");
+
+import("etherpad.globals.*");
+import("etherpad.log");
+import("etherpad.pad.padutils");
+import("etherpad.pne.pne_utils");
+
+jimport("com.etherpad.Licensing");
+jimport("java.lang.System.out.println");
+
+var _editionNames = {
+ 0: 'ETHERPAD.COM',
+ 1: 'PRIVATE_NETWORK_EVALUATION',
+ 2: 'PRIVATE_NETWORK'
+};
+
+function onStartup() { }
+
+//----------------------------------------------------------------
+
+/**
+ * expires is a long timestamp (set to null for never expiring).
+ * maxUsers is also a long (set to -1 for infinite users).
+ */
+function generateNewKey(personName, orgName, expires, editionId, maxUsers) {
+ return null;
+}
+
+function decodeLicenseInfoFromKey(key) {
+ return null;
+}
+
+//----------------------------------------------------------------
+
+function _getCache() {
+ return {};
+}
+
+function _readKeyFile(f) {
+ return null;
+}
+
+function _readLicenseKey() {
+ return null;
+}
+
+function reloadLicense() {
+}
+
+function getLicense() {
+ return null;
+}
+
+function isPrivateNetworkEdition() {
+ return false;
+}
+
+// should really only be called for PNE requests.
+// see etherpad.quotas module
+function getMaxUsersPerPad() {
+ return 1e9;
+}
+
+function getEditionId(editionName) {
+ return _editionNames[0];
+}
+
+function getEditionName(editionId) {
+ return _editionNames[editionId];
+}
+
+function isEvaluation() {
+ return false;
+}
+
+function isExpired() {
+ return false;
+}
+
+function isValidKey(key) {
+ return true;
+}
+
+function getVersionString() {
+ return "0";
+}
+
+function isVersionTooOld() {
+ return false;
+}
+
+//----------------------------------------------------------------
+// counting active users
+//----------------------------------------------------------------
+
+function getActiveUserQuota() {
+ return 1e9;
+}
+
+function _previousMidnight() {
+ // return midnight of today.
+ var d = new Date();
+ d.setHours(0);
+ d.setMinutes(0);
+ d.setSeconds(0);
+ d.setMilliseconds(1); // just north of midnight
+ return d;
+}
+
+function _resetActiveUserStats() {
+}
+
+function getActiveUserWindowStart() {
+ return null;
+}
+
+function getActiveUserWindowHours() {
+ return null;
+}
+
+function getActiveUserCount() {
+ return 0;
+}
+
+function canSessionUserJoin() {
+ return true;
+}
+
+function onUserJoin(userInfo) {
+}
+
+function onUserLeave() {
+ // do nothing.
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/log.js b/trunk/etherpad/src/etherpad/log.js
new file mode 100644
index 0000000..cfc82de
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/log.js
@@ -0,0 +1,255 @@
+/**
+ * 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("sqlbase.sqlobj");
+import("sqlbase.sqlcommon");
+import("stringutils.startsWith");
+import("sync.{callsync,callsyncIfTrue}");
+import("jsutils.*");
+import("exceptionutils");
+
+import("etherpad.globals.*");
+import("etherpad.pad.padutils");
+import("etherpad.sessions");
+import("etherpad.utils.*");
+
+import("etherpad.pro.pro_accounts.getSessionProAccount");
+
+jimport("java.io.FileWriter");
+jimport("java.lang.System.out.println");
+jimport("java.io.File");
+jimport("net.appjet.ajstdlib.execution");
+
+
+function getReadableTime() {
+ return (new Date()).toString().split(' ').slice(0, 5).join('-');
+}
+
+serverhandlers.tasks.trackerAndSessionIds = function() {
+ var m = new Packages.scala.collection.mutable.HashMap();
+ if (request.isDefined) {
+ try {
+ if (sessions.getTrackingId()) {
+ m.update("tracker", sessions.getTrackingId());
+ }
+ if (sessions.getSessionId()) {
+ m.update("session", sessions.getSessionId());
+ }
+ if (request.path) {
+ m.update("path", request.path);
+ }
+ if (request.clientAddr) {
+ m.update("clientAddr", request.clientAddr);
+ }
+ if (request.host) {
+ m.update("host", request.host);
+ }
+ if (getSessionProAccount()) {
+ m.update("proAccountId", getSessionProAccount().id);
+ }
+ } catch (e) {
+ // do nothing.
+ }
+ }
+ return m;
+}
+
+function onStartup() {
+ var f = execution.wrapRunTask("trackerAndSessionIds", null,
+ java.lang.Class.forName("scala.collection.mutable.HashMap"));
+ net.appjet.oui.GenericLoggerUtils.setExtraPropertiesFunction(f);
+}
+
+//----------------------------------------------------------------
+// Logfile parsing
+//----------------------------------------------------------------
+
+function _n(x) {
+ if (x < 10) { return "0"+x; }
+ else { return x; }
+}
+
+function logFileName(prefix, logName, day) {
+ var fmt = [day.getFullYear(), _n(day.getMonth()+1), _n(day.getDate())].join('-');
+ var fname = (appjet.config['logDir'] + '/'+prefix+'/' + logName + '/' +
+ logName + '-' + fmt + '.jslog');
+
+ // make sure file exists
+ if (!(new File(fname)).exists()) {
+ //log.warn("WARNING: file does not exist: "+fname);
+ return null;
+ }
+
+ return fname;
+}
+
+function frontendLogFileName(logName, day) {
+ return logFileName('frontend', logName, day);
+}
+
+function backendLogFileName(logName, day) {
+ return logFileName('backend', logName, day);
+}
+
+//----------------------------------------------------------------
+function _getRequestLogEntry() {
+ if (request.isDefined) {
+ var logEntry = {
+ clientAddr: request.clientAddr,
+ method: request.method.toLowerCase(),
+ scheme: request.scheme,
+ host: request.host,
+ path: request.path,
+ query: request.query,
+ referer: request.headers['Referer'],
+ userAgent: request.headers['User-Agent'],
+ statusCode: response.getStatusCode(),
+ }
+ if ('globalPadId' in request.cache) {
+ logEntry.padId = request.cache.globalPadId;
+ }
+ return logEntry;
+ } else {
+ return {};
+ }
+}
+
+function logRequest() {
+ if ((! request.isDefined) ||
+ startsWith(request.path, COMETPATH) ||
+ isStaticRequest()) {
+ return;
+ }
+
+ _log("request", _getRequestLogEntry());
+}
+
+function _log(name, m) {
+ var cache = appjet.cache;
+
+ callsyncIfTrue(
+ cache,
+ function() { return ! ('logWriters' in cache)},
+ function() { cache.logWriters = {}; }
+ );
+
+ callsyncIfTrue(
+ cache.logWriters,
+ function() { return !(name in cache.logWriters) },
+ function() {
+ lw = new net.appjet.oui.GenericLogger('frontend', name, true);
+ if (! isProduction()) {
+ lw.setEchoToStdOut(true);
+ }
+ lw.start();
+ cache.logWriters[name] = lw;
+ });
+
+ var lw = cache.logWriters[name];
+ if (typeof(m) == 'object') {
+ lw.logObject(m);
+ } else {
+ lw.log(m);
+ }
+}
+
+function custom(name, m) {
+ _log(name, m);
+}
+
+function _stampedMessage(m) {
+ var obj = {};
+ if (typeof(m) == 'string') {
+ obj.message = m;
+ } else {
+ eachProperty(m, function(k, v) {
+ obj[k] = v;
+ });
+ }
+ // stamp message with pad and path
+ if (request.isDefined) {
+ obj.path = request.path;
+ }
+
+ var currentPad = padutils.getCurrentPad();
+ if (currentPad) {
+ obj.currentPad = currentPad;
+ }
+
+ return obj;
+}
+
+//----------------------------------------------------------------
+// logException
+//----------------------------------------------------------------
+
+function logException(ex) {
+ if (typeof(ex) != 'object' || ! (ex instanceof java.lang.Throwable)) {
+ ex = new java.lang.RuntimeException(String(ex));
+ }
+ // NOTE: ex is always a java.lang.Throwable
+ var m = _getRequestLogEntry();
+ m.jsTrace = exceptionutils.getStackTracePlain(ex);
+ var s = new java.io.StringWriter();
+ ex.printStackTrace(new java.io.PrintWriter(s));
+ m.trace = s.toString();
+ _log("exception", m);
+}
+
+function callCatchingExceptions(func) {
+ try {
+ return func();
+ }
+ catch (e) {
+ logException(toJavaException(e));
+ }
+ return undefined;
+}
+
+//----------------------------------------------------------------
+// warning
+//----------------------------------------------------------------
+function warn(m) {
+ _log("warn", _stampedMessage(m));
+}
+
+//----------------------------------------------------------------
+// info
+//----------------------------------------------------------------
+function info(m) {
+ _log("info", _stampedMessage(m));
+}
+
+function onUserJoin(userId) {
+ function doUpdate() {
+ sqlobj.update('pad_cookie_userids', {id: userId}, {lastActiveDate: new Date()});
+ }
+ try {
+ sqlcommon.inTransaction(function() {
+ if (sqlobj.selectSingle('pad_cookie_userids', {id: userId})) {
+ doUpdate();
+ } else {
+ sqlobj.insert('pad_cookie_userids',
+ {id: userId, createdDate: new Date(), lastActiveDate: new Date()});
+ }
+ });
+ }
+ catch (e) {
+ sqlcommon.inTransaction(function() {
+ doUpdate();
+ });
+ }
+}
diff --git a/trunk/etherpad/src/etherpad/metrics/metrics.js b/trunk/etherpad/src/etherpad/metrics/metrics.js
new file mode 100644
index 0000000..435a5be
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/metrics/metrics.js
@@ -0,0 +1,438 @@
+/**
+ * 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("etherpad.log.frontendLogFileName");
+import("jsutils.eachProperty");
+import("stringutils.startsWith");
+import("fileutils.eachFileLine");
+
+jimport("java.lang.System.out.println");
+
+var _idleTime = 5*60*1000 // 5 minutes?
+
+function _isPadUrl(url) {
+ return url != '/' && ! startsWith(url, '/ep/');
+}
+
+function VisitData(url, referer) {
+ this.url = url;
+ this.referer = referer;
+ this.__defineGetter__('isPadVisit', function() {
+ return _isPadUrl(this.url);
+ });
+}
+VisitData.prototype.toString = function() {
+ var re = new RegExp("^https?://"+request.host);
+ if (this.referer && ! re.test(this.referer)) {
+ return this.url+", from "+this.referer;
+ } else {
+ return this.url;
+ }
+}
+
+function Event(time, type, data) {
+ this.time = time;
+ this.type = type;
+ this.data = data;
+}
+Event.prototype.toString = function() {
+ return "("+this.type+" "+this.data+" @ "+this.time.getTime()+")";
+}
+
+function Flow(sessionKey, startEvent) {
+ this.sessionKey = sessionKey;
+ this.events = [];
+ this.visitedPaths = {};
+ var visitCount = 0;
+ var visitsCache;
+ this._updateVisitedPaths = function(url) {
+ if (! this.visitedPaths[url]) {
+ this.visitedPaths[url] = [visitCount];
+ } else {
+ this.visitedPaths[url].push(visitCount);
+ }
+ }
+ var isInPad = 0;
+ this.push = function(evt) {
+ evt.flow = this;
+ this.events.push(evt);
+ if (evt.type == 'visit') {
+ this._updateVisitedPaths(evt.data.url);
+ if (_isPadUrl(evt.data.url)) {
+ this._updateVisitedPaths("(pad)");
+ }
+ visitCount++;
+ visitsCache = undefined;
+ } else if (evt.type == 'userjoin') {
+ isInPad++;
+ } else if (evt.type == 'userleave') {
+ isInPad--;
+ }
+ }
+ this.__defineGetter__("isInPad", function() { return isInPad > 0; });
+ this.__defineGetter__("lastEvent", function() {
+ return this.events[this.events.length-1];
+ });
+ this.__defineGetter__("visits", function() {
+ if (! visitsCache) {
+ visitsCache = this.events.filter(function(x) { return x.type == "visit" });
+ }
+ return visitsCache;
+ });
+ startEvent.flow = this;
+ this.push(startEvent);
+}
+Flow.prototype.toString = function() {
+ return "["+this.events.map(function(x) { return x.toString(); }).join(", ")+"]";
+}
+Flow.prototype.includesVisit = function(path, index, useExactIndexMatch) {
+ if (! this.visitedPaths[path]) return false;
+ if (useExactIndexMatch) {
+ return this.visitedPaths[path].some(function(x) { return x == index });
+ } else {
+ if (index) {
+ for (var i = 0; i < this.visitedPaths[path].length; ++i) {
+ if (this.visitedPaths[path][i] >= index)
+ return this.visitedPaths[path][i];
+ }
+ return false;
+ } else {
+ return true;
+ }
+ }
+}
+Flow.prototype.visitIndices = function(path) {
+ return this.visitedPaths[path] || [];
+}
+
+function getKeyForDate(date) {
+ return date.getYear()+":"+date.getMonth()+":"+date.getDay();
+}
+
+function parseEvents(dates) {
+ if (! appjet.cache["metrics-events"]) {
+ appjet.cache["metrics-events"] = {};
+ }
+ var events = {};
+ function eventArray(key) {
+ if (! events[key]) {
+ events[key] = [];
+ }
+ return events[key];
+ }
+
+ dates.sort(function(a, b) { return a.getTime() - b.getTime(); });
+ dates.forEach(function(day) {
+ if (! appjet.cache["metrics-events"][getKeyForDate(day)]) {
+ var daysEvents = {};
+ function daysEventArray(key) {
+ if (! daysEvents[key]) {
+ daysEvents[key] = [];
+ }
+ return daysEvents[key];
+ }
+ var requestLog = frontendLogFileName("request", day);
+ if (requestLog) {
+ eachFileLine(requestLog, function(line) {
+ var s = line.split("\t");
+ var sessionKey = s[3];
+ if (sessionKey == "-") { return; }
+ var time = new Date(Number(s[1]));
+ var path = s[7];
+ var referer = (s[9] == "-" ? null : s[9]);
+ var userAgent = s[10];
+ var statusCode = s[5];
+ // Remove bots and other automatic or irrelevant requests.
+ // There's got to be something better than a whitelist.
+ if (userAgent.indexOf("Mozilla") < 0 &&
+ userAgent.indexOf("Opera") < 0) {
+ return;
+ }
+ if (path == "/favicon.ico") { return; }
+ daysEventArray(sessionKey).push(new Event(time, "visit", new VisitData(path, referer)));
+ });
+ }
+ var padEventLog = frontendLogFileName("padevents", day);
+ if (padEventLog) {
+ eachFileLine(padEventLog, function(line) {
+ var s = line.split("\t");
+ var sessionKey = s[7];
+ if (sessionKey == "-") { return; }
+ var time = new Date(Number(s[1]));
+ var padId = s[3];
+ var evt = s[2];
+ daysEventArray(sessionKey).push(new Event(time, evt, padId));
+ });
+ }
+ var chatLog = frontendLogFileName("chat", day);
+ if (chatLog) {
+ eachFileLine(chatLog, function(line) {
+ var s = line.split("\t");
+ var sessionKey = s[4];
+ if (sessionKey == "-") { return; }
+ var time = new Date(Number(s[1]));
+ var padId = s[2];
+ daysEventArray(sessionKey).push(new Event(time, "chat", padId));
+ });
+ }
+ eachProperty(daysEvents, function(k, v) {
+ v.sort(function(a, b) { return a.time.getTime() - b.time.getTime()});
+ });
+ appjet.cache["metrics-events"][getKeyForDate(day)] = daysEvents;
+ }
+ eachProperty(appjet.cache["metrics-events"][getKeyForDate(day)], function(k, v) {
+ Array.prototype.push.apply(eventArray(k), v);
+ });
+ });
+
+ return events;
+}
+
+function getFlows(startDate, endDate) {
+ if (! endDate) { endDate = startDate; }
+ if (! appjet.cache.flows || request.params.clearCache == "1") {
+ appjet.cache.flows = {};
+ }
+ if (appjet.cache.flows[getKeyForDate(startDate)+"-"+getKeyForDate(endDate)]) {
+ return appjet.cache.flows[getKeyForDate(startDate)+"-"+getKeyForDate(endDate)];
+ }
+
+ var datesForEvents = [];
+ for (var i = startDate; i.getTime() <= endDate.getTime(); i = new Date(i.getTime()+86400*1000)) {
+ datesForEvents.push(i);
+ }
+
+ var events = parseEvents(datesForEvents);
+ var flows = {};
+
+ eachProperty(events, function(k, eventArray) {
+ flows[k] = [];
+ function lastFlow() {
+ var f = flows[k];
+ if (f.length > 0) {
+ return f[f.length-1];
+ }
+ }
+ var lastTime = 0;
+ eventArray.forEach(function(evt) {
+ var l = lastFlow();
+
+ if (l && (l.lastEvent.time.getTime() + _idleTime > evt.time.getTime() || l.isInPad)) {
+ l.push(evt);
+ } else {
+ flows[k].push(new Flow(k, evt));
+ }
+ });
+ });
+ appjet.cache.flows[getKeyForDate(startDate)+"-"+getKeyForDate(endDate)] = flows;
+ return flows;
+}
+
+function _uniq(array) {
+ var seen = {};
+ return array.filter(function(x) {
+ if (seen[x]) {
+ return false;
+ }
+ seen[x] = true;
+ return true;
+ });
+}
+
+function getFunnel(startDate, endDate, pathsArray, useConsecutivePaths) {
+ var flows = getFlows(startDate, endDate)
+
+ var flowsAtStep = pathsArray.map(function() { return []; });
+ eachProperty(flows, function(k, flowArray) {
+ flowArray.forEach(function(flow) {
+ if (flow.includesVisit(pathsArray[0])) {
+ flowsAtStep[0].push({f: flow, i: flow.visitIndices(pathsArray[0])});
+ }
+ });
+ });
+ for (var i = 0; i < pathsArray.length-1; ++i) {
+ flowsAtStep[i].forEach(function(fobj) {
+ var newIndices = fobj.i.map(function(index) {
+ var nextIndex =
+ fobj.f.includesVisit(pathsArray[i+1], index+1, useConsecutivePaths);
+ if (nextIndex !== false) {
+ return (useConsecutivePaths ? index+1 : nextIndex);
+ }
+ }).filter(function(x) { return x !== undefined; });
+ if (newIndices.length > 0) {
+ flowsAtStep[i+1].push({f: fobj.f, i: newIndices});
+ }
+ });
+ }
+ return {
+ flows: flowsAtStep.map(function(x) { return x.map(function(y) { return y.f; }); }),
+ visitCounts: flowsAtStep.map(function(x) { return x.length; }),
+ visitorCounts: flowsAtStep.map(function(x) {
+ return _uniq(x.map(function(y) { return y.f.sessionKey; })).length
+ })
+ };
+}
+
+function makeHistogram(array) {
+ var counts = {};
+ for (var i = 0; i < array.length; ++i) {
+ var value = array[i]
+ if (! counts[value]) {
+ counts[value] = 0;
+ }
+ counts[value]++;
+ }
+ var histogram = [];
+ eachProperty(counts, function(k, v) {
+ histogram.push({value: k, count: v, fraction: (v / array.length)});
+ });
+ histogram.sort(function(a, b) { return b.count - a.count; });
+ return histogram;
+}
+
+function getOrigins(startDate, endDate, useReferer, shouldAggregatePads) {
+ var key = (useReferer ? "referer" : "url");
+ var flows = getFlows(startDate, endDate);
+
+ var sessionKeyFirsts = [];
+ var flowFirsts = [];
+ eachProperty(flows, function(k, flowArray) {
+ if (flowArray[0].visits[0] && flowArray[0].visits[0].data &&
+ flowArray[0].visits[0].data[key]) {
+ var path = flowArray[0].visits[0].data[key];
+ sessionKeyFirsts.push(
+ (shouldAggregatePads && ! useReferer && _isPadUrl(path) ?
+ "(pad)" : path));
+ }
+ flowArray.forEach(function(flow) {
+ if (flow.visits[0] && flow.visits[0].data &&
+ flow.visits[0].data[key]) {
+ var path = flow.visits[0].data[key];
+ flowFirsts.push(
+ (shouldAggregatePads && ! useReferer && _isPadUrl(path) ?
+ "(pad)" : path));
+ }
+ });
+ });
+
+ if (useReferer) {
+ flowFirsts = flowFirsts.filter(function(x) { return ! startsWith(x, "http://pad.spline.inf.fu-berlin.de"); });
+ sessionKeyFirsts = sessionKeyFirsts.filter(function(x) { return ! startsWith(x, "http://pad.spline.inf.fu-berlin.de"); });
+ }
+
+ return {
+ flowFirsts: makeHistogram(flowFirsts),
+ sessionKeyFirsts: makeHistogram(sessionKeyFirsts)
+ }
+}
+
+function getExits(startDate, endDate, src, shouldAggregatePads) {
+ var flows = getFlows(startDate, endDate);
+
+ var exits = [];
+
+ eachProperty(flows, function(k, flowArray) {
+ flowArray.forEach(function(flow) {
+ var indices = flow.visitIndices(src);
+ for (var i = 0; i < indices.length; ++i) {
+ if (indices[i]+1 < flow.visits.length) {
+ if (src != flow.visits[indices[i]+1].data.url) {
+ exits.push(flow.visits[indices[i]+1]);
+ }
+ } else {
+ exits.push("(nothing)");
+ }
+ }
+ });
+ });
+ return {
+ nextVisits: exits,
+ histogram: makeHistogram(exits.map(function(x) {
+ if (typeof(x) == 'string') return x;
+ return ((! shouldAggregatePads) || ! _isPadUrl(x.data.url) ?
+ x.data.url : "(pad)" )
+ }))
+ }
+}
+
+jimport("org.jfree.data.general.DefaultPieDataset");
+jimport("org.jfree.chart.plot.PiePlot");
+jimport("org.jfree.chart.ChartUtilities");
+jimport("org.jfree.chart.JFreeChart");
+
+function _fToPct(f) {
+ return Math.round(f*10000)/100;
+}
+
+function _shorten(str) {
+ if (startsWith(str, "http://")) {
+ str = str.substring("http://".length);
+ }
+ var len = 35;
+ if (str.length > len) {
+ return str.substring(0, len-3)+"..."
+ } else {
+ return str;
+ }
+}
+
+function respondWithPieChart(name, histogram) {
+ var width = 900;
+ var height = 300;
+
+ var ds = new DefaultPieDataset();
+
+ var cumulative = 0;
+ var other = 0;
+ var otherCount = 0;
+ histogram.forEach(function(x, i) {
+ cumulative += x.fraction;
+ if (cumulative < 0.98 && x.fraction > .01) {
+ ds.setValue(_shorten(x.value)+"\n ("+x.count+" visits - "+_fToPct(x.fraction)+"%)", x.fraction);
+ } else {
+ other += x.fraction;
+ otherCount += x.count;
+ }
+ });
+ if (other > 0) {
+ ds.setValue("Other ("+otherCount + " visits - "+_fToPct(other)+"%)", other);
+ }
+
+ var piePlot = new PiePlot(ds);
+
+ var chart = new JFreeChart(piePlot);
+ chart.setTitle(name);
+ chart.removeLegend();
+
+ var jos = new java.io.ByteArrayOutputStream();
+ ChartUtilities.writeChartAsJPEG(
+ jos, 1.0, chart, width, height);
+
+ response.setContentType('image/jpeg');
+ response.writeBytes(jos.toByteArray());
+}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/trunk/etherpad/src/etherpad/pad/activepads.js b/trunk/etherpad/src/etherpad/pad/activepads.js
new file mode 100644
index 0000000..07f5e2e
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pad/activepads.js
@@ -0,0 +1,52 @@
+/**
+ * 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("jsutils.cmp");
+
+jimport("net.appjet.common.util.LimitedSizeMapping");
+
+var HISTORY_SIZE = 100;
+
+function _getMap() {
+ if (!appjet.cache['activepads']) {
+ appjet.cache['activepads'] = {
+ map: new LimitedSizeMapping(HISTORY_SIZE)
+ };
+ }
+ return appjet.cache['activepads'].map;
+}
+
+function touch(padId) {
+ _getMap().put(padId, +(new Date));
+}
+
+function getActivePads() {
+ var m = _getMap();
+ var a = m.listAllKeys().toArray();
+ var activePads = [];
+ for (var i = 0; i < a.length; i++) {
+ activePads.push({
+ padId: a[i],
+ timestamp: m.get(a[i])
+ });
+ }
+
+ activePads.sort(function(a,b) { return cmp(b.timestamp,a.timestamp); });
+ return activePads;
+}
+
+
+
diff --git a/trunk/etherpad/src/etherpad/pad/chatarchive.js b/trunk/etherpad/src/etherpad/pad/chatarchive.js
new file mode 100644
index 0000000..2f8e33a
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pad/chatarchive.js
@@ -0,0 +1,67 @@
+/**
+ * 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("stringutils");
+import("etherpad.log");
+
+jimport("java.lang.System.out.println");
+
+function onChatMessage(pad, senderUserInfo, msg) {
+ pad.appendChatMessage({
+ name: senderUserInfo.name,
+ userId: senderUserInfo.userId,
+ time: +(new Date),
+ lineText: msg.lineText
+ });
+}
+
+function getRecentChatBlock(pad, howMany) {
+ var numMessages = pad.getNumChatMessages();
+ var firstToGet = Math.max(0, numMessages - howMany);
+
+ return getChatBlock(pad, firstToGet, numMessages);
+}
+
+function getChatBlock(pad, start, end) {
+ if (start < 0) {
+ start = 0;
+ }
+ if (end > pad.getNumChatMessages()) {
+ end = pad.getNumChatMessages();
+ }
+
+ var historicalAuthorData = {};
+ var lines = [];
+ var block = {start: start, end: end,
+ historicalAuthorData: historicalAuthorData,
+ lines: lines};
+
+ for(var i=start; i<end; i++) {
+ var x = pad.getChatMessage(i);
+ var userId = x.userId;
+ if (! historicalAuthorData[userId]) {
+ historicalAuthorData[userId] = (pad.getAuthorData(userId) || {});
+ }
+ lines.push({
+ name: x.name,
+ time: x.time,
+ userId: x.userId,
+ lineText: x.lineText
+ });
+ }
+
+ return block;
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/pad/dbwriter.js b/trunk/etherpad/src/etherpad/pad/dbwriter.js
new file mode 100644
index 0000000..233622b
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pad/dbwriter.js
@@ -0,0 +1,338 @@
+/**
+ * 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("execution");
+import("profiler");
+
+import("etherpad.pad.model");
+import("etherpad.pad.model.accessPadGlobal");
+import("etherpad.log");
+import("etherpad.utils");
+
+jimport("net.appjet.oui.exceptionlog");
+jimport("java.util.concurrent.ConcurrentHashMap");
+jimport("java.lang.System.out.println");
+
+var MIN_WRITE_INTERVAL_MS = 2000; // 2 seconds
+var MIN_WRITE_DELAY_NOTIFY_MS = 2000; // 2 seconds
+var AGE_FOR_PAD_FLUSH_MS = 5*60*1000; // 5 minutes
+var DBUNWRITABLE_WRITE_DELAY_MS = 30*1000; // 30 seconds
+
+// state is { constant: true }, { constant: false }, { trueAfter: timeInMs }
+function setWritableState(state) {
+ _dbwriter().dbWritable = state;
+}
+
+function getWritableState() {
+ return _dbwriter().dbWritable;
+}
+
+function isDBWritable() {
+ return _isDBWritable();
+}
+
+function _isDBWritable() {
+ var state = _dbwriter().dbWritable;
+ if (typeof state != "object") {
+ return true;
+ }
+ else if (state.constant !== undefined) {
+ return !! state.constant;
+ }
+ else if (state.trueAfter !== undefined) {
+ return (+new Date()) > state.trueAfter;
+ }
+ else return true;
+}
+
+function getWritableStateDescription(state) {
+ var v = _isDBWritable();
+ var restOfMessage = "";
+ if (state.trueAfter !== undefined) {
+ var now = +new Date();
+ var then = state.trueAfter;
+ var diffSeconds = java.lang.String.format("%.1f", Math.abs(now - then)/1000);
+ if (now < then) {
+ restOfMessage = " until "+diffSeconds+" seconds from now";
+ }
+ else {
+ restOfMessage = " since "+diffSeconds+" seconds ago";
+ }
+ }
+ return v+restOfMessage;
+}
+
+function _dbwriter() {
+ return appjet.cache.dbwriter;
+}
+
+function onStartup() {
+ appjet.cache.dbwriter = {};
+ var dbwriter = _dbwriter();
+ dbwriter.pendingWrites = new ConcurrentHashMap();
+ dbwriter.scheduledFor = new ConcurrentHashMap(); // padId --> long
+ dbwriter.dbWritable = { constant: true };
+
+ execution.initTaskThreadPool("dbwriter", 4);
+ // we don't wait for scheduled tasks in the infreq pool to run and complete
+ execution.initTaskThreadPool("dbwriter_infreq", 1);
+
+ _scheduleCheckForStalePads();
+}
+
+function _scheduleCheckForStalePads() {
+ execution.scheduleTask("dbwriter_infreq", "checkForStalePads", AGE_FOR_PAD_FLUSH_MS, []);
+}
+
+function onShutdown() {
+ log.info("Doing final DB writes before shutdown...");
+ var success = execution.shutdownAndWaitOnTaskThreadPool("dbwriter", 10000);
+ if (! success) {
+ log.warn("ERROR! DB WRITER COULD NOT SHUTDOWN THREAD POOL!");
+ }
+}
+
+function _logException(e) {
+ var exc = utils.toJavaException(e);
+ log.warn("writeAllToDB: Error writing to SQL! Written to exceptions.log: "+exc);
+ log.logException(exc);
+ exceptionlog.apply(exc);
+}
+
+function taskFlushPad(padId, reason) {
+ var dbwriter = _dbwriter();
+ if (! _isDBWritable()) {
+ // DB is unwritable, delay
+ execution.scheduleTask("dbwriter_infreq", "flushPad", DBUNWRITABLE_WRITE_DELAY_MS, [padId, reason]);
+ return;
+ }
+
+ model.accessPadGlobal(padId, function(pad) {
+ writePadNow(pad, true);
+ }, "r");
+
+ log.info("taskFlushPad: flushed "+padId+(reason?(" (reason: "+reason+")"):''));
+}
+
+function taskWritePad(padId) {
+ var dbwriter = _dbwriter();
+ if (! _isDBWritable()) {
+ // DB is unwritable, delay
+ dbwriter.scheduledFor.put(padId, (+(new Date)+DBUNWRITABLE_WRITE_DELAY_MS));
+ execution.scheduleTask("dbwriter", "writePad", DBUNWRITABLE_WRITE_DELAY_MS, [padId]);
+ return;
+ }
+
+ profiler.reset();
+ var t1 = profiler.rcb("lock wait");
+ model.accessPadGlobal(padId, function(pad) {
+ t1();
+ _dbwriter().pendingWrites.remove(padId); // do this first
+
+ var success = false;
+ try {
+ var t2 = profiler.rcb("write");
+ writePadNow(pad);
+ t2();
+
+ success = true;
+ }
+ finally {
+ if (! success) {
+ log.warn("DB WRITER FAILED TO WRITE PAD: "+padId);
+ }
+ profiler.print();
+ }
+ }, "r");
+}
+
+function taskCheckForStalePads() {
+ // do this first
+ _scheduleCheckForStalePads();
+
+ if (! _isDBWritable()) return;
+
+ // get "active" pads into an array
+ var padIter = appjet.cache.pads.meta.keySet().iterator();
+ var padList = [];
+ while (padIter.hasNext()) { padList.push(padIter.next()); }
+
+ var numStale = 0;
+
+ for (var i = 0; i < padList.length; i++) {
+ if (! _isDBWritable()) break;
+ var p = padList[i];
+ if (model.isPadLockHeld(p)) {
+ // skip it, don't want to lock up stale pad flusher
+ }
+ else {
+ accessPadGlobal(p, function(pad) {
+ if (pad.exists()) {
+ var padAge = (+new Date()) - pad._meta.status.lastAccess;
+ if (padAge > AGE_FOR_PAD_FLUSH_MS) {
+ writePadNow(pad, true);
+ numStale++;
+ }
+ }
+ }, "r");
+ }
+ }
+
+ log.info("taskCheckForStalePads: flushed "+numStale+" stale pads");
+}
+
+function notifyPadDirty(padId) {
+ var dbwriter = _dbwriter();
+ if (! dbwriter.pendingWrites.containsKey(padId)) {
+ dbwriter.pendingWrites.put(padId, "pending");
+ dbwriter.scheduledFor.put(padId, (+(new Date)+MIN_WRITE_INTERVAL_MS));
+ execution.scheduleTask("dbwriter", "writePad", MIN_WRITE_INTERVAL_MS, [padId]);
+ }
+}
+
+function scheduleFlushPad(padId, reason) {
+ execution.scheduleTask("dbwriter_infreq", "flushPad", 0, [padId, reason]);
+}
+
+/*function _dbwriterLoopBody(executor) {
+ try {
+ var info = writeAllToDB(executor);
+ if (!info.boring) {
+ log.info("DB writer: "+info.toSource());
+ }
+ java.lang.Thread.sleep(Math.max(0, MIN_WRITE_INTERVAL_MS - info.elapsed));
+ }
+ catch (e) {
+ _logException(e);
+ java.lang.Thread.sleep(MIN_WRITE_INTERVAL_MS);
+ }
+}
+
+function _startInThread(name, func) {
+ (new Thread(new Runnable({
+ run: function() {
+ func();
+ }
+ }), name)).start();
+}
+
+function killDBWriterThreadAndWait() {
+ appjet.cache.abortDBWriter = true;
+ while (appjet.cache.runningDBWriter) {
+ java.lang.Thread.sleep(100);
+ }
+}*/
+
+/*function writeAllToDB(executor, andFlush) {
+ if (!executor) {
+ executor = new ScheduledThreadPoolExecutor(NUM_WRITER_THREADS);
+ }
+
+ profiler.reset();
+ var startWriteTime = profiler.time();
+ var padCount = new AtomicInteger(0);
+ var writeCount = new AtomicInteger(0);
+ var removeCount = new AtomicInteger(0);
+
+ // get pads into an array
+ var padIter = appjet.cache.pads.meta.keySet().iterator();
+ var padList = [];
+ while (padIter.hasNext()) { padList.push(padIter.next()); }
+
+ var latch = new CountDownLatch(padList.length);
+
+ for (var i = 0; i < padList.length; i++) {
+ _spawnCall(executor, function(p) {
+ try {
+ var padWriteResult = {};
+ accessPadGlobal(p, function(pad) {
+ if (pad.exists()) {
+ padCount.getAndIncrement();
+ padWriteResult = writePad(pad, andFlush);
+ if (padWriteResult.didWrite) writeCount.getAndIncrement();
+ if (padWriteResult.didRemove) removeCount.getAndIncrement();
+ }
+ }, "r");
+ } catch (e) {
+ _logException(e);
+ } finally {
+ latch.countDown();
+ }
+ }, padList[i]);
+ }
+
+ // wait for them all to finish
+ latch.await();
+
+ var endWriteTime = profiler.time();
+ var elapsed = Math.round((endWriteTime - startWriteTime)/1000)/1000;
+ var interesting = (writeCount.get() > 0 || removeCount.get() > 0);
+
+ var obj = {padCount:padCount.get(), writeCount:writeCount.get(), elapsed:elapsed, removeCount:removeCount.get()};
+ if (! interesting) obj.boring = true;
+ if (interesting) {
+ profiler.record("writeAll", profiler.time()-startWriteTime);
+ profiler.print();
+ }
+
+ return obj;
+}*/
+
+function writePadNow(pad, andFlush) {
+ var didWrite = false;
+ var didRemove = false;
+
+ if (pad.exists()) {
+ var dbUpToDate = false;
+ if (pad._meta.status.dirty) {
+ /*log.info("Writing pad "+pad.getId());*/
+ pad._meta.status.dirty = false;
+ //var t1 = +new Date();
+ pad.writeToDB();
+ //var t2 = +new Date();
+ didWrite = true;
+
+ //log.info("Wrote pad "+pad.getId()+" in "+(t2-t1)+" ms.");
+
+ var now = +(new Date);
+ var sched = _dbwriter().scheduledFor.get(pad.getId());
+ if (sched) {
+ var delay = now - sched;
+ if (delay > MIN_WRITE_DELAY_NOTIFY_MS) {
+ log.warn("dbwriter["+pad.getId()+"] behind schedule by "+delay+"ms");
+ }
+ _dbwriter().scheduledFor.remove(pad.getId());
+ }
+ }
+ if (andFlush) {
+ // remove from cache
+ model.removeFromMemory(pad);
+ didRemove = true;
+ }
+ }
+ return {didWrite:didWrite, didRemove:didRemove};
+}
+
+/*function _spawnCall(executor, func, varargs) {
+ var args = Array.prototype.slice.call(arguments, 2);
+ var that = this;
+ executor.schedule(new Runnable({
+ run: function() {
+ func.apply(that, args);
+ }
+ }), 0, TimeUnit.MICROSECONDS);
+}*/
+
diff --git a/trunk/etherpad/src/etherpad/pad/easysync2migration.js b/trunk/etherpad/src/etherpad/pad/easysync2migration.js
new file mode 100644
index 0000000..c2a1523
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pad/easysync2migration.js
@@ -0,0 +1,675 @@
+/**
+ * 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("etherpad.collab.ace.easysync1");
+import("etherpad.collab.ace.easysync2");
+import("sqlbase.sqlbase");
+import("fastJSON");
+import("sqlbase.sqlcommon.*");
+import("etherpad.collab.ace.contentcollector.sanitizeUnicode");
+
+function _getPadStringArrayNumId(padId, arrayName) {
+ var stmnt = "SELECT NUMID FROM "+btquote("PAD_"+arrayName.toUpperCase()+"_META")+
+ " WHERE ("+btquote("ID")+" = ?)";
+
+ return withConnection(function(conn) {
+ var pstmnt = conn.prepareStatement(stmnt);
+ return closing(pstmnt, function() {
+ pstmnt.setString(1, padId);
+ var resultSet = pstmnt.executeQuery();
+ return closing(resultSet, function() {
+ if (! resultSet.next()) {
+ return -1;
+ }
+ return resultSet.getInt(1);
+ });
+ });
+ });
+}
+
+function _getEntirePadStringArray(padId, arrayName) {
+ var numId = _getPadStringArrayNumId(padId, arrayName);
+ if (numId < 0) {
+ return [];
+ }
+
+ var stmnt = "SELECT PAGESTART, OFFSETS, DATA FROM "+btquote("PAD_"+arrayName.toUpperCase()+"_TEXT")+
+ " WHERE ("+btquote("NUMID")+" = ?)";
+
+ return withConnection(function(conn) {
+ var pstmnt = conn.prepareStatement(stmnt);
+ return closing(pstmnt, function() {
+ pstmnt.setInt(1, numId);
+ var resultSet = pstmnt.executeQuery();
+ return closing(resultSet, function() {
+ var array = [];
+ while (resultSet.next()) {
+ var pageStart = resultSet.getInt(1);
+ var lengthsString = resultSet.getString(2);
+ var dataString = resultSet.getString(3);
+ var dataIndex = 0;
+ var arrayIndex = pageStart;
+ lengthsString.split(',').forEach(function(len) {
+ if (len) {
+ len = Number(len);
+ array[arrayIndex] = dataString.substr(dataIndex, len);
+ dataIndex += len;
+ }
+ arrayIndex++;
+ });
+ }
+ return array;
+ });
+ });
+ });
+}
+
+function _overwriteEntirePadStringArray(padId, arrayName, array) {
+ var numId = _getPadStringArrayNumId(padId, arrayName);
+ if (numId < 0) {
+ // generate numId
+ withConnection(function(conn) {
+ var ps = conn.prepareStatement("INSERT INTO "+btquote("PAD_"+arrayName.toUpperCase()+"_META")+
+ " ("+btquote("ID")+") VALUES (?)",
+ java.sql.Statement.RETURN_GENERATED_KEYS);
+ closing(ps, function() {
+ ps.setString(1, padId);
+ ps.executeUpdate();
+ var keys = ps.getGeneratedKeys();
+ if ((! keys) || (! keys.next())) {
+ throw new Error("Couldn't generate key for "+arrayName+" table for pad "+padId);
+ }
+ closing(keys, function() {
+ numId = keys.getInt(1);
+ });
+ });
+ });
+ }
+
+ withConnection(function(conn) {
+
+ var stmnt1 = "DELETE FROM "+btquote("PAD_"+arrayName.toUpperCase()+"_TEXT")+
+ " WHERE ("+btquote("NUMID")+" = ?)";
+ var pstmnt1 = conn.prepareStatement(stmnt1);
+ closing(pstmnt1, function() {
+ pstmnt1.setInt(1, numId);
+ pstmnt1.executeUpdate();
+ });
+
+ var PAGE_SIZE = 20;
+ var numPages = Math.floor((array.length-1) / PAGE_SIZE + 1);
+
+ var PAGES_PER_BATCH = 20;
+ var curPage = 0;
+
+ while (curPage < numPages) {
+ var stmnt2 = "INSERT INTO "+btquote("PAD_"+arrayName.toUpperCase()+"_TEXT")+
+ " ("+btquote("NUMID")+", "+btquote("PAGESTART")+", "+btquote("OFFSETS")+
+ ", "+btquote("DATA")+") VALUES (?, ?, ?, ?)";
+ var pstmnt2 = conn.prepareStatement(stmnt2);
+ closing(pstmnt2, function() {
+ for(var n=0;n<PAGES_PER_BATCH && curPage < numPages;n++) {
+ var pageStart = curPage*PAGE_SIZE;
+ var r = pageStart;
+ var lengthPieces = [];
+ var dataPieces = [];
+ for(var i=0;i<PAGE_SIZE;i++) {
+ var str = (array[r] || '');
+ dataPieces.push(str);
+ lengthPieces.push(String(str.length || ''));
+ r++;
+ }
+ var lengthsString = lengthPieces.join(',');
+ var dataString = dataPieces.join('');
+ pstmnt2.setInt(1, numId);
+ pstmnt2.setInt(2, pageStart);
+ pstmnt2.setString(3, lengthsString);
+ pstmnt2.setString(4, dataString);
+ pstmnt2.addBatch();
+
+ curPage++;
+ }
+ pstmnt2.executeBatch();
+ });
+ }
+ });
+
+}
+
+function _getEntirePadJSONArray(padId, arrayName) {
+ var array = _getEntirePadStringArray(padId, arrayName);
+ for(var k in array) {
+ if (array[k]) {
+ array[k] = fastJSON.parse(array[k]);
+ }
+ }
+ return array;
+}
+
+function _overwriteEntirePadJSONArray(padId, arrayName, objArray) {
+ var array = [];
+ for(var k in objArray) {
+ if (objArray[k]) {
+ array[k] = fastJSON.stringify(objArray[k]);
+ }
+ }
+ _overwriteEntirePadStringArray(padId, arrayName, array);
+}
+
+function _getMigrationPad(padId) {
+ var oldRevs = _getEntirePadStringArray(padId, "revs");
+ var oldRevMeta = _getEntirePadJSONArray(padId, "revmeta");
+ var oldAuthors = _getEntirePadJSONArray(padId, "authors");
+ var oldMeta = sqlbase.getJSON("PAD_META", padId);
+
+ var oldPad = {
+ getHeadRevisionNumber: function() {
+ return oldMeta.head;
+ },
+ getRevisionChangesetString: function(r) {
+ return oldRevs[r];
+ },
+ getRevisionAuthor: function(r) {
+ return oldMeta.numToAuthor[oldRevMeta[r].a];
+ },
+ getId: function() { return padId; },
+ getKeyRevisionNumber: function(r) {
+ return Math.floor(r / oldMeta.keyRevInterval) * oldMeta.keyRevInterval;
+ },
+ getInternalRevisionText: function(r) {
+ if (r != oldPad.getKeyRevisionNumber(r)) {
+ throw new Error("Assertion error: "+r+" != "+oldPad.getKeyRevisionNumber(r));
+ }
+ return oldRevMeta[r].atext.text;
+ },
+ _meta: oldMeta,
+ getAuthorArrayEntry: function(n) {
+ return oldAuthors[n];
+ },
+ getRevMetaArrayEntry: function(r) {
+ return oldRevMeta[r];
+ }
+ };
+
+ var apool = new easysync2.AttribPool();
+ var newRevMeta = [];
+ var newAuthors = [];
+ var newRevs = [];
+ var metaPropsToDelete = [];
+
+ var newPad = {
+ pool: function() { return apool; },
+ setAuthorArrayEntry: function(n, obj) {
+ newAuthors[n] = obj;
+ },
+ setRevMetaArrayEntry: function(r, obj) {
+ newRevMeta[r] = obj;
+ },
+ setRevsArrayEntry: function(r, cs) {
+ newRevs[r] = cs;
+ },
+ deleteMetaProp: function(propName) {
+ metaPropsToDelete.push(propName);
+ }
+ };
+
+ function writeToDB() {
+ var newMeta = {};
+ for(var k in oldMeta) {
+ newMeta[k] = oldMeta[k];
+ }
+ metaPropsToDelete.forEach(function(p) {
+ delete newMeta[p];
+ });
+
+ sqlbase.putJSON("PAD_META", padId, newMeta);
+ sqlbase.putJSON("PAD_APOOL", padId, apool.toJsonable());
+
+ _overwriteEntirePadStringArray(padId, "revs", newRevs);
+ _overwriteEntirePadJSONArray(padId, "revmeta", newRevMeta);
+ _overwriteEntirePadJSONArray(padId, "authors", newAuthors);
+ }
+
+ return {oldPad:oldPad, newPad:newPad, writeToDB:writeToDB};
+}
+
+function migratePad(padId) {
+
+ var mpad = _getMigrationPad(padId);
+ var oldPad = mpad.oldPad;
+ var newPad = mpad.newPad;
+
+ var headRev = oldPad.getHeadRevisionNumber();
+ var txt = "\n";
+ var newChangesets = [];
+ var newChangesetAuthorNums = [];
+ var cumCs = easysync2.Changeset.identity(1);
+
+ var pool = newPad.pool();
+
+ var isExtraFinalNewline = false;
+
+ function authorToNewNum(author) {
+ return pool.putAttrib(['author',author||'']);
+ }
+
+ //S var oldTotalChangesetSize = 0;
+ //S var newTotalChangesetSize = 0;
+ //S function stringSize(str) {
+ //S return new java.lang.String(str).getBytes("UTF-8").length;
+ //S }
+
+ //P var diffTotals = [];
+ for(var r=0;r<=headRev;r++) {
+ //P var times = [];
+ //P times.push(+new Date);
+ var author = oldPad.getRevisionAuthor(r);
+ //P times.push(+new Date);
+ newChangesetAuthorNums.push(authorToNewNum(author));
+
+ var newCs, newText;
+ if (r == 0) {
+ newText = oldPad.getInternalRevisionText(0);
+ newCs = getInitialChangeset(newText, pool, author);
+ //S oldTotalChangesetSize += stringSize(pad.getRevisionChangesetString(0));
+ }
+ else {
+ var oldCsStr = oldPad.getRevisionChangesetString(r);
+ //S oldTotalChangesetSize += stringSize(oldCsStr);
+ //P times.push(+new Date);
+ var oldCs = easysync1.Changeset.decodeFromString(oldCsStr);
+ //P times.push(+new Date);
+
+ /*var newTextFromOldCs = oldCs.applyToText(txt);
+ if (newTextFromOldCs.charAt(newTextFromOldCs.length-1) != '\n') {
+ var e = new Error("Violation of final newline property at revision "+r);
+ e.finalNewlineMissing = true;
+ throw e;
+ }*/
+ //var newCsNewTxt1 = upgradeChangeset(oldCs, txt, pool, author);
+ var oldIsExtraFinalNewline = isExtraFinalNewline;
+ var newCsNewTxt2 = upgradeChangeset(oldCs, txt, pool, author, isExtraFinalNewline);
+ //P times.push(+new Date);
+ /*if (newCsNewTxt1[1] != newCsNewTxt2[1]) {
+ _putFile(newCsNewTxt1[1], "/tmp/file1");
+ _putFile(newCsNewTxt2[1], "/tmp/file2");
+ throw new Error("MISMATCH 1");
+ }
+ if (newCsNewTxt1[0] != newCsNewTxt2[0]) {
+ _putFile(newCsNewTxt1[0], "/tmp/file1");
+ _putFile(newCsNewTxt2[0], "/tmp/file2");
+ throw new Error("MISMATCH 0");
+ }*/
+ newCs = newCsNewTxt2[0];
+ newText = newCsNewTxt2[1];
+ isExtraFinalNewline = newCsNewTxt2[2];
+
+ /*if (oldIsExtraFinalNewline || isExtraFinalNewline) {
+ System.out.print("\nnewline fix for rev "+r+"/"+headRev+"... ");
+ }*/
+ }
+
+ var oldText = txt;
+ newChangesets.push(newCs);
+ txt = newText;
+ //System.out.println(easysync2.Changeset.toBaseTen(cumCs)+" * "+
+ //easysync2.Changeset.toBaseTen(newCs));
+ /*cumCs = easysync2.Changeset.checkRep(easysync2.Changeset.compose(cumCs, newCs));
+ if (easysync2.Changeset.applyToText(cumCs, "\n") != txt) {
+ throw new Error("cumCs mismatch");
+ }*/
+
+ //P times.push(+new Date);
+
+ easysync2.Changeset.checkRep(newCs);
+ //P times.push(+new Date);
+ var origText = txt;
+ if (isExtraFinalNewline) {
+ origText = origText.slice(0, -1);
+ }
+ if (r == oldPad.getKeyRevisionNumber(r)) {
+ // only check key revisions (and final outcome), for speed
+ if (oldPad.getInternalRevisionText(r) != origText) {
+ var expected = oldPad.getInternalRevisionText(r);
+ var actual = origText;
+ //_putFile(expected, "/tmp/file1");
+ //_putFile(actual, "/tmp/file2");
+ //_putFile(oldText, "/tmp/file3");
+ //java.lang.System.out.println(String(oldCs));
+ //java.lang.System.out.println(easysync2.Changeset.toBaseTen(newCs));
+ throw new Error("Migration mismatch, pad "+padId+", revision "+r);
+ }
+ }
+
+ //S newTotalChangesetSize += stringSize(newCs);
+
+ //P if (r > 0) {
+ //P var diffs = [];
+ //P for(var i=0;i<times.length-1;i++) {
+ //P diffs[i] = times[i+1] - times[i];
+ //P }
+ //P for(var i=0;i<diffs.length;i++) {
+ //P diffTotals[i] = (diffTotals[i] || 0) + diffs[i]*1000/headRev;
+ //P }
+ //P }
+ }
+ //P System.out.println(String(diffTotals));
+
+ //S System.out.println("New data is "+(newTotalChangesetSize/oldTotalChangesetSize*100)+
+ //S "% size of old data (average "+(newTotalChangesetSize/(headRev+1))+
+ //S " bytes instead of "+(oldTotalChangesetSize/(headRev+1))+")");
+
+ var atext = easysync2.Changeset.makeAText("\n");
+ for(var r=0; r<=headRev; r++) {
+ newPad.setRevsArrayEntry(r, newChangesets[r]);
+
+ atext = easysync2.Changeset.applyToAText(newChangesets[r], atext, pool);
+
+ var rm = oldPad.getRevMetaArrayEntry(r);
+ rm.a = newChangesetAuthorNums[r];
+ if (rm.atext) {
+ rm.atext = easysync2.Changeset.cloneAText(atext);
+ }
+ newPad.setRevMetaArrayEntry(r, rm);
+ }
+
+ var newAuthors = [];
+ var newAuthorDatas = [];
+ for(var k in oldPad._meta.numToAuthor) {
+ var n = Number(k);
+ var authorData = oldPad.getAuthorArrayEntry(n) || {};
+ var authorName = oldPad._meta.numToAuthor[n];
+ var newAuthorNum = pool.putAttrib(['author',authorName]);
+ newPad.setAuthorArrayEntry(newAuthorNum, authorData);
+ }
+
+ newPad.deleteMetaProp('numToAuthor');
+ newPad.deleteMetaProp('authorToNum');
+
+ mpad.writeToDB();
+}
+
+function getInitialChangeset(txt, pool, author) {
+ var txt2 = txt.substring(0, txt.length-1); // strip off final newline
+
+ var assem = easysync2.Changeset.smartOpAssembler();
+ assem.appendOpWithText('+', txt2, pool && author && [['author',author]], pool);
+ assem.endDocument();
+ return easysync2.Changeset.pack(1, txt2.length+1, assem.toString(), txt2);
+}
+
+function upgradeChangeset(cs, inputText, pool, author, isExtraNewlineInSource) {
+ var attribs = '';
+ if (pool && author) {
+ attribs = '*'+easysync2.Changeset.numToString(pool.putAttrib(['author', author]));
+ }
+
+ function keepLastCharacter(c) {
+ if (! c[c.length-1] && c[c.length-3] + c[c.length-2] >= (c.oldLen() - 1)) {
+ c[c.length-2] = c.oldLen() - c[c.length-3];
+ }
+ else {
+ c.push(c.oldLen() - 1, 1, "");
+ }
+ }
+
+ var isExtraNewlineInOutput = false;
+ if (isExtraNewlineInSource) {
+ cs[1] += 1; // oldLen ++
+ }
+ if ((cs[cs.length-1] && cs[cs.length-1].slice(-1) != '\n') ||
+ ((! cs[cs.length-1]) && inputText.charAt(cs[cs.length-3] + cs[cs.length-2] - 1) != '\n')) {
+ // new text won't end with newline!
+ if (isExtraNewlineInSource) {
+ keepLastCharacter(cs);
+ }
+ else {
+ cs[cs.length-1] += "\n";
+ }
+ cs[2] += 1; // newLen ++
+ isExtraNewlineInOutput = true;
+ }
+
+ var oldLen = cs.oldLen();
+ var newLen = cs.newLen();
+
+ // final-newline-preserving modifications to changeset {{{
+ // These fixes are required for changesets that don't respect the
+ // new rule that the final newline of the document not be touched,
+ // and also for changesets tweaked above. It is important that the
+ // fixed changesets obey all the constraints on version 1 changesets
+ // so that they may become valid version 2 changesets.
+ {
+ function collapsePotentialEmptyLastTake(c) {
+ if (c[c.length-2] == 0 && c.length > 6) {
+ if (! c[c.length-1]) {
+ // last strip doesn't take or insert now
+ c.length -= 3;
+ }
+ else {
+ // the last two strips should be merged
+ // e.g. fo\n -> rock\nbar\n: then in this block,
+ // "Changeset,3,9,0,0,r,1,1,ck,2,0,\nbar" becomes
+ // "Changeset,3,9,0,0,r,1,1,ck\nbar"
+ c[c.length-4] += c[c.length-1];
+ c.length -= 3;
+ }
+ }
+ }
+ var lastStripStart = cs[cs.length-3];
+ var lastStripTake = cs[cs.length-2];
+ var lastStripInsert = cs[cs.length-1];
+ if (lastStripStart + lastStripTake == oldLen && lastStripInsert) {
+ // an insert at end
+ // e.g. foo\n -> foo\nbar\n:
+ // "Changeset,4,8,0,4,bar\n" becomes "Changeset,4,8,0,3,\nbar,3,1,"
+ // first make the previous newline part of the insertion
+ cs[cs.length-2] -= 1;
+ cs[cs.length-1] = '\n'+cs[cs.length-1].slice(0,-1);
+ collapsePotentialEmptyLastTake(cs);
+ keepLastCharacter(cs);
+ }
+ else if (lastStripStart + lastStripTake < oldLen && ! lastStripInsert) {
+ // ends with pure deletion
+ cs[cs.length-2] -= 1;
+ collapsePotentialEmptyLastTake(cs);
+ keepLastCharacter(cs);
+ }
+ else if (lastStripStart + lastStripTake < oldLen) {
+ // ends with replacement
+ cs[cs.length-1] = cs[cs.length-1].slice(0,-1);
+ keepLastCharacter(cs);
+ }
+ }
+ // }}}
+
+ var ops = [];
+ var lastOpcode = '';
+ function appendOp(opcode, text, startChar, endChar) {
+ function num(n) {
+ return easysync2.Changeset.numToString(n);
+ }
+ var lines = 0;
+ var lastNewlineEnd = startChar;
+ for (;;) {
+ var index = text.indexOf('\n', lastNewlineEnd);
+ if (index < 0 || index >= endChar) {
+ break;
+ }
+ lines++;
+ lastNewlineEnd = index+1;
+ }
+ var a = (opcode == '+' ? attribs : '');
+ var multilineChars = (lastNewlineEnd - startChar);
+ var seqLength = endChar - startChar;
+ var op = '';
+ if (lines > 0) {
+ op = [a, '|', num(lines), opcode, num(multilineChars)].join('');
+ }
+ if (multilineChars < seqLength) {
+ op += [a, opcode, num(seqLength - multilineChars)].join('');
+ }
+ if (op) {
+ // we reorder a single - and a single +
+ if (opcode == '-' && lastOpcode == '+') {
+ ops.splice(ops.length-1, 0, op);
+ }
+ else {
+ ops.push(op);
+ lastOpcode = opcode;
+ }
+ }
+ }
+
+ var oldPos = 0;
+
+ var textPieces = [];
+ var charBankPieces = [];
+ cs.eachStrip(function(start, take, insert) {
+ if (start > oldPos) {
+ appendOp('-', inputText, oldPos, start);
+ }
+ if (take) {
+ if (start+take < oldLen || insert) {
+ appendOp('=', inputText, start, start+take);
+ }
+ textPieces.push(inputText.substring(start, start+take));
+ }
+ if (insert) {
+ appendOp('+', insert, 0, insert.length);
+ textPieces.push(insert);
+ charBankPieces.push(insert);
+ }
+ oldPos = start+take;
+ });
+ // ... and no final deletions after the newline fixing.
+
+ var newCs = easysync2.Changeset.pack(oldLen, newLen, ops.join(''),
+ sanitizeUnicode(charBankPieces.join('')));
+ var newText = textPieces.join('');
+
+ return [newCs, newText, isExtraNewlineInOutput];
+}
+
+////////////////////////////////////////////////////////////////////////////////
+
+// unicode issues: 5SaYQp7cKV
+
+// // hard-coded just for testing; any pad is allowed to have corruption.
+// var newlineCorruptedPads = [
+// '0OCGFKkjDv', '14dWjOiOxP', '1LL8XQCBjC', '1jMnjEEK6e', '21',
+// '23DytOPN7d', '32YzfdT2xS', '3E6GB7l7FZ', '3Un8qaCfJh', '3YAj3rC9em',
+// '3vY2eaHSw5', '4834RRTLlg', '4Fm1iVSTWI', '5NpTNqWHGC', '7FYNSdYQVa',
+// '7RZCbvgw1z', '8EVpyN6HyY', '8P5mPRxPVr', '8aHFRmLxKR', '8dsj9eGQfP',
+// 'BSoGobOJZZ', 'Bf0uVghKy0', 'C2f3umStKd', 'CHlu2CA8F3', 'D2WEwgvg1W',
+// 'DNLTpuP2wl', 'DwNpm2TDgu', 'EKPByZ3EGZ', 'FwQxu6UKQx', 'HUn9O34rFl',
+// 'JKZhxMo20E', 'JVjuukL42N', 'JVuBlWxaxL', 'Jmw5lPNYcl', 'KnZHz6jE2P',
+// 'Luyp6ylbgR', 'MB6lPoN1eI', 'McsCrQUM6c', 'NWIuVobIw9', 'OKERTLQCCn',
+// 'OchiOchi', 'OfhKHCB8jJ', 'OkM3Jv3XY9', 'PX5Z89mx29', 'PdmKQIvOEd',
+// 'R9NQNB66qt', 'RvULFSvCbV', 'RyLJC6Qo1x', 'SBlKLwr2Ag', 'SavD72Q9P7',
+// 'SfXyxseAeF', 'TTGZ4yO2PI', 'U3U7rT3d6w', 'UFmqpQIDAi', 'V7Or0QQk4m',
+// 'VPCM5ReAQm', 'VvIYHzIJUY', 'W0Ccc3BVGb', 'Wv3cGgSgjg', 'WwVPgaZUK5',
+// 'WyIFUJXfm5', 'XxESEsgQ6R', 'Yc5Yq3WCuU', 'ZRqCFaRx6h', 'ZepX6TLFbD',
+// 'bSeImT5po4', 'bqIlTkFDiH', 'btt9vNPSQ9', 'c97YJj8PSN', 'd9YV3sypKF',
+// 'eDzzkrwDRU', 'eFQJZWclzo', 'eaz44OhFDu', 'ehKkx1YpLA', 'ep',
+// 'foNq3v3e9T', 'form6rooma', 'fqhtIHG0Ii', 'fvZyCRZjv2', 'gZnadICPYV',
+// 'gvGXtMKhQk', 'h7AYuTxUOd', 'hc1UZSti3J', 'hrFQtae2jW', 'i8rENUZUMu',
+// 'iFW9dceEmh', 'iRNEc8SlOc', 'jEDsDgDlaK', 'jo8ngXlSJh', 'kgJrB9Gh2M',
+// 'klassennetz76da2661f8ceccfe74faf97d25a4b418',
+// 'klassennetzf06d4d8176d0804697d9650f836cb1f7', 'lDHgmfyiSu',
+// 'mA1cbvxFwA', 'mSJpW1th29', 'mXHAqv1Emu', 'monocles12', 'n0NhU3FxxT',
+// 'ng7AlzPb5b', 'ntbErnnuyz', 'oVnMO0dX80', 'omOTPVY3Gl', 'p5aNFCfYG9',
+// 'pYxjVCILuL', 'phylab', 'pjVBFmnhf1', 'qGohFW3Lbr', 'qYlbjeIHDs',
+// 'qgf4OwkFI6', 'qsi', 'rJQ09pRexM', 'snNjlS1aLC', 'tYKC53TDF9',
+// 'u1vZmL8Yjv', 'ur4sb7DBJB', 'vesti', 'w9NJegEAZt', 'wDwlSCby2s',
+// 'wGFJJRT514', 'wTgEoQGqng', 'xomMZGhius', 'yFEFYWBSvr', 'z7tGFKsGk6',
+// 'zIJWNK8Z4i', 'zNMGJYI7hq'];
+
+// function _time(f) {
+// var t1 = +(new Date);
+// f();
+// var t2 = +(new Date);
+// return t2 - t1;
+// }
+
+// function listAllRevisionCounts() {
+// var padList = sqlbase.getAllJSONKeys("PAD_META");
+// //padList.length = 10;
+// padList = padList.slice(68000, 68100);
+// padList.forEach(function(id) {
+// model.accessPadGlobal(id, function(pad) {
+// System.out.println((new java.lang.Integer(pad.getHeadRevisionNumber()).toString())+
+// " "+id);
+// dbwriter.writePadNow(pad, true);
+// }, 'r');
+// });
+// }
+
+// function verifyAllPads() {
+// //var padList = sqlbase.getAllJSONKeys("PAD_META");
+// //padList = newlineCorruptedPads;
+// var padList = ['0OCGFKkjDv'];
+// //padList = ['form6rooma'];
+// //padList.length = 10;
+// var numOks = 0;
+// var numErrors = 0;
+// var numNewlineBugs = 0;
+// var longestPad;
+// var longestPadTime = -1;
+// System.out.println(padList.length+" pads.");
+// var totalTime = _time(function() {
+// padList.forEach(function(id) {
+// model.accessPadGlobal(id, function(pad) {
+// var padTime = _time(function() {
+// System.out.print(id+"... ");
+// try {
+// verifyMigration(pad);
+// System.out.println("OK ("+(++numOks)+")");
+// }
+// catch (e) {
+// System.out.println("ERROR ("+(++numErrors)+")"+(e.finalNewlineMissing?" [newline]":""));
+// System.out.println(e.toString());
+// if (e.finalNewlineMissing) {
+// numNewlineBugs++;
+// }
+// }
+// });
+// if (padTime > longestPadTime) {
+// longestPadTime = padTime;
+// longestPad = id;
+// }
+// }, 'r');
+// });
+// });
+// System.out.println("finished verifyAllPads in "+(totalTime/1000)+" seconds.");
+// System.out.println(numOks+" OK");
+// System.out.println(numErrors+" ERROR");
+// System.out.println("Most time-consuming pad: "+longestPad+" / "+longestPadTime+" ms");
+// }
+
+// function _literal(v) {
+// if ((typeof v) == "string") {
+// return '"'+v.replace(/[\\\"]/g, '\\$1').replace(/\n/g, '\\n')+'"';
+// }
+// else return v.toSource();
+// }
+
+// function _putFile(str, path) {
+// var writer = new java.io.FileWriter(path);
+// writer.write(str);
+// writer.close();
+// }
diff --git a/trunk/etherpad/src/etherpad/pad/exporthtml.js b/trunk/etherpad/src/etherpad/pad/exporthtml.js
new file mode 100644
index 0000000..2512603
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pad/exporthtml.js
@@ -0,0 +1,383 @@
+/**
+ * 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("etherpad.collab.ace.easysync2.Changeset");
+
+function getPadPlainText(pad, revNum) {
+ var atext = ((revNum !== undefined) ? pad.getInternalRevisionAText(revNum) :
+ pad.atext());
+ var textLines = atext.text.slice(0,-1).split('\n');
+ var attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text);
+ var apool = pad.pool();
+
+ var pieces = [];
+ for(var i=0;i<textLines.length;i++) {
+ var line = _analyzeLine(textLines[i], attribLines[i], apool);
+ if (line.listLevel) {
+ var numSpaces = line.listLevel*2-1;
+ var bullet = '*';
+ pieces.push(new Array(numSpaces+1).join(' '), bullet, ' ', line.text, '\n');
+ }
+ else {
+ pieces.push(line.text, '\n');
+ }
+ }
+
+ return pieces.join('');
+}
+
+function getPadHTML(pad, revNum) {
+ var atext = ((revNum !== undefined) ? pad.getInternalRevisionAText(revNum) :
+ pad.atext());
+ var textLines = atext.text.slice(0,-1).split('\n');
+ var attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text);
+
+ var apool = pad.pool();
+
+ var tags = ['b','i','u','s','h1','h2','h3','h4','h5','h6'];
+ var props = ['bold','italic','underline','strikethrough','h1','h2','h3','h4','h5','h6'];
+ var anumMap = {};
+ props.forEach(function(propName, i) {
+ var propTrueNum = apool.putAttrib([propName,true], true);
+ if (propTrueNum >= 0) {
+ anumMap[propTrueNum] = i;
+ }
+ });
+
+ function getLineHTML(text, attribs) {
+ var propVals = [false, false, false];
+ var ENTER = 1;
+ var STAY = 2;
+ var LEAVE = 0;
+
+ // Use order of tags (b/i/u) as order of nesting, for simplicity
+ // and decent nesting. For example,
+ // <b>Just bold<b> <b><i>Bold and italics</i></b> <i>Just italics</i>
+ // becomes
+ // <b>Just bold <i>Bold and italics</i></b> <i>Just italics</i>
+
+ var taker = Changeset.stringIterator(text);
+ var assem = Changeset.stringAssembler();
+
+ function emitOpenTag(i) {
+ assem.append('<');
+ assem.append(tags[i]);
+ assem.append('>');
+ }
+ function emitCloseTag(i) {
+ assem.append('</');
+ assem.append(tags[i]);
+ assem.append('>');
+ }
+
+ var urls = _findURLs(text);
+
+ var idx = 0;
+ function processNextChars(numChars) {
+ if (numChars <= 0) {
+ return;
+ }
+
+ var iter = Changeset.opIterator(Changeset.subattribution(attribs,
+ idx, idx+numChars));
+ idx += numChars;
+
+ while (iter.hasNext()) {
+ var o = iter.next();
+ var propChanged = false;
+ Changeset.eachAttribNumber(o.attribs, function(a) {
+ if (a in anumMap) {
+ var i = anumMap[a]; // i = 0 => bold, etc.
+ if (! propVals[i]) {
+ propVals[i] = ENTER;
+ propChanged = true;
+ }
+ else {
+ propVals[i] = STAY;
+ }
+ }
+ });
+ for(var i=0;i<propVals.length;i++) {
+ if (propVals[i] === true) {
+ propVals[i] = LEAVE;
+ propChanged = true;
+ }
+ else if (propVals[i] === STAY) {
+ propVals[i] = true; // set it back
+ }
+ }
+ // now each member of propVal is in {false,LEAVE,ENTER,true}
+ // according to what happens at start of span
+
+ if (propChanged) {
+ // leaving bold (e.g.) also leaves italics, etc.
+ var left = false;
+ for(var i=0;i<propVals.length;i++) {
+ var v = propVals[i];
+ if (! left) {
+ if (v === LEAVE) {
+ left = true;
+ }
+ }
+ else {
+ if (v === true) {
+ propVals[i] = STAY; // tag will be closed and re-opened
+ }
+ }
+ }
+
+ for(var i=propVals.length-1; i>=0; i--) {
+ if (propVals[i] === LEAVE) {
+ emitCloseTag(i);
+ propVals[i] = false;
+ }
+ else if (propVals[i] === STAY) {
+ emitCloseTag(i);
+ }
+ }
+ for(var i=0; i<propVals.length; i++) {
+ if (propVals[i] === ENTER || propVals[i] === STAY) {
+ emitOpenTag(i);
+ propVals[i] = true;
+ }
+ }
+ // propVals is now all {true,false} again
+ } // end if (propChanged)
+
+ var chars = o.chars;
+ if (o.lines) {
+ chars--; // exclude newline at end of line, if present
+ }
+ var s = taker.take(chars);
+
+ assem.append(_escapeHTML(s));
+ } // end iteration over spans in line
+
+ for(var i=propVals.length-1; i>=0; i--) {
+ if (propVals[i]) {
+ emitCloseTag(i);
+ propVals[i] = false;
+ }
+ }
+ } // end processNextChars
+
+ if (urls) {
+ urls.forEach(function(urlData) {
+ var startIndex = urlData[0];
+ var url = urlData[1];
+ var urlLength = url.length;
+ processNextChars(startIndex - idx);
+ assem.append('<a href="'+url.replace(/\"/g, '&quot;')+'">');
+ processNextChars(urlLength);
+ assem.append('</a>');
+ });
+ }
+ processNextChars(text.length - idx);
+
+ return _processSpaces(assem.toString());
+ } // end getLineHTML
+
+ var pieces = [];
+
+ // Need to deal with constraints imposed on HTML lists; can
+ // only gain one level of nesting at once, can't change type
+ // mid-list, etc.
+ // People might use weird indenting, e.g. skip a level,
+ // so we want to do something reasonable there. We also
+ // want to deal gracefully with blank lines.
+ var lists = []; // e.g. [[1,'bullet'], [3,'bullet'], ...]
+ for(var i=0;i<textLines.length;i++) {
+ var line = _analyzeLine(textLines[i], attribLines[i], apool);
+ var lineContent = getLineHTML(line.text, line.aline);
+
+ if (line.listLevel || lists.length > 0) {
+ // do list stuff
+ var whichList = -1; // index into lists or -1
+ if (line.listLevel) {
+ whichList = lists.length;
+ for(var j=lists.length-1;j>=0;j--) {
+ if (line.listLevel <= lists[j][0]) {
+ whichList = j;
+ }
+ }
+ }
+
+ if (whichList >= lists.length) {
+ lists.push([line.listLevel, line.listTypeName]);
+ pieces.push('<ul><li>', lineContent || '<br/>');
+ }
+ else if (whichList == -1) {
+ if (line.text) {
+ // non-blank line, end all lists
+ pieces.push(new Array(lists.length+1).join('</li></ul\n>'));
+ lists.length = 0;
+ pieces.push(lineContent, '<br\n/>');
+ }
+ else {
+ pieces.push('<br/><br\n/>');
+ }
+ }
+ else {
+ while (whichList < lists.length-1) {
+ pieces.push('</li></ul\n>');
+ lists.length--;
+ }
+ pieces.push('</li\n><li>', lineContent || '<br/>');
+ }
+ }
+ else {
+ pieces.push(lineContent, '<br\n/>');
+ }
+ }
+ pieces.push(new Array(lists.length+1).join('</li></ul\n>'));
+
+ return pieces.join('');
+}
+
+function _analyzeLine(text, aline, apool) {
+ var line = {};
+
+ // identify list
+ var lineMarker = 0;
+ line.listLevel = 0;
+ if (aline) {
+ var opIter = Changeset.opIterator(aline);
+ if (opIter.hasNext()) {
+ var listType = Changeset.opAttributeValue(opIter.next(), 'list', apool);
+ if (listType) {
+ lineMarker = 1;
+ listType = /([a-z]+)([12345678])/.exec(listType);
+ if (listType) {
+ line.listTypeName = listType[1];
+ line.listLevel = Number(listType[2]);
+ }
+ }
+ }
+ }
+ if (lineMarker) {
+ line.text = text.substring(1);
+ line.aline = Changeset.subattribution(aline, 1);
+ }
+ else {
+ line.text = text;
+ line.aline = aline;
+ }
+
+ return line;
+}
+
+function getPadHTMLDocument(pad, revNum, noDocType) {
+ var head = (noDocType?'':'<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" '+
+ '"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">\n')+
+ '<html xmlns="http://www.w3.org/1999/xhtml" lang="en" xml:lang="en">\n'+
+ (noDocType?'':
+ '<head>\n'+
+ '<meta http-equiv="Content-type" content="text/html; charset=utf-8" />\n'+
+ '<meta http-equiv="Content-Language" content="en-us" />\n'+
+ '<title>'+'/'+pad.getId()+'</title>\n'+
+ '<style type="text/css">h1,h2,h3,h4,h5,h6 { display: inline; }</style>\n' +
+ '</head>\n')+
+ '<body>';
+
+ var foot = '</body>\n</html>\n';
+
+ return head + getPadHTML(pad, revNum) + foot;
+}
+
+function _escapeHTML(s) {
+ var re = /[&<>]/g;
+ if (! re.MAP) {
+ // persisted across function calls!
+ re.MAP = {
+ '&': '&amp;',
+ '<': '&lt;',
+ '>': '&gt;',
+ };
+ }
+ return s.replace(re, function(c) { return re.MAP[c]; });
+}
+
+// copied from ACE
+function _processSpaces(s) {
+ var doesWrap = true;
+ if (s.indexOf("<") < 0 && ! doesWrap) {
+ // short-cut
+ return s.replace(/ /g, '&nbsp;');
+ }
+ var parts = [];
+ s.replace(/<[^>]*>?| |[^ <]+/g, function(m) { parts.push(m); });
+ if (doesWrap) {
+ var endOfLine = true;
+ var beforeSpace = false;
+ // last space in a run is normal, others are nbsp,
+ // end of line is nbsp
+ for(var i=parts.length-1;i>=0;i--) {
+ var p = parts[i];
+ if (p == " ") {
+ if (endOfLine || beforeSpace)
+ parts[i] = '&nbsp;';
+ endOfLine = false;
+ beforeSpace = true;
+ }
+ else if (p.charAt(0) != "<") {
+ endOfLine = false;
+ beforeSpace = false;
+ }
+ }
+ // beginning of line is nbsp
+ for(var i=0;i<parts.length;i++) {
+ var p = parts[i];
+ if (p == " ") {
+ parts[i] = '&nbsp;';
+ break;
+ }
+ else if (p.charAt(0) != "<") {
+ break;
+ }
+ }
+ }
+ else {
+ for(var i=0;i<parts.length;i++) {
+ var p = parts[i];
+ if (p == " ") {
+ parts[i] = '&nbsp;';
+ }
+ }
+ }
+ return parts.join('');
+}
+
+
+// copied from ACE
+var _REGEX_WORDCHAR = /[\u0030-\u0039\u0041-\u005A\u0061-\u007A\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF\u0100-\u1FFF\u3040-\u9FFF\uF900-\uFDFF\uFE70-\uFEFE\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFDC]/;
+var _REGEX_SPACE = /\s/;
+var _REGEX_URLCHAR = new RegExp('('+/[-:@a-zA-Z0-9_.,~%+\/\\?=&#;()$]/.source+'|'+_REGEX_WORDCHAR.source+')');
+var _REGEX_URL = new RegExp(/(?:(?:https?|s?ftp|ftps|file|smb|afp|nfs|(x-)?man|gopher|txmt):\/\/|mailto:)/.source+_REGEX_URLCHAR.source+'*(?![:.,;])'+_REGEX_URLCHAR.source, 'g');
+
+// returns null if no URLs, or [[startIndex1, url1], [startIndex2, url2], ...]
+function _findURLs(text) {
+ _REGEX_URL.lastIndex = 0;
+ var urls = null;
+ var execResult;
+ while ((execResult = _REGEX_URL.exec(text))) {
+ urls = (urls || []);
+ var startIndex = execResult.index;
+ var url = execResult[0];
+ urls.push([startIndex, url]);
+ }
+
+ return urls;
+}
diff --git a/trunk/etherpad/src/etherpad/pad/importhtml.js b/trunk/etherpad/src/etherpad/pad/importhtml.js
new file mode 100644
index 0000000..4a48c6f
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pad/importhtml.js
@@ -0,0 +1,230 @@
+/**
+ * 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.
+ */
+
+
+jimport("org.ccil.cowan.tagsoup.Parser");
+jimport("org.ccil.cowan.tagsoup.PYXWriter");
+jimport("java.io.StringReader");
+jimport("java.io.StringWriter");
+jimport("org.xml.sax.InputSource");
+
+import("etherpad.collab.ace.easysync2.{Changeset,AttribPool}");
+import("etherpad.collab.ace.contentcollector.makeContentCollector");
+import("etherpad.collab.collab_server");
+
+function setPadHTML(pad, html) {
+ var atext = htmlToAText(html, pad.pool());
+ collab_server.setPadAText(pad, atext);
+}
+
+function _html2pyx(html) {
+ var p = new Parser();
+ var w = new StringWriter();
+ var h = new PYXWriter(w);
+ p.setContentHandler(h);
+ var s = new InputSource();
+ s.setCharacterStream(new StringReader(html));
+ p.parse(s);
+ return w.toString().replace(/\r\n|\r|\n/g, '\n');
+}
+
+function _htmlBody2js(html) {
+ var pyx = _html2pyx(html);
+ var plines = pyx.split("\n");
+
+ function pyxUnescape(s) {
+ return s.replace(/\\t/g, '\t').replace(/\\/g, '\\');
+ }
+ var inAttrs = false;
+
+ var nodeStack = [];
+ var topNode = {};
+
+ var bodyNode = {name:"body"};
+
+ plines.forEach(function(pline) {
+ var t = pline.charAt(0);
+ var v = pline.substring(1);
+ if (inAttrs && t != 'A') {
+ inAttrs = false;
+ }
+ if (t == '?') { /* ignore */ }
+ else if (t == '(') {
+ var newNode = {name: v};
+ if (v.toLowerCase() == "body") {
+ bodyNode = newNode;
+ }
+ topNode.children = (topNode.children || []);
+ topNode.children.push(newNode);
+ nodeStack.push(topNode);
+ topNode = newNode;
+ inAttrs = true;
+ }
+ else if (t == 'A') {
+ var spaceIndex = v.indexOf(' ');
+ var key = v.substring(0, spaceIndex);
+ var value = pyxUnescape(v.substring(spaceIndex+1));
+ topNode.attrs = (topNode.attrs || {});
+ topNode.attrs['$'+key] = value;
+ }
+ else if (t == '-') {
+ if (v == "\\n") {
+ v = '\n';
+ }
+ else {
+ v = pyxUnescape(v);
+ }
+ if (v) {
+ topNode.children = (topNode.children || []);
+ if (topNode.children.length > 0 &&
+ ((typeof topNode.children[topNode.children.length-1]) == "string")) {
+ // coallesce
+ topNode.children.push(topNode.children.pop() + v);
+ }
+ else {
+ topNode.children.push(v);
+ }
+ }
+ }
+ else if (t == ')') {
+ topNode = nodeStack.pop();
+ }
+ });
+
+ return bodyNode;
+}
+
+function _trimDomNode(n) {
+ function isWhitespace(str) {
+ return /^\s*$/.test(str);
+ }
+ function trimBeginningOrEnd(n, endNotBeginning) {
+ var cc = n.children;
+ var backwards = endNotBeginning;
+ if (cc) {
+ var i = (backwards ? cc.length-1 : 0);
+ var done = false;
+ var hitActualText = false;
+ while (! done) {
+ if (! (backwards ? (i >= 0) : (i < cc.length-1))) {
+ done = true;
+ }
+ else {
+ var c = cc[i];
+ if ((typeof c) == "string") {
+ if (! isWhitespace(c)) {
+ // actual text
+ hitActualText = true;
+ break;
+ }
+ else {
+ // whitespace
+ cc[i] = '';
+ }
+ }
+ else {
+ // recurse
+ if (trimBeginningOrEnd(cc[i], endNotBeginning)) {
+ hitActualText = true;
+ break;
+ }
+ }
+ i += (backwards ? -1 : 1);
+ }
+ }
+ n.children = n.children.filter(function(x) { return !!x; });
+ return hitActualText;
+ }
+ return false;
+ }
+ trimBeginningOrEnd(n, false);
+ trimBeginningOrEnd(n, true);
+}
+
+function htmlToAText(html, apool) {
+ var body = _htmlBody2js(html);
+ _trimDomNode(body);
+
+ var dom = {
+ isNodeText: function(n) {
+ return (typeof n) == "string";
+ },
+ nodeTagName: function(n) {
+ return ((typeof n) == "object") && n.name;
+ },
+ nodeValue: function(n) {
+ return String(n);
+ },
+ nodeNumChildren: function(n) {
+ return (((typeof n) == "object") && n.children && n.children.length) || 0;
+ },
+ nodeChild: function(n, i) {
+ return (((typeof n) == "object") && n.children && n.children[i]) || null;
+ },
+ nodeProp: function(n, p) {
+ return (((typeof n) == "object") && n.attrs && n.attrs[p]) || null;
+ },
+ nodeAttr: function(n, a) {
+ return (((typeof n) == "object") && n.attrs && n.attrs[a]) || null;
+ },
+ optNodeInnerHTML: function(n) {
+ return null;
+ }
+ }
+
+ var cc = makeContentCollector(true, null, apool, dom);
+ for(var i=0; i<dom.nodeNumChildren(body); i++) {
+ var n = dom.nodeChild(body, i);
+ cc.collectContent(n);
+ }
+ cc.notifyNextNode(null);
+ var ccData = cc.finish();
+
+ var textLines = ccData.lines;
+ var attLines = ccData.lineAttribs;
+ for(var i=0;i<textLines.length;i++) {
+ var txt = textLines[i];
+ if (txt == " " || txt == "\xa0") {
+ // space or nbsp all alone on a line, remove
+ textLines[i] = "";
+ attLines[i] = "";
+ }
+ }
+
+ var text = textLines.join('\n')+'\n';
+ var attribs = _joinLineAttribs(attLines);
+ var atext = Changeset.makeAText(text, attribs);
+
+ return atext;
+}
+
+function _joinLineAttribs(lineAttribs) {
+ var assem = Changeset.smartOpAssembler();
+
+ var newline = Changeset.newOp('+');
+ newline.chars = 1;
+ newline.lines = 1;
+
+ lineAttribs.forEach(function(aline) {
+ var iter = Changeset.opIterator(aline);
+ while (iter.hasNext()) {
+ assem.append(iter.next());
+ }
+ assem.append(newline);
+ });
+
+ return assem.toString();
+} \ No newline at end of file
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);
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/pad/noprowatcher.js b/trunk/etherpad/src/etherpad/pad/noprowatcher.js
new file mode 100644
index 0000000..8eb2a92
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pad/noprowatcher.js
@@ -0,0 +1,110 @@
+/**
+ * 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.
+ */
+
+/*
+ * noprowatcher keeps track of when a pad has had no pro user
+ * in it for a certain period of time, after which all guests
+ * are booted.
+ */
+
+import("etherpad.pad.padutils");
+import("etherpad.collab.collab_server");
+import("etherpad.pad.padusers");
+import("etherpad.pad.pad_security");
+import("etherpad.pad.model");
+import("cache_utils.syncedWithCache");
+import("execution");
+import("etherpad.sessions");
+
+function onStartup() {
+ execution.initTaskThreadPool("noprowatcher", 1);
+}
+
+function getNumProUsers(pad) {
+ var n = 0;
+ collab_server.getConnectedUsers(pad).forEach(function(info) {
+ if (! padusers.isGuest(info.userId)) {
+ n++; // found a non-guest
+ }
+ });
+ return n;
+}
+
+var _EMPTY_TIME = 60000;
+
+function checkPad(padOrPadId) {
+ if ((typeof padOrPadId) == "string") {
+ return model.accessPadGlobal(padOrPadId, function(pad) {
+ return checkPad(pad);
+ });
+ }
+ var pad = padOrPadId;
+
+ if (! padutils.isProPad(pad)) {
+ return; // public pad
+ }
+
+ if (pad.getGuestPolicy() == 'allow') {
+ return; // public access
+ }
+
+ if (sessions.isAnEtherpadAdmin()) {
+ return;
+ }
+
+ var globalPadId = pad.getId();
+
+ var numConnections = collab_server.getNumConnections(pad);
+ var numProUsers = getNumProUsers(pad);
+ syncedWithCache('noprowatcher.no_pros_since', function(noProsSince) {
+ if (! numConnections) {
+ // no connections, clear state and we're done
+ delete noProsSince[globalPadId];
+ }
+ else if (numProUsers) {
+ // pro users in pad, so we're not in a span of time with
+ // no pro users
+ delete noProsSince[globalPadId];
+ }
+ else {
+ // no pro users in pad
+ var since = noProsSince[globalPadId];
+ if (! since) {
+ // no entry in cache, that means last time we checked
+ // there were still pro users, but now there aren't
+ noProsSince[globalPadId] = +new Date;
+ execution.scheduleTask("noprowatcher", "noProWatcherCheckPad",
+ _EMPTY_TIME+1000, [globalPadId]);
+ }
+ else {
+ // already in a span of time with no pro users
+ if ((+new Date) - since > _EMPTY_TIME) {
+ // _EMPTY_TIME milliseconds since we first noticed no pro users
+ collab_server.bootAllUsersFromPad(pad, "unauth");
+ pad_security.revokeAllPadAccess(globalPadId);
+ }
+ }
+ }
+ });
+}
+
+function onUserJoin(pad, userInfo) {
+ checkPad(pad);
+}
+
+function onUserLeave(pad, userInfo) {
+ checkPad(pad);
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/pad/pad_migrations.js b/trunk/etherpad/src/etherpad/pad/pad_migrations.js
new file mode 100644
index 0000000..e81cf63
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pad/pad_migrations.js
@@ -0,0 +1,206 @@
+/**
+ * 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("sqlbase.sqlobj");
+import("etherpad.pad.model");
+import("etherpad.pad.easysync2migration");
+import("sqlbase.sqlcommon");
+import("sqlbase.sqlobj");
+import("etherpad.log");
+import("etherpad.pne.pne_utils");
+jimport("java.util.concurrent.ConcurrentHashMap");
+jimport("java.lang.System");
+jimport("java.util.ArrayList");
+jimport("java.util.Collections");
+
+function onStartup() {
+ if (! appjet.cache.pad_migrations) {
+ appjet.cache.pad_migrations = {};
+ }
+
+ // this part can be removed when all pads are migrated on pad.spline.inf.fu-berlin.de
+ //if (! pne_utils.isPNE()) {
+ // System.out.println("Building cache for live migrations...");
+ // initLiveMigration();
+ //}
+}
+
+function initLiveMigration() {
+
+ if (! appjet.cache.pad_migrations) {
+ appjet.cache.pad_migrations = {};
+ }
+ appjet.cache.pad_migrations.doingAnyLiveMigrations = true;
+ appjet.cache.pad_migrations.doingBackgroundLiveMigrations = true;
+ appjet.cache.pad_migrations.padMap = new ConcurrentHashMap();
+
+ // presence of a pad in padMap indicates migration is needed
+ var padMap = _padMap();
+ var migrationsNeeded = sqlobj.selectMulti("PAD_SQLMETA", {version: 1});
+ migrationsNeeded.forEach(function(obj) {
+ padMap.put(String(obj.id), {from: obj.version});
+ });
+}
+
+function _padMap() {
+ return appjet.cache.pad_migrations.padMap;
+}
+
+function _doingItLive() {
+ return !! appjet.cache.pad_migrations.doingAnyLiveMigrations;
+}
+
+function checkPadStatus(padId) {
+ if (! _doingItLive()) {
+ return "ready";
+ }
+ var info = _padMap().get(padId);
+ if (! info) {
+ return "ready";
+ }
+ else if (info.migrating) {
+ return "migrating";
+ }
+ else {
+ return "oldversion";
+ }
+}
+
+function ensureMigrated(padId, async) {
+ if (! _doingItLive()) {
+ return false;
+ }
+
+ var info = _padMap().get(padId);
+ if (! info) {
+ // pad is up-to-date
+ return false;
+ }
+ else if (async && info.migrating) {
+ // pad is already being migrated, don't wait on the lock
+ return false;
+ }
+
+ return model.doWithPadLock(padId, function() {
+ // inside pad lock...
+ var info = _padMap().get(padId);
+ if (!info) {
+ return false;
+ }
+ // migrate from version 1 to version 2 in a transaction
+ var migrateSucceeded = false;
+ try {
+ info.migrating = true;
+ log.info("Migrating pad "+padId+" from version 1 to version 2...");
+
+ var success = false;
+ var whichTry = 1;
+ while ((! success) && whichTry <= 3) {
+ success = sqlcommon.inTransaction(function() {
+ try {
+ easysync2migration.migratePad(padId);
+ sqlobj.update("PAD_SQLMETA", {id: padId}, {version: 2});
+ return true;
+ }
+ catch (e if (e.toString().indexOf("try restarting transaction") >= 0)) {
+ whichTry++;
+ return false;
+ }
+ });
+ if (! success) {
+ java.lang.Thread.sleep(Math.floor(Math.random()*200));
+ }
+ }
+ if (! success) {
+ throw new Error("too many retries");
+ }
+
+ migrateSucceeded = true;
+ log.info("Migrated pad "+padId+".");
+ _padMap().remove(padId);
+ }
+ finally {
+ info.migrating = false;
+ if (! migrateSucceeded) {
+ log.info("Migration failed for pad "+padId+".");
+ throw new Error("Migration failed for pad "+padId+".");
+ }
+ }
+ return true;
+ });
+}
+
+function numUnmigratedPads() {
+ if (! _doingItLive()) {
+ return 0;
+ }
+
+ return _padMap().size();
+}
+
+////////// BACKGROUND MIGRATIONS
+
+function _logPadMigration(runnerId, padNumber, padTotal, timeMs, fourCharResult, padId) {
+ log.custom("pad_migrations", {
+ runnerId: runnerId,
+ padNumber: Math.round(padNumber+1),
+ padTotal: Math.round(padTotal),
+ timeMs: Math.round(timeMs),
+ fourCharResult: fourCharResult,
+ padId: padId});
+}
+
+function _getNeededMigrationsArrayList(filter) {
+ var L = new ArrayList(_padMap().keySet());
+ for(var i=L.size()-1; i>=0; i--) {
+ if (! filter(String(L.get(i)))) {
+ L.remove(i);
+ }
+ }
+ return L;
+}
+
+function runBackgroundMigration(residue, modulus, runnerId) {
+ var L = _getNeededMigrationsArrayList(function(padId) {
+ return (padId.charCodeAt(0) % modulus) == residue;
+ });
+ Collections.shuffle(L);
+
+ var totalPads = L.size();
+ for(var i=0;i<totalPads;i++) {
+ if (! appjet.cache.pad_migrations.doingBackgroundLiveMigrations) {
+ break;
+ }
+ var padId = L.get(i);
+ var result = "FAIL";
+ var t1 = System.currentTimeMillis();
+ try {
+ if (ensureMigrated(padId, true)) {
+ result = " OK "; // migrated successfully
+ }
+ else {
+ result = " -- "; // no migration needed after all
+ }
+ }
+ catch (e) {
+ // e just says "migration failed", but presumably
+ // inTransaction() printed a stack trace.
+ // result == "FAIL", do nothing.
+ }
+ var t2 = System.currentTimeMillis();
+ _logPadMigration(runnerId, i, totalPads, t2 - t1, result, padId);
+ }
+}
diff --git a/trunk/etherpad/src/etherpad/pad/pad_security.js b/trunk/etherpad/src/etherpad/pad/pad_security.js
new file mode 100644
index 0000000..0ff8783
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pad/pad_security.js
@@ -0,0 +1,237 @@
+/**
+ * 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("stringutils");
+import("cache_utils.syncedWithCache");
+
+import("etherpad.sessions.getSession");
+import("etherpad.sessions");
+
+import("etherpad.pad.model");
+import("etherpad.pad.padutils");
+import("etherpad.pad.padusers");
+import("etherpad.pro.domains");
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.pro_accounts.getSessionProAccount");
+import("etherpad.pro.pro_padmeta");
+import("etherpad.pro.pro_utils");
+import("etherpad.pro.pro_utils.isProDomainRequest");
+import("etherpad.pad.noprowatcher");
+
+//--------------------------------------------------------------------------------
+// granting session permanent access to pads (for the session)
+//--------------------------------------------------------------------------------
+
+function _grantSessionAccessTo(globalPadId) {
+ var userId = padusers.getUserId();
+ syncedWithCache("pad-auth."+globalPadId, function(c) {
+ c[userId] = true;
+ });
+}
+
+function _doesSessionHaveAccessTo(globalPadId) {
+ var userId = padusers.getUserId();
+ return syncedWithCache("pad-auth."+globalPadId, function(c) {
+ return c[userId];
+ });
+}
+
+function revokePadUserAccess(globalPadId, userId) {
+ syncedWithCache("pad-auth."+globalPadId, function(c) {
+ delete c[userId];
+ });
+}
+
+function revokeAllPadAccess(globalPadId) {
+ syncedWithCache("pad-auth."+globalPadId, function(c) {
+ for (k in c) {
+ delete c[k];
+ }
+ });
+}
+
+//--------------------------------------------------------------------------------
+// knock/answer
+//--------------------------------------------------------------------------------
+
+function clearKnockStatus(userId, globalPadId) {
+ syncedWithCache("pad-guest-knocks."+globalPadId, function(c) {
+ delete c[userId];
+ });
+}
+
+// called by collab_server when accountholders approve or deny
+function answerKnock(userId, globalPadId, status) {
+ // status is either "approved" or "denied"
+ syncedWithCache("pad-guest-knocks."+globalPadId, function(c) {
+ // If two account-holders respond to the knock, keep the first one.
+ if (!c[userId]) {
+ c[userId] = status;
+ }
+ });
+}
+
+// returns "approved", "denied", or undefined
+function getKnockAnswer(userId, globalPadId) {
+ return syncedWithCache("pad-guest-knocks."+globalPadId, function(c) {
+ return c[userId];
+ });
+}
+
+//--------------------------------------------------------------------------------
+// main entrypoint called for every accessPad()
+//--------------------------------------------------------------------------------
+
+var _insideCheckAccessControl = false;
+
+function checkAccessControl(globalPadId, rwMode) {
+ if (!request.isDefined) {
+ return; // TODO: is this the right thing to do here?
+ // Empirical evidence indicates request.isDefined during comet requests,
+ // but not during tasks, which is the behavior we want.
+ }
+
+ if (_insideCheckAccessControl) {
+ // checkAccessControl is always allowed to access pads itself
+ return;
+ }
+ if (isProDomainRequest() && (request.path == "/ep/account/guest-knock")) {
+ return;
+ }
+ if (!isProDomainRequest() && (request.path == "/ep/admin/padinspector")) {
+ return;
+ }
+ if (isProDomainRequest() && (request.path == "/ep/padlist/all-pads.zip")) {
+ return;
+ }
+ try {
+ _insideCheckAccessControl = true;
+
+ if (!padutils.isProPadId(globalPadId)) {
+ // no access control on non-pro pads yet.
+ return;
+ }
+
+ if (sessions.isAnEtherpadAdmin()) {
+ return;
+ }
+ if (_doesSessionHaveAccessTo(globalPadId)) {
+ return;
+ }
+ _checkDomainSecurity(globalPadId);
+ _checkGuestSecurity(globalPadId);
+ _checkPasswordSecurity(globalPadId);
+
+ // remember that this user has access
+ _grantSessionAccessTo(globalPadId);
+ }
+ finally {
+ // this always runs, even on error or stop
+ _insideCheckAccessControl = false;
+ }
+}
+
+function _checkDomainSecurity(globalPadId) {
+ var padDomainId = padutils.getDomainId(globalPadId);
+ if (!padDomainId) {
+ return; // global pad
+ }
+ if (pro_utils.isProDomainRequest()) {
+ var requestDomainId = domains.getRequestDomainId();
+ if (requestDomainId != padDomainId) {
+ throw Error("Request cross-domain pad access not allowed.");
+ }
+ }
+}
+
+function _checkGuestSecurity(globalPadId) {
+ if (!getSession().guestPadAccess) {
+ getSession().guestPadAccess = {};
+ }
+
+ var padDomainId = padutils.getDomainId(globalPadId);
+ var isAccountHolder = pro_accounts.isAccountSignedIn();
+ if (isAccountHolder) {
+ if (getSessionProAccount().domainId != padDomainId) {
+ throw Error("Account cross-domain pad access not allowed.");
+ }
+ return; // OK
+ }
+
+ // Not an account holder ==> Guest
+
+ // returns either "allow", "ask", or "deny"
+ var guestPolicy = model.accessPadGlobal(globalPadId, function(p) {
+ if (!p.exists()) {
+ return "deny";
+ } else {
+ return p.getGuestPolicy();
+ }
+ });
+
+ var numProUsers = model.accessPadGlobal(globalPadId, function(pad) {
+ return noprowatcher.getNumProUsers(pad);
+ });
+
+ if (guestPolicy == "allow") {
+ return;
+ }
+ if (guestPolicy == "deny") {
+ pro_accounts.requireAccount("Guests are not allowed to join that pad. Please sign in.");
+ }
+ if (guestPolicy == "ask") {
+ if (numProUsers < 1) {
+ pro_accounts.requireAccount("This pad's security policy does not allow guests to join unless an account-holder is connected to the pad.");
+ }
+ var userId = padusers.getUserId();
+
+ // one of {"approved", "denied", undefined}
+ var knockAnswer = getKnockAnswer(userId, globalPadId);
+ if (knockAnswer == "approved") {
+ return;
+ } else {
+ var localPadId = padutils.globalToLocalId(globalPadId);
+ response.redirect('/ep/account/guest-sign-in?padId='+encodeURIComponent(localPadId));
+ }
+ }
+}
+
+function _checkPasswordSecurity(globalPadId) {
+ if (!getSession().padPasswordAuth) {
+ getSession().padPasswordAuth = {};
+ }
+ if (getSession().padPasswordAuth[globalPadId] == true) {
+ return;
+ }
+ var domainId = padutils.getDomainId(globalPadId);
+ var localPadId = globalPadId.split("$")[1];
+
+ if (stringutils.startsWith(request.path, "/ep/admin/recover-padtext")) {
+ return;
+ }
+
+ var p = pro_padmeta.accessProPad(globalPadId, function(propad) {
+ if (propad.exists()) {
+ return propad.getPassword();
+ } else {
+ return null;
+ }
+ });
+ if (p) {
+ response.redirect('/ep/pad/auth/'+localPadId+'?cont='+encodeURIComponent(request.url));
+ }
+}
+
diff --git a/trunk/etherpad/src/etherpad/pad/padevents.js b/trunk/etherpad/src/etherpad/pad/padevents.js
new file mode 100644
index 0000000..52b303c
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pad/padevents.js
@@ -0,0 +1,170 @@
+/**
+ * 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.
+ */
+
+// src/etherpad/events.js
+
+import("etherpad.licensing");
+import("etherpad.log");
+import("etherpad.pad.chatarchive");
+import("etherpad.pad.activepads");
+import("etherpad.pad.padutils");
+import("etherpad.sessions");
+import("etherpad.pro.pro_padmeta");
+import("etherpad.pro.pro_pad_db");
+import("etherpad.pad.padusers");
+import("etherpad.pad.pad_security");
+import("etherpad.pad.noprowatcher");
+import("etherpad.collab.collab_server");
+jimport("java.lang.System.out.println");
+
+function onNewPad(pad) {
+ log.custom("padevents", {
+ type: "newpad",
+ padId: pad.getId()
+ });
+ pro_pad_db.onCreatePad(pad);
+}
+
+function onDestroyPad(pad) {
+ log.custom("padevents", {
+ type: "destroypad",
+ padId: pad.getId()
+ });
+ pro_pad_db.onDestroyPad(pad);
+}
+
+function onUserJoin(pad, userInfo) {
+ log.callCatchingExceptions(function() {
+
+ var name = userInfo.name || "unnamed";
+ log.custom("padevents", {
+ type: "userjoin",
+ padId: pad.getId(),
+ username: name,
+ ip: userInfo.ip,
+ userId: userInfo.userId
+ });
+ activepads.touch(pad.getId());
+ licensing.onUserJoin(userInfo);
+ log.onUserJoin(userInfo.userId);
+ padusers.notifyActive();
+ noprowatcher.onUserJoin(pad, userInfo);
+
+ });
+}
+
+function onUserLeave(pad, userInfo) {
+ log.callCatchingExceptions(function() {
+
+ var name = userInfo.name || "unnamed";
+ log.custom("padevents", {
+ type: "userleave",
+ padId: pad.getId(),
+ username: name,
+ ip: userInfo.ip,
+ userId: userInfo.userId
+ });
+ activepads.touch(pad.getId());
+ licensing.onUserLeave(userInfo);
+ noprowatcher.onUserLeave(pad, userInfo);
+
+ });
+}
+
+function onUserInfoChange(pad, userInfo) {
+ log.callCatchingExceptions(function() {
+
+ activepads.touch(pad.getId());
+
+ });
+}
+
+function onClientMessage(pad, senderUserInfo, msg) {
+ var padId = pad.getId();
+ activepads.touch(padId);
+
+ if (msg.type == "chat") {
+
+ chatarchive.onChatMessage(pad, senderUserInfo, msg);
+
+ var name = "unnamed";
+ if (senderUserInfo.name) {
+ name = senderUserInfo.name;
+ }
+
+ log.custom("chat", {
+ padId: padId,
+ userId: senderUserInfo.userId,
+ username: name,
+ text: msg.lineText
+ });
+ }
+ else if (msg.type == "padtitle") {
+ if (msg.title && padutils.isProPadId(pad.getId())) {
+ pro_padmeta.accessProPad(pad.getId(), function(propad) {
+ propad.setTitle(String(msg.title).substring(0, 80));
+ });
+ }
+ }
+ else if (msg.type == "padpassword") {
+ if (padutils.isProPadId(pad.getId())) {
+ pro_padmeta.accessProPad(pad.getId(), function(propad) {
+ propad.setPassword(msg.password || null);
+ });
+ }
+ }
+ else if (msg.type == "padoptions") {
+ // options object is a full set of options or just
+ // some options to change
+ var opts = msg.options;
+ var padOptions = pad.getPadOptionsObj();
+ if (opts.view) {
+ if (! padOptions.view) {
+ padOptions.view = {};
+ }
+ for(var k in opts.view) {
+ padOptions.view[k] = opts.view[k];
+ }
+ }
+ if (opts.guestPolicy) {
+ padOptions.guestPolicy = opts.guestPolicy;
+ if (opts.guestPolicy == 'deny') {
+ // boot guests!
+ collab_server.bootUsersFromPad(pad, "unauth", function(userInfo) {
+ return padusers.isGuest(userInfo.userId); }).forEach(function(userInfo) {
+ pad_security.revokePadUserAccess(padId, userInfo.userId); });
+ }
+ }
+ }
+ else if (msg.type == "guestanswer") {
+ if ((! msg.authId) || padusers.isGuest(msg.authId)) {
+ // not a pro user, forbid.
+ }
+ else {
+ pad_security.answerKnock(msg.guestId, padId, msg.answer);
+ }
+ }
+}
+
+function onEditPad(pad, authorId) {
+ log.callCatchingExceptions(function() {
+
+ pro_pad_db.onEditPad(pad, authorId);
+
+ });
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/pad/padusers.js b/trunk/etherpad/src/etherpad/pad/padusers.js
new file mode 100644
index 0000000..f04f0eb
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pad/padusers.js
@@ -0,0 +1,397 @@
+/**
+ * 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("sqlbase.sqlobj");
+import("fastJSON");
+import("stringutils");
+import("jsutils.eachProperty");
+import("sync");
+import("etherpad.sessions");
+import("etherpad.pro.pro_utils");
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.pro_accounts.getSessionProAccount");
+import("etherpad.pro.domains");
+import("stringutils.randomHash");
+
+var _table = cachedSqlTable('pad_guests', 'pad_guests',
+ ['id', 'privateKey', 'userId'], processGuestRow);
+function processGuestRow(row) {
+ row.data = fastJSON.parse(row.data);
+}
+
+function notifySignIn() {
+ /*if (pro_accounts.isAccountSignedIn()) {
+ var proId = getUserId();
+ var guestId = _getGuestUserId();
+
+ var guestUser = _getGuestByKey('userId', guestId);
+ if (guestUser) {
+ var mods = {};
+ mods.data = guestUser.data;
+ // associate guest with proId
+ mods.data.replacement = proId;
+ // de-associate ET cookie with guest, otherwise
+ // the ET cookie would provide a semi-permanent way
+ // to effect changes under the pro account's name!
+ mods.privateKey = "replaced$"+_randomString(20);
+ _updateGuest('userId', guestId, mods);
+ }
+ }*/
+}
+
+function notifyActive() {
+ if (isGuest(getUserId())) {
+ _updateGuest('userId', getUserId(), {});
+ }
+}
+
+function notifyUserData(userData) {
+ var uid = getUserId();
+ if (isGuest(uid)) {
+ var data = _getGuestByKey('userId', uid).data;
+ if (userData.name) {
+ data.name = userData.name;
+ }
+ _updateGuest('userId', uid, {data: data});
+ }
+}
+
+function getUserId() {
+ if (pro_accounts.isAccountSignedIn()) {
+ return "p."+(getSessionProAccount().id);
+ }
+ else {
+ return getGuestUserId();
+ }
+}
+
+function getUserName() {
+ var uid = getUserId();
+ if (isGuest(uid)) {
+ var fromSession = sessions.getSession().guestDisplayName;
+ return fromSession || _getGuestByKey('userId', uid).data.name || null;
+ }
+ else {
+ return getSessionProAccount().fullName;
+ }
+}
+
+function getAccountIdForProAuthor(uid) {
+ if (uid.indexOf("p.") == 0) {
+ return Number(uid.substring(2));
+ }
+ else {
+ return -1;
+ }
+}
+
+function getNameForUserId(uid) {
+ if (isGuest(uid)) {
+ return _getGuestByKey('userId', uid).data.name || null;
+ }
+ else {
+ var accountNum = getAccountIdForProAuthor(uid);
+ if (accountNum < 0) {
+ return null;
+ }
+ else {
+ return pro_accounts.getAccountById(accountNum).fullName;
+ }
+ }
+}
+
+function isGuest(userId) {
+ return /^g/.test(userId);
+}
+
+function getGuestUserId() {
+ // cache the userId in the requestCache,
+ // for efficiency and consistency
+ var c = appjet.requestCache;
+ if (c.padGuestUserId === undefined) {
+ c.padGuestUserId = _computeGuestUserId();
+ }
+ return c.padGuestUserId;
+}
+
+function _getGuestTrackerId() {
+ // get ET cookie
+ var tid = sessions.getTrackingId();
+ if (tid == '-') {
+ // no tracking cookie? not a normal request?
+ return null;
+ }
+
+ // get domain ID
+ var domain = "-";
+ if (pro_utils.isProDomainRequest()) {
+ // e.g. "3"
+ domain = String(domains.getRequestDomainId());
+ }
+
+ // combine them
+ return domain+"$"+tid;
+}
+
+function _insertGuest(obj) {
+ // only requires 'userId' in obj
+
+ obj.createdDate = new Date;
+ obj.lastActiveDate = new Date;
+ if (! obj.data) {
+ obj.data = {};
+ }
+ if ((typeof obj.data) == "object") {
+ obj.data = fastJSON.stringify(obj.data);
+ }
+ if (! obj.privateKey) {
+ // private keys must be unique
+ obj.privateKey = "notracker$"+_randomString(20);
+ }
+
+ return _table.insert(obj);
+}
+
+function _getGuestByKey(keyColumn, value) {
+ return _table.getByKey(keyColumn, value);
+}
+
+function _updateGuest(keyColumn, value, obj) {
+ var obj2 = {};
+ eachProperty(obj, function(k,v) {
+ if (k == "data" && (typeof v) == "object") {
+ obj2.data = fastJSON.stringify(v);
+ }
+ else {
+ obj2[k] = v;
+ }
+ });
+
+ obj2.lastActiveDate = new Date;
+
+ _table.updateByKey(keyColumn, value, obj2);
+}
+
+function _newGuestUserId() {
+ return "g."+_randomString(16);
+}
+
+function _computeGuestUserId() {
+ // always returns some userId
+
+ var privateKey = _getGuestTrackerId();
+
+ if (! privateKey) {
+ // no tracking cookie, pretend there is one
+ privateKey = randomHash(16);
+ }
+
+ var userFromTracker = _table.getByKey('privateKey', privateKey);
+ if (userFromTracker) {
+ // we know this guy
+ return userFromTracker.userId;
+ }
+
+ // generate userId
+ var userId = _newGuestUserId();
+ var guest = {userId:userId, privateKey:privateKey};
+ var data = {};
+ guest.data = data;
+
+ var prefsCookieData = _getPrefsCookieData();
+ if (prefsCookieData) {
+ // found an old prefs cookie with an old userId
+ var oldUserId = prefsCookieData.userId;
+ // take the name and preferences
+ if ('name' in prefsCookieData) {
+ data.name = prefsCookieData.name;
+ }
+ /*['fullWidth','viewZoom'].forEach(function(pref) {
+ if (pref in prefsCookieData) {
+ data.prefs[pref] = prefsCookieData[pref];
+ }
+ });*/
+ }
+
+ _insertGuest(guest);
+ return userId;
+}
+
+function _getPrefsCookieData() {
+ // get userId from old prefs cookie if possible,
+ // but don't allow modern usernames
+
+ var prefsCookie = request.cookies['prefs'];
+ if (! prefsCookie) {
+ return null;
+ }
+ if (prefsCookie.charAt(0) != '%') {
+ return null;
+ }
+ try {
+ var cookieData = fastJSON.parse(unescape(prefsCookie));
+ // require one to three digits followed by dot at beginning of userId
+ if (/^[0-9]{1,3}\./.test(String(cookieData.userId))) {
+ return cookieData;
+ }
+ }
+ catch (e) {
+ return null;
+ }
+
+ return null;
+}
+
+function _randomString(len) {
+ // use only numbers and lowercase letters
+ var pieces = [];
+ for(var i=0;i<len;i++) {
+ pieces.push(Math.floor(Math.random()*36).toString(36).slice(-1));
+ }
+ return pieces.join('');
+}
+
+
+function cachedSqlTable(cacheName, tableName, keyColumns, processFetched) {
+ // Keeps a cache of sqlobj rows for the case where
+ // you want to select one row at a time by a single column
+ // at a time, taken from some set of key columns.
+ // The cache maps (keyColumn, value), e.g. ("id", 4) or
+ // ("secondaryKey", "foo123"), to an object, and each
+ // object is either present for all keyColumns
+ // (e.g. "id", "secondaryKey") or none.
+
+ if ((typeof keyColumns) == "string") {
+ keyColumns = [keyColumns];
+ }
+ processFetched = processFetched || (function(o) {});
+
+ function getCache() {
+ // this function is normally fast, only slow when cache
+ // needs to be created for the first time
+ var cache = appjet.cache[cacheName];
+ if (cache) {
+ return cache;
+ }
+ else {
+ // initialize in a synchronized block (double-checked locking);
+ // uses same lock as cache_utils.syncedWithCache would use.
+ sync.doWithStringLock("cache/"+cacheName, function() {
+ if (! appjet.cache[cacheName]) {
+ // values expire after 10 minutes
+ appjet.cache[cacheName] =
+ new net.appjet.common.util.ExpiringMapping(10*60*1000);
+ }
+ });
+ return appjet.cache[cacheName];
+ }
+ }
+
+ function cacheKey(keyColumn, value) {
+ // e.g. "id$4"
+ return keyColumn+"$"+String(value);
+ }
+
+ function getFromCache(keyColumn, value) {
+ return getCache().get(cacheKey(keyColumn, value));
+ }
+ function putInCache(obj) {
+ var cache = getCache();
+ // put in cache, keyed on all keyColumns we care about
+ keyColumns.forEach(function(keyColumn) {
+ cache.put(cacheKey(keyColumn, obj[keyColumn]), obj);
+ });
+ }
+ function touchInCache(obj) {
+ var cache = getCache();
+ keyColumns.forEach(function(keyColumn) {
+ cache.touch(cacheKey(keyColumn, obj[keyColumn]));
+ });
+ }
+ function removeObjFromCache(obj) {
+ var cache = getCache();
+ keyColumns.forEach(function(keyColumn) {
+ cache.remove(cacheKey(keyColumn, obj[keyColumn]));
+ });
+ }
+ function removeFromCache(keyColumn, value) {
+ var cached = getFromCache(keyColumn, value);
+ if (cached) {
+ removeObjFromCache(cached);
+ }
+ }
+
+ var self = {
+ clearCache: function() {
+ getCache().clear();
+ },
+ getByKey: function(keyColumn, value) {
+ // get cached object, if any
+ var cached = getFromCache(keyColumn, value);
+ if (! cached) {
+ // nothing in cache for this query, fetch from SQL
+ var keyToValue = {};
+ keyToValue[keyColumn] = value;
+ var fetched = sqlobj.selectSingle(tableName, keyToValue);
+ if (fetched) {
+ processFetched(fetched);
+ // fetched something, stick it in the cache
+ putInCache(fetched);
+ }
+ return fetched;
+ }
+ else {
+ // touch cached object and return
+ touchInCache(cached);
+ return cached;
+ }
+ },
+ updateByKey: function(keyColumn, value, obj) {
+ var keyToValue = {};
+ keyToValue[keyColumn] = value;
+ sqlobj.updateSingle(tableName, keyToValue, obj);
+ // remove old object from caches but
+ // don't put obj in cache, because it
+ // is likely a partial object
+ removeFromCache(keyColumn, value);
+ },
+ insert: function(obj) {
+ var returnVal = sqlobj.insert(tableName, obj);
+ // remove old object from caches but
+ // don't put obj in the cache; it doesn't
+ // have all values, e.g. for auto-generated ids
+ removeObjFromCache(obj);
+ return returnVal;
+ },
+ deleteByKey: function(keyColumn, value) {
+ var keyToValue = {};
+ keyToValue[keyColumn] = value;
+ sqlobj.deleteRows(tableName, keyToValue);
+ removeFromCache(keyColumn, value);
+ }
+ };
+ return self;
+}
+
+function _getClientIp() {
+ return (request.isDefined && request.clientIp) || '';
+}
+
+function getUserIdCreatedDate(userId) {
+ var record = sqlobj.selectSingle('pad_cookie_userids', {id: userId});
+ if (! record) { return; } // hm. weird case.
+ return record.createdDate;
+}
diff --git a/trunk/etherpad/src/etherpad/pad/padutils.js b/trunk/etherpad/src/etherpad/pad/padutils.js
new file mode 100644
index 0000000..3ffe70c
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pad/padutils.js
@@ -0,0 +1,154 @@
+/**
+ * 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("stringutils");
+
+import("etherpad.control.pro.account_control");
+
+import("etherpad.pro.pro_utils");
+import("etherpad.pro.domains");
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.pro_padmeta");
+import("etherpad.pad.model");
+import("etherpad.sessions.getSession");
+
+jimport("java.lang.System.out.println");
+
+
+function setCurrentPad(p) {
+ appjet.context.attributes().update("currentPadId", p);
+}
+
+function clearCurrentPad() {
+ appjet.context.attributes()['$minus$eq']("currentPadId");
+}
+
+function getCurrentPad() {
+ var padOpt = appjet.context.attributes().get("currentPadId");
+ if (padOpt.isEmpty()) return null;
+ return padOpt.get();
+}
+
+function _parseCookie(text) {
+ try {
+ var cookieData = fastJSON.parse(unescape(text));
+ return cookieData;
+ }
+ catch (e) {
+ return null;
+ }
+}
+
+function getPrefsCookieData() {
+ var prefsCookie = request.cookies['prefs'];
+ if (!prefsCookie) {
+ return null;
+ }
+
+ return _parseCookie(prefsCookie);
+}
+
+function getPrefsCookieUserId() {
+ var cookieData = getPrefsCookieData();
+ if (! cookieData) {
+ return null;
+ }
+ return cookieData.userId || null;
+}
+
+/**
+ * Not valid to call this function outisde a HTTP request.
+ */
+function accessPadLocal(localPadId, fn, rwMode) {
+ if (!request.isDefined) {
+ throw Error("accessPadLocal() cannot run outside an HTTP request.");
+ }
+ var globalPadId = getGlobalPadId(localPadId);
+ var fnwrap = function(pad) {
+ pad.getLocalId = function() {
+ return getLocalPadId(pad);
+ };
+ return fn(pad);
+ }
+ return model.accessPadGlobal(globalPadId, fnwrap, rwMode);
+}
+
+/**
+ * Not valid to call this function outisde a HTTP request.
+ */
+function getGlobalPadId(localPadId) {
+ if (!request.isDefined) {
+ throw Error("getGlobalPadId() cannot run outside an HTTP request.");
+ }
+ if (pro_utils.isProDomainRequest()) {
+ return makeGlobalId(domains.getRequestDomainId(), localPadId);
+ } else {
+ // pad.spline.inf.fu-berlin.de pads
+ return localPadId;
+ }
+}
+
+function makeGlobalId(domainId, localPadId) {
+ return [domainId, localPadId].map(String).join('$');
+}
+
+function globalToLocalId(globalId) {
+ var parts = globalId.split('$');
+ if (parts.length == 1) {
+ return parts[0];
+ } else {
+ return parts[1];
+ }
+}
+
+function getLocalPadId(pad) {
+ var globalId = pad.getId();
+ return globalToLocalId(globalId);
+}
+
+function isProPadId(globalPadId) {
+ return (globalPadId.indexOf("$") > 0);
+}
+
+function isProPad(pad) {
+ return isProPadId(pad.getId());
+}
+
+function getDomainId(globalPadId) {
+ var parts = globalPadId.split("$");
+ if (parts.length < 2) {
+ return null;
+ } else {
+ return Number(parts[0]);
+ }
+}
+
+function makeValidLocalPadId(str) {
+ return str.replace(/[^a-zA-Z0-9\-]/g, '-');
+}
+
+function getProDisplayTitle(localPadId, title) {
+ if (title) {
+ return title;
+ }
+ if (stringutils.isNumeric(localPadId)) {
+ return ("Untitled "+localPadId);
+ } else {
+ return (localPadId);
+ }
+}
+
diff --git a/trunk/etherpad/src/etherpad/pad/revisions.js b/trunk/etherpad/src/etherpad/pad/revisions.js
new file mode 100644
index 0000000..c7c84e8
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pad/revisions.js
@@ -0,0 +1,103 @@
+/**
+ * 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("jsutils.cmp");
+import("stringutils");
+
+import("etherpad.utils.*");
+
+jimport("java.lang.System.out.println");
+
+/* revisionList is an array of revisionInfo structures.
+ *
+ * Each revisionInfo structure looks like:
+ *
+ * {
+ * timestamp: a number unix timestamp
+ * label: string
+ * savedBy: string of author name
+ * savedById: string id of the author
+ * revNum: revision number in the edit history
+ * id: the view id of the (formerly the id of the StorableObject)
+ * }
+ */
+
+/* returns array */
+function _getRevisionsArray(pad) {
+ var dataRoot = pad.getDataRoot();
+ if (!dataRoot.savedRevisions) {
+ dataRoot.savedRevisions = [];
+ }
+ dataRoot.savedRevisions.sort(function(a,b) {
+ return cmp(b.timestamp, a.timestamp);
+ });
+ return dataRoot.savedRevisions;
+}
+
+function _getPadRevisionById(pad, savedRevId) {
+ var revs = _getRevisionsArray(pad);
+ var rev;
+ for(var i=0;i<revs.length;i++) {
+ if (revs[i].id == savedRevId) {
+ rev = revs[i];
+ break;
+ }
+ }
+ return rev || null;
+}
+
+/*----------------------------------------------------------------*/
+/* public functions */
+/*----------------------------------------------------------------*/
+
+function getRevisionList(pad) {
+ return _getRevisionsArray(pad);
+}
+
+function saveNewRevision(pad, savedBy, savedById, revisionNumber, optIP, optTimestamp, optId) {
+ var revArray = _getRevisionsArray(pad);
+ var rev = {
+ timestamp: (optTimestamp || (+(new Date))),
+ label: null,
+ savedBy: savedBy,
+ savedById: savedById,
+ revNum: revisionNumber,
+ ip: (optIP || request.clientAddr),
+ id: (optId || stringutils.randomString(10)) // *probably* unique
+ };
+ revArray.push(rev);
+ rev.label = "Revision "+revArray.length;
+ return rev;
+}
+
+function setLabel(pad, savedRevId, userId, newLabel) {
+ var rev = _getPadRevisionById(pad, savedRevId);
+ if (!rev) {
+ throw new Error("revision does not exist: "+savedRevId);
+ }
+ /*if (rev.savedById != userId) {
+ throw new Error("cannot label someone else's revision.");
+ }
+ if (((+new Date) - rev.timestamp) > (24*60*60*1000)) {
+ throw new Error("revision is too old to label: "+savedRevId);
+ }*/
+ rev.label = newLabel;
+}
+
+function getStoredRevision(pad, savedRevId) {
+ return _getPadRevisionById(pad, savedRevId);
+}
+
diff --git a/trunk/etherpad/src/etherpad/pne/pne_utils.js b/trunk/etherpad/src/etherpad/pne/pne_utils.js
new file mode 100644
index 0000000..74e0598
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pne/pne_utils.js
@@ -0,0 +1,187 @@
+/**
+ * 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("funhtml.*");
+import("stringutils.md5");
+import("sqlbase.persistent_vars");
+
+import("etherpad.licensing");
+
+jimport("java.lang.System.out.println");
+jimport("java.lang.System");
+
+
+function isPNE() {
+ if (appjet.cache.fakePNE || appjet.config['etherpad.fakePNE']) {
+ return true;
+ }
+ if (getVersionString()) {
+ return true;
+ }
+ return false;
+}
+
+/**
+ * Versioning scheme: we basically just use the apache scheme of MAJOR.MINOR.PATCH:
+ *
+ * Versions are denoted using a standard triplet of integers: MAJOR.MINOR.PATCH. The
+ * basic intent is that MAJOR versions are incompatible, large-scale upgrades of the API.
+ * MINOR versions retain source and binary compatibility with older minor versions, and
+ * changes in the PATCH level are perfectly compatible, forwards and backwards.
+ */
+
+function getVersionString() {
+ return appjet.config['etherpad.pneVersion'];
+}
+
+function parseVersionString(x) {
+ var parts = x.split('.');
+ return {
+ major: Number(parts[0] || 0),
+ minor: Number(parts[1] || 0),
+ patch: Number(parts[2] || 0)
+ };
+}
+
+/* returns {major: int, minor: int, patch: int} */
+function getVersionNumbers() {
+ return parseVersionString(getVersionString());
+}
+
+function checkDbVersionUpgrade() {
+ var dbVersionString = persistent_vars.get("db_pne_version");
+ var runningVersionString = getVersionString();
+
+ if (!dbVersionString) {
+ println("Upgrading to Private Network Edition, version: "+runningVersionString);
+ return;
+ }
+
+ var dbVersion = parseVersionString(dbVersionString);
+ var runningVersion = getVersionNumbers();
+ var force = (appjet.config['etherpad.forceDbUpgrade'] == "true");
+
+ if (!force && (runningVersion.major != dbVersion.major)) {
+ println("Error: you are attempting to update an EtherPad["+dbVersionString+
+ "] database to version ["+runningVersionString+"]. This is not possible.");
+ println("Exiting...");
+ System.exit(1);
+ }
+ if (!force && (runningVersion.minor < dbVersion.minor)) {
+ println("Error: your etherpad database is at a newer version ["+dbVersionString+"] than"+
+ " the current running etherpad ["+runningVersionString+"]. Please upgrade to the "+
+ " latest version.");
+ println("Exiting...");
+ System.exit(1);
+ }
+ if (!force && (runningVersion.minor > (dbVersion.minor + 1))) {
+ println("\n\nWARNING: you are attempting to upgrade from version "+dbVersionString+" to version "+
+ runningVersionString+". It is recommended that you upgrade one minor version at a time."+
+ " (The \"minor\" version number is the second number separated by dots. For example,"+
+ " if you are running version 1.2, it is recommended that you upgrade to 1.3 and then 1.4 "+
+ " instead of going directly from 1.2 to 1.4.");
+ println("\n\nIf you really want to do this, you can force us to attempt the upgrade with "+
+ " the --etherpad.forceDbUpgrade=true flag.");
+ println("\n\nExiting...");
+ System.exit(1);
+ }
+ if (runningVersion.minor > dbVersion.minor) {
+ println("Upgrading database to version "+runningVersionString);
+ }
+}
+
+function saveDbVersion() {
+ var dbVersionString = persistent_vars.get("db_pne_version");
+ if (getVersionString() != dbVersionString) {
+ persistent_vars.put('db_pne_version', getVersionString());
+ println("Upgraded Private Network Edition version to ["+getVersionString()+"]");
+ }
+}
+
+// These are a list of some of the config vars documented in the PNE manual. They are here
+// temporarily, until we move them to the PNE config UI.
+
+var _eepneAllowedConfigVars = [
+ 'configFile',
+ 'etherpad.useMySQL',
+ 'etherpad.SQL_JDBC_DRIVER',
+ 'etherpad.SQL_JDBC_URL',
+ 'etherpad.SQL_PASSWORD',
+ 'etherpad.SQL_USERNAME',
+ 'etherpad.adminPass',
+ 'etherpad.licenseKey',
+ 'listen',
+ 'listenSecure',
+ 'smtpPass',
+ 'smtpServer',
+ 'smtpUser',
+ 'sslKeyPassword',
+ 'sslKeyStore'
+];
+
+function isServerLicensed() {
+ var licenseInfo = licensing.getLicense();
+ if (!licenseInfo) {
+ return false;
+ }
+ if (licensing.isVersionTooOld()) {
+ return false;
+ }
+ if (licensing.isExpired()) {
+ return false;
+ }
+ return true;
+}
+
+function enableTrackingAgain() {
+ delete appjet.cache.noMorePneTracking;
+}
+
+function pneTrackerHtml() {
+ if (!isPNE()) {
+ return "";
+ }
+ if (appjet.cache.noMorePneTracking) {
+ return "";
+ }
+
+ var div = DIV({style: "height: 1px; width: 1px; overflow: hidden;"});
+
+ var licenseInfo = licensing.getLicense();
+ var key = null;
+ if (licenseInfo) {
+ key = md5(licenseInfo.key).substr(0, 16);
+ }
+
+ function trackData(name, value) {
+ var imgurl = "http://pad.spline.inf.fu-berlin.de/ep/tpne/t?";
+ if (key) {
+ imgurl += ("k="+key+"&");
+ }
+ imgurl += (encodeURIComponent(name) + "=" + encodeURIComponent(value));
+ div.push(IMG({src: imgurl}));
+ }
+
+ trackData("ping", "1");
+ trackData("dbdriver", appjet.config['etherpad.SQL_JDBC_DRIVER']);
+ trackData("request.url", request.url);
+
+ appjet.cache.noMorePneTracking = true;
+ return div;
+}
+
+
+
diff --git a/trunk/etherpad/src/etherpad/pro/domains.js b/trunk/etherpad/src/etherpad/pro/domains.js
new file mode 100644
index 0000000..e56a408
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pro/domains.js
@@ -0,0 +1,141 @@
+/**
+ * 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.
+ */
+
+// Library for managing subDomains
+
+import("jsutils.*");
+import("sqlbase.sqlobj");
+
+import("etherpad.pro.pro_utils");
+import("etherpad.pne.pne_utils");
+import("etherpad.licensing");
+
+jimport("java.lang.System.out.println");
+
+// reserved domains
+var reservedSubdomains = {
+ 'alpha': 1,
+ 'beta': 1,
+ 'blog': 1,
+ 'comet': 1,
+ 'diagnostic': 1,
+ 'forums': 1,
+ 'forumsdev': 1,
+ 'staging': 1,
+ 'web': 1,
+ 'www': 1
+};
+
+function _getCache() {
+ if (!appjet.cache.pro_domains) {
+ appjet.cache.pro_domains = {
+ records: {id: {}, subDomain: {}}
+ };
+ }
+ return appjet.cache.pro_domains;
+}
+
+function doesSubdomainExist(subDomain) {
+ if (reservedSubdomains[subDomain]) {
+ return true;
+ }
+ if (getDomainRecordFromSubdomain(subDomain) != null) {
+ return true;
+ }
+ return false;
+}
+
+function _updateCache(locator) {
+ var record = sqlobj.selectSingle('pro_domains', locator);
+ var recordCache = _getCache().records;
+
+ if (record) {
+ // update both maps: recordCache.id, recordCache.subDomain
+ keys(recordCache).forEach(function(key) {
+ recordCache[key][record[key]] = record;
+ });
+ } else {
+ // write false for whatever hit with this locator
+ keys(locator).forEach(function(key) {
+ recordCache[key][locator[key]] = false;
+ });
+ }
+}
+
+function getDomainRecord(domainId) {
+ if (!(domainId in _getCache().records.id)) {
+ _updateCache({id: domainId});
+ }
+ var record = _getCache().records.id[domainId];
+ return (record ? record : null);
+}
+
+function getDomainRecordFromSubdomain(subDomain) {
+ subDomain = subDomain.toLowerCase();
+ if (!(subDomain in _getCache().records.subDomain)) {
+ _updateCache({subDomain: subDomain});
+ }
+ var record = _getCache().records.subDomain[subDomain];
+ return (record ? record : null);
+}
+
+/** returns id of newly created subDomain */
+function createNewSubdomain(subDomain, orgName) {
+ var id = sqlobj.insert('pro_domains', {subDomain: subDomain, orgName: orgName});
+ _updateCache({id: id});
+ return id;
+}
+
+function getPrivateNetworkDomainId() {
+ var r = getDomainRecordFromSubdomain('<<private-network>>');
+ if (!r) {
+ throw Error("<<private-network>> does not exist in the domains table!");
+ }
+ return r.id;
+}
+
+/** returns null if not found. */
+function getRequestDomainRecord() {
+ if (pne_utils.isPNE()) {
+ var r = getDomainRecord(getPrivateNetworkDomainId());
+ if (appjet.cache.fakePNE) {
+ r.orgName = "fake";
+ } else {
+ var licenseInfo = licensing.getLicense();
+ if (licenseInfo) {
+ r.orgName = licenseInfo.organizationName;
+ } else {
+ r.orgName = "Private Network Edition TRIAL";
+ }
+ }
+ return r;
+ } else {
+ var subDomain = pro_utils.getProRequestSubdomain();
+ var r = getDomainRecordFromSubdomain(subDomain);
+ return r;
+ }
+}
+
+/* throws exception if not pro domain request. */
+function getRequestDomainId() {
+ var r = getRequestDomainRecord();
+ if (!r) {
+ throw Error("Error getting request domain id.");
+ }
+ return r.id;
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/pro/pro_account_auto_signin.js b/trunk/etherpad/src/etherpad/pro/pro_account_auto_signin.js
new file mode 100644
index 0000000..ebcd227
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pro/pro_account_auto_signin.js
@@ -0,0 +1,101 @@
+/**
+ * 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("sqlbase.sqlobj");
+import("stringutils");
+
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.pro_accounts.getSessionProAccount");
+
+jimport("java.lang.System.out.println");
+
+var _COOKIE_NAME = "PUAS";
+
+function dmesg(m) {
+ if (false) {
+ println("[pro-account-auto-sign-in]: "+m);
+ }
+}
+
+function checkAutoSignin() {
+ dmesg("checking auto sign-in...");
+ if (pro_accounts.isAccountSignedIn()) {
+ dmesg("account already signed in...");
+ // don't mess with already signed-in account
+ return;
+ }
+ var cookie = request.cookies[_COOKIE_NAME];
+ if (!cookie) {
+ dmesg("no auto-sign-in cookie found...");
+ return;
+ }
+ var record = sqlobj.selectSingle('pro_accounts_auto_signin', {cookie: cookie}, {});
+ if (!record) {
+ return;
+ }
+
+ var now = +(new Date);
+ if (+record.expires < now) {
+ sqlobj.deleteRows('pro_accounts_auto_signin', {id: record.id});
+ response.deleteCookie(_COOKIE_NAME);
+ dmesg("deleted expired record...");
+ return;
+ }
+ // do auto-signin (bypasses normal security)
+ dmesg("Doing auto sign in...");
+ var account = pro_accounts.getAccountById(record.accountId);
+ pro_accounts.signInSession(account);
+ response.redirect('/ep/account/sign-in?cont='+encodeURIComponent(request.url));
+}
+
+function setAutoSigninCookie(rememberMe) {
+ if (!pro_accounts.isAccountSignedIn()) {
+ return; // only call this function after account is already signed in.
+ }
+
+ var accountId = getSessionProAccount().id;
+ // delete any existing auto-signins for this account.
+ sqlobj.deleteRows('pro_accounts_auto_signin', {accountId: accountId});
+
+ // set this insecure cookie just to indicate that account is auto-sign-in-able
+ response.setCookie({
+ name: "ASIE",
+ value: (rememberMe ? "T" : "F"),
+ path: "/",
+ domain: request.domain,
+ expires: new Date(32503708800000), // year 3000
+ });
+
+ if (!rememberMe) {
+ return;
+ }
+
+ var cookie = stringutils.randomHash(16);
+ var now = +(new Date);
+ var expires = new Date(now + 1000*60*60*24*30); // 30 days
+ //var expires = new Date(now + 1000 * 60 * 5); // 2 minutes
+
+ sqlobj.insert('pro_accounts_auto_signin', {cookie: cookie, accountId: accountId, expires: expires});
+ response.setCookie({
+ name: _COOKIE_NAME,
+ value: cookie,
+ path: "/ep/account/",
+ domain: request.domain,
+ expires: new Date(32503708800000), // year 3000
+ secure: true
+ });
+}
+
diff --git a/trunk/etherpad/src/etherpad/pro/pro_accounts.js b/trunk/etherpad/src/etherpad/pro/pro_accounts.js
new file mode 100644
index 0000000..2024970
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pro/pro_accounts.js
@@ -0,0 +1,496 @@
+/**
+ * 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.
+ */
+
+// library for pro accounts
+
+import("funhtml.*");
+import("sqlbase.sqlobj");
+import("sqlbase.sqlcommon.inTransaction");
+import("email.sendEmail");
+import("cache_utils.syncedWithCache");
+import("stringutils.*");
+
+import("etherpad.globals.*");
+import("etherpad.sessions");
+import("etherpad.sessions.getSession");
+import("etherpad.utils.*");
+import("etherpad.pro.domains");
+import("etherpad.control.pro.account_control");
+import("etherpad.pro.pro_utils");
+import("etherpad.pro.pro_quotas");
+import("etherpad.pad.padusers");
+import("etherpad.log");
+import("etherpad.billing.team_billing");
+
+jimport("org.mindrot.BCrypt");
+jimport("java.lang.System.out.println");
+
+function _dmesg(m) {
+ if (!isProduction()) {
+ println(m);
+ }
+}
+
+function _computePasswordHash(p) {
+ var pwh;
+ pwh = BCrypt.hashpw(p, BCrypt.gensalt(10));
+ return pwh;
+}
+
+function _withCache(name, fn) {
+ return syncedWithCache('pro_accounts.'+name, fn);
+}
+
+//----------------------------------------------------------------
+// validation
+//----------------------------------------------------------------
+
+function validateEmail(email) {
+ if (!email) { return "Email is required."; }
+ if (!isValidEmail(email)) { return "\""+email+"\" does not look like a valid email address."; }
+ return null;
+}
+
+function validateFullName(name) {
+ if (!name) { return "Full name is required."; }
+ if (name.length < 2) { return "Full name must be at least 2 characters."; }
+ return null;
+}
+
+function validatePassword(p) {
+ if (!p) { return "Password is required."; }
+ if (p.length < 6) { return "Passwords must be at least 6 characters."; }
+ return null;
+}
+
+function validateEmailDomainPair(email, domainId) {
+ // TODO: make sure the same email address cannot exist more than once within
+ // the same domainid.
+}
+
+/* if domainId is null, then use domainId of current request. */
+function createNewAccount(domainId, fullName, email, password, isAdmin) {
+ if (!domainId) {
+ domainId = domains.getRequestDomainId();
+ }
+ email = trim(email);
+ isAdmin = !!isAdmin; // convert to bool
+
+ // validation
+ var e;
+ e = validateEmail(email); if (e) { throw Error(e); }
+ e = validateFullName(fullName); if (e) { throw Error(e); }
+ e = validatePassword(password); if (e) { throw Error(e); }
+
+ // xss normalization
+ fullName = toHTML(fullName);
+
+ // make sure account does not already exist on this domain.
+ var ret = inTransaction(function() {
+ var existingAccount = getAccountByEmail(email, domainId);
+ if (existingAccount) {
+ throw Error("There is already an account with that email address.");
+ }
+ // No existing account. Proceed.
+ var now = new Date();
+ var account = {
+ domainId: domainId,
+ fullName: fullName,
+ email: email,
+ passwordHash: _computePasswordHash(password),
+ createdDate: now,
+ isAdmin: isAdmin
+ };
+ return sqlobj.insert('pro_accounts', account);
+ });
+
+ _withCache('does-domain-admin-exist', function(cache) {
+ delete cache[domainId];
+ });
+
+ pro_quotas.updateAccountUsageCount(domainId);
+ updateCachedActiveCount(domainId);
+
+ if (ret) {
+ log.custom('pro-accounts',
+ {type: "account-created",
+ accountId: ret,
+ domainId: domainId,
+ name: fullName,
+ email: email,
+ admin: isAdmin});
+ }
+
+ return ret;
+}
+
+function _checkAccess(account) {
+ if (sessions.isAnEtherpadAdmin()) {
+ return;
+ }
+ if (account.domainId != domains.getRequestDomainId()) {
+ throw Error("access denied");
+ }
+}
+
+function setPassword(account, newPass) {
+ _checkAccess(account);
+ var passHash = _computePasswordHash(newPass);
+ sqlobj.update('pro_accounts', {id: account.id}, {passwordHash: passHash});
+ markDirtySessionAccount(account.id);
+}
+
+function setTempPassword(account, tempPass) {
+ _checkAccess(account);
+ var tempPassHash = _computePasswordHash(tempPass);
+ sqlobj.update('pro_accounts', {id: account.id}, {tempPassHash: tempPassHash});
+ markDirtySessionAccount(account.id);
+}
+
+function setEmail(account, newEmail) {
+ _checkAccess(account);
+ sqlobj.update('pro_accounts', {id: account.id}, {email: newEmail});
+ markDirtySessionAccount(account.id);
+}
+
+function setFullName(account, newName) {
+ _checkAccess(account);
+ sqlobj.update('pro_accounts', {id: account.id}, {fullName: newName});
+ markDirtySessionAccount(account.id);
+}
+
+function setIsAdmin(account, newVal) {
+ _checkAccess(account);
+ sqlobj.update('pro_accounts', {id: account.id}, {isAdmin: newVal});
+ markDirtySessionAccount(account.id);
+}
+
+function setDeleted(account) {
+ _checkAccess(account);
+ if (!isNumeric(account.id)) {
+ throw new Error("Invalid account id: "+account.id);
+ }
+ sqlobj.update('pro_accounts', {id: account.id}, {isDeleted: true});
+ markDirtySessionAccount(account.id);
+ pro_quotas.updateAccountUsageCount(account.domainId);
+ updateCachedActiveCount(account.domainId);
+
+ log.custom('pro-accounts',
+ {type: "account-deleted",
+ accountId: account.id,
+ domainId: account.domainId,
+ name: account.fullName,
+ email: account.email,
+ admin: account.isAdmin,
+ createdDate: account.createdDate.getTime()});
+}
+
+//----------------------------------------------------------------
+
+function doesAdminExist() {
+ var domainId = domains.getRequestDomainId();
+ return _withCache('does-domain-admin-exist', function(cache) {
+ if (cache[domainId] === undefined) {
+ _dmesg("cache miss for doesAdminExist (domainId="+domainId+")");
+ var admins = sqlobj.selectMulti('pro_accounts', {domainId: domainId, isAdmin: true}, {});
+ cache[domainId] = (admins.length > 0);
+ }
+ return cache[domainId]
+ });
+}
+
+function getSessionProAccount() {
+ if (sessions.isAnEtherpadAdmin()) {
+ return getEtherpadAdminAccount();
+ }
+ var account = getSession().proAccount;
+ if (!account) {
+ return null;
+ }
+ if (account.isDeleted) {
+ delete getSession().proAccount;
+ return null;
+ }
+ return account;
+}
+
+function isAccountSignedIn() {
+ if (getSessionProAccount()) {
+ return true;
+ } else {
+ return false;
+ }
+}
+
+function isAdminSignedIn() {
+ return isAccountSignedIn() && getSessionProAccount().isAdmin;
+}
+
+function requireAccount(message) {
+ if ((request.path == "/ep/account/sign-in") ||
+ (request.path == "/ep/account/sign-out") ||
+ (request.path == "/ep/account/guest-sign-in") ||
+ (request.path == "/ep/account/guest-knock") ||
+ (request.path == "/ep/account/forgot-password")) {
+ return;
+ }
+
+ function checkSessionAccount() {
+ if (!getSessionProAccount()) {
+ if (message) {
+ account_control.setSigninNotice(message);
+ }
+ response.redirect('/ep/account/sign-in?cont='+encodeURIComponent(request.url));
+ }
+ }
+
+ checkSessionAccount();
+
+ if (getSessionProAccount().domainId != domains.getRequestDomainId()) {
+ // This should theoretically never happen unless the account is spoofing cookies / trying to
+ // hack the site.
+ pro_utils.renderFramedMessage("Permission denied.");
+ response.stop();
+ }
+ // update dirty session account if necessary
+ _withCache('dirty-session-accounts', function(cache) {
+ var uid = getSessionProAccount().id;
+ if (cache[uid]) {
+ reloadSessionAccountData(uid);
+ cache[uid] = false;
+ }
+ });
+
+ // need to check again in case dirty update caused account to be marked
+ // deleted.
+ checkSessionAccount();
+}
+
+function requireAdminAccount() {
+ requireAccount();
+ if (!getSessionProAccount().isAdmin) {
+ pro_utils.renderFramedMessage("Permission denied.");
+ response.stop();
+ }
+}
+
+/* returns undefined on success, error string otherise. */
+function authenticateSignIn(email, password) {
+ var accountRecord = getAccountByEmail(email, null);
+ if (!accountRecord) {
+ return "Account not found: "+email;
+ }
+
+ if (BCrypt.checkpw(password, accountRecord.passwordHash) != true) {
+ return "Incorrect password. Please try again.";
+ }
+
+ signInSession(accountRecord);
+
+ return undefined; // success
+}
+
+function signOut() {
+ delete getSession().proAccount;
+}
+
+function authenticateTempSignIn(uid, tempPass) {
+ var emsg = "That password reset link that is no longer valid.";
+
+ var account = getAccountById(uid);
+ if (!account) {
+ return emsg+" (Account not found.)";
+ }
+ if (account.domainId != domains.getRequestDomainId()) {
+ return emsg+" (Wrong domain.)";
+ }
+ if (!account.tempPassHash) {
+ return emsg+" (Expired.)";
+ }
+ if (BCrypt.checkpw(tempPass, account.tempPassHash) != true) {
+ return emsg+" (Bad temp pass.)";
+ }
+
+ signInSession(account);
+
+ getSession().accountMessage = "Please choose a new password";
+ getSession().changePass = true;
+
+ response.redirect("/ep/account/");
+}
+
+function signInSession(account) {
+ account.lastLoginDate = new Date();
+ account.tempPassHash = null;
+ sqlobj.updateSingle('pro_accounts', {id: account.id}, account);
+ reloadSessionAccountData(account.id);
+ padusers.notifySignIn();
+}
+
+function listAllDomainAccounts(domainId) {
+ if (domainId === undefined) {
+ domainId = domains.getRequestDomainId();
+ }
+ var records = sqlobj.selectMulti('pro_accounts',
+ {domainId: domainId, isDeleted: false}, {});
+ return records;
+}
+
+function listAllDomainAdmins(domainId) {
+ if (domainId === undefined) {
+ domainId = domains.getRequestDomainId();
+ }
+ var records = sqlobj.selectMulti('pro_accounts',
+ {domainId: domainId, isDeleted: false, isAdmin: true},
+ {});
+ return records;
+}
+
+function getActiveCount(domainId) {
+ var records = sqlobj.selectMulti('pro_accounts',
+ {domainId: domainId, isDeleted: false}, {});
+ return records.length;
+}
+
+/* getAccountById works for deleted and non-deleted accounts.
+ * The assumption is that cases whewre you look up an account by ID, you
+ * want the account info even if the account has been deleted. For
+ * example, when asking who created a pad.
+ */
+function getAccountById(accountId) {
+ var r = sqlobj.selectSingle('pro_accounts', {id: accountId});
+ if (r) {
+ return r;
+ } else {
+ return undefined;
+ }
+}
+
+/* getting an account by email only returns the account if it is
+ * not deleted. The assumption is that when you look up an account by
+ * email address, you only want active accounts. Furthermore, some
+ * deleted accounts may match a given email, but only one non-deleted
+ * account should ever match a single (email,domainId) pair.
+ */
+function getAccountByEmail(email, domainId) {
+ if (!domainId) {
+ domainId = domains.getRequestDomainId();
+ }
+ var r = sqlobj.selectSingle('pro_accounts', {domainId: domainId, email: email, isDeleted: false});
+ if (r) {
+ return r;
+ } else {
+ return undefined;
+ }
+}
+
+function getFullNameById(id) {
+ if (!id) {
+ return null;
+ }
+
+ return _withCache('names-by-id', function(cache) {
+ if (cache[id] === undefined) {
+ _dmesg("cache miss for getFullNameById (accountId="+id+")");
+ var r = getAccountById(id);
+ if (r) {
+ cache[id] = r.fullName;
+ } else {
+ cache[id] = false;
+ }
+ }
+ if (cache[id]) {
+ return cache[id];
+ } else {
+ return null;
+ }
+ });
+}
+
+function getTempSigninUrl(account, tempPass) {
+ return [
+ 'https://', httpsHost(pro_utils.getFullProHost()), '/ep/account/sign-in?',
+ 'uid=', account.id, '&tp=', tempPass
+ ].join('');
+}
+
+
+// TODO: this session account object storage / dirty cache is a
+// ridiculous hack. What we should really do is have a caching/access
+// layer for accounts similar to accessPad() and accessProPadMeta(), and
+// have that abstraction take care of caching and marking accounts as
+// dirty. This can be incorporated into getSessionProAccount(), and we
+// should actually refactor that into accessSessionProAccount().
+
+/* will force session data for this account to be updated next time that
+ * account requests a page. */
+function markDirtySessionAccount(uid) {
+ var domainId = domains.getRequestDomainId();
+
+ _withCache('dirty-session-accounts', function(cache) {
+ cache[uid] = true;
+ });
+ _withCache('names-by-id', function(cache) {
+ delete cache[uid];
+ });
+ _withCache('does-domain-admin-exist', function(cache) {
+ delete cache[domainId];
+ });
+}
+
+function reloadSessionAccountData(uid) {
+ if (!uid) {
+ uid = getSessionProAccount().id;
+ }
+ getSession().proAccount = getAccountById(uid);
+}
+
+function getAllAccountsWithEmail(email) {
+ var accountRecords = sqlobj.selectMulti('pro_accounts', {email: email, isDeleted: false}, {});
+ return accountRecords;
+}
+
+function getEtherpadAdminAccount() {
+ return {
+ id: 0,
+ isAdmin: true,
+ fullName: "ETHERPAD ADMIN",
+ email: "support@pad.spline.inf.fu-berlin.de",
+ domainId: domains.getRequestDomainId(),
+ isDeleted: false
+ };
+}
+
+function getCachedActiveCount(domainId) {
+ return _withCache('user-counts.'+domainId, function(c) {
+ if (!c.count) {
+ c.count = getActiveCount(domainId);
+ }
+ return c.count;
+ });
+}
+
+function updateCachedActiveCount(domainId) {
+ _withCache('user-counts.'+domainId, function(c) {
+ c.count = getActiveCount(domainId);
+ });
+}
+
+
+
+
+
+
diff --git a/trunk/etherpad/src/etherpad/pro/pro_config.js b/trunk/etherpad/src/etherpad/pro/pro_config.js
new file mode 100644
index 0000000..d2d119f
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pro/pro_config.js
@@ -0,0 +1,92 @@
+/**
+ * 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.sqlobj");
+import("cache_utils.syncedWithCache");
+
+import("etherpad.globals.*");
+import("etherpad.utils.*");
+import("etherpad.pro.domains");
+import("etherpad.pro.pro_utils");
+
+function _guessSiteName() {
+ var x = request.host.split('.')[0];
+ x = (x.charAt(0).toUpperCase() + x.slice(1));
+ return x;
+}
+
+function _getDefaultConfig() {
+ return {
+ siteName: _guessSiteName(),
+ alwaysHttps: false,
+ defaultPadText: renderTemplateAsString("misc/pad_default.ejs")
+ };
+}
+
+// must be fast! gets called per request, on every request.
+function getConfig() {
+ if (!pro_utils.isProDomainRequest()) {
+ return null;
+ }
+
+ if (!appjet.cache.pro_config) {
+ appjet.cache.pro_config = {};
+ }
+
+ var domainId = domains.getRequestDomainId();
+ if (!appjet.cache.pro_config[domainId]) {
+ reloadConfig();
+ }
+
+ return appjet.cache.pro_config[domainId];
+}
+
+function reloadConfig() {
+ var domainId = domains.getRequestDomainId();
+ var config = _getDefaultConfig();
+ var records = sqlobj.selectMulti('pro_config', {domainId: domainId}, {});
+
+ records.forEach(function(r) {
+ var name = r.name;
+ var val = fastJSON.parse(r.jsonVal).x;
+ config[name] = val;
+ });
+
+ if (!appjet.cache.pro_config) {
+ appjet.cache.pro_config = {};
+ }
+
+ appjet.cache.pro_config[domainId] = config;
+}
+
+function setConfigVal(name, val) {
+ var domainId = domains.getRequestDomainId();
+ var jsonVal = fastJSON.stringify({x: val});
+
+ var r = sqlobj.selectSingle('pro_config', {domainId: domainId, name: name});
+ if (!r) {
+ sqlobj.insert('pro_config',
+ {domainId: domainId, name: name, jsonVal: jsonVal});
+ } else {
+ sqlobj.update('pro_config',
+ {name: name, domainId: domainId},
+ {jsonVal: jsonVal});
+ }
+
+ reloadConfig();
+}
+
diff --git a/trunk/etherpad/src/etherpad/pro/pro_pad_db.js b/trunk/etherpad/src/etherpad/pro/pro_pad_db.js
new file mode 100644
index 0000000..dbb412c
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pro/pro_pad_db.js
@@ -0,0 +1,232 @@
+/**
+ * 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.sqlobj");
+import("cache_utils.syncedWithCache");
+import("stringutils");
+
+import("etherpad.pad.padutils");
+import("etherpad.collab.collab_server");
+
+import("etherpad.pro.pro_pad_editors");
+import("etherpad.pro.domains");
+import("etherpad.pro.pro_accounts.getSessionProAccount");
+
+jimport("java.lang.System.out.println");
+
+
+// TODO: actually implement the cache part
+
+// NOTE: must return a deep-CLONE of the actual record, because caller
+// may proceed to mutate the returned record.
+
+function _makeRecord(r) {
+ if (!r) {
+ return null;
+ }
+ r.proAttrs = {};
+ if (r.proAttrsJson) {
+ r.proAttrs = fastJSON.parse(r.proAttrsJson);
+ }
+ if (!r.proAttrs.editors) {
+ r.proAttrs.editors = [];
+ }
+ r.proAttrs.editors.sort();
+ return r;
+}
+
+function getSingleRecord(domainId, localPadId) {
+ // TODO: make clone
+ // TODO: use cache
+ var record = sqlobj.selectSingle('pro_padmeta', {domainId: domainId, localPadId: localPadId});
+ return _makeRecord(record);
+}
+
+function update(padRecord) {
+ // TODO: use cache
+
+ padRecord.proAttrsJson = fastJSON.stringify(padRecord.proAttrs);
+ delete padRecord.proAttrs;
+
+ sqlobj.update('pro_padmeta', {id: padRecord.id}, padRecord);
+}
+
+
+//--------------------------------------------------------------------------------
+// create/edit/destory events
+//--------------------------------------------------------------------------------
+
+function onCreatePad(pad) {
+ if (!padutils.isProPad(pad)) { return; }
+
+ var data = {
+ domainId: padutils.getDomainId(pad.getId()),
+ localPadId: padutils.getLocalPadId(pad),
+ createdDate: new Date(),
+ };
+
+ if (getSessionProAccount()) {
+ data.creatorId = getSessionProAccount().id;
+ }
+
+ sqlobj.insert('pro_padmeta', data);
+}
+
+// Not a normal part of the UI. This is only called from admin interface,
+// and thus should actually destroy all record of the pad.
+function onDestroyPad(pad) {
+ if (!padutils.isProPad(pad)) { return; }
+
+ sqlobj.deleteRows('pro_padmeta', {
+ domainId: padutils.getDomainId(pad.getId()),
+ localPadId: padutils.getLocalPadId(pad)
+ });
+}
+
+// Called within the context of a comet post.
+function onEditPad(pad, padAuthorId) {
+ if (!padutils.isProPad(pad)) { return; }
+
+ var editorId = undefined;
+ if (getSessionProAccount()) {
+ editorId = getSessionProAccount().id;
+ }
+
+ if (!(editorId && (editorId > 0))) {
+ return; // etherpad admins
+ }
+
+ pro_pad_editors.notifyEdit(
+ padutils.getDomainId(pad.getId()),
+ padutils.getLocalPadId(pad),
+ editorId,
+ new Date()
+ );
+}
+
+//--------------------------------------------------------------------------------
+// accessing the pad list.
+//--------------------------------------------------------------------------------
+
+function _makeRecordList(lis) {
+ lis.forEach(function(r) {
+ r = _makeRecord(r);
+ });
+ return lis;
+}
+
+function listMyPads() {
+ var domainId = domains.getRequestDomainId();
+ var accountId = getSessionProAccount().id;
+
+ var padlist = sqlobj.selectMulti('pro_padmeta', {domainId: domainId, creatorId: accountId, isDeleted: false, isArchived: false});
+ return _makeRecordList(padlist);
+}
+
+function listAllDomainPads() {
+ var domainId = domains.getRequestDomainId();
+ var padlist = sqlobj.selectMulti('pro_padmeta', {domainId: domainId, isDeleted: false, isArchived: false});
+ return _makeRecordList(padlist);
+}
+
+function listArchivedPads() {
+ var domainId = domains.getRequestDomainId();
+ var padlist = sqlobj.selectMulti('pro_padmeta', {domainId: domainId, isDeleted: false, isArchived: true});
+ return _makeRecordList(padlist);
+}
+
+function listPadsByEditor(editorId) {
+ editorId = Number(editorId);
+ var domainId = domains.getRequestDomainId();
+ var padlist = sqlobj.selectMulti('pro_padmeta', {domainId: domainId, isDeleted: false, isArchived: false});
+ padlist = _makeRecordList(padlist);
+ padlist = padlist.filter(function(p) {
+ // NOTE: could replace with binary search to speed things up,
+ // since we know that editors array is sorted.
+ return (p.proAttrs.editors.indexOf(editorId) >= 0);
+ });
+ return padlist;
+}
+
+function listLiveDomainPads() {
+ var thisDomainId = domains.getRequestDomainId();
+ var allLivePadIds = collab_server.getAllPadsWithConnections();
+ var livePadMap = {};
+
+ allLivePadIds.forEach(function(globalId) {
+ if (padutils.isProPadId(globalId)) {
+ var domainId = padutils.getDomainId(globalId);
+ var localId = padutils.globalToLocalId(globalId);
+ if (domainId == thisDomainId) {
+ livePadMap[localId] = true;
+ }
+ }
+ });
+
+ var padList = listAllDomainPads();
+ padList = padList.filter(function(p) {
+ return (!!livePadMap[p.localPadId]);
+ });
+
+ return padList;
+}
+
+//--------------------------------------------------------------------------------
+// misc utils
+//--------------------------------------------------------------------------------
+
+
+function _withCache(name, fn) {
+ return syncedWithCache('pro-padmeta.'+name, fn);
+}
+
+function _withDomainCache(domainId, name, fn) {
+ return _withCache(name+"."+domainId, fn);
+}
+
+
+
+// returns the next pad ID to use for a newly-created pad on this domain.
+function getNextPadId() {
+ var domainId = domains.getRequestDomainId();
+ return _withDomainCache(domainId, 'padcounters', function(c) {
+ var ret;
+ if (c.x === undefined) {
+ c.x = _getLargestNumericPadId(domainId) + 1;
+ }
+ while (sqlobj.selectSingle('pro_padmeta', {domainId: domainId, localPadId: String(c.x)})) {
+ c.x++;
+ }
+ ret = c.x;
+ c.x++;
+ return ret;
+ });
+}
+
+function _getLargestNumericPadId(domainId) {
+ var max = 0;
+ var allPads = listAllDomainPads();
+ allPads.forEach(function(p) {
+ if (stringutils.isNumeric(p.localPadId)) {
+ max = Math.max(max, Number(p.localPadId));
+ }
+ });
+ return max;
+}
+
+
+
diff --git a/trunk/etherpad/src/etherpad/pro/pro_pad_editors.js b/trunk/etherpad/src/etherpad/pro/pro_pad_editors.js
new file mode 100644
index 0000000..a90f05b
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pro/pro_pad_editors.js
@@ -0,0 +1,104 @@
+/**
+ * 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("execution");
+import("jsutils.*");
+import("cache_utils.syncedWithCache");
+
+import("etherpad.pad.padutils");
+import("etherpad.pro.pro_padmeta");
+import("etherpad.log");
+
+var _DOMAIN_EDIT_WRITE_INTERVAL = 2000; // 2 seconds
+
+function _withCache(name, fn) {
+ return syncedWithCache('pro-padmeta.'+name, fn);
+}
+
+function _withDomainCache(domainId, name, fn) {
+ return _withCache(name+"."+domainId, fn);
+}
+
+
+function onStartup() {
+ execution.initTaskThreadPool("pro-padmeta-edits", 1);
+}
+
+function onShutdown() {
+ var success = execution.shutdownAndWaitOnTaskThreadPool("pro-padmeta-edits", 4000);
+ if (!success) {
+ log.warn("Warning: pro.padmeta failed to flush pad edits on shutdown.");
+ }
+}
+
+function notifyEdit(domainId, localPadId, editorId, editTime) {
+ if (!editorId) {
+ // guest editors
+ return;
+ }
+ _withDomainCache(domainId, "edits", function(c) {
+ if (!c[localPadId]) {
+ c[localPadId] = {
+ lastEditorId: editorId,
+ lastEditTime: editTime,
+ recentEditors: []
+ };
+ }
+ var info = c[localPadId];
+ if (info.recentEditors.indexOf(editorId) < 0) {
+ info.recentEditors.push(editorId);
+ }
+ });
+ _flushPadEditsEventually(domainId);
+}
+
+
+function _flushPadEditsEventually(domainId) {
+ // Make sure there is a recurring edit-writer for this domain
+ _withDomainCache(domainId, "recurring-edit-writers", function(c) {
+ if (!c[domainId]) {
+ flushEditsNow(domainId);
+ c[domainId] = true;
+ }
+ });
+}
+
+function flushEditsNow(domainId) {
+ if (!appjet.cache.shutdownHandlerIsRunning) {
+ execution.scheduleTask("pro-padmeta-edits", "proPadmetaFlushEdits",
+ _DOMAIN_EDIT_WRITE_INTERVAL, [domainId]);
+ }
+
+ _withDomainCache(domainId, "edits", function(edits) {
+ var padIdList = keys(edits);
+ padIdList.forEach(function(localPadId) {
+ _writePadEditsToDbNow(domainId, localPadId, edits[localPadId]);
+ delete edits[localPadId];
+ });
+ });
+}
+
+function _writePadEditsToDbNow(domainId, localPadId, editInfo) {
+ var globalPadId = padutils.makeGlobalId(domainId, localPadId);
+ pro_padmeta.accessProPad(globalPadId, function(propad) {
+ propad.setLastEditedDate(editInfo.lastEditTime);
+ propad.setLastEditor(editInfo.lastEditorId);
+ editInfo.recentEditors.forEach(function(eid) {
+ propad.addEditor(eid);
+ });
+ });
+}
+
diff --git a/trunk/etherpad/src/etherpad/pro/pro_padlist.js b/trunk/etherpad/src/etherpad/pro/pro_padlist.js
new file mode 100644
index 0000000..73b179c
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pro/pro_padlist.js
@@ -0,0 +1,289 @@
+/**
+ * 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("funhtml.*");
+import("jsutils.*");
+import("stringutils");
+
+import("etherpad.utils.*");
+import("etherpad.helpers");
+import("etherpad.pad.padutils");
+import("etherpad.collab.collab_server");
+
+import("etherpad.pro.pro_accounts");
+
+function _getColumnMeta() {
+ // returns map of {id --> {
+ // title,
+ // sortFn(a,b),
+ // render(p)
+ // }
+
+ function _dateNum(d) {
+ if (!d) {
+ return 0;
+ }
+ return -1 * (+d);
+ }
+
+
+ var cols = {};
+
+ function addAvailableColumn(id, cdata) {
+ if (!cdata.render) {
+ cdata.render = function(p) {
+ return p[id];
+ };
+ }
+ if (!cdata.cmpFn) {
+ cdata.cmpFn = function(a,b) {
+ return cmp(a[id], b[id]);
+ };
+ }
+ cdata.id = id;
+ cols[id] = cdata;
+ }
+
+ addAvailableColumn('public', {
+ title: "",
+ render: function(p) {
+ // TODO: implement an icon with hover text that says public vs.
+ // private
+ return "";
+ },
+ cmpFn: function(a,b) {
+ return 0; // not sort-able
+ }
+ });
+ addAvailableColumn('secure', {
+ title: "",
+ render: function(p) {
+ if (p.password) {
+ return IMG({src: '/static/img/may09/padlock.gif'});
+ } else {
+ return "";
+ }
+ },
+ cmpFn: function(a,b) {
+ return cmp(a.password, b.password);
+ }
+ });
+ addAvailableColumn('title', {
+ title: "Title",
+ render: function(p) {
+ var t = padutils.getProDisplayTitle(p.localPadId, p.title);
+ return A({href: "/"+p.localPadId}, t);
+ },
+ sortFn: function(a, b) {
+ return cmp(padutils.getProDisplayTitle(a.localPadId, a.title),
+ padutils.getProDisplayTitle(b.localPadId, b.title));
+ }
+ });
+ addAvailableColumn('creatorId', {
+ title: "Creator",
+ render: function(p) {
+ return pro_accounts.getFullNameById(p.creatorId);
+ },
+ sortFn: function(a, b) {
+ return cmp(pro_accounts.getFullNameById(a.creatorId),
+ pro_accounts.getFullNameById(b.creatorId));
+ }
+ });
+ addAvailableColumn('createdDate', {
+ title: "Created",
+ render: function(p) {
+ return timeAgo(p.createdDate);
+ },
+ sortFn: function(a, b) {
+ return cmp(_dateNum(a.createdDate), _dateNum(b.createdDate));
+ }
+ });
+ addAvailableColumn('lastEditorId', {
+ title: "Last Editor",
+ render: function(p) {
+ if (p.lastEditorId) {
+ return pro_accounts.getFullNameById(p.lastEditorId);
+ } else {
+ return "";
+ }
+ },
+ sortFn: function(a, b) {
+ var a_ = a.lastEditorId ? pro_accounts.getFullNameById(a.lastEditorId) : "ZZZZZZZZZZ";
+ var b_ = b.lastEditorId ? pro_accounts.getFullNameById(b.lastEditorId) : "ZZZZZZZZZZ";
+ return cmp(a_, b_);
+ }
+ });
+
+ addAvailableColumn('editors', {
+ title: "Editors",
+ render: function(p) {
+ var editors = [];
+ p.proAttrs.editors.forEach(function(editorId) {
+ editors.push([editorId, pro_accounts.getFullNameById(editorId)]);
+ });
+ editors.sort(function(a,b) { return cmp(a[1], b[1]); });
+ var sp = SPAN();
+ for (var i = 0; i < editors.length; i++) {
+ if (i > 0) {
+ sp.push(", ");
+ }
+ sp.push(A({href: "/ep/padlist/edited-by?editorId="+editors[i][0]}, editors[i][1]));
+ }
+ return sp;
+ }
+ });
+
+ addAvailableColumn('lastEditedDate', {
+ title: "Last Edited",
+ render: function(p) {
+ if (p.lastEditedDate) {
+ return timeAgo(p.lastEditedDate);
+ } else {
+ return "never";
+ }
+ },
+ sortFn: function(a,b) {
+ return cmp(_dateNum(a.lastEditedDate), _dateNum(b.lastEditedDate));
+ }
+ });
+ addAvailableColumn('localPadId', {
+ title: "Path",
+ });
+ addAvailableColumn('actions', {
+ title: "",
+ render: function(p) {
+ return DIV({className: "gear-drop", id: "pad-gear-"+p.id}, " ");
+ }
+ });
+
+ addAvailableColumn('connectedUsers', {
+ title: "Connected Users",
+ render: function(p) {
+ var names = [];
+ padutils.accessPadLocal(p.localPadId, function(pad) {
+ var userList = collab_server.getConnectedUsers(pad);
+ userList.forEach(function(u) {
+ if (collab_server.translateSpecialKey(u.specialKey) != 'invisible') {
+ // excludes etherpad admin user
+ names.push(u.name);
+ }
+ });
+ });
+ return names.join(", ");
+ }
+ });
+
+ return cols;
+}
+
+function _sortPads(padList) {
+ var meta = _getColumnMeta();
+ var sortId = _getCurrentSortId();
+ var reverse = false;
+ if (sortId.charAt(0) == '-') {
+ reverse = true;
+ sortId = sortId.slice(1);
+ }
+ padList.sort(function(a,b) { return cmp(a.localPadId, b.localPadId); });
+ padList.sort(function(a,b) { return meta[sortId].sortFn(a, b); });
+ if (reverse) { padList.reverse(); }
+}
+
+function _addClientVars(padList) {
+ var padTitles = {}; // maps localPadId -> title
+ var localPadIds = {}; // maps padmetaId -> localPadId
+ padList.forEach(function(p) {
+ padTitles[p.localPadId] = stringutils.toHTML(padutils.getProDisplayTitle(p.localPadId, p.title));
+ localPadIds[p.id] = p.localPadId;
+ });
+ helpers.addClientVars({
+ padTitles: padTitles,
+ localPadIds: localPadIds
+ });
+}
+
+function _getCurrentSortId() {
+ return request.params.sortBy || "lastEditedDate";
+}
+
+function _renderColumnHeader(m) {
+ var sp = SPAN();
+ var sortBy = _getCurrentSortId();
+ if (m.sortFn) {
+ var d = {sortBy: m.id};
+ var arrow = "";
+ if (sortBy == m.id) {
+ d.sortBy = ("-"+m.id);
+ arrow = html("&#8595;");
+ }
+ if (sortBy == ("-"+m.id)) {
+ arrow = html("&#8593;");
+ }
+ sp.push(arrow, " ", A({href: qpath(d)}, m.title));
+ } else {
+ sp.push(m.title);
+ }
+ return sp;
+}
+
+function renderPadList(padList, columnIds, limit) {
+ _sortPads(padList);
+ _addClientVars(padList);
+
+ if (limit && (limit < padList.length)) {
+ padList = padList.slice(0,limit);
+ }
+
+ var showSecurityInfo = false;
+ padList.forEach(function(p) {
+ if (p.password && p.password.length > 0) { showSecurityInfo = true; }
+ });
+ if (!showSecurityInfo && (columnIds[0] == 'secure')) {
+ columnIds.shift();
+ }
+
+ var columnMeta = _getColumnMeta();
+
+ var t = TABLE({id: "padtable", cellspacing:"0", cellpadding:"0"});
+ var toprow = TR({className: "toprow"});
+ columnIds.forEach(function(cid) {
+ toprow.push(TH(_renderColumnHeader(columnMeta[cid])));
+ });
+ t.push(toprow);
+
+ padList.forEach(function(p) {
+ // Note that this id is always numeric, and is the actual
+ // canonical padmeta id.
+ var row = TR({id: 'padmeta-'+p.id});
+ var first = true;
+ for (var i = 0; i < columnIds.length; i++) {
+ var cid = columnIds[i];
+ var m = columnMeta[cid];
+ var classes = cid;
+ if (i == 0) {
+ classes += (" first");
+ }
+ if (i == (columnIds.length - 1)) {
+ classes += (" last");
+ }
+ row.push(TD({className: classes}, m.render(p)));
+ }
+ t.push(row);
+ });
+
+ return t;
+}
+
diff --git a/trunk/etherpad/src/etherpad/pro/pro_padmeta.js b/trunk/etherpad/src/etherpad/pro/pro_padmeta.js
new file mode 100644
index 0000000..6f911b2
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pro/pro_padmeta.js
@@ -0,0 +1,111 @@
+/**
+ * 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("stringutils");
+import("cache_utils.syncedWithCache");
+import("sync");
+
+import("etherpad.pad.padutils");
+import("etherpad.pro.pro_pad_db");
+
+function _doWithProPadLock(domainId, localPadId, func) {
+ var lockName = ["pro-pad", domainId, localPadId].join("/");
+ return sync.doWithStringLock(lockName, func);
+}
+
+function accessProPad(globalPadId, fn) {
+ // retrieve pad from cache
+ var domainId = padutils.getDomainId(globalPadId);
+ if (!domainId) {
+ throw Error("not a pro pad: "+globalPadId);
+ }
+ var localPadId = padutils.globalToLocalId(globalPadId);
+ var padRecord = pro_pad_db.getSingleRecord(domainId, localPadId);
+
+ return _doWithProPadLock(domainId, localPadId, function() {
+ var isDirty = false;
+
+ var proPad = {
+ exists: function() { return !!padRecord; },
+ getDomainId: function() { return domainId; },
+ getLocalPadId: function() { return localPadId; },
+ getGlobalId: function() { return globalPadId; },
+ getDisplayTitle: function() { return padutils.getProDisplayTitle(localPadId, padRecord.title); },
+ setTitle: function(newTitle) {
+ padRecord.title = newTitle;
+ isDirty = true;
+ },
+ isDeleted: function() { return padRecord.isDeleted; },
+ markDeleted: function() {
+ padRecord.isDeleted = true;
+ isDirty = true;
+ },
+ getPassword: function() { return padRecord.password; },
+ setPassword: function(newPass) {
+ if (newPass == "") {
+ newPass = null;
+ }
+ padRecord.password = newPass;
+ isDirty = true;
+ },
+ isArchived: function() { return padRecord.isArchived; },
+ markArchived: function() {
+ padRecord.isArchived = true;
+ isDirty = true;
+ },
+ unmarkArchived: function() {
+ padRecord.isArchived = false;
+ isDirty = true;
+ },
+ setLastEditedDate: function(d) {
+ padRecord.lastEditedDate = d;
+ isDirty = true;
+ },
+ addEditor: function(editorId) {
+ var es = String(editorId);
+ if (es && es.length > 0 && stringutils.isNumeric(editorId)) {
+ if (padRecord.proAttrs.editors.indexOf(editorId) < 0) {
+ padRecord.proAttrs.editors.push(editorId);
+ padRecord.proAttrs.editors.sort();
+ }
+ isDirty = true;
+ }
+ },
+ setLastEditor: function(editorId) {
+ var es = String(editorId);
+ if (es && es.length > 0 && stringutils.isNumeric(editorId)) {
+ padRecord.lastEditorId = editorId;
+ this.addEditor(editorId);
+ isDirty = true;
+ }
+ }
+ };
+
+ var ret = fn(proPad);
+
+ if (isDirty) {
+ pro_pad_db.update(padRecord);
+ }
+
+ return ret;
+ });
+}
+
+function accessProPadLocal(localPadId, fn) {
+ var globalPadId = padutils.getGlobalPadId(localPadId);
+ return accessProPad(globalPadId, fn);
+}
+
diff --git a/trunk/etherpad/src/etherpad/pro/pro_quotas.js b/trunk/etherpad/src/etherpad/pro/pro_quotas.js
new file mode 100644
index 0000000..ed69e1c
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pro/pro_quotas.js
@@ -0,0 +1,141 @@
+/**
+ * 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("funhtml.*");
+import("stringutils.startsWith");
+import("sqlbase.sqlobj");
+import("sqlbase.sqlcommon.inTransaction");
+
+import("etherpad.billing.team_billing");
+import("etherpad.globals.*");
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.domains");
+import("etherpad.sessions.getSession");
+import("etherpad.store.checkout");
+
+function _createRecordIfNecessary(domainId) {
+ inTransaction(function() {
+ var r = sqlobj.selectSingle('pro_account_usage', {domainId: domainId});
+ if (!r) {
+ var count = pro_accounts.getActiveCount(domainId);
+ sqlobj.insert('pro_account_usage', {
+ domainId: domainId,
+ count: count,
+ lastReset: (new Date),
+ lastUpdated: (new Date)
+ });
+ }
+ });
+}
+
+/**
+ * Called after a successful payment has been made.
+ * Effect: counts the current number of domain accounts and stores that
+ * as the current account usage count.
+ */
+function resetAccountUsageCount(domainId) {
+ _createRecordIfNecessary(domainId);
+ var newCount = pro_accounts.getActiveCount(domainId);
+ sqlobj.update(
+ 'pro_account_usage',
+ {domainId: domainId},
+ {count: newCount, lastUpdated: (new Date), lastReset: (new Date)}
+ );
+}
+
+/**
+ * Returns the max number of accounts that have existed simultaneously
+ * since the last reset.
+ */
+function getAccountUsageCount(domainId) {
+ _createRecordIfNecessary(domainId);
+ var record = sqlobj.selectSingle('pro_account_usage', {domainId: domainId});
+ return record.count;
+}
+
+
+/**
+ * Updates the current account usage count by computing:
+ * usage_count = max(current_accounts, usage_count)
+ */
+function updateAccountUsageCount(domainId) {
+ _createRecordIfNecessary(domainId);
+ var record = sqlobj.selectSingle('pro_account_usage', {domainId: domainId});
+ var currentCount = pro_accounts.getActiveCount(domainId);
+ var newCount = Math.max(record.count, currentCount);
+ sqlobj.update(
+ 'pro_account_usage',
+ {domainId: domainId},
+ {count: newCount, lastUpdated: (new Date)}
+ );
+}
+
+// called per request
+
+function _generateGlobalBillingNotice(status) {
+ if (status == team_billing.CURRENT) {
+ return;
+ }
+ var notice = SPAN();
+ if (status == team_billing.PAST_DUE) {
+ var suspensionDate = checkout.formatDate(team_billing.getDomainSuspensionDate(domains.getRequestDomainId()));
+ notice.push(
+ "Warning: your account is past due and will be suspended on ",
+ suspensionDate, ".");
+ }
+ if (status == team_billing.SUSPENDED) {
+ notice.push(
+ "Warning: your account is suspended because it is more than ",
+ team_billing.GRACE_PERIOD_DAYS, " days past due.");
+ }
+
+ if (pro_accounts.isAdminSignedIn()) {
+ notice.push(" ", A({href: "/ep/admin/billing/"}, "Manage billing"), ".");
+ } else {
+ getSession().billingProblem = "Payment is required for sites with more than "+PRO_FREE_ACCOUNTS+" accounts.";
+ notice.push(" ", "Please ",
+ A({href: "/ep/payment-required"}, "contact a site administrator"), ".");
+ }
+ request.cache.globalProNotice = notice;
+}
+
+function perRequestBillingCheck() {
+ // Do nothing if under the free account limit.
+ var activeAccounts = pro_accounts.getCachedActiveCount(domains.getRequestDomainId());
+ if (activeAccounts <= PRO_FREE_ACCOUNTS) {
+ return;
+ }
+
+ var status = team_billing.getDomainStatus(domains.getRequestDomainId());
+ _generateGlobalBillingNotice(status);
+
+ // now see if we need to block the request because of account
+ // suspension
+ if (status != team_billing.SUSPENDED) {
+ return;
+ }
+ // These path sare still OK if a suspension is on.
+ if ((startsWith(request.path, "/ep/account/") ||
+ startsWith(request.path, "/ep/admin/") ||
+ startsWith(request.path, "/ep/pro-help/") ||
+ startsWith(request.path, "/ep/payment-required"))) {
+ return;
+ }
+
+ getSession().billingProblem = "Payment is required for sites with more than "+PRO_FREE_ACCOUNTS+" accounts.";
+ response.redirect('/ep/payment-required');
+}
+
diff --git a/trunk/etherpad/src/etherpad/pro/pro_utils.js b/trunk/etherpad/src/etherpad/pro/pro_utils.js
new file mode 100644
index 0000000..1dc2468
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/pro/pro_utils.js
@@ -0,0 +1,165 @@
+/**
+ * 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("funhtml.*");
+import("stringutils.startsWith");
+
+import("etherpad.utils.*");
+import("etherpad.globals.*");
+import("etherpad.log");
+import("etherpad.pne.pne_utils");
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.pro_accounts.getSessionProAccount");
+import("etherpad.pro.domains");
+import("etherpad.pro.pro_quotas");
+import("etherpad.sessions");
+import("etherpad.sessions.getSession");
+
+import("etherpad.control.pro.pro_main_control");
+
+jimport("java.lang.System.out.println");
+
+function _stripComet(x) {
+ if (x.indexOf('.comet.') > 0) {
+ x = x.split('.comet.')[1];
+ }
+ return x;
+}
+
+function getProRequestSubdomain() {
+ var d = _stripComet(request.domain);
+ return d.split('.')[0];
+}
+
+function getRequestSuperdomain() {
+ var parts = request.domain.split('.');
+ while (parts.length > 0) {
+ var domain = parts.join('.');
+ if (SUPERDOMAINS[domain]) {
+ return domain;
+ }
+ parts.shift(); // Remove next level
+ }
+}
+
+function isProDomainRequest() {
+ // the result of this function never changes within the same request.
+ var c = appjet.requestCache;
+ if (c.isProDomainRequest === undefined) {
+ c.isProDomainRequest = _computeIsProDomainRequest();
+ }
+ return c.isProDomainRequest;
+}
+
+function _computeIsProDomainRequest() {
+ if (pne_utils.isPNE()) {
+ return true;
+ }
+
+ var domain = _stripComet(request.domain);
+
+ if (SUPERDOMAINS[domain]) {
+ return false;
+ }
+
+ var requestSuperdomain = getRequestSuperdomain();
+
+ if (SUPERDOMAINS[requestSuperdomain]) {
+ // now see if this subdomain is actually in our database.
+ if (domains.getRequestDomainRecord()) {
+ return true;
+ } else {
+ return false;
+ }
+ }
+
+ return false;
+}
+
+function preDispatchAccountCheck() {
+ // if account is not logged in, redirect to /ep/account/login
+ //
+ // if it's PNE and there is no admin account, allow them to create an admin
+ // account.
+
+ if (pro_main_control.isActivationAllowed()) {
+ return;
+ }
+
+ if (!pro_accounts.doesAdminExist()) {
+ if (request.path != '/ep/account/create-admin-account') {
+ // should only happen for eepnet installs
+ response.redirect('/ep/account/create-admin-account');
+ }
+ } else {
+ pro_accounts.requireAccount();
+ }
+
+ pro_quotas.perRequestBillingCheck();
+}
+
+function renderFramedMessage(m) {
+ renderFramedHtml(
+ DIV(
+ {style: "font-size: 2em; padding: 2em; margin: 4em; border: 1px solid #ccc; background: #e6e6e6;"},
+ m));
+}
+
+function getFullProDomain() {
+ // TODO: have a special config param for this? --etherpad.canonicalDomain
+ return request.domain;
+}
+
+// domain, including port if necessary
+function getFullProHost() {
+ var h = getFullProDomain();
+ var parts = request.host.split(':');
+ if (parts.length > 1) {
+ h += (':' + parts[1]);
+ }
+ return h;
+}
+
+function getFullSuperdomainHost() {
+ if (isProDomainRequest()) {
+ var h = getRequestSuperdomain()
+ var parts = request.host.split(':');
+ if (parts.length > 1) {
+ h += (':' + parts[1]);
+ }
+ return h;
+ } else {
+ return request.host;
+ }
+}
+
+function getEmailFromAddr() {
+ var fromDomain = 'pad.spline.inf.fu-berlin.de';
+ if (pne_utils.isPNE()) {
+ fromDomain = getFullProDomain();
+ }
+ return ('"EtherPad" <noreply@'+fromDomain+'>');
+}
+
+function renderGlobalProNotice() {
+ if (request.cache.globalProNotice) {
+ return DIV({className: 'global-pro-notice'},
+ request.cache.globalProNotice);
+ } else {
+ return "";
+ }
+}
+
diff --git a/trunk/etherpad/src/etherpad/quotas.js b/trunk/etherpad/src/etherpad/quotas.js
new file mode 100644
index 0000000..7e939ec
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/quotas.js
@@ -0,0 +1,50 @@
+/**
+ * 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("stringutils");
+import("etherpad.licensing");
+import("etherpad.utils.*");
+import("etherpad.pne.pne_utils");
+
+// TODO: hook into PNE?
+
+function getMaxSimultaneousPadEditors(globalPadId) {
+ if (isProDomainRequest()) {
+ if (pne_utils.isPNE()) {
+ return licensing.getMaxUsersPerPad();
+ } else {
+ return 1e6;
+ }
+ } else {
+ // pad.spline.inf.fu-berlin.de public pads
+ if (globalPadId && stringutils.startsWith(globalPadId, "conf-")) {
+ return 64;
+ } else {
+ return 16;
+ }
+ }
+ return 1e6;
+}
+
+function getMaxSavedRevisionsPerPad() {
+ if (isProDomainRequest()) {
+ return 1e3;
+ } else {
+ // free public pad.spline.inf.fu-berlin.de
+ return 100;
+ }
+}
+
diff --git a/trunk/etherpad/src/etherpad/sessions.js b/trunk/etherpad/src/etherpad/sessions.js
new file mode 100644
index 0000000..c218da8
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/sessions.js
@@ -0,0 +1,203 @@
+/**
+ * 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("sessions");
+import("stringutils.randomHash");
+import("funhtml.*");
+
+import("etherpad.log");
+import("etherpad.globals.*");
+import("etherpad.pro.pro_utils");
+import("etherpad.utils.*");
+import("cache_utils.syncedWithCache");
+
+jimport("java.lang.System.out.println");
+
+var _TRACKING_COOKIE_NAME = "ET";
+var _SESSION_COOKIE_NAME = "ES";
+
+function _updateInitialReferrer(data) {
+
+ if (data.initialReferer) {
+ return;
+ }
+
+ var ref = request.headers["Referer"];
+
+ if (!ref) {
+ return;
+ }
+ if (ref.indexOf('http://'+request.host) == 0) {
+ return;
+ }
+ if (ref.indexOf('https://'+request.host) == 0) {
+ return;
+ }
+
+ data.initialReferer = ref;
+ log.custom("referers", {referer: ref});
+}
+
+function _getScopedDomain(subDomain) {
+ var d = request.domain;
+ if (d.indexOf(".") == -1) {
+ // special case for "localhost". For some reason, firefox does not like cookie domains
+ // to be ".localhost".
+ return undefined;
+ }
+ if (subDomain) {
+ d = subDomain + "." + d;
+ }
+ return "." + d;
+}
+//--------------------------------------------------------------------------------
+
+// pass in subDomain to get the session data for a particular subdomain --
+// intended for debugging.
+function getSession(subDomain) {
+ var sessionData = sessions.getSession({
+ cookieName: _SESSION_COOKIE_NAME,
+ domain: _getScopedDomain(subDomain)
+ });
+ _updateInitialReferrer(sessionData);
+ return sessionData;
+}
+
+function getSessionId() {
+ return sessions.getSessionId(_SESSION_COOKIE_NAME, false, _getScopedDomain());
+}
+
+function _getGlobalSessionId() {
+ return (request.isDefined && request.cookies[_SESSION_COOKIE_NAME]) || null;
+}
+
+function isAnEtherpadAdmin() {
+ var sessionId = _getGlobalSessionId();
+ if (! sessionId) {
+ return false;
+ }
+
+ return syncedWithCache("isAnEtherpadAdmin", function(c) {
+ return !! c[sessionId];
+ });
+}
+
+function setIsAnEtherpadAdmin(v) {
+ var sessionId = _getGlobalSessionId();
+ if (! sessionId) {
+ return;
+ }
+
+ syncedWithCache("isAnEtherpadAdmin", function(c) {
+ if (v) {
+ c[sessionId] = true;
+ }
+ else {
+ delete c[sessionId];
+ }
+ });
+}
+
+//--------------------------------------------------------------------------------
+
+function setTrackingCookie() {
+ if (request.cookies[_TRACKING_COOKIE_NAME]) {
+ return;
+ }
+
+ var trackingVal = randomHash(16);
+ var expires = new Date(32503708800000); // year 3000
+
+ response.setCookie({
+ name: _TRACKING_COOKIE_NAME,
+ value: trackingVal,
+ path: "/",
+ domain: _getScopedDomain(),
+ expires: expires
+ });
+}
+
+function getTrackingId() {
+ // returns '-' if no tracking ID (caller can assume)
+ return (request.cookies[_TRACKING_COOKIE_NAME] || response.getCookie(_TRACKING_COOKIE_NAME) || '-');
+}
+
+//--------------------------------------------------------------------------------
+
+function preRequestCookieCheck() {
+ if (isStaticRequest()) {
+ return;
+ }
+
+ // If this function completes without redirecting, then it means
+ // there is a valid session cookie and tracking cookie.
+
+ if (request.cookies[_SESSION_COOKIE_NAME] &&
+ request.cookies[_TRACKING_COOKIE_NAME]) {
+
+ if (request.params.cookieShouldBeSet) {
+ response.redirect(qpath({cookieShouldBeSet: null}));
+ }
+ return;
+ }
+
+ // Only superdomains can set cookies.
+ var isSuperdomain = SUPERDOMAINS[request.domain];
+
+ if (isSuperdomain) {
+ // superdomain without cookies
+
+ getSession();
+ setTrackingCookie();
+
+ // check if we need to redirect back to a subdomain.
+ if ((request.path == "/") &&
+ (request.params.setCookie) &&
+ (request.params.contUrl)) {
+
+ var contUrl = request.params.contUrl;
+ if (contUrl.indexOf("?") == -1) {
+ contUrl += "?";
+ }
+ contUrl += "&cookieShouldBeSet=1";
+ response.redirect(contUrl);
+ }
+ } else {
+ var parts = request.domain.split(".");
+ if (parts.length < 3) {
+ // invalid superdomain
+ response.write("invalid superdomain");
+ response.stop();
+ }
+ // subdomain without cookies
+ if (request.params.cookieShouldBeSet) {
+ log.warn("Cookie failure!");
+ renderFramedHtml(DIV({style: "border: 1px solid #ccc; padding: 1em; width: 600px; margin: 1em auto; font-size: 1.4em;"},
+ P("Please enable cookies in your browser in order to access this site."),
+ BR(),
+ P(A({href: "/"}, "Continue"))));
+ response.stop();
+ } else {
+ var contUrl = request.url;
+ var p = request.host.split(':')[1];
+ p = (p ? (":"+p) : "");
+ response.redirect(request.scheme+"://"+pro_utils.getRequestSuperdomain()+p+
+ "/?setCookie=1&contUrl="+encodeURIComponent(contUrl));
+ }
+ }
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/statistics/exceptions.js b/trunk/etherpad/src/etherpad/statistics/exceptions.js
new file mode 100644
index 0000000..723085d
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/statistics/exceptions.js
@@ -0,0 +1,231 @@
+/**
+ * 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("etherpad.log");
+import("cache_utils.syncedWithCache");
+import("funhtml.*");
+import("jsutils.{eachProperty,keys}");
+
+function _dayKey(date) {
+ return [date.getFullYear(), date.getMonth()+1, date.getDate()].join(',');
+}
+
+function _dateAddDays(date, numDays) {
+ return new Date((+date) + numDays*1000*60*60*24);
+}
+
+function _loadDay(date) {
+ var fileName = log.frontendLogFileName('exception', date);
+ if (! fileName) {
+ return [];
+ }
+ var reader = new java.io.BufferedReader(new java.io.FileReader(fileName));
+ var line = null;
+ var array = [];
+ while ((line = reader.readLine()) !== null) {
+ array.push(fastJSON.parse(line));
+ }
+ return array;
+}
+
+function _accessLatestLogs(func) {
+ syncedWithCache("etherpad.statistics.exceptions", function(exc) {
+ if (! exc.byDay) {
+ exc.byDay = {};
+ }
+ // always reload today from disk
+ var now = new Date();
+ var today = now;
+ var todayKey = _dayKey(today);
+ exc.byDay[todayKey] = _loadDay(today);
+ var activeKeys = {};
+ activeKeys[todayKey] = true;
+ // load any of 7 previous days that aren't loaded or
+ // were not loaded as a historical day
+ for(var i=1;i<=7;i++) {
+ var pastDay = _dateAddDays(today, -i);
+ var pastDayKey = _dayKey(pastDay);
+ activeKeys[pastDayKey] = true;
+ if ((! exc.byDay[pastDayKey]) || (! exc.byDay[pastDayKey].sealed)) {
+ exc.byDay[pastDayKey] = _loadDay(pastDay);
+ exc.byDay[pastDayKey].sealed = true; // in the past, won't change
+ }
+ }
+ // clear old days
+ for(var k in exc.byDay) {
+ if (! (k in activeKeys)) {
+ delete exc.byDay[k];
+ }
+ }
+
+ var logs = {
+ getDay: function(daysAgo) {
+ return exc.byDay[_dayKey(_dateAddDays(today, -daysAgo))];
+ },
+ eachLineInLastNDays: function(n, func) {
+ var oldest = _dateAddDays(now, -n);
+ var oldestNum = +oldest;
+ for(var i=n;i>=0;i--) {
+ var lines = logs.getDay(i);
+ lines.forEach(function(line) {
+ if (line.date > oldestNum) {
+ func(line);
+ }
+ });
+ }
+ }
+ };
+
+ func(logs);
+ });
+}
+
+function _exceptionHash(line) {
+ // skip the first line of jsTrace, take hashCode of rest
+ var trace = line.jsTrace;
+ var stack = trace.substring(trace.indexOf('\n') + 1);
+ return new java.lang.String(stack).hashCode();
+}
+
+// Used to take a series of strings and produce an array of
+// [common prefix, example middle, common suffix], or
+// [string] if the strings are the same. Takes oldInfo
+// and returns newInfo; each is either null or an array
+// of length 1 or 3.
+function _accumCommonPrefixSuffix(oldInfo, newString) {
+ function _commonPrefixLength(a, b) {
+ var x = 0;
+ while (x < a.length && x < b.length && a.charAt(x) == b.charAt(x)) {
+ x++;
+ }
+ return x;
+ }
+
+ function _commonSuffixLength(a, b) {
+ var x = 0;
+ while (x < a.length && x < b.length &&
+ a.charAt(a.length-1-x) == b.charAt(b.length-1-x)) {
+ x++;
+ }
+ return x;
+ }
+
+ if (! oldInfo) {
+ return [newString];
+ }
+ else if (oldInfo.length == 1) {
+ var oldString = oldInfo[0];
+ if (oldString == newString) {
+ return oldInfo;
+ }
+ var newInfo = [];
+ var a = _commonPrefixLength(oldString, newString);
+ newInfo[0] = newString.substring(0, a);
+ oldString = oldString.substring(a);
+ newString = newString.substring(a);
+ var b = _commonSuffixLength(oldString, newString);
+ newInfo[2] = newString.slice(-b);
+ oldString = oldString.slice(0, -b);
+ newString = newString.slice(0, -b);
+ newInfo[1] = newString;
+ return newInfo;
+ }
+ else {
+ // oldInfo.length == 3
+ var a = _commonPrefixLength(oldInfo[0], newString);
+ var b = _commonSuffixLength(oldInfo[2], newString);
+ return [newString.slice(0, a), newString.slice(a, -b),
+ newString.slice(-b)];
+ }
+}
+
+function render() {
+
+ _accessLatestLogs(function(logs) {
+ var weekCounts = {};
+ var totalWeekCount = 0;
+
+ // count exceptions of each kind in last week
+ logs.eachLineInLastNDays(7, function(line) {
+ var hash = _exceptionHash(line);
+ weekCounts[hash] = (weekCounts[hash] || 0) + 1;
+ totalWeekCount++;
+ });
+
+ var dayData = {};
+ var totalDayCount = 0;
+
+ // accumulate data about each exception in last 24 hours
+ logs.eachLineInLastNDays(1, function(line) {
+ var hash = _exceptionHash(line);
+ var oldData = dayData[hash];
+ var data = (oldData || {});
+ if (! oldData) {
+ data.hash = hash;
+ data.trace = line.jsTrace.substring(line.jsTrace.indexOf('\n')+1);
+ data.trackers = {};
+ }
+ var msg = line.jsTrace.substring(0, line.jsTrace.indexOf('\n'));
+ data.message = _accumCommonPrefixSuffix(data.message, msg);
+ data.count = (data.count || 0)+1;
+ data.trackers[line.tracker] = true;
+ totalDayCount++;
+ dayData[hash] = data;
+ });
+
+ // put day datas in an array and sort
+ var dayDatas = [];
+ eachProperty(dayData, function(k,v) {
+ dayDatas.push(v);
+ });
+ dayDatas.sort(function(a, b) {
+ return b.count - a.count;
+ });
+
+ // process
+ dayDatas.forEach(function(data) {
+ data.weekCount = (weekCounts[data.hash] || 0);
+ data.numTrackers = keys(data.trackers).length;
+ });
+
+ // gen HTML
+ function num(n) { return SPAN({className:'num'}, n); }
+
+ response.write(STYLE(html(".trace { height: 300px; overflow: auto; background: #eee; margin-left: 1em; font-family: monospace; border: 1px solid #833; padding: 4px; }\n"+
+ ".exc { margin: 1em 0; }\n"+
+ ".num { font-size: 150%; }")));
+
+ response.write(P("Total exceptions in past day: ", num(totalDayCount),
+ ", past week: ", totalWeekCount));
+
+ response.write(P(SMALL(EM("Data on this page is live."))));
+
+ response.write(H2("Exceptions grouped by stack trace:"));
+
+ dayDatas.forEach(function(data) {
+ response.write(DIV({className:'exc'},
+ 'Past day: ',num(data.count),', Past week: ',
+ data.weekCount,', Different tracker cookies today: ',
+ data.numTrackers,
+ '\n',data.message[0],
+ (data.message[1] && I(data.message[1])) || '',
+ (data.message[2] || ''),'\n',
+ DIV({className:'trace'}, data.trace)));
+ });
+ });
+}
diff --git a/trunk/etherpad/src/etherpad/statistics/statistics.js b/trunk/etherpad/src/etherpad/statistics/statistics.js
new file mode 100644
index 0000000..8174405
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/statistics/statistics.js
@@ -0,0 +1,1248 @@
+/**
+ * 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("dateutils.noon");
+import("execution");
+import("exceptionutils");
+import("fastJSON");
+import("fileutils.fileLineIterator");
+import("jsutils.*");
+import("sqlbase.sqlobj");
+
+import("etherpad.log");
+
+jimport("net.appjet.oui.GenericLoggerUtils");
+jimport("net.appjet.oui.LoggableFromJson");
+jimport("net.appjet.oui.FilterWrangler");
+jimport("java.lang.System.out.println");
+jimport("net.appjet.common.util.ExpiringMapping");
+
+var millisInDay = 86400*1000;
+
+function _stats() {
+ if (! appjet.cache.statistics) {
+ appjet.cache.statistics = {};
+ }
+ return appjet.cache.statistics;
+}
+
+function onStartup() {
+ execution.initTaskThreadPool("statistics", 1);
+ _scheduleNextDailyUpdate();
+
+ onReset();
+}
+
+function _info(m) {
+ log.info({type: 'statistics', message: m});
+}
+
+function _warn(m) {
+ log.info({type: 'statistics', message: m});
+}
+
+function _statData() {
+ return _stats().stats;
+}
+
+function getAllStatNames() {
+ return keys(_statData());
+}
+
+function getStatData(statName) {
+ return _statData()[statName];
+}
+
+function _setStatData(statName, data) {
+ _statData()[statName] = data;
+}
+
+function liveSnapshot(stat) {
+ var statObject;
+ if (typeof(stat) == 'string') {
+ // "stat" is the stat name.
+ statObject = getStatData(stat);
+ } else if (typeof(stat) == 'object') {
+ statObject = stat;
+ } else {
+ return;
+ }
+ return _callFunction(statObject.snapshot_f,
+ statObject.name, statObject.options, statObject.data);
+}
+
+// ------------------------------------------------------------------
+// stats processing
+// ------------------------------------------------------------------
+
+// some useful constants
+var LIVE = 'live';
+var HIST = 'historical';
+var HITS = 'hits';
+var UNIQ = 'uniques';
+var VALS = 'values';
+var HGRM = 'histogram';
+
+// helpers
+
+function _date(d) {
+ return new Date(d);
+}
+
+function _saveStat(day, name, value) {
+ var timestamp = Math.floor(day.valueOf() / 1000);
+ _info({statistic: name,
+ timestamp: timestamp,
+ value: value});
+ try {
+ sqlobj.insert('statistics', {
+ name: name,
+ timestamp: timestamp,
+ value: fastJSON.stringify(value)
+ });
+ } catch (e) {
+ var msg;
+ try {
+ msg = e.getMessage();
+ } catch (e2) {
+ try {
+ msg = e.toSource();
+ } catch (e3) {
+ msg = "(none)";
+ }
+ }
+ _warn("failed to save stat "+name+": "+msg);
+ }
+}
+
+function _convertScalaTopValuesToJs(topValues) {
+ var totalValue = topValues._1();
+ var countsMap = topValues._2();
+ countsObj = {};
+ countsMap.foreach(scalaF1(function(pair) { countsObj[pair._1()] = pair._2(); }));
+ return {total: totalValue, counts: countsObj};
+}
+
+function _fakeMap() {
+ var map = {}
+ return {
+ get: function(k) { return map[k]; },
+ put: function(k, v) { map[k] = v; },
+ remove: function(k) { delete map[k]; }
+ }
+}
+
+function _withinSecondsOf(numSeconds, t1, t2) {
+ return (t1 > t2-numSeconds*1000) && (t1 < t2+numSeconds*1000);
+}
+
+function _callFunction(functionName, arg1, arg2, etc) {
+ var f = this[functionName];
+ var args = Array.prototype.slice.call(arguments, 1);
+ return f.apply(this, args);
+}
+
+// trackers and other init functions
+
+function _hitTracker(trackerType, timescaleType) {
+ var className;
+ switch (trackerType) {
+ case HITS: className = "BucketedLastHits"; break;
+ case UNIQ: className = "BucketedUniques"; break;
+ case VALS: className = "BucketedValueCounts"; break;
+ case HGRM: className = "BucketedLastHitsHistogram"; break;
+ }
+ var tracker;
+ switch (timescaleType) {
+ case LIVE:
+ tracker = new net.appjet.oui[className](24*60*60*1000);
+ break;
+ case HIST:
+ // timescale just needs to be longer than a day.
+ tracker = new net.appjet.oui[className](365*24*60*60*1000, true);
+ break;
+ }
+
+ var conversionData = {
+ total_f: "count",
+ history_f: "history",
+ latest_f: "latest",
+ };
+ switch (trackerType) {
+ case HITS: case UNIQ:
+ conversionData.conversionFunction =
+ function(x) { return x; } // no conversion necessary.
+ break;
+ case VALS:
+ conversionData.conversionFunction = _convertScalaTopValuesToJs
+ break;
+ case HGRM:
+ conversionData.conversionFunction =
+ function(hFunc) { return function(pct) { return hFunc.apply(pct); } }
+ break;
+ }
+
+
+ return {
+ tracker: tracker,
+ conversionData: conversionData,
+ hit: function(d, n1, n2) {
+ d = _date(d);
+ if (n2 === undefined) {
+ this.tracker.hit(d, n1);
+ } else {
+ this.tracker.hit(d, n1, n2);
+ }
+ },
+ get total() {
+ return this.conversionData.conversionFunction(this.tracker[this.conversionData.total_f]());
+ },
+ history: function(bucketsPerSample, numSamples) {
+ var scalaArray = this.tracker[this.conversionData.history_f](bucketsPerSample, numSamples);
+ var jsArray = [];
+ for (var i = 0; i < scalaArray.length(); ++i) {
+ jsArray.push(this.conversionData.conversionFunction(scalaArray.apply(i)));
+ }
+ return jsArray;
+ },
+ latest: function(bucketsPerSample) {
+ return this.conversionData.conversionFunction(this.tracker[this.conversionData.latest_f](bucketsPerSample));
+ }
+ }
+}
+
+function _initCount(statName, options, timescaleType) {
+ return _hitTracker(HITS, timescaleType);
+}
+function _initUniques(statName, options, timescaleType) {
+ return _hitTracker(UNIQ, timescaleType);
+}
+function _initTopValues(statName, options, timescaleType) {
+ return _hitTracker(VALS, timescaleType);
+}
+function _initHistogram(statName, options, timescaleType) {
+ return _hitTracker(HGRM, timescaleType);
+}
+
+function _initLatencies(statName, options, type) {
+ var hits = _initTopValues(statName, options, type);
+ var latencies = _initTopValues(statName, options, type);
+
+ return {
+ hit: function(d, value, latency) {
+ hits.hit(d, value);
+ latencies.hit(d, value, latency);
+ },
+ hits: hits,
+ latencies: latencies
+ }
+}
+
+function _initDisconnectTracker(statName, options, timescaleType) {
+ return {
+ map: (timescaleType == LIVE ? new ExpiringMapping(60*1000) : _fakeMap()),
+ counter: _initCount(statName, options, timescaleType),
+ uniques: _initUniques(statName, options, timescaleType),
+ isLive: timescaleType == LIVE
+ }
+}
+
+// update functions
+
+function _updateCount(statName, options, logName, data, logObject) {
+ // println("update count: "+statName+" on log "+logName+", with data: "+data.toSource()+" with log entry: "+logObject.toSource());
+ if (options.filter == null || options.filter(logObject)) {
+ data.hit(logObject.date, 1);
+ }
+}
+
+function _updateSum(statName, options, logName, data, logObject) {
+ // println("update sum: "+statName+" on log "+logName+", with data: "+data.toSource()+" with log entry: "+logObject.toSource());
+ if (options.filter == null || options.filter(logObject)) {
+ data.hit(logObject.date, Math.round(Number(logObject[options.fieldName])));
+ }
+}
+
+function _updateUniquenessCount(statName, options, logName, data, logObject) {
+ // println("update uniqueness: "+statName+" on log "+logName+", with data: "+data.toSource()+" with log entry: "+logObject.toSource());
+ if (options.filter == null || options.filter(logObject)) {
+ var value = logObject[options.fieldName];
+ if (value === undefined) { return; }
+ data.hit(logObject.date, value);
+ }
+}
+
+function _updateTopValues(statName, options, logName, data, logObject) {
+ // println("update topvalues: "+statName+" on log "+logName+", with data: "+data.toSource()+" with log entry: "+logObject.toSource());
+
+ if (options.filter == null || options.filter(logObject)) {
+ var value = logObject[options.fieldName];
+ if (value === undefined) { return; }
+ if (options.canonicalizer) {
+ value = options.canonicalizer(value);
+ }
+ data.hit(logObject.date, value);
+ }
+}
+
+function _updateLatencies(statName, options, logName, data, logObject) {
+ // println("update latencies: "+statName+" on log "+logName+", with data: "+data.toSource()+" with log entry: "+logObject.toSource());
+
+ if (options.filter == null || options.filter(logObject)) {
+ var value = logObject[options.fieldName];
+ var latency = logObject[options.latencyFieldName];
+ if (value === undefined) { return; }
+ data.hit(logObject.date, value, latency);
+ }
+}
+
+function _updateDisconnectTracker(statName, options, logName, data, logObject) {
+ if (logName == "frontend/padevents" && logObject.type != "userleave") {
+ // we only care about userleaves from the padevents log.
+ return;
+ }
+
+ var [evtPrefix, otherPrefix] =
+ (logName == "frontend/padevents" ? ["l-", "d-"] : ["d-", "l-"]);
+ var dateLong = logObject.date;
+ var userId = logObject.session;
+
+ var lastOtherEvent = data.map.get(otherPrefix+userId);
+ if (lastOtherEvent != null && _withinSecondsOf(60, dateLong, lastOtherEvent.date)) {
+ data.counter.hit(logObject.date, 1);
+ data.uniques.hit(logObject.date, userId);
+ data.map.remove(otherPrefix+userId);
+ if (data.isLive) {
+ log.custom("avoidable_disconnects",
+ {userId: userId,
+ errorMessage: lastOtherEvent.errorMessage || logObject.errorMessage});
+ }
+ } else {
+ data.map.put(evtPrefix+userId, {date: dateLong, message: logObject.errorMessage});
+ }
+}
+
+// snapshot functions
+
+function _lazySnapshot(snapshot) {
+ var total;
+ var history = {};
+ var latest = {};
+ return {
+ get total() {
+ if (total === undefined) {
+ total = snapshot.total;
+ }
+ return total;
+ },
+ history: function(bucketsPerSample, numSamples) {
+ if (history[""+bucketsPerSample+":"+numSamples] === undefined) {
+ history[""+bucketsPerSample+":"+numSamples] = snapshot.history(bucketsPerSample, numSamples);
+ }
+ return history[""+bucketsPerSample+":"+numSamples];
+ },
+ latest: function(bucketsPerSample) {
+ if (latest[""+bucketsPerSample] === undefined) {
+ latest[""+bucketsPerSample] = snapshot.latest(bucketsPerSample);
+ }
+ return latest[""+bucketsPerSample];
+ }
+ }
+}
+
+function _snapshotTotal(statName, options, data) {
+ return _lazySnapshot(data);
+}
+
+function _convertTopValue(topValue) {
+ var counts = topValue.counts;
+ var sortedValues = keys(counts).sort(function(x, y) {
+ return counts[y] - counts[x];
+ }).map(function(key) {
+ return { value: key, count: counts[key] };
+ });
+ return {count: topValue.total, topValues: sortedValues.slice(0, 50) };
+}
+
+function _snapshotTopValues(statName, options, data) {
+ var convertedData = {};
+
+ return _lazySnapshot({
+ get total() {
+ return _convertTopValue(data.total);
+ },
+ history: function(bucketsPerSample, numSamples) {
+ return data.history(bucketsPerSample, numSamples).map(_convertTopValue);
+ },
+ latest: function(bucketsPerSample) {
+ return _convertTopValue(data.latest(bucketsPerSample));
+ }
+ });
+}
+
+function _snapshotLatencies(statName, options, data) {
+ // convert the hits + total latencies into a topValues-style data object.
+ var hits = data.hits;
+ var totalLatencies = data.latencies;
+
+ function convertCountsObjects(latencyCounts, hitCounts) {
+ var mergedCounts = {}
+ keys(latencyCounts.counts).forEach(function(value) {
+ mergedCounts[value] =
+ Math.round(latencyCounts.counts[value] / (hitCounts.counts[value] || 1));
+ });
+ return {counts: mergedCounts, total: latencyCounts.total / (hitCounts.total || 1)};
+ }
+
+ // ...and then convert that object into a snapshot.
+ return _snapshotTopValues(statName, options, {
+ get total() {
+ return convertCountsObjects(totalLatencies.total, hits.total);
+ },
+ history: function(bucketsPerSample, numSamples) {
+ return mergeArrays(
+ convertCountsObjects,
+ totalLatencies.history(bucketsPerSample, numSamples),
+ hits.history(bucketsPerSample, numSamples));
+ },
+ latest: function(bucketsPerSample) {
+ return convertCountsObjects(totalLatencies.latest(bucketsPerSample), hits.latest(bucketsPerSample));
+ }
+ });
+}
+
+function _snapshotDisconnectTracker(statName, options, data) {
+ var topValues = {};
+ var counts = data.counter;
+ var uniques = data.uniques;
+ function topValue(counts, uniques) {
+ return {
+ count: counts,
+ topValues: [{value: "total_disconnects", count: counts},
+ {value: "disconnected_userids", count: uniques}]
+ }
+ }
+ return _lazySnapshot({
+ get total() {
+ return topValue(counts.total, uniques.total);
+ },
+ history: function(bucketsPerSample, numSamples) {
+ return mergeArrays(
+ topValue,
+ counts.history(bucketsPerSample, numSamples),
+ uniques.history(bucketsPerSample, numSamples));
+ },
+ latest: function(bucketsPerSample) {
+ return topValue(counts.latest(bucketsPerSample), uniques.latest(bucketsPerSample));
+ }
+ });
+}
+
+function _generateLogInterestMap(statNames) {
+ var interests = {};
+ statNames.forEach(function(statName) {
+ var logs = getStatData(statName).logNames;
+ logs.forEach(function(logName) {
+ if (! interests[logName]) {
+ interests[logName] = {};
+ }
+ interests[logName][statName] = true;
+ });
+ });
+ return interests;
+}
+
+
+// ------------------------------------------------------------------
+// stat generators
+// ------------------------------------------------------------------
+
+// statSpec has these properties
+// name
+// dataType - line, topvalues, histogram, etc.
+// logNames
+// init_f
+// update_f
+// snapshot_f
+// options - object containing any additional data, passed in to to the various functions.
+
+// init_f gets (statName, options, "live"|"historical")
+// update_f gets (statName, options, logName, data, logObject)
+// snapshot_f gets (statName, options, data)
+function addStat(statSpec) {
+ var statName = statSpec.name;
+ if (! getStatData(statName)) {
+ var initialData =
+ _callFunction(statSpec.init_f, statName, statSpec.options, LIVE);
+ _setStatData(statName, {
+ data: initialData,
+ });
+ }
+
+ var s = getStatData(statName);
+
+ s.options = statSpec.options;
+ s.name = statName;
+ s.logNames = statSpec.logNames;
+ s.dataType = statSpec.dataType;
+ s.historicalDays = ("historicalDays" in statSpec ? statSpec.historicalDays : 1);
+
+ s.init_f = statSpec.init_f;
+ s.update_f = statSpec.update_f;
+ s.snapshot_f = statSpec.snapshot_f;
+
+ function registerInterest(logName) {
+ if (! _stats().logNamesToInterestedStatNames[logName]) {
+ _stats().logNamesToInterestedStatNames[logName] = {};
+ }
+ _stats().logNamesToInterestedStatNames[logName][statName] = true;
+ }
+ statSpec.logNames.forEach(registerInterest);
+}
+
+function addSimpleCount(statName, historicalDays, logName, filter) {
+ addStat({
+ name: statName,
+ dataType: "line",
+ logNames: (logName instanceof Array ? logName : [logName]),
+ init_f: "_initCount",
+ update_f: "_updateCount",
+ snapshot_f: "_snapshotTotal",
+ options: { filter: filter },
+ historicalDays: historicalDays || 1
+ });
+}
+
+function addSimpleSum(statName, historicalDays, logName, filter, fieldName) {
+ addStat({
+ name: statName,
+ dataType: "line",
+ logNames: (logName instanceof Array ? logName : [logName]),
+ init_f: "_initCount",
+ update_f: "_updateSum",
+ snapshot_f: "_snapshotTotal",
+ options: { filter: filter, fieldName: fieldName },
+ historicalDays: historicalDays || 1
+ });
+}
+
+function addUniquenessCount(statName, historicalDays, logName, filter, fieldName) {
+ addStat({
+ name: statName,
+ dataType: "line",
+ logNames: (logName instanceof Array ? logName : [logName]),
+ init_f: "_initUniques",
+ update_f: "_updateUniquenessCount",
+ snapshot_f: "_snapshotTotal",
+ options: { filter: filter, fieldName: fieldName },
+ historicalDays: historicalDays || 1
+ })
+}
+
+function addTopValuesStat(statName, historicalDays, logName, filter, fieldName, canonicalizer) {
+ addStat({
+ name: statName,
+ dataType: "topValues",
+ logNames: (logName instanceof Array ? logName : [logName]),
+ init_f: "_initTopValues",
+ update_f: "_updateTopValues",
+ snapshot_f: "_snapshotTopValues",
+ options: { filter: filter, fieldName: fieldName, canonicalizer: canonicalizer },
+ historicalDays: historicalDays || 1
+ });
+}
+
+function addLatenciesStat(statName, historicalDays, logName, filter, fieldName, latencyFieldName) {
+ addStat({
+ name: statName,
+ dataType: "topValues",
+ logNames: (logName instanceof Array ? logName : [logName]),
+ init_f: "_initLatencies",
+ update_f: "_updateLatencies",
+ snapshot_f: "_snapshotLatencies",
+ options: { filter: filter, fieldName: fieldName, latencyFieldName: latencyFieldName },
+ historicalDays: historicalDays || 1
+ });
+}
+
+
+// RETURNING USERS
+
+function _initReturningUsers(statName, options, timescaleType) {
+ return { cache: {}, uniques: _initUniques(statName, options, timescaleType) };
+}
+
+function _returningUsersUserId(logObject) {
+ if (logObject.type == "userjoin") {
+ return logObject.userId;
+ }
+}
+
+function _returningUsersUserCreationDate(userId) {
+ var record = sqlobj.selectSingle('pad_cookie_userids', {id: userId});
+ if (record) {
+ return record.createdDate.getTime();
+ }
+}
+
+function _returningUsersAccountId(logObject) {
+ return logObject.proAccountId;
+}
+
+function _returningUsersAccountCreationDate(accountId) {
+ var record = sqlobj.selectSingle('pro_accounts', {id: accountId});
+ if (record) {
+ return record.createdDate.getTime();
+ }
+}
+
+
+function _updateReturningUsers(statName, options, logName, data, logObject) {
+ var userId = (options.useProAccountId ? _returningUsersAccountId(logObject) : _returningUsersUserId(logObject));
+ if (! userId) { return; }
+ var date = logObject.date;
+ if (! data.cache[""+userId]) {
+ var creationTime = (options.useProAccountId ? _returningUsersAccountCreationDate(userId) : _returningUsersUserCreationDate(userId));
+ if (! creationTime) { return; } // hm. weird case.
+ data.cache[""+userId] = creationTime;
+ }
+ if (data.cache[""+userId] < date - options.registeredNDaysAgo*24*60*60*1000) {
+ data.uniques.hit(logObject.date, ""+userId);
+ }
+}
+function _snapshotReturningUsers(statName, options, data) {
+ return _lazySnapshot(data.uniques);
+}
+
+function addReturningUserStat(statName, pastNDays, registeredNDaysAgo) {
+ addStat({
+ name: statName,
+ dataType: "line",
+ logNames: ["frontend/padevents"],
+ init_f: "_initReturningUsers",
+ update_f: "_updateReturningUsers",
+ snapshot_f: "_snapshotReturningUsers",
+ options: { registeredNDaysAgo: registeredNDaysAgo },
+ historicalDays: pastNDays
+ });
+}
+
+function addReturningProAccountStat(statName, pastNDays, registeredNDaysAgo) {
+ addStat({
+ name: statName,
+ dataType: "line",
+ logNames: ["frontend/request"],
+ init_f: "_initReturningUsers",
+ update_f: "_updateReturningUsers",
+ snapshot_f: "_snapshotReturningUsers",
+ options: { registeredNDaysAgo: registeredNDaysAgo, useProAccountId: true },
+ historicalDays: pastNDays
+ });
+}
+
+
+function addDisconnectStat() {
+ addStat({
+ name: "streaming_disconnects",
+ dataType: "topValues",
+ logNames: ["frontend/padevents", "frontend/reconnect", "frontend/disconnected_autopost"],
+ init_f: "_initDisconnectTracker",
+ update_f: "_updateDisconnectTracker",
+ snapshot_f: "_snapshotDisconnectTracker",
+ options: {}
+ });
+}
+
+// PAD STARTUP LATENCY
+function _initPadStartupLatency(statName, options, timescaleType) {
+ return {
+ recentGets: (timescaleType == LIVE ? new ExpiringMapping(60*1000) : _fakeMap()),
+ latencies: _initHistogram(statName, options, timescaleType),
+ }
+}
+
+function _updatePadStartupLatency(statName, options, logName, data, logObject) {
+ var session = logObject.session;
+ if (logName == "frontend/request") {
+ if (! ('padId' in logObject)) { return; }
+ var padId = logObject.padId;
+ if (! data.recentGets.get(session)) {
+ data.recentGets.put(session, {});
+ }
+ data.recentGets.get(session)[padId] = logObject.date;
+ }
+ if (logName == "frontend/padevents") {
+ if (logObject.type != 'userjoin') { return; }
+ if (! data.recentGets.get(session)) { return; }
+ var padId = logObject.padId;
+ var getTime = data.recentGets.get(session)[padId];
+ if (! getTime) { return; }
+ delete data.recentGets.get(session)[padId];
+ var latency = logObject.date - getTime;
+ if (latency < 60*1000) {
+ // latencies longer than 60 seconds don't represent data we care about for this stat.
+ data.latencies.hit(logObject.date, latency);
+ }
+ }
+}
+
+function _snapshotPadStartupLatency(statName, options, data) {
+ var latencies = data.latencies;
+ function convertHistogram(histogram_f) {
+ var deciles = {};
+ [0, 1, 5, 10, 25, 50, 75, 90, 95, 99, 100].forEach(function(pct) {
+ deciles[""+pct] = histogram_f(pct);
+ });
+ return deciles;
+ }
+ return _lazySnapshot({
+ latencies: latencies,
+ get total() {
+ return convertHistogram(this.latencies.total);
+ },
+ history: function(bucketsPerSample, numSamples) {
+ return this.latencies.history(bucketsPerSample, numSamples).map(convertHistogram);
+ },
+ latest: function(bucketsPerSample) {
+ return convertHistogram(this.latencies.latest(bucketsPerSample));
+ }
+ });
+}
+
+function addPadStartupLatencyStat() {
+ addStat({
+ name: "pad_startup_times",
+ dataType: "histogram",
+ logNames: ["frontend/padevents", "frontend/request"],
+ init_f: "_initPadStartupLatency",
+ update_f: "_updatePadStartupLatency",
+ snapshot_f: "_snapshotPadStartupLatency",
+ options: {}
+ });
+}
+
+
+function _initSampleTracker(statName, options, timescaleType) {
+ return {
+ samples: Array(1440), // 1 hour at 1 sample/minute
+ nextSample: 0,
+ numSamples: 0
+ }
+}
+
+function _updateSampleTracker(statName, options, logName, data, logObject) {
+ if (options.filter && ! options.filter(logObject)) {
+ return;
+ }
+ if (options.fieldName && ! (options.fieldName in logObject)) {
+ return;
+ }
+ data.samples[data.nextSample] = (options.fieldName ? logObject[fieldName] : logObject);
+ data.nextSample++;
+ data.nextSample %= data.samples.length;
+ data.numSamples = Math.min(data.samples.length, data.numSamples+1);
+}
+
+function _snapshotSampleTracker(statName, options, data) {
+ function indexTransform(i) {
+ return (data.nextSample-data.numSamples+i + data.samples.length) % data.samples.length;
+ }
+ var merge_f = options.mergeFunction || function(a, b) { return a+b; }
+ var process_f = options.processFunction || function(a) { return a; }
+ function mergeValues(values) {
+ if (values.length <= 1) { return values[0]; }
+ var t = values[0];
+ for (var i = 1; i < values.length; ++i) {
+ t = merge_f(values[i], t);
+ }
+ return t;
+ }
+ return _lazySnapshot({
+ get total() {
+ var t = [];
+ for (var i = 0; i < data.numSamples; ++i) {
+ t.push(data.samples[indexTransform(i)]);
+ }
+ return process_f(mergeValues(t), t.length);
+ },
+ history: function(bucketsPerSample, numSamples) {
+ var allSamples = [];
+ for (var i = data.numSamples-1; i >= Math.max(0, data.numSamples - bucketsPerSample*numSamples); --i) {
+ allSamples.push(data.samples[indexTransform(i)]);
+ }
+ var out = [];
+ for (var i = 0; i < numSamples && i*bucketsPerSample < allSamples.length; ++i) {
+ var subArray = [];
+ for (var j = 0; j < bucketsPerSample && i*bucketsPerSample+j < allSamples.length; ++j) {
+ subArray.push(allSamples[i*bucketsPerSample+j]);
+ }
+ out.push(process_f(mergeValues(subArray), subArray.length));
+ }
+ return out.reverse();
+ },
+ latest: function(bucketsPerSample) {
+ var t = [];
+ for (var i = data.numSamples-1; i >= Math.max(0, data.numSamples-bucketsPerSample); --i) {
+ t.push(data.samples[indexTransform(i)]);
+ }
+ return process_f(mergeValues(t), t.length);
+ }
+ });
+}
+
+function addSampleTracker(statName, logName, filter, fieldName, mergeFunction, processFunction) {
+ addStat({
+ name: statName,
+ dataType: "histogram",
+ logNames: (logName instanceof Array ? logName : [logName]),
+ init_f: "_initSampleTracker",
+ update_f: "_updateSampleTracker",
+ snapshot_f: "_snapshotSampleTracker",
+ options: { filter: filter, fieldName: fieldName,
+ mergeFunction: mergeFunction, processFunction: processFunction }
+ });
+}
+
+function addCometLatencySampleTracker(statName) {
+ addSampleTracker(statName, "backend/server-events", typeMatcher("streaming-message-latencies"), null,
+ function(a, b) {
+ var ret = {};
+ ["count", "p50", "p90", "p95", "p99", "max"].forEach(function(key) {
+ ret[key] = (Number(a[key]) || 0) + (Number(b[key]) || 0);
+ });
+ return ret;
+ },
+ function(v, count) {
+ if (count == 0) {
+ return {
+ "50": 0, "90": 0, "95": 0, "99": 0, "100": 0
+ }
+ }
+ var ret = {count: v.count};
+ ["p50", "p90", "p95", "p99", "max"].forEach(function(key) {
+ ret[key] = (Number(v[key]) || 0)/(Number(count) || 1);
+ });
+ return {"50": Math.round(ret.p50/1000),
+ "90": Math.round(ret.p90/1000),
+ "95": Math.round(ret.p95/1000),
+ "99": Math.round(ret.p99/1000),
+ "100": Math.round(ret.max/1000)};
+ });
+}
+
+function addConnectionTypeSampleTracker(statName) {
+ var caredAboutFields = ["streaming", "longpolling", "shortpolling", "(unconnected)"];
+
+ addSampleTracker(statName, "backend/server-events", typeMatcher("streaming-connection-count"), null,
+ function(a, b) {
+ var ret = {};
+ caredAboutFields.forEach(function(k) {
+ ret[k] = (Number(a[k]) || 0) + (Number(b[k]) || 0);
+ });
+ return ret;
+ },
+ function(v, count) {
+ if (count == 0) {
+ return _convertTopValue({total: 0, counts: {}});
+ }
+ var values = {};
+ var total = 0;
+ caredAboutFields.forEach(function(k) {
+ values[k] = Math.round((Number(v[k]) || 0)/count);
+ total += values[k];
+ });
+ values["Total"] = total;
+ return _convertTopValue({
+ total: Math.round(total),
+ counts: values
+ });
+ });
+}
+
+// helpers for filter functions
+
+function expectedHostnames() {
+ var hostPart = appjet.config.listenHost || "localhost";
+ if (appjet.config.listenSecureHost != hostPart) {
+ hostPart = "("+hostPart+"|"+(appjet.config.listenSecureHost || "localhost")+")";
+ }
+ var ports = [];
+ if (appjet.config.listenPort != 80) {
+ ports.push(""+appjet.config.listenPort);
+ }
+ if (appjet.config.listenSecurePort != 443) {
+ ports.push(""+appjet.config.listenSecurePort);
+ }
+ var portPart = (ports.length > 0 ? ":("+ports.join("|")+")" : "");
+ return hostPart + portPart;
+}
+
+function fieldMatcher(fieldName, fieldValue) {
+ if (fieldValue instanceof RegExp) {
+ return function(logObject) {
+ return fieldValue.test(logObject[fieldName]);
+ }
+ } else {
+ return function(logObject) {
+ return logObject[fieldName] == fieldValue;
+ }
+ }
+}
+
+function typeMatcher(type) {
+ return fieldMatcher("type", type);
+}
+
+function invertMatcher(f) {
+ return function(logObject) {
+ return ! f(logObject);
+ }
+}
+
+function setupStatsCollector() {
+ var c;
+
+ function unwatchLog(logName) {
+ GenericLoggerUtils.clearWrangler(logName.split('/')[1], c.wranglers[logName]);
+ }
+ function watchLog(logName) {
+ c.wranglers[logName] = new Packages.net.appjet.oui.LogWrangler({
+ tell: function(lpb) {
+ c.queue.add({logName: logName, json: lpb.json()});
+ }
+ });
+ c.wranglers[logName].watch(logName.split('/')[1]);
+ }
+
+ c = _stats().liveCollector;
+ if (c) {
+ c.watchedLogs.forEach(unwatchLog);
+ delete c.wrangler;
+ } else {
+ c = _stats().liveCollector = {};
+ }
+ c.watchedLogs = keys(_stats().logNamesToInterestedStatNames);
+ c.queue = new java.util.concurrent.ConcurrentLinkedQueue();
+ c.wranglers = {};
+ c.watchedLogs.forEach(watchLog);
+
+ if (! c.updateTask || c.updateTask.isDone()) {
+ c.updateTask = execution.scheduleTask('statistics', "statisticsLiveUpdate", 2000, []);
+ }
+}
+
+serverhandlers.tasks.statisticsLiveUpdate = function() {
+ var c = _stats().liveCollector;
+ try {
+ while (true) {
+ var obj = c.queue.poll();
+ if (obj != null) {
+ var statNames =
+ keys(_stats().logNamesToInterestedStatNames[obj.logName]);
+ var logObject = fastJSON.parse(obj.json);
+ statNames.forEach(function(statName) {
+ var statObject = getStatData(statName);
+ _callFunction(statObject.update_f,
+ statName, statObject.options, obj.logName, statObject.data, logObject);
+ });
+ } else {
+ break;
+ }
+ }
+ } catch (e) {
+ println("EXCEPTION IN LIVE UPDATE: "+e+" / "+e.fileName+":"+e.lineNumber)
+ println(exceptionutils.getStackTracePlain(new net.appjet.bodylock.JSRuntimeException(String(e), e.javaException || e.rhinoException)));
+ } finally {
+ c.updateTask = execution.scheduleTask('statistics', "statisticsLiveUpdate", 2000, []);
+ }
+}
+
+function onReset() {
+ // this gets refilled every reset.
+ _stats().logNamesToInterestedStatNames = {};
+
+ // we'll want to keep around the live data, though, so this is conditionally set.
+ if (! _stats().stats) {
+ _stats().stats = {};
+ }
+
+ addSimpleCount("site_pageviews", 1, "frontend/request", null);
+ addUniquenessCount("site_unique_ips", 1, "frontend/request", null, "clientAddr");
+
+ addUniquenessCount("active_user_ids", 1, "frontend/padevents", typeMatcher("userjoin"), "userId");
+ addUniquenessCount("active_user_ids_7days", 7, "frontend/padevents", typeMatcher("userjoin"), "userId");
+ addUniquenessCount("active_user_ids_30days", 30, "frontend/padevents", typeMatcher("userjoin"), "userId");
+
+ addUniquenessCount("active_pro_accounts", 1, "frontend/request", invertMatcher(fieldMatcher("proAccountId", undefined)),
+ "proAccountId");
+ addUniquenessCount("active_pro_accounts_7days", 7, "frontend/request", invertMatcher(fieldMatcher("proAccountId", undefined)),
+ "proAccountId");
+ addUniquenessCount("active_pro_accounts_30days", 30, "frontend/request", invertMatcher(fieldMatcher("proAccountId", undefined)),
+ "proAccountId");
+
+
+ addUniquenessCount("active_pads", 1, "frontend/padevents", typeMatcher("userjoin"), "padId");
+ addSimpleCount("new_pads", 1, "frontend/padevents", typeMatcher("newpad"));
+
+ addSimpleCount("chat_messages", 1, "frontend/chat", null);
+ addUniquenessCount("active_chatters", 1, "frontend/chat", null, "userId");
+
+ addSimpleCount("exceptions", 1, "frontend/exception", null);
+
+ addSimpleCount("eepnet_trial_downloads", 1, "frontend/eepnet_download_info", null);
+
+ addSimpleSum("revenue", 1, "frontend/billing", typeMatcher("purchase-complete"), "dollars")
+
+ var hostRegExp = new RegExp("^https?:\\/\\/([-a-zA-Z0-9]+.)?"+expectedHostnames()+"\\/");
+ addTopValuesStat("top_referers", 1, "frontend/request",
+ invertMatcher(fieldMatcher(
+ "referer", hostRegExp)),
+ "referer");
+
+ addTopValuesStat("paths_404", 1, "frontend/request", fieldMatcher("statusCode", 404), "path");
+ addTopValuesStat("paths_500", 1, "frontend/request", fieldMatcher("statusCode", 500), "path");
+ addTopValuesStat("paths_exception", 1, "frontend/exception", null, "path");
+
+ addTopValuesStat("top_exceptions", 1, ["frontend/exception", "backend/exceptions"],
+ invertMatcher(fieldMatcher("trace", undefined)),
+ "trace", function(trace) {
+ var jstrace = trace.split("\n").filter(function(line) {
+ return /^\tat JS\$.*?\.js:\d+\)$/.test(line);
+ });
+ if (jstrace.length > 3) {
+ return "JS Exception:\n"+jstrace.slice(0, 10).join("\n").replace(/\t[^\(]*/g, "");
+ }
+ return trace.split("\n").slice(1, 10).join("\n").replace(/\t/g, "");
+ });
+
+ addReturningUserStat("users_1day_returning_7days", 1, 7);
+ addReturningUserStat("users_7day_returning_7days", 7, 7);
+ addReturningUserStat("users_30day_returning_7days", 30, 7);
+
+ addReturningUserStat("users_1day_returning_30days", 1, 30);
+ addReturningUserStat("users_7day_returning_30days", 7, 30);
+ addReturningUserStat("users_30day_returning_30days", 30, 30);
+
+ addReturningProAccountStat("pro_accounts_1day_returning_7days", 1, 7);
+ addReturningProAccountStat("pro_accounts_7day_returning_7days", 7, 7);
+ addReturningProAccountStat("pro_accounts_30day_returning_7days", 30, 7);
+
+ addReturningProAccountStat("pro_accounts_1day_returning_30days", 1, 30);
+ addReturningProAccountStat("pro_accounts_7day_returning_30days", 7, 30);
+ addReturningProAccountStat("pro_accounts_30day_returning_30days", 30, 30);
+
+
+ addDisconnectStat();
+ addTopValuesStat("disconnect_causes", 1, "frontend/avoidable_disconnects", null, "errorMessage");
+
+ var staticFileRegExp = /^\/static\/|^\/favicon.ico/;
+ addLatenciesStat("execution_latencies", 1, "backend/latency",
+ invertMatcher(fieldMatcher('path', staticFileRegExp)),
+ "path", "time");
+ addLatenciesStat("static_file_latencies", 1, "backend/latency",
+ fieldMatcher('path', staticFileRegExp),
+ "path", "time");
+
+ addUniquenessCount("disconnects_with_clientside_errors", 1,
+ ["frontend/reconnect", "frontend/disconnected_autopost"],
+ fieldMatcher("hasClientErrors", true), "uniqueId");
+
+ addTopValuesStat("imports_exports_counts", 1, "frontend/import-export",
+ typeMatcher("request"), "direction");
+
+ addPadStartupLatencyStat();
+
+ addCometLatencySampleTracker("streaming_latencies");
+ addConnectionTypeSampleTracker("streaming_connections");
+ // TODO: add more stats here.
+
+ setupStatsCollector();
+}
+
+//----------------------------------------------------------------
+// Log processing
+//----------------------------------------------------------------
+
+function _whichStats(statNames) {
+ var whichStats = _statData();
+ var logNamesToInterestedStatNames = _stats().logNamesToInterestedStatNames;
+
+ if (statNames) {
+ whichStats = {};
+ statNames.forEach(function(statName) { whichStats[statName] = getStatData(statName) });
+ logNamesToInterestedStatNames = _generateLogInterestMap(statNames);
+ }
+
+ return [whichStats, logNamesToInterestedStatNames];
+}
+
+function _initStatDataMap(statNames) {
+ var [whichStats, logNamesToInterestedStatNames] = _whichStats(statNames);
+
+ var statDataMap = {};
+
+ function initStat(statName, statObject) {
+ statDataMap[statName] =
+ _callFunction(statObject.init_f, statName, statObject.options, HIST);
+ }
+ eachProperty(whichStats, initStat);
+
+ return statDataMap;
+}
+
+function _saveStats(day, statDataMap, statNames) {
+ var [whichStats, logNamesToInterestedStatNames] = _whichStats(statNames);
+
+ function saveStat(statName, statObject) {
+ var value = _callFunction(statObject.snapshot_f,
+ statName, statObject.options, statDataMap[statName]).total;
+ if (typeof(value) != 'object') {
+ value = {value: value};
+ }
+ _saveStat(day, statName, value);
+ }
+ eachProperty(whichStats, saveStat);
+}
+
+function _processSingleDayLogs(day, logNamesToInterestedStatNames, statDataMap) {
+ var iterators = {};
+ keys(logNamesToInterestedStatNames).forEach(function(logName) {
+ var [prefix, logId] = logName.split("/");
+ var fileName = log.logFileName(prefix, logId, day);
+ if (! fileName) {
+ _info("No such file: "+logName+" on day "+day);
+ return;
+ }
+ iterators[logName] = fileLineIterator(fileName);
+ });
+
+ var numIterators = keys(iterators).length;
+ if (numIterators == 0) {
+ _info("No logs to process on day "+day);
+ return;
+ }
+ var sortedLogObjects = new java.util.PriorityQueue(numIterators,
+ new java.util.Comparator({
+ compare: function(o1, o2) { return o1.logObject.date - o2.logObject.date }
+ }));
+
+ function lineToLogObject(logName, json) {
+ return {logName: logName, logObject: fastJSON.parse(json)};
+ }
+
+ // begin by filling the queue with one object from each log.
+ eachProperty(iterators, function(logName, iterator) {
+ if (iterator.hasNext) {
+ sortedLogObjects.add(lineToLogObject(logName, iterator.next));
+ }
+ });
+
+ // update with all log objects, in date order (enforced by priority queue).
+ while (! sortedLogObjects.isEmpty()) {
+ var nextObject = sortedLogObjects.poll();
+ var logName = nextObject.logName;
+
+ keys(logNamesToInterestedStatNames[logName]).forEach(function(statName) {
+ var statObject = getStatData(statName);
+ _callFunction(statObject.update_f,
+ statName, statObject.options, logName, statDataMap[statName], nextObject.logObject);
+ });
+
+ // get next entry from this log, if there is one.
+ if (iterators[logName].hasNext) {
+ sortedLogObjects.add(lineToLogObject(logName, iterators[logName].next));
+ }
+ }
+}
+
+function processStatsForDay(day, statNames, statDataMap) {
+ var [whichStats, logNamesToInterestedStatNames] = _whichStats(statNames);
+
+ // process the logs, notifying the right statistics updaters.
+ _processSingleDayLogs(day, logNamesToInterestedStatNames, statDataMap);
+}
+
+//----------------------------------------------------------------
+// Daily update
+//----------------------------------------------------------------
+serverhandlers.tasks.statisticsDailyUpdate = function() {
+// do nothing for now.
+
+// dailyUpdate();
+};
+
+function _scheduleNextDailyUpdate() {
+ // Run at 1:11am every day
+ var now = +(new Date);
+ var tomorrow = new Date(now + 1000*60*60*24);
+ tomorrow.setHours(1);
+ tomorrow.setMinutes(11);
+ tomorrow.setMilliseconds(111);
+ log.info("Scheduling next daily statistics update for: "+tomorrow.toString());
+ var delay = +tomorrow - (+(new Date));
+ execution.scheduleTask("statistics", "statisticsDailyUpdate", delay, []);
+}
+
+function processStatsAsOfDay(date, statNames) {
+ var latestDay = noon(new Date(date - 1000*60*60*24));
+
+ _processLogsForNeededDays(latestDay, statNames);
+}
+
+function _processLogsForNeededDays(latestDay, statNames) {
+ if (! statNames) {
+ statNames = getAllStatNames();
+ }
+ var statDataMap = _initStatDataMap(statNames);
+
+ var agesToStats = [];
+ var atLeastOneStat = true;
+ for (var i = 0; atLeastOneStat; ++i) {
+ atLeastOneStat = false;
+ agesToStats[i] = [];
+ statNames.forEach(function(statName) {
+ var statData = getStatData(statName);
+ if (statData.historicalDays > i) {
+ atLeastOneStat = true;
+ agesToStats[i].push(statName);
+ }
+ });
+ }
+ agesToStats.pop();
+
+ for (var i = agesToStats.length-1; i >= 0; --i) {
+ var day = new Date(+latestDay - i*24*60*60*1000);
+ processStatsForDay(day, agesToStats[i], statDataMap);
+ }
+ _saveStats(latestDay, statDataMap, statNames);
+}
+
+function doDailyUpdate(date) {
+ var now = (date === undefined ? new Date() : date);
+ var yesterdayNoon = noon(new Date(+now - 1000*60*60*24));
+
+ _processLogsForNeededDays(yesterdayNoon);
+}
+
+function dailyUpdate() {
+ try {
+ doDailyUpdate();
+ } catch (ex) {
+ log.warn("statistics.dailyUpdate() failed: "+ex.toString());
+ } finally {
+ _scheduleNextDailyUpdate();
+ }
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/store/checkout.js b/trunk/etherpad/src/etherpad/store/checkout.js
new file mode 100644
index 0000000..2a4d7e7
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/store/checkout.js
@@ -0,0 +1,300 @@
+/**
+ * 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("dateutils");
+import("email.sendEmail");
+import("jsutils.*");
+import("sqlbase.sqlobj");
+import("stringutils");
+import("sync");
+
+import("etherpad.globals");
+import("etherpad.globals.*");
+import("etherpad.licensing");
+import("etherpad.utils.*");
+
+import("static.js.billing_shared.{billing=>billingJS}");
+
+function dollars(x, nocommas) {
+ if (! x) { return "0.00"; }
+ var s = String(x);
+ var dollars = s.split('.')[0];
+ var pennies = s.split('.')[1];
+
+ if (!dollars) {
+ dollars = "0";
+ }
+
+ if (!nocommas && dollars.length > 3) {
+ var newDollars = [];
+ newDollars.push(dollars[dollars.length-1]);
+
+ for (var i = 1; i < dollars.length; ++i) {
+ if (i % 3 == 0) {
+ newDollars.push(",");
+ }
+ newDollars.push(dollars[dollars.length-1-i]);
+ }
+ dollars = newDollars.reverse().join('');
+ }
+
+ if (!pennies) {
+ pennies = "00";
+ }
+
+ if (pennies.length == 1) {
+ pennies = pennies + "0";
+ }
+
+ if (pennies.length > 2) {
+ pennies = pennies.substr(0,2);
+ }
+
+ return [dollars,pennies].join('.');
+}
+
+function obfuscateCC(x) {
+ if (x.length == 16 || x.length == 15) {
+ return stringutils.repeat("X", x.length-4) + x.substr(-4);
+ } else {
+ return x;
+ }
+}
+
+
+// validation functions
+
+function isOnlyDigits(s) {
+ return /^[0-9]+$/.test(s);
+}
+
+function isOnlyLettersAndSpaces(s) {
+ return /^[a-zA-Z ]+$/.test(s);
+}
+
+function isLength(s, minLen, maxLen) {
+ if (maxLen === undefined) {
+ return (typeof(s) == 'string' && s.length == minLen);
+ } else {
+ return (typeof(s) == 'string' && s.length >= minLen && s.length <= maxLen);
+ }
+}
+
+function errorMissing(validationError, name, description) {
+ validationError(name, "Please enter a "+description+".");
+}
+
+function errorTooSomething(validationError, name, description, max, tooWhat, betterAdjective) {
+ validationError(name, "Your "+description+" is too " + tooWhat + "; please provide a "+description+
+ " that is "+max+" characters or "+betterAdjective);
+}
+
+function validateString(validationError, s, name, description, mustExist, maxLength, minLength) {
+ if (mustExist && ! s) {
+ errorMissing(validationError, name, description);
+ }
+ if (s && s.length > maxLength) {
+ errorTooSomething(validationError, name, description, maxLength, "long", "shorter");
+ }
+ if (minLength > 0 && s.length < minLength) {
+ errorTooSomething(validationError, name, description, minLength, "short", "longer");
+ }
+}
+
+function validateZip(validationError, s) {
+ if (! s) {
+ errorMissing(validationError, 'billingZipCode', "ZIP code");
+ }
+ if (! (/^\d{5}(-\d{4})?$/.test(s))) {
+ validationError('billingZipCode', "Please enter a valid ZIP code");
+ }
+}
+
+function validateBillingCart(validationError, cart) {
+ var p = cart;
+
+ if (! isOnlyLettersAndSpaces(p.billingFirstName)) {
+ validationError("billingFirstName", "Name fields may only contain alphanumeric characters.");
+ }
+
+ if (! isOnlyLettersAndSpaces(p.billingLastName)) {
+ validationError("billingLastName", "Name fields may only contain alphanumeric characters.");
+ }
+
+ var validPurchaseTypes = arrayToSet(['creditcard', 'invoice', 'paypal']);
+ if (! p.billingPurchaseType in validPurchaseTypes) {
+ validationError("billingPurchaseType", "Please select a valid purchase type.")
+ }
+
+ switch (p.billingPurchaseType) {
+ case 'creditcard':
+ if (! billingJS.validateCcNumber(p.billingCCNumber)) {
+ validationError("billingCCNumber", "Your card number doesn't appear to be valid.");
+ }
+ if (! isOnlyDigits(p.billingExpirationMonth) ||
+ ! isLength(p.billingExpirationMonth, 1, 2)) {
+ validationError("billingMeta", "Invalid expiration month.");
+ }
+ if (! isOnlyDigits(p.billingExpirationYear) ||
+ ! isLength(p.billingExpirationYear, 1, 2)) {
+ validationError("billingMeta", "Invalid expiration year.");
+ }
+ if (Number("20"+p.billingExpirationYear) <= (new Date()).getFullYear() &&
+ Number(p.billingExpirationMonth) < (new Date()).getMonth()+1) {
+ validationError("billingMeta", "Invalid expiration date.");
+ }
+ var ccType = billingJS.getCcType(p.billingCCNumber);
+ if (! isOnlyDigits(p.billingCSC) ||
+ ! isLength(p.billingCSC, (ccType == 'amex' ? 4 : 3))) {
+ validationError("billingMeta", "Invalid CSC.");
+ }
+ // falling through here!
+ case 'invoice':
+ validateString(validationError, p.billingCountry, "billingCountry", "country name", true, 2);
+ validateString(validationError, p.billingAddressLine1, "billingAddressLine1", "billing address", true, 100);
+ validateString(validationError, p.billingAddressLine2, "billingAddressLine2", "billing address", false, 100);
+ validateString(validationError, p.billingCity, "billingCity", "city name", true, 40);
+ if (p.billingCountry == "US") {
+ validateString(validationError, p.billingState, "billingState", "state name", true, 2);
+ validateZip(validationError, p.billingZipCode);
+ } else {
+ validateString(validationError, p.billingProvince, "billingProvince", "province name", true, 40, 1);
+ validateString(validationError, p.billingPostalCode, "billingPostalCode", "postal code", true, 20, 5);
+ }
+ }
+}
+
+function _cardType(number) {
+ var cardType = billingJS.getCcType(number);
+ switch (cardType) {
+ case 'visa':
+ return "Visa";
+ case 'amex':
+ return "Amex";
+ case 'disc':
+ return "Discover";
+ case 'mc':
+ return "MasterCard";
+ }
+}
+
+function generatePayInfo(cart) {
+ var isUs = cart.billingCountry == "US";
+
+ var payInfo = {
+ cardType: _cardType(cart.billingCCNumber),
+ cardNumber: cart.billingCCNumber,
+ cardExpiration: ""+cart.billingExpirationMonth+"20"+cart.billingExpirationYear,
+ cardCvv: cart.billingCSC,
+
+ nameSalutation: "",
+ nameFirst: cart.billingFirstName,
+ nameMiddle: "",
+ nameLast: cart.billingLastName,
+ nameSuffix: "",
+
+ addressStreet: cart.billingAddressLine1,
+ addressStreet2: cart.billingAddressLine2,
+ addressCity: cart.billingCity,
+ addressState: (isUs ? cart.billingState : cart.billingProvince),
+ addressZip: (isUs ? cart.billingZipCode : cart.billingPostalCode),
+ addressCountry: cart.billingCountry
+ }
+
+ return payInfo;
+}
+
+var billingCartFieldMap = {
+ cardType: {f: ["billingCCNumber"], d: "credit card number"},
+ cardNumber: { f: ["billingCCNumber"], d: "credit card number"},
+ cardExpiration: { f: ["billingMeta", "billingMeta"], d: "expiration date" },
+ cardCvv: { f: ["billingMeta"], d: "card security code" },
+ card: { f: ["billingCCNumber", "billingMeta"], d: "credit card"},
+ nameFirst: { f: ["billingFirstName"], d: "first name" },
+ nameLast: {f: ["billingLastName"], d: "last name" },
+ addressStreet: { f: ["billingAddressLine1"], d: "billing address" },
+ addressStreet2: { f: ["billingAddressLine2"], d: "billing address" },
+ addressCity: { f: ["billingCity"], d: "city" },
+ addressState: { f: ["billingState", "billingProvince"], d: "state or province" },
+ addressCountry: { f: ["billingCountry"], d: "country" },
+ addressZip: { f: ["billingZipCode", "billingPostalCode"], d: "ZIP or postal code" },
+ address: { f: ["billingAddressLine1", "billingAddressLine2", "billingCity", "billingState", "billingCountry", "billingZipCode"], d: "address" }
+}
+
+function validateErrorFields(validationError, errorPrefix, fieldList) {
+ if (fieldList.length > 0) {
+ var errorMsg;
+ var errorFields;
+ errorMsg = errorPrefix +
+ fieldList.map(function(field) { return billingCartFieldMap[field].d }).join(", ") +
+ ".";
+ errorFields = [];
+ fieldList.forEach(function(field) {
+ errorFields = errorFields.concat(billingCartFieldMap[field].f);
+ });
+ validationError(errorFields, errorMsg);
+ }
+}
+
+function guessBillingNames(cart, name) {
+ if (! cart.billingFirstName && ! cart.billingLastName) {
+ var nameParts = name.split(/\s+/);
+ if (nameParts.length == 1) {
+ cart.billingFirstName = nameParts[0];
+ } else {
+ cart.billingLastName = nameParts[nameParts.length-1];
+ cart.billingFirstName = nameParts.slice(0, nameParts.length-1).join(' ');
+ }
+ }
+}
+
+function writeToEncryptedLog(s) {
+ if (! appjet.config["etherpad.billingEncryptedLog"]) {
+ // no need to log, this probably isn't the live server.
+ return;
+ }
+ var e = net.appjet.oui.Encryptomatic;
+ sync.callsyncIfTrue(appjet.cache,
+ function() { return ! appjet.cache.billingEncryptedLog },
+ function() {
+ appjet.cache.billingEncryptedLog = {
+ writer: new java.io.FileWriter(appjet.config["etherpad.billingEncryptedLog"], true),
+ key: e.readPublicKey("RSA", new java.io.FileInputStream(appjet.config["etherpad.billingPublicKey"]))
+ }
+ });
+ var l = appjet.cache.billingEncryptedLog;
+ sync.callsync(l, function() {
+ l.writer.write(e.bytesToAscii(e.encrypt(
+ new java.io.ByteArrayInputStream((new java.lang.String(s)).getBytes("UTF-8")),
+ l.key))+"\n");
+ l.writer.flush();
+ })
+}
+
+function formatExpiration(expiration) {
+ return dateutils.shortMonths[Number(expiration.substr(0, 2))-1]+" "+expiration.substr(2);
+}
+
+function formatDate(date) {
+ return dateutils.months[date.getMonth()]+" "+date.getDate()+", "+date.getFullYear();
+}
+
+function salesEmail(to, from, subject, headers, body) {
+ sendEmail(to, from, subject, headers, body);
+ if (globals.isProduction()) {
+ sendEmail("sales@pad.spline.inf.fu-berlin.de", from, subject, headers, body);
+ }
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/store/eepnet_checkout.js b/trunk/etherpad/src/etherpad/store/eepnet_checkout.js
new file mode 100644
index 0000000..62137d3
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/store/eepnet_checkout.js
@@ -0,0 +1,101 @@
+/**
+ * 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("email.sendEmail");
+import("sqlbase.sqlobj");
+import("stringutils");
+
+import("etherpad.globals");
+import("etherpad.globals.*");
+import("etherpad.licensing");
+import("etherpad.utils.*");
+import("etherpad.store.checkout.*");
+
+var COST_PER_USER = 99;
+var SUPPORT_COST_PCT = 20;
+var SUPPORT_MIN_COST = 50;
+
+function getPurchaseByEmail(email) {
+ return sqlobj.selectSingle('checkout_purchase', {email: email});
+}
+
+function hasEmailAlreadyPurchased(email) {
+ var purchase = getPurchaseByEmail(email);
+ return purchase && purchase.licenseKey ? true : false;
+}
+
+function mailLostLicense(email) {
+ var purchase = getPurchaseByEmail(email);
+ if (purchase && purchase.licenseKey) {
+ sendLicenseEmail({
+ email: email,
+ ownerName: purchase.owner,
+ orgName: purchase.organization,
+ licenseKey: purchase.licenseKey
+ });
+ }
+}
+
+function _updatePurchaseWithKey(id, key) {
+ sqlobj.updateSingle('checkout_purchase', {id: id}, {licenseKey: key});
+}
+
+function updatePurchaseWithReceipt(id, text) {
+ sqlobj.updateSingle('checkout_purchase', {id: id}, {receiptEmail: text});
+}
+
+function getPurchaseByInvoiceId(id) {
+ sqlobj.selectSingle('checkout_purchase', {invoiceId: id});
+}
+
+function generateLicenseKey(cart) {
+ var licenseKey = licensing.generateNewKey(cart.ownerName, cart.orgName, null, 2, cart.userCount);
+ cart.licenseKey = licenseKey;
+ _updatePurchaseWithKey(cart.customerId, cart.licenseKey);
+ return licenseKey;
+}
+
+function receiptEmailText(cart) {
+ return renderTemplateAsString('email/eepnet_purchase_receipt.ejs', {
+ cart: cart,
+ dollars: dollars,
+ obfuscateCC: obfuscateCC
+ });
+}
+
+function licenseEmailText(userName, licenseKey) {
+ return renderTemplateAsString('email/eepnet_license_info.ejs', {
+ userName: userName,
+ licenseKey: licenseKey,
+ isEvaluation: false
+ });
+}
+
+function sendReceiptEmail(cart) {
+ var receipt = cart.receiptEmail || receiptEmailText(cart);
+
+ salesEmail(cart.email, "sales@pad.spline.inf.fu-berlin.de",
+ "EtherPad: Receipt for "+cart.ownerName+" ("+cart.orgName+")",
+ {}, receipt);
+}
+
+function sendLicenseEmail(cart) {
+ var licenseEmail = licenseEmailText(cart.ownerName, cart.licenseKey);
+
+ salesEmail(cart.email, "sales@pad.spline.inf.fu-berlin.de",
+ "EtherPad: License Key for "+cart.ownerName+" ("+cart.orgName+")",
+ {}, licenseEmail);
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/store/eepnet_trial.js b/trunk/etherpad/src/etherpad/store/eepnet_trial.js
new file mode 100644
index 0000000..570d351
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/store/eepnet_trial.js
@@ -0,0 +1,241 @@
+/**
+ * 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("email.sendEmail");
+import("sqlbase.sqlobj");
+import("sqlbase.sqlcommon");
+import("execution");
+
+import("etherpad.sessions.getSession");
+import("etherpad.log");
+import("etherpad.licensing");
+import("etherpad.utils.*");
+import("etherpad.globals.*");
+
+//----------------------------------------------------------------
+
+function getTrialDays() {
+ return 30;
+}
+
+function getTrialUserQuota() {
+ return 100;
+}
+
+function mailLicense(data, licenseKey, expiresDate) {
+ var toAddr = data.email;
+ if (isTestEmail(toAddr)) {
+ toAddr = "blackhole@appjet.com";
+ }
+ var subject = ('EtherPad: Trial License Information for '+
+ data.firstName+' '+data.lastName+' ('+data.orgName+')');
+
+ var emailBody = renderTemplateAsString("email/eepnet_license_info.ejs", {
+ userName: data.firstName+" "+data.lastName,
+ licenseKey: licenseKey,
+ expiresDate: expiresDate,
+ isEvaluation: true
+ });
+
+ sendEmail(
+ toAddr,
+ 'sales@pad.spline.inf.fu-berlin.de',
+ subject,
+ {},
+ emailBody
+ );
+}
+
+function mailLostLicense(email) {
+ var data = sqlobj.selectSingle('eepnet_signups', {email: email});
+ var keyInfo = licensing.decodeLicenseInfoFromKey(data.licenseKey);
+ var expiresDate = keyInfo.expiresDate;
+
+ mailLicense(data, data.licenseKey, expiresDate);
+}
+
+function hasEmailAlreadyDownloaded(email) {
+ var existingRecord = sqlobj.selectSingle('eepnet_signups', {email: email});
+ if (existingRecord) {
+ return true;
+ } else {
+ return false
+ }
+}
+
+function createAndMailNewLicense(data) {
+ sqlcommon.inTransaction(function() {
+ var expiresDate = new Date(+(new Date)+(1000*60*60*24*getTrialDays()));
+ var licenseKey = licensing.generateNewKey(
+ data.firstName + ' ' + data.lastName,
+ data.orgName,
+ +expiresDate,
+ licensing.getEditionId('PRIVATE_NETWORK_EVALUATION'),
+ getTrialUserQuota()
+ );
+
+ // confirm key
+ if (!licensing.isValidKey(licenseKey)) {
+ throw Error("License key I just created is not valid: "+l);
+ }
+
+ // Log all this precious info
+ _logDownloadData(data, licenseKey);
+
+ // Store in database
+ sqlobj.insert("eepnet_signups", {
+ firstName: data.firstName,
+ lastName: data.lastName,
+ email: data.email,
+ orgName: data.orgName,
+ jobTitle: data.jobTitle,
+ date: new Date(),
+ signupIp: String(request.clientAddr).substr(0,16),
+ estUsers: data.estUsers,
+ licenseKey: licenseKey,
+ phone: data.phone,
+ industry: data.industry
+ });
+
+ mailLicense(data, licenseKey, expiresDate);
+
+ // Send sales notification
+ var clientAddr = request.clientAddr;
+ var initialReferer = getSession().initialReferer;
+ execution.async(function() {
+ _sendSalesNotification(data, clientAddr, initialReferer);
+ });
+
+ }); // end transaction
+}
+
+function _logDownloadData(data, licenseKey) {
+ log.custom("eepnet_download_info", {
+ email: data.email,
+ firstName: data.firstName,
+ lastName: data.lastName,
+ org: data.orgName,
+ jobTitle: data.jobTitle,
+ phone: data.phone,
+ estUsers: data.estUsers,
+ licenseKey: licenseKey,
+ ip: request.clientAddr,
+ industry: data.industry,
+ referer: getSession().initialReferer
+ });
+}
+
+function getWeb2LeadData(data, ip, ref) {
+ var googleQuery = extractGoogleQuery(ref);
+ var w2ldata = {
+ oid: "00D80000000b7ey",
+ first_name: data.firstName,
+ last_name: data.lastName,
+ email: data.email,
+ company: data.orgName,
+ title: data.jobTitle,
+ phone: data.phone,
+ '00N80000003FYtG': data.estUsers,
+ '00N80000003FYto': ref,
+ '00N80000003FYuI': googleQuery,
+ lead_source: 'EEPNET Download',
+ industry: data.industry
+ };
+
+ if (!isProduction()) {
+// w2ldata.debug = "1";
+// w2ldata.debugEmail = "aaron@appjet.com";
+ }
+
+ return w2ldata;
+}
+
+function _sendSalesNotification(data, ip, ref) {
+ var hostname = ipToHostname(ip) || "unknown";
+
+ var subject = "EEPNET Trial Download: "+[data.orgName, data.firstName + ' ' + data.lastName, data.email].join(" / ");
+
+ var body = [
+ "",
+ "This is an automated message.",
+ "",
+ "Somebody downloaded a "+getTrialDays()+"-day trial of EEPNET.",
+ "",
+ "This lead should be automatically added to the AppJet salesforce account.",
+ "",
+ "Organization: "+data.orgName,
+ "Industry: "+data.industry,
+ "Full Name: "+data.firstName + ' ' + data.lastName,
+ "Job Title: "+data.jobTitle,
+ "Email: "+data.email,
+ 'Phone: '+data.phone,
+ "Est. Users: "+data.estUsers,
+ "IP Address: "+ip+" ("+hostname+")",
+ "Session Referer: "+ref,
+ ""
+ ].join("\n");
+
+ var toAddr = 'sales@pad.spline.inf.fu-berlin.de';
+ if (isTestEmail(data.email)) {
+ toAddr = 'blackhole@appjet.com';
+ }
+ sendEmail(
+ toAddr,
+ 'sales@pad.spline.inf.fu-berlin.de',
+ subject,
+ {'Reply-To': data.email},
+ body
+ );
+}
+
+function getSalesforceIndustryList() {
+ return [
+ '--None--',
+ 'Agriculture',
+ 'Apparel',
+ 'Banking',
+ 'Biotechnology',
+ 'Chemicals',
+ 'Communications',
+ 'Construction',
+ 'Consulting',
+ 'Education',
+ 'Electronics',
+ 'Energy',
+ 'Engineering',
+ 'Entertainment',
+ 'Environmental',
+ 'Finance',
+ 'Food & Beverage',
+ 'Government',
+ 'Healthcare',
+ 'Hospitality',
+ 'Insurance',
+ 'Machinery',
+ 'Manufacturing',
+ 'Media',
+ 'Not For Profit',
+ 'Other',
+ 'Recreation',
+ 'Retail',
+ 'Shipping',
+ 'Technology',
+ 'Telecommunications',
+ 'Transportation',
+ 'Utilities'
+ ];
+}
+
diff --git a/trunk/etherpad/src/etherpad/testing/testutils.js b/trunk/etherpad/src/etherpad/testing/testutils.js
new file mode 100644
index 0000000..eac7840
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/testing/testutils.js
@@ -0,0 +1,23 @@
+/**
+ * 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.
+ */
+
+function assertTruthy(x) {
+ if (!x) {
+ throw new Error("assertTruthy failure: "+x);
+ }
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/testing/unit_tests/t0000_test.js b/trunk/etherpad/src/etherpad/testing/unit_tests/t0000_test.js
new file mode 100644
index 0000000..9e0e78b
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/testing/unit_tests/t0000_test.js
@@ -0,0 +1,22 @@
+/**
+ * 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.
+ */
+
+
+function run() {
+ return "This is a test test.";
+}
+
+
diff --git a/trunk/etherpad/src/etherpad/testing/unit_tests/t0001_sqlbase_transaction_rollback.js b/trunk/etherpad/src/etherpad/testing/unit_tests/t0001_sqlbase_transaction_rollback.js
new file mode 100644
index 0000000..96a74e4
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/testing/unit_tests/t0001_sqlbase_transaction_rollback.js
@@ -0,0 +1,48 @@
+/**
+ * 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("sqlbase.sqlcommon.{withConnection,inTransaction,closing}");
+import("sqlbase.sqlobj");
+
+import("etherpad.testing.testutils.*");
+
+function run() {
+
+ withConnection(function(conn) {
+ var s = conn.createStatement();
+ closing(s, function() {
+ s.execute("delete from just_a_test");
+ });
+ });
+
+ sqlobj.insert("just_a_test", {id: 1, x: "a"});
+
+ try { // this should fail
+ inTransaction(function(conn) {
+ sqlobj.updateSingle("just_a_test", {id: 1}, {id: 1, x: "b"});
+ // note: this will be pritned to the console, but that's OK
+ throw Error();
+ });
+ } catch (e) {}
+
+ var testRecord = sqlobj.selectSingle("just_a_test", {id: 1});
+
+ assertTruthy(testRecord.x == "a");
+}
+
+
+
+
diff --git a/trunk/etherpad/src/etherpad/testing/unit_tests/t0002_license_generation.js b/trunk/etherpad/src/etherpad/testing/unit_tests/t0002_license_generation.js
new file mode 100644
index 0000000..67c79d8
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/testing/unit_tests/t0002_license_generation.js
@@ -0,0 +1,89 @@
+/**
+ * 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("stringutils");
+import("sqlbase.sqlobj");
+
+import("etherpad.licensing");
+
+jimport("java.util.Random");
+
+function run() {
+ var r = new Random(0);
+
+ function testLicense(name, org, expires, editionId, userQuota) {
+ function keydataString() {
+ return "{name: "+name+", org: "+org+", expires: "+expires+", editionId: "+editionId+", userQuota: "+userQuota+"}";
+ }
+ var key = licensing.generateNewKey(name, org, expires, editionId, userQuota);
+ var info = licensing.decodeLicenseInfoFromKey(key);
+ if (!info) {
+ println("Generated key does not decode at all: "+keydataString());
+ println(" generated key: "+key);
+ throw new Error("Generated key does not decode at all. See stdout.");
+ }
+ function testMatch(name, x, y) {
+ if (x != y) {
+ println("key match error ("+name+"): ["+x+"] != ["+y+"]");
+ println(" key data: "+keydataString());
+ println(" generated key: "+key);
+ println(" decoded key: "+info.toSource());
+ throw new Error(name+" mismatch. see stdout.");
+ }
+ }
+ testMatch("personName", info.personName, name);
+ testMatch("orgName", info.organizationName, org);
+ testMatch("expires", +info.expiresDate, +expires);
+ testMatch("editionName", info.editionName, licensing.getEditionName(editionId));
+ testMatch("userQuota", +info.userQuota, +userQuota);
+ }
+
+ testLicense("aaron", "test", +(new Date)+1000*60*60*24*30, licensing.getEditionId('PRIVATE_NETWORK_EVALUATION'), 1001);
+
+ for (var editionId = 0; editionId < 3; editionId++) {
+ for (var unlimitedUsers = 0; unlimitedUsers <= 1; unlimitedUsers++) {
+ for (var noExpiry = 0; noExpiry <= 1; noExpiry++) {
+ for (var j = 0; j < 100; j++) {
+ var name = stringutils.randomString(1+r.nextInt(39));
+ var org = stringutils.randomString(1+r.nextInt(39));
+ var expires = null;
+ if (noExpiry == 0) {
+ expires = +(new Date)+(1000*60*60*24*r.nextInt(100));
+ }
+ var userQuota = -1;
+ if (unlimitedUsers == 1) {
+ userQuota = r.nextInt(1e6);
+ }
+
+ testLicense(name, org, expires, editionId, userQuota);
+ }
+ }
+ }
+ }
+
+ // test that all previously generated keys continue to decode.
+ var historicalKeys = sqlobj.selectMulti('eepnet_signups', {}, {});
+ historicalKeys.forEach(function(d) {
+ var key = d.licenseKey;
+ if (key && !licensing.isValidKey(key)) {
+ throw new Error("Historical license key no longer validates: "+key);
+ }
+ });
+
+}
+
+
+
diff --git a/trunk/etherpad/src/etherpad/testing/unit_tests/t0003_persistent_vars.js b/trunk/etherpad/src/etherpad/testing/unit_tests/t0003_persistent_vars.js
new file mode 100644
index 0000000..0898fbe
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/testing/unit_tests/t0003_persistent_vars.js
@@ -0,0 +1,42 @@
+/**
+ * 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("sqlbase.persistent_vars");
+
+import("stringutils");
+
+import("etherpad.testing.testutils.*");
+
+function run() {
+ var varname = stringutils.randomString(50);
+ var varval = stringutils.randomString(50);
+
+ var x = persistent_vars.get(varname);
+ assertTruthy(!x);
+
+ persistent_vars.put(varname, varval);
+
+ for (var i = 0; i < 3; i++) {
+ x = persistent_vars.get(varname);
+ assertTruthy(x == varval);
+ }
+
+ persistent_vars.remove(varname);
+
+ var x = persistent_vars.get(varname);
+ assertTruthy(!x);
+}
+
diff --git a/trunk/etherpad/src/etherpad/testing/unit_tests/t0004_sqlobj.js b/trunk/etherpad/src/etherpad/testing/unit_tests/t0004_sqlobj.js
new file mode 100644
index 0000000..7f8c996
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/testing/unit_tests/t0004_sqlobj.js
@@ -0,0 +1,214 @@
+/**
+ * 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("jsutils.*");
+import("stringutils");
+
+import("sqlbase.sqlcommon");
+import("sqlbase.sqlobj");
+
+import("etherpad.globals.*");
+import("etherpad.testing.testutils.*");
+
+function run() {
+ cleanUpTables();
+ testGeneral();
+ testAlterColumn();
+ cleanUpTables();
+}
+
+function _getTestTableName() {
+ return 'sqlobj_unit_test_'+stringutils.randomString(10);
+}
+
+function testGeneral() {
+
+ if (isProduction()) {
+ return; // we dont run this in productin!
+ }
+
+ // create a test table
+ var tableName = _getTestTableName();
+
+ sqlobj.createTable(tableName, {
+ id: sqlobj.getIdColspec(),
+ varChar: 'VARCHAR(128)',
+ dateTime: sqlobj.getDateColspec("NOT NULL"),
+ int11: 'INT',
+ tinyInt: sqlobj.getBoolColspec("DEFAULT 0")
+ });
+
+ // add some columns
+ sqlobj.addColumns(tableName, {
+ a: 'VARCHAR(256)',
+ b: 'VARCHAR(256)',
+ c: 'VARCHAR(256)',
+ d: 'VARCHAR(256)'
+ });
+
+ // drop columns
+ sqlobj.dropColumn(tableName, 'c');
+ sqlobj.dropColumn(tableName, 'd');
+
+ // list tables and make sure it contains tableName
+ var l = sqlobj.listTables();
+ var found = false;
+ l.forEach(function(x) {
+ if (x == tableName) { found = true; }
+ });
+ assertTruthy(found);
+
+ if (sqlcommon.isMysql()) {
+ for (var i = 0; i < 3; i++) {
+ ['MyISAM', 'InnoDB'].forEach(function(e) {
+ sqlobj.setTableEngine(tableName, e);
+ assertTruthy(e == sqlobj.getTableEngine(tableName));
+ });
+ }
+ }
+
+ sqlobj.createIndex(tableName, ['a', 'b']);
+ sqlobj.createIndex(tableName, ['int11', 'a', 'b']);
+
+ // test null columns
+ for (var i = 0; i < 10; i++) {
+ var id = sqlobj.insert(tableName, {dateTime: new Date(), a: null, b: null});
+ sqlobj.deleteRows(tableName, {id: id});
+ }
+
+ //----------------------------------------------------------------
+ // data management
+ //----------------------------------------------------------------
+
+ // insert + selectSingle
+ function _randomDate() {
+ // millisecond accuracy is lost in DB.
+ var d = +(new Date);
+ d = Math.round(d / 1000) * 1000;
+ return new Date(d);
+ }
+ var obj_data_list = [];
+ for (var i = 0; i < 40; i++) {
+ var obj_data = {
+ varChar: stringutils.randomString(20),
+ dateTime: _randomDate(),
+ int11: +(new Date) % 10000,
+ tinyInt: !!(+(new Date) % 2),
+ a: "foo",
+ b: "bar"
+ };
+ obj_data_list.push(obj_data);
+
+ var obj_id = sqlobj.insert(tableName, obj_data);
+ var obj_result = sqlobj.selectSingle(tableName, {id: obj_id});
+
+ assertTruthy(obj_result.id == obj_id);
+ keys(obj_data).forEach(function(k) {
+ var d1 = obj_data[k];
+ var d2 = obj_result[k];
+ if (k == "dateTime") {
+ d1 = +d1;
+ d2 = +d2;
+ }
+ if (d1 != d2) {
+ throw Error("result mismatch ["+k+"]: "+d1+" != "+d2);
+ }
+ });
+ }
+
+ // selectMulti: no constraints, no options
+ var obj_result_list = sqlobj.selectMulti(tableName, {}, {});
+ assertTruthy(obj_result_list.length == obj_data_list.length);
+ // orderBy
+ ['int11', 'a', 'b'].forEach(function(colName) {
+ obj_result_list = sqlobj.selectMulti(tableName, {}, {orderBy: colName});
+ assertTruthy(obj_result_list.length == obj_data_list.length);
+ for (var i = 1; i < obj_result_list.length; i++) {
+ assertTruthy(obj_result_list[i-1][colName] <= obj_result_list[i][colName]);
+ }
+
+ obj_result_list = sqlobj.selectMulti(tableName, {}, {orderBy: "-"+colName});
+ assertTruthy(obj_result_list.length == obj_data_list.length);
+ for (var i = 1; i < obj_result_list.length; i++) {
+ assertTruthy(obj_result_list[i-1][colName] >= obj_result_list[i][colName]);
+ }
+ });
+
+ // selectMulti: with constraints
+ var obj_result_list1 = sqlobj.selectMulti(tableName, {tinyInt: true}, {});
+ var obj_result_list2 = sqlobj.selectMulti(tableName, {tinyInt: false}, {});
+ assertTruthy((obj_result_list1.length + obj_result_list2.length) == obj_data_list.length);
+ obj_result_list1.forEach(function(o) {
+ assertTruthy(o.tinyInt == true);
+ });
+ obj_result_list2.forEach(function(o) {
+ assertTruthy(o.tinyInt == false);
+ });
+
+ // updateSingle
+ obj_result_list1.forEach(function(o) {
+ o.a = "ttt";
+ sqlobj.updateSingle(tableName, {id: o.id}, o);
+ });
+ // update
+ sqlobj.update(tableName, {tinyInt: false}, {a: "fff"});
+ // verify
+ obj_result_list = sqlobj.selectMulti(tableName, {}, {});
+ obj_result_list.forEach(function(o) {
+ if (o.tinyInt) {
+ assertTruthy(o.a == "ttt");
+ } else {
+ assertTruthy(o.a == "fff");
+ }
+ });
+
+ // deleteRows
+ sqlobj.deleteRows(tableName, {a: "ttt"});
+ sqlobj.deleteRows(tableName, {a: "fff"});
+ // verify
+ obj_result_list = sqlobj.selectMulti(tableName, {}, {});
+ assertTruthy(obj_result_list.length == 0);
+}
+
+function cleanUpTables() {
+ // delete testing table (and any other old testing tables)
+ sqlobj.listTables().forEach(function(t) {
+ if (t.indexOf("sqlobj_unit_test") == 0) {
+ sqlobj.dropTable(t);
+ }
+ });
+}
+
+function testAlterColumn() {
+ var tableName = _getTestTableName();
+
+ sqlobj.createTable(tableName, {
+ x: 'INT',
+ a: 'INT NOT NULL',
+ b: 'INT NOT NULL'
+ });
+
+ if (sqlcommon.isMysql()) {
+ sqlobj.modifyColumn(tableName, 'a', 'INT');
+ sqlobj.modifyColumn(tableName, 'b', 'INT');
+ } else {
+ sqlobj.alterColumn(tableName, 'a', 'NULL');
+ sqlobj.alterColumn(tableName, 'b', 'NULL');
+ }
+
+ sqlobj.insert(tableName, {a: 5});
+}
+
diff --git a/trunk/etherpad/src/etherpad/testing/unit_tests/t0005_easysync.js b/trunk/etherpad/src/etherpad/testing/unit_tests/t0005_easysync.js
new file mode 100644
index 0000000..9cd3f21
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/testing/unit_tests/t0005_easysync.js
@@ -0,0 +1,22 @@
+/**
+ * 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("etherpad.collab.ace.easysync2_tests");
+
+function run() {
+ easysync2_tests.runTests();
+} \ No newline at end of file
diff --git a/trunk/etherpad/src/etherpad/usage_stats/usage_stats.js b/trunk/etherpad/src/etherpad/usage_stats/usage_stats.js
new file mode 100644
index 0000000..59074ed
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/usage_stats/usage_stats.js
@@ -0,0 +1,162 @@
+/**
+ * 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("execution");
+import("sqlbase.sqlobj");
+import("sqlbase.sqlcommon");
+import("jsutils.*");
+import("fastJSON");
+
+import("etherpad.log");
+import("etherpad.log.frontendLogFileName");
+import("etherpad.statistics.statistics");
+import("fileutils.eachFileLine");
+
+jimport("java.lang.System.out.println");
+jimport("java.io.BufferedReader");
+jimport("java.io.FileReader");
+jimport("java.io.File");
+jimport("java.awt.Color");
+
+jimport("org.jfree.chart.ChartFactory");
+jimport("org.jfree.chart.ChartUtilities");
+jimport("org.jfree.chart.JFreeChart");
+jimport("org.jfree.chart.axis.DateAxis");
+jimport("org.jfree.chart.axis.NumberAxis");
+jimport("org.jfree.chart.plot.XYPlot");
+jimport("org.jfree.chart.renderer.xy.XYLineAndShapeRenderer");
+jimport("org.jfree.data.time.Day");
+jimport("org.jfree.data.time.TimeSeries");
+jimport("org.jfree.data.time.TimeSeriesCollection");
+
+//----------------------------------------------------------------
+// Database reading/writing
+//----------------------------------------------------------------
+
+
+function _listStats(statName) {
+ return sqlobj.selectMulti('statistics', {name: statName}, {orderBy: '-timestamp'});
+}
+
+// public accessor
+function getStatData(statName) {
+ return _listStats(statName);
+}
+
+//----------------------------------------------------------------
+// HTML & Graph generating
+//----------------------------------------------------------------
+
+function respondWithGraph(statName) {
+ var width = 500;
+ var height = 300;
+ if (request.params.size) {
+ var parts = request.params.size.split('x');
+ width = +parts[0];
+ height = +parts[1];
+ }
+
+ var dataset = new TimeSeriesCollection();
+ var hideLegend = true;
+
+ switch (statistics.getStatData(statName).plotType) {
+ case 'line':
+ var ts = new TimeSeries(statName);
+
+ _listStats(statName).forEach(function(stat) {
+ var day = new Day(new java.util.Date(stat.timestamp * 1000));
+ ts.addOrUpdate(day, fastJSON.parse(stat.value).value);
+ });
+ dataset.addSeries(ts);
+ break;
+ case 'topValues':
+ hideLegend = false;
+ var stats = _listStats(statName);
+ if (stats.length == 0) break;
+ var latestStat = fastJSON.parse(stats[0].value);
+ var valuesToWatch = [];
+ var series = {};
+ var nLines = 5;
+ function forEachFirstN(n, stat, f) {
+ for (var i = 0; i < Math.min(n, stat.topValues.length); i++) {
+ f(stat.topValues[i].value, stat.topValues[i].count);
+ }
+ }
+ forEachFirstN(nLines, latestStat, function(value, count) {
+ valuesToWatch.push(value);
+ series[value] = new TimeSeries(value);
+ });
+ stats.forEach(function(stat) {
+ var day = new Day(new java.util.Date(stat.timestamp*1000));
+ var statData = fastJSON.parse(stat.value);
+ valuesToWatch.forEach(function(value) { series[value].addOrUpdate(day, 0); })
+ forEachFirstN(nLines, statData, function(value, count) {
+ if (series[value]) {
+ series[value].addOrUpdate(day, count);
+ }
+ });
+ });
+ valuesToWatch.forEach(function(value) {
+ dataset.addSeries(series[value]);
+ });
+ break;
+ case 'histogram':
+ hideLegend = false;
+ var stats = _listStats(statName);
+ percentagesToGraph = ["50", "90", "100"];
+ series = {};
+ percentagesToGraph.forEach(function(pct) {
+ series[pct] = new TimeSeries(pct+"%");
+ dataset.addSeries(series[pct]);
+ });
+ if (stats.length == 0) break;
+ stats.forEach(function(stat) {
+ var day = new Day(new java.util.Date(stat.timestamp*1000));
+ var statData = fastJSON.parse(stat.value);
+ eachProperty(series, function(pct, timeseries) {
+ timeseries.addOrUpdate(day, statData[pct] || 0);
+ });
+ });
+ break;
+ }
+
+ var domainAxis = new DateAxis("");
+ var rangeAxis = new NumberAxis();
+ var renderer = new XYLineAndShapeRenderer();
+
+ var numSeries = dataset.getSeriesCount();
+ var colors = [Color.blue, Color.red, Color.green, Color.orange, Color.pink, Color.magenta];
+ for (var i = 0; i < numSeries; ++i) {
+ renderer.setSeriesPaint(i, colors[i]);
+ renderer.setSeriesShapesVisible(i, false);
+ }
+
+ var plot = new XYPlot(dataset, domainAxis, rangeAxis, renderer);
+
+ var chart = new JFreeChart(plot);
+ chart.setTitle(statName);
+ if (hideLegend) {
+ chart.removeLegend();
+ }
+
+ var jos = new java.io.ByteArrayOutputStream();
+ ChartUtilities.writeChartAsJPEG(
+ jos, 1.0, chart, width, height);
+
+ response.setContentType('image/jpeg');
+ response.writeBytes(jos.toByteArray());
+}
+
diff --git a/trunk/etherpad/src/etherpad/utils.js b/trunk/etherpad/src/etherpad/utils.js
new file mode 100644
index 0000000..da9972f
--- /dev/null
+++ b/trunk/etherpad/src/etherpad/utils.js
@@ -0,0 +1,396 @@
+/**
+ * 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("exceptionutils");
+import("fileutils.{readFile,fileLastModified}");
+import("ejs.EJS");
+import("funhtml.*");
+import("stringutils");
+import("stringutils.startsWith");
+import("jsutils.*");
+
+import("etherpad.sessions");
+import("etherpad.sessions.getSession");
+import("etherpad.globals.*");
+import("etherpad.helpers");
+import("etherpad.collab.collab_server");
+import("etherpad.pad.model");
+import("etherpad.pro.domains");
+import("etherpad.pne.pne_utils");
+import("etherpad.pro.pro_utils");
+import("etherpad.pro.pro_config");
+import("etherpad.pro.pro_accounts");
+import("etherpad.pro.pro_accounts.getSessionProAccount");
+
+jimport("java.lang.System.out.print");
+jimport("java.lang.System.out.println");
+
+//----------------------------------------------------------------
+// utilities
+//----------------------------------------------------------------
+
+// returns globally-unique padId
+function randomUniquePadId() {
+ var id = stringutils.randomString(10);
+ while (model.accessPadGlobal(id, function(p) { return p.exists(); }, "r")) {
+ id = stringutils.randomString(10);
+ }
+ return id;
+}
+
+//----------------------------------------------------------------
+// template rendering
+//----------------------------------------------------------------
+
+function renderTemplateAsString(filename, data) {
+ data = data || {};
+ data.helpers = helpers; // global helpers
+
+ var f = "/templates/"+filename;
+ if (! appjet.scopeCache.ejs) {
+ appjet.scopeCache.ejs = {};
+ }
+ var cacheObj = appjet.scopeCache.ejs[filename];
+ if (cacheObj === undefined || fileLastModified(f) > cacheObj.mtime) {
+ var templateText = readFile(f);
+ cacheObj = {};
+ cacheObj.tmpl = new EJS({text: templateText, name: filename});
+ cacheObj.mtime = fileLastModified(f);
+ appjet.scopeCache.ejs[filename] = cacheObj;
+ }
+ var html = cacheObj.tmpl.render(data);
+ return html;
+}
+
+function renderTemplate(filename, data) {
+ response.write(renderTemplateAsString(filename, data));
+ if (request.acceptsGzip) {
+ response.setGzip(true);
+ }
+}
+
+function renderHtml(bodyFileName, data) {
+ var bodyHtml = renderTemplateAsString(bodyFileName, data);
+ response.write(renderTemplateAsString("html.ejs", {bodyHtml: bodyHtml}));
+ if (request.acceptsGzip) {
+ response.setGzip(true);
+ }
+}
+
+function renderFramedHtml(contentHtml) {
+ var getContentHtml;
+ if (typeof(contentHtml) == 'function') {
+ getContentHtml = contentHtml;
+ } else {
+ getContentHtml = function() { return contentHtml; }
+ }
+
+ var template = "framed/framedpage.ejs";
+ if (isProDomainRequest()) {
+ template = "framed/framedpage-pro.ejs";
+ }
+
+ renderHtml(template, {
+ renderHeader: renderMainHeader,
+ renderFooter: renderMainFooter,
+ getContentHtml: getContentHtml,
+ isProDomainRequest: isProDomainRequest(),
+ renderGlobalProNotice: pro_utils.renderGlobalProNotice
+ });
+}
+
+function renderFramed(bodyFileName, data) {
+ function _getContentHtml() {
+ return renderTemplateAsString(bodyFileName, data);
+ }
+ renderFramedHtml(_getContentHtml);
+}
+
+function renderFramedError(error) {
+ var content = DIV({className: 'fpcontent'},
+ DIV({style: "padding: 2em 1em;"},
+ DIV({style: "padding: 1em; border: 1px solid #faa; background: #fdd;"},
+ B("Error: "), error)));
+ renderFramedHtml(content);
+}
+
+function renderNotice(bodyFileName, data) {
+ renderNoticeString(renderTemplateAsString(bodyFileName, data));
+}
+
+function renderNoticeString(contentHtml) {
+ renderFramed("notice.ejs", {content: contentHtml});
+}
+
+function render404(noStop) {
+ response.reset();
+ response.setStatusCode(404);
+ renderFramedHtml(DIV({className: "fpcontent"},
+ DIV({style: "padding: 2em 1em;"},
+ DIV({style: "border: 1px solid #aaf; background: #def; padding: 1em; font-size: 150%;"},
+ "404 not found: "+request.path))));
+ if (! noStop) {
+ response.stop();
+ }
+}
+
+function render500(ex) {
+ response.reset();
+ response.setStatusCode(500);
+ var trace = null;
+ if (ex && (!isProduction())) {
+ trace = exceptionutils.getStackTracePlain(ex);
+ }
+ renderFramed("500_body.ejs", {trace: trace});
+}
+
+function _renderEtherpadDotComHeader(data) {
+ if (!data) {
+ data = {selected: ''};
+ }
+ data.html = stringutils.html;
+ data.UL = UL;
+ data.LI = LI;
+ data.A = A;
+ data.isPNE = isPrivateNetworkEdition();
+ return renderTemplateAsString("framed/framedheader.ejs", data);
+}
+
+function _renderProHeader(data) {
+ if (!pro_accounts.isAccountSignedIn()) {
+ return '<div style="height: 140px;">&nbsp;</div>';
+ }
+
+ var r = domains.getRequestDomainRecord();
+ if (!data) { data = {}; }
+ data.navSelection = (data.navSelection || appjet.requestCache.proTopNavSelection || '');
+ data.proDomainOrgName = pro_config.getConfig().siteName;
+ data.isPNE = isPrivateNetworkEdition();
+ data.account = getSessionProAccount();
+ data.validLicense = pne_utils.isServerLicensed();
+ data.pneTrackerHtml = pne_utils.pneTrackerHtml();
+ data.isAnEtherpadAdmin = sessions.isAnEtherpadAdmin();
+ data.fullSuperdomain = pro_utils.getFullSuperdomainHost();
+ return renderTemplateAsString("framed/framedheader-pro.ejs", data);
+}
+
+function renderMainHeader(data) {
+ if (isProDomainRequest()) {
+ return _renderProHeader(data);
+ } else {
+ return _renderEtherpadDotComHeader(data);
+ }
+}
+
+function renderMainFooter() {
+ return renderTemplateAsString("framed/framedfooter.ejs", {
+ isProDomainRequest: isProDomainRequest()
+ });
+}
+
+//----------------------------------------------------------------
+// isValidEmail
+//----------------------------------------------------------------
+
+// TODO: make better and use the better version on the client in
+// various places as well (pad.js and etherpad.js)
+function isValidEmail(x) {
+ return (x &&
+ ((x.length > 0) &&
+ (x.match(/^[\w\.\_\+\-]+\@[\w\_\-]+\.[\w\_\-\.]+$/))));
+}
+
+//----------------------------------------------------------------
+
+function timeAgo(d, now) {
+ if (!now) { now = new Date(); }
+
+ function format(n, word) {
+ n = Math.round(n);
+ return ('' + n + ' ' + word + (n != 1 ? 's' : '') + ' ago');
+ }
+
+ d = (+now - (+d)) / 1000;
+ if (d < 60) { return format(d, 'second'); }
+ d /= 60;
+ if (d < 60) { return format(d, 'minute'); }
+ d /= 60;
+ if (d < 24) { return format(d, 'hour'); }
+ d /= 24;
+ return format(d, 'day');
+};
+
+
+//----------------------------------------------------------------
+// linking to a set of new CGI parameters
+//----------------------------------------------------------------
+function qpath(m) {
+ var q = {};
+ if (request.query) {
+ request.query.split('&').forEach(function(kv) {
+ if (kv) {
+ var parts = kv.split('=');
+ q[parts[0]] = parts[1];
+ }
+ });
+ }
+ eachProperty(m, function(k,v) {
+ q[k] = v;
+ });
+ var r = request.path + '?';
+ eachProperty(q, function(k,v) {
+ if (v !== undefined && v !== null) {
+ r += ('&' + k + '=' + v);
+ }
+ });
+ return r;
+}
+
+//----------------------------------------------------------------
+
+function ipToHostname(ip) {
+ var DNS = Packages.org.xbill.DNS;
+
+ if (!DNS.Address.isDottedQuad(ip)) {
+ return null
+ }
+
+ try {
+ var addr = DNS.Address.getByAddress(ip);
+ return DNS.Address.getHostName(addr);
+ } catch (ex) {
+ return null;
+ }
+}
+
+function extractGoogleQuery(ref) {
+ ref = String(ref);
+ ref = ref.toLowerCase();
+ if (!(ref.indexOf("google") >= 0)) {
+ return "";
+ }
+
+ ref = ref.split('?')[1];
+
+ var q = "";
+ ref.split("&").forEach(function(x) {
+ var parts = x.split("=");
+ if (parts[0] == "q") {
+ q = parts[1];
+ }
+ });
+
+ q = decodeURIComponent(q);
+ q = q.replace(/\+/g, " ");
+
+ return q;
+}
+
+function isTestEmail(x) {
+ return (x.indexOf("+appjetseleniumtest+") >= 0);
+}
+
+function isPrivateNetworkEdition() {
+ return pne_utils.isPNE();
+}
+
+function isProDomainRequest() {
+ return pro_utils.isProDomainRequest();
+}
+
+function hasOffice() {
+ return appjet.config["etherpad.soffice"] || appjet.config["etherpad.sofficeConversionServer"];
+}
+
+////////// console progress bar
+
+function startConsoleProgressBar(barWidth, updateIntervalSeconds) {
+ barWidth = barWidth || 40;
+ updateIntervalSeconds = ((typeof updateIntervalSeconds) == "number" ? updateIntervalSeconds : 1.0);
+
+ var unseenStatus = null;
+ var lastPrintTime = 0;
+ var column = 0;
+
+ function replaceLineWith(str) {
+ //print((new Array(column+1)).join('\b')+str);
+ print('\r'+str);
+ column = str.length;
+ }
+
+ var bar = {
+ update: function(frac, msg, force) {
+ var t = +new Date();
+ if ((!force) && ((t - lastPrintTime)/1000 < updateIntervalSeconds)) {
+ unseenStatus = {frac:frac, msg:msg};
+ }
+ else {
+ var pieces = [];
+ pieces.push(' ', (' '+Math.round(frac*100)).slice(-3), '%', ' [');
+ var barEndLoc = Math.max(0, Math.min(barWidth-1, Math.floor(frac*barWidth)));
+ for(var i=0;i<barWidth;i++) {
+ if (i < barEndLoc) pieces.push('=');
+ else if (i == barEndLoc) pieces.push('>');
+ else pieces.push(' ');
+ }
+ pieces.push('] ', msg || '');
+ replaceLineWith(pieces.join(''));
+
+ unseenStatus = null;
+ lastPrintTime = t;
+ }
+ },
+ finish: function() {
+ if (unseenStatus) {
+ bar.update(unseenStatus.frac, unseenStatus.msg, true);
+ }
+ println();
+ }
+ };
+
+ println();
+ bar.update(0, null, true);
+
+ return bar;
+}
+
+function isStaticRequest() {
+ return (startsWith(request.path, '/static/') ||
+ startsWith(request.path, '/favicon.ico') ||
+ startsWith(request.path, '/robots.txt'));
+}
+
+function httpsHost(h) {
+ h = h.split(":")[0]; // strip any existing port
+ if (appjet.config.listenSecurePort != "443") {
+ h = (h + ":" + appjet.config.listenSecurePort);
+ }
+ return h;
+}
+
+function httpHost(h) {
+ h = h.split(":")[0]; // strip any existing port
+ if (appjet.config.listenPort != "80") {
+ h = (h + ":" + appjet.config.listenPort);
+ }
+ return h;
+}
+
+function toJavaException(e) {
+ var exc = ((e instanceof java.lang.Throwable) && e) || e.rhinoException || e.javaException ||
+ new java.lang.Throwable(e.message+"/"+e.fileName+"/"+e.lineNumber);
+ return exc;
+}