diff --git a/.gitignore b/.gitignore index 7615acd90..625f153d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ node_modules -settings.json +/settings.json !settings.json.template APIKEY.txt SESSIONKEY.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f95accf8..37978126b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ +# 1.8 +* FEATURE: code was migrated to `async`/`await`, getting rid of a lot of callbacks (see https://github.com/ether/etherpad-lite/issues/3540) +* FEATURE: support configuration via environment variables +* FEATURE: include an official Dockerfile in the main repository +* FEATURE: support including plugins in custom Docker builds +* REQUIREMENTS: minimum required Node version is **8.9.0 LTS**. Release 1.8.3 will require at least Node **10.13.0** LTS +* MINOR: in the HTTP API, allow URL parameters and POST bodies to co-exist +* MINOR: fix Unicode bug in HTML export +* MINOR: bugfixes to colibris chat window +* MINOR: code simplification (avoided double negations, introduced early exits, ...) +* MINOR: reduced the size of the Windows package +* MINOR: upgraded the nodejs runtime to 10.16.3 in the Windows package +* SECURITY: avoided XSS in IE11 +* SECURITY: the version is exposed in http header only when configured +* SECURITY: updated vendored jQuery version +* SECURITY: bumped dependencies + # 1.7.5 -* FEATURE: introduced support for multiple skins. See http://etherpad.org/doc/v1.7.5/#index_skins +* FEATURE: introduced support for multiple skins. See https://etherpad.org/doc/v1.7.5/#index_skins * FEATURE: added a new, optional skin. It can be activated choosing `skinName: "colibris"` in `settings.json` * FEATURE: allow file import using LibreOffice * SECURITY: updated many dependencies. No known high or moderate risk dependencies remain. @@ -297,7 +314,7 @@ finally put them back in their new location, uder `src/static/skins/no-skin`. # 1.3 * NEW: We now follow the semantic versioning scheme! * NEW: Option to disable IP logging - * NEW: Localisation updates from http://translatewiki.net. + * NEW: Localisation updates from https://translatewiki.net. * Fix: Fix readOnly group pads * Fix: don't fetch padList on every request diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11cf2f818..724e02ac0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ * contain meaningful and detailed **commit messages** in the form: ``` submodule: description - + longer description of the change you have made, eventually mentioning the number of the issue that is being fixed, in the form: Fixes #someIssueNumber ``` @@ -130,5 +130,4 @@ Etherpad is much more than software. So if you aren't a developer then worry no * Co-Author and Publish CVEs * Work with SFC to maintain legal side of project * Maintain TODO page - https://github.com/ether/etherpad-lite/wiki/TODO#IMPORTANT_TODOS - * Replying to messages on IRC / The Mailing list / Emails - + diff --git a/README.md b/README.md index 41ac95848..685ba8966 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,27 @@ -# A really-real time collaborative word processor for the web -![Demo Etherpad Animated Jif](https://i.imgur.com/zYrGkg3.gif "Etherpad in action on PrimaryPad") +# A real-time collaborative editor for the web +![Demo Etherpad Animated Jif](https://i.imgur.com/zYrGkg3.gif "Etherpad in action") # About -Etherpad is a really-real time collaborative editor scalable to thousands of simultaneous real time users. Unlike all other collaborative tools Etherpad provides full fidelity data export and portability making it fully GDPR compliant. +Etherpad is a real-time collaborative editor scalable to thousands of simultaneous real time users. It provides full data export capabilities, and runs on _your_ server, under _your_ control. **[Try it out](https://beta.etherpad.org)** # Installation ## Requirements -- `nodejs` >= **6.9.0** (preferred: `nodejs` >= **8.9**) +- `nodejs` >= **8.9.0** (preferred: `nodejs` >= **10.13.0**). Please note that starting Jan 1st, 2020, nodejs 8.x is deprecated. -## Uber-Quick Ubuntu +## GNU/Linux and other UNIX-like systems + +### Quick install on Debian/Ubuntu ``` -curl -sL https://deb.nodesource.com/setup_9.x | sudo -E bash - -sudo apt-get install -y nodejs +curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash - +sudo apt install -y nodejs git clone --branch master https://github.com/ether/etherpad-lite.git && cd etherpad-lite && bin/run.sh ``` -## GNU/Linux and other UNIX-like systems -You'll need git and [node.js](https://nodejs.org) installed (minimum required Node version: **6.9.0**, preferred: >= **8.9**). +### Manual install +You'll need git and [node.js](https://nodejs.org) installed (minimum required Node version: **8.9.0**, preferred: >= **10.13.0**). **As any user (we recommend creating a separate user called etherpad):** @@ -34,19 +36,19 @@ To update to the latest released version, execute `git pull origin`. The next st ## Windows ### Prebuilt Windows package -This package works out of the box on any windows machine, but it's not very useful for developing purposes... +This package runs on any Windows machine, but for development purposes, please do a manual install. -1. [Download the latest Windows package](http://etherpad.org/#download) +1. [Download the latest Windows package](https://etherpad.org/#download) 2. Extract the folder -Now, run `start.bat` and open in your browser. You like it? [Next steps](#next-steps). +Run `start.bat` and open in your browser. You like it? [Next steps](#next-steps). ### Manually install on Windows You'll need [node.js](https://nodejs.org) and (optionally, though recommended) git. 1. Grab the source, either - download - - or `git clone --branch master https://github.com/ether/etherpad-lite.git` (for this you need git, obviously) + - or `git clone --branch master https://github.com/ether/etherpad-lite.git` 2. start `bin\installOnWindows.bat` Now, run `start.bat` and open in your browser. @@ -65,9 +67,10 @@ If cloning to a subdirectory within another project, you may need to do the foll You can modify the settings in `settings.json`. If you need to handle multiple settings files, you can pass the path to a settings file to `bin/run.sh` using the `-s|--settings` option: this allows you to run multiple Etherpad instances from the same installation. Similarly, `--credentials` can be used to give a settings override file, `--apikey` to give a different APIKEY.txt file and `--sessionkey` to give a non-default SESSIONKEY.txt. -Once you have access to your /admin section settings can be modified through the web browser. +**Each configuration parameter can also be set via an environment variable**, using the syntax `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`. For details, refer to `settings.json.template`. +Once you have access to your `/admin` section settings can be modified through the web browser. -You should use a dedicated database such as "mysql", if you are planning on using etherpad-in a production environment, since the "dirtyDB" database driver is only for testing and/or development purposes. +If you are planning to use Etherpad in a production environment, you should use a dedicated database such as `mysql`, since the `dirtyDB` database driver is only for testing and/or development purposes. ## Secure your installation If you have enabled authentication in `users` section in `settings.json`, it is a good security practice to **store hashes instead of plain text passwords** in that file. This is _especially_ advised if you are running a production installation. @@ -87,10 +90,6 @@ Documentation can be found in `doc/`. # Development ## Things you should know -Understand [git](https://training.github.com/) and watch this [video on getting started with Etherpad Development](https://youtu.be/67-Q26YH97E). - -If you're new to node.js, start with Ryan Dahl's [Introduction to Node.js](https://youtu.be/jo_B4LTHi3I). - You can debug Etherpad using `bin/debugRun.sh`. If you want to find out how Etherpad's `Easysync` works (the library that makes it really realtime), start with this [PDF](https://github.com/ether/etherpad-lite/raw/master/doc/easysync/easysync-full-description.pdf) (complex, but worth reading). @@ -99,11 +98,9 @@ If you want to find out how Etherpad's `Easysync` works (the library that makes Read our [**Developer Guidelines**](https://github.com/ether/etherpad-lite/blob/master/CONTRIBUTING.md) # Get in touch -[mailinglist](https://groups.google.com/group/etherpad-lite-dev) -[#etherpad-lite-dev freenode IRC](https://webchat.freenode.net?channels=#etherpad-lite-dev)! +The official channel for contacting the development team is via the [Github issues](https://github.com/ether/etherpad-lite/issues). -# Languages -Etherpad is written in JavaScript on both the server and client so it's easy for developers to maintain and add new features. +For **responsible disclosure of vulnerabilities**, please write a mail to the maintainer (a.mux@inwind.it). # HTTP API Etherpad is designed to be easily embeddable and provides a [HTTP API](https://github.com/ether/etherpad-lite/wiki/HTTP-API) @@ -113,7 +110,7 @@ that allows your web application to manage pads, users and groups. It is recomme There is a [jQuery plugin](https://github.com/ether/etherpad-lite-jquery-plugin) that helps you to embed Pads into your website. # Plugin Framework -Etherpad offers a plugin framework, allowing you to easily add your own features. By default your Etherpad is extremely light-weight and it's up to you to customize your experience. Once you have Etherpad installed you should visit the plugin page and take control. +Etherpad offers a plugin framework, allowing you to easily add your own features. By default your Etherpad is extremely light-weight and it's up to you to customize your experience. Once you have Etherpad installed you should visit the plugin page and take control. # Translations / Localizations (i18n / l10n) Etherpad comes with translations into all languages thanks to the team at TranslateWiki. @@ -121,12 +118,5 @@ Etherpad comes with translations into all languages thanks to the team at Transl # FAQ Visit the **[FAQ](https://github.com/ether/etherpad-lite/wiki/FAQ)**. -# Donate! -* [Flattr](https://flattr.com/thing/71378/Etherpad-Foundation) -* Paypal - Press the donate button on [etherpad.org](http://etherpad.org) -* [Bitcoin](https://coinbase.com/checkouts/1e572bf8a82e4663499f7f1f66c2d15a) - -All donations go to the Etherpad foundation which is part of Software Freedom Conservency - # License [Apache License v2](http://www.apache.org/licenses/LICENSE-2.0.html) diff --git a/bin/buildDebian.sh b/bin/buildDebian.sh index 58431f738..a0fa180a7 100755 --- a/bin/buildDebian.sh +++ b/bin/buildDebian.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash -# IMPORTANT +# IMPORTANT # Protect against misspelling a var and rm -rf / set -u set -e diff --git a/bin/buildForWindows.sh b/bin/buildForWindows.sh index a9fbd70b1..9d9e2f10d 100755 --- a/bin/buildForWindows.sh +++ b/bin/buildForWindows.sh @@ -1,6 +1,6 @@ #!/bin/sh -NODE_VERSION="8.9.0" +NODE_VERSION="10.16.3" #Move to the folder where ep-lite is installed cd `dirname $0` @@ -11,21 +11,21 @@ if [ -d "../bin" ]; then fi #Is wget installed? -hash wget > /dev/null 2>&1 || { +hash wget > /dev/null 2>&1 || { echo "Please install wget" >&2 - exit 1 + exit 1 } #Is zip installed? -hash zip > /dev/null 2>&1 || { +hash zip > /dev/null 2>&1 || { echo "Please install zip" >&2 - exit 1 + exit 1 } #Is zip installed? -hash unzip > /dev/null 2>&1 || { +hash unzip > /dev/null 2>&1 || { echo "Please install unzip" >&2 - exit 1 + exit 1 } START_FOLDER=$(pwd); @@ -37,6 +37,10 @@ cd $TMP_FOLDER rm -rf node_modules rm -f etherpad-lite-win.zip +# setting NODE_ENV=production ensures that dev dependencies are not installed, +# making the windows package smaller +export NODE_ENV=production + echo "do a normal unix install first..." bin/installDeps.sh || exit 1 diff --git a/bin/checkAllPads.js b/bin/checkAllPads.js index a94c38d23..0d4e8bb8d 100644 --- a/bin/checkAllPads.js +++ b/bin/checkAllPads.js @@ -1,145 +1,94 @@ /* - This is a debug tool. It checks all revisions for data corruption -*/ + * This is a debug tool. It checks all revisions for data corruption + */ -if(process.argv.length != 2) -{ +if (process.argv.length != 2) { console.error("Use: node bin/checkAllPads.js"); process.exit(1); } -//initialize the variables -var db, settings, padManager; -var npm = require("../src/node_modules/npm"); -var async = require("../src/node_modules/async"); +// load and initialize NPM +let npm = require('../src/node_modules/npm'); +npm.load({}, async function() { -var Changeset = require("../src/static/js/Changeset"); + try { + // initialize the database + let settings = require('../src/node/utils/Settings'); + let db = require('../src/node/db/DB'); + await db.init(); -async.series([ - //load npm - function(callback) { - npm.load({}, callback); - }, - //load modules - function(callback) { - settings = require('../src/node/utils/Settings'); - db = require('../src/node/db/DB'); + // load modules + let Changeset = require('../src/static/js/Changeset'); + let padManager = require('../src/node/db/PadManager'); - //initialize the database - db.init(callback); - }, - //load pads - function (callback) - { - padManager = require('../src/node/db/PadManager'); - - padManager.listAllPads(function(err, res) - { - padIds = res.padIDs; - callback(err); - }); - }, - function (callback) - { - async.forEach(padIds, function(padId, callback) - { - padManager.getPad(padId, function(err, pad) { - if (err) { - callback(err); - } - - //check if the pad has a pool - if(pad.pool === undefined ) - { - console.error("[" + pad.id + "] Missing attribute pool"); - callback(); - return; - } + // get all pads + let res = await padManager.listAllPads(); - //create an array with key kevisions - //key revisions always save the full pad atext - var head = pad.getHeadRevisionNumber(); - var keyRevisions = []; - for(var i=0;i 'globalAuthor:' + author)); + + // add all revisions + for (let rev = 0; rev <= pad.head; ++rev) { + neededDBValues.push('pad:' + padId + ':revs:' + rev); + } + + // add all chat values + for (let chat = 0; chat <= pad.chatHead; ++chat) { + neededDBValues.push('pad:' + padId + ':chat:' + chat); + } + + for (let dbkey of neededDBValues) { + let dbvalue = await get(dbkey); + if (dbvalue && typeof dbvalue !== 'object') { + dbvalue = JSON.parse(dbvalue); + } + await set(dbkey, dbvalue); + } + + console.log('finished'); + process.exit(0); + } catch (er) { + console.error(er); + process.exit(1); } }); - -//get the pad object -//get all revisions of this pad -//get all authors related to this pad -//get the readonly link related to this pad -//get the chat entries related to this pad diff --git a/bin/importSqlFile.js b/bin/importSqlFile.js index 5cdf46e5d..25592438f 100644 --- a/bin/importSqlFile.js +++ b/bin/importSqlFile.js @@ -1,4 +1,4 @@ -var startTime = new Date().getTime(); +var startTime = Date.now(); require("ep_etherpad-lite/node_modules/npm").load({}, function(er,npm) { @@ -73,7 +73,7 @@ require("ep_etherpad-lite/node_modules/npm").load({}, function(er,npm) { function log(str) { - console.log((new Date().getTime() - startTime)/1000 + "\t" + str); + console.log((Date.now() - startTime)/1000 + "\t" + str); } unescape = function(val) { diff --git a/bin/installDeps.sh b/bin/installDeps.sh index a56031217..b3b2d013a 100755 --- a/bin/installDeps.sh +++ b/bin/installDeps.sh @@ -1,12 +1,12 @@ #!/bin/sh # minimum required node version -REQUIRED_NODE_MAJOR=6 +REQUIRED_NODE_MAJOR=8 REQUIRED_NODE_MINOR=9 # minimum required npm version -REQUIRED_NPM_MAJOR=3 -REQUIRED_NPM_MINOR=10 +REQUIRED_NPM_MAJOR=5 +REQUIRED_NPM_MINOR=5 require_minimal_version() { PROGRAM_LABEL="$1" diff --git a/bin/repairPad.js b/bin/repairPad.js index 28f28cb6e..d495baef5 100644 --- a/bin/repairPad.js +++ b/bin/repairPad.js @@ -1,106 +1,78 @@ /* - This is a repair tool. It extracts all datas of a pad, removes and inserts them again. -*/ + * This is a repair tool. It extracts all datas of a pad, removes and inserts them again. + */ console.warn("WARNING: This script must not be used while etherpad is running!"); -if(process.argv.length != 3) -{ +if (process.argv.length != 3) { console.error("Use: node bin/repairPad.js $PADID"); process.exit(1); } -//get the padID + +// get the padID var padId = process.argv[2]; -var db, padManager, pad, settings; -var neededDBValues = ["pad:"+padId]; +let npm = require("../src/node_modules/npm"); +npm.load({}, async function(er) { + if (er) { + console.error("Could not load NPM: " + er) + process.exit(1); + } -var npm = require("../src/node_modules/npm"); -var async = require("../src/node_modules/async"); + try { + // intialize database + let settings = require('../src/node/utils/Settings'); + let db = require('../src/node/db/DB'); + await db.init(); -async.series([ - // load npm - function(callback) { - npm.load({}, function(er) { - if(er) - { - console.error("Could not load NPM: " + er) - process.exit(1); - } - else - { - callback(); - } - }) - }, - // load modules - function(callback) { - settings = require('../src/node/utils/Settings'); - db = require('../src/node/db/DB'); - callback(); - }, - //initialize the database - function (callback) - { - db.init(callback); - }, - //get the pad - function (callback) - { - padManager = require('../src/node/db/PadManager'); - - padManager.getPad(padId, function(err, _pad) - { - pad = _pad; - callback(err); - }); - }, - function (callback) - { - //add all authors - var authors = pad.getAllAuthors(); - for(var i=0;i "globalAuthor:")); + + // add all revisions + for (let rev = 0; rev <= pad.head; ++rev) { + neededDBValues.push("pad:" + padId + ":revs:" + rev); } - - //add all revisions - var revHead = pad.head; - for(var i=0;i<=revHead;i++) - { - neededDBValues.push("pad:"+padId+":revs:" + i); + + // add all chat values + for (let chat = 0; chat <= pad.chatHead; ++chat) { + neededDBValues.push("pad:" + padId + ":chat:" + chat); } - - //get all chat values - var chatHead = pad.chatHead; - for(var i=0;i<=chatHead;i++) - { - neededDBValues.push("pad:"+padId+":chat:" + i); - } - callback(); - }, - function (callback) { - db = db.db; + + // + // NB: this script doesn't actually does what's documented + // since the `value` fields in the following `.forEach` + // block are just the array index numbers + // + // the script therefore craps out now before it can do + // any damage. + // + // See gitlab issue #3545 + // + console.info("aborting [gitlab #3545]"); + process.exit(1); + + // now fetch and reinsert every key neededDBValues.forEach(function(key, value) { - console.debug("Key: "+key+", value: "+value); + console.log("Key: " + key+ ", value: " + value); db.remove(key); db.set(key, value); }); - callback(); - } -], function (err) -{ - if(err) throw err; - else - { + console.info("finished"); - process.exit(); + process.exit(0); + + } catch (er) { + if (er.name === "apierror") { + console.error(er); + } else { + console.trace(er); + } } }); - -//get the pad object -//get all revisions of this pad -//get all authors related to this pad -//get the readonly link related to this pad -//get the chat entries related to this pad -//remove all keys from database and insert them again diff --git a/bin/safeRun.sh b/bin/safeRun.sh index d024f0b27..484c325ea 100755 --- a/bin/safeRun.sh +++ b/bin/safeRun.sh @@ -8,8 +8,8 @@ ERROR_HANDLING=0 # Your email address which should receive the error messages EMAIL_ADDRESS="no-reply@example.com" -# Sets the minimum amount of time between the sending of error emails. -# This ensures you do not get spammed during an endless reboot loop +# Sets the minimum amount of time between the sending of error emails. +# This ensures you do not get spammed during an endless reboot loop # It's the time in seconds TIME_BETWEEN_EMAILS=600 # 10 minutes @@ -39,7 +39,7 @@ do if [ ! -f ${LOG} ]; then touch ${LOG} || ( echo "Logfile '${LOG}' is not writeable" && exit 1 ) fi - + #Check if the file is writeable if [ ! -w ${LOG} ]; then echo "Logfile '${LOG}' is not writeable" @@ -48,21 +48,21 @@ do #Start the application bin/run.sh $@ >>${LOG} 2>>${LOG} - + #Send email if [ $ERROR_HANDLING = 1 ]; then TIME_NOW=$(date +%s) TIME_SINCE_LAST_SEND=$(($TIME_NOW - $LAST_EMAIL_SEND)) - + if [ $TIME_SINCE_LAST_SEND -gt $TIME_BETWEEN_EMAILS ]; then printf "Server was restarted at: $(date)\nThe last 50 lines of the log before the error happens:\n $(tail -n 50 ${LOG})" | mail -s "Pad Server was restarted" $EMAIL_ADDRESS - + LAST_EMAIL_SEND=$TIME_NOW fi fi - + echo "RESTART!" >>${LOG} - + #Sleep 10 seconds before restart sleep 10 done diff --git a/doc/api/changeset_library.md b/doc/api/changeset_library.md index 3bb4f055a..863ae1cf2 100644 --- a/doc/api/changeset_library.md +++ b/doc/api/changeset_library.md @@ -70,11 +70,11 @@ This creates an empty apool. An apool saves which attributes were used during th ``` > apool.fromJsonable({"numToAttrib":{"0":["author","a.kVnWeomPADAT2pn9"],"1":["bold","true"],"2":["italic","true"]},"nextNum":3}); > console.log(apool) -{ numToAttrib: +{ numToAttrib: { '0': [ 'author', 'a.kVnWeomPADAT2pn9' ], '1': [ 'bold', 'true' ], '2': [ 'italic', 'true' ] }, - attribToNum: + attribToNum: { 'author,a.kVnWeomPADAT2pn9': 0, 'bold,true': 1, 'italic,true': 2 }, diff --git a/doc/api/embed_parameters.md b/doc/api/embed_parameters.md index ea225d842..79b60f214 100644 --- a/doc/api/embed_parameters.md +++ b/doc/api/embed_parameters.md @@ -62,7 +62,7 @@ Example: `lang=ar` (translates the interface into Arabic) ## rtl * Boolean - + Default: true Displays pad text from right to left. diff --git a/doc/api/http_api.md b/doc/api/http_api.md index 59008743a..2105e4fde 100644 --- a/doc/api/http_api.md +++ b/doc/api/http_api.md @@ -67,7 +67,28 @@ The current version can be queried via /api. ### Request Format -The API is accessible via HTTP. HTTP Requests are in the format /api/$APIVERSION/$FUNCTIONNAME. Parameters are transmitted via HTTP GET. $APIVERSION depends on the endpoints you want to use. +The API is accessible via HTTP. Starting from **1.8**, API endpoints can be invoked indifferently via GET or POST. + +The URL of the HTTP request is of the form: `/api/$APIVERSION/$FUNCTIONNAME`. $APIVERSION depends on the endpoint you want to use. Depending on the verb you use (GET or POST) **parameters** can be passed differently. + +When invoking via GET (mandatory until **1.7.5** included), parameters must be included in the query string (example: `/api/$APIVERSION/$FUNCTIONNAME?apikey=¶m1=value1`). Please note that starting with nodejs 8.14+ the total size of HTTP request headers has been capped to 8192 bytes. This limits the quantity of data that can be sent in an API request. + +Starting from Etherpad **1.8** it is also possible to invoke the HTTP API via POST. In this case, querystring parameters will still be accepted, but **any parameter with the same name sent via POST will take precedence**. If you need to send large chunks of text (for example, for `setText()`) it is advisable to invoke via POST. + +Example with cURL using GET (toy example, no encoding): +``` +curl "http://pad.domain/api/1/setText?apikey=secret&padID=padname&text=this_text_will_NOT_be_encoded_by_curl_use_next_example" +``` + +Example with cURL using GET (better example, encodes text): +``` +curl "http://pad.domain/api/1/setText?apikey=secret&padID=padname" --get --data-urlencode "text=Text sent via GET with proper encoding. For big documents, please use POST" +``` + +Example with cURL using POST: +``` +curl "http://pad.domain/api/1/setText?apikey=secret&padID=padname" --data-urlencode "text=Text sent via POST with proper encoding. For big texts (>8 KB), use this method" +``` ### Response Format Responses are valid JSON in the following format: @@ -278,7 +299,9 @@ returns the text of a pad #### setText(padID, text) * API >= 1 -sets the text of a pad +Sets the text of a pad. + +If your text is long (>8 KB), please invoke via POST and include `text` parameter in the body of the request, not in the URL (since Etherpad **1.8**). *Example returns:* * `{code: 0, message:"ok", data: null}` @@ -288,7 +311,9 @@ sets the text of a pad #### appendText(padID, text) * API >= 1.2.13 -appends text to a pad +Appends text to a pad. + +If your text is long (>8 KB), please invoke via POST and include `text` parameter in the body of the request, not in the URL (since Etherpad **1.8**). *Example returns:* * `{code: 0, message:"ok", data: null}` @@ -309,6 +334,8 @@ returns the text of a pad formatted as HTML sets the text of a pad based on HTML, HTML must be well-formed. Malformed HTML will send a warning to the API log. +If `html` is long (>8 KB), please invoke via POST and include `html` parameter in the body of the request, not in the URL (since Etherpad **1.8**). + *Example returns:* * `{code: 0, message:"ok", data: null}` * `{code: 1, message:"padID does not exist", data: null}` @@ -349,7 +376,7 @@ get the changeset at a given revision, or last revision if 'rev' is not defined. *Example returns:* * `{ "code" : 0, "message" : "ok", - "data" : "Z:1>6b|5+6b$Welcome to Etherpad!\n\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\n\nGet involved with Etherpad at http://etherpad.org\n" + "data" : "Z:1>6b|5+6b$Welcome to Etherpad!\n\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\n\nGet involved with Etherpad at https://etherpad.org\n" }` * `{"code":1,"message":"padID does not exist","data":null}` * `{"code":1,"message":"rev is higher than the head revision of the pad","data":null}` diff --git a/doc/api/toolbar.md b/doc/api/toolbar.md index 03a3d8f89..bed845350 100644 --- a/doc/api/toolbar.md +++ b/doc/api/toolbar.md @@ -40,7 +40,7 @@ Returns: {SelectButton} * {String} value - The value of this option * {String} text - the label text used for this option * {Object} attributes - any additional html attributes go here (e.g. `data-l10n-id`) - + ## registerButton(name, item) * {String} name - used to reference the item in the toolbar config in settings.json * {Button|SelectButton} item - the button to add \ No newline at end of file diff --git a/doc/plugins.md b/doc/plugins.md index 5cb8d0ebf..a91569ad5 100644 --- a/doc/plugins.md +++ b/doc/plugins.md @@ -115,7 +115,7 @@ Your plugin must also contain a [package definition file](https://docs.npmjs.com "author": "USERNAME (REAL NAME) ", "contributors": [], "dependencies": {"MODULE": "0.3.20"}, - "engines": { "node": ">= 6.9.0"} + "engines": { "node": ">= 8.9.0"} } ``` @@ -124,7 +124,7 @@ If your plugin adds or modifies the front end HTML (e.g. adding buttons or chang ## Writing and running front-end tests for your plugin -Etherpad allows you to easily create front-end tests for plugins. +Etherpad allows you to easily create front-end tests for plugins. 1. Create a new folder ``` diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 000000000..31e10671d --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,65 @@ +# Etherpad Lite Dockerfile +# +# https://github.com/ether/etherpad-docker +# +# Author: muxator +# +# Version 0.1 + +FROM node:buster-slim +LABEL maintainer="Etherpad team, https://github.com/ether/etherpad-lite" + +# git hash of the version to be built. +# If not given, build the latest development version. +ARG ETHERPAD_VERSION=develop + +# plugins to install while building the container. By default no plugins are +# installed. +# If given a value, it has to be a space-separated, quoted list of plugin names. +# +# EXAMPLE: +# ETHERPAD_PLUGINS="ep_codepad ep_author_neat" +ARG ETHERPAD_PLUGINS= + +# Set the following to production to avoid installing devDeps +# this can be done with build args (and is mandatory to build ARM version) +ENV NODE_ENV=development + +# grab the ETHERPAD_VERSION tarball from github (no need to clone the whole +# repository) +RUN echo "Getting version: ${ETHERPAD_VERSION}" && \ + curl \ + --location \ + --fail \ + --silent \ + --show-error \ + --output /opt/etherpad-lite.tar.gz \ + https://github.com/ether/etherpad-lite/archive/"${ETHERPAD_VERSION}".tar.gz && \ + mkdir /opt/etherpad-lite && \ + tar xf /opt/etherpad-lite.tar.gz \ + --directory /opt/etherpad-lite \ + --strip-components=1 && \ + rm /opt/etherpad-lite.tar.gz + +WORKDIR /opt/etherpad-lite + +# install node dependencies for Etherpad +RUN bin/installDeps.sh && \ + rm -rf ~/.npm/_cacache + +# Install the plugins, if ETHERPAD_PLUGINS is not empty. +# +# Bash trick: in the for loop ${ETHERPAD_PLUGINS} is NOT quoted, in order to be +# able to split at spaces. +RUN for PLUGIN_NAME in ${ETHERPAD_PLUGINS}; do npm install "${PLUGIN_NAME}"; done + +# Copy the custom configuration file, if present. The configuration file has to +# be manually put inside the same directory containing the Dockerfile (we cannot +# directly point to "../settings.json" for Docker's security restrictions). +# +# For the conditional COPY trick, see: +# https://stackoverflow.com/questions/31528384/conditional-copy-add-in-dockerfile#46801962 +COPY ./settings.json /opt/etherpad-lite/ + +EXPOSE 9001 +CMD ["node", "node_modules/ep_etherpad-lite/node/server.js"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 000000000..34a242fcb --- /dev/null +++ b/docker/README.md @@ -0,0 +1,120 @@ +# Docker image + +This directory contains the files that are used to build the official Docker image on https://hub.docker.com/r/etherpad/etherpad. + +# Downloading from Docker Hub +If you are ok downloading a [prebuilt image from Docker Hub](https://hub.docker.com/r/etherpad/etherpad), these are the commands: +```bash +# gets the latest published version +docker pull etherpad/etherpad + +# gets a specific version +docker pull etherpad/etherpad:1.7.5 +``` + +# Build a personalized container + +If you want to use a personalized settings file, **you will have to rebuild your image**. +All of the following instructions are as a member of the `docker` group. + +## Rebuilding with custom settings +Prepare your custom `settings.json` file: +```bash +cd /docker +cp ../settings.json.template settings.json +[ further edit your settings.json as needed] +``` + +**Each configuration parameter can also be set via an environment variable**, using the syntax `"${ENV_VAR}"` or `"${ENV_VAR:default_value}"`. For details, refer to `settings.json.template`. + +## Rebuilding including some plugins +If you want to install some plugins in your container, it is sufficient to list them in the ETHERPAD_PLUGINS build variable. +The variable value has to be a space separated, double quoted list of plugin names (see examples). + +Some plugins will need personalized settings in the `settings.json` file. Just refer to the previous section, and include them in your custom `settings.json`. + +## Examples + +Build the latest development version: +```bash +docker build --tag /etherpad . +``` + +Build the latest stable version: +```bash +docker build --build-arg ETHERPAD_VERSION=master --build-arg NODE_ENV=production --tag /etherpad . +``` + +Build a specific tagged version: +```bash +docker build --build-arg ETHERPAD_VERSION=1.7.5 --build-arg NODE_ENV=production --tag /etherpad . +``` + +Build a specific git hash: +```bash +docker build --build-arg ETHERPAD_VERSION=4c45ac3cb1ae --tag /etherpad . +``` + +Include two plugins in the container: +```bash +docker build --build-arg ETHERPAD_PLUGINS="ep_codepad ep_author_neat" --tag /etherpad . +``` + +# Running your instance: + +To run your instance: +```bash +docker run --detach --publish :9001 /etherpad +``` + +And point your browser to `http://:` + +# Options available by default + +The `settings.json` available by default enables some configuration to be set from the environment. + +Available options: +* `TITLE`: The name of the instance +* `FAVICON`: favicon default name, or a fully specified URL to your own favicon +* `SKIN_NAME`: either `no-skin`, `colibris` or an existing directory under `src/static/skins`. +* `IP`: IP which etherpad should bind at. Change to `::` for IPv6 +* `PORT`: port which etherpad should bind at +* `SHOW_SETTINGS_IN_ADMIN_PAGE`: hide/show the settings.json in admin page +* `DB_TYPE`: a database supported by https://www.npmjs.com/package/ueberdb2 +* `DB_HOST`: the host of the database +* `DB_PORT`: the port of the database +* `DB_NAME`: the database name +* `DB_USER`: a database user with sufficient permissions to create tables +* `DB_PASS`: the password for the database username +* `DB_CHARSET`: the character set for the tables (only required for MySQL) +* `DB_FILENAME`: in case `DB_TYPE` is `DirtyDB`, the database filename. Default: `var/dirty.db` +* `ADMIN_PASSWORD`: the password for the `admin` user (leave unspecified if you do not want to create it) +* `USER_PASSWORD`: the password for the first user `user` (leave unspecified if you do not want to create it) +* `LOGLEVEL`: valid values are `DEBUG`, `INFO`, `WARN` and `ERROR` + +## Examples + +Use a Postgres database, no admin user enabled: + +```shell +docker run -d \ + --name etherpad \ + -p 9001:9001 \ + -e 'DB_TYPE=postgres' \ + -e 'DB_HOST=db.local' \ + -e 'DB_PORT=4321' \ + -e 'DB_NAME=etherpad' \ + -e 'DB_USER=dbusername' \ + -e 'DB_PASS=mypassword' \ + etherpad/etherpad +``` + +Run enabling the administrative user `admin`: + +```shell +docker run -d \ + --name etherpad \ + -p 9001:9001 \ + -e 'ADMIN_PASSWORD=supersecret' \ + etherpad/etherpad +``` diff --git a/docker/settings.json b/docker/settings.json new file mode 100644 index 000000000..8596f81fe --- /dev/null +++ b/docker/settings.json @@ -0,0 +1,475 @@ +/* + * This file must be valid JSON. But comments are allowed + * + * Please edit settings.json, not settings.json.template + * + * Please note that starting from Etherpad 1.6.0 you can store DB credentials in + * a separate file (credentials.json). + * + * + * ENVIRONMENT VARIABLE SUBSTITUTION + * ================================= + * + * All the configuration values can be read from environment variables using the + * syntax "${ENV_VAR}" or "${ENV_VAR:default_value}". + * + * This is useful, for example, when running in a Docker container. + * + * EXAMPLE: + * "port": "${PORT:9001}" + * "minify": "${MINIFY}" + * "skinName": "${SKIN_NAME:colibris}" + * + * Would read the configuration values for those items from the environment + * variables PORT, MINIFY and SKIN_NAME. + * + * If PORT and SKIN_NAME variables were not defined, the default values 9001 and + * "colibris" would be used. + * The configuration value "minify", on the other hand, does not have a + * designated default value. Thus, if the environment variable MINIFY were + * undefined, "minify" would be null. + * + * REMARKS: + * 1) please note that variable substitution always needs to be quoted. + * + * "port": 9001, <-- Literal values. When not using + * "minify": false substitution, only strings must be + * "skinName": "colibris" quoted. Booleans and numbers must not. + * + * "port": "${PORT:9001}" <-- CORRECT: if you want to use a variable + * "minify": "${MINIFY:true}" substitution, put quotes around its name, + * "skinName": "${SKIN_NAME}" even if the required value is a number or + * a boolean. + * Etherpad will take care of rewriting it + * to the proper type if necessary. + * + * "port": ${PORT:9001} <-- ERROR: this is not valid json. Quotes + * "minify": ${MINIFY} around variable names are missing. + * "skinName": ${SKIN_NAME} + * + * 2) Beware of undefined variables and default values: nulls and empty strings + * are different! + * + * This is particularly important for user's passwords (see the relevant + * section): + * + * "password": "${PASSW}" // if PASSW is not defined would result in password === null + * "password": "${PASSW:}" // if PASSW is not defined would result in password === '' + * + */ +{ + /* + * Name your instance! + */ + "title": "${TITLE:Etherpad}", + + /* + * favicon default name + * alternatively, set up a fully specified Url to your own favicon + */ + "favicon": "${FAVICON:favicon.ico}", + + /* + * Skin name. + * + * Its value has to be an existing directory under src/static/skins. + * You can write your own, or use one of the included ones: + * + * - "no-skin": an empty skin (default). This yields the unmodified, + * traditional Etherpad theme. + * - "colibris": the new experimental skin (since Etherpad 1.8), candidate to + * become the default in Etherpad 2.0 + */ + "skinName": "${SKIN_NAME:colibris}", + + /* + * IP and port which etherpad should bind at + */ + "ip": "${IP:0.0.0.0}", + "port": "${PORT:9001}", + + /* + * Option to hide/show the settings.json in admin page. + * + * Default option is set to true + */ + "showSettingsInAdminPage": "${SHOW_SETTINGS_IN_ADMIN_PAGE:true}", + + /* + * Node native SSL support + * + * This is disabled by default. + * Make sure to have the minimum and correct file access permissions set so + * that the Etherpad server can access them + */ + + /* + "ssl" : { + "key" : "/path-to-your/epl-server.key", + "cert" : "/path-to-your/epl-server.crt", + "ca": ["/path-to-your/epl-intermediate-cert1.crt", "/path-to-your/epl-intermediate-cert2.crt"] + }, + */ + + /* + * The type of the database. + * + * You can choose between many DB drivers, for example: dirty, postgres, + * sqlite, mysql. + * + * You shouldn't use "dirty" for for anything else than testing or + * development. + * + * + * Database specific settings are dependent on dbType, and go in dbSettings. + * Remember that since Etherpad 1.6.0 you can also store these informations in + * credentials.json. + * + * For a complete list of the supported drivers, please refer to: + * https://www.npmjs.com/package/ueberdb2 + */ + + "dbType": "${DB_TYPE:dirty}", + "dbSettings": { + "host": "${DB_HOST}", + "port": "${DB_PORT}", + "database": "${DB_NAME}", + "user": "${DB_USER}", + "password": "${DB_PASS}", + "charset": "${DB_CHARSET}", + "filename": "${DB_FILENAME:var/dirty.db}" + }, + + /* + * The default text of a pad + */ + "defaultPadText" : "Welcome to Etherpad!\n\nThis pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!\n\nGet involved with Etherpad at http:\/\/etherpad.org\n", + + /* + * Default Pad behavior. + * + * Change them if you want to override. + */ + "padOptions": { + "noColors": false, + "showControls": true, + "showChat": true, + "showLineNumbers": true, + "useMonospaceFont": false, + "userName": false, + "userColor": false, + "rtl": false, + "alwaysShowChat": false, + "chatAndUsers": false, + "lang": "en-gb" + }, + + /* + * Pad Shortcut Keys + */ + "padShortcutEnabled" : { + "altF9": true, /* focus on the File Menu and/or editbar */ + "altC": true, /* focus on the Chat window */ + "cmdShift2": true, /* shows a gritter popup showing a line author */ + "delete": true, + "return": true, + "esc": true, /* in mozilla versions 14-19 avoid reconnecting pad */ + "cmdS": true, /* save a revision */ + "tab": true, /* indent */ + "cmdZ": true, /* undo/redo */ + "cmdY": true, /* redo */ + "cmdI": true, /* italic */ + "cmdB": true, /* bold */ + "cmdU": true, /* underline */ + "cmd5": true, /* strike through */ + "cmdShiftL": true, /* unordered list */ + "cmdShiftN": true, /* ordered list */ + "cmdShift1": true, /* ordered list */ + "cmdShiftC": true, /* clear authorship */ + "cmdH": true, /* backspace */ + "ctrlHome": true, /* scroll to top of pad */ + "pageUp": true, + "pageDown": true + }, + + /* + * Should we suppress errors from being visible in the default Pad Text? + */ + "suppressErrorsInPadText": false, + + /* + * If this option is enabled, a user must have a session to access pads. + * This effectively allows only group pads to be accessed. + */ + "requireSession": false, + + /* + * Users may edit pads but not create new ones. + * + * Pad creation is only via the API. + * This applies both to group pads and regular pads. + */ + "editOnly": false, + + /* + * If set to true, those users who have a valid session will automatically be + * granted access to password protected pads. + */ + "sessionNoPassword": false, + + /* + * If true, all css & js will be minified before sending to the client. + * + * This will improve the loading performance massively, but makes it difficult + * to debug the javascript/css + */ + "minify": true, + + /* + * How long may clients use served javascript code (in seconds)? + * + * Not setting this may cause problems during deployment. + * Set to 0 to disable caching. + */ + "maxAge": 21600, // 60 * 60 * 6 = 6 hours + + /* + * Absolute path to the Abiword executable. + * + * Abiword is needed to get advanced import/export features of pads. Setting + * it to null disables Abiword and will only allow plain text and HTML + * import/exports. + */ + "abiword": null, + + /* + * This is the absolute path to the soffice executable. + * + * LibreOffice can be used in lieu of Abiword to export pads. + * Setting it to null disables LibreOffice exporting. + */ + "soffice": null, + + /* + * Path to the Tidy executable. + * + * Tidy is used to improve the quality of exported pads. + * Setting it to null disables Tidy. + */ + "tidyHtml": null, + + /* + * Allow import of file types other than the supported ones: + * txt, doc, docx, rtf, odt, html & htm + */ + "allowUnknownFileEnds": true, + + /* + * This setting is used if you require authentication of all users. + * + * Note: "/admin" always requires authentication. + */ + "requireAuthentication": false, + + /* + * Require authorization by a module, or a user with is_admin set, see below. + */ + "requireAuthorization": false, + + /* + * When you use NGINX or another proxy/load-balancer set this to true. + */ + "trustProxy": false, + + /* + * Privacy: disable IP logging + */ + "disableIPlogging": false, + + /* + * Time (in seconds) to automatically reconnect pad when a "Force reconnect" + * message is shown to user. + * + * Set to 0 to disable automatic reconnection. + */ + "automaticReconnectionTimeout": 0, + + /* + * By default, when caret is moved out of viewport, it scrolls the minimum + * height needed to make this line visible. + */ + "scrollWhenFocusLineIsOutOfViewport": { + + /* + * Percentage of viewport height to be additionally scrolled. + * + * E.g.: use "percentage.editionAboveViewport": 0.5, to place caret line in + * the middle of viewport, when user edits a line above of the + * viewport + * + * Set to 0 to disable extra scrolling + */ + "percentage": { + "editionAboveViewport": 0, + "editionBelowViewport": 0 + }, + + /* + * Time (in milliseconds) used to animate the scroll transition. + * Set to 0 to disable animation + */ + "duration": 0, + + /* + * Flag to control if it should scroll when user places the caret in the + * last line of the viewport + */ + "scrollWhenCaretIsInTheLastLineOfViewport": false, + + /* + * Percentage of viewport height to be additionally scrolled when user + * presses arrow up in the line of the top of the viewport. + * + * Set to 0 to let the scroll to be handled as default by Etherpad + */ + "percentageToScrollWhenUserPressesArrowUp": 0 + }, + + /* + * Users for basic authentication. + * + * is_admin = true gives access to /admin. + * If you do not uncomment this, /admin will not be available! + * + * WARNING: passwords should not be stored in plaintext in this file. + * If you want to mitigate this, please install ep_hash_auth and + * follow the section "secure your installation" in README.md + */ + + "users": { + "admin": { + // 1) "password" can be replaced with "hash" if you install ep_hash_auth + // 2) please note that if password is null, the user will not be created + "password": "${ADMIN_PASSWORD}", + "is_admin": true + }, + "user": { + // 1) "password" can be replaced with "hash" if you install ep_hash_auth + // 2) please note that if password is null, the user will not be created + "password": "${USER_PASSWORD}", + "is_admin": false + } + }, + + /* + * Restrict socket.io transport methods + */ + "socketTransportProtocols" : ["xhr-polling", "jsonp-polling", "htmlfile"], + + /* + * Allow Load Testing tools to hit the Etherpad Instance. + * + * WARNING: this will disable security on the instance. + */ + "loadTest": false, + + /* + * Disable indentation on new line when previous line ends with some special + * chars (':', '[', '(', '{') + */ + + /* + "indentationOnNewLine": false, + */ + + /* + * Toolbar buttons configuration. + * + * Uncomment to customize. + */ + + /* + "toolbar": { + "left": [ + ["bold", "italic", "underline", "strikethrough"], + ["orderedlist", "unorderedlist", "indent", "outdent"], + ["undo", "redo"], + ["clearauthorship"] + ], + "right": [ + ["importexport", "timeslider", "savedrevision"], + ["settings", "embed"], + ["showusers"] + ], + "timeslider": [ + ["timeslider_export", "timeslider_returnToPad"] + ] + }, + */ + + /* + * Expose Etherpad version in the web interface and in the Server http header. + * + * Do not enable on production machines. + */ + "exposeVersion": false, + + /* + * The log level we are using. + * + * Valid values: DEBUG, INFO, WARN, ERROR + */ + "loglevel": "${LOGLEVEL:INFO}", + + /* + * Logging configuration. See log4js documentation for further information: + * https://github.com/nomiddlename/log4js-node + * + * You can add as many appenders as you want here. + */ + "logconfig" : + { "appenders": [ + { "type": "console" + //, "category": "access"// only logs pad access + } + + /* + , { "type": "file" + , "filename": "your-log-file-here.log" + , "maxLogSize": 1024 + , "backups": 3 // how many log files there're gonna be at max + //, "category": "test" // only log a specific category + } + */ + + /* + , { "type": "logLevelFilter" + , "level": "warn" // filters out all log messages that have a lower level than "error" + , "appender": + { Use whatever appender you want here } + } + */ + + /* + , { "type": "logLevelFilter" + , "level": "error" // filters out all log messages that have a lower level than "error" + , "appender": + { "type": "smtp" + , "subject": "An error occurred in your EPL instance!" + , "recipients": "bar@blurdybloop.com, baz@blurdybloop.com" + , "sendInterval": 300 // 60 * 5 = 5 minutes -- will buffer log messages; set to 0 to send a mail for every message + , "transport": "SMTP", "SMTP": { // see https://github.com/andris9/Nodemailer#possible-transport-methods + "host": "smtp.example.com", "port": 465, + "secureConnection": true, + "auth": { + "user": "foo@example.com", + "pass": "bar_foo" + } + } + } + } + */ + + ] + } // logconfig +} diff --git a/settings.json.template b/settings.json.template index a89cc4247..5f94d3ada 100644 --- a/settings.json.template +++ b/settings.json.template @@ -3,8 +3,59 @@ * * Please edit settings.json, not settings.json.template * - * Please note that since Etherpad 1.6.0 you can store DB credentials in a - * separate file (credentials.json). + * Please note that starting from Etherpad 1.6.0 you can store DB credentials in + * a separate file (credentials.json). + * + * + * ENVIRONMENT VARIABLE SUBSTITUTION + * ================================= + * + * All the configuration values can be read from environment variables using the + * syntax "${ENV_VAR}" or "${ENV_VAR:default_value}". + * + * This is useful, for example, when running in a Docker container. + * + * EXAMPLE: + * "port": "${PORT:9001}" + * "minify": "${MINIFY}" + * "skinName": "${SKIN_NAME:colibris}" + * + * Would read the configuration values for those items from the environment + * variables PORT, MINIFY and SKIN_NAME. + * + * If PORT and SKIN_NAME variables were not defined, the default values 9001 and + * "colibris" would be used. + * The configuration value "minify", on the other hand, does not have a + * designated default value. Thus, if the environment variable MINIFY were + * undefined, "minify" would be null. + * + * REMARKS: + * 1) please note that variable substitution always needs to be quoted. + * + * "port": 9001, <-- Literal values. When not using + * "minify": false substitution, only strings must be + * "skinName": "colibris" quoted. Booleans and numbers must not. + * + * "port": "${PORT:9001}" <-- CORRECT: if you want to use a variable + * "minify": "${MINIFY:true}" substitution, put quotes around its name, + * "skinName": "${SKIN_NAME}" even if the required value is a number or + * a boolean. + * Etherpad will take care of rewriting it + * to the proper type if necessary. + * + * "port": ${PORT:9001} <-- ERROR: this is not valid json. Quotes + * "minify": ${MINIFY} around variable names are missing. + * "skinName": ${SKIN_NAME} + * + * 2) Beware of undefined variables and default values: nulls and empty strings + * are different! + * + * This is particularly important for user's passwords (see the relevant + * section): + * + * "password": "${PASSW}" // if PASSW is not defined would result in password === null + * "password": "${PASSW:}" // if PASSW is not defined would result in password === '' + * */ { /* @@ -35,14 +86,14 @@ * IP and port which etherpad should bind at */ "ip": "0.0.0.0", - "port" : 9001, + "port": 9001, /* * Option to hide/show the settings.json in admin page. * * Default option is set to true */ - "showSettingsInAdminPage" : true, + "showSettingsInAdminPage": true, /* * Node native SSL support @@ -78,10 +129,10 @@ * https://www.npmjs.com/package/ueberdb2 */ - "dbType" : "dirty", - "dbSettings" : { - "filename" : "var/dirty.db" - }, + "dbType": "dirty", + "dbSettings": { + "filename": "var/dirty.db" + }, /* * An Example of MySQL Configuration (commented out). @@ -92,13 +143,13 @@ /* "dbType" : "mysql", "dbSettings" : { - "user" : "etherpaduser", - "host" : "localhost", - "port" : 3306, - "password": "PASSWORD", - "database": "etherpad_lite_db", - "charset" : "utf8mb4" - }, + "user": "etherpaduser", + "host": "localhost", + "port": 3306, + "password": "PASSWORD", + "database": "etherpad_lite_db", + "charset": "utf8mb4" + }, */ /* @@ -112,57 +163,57 @@ * Change them if you want to override. */ "padOptions": { - "noColors": false, - "showControls": true, - "showChat": true, - "showLineNumbers": true, + "noColors": false, + "showControls": true, + "showChat": true, + "showLineNumbers": true, "useMonospaceFont": false, - "userName": false, - "userColor": false, - "rtl": false, - "alwaysShowChat": false, - "chatAndUsers": false, - "lang": "en-gb" + "userName": false, + "userColor": false, + "rtl": false, + "alwaysShowChat": false, + "chatAndUsers": false, + "lang": "en-gb" }, /* * Pad Shortcut Keys */ "padShortcutEnabled" : { - "altF9" : true, /* focus on the File Menu and/or editbar */ - "altC" : true, /* focus on the Chat window */ - "cmdShift2" : true, /* shows a gritter popup showing a line author */ - "delete" : true, - "return" : true, - "esc" : true, /* in mozilla versions 14-19 avoid reconnecting pad */ - "cmdS" : true, /* save a revision */ - "tab" : true, /* indent */ - "cmdZ" : true, /* undo/redo */ - "cmdY" : true, /* redo */ - "cmdI" : true, /* italic */ - "cmdB" : true, /* bold */ - "cmdU" : true, /* underline */ - "cmd5" : true, /* strike through */ - "cmdShiftL" : true, /* unordered list */ - "cmdShiftN" : true, /* ordered list */ - "cmdShift1" : true, /* ordered list */ - "cmdShiftC" : true, /* clear authorship */ - "cmdH" : true, /* backspace */ - "ctrlHome" : true, /* scroll to top of pad */ - "pageUp" : true, - "pageDown" : true + "altF9": true, /* focus on the File Menu and/or editbar */ + "altC": true, /* focus on the Chat window */ + "cmdShift2": true, /* shows a gritter popup showing a line author */ + "delete": true, + "return": true, + "esc": true, /* in mozilla versions 14-19 avoid reconnecting pad */ + "cmdS": true, /* save a revision */ + "tab": true, /* indent */ + "cmdZ": true, /* undo/redo */ + "cmdY": true, /* redo */ + "cmdI": true, /* italic */ + "cmdB": true, /* bold */ + "cmdU": true, /* underline */ + "cmd5": true, /* strike through */ + "cmdShiftL": true, /* unordered list */ + "cmdShiftN": true, /* ordered list */ + "cmdShift1": true, /* ordered list */ + "cmdShiftC": true, /* clear authorship */ + "cmdH": true, /* backspace */ + "ctrlHome": true, /* scroll to top of pad */ + "pageUp": true, + "pageDown": true }, /* * Should we suppress errors from being visible in the default Pad Text? */ - "suppressErrorsInPadText" : false, + "suppressErrorsInPadText": false, /* * If this option is enabled, a user must have a session to access pads. * This effectively allows only group pads to be accessed. */ - "requireSession" : false, + "requireSession": false, /* * Users may edit pads but not create new ones. @@ -170,13 +221,13 @@ * Pad creation is only via the API. * This applies both to group pads and regular pads. */ - "editOnly" : false, + "editOnly": false, /* * If set to true, those users who have a valid session will automatically be * granted access to password protected pads. */ - "sessionNoPassword" : false, + "sessionNoPassword": false, /* * If true, all css & js will be minified before sending to the client. @@ -184,7 +235,7 @@ * This will improve the loading performance massively, but makes it difficult * to debug the javascript/css */ - "minify" : true, + "minify": true, /* * How long may clients use served javascript code (in seconds)? @@ -192,7 +243,7 @@ * Not setting this may cause problems during deployment. * Set to 0 to disable caching. */ - "maxAge" : 21600, // 60 * 60 * 6 = 6 hours + "maxAge": 21600, // 60 * 60 * 6 = 6 hours /* * Absolute path to the Abiword executable. @@ -201,7 +252,7 @@ * it to null disables Abiword and will only allow plain text and HTML * import/exports. */ - "abiword" : null, + "abiword": null, /* * This is the absolute path to the soffice executable. @@ -209,7 +260,7 @@ * LibreOffice can be used in lieu of Abiword to export pads. * Setting it to null disables LibreOffice exporting. */ - "soffice" : null, + "soffice": null, /* * Path to the Tidy executable. @@ -217,35 +268,35 @@ * Tidy is used to improve the quality of exported pads. * Setting it to null disables Tidy. */ - "tidyHtml" : null, + "tidyHtml": null, /* * Allow import of file types other than the supported ones: * txt, doc, docx, rtf, odt, html & htm */ - "allowUnknownFileEnds" : true, + "allowUnknownFileEnds": true, /* * This setting is used if you require authentication of all users. * * Note: "/admin" always requires authentication. */ - "requireAuthentication" : false, + "requireAuthentication": false, /* * Require authorization by a module, or a user with is_admin set, see below. */ - "requireAuthorization" : false, + "requireAuthorization": false, /* * When you use NGINX or another proxy/load-balancer set this to true. */ - "trustProxy" : false, + "trustProxy": false, /* * Privacy: disable IP logging */ - "disableIPlogging" : false, + "disableIPlogging": false, /* * Time (in seconds) to automatically reconnect pad when a "Force reconnect" @@ -253,7 +304,7 @@ * * Set to 0 to disable automatic reconnection. */ - "automaticReconnectionTimeout" : 0, + "automaticReconnectionTimeout": 0, /* * By default, when caret is moved out of viewport, it scrolls the minimum @@ -310,12 +361,14 @@ /* "users": { "admin": { - // "password" can be replaced with "hash" if you install ep_hash_auth + // 1) "password" can be replaced with "hash" if you install ep_hash_auth + // 2) please note that if password is null, the user will not be created "password": "changeme1", "is_admin": true }, "user": { - // "password" can be replaced with "hash" if you install ep_hash_auth + // 1) "password" can be replaced with "hash" if you install ep_hash_auth + // 2) please note that if password is null, the user will not be created "password": "changeme1", "is_admin": false } @@ -368,6 +421,13 @@ }, */ + /* + * Expose Etherpad version in the web interface and in the Server http header. + * + * Do not enable on production machines. + */ + "exposeVersion": false, + /* * The log level we are using. * diff --git a/src/locales/ar.json b/src/locales/ar.json index 0237830f1..b332508b2 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -15,16 +15,16 @@ }, "index.newPad": "باد جديد", "index.createOpenPad": "أو صنع/فتح باد بوضع اسمه:", - "pad.toolbar.bold.title": "سميك (Ctrl-B)", - "pad.toolbar.italic.title": "مائل (Ctrl-I)", - "pad.toolbar.underline.title": "تسطير (Ctrl-U)", + "pad.toolbar.bold.title": "سميك (Ctrl+B)", + "pad.toolbar.italic.title": "مائل (Ctrl+I)", + "pad.toolbar.underline.title": "تسطير (Ctrl+U)", "pad.toolbar.strikethrough.title": "شطب (Ctrl+5)", "pad.toolbar.ol.title": "قائمة مرتبة (Ctrl+Shift+N)", "pad.toolbar.ul.title": "قائمة غير مرتبة (Ctrl+Shift+L)", - "pad.toolbar.indent.title": "إزاحة", - "pad.toolbar.unindent.title": "حذف الإزاحة", - "pad.toolbar.undo.title": "فك (Ctrl-Z)", - "pad.toolbar.redo.title": "تكرار (Ctrl-Y)", + "pad.toolbar.indent.title": "إزاحة (TAB)", + "pad.toolbar.unindent.title": "حذف الإزاحة (Shift+TAB)", + "pad.toolbar.undo.title": "فك (Ctrl+Z)", + "pad.toolbar.redo.title": "تكرار (Ctrl+Y)", "pad.toolbar.clearAuthorship.title": "مسح ألوان التأليف (Ctrl+Shift+C)", "pad.toolbar.import_export.title": "استيراد/تصدير من/إلى تنسيقات ملفات مختلفة", "pad.toolbar.timeslider.title": "متصفح التاريخ", @@ -32,13 +32,13 @@ "pad.toolbar.settings.title": "الإعدادات", "pad.toolbar.embed.title": "تبادل و تضمين هذا الباد", "pad.toolbar.showusers.title": "عرض المستخدمين على هذا الباد", - "pad.colorpicker.save": "تسجيل", + "pad.colorpicker.save": "حفظ", "pad.colorpicker.cancel": "إلغاء", "pad.loading": "جارٍ التحميل...", "pad.noCookie": "الكوكيز غير متاحة. الرجاء السماح بتحميل الكوكيز على متصفحك!", - "pad.passwordRequired": "تحتاج إلى كلمة مرور للوصول إلى هذا الباد", + "pad.passwordRequired": "تحتاج إلى كلمة سر للوصول إلى هذا الباد", "pad.permissionDenied": "ليس لديك إذن لدخول هذا الباد", - "pad.wrongPassword": "كانت كلمة المرور خاطئة", + "pad.wrongPassword": "كانت كلمة السر خاطئة", "pad.settings.padSettings": "إعدادات الباد", "pad.settings.myView": "رؤيتي", "pad.settings.stickychat": "الدردشة دائما على الشاشة", @@ -63,9 +63,9 @@ "pad.importExport.exportopen": "ODF (نسق المستند المفتوح)", "pad.importExport.abiword.innerHTML": "لا يمكنك الاستيراد إلا من نص عادي أو من تنسيقات HTML. للحصول على المزيد من ميزات الاستيراد المتقدمة، يرجى تثبيت AbiWord.", "pad.modals.connected": "متصل.", - "pad.modals.reconnecting": "إعادة الاتصال ببادك", + "pad.modals.reconnecting": "إعادة الاتصال ببادك..", "pad.modals.forcereconnect": "فرض إعادة الاتصال", - "pad.modals.reconnecttimer": "حاول إعادة الاتصال", + "pad.modals.reconnecttimer": "جاري محاولة إعادة الاتصال", "pad.modals.cancel": "إلغاء", "pad.modals.userdup": "مفتوح في نافذة أخرى", "pad.modals.userdup.explanation": "يبدو أن هذا الباد تم فتحه في أكثر من نافذة متصفح في هذا الحاسوب.", @@ -74,9 +74,9 @@ "pad.modals.unauth.explanation": "لقد تغيرت الأذونات الخاصة بك أثناء عرض هذه الصفحة. أعد محاولة الاتصال.", "pad.modals.looping.explanation": "هناك مشاكل في الاتصال مع ملقم التزامن.", "pad.modals.looping.cause": "ربما كنت متصلاً من خلال وكيل أو جدار حماية غير متوافق.", - "pad.modals.initsocketfail": "لا يمكن الوصول إلى الخادم", + "pad.modals.initsocketfail": "لا يمكن الوصول إلى الخادم.", "pad.modals.initsocketfail.explanation": "تعذر الاتصال بخادم المزامنة.", - "pad.modals.initsocketfail.cause": "هذا على الأرجح بسبب مشكلة في المستعرض الخاص بك أو الاتصال بإنترنت.", + "pad.modals.initsocketfail.cause": "هذا على الأرجح بسبب مشكلة في المستعرض الخاص بك أو الاتصال بالإنترنت.", "pad.modals.slowcommit.explanation": "الخادم لا يستجيب.", "pad.modals.slowcommit.cause": "يمكن أن يكون هذا بسبب مشاكل في الاتصال بالشبكة.", "pad.modals.badChangeset.explanation": "لقد صُنفَت إحدى عمليات التحرير التي قمت بها كعملية غير مسموح بها من قبل ملقم التزامن.", @@ -84,17 +84,19 @@ "pad.modals.corruptPad.explanation": "الباد الذي تحاول الوصول إليه تالف.", "pad.modals.corruptPad.cause": "قد يكون هذا بسبب تكوين ملقم خاطئ أو بسبب سلوك آخر غير متوقع. يرجى الاتصال بمسؤول الخدمة.", "pad.modals.deleted": "محذوف.", - "pad.modals.deleted.explanation": "تمت إزالة هذا الباد", + "pad.modals.deleted.explanation": "تمت إزالة هذا الباد.", "pad.modals.disconnected": "لم تعد متصلا.", "pad.modals.disconnected.explanation": "تم فقدان الاتصال بالخادم", "pad.modals.disconnected.cause": "قد يكون الخادم غير متوفر. يرجى إعلام مسؤول الخدمة إذا كان هذا لا يزال يحدث.", "pad.share": "شارك هذه الباد", "pad.share.readonly": "للقراءة فقط", - "pad.share.link": "رابط", + "pad.share.link": "وصلة", "pad.share.emebdcode": "URL للتضمين", "pad.chat": "دردشة", - "pad.chat.title": "فتح الدردشة لهذا الباد", + "pad.chat.title": "فتح الدردشة لهذا الباد.", "pad.chat.loadmessages": "تحميل المزيد من الرسائل", + "pad.chat.stick.title": "ألصق الدردشة بالشاشة", + "pad.chat.writeMessage.placeholder": "اكتب رسالتك هنا", "timeslider.pageTitle": "{{appTitle}} متصفح التاريخ", "timeslider.toolbar.returnbutton": "العودة إلى الباد", "timeslider.toolbar.authors": "المؤلفون:", diff --git a/src/locales/ast.json b/src/locales/ast.json index f1fd2f5e3..17bc97586 100644 --- a/src/locales/ast.json +++ b/src/locales/ast.json @@ -52,7 +52,7 @@ "pad.importExport.exportword": "Microsoft Word", "pad.importExport.exportpdf": "PDF", "pad.importExport.exportopen": "ODF (Open Document Format)", - "pad.importExport.abiword.innerHTML": "Sólo se pue importar dende los formatos de testu planu o html. Pa carauterístiques d'importación más avanzaes instala abiword.", + "pad.importExport.abiword.innerHTML": "Sólo se pue importar dende los formatos de testu planu o HTML. Pa carauterístiques d'importación más avanzaes instala Abiword.", "pad.modals.connected": "Coneutáu.", "pad.modals.reconnecting": "Reconeutando col to bloc...", "pad.modals.forcereconnect": "Forzar la reconexón", @@ -86,6 +86,8 @@ "pad.chat": "Chat", "pad.chat.title": "Abrir el chat d'esti bloc.", "pad.chat.loadmessages": "Cargar más mensaxes", + "pad.chat.stick.title": "Pegar charra a la pantalla", + "pad.chat.writeMessage.placeholder": "Escribi'l mensaxe equí", "timeslider.pageTitle": "Eslizador de tiempu de {{appTitle}}", "timeslider.toolbar.returnbutton": "Tornar al bloc", "timeslider.toolbar.authors": "Autores:", diff --git a/src/locales/bcc.json b/src/locales/bcc.json index 7ae420053..a63e2a2eb 100644 --- a/src/locales/bcc.json +++ b/src/locales/bcc.json @@ -1,7 +1,8 @@ { "@metadata": { "authors": [ - "Baloch Afghanistan" + "Baloch Afghanistan", + "Sultanselim baloch" ] }, "index.newPad": "دفترچه یادداشت تازه", @@ -30,7 +31,7 @@ "pad.permissionDenied": "شرمنده، شما را اجازت په دسترسی ای صفحه نیست.", "pad.wrongPassword": "گذرواژه‌ی شما درست نیست", "pad.settings.padSettings": "تنظیمات دفترچه یادداشت", - "pad.settings.myView": "نمای من", + "pad.settings.myView": "منی سۏج", "pad.settings.stickychat": "گفتگو همیشه روی صفحه نمایش باشد", "pad.settings.colorcheck": "رنگ‌های نویسندگی", "pad.settings.linenocheck": "شماره‌ی خطوط", @@ -71,7 +72,7 @@ "pad.modals.corruptPad.cause": "این احتمالاً به دلیل تنظیمات اشتباه کارساز یا سایر رفتارهای غیرمنتظره است. لطفاً با مدیر خدمت تماس حاصل کنید.", "pad.modals.deleted": "پاک کورتین", "pad.modals.deleted.explanation": "این دفترچه یادداشت پاک شده‌است.", - "pad.modals.disconnected": "اتصال شما قطع شده‌است.", + "pad.modals.disconnected": "شمئی سکّی کھت اِنت۔", "pad.modals.disconnected.explanation": "اتصال به سرور قطع شده‌است.", "pad.modals.disconnected.cause": "ممکن است سرور در دسترس نباشد. اگر این مشکل باز هم رخ داد مدیر حدمت را آگاه کنید.", "pad.share": "به اشتراک‌گذاری این دفترچه یادداشت", @@ -80,7 +81,7 @@ "pad.share.emebdcode": "جاسازی نشانی", "pad.chat": "گفتگو", "pad.chat.title": "بازکردن گفتگو برای این دفترچه یادداشت", - "pad.chat.loadmessages": "بارگیری پیام‌های بیشتر", + "pad.chat.loadmessages": "گݔشترݔں پیگامء چارگ", "timeslider.pageTitle": "لغزندهٔ زمان {{appTitle}}", "timeslider.toolbar.returnbutton": "بازگشت به دفترچه یادداشت", "timeslider.toolbar.authors": "نویسوک:", diff --git a/src/locales/be-tarask.json b/src/locales/be-tarask.json index 84af73156..d29f9cb20 100644 --- a/src/locales/be-tarask.json +++ b/src/locales/be-tarask.json @@ -88,6 +88,8 @@ "pad.chat": "Чат", "pad.chat.title": "Адкрыць чат для гэтага дакумэнту.", "pad.chat.loadmessages": "Загрузіць болей паведамленьняў", + "pad.chat.stick.title": "Замацаваць чат на экране", + "pad.chat.writeMessage.placeholder": "Напішыце вашае паведамленьне тут", "timeslider.pageTitle": "Часавая шкала {{appTitle}}", "timeslider.toolbar.returnbutton": "Вярнуцца да дакумэнту", "timeslider.toolbar.authors": "Аўтары:", diff --git a/src/locales/bg.json b/src/locales/bg.json index 0b29b0e77..2a22b5230 100644 --- a/src/locales/bg.json +++ b/src/locales/bg.json @@ -2,7 +2,8 @@ "@metadata": { "authors": [ "Vodnokon4e", - "StanProg" + "StanProg", + "Vlad5250" ] }, "index.newPad": "Нов пад", @@ -46,6 +47,8 @@ "pad.chat": "Чат", "pad.chat.title": "Отваряне на чат за този пад.", "pad.chat.loadmessages": "Зареждане на повече съобщения", + "pad.chat.stick.title": "Залепяне на разговора на екрана", + "pad.chat.writeMessage.placeholder": "Тук напишете съобщение", "timeslider.toolbar.returnbutton": "Връщане към пада", "timeslider.toolbar.authors": "Автори:", "timeslider.toolbar.authorsList": "Няма автори", diff --git a/src/locales/bn.json b/src/locales/bn.json index d8ee8bb1e..42dae70b7 100644 --- a/src/locales/bn.json +++ b/src/locales/bn.json @@ -75,6 +75,7 @@ "pad.chat": "চ্যাট", "pad.chat.title": "এই প্যাডের জন্য চ্যাট চালু করুন।", "pad.chat.loadmessages": "আরও বার্তা লোড করুন", + "pad.chat.writeMessage.placeholder": "আপনার বার্তাটি এখানে লিখুন", "timeslider.toolbar.returnbutton": "প্যাডে ফিরে যাও", "timeslider.toolbar.authors": "লেখকগণ:", "timeslider.toolbar.authorsList": "কোনো লেখক নেই", diff --git a/src/locales/br.json b/src/locales/br.json index ea3da7687..b020f9630 100644 --- a/src/locales/br.json +++ b/src/locales/br.json @@ -17,8 +17,8 @@ "pad.toolbar.ul.title": "Listenn en dizurzh (Ktrl+Pennlizherenn+L)", "pad.toolbar.indent.title": "Endantañ (TAB)", "pad.toolbar.unindent.title": "Diendantañ (Shift+TAB)", - "pad.toolbar.undo.title": "Dizober (Ktrl-Z)", - "pad.toolbar.redo.title": "Adober (Ktrl-Y)", + "pad.toolbar.undo.title": "Dizober (Ktrl+Z)", + "pad.toolbar.redo.title": "Adober (Ktrl+Y)", "pad.toolbar.clearAuthorship.title": "Diverkañ al livioù oc'h anaout an aozerien (Ktrl+Pennlizherenn+C)", "pad.toolbar.import_export.title": "Enporzhiañ/Ezporzhiañ eus/war-zu ur furmad restr disheñvel", "pad.toolbar.timeslider.title": "Istor dinamek", @@ -63,7 +63,7 @@ "pad.modals.cancel": "Nullañ", "pad.modals.userdup": "Digor en ur prenestr all", "pad.modals.userdup.explanation": "Digor eo ho pad, war a seblant, e meur a brenestr eus ho merdeer en urzhiataer-mañ.", - "pad.modals.userdup.advice": "Kevreañ en ur implijout ar prenestr-mañ.", + "pad.modals.userdup.advice": "Kevreañ en-dro en ur implijout ar prenestr-mañ.", "pad.modals.unauth": "N'eo ket aotreet", "pad.modals.unauth.explanation": "Kemmet e vo hoc'h aotreoù pa vo diskwelet ar bajenn.-mañ Klaskit kevreañ en-dro.", "pad.modals.looping.explanation": "Kudennoù kehentiñ zo gant ar servijer sinkronelekaat.", @@ -89,6 +89,8 @@ "pad.chat": "Flap", "pad.chat.title": "Digeriñ ar flap kevelet gant ar pad-mañ.", "pad.chat.loadmessages": "Kargañ muioc'h a gemennadennoù", + "pad.chat.stick.title": "Gwriziennañ an diviz war ar skramm", + "pad.chat.writeMessage.placeholder": "Skrivañ ho kemenadenn amañ", "timeslider.pageTitle": "Istor dinamek eus {{appTitle}}", "timeslider.toolbar.returnbutton": "Distreiñ d'ar pad-mañ.", "timeslider.toolbar.authors": "Aozerien :", @@ -126,7 +128,7 @@ "pad.impexp.importing": "Oc'h enporzhiañ...", "pad.impexp.confirmimport": "Ma vez enporzhiet ur restr e vo diverket ar pezh zo en teul a-vremañ. Ha sur oc'h e fell deoc'h mont betek penn ?", "pad.impexp.convertFailed": "N'eus ket bet gallet enporzhiañ ar restr. Ober gant ur furmad teul all pe eilañ/pegañ gant an dorn.", - "pad.impexp.padHasData": "N'hon eus ket gallet enporzhiañ ar restr-mañdre ma'z eus bet degaset kemmoù er bloc'h-se ; enporzhiit anezhi war-zu ur bloc'h nevez, mar plij.", + "pad.impexp.padHasData": "N'hon eus ket gallet enporzhiañ ar restr-mañ dre ma'z eus bet degaset kemmoù er bloc'h-se ; enporzhiit anezhi war-zu ur bloc'h nevez, mar plij.", "pad.impexp.uploadFailed": "C'hwitet eo bet an enporzhiañ. Klaskit en-dro.", "pad.impexp.importfailed": "C'hwitet eo an enporzhiadenn", "pad.impexp.copypaste": "Eilit/pegit, mar plij", diff --git a/src/locales/ca.json b/src/locales/ca.json index 714306106..3b0eba48b 100644 --- a/src/locales/ca.json +++ b/src/locales/ca.json @@ -60,7 +60,7 @@ "pad.importExport.exportword": "Microsoft Word", "pad.importExport.exportpdf": "PDF", "pad.importExport.exportopen": "ODF (Open Document Format)", - "pad.importExport.abiword.innerHTML": "Només podeu importar de text net o html. Per a opcions d'importació més avançades instal·leu l'Abiword.", + "pad.importExport.abiword.innerHTML": "Només podeu importar de text net o HTML. Per a opcions d'importació més avançades instal·leu l'Abiword.", "pad.modals.connected": "Connectat.", "pad.modals.reconnecting": "S'està tornant a connectar al vostre pad…", "pad.modals.forcereconnect": "Força tornar a connectar", @@ -94,6 +94,8 @@ "pad.chat": "Xat", "pad.chat.title": "Obre el xat d'aquest pad.", "pad.chat.loadmessages": "Carrega més missatges", + "pad.chat.stick.title": "Ancora el xat a la pantalla", + "pad.chat.writeMessage.placeholder": "Escriviu el vostre missatge a continuació", "timeslider.pageTitle": "Línia temporal — {{appTitle}}", "timeslider.toolbar.returnbutton": "Torna al pad", "timeslider.toolbar.authors": "Autors:", diff --git a/src/locales/de.json b/src/locales/de.json index 85bd3bfff..5bbbb9b1b 100644 --- a/src/locales/de.json +++ b/src/locales/de.json @@ -92,6 +92,8 @@ "pad.chat": "Unterhaltung", "pad.chat.title": "Den Chat für dieses Pad öffnen.", "pad.chat.loadmessages": "Weitere Nachrichten laden", + "pad.chat.stick.title": "Chat an den Bildschirm anheften", + "pad.chat.writeMessage.placeholder": "Schreibe hier deine Nachricht", "timeslider.pageTitle": "{{appTitle}} Bearbeitungsverlauf", "timeslider.toolbar.returnbutton": "Zurück zum Pad", "timeslider.toolbar.authors": "Autoren:", diff --git a/src/locales/diq.json b/src/locales/diq.json index 453867566..9475c68ae 100644 --- a/src/locales/diq.json +++ b/src/locales/diq.json @@ -6,12 +6,13 @@ "Mirzali", "Kumkumuk", "1917 Ekim Devrimi", - "Gırd" + "Gırd", + "Orbot707" ] }, - "index.newPad": "Pedo newe", - "index.createOpenPad": "Yana eno bamaeya bloknot vıraz/ak:", - "pad.toolbar.bold.title": "Qalın (Ctrl-B)", + "index.newPad": "Bloknoto newe", + "index.createOpenPad": "ya zi be nê nameyi ra yew bloknot vıraze/ake:", + "pad.toolbar.bold.title": "Qalınd (Ctrl-B)", "pad.toolbar.italic.title": "Namıte (Ctrl-I)", "pad.toolbar.underline.title": "Bınxetın (Ctrl-U)", "pad.toolbar.strikethrough.title": "Serxetın (Ctrl+5)", @@ -20,12 +21,12 @@ "pad.toolbar.indent.title": "Serrêze (TAB)", "pad.toolbar.unindent.title": "Teberdayış (Shift+TAB)", "pad.toolbar.undo.title": "Meke (Ctrl-Z)", - "pad.toolbar.redo.title": "Fına bıke (Ctrl-Y)", + "pad.toolbar.redo.title": "Newe ke (Ctrl-Y)", "pad.toolbar.clearAuthorship.title": "Rengê Nuştoğiê Arıstey (Ctrl+Shift+C)", "pad.toolbar.import_export.title": "Babaetna tewranê dosyaya azere/ateber ke", "pad.toolbar.timeslider.title": "Ğızagê zemani", "pad.toolbar.savedRevision.title": "Çımraviyarnayışi qeyd ke", - "pad.toolbar.settings.title": "Sazkerdışi", + "pad.toolbar.settings.title": "Eyari", "pad.toolbar.embed.title": "Na bloknot degusn u bıhesrne", "pad.toolbar.showusers.title": "Karbera ena bloknot dı bımotné", "pad.colorpicker.save": "Qeyd ke", @@ -58,7 +59,7 @@ "pad.importExport.exportpdf": "PDF", "pad.importExport.exportopen": "ODF (Open Document Format)", "pad.importExport.abiword.innerHTML": "Şıma şenê tenya metınanê zelalan ya zi formatanê HTML-i biyarê. Seba vêşi xısusiyetanê arezekerdışi ra gırey AbiWord-i bar kerên.", - "pad.modals.connected": "Gırediya.", + "pad.modals.connected": "Gıre diya.", "pad.modals.reconnecting": "Bloknot da şıma rê fına irtibat kewê no", "pad.modals.forcereconnect": "Mecbur anciya gırê de", "pad.modals.reconnecttimer": "Anciya gırê beno", @@ -91,6 +92,8 @@ "pad.chat": "Mıhebet", "pad.chat.title": "Qandê ena ped mıhebet ake.", "pad.chat.loadmessages": "Dehana zaf mesaci bar keri", + "pad.chat.stick.title": "Mobet ekran de bıvındarne", + "pad.chat.writeMessage.placeholder": "Mesacê xo tiya bınusne", "timeslider.pageTitle": "Ğızagê zemani {{appTitle}}", "timeslider.toolbar.returnbutton": "Peyser şo ped", "timeslider.toolbar.authors": "Nuştoği:", @@ -104,7 +107,7 @@ "timeslider.forwardRevision": "Ena bloknot de şo revizyonê bini", "timeslider.dateformat": "{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}", "timeslider.month.january": "Çele", - "timeslider.month.february": "Zemherı", + "timeslider.month.february": "Sıbate", "timeslider.month.march": "Adar", "timeslider.month.april": "Nisane", "timeslider.month.may": "Gulane", @@ -119,7 +122,7 @@ "pad.savedrevs.marked": "Eno vurriyayış henda qeyd bıyaye yew vurriyayış deyne nışan bıyo", "pad.savedrevs.timeslider": "Xızberê zemani ziyer kerdış ra şıma şenê revizyonanê qeyd bıyayan bıvinê", "pad.userlist.entername": "Namey xo cıkewe", - "pad.userlist.unnamed": "Name nébıyo", + "pad.userlist.unnamed": "bêname", "pad.userlist.guest": "Meyman", "pad.userlist.deny": "Red ke", "pad.userlist.approve": "Tesdiq ke", diff --git a/src/locales/el.json b/src/locales/el.json index 2fff2e470..2c9d378e8 100644 --- a/src/locales/el.json +++ b/src/locales/el.json @@ -56,7 +56,7 @@ "pad.importExport.exportword": "Microsoft Word", "pad.importExport.exportpdf": "PDF", "pad.importExport.exportopen": "ODF (Μορφή Open Document)", - "pad.importExport.abiword.innerHTML": "Μπορείτε να κάνετε εισαγωγή απλού κειμένου ή μορφής html. Για πιο προηγμένες δυνατότητες εισαγωγής παρακαλώ εγκαταστήστε το abiword.", + "pad.importExport.abiword.innerHTML": "Μπορείτε να εισάγετε απλό κείμενο ή HTML. Για προηγμένες δυνατότητες εισαγωγής παρακαλούμε εγκαταστήστε το AbiWord.", "pad.modals.connected": "Συνδεμένοι.", "pad.modals.reconnecting": "Επανασύνδεση στο pad σας...", "pad.modals.forcereconnect": "Επιβολή επανασύνδεσης", @@ -90,6 +90,8 @@ "pad.chat": "Συνομιλία", "pad.chat.title": "Άνοιγμα της συνομιλίας για αυτό το pad.", "pad.chat.loadmessages": "Φόρτωση περισσότερων μηνυμάτων", + "pad.chat.stick.title": "Κρατήστε τη συνομιλία στην οθόνη", + "pad.chat.writeMessage.placeholder": "Γράψτε το μήνυμα σας εδώ", "timeslider.pageTitle": "{{appTitle}} Χρονοδιάγραμμα", "timeslider.toolbar.returnbutton": "Επιστροφή στο pad", "timeslider.toolbar.authors": "Συντάκτες:", diff --git a/src/locales/en-gb.json b/src/locales/en-gb.json index 6206f7736..f6199e4fe 100644 --- a/src/locales/en-gb.json +++ b/src/locales/en-gb.json @@ -4,7 +4,8 @@ "Chase me ladies, I'm the Cavalry", "Shirayuki", "Andibing", - "HairyFotr" + "HairyFotr", + "Cblair91" ] }, "index.newPad": "New Pad", @@ -89,6 +90,8 @@ "pad.chat": "Chat", "pad.chat.title": "Open the chat for this pad.", "pad.chat.loadmessages": "Load more messages", + "pad.chat.stick.title": "Stick chat to screen", + "pad.chat.writeMessage.placeholder": "Write your message here", "timeslider.pageTitle": "{{appTitle}} Timeslider", "timeslider.toolbar.returnbutton": "Return to pad", "timeslider.toolbar.authors": "Authors:", diff --git a/src/locales/en.json b/src/locales/en.json index e438fa1b4..c8ef1a7ae 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -116,6 +116,8 @@ "pad.chat": "Chat", "pad.chat.title": "Open the chat for this pad.", "pad.chat.loadmessages": "Load more messages", + "pad.chat.stick.title": "Stick chat to screen", + "pad.chat.writeMessage.placeholder": "Write your message here", "timeslider.pageTitle": "{{appTitle}} Timeslider", "timeslider.toolbar.returnbutton": "Return to pad", diff --git a/src/locales/eo.json b/src/locales/eo.json index 2754eaf7a..a56719ab3 100644 --- a/src/locales/eo.json +++ b/src/locales/eo.json @@ -4,7 +4,8 @@ "Eliovir", "Mschmitt", "Objectivesea", - "Robin van der Vliet" + "Robin van der Vliet", + "Mirin" ] }, "index.newPad": "Nova Teksto", @@ -89,6 +90,8 @@ "pad.chat": "Babilejo", "pad.chat.title": "Malfermi la babilejon por ĉi tiu teksto.", "pad.chat.loadmessages": "Ŝargi pliajn mesaĝojn", + "pad.chat.stick.title": "Alpingli babilejon al ekrano", + "pad.chat.writeMessage.placeholder": "Verki vian mesaĝon ĉi tie", "timeslider.pageTitle": "{{appTitle}} Tempoŝovilo", "timeslider.toolbar.returnbutton": "Reiri al teksto", "timeslider.toolbar.authors": "Aŭtoroj:", diff --git a/src/locales/es.json b/src/locales/es.json index 747ec7bf5..c22803f3e 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -99,6 +99,8 @@ "pad.chat": "Chat", "pad.chat.title": "Abrir el chat para este pad.", "pad.chat.loadmessages": "Cargar más mensajes", + "pad.chat.stick.title": "Ampliar", + "pad.chat.writeMessage.placeholder": "Enviar un mensaje", "timeslider.pageTitle": "{{appTitle}} Línea de tiempo", "timeslider.toolbar.returnbutton": "Volver al pad", "timeslider.toolbar.authors": "Autores:", diff --git a/src/locales/eu.json b/src/locales/eu.json index fd6c39504..f8ebb9cda 100644 --- a/src/locales/eu.json +++ b/src/locales/eu.json @@ -91,6 +91,8 @@ "pad.chat": "Txata", "pad.chat.title": "Pad honetarako txata ireki.", "pad.chat.loadmessages": "Mezu gehiago kargatu", + "pad.chat.stick.title": "Handitu", + "pad.chat.writeMessage.placeholder": "Zure mezua hemen idatzi", "timeslider.pageTitle": "{{appTitle}} denbora lerroa", "timeslider.toolbar.returnbutton": "Padera itzuli", "timeslider.toolbar.authors": "Egileak:", diff --git a/src/locales/fa.json b/src/locales/fa.json index 4b29669e4..099d67594 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -7,7 +7,8 @@ "Reza1615", "ZxxZxxZ", "الناز", - "Omid.koli" + "Omid.koli", + "FarsiNevis" ] }, "index.newPad": "دفترچه یادداشت تازه", @@ -92,6 +93,8 @@ "pad.chat": "گفتگو", "pad.chat.title": "بازکردن گفتگو برای این دفترچه یادداشت", "pad.chat.loadmessages": "بارگیری پیام‌های بیشتر", + "pad.chat.stick.title": "چسباندن چت به صفحه", + "pad.chat.writeMessage.placeholder": "پیام خود را این‌جا بنویسید", "timeslider.pageTitle": "لغزندهٔ زمان {{appTitle}}", "timeslider.toolbar.returnbutton": "بازگشت به دفترچه یادداشت", "timeslider.toolbar.authors": "نویسندگان:", diff --git a/src/locales/fr.json b/src/locales/fr.json index b7f59885b..d1f2a0855 100644 --- a/src/locales/fr.json +++ b/src/locales/fr.json @@ -24,11 +24,12 @@ "C13m3n7", "Wladek92", "Urhixidur", - "Envlh" + "Envlh", + "Verdy p" ] }, - "index.newPad": "Nouveau pad", - "index.createOpenPad": "ou créer/ouvrir un pad intitulé :", + "index.newPad": "Nouveau bloc-notes", + "index.createOpenPad": "ou créer/ouvrir un bloc-notes intitulé :", "pad.toolbar.bold.title": "Gras (Ctrl+B)", "pad.toolbar.italic.title": "Italique (Ctrl+I)", "pad.toolbar.underline.title": "Souligné (Ctrl+U)", @@ -39,87 +40,89 @@ "pad.toolbar.unindent.title": "Désindenter (Maj+TAB)", "pad.toolbar.undo.title": "Annuler (Ctrl+Z)", "pad.toolbar.redo.title": "Rétablir (Ctrl+Y)", - "pad.toolbar.clearAuthorship.title": "Effacer les couleurs identifiant les auteurs (Ctrl+Shift+C)", - "pad.toolbar.import_export.title": "Importer/Exporter de/vers un format de fichier différent", + "pad.toolbar.clearAuthorship.title": "Effacer le surlignage par auteur (Ctrl+Shift+C)", + "pad.toolbar.import_export.title": "Importer de/Exporter vers un format de fichier différent", "pad.toolbar.timeslider.title": "Historique dynamique", "pad.toolbar.savedRevision.title": "Enregistrer la révision", "pad.toolbar.settings.title": "Paramètres", - "pad.toolbar.embed.title": "Partager et intégrer ce pad", - "pad.toolbar.showusers.title": "Afficher les utilisateurs du pad", + "pad.toolbar.embed.title": "Partager et intégrer ce bloc-notes", + "pad.toolbar.showusers.title": "Afficher les utilisateurs du bloc-notes", "pad.colorpicker.save": "Enregistrer", "pad.colorpicker.cancel": "Annuler", - "pad.loading": "Chargement…", - "pad.noCookie": "Le cookie n’a pas pu être trouvé. Veuillez autoriser les cookies dans votre navigateur !", - "pad.passwordRequired": "Vous avez besoin d'un mot de passe pour accéder à ce pad", - "pad.permissionDenied": "Vous n'avez pas la permission d’accéder à ce pad", + "pad.loading": "Chargement...", + "pad.noCookie": "Un cookie n’a pas pu être trouvé. Veuillez autoriser les fichiers témoins (ou cookies) dans votre navigateur !", + "pad.passwordRequired": "Vous avez besoin d'un mot de passe pour accéder à ce bloc-note", + "pad.permissionDenied": "Vous n’êtes pas autorisé à accéder à ce bloc-notes", "pad.wrongPassword": "Votre mot de passe est incorrect", - "pad.settings.padSettings": "Paramètres du pad", + "pad.settings.padSettings": "Paramètres du bloc-notes", "pad.settings.myView": "Ma vue", "pad.settings.stickychat": "Toujours afficher le clavardage", - "pad.settings.chatandusers": "Afficher la discussion et les utilisateurs", - "pad.settings.colorcheck": "Couleurs d’identification", + "pad.settings.chatandusers": "Afficher le clavardage et les utilisateurs", + "pad.settings.colorcheck": "Surlignage par auteur", "pad.settings.linenocheck": "Numéros de lignes", - "pad.settings.rtlcheck": "Le contenu doit-il être lu de droite à gauche ?", - "pad.settings.fontType": "Police :", + "pad.settings.rtlcheck": "Le contenu doit-il être lu de droite à gauche ?", + "pad.settings.fontType": "Police :", "pad.settings.fontType.normal": "Normal", "pad.settings.fontType.monospaced": "Monospace", "pad.settings.globalView": "Vue d’ensemble", - "pad.settings.language": "Langue :", + "pad.settings.language": "Langue :", "pad.importExport.import_export": "Importer/Exporter", "pad.importExport.import": "Charger un texte ou un document", - "pad.importExport.importSuccessful": "Réussi !", - "pad.importExport.export": "Exporter le pad actuel comme :", + "pad.importExport.importSuccessful": "Réussi !", + "pad.importExport.export": "Exporter le bloc-notes actuel en :", "pad.importExport.exportetherpad": "Etherpad", "pad.importExport.exporthtml": "HTML", "pad.importExport.exportplain": "Texte brut", "pad.importExport.exportword": "Microsoft Word", "pad.importExport.exportpdf": "PDF", "pad.importExport.exportopen": "ODF (Open Document Format)", - "pad.importExport.abiword.innerHTML": "Vous ne pouvez importer que des formats texte brut ou HTML. Pour des fonctionnalités d'importation plus évoluées, veuillez installer AbiWord.", + "pad.importExport.abiword.innerHTML": "Vous ne pouvez importer que des formats texte brut ou HTML. Pour des fonctionnalités d’importation plus évoluées, veuillez installer AbiWord.", "pad.modals.connected": "Connecté.", - "pad.modals.reconnecting": "Reconnexion vers votre pad...", + "pad.modals.reconnecting": "Reconnexion à votre bloc-notes...", "pad.modals.forcereconnect": "Forcer la reconnexion", "pad.modals.reconnecttimer": "Essai de reconnexion", "pad.modals.cancel": "Annuler", "pad.modals.userdup": "Ouvert dans une autre fenêtre", - "pad.modals.userdup.explanation": "Ce pad semble être ouvert dans plusieurs fenêtres sur cet ordinateur.", + "pad.modals.userdup.explanation": "Ce bloc-notes semble être ouvert dans plusieurs fenêtres sur cet ordinateur.", "pad.modals.userdup.advice": "Se reconnecter en utilisant cette fenêtre.", "pad.modals.unauth": "Non autorisé", - "pad.modals.unauth.explanation": "Vos permissions ont été changées lors de l'affichage de cette page. Essayez de vous reconnecter.", - "pad.modals.looping.explanation": "Nous éprouvons un problème de communication au serveur de synchronisation.", + "pad.modals.unauth.explanation": "Vos autorisations ont été changées lors de l’affichage de cette page. Essayez de vous reconnecter.", + "pad.modals.looping.explanation": "Nous éprouvons des problèmes de communication au serveur de synchronisation.", "pad.modals.looping.cause": "Il est possible que vous soyez connecté avec un pare-feu ou un mandataire incompatible.", "pad.modals.initsocketfail": "Le serveur est introuvable.", "pad.modals.initsocketfail.explanation": "Impossible de se connecter au serveur de synchronisation.", "pad.modals.initsocketfail.cause": "Ceci est probablement dû à un problème avec votre navigateur ou votre connexion internet.", "pad.modals.slowcommit.explanation": "Le serveur ne répond pas.", - "pad.modals.slowcommit.cause": "Ce problème peut venir d'une mauvaise connectivité au réseau.", - "pad.modals.badChangeset.explanation": "Une modification que vous avez effectuée a été classée comme impossible par le serveur de synchronisation.", + "pad.modals.slowcommit.cause": "Ce problème peut venir d’une mauvaise connectivité au réseau.", + "pad.modals.badChangeset.explanation": "Une modification que vous avez effectuée a été classée comme interdite par le serveur de synchronisation.", "pad.modals.badChangeset.cause": "Cela peut être dû à une mauvaise configuration du serveur ou à un autre comportement inattendu. Veuillez contacter l’administrateur du service si vous pensez que c’est une erreur. Essayez de vous reconnecter pour continuer à modifier.", - "pad.modals.corruptPad.explanation": "Le pad auquel vous essayez d’accéder est corrompu.", + "pad.modals.corruptPad.explanation": "Le bloc-notes auquel vous essayez d’accéder est corrompu.", "pad.modals.corruptPad.cause": "Cela peut être dû à une mauvaise configuration du serveur ou à un autre comportement inattendu. Veuillez contacter l’administrateur du service.", "pad.modals.deleted": "Supprimé.", - "pad.modals.deleted.explanation": "Ce pad a été supprimé.", + "pad.modals.deleted.explanation": "Ce bloc-notes a été supprimé.", "pad.modals.disconnected": "Vous avez été déconnecté.", "pad.modals.disconnected.explanation": "La connexion au serveur a échoué.", "pad.modals.disconnected.cause": "Il se peut que le serveur soit indisponible. Si le problème persiste, veuillez en informer l’administrateur du service.", - "pad.share": "Partager ce pad", + "pad.share": "Partager ce bloc-notes", "pad.share.readonly": "Lecture seule", "pad.share.link": "Lien", "pad.share.emebdcode": "Incorporer un lien", "pad.chat": "Clavardage", - "pad.chat.title": "Ouvrir le clavardoir de ce pad.", + "pad.chat.title": "Ouvrir le clavardage sur ce bloc-notes.", "pad.chat.loadmessages": "Charger davantage de messages", + "pad.chat.stick.title": "Ancrer la discussion sur l’écran", + "pad.chat.writeMessage.placeholder": "Entrez votre message ici", "timeslider.pageTitle": "Historique dynamique de {{appTitle}}", - "timeslider.toolbar.returnbutton": "Retourner au pad", - "timeslider.toolbar.authors": "Auteurs :", + "timeslider.toolbar.returnbutton": "Retourner au bloc-notes", + "timeslider.toolbar.authors": "Auteurs :", "timeslider.toolbar.authorsList": "Aucun auteur", "timeslider.toolbar.exportlink.title": "Exporter", - "timeslider.exportCurrent": "Exporter la version actuelle sous :", + "timeslider.exportCurrent": "Exporter la version actuelle sous :", "timeslider.version": "Version {{version}}", "timeslider.saved": "Enregistré le {{day}} {{month}} {{year}}", - "timeslider.playPause": "Lecture / Pause des contenus du pad", - "timeslider.backRevision": "Reculer d’une révision dans ce pad", - "timeslider.forwardRevision": "Avancer d’une révision dans ce pad", + "timeslider.playPause": "Lecture / Pause des contenus du bloc-notes", + "timeslider.backRevision": "Reculer d’une révision dans ce bloc-notes", + "timeslider.forwardRevision": "Avancer d’une révision dans ce bloc-notes", "timeslider.dateformat": "{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}", "timeslider.month.january": "janvier", "timeslider.month.february": "février", @@ -135,20 +138,20 @@ "timeslider.month.december": "décembre", "timeslider.unnamedauthors": "{{num}} {[plural(num) one: auteur anonyme, other: auteurs anonymes ]}", "pad.savedrevs.marked": "Cette révision est maintenant marquée comme révision enregistrée", - "pad.savedrevs.timeslider": "Vous pouvez voir les révisions enregistrées en ouvrant l'historique", + "pad.savedrevs.timeslider": "Vous pouvez voir les révisions enregistrées en ouvrant l’historique", "pad.userlist.entername": "Entrez votre nom", "pad.userlist.unnamed": "anonyme", "pad.userlist.guest": "Invité", "pad.userlist.deny": "Refuser", "pad.userlist.approve": "Approuver", - "pad.editbar.clearcolors": "Effacer les couleurs de paternité des auteurs dans tout le document ?", + "pad.editbar.clearcolors": "Effacer le surlignage par auteur dans tout le document ?", "pad.impexp.importbutton": "Importer maintenant", "pad.impexp.importing": "Import en cours...", - "pad.impexp.confirmimport": "Importer un fichier écrasera le contenu actuel du pad. Êtes-vous sûr de vouloir le faire ?", + "pad.impexp.confirmimport": "Importer un fichier écrasera le contenu actuel du bloc-notes. Êtes-vous sûr de vouloir le faire ?", "pad.impexp.convertFailed": "Nous ne pouvons pas importer ce fichier. Veuillez utiliser un autre format de document ou faire manuellement un copier/coller du texte brut", - "pad.impexp.padHasData": "Nous n’avons pas pu importer ce fichier parce que ce pad a déjà eu des modifications ; veuillez donc créer un nouveau pad", + "pad.impexp.padHasData": "Nous n’avons pas pu importer ce fichier parce que ce bloc-notes a déjà été modifié ; veuillez l’importer vers un nouveau bloc-notes", "pad.impexp.uploadFailed": "Le téléversement a échoué, veuillez réessayer", - "pad.impexp.importfailed": "Échec de l'importation", - "pad.impexp.copypaste": "Veuillez copier/coller", - "pad.impexp.exportdisabled": "L'option d'export au format {{type}} est désactivée. Veuillez contacter votre administrateur système pour plus de détails." + "pad.impexp.importfailed": "Échec de l’importation", + "pad.impexp.copypaste": "Veuillez copier-coller", + "pad.impexp.exportdisabled": "L’exportation au format {{type}} est désactivée. Veuillez contacter votre administrateur système pour plus de détails." } diff --git a/src/locales/he.json b/src/locales/he.json index a857671da..01cad9352 100644 --- a/src/locales/he.json +++ b/src/locales/he.json @@ -89,6 +89,8 @@ "pad.chat": "שיחה", "pad.chat.title": "פתיחת השיחה של הפנקס הזה.", "pad.chat.loadmessages": "טעינת הודעות נוספות", + "pad.chat.stick.title": "הצמדת צ׳אט למסך", + "pad.chat.writeMessage.placeholder": "מקום לכתיבת ההודעה שלך", "timeslider.pageTitle": "גולל זמן של {{appTitle}}", "timeslider.toolbar.returnbutton": "חזרה אל הפנקס", "timeslider.toolbar.authors": "כותבים:", diff --git a/src/locales/hr.json b/src/locales/hr.json index 2c7190f5b..186ad1445 100644 --- a/src/locales/hr.json +++ b/src/locales/hr.json @@ -1,7 +1,8 @@ { "@metadata": { "authors": [ - "Bugoslav" + "Bugoslav", + "Hmxhmx" ] }, "index.newPad": "Novi blokić", @@ -84,6 +85,8 @@ "pad.chat": "Čavrljanje", "pad.chat.title": "Otvori čavrljanje uz ovaj blokić.", "pad.chat.loadmessages": "Učitaj više poruka", + "pad.chat.stick.title": "Prilijepi razgovor na zaslon", + "pad.chat.writeMessage.placeholder": "Napišite Vašu poruku ovdje", "timeslider.pageTitle": "{{appTitle}} Vremenska lenta", "timeslider.toolbar.returnbutton": "Vrati se natrag na blokić", "timeslider.toolbar.authors": "Autori:", diff --git a/src/locales/hu.json b/src/locales/hu.json index af273f695..fb00c6df2 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -93,6 +93,8 @@ "pad.chat": "Csevegés", "pad.chat.title": "A noteszhez tartozó csevegés megnyitása.", "pad.chat.loadmessages": "További üzenetek betöltése", + "pad.chat.stick.title": "Csevegés a képernyőre", + "pad.chat.writeMessage.placeholder": "Írja az üzenetét ide", "timeslider.pageTitle": "{{appTitle}} időcsúszka", "timeslider.toolbar.returnbutton": "Vissza a noteszhez", "timeslider.toolbar.authors": "Szerzők:", diff --git a/src/locales/is.json b/src/locales/is.json index 65226445d..87f52ac0d 100644 --- a/src/locales/is.json +++ b/src/locales/is.json @@ -87,6 +87,8 @@ "pad.chat": "Spjall", "pad.chat.title": "Opna spjallið fyrir þessa skrifblokk.", "pad.chat.loadmessages": "Hlaða inn fleiri skeytum", + "pad.chat.stick.title": "Festa spjallið á skjáinn", + "pad.chat.writeMessage.placeholder": "Skrifaðu skilaboðin þín hér", "timeslider.pageTitle": "Tímalína {{appTitle}}", "timeslider.toolbar.returnbutton": "Fara til baka í skrifblokk", "timeslider.toolbar.authors": "Höfundar:", diff --git a/src/locales/it.json b/src/locales/it.json index d7d7c095a..30881889d 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -17,8 +17,8 @@ "pad.toolbar.strikethrough.title": "Barrato (Ctrl+5)", "pad.toolbar.ol.title": "Elenco numerato (Ctrl+Shift+N)", "pad.toolbar.ul.title": "Elenco puntato (Ctrl+Shift+L)", - "pad.toolbar.indent.title": "Rientro (TAB)", - "pad.toolbar.unindent.title": "Riduci rientro (Shift+TAB)", + "pad.toolbar.indent.title": "Indentazione (TAB)", + "pad.toolbar.unindent.title": "Riduci indentazione (Shift+TAB)", "pad.toolbar.undo.title": "Annulla (Ctrl-Z)", "pad.toolbar.redo.title": "Ripeti (Ctrl-Y)", "pad.toolbar.clearAuthorship.title": "Elimina i colori che indicano gli autori (Ctrl+Shift+C)", @@ -91,6 +91,8 @@ "pad.chat": "Chat", "pad.chat.title": "Apri la chat per questo Pad.", "pad.chat.loadmessages": "Carica altri messaggi", + "pad.chat.stick.title": "Ancora chat nello schermo", + "pad.chat.writeMessage.placeholder": "Scrivi il tuo messaggio qui", "timeslider.pageTitle": "Cronologia {{appTitle}}", "timeslider.toolbar.returnbutton": "Ritorna al Pad", "timeslider.toolbar.authors": "Autori:", diff --git a/src/locales/ja.json b/src/locales/ja.json index fe200fc21..13d3d0c4a 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -4,7 +4,8 @@ "Shirayuki", "Torinky", "Omotecho", - "Aefgh39622" + "Aefgh39622", + "Afaz" ] }, "index.newPad": "新規作成", @@ -89,6 +90,8 @@ "pad.chat": "チャット", "pad.chat.title": "このパッドのチャットを開きます。", "pad.chat.loadmessages": "その他のメッセージを読み込む", + "pad.chat.stick.title": "チャットを画面に貼り付ける", + "pad.chat.writeMessage.placeholder": "ここにメッセージを書き込んでください", "timeslider.pageTitle": "{{appTitle}} タイムスライダー", "timeslider.toolbar.returnbutton": "パッドに戻る", "timeslider.toolbar.authors": "作者:", diff --git a/src/locales/kab.json b/src/locales/kab.json index 905c38558..191068ed8 100644 --- a/src/locales/kab.json +++ b/src/locales/kab.json @@ -84,6 +84,8 @@ "pad.chat": "Asqerdec", "pad.chat.title": "Ldi asqerdec deg upad-agi.", "pad.chat.loadmessages": "Sali-d ugar n yiznan", + "pad.chat.stick.title": "Senṭeḍ adiwenni deg ugdil", + "pad.chat.writeMessage.placeholder": "Aru izen dagi", "timeslider.pageTitle": "Amazray asmussan n {{appTitle}}", "timeslider.toolbar.returnbutton": "Uqal ar upad", "timeslider.toolbar.authors": "Imeskaren:", diff --git a/src/locales/ko.json b/src/locales/ko.json index 19625eaea..dc325cdd4 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -92,6 +92,8 @@ "pad.chat": "대화", "pad.chat.title": "이 패드에 대화를 엽니다.", "pad.chat.loadmessages": "더 많은 메시지 불러오기", + "pad.chat.stick.title": "채팅을 화면에 고정", + "pad.chat.writeMessage.placeholder": "여기에 메시지를 적으십시오", "timeslider.pageTitle": "{{appTitle}} 시간슬라이더", "timeslider.toolbar.returnbutton": "패드로 돌아가기", "timeslider.toolbar.authors": "저자:", diff --git a/src/locales/ku-latn.json b/src/locales/ku-latn.json index b5edc68b0..f80466036 100644 --- a/src/locales/ku-latn.json +++ b/src/locales/ku-latn.json @@ -6,7 +6,8 @@ "George Animal", "Gomada", "Mehk63", - "Ghybu" + "Ghybu", + "MikaelF" ] }, "index.newPad": "Bloknota nû", @@ -60,18 +61,18 @@ "timeslider.version": "Guhertoya {{version}}", "timeslider.saved": "Di dîroka {{day}} {{month}} {{year}} de hate tomarkirin", "timeslider.dateformat": "{{day}}/{{month}}/{{year}} {{hours}}.{{minutes}}.{{seconds}}", - "timeslider.month.january": "rêbendan", - "timeslider.month.february": "reşemî", + "timeslider.month.january": "kanûna paşîn", + "timeslider.month.february": "sibat", "timeslider.month.march": "adar", - "timeslider.month.april": "avrêl", + "timeslider.month.april": "nîsan", "timeslider.month.may": "gulan", - "timeslider.month.june": "pûşper", + "timeslider.month.june": "hezîran", "timeslider.month.july": "tîrmeh", - "timeslider.month.august": "gelawêj", - "timeslider.month.september": "rezber", - "timeslider.month.october": "kewçêr", - "timeslider.month.november": "Mijdar", - "timeslider.month.december": "berfanbar", + "timeslider.month.august": "tebax", + "timeslider.month.september": "îlon", + "timeslider.month.october": "çiriya pêşîn", + "timeslider.month.november": "Çiriya paşîn", + "timeslider.month.december": "kanûna pêşîn", "pad.userlist.entername": "Navê xwe têkeve", "pad.userlist.unnamed": "nenavkirî", "pad.userlist.guest": "Mêvan", diff --git a/src/locales/lb.json b/src/locales/lb.json index 2f1432a61..c81ee018d 100644 --- a/src/locales/lb.json +++ b/src/locales/lb.json @@ -12,8 +12,8 @@ "pad.toolbar.italic.title": "Schréi (Ctrl+I)", "pad.toolbar.underline.title": "Ënnerstrach (Ctrl+U)", "pad.toolbar.strikethrough.title": "Duerchgestrach (Ctrl+5)", - "pad.toolbar.ol.title": "Numeréiert Lëscht (Ctrl+Shift+N)", - "pad.toolbar.ul.title": "Net-numeréiert Lëscht (Ctrl+Shift+L)", + "pad.toolbar.ol.title": "Nummeréiert Lëscht (Ctrl+Shift+N)", + "pad.toolbar.ul.title": "Net-nummeréiert Lëscht (Ctrl+Shift+L)", "pad.toolbar.indent.title": "Aréckelen (TAB)", "pad.toolbar.unindent.title": "Erausréckelen (Shift+TAB)", "pad.toolbar.undo.title": "Réckgängeg (Ctrl-Z)", @@ -59,6 +59,7 @@ "pad.share.link": "Link", "pad.chat": "Chat", "pad.chat.loadmessages": "Méi Message lueden", + "pad.chat.writeMessage.placeholder": "Schreift Äre Message hei", "timeslider.toolbar.authors": "Auteuren:", "timeslider.toolbar.authorsList": "Keng Auteuren", "timeslider.toolbar.exportlink.title": "Exportéieren", diff --git a/src/locales/lrc.json b/src/locales/lrc.json index 910f8f1ef..654297810 100644 --- a/src/locales/lrc.json +++ b/src/locales/lrc.json @@ -1,78 +1,80 @@ { "@metadata": { "authors": [ - "Mogoeilor" + "Mogoeilor", + "Lorestani" ] }, - "index.newPad": "دشته تازه", - "pad.toolbar.bold.title": "توپر", - "pad.toolbar.italic.title": "کج کوله(ctrl-l)", - "pad.toolbar.underline.title": "زیر خط دار بین (Ctrl-U)", - "pad.toolbar.ol.title": "نوم گه منظم", - "pad.toolbar.ul.title": "نوم گه بی نظم", - "pad.toolbar.indent.title": "مئن رئته(TAB)", - "pad.toolbar.unindent.title": "وه در رئته (Shift+TAB)", - "pad.toolbar.undo.title": "رد انجوم دئین (Ctrl-Z)", - "pad.toolbar.redo.title": "د نو انجوم دئین(Ctrl-Y)", + "index.newPad": "دٱفتٱرچٱ تازٱ", + "pad.toolbar.bold.title": "تۊپور", + "pad.toolbar.italic.title": "هٱلٛ هار(ctrl-l)", + "pad.toolbar.underline.title": "زؽر خٱت (Ctrl-U)", + "pad.toolbar.ol.title": "نومگٱ مورٱتٱب بیٱ", + "pad.toolbar.ul.title": "نومگٱ مورٱتٱب ناٛیٱ", + "pad.toolbar.indent.title": "قوپساٛیی(TAB)", + "pad.toolbar.unindent.title": "ڤ دٱر رٱتاٛیی (Shift+TAB)", + "pad.toolbar.undo.title": "رٱد ٱنجوم داٛئن (Ctrl-Z)", + "pad.toolbar.redo.title": "د نۊ ٱنجوم داٛئن(Ctrl-Y)", + "pad.toolbar.savedRevision.title": "ڤانری بٱلگٱ", "pad.toolbar.settings.title": "میزوکاری", - "pad.colorpicker.save": "ذخيره كردن", - "pad.colorpicker.cancel": "انجوم شیو كردن", - "pad.loading": "د حالت سوار كرد", - "pad.wrongPassword": "پاسوردتو اشتوائه", - "pad.settings.padSettings": "میزوکاری دشته", - "pad.settings.myView": "نظرگه مه", - "pad.settings.stickychat": "همیشه د بلگه چک چنه بکید", - "pad.settings.linenocheck": "شماره خطیا", + "pad.colorpicker.save": "زٱخیرٱ كردن", + "pad.colorpicker.cancel": "ٱنجوم شؽڤ كردن", + "pad.loading": "د هالٱت سڤار كرد...", + "pad.wrongPassword": "پٱسڤردتو اْشتبائٱ", + "pad.settings.padSettings": "میزوکاری دٱفتٱرچٱ", + "pad.settings.myView": "نٱزٱرگٱ ماْ", + "pad.settings.stickychat": "همیشٱ د بٱلگٱ چٱک چنٱ بٱکؽت", + "pad.settings.linenocheck": "شمارٱ خٱتؽا", "pad.settings.fontType": "نوع فونت:", "pad.settings.fontType.normal": "عادی", "pad.settings.fontType.monospaced": "تک جاگه", "pad.settings.globalView": "دیئن جهونی", - "pad.settings.language": "زون:", - "pad.importExport.import_export": "وامین آوردن/د در دئن", - "pad.importExport.importSuccessful": "موفق بی!", - "pad.importExport.export": "دشته تازه چی وه در بیه:", - "pad.importExport.exporthtml": "اچ تی ام ال", - "pad.importExport.exportplain": "نیسسه ساده", - "pad.importExport.exportword": "واجه پالایشتگر مایکروسافت", - "pad.importExport.exportpdf": "پی دی اف", - "pad.importExport.exportopen": "او دی اف(قالو سند وا بیه)", - "pad.modals.connected": "وصل بیه", - "pad.modals.forcereconnect": "سی وصل بین مژبور کو", - "pad.modals.userdup": "د نیمدری هنی واز بیه", - "pad.modals.initsocketfail": "سرور د دسرسی نئ.", - "pad.modals.deleted": "پاک بیه", - "pad.modals.deleted.explanation": "ای دشته جا وه جا بیه", - "pad.modals.disconnected": "ارتواطتو قطع بیه", - "pad.share": "ای دشته نه بهر کو", - "pad.share.readonly": "فقط بحون", - "pad.share.link": "هوم پیوند", - "pad.chat": "گپ زئن", - "pad.chat.title": "گپ چنه نه سی دشته وا کو.", - "pad.chat.loadmessages": "پیغومیا بیشتر نه سوار کو", - "timeslider.toolbar.returnbutton": "ورگرد د دشته", - "timeslider.toolbar.authors": "نیسنه یا:", - "timeslider.toolbar.authorsList": "بی نیسنه", - "timeslider.toolbar.exportlink.title": "وه در ديئن", - "timeslider.version": "نسقه{{نسقه}}", - "timeslider.month.january": "جانويه", - "timeslider.month.february": "فوريه", + "pad.settings.language": "زڤون:", + "pad.importExport.import_export": "ڤامین آوئردن/ڤ دٱر داٛئن", + "pad.importExport.importSuccessful": "موئٱفٱق بی!", + "pad.importExport.export": "دٱفتٱرچٱ تازٱ چی ڤ دٱر بیٱ:", + "pad.importExport.exporthtml": "اْچ تی اْم اْل", + "pad.importExport.exportplain": "نیسسٱ سادٱ", + "pad.importExport.exportword": "ڤاژٱ پالایشگٱر مایکروسافت", + "pad.importExport.exportpdf": "پی دی اْف", + "pad.importExport.exportopen": "او دی اْف(قالب سٱنٱد ڤاز)", + "pad.modals.connected": "ڤٱسل بیٱ", + "pad.modals.forcereconnect": "سی ڤٱسل بیئن دوئارٱ مٱجبۊر کو", + "pad.modals.userdup": "د نیمدری هنی ڤاز بیٱ", + "pad.modals.initsocketfail": "سرور د دٱسرسی نؽ.", + "pad.modals.deleted": "پاک بیٱ", + "pad.modals.deleted.explanation": "اؽ دٱفتٱرچٱ جا ڤ جا بیٱ", + "pad.modals.disconnected": "اْرتبات تو قٱت بیٱ.", + "pad.share": "اؽ دٱفتٱرچٱ ناْ بٱئر کو", + "pad.share.readonly": "فقٱت ڤٱننی", + "pad.share.link": "هوم پاٛڤٱن", + "pad.chat": "سالفٱ", + "pad.chat.title": "سالفٱ ناْ سی دٱفتٱرچٱ ڤاز کو.", + "pad.chat.loadmessages": "پاٛغومؽا ؽشتر ناْ سڤار کو", + "timeslider.toolbar.returnbutton": "ڤرگٱشتن ڤ دٱفتٱرچٱ", + "timeslider.toolbar.authors": "نیسٱنٱ یا:", + "timeslider.toolbar.authorsList": "بؽ نیسٱنٱ", + "timeslider.toolbar.exportlink.title": "ڤ دٱر داٛئن", + "timeslider.version": "نۏسخٱ{{نۏسخٱ}}", + "timeslider.month.january": "ژانڤیٱ", + "timeslider.month.february": "فڤریٱ", "timeslider.month.march": "مارس", - "timeslider.month.april": "آوريل", - "timeslider.month.may": "ما", - "timeslider.month.june": "جوئن", - "timeslider.month.july": "جولای", - "timeslider.month.august": "اگوست", + "timeslider.month.april": "آڤريل", + "timeslider.month.may": "ماٛی", + "timeslider.month.june": "ژوئٱن", + "timeslider.month.july": "جۊلای", + "timeslider.month.august": "آگوست", "timeslider.month.september": "سپتامر", - "timeslider.month.october": "اكتور", - "timeslider.month.november": "نوامر", + "timeslider.month.october": "اوكتوبر", + "timeslider.month.november": "نوڤامر", "timeslider.month.december": "دسامر", - "pad.userlist.entername": "نوم تونه وارد بکید", - "pad.userlist.unnamed": "نوم نهشته", - "pad.userlist.guest": "میزوان", - "pad.userlist.deny": "پرو کردن", - "pad.userlist.approve": "اصلا کردن", - "pad.impexp.importbutton": "ایسه وارد کو", - "pad.impexp.importing": "د حالت وارد کردن", - "pad.impexp.importfailed": "وامین آوردن شکست حرد", - "pad.impexp.copypaste": "خواهشن وردار بدیسن" + "pad.userlist.entername": "نوم توناْ ڤارد بٱکؽت", + "pad.userlist.unnamed": "بؽ نوم", + "pad.userlist.guest": "ماٛموݩ", + "pad.userlist.deny": "رٱد کردن", + "pad.userlist.approve": "قبۊل کردن", + "pad.impexp.importbutton": "ایساْ ڤارد کو", + "pad.impexp.importing": "د هالٱت ڤارد کردن...", + "pad.impexp.importfailed": "ڤامؽن آوئردن شکٱست هٱرد", + "pad.impexp.copypaste": "خاهشٱن ڤردار بٱدیسن" } diff --git a/src/locales/lt.json b/src/locales/lt.json index 0dca1db38..29b944598 100644 --- a/src/locales/lt.json +++ b/src/locales/lt.json @@ -5,7 +5,8 @@ "Mantak111", "I-svetaines", "Zygimantus", - "Vogone" + "Vogone", + "Naktis" ] }, "index.newPad": "Naujas bloknotas", @@ -59,6 +60,8 @@ "pad.modals.connected": "Prisijungta.", "pad.modals.reconnecting": "Iš naujo prisijungiama prie Jūsų bloknoto", "pad.modals.forcereconnect": "Priversti prisijungti iš naujo", + "pad.modals.reconnecttimer": "Bandoma vėl prisijungti", + "pad.modals.cancel": "Atšaukti", "pad.modals.userdup": "Atidaryta kitame lange", "pad.modals.userdup.explanation": "Šis bloknotas, atrodo yra atidarytas daugiau nei viename šio kompiuterio naršyklės lange.", "pad.modals.userdup.advice": "Prisijunkite iš naujo, kad vietoj to naudotumėte šį langą.", @@ -87,6 +90,8 @@ "pad.chat": "Pokalbiai", "pad.chat.title": "Atverti šio bloknoto pokalbį.", "pad.chat.loadmessages": "Įkrauti daugiau pranešimų", + "pad.chat.stick.title": "Priklijuoti pokalbį", + "pad.chat.writeMessage.placeholder": "Rašykite savo žinutę čia", "timeslider.pageTitle": "{{appTitle}} Laiko slinkiklis", "timeslider.toolbar.returnbutton": "Grįžti į bloknotą", "timeslider.toolbar.authors": "Autoriai:", diff --git a/src/locales/mk.json b/src/locales/mk.json index fda3bf4ff..0424e1abe 100644 --- a/src/locales/mk.json +++ b/src/locales/mk.json @@ -2,7 +2,8 @@ "@metadata": { "authors": [ "Bjankuloski06", - "Brest" + "Brest", + "Vlad5250" ] }, "index.newPad": "Нова тетратка", @@ -23,7 +24,7 @@ "pad.toolbar.savedRevision.title": "Зачувај преработка", "pad.toolbar.settings.title": "Поставки", "pad.toolbar.embed.title": "Споделете и вметнете ја тетраткава", - "pad.toolbar.showusers.title": "Прикаж. корисниците на тетраткава", + "pad.toolbar.showusers.title": "Прикажи корисниците на тетраткава", "pad.colorpicker.save": "Зачувај", "pad.colorpicker.cancel": "Откажи", "pad.loading": "Вчитувам...", @@ -87,6 +88,8 @@ "pad.chat": "Разговор", "pad.chat.title": "Отвори го разговорот за оваа тетратка.", "pad.chat.loadmessages": "Вчитај уште пораки", + "pad.chat.stick.title": "Залепи го разговорот на екранот", + "pad.chat.writeMessage.placeholder": "Тука напишете порака", "timeslider.pageTitle": "{{appTitle}} Историски преглед", "timeslider.toolbar.returnbutton": "Назад на тетратката", "timeslider.toolbar.authors": "Автори:", @@ -122,7 +125,7 @@ "pad.editbar.clearcolors": "Да ги отстранам авторските бои од целиот документ?", "pad.impexp.importbutton": "Увези сега", "pad.impexp.importing": "Увезувам...", - "pad.impexp.confirmimport": "Увезувајќи ја податотеката ќе го замените целиот досегашен текст во тетратката. Дали сте сигурни дека сакате да продолжите?", + "pad.impexp.confirmimport": "Увезувањето на податотека ќе го презапише тековниот текст на тетратката. Дали сте сигурни дека сакате да продолжите?", "pad.impexp.convertFailed": "Не можев да ја увезам податотеката. Послужете се со поинаков формат или прекопирајте го текстот рачно.", "pad.impexp.padHasData": "Не можевме да ја увеземе оваа податотека бидејќи оваа тетратка веќе има промени. Увезете ја во нова тетратка.", "pad.impexp.uploadFailed": "Подигањето не успеа. Обидете се повторно.", diff --git a/src/locales/nb.json b/src/locales/nb.json index bd39b18a9..e64508075 100644 --- a/src/locales/nb.json +++ b/src/locales/nb.json @@ -90,6 +90,8 @@ "pad.chat": "Chat", "pad.chat.title": "Åpne chatten for denne blokken.", "pad.chat.loadmessages": "Last flere beskjeder", + "pad.chat.stick.title": "Fest chatten til skjermen", + "pad.chat.writeMessage.placeholder": "Skriv beskjeden din her", "timeslider.pageTitle": "{{appTitle}} Tidslinje", "timeslider.toolbar.returnbutton": "Gå tilbake til blokk", "timeslider.toolbar.authors": "Forfattere:", diff --git a/src/locales/nl.json b/src/locales/nl.json index 6f468d531..25dc06714 100644 --- a/src/locales/nl.json +++ b/src/locales/nl.json @@ -7,7 +7,8 @@ "Robin van der Vliet", "Mainframe98", "KlaasZ4usV", - "Rickvl" + "Rickvl", + "Marcelhospers" ] }, "index.newPad": "Nieuw pad", @@ -92,6 +93,8 @@ "pad.chat": "Chatten", "pad.chat.title": "Chat voor dit pad opnenen", "pad.chat.loadmessages": "Meer berichten laden", + "pad.chat.stick.title": "Zet de chat op het scherm", + "pad.chat.writeMessage.placeholder": "Schrijf je bericht hier", "timeslider.pageTitle": "Tijdlijn voor {{appTitle}}", "timeslider.toolbar.returnbutton": "Terug naar pad", "timeslider.toolbar.authors": "Auteurs:", diff --git a/src/locales/pa.json b/src/locales/pa.json index f1e8ffc92..2b3a085af 100644 --- a/src/locales/pa.json +++ b/src/locales/pa.json @@ -4,7 +4,8 @@ "Aalam", "Babanwalia", "ਪ੍ਰਚਾਰਕ", - "Tow" + "Tow", + "ਗੁਰਪ੍ਰੀਤ ਹੁੰਦਲ" ] }, "index.newPad": "ਨਵਾਂ ਪੈਡ", @@ -22,7 +23,7 @@ "pad.toolbar.clearAuthorship.title": "ਪਰਮਾਣਕਿਤਾ ਰੰਗ ਸਾਫ਼ ਕਰੋ (Ctrl+Shift+C)", "pad.toolbar.import_export.title": "ਵੱਖ-ਵੱਖ ਫਾਇਲ ਫਾਰਮੈਟ ਤੋਂ/ਵਿੱਚ ਇੰਪੋਰਟ/ਐਕਸਪੋਰਟ ਕਰੋ", "pad.toolbar.timeslider.title": "ਸਮਾਂ-ਲਕੀਰ", - "pad.toolbar.savedRevision.title": "ਰੀਵਿਜ਼ਨ ਸੰਭਾਲੋ", + "pad.toolbar.savedRevision.title": "ਦੁਹਰਾਅ ਸਾਂਭੋ", "pad.toolbar.settings.title": "ਸੈਟਿੰਗ", "pad.toolbar.embed.title": "ਇਹ ਪੈਡ ਸਾਂਝਾ ਤੇ ਇੰਬੈੱਡ ਕਰੋ", "pad.toolbar.showusers.title": "ਇਹ ਪੈਡ ਉੱਤੇ ਯੂਜ਼ਰ ਵੇਖਾਓ", @@ -30,9 +31,9 @@ "pad.colorpicker.cancel": "ਰੱਦ ਕਰੋ", "pad.loading": "…ਲੋਡ ਕੀਤਾ ਜਾ ਰਿਹਾ ਹੈ", "pad.noCookie": "ਕੂਕੀਜ਼ ਨਹੀਂ ਲੱਭੀਅਾਂ। ਕਿਰਪਾ ਕਰਕੇ ਬ੍ਰਾੳੂਜ਼ਰ ਵਿੱਚ ਕੂਕੀਜ਼ ਲਾਗੂ ਕਰੋ।", - "pad.passwordRequired": "ਇਹ ਪੈਡ ਦੀ ਵਰਤੋਂ ਕਰਨ ਲਈ ਤੁਹਾਨੂੰ ਪਾਸਵਰਡ ਚਾਹੀਦਾ ਹੈ", + "pad.passwordRequired": "ਇਸ ਪੈਡ ਤੱਕ ਅਪੜਨ ਲਈ ਤੁਹਾਨੂੰ ਇੱਕ ਲੰਘ-ਸ਼ਬਦ ਦੀ ਲੋੜ ਹੈ", "pad.permissionDenied": "ਇਹ ਪੈਡ ਵਰਤਨ ਲਈ ਤੁਹਾਨੂੰ ਅਧਿਕਾਰ ਨਹੀਂ ਹਨ", - "pad.wrongPassword": "ਤੁਹਾਡਾ ਪਾਸਵਰਡ ਗਲਤੀ ਸੀ", + "pad.wrongPassword": "ਤੁਹਾਡਾ ਲੰਘ-ਸ਼ਬਦ ਗਲਤ ਸੀ", "pad.settings.padSettings": "ਪੈਡ ਸੈਟਿੰਗ", "pad.settings.myView": "ਮੇਰੀ ਝਲਕ", "pad.settings.stickychat": "ਹਮੇਸ਼ਾ ਸਕਰੀਨ ਉੱਤੇ ਗੱਲ ਕਰੋ", @@ -97,7 +98,7 @@ "timeslider.saved": "{{day}} {{month}} {{year}} ਨੂੰ ਸੰਭਾਲਿਆ", "timeslider.playPause": "ਪੈਡ ਸਮੱਗਰੀ ਚਲਾਓ / ਵਿਰਾਮ ਕਰੋ", "timeslider.backRevision": "ਇਸ ਪੈਡ ਵਿੱਚ ਪਿਛਲੇ ਰੀਵਿਜ਼ਨ ਤੇ ਜਾਓ", - "timeslider.forwardRevision": "ਇਸ ਪੈਡ ਵਿੱਚ ਅਗਲੇ ਰੀਵਿਜ਼ਨ ਤੇ ਜਾਓ", + "timeslider.forwardRevision": "ਇਸ ਪੈਡ ਵਿੱਚ ਅਗਲੇ ਦੁਹਰਾਅ ਤੇ ਜਾਓ", "timeslider.dateformat": "{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}", "timeslider.month.january": "ਜਨਵਰੀ", "timeslider.month.february": "ਫ਼ਰਵਰੀ", diff --git a/src/locales/pms.json b/src/locales/pms.json index cdc80bea3..0ae18bf1e 100644 --- a/src/locales/pms.json +++ b/src/locales/pms.json @@ -84,6 +84,8 @@ "pad.chat": "Ciaciarada", "pad.chat.title": "Duverté la ciaciarada për cost feuj.", "pad.chat.loadmessages": "Carié pi 'd mëssagi", + "pad.chat.stick.title": "Taché la ciaciarada an slë scren", + "pad.chat.writeMessage.placeholder": "Ch'a scriva sò mëssage ambelessì", "timeslider.pageTitle": "Stòria dinàmica ëd {{appTitle}}", "timeslider.toolbar.returnbutton": "Torné al feuj", "timeslider.toolbar.authors": "Autor:", diff --git a/src/locales/pt-br.json b/src/locales/pt-br.json index 967af6a17..5f27927f8 100644 --- a/src/locales/pt-br.json +++ b/src/locales/pt-br.json @@ -101,6 +101,8 @@ "pad.chat": "Bate-papo", "pad.chat.title": "Abrir o bate-papo desta nota.", "pad.chat.loadmessages": "Carregar mais mensagens", + "pad.chat.stick.title": "Cole o bate-papo na tela", + "pad.chat.writeMessage.placeholder": "Escreva sua mensagem aqui", "timeslider.pageTitle": "Linha do tempo de {{appTitle}}", "timeslider.toolbar.returnbutton": "Retornar para a nota", "timeslider.toolbar.authors": "Autores:", diff --git a/src/locales/pt.json b/src/locales/pt.json index a65730a08..ecd8da944 100644 --- a/src/locales/pt.json +++ b/src/locales/pt.json @@ -9,38 +9,39 @@ "Macofe", "Ti4goc", "Cainamarques", - "Athena in Wonderland" + "Athena in Wonderland", + "Waldyrious" ] }, "index.newPad": "Nova Nota", - "index.createOpenPad": "ou crie/abra uma Nota com o nome:", - "pad.toolbar.bold.title": "Negrito (Ctrl-B)", - "pad.toolbar.italic.title": "Itálico (Ctrl-I)", - "pad.toolbar.underline.title": "Sublinhado (Ctrl-U)", + "index.createOpenPad": "ou crie/abra uma nota com o nome:", + "pad.toolbar.bold.title": "Negrito (Ctrl+B)", + "pad.toolbar.italic.title": "Itálico (Ctrl+I)", + "pad.toolbar.underline.title": "Sublinhado (Ctrl+U)", "pad.toolbar.strikethrough.title": "Riscar (Ctrl+5)", "pad.toolbar.ol.title": "Lista ordenada (Ctrl+Shift+N)", "pad.toolbar.ul.title": "Lista desordenada (Ctrl+Shift+L)", - "pad.toolbar.indent.title": "Avançar (TAB)", - "pad.toolbar.unindent.title": "Recuar (Shift+TAB)", - "pad.toolbar.undo.title": "Desfazer (Ctrl-Z)", - "pad.toolbar.redo.title": "Refazer (Ctrl-Y)", + "pad.toolbar.indent.title": "Indentar (TAB)", + "pad.toolbar.unindent.title": "Remover indentação (Shift+TAB)", + "pad.toolbar.undo.title": "Desfazer (Ctrl+Z)", + "pad.toolbar.redo.title": "Refazer (Ctrl+Y)", "pad.toolbar.clearAuthorship.title": "Limpar cores de autoria (Ctrl+Shift+C)", "pad.toolbar.import_export.title": "Importar/exportar de/para diferentes formatos de ficheiro", "pad.toolbar.timeslider.title": "Linha de tempo", - "pad.toolbar.savedRevision.title": "Salvar revisão", + "pad.toolbar.savedRevision.title": "Gravar revisão", "pad.toolbar.settings.title": "Configurações", - "pad.toolbar.embed.title": "Compartilhar e incorporar este pad", - "pad.toolbar.showusers.title": "Mostrar os utilizadores nesta Nota", + "pad.toolbar.embed.title": "Partilhar e incorporar esta nota", + "pad.toolbar.showusers.title": "Mostrar os utilizadores nesta nota", "pad.colorpicker.save": "Gravar", "pad.colorpicker.cancel": "Cancelar", "pad.loading": "A carregar…", "pad.noCookie": "O cookie não foi encontrado. Por favor, ative os cookies no seu navegador!", - "pad.passwordRequired": "Precisa de uma senha para aceder a este pad", - "pad.permissionDenied": "Não tem permissão para aceder a este pad.", - "pad.wrongPassword": "A palavra-chave está errada", - "pad.settings.padSettings": "Configurações da Nota", - "pad.settings.myView": "Minha vista", - "pad.settings.stickychat": "Bate-papo sempre no ecrã", + "pad.passwordRequired": "Precisa de uma palavra-passe para aceder a esta nota", + "pad.permissionDenied": "Não tem permissão para aceder a esta nota", + "pad.wrongPassword": "A sua palavra-passe estava errada", + "pad.settings.padSettings": "Configurações da nota", + "pad.settings.myView": "A minha vista", + "pad.settings.stickychat": "Conversação sempre no ecrã", "pad.settings.chatandusers": "Mostrar a conversação e os utilizadores", "pad.settings.colorcheck": "Cores de autoria", "pad.settings.linenocheck": "Números de linha", @@ -52,8 +53,8 @@ "pad.settings.language": "Língua:", "pad.importExport.import_export": "Importar/Exportar", "pad.importExport.import": "Carregar qualquer ficheiro de texto ou documento", - "pad.importExport.importSuccessful": "Bem sucedido!", - "pad.importExport.export": "Exportar a Nota atual como:", + "pad.importExport.importSuccessful": "Completo!", + "pad.importExport.export": "Exportar a nota atual como:", "pad.importExport.exportetherpad": "Etherpad", "pad.importExport.exporthtml": "HTML", "pad.importExport.exportplain": "Texto simples", @@ -62,19 +63,19 @@ "pad.importExport.exportopen": "ODF (Open Document Format)", "pad.importExport.abiword.innerHTML": "Só é possível importar texto sem formatação ou HTML. Para obter funcionalidades de importação mais avançadas, por favor instale o AbiWord.", "pad.modals.connected": "Ligado.", - "pad.modals.reconnecting": "Reconectando-se ao seu bloco…", - "pad.modals.forcereconnect": "Forçar reconexão", - "pad.modals.reconnecttimer": "A tentar religar", + "pad.modals.reconnecting": "A restabelecer ligação ao seu bloco…", + "pad.modals.forcereconnect": "Forçar restabelecimento de ligação", + "pad.modals.reconnecttimer": "A tentar restabelecer ligação", "pad.modals.cancel": "Cancelar", "pad.modals.userdup": "Aberto noutra janela", - "pad.modals.userdup.explanation": "Este pad parece estar aberto em mais do que uma janela do navegador neste computador.", + "pad.modals.userdup.explanation": "Esta nota parece estar aberta em mais do que uma janela do navegador neste computador.", "pad.modals.userdup.advice": "Religar para utilizar esta janela.", "pad.modals.unauth": "Não autorizado", "pad.modals.unauth.explanation": "As suas permissões foram alteradas enquanto revia esta página. Tente religar.", "pad.modals.looping.explanation": "Existem problemas de comunicação com o servidor de sincronização.", "pad.modals.looping.cause": "Talvez tenha ligado por um firewall ou proxy incompatível.", "pad.modals.initsocketfail": "O servidor está inacessível.", - "pad.modals.initsocketfail.explanation": "Não foi possível a conexão ao servidor de sincronização.", + "pad.modals.initsocketfail.explanation": "Não foi possível ligar ao servidor de sincronização.", "pad.modals.initsocketfail.cause": "Isto provavelmente ocorreu por um problema no seu navegador ou na sua ligação de Internet.", "pad.modals.slowcommit.explanation": "O servidor não está a responder.", "pad.modals.slowcommit.cause": "Isto pode ser por problemas com a ligação de rede.", @@ -83,28 +84,30 @@ "pad.modals.corruptPad.explanation": "A nota que está a tentar aceder está corrompida.", "pad.modals.corruptPad.cause": "Isto pode ocorrer devido a uma configuração errada do servidor ou algum outro comportamento inesperado. Por favor contacte o administrador.", "pad.modals.deleted": "Eliminado.", - "pad.modals.deleted.explanation": "Este pad foi removido.", - "pad.modals.disconnected": "Você foi desconectado.", - "pad.modals.disconnected.explanation": "A conexão com o servidor foi perdida", + "pad.modals.deleted.explanation": "Esta nota foi removida.", + "pad.modals.disconnected": "Você foi desligado.", + "pad.modals.disconnected.explanation": "A ligação ao servidor foi perdida", "pad.modals.disconnected.cause": "O servidor pode estar indisponível. Por favor, notifique o administrador de serviço se isto continuar a acontecer.", - "pad.share": "Compartilhar este pad", + "pad.share": "Partilhar esta nota", "pad.share.readonly": "Somente para leitura", - "pad.share.link": "Ligação", + "pad.share.link": "Hiperligação", "pad.share.emebdcode": "Incorporar o URL", - "pad.chat": "Bate-papo", - "pad.chat.title": "Abrir o bate-papo para este pad.", + "pad.chat": "Conversação", + "pad.chat.title": "Abrir a conversação para esta nota.", "pad.chat.loadmessages": "Carregar mais mensagens", + "pad.chat.stick.title": "Colar conversação no ecrã", + "pad.chat.writeMessage.placeholder": "Escreva a sua mensagem aqui", "timeslider.pageTitle": "Linha do tempo de {{appTitle}}", - "timeslider.toolbar.returnbutton": "Voltar ao pad", + "timeslider.toolbar.returnbutton": "Voltar à nota", "timeslider.toolbar.authors": "Autores:", "timeslider.toolbar.authorsList": "Sem Autores", "timeslider.toolbar.exportlink.title": "Exportar", "timeslider.exportCurrent": "Exportar versão atual como:", "timeslider.version": "Versão {{version}}", "timeslider.saved": "Gravado a {{day}} de {{month}} de {{ano}}", - "timeslider.playPause": "Reproduzir / Pausar conteúdo do Pad", - "timeslider.backRevision": "Voltar a uma revisão anterior neste Pad", - "timeslider.forwardRevision": "Ir a uma revisão posterior neste Pad", + "timeslider.playPause": "Reproduzir / pausar conteúdo da nota", + "timeslider.backRevision": "Voltar a uma revisão anterior desta nota", + "timeslider.forwardRevision": "Avançar para uma revisão posterior desta nota", "timeslider.dateformat": "{{day}}/{{month}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}", "timeslider.month.january": "Janeiro", "timeslider.month.february": "Fevereiro", @@ -129,11 +132,11 @@ "pad.editbar.clearcolors": "Deseja limpar as cores de autoria em todo o documento?", "pad.impexp.importbutton": "Importar agora", "pad.impexp.importing": "Importando...", - "pad.impexp.confirmimport": "A importação de um ficheiro irá substituir o texto atual do pad. Tem certeza que deseja continuar?", + "pad.impexp.confirmimport": "A importação de um ficheiro irá substituir o texto atual da nota. Tem certeza que deseja continuar?", "pad.impexp.convertFailed": "Não foi possível importar este ficheiro. Utilize outro formato ou copie e insira manualmente", - "pad.impexp.padHasData": "Não fomos capazes de importar este ficheiro porque este Pad já tinha alterações, por favor importe para um novo pad", - "pad.impexp.uploadFailed": "O upload falhou. Por favor, tente novamente", + "pad.impexp.padHasData": "Não fomos capazes de importar este ficheiro porque esta nota já tinha alterações; importe para uma nota nova, por favor", + "pad.impexp.uploadFailed": "O carregamento falhou; tente novamente, por favor", "pad.impexp.importfailed": "A importação falhou", - "pad.impexp.copypaste": "Por favor, copie e cole", + "pad.impexp.copypaste": "Copie e insira, por favor", "pad.impexp.exportdisabled": "A exportação no formato {{type}} está desativada. Por favor, contacte o administrador do sistema para mais informações." } diff --git a/src/locales/qqq.json b/src/locales/qqq.json index 60d62b196..512ef6a31 100644 --- a/src/locales/qqq.json +++ b/src/locales/qqq.json @@ -83,6 +83,8 @@ "pad.chat": "Used as button text and as title of Chat window.\n{{Identical|Chat}}", "pad.chat.title": "Used as tooltip for the Chat button", "pad.chat.loadmessages": "chat messages", + "pad.chat.stick.title": "Tooltip for the stick chat button", + "pad.chat.writeMessage.placeholder": "Placeholder for the chat input", "timeslider.pageTitle": "{{doc-important|Please leave {{appTitle}} parameter untouched. It will be replaced by app title.}}\nInserted into HTML title tag.", "timeslider.toolbar.returnbutton": "Used as link title", "timeslider.toolbar.authors": "A list of Authors follows after the colon.\n{{Identical|Author}}", diff --git a/src/locales/ru.json b/src/locales/ru.json index 5d9911466..c7b425cbb 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -9,7 +9,8 @@ "Nzeemin", "Facenapalm", "Patrick Star", - "Movses" + "Movses", + "Diralik" ] }, "index.newPad": "Создать", @@ -82,8 +83,8 @@ "pad.modals.badChangeset.cause": "Это может быть из-за неправильной конфигурации сервера или некоторых других неожиданных действий. Пожалуйста, свяжитесь с администратором службы, если вы считаете, что это ошибка. Попробуйте переподключиться для того, чтобы продолжить редактирование.", "pad.modals.corruptPad.explanation": "Документ, к которому вы пытаетесь получить доступ, повреждён.", "pad.modals.corruptPad.cause": "Это может быть из-за неправильной конфигурации сервера или некоторых других неожиданных действий. Пожалуйста, свяжитесь с администратором службы.", - "pad.modals.deleted": "Удален.", - "pad.modals.deleted.explanation": "Этот документ был удален.", + "pad.modals.deleted": "Удалён.", + "pad.modals.deleted.explanation": "Этот документ был удалён.", "pad.modals.disconnected": "Соединение разорвано.", "pad.modals.disconnected.explanation": "Подключение к серверу потеряно", "pad.modals.disconnected.cause": "Сервер, возможно, недоступен. Пожалуйста, сообщите администратору службы, если проблема будет повторятся.", @@ -93,7 +94,9 @@ "pad.share.emebdcode": "Вставить URL", "pad.chat": "Чат", "pad.chat.title": "Открыть чат для этого документа.", - "pad.chat.loadmessages": "Еще сообщения", + "pad.chat.loadmessages": "Ещё сообщения", + "pad.chat.stick.title": "Закрепить чат на экране", + "pad.chat.writeMessage.placeholder": "Напишите своё сообщение сюда", "timeslider.pageTitle": "Временная шкала {{appTitle}}", "timeslider.toolbar.returnbutton": "К документу", "timeslider.toolbar.authors": "Авторы:", @@ -135,5 +138,5 @@ "pad.impexp.uploadFailed": "Загрузка не удалась, пожалуйста, попробуйте ещё раз", "pad.impexp.importfailed": "Ошибка при импорте", "pad.impexp.copypaste": "Пожалуйста, скопируйте", - "pad.impexp.exportdisabled": "Экспорт в формате {{type}} отключен. Для подробной информации обратитесь к системному администратору." + "pad.impexp.exportdisabled": "Экспорт в формате {{type}} отключён. Для подробной информации обратитесь к системному администратору." } diff --git a/src/locales/sd.json b/src/locales/sd.json index 533c6c50c..87ca2cb18 100644 --- a/src/locales/sd.json +++ b/src/locales/sd.json @@ -66,7 +66,7 @@ "timeslider.toolbar.authorsList": "ڪوبه ليکڪ ناهي", "timeslider.toolbar.exportlink.title": "برآمد ڪريو", "timeslider.version": "ورزن {{version}}", - "timeslider.saved": "شانڍيل {{مهينو}} {{ڏينهن}}, {{سال}}", + "timeslider.saved": "سانڍيل {{month}} {{day}}، {{year}}", "timeslider.dateformat": "{{مهينو}}/{{ڏينهن}}/{{سال}} {{ڪلاڪ}}:{{منٽ}}:{{سيڪنڊ}}", "timeslider.month.january": "جنوري", "timeslider.month.february": "فيبروري", diff --git a/src/locales/sh.json b/src/locales/sh.json new file mode 100644 index 000000000..f3dd2bbac --- /dev/null +++ b/src/locales/sh.json @@ -0,0 +1,132 @@ +{ + "@metadata": { + "authors": [ + "Conquistador", + "Vlad5250" + ] + }, + "index.newPad": "Novi blokić", + "index.createOpenPad": "ili napravite/otvorite blokić s imenom:", + "pad.toolbar.bold.title": "Podebljano (Ctrl+B)", + "pad.toolbar.italic.title": "Ukošeno (Ctrl+I)", + "pad.toolbar.underline.title": "Podcrtano (Ctrl+U)", + "pad.toolbar.strikethrough.title": "Precrtano (Ctrl+5)", + "pad.toolbar.ol.title": "Poredani spisak (Ctrl+Shift+N)", + "pad.toolbar.ul.title": "Neporedani spisak (Ctrl+Shift+L)", + "pad.toolbar.indent.title": "Uvlaka (TAB)", + "pad.toolbar.unindent.title": "Izvlaka (Shift+TAB)", + "pad.toolbar.undo.title": "Vrati (Ctrl+Z)", + "pad.toolbar.redo.title": "Ponovi (Ctrl+Y)", + "pad.toolbar.clearAuthorship.title": "Ukloni boje autorstva (Ctrl+Shift+C)", + "pad.toolbar.import_export.title": "Uvoz/Izvoz iz/na različite datotečne formate", + "pad.toolbar.timeslider.title": "Historijski pregled", + "pad.toolbar.savedRevision.title": "Snimi inačicu", + "pad.toolbar.settings.title": "Postavke", + "pad.toolbar.embed.title": "Dijelite i umetnite ovaj blokić", + "pad.toolbar.showusers.title": "Pokaži korisnike ovoga blokića", + "pad.colorpicker.save": "Snimi", + "pad.colorpicker.cancel": "Otkaži", + "pad.loading": "Učitavam...", + "pad.noCookie": "Nisam mogao pronaći kolačić. Omogućite kolačiće u vašem pregledniku!", + "pad.passwordRequired": "Potrebna je lozinka za pristup", + "pad.permissionDenied": "Za ovdje nije potrebna dozvola za pristup", + "pad.wrongPassword": "Pogrešna lozinka", + "pad.settings.padSettings": "Postavke blokića", + "pad.settings.myView": "Moj prikaz", + "pad.settings.stickychat": "Ćaskanje uvijek na ekranu", + "pad.settings.chatandusers": "Prikaži ćaskanje i korisnike", + "pad.settings.colorcheck": "Boje autorstva", + "pad.settings.linenocheck": "Brojevi redova", + "pad.settings.rtlcheck": "Da prikažem sadržaj zdesna ulijevo?", + "pad.settings.fontType": "Tip fonta:", + "pad.settings.globalView": "Globalni prikaz", + "pad.settings.language": "Jezik:", + "pad.importExport.import_export": "Uvoz/Izvoz", + "pad.importExport.import": "Otpremanje bilo koje tekstualne datoteke ili dokumenta", + "pad.importExport.importSuccessful": "Uspješno!", + "pad.importExport.export": "Izvezi trenutni blokić kao:", + "pad.importExport.exportetherpad": "Etherpad", + "pad.importExport.exporthtml": "HTML", + "pad.importExport.exportplain": "Obični tekst", + "pad.importExport.exportword": "Microsoft Word", + "pad.importExport.exportpdf": "PDF", + "pad.importExport.exportopen": "ODF (Open Document Format)", + "pad.importExport.abiword.innerHTML": "Možete uvoziti samo iz običnog teksta te HTML-formata. Naprednije mogućnosti uvoza dobit ćete ako uspostavite AbiWord.", + "pad.modals.connected": "Povezano.", + "pad.modals.reconnecting": "Prepovezujemo Vas s blokićem...", + "pad.modals.forcereconnect": "Nametni prepovezivanje", + "pad.modals.reconnecttimer": "Se prepovezivam za", + "pad.modals.cancel": "Otkaži", + "pad.modals.userdup": "Otvoreno u drugom prozoru", + "pad.modals.userdup.explanation": "Ovaj je blokić otvoren u više od jednoga prozora (u pregledniku) na računalu.", + "pad.modals.userdup.advice": "Prepovežite se da biste koristili ovaj prozor.", + "pad.modals.unauth": "Neovlašteno", + "pad.modals.unauth.explanation": "Vaše su dozvole izmijenjene za vrijeme dok ste pregledavali ovu stranicu. Pokušajte se prepovezati.", + "pad.modals.looping.explanation": "Postoje problemi s vezom sa usklađivnim poslužiteljem.", + "pad.modals.looping.cause": "Možda ste se spojili preko neskladne sigurnosne stijene ili proxyja.", + "pad.modals.initsocketfail": "Server je nedostupan.", + "pad.modals.initsocketfail.explanation": "Nisam mogao se povezati sa usklađivnim serverom.", + "pad.modals.initsocketfail.cause": "Ovo je vjerojatno zbog problema s vašim preglednikom ili svemrežnom vezom.", + "pad.modals.slowcommit.explanation": "Server se ne odaziva.", + "pad.modals.slowcommit.cause": "Ovo je vjerojatno zbog problema s mrežnim povezivanjem.", + "pad.modals.badChangeset.explanation": "Poslužitelj za usklađivanje smatra da je izmjena koju ste napravili nedopuštena.", + "pad.modals.badChangeset.cause": "Ovo može biti zbog pogrešne postavljenosti poslužitelja ili nekog drugog neočekivanog ponašanja. Obratite se administratoru ukoliko držite da je to greška. Pokušajte se preuključiti kako biste nastavili s uređivanjem.", + "pad.modals.corruptPad.explanation": "Blokić što pokušavate otvoriti je oštećen.", + "pad.modals.corruptPad.cause": "Ovo može biti zbog pogrešne postavljenosti poslužitelja ili nekog drugog neočekivanog ponašanja. Obratite se administratoru.", + "pad.modals.deleted": "Obrisano.", + "pad.modals.deleted.explanation": "Ovaj blokić je uklonjen.", + "pad.modals.disconnected": "Veza je prekinuta.", + "pad.modals.disconnected.explanation": "Veza s poslužiteljem je prekinuta", + "pad.modals.disconnected.cause": "Moguće je da server nije dostupan. Obavijestite administratora ako se ovo nastavi događati.", + "pad.share": "Dijeli ovaj blokić", + "pad.share.readonly": "Samo čitanje", + "pad.share.link": "Link", + "pad.share.emebdcode": "Umetni URL", + "pad.chat": "Ćaskanje", + "pad.chat.title": "Otvori ćaskanje uz ovaj blokić.", + "pad.chat.loadmessages": "Učitaj više poruka", + "pad.chat.stick.title": "Zalijepi ćaskanje na ekranu", + "pad.chat.writeMessage.placeholder": "Ovdje napišite poruku", + "timeslider.pageTitle": "{{appTitle}} Historijski pregled", + "timeslider.toolbar.returnbutton": "Natrag na blokić", + "timeslider.toolbar.authors": "Autori:", + "timeslider.toolbar.authorsList": "Nema autora", + "timeslider.toolbar.exportlink.title": "Izvoz", + "timeslider.exportCurrent": "Izvezi trenutnu verziju kao:", + "timeslider.version": "Verzija {{version}}", + "timeslider.saved": "Spremljeno {{day}}. {{month}} {{year}}.", + "timeslider.playPause": "Izvrti/pauziraj sadržaj blokića", + "timeslider.backRevision": "Nazad na jednu inačicu ovog blokića", + "timeslider.forwardRevision": "Naprijed na jednu inačicu ovog blokića", + "timeslider.dateformat": "{{day}}. {{month}}. {{year}}. {{hours}}:{{minutes}}:{{seconds}}", + "timeslider.month.january": "januara", + "timeslider.month.february": "februara", + "timeslider.month.march": "marta", + "timeslider.month.april": "aprila", + "timeslider.month.may": "maja", + "timeslider.month.june": "juna", + "timeslider.month.july": "jula", + "timeslider.month.august": "augusta", + "timeslider.month.september": "septembra", + "timeslider.month.october": "oktobra", + "timeslider.month.november": "novembra", + "timeslider.month.december": "decembra", + "timeslider.unnamedauthors": "{{num}} {[plural(num) one: neimenovani autor, plural(num) two: neimenovana autora, plural(num) other: neimenovanih autora ]}", + "pad.savedrevs.marked": "Ova inačica označena je sada kao spremljena", + "pad.savedrevs.timeslider": "Možete pogledati spremljene inačice rabeći vremesledni klizač", + "pad.userlist.entername": "Upišite svoje ime", + "pad.userlist.unnamed": "bez imena", + "pad.userlist.guest": "Gost", + "pad.userlist.deny": "Odbij", + "pad.userlist.approve": "Odobri", + "pad.editbar.clearcolors": "Ukloniti boje autorstva sa cijelog dokumenta?", + "pad.impexp.importbutton": "Uvezi odmah", + "pad.impexp.importing": "Uvozim...", + "pad.impexp.confirmimport": "Uvoženje datoteke presnimit će trenutni sadržaj blokića.\nJeste li sigurni da želite nastaviti?", + "pad.impexp.convertFailed": "Nisam mogao uvesti datoteku. Poslužite se uz neki drugi format ili prekopirajte tekst ručno.", + "pad.impexp.padHasData": "Nismo mogli uvesti ovu datoteku jer je ovaj blokić već ima promjene. Uvezite je u novi blokić.", + "pad.impexp.uploadFailed": "Postavljanje nije uspjelo. Pokušajte ponovo.", + "pad.impexp.importfailed": "Uvoz nije uspio", + "pad.impexp.copypaste": "Prekopirajte", + "pad.impexp.exportdisabled": "Izvoz u formatu {{type}} je onemogućen. Ako želite saznati više o ovome, obratite se administratoru sustava." +} diff --git a/src/locales/skr-arab.json b/src/locales/skr-arab.json index 833d341ec..2130b5431 100644 --- a/src/locales/skr-arab.json +++ b/src/locales/skr-arab.json @@ -38,6 +38,7 @@ "pad.share.emebdcode": "امنیڈ یو آر ایل", "pad.chat": "چیٹ", "pad.chat.loadmessages": "ٻئے سنیہے لوڈ کرو", + "pad.chat.writeMessage.placeholder": "آپݨاں سنیہا اتھ لکھو", "timeslider.toolbar.returnbutton": "واپس پیڈ تے ونڄو", "timeslider.toolbar.authors": "مصنف:", "timeslider.toolbar.authorsList": "کوئی مصنف کائنی", diff --git a/src/locales/sl.json b/src/locales/sl.json index f4168f2c1..37f716cd2 100644 --- a/src/locales/sl.json +++ b/src/locales/sl.json @@ -90,6 +90,8 @@ "pad.chat": "Klepet", "pad.chat.title": "Odpri klepetalno okno dokumenta.", "pad.chat.loadmessages": "Naloži več sporočil", + "pad.chat.stick.title": "Prilepi klepet na zaslon", + "pad.chat.writeMessage.placeholder": "Napišite sporočilo", "timeslider.pageTitle": "Časovni trak {{appTitle}}", "timeslider.toolbar.returnbutton": "Vrni se na dokument", "timeslider.toolbar.authors": "Avtorji:", diff --git a/src/locales/sq.json b/src/locales/sq.json index e498c202b..ad2dde8d8 100644 --- a/src/locales/sq.json +++ b/src/locales/sq.json @@ -2,7 +2,8 @@ "@metadata": { "authors": [ "Besnik b", - "Kosovastar" + "Kosovastar", + "Liridon" ] }, "index.newPad": "Bllok i ri", @@ -87,6 +88,8 @@ "pad.chat": "Fjalosje", "pad.chat.title": "Hapni fjalosjen për këtë bllok.", "pad.chat.loadmessages": "Ngarko më tepër mesazhe", + "pad.chat.stick.title": "Ngjit bisedën në ekran", + "pad.chat.writeMessage.placeholder": "Shkruajeni mesazhin tuaj këtu", "timeslider.pageTitle": "Rrjedhë kohore e {{appTitle}}", "timeslider.toolbar.returnbutton": "Rikthehuni te blloku", "timeslider.toolbar.authors": "Autorë:", diff --git a/src/locales/sr-ec.json b/src/locales/sr-ec.json index 59f77ef2a..15c553f55 100644 --- a/src/locales/sr-ec.json +++ b/src/locales/sr-ec.json @@ -6,7 +6,8 @@ "Милан Јелисавчић", "Srdjan m", "Obsuser", - "Acamicamacaraca" + "Acamicamacaraca", + "BadDog" ] }, "index.newPad": "Нови Пад", @@ -91,6 +92,8 @@ "pad.chat": "Ћаскање", "pad.chat.title": "Отворите ћаскање за овај пад.", "pad.chat.loadmessages": "Учитај више порука", + "pad.chat.stick.title": "Залепите ћаскање на екран", + "pad.chat.writeMessage.placeholder": "Напишите поруку овде", "timeslider.pageTitle": "{{appTitle}} временска линија", "timeslider.toolbar.returnbutton": "Врати се на пад", "timeslider.toolbar.authors": "Аутори:", diff --git a/src/locales/sr-el.json b/src/locales/sr-el.json new file mode 100644 index 000000000..78771f07f --- /dev/null +++ b/src/locales/sr-el.json @@ -0,0 +1,129 @@ +{ + "@metadata": [], + "index.newPad": "Novi Pad", + "index.createOpenPad": "ili napravite/otvorite pad sledećeg naziva:", + "pad.toolbar.bold.title": "Podebljano (Ctrl+B)", + "pad.toolbar.italic.title": "Iskošeno (Ctrl+I)", + "pad.toolbar.underline.title": "Podvučeno (Ctrl+U)", + "pad.toolbar.strikethrough.title": "Precrtano (Ctrl+5)", + "pad.toolbar.ol.title": "Uređen spisak (Ctrl+Shift+N)", + "pad.toolbar.ul.title": "Neuređen spisak (Ctrl+Shift+L)", + "pad.toolbar.indent.title": "Uvlačenje (TAB)", + "pad.toolbar.unindent.title": "Izvlačenje (Shift+TAB)", + "pad.toolbar.undo.title": "Opozovi (Ctrl+Z)", + "pad.toolbar.redo.title": "Ponovi (Ctrl+Z)", + "pad.toolbar.clearAuthorship.title": "Očisti autorske boje (Ctrl+Shift+C)", + "pad.toolbar.import_export.title": "Uvezi/izvezi iz/na druge datotečne formate", + "pad.toolbar.timeslider.title": "Vremenska linija", + "pad.toolbar.savedRevision.title": "Sačuvaj verziju", + "pad.toolbar.settings.title": "Podešavanja", + "pad.toolbar.embed.title": "Sačuvaj i ugradi ovaj pad", + "pad.toolbar.showusers.title": "Prikaži korisnike na ovom padu", + "pad.colorpicker.save": "Sačuvaj", + "pad.colorpicker.cancel": "Otkaži", + "pad.loading": "Učitavam…", + "pad.noCookie": "Kolačić nije pronađen. Molimo da uključite kolačiće u vašem pregledavaču!", + "pad.passwordRequired": "Trebate imati lozinku kako biste pristupili ovom padu", + "pad.permissionDenied": "Nemate dozvolu da pristupite ovom padu", + "pad.wrongPassword": "Vaša lozinka nije ispravna", + "pad.settings.padSettings": "Podešavanja pada", + "pad.settings.myView": "Moj prikaz", + "pad.settings.stickychat": "Ćaskanje uvek na ekranu", + "pad.settings.chatandusers": "Prikaži ćaskanje i korisnike", + "pad.settings.colorcheck": "Autorske boje", + "pad.settings.linenocheck": "Brojevi redova", + "pad.settings.rtlcheck": "Čitaj sadržaj s desna na levo?", + "pad.settings.fontType": "Vrsta fonta:", + "pad.settings.fontType.normal": "Normalno", + "pad.settings.fontType.monospaced": "Monospace", + "pad.settings.globalView": "Globalni prikaz", + "pad.settings.language": "Jezik:", + "pad.importExport.import_export": "Uvoz/izvoz", + "pad.importExport.import": "Otpremite bilo koju tekstualnu datoteku ili dokument", + "pad.importExport.importSuccessful": "Uspešno!", + "pad.importExport.export": "Izvezi trenutni pad kao:", + "pad.importExport.exportetherpad": "Etherpad", + "pad.importExport.exporthtml": "HTML", + "pad.importExport.exportplain": "Čist tekst", + "pad.importExport.exportword": "Microsoft Word", + "pad.importExport.exportpdf": "PDF", + "pad.importExport.exportopen": "ODF (Open Document Format)", + "pad.importExport.abiword.innerHTML": "Jedino možete uvesti sa jednostavnog tekstualnog formata ili HTML formata. Za komplikovanije funkcije o uvozu, molimo da instalirate AbiWord.", + "pad.modals.connected": "Povezano.", + "pad.modals.reconnecting": "Ponovo se povezujem na vaš pad..", + "pad.modals.forcereconnect": "Prisilno se ponovo poveži", + "pad.modals.reconnecttimer": "Pokušavam se ponovo povezati", + "pad.modals.cancel": "Otkaži", + "pad.modals.userdup": "Otvoreno u drugom prozoru", + "pad.modals.userdup.explanation": "Izgleda da je ovaj pad otvoren u dva ili više prozora na ovom računaru.", + "pad.modals.userdup.advice": "Ponovo se povežite na ovoj prozor.", + "pad.modals.unauth": "Niste ovlašćeni", + "pad.modals.unauth.explanation": "Vaša dopuštenja se se promenila dok ste pregledavali stranicu. Pokušajte se ponovo povezati.", + "pad.modals.looping.explanation": "Postoje komunikacijski problemi sa sinhronizacionim serverom.", + "pad.modals.looping.cause": "Možda ste se povezali preko nepodržanog zaštitnog zida ili proksija.", + "pad.modals.initsocketfail": "Server je nedostupan.", + "pad.modals.initsocketfail.explanation": "Ne mogu se povezati na sinhronizacioni server.", + "pad.modals.initsocketfail.cause": "Najverovatnije je došlo do problem sa vašim pregledačem ili vašom internetskom vezom.", + "pad.modals.slowcommit.explanation": "Server ne odgovara.", + "pad.modals.slowcommit.cause": "Najverovatnije je došlo do problema sa mrežnom povezanošću.", + "pad.modals.badChangeset.explanation": "Sinhronizacioni server je uređivanje koje ste načili označio kao neispravno.", + "pad.modals.badChangeset.cause": "Moguće da je došlo do pogrešne konfiguracije servera ili nekog drugog neočekivanog događaja. Molimo vas da kontaktirate servisnog administratora ako mislite da je ovo greška. Pokušajte se ponovo povezati kako biste nastavili s uređivanjem.", + "pad.modals.corruptPad.explanation": "Pad kojem pokušavate pristupiti je oštećen.", + "pad.modals.corruptPad.cause": "Moguće da je došlo do pogrešne konfiguracije servera ili nekog drugog neočekivanog događaja. Molimo vas da kontaktirate servisnog administratora.", + "pad.modals.deleted": "Obrisano.", + "pad.modals.deleted.explanation": "Ovaj pad je uklonjen.", + "pad.modals.disconnected": "Veza je prekinuta.", + "pad.modals.disconnected.explanation": "Izgubljena je veza sa serverom", + "pad.modals.disconnected.cause": "Server nije dostupan. Obavestite servisnog administratora ako se ovo nastavi dešavati.", + "pad.share": "Pofeli ovaj pad", + "pad.share.readonly": "Samo za čitanje", + "pad.share.link": "Veza", + "pad.share.emebdcode": "Ugradi vezu", + "pad.chat": "Ćaskanje", + "pad.chat.title": "Otvorite ćaskanje za ovaj pad.", + "pad.chat.loadmessages": "Učitaj više poruka", + "pad.chat.stick.title": "Postavi čet na ekran", + "pad.chat.writeMessage.placeholder": "Napiši poruku ovde", + "timeslider.pageTitle": "{{appTitle}} vremenska linija", + "timeslider.toolbar.returnbutton": "Vrati se na pad", + "timeslider.toolbar.authors": "Autori:", + "timeslider.toolbar.authorsList": "Nema autora", + "timeslider.toolbar.exportlink.title": "Izvezi", + "timeslider.exportCurrent": "Izvezi trenutnu verziju kao:", + "timeslider.version": "Izdanje {{version}}", + "timeslider.saved": "Sačuvano na {{day}}. {{month}}. {{year}}", + "timeslider.playPause": "Pusti/pauziraj sadržaj pada", + "timeslider.backRevision": "Idi na prethodnu verziju ovog pada", + "timeslider.forwardRevision": "Idi na sledeće izdanje pada", + "timeslider.dateformat": "{{month}}/{{day}}/{{year}} {{hours}}:{{minutes}}:{{seconds}}", + "timeslider.month.january": "januar", + "timeslider.month.february": "februar", + "timeslider.month.march": "mart", + "timeslider.month.april": "april", + "timeslider.month.may": "maj", + "timeslider.month.june": "jun", + "timeslider.month.july": "jul", + "timeslider.month.august": "avgust", + "timeslider.month.september": "septembar", + "timeslider.month.october": "oktobar", + "timeslider.month.november": "novembar", + "timeslider.month.december": "decembar", + "timeslider.unnamedauthors": "{{num}} neimenovan(i) {[plural(num) one: autor, other: autori ]}", + "pad.savedrevs.marked": "Ova izmena je sada označena kao sačuvana", + "pad.savedrevs.timeslider": "Možete videti sačuvane izmene koristeći se vremenskom linijom", + "pad.userlist.entername": "Upišite svoje ime", + "pad.userlist.unnamed": "neimenovan", + "pad.userlist.guest": "Gost", + "pad.userlist.deny": "Odbij", + "pad.userlist.approve": "Odobri", + "pad.editbar.clearcolors": "Očisti autorske boje za celi dokument?", + "pad.impexp.importbutton": "Uvezi odmah", + "pad.impexp.importing": "Uvozim...", + "pad.impexp.confirmimport": "Uvoz datoteke će prepisati trenutni tekst pada. Da li ste sigurni da želite nastaviti?", + "pad.impexp.convertFailed": "Ne mogu da uvezem ovu datoteku. Molimo da koristite drugi format dokumenta ili da dokument kopirate ručno", + "pad.impexp.padHasData": "Ne mogu da uvezem ovu datoteku zato što je već bilo promena na ovom padu, molimo da uvezete novi pad", + "pad.impexp.uploadFailed": "Nisam uspeo da otpremim, molimo pokušate ponovo", + "pad.impexp.importfailed": "Nisam uspeo da uvezem", + "pad.impexp.copypaste": "Kopirajte i zalepite", + "pad.impexp.exportdisabled": "Izvoz u formatu {{type}} nije dozvoljen. Kontaktirajte sistemskog administratora za detalje." +} diff --git a/src/locales/sv.json b/src/locales/sv.json index e73c3e7f3..7b03c12b2 100644 --- a/src/locales/sv.json +++ b/src/locales/sv.json @@ -88,6 +88,8 @@ "pad.chat": "Chatt", "pad.chat.title": "Öppna chatten för detta block.", "pad.chat.loadmessages": "Läs in fler meddelanden", + "pad.chat.stick.title": "Fäst chatten på skärmen", + "pad.chat.writeMessage.placeholder": "Skriv ditt meddelande här", "timeslider.pageTitle": "{{appTitle}} tidsreglage", "timeslider.toolbar.returnbutton": "Återvänd till blocket", "timeslider.toolbar.authors": "Författare:", diff --git a/src/locales/tr.json b/src/locales/tr.json index 0be2588f1..1358bdeda 100644 --- a/src/locales/tr.json +++ b/src/locales/tr.json @@ -7,7 +7,9 @@ "Meelo", "Trockya", "McAang", - "Vito Genovese" + "Vito Genovese", + "Hedda", + "Grkn gll" ] }, "index.newPad": "Yeni Bloknot", @@ -48,7 +50,7 @@ "pad.settings.fontType.monospaced": "Tek aralıklı", "pad.settings.globalView": "Genel Görünüm", "pad.settings.language": "Dil:", - "pad.importExport.import_export": "İçerik/Dışarı Aktar", + "pad.importExport.import_export": "İçeri aktar/Dışarı aktar", "pad.importExport.import": "Herhangi bir metin dosyası ya da belgesi yükle", "pad.importExport.importSuccessful": "Başarılı!", "pad.importExport.export": "Mevcut bloknotu şu olarak dışa aktar:", @@ -92,6 +94,8 @@ "pad.chat": "Sohbet", "pad.chat.title": "Bu bloknot için sohbeti açın.", "pad.chat.loadmessages": "Daha fazla mesaj yükle", + "pad.chat.stick.title": "Sohbeti ekrana yapıştır", + "pad.chat.writeMessage.placeholder": "Mesajını buraya yaz", "timeslider.pageTitle": "{{appTitle}} Zaman Çizelgesi", "timeslider.toolbar.returnbutton": "Bloknota geri dön", "timeslider.toolbar.authors": "Yazarlar:", diff --git a/src/locales/uk.json b/src/locales/uk.json index e6e6489bd..9a6ab8e71 100644 --- a/src/locales/uk.json +++ b/src/locales/uk.json @@ -95,6 +95,8 @@ "pad.chat": "Чат", "pad.chat.title": "Відкрити чат для цього документа.", "pad.chat.loadmessages": "Завантажити більше повідомлень", + "pad.chat.stick.title": "Закріпити чат на екрані", + "pad.chat.writeMessage.placeholder": "Напишіть своє повідомлення сюди", "timeslider.pageTitle": "Часова шкала {{appTitle}}", "timeslider.toolbar.returnbutton": "Повернутись до документа", "timeslider.toolbar.authors": "Автори:", diff --git a/src/locales/zh-hans.json b/src/locales/zh-hans.json index a598d9aad..1d456ff76 100644 --- a/src/locales/zh-hans.json +++ b/src/locales/zh-hans.json @@ -11,7 +11,9 @@ "Yfdyh000", "乌拉跨氪", "燃玉", - "JuneAugust" + "JuneAugust", + "94rain", + "VulpesVulpes825" ] }, "index.newPad": "新记事本", @@ -96,6 +98,8 @@ "pad.chat": "聊天", "pad.chat.title": "打开此记事本的聊天窗口。", "pad.chat.loadmessages": "加载更多信息", + "pad.chat.stick.title": "在屏幕上固定聊天界面", + "pad.chat.writeMessage.placeholder": "在此写下您的消息", "timeslider.pageTitle": "{{appTitle}} 时间轴", "timeslider.toolbar.returnbutton": "返回记事本", "timeslider.toolbar.authors": "作者:", diff --git a/src/locales/zh-hant.json b/src/locales/zh-hant.json index 2a7d6d1ef..6d43f67d7 100644 --- a/src/locales/zh-hant.json +++ b/src/locales/zh-hant.json @@ -93,6 +93,8 @@ "pad.chat": "聊天功能", "pad.chat.title": "打開記事本聊天功能", "pad.chat.loadmessages": "載入更多訊息", + "pad.chat.stick.title": "釘住聊天在螢幕上", + "pad.chat.writeMessage.placeholder": "在此編寫您的訊息", "timeslider.pageTitle": "{{appTitle}}時間軸", "timeslider.toolbar.returnbutton": "返回到記事本", "timeslider.toolbar.authors": "協作者:", diff --git a/src/node/README.md b/src/node/README.md index 56ff491f1..d0a61287a 100644 --- a/src/node/README.md +++ b/src/node/README.md @@ -1,7 +1,7 @@ # About the folder structure * **db** - all modules that are accessing the data structure and are communicating directly to the database -* **handler** - all modules that responds directly to requests/messages of the browser +* **handler** - all modules that respond directly to requests/messages of the browser * **utils** - helper modules # Module name conventions diff --git a/src/node/db/API.js b/src/node/db/API.js index 21c958809..998d9acd1 100644 --- a/src/node/db/API.js +++ b/src/node/db/API.js @@ -18,7 +18,7 @@ * limitations under the License. */ -var ERR = require("async-stacktrace"); +var Changeset = require("ep_etherpad-lite/static/js/Changeset"); var customError = require("../utils/customError"); var padManager = require("./PadManager"); var padMessageHandler = require("../handler/PadMessageHandler"); @@ -26,7 +26,6 @@ var readOnlyManager = require("./ReadOnlyManager"); var groupManager = require("./GroupManager"); var authorManager = require("./AuthorManager"); var sessionManager = require("./SessionManager"); -var async = require("async"); var exportHtml = require("../utils/ExportHtml"); var exportTxt = require("../utils/ExportTxt"); var importHtml = require("../utils/ImportHtml"); @@ -102,13 +101,10 @@ Example returns: } */ -exports.getAttributePool = function (padID, callback) +exports.getAttributePool = async function(padID) { - getPadSafe(padID, true, function(err, pad) - { - if (ERR(err, callback)) return; - callback(null, {pool: pad.pool}); - }); + let pad = await getPadSafe(padID, true); + return { pool: pad.pool }; } /** @@ -124,158 +120,72 @@ Example returns: } */ -exports.getRevisionChangeset = function(padID, rev, callback) +exports.getRevisionChangeset = async function(padID, rev) { - // check if rev is set - if (typeof rev === "function") - { - callback = rev; - rev = undefined; - } - - // check if rev is a number - if (rev !== undefined && typeof rev !== "number") - { - // try to parse the number - if (isNaN(parseInt(rev))) - { - callback(new customError("rev is not a number", "apierror")); - return; - } - - rev = parseInt(rev); - } - - // ensure this is not a negative number - if (rev !== undefined && rev < 0) - { - callback(new customError("rev is not a negative number", "apierror")); - return; - } - - // ensure this is not a float value - if (rev !== undefined && !is_int(rev)) - { - callback(new customError("rev is a float value", "apierror")); - return; + // try to parse the revision number + if (rev !== undefined) { + rev = checkValidRev(rev); } // get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - //the client asked for a special revision - if(rev !== undefined) - { - //check if this is a valid revision - if(rev > pad.getHeadRevisionNumber()) - { - callback(new customError("rev is higher than the head revision of the pad","apierror")); - return; - } - - //get the changeset for this revision - pad.getRevisionChangeset(rev, function(err, changeset) - { - if(ERR(err, callback)) return; - - callback(null, changeset); - }) + let pad = await getPadSafe(padID, true); + let head = pad.getHeadRevisionNumber(); - return; + // the client asked for a special revision + if (rev !== undefined) { + + // check if this is a valid revision + if (rev > head) { + throw new customError("rev is higher than the head revision of the pad", "apierror"); } - //the client wants the latest changeset, lets return it to him - pad.getRevisionChangeset(pad.getHeadRevisionNumber(), function(err, changeset) - { - if(ERR(err, callback)) return; + // get the changeset for this revision + return pad.getRevisionChangeset(rev); + } - callback(null, changeset); - }) - }); + // the client wants the latest changeset, lets return it to him + return pad.getRevisionChangeset(head); } /** -getText(padID, [rev]) returns the text of a pad +getText(padID, [rev]) returns the text of a pad Example returns: {code: 0, message:"ok", data: {text:"Welcome Text"}} {code: 1, message:"padID does not exist", data: null} */ -exports.getText = function(padID, rev, callback) +exports.getText = async function(padID, rev) { - //check if rev is set - if(typeof rev == "function") - { - callback = rev; - rev = undefined; + // try to parse the revision number + if (rev !== undefined) { + rev = checkValidRev(rev); } - - //check if rev is a number - if(rev !== undefined && typeof rev != "number") - { - //try to parse the number - if(isNaN(parseInt(rev))) - { - callback(new customError("rev is not a number", "apierror")); - return; + + // get the pad + let pad = await getPadSafe(padID, true); + let head = pad.getHeadRevisionNumber(); + + // the client asked for a special revision + if (rev !== undefined) { + + // check if this is a valid revision + if (rev > head) { + throw new customError("rev is higher than the head revision of the pad", "apierror"); } - rev = parseInt(rev); + // get the text of this revision + let text = await pad.getInternalRevisionAText(rev); + return { text }; } - - //ensure this is not a negativ number - if(rev !== undefined && rev < 0) - { - callback(new customError("rev is a negativ number","apierror")); - return; - } - - //ensure this is not a float value - if(rev !== undefined && !is_int(rev)) - { - callback(new customError("rev is a float value","apierror")); - return; - } - - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - //the client asked for a special revision - if(rev !== undefined) - { - //check if this is a valid revision - if(rev > pad.getHeadRevisionNumber()) - { - callback(new customError("rev is higher than the head revision of the pad","apierror")); - return; - } - - //get the text of this revision - pad.getInternalRevisionAText(rev, function(err, atext) - { - if(ERR(err, callback)) return; - - var data = {text: atext.text}; - - callback(null, data); - }) - return; - } - - //the client wants the latest text, lets return it to him - var padText = exportTxt.getTXTFromAtext(pad, pad.atext); - callback(null, {"text": padText}); - }); + // the client wants the latest text, lets return it to him + let text = exportTxt.getTXTFromAtext(pad, pad.atext); + return { text }; } /** -setText(padID, text) sets the text of a pad +setText(padID, text) sets the text of a pad Example returns: @@ -283,26 +193,21 @@ Example returns: {code: 1, message:"padID does not exist", data: null} {code: 1, message:"text too long", data: null} */ -exports.setText = function(padID, text, callback) -{ - //text is required - if(typeof text != "string") - { - callback(new customError("text is no string","apierror")); - return; +exports.setText = async function(padID, text) +{ + // text is required + if (typeof text !== "string") { + throw new customError("text is not a string", "apierror"); } - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - //set the text - pad.setText(text); - - //update the clients on the pad - padMessageHandler.updatePadClients(pad, callback); - }); + // get the pad + let pad = await getPadSafe(padID, true); + + // set the text + pad.setText(text); + + // update the clients on the pad + padMessageHandler.updatePadClients(pad); } /** @@ -314,105 +219,52 @@ Example returns: {code: 1, message:"padID does not exist", data: null} {code: 1, message:"text too long", data: null} */ -exports.appendText = function(padID, text, callback) +exports.appendText = async function(padID, text) { - //text is required - if(typeof text != "string") - { - callback(new customError("text is no string","apierror")); - return; + // text is required + if (typeof text !== "string") { + throw new customError("text is not a string", "apierror"); } - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - pad.appendText(text); - - //update the clients on the pad - padMessageHandler.updatePadClients(pad, callback); - }); -}; - + // get and update the pad + let pad = await getPadSafe(padID, true); + pad.appendText(text); + // update the clients on the pad + padMessageHandler.updatePadClients(pad); +} /** -getHTML(padID, [rev]) returns the html of a pad +getHTML(padID, [rev]) returns the html of a pad Example returns: {code: 0, message:"ok", data: {text:"Welcome Text"}} {code: 1, message:"padID does not exist", data: null} */ -exports.getHTML = function(padID, rev, callback) +exports.getHTML = async function(padID, rev) { - if(typeof rev == "function") - { - callback = rev; - rev = undefined; + if (rev !== undefined) { + rev = checkValidRev(rev); } - if (rev !== undefined && typeof rev != "number") - { - if (isNaN(parseInt(rev))) - { - callback(new customError("rev is not a number","apierror")); - return; + let pad = await getPadSafe(padID, true); + + // the client asked for a special revision + if (rev !== undefined) { + // check if this is a valid revision + let head = pad.getHeadRevisionNumber(); + if (rev > head) { + throw new customError("rev is higher than the head revision of the pad", "apierror"); } - - rev = parseInt(rev); } - if(rev !== undefined && rev < 0) - { - callback(new customError("rev is a negative number","apierror")); - return; - } + // get the html of this revision + let html = await exportHtml.getPadHTML(pad, rev); - if(rev !== undefined && !is_int(rev)) - { - callback(new customError("rev is a float value","apierror")); - return; - } - - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - //the client asked for a special revision - if(rev !== undefined) - { - //check if this is a valid revision - if(rev > pad.getHeadRevisionNumber()) - { - callback(new customError("rev is higher than the head revision of the pad","apierror")); - return; - } - - //get the html of this revision - exportHtml.getPadHTML(pad, rev, function(err, html) - { - if(ERR(err, callback)) return; - html = "" +html; // adds HTML head - html += ""; - var data = {html: html}; - callback(null, data); - }); - - return; - } - - //the client wants the latest text, lets return it to him - exportHtml.getPadHTML(pad, undefined, function (err, html) - { - if(ERR(err, callback)) return; - html = "" +html; // adds HTML head - html += ""; - var data = {html: html}; - callback(null, data); - }); - }); + // wrap the HTML + html = "" + html + ""; + return { html }; } /** @@ -423,32 +275,26 @@ Example returns: {code: 0, message:"ok", data: null} {code: 1, message:"padID does not exist", data: null} */ -exports.setHTML = function(padID, html, callback) +exports.setHTML = async function(padID, html) { - //html is required - if(typeof html != "string") - { - callback(new customError("html is no string","apierror")); - return; + // html string is required + if (typeof html !== "string") { + throw new customError("html is not a string", "apierror"); } - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; + // get the pad + let pad = await getPadSafe(padID, true); - // add a new changeset with the new html to the pad - importHtml.setPadHTML(pad, cleanText(html), function(e){ - if(e){ - callback(new customError("HTML is malformed","apierror")); - return; - } + // add a new changeset with the new html to the pad + try { + importHtml.setPadHTML(pad, cleanText(html)); + } catch (e) { + throw new customError("HTML is malformed", "apierror"); + } - //update the clients on the pad - padMessageHandler.updatePadClients(pad, callback); - }); - }); -} + // update the clients on the pad + padMessageHandler.updatePadClients(pad); +}; /******************/ /**CHAT FUNCTIONS */ @@ -466,59 +312,42 @@ Example returns: {code: 1, message:"padID does not exist", data: null} */ -exports.getChatHistory = function(padID, start, end, callback) +exports.getChatHistory = async function(padID, start, end) { - if(start && end) - { - if(start < 0) - { - callback(new customError("start is below zero","apierror")); - return; + if (start && end) { + if (start < 0) { + throw new customError("start is below zero", "apierror"); } - if(end < 0) - { - callback(new customError("end is below zero","apierror")); - return; + if (end < 0) { + throw new customError("end is below zero", "apierror"); } - if(start > end) - { - callback(new customError("start is higher than end","apierror")); - return; + if (start > end) { + throw new customError("start is higher than end", "apierror"); } } - - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - var chatHead = pad.chatHead; - - // fall back to getting the whole chat-history if a parameter is missing - if(!start || !end) - { + + // get the pad + let pad = await getPadSafe(padID, true); + + var chatHead = pad.chatHead; + + // fall back to getting the whole chat-history if a parameter is missing + if (!start || !end) { start = 0; end = pad.chatHead; - } - - if(start > chatHead) - { - callback(new customError("start is higher than the current chatHead","apierror")); - return; - } - if(end > chatHead) - { - callback(new customError("end is higher than the current chatHead","apierror")); - return; - } - - // the the whole message-log and return it to the client - pad.getChatMessages(start, end, - function(err, msgs) - { - if(ERR(err, callback)) return; - callback(null, {messages: msgs}); - }); - }); + } + + if (start > chatHead) { + throw new customError("start is higher than the current chatHead", "apierror"); + } + if (end > chatHead) { + throw new customError("end is higher than the current chatHead", "apierror"); + } + + // the the whole message-log and return it to the client + let messages = await pad.getChatMessages(start, end); + + return { messages }; } /** @@ -529,27 +358,22 @@ Example returns: {code: 0, message:"ok", data: null} {code: 1, message:"padID does not exist", data: null} */ -exports.appendChatMessage = function(padID, text, authorID, time, callback) +exports.appendChatMessage = async function(padID, text, authorID, time) { - //text is required - if(typeof text != "string") - { - callback(new customError("text is no string","apierror")); - return; - } - - // if time is not an integer value - if(time === undefined || !is_int(time)) - { - // set time to current timestamp - time = new Date().getTime(); + // text is required + if (typeof text !== "string") { + throw new customError("text is not a string", "apierror"); } - var padMessage = require("ep_etherpad-lite/node/handler/PadMessageHandler.js"); + // if time is not an integer value set time to current timestamp + if (time === undefined || !is_int(time)) { + time = Date.now(); + } + + // @TODO - missing getPadSafe() call ? + // save chat message to database and send message to all connected clients - padMessage.sendChatMessageToPadClients(parseInt(time), authorID, text, padID); - - callback(); + padMessageHandler.sendChatMessageToPadClients(time, authorID, text, padID); } /*****************/ @@ -557,22 +381,18 @@ exports.appendChatMessage = function(padID, text, authorID, time, callback) /*****************/ /** -getRevisionsCount(padID) returns the number of revisions of this pad +getRevisionsCount(padID) returns the number of revisions of this pad Example returns: {code: 0, message:"ok", data: {revisions: 56}} {code: 1, message:"padID does not exist", data: null} */ -exports.getRevisionsCount = function(padID, callback) +exports.getRevisionsCount = async function(padID) { - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - callback(null, {revisions: pad.getHeadRevisionNumber()}); - }); + // get the pad + let pad = await getPadSafe(padID, true); + return { revisions: pad.getHeadRevisionNumber() }; } /** @@ -583,15 +403,11 @@ Example returns: {code: 0, message:"ok", data: {savedRevisions: 42}} {code: 1, message:"padID does not exist", data: null} */ -exports.getSavedRevisionsCount = function(padID, callback) +exports.getSavedRevisionsCount = async function(padID) { - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - callback(null, {savedRevisions: pad.getSavedRevisionsNumber()}); - }); + // get the pad + let pad = await getPadSafe(padID, true); + return { savedRevisions: pad.getSavedRevisionsNumber() }; } /** @@ -602,15 +418,11 @@ Example returns: {code: 0, message:"ok", data: {savedRevisions: [2, 42, 1337]}} {code: 1, message:"padID does not exist", data: null} */ -exports.listSavedRevisions = function(padID, callback) +exports.listSavedRevisions = async function(padID) { - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - callback(null, {savedRevisions: pad.getSavedRevisionsList()}); - }); + // get the pad + let pad = await getPadSafe(padID, true); + return { savedRevisions: pad.getSavedRevisionsList() }; } /** @@ -621,67 +433,28 @@ Example returns: {code: 0, message:"ok", data: null} {code: 1, message:"padID does not exist", data: null} */ -exports.saveRevision = function(padID, rev, callback) +exports.saveRevision = async function(padID, rev) { - //check if rev is set - if(typeof rev == "function") - { - callback = rev; - rev = undefined; + // check if rev is a number + if (rev !== undefined) { + rev = checkValidRev(rev); } - //check if rev is a number - if(rev !== undefined && typeof rev != "number") - { - //try to parse the number - if(isNaN(parseInt(rev))) - { - callback(new customError("rev is not a number", "apierror")); - return; + // get the pad + let pad = await getPadSafe(padID, true); + let head = pad.getHeadRevisionNumber(); + + // the client asked for a special revision + if (rev !== undefined) { + if (rev > head) { + throw new customError("rev is higher than the head revision of the pad", "apierror"); } - - rev = parseInt(rev); + } else { + rev = pad.getHeadRevisionNumber(); } - //ensure this is not a negativ number - if(rev !== undefined && rev < 0) - { - callback(new customError("rev is a negativ number","apierror")); - return; - } - - //ensure this is not a float value - if(rev !== undefined && !is_int(rev)) - { - callback(new customError("rev is a float value","apierror")); - return; - } - - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - //the client asked for a special revision - if(rev !== undefined) - { - //check if this is a valid revision - if(rev > pad.getHeadRevisionNumber()) - { - callback(new customError("rev is higher than the head revision of the pad","apierror")); - return; - } - } else { - rev = pad.getHeadRevisionNumber(); - } - - authorManager.createAuthor('API', function(err, author) { - if(ERR(err, callback)) return; - - pad.addSavedRevision(rev, author.authorID, 'Saved through API call'); - callback(); - }); - }); + let author = await authorManager.createAuthor('API'); + pad.addSavedRevision(rev, author.authorID, 'Saved through API call'); } /** @@ -692,71 +465,54 @@ Example returns: {code: 0, message:"ok", data: {lastEdited: 1340815946602}} {code: 1, message:"padID does not exist", data: null} */ -exports.getLastEdited = function(padID, callback) +exports.getLastEdited = async function(padID) { - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - pad.getLastEdit(function(err, value) { - if(ERR(err, callback)) return; - callback(null, {lastEdited: value}); - }); - }); + // get the pad + let pad = await getPadSafe(padID, true); + let lastEdited = await pad.getLastEdit(); + return { lastEdited }; } /** -createPad(padName [, text]) creates a new pad in this group +createPad(padName [, text]) creates a new pad in this group Example returns: {code: 0, message:"ok", data: null} {code: 1, message:"pad does already exist", data: null} */ -exports.createPad = function(padID, text, callback) -{ - //ensure there is no $ in the padID - if(padID) - { - if(padID.indexOf("$") != -1) - { - callback(new customError("createPad can't create group pads","apierror")); - return; +exports.createPad = async function(padID, text) +{ + if (padID) { + // ensure there is no $ in the padID + if (padID.indexOf("$") !== -1) { + throw new customError("createPad can't create group pads", "apierror"); } - //check for url special characters - if(padID.match(/(\/|\?|&|#)/)) - { - callback(new customError("malformed padID: Remove special characters","apierror")); - return; + // check for url special characters + if (padID.match(/(\/|\?|&|#)/)) { + throw new customError("malformed padID: Remove special characters", "apierror"); } } - //create pad - getPadSafe(padID, false, text, function(err) - { - if(ERR(err, callback)) return; - callback(); - }); + // create pad + await getPadSafe(padID, false, text); } /** -deletePad(padID) deletes a pad +deletePad(padID) deletes a pad Example returns: {code: 0, message:"ok", data: null} {code: 1, message:"padID does not exist", data: null} */ -exports.deletePad = function(padID, callback) +exports.deletePad = async function(padID) { - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - pad.remove(callback); - }); + let pad = await getPadSafe(padID, true); + await pad.remove(); } + /** restoreRevision(padID, [rev]) Restores revision from past as new changeset @@ -765,110 +521,69 @@ exports.deletePad = function(padID, callback) {code:0, message:"ok", data:null} {code: 1, message:"padID does not exist", data: null} */ -exports.restoreRevision = function (padID, rev, callback) +exports.restoreRevision = async function(padID, rev) { - var Changeset = require("ep_etherpad-lite/static/js/Changeset"); - var padMessage = require("ep_etherpad-lite/node/handler/PadMessageHandler.js"); + // check if rev is a number + if (rev === undefined) { + throw new customError("rev is not defined", "apierror"); + } + rev = checkValidRev(rev); - //check if rev is a number - if (rev !== undefined && typeof rev != "number") - { - //try to parse the number - if (isNaN(parseInt(rev))) - { - callback(new customError("rev is not a number", "apierror")); - return; - } + // get the pad + let pad = await getPadSafe(padID, true); - rev = parseInt(rev); + // check if this is a valid revision + if (rev > pad.getHeadRevisionNumber()) { + throw new customError("rev is higher than the head revision of the pad", "apierror"); } - //ensure this is not a negativ number - if (rev !== undefined && rev < 0) - { - callback(new customError("rev is a negativ number", "apierror")); - return; - } + let atext = await pad.getInternalRevisionAText(rev); - //ensure this is not a float value - if (rev !== undefined && !is_int(rev)) - { - callback(new customError("rev is a float value", "apierror")); - return; - } + var oldText = pad.text(); + atext.text += "\n"; - //get the pad - getPadSafe(padID, true, function (err, pad) - { - if (ERR(err, callback)) return; - - - //check if this is a valid revision - if (rev > pad.getHeadRevisionNumber()) - { - callback(new customError("rev is higher than the head revision of the pad", "apierror")); - return; - } - - pad.getInternalRevisionAText(rev, function (err, atext) - { - if (ERR(err, callback)) return; - - var oldText = pad.text(); - atext.text += "\n"; - function eachAttribRun(attribs, func) - { - var attribsIter = Changeset.opIterator(attribs); - var textIndex = 0; - var newTextStart = 0; - var newTextEnd = atext.text.length; - while (attribsIter.hasNext()) - { - var op = attribsIter.next(); - var nextIndex = textIndex + op.chars; - if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) - { - func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs); - } - textIndex = nextIndex; - } + function eachAttribRun(attribs, func) { + var attribsIter = Changeset.opIterator(attribs); + var textIndex = 0; + var newTextStart = 0; + var newTextEnd = atext.text.length; + while (attribsIter.hasNext()) { + var op = attribsIter.next(); + var nextIndex = textIndex + op.chars; + if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { + func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs); } + textIndex = nextIndex; + } + } - // create a new changeset with a helper builder object - var builder = Changeset.builder(oldText.length); - - // assemble each line into the builder - eachAttribRun(atext.attribs, function (start, end, attribs) - { - builder.insert(atext.text.substring(start, end), attribs); - }); - - var lastNewlinePos = oldText.lastIndexOf('\n'); - if (lastNewlinePos < 0) - { - builder.remove(oldText.length - 1, 0); - } else - { - builder.remove(lastNewlinePos, oldText.match(/\n/g).length - 1); - builder.remove(oldText.length - lastNewlinePos - 1, 0); - } - - var changeset = builder.toString(); - - //append the changeset - pad.appendRevision(changeset); - // - padMessage.updatePadClients(pad, function () - { - }); - callback(null, null); - }); + // create a new changeset with a helper builder object + var builder = Changeset.builder(oldText.length); + // assemble each line into the builder + eachAttribRun(atext.attribs, function(start, end, attribs) { + builder.insert(atext.text.substring(start, end), attribs); }); -}; + + var lastNewlinePos = oldText.lastIndexOf('\n'); + if (lastNewlinePos < 0) { + builder.remove(oldText.length - 1, 0); + } else { + builder.remove(lastNewlinePos, oldText.match(/\n/g).length - 1); + builder.remove(oldText.length - lastNewlinePos - 1, 0); + } + + var changeset = builder.toString(); + + // append the changeset + pad.appendRevision(changeset); + + // update the clients on the pad + padMessageHandler.updatePadClients(pad); +} /** -copyPad(sourceID, destinationID[, force=false]) copies a pad. If force is true, +copyPad(sourceID, destinationID[, force=false]) copies a pad. If force is true, the destination will be overwritten if it exists. Example returns: @@ -876,18 +591,14 @@ Example returns: {code: 0, message:"ok", data: {padID: destinationID}} {code: 1, message:"padID does not exist", data: null} */ -exports.copyPad = function(sourceID, destinationID, force, callback) +exports.copyPad = async function(sourceID, destinationID, force) { - getPadSafe(sourceID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - pad.copy(destinationID, force, callback); - }); + let pad = await getPadSafe(sourceID, true); + await pad.copy(destinationID, force); } /** -movePad(sourceID, destinationID[, force=false]) moves a pad. If force is true, +movePad(sourceID, destinationID[, force=false]) moves a pad. If force is true, the destination will be overwritten if it exists. Example returns: @@ -895,40 +606,30 @@ Example returns: {code: 0, message:"ok", data: {padID: destinationID}} {code: 1, message:"padID does not exist", data: null} */ -exports.movePad = function(sourceID, destinationID, force, callback) +exports.movePad = async function(sourceID, destinationID, force) { - getPadSafe(sourceID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - pad.copy(destinationID, force, function(err) { - if(ERR(err, callback)) return; - pad.remove(callback); - }); - }); + let pad = await getPadSafe(sourceID, true); + await pad.copy(destinationID, force); + await pad.remove(); } + /** -getReadOnlyLink(padID) returns the read only link of a pad +getReadOnlyLink(padID) returns the read only link of a pad Example returns: {code: 0, message:"ok", data: null} {code: 1, message:"padID does not exist", data: null} */ -exports.getReadOnlyID = function(padID, callback) +exports.getReadOnlyID = async function(padID) { - //we don't need the pad object, but this function does all the security stuff for us - getPadSafe(padID, true, function(err) - { - if(ERR(err, callback)) return; - - //get the readonlyId - readOnlyManager.getReadOnlyId(padID, function(err, readOnlyId) - { - if(ERR(err, callback)) return; - callback(null, {readOnlyID: readOnlyId}); - }); - }); + // we don't need the pad object, but this function does all the security stuff for us + await getPadSafe(padID, true); + + // get the readonlyId + let readOnlyID = await readOnlyManager.getReadOnlyId(padID); + + return { readOnlyID }; } /** @@ -939,154 +640,112 @@ Example returns: {code: 0, message:"ok", data: {padID: padID}} {code: 1, message:"padID does not exist", data: null} */ -exports.getPadID = function(roID, callback) +exports.getPadID = async function(roID) { - //get the PadId - readOnlyManager.getPadId(roID, function(err, retrievedPadID) - { - if(ERR(err, callback)) return; + // get the PadId + let padID = await readOnlyManager.getPadId(roID); + if (padID === null) { + throw new customError("padID does not exist", "apierror"); + } - if(retrievedPadID == null) - { - callback(new customError("padID does not exist","apierror")); - return; - } - - callback(null, {padID: retrievedPadID}); - }); + return { padID }; } /** -setPublicStatus(padID, publicStatus) sets a boolean for the public status of a pad +setPublicStatus(padID, publicStatus) sets a boolean for the public status of a pad Example returns: {code: 0, message:"ok", data: null} {code: 1, message:"padID does not exist", data: null} */ -exports.setPublicStatus = function(padID, publicStatus, callback) +exports.setPublicStatus = async function(padID, publicStatus) { - //ensure this is a group pad - if(padID && padID.indexOf("$") == -1) - { - callback(new customError("You can only get/set the publicStatus of pads that belong to a group","apierror")); - return; + // ensure this is a group pad + checkGroupPad(padID, "publicStatus"); + + // get the pad + let pad = await getPadSafe(padID, true); + + // convert string to boolean + if (typeof publicStatus === "string") { + publicStatus = (publicStatus.toLowerCase() === "true"); } - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - //convert string to boolean - if(typeof publicStatus == "string") - publicStatus = publicStatus == "true" ? true : false; - - //set the password - pad.setPublicStatus(publicStatus); - - callback(); - }); + // set the password + pad.setPublicStatus(publicStatus); } /** -getPublicStatus(padID) return true of false +getPublicStatus(padID) return true of false Example returns: {code: 0, message:"ok", data: {publicStatus: true}} {code: 1, message:"padID does not exist", data: null} */ -exports.getPublicStatus = function(padID, callback) +exports.getPublicStatus = async function(padID) { - //ensure this is a group pad - if(padID && padID.indexOf("$") == -1) - { - callback(new customError("You can only get/set the publicStatus of pads that belong to a group","apierror")); - return; - } - - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - callback(null, {publicStatus: pad.getPublicStatus()}); - }); + // ensure this is a group pad + checkGroupPad(padID, "publicStatus"); + + // get the pad + let pad = await getPadSafe(padID, true); + return { publicStatus: pad.getPublicStatus() }; } /** -setPassword(padID, password) returns ok or a error message +setPassword(padID, password) returns ok or a error message Example returns: {code: 0, message:"ok", data: null} {code: 1, message:"padID does not exist", data: null} */ -exports.setPassword = function(padID, password, callback) +exports.setPassword = async function(padID, password) { - //ensure this is a group pad - if(padID && padID.indexOf("$") == -1) - { - callback(new customError("You can only get/set the password of pads that belong to a group","apierror")); - return; - } - - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - //set the password - pad.setPassword(password == "" ? null : password); - - callback(); - }); + // ensure this is a group pad + checkGroupPad(padID, "password"); + + // get the pad + let pad = await getPadSafe(padID, true); + + // set the password + pad.setPassword(password == "" ? null : password); } /** -isPasswordProtected(padID) returns true or false +isPasswordProtected(padID) returns true or false Example returns: {code: 0, message:"ok", data: {passwordProtection: true}} {code: 1, message:"padID does not exist", data: null} */ -exports.isPasswordProtected = function(padID, callback) +exports.isPasswordProtected = async function(padID) { - //ensure this is a group pad - if(padID && padID.indexOf("$") == -1) - { - callback(new customError("You can only get/set the password of pads that belong to a group","apierror")); - return; - } + // ensure this is a group pad + checkGroupPad(padID, "password"); - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - callback(null, {isPasswordProtected: pad.isPasswordProtected()}); - }); + // get the pad + let pad = await getPadSafe(padID, true); + return { isPasswordProtected: pad.isPasswordProtected() }; } /** -listAuthorsOfPad(padID) returns an array of authors who contributed to this pad +listAuthorsOfPad(padID) returns an array of authors who contributed to this pad Example returns: {code: 0, message:"ok", data: {authorIDs : ["a.s8oes9dhwrvt0zif", "a.akf8finncvomlqva"]} {code: 1, message:"padID does not exist", data: null} */ -exports.listAuthorsOfPad = function(padID, callback) +exports.listAuthorsOfPad = async function(padID) { - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - - callback(null, {authorIDs: pad.getAllAuthors()}); - }); + // get the pad + let pad = await getPadSafe(padID, true); + let authorIDs = pad.getAllAuthors(); + return { authorIDs }; } /** @@ -1112,14 +771,9 @@ Example returns: {code: 1, message:"padID does not exist"} */ -exports.sendClientsMessage = function (padID, msg, callback) { - getPadSafe(padID, true, function (err, pad) { - if (ERR(err, callback)) { - return; - } - - padMessageHandler.handleCustomMessage(padID, msg, callback); - } ); +exports.sendClientsMessage = async function(padID, msg) { + let pad = await getPadSafe(padID, true); + padMessageHandler.handleCustomMessage(padID, msg); } /** @@ -1130,9 +784,8 @@ Example returns: {"code":0,"message":"ok","data":null} {"code":4,"message":"no or wrong API Key","data":null} */ -exports.checkToken = function(callback) +exports.checkToken = async function() { - callback(); } /** @@ -1143,14 +796,11 @@ Example returns: {code: 0, message:"ok", data: {chatHead: 42}} {code: 1, message:"padID does not exist", data: null} */ -exports.getChatHead = function(padID, callback) +exports.getChatHead = async function(padID) { - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(ERR(err, callback)) return; - callback(null, {chatHead: pad.chatHead}); - }); + // get the pad + let pad = await getPadSafe(padID, true); + return { chatHead: pad.chatHead }; } /** @@ -1161,126 +811,103 @@ Example returns: {"code":0,"message":"ok","data":{"html":"Welcome to Etherpad!

This pad text is synchronized as you type, so that everyone viewing this page sees the same text. This allows you to collaborate seamlessly on documents!

Get involved with Etherpad at http://etherpad.org
aw

","authors":["a.HKIv23mEbachFYfH",""]}} {"code":4,"message":"no or wrong API Key","data":null} */ -exports.createDiffHTML = function(padID, startRev, endRev, callback){ - //check if rev is a number - if(startRev !== undefined && typeof startRev != "number") - { - //try to parse the number - if(isNaN(parseInt(startRev))) - { - callback({stop: "startRev is not a number"}); - return; - } +exports.createDiffHTML = async function(padID, startRev, endRev) { - startRev = parseInt(startRev, 10); + // check if startRev is a number + if (startRev !== undefined) { + startRev = checkValidRev(startRev); } - - //check if rev is a number - if(endRev !== undefined && typeof endRev != "number") - { - //try to parse the number - if(isNaN(parseInt(endRev))) - { - callback({stop: "endRev is not a number"}); - return; - } - endRev = parseInt(endRev, 10); + // check if endRev is a number + if (endRev !== undefined) { + endRev = checkValidRev(endRev); } - - //get the pad - getPadSafe(padID, true, function(err, pad) - { - if(err){ - return callback(err); - } - - try { - var padDiff = new PadDiff(pad, startRev, endRev); - } catch(e) { - return callback({stop:e.message}); - } - var html, authors; - - async.series([ - function(callback){ - padDiff.getHtml(function(err, _html){ - if(err){ - return callback(err); - } - - html = _html; - callback(); - }); - }, - function(callback){ - padDiff.getAuthors(function(err, _authors){ - if(err){ - return callback(err); - } - - authors = _authors; - callback(); - }); - } - ], function(err){ - callback(err, {html: html, authors: authors}) - }); - }); + + // get the pad + let pad = await getPadSafe(padID, true); + try { + var padDiff = new PadDiff(pad, startRev, endRev); + } catch (e) { + throw { stop: e.message }; + } + + let html = await padDiff.getHtml(); + let authors = await padDiff.getAuthors(); + + return { html, authors }; } /******************************/ /** INTERNAL HELPER FUNCTIONS */ /******************************/ -//checks if a number is an int +// checks if a number is an int function is_int(value) -{ - return (parseFloat(value) == parseInt(value)) && !isNaN(value) -} - -//gets a pad safe -function getPadSafe(padID, shouldExist, text, callback) { - if(typeof text == "function") - { - callback = text; - text = null; + return (parseFloat(value) == parseInt(value, 10)) && !isNaN(value) +} + +// gets a pad safe +async function getPadSafe(padID, shouldExist, text) +{ + // check if padID is a string + if (typeof padID !== "string") { + throw new customError("padID is not a string", "apierror"); } - //check if padID is a string - if(typeof padID != "string") - { - callback(new customError("padID is not a string","apierror")); - return; + // check if the padID maches the requirements + if (!padManager.isValidPadId(padID)) { + throw new customError("padID did not match requirements", "apierror"); } - - //check if the padID maches the requirements - if(!padManager.isValidPadId(padID)) - { - callback(new customError("padID did not match requirements","apierror")); - return; + + // check if the pad exists + let exists = await padManager.doesPadExists(padID); + + if (!exists && shouldExist) { + // does not exist, but should + throw new customError("padID does not exist", "apierror"); + } + + if (exists && !shouldExist) { + // does exist, but shouldn't + throw new customError("padID does already exist", "apierror"); + } + + // pad exists, let's get it + return padManager.getPad(padID, text); +} + +// checks if a rev is a legal number +// pre-condition is that `rev` is not undefined +function checkValidRev(rev) +{ + if (typeof rev !== "number") { + rev = parseInt(rev, 10); + } + + // check if rev is a number + if (isNaN(rev)) { + throw new customError("rev is not a number", "apierror"); + } + + // ensure this is not a negative number + if (rev < 0) { + throw new customError("rev is not a negative number", "apierror"); + } + + // ensure this is not a float value + if (!is_int(rev)) { + throw new customError("rev is a float value", "apierror"); + } + + return rev; +} + +// checks if a padID is part of a group +function checkGroupPad(padID, field) +{ + // ensure this is a group pad + if (padID && padID.indexOf("$") === -1) { + throw new customError(`You can only get/set the ${field} of pads that belong to a group`, "apierror"); } - - //check if the pad exists - padManager.doesPadExists(padID, function(err, exists) - { - if(ERR(err, callback)) return; - - //does not exist, but should - if(exists == false && shouldExist == true) - { - callback(new customError("padID does not exist","apierror")); - } - //does exists, but shouldn't - else if(exists == true && shouldExist == false) - { - callback(new customError("padID does already exist","apierror")); - } - //pad exists, let's get it - else - { - padManager.getPad(padID, text, callback); - } - }); } diff --git a/src/node/db/AuthorManager.js b/src/node/db/AuthorManager.js index c7ebf47f4..a17952248 100644 --- a/src/node/db/AuthorManager.js +++ b/src/node/db/AuthorManager.js @@ -18,211 +18,189 @@ * limitations under the License. */ - -var ERR = require("async-stacktrace"); -var db = require("./DB").db; +var db = require("./DB"); var customError = require("../utils/customError"); var randomString = require('ep_etherpad-lite/static/js/pad_utils').randomString; -exports.getColorPalette = function(){ - return ["#ffc7c7", "#fff1c7", "#e3ffc7", "#c7ffd5", "#c7ffff", "#c7d5ff", "#e3c7ff", "#ffc7f1", "#ffa8a8", "#ffe699", "#cfff9e", "#99ffb3", "#a3ffff", "#99b3ff", "#cc99ff", "#ff99e5", "#e7b1b1", "#e9dcAf", "#cde9af", "#bfedcc", "#b1e7e7", "#c3cdee", "#d2b8ea", "#eec3e6", "#e9cece", "#e7e0ca", "#d3e5c7", "#bce1c5", "#c1e2e2", "#c1c9e2", "#cfc1e2", "#e0bdd9", "#baded3", "#a0f8eb", "#b1e7e0", "#c3c8e4", "#cec5e2", "#b1d5e7", "#cda8f0", "#f0f0a8", "#f2f2a6", "#f5a8eb", "#c5f9a9", "#ececbb", "#e7c4bc", "#daf0b2", "#b0a0fd", "#bce2e7", "#cce2bb", "#ec9afe", "#edabbd", "#aeaeea", "#c4e7b1", "#d722bb", "#f3a5e7", "#ffa8a8", "#d8c0c5", "#eaaedd", "#adc6eb", "#bedad1", "#dee9af", "#e9afc2", "#f8d2a0", "#b3b3e6"]; +exports.getColorPalette = function() { + return [ + "#ffc7c7", "#fff1c7", "#e3ffc7", "#c7ffd5", "#c7ffff", "#c7d5ff", "#e3c7ff", "#ffc7f1", + "#ffa8a8", "#ffe699", "#cfff9e", "#99ffb3", "#a3ffff", "#99b3ff", "#cc99ff", "#ff99e5", + "#e7b1b1", "#e9dcAf", "#cde9af", "#bfedcc", "#b1e7e7", "#c3cdee", "#d2b8ea", "#eec3e6", + "#e9cece", "#e7e0ca", "#d3e5c7", "#bce1c5", "#c1e2e2", "#c1c9e2", "#cfc1e2", "#e0bdd9", + "#baded3", "#a0f8eb", "#b1e7e0", "#c3c8e4", "#cec5e2", "#b1d5e7", "#cda8f0", "#f0f0a8", + "#f2f2a6", "#f5a8eb", "#c5f9a9", "#ececbb", "#e7c4bc", "#daf0b2", "#b0a0fd", "#bce2e7", + "#cce2bb", "#ec9afe", "#edabbd", "#aeaeea", "#c4e7b1", "#d722bb", "#f3a5e7", "#ffa8a8", + "#d8c0c5", "#eaaedd", "#adc6eb", "#bedad1", "#dee9af", "#e9afc2", "#f8d2a0", "#b3b3e6" + ]; }; /** * Checks if the author exists */ -exports.doesAuthorExists = function (authorID, callback) +exports.doesAuthorExist = async function(authorID) { - //check if the database entry of this author exists - db.get("globalAuthor:" + authorID, function (err, author) - { - if(ERR(err, callback)) return; - callback(null, author != null); - }); + let author = await db.get("globalAuthor:" + authorID); + + return author !== null; } +/* exported for backwards compatibility */ +exports.doesAuthorExists = exports.doesAuthorExist; + /** * Returns the AuthorID for a token. * @param {String} token The token - * @param {Function} callback callback (err, author) */ -exports.getAuthor4Token = function (token, callback) +exports.getAuthor4Token = async function(token) { - mapAuthorWithDBKey("token2author", token, function(err, author) - { - if(ERR(err, callback)) return; - //return only the sub value authorID - callback(null, author ? author.authorID : author); - }); + let author = await mapAuthorWithDBKey("token2author", token); + + // return only the sub value authorID + return author ? author.authorID : author; } /** * Returns the AuthorID for a mapper. * @param {String} token The mapper * @param {String} name The name of the author (optional) - * @param {Function} callback callback (err, author) */ -exports.createAuthorIfNotExistsFor = function (authorMapper, name, callback) +exports.createAuthorIfNotExistsFor = async function(authorMapper, name) { - mapAuthorWithDBKey("mapper2author", authorMapper, function(err, author) - { - if(ERR(err, callback)) return; + let author = await mapAuthorWithDBKey("mapper2author", authorMapper); - //set the name of this author - if(name) - exports.setAuthorName(author.authorID, name); + if (name) { + // set the name of this author + await exports.setAuthorName(author.authorID, name); + } - //return the authorID - callback(null, author); - }); -} + return author; +}; /** * Returns the AuthorID for a mapper. We can map using a mapperkey, * so far this is token2author and mapper2author * @param {String} mapperkey The database key name for this mapper * @param {String} mapper The mapper - * @param {Function} callback callback (err, author) */ -function mapAuthorWithDBKey (mapperkey, mapper, callback) +async function mapAuthorWithDBKey (mapperkey, mapper) { - //try to map to an author - db.get(mapperkey + ":" + mapper, function (err, author) - { - if(ERR(err, callback)) return; + // try to map to an author + let author = await db.get(mapperkey + ":" + mapper); - //there is no author with this mapper, so create one - if(author == null) - { - exports.createAuthor(null, function(err, author) - { - if(ERR(err, callback)) return; + if (author === null) { + // there is no author with this mapper, so create one + let author = await exports.createAuthor(null); - //create the token2author relation - db.set(mapperkey + ":" + mapper, author.authorID); + // create the token2author relation + await db.set(mapperkey + ":" + mapper, author.authorID); - //return the author - callback(null, author); - }); + // return the author + return author; + } - return; - } + // there is an author with this mapper + // update the timestamp of this author + await db.setSub("globalAuthor:" + author, ["timestamp"], Date.now()); - //there is a author with this mapper - //update the timestamp of this author - db.setSub("globalAuthor:" + author, ["timestamp"], new Date().getTime()); - - //return the author - callback(null, {authorID: author}); - }); + // return the author + return { authorID: author}; } /** * Internal function that creates the database entry for an author * @param {String} name The name of the author */ -exports.createAuthor = function(name, callback) +exports.createAuthor = function(name) { - //create the new author name - var author = "a." + randomString(16); + // create the new author name + let author = "a." + randomString(16); - //create the globalAuthors db entry - var authorObj = {"colorId" : Math.floor(Math.random()*(exports.getColorPalette().length)), "name": name, "timestamp": new Date().getTime()}; + // create the globalAuthors db entry + let authorObj = { + "colorId": Math.floor(Math.random() * (exports.getColorPalette().length)), + "name": name, + "timestamp": Date.now() + }; - //set the global author db entry + // set the global author db entry + // NB: no await, since we're not waiting for the DB set to finish db.set("globalAuthor:" + author, authorObj); - callback(null, {authorID: author}); + return { authorID: author }; } /** * Returns the Author Obj of the author * @param {String} author The id of the author - * @param {Function} callback callback(err, authorObj) */ -exports.getAuthor = function (author, callback) +exports.getAuthor = function(author) { - db.get("globalAuthor:" + author, callback); + // NB: result is already a Promise + return db.get("globalAuthor:" + author); } - - /** * Returns the color Id of the author * @param {String} author The id of the author - * @param {Function} callback callback(err, colorId) */ -exports.getAuthorColorId = function (author, callback) +exports.getAuthorColorId = function(author) { - db.getSub("globalAuthor:" + author, ["colorId"], callback); + return db.getSub("globalAuthor:" + author, ["colorId"]); } /** * Sets the color Id of the author * @param {String} author The id of the author * @param {String} colorId The color id of the author - * @param {Function} callback (optional) */ -exports.setAuthorColorId = function (author, colorId, callback) +exports.setAuthorColorId = function(author, colorId) { - db.setSub("globalAuthor:" + author, ["colorId"], colorId, callback); + return db.setSub("globalAuthor:" + author, ["colorId"], colorId); } /** * Returns the name of the author * @param {String} author The id of the author - * @param {Function} callback callback(err, name) */ -exports.getAuthorName = function (author, callback) +exports.getAuthorName = function(author) { - db.getSub("globalAuthor:" + author, ["name"], callback); + return db.getSub("globalAuthor:" + author, ["name"]); } /** * Sets the name of the author * @param {String} author The id of the author * @param {String} name The name of the author - * @param {Function} callback (optional) */ -exports.setAuthorName = function (author, name, callback) +exports.setAuthorName = function(author, name) { - db.setSub("globalAuthor:" + author, ["name"], name, callback); + return db.setSub("globalAuthor:" + author, ["name"], name); } /** * Returns an array of all pads this author contributed to * @param {String} author The id of the author - * @param {Function} callback (optional) */ -exports.listPadsOfAuthor = function (authorID, callback) +exports.listPadsOfAuthor = async function(authorID) { /* There are two other places where this array is manipulated: * (1) When the author is added to a pad, the author object is also updated * (2) When a pad is deleted, each author of that pad is also updated */ - //get the globalAuthor - db.get("globalAuthor:" + authorID, function(err, author) - { - if(ERR(err, callback)) return; - //author does not exists - if(author == null) - { - callback(new customError("authorID does not exist","apierror")) - return; - } + // get the globalAuthor + let author = await db.get("globalAuthor:" + authorID); - //everything is fine, return the pad IDs - var pads = []; - if(author.padIDs != null) - { - for (var padId in author.padIDs) - { - pads.push(padId); - } - } - callback(null, {padIDs: pads}); - }); + if (author === null) { + // author does not exist + throw new customError("authorID does not exist", "apierror"); + } + + // everything is fine, return the pad IDs + let padIDs = Object.keys(author.padIDs || {}); + + return { padIDs }; } /** @@ -230,26 +208,27 @@ exports.listPadsOfAuthor = function (authorID, callback) * @param {String} author The id of the author * @param {String} padID The id of the pad the author contributes to */ -exports.addPad = function (authorID, padID) +exports.addPad = async function(authorID, padID) { - //get the entry - db.get("globalAuthor:" + authorID, function(err, author) - { - if(ERR(err)) return; - if(author == null) return; + // get the entry + let author = await db.get("globalAuthor:" + authorID); - //the entry doesn't exist so far, let's create it - if(author.padIDs == null) - { - author.padIDs = {}; - } + if (author === null) return; - //add the entry for this pad - author.padIDs[padID] = 1;// anything, because value is not used + /* + * ACHTUNG: padIDs can also be undefined, not just null, so it is not possible + * to perform a strict check here + */ + if (!author.padIDs) { + // the entry doesn't exist so far, let's create it + author.padIDs = {}; + } - //save the new element back - db.set("globalAuthor:" + authorID, author); - }); + // add the entry for this pad + author.padIDs[padID] = 1; // anything, because value is not used + + // save the new element back + db.set("globalAuthor:" + authorID, author); } /** @@ -257,18 +236,15 @@ exports.addPad = function (authorID, padID) * @param {String} author The id of the author * @param {String} padID The id of the pad the author contributes to */ -exports.removePad = function (authorID, padID) +exports.removePad = async function(authorID, padID) { - db.get("globalAuthor:" + authorID, function (err, author) - { - if(ERR(err)) return; - if(author == null) return; + let author = await db.get("globalAuthor:" + authorID); - if(author.padIDs != null) - { - //remove pad from author - delete author.padIDs[padID]; - db.set("globalAuthor:" + authorID, author); - } - }); + if (author === null) return; + + if (author.padIDs !== null) { + // remove pad from author + delete author.padIDs[padID]; + db.set("globalAuthor:" + authorID, author); + } } diff --git a/src/node/db/DB.js b/src/node/db/DB.js index 3c65d5cdb..573563665 100644 --- a/src/node/db/DB.js +++ b/src/node/db/DB.js @@ -1,5 +1,5 @@ /** - * The DB Module provides a database initalized with the settings + * The DB Module provides a database initalized with the settings * provided by the settings module */ @@ -22,9 +22,10 @@ var ueberDB = require("ueberdb2"); var settings = require("../utils/Settings"); var log4js = require('log4js'); +const util = require("util"); -//set database settings -var db = new ueberDB.database(settings.dbType, settings.dbSettings, null, log4js.getLogger("ueberDB")); +// set database settings +let db = new ueberDB.database(settings.dbType, settings.dbSettings, null, log4js.getLogger("ueberDB")); /** * The UeberDB Object that provides the database functions @@ -33,25 +34,40 @@ exports.db = null; /** * Initalizes the database with the settings provided by the settings module - * @param {Function} callback + * @param {Function} callback */ -exports.init = function(callback) -{ - //initalize the database async - db.init(function(err) - { - //there was an error while initializing the database, output it and stop - if(err) - { - console.error("ERROR: Problem while initalizing the database"); - console.error(err.stack ? err.stack : err); - process.exit(1); - } - //everything ok - else - { - exports.db = db; - callback(null); - } +exports.init = function() { + // initalize the database async + return new Promise((resolve, reject) => { + db.init(function(err) { + if (err) { + // there was an error while initializing the database, output it and stop + console.error("ERROR: Problem while initalizing the database"); + console.error(err.stack ? err.stack : err); + process.exit(1); + } else { + // everything ok, set up Promise-based methods + ['get', 'set', 'findKeys', 'getSub', 'setSub', 'remove', 'doShutdown'].forEach(fn => { + exports[fn] = util.promisify(db[fn].bind(db)); + }); + + // set up wrappers for get and getSub that can't return "undefined" + let get = exports.get; + exports.get = async function(key) { + let result = await get(key); + return (result === undefined) ? null : result; + }; + + let getSub = exports.getSub; + exports.getSub = async function(key, sub) { + let result = await getSub(key, sub); + return (result === undefined) ? null : result; + }; + + // exposed for those callers that need the underlying raw API + exports.db = db; + resolve(); + } + }); }); } diff --git a/src/node/db/GroupManager.js b/src/node/db/GroupManager.js index 0c9be1221..5df034ef6 100644 --- a/src/node/db/GroupManager.js +++ b/src/node/db/GroupManager.js @@ -17,319 +17,167 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -var ERR = require("async-stacktrace"); var customError = require("../utils/customError"); var randomString = require('ep_etherpad-lite/static/js/pad_utils').randomString; -var db = require("./DB").db; -var async = require("async"); +var db = require("./DB"); var padManager = require("./PadManager"); var sessionManager = require("./SessionManager"); -exports.listAllGroups = function(callback) { - db.get("groups", function (err, groups) { - if(ERR(err, callback)) return; - - // there are no groups - if(groups == null) { - callback(null, {groupIDs: []}); - return; - } - - var groupIDs = []; - for ( var groupID in groups ) { - groupIDs.push(groupID); - } - callback(null, {groupIDs: groupIDs}); - }); -} - -exports.deleteGroup = function(groupID, callback) +exports.listAllGroups = async function() { - var group; + let groups = await db.get("groups"); + groups = groups || {}; - async.series([ - //ensure group exists - function (callback) - { - //try to get the group entry - db.get("group:" + groupID, function (err, _group) - { - if(ERR(err, callback)) return; - - //group does not exist - if(_group == null) - { - callback(new customError("groupID does not exist","apierror")); - return; - } - - //group exists, everything is fine - group = _group; - callback(); - }); - }, - //iterate trough all pads of this groups and delete them - function(callback) - { - //collect all padIDs in an array, that allows us to use async.forEach - var padIDs = []; - for(var i in group.pads) - { - padIDs.push(i); - } - - //loop trough all pads and delete them - async.forEach(padIDs, function(padID, callback) - { - padManager.getPad(padID, function(err, pad) - { - if(ERR(err, callback)) return; - - pad.remove(callback); - }); - }, callback); - }, - //iterate trough group2sessions and delete all sessions - function(callback) - { - //try to get the group entry - db.get("group2sessions:" + groupID, function (err, group2sessions) - { - if(ERR(err, callback)) return; - - //skip if there is no group2sessions entry - if(group2sessions == null) {callback(); return} - - //collect all sessions in an array, that allows us to use async.forEach - var sessions = []; - for(var i in group2sessions.sessionsIDs) - { - sessions.push(i); - } - - //loop trough all sessions and delete them - async.forEach(sessions, function(session, callback) - { - sessionManager.deleteSession(session, callback); - }, callback); - }); - }, - //remove group and group2sessions entry - function(callback) - { - db.remove("group2sessions:" + groupID); - db.remove("group:" + groupID); - callback(); - }, - //unlist the group - function(callback) - { - exports.listAllGroups(function(err, groups) { - if(ERR(err, callback)) return; - groups = groups? groups.groupIDs : []; - - // it's not listed - if(groups.indexOf(groupID) == -1) { - callback(); - return; - } - - groups.splice(groups.indexOf(groupID), 1); - - // store empty groupe list - if(groups.length == 0) { - db.set("groups", {}); - callback(); - return; - } - - // regenerate group list - var newGroups = {}; - async.forEach(groups, function(group, cb) { - newGroups[group] = 1; - cb(); - },function() { - db.set("groups", newGroups); - callback(); - }); - }); - } - ], function(err) - { - if(ERR(err, callback)) return; - callback(); - }); -} - -exports.doesGroupExist = function(groupID, callback) -{ - //try to get the group entry - db.get("group:" + groupID, function (err, group) - { - if(ERR(err, callback)) return; - callback(null, group != null); - }); + let groupIDs = Object.keys(groups); + return { groupIDs }; } -exports.createGroup = function(callback) +exports.deleteGroup = async function(groupID) { - //search for non existing groupID - var groupID = "g." + randomString(16); - - //create the group - db.set("group:" + groupID, {pads: {}}); - - //list the group - exports.listAllGroups(function(err, groups) { - if(ERR(err, callback)) return; - groups = groups? groups.groupIDs : []; - - groups.push(groupID); - - // regenerate group list - var newGroups = {}; - async.forEach(groups, function(group, cb) { - newGroups[group] = 1; - cb(); - },function() { - db.set("groups", newGroups); - callback(null, {groupID: groupID}); - }); - }); -} + let group = await db.get("group:" + groupID); + + // ensure group exists + if (group == null) { + // group does not exist + throw new customError("groupID does not exist", "apierror"); + } + + // iterate through all pads of this group and delete them (in parallel) + await Promise.all(Object.keys(group.pads).map(padID => { + return padManager.getPad(padID).then(pad => pad.remove()); + })); + + // iterate through group2sessions and delete all sessions + let group2sessions = await db.get("group2sessions:" + groupID); + let sessions = group2sessions ? group2sessions.sessionsIDs : {}; + + // loop through all sessions and delete them (in parallel) + await Promise.all(Object.keys(sessions).map(session => { + return sessionManager.deleteSession(session); + })); + + // remove group and group2sessions entry + await db.remove("group2sessions:" + groupID); + await db.remove("group:" + groupID); + + // unlist the group + let groups = await exports.listAllGroups(); + groups = groups ? groups.groupIDs : []; + + let index = groups.indexOf(groupID); + + if (index === -1) { + // it's not listed -exports.createGroupIfNotExistsFor = function(groupMapper, callback) -{ - //ensure mapper is optional - if(typeof groupMapper != "string") - { - callback(new customError("groupMapper is no string","apierror")); return; } - - //try to get a group for this mapper - db.get("mapper2group:"+groupMapper, function(err, groupID) - { - function createGroupForMapper(cb) { - exports.createGroup(function(err, responseObj) - { - if(ERR(err, cb)) return; - - //create the mapper entry for this group - db.set("mapper2group:"+groupMapper, responseObj.groupID); - - cb(null, responseObj); - }); - } - if(ERR(err, callback)) return; - + // remove from the list + groups.splice(index, 1); + + // regenerate group list + var newGroups = {}; + groups.forEach(group => newGroups[group] = 1); + await db.set("groups", newGroups); +} + +exports.doesGroupExist = async function(groupID) +{ + // try to get the group entry + let group = await db.get("group:" + groupID); + + return (group != null); +} + +exports.createGroup = async function() +{ + // search for non existing groupID + var groupID = "g." + randomString(16); + + // create the group + await db.set("group:" + groupID, {pads: {}}); + + // list the group + let groups = await exports.listAllGroups(); + groups = groups? groups.groupIDs : []; + groups.push(groupID); + + // regenerate group list + var newGroups = {}; + groups.forEach(group => newGroups[group] = 1); + await db.set("groups", newGroups); + + return { groupID }; +} + +exports.createGroupIfNotExistsFor = async function(groupMapper) +{ + // ensure mapper is optional + if (typeof groupMapper !== "string") { + throw new customError("groupMapper is not a string", "apierror"); + } + + // try to get a group for this mapper + let groupID = await db.get("mapper2group:" + groupMapper); + + if (groupID) { // there is a group for this mapper - if(groupID) { - exports.doesGroupExist(groupID, function(err, exists) { - if(ERR(err, callback)) return; - if(exists) return callback(null, {groupID: groupID}); + let exists = await exports.doesGroupExist(groupID); - // hah, the returned group doesn't exist, let's create one - createGroupForMapper(callback) - }) + if (exists) return { groupID }; + } - return; - } + // hah, the returned group doesn't exist, let's create one + let result = await exports.createGroup(); - //there is no group for this mapper, let's create a group - createGroupForMapper(callback) - }); + // create the mapper entry for this group + await db.set("mapper2group:" + groupMapper, result.groupID); + + return result; } -exports.createGroupPad = function(groupID, padName, text, callback) +exports.createGroupPad = async function(groupID, padName, text) { - //create the padID - var padID = groupID + "$" + padName; + // create the padID + let padID = groupID + "$" + padName; - async.series([ - //ensure group exists - function (callback) - { - exports.doesGroupExist(groupID, function(err, exists) - { - if(ERR(err, callback)) return; - - //group does not exist - if(exists == false) - { - callback(new customError("groupID does not exist","apierror")); - return; - } + // ensure group exists + let groupExists = await exports.doesGroupExist(groupID); - //group exists, everything is fine - callback(); - }); - }, - //ensure pad does not exists - function (callback) - { - padManager.doesPadExists(padID, function(err, exists) - { - if(ERR(err, callback)) return; - - //pad exists already - if(exists == true) - { - callback(new customError("padName does already exist","apierror")); - return; - } + if (!groupExists) { + throw new customError("groupID does not exist", "apierror"); + } - //pad does not exist, everything is fine - callback(); - }); - }, - //create the pad - function (callback) - { - padManager.getPad(padID, text, function(err) - { - if(ERR(err, callback)) return; - callback(); - }); - }, - //create an entry in the group for this pad - function (callback) - { - db.setSub("group:" + groupID, ["pads", padID], 1); - callback(); - } - ], function(err) - { - if(ERR(err, callback)) return; - callback(null, {padID: padID}); - }); + // ensure pad doesn't exist already + let padExists = await padManager.doesPadExists(padID); + + if (padExists) { + // pad exists already + throw new customError("padName does already exist", "apierror"); + } + + // create the pad + await padManager.getPad(padID, text); + + //create an entry in the group for this pad + await db.setSub("group:" + groupID, ["pads", padID], 1); + + return { padID }; } -exports.listPads = function(groupID, callback) +exports.listPads = async function(groupID) { - exports.doesGroupExist(groupID, function(err, exists) - { - if(ERR(err, callback)) return; - - //group does not exist - if(exists == false) - { - callback(new customError("groupID does not exist","apierror")); - return; - } + let exists = await exports.doesGroupExist(groupID); - //group exists, let's get the pads - db.getSub("group:" + groupID, ["pads"], function(err, result) - { - if(ERR(err, callback)) return; - var pads = []; - for ( var padId in result ) { - pads.push(padId); - } - callback(null, {padIDs: pads}); - }); - }); + // ensure the group exists + if (!exists) { + throw new customError("groupID does not exist", "apierror"); + } + + // group exists, let's get the pads + let result = await db.getSub("group:" + groupID, ["pads"]); + let padIDs = Object.keys(result); + + return { padIDs }; } diff --git a/src/node/db/Pad.js b/src/node/db/Pad.js index 91ab7f792..cf016444c 100644 --- a/src/node/db/Pad.js +++ b/src/node/db/Pad.js @@ -3,11 +3,9 @@ */ -var ERR = require("async-stacktrace"); var Changeset = require("ep_etherpad-lite/static/js/Changeset"); var AttributePool = require("ep_etherpad-lite/static/js/AttributePool"); -var db = require("./DB").db; -var async = require("async"); +var db = require("./DB"); var settings = require('../utils/Settings'); var authorManager = require("./AuthorManager"); var padManager = require("./PadManager"); @@ -19,7 +17,7 @@ var crypto = require("crypto"); var randomString = require("../utils/randomstring"); var hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks'); -//serialization/deserialization attributes +// serialization/deserialization attributes var attributeBlackList = ["id"]; var jsonableList = ["pool"]; @@ -32,8 +30,7 @@ exports.cleanText = function (txt) { }; -var Pad = function Pad(id) { - +let Pad = function Pad(id) { this.atext = Changeset.makeAText("\n"); this.pool = new AttributePool(); this.head = -1; @@ -60,7 +57,7 @@ Pad.prototype.getSavedRevisionsNumber = function getSavedRevisionsNumber() { Pad.prototype.getSavedRevisionsList = function getSavedRevisionsList() { var savedRev = new Array(); - for(var rev in this.savedRevisions){ + for (var rev in this.savedRevisions) { savedRev.push(this.savedRevisions[rev].revNum); } savedRev.sort(function(a, b) { @@ -74,8 +71,9 @@ Pad.prototype.getPublicStatus = function getPublicStatus() { }; Pad.prototype.appendRevision = function appendRevision(aChangeset, author) { - if(!author) + if (!author) { author = ''; + } var newAText = Changeset.applyToAText(aChangeset, this.atext, this.pool); Changeset.copyAText(newAText, this.atext); @@ -86,23 +84,24 @@ Pad.prototype.appendRevision = function appendRevision(aChangeset, author) { newRevData.changeset = aChangeset; newRevData.meta = {}; newRevData.meta.author = author; - newRevData.meta.timestamp = new Date().getTime(); + newRevData.meta.timestamp = Date.now(); - //ex. getNumForAuthor - if(author != '') + // ex. getNumForAuthor + if (author != '') { this.pool.putAttrib(['author', author || '']); + } - if(newRev % 100 == 0) - { + if (newRev % 100 == 0) { newRevData.meta.atext = this.atext; } - db.set("pad:"+this.id+":revs:"+newRev, newRevData); + db.set("pad:" + this.id + ":revs:" + newRev, newRevData); this.saveToDatabase(); // set the author to pad - if(author) + if (author) { authorManager.addPad(author, this.id); + } if (this.head == 0) { hooks.callAll("padCreate", {'pad':this, 'author': author}); @@ -111,49 +110,47 @@ Pad.prototype.appendRevision = function appendRevision(aChangeset, author) { } }; -//save all attributes to the database -Pad.prototype.saveToDatabase = function saveToDatabase(){ +// save all attributes to the database +Pad.prototype.saveToDatabase = function saveToDatabase() { var dbObject = {}; - for(var attr in this){ - if(typeof this[attr] === "function") continue; - if(attributeBlackList.indexOf(attr) !== -1) continue; + for (var attr in this) { + if (typeof this[attr] === "function") continue; + if (attributeBlackList.indexOf(attr) !== -1) continue; dbObject[attr] = this[attr]; - if(jsonableList.indexOf(attr) !== -1){ + if (jsonableList.indexOf(attr) !== -1) { dbObject[attr] = dbObject[attr].toJsonable(); } } - db.set("pad:"+this.id, dbObject); + db.set("pad:" + this.id, dbObject); } // get time of last edit (changeset application) -Pad.prototype.getLastEdit = function getLastEdit(callback){ +Pad.prototype.getLastEdit = function getLastEdit() { var revNum = this.getHeadRevisionNumber(); - db.getSub("pad:"+this.id+":revs:"+revNum, ["meta", "timestamp"], callback); + return db.getSub("pad:" + this.id + ":revs:" + revNum, ["meta", "timestamp"]); } -Pad.prototype.getRevisionChangeset = function getRevisionChangeset(revNum, callback) { - db.getSub("pad:"+this.id+":revs:"+revNum, ["changeset"], callback); -}; +Pad.prototype.getRevisionChangeset = function getRevisionChangeset(revNum) { + return db.getSub("pad:" + this.id + ":revs:" + revNum, ["changeset"]); +} -Pad.prototype.getRevisionAuthor = function getRevisionAuthor(revNum, callback) { - db.getSub("pad:"+this.id+":revs:"+revNum, ["meta", "author"], callback); -}; +Pad.prototype.getRevisionAuthor = function getRevisionAuthor(revNum) { + return db.getSub("pad:" + this.id + ":revs:" + revNum, ["meta", "author"]); +} -Pad.prototype.getRevisionDate = function getRevisionDate(revNum, callback) { - db.getSub("pad:"+this.id+":revs:"+revNum, ["meta", "timestamp"], callback); -}; +Pad.prototype.getRevisionDate = function getRevisionDate(revNum) { + return db.getSub("pad:" + this.id + ":revs:" + revNum, ["meta", "timestamp"]); +} Pad.prototype.getAllAuthors = function getAllAuthors() { var authors = []; - for(var key in this.pool.numToAttrib) - { - if(this.pool.numToAttrib[key][0] == "author" && this.pool.numToAttrib[key][1] != "") - { + for(var key in this.pool.numToAttrib) { + if (this.pool.numToAttrib[key][0] == "author" && this.pool.numToAttrib[key][1] != "") { authors.push(this.pool.numToAttrib[key][1]); } } @@ -161,120 +158,77 @@ Pad.prototype.getAllAuthors = function getAllAuthors() { return authors; }; -Pad.prototype.getInternalRevisionAText = function getInternalRevisionAText(targetRev, callback) { - var _this = this; +Pad.prototype.getInternalRevisionAText = async function getInternalRevisionAText(targetRev) { + let keyRev = this.getKeyRevisionNumber(targetRev); - var keyRev = this.getKeyRevisionNumber(targetRev); - var atext; - var changesets = []; - - //find out which changesets are needed - var neededChangesets = []; - var curRev = keyRev; - while (curRev < targetRev) - { - curRev++; - neededChangesets.push(curRev); + // find out which changesets are needed + let neededChangesets = []; + for (let curRev = keyRev; curRev < targetRev; ) { + neededChangesets.push(++curRev); } - async.series([ - //get all needed data out of the database - function(callback) - { - async.parallel([ - //get the atext of the key revision - function (callback) - { - db.getSub("pad:"+_this.id+":revs:"+keyRev, ["meta", "atext"], function(err, _atext) - { - if(ERR(err, callback)) return; - try { - atext = Changeset.cloneAText(_atext); - } catch (e) { - return callback(e); - } + // get all needed data out of the database - callback(); - }); - }, - //get all needed changesets - function (callback) - { - async.forEach(neededChangesets, function(item, callback) - { - _this.getRevisionChangeset(item, function(err, changeset) - { - if(ERR(err, callback)) return; - changesets[item] = changeset; - callback(); - }); - }, callback); - } - ], callback); - }, - //apply all changesets to the key changeset - function(callback) - { - var apool = _this.apool(); - var curRev = keyRev; + // start to get the atext of the key revision + let p_atext = db.getSub("pad:" + this.id + ":revs:" + keyRev, ["meta", "atext"]); - while (curRev < targetRev) - { - curRev++; - var cs = changesets[curRev]; - try{ - atext = Changeset.applyToAText(cs, atext, apool); - }catch(e) { - return callback(e) - } - } - - callback(null); - } - ], function(err) - { - if(ERR(err, callback)) return; - callback(null, atext); - }); -}; - -Pad.prototype.getRevision = function getRevisionChangeset(revNum, callback) { - db.get("pad:"+this.id+":revs:"+revNum, callback); -}; - -Pad.prototype.getAllAuthorColors = function getAllAuthorColors(callback){ - var authors = this.getAllAuthors(); - var returnTable = {}; - var colorPalette = authorManager.getColorPalette(); - - async.forEach(authors, function(author, callback){ - authorManager.getAuthorColorId(author, function(err, colorId){ - if(err){ - return callback(err); - } - //colorId might be a hex color or an number out of the palette - returnTable[author]=colorPalette[colorId] || colorId; - - callback(); + // get all needed changesets + let changesets = []; + await Promise.all(neededChangesets.map(item => { + return this.getRevisionChangeset(item).then(changeset => { + changesets[item] = changeset; }); - }, function(err){ - callback(err, returnTable); - }); -}; + })); + + // we should have the atext by now + let atext = await p_atext; + atext = Changeset.cloneAText(atext); + + // apply all changesets to the key changeset + let apool = this.apool(); + for (let curRev = keyRev; curRev < targetRev; ) { + let cs = changesets[++curRev]; + atext = Changeset.applyToAText(cs, atext, apool); + } + + return atext; +} + +Pad.prototype.getRevision = function getRevisionChangeset(revNum) { + return db.get("pad:" + this.id + ":revs:" + revNum); +} + +Pad.prototype.getAllAuthorColors = async function getAllAuthorColors() { + let authors = this.getAllAuthors(); + let returnTable = {}; + let colorPalette = authorManager.getColorPalette(); + + await Promise.all(authors.map(author => { + return authorManager.getAuthorColorId(author).then(colorId => { + // colorId might be a hex color or an number out of the palette + returnTable[author] = colorPalette[colorId] || colorId; + }); + })); + + return returnTable; +} Pad.prototype.getValidRevisionRange = function getValidRevisionRange(startRev, endRev) { startRev = parseInt(startRev, 10); var head = this.getHeadRevisionNumber(); endRev = endRev ? parseInt(endRev, 10) : head; - if(isNaN(startRev) || startRev < 0 || startRev > head) { + + if (isNaN(startRev) || startRev < 0 || startRev > head) { startRev = null; } - if(isNaN(endRev) || endRev < startRev) { + + if (isNaN(endRev) || endRev < startRev) { endRev = null; - } else if(endRev > head) { + } else if (endRev > head) { endRev = head; } - if(startRev !== null && endRev !== null) { + + if (startRev !== null && endRev !== null) { return { startRev: startRev , endRev: endRev } } return null; @@ -289,12 +243,12 @@ Pad.prototype.text = function text() { }; Pad.prototype.setText = function setText(newText) { - //clean the new text + // clean the new text newText = exports.cleanText(newText); var oldText = this.text(); - //create the changeset + // create the changeset // We want to ensure the pad still ends with a \n, but otherwise keep // getText() and setText() consistent. var changeset; @@ -304,165 +258,112 @@ Pad.prototype.setText = function setText(newText) { changeset = Changeset.makeSplice(oldText, 0, oldText.length-1, newText); } - //append the changeset + // append the changeset this.appendRevision(changeset); }; Pad.prototype.appendText = function appendText(newText) { - //clean the new text + // clean the new text newText = exports.cleanText(newText); var oldText = this.text(); - //create the changeset + // create the changeset var changeset = Changeset.makeSplice(oldText, oldText.length, 0, newText); - //append the changeset + // append the changeset this.appendRevision(changeset); }; Pad.prototype.appendChatMessage = function appendChatMessage(text, userId, time) { this.chatHead++; - //save the chat entry in the database - db.set("pad:"+this.id+":chat:"+this.chatHead, {"text": text, "userId": userId, "time": time}); + // save the chat entry in the database + db.set("pad:" + this.id + ":chat:" + this.chatHead, { "text": text, "userId": userId, "time": time }); this.saveToDatabase(); }; -Pad.prototype.getChatMessage = function getChatMessage(entryNum, callback) { - var _this = this; - var entry; +Pad.prototype.getChatMessage = async function getChatMessage(entryNum) { + // get the chat entry + let entry = await db.get("pad:" + this.id + ":chat:" + entryNum); - async.series([ - //get the chat entry - function(callback) - { - db.get("pad:"+_this.id+":chat:"+entryNum, function(err, _entry) - { - if(ERR(err, callback)) return; - entry = _entry; - callback(); - }); - }, - //add the authorName - function(callback) - { - //this chat message doesn't exist, return null - if(entry == null) - { - callback(); - return; - } - - //get the authorName - authorManager.getAuthorName(entry.userId, function(err, authorName) - { - if(ERR(err, callback)) return; - entry.userName = authorName; - callback(); - }); - } - ], function(err) - { - if(ERR(err, callback)) return; - callback(null, entry); - }); -}; - -Pad.prototype.getChatMessages = function getChatMessages(start, end, callback) { - //collect the numbers of chat entries and in which order we need them - var neededEntries = []; - var order = 0; - for(var i=start;i<=end; i++) - { - neededEntries.push({entryNum:i, order: order}); - order++; + // get the authorName if the entry exists + if (entry != null) { + entry.userName = await authorManager.getAuthorName(entry.userId); } - var _this = this; - - //get all entries out of the database - var entries = []; - async.forEach(neededEntries, function(entryObject, callback) - { - _this.getChatMessage(entryObject.entryNum, function(err, entry) - { - if(ERR(err, callback)) return; - entries[entryObject.order] = entry; - callback(); - }); - }, function(err) - { - if(ERR(err, callback)) return; - - //sort out broken chat entries - //it looks like in happend in the past that the chat head was - //incremented, but the chat message wasn't added - var cleanedEntries = []; - for(var i=0;i { + return this.getChatMessage(entryObject.entryNum).then(entry => { + entries[entryObject.order] = entry; + }); + })); + + // sort out broken chat entries + // it looks like in happened in the past that the chat head was + // incremented, but the chat message wasn't added + let cleanedEntries = entries.filter(entry => { + let pass = (entry != null); + if (!pass) { + console.warn("WARNING: Found broken chat entry in pad " + this.id); + } + return pass; + }); + + return cleanedEntries; +} + +Pad.prototype.init = async function init(text) { + + // replace text with default text if text isn't set + if (text == null) { text = settings.defaultPadText; } - //try to load the pad - db.get("pad:"+this.id, function(err, value) - { - if(ERR(err, callback)) return; + // try to load the pad + let value = await db.get("pad:" + this.id); - //if this pad exists, load it - if(value != null) - { - //copy all attr. To a transfrom via fromJsonable if necassary - for(var attr in value){ - if(jsonableList.indexOf(attr) !== -1){ - _this[attr] = _this[attr].fromJsonable(value[attr]); - } else { - _this[attr] = value[attr]; - } + // if this pad exists, load it + if (value != null) { + // copy all attr. To a transfrom via fromJsonable if necassary + for (var attr in value) { + if (jsonableList.indexOf(attr) !== -1) { + this[attr] = this[attr].fromJsonable(value[attr]); + } else { + this[attr] = value[attr]; } } - //this pad doesn't exist, so create it - else - { - var firstChangeset = Changeset.makeSplice("\n", 0, 0, exports.cleanText(text)); + } else { + // this pad doesn't exist, so create it + let firstChangeset = Changeset.makeSplice("\n", 0, 0, exports.cleanText(text)); - _this.appendRevision(firstChangeset, ''); - } - - hooks.callAll("padLoad", {'pad':_this}); - callback(null); - }); -}; - -Pad.prototype.copy = function copy(destinationID, force, callback) { - var sourceID = this.id; - var _this = this; - var destGroupID; - - // make force optional - if (typeof force == "function") { - callback = force; - force = false; + this.appendRevision(firstChangeset, ''); } - else if (force == undefined || force.toLowerCase() != "true") { - force = false; + + hooks.callAll("padLoad", { 'pad': this }); +} + +Pad.prototype.copy = async function copy(destinationID, force) { + + let sourceID = this.id; + + // allow force to be a string + if (typeof force === "string") { + force = (force.toLowerCase() === "true"); + } else { + force = !!force; } - else force = true; // Kick everyone from this pad. // This was commented due to https://github.com/ether/etherpad-lite/issues/3183. @@ -470,247 +371,137 @@ Pad.prototype.copy = function copy(destinationID, force, callback) { // padMessageHandler.kickSessionsFromPad(sourceID); // flush the source pad: - _this.saveToDatabase(); + this.saveToDatabase(); - async.series([ - // if it's a group pad, let's make sure the group exists. - function(callback) - { - if (destinationID.indexOf("$") === -1) - { - callback(); - return; - } + // if it's a group pad, let's make sure the group exists. + let destGroupID; + if (destinationID.indexOf("$") >= 0) { - destGroupID = destinationID.split("$")[0] - groupManager.doesGroupExist(destGroupID, function (err, exists) - { - if(ERR(err, callback)) return; + destGroupID = destinationID.split("$")[0] + let groupExists = await groupManager.doesGroupExist(destGroupID); - //group does not exist - if(exists == false) - { - callback(new customError("groupID does not exist for destinationID","apierror")); - return; - } - - //everything is fine, continue - callback(); - }); - }, - // if the pad exists, we should abort, unless forced. - function(callback) - { - padManager.doesPadExists(destinationID, function (err, exists) - { - if(ERR(err, callback)) return; - - /* - * this is the negation of a truthy comparison. Has been left in this - * wonky state to keep the old (possibly buggy) behaviour - */ - if (!(exists == true)) - { - callback(); - return; - } - - if (!force) - { - console.error("erroring out without force"); - callback(new customError("destinationID already exists","apierror")); - console.error("erroring out without force - after"); - return; - } - - // exists and forcing - padManager.getPad(destinationID, function(err, pad) { - if (ERR(err, callback)) return; - pad.remove(callback); - }); - }); - }, - // copy the 'pad' entry - function(callback) - { - db.get("pad:"+sourceID, function(err, pad) { - db.set("pad:"+destinationID, pad); - }); - - callback(); - }, - //copy all relations - function(callback) - { - async.parallel([ - //copy all chat messages - function(callback) - { - var chatHead = _this.chatHead; - - for(var i=0;i<=chatHead;i++) - { - db.get("pad:"+sourceID+":chat:"+i, function (err, chat) { - if (ERR(err, callback)) return; - db.set("pad:"+destinationID+":chat:"+i, chat); - }); - } - - callback(); - }, - //copy all revisions - function(callback) - { - var revHead = _this.head; - for(var i=0;i<=revHead;i++) - { - db.get("pad:"+sourceID+":revs:"+i, function (err, rev) { - if (ERR(err, callback)) return; - db.set("pad:"+destinationID+":revs:"+i, rev); - }); - } - - callback(); - }, - //add the new pad to all authors who contributed to the old one - function(callback) - { - var authorIDs = _this.getAllAuthors(); - authorIDs.forEach(function (authorID) - { - authorManager.addPad(authorID, destinationID); - }); - - callback(); - }, - // parallel - ], callback); - }, - function(callback) { - // Group pad? Add it to the group's list - if(destGroupID) db.setSub("group:" + destGroupID, ["pads", destinationID], 1); - - // Initialize the new pad (will update the listAllPads cache) - setTimeout(function(){ - padManager.getPad(destinationID, null, callback) // this runs too early. - },10); - }, - // let the plugins know the pad was copied - function(callback) { - hooks.callAll('padCopy', { 'originalPad': _this, 'destinationID': destinationID }); - callback(); + // group does not exist + if (!groupExists) { + throw new customError("groupID does not exist for destinationID", "apierror"); } - // series - ], function(err) - { - if(ERR(err, callback)) return; - callback(null, {padID: destinationID}); + } + + // if the pad exists, we should abort, unless forced. + let exists = await padManager.doesPadExist(destinationID); + + if (exists) { + if (!force) { + console.error("erroring out without force"); + throw new customError("destinationID already exists", "apierror"); + } + + // exists and forcing + let pad = await padManager.getPad(destinationID); + await pad.remove(); + } + + // copy the 'pad' entry + let pad = await db.get("pad:" + sourceID); + db.set("pad:" + destinationID, pad); + + // copy all relations in parallel + let promises = []; + + // copy all chat messages + let chatHead = this.chatHead; + for (let i = 0; i <= chatHead; ++i) { + let p = db.get("pad:" + sourceID + ":chat:" + i).then(chat => { + return db.set("pad:" + destinationID + ":chat:" + i, chat); + }); + promises.push(p); + } + + // copy all revisions + let revHead = this.head; + for (let i = 0; i <= revHead; ++i) { + let p = db.get("pad:" + sourceID + ":revs:" + i).then(rev => { + return db.set("pad:" + destinationID + ":revs:" + i, rev); + }); + promises.push(p); + } + + // add the new pad to all authors who contributed to the old one + this.getAllAuthors().forEach(authorID => { + authorManager.addPad(authorID, destinationID); }); -}; -Pad.prototype.remove = function remove(callback) { + // wait for the above to complete + await Promise.all(promises); + + // Group pad? Add it to the group's list + if (destGroupID) { + await db.setSub("group:" + destGroupID, ["pads", destinationID], 1); + } + + // delay still necessary? + await new Promise(resolve => setTimeout(resolve, 10)); + + // Initialize the new pad (will update the listAllPads cache) + await padManager.getPad(destinationID, null); // this runs too early. + + // let the plugins know the pad was copied + hooks.callAll('padCopy', { 'originalPad': this, 'destinationID': destinationID }); + + return { padID: destinationID }; +} + +Pad.prototype.remove = async function remove() { var padID = this.id; - var _this = this; - //kick everyone from this pad + // kick everyone from this pad padMessageHandler.kickSessionsFromPad(padID); - async.series([ - //delete all relations - function(callback) - { - async.parallel([ - //is it a group pad? -> delete the entry of this pad in the group - function(callback) - { - if(padID.indexOf("$") === -1) - { - // it isn't a group pad, nothing to do here - callback(); - return; - } + // delete all relations - the original code used async.parallel but + // none of the operations except getting the group depended on callbacks + // so the database operations here are just started and then left to + // run to completion - // it is a group pad - var groupID = padID.substring(0,padID.indexOf("$")); + // is it a group pad? -> delete the entry of this pad in the group + if (padID.indexOf("$") >= 0) { - db.get("group:" + groupID, function (err, group) - { - if(ERR(err, callback)) return; + // it is a group pad + let groupID = padID.substring(0, padID.indexOf("$")); + let group = await db.get("group:" + groupID); - //remove the pad entry - delete group.pads[padID]; + // remove the pad entry + delete group.pads[padID]; - //set the new value - db.set("group:" + groupID, group); + // set the new value + db.set("group:" + groupID, group); + } - callback(); - }); - }, - //remove the readonly entries - function(callback) - { - readOnlyManager.getReadOnlyId(padID, function(err, readonlyID) - { - if(ERR(err, callback)) return; + // remove the readonly entries + let readonlyID = readOnlyManager.getReadOnlyId(padID); - db.remove("pad2readonly:" + padID); - db.remove("readonly2pad:" + readonlyID); + db.remove("pad2readonly:" + padID); + db.remove("readonly2pad:" + readonlyID); - callback(); - }); - }, - //delete all chat messages - function(callback) - { - var chatHead = _this.chatHead; + // delete all chat messages + for (let i = 0, n = this.chatHead; i <= n; ++i) { + db.remove("pad:" + padID + ":chat:" + i); + } - for(var i=0;i<=chatHead;i++) - { - db.remove("pad:"+padID+":chat:"+i); - } + // delete all revisions + for (let i = 0, n = this.head; i <= n; ++i) { + db.remove("pad:" + padID + ":revs:" + i); + } - callback(); - }, - //delete all revisions - function(callback) - { - var revHead = _this.head; - - for(var i=0;i<=revHead;i++) - { - db.remove("pad:"+padID+":revs:"+i); - } - - callback(); - }, - //remove pad from all authors who contributed - function(callback) - { - var authorIDs = _this.getAllAuthors(); - - authorIDs.forEach(function (authorID) - { - authorManager.removePad(authorID, padID); - }); - - callback(); - } - ], callback); - }, - //delete the pad entry and delete pad from padManager - function(callback) - { - padManager.removePad(padID); - hooks.callAll("padRemove", {'padID':padID}); - callback(); - } - ], function(err) - { - if(ERR(err, callback)) return; - callback(); + // remove pad from all authors who contributed + this.getAllAuthors().forEach(authorID => { + authorManager.removePad(authorID, padID); }); -}; - //set in db + + // delete the pad entry and delete pad from padManager + padManager.removePad(padID); + hooks.callAll("padRemove", { padID }); +} + +// set in db Pad.prototype.setPublicStatus = function setPublicStatus(publicStatus) { this.publicStatus = publicStatus; this.saveToDatabase(); @@ -730,22 +521,22 @@ Pad.prototype.isPasswordProtected = function isPasswordProtected() { }; Pad.prototype.addSavedRevision = function addSavedRevision(revNum, savedById, label) { - //if this revision is already saved, return silently - for(var i in this.savedRevisions){ - if(this.savedRevisions[i] && this.savedRevisions[i].revNum === revNum){ + // if this revision is already saved, return silently + for (var i in this.savedRevisions) { + if (this.savedRevisions[i] && this.savedRevisions[i].revNum === revNum) { return; } } - //build the saved revision object + // build the saved revision object var savedRevision = {}; savedRevision.revNum = revNum; savedRevision.savedById = savedById; savedRevision.label = label || "Revision " + revNum; - savedRevision.timestamp = new Date().getTime(); + savedRevision.timestamp = Date.now(); savedRevision.id = randomString(10); - //save this new saved revision + // save this new saved revision this.savedRevisions.push(savedRevision); this.saveToDatabase(); }; @@ -756,19 +547,17 @@ Pad.prototype.getSavedRevisions = function getSavedRevisions() { /* Crypto helper methods */ -function hash(password, salt) -{ +function hash(password, salt) { var shasum = crypto.createHash('sha512'); shasum.update(password + salt); + return shasum.digest("hex") + "$" + salt; } -function generateSalt() -{ +function generateSalt() { return randomString(86); } -function compare(hashStr, password) -{ +function compare(hashStr, password) { return hash(password, hashStr.split("$")[1]) === hashStr; } diff --git a/src/node/db/PadManager.js b/src/node/db/PadManager.js index 035ef3e5e..23164a7a9 100644 --- a/src/node/db/PadManager.js +++ b/src/node/db/PadManager.js @@ -18,12 +18,11 @@ * limitations under the License. */ -var ERR = require("async-stacktrace"); var customError = require("../utils/customError"); var Pad = require("../db/Pad").Pad; -var db = require("./DB").db; +var db = require("./DB"); -/** +/** * A cache of all loaded Pads. * * Provides "get" and "set" functions, @@ -35,12 +34,11 @@ var db = require("./DB").db; * that's defined somewhere more sensible. */ var globalPads = { - get: function (name) { return this[':'+name]; }, - set: function (name, value) - { + get: function(name) { return this[':'+name]; }, + set: function(name, value) { this[':'+name] = value; }, - remove: function (name) { + remove: function(name) { delete this[':'+name]; } }; @@ -50,183 +48,151 @@ var globalPads = { * * Updated without db access as new pads are created/old ones removed. */ -var padList = { +let padList = { list: [], sorted : false, initiated: false, - init: function(cb) - { - db.findKeys("pad:*", "*:*:*", function(err, dbData) - { - if(ERR(err, cb)) return; - if(dbData != null){ - padList.initiated = true - dbData.forEach(function(val){ - padList.addPad(val.replace(/pad:/,""),false); - }); - cb && cb() + init: async function() { + let dbData = await db.findKeys("pad:*", "*:*:*"); + + if (dbData != null) { + this.initiated = true; + + for (let val of dbData) { + this.addPad(val.replace(/pad:/,""), false); } - }); + } + return this; }, - load: function(cb) { - if(this.initiated) cb && cb() - else this.init(cb) + load: async function() { + if (!this.initiated) { + return this.init(); + } + + return this; }, /** * Returns all pads in alphabetical order as array. */ - getPads: function(cb){ - this.load(function() { - if(!padList.sorted){ - padList.list = padList.list.sort(); - padList.sorted = true; - } - cb && cb(padList.list); - }) + getPads: async function() { + await this.load(); + + if (!this.sorted) { + this.list.sort(); + this.sorted = true; + } + + return this.list; }, - addPad: function(name) - { - if(!this.initiated) return; - if(this.list.indexOf(name) == -1){ + addPad: function(name) { + if (!this.initiated) return; + + if (this.list.indexOf(name) == -1) { this.list.push(name); - this.sorted=false; + this.sorted = false; } }, - removePad: function(name) - { - if(!this.initiated) return; + removePad: function(name) { + if (!this.initiated) return; + var index = this.list.indexOf(name); - if(index>-1){ - this.list.splice(index,1); - this.sorted=false; + + if (index > -1) { + this.list.splice(index, 1); + this.sorted = false; } } }; -//initialises the allknowing data structure + +// initialises the all-knowing data structure + +/** + * Returns a Pad Object with the callback + * @param id A String with the id of the pad + * @param {Function} callback + */ +exports.getPad = async function(id, text) +{ + // check if this is a valid padId + if (!exports.isValidPadId(id)) { + throw new customError(id + " is not a valid padId", "apierror"); + } + + // check if this is a valid text + if (text != null) { + // check if text is a string + if (typeof text != "string") { + throw new customError("text is not a string", "apierror"); + } + + // check if text is less than 100k chars + if (text.length > 100000) { + throw new customError("text must be less than 100k chars", "apierror"); + } + } + + let pad = globalPads.get(id); + + // return pad if it's already loaded + if (pad != null) { + return pad; + } + + // try to load pad + pad = new Pad(id); + + // initalize the pad + await pad.init(text); + globalPads.set(id, pad); + padList.addPad(id); + + return pad; +} + +exports.listAllPads = async function() +{ + let padIDs = await padList.getPads(); + + return { padIDs }; +} + +// checks if a pad exists +exports.doesPadExist = async function(padId) +{ + let value = await db.get("pad:" + padId); + + return (value != null && value.atext); +} + +// alias for backwards compatibility +exports.doesPadExists = exports.doesPadExist; /** * An array of padId transformations. These represent changes in pad name policy over * time, and allow us to "play back" these changes so legacy padIds can be found. */ -var padIdTransforms = [ +const padIdTransforms = [ [/\s+/g, '_'], [/:+/g, '_'] ]; -/** - * Returns a Pad Object with the callback - * @param id A String with the id of the pad - * @param {Function} callback - */ -exports.getPad = function(id, text, callback) -{ - //check if this is a valid padId - if(!exports.isValidPadId(id)) - { - callback(new customError(id + " is not a valid padId","apierror")); - return; - } - - //make text an optional parameter - if(typeof text == "function") - { - callback = text; - text = null; - } - - //check if this is a valid text - if(text != null) - { - //check if text is a string - if(typeof text != "string") - { - callback(new customError("text is not a string","apierror")); - return; +// returns a sanitized padId, respecting legacy pad id formats +exports.sanitizePadId = async function sanitizePadId(padId) { + for (let i = 0, n = padIdTransforms.length; i < n; ++i) { + let exists = await exports.doesPadExist(padId); + + if (exists) { + return padId; } - - //check if text is less than 100k chars - if(text.length > 100000) - { - callback(new customError("text must be less than 100k chars","apierror")); - return; - } - } - - var pad = globalPads.get(id); - - //return pad if its already loaded - if(pad != null) - { - callback(null, pad); - return; + + let [from, to] = padIdTransforms[i]; + + padId = padId.replace(from, to); } - //try to load pad - pad = new Pad(id); - - //initalize the pad - pad.init(text, function(err) - { - if(ERR(err, callback)) return; - globalPads.set(id, pad); - padList.addPad(id); - callback(null, pad); - }); -} - -exports.listAllPads = function(cb) -{ - padList.getPads(function(list) { - cb && cb(null, {padIDs: list}); - }); -} - -//checks if a pad exists -exports.doesPadExists = function(padId, callback) -{ - db.get("pad:"+padId, function(err, value) - { - if(ERR(err, callback)) return; - if(value != null && value.atext){ - callback(null, true); - } - else - { - callback(null, false); - } - }); -} - -//returns a sanitized padId, respecting legacy pad id formats -exports.sanitizePadId = function(padId, callback) { - var transform_index = arguments[2] || 0; - //we're out of possible transformations, so just return it - if(transform_index >= padIdTransforms.length) - { - callback(padId); - return; - } - - //check if padId exists - exports.doesPadExists(padId, function(junk, exists) - { - if(exists) - { - callback(padId); - return; - } - - //get the next transformation *that's different* - var transformedPadId = padId; - while(transformedPadId == padId && transform_index < padIdTransforms.length) - { - transformedPadId = padId.replace(padIdTransforms[transform_index][0], padIdTransforms[transform_index][1]); - transform_index += 1; - } - //check the next transform - exports.sanitizePadId(transformedPadId, callback, transform_index); - }); + // we're out of possible transformations, so just return it + return padId; } exports.isValidPadId = function(padId) @@ -237,13 +203,13 @@ exports.isValidPadId = function(padId) /** * Removes the pad from database and unloads it. */ -exports.removePad = function(padId){ - db.remove("pad:"+padId); +exports.removePad = function(padId) { + db.remove("pad:" + padId); exports.unloadPad(padId); padList.removePad(padId); } -//removes a pad from the cache +// removes a pad from the cache exports.unloadPad = function(padId) { globalPads.remove(padId); diff --git a/src/node/db/ReadOnlyManager.js b/src/node/db/ReadOnlyManager.js index f49f71e23..96a52d479 100644 --- a/src/node/db/ReadOnlyManager.js +++ b/src/node/db/ReadOnlyManager.js @@ -19,80 +19,47 @@ */ -var ERR = require("async-stacktrace"); -var db = require("./DB").db; -var async = require("async"); +var db = require("./DB"); var randomString = require("../utils/randomstring"); /** * returns a read only id for a pad * @param {String} padId the id of the pad */ -exports.getReadOnlyId = function (padId, callback) -{ - var readOnlyId; - - async.waterfall([ - //check if there is a pad2readonly entry - function(callback) - { - db.get("pad2readonly:" + padId, callback); - }, - function(dbReadOnlyId, callback) - { - //there is no readOnly Entry in the database, let's create one - if(dbReadOnlyId == null) - { - readOnlyId = "r." + randomString(16); - - db.set("pad2readonly:" + padId, readOnlyId); - db.set("readonly2pad:" + readOnlyId, padId); - } - //there is a readOnly Entry in the database, let's take this one - else - { - readOnlyId = dbReadOnlyId; - } - - callback(); - } - ], function(err) - { - if(ERR(err, callback)) return; - //return the results - callback(null, readOnlyId); - }) +exports.getReadOnlyId = async function (padId) +{ + // check if there is a pad2readonly entry + let readOnlyId = await db.get("pad2readonly:" + padId); + + // there is no readOnly Entry in the database, let's create one + if (readOnlyId == null) { + readOnlyId = "r." + randomString(16); + db.set("pad2readonly:" + padId, readOnlyId); + db.set("readonly2pad:" + readOnlyId, padId); + } + + return readOnlyId; } /** - * returns a the padId for a read only id + * returns the padId for a read only id * @param {String} readOnlyId read only id */ -exports.getPadId = function(readOnlyId, callback) +exports.getPadId = function(readOnlyId) { - db.get("readonly2pad:" + readOnlyId, callback); + return db.get("readonly2pad:" + readOnlyId); } /** - * returns a the padId and readonlyPadId in an object for any id + * returns the padId and readonlyPadId in an object for any id * @param {String} padIdOrReadonlyPadId read only id or real pad id */ -exports.getIds = function(id, callback) { - if (id.indexOf("r.") == 0) - exports.getPadId(id, function (err, value) { - if(ERR(err, callback)) return; - callback(null, { - readOnlyPadId: id, - padId: value, // Might be null, if this is an unknown read-only id - readonly: true - }); - }); - else - exports.getReadOnlyId(id, function (err, value) { - callback(null, { - readOnlyPadId: value, - padId: id, - readonly: false - }); - }); +exports.getIds = async function(id) { + let readonly = (id.indexOf("r.") === 0); + + // Might be null, if this is an unknown read-only id + let readOnlyPadId = readonly ? id : await exports.getReadOnlyId(id); + let padId = readonly ? await exports.getPadId(id) : id; + + return { readOnlyPadId, padId, readonly }; } diff --git a/src/node/db/SecurityManager.js b/src/node/db/SecurityManager.js index f930b9618..23af82836 100644 --- a/src/node/db/SecurityManager.js +++ b/src/node/db/SecurityManager.js @@ -18,9 +18,6 @@ * limitations under the License. */ - -var ERR = require("async-stacktrace"); -var async = require("async"); var authorManager = require("./AuthorManager"); var hooks = require("ep_etherpad-lite/static/js/pluginfw/hooks.js"); var padManager = require("./PadManager"); @@ -34,296 +31,231 @@ var authLogger = log4js.getLogger("auth"); * @param padID the pad the user wants to access * @param sessionCookie the session the user has (set via api) * @param token the token of the author (randomly generated at client side, used for public pads) - * @param password the password the user has given to access this pad, can be null - * @param callback will be called with (err, {accessStatus: grant|deny|wrongPassword|needPassword, authorID: a.xxxxxx}) - */ -exports.checkAccess = function (padID, sessionCookie, token, password, callback) -{ - var statusObject; - - if(!padID) { - callback(null, {accessStatus: "deny"}); - return; + * @param password the password the user has given to access this pad, can be null + * @return {accessStatus: grant|deny|wrongPassword|needPassword, authorID: a.xxxxxx}) + */ +exports.checkAccess = async function(padID, sessionCookie, token, password) +{ + // immutable object + let deny = Object.freeze({ accessStatus: "deny" }); + + if (!padID) { + return deny; } // allow plugins to deny access var deniedByHook = hooks.callAll("onAccessCheck", {'padID': padID, 'password': password, 'token': token, 'sessionCookie': sessionCookie}).indexOf(false) > -1; - if(deniedByHook) - { - callback(null, {accessStatus: "deny"}); - return; + if (deniedByHook) { + return deny; } - // a valid session is required (api-only mode) - if(settings.requireSession) - { - // without sessionCookie, access is denied - if(!sessionCookie) - { - callback(null, {accessStatus: "deny"}); - return; + // start to get author for this token + let p_tokenAuthor = authorManager.getAuthor4Token(token); + + // start to check if pad exists + let p_padExists = padManager.doesPadExist(padID); + + if (settings.requireSession) { + // a valid session is required (api-only mode) + if (!sessionCookie) { + // without sessionCookie, access is denied + return deny; } - } - // a session is not required, so we'll check if it's a public pad - else - { - // it's not a group pad, means we can grant access - if(padID.indexOf("$") == -1) - { - //get author for this token - authorManager.getAuthor4Token(token, function(err, author) - { - if(ERR(err, callback)) return; - - // assume user has access - statusObject = {accessStatus: "grant", authorID: author}; + } else { + // a session is not required, so we'll check if it's a public pad + if (padID.indexOf("$") === -1) { + // it's not a group pad, means we can grant access + + // assume user has access + let authorID = await p_tokenAuthor; + let statusObject = { accessStatus: "grant", authorID }; + + if (settings.editOnly) { // user can't create pads - if(settings.editOnly) - { - // check if pad exists - padManager.doesPadExists(padID, function(err, exists) - { - if(ERR(err, callback)) return; - - // pad doesn't exist - user can't have access - if(!exists) statusObject.accessStatus = "deny"; - // grant or deny access, with author of token - callback(null, statusObject); - }); - return; - } + let padExists = await p_padExists; - // user may create new pads - no need to check anything - // grant access, with author of token - callback(null, statusObject); - }); - - //don't continue - return; - } - } - - var groupID = padID.split("$")[0]; - var padExists = false; - var validSession = false; - var sessionAuthor; - var tokenAuthor; - var isPublic; - var isPasswordProtected; - var passwordStatus = password == null ? "notGiven" : "wrong"; // notGiven, correct, wrong - - async.series([ - //get basic informations from the database - function(callback) - { - async.parallel([ - //does pad exists - function(callback) - { - padManager.doesPadExists(padID, function(err, exists) - { - if(ERR(err, callback)) return; - padExists = exists; - callback(); - }); - }, - //get information about all sessions contained in this cookie - function(callback) - { - if (!sessionCookie) - { - callback(); - return; - } - - var sessionIDs = sessionCookie.split(','); - async.forEach(sessionIDs, function(sessionID, callback) - { - sessionManager.getSessionInfo(sessionID, function(err, sessionInfo) - { - //skip session if it doesn't exist - if(err && err.message == "sessionID does not exist") - { - authLogger.debug("Auth failed: unknown session"); - callback(); - return; - } - - if(ERR(err, callback)) return; - - var now = Math.floor(new Date().getTime()/1000); - - //is it for this group? - if(sessionInfo.groupID != groupID) - { - authLogger.debug("Auth failed: wrong group"); - callback(); - return; - } - - //is validUntil still ok? - if(sessionInfo.validUntil <= now) - { - authLogger.debug("Auth failed: validUntil"); - callback(); - return; - } - - // There is a valid session - validSession = true; - sessionAuthor = sessionInfo.authorID; - - callback(); - }); - }, callback); - }, - //get author for token - function(callback) - { - //get author for this token - authorManager.getAuthor4Token(token, function(err, author) - { - if(ERR(err, callback)) return; - tokenAuthor = author; - callback(); - }); - } - ], callback); - }, - //get more informations of this pad, if avaiable - function(callback) - { - //skip this if the pad doesn't exists - if(padExists == false) - { - callback(); - return; - } - - padManager.getPad(padID, function(err, pad) - { - if(ERR(err, callback)) return; - - //is it a public pad? - isPublic = pad.getPublicStatus(); - - //is it password protected? - isPasswordProtected = pad.isPasswordProtected(); - - //is password correct? - if(isPasswordProtected && password && pad.isCorrectPassword(password)) - { - passwordStatus = "correct"; - } - - callback(); - }); - }, - function(callback) - { - //- a valid session for this group is avaible AND pad exists - if(validSession && padExists) - { - //- the pad is not password protected - if(!isPasswordProtected) - { - //--> grant access - statusObject = {accessStatus: "grant", authorID: sessionAuthor}; - } - //- the setting to bypass password validation is set - else if(settings.sessionNoPassword) - { - //--> grant access - statusObject = {accessStatus: "grant", authorID: sessionAuthor}; - } - //- the pad is password protected and password is correct - else if(isPasswordProtected && passwordStatus == "correct") - { - //--> grant access - statusObject = {accessStatus: "grant", authorID: sessionAuthor}; - } - //- the pad is password protected but wrong password given - else if(isPasswordProtected && passwordStatus == "wrong") - { - //--> deny access, ask for new password and tell them that the password is wrong - statusObject = {accessStatus: "wrongPassword"}; - } - //- the pad is password protected but no password given - else if(isPasswordProtected && passwordStatus == "notGiven") - { - //--> ask for password - statusObject = {accessStatus: "needPassword"}; - } - else - { - throw new Error("Ops, something wrong happend"); - } - } - //- a valid session for this group avaible but pad doesn't exists - else if(validSession && !padExists) - { - //--> grant access - statusObject = {accessStatus: "grant", authorID: sessionAuthor}; - //--> deny access if user isn't allowed to create the pad - if(settings.editOnly) - { - authLogger.debug("Auth failed: valid session & pad does not exist"); + if (!padExists) { + // pad doesn't exist - user can't have access statusObject.accessStatus = "deny"; } } - // there is no valid session avaiable AND pad exists - else if(!validSession && padExists) - { - //-- its public and not password protected - if(isPublic && !isPasswordProtected) - { - //--> grant access, with author of token - statusObject = {accessStatus: "grant", authorID: tokenAuthor}; - } - //- its public and password protected and password is correct - else if(isPublic && isPasswordProtected && passwordStatus == "correct") - { - //--> grant access, with author of token - statusObject = {accessStatus: "grant", authorID: tokenAuthor}; - } - //- its public and the pad is password protected but wrong password given - else if(isPublic && isPasswordProtected && passwordStatus == "wrong") - { - //--> deny access, ask for new password and tell them that the password is wrong - statusObject = {accessStatus: "wrongPassword"}; - } - //- its public and the pad is password protected but no password given - else if(isPublic && isPasswordProtected && passwordStatus == "notGiven") - { - //--> ask for password - statusObject = {accessStatus: "needPassword"}; - } - //- its not public - else if(!isPublic) - { - authLogger.debug("Auth failed: invalid session & pad is not public"); - //--> deny access - statusObject = {accessStatus: "deny"}; - } - else - { - throw new Error("Ops, something wrong happend"); - } - } - // there is no valid session avaiable AND pad doesn't exists - else - { - authLogger.debug("Auth failed: invalid session & pad does not exist"); - //--> deny access - statusObject = {accessStatus: "deny"}; - } - - callback(); + + // user may create new pads - no need to check anything + // grant access, with author of token + return statusObject; } - ], function(err) - { - if(ERR(err, callback)) return; - callback(null, statusObject); - }); -}; + } + + let validSession = false; + let sessionAuthor; + let isPublic; + let isPasswordProtected; + let passwordStatus = password == null ? "notGiven" : "wrong"; // notGiven, correct, wrong + + // get information about all sessions contained in this cookie + if (sessionCookie) { + let groupID = padID.split("$")[0]; + let sessionIDs = sessionCookie.split(','); + + // was previously iterated in parallel using async.forEach + let sessionInfos = await Promise.all(sessionIDs.map(sessionID => { + return sessionManager.getSessionInfo(sessionID); + })); + + // seperated out the iteration of sessioninfos from the (parallel) fetches from the DB + for (let sessionInfo of sessionInfos) { + try { + // is it for this group? + if (sessionInfo.groupID != groupID) { + authLogger.debug("Auth failed: wrong group"); + continue; + } + + // is validUntil still ok? + let now = Math.floor(Date.now() / 1000); + if (sessionInfo.validUntil <= now) { + authLogger.debug("Auth failed: validUntil"); + continue; + } + + // fall-through - there is a valid session + validSession = true; + sessionAuthor = sessionInfo.authorID; + break; + } catch (err) { + // skip session if it doesn't exist + if (err.message == "sessionID does not exist") { + authLogger.debug("Auth failed: unknown session"); + } else { + throw err; + } + } + } + } + + let padExists = await p_padExists; + + if (padExists) { + let pad = await padManager.getPad(padID); + + // is it a public pad? + isPublic = pad.getPublicStatus(); + + // is it password protected? + isPasswordProtected = pad.isPasswordProtected(); + + // is password correct? + if (isPasswordProtected && password && pad.isCorrectPassword(password)) { + passwordStatus = "correct"; + } + } + + // - a valid session for this group is avaible AND pad exists + if (validSession && padExists) { + let authorID = sessionAuthor; + let grant = Object.freeze({ accessStatus: "grant", authorID }); + + if (!isPasswordProtected) { + // - the pad is not password protected + + // --> grant access + return grant; + } + + if (settings.sessionNoPassword) { + // - the setting to bypass password validation is set + + // --> grant access + return grant; + } + + if (isPasswordProtected && passwordStatus === "correct") { + // - the pad is password protected and password is correct + + // --> grant access + return grant; + } + + if (isPasswordProtected && passwordStatus === "wrong") { + // - the pad is password protected but wrong password given + + // --> deny access, ask for new password and tell them that the password is wrong + return { accessStatus: "wrongPassword" }; + } + + if (isPasswordProtected && passwordStatus === "notGiven") { + // - the pad is password protected but no password given + + // --> ask for password + return { accessStatus: "needPassword" }; + } + + throw new Error("Oops, something wrong happend"); + } + + if (validSession && !padExists) { + // - a valid session for this group avaible but pad doesn't exist + + // --> grant access by default + let accessStatus = "grant"; + let authorID = sessionAuthor; + + // --> deny access if user isn't allowed to create the pad + if (settings.editOnly) { + authLogger.debug("Auth failed: valid session & pad does not exist"); + accessStatus = "deny"; + } + + return { accessStatus, authorID }; + } + + if (!validSession && padExists) { + // there is no valid session avaiable AND pad exists + + let authorID = await p_tokenAuthor; + let grant = Object.freeze({ accessStatus: "grant", authorID }); + + if (isPublic && !isPasswordProtected) { + // -- it's public and not password protected + + // --> grant access, with author of token + return grant; + } + + if (isPublic && isPasswordProtected && passwordStatus === "correct") { + // - it's public and password protected and password is correct + + // --> grant access, with author of token + return grant; + } + + if (isPublic && isPasswordProtected && passwordStatus === "wrong") { + // - it's public and the pad is password protected but wrong password given + + // --> deny access, ask for new password and tell them that the password is wrong + return { accessStatus: "wrongPassword" }; + } + + if (isPublic && isPasswordProtected && passwordStatus === "notGiven") { + // - it's public and the pad is password protected but no password given + + // --> ask for password + return { accessStatus: "needPassword" }; + } + + if (!isPublic) { + // - it's not public + + authLogger.debug("Auth failed: invalid session & pad is not public"); + // --> deny access + return { accessStatus: "deny" }; + } + + throw new Error("Oops, something wrong happend"); + } + + // there is no valid session avaiable AND pad doesn't exist + authLogger.debug("Auth failed: invalid session & pad does not exist"); + return { accessStatus: "deny" }; +} diff --git a/src/node/db/SessionManager.js b/src/node/db/SessionManager.js index 954203758..9161205d7 100644 --- a/src/node/db/SessionManager.js +++ b/src/node/db/SessionManager.js @@ -17,361 +17,208 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -var ERR = require("async-stacktrace"); var customError = require("../utils/customError"); var randomString = require("../utils/randomstring"); -var db = require("./DB").db; -var async = require("async"); -var groupMangager = require("./GroupManager"); -var authorMangager = require("./AuthorManager"); - -exports.doesSessionExist = function(sessionID, callback) +var db = require("./DB"); +var groupManager = require("./GroupManager"); +var authorManager = require("./AuthorManager"); + +exports.doesSessionExist = async function(sessionID) { //check if the database entry of this session exists - db.get("session:" + sessionID, function (err, session) - { - if(ERR(err, callback)) return; - callback(null, session != null); - }); + let session = await db.get("session:" + sessionID); + return (session !== null); } - + /** * Creates a new session between an author and a group */ -exports.createSession = function(groupID, authorID, validUntil, callback) +exports.createSession = async function(groupID, authorID, validUntil) { - var sessionID; + // check if the group exists + let groupExists = await groupManager.doesGroupExist(groupID); + if (!groupExists) { + throw new customError("groupID does not exist", "apierror"); + } - async.series([ - //check if group exists - function(callback) - { - groupMangager.doesGroupExist(groupID, function(err, exists) - { - if(ERR(err, callback)) return; - - //group does not exist - if(exists == false) - { - callback(new customError("groupID does not exist","apierror")); - } - //everything is fine, continue - else - { - callback(); - } - }); - }, - //check if author exists - function(callback) - { - authorMangager.doesAuthorExists(authorID, function(err, exists) - { - if(ERR(err, callback)) return; - - //author does not exist - if(exists == false) - { - callback(new customError("authorID does not exist","apierror")); - } - //everything is fine, continue - else - { - callback(); - } - }); - }, - //check validUntil and create the session db entry - function(callback) - { - //check if rev is a number - if(typeof validUntil != "number") - { - //try to parse the number - if(isNaN(parseInt(validUntil))) - { - callback(new customError("validUntil is not a number","apierror")); - return; - } + // check if the author exists + let authorExists = await authorManager.doesAuthorExist(authorID); + if (!authorExists) { + throw new customError("authorID does not exist", "apierror"); + } - validUntil = parseInt(validUntil); - } - - //ensure this is not a negativ number - if(validUntil < 0) - { - callback(new customError("validUntil is a negativ number","apierror")); - return; - } - - //ensure this is not a float value - if(!is_int(validUntil)) - { - callback(new customError("validUntil is a float value","apierror")); - return; - } - - //check if validUntil is in the future - if(Math.floor(new Date().getTime()/1000) > validUntil) - { - callback(new customError("validUntil is in the past","apierror")); - return; - } - - //generate sessionID - sessionID = "s." + randomString(16); - - //set the session into the database - db.set("session:" + sessionID, {"groupID": groupID, "authorID": authorID, "validUntil": validUntil}); - - callback(); - }, - //set the group2sessions entry - function(callback) - { - //get the entry - db.get("group2sessions:" + groupID, function(err, group2sessions) - { - if(ERR(err, callback)) return; - - //the entry doesn't exist so far, let's create it - if(group2sessions == null || group2sessions.sessionIDs == null) - { - group2sessions = {sessionIDs : {}}; - } - - //add the entry for this session - group2sessions.sessionIDs[sessionID] = 1; - - //save the new element back - db.set("group2sessions:" + groupID, group2sessions); - - callback(); - }); - }, - //set the author2sessions entry - function(callback) - { - //get the entry - db.get("author2sessions:" + authorID, function(err, author2sessions) - { - if(ERR(err, callback)) return; - - //the entry doesn't exist so far, let's create it - if(author2sessions == null || author2sessions.sessionIDs == null) - { - author2sessions = {sessionIDs : {}}; - } - - //add the entry for this session - author2sessions.sessionIDs[sessionID] = 1; - - //save the new element back - db.set("author2sessions:" + authorID, author2sessions); - - callback(); - }); - } - ], function(err) - { - if(ERR(err, callback)) return; - - //return error and sessionID - callback(null, {sessionID: sessionID}); - }) + // try to parse validUntil if it's not a number + if (typeof validUntil !== "number") { + validUntil = parseInt(validUntil); + } + + // check it's a valid number + if (isNaN(validUntil)) { + throw new customError("validUntil is not a number", "apierror"); + } + + // ensure this is not a negative number + if (validUntil < 0) { + throw new customError("validUntil is a negative number", "apierror"); + } + + // ensure this is not a float value + if (!is_int(validUntil)) { + throw new customError("validUntil is a float value", "apierror"); + } + + // check if validUntil is in the future + if (validUntil < Math.floor(Date.now() / 1000)) { + throw new customError("validUntil is in the past", "apierror"); + } + + // generate sessionID + let sessionID = "s." + randomString(16); + + // set the session into the database + await db.set("session:" + sessionID, {"groupID": groupID, "authorID": authorID, "validUntil": validUntil}); + + // get the entry + let group2sessions = await db.get("group2sessions:" + groupID); + + /* + * In some cases, the db layer could return "undefined" as well as "null". + * Thus, it is not possible to perform strict null checks on group2sessions. + * In a previous version of this code, a strict check broke session + * management. + * + * See: https://github.com/ether/etherpad-lite/issues/3567#issuecomment-468613960 + */ + if (!group2sessions || !group2sessions.sessionIDs) { + // the entry doesn't exist so far, let's create it + group2sessions = {sessionIDs : {}}; + } + + // add the entry for this session + group2sessions.sessionIDs[sessionID] = 1; + + // save the new element back + await db.set("group2sessions:" + groupID, group2sessions); + + // get the author2sessions entry + let author2sessions = await db.get("author2sessions:" + authorID); + + if (author2sessions == null || author2sessions.sessionIDs == null) { + // the entry doesn't exist so far, let's create it + author2sessions = {sessionIDs : {}}; + } + + // add the entry for this session + author2sessions.sessionIDs[sessionID] = 1; + + //save the new element back + await db.set("author2sessions:" + authorID, author2sessions); + + return { sessionID }; } -exports.getSessionInfo = function(sessionID, callback) +exports.getSessionInfo = async function(sessionID) { - //check if the database entry of this session exists - db.get("session:" + sessionID, function (err, session) - { - if(ERR(err, callback)) return; - - //session does not exists - if(session == null) - { - callback(new customError("sessionID does not exist","apierror")) - } - //everything is fine, return the sessioninfos - else - { - callback(null, session); - } - }); + // check if the database entry of this session exists + let session = await db.get("session:" + sessionID); + + if (session == null) { + // session does not exist + throw new customError("sessionID does not exist", "apierror"); + } + + // everything is fine, return the sessioninfos + return session; } /** * Deletes a session */ -exports.deleteSession = function(sessionID, callback) +exports.deleteSession = async function(sessionID) { - var authorID, groupID; - var group2sessions, author2sessions; + // ensure that the session exists + let session = await db.get("session:" + sessionID); + if (session == null) { + throw new customError("sessionID does not exist", "apierror"); + } - async.series([ - function(callback) - { - //get the session entry - db.get("session:" + sessionID, function (err, session) - { - if(ERR(err, callback)) return; - - //session does not exists - if(session == null) - { - callback(new customError("sessionID does not exist","apierror")) - } - //everything is fine, return the sessioninfos - else - { - authorID = session.authorID; - groupID = session.groupID; - - callback(); - } - }); - }, - //get the group2sessions entry - function(callback) - { - db.get("group2sessions:" + groupID, function (err, _group2sessions) - { - if(ERR(err, callback)) return; - group2sessions = _group2sessions; - callback(); - }); - }, - //get the author2sessions entry - function(callback) - { - db.get("author2sessions:" + authorID, function (err, _author2sessions) - { - if(ERR(err, callback)) return; - author2sessions = _author2sessions; - callback(); - }); - }, - //remove the values from the database - function(callback) - { - //remove the session - db.remove("session:" + sessionID); - - //remove session from group2sessions - if(group2sessions != null) { // Maybe the group was already deleted - delete group2sessions.sessionIDs[sessionID]; - db.set("group2sessions:" + groupID, group2sessions); - } + // everything is fine, use the sessioninfos + let groupID = session.groupID; + let authorID = session.authorID; - //remove session from author2sessions - if(author2sessions != null) { // Maybe the author was already deleted - delete author2sessions.sessionIDs[sessionID]; - db.set("author2sessions:" + authorID, author2sessions); - } - - callback(); - } - ], function(err) - { - if(ERR(err, callback)) return; - callback(); - }) + // get the group2sessions and author2sessions entries + let group2sessions = await db.get("group2sessions:" + groupID); + let author2sessions = await db.get("author2sessions:" + authorID); + + // remove the session + await db.remove("session:" + sessionID); + + // remove session from group2sessions + if (group2sessions != null) { // Maybe the group was already deleted + delete group2sessions.sessionIDs[sessionID]; + await db.set("group2sessions:" + groupID, group2sessions); + } + + // remove session from author2sessions + if (author2sessions != null) { // Maybe the author was already deleted + delete author2sessions.sessionIDs[sessionID]; + await db.set("author2sessions:" + authorID, author2sessions); + } } -exports.listSessionsOfGroup = function(groupID, callback) +exports.listSessionsOfGroup = async function(groupID) { - groupMangager.doesGroupExist(groupID, function(err, exists) - { - if(ERR(err, callback)) return; - - //group does not exist - if(exists == false) - { - callback(new customError("groupID does not exist","apierror")); - } - //everything is fine, continue - else - { - listSessionsWithDBKey("group2sessions:" + groupID, callback); - } - }); + // check that the group exists + let exists = await groupManager.doesGroupExist(groupID); + if (!exists) { + throw new customError("groupID does not exist", "apierror"); + } + + let sessions = await listSessionsWithDBKey("group2sessions:" + groupID); + return sessions; } -exports.listSessionsOfAuthor = function(authorID, callback) -{ - authorMangager.doesAuthorExists(authorID, function(err, exists) - { - if(ERR(err, callback)) return; - - //group does not exist - if(exists == false) - { - callback(new customError("authorID does not exist","apierror")); - } - //everything is fine, continue - else - { - listSessionsWithDBKey("author2sessions:" + authorID, callback); - } - }); -} - -//this function is basicly the code listSessionsOfAuthor and listSessionsOfGroup has in common -function listSessionsWithDBKey (dbkey, callback) +exports.listSessionsOfAuthor = async function(authorID) { - var sessions; + // check that the author exists + let exists = await authorManager.doesAuthorExist(authorID) + if (!exists) { + throw new customError("authorID does not exist", "apierror"); + } - async.series([ - function(callback) - { - //get the group2sessions entry - db.get(dbkey, function(err, sessionObject) - { - if(ERR(err, callback)) return; - sessions = sessionObject ? sessionObject.sessionIDs : null; - callback(); - }); - }, - function(callback) - { - //collect all sessionIDs in an arrary - var sessionIDs = []; - for (var i in sessions) - { - sessionIDs.push(i); - } - - //foreach trough the sessions and get the sessioninfos - async.forEach(sessionIDs, function(sessionID, callback) - { - exports.getSessionInfo(sessionID, function(err, sessionInfo) - { - if (err == "apierror: sessionID does not exist") - { - console.warn(`Found bad session ${sessionID} in ${dbkey}`); - } - else if(ERR(err, callback)) - { - return; - } - - sessions[sessionID] = sessionInfo; - callback(); - }); - }, callback); - } - ], function(err) - { - if(ERR(err, callback)) return; - callback(null, sessions); - }); + let sessions = await listSessionsWithDBKey("author2sessions:" + authorID); + return sessions; } -//checks if a number is an int +// this function is basically the code listSessionsOfAuthor and listSessionsOfGroup has in common +// required to return null rather than an empty object if there are none +async function listSessionsWithDBKey(dbkey) +{ + // get the group2sessions entry + let sessionObject = await db.get(dbkey); + let sessions = sessionObject ? sessionObject.sessionIDs : null; + + // iterate through the sessions and get the sessioninfos + for (let sessionID in sessions) { + try { + let sessionInfo = await exports.getSessionInfo(sessionID); + sessions[sessionID] = sessionInfo; + } catch (err) { + if (err == "apierror: sessionID does not exist") { + console.warn(`Found bad session ${sessionID} in ${dbkey}`); + sessions[sessionID] = null; + } else { + throw err; + } + } + } + + return sessions; +} + +// checks if a number is an int function is_int(value) -{ - return (parseFloat(value) == parseInt(value)) && !isNaN(value) +{ + return (parseFloat(value) == parseInt(value)) && !isNaN(value); } diff --git a/src/node/db/SessionStore.js b/src/node/db/SessionStore.js index 974046908..647cbbc8d 100644 --- a/src/node/db/SessionStore.js +++ b/src/node/db/SessionStore.js @@ -1,7 +1,10 @@ - /* +/* * Stores session data in the database * Source; https://github.com/edy-b/SciFlowWriter/blob/develop/available_plugins/ep_sciflowwriter/db/DirtyStore.js * This is not used for authors that are created via the API at current + * + * RPB: this module was not migrated to Promises, because it is only used via + * express-session, which can't actually use promises anyway. */ var Store = require('ep_etherpad-lite/node_modules/express-session').Store, @@ -13,11 +16,12 @@ var SessionStore = module.exports = function SessionStore() {}; SessionStore.prototype.__proto__ = Store.prototype; -SessionStore.prototype.get = function(sid, fn){ +SessionStore.prototype.get = function(sid, fn) { messageLogger.debug('GET ' + sid); + var self = this; - db.get("sessionstorage:" + sid, function (err, sess) - { + + db.get("sessionstorage:" + sid, function(err, sess) { if (sess) { sess.cookie.expires = 'string' == typeof sess.cookie.expires ? new Date(sess.cookie.expires) : sess.cookie.expires; if (!sess.cookie.expires || new Date() < sess.cookie.expires) { @@ -31,50 +35,64 @@ SessionStore.prototype.get = function(sid, fn){ }); }; -SessionStore.prototype.set = function(sid, sess, fn){ +SessionStore.prototype.set = function(sid, sess, fn) { messageLogger.debug('SET ' + sid); + db.set("sessionstorage:" + sid, sess); - process.nextTick(function(){ - if(fn) fn(); - }); + if (fn) { + process.nextTick(fn); + } }; -SessionStore.prototype.destroy = function(sid, fn){ +SessionStore.prototype.destroy = function(sid, fn) { messageLogger.debug('DESTROY ' + sid); + db.remove("sessionstorage:" + sid); - process.nextTick(function(){ - if(fn) fn(); - }); + if (fn) { + process.nextTick(fn); + } }; -SessionStore.prototype.all = function(fn){ - messageLogger.debug('ALL'); - var sessions = []; - db.forEach(function(key, value){ - if (key.substr(0,15) === "sessionstorage:") { - sessions.push(value); - } - }); - fn(null, sessions); -}; +/* + * RPB: the following methods are optional requirements for a compatible session + * store for express-session, but in any case appear to depend on a + * non-existent feature of ueberdb2 + */ +if (db.forEach) { + SessionStore.prototype.all = function(fn) { + messageLogger.debug('ALL'); -SessionStore.prototype.clear = function(fn){ - messageLogger.debug('CLEAR'); - db.forEach(function(key, value){ - if (key.substr(0,15) === "sessionstorage:") { - db.db.remove("session:" + key); - } - }); - if(fn) fn(); -}; + var sessions = []; -SessionStore.prototype.length = function(fn){ - messageLogger.debug('LENGTH'); - var i = 0; - db.forEach(function(key, value){ - if (key.substr(0,15) === "sessionstorage:") { - i++; - } - }); - fn(null, i); + db.forEach(function(key, value) { + if (key.substr(0,15) === "sessionstorage:") { + sessions.push(value); + } + }); + fn(null, sessions); + }; + + SessionStore.prototype.clear = function(fn) { + messageLogger.debug('CLEAR'); + + db.forEach(function(key, value) { + if (key.substr(0,15) === "sessionstorage:") { + db.remove("session:" + key); + } + }); + if (fn) fn(); + }; + + SessionStore.prototype.length = function(fn) { + messageLogger.debug('LENGTH'); + + var i = 0; + + db.forEach(function(key, value) { + if (key.substr(0,15) === "sessionstorage:") { + i++; + } + }); + fn(null, i); + } }; diff --git a/src/node/handler/APIHandler.js b/src/node/handler/APIHandler.js index 6ec5907e2..3898daaf5 100644 --- a/src/node/handler/APIHandler.js +++ b/src/node/handler/APIHandler.js @@ -19,7 +19,6 @@ */ var absolutePaths = require('../utils/AbsolutePaths'); -var ERR = require("async-stacktrace"); var fs = require("fs"); var api = require("../db/API"); var log4js = require('log4js'); @@ -32,19 +31,17 @@ var apiHandlerLogger = log4js.getLogger('APIHandler'); //ensure we have an apikey var apikey = null; var apikeyFilename = absolutePaths.makeAbsolute(argv.apikey || "./APIKEY.txt"); -try -{ + +try { apikey = fs.readFileSync(apikeyFilename,"utf8"); apiHandlerLogger.info(`Api key file read from: "${apikeyFilename}"`); -} -catch(e) -{ +} catch(e) { apiHandlerLogger.info(`Api key file "${apikeyFilename}" not found. Creating with random contents.`); apikey = randomString(32); fs.writeFileSync(apikeyFilename,apikey,"utf8"); } -//a list of all functions +// a list of all functions var version = {}; version["1"] = Object.assign({}, @@ -152,110 +149,73 @@ exports.version = version; * @req express request object * @res express response object */ -exports.handle = function(apiVersion, functionName, fields, req, res) +exports.handle = async function(apiVersion, functionName, fields, req, res) { - //check if this is a valid apiversion - var isKnownApiVersion = false; - for(var knownApiVersion in version) - { - if(knownApiVersion == apiVersion) - { - isKnownApiVersion = true; - break; - } - } - - //say goodbye if this is an unknown API version - if(!isKnownApiVersion) - { + // say goodbye if this is an unknown API version + if (!(apiVersion in version)) { res.statusCode = 404; res.send({code: 3, message: "no such api version", data: null}); return; } - //check if this is a valid function name - var isKnownFunctionname = false; - for(var knownFunctionname in version[apiVersion]) - { - if(knownFunctionname == functionName) - { - isKnownFunctionname = true; - break; - } - } - - //say goodbye if this is a unknown function - if(!isKnownFunctionname) - { + // say goodbye if this is an unknown function + if (!(functionName in version[apiVersion])) { + // no status code?! res.send({code: 3, message: "no such function", data: null}); return; } - //check the api key! + // check the api key! fields["apikey"] = fields["apikey"] || fields["api_key"]; - if(fields["apikey"] != apikey.trim()) - { + if (fields["apikey"] !== apikey.trim()) { res.statusCode = 401; res.send({code: 4, message: "no or wrong API Key", data: null}); return; } - //sanitize any pad id's before continuing - if(fields["padID"]) - { - padManager.sanitizePadId(fields["padID"], function(padId) - { - fields["padID"] = padId; - callAPI(apiVersion, functionName, fields, req, res); - }); + // sanitize any padIDs before continuing + if (fields["padID"]) { + fields["padID"] = await padManager.sanitizePadId(fields["padID"]); } - else if(fields["padName"]) - { - padManager.sanitizePadId(fields["padName"], function(padId) - { - fields["padName"] = padId; - callAPI(apiVersion, functionName, fields, req, res); - }); - } - else - { - callAPI(apiVersion, functionName, fields, req, res); + // there was an 'else' here before - removed it to ensure + // that this sanitize step can't be circumvented by forcing + // the first branch to be taken + if (fields["padName"]) { + fields["padName"] = await padManager.sanitizePadId(fields["padName"]); } + + // no need to await - callAPI returns a promise + return callAPI(apiVersion, functionName, fields, req, res); } -//calls the api function -function callAPI(apiVersion, functionName, fields, req, res) +// calls the api function +async function callAPI(apiVersion, functionName, fields, req, res) { - //put the function parameters in an array + // put the function parameters in an array var functionParams = version[apiVersion][functionName].map(function (field) { return fields[field] - }) - - //add a callback function to handle the response - functionParams.push(function(err, data) - { - // no error happend, everything is fine - if(err == null) - { - if(!data) - data = null; - - res.send({code: 0, message: "ok", data: data}); - } - // parameters were wrong and the api stopped execution, pass the error - else if(err.name == "apierror") - { - res.send({code: 1, message: err.message, data: null}); - } - //an unknown error happend - else - { - res.send({code: 2, message: "internal error", data: null}); - ERR(err); - } }); - //call the api function - api[functionName].apply(this, functionParams); + try { + // call the api function + let data = await api[functionName].apply(this, functionParams); + + if (!data) { + data = null; + } + + res.send({code: 0, message: "ok", data: data}); + } catch (err) { + if (err.name == "apierror") { + // parameters were wrong and the api stopped execution, pass the error + + res.send({code: 1, message: err.message, data: null}); + } else { + // an unknown error happened + + res.send({code: 2, message: "internal error", data: null}); + throw err; + } + } } diff --git a/src/node/handler/ExportHandler.js b/src/node/handler/ExportHandler.js index 678519fbc..39638c222 100644 --- a/src/node/handler/ExportHandler.js +++ b/src/node/handler/ExportHandler.js @@ -19,169 +19,122 @@ * limitations under the License. */ -var ERR = require("async-stacktrace"); var exporthtml = require("../utils/ExportHtml"); var exporttxt = require("../utils/ExportTxt"); var exportEtherpad = require("../utils/ExportEtherpad"); -var async = require("async"); var fs = require("fs"); var settings = require('../utils/Settings'); var os = require('os'); var hooks = require("ep_etherpad-lite/static/js/pluginfw/hooks"); var TidyHtml = require('../utils/TidyHtml'); +const util = require("util"); -var convertor = null; +const fsp_writeFile = util.promisify(fs.writeFile); +const fsp_unlink = util.promisify(fs.unlink); -//load abiword only if its enabled -if(settings.abiword != null) +let convertor = null; + +// load abiword only if it is enabled +if (settings.abiword != null) { convertor = require("../utils/Abiword"); +} // Use LibreOffice if an executable has been defined in the settings -if(settings.soffice != null) +if (settings.soffice != null) { convertor = require("../utils/LibreOffice"); - -var tempDirectory = "/tmp"; - -//tempDirectory changes if the operating system is windows -if(os.type().indexOf("Windows") > -1) -{ - tempDirectory = process.env.TEMP; } +const tempDirectory = os.tmpdir(); + /** * do a requested export */ -exports.doExport = function(req, res, padId, type) +async function doExport(req, res, padId, type) { var fileName = padId; // allow fileName to be overwritten by a hook, the type type is kept static for security reasons - hooks.aCallFirst("exportFileName", padId, - function(err, hookFileName){ - // if fileName is set then set it to the padId, note that fileName is returned as an array. - if(hookFileName.length) fileName = hookFileName; + let hookFileName = await hooks.aCallFirst("exportFileName", padId); - //tell the browser that this is a downloadable file - res.attachment(fileName + "." + type); + // if fileName is set then set it to the padId, note that fileName is returned as an array. + if (hookFileName.length) { + fileName = hookFileName; + } - //if this is a plain text export, we can do this directly - // We have to over engineer this because tabs are stored as attributes and not plain text - if(type == "etherpad"){ - exportEtherpad.getPadRaw(padId, function(err, pad){ - if(!err){ - res.send(pad); - // return; - } - }); - } - else if(type == "txt") - { - exporttxt.getPadTXTDocument(padId, req.params.rev, function(err, txt) - { - if(!err) { - res.send(txt); - } - }); - } - else - { - var html; - var randNum; - var srcFile, destFile; + // tell the browser that this is a downloadable file + res.attachment(fileName + "." + type); - async.series([ - //render the html document - function(callback) - { - exporthtml.getPadHTMLDocument(padId, req.params.rev, function(err, _html) - { - if(ERR(err, callback)) return; - html = _html; - callback(); - }); - }, - //decide what to do with the html export - function(callback) - { - //if this is a html export, we can send this from here directly - if(type == "html") - { - // do any final changes the plugin might want to make - hooks.aCallFirst("exportHTMLSend", html, function(err, newHTML){ - if(newHTML.length) html = newHTML; - res.send(html); - callback("stop"); - }); - } - else //write the html export to a file - { - randNum = Math.floor(Math.random()*0xFFFFFFFF); - srcFile = tempDirectory + "/etherpad_export_" + randNum + ".html"; - fs.writeFile(srcFile, html, callback); - } - }, + // if this is a plain text export, we can do this directly + // We have to over engineer this because tabs are stored as attributes and not plain text + if (type === "etherpad") { + let pad = await exportEtherpad.getPadRaw(padId); + res.send(pad); + } else if (type === "txt") { + let txt = await exporttxt.getPadTXTDocument(padId, req.params.rev); + res.send(txt); + } else { + // render the html document + let html = await exporthtml.getPadHTMLDocument(padId, req.params.rev); - // Tidy up the exported HTML - function(callback) - { - //ensure html can be collected by the garbage collector - html = null; + // decide what to do with the html export - TidyHtml.tidy(srcFile, callback); - }, - - //send the convert job to the convertor (abiword, libreoffice, ..) - function(callback) - { - destFile = tempDirectory + "/etherpad_export_" + randNum + "." + type; - - // Allow plugins to overwrite the convert in export process - hooks.aCallAll("exportConvert", {srcFile: srcFile, destFile: destFile, req: req, res: res}, function(err, result){ - if(!err && result.length > 0){ - // console.log("export handled by plugin", destFile); - handledByPlugin = true; - callback(); - }else{ - convertor.convertFile(srcFile, destFile, type, callback); - } - }); - - }, - //send the file - function(callback) - { - res.sendFile(destFile, null, callback); - }, - //clean up temporary files - function(callback) - { - async.parallel([ - function(callback) - { - fs.unlink(srcFile, callback); - }, - function(callback) - { - //100ms delay to accomidate for slow windows fs - if(os.type().indexOf("Windows") > -1) - { - setTimeout(function() - { - fs.unlink(destFile, callback); - }, 100); - } - else - { - fs.unlink(destFile, callback); - } - } - ], callback); - } - ], function(err) - { - if(err && err != "stop") ERR(err); - }) - } + // if this is a html export, we can send this from here directly + if (type === "html") { + // do any final changes the plugin might want to make + let newHTML = await hooks.aCallFirst("exportHTMLSend", html); + if (newHTML.length) html = newHTML; + res.send(html); + throw "stop"; } - ); -}; + + // else write the html export to a file + let randNum = Math.floor(Math.random()*0xFFFFFFFF); + let srcFile = tempDirectory + "/etherpad_export_" + randNum + ".html"; + await fsp_writeFile(srcFile, html); + + // Tidy up the exported HTML + // ensure html can be collected by the garbage collector + html = null; + await TidyHtml.tidy(srcFile); + + // send the convert job to the convertor (abiword, libreoffice, ..) + let destFile = tempDirectory + "/etherpad_export_" + randNum + "." + type; + + // Allow plugins to overwrite the convert in export process + let result = await hooks.aCallAll("exportConvert", { srcFile, destFile, req, res }); + if (result.length > 0) { + // console.log("export handled by plugin", destFile); + handledByPlugin = true; + } else { + // @TODO no Promise interface for convertors (yet) + await new Promise((resolve, reject) => { + convertor.convertFile(srcFile, destFile, type, function(err) { + err ? reject("convertFailed") : resolve(); + }); + }); + } + + // send the file + let sendFile = util.promisify(res.sendFile); + await res.sendFile(destFile, null); + + // clean up temporary files + await fsp_unlink(srcFile); + + // 100ms delay to accommodate for slow windows fs + if (os.type().indexOf("Windows") > -1) { + await new Promise(resolve => setTimeout(resolve, 100)); + } + + await fsp_unlink(destFile); + } +} + +exports.doExport = function(req, res, padId, type) +{ + doExport(req, res, padId, type).catch(err => { + if (err !== "stop") { + throw err; + } + }); +} diff --git a/src/node/handler/ImportHandler.js b/src/node/handler/ImportHandler.js index 45c4c6ea8..d2bd05289 100644 --- a/src/node/handler/ImportHandler.js +++ b/src/node/handler/ImportHandler.js @@ -20,10 +20,8 @@ * limitations under the License. */ -var ERR = require("async-stacktrace") - , padManager = require("../db/PadManager") +var padManager = require("../db/PadManager") , padMessageHandler = require("./PadMessageHandler") - , async = require("async") , fs = require("fs") , path = require("path") , settings = require('../utils/Settings') @@ -32,301 +30,241 @@ var ERR = require("async-stacktrace") , importHtml = require("../utils/ImportHtml") , importEtherpad = require("../utils/ImportEtherpad") , log4js = require("log4js") - , hooks = require("ep_etherpad-lite/static/js/pluginfw/hooks.js"); + , hooks = require("ep_etherpad-lite/static/js/pluginfw/hooks.js") + , util = require("util"); -var convertor = null; -var exportExtension = "htm"; +let fsp_exists = util.promisify(fs.exists); +let fsp_rename = util.promisify(fs.rename); +let fsp_readFile = util.promisify(fs.readFile); +let fsp_unlink = util.promisify(fs.unlink) -//load abiword only if its enabled and if soffice is disabled -if(settings.abiword != null && settings.soffice === null) +let convertor = null; +let exportExtension = "htm"; + +// load abiword only if it is enabled and if soffice is disabled +if (settings.abiword != null && settings.soffice === null) { convertor = require("../utils/Abiword"); +} -//load soffice only if its enabled -if(settings.soffice != null) { +// load soffice only if it is enabled +if (settings.soffice != null) { convertor = require("../utils/LibreOffice"); exportExtension = "html"; } -//for node 0.6 compatibily, os.tmpDir() only works from 0.8 -var tmpDirectory = process.env.TEMP || process.env.TMPDIR || process.env.TMP || '/tmp'; - +const tmpDirectory = os.tmpdir(); + /** * do a requested import - */ -exports.doImport = function(req, res, padId) + */ +async function doImport(req, res, padId) { var apiLogger = log4js.getLogger("ImportHandler"); - //pipe to a file - //convert file to html via abiword or soffice - //set html in the pad - - var srcFile, destFile - , pad - , text - , importHandledByPlugin - , directDatabaseAccess - , useConvertor; - + // pipe to a file + // convert file to html via abiword or soffice + // set html in the pad var randNum = Math.floor(Math.random()*0xFFFFFFFF); - + // setting flag for whether to use convertor or not - useConvertor = (convertor != null); + let useConvertor = (convertor != null); - async.series([ - //save the uploaded file to /tmp - function(callback) { - var form = new formidable.IncomingForm(); - form.keepExtensions = true; - form.uploadDir = tmpDirectory; - - form.parse(req, function(err, fields, files) { - //the upload failed, stop at this point - if(err || files.file === undefined) { - if(err) console.warn("Uploading Error: " + err.stack); - callback("uploadFailed"); + let form = new formidable.IncomingForm(); + form.keepExtensions = true; + form.uploadDir = tmpDirectory; - return; + // locally wrapped Promise, since form.parse requires a callback + let srcFile = await new Promise((resolve, reject) => { + form.parse(req, function(err, fields, files) { + if (err || files.file === undefined) { + // the upload failed, stop at this point + if (err) { + console.warn("Uploading Error: " + err.stack); } - - //everything ok, continue - //save the path of the uploaded file - srcFile = files.file.path; - callback(); - }); - }, - - //ensure this is a file ending we know, else we change the file ending to .txt - //this allows us to accept source code files like .c or .java - function(callback) { - var fileEnding = path.extname(srcFile).toLowerCase() - , knownFileEndings = [".txt", ".doc", ".docx", ".pdf", ".odt", ".html", ".htm", ".etherpad", ".rtf"] - , fileEndingKnown = (knownFileEndings.indexOf(fileEnding) > -1); - - //if the file ending is known, continue as normal - if(fileEndingKnown) { - callback(); - - return; + reject("uploadFailed"); } + resolve(files.file.path); + }); + }); - //we need to rename this file with a .txt ending - if(settings.allowUnknownFileEnds === true){ - var oldSrcFile = srcFile; - srcFile = path.join(path.dirname(srcFile),path.basename(srcFile, fileEnding)+".txt"); - fs.rename(oldSrcFile, srcFile, callback); - }else{ - console.warn("Not allowing unknown file type to be imported", fileEnding); - callback("uploadFailed"); - } - }, - function(callback){ - destFile = path.join(tmpDirectory, "etherpad_import_" + randNum + "." + exportExtension); + // ensure this is a file ending we know, else we change the file ending to .txt + // this allows us to accept source code files like .c or .java + let fileEnding = path.extname(srcFile).toLowerCase() + , knownFileEndings = [".txt", ".doc", ".docx", ".pdf", ".odt", ".html", ".htm", ".etherpad", ".rtf"] + , fileEndingUnknown = (knownFileEndings.indexOf(fileEnding) < 0); - // Logic for allowing external Import Plugins - hooks.aCallAll("import", {srcFile: srcFile, destFile: destFile}, function(err, result){ - if(ERR(err, callback)) return callback(); - if(result.length > 0){ // This feels hacky and wrong.. - importHandledByPlugin = true; - } - callback(); - }); - }, - function(callback) { - var fileEnding = path.extname(srcFile).toLowerCase() - var fileIsNotEtherpad = (fileEnding !== ".etherpad"); + if (fileEndingUnknown) { + // the file ending is not known - if (fileIsNotEtherpad) { - callback(); + if (settings.allowUnknownFileEnds === true) { + // we need to rename this file with a .txt ending + let oldSrcFile = srcFile; - return; - } + srcFile = path.join(path.dirname(srcFile), path.basename(srcFile, fileEnding) + ".txt"); + await fs.rename(oldSrcFile, srcFile); + } else { + console.warn("Not allowing unknown file type to be imported", fileEnding); + throw "uploadFailed"; + } + } - // we do this here so we can see if the pad has quit ea few edits - padManager.getPad(padId, function(err, _pad){ - var headCount = _pad.head; - if(headCount >= 10){ - apiLogger.warn("Direct database Import attempt of a pad that already has content, we wont be doing this") - return callback("padHasData"); - } + let destFile = path.join(tmpDirectory, "etherpad_import_" + randNum + "." + exportExtension); - fs.readFile(srcFile, "utf8", function(err, _text){ - directDatabaseAccess = true; - importEtherpad.setPadRaw(padId, _text, function(err){ - callback(); - }); - }); - }); - }, - //convert file to html - function(callback) { - if (importHandledByPlugin || directDatabaseAccess) { - callback(); + // Logic for allowing external Import Plugins + let result = await hooks.aCallAll("import", { srcFile, destFile }); + let importHandledByPlugin = (result.length > 0); // This feels hacky and wrong.. - return; - } + let fileIsEtherpad = (fileEnding === ".etherpad"); + let fileIsHTML = (fileEnding === ".html" || fileEnding === ".htm"); + let fileIsTXT = (fileEnding === ".txt"); - var fileEnding = path.extname(srcFile).toLowerCase(); - var fileIsHTML = (fileEnding === ".html" || fileEnding === ".htm"); - var fileIsTXT = (fileEnding === ".txt"); - if (fileIsTXT) useConvertor = false; // Don't use convertor for text files - // See https://github.com/ether/etherpad-lite/issues/2572 - if (fileIsHTML || (useConvertor === false)) { - // if no convertor only rename - fs.rename(srcFile, destFile, callback); - - return; - } + if (fileIsEtherpad) { + // we do this here so we can see if the pad has quite a few edits + let _pad = await padManager.getPad(padId); + let headCount = _pad.head; - convertor.convertFile(srcFile, destFile, exportExtension, function(err) { - //catch convert errors - if(err) { - console.warn("Converting Error:", err); - return callback("convertFailed"); - } + if (headCount >= 10) { + apiLogger.warn("Direct database Import attempt of a pad that already has content, we won't be doing this"); + throw "padHasData"; + } - callback(); - }); - }, - - function(callback) { - if (useConvertor || directDatabaseAccess) { - callback(); + const fsp_readFile = util.promisify(fs.readFile); + let _text = await fsp_readFile(srcFile, "utf8"); + req.directDatabaseAccess = true; + await importEtherpad.setPadRaw(padId, _text); + } - return; - } + // convert file to html if necessary + if (!importHandledByPlugin && !req.directDatabaseAccess) { + if (fileIsTXT) { + // Don't use convertor for text files + useConvertor = false; + } - // Read the file with no encoding for raw buffer access. - fs.readFile(destFile, function(err, buf) { - if (err) throw err; - var isAscii = true; - // Check if there are only ascii chars in the uploaded file - for (var i=0, len=buf.length; i 240) { - isAscii=false; - break; + // See https://github.com/ether/etherpad-lite/issues/2572 + if (fileIsHTML || !useConvertor) { + // if no convertor only rename + fs.renameSync(srcFile, destFile); + } else { + // @TODO - no Promise interface for convertors (yet) + await new Promise((resolve, reject) => { + convertor.convertFile(srcFile, destFile, exportExtension, function(err) { + // catch convert errors + if (err) { + console.warn("Converting Error:", err); + reject("convertFailed"); } - } - - if (!isAscii) { - callback("uploadFailed"); - - return; - } - - callback(); - }); - }, - - //get the pad object - function(callback) { - padManager.getPad(padId, function(err, _pad){ - if(ERR(err, callback)) return; - pad = _pad; - callback(); - }); - }, - - //read the text - function(callback) { - if (directDatabaseAccess) { - callback(); - - return; - } - - fs.readFile(destFile, "utf8", function(err, _text){ - if(ERR(err, callback)) return; - text = _text; - // Title needs to be stripped out else it appends it to the pad.. - text = text.replace("", "<!-- <title>"); - text = text.replace("","-->"); - - //node on windows has a delay on releasing of the file lock. - //We add a 100ms delay to work around this - if(os.type().indexOf("Windows") > -1){ - setTimeout(function() {callback();}, 100); - } else { - callback(); - } - }); - }, - - //change text of the pad and broadcast the changeset - function(callback) { - if(!directDatabaseAccess){ - var fileEnding = path.extname(srcFile).toLowerCase(); - if (importHandledByPlugin || useConvertor || fileEnding == ".htm" || fileEnding == ".html") { - importHtml.setPadHTML(pad, text, function(e){ - if(e) apiLogger.warn("Error importing, possibly caused by malformed HTML"); - }); - } else { - pad.setText(text); - } - } - - // Load the Pad into memory then brodcast updates to all clients - padManager.unloadPad(padId); - padManager.getPad(padId, function(err, _pad){ - var pad = _pad; - padManager.unloadPad(padId); - // direct Database Access means a pad user should perform a switchToPad - // and not attempt to recieve updated pad data.. - if (directDatabaseAccess) { - callback(); - - return; - } - - padMessageHandler.updatePadClients(pad, function(){ - callback(); + resolve(); }); }); + } + } - }, - - //clean up temporary files - function(callback) { - if (directDatabaseAccess) { - callback(); + if (!useConvertor && !req.directDatabaseAccess) { + // Read the file with no encoding for raw buffer access. + let buf = await fsp_readFile(destFile); - return; + // Check if there are only ascii chars in the uploaded file + let isAscii = ! Array.prototype.some.call(buf, c => (c > 240)); + + if (!isAscii) { + throw "uploadFailed"; + } + } + + // get the pad object + let pad = await padManager.getPad(padId); + + // read the text + let text; + + if (!req.directDatabaseAccess) { + text = await fsp_readFile(destFile, "utf8"); + + // Title needs to be stripped out else it appends it to the pad.. + text = text.replace("", "<!-- <title>"); + text = text.replace("","-->"); + + // node on windows has a delay on releasing of the file lock. + // We add a 100ms delay to work around this + if (os.type().indexOf("Windows") > -1){ + await new Promise(resolve => setTimeout(resolve, 100)); + } + } + + // change text of the pad and broadcast the changeset + if (!req.directDatabaseAccess) { + if (importHandledByPlugin || useConvertor || fileIsHTML) { + try { + importHtml.setPadHTML(pad, text); + } catch (e) { + apiLogger.warn("Error importing, possibly caused by malformed HTML"); } - - //for node < 0.7 compatible - var fileExists = fs.exists || path.exists; - async.parallel([ - function(callback){ - fileExists (srcFile, function(exist) { (exist)? fs.unlink(srcFile, callback): callback(); }); - }, - function(callback){ - fileExists (destFile, function(exist) { (exist)? fs.unlink(destFile, callback): callback(); }); - } - ], callback); + } else { + pad.setText(text); } - ], function(err) { - var status = "ok"; - - //check for known errors and replace the status - if(err == "uploadFailed" || err == "convertFailed" || err == "padHasData") - { + } + + // Load the Pad into memory then broadcast updates to all clients + padManager.unloadPad(padId); + pad = await padManager.getPad(padId); + padManager.unloadPad(padId); + + // direct Database Access means a pad user should perform a switchToPad + // and not attempt to receive updated pad data + if (req.directDatabaseAccess) { + return; + } + + // tell clients to update + await padMessageHandler.updatePadClients(pad); + + // clean up temporary files + + /* + * TODO: directly delete the file and handle the eventual error. Checking + * before for existence is prone to race conditions, and does not handle any + * errors anyway. + */ + if (await fsp_exists(srcFile)) { + fsp_unlink(srcFile); + } + + if (await fsp_exists(destFile)) { + fsp_unlink(destFile); + } +} + +exports.doImport = function (req, res, padId) +{ + /** + * NB: abuse the 'req' object by storing an additional + * 'directDatabaseAccess' property on it so that it can + * be passed back in the HTML below. + * + * this is necessary because in the 'throw' paths of + * the function above there's no other way to return + * a value to the caller. + */ + let status = "ok"; + doImport(req, res, padId).catch(err => { + // check for known errors and replace the status + if (err == "uploadFailed" || err == "convertFailed" || err == "padHasData") { status = err; - err = null; + } else { + throw err; } - - ERR(err); - - //close the connection + }).then(() => { + // close the connection res.send( - " \ - \ - \ - " + " \ + \ + \ + " ); }); } - diff --git a/src/node/handler/PadMessageHandler.js b/src/node/handler/PadMessageHandler.js index c52565de3..ffbb74ea8 100644 --- a/src/node/handler/PadMessageHandler.js +++ b/src/node/handler/PadMessageHandler.js @@ -19,8 +19,6 @@ */ -var ERR = require("async-stacktrace"); -var async = require("async"); var padManager = require("../db/PadManager"); var Changeset = require("ep_etherpad-lite/static/js/Changeset"); var AttributePool = require("ep_etherpad-lite/static/js/AttributePool"); @@ -38,6 +36,7 @@ var hooks = require("ep_etherpad-lite/static/js/pluginfw/hooks.js"); var channels = require("channels"); var stats = require('../stats'); var remoteAddress = require("../utils/RemoteAddress").remoteAddress; +const nodeify = require("nodeify"); /** * A associative array that saves informations about a session @@ -54,18 +53,20 @@ exports.sessioninfos = sessioninfos; // Measure total amount of users stats.gauge('totalUsers', function() { - return Object.keys(socketio.sockets.sockets).length -}) + return Object.keys(socketio.sockets.sockets).length; +}); /** * A changeset queue per pad that is processed by handleUserChanges() */ -var padChannels = new channels.channels(handleUserChanges); +var padChannels = new channels.channels(function(data, callback) { + return nodeify(handleUserChanges(data), callback); +}); /** - * Saves the Socket class we need to send and recieve data from the client + * Saves the Socket class we need to send and receive data from the client */ -var socketio; +let socketio; /** * This Method is called by server.js to tell the message handler on which socket it should send @@ -84,7 +85,7 @@ exports.handleConnect = function(client) { stats.meter('connects').mark(); - //Initalize sessioninfos for this new session + // Initalize sessioninfos for this new session sessioninfos[client.id]={}; } @@ -97,11 +98,11 @@ exports.kickSessionsFromPad = function(padID) if(typeof socketio.sockets['clients'] !== 'function') return; - //skip if there is nobody on this pad + // skip if there is nobody on this pad if(_getRoomClients(padID).length == 0) return; - //disconnect everyone from this pad + // disconnect everyone from this pad socketio.sockets.in(padID).json.send({disconnect:"deleted"}); } @@ -109,55 +110,50 @@ exports.kickSessionsFromPad = function(padID) * Handles the disconnection of a user * @param client the client that leaves */ -exports.handleDisconnect = function(client) +exports.handleDisconnect = async function(client) { stats.meter('disconnects').mark(); - //save the padname of this session - var session = sessioninfos[client.id]; - - //if this connection was already etablished with a handshake, send a disconnect message to the others - if(session && session.author) - { + // save the padname of this session + let session = sessioninfos[client.id]; + // if this connection was already etablished with a handshake, send a disconnect message to the others + if (session && session.author) { // Get the IP address from our persistant object - var ip = remoteAddress[client.id]; + let ip = remoteAddress[client.id]; // Anonymize the IP address if IP logging is disabled - if(settings.disableIPlogging) { + if (settings.disableIPlogging) { ip = 'ANONYMOUS'; } - accessLogger.info('[LEAVE] Pad "'+session.padId+'": Author "'+session.author+'" on client '+client.id+' with IP "'+ip+'" left the pad') + accessLogger.info('[LEAVE] Pad "' + session.padId + '": Author "' + session.author + '" on client ' + client.id + ' with IP "' + ip + '" left the pad'); - //get the author color out of the db - authorManager.getAuthorColorId(session.author, function(err, color) - { - ERR(err); + // get the author color out of the db + let color = await authorManager.getAuthorColorId(session.author); - //prepare the notification for the other users on the pad, that this user left - var messageToTheOtherUsers = { - "type": "COLLABROOM", - "data": { - type: "USER_LEAVE", - userInfo: { - "ip": "127.0.0.1", - "colorId": color, - "userAgent": "Anonymous", - "userId": session.author - } + // prepare the notification for the other users on the pad, that this user left + let messageToTheOtherUsers = { + "type": "COLLABROOM", + "data": { + type: "USER_LEAVE", + userInfo: { + "ip": "127.0.0.1", + "colorId": color, + "userAgent": "Anonymous", + "userId": session.author } - }; + } + }; - //Go trough all user that are still on the pad, and send them the USER_LEAVE message - client.broadcast.to(session.padId).json.send(messageToTheOtherUsers); + // Go through all user that are still on the pad, and send them the USER_LEAVE message + client.broadcast.to(session.padId).json.send(messageToTheOtherUsers); - // Allow plugins to hook into users leaving the pad - hooks.callAll("userLeave", session); - }); + // Allow plugins to hook into users leaving the pad + hooks.callAll("userLeave", session); } - //Delete the sessioninfos entrys of this session + // Delete the sessioninfos entrys of this session delete sessioninfos[client.id]; } @@ -166,62 +162,61 @@ exports.handleDisconnect = function(client) * @param client the client that send this message * @param message the message from the client */ -exports.handleMessage = function(client, message) +exports.handleMessage = async function(client, message) { - if(message == null) - { + if (message == null) { return; } - if(!message.type) - { + + if (!message.type) { return; } - var thisSession = sessioninfos[client.id] - if(!thisSession) { + + let thisSession = sessioninfos[client.id]; + + if (!thisSession) { messageLogger.warn("Dropped message from an unknown connection.") return; } - var handleMessageHook = function(callback){ + async function handleMessageHook() { // Allow plugins to bypass the readonly message blocker - hooks.aCallAll("handleMessageSecurity", { client: client, message: message }, function ( err, messages ) { - if(ERR(err, callback)) return; - _.each(messages, function(newMessage){ - if ( newMessage === true ) { - thisSession.readonly = false; - } - }); - }); + let messages = await hooks.aCallAll("handleMessageSecurity", { client: client, message: message }); + + for (let message of messages) { + if (message === true) { + thisSession.readonly = false; + break; + } + } + + let dropMessage = false; - var dropMessage = false; // Call handleMessage hook. If a plugin returns null, the message will be dropped. Note that for all messages // handleMessage will be called, even if the client is not authorized - hooks.aCallAll("handleMessage", { client: client, message: message }, function ( err, messages ) { - if(ERR(err, callback)) return; - _.each(messages, function(newMessage){ - if ( newMessage === null ) { - dropMessage = true; - } - }); - - // If no plugins explicitly told us to drop the message, its ok to proceed - if(!dropMessage){ callback() }; - }); + messages = await hooks.aCallAll("handleMessage", { client: client, message: message }); + for (let message of messages) { + if (message === null ) { + dropMessage = true; + break; + } + } + return dropMessage; } - var finalHandler = function () { - //Check what type of message we get and delegate to the other methodes - if(message.type == "CLIENT_READY") { + function finalHandler() { + // Check what type of message we get and delegate to the other methods + if (message.type == "CLIENT_READY") { handleClientReady(client, message); - } else if(message.type == "CHANGESET_REQ") { + } else if (message.type == "CHANGESET_REQ") { handleChangesetRequest(client, message); } else if(message.type == "COLLABROOM") { if (thisSession.readonly) { messageLogger.warn("Dropped message, COLLABROOM for readonly pad"); } else if (message.data.type == "USER_CHANGES") { stats.counter('pendingEdits').inc() - padChannels.emit(message.padId, {client: client, message: message});// add to pad queue + padChannels.emit(message.padId, {client: client, message: message}); // add to pad queue } else if (message.data.type == "USERINFO_UPDATE") { handleUserInfoUpdate(client, message); } else if (message.data.type == "CHAT_MESSAGE") { @@ -242,11 +237,11 @@ exports.handleMessage = function(client, message) } else { messageLogger.warn("Dropped message, unknown Message Type " + message.type); } - }; + } /* * In a previous version of this code, an "if (message)" wrapped the - * following async.series(). + * following series of async calls [now replaced with await calls] * This ugly "!Boolean(message)" is a lame way to exactly negate the truthy * condition and replace it with an early return, while being sure to leave * the original behaviour unchanged. @@ -257,58 +252,55 @@ exports.handleMessage = function(client, message) return; } - async.series([ - handleMessageHook, - //check permissions - function(callback) - { + let dropMessage = await handleMessageHook(); + if (!dropMessage) { + + // check permissions + + if (message.type == "CLIENT_READY") { // client tried to auth for the first time (first msg from the client) - if(message.type == "CLIENT_READY") { - createSessionInfo(client, message); - } + createSessionInfo(client, message); + } - // Note: message.sessionID is an entirely different kind of - // session from the sessions we use here! Beware! - // FIXME: Call our "sessions" "connections". - // FIXME: Use a hook instead - // FIXME: Allow to override readwrite access with readonly + // Note: message.sessionID is an entirely different kind of + // session from the sessions we use here! Beware! + // FIXME: Call our "sessions" "connections". + // FIXME: Use a hook instead + // FIXME: Allow to override readwrite access with readonly - // Simulate using the load testing tool - if(!sessioninfos[client.id].auth){ - console.error("Auth was never applied to a session. If you are using the stress-test tool then restart Etherpad and the Stress test tool.") - return; - } + // the session may have been dropped during earlier processing + if (!sessioninfos[client.id]) { + messageLogger.warn("Dropping message from a connection that has gone away.") + return; + } - var auth = sessioninfos[client.id].auth; - var checkAccessCallback = function(err, statusObject) - { - if(ERR(err, callback)) return; + // Simulate using the load testing tool + if (!sessioninfos[client.id].auth) { + console.error("Auth was never applied to a session. If you are using the stress-test tool then restart Etherpad and the Stress test tool.") + return; + } - //access was granted - if(statusObject.accessStatus == "grant") - { - callback(); - } - //no access, send the client a message that tell him why - else - { - client.json.send({accessStatus: statusObject.accessStatus}) - } - }; + let auth = sessioninfos[client.id].auth; - //check if pad is requested via readOnly - if (auth.padID.indexOf("r.") === 0) { - //Pad is readOnly, first get the real Pad ID - readOnlyManager.getPadId(auth.padID, function(err, value) { - ERR(err); - securityManager.checkAccess(value, auth.sessionID, auth.token, auth.password, checkAccessCallback); - }); - } else { - securityManager.checkAccess(auth.padID, auth.sessionID, auth.token, auth.password, checkAccessCallback); - } - }, - finalHandler - ]); + // check if pad is requested via readOnly + let padId = auth.padID; + + if (padId.indexOf("r.") === 0) { + // Pad is readOnly, first get the real Pad ID + padId = await readOnlyManager.getPadId(padId); + } + + let { accessStatus } = await securityManager.checkAccess(padId, auth.sessionID, auth.token, auth.password); + + if (accessStatus !== "grant") { + // no access, send the client a message that tells him why + client.json.send({ accessStatus }); + return; + } + + // access was granted + finalHandler(); + } } @@ -317,55 +309,50 @@ exports.handleMessage = function(client, message) * @param client the client that send this message * @param message the message from the client */ -function handleSaveRevisionMessage(client, message){ +async function handleSaveRevisionMessage(client, message) +{ var padId = sessioninfos[client.id].padId; var userId = sessioninfos[client.id].author; - padManager.getPad(padId, function(err, pad) - { - if(ERR(err)) return; - - pad.addSavedRevision(pad.head, userId); - }); + let pad = await padManager.getPad(padId); + pad.addSavedRevision(pad.head, userId); } /** - * Handles a custom message, different to the function below as it handles objects not strings and you can - * direct the message to specific sessionID + * Handles a custom message, different to the function below as it handles + * objects not strings and you can direct the message to specific sessionID * * @param msg {Object} the message we're sending * @param sessionID {string} the socketIO session to which we're sending this message */ -exports.handleCustomObjectMessage = function (msg, sessionID, cb) { - if(msg.data.type === "CUSTOM"){ - if(sessionID){ // If a sessionID is targeted then send directly to this sessionID - socketio.sockets.socket(sessionID).json.send(msg); // send a targeted message - }else{ - socketio.sockets.in(msg.data.payload.padId).json.send(msg); // broadcast to all clients on this pad +exports.handleCustomObjectMessage = function(msg, sessionID) { + if (msg.data.type === "CUSTOM") { + if (sessionID){ + // a sessionID is targeted: directly to this sessionID + socketio.sockets.socket(sessionID).json.send(msg); + } else { + // broadcast to all clients on this pad + socketio.sockets.in(msg.data.payload.padId).json.send(msg); } } - cb(null, {}); } - /** * Handles a custom message (sent via HTTP API request) * * @param padID {Pad} the pad to which we're sending this message - * @param msg {String} the message we're sending + * @param msgString {String} the message we're sending */ -exports.handleCustomMessage = function (padID, msg, cb) { - var time = new Date().getTime(); - var msg = { +exports.handleCustomMessage = function(padID, msgString) { + let time = Date.now(); + let msg = { type: 'COLLABROOM', data: { - type: msg, + type: msgString, time: time } }; socketio.sockets.in(padID).json.send(msg); - - cb(null, {}); } /** @@ -375,7 +362,7 @@ exports.handleCustomMessage = function (padID, msg, cb) { */ function handleChatMessage(client, message) { - var time = new Date().getTime(); + var time = Date.now(); var userId = sessioninfos[client.id].author; var text = message.data.text; var padId = sessioninfos[client.id].padId; @@ -390,56 +377,24 @@ function handleChatMessage(client, message) * @param text the text of the chat message * @param padId the padId to send the chat message to */ -exports.sendChatMessageToPadClients = function (time, userId, text, padId) { - var pad; - var userName; +exports.sendChatMessageToPadClients = async function(time, userId, text, padId) +{ + // get the pad + let pad = await padManager.getPad(padId); - async.series([ - //get the pad - function(callback) - { - padManager.getPad(padId, function(err, _pad) - { - if(ERR(err, callback)) return; - pad = _pad; - callback(); - }); - }, - function(callback) - { - authorManager.getAuthorName(userId, function(err, _userName) - { - if(ERR(err, callback)) return; - userName = _userName; - callback(); - }); - }, - //save the chat message and broadcast it - function(callback) - { - //save the chat message - pad.appendChatMessage(text, userId, time); + // get the author + let userName = await authorManager.getAuthorName(userId); - var msg = { - type: "COLLABROOM", - data: { - type: "CHAT_MESSAGE", - userId: userId, - userName: userName, - time: time, - text: text - } - }; + // save the chat message + pad.appendChatMessage(text, userId, time); - //broadcast the chat message to everyone on the pad - socketio.sockets.in(padId).json.send(msg); + let msg = { + type: "COLLABROOM", + data: { type: "CHAT_MESSAGE", userId, userName, time, text } + }; - callback(); - } - ], function(err) - { - ERR(err); - }); + // broadcast the chat message to everyone on the pad + socketio.sockets.in(padId).json.send(msg); } /** @@ -447,61 +402,41 @@ exports.sendChatMessageToPadClients = function (time, userId, text, padId) { * @param client the client that send this message * @param message the message from the client */ -function handleGetChatMessages(client, message) +async function handleGetChatMessages(client, message) { - if(message.data.start == null) - { - messageLogger.warn("Dropped message, GetChatMessages Message has no start!"); - return; - } - if(message.data.end == null) - { + if (message.data.start == null) { messageLogger.warn("Dropped message, GetChatMessages Message has no start!"); return; } - var start = message.data.start; - var end = message.data.end; - var count = start - count; - - if(count < 0 && count > 100) - { - messageLogger.warn("Dropped message, GetChatMessages Message, client requested invalid amout of messages!"); + if (message.data.end == null) { + messageLogger.warn("Dropped message, GetChatMessages Message has no start!"); return; } - var padId = sessioninfos[client.id].padId; - var pad; + let start = message.data.start; + let end = message.data.end; + let count = end - start; - async.series([ - //get the pad - function(callback) - { - padManager.getPad(padId, function(err, _pad) - { - if(ERR(err, callback)) return; - pad = _pad; - callback(); - }); - }, - function(callback) - { - pad.getChatMessages(start, end, function(err, chatMessages) - { - if(ERR(err, callback)) return; + if (count < 0 || count > 100) { + messageLogger.warn("Dropped message, GetChatMessages Message, client requested invalid amount of messages!"); + return; + } - var infoMsg = { - type: "COLLABROOM", - data: { - type: "CHAT_MESSAGES", - messages: chatMessages - } - }; + let padId = sessioninfos[client.id].padId; + let pad = await padManager.getPad(padId); - // send the messages back to the client - client.json.send(infoMsg); - }); - }]); + let chatMessages = await pad.getChatMessages(start, end); + let infoMsg = { + type: "COLLABROOM", + data: { + type: "CHAT_MESSAGES", + messages: chatMessages + } + }; + + // send the messages back to the client + client.json.send(infoMsg); } /** @@ -511,14 +446,13 @@ function handleGetChatMessages(client, message) */ function handleSuggestUserName(client, message) { - //check if all ok - if(message.data.payload.newName == null) - { + // check if all ok + if (message.data.payload.newName == null) { messageLogger.warn("Dropped message, suggestUserName Message has no newName!"); return; } - if(message.data.payload.unnamedId == null) - { + + if (message.data.payload.unnamedId == null) { messageLogger.warn("Dropped message, suggestUserName Message has no unnamedId!"); return; } @@ -526,12 +460,11 @@ function handleSuggestUserName(client, message) var padId = sessioninfos[client.id].padId; var roomClients = _getRoomClients(padId); - //search the author and send him this message + // search the author and send him this message roomClients.forEach(function(client) { var session = sessioninfos[client.id]; - if(session && session.author == message.data.payload.unnamedId) { + if (session && session.author == message.data.payload.unnamedId) { client.json.send(message); - return; } }); } @@ -543,37 +476,35 @@ function handleSuggestUserName(client, message) */ function handleUserInfoUpdate(client, message) { - //check if all ok - if(message.data.userInfo == null) - { + // check if all ok + if (message.data.userInfo == null) { messageLogger.warn("Dropped message, USERINFO_UPDATE Message has no userInfo!"); return; } - if(message.data.userInfo.colorId == null) - { + + if (message.data.userInfo.colorId == null) { messageLogger.warn("Dropped message, USERINFO_UPDATE Message has no colorId!"); return; } // Check that we have a valid session and author to update. var session = sessioninfos[client.id]; - if(!session || !session.author || !session.padId) - { + if (!session || !session.author || !session.padId) { messageLogger.warn("Dropped message, USERINFO_UPDATE Session not ready." + message.data); return; } - //Find out the author name of this session + // Find out the author name of this session var author = session.author; // Check colorId is a Hex color var isColor = /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(message.data.userInfo.colorId) // for #f00 (Thanks Smamatti) - if(!isColor){ + if (!isColor) { messageLogger.warn("Dropped message, USERINFO_UPDATE Color is malformed." + message.data); return; } - //Tell the authorManager about the new attributes + // Tell the authorManager about the new attributes authorManager.setAuthorColorId(author, message.data.userInfo.colorId); authorManager.setAuthorName(author, message.data.userInfo.name); @@ -586,7 +517,7 @@ function handleUserInfoUpdate(client, message) type: "USER_NEWINFO", userInfo: { userId: author, - //set a null name, when there is no name set. cause the client wants it null + // set a null name, when there is no name set. cause the client wants it null name: message.data.userInfo.name || null, colorId: message.data.userInfo.colorId, userAgent: "Anonymous", @@ -595,7 +526,7 @@ function handleUserInfoUpdate(client, message) } }; - //Send the other clients on the pad the update message + // Send the other clients on the pad the update message client.broadcast.to(padId).json.send(infoMsg); } @@ -613,7 +544,7 @@ function handleUserInfoUpdate(client, message) * @param client the client that send this message * @param message the message from the client */ -function handleUserChanges(data, cb) +async function handleUserChanges(data) { var client = data.client , message = data.message @@ -622,278 +553,227 @@ function handleUserChanges(data, cb) stats.counter('pendingEdits').dec() // Make sure all required fields are present - if(message.data.baseRev == null) - { + if (message.data.baseRev == null) { messageLogger.warn("Dropped message, USER_CHANGES Message has no baseRev!"); - return cb(); - } - if(message.data.apool == null) - { - messageLogger.warn("Dropped message, USER_CHANGES Message has no apool!"); - return cb(); - } - if(message.data.changeset == null) - { - messageLogger.warn("Dropped message, USER_CHANGES Message has no changeset!"); - return cb(); - } - //TODO: this might happen with other messages too => find one place to copy the session - //and always use the copy. atm a message will be ignored if the session is gone even - //if the session was valid when the message arrived in the first place - if(!sessioninfos[client.id]) - { - messageLogger.warn("Dropped message, disconnect happened in the mean time"); - return cb(); + return; } - //get all Vars we need + if (message.data.apool == null) { + messageLogger.warn("Dropped message, USER_CHANGES Message has no apool!"); + return; + } + + if (message.data.changeset == null) { + messageLogger.warn("Dropped message, USER_CHANGES Message has no changeset!"); + return; + } + + // TODO: this might happen with other messages too => find one place to copy the session + // and always use the copy. atm a message will be ignored if the session is gone even + // if the session was valid when the message arrived in the first place + if (!sessioninfos[client.id]) { + messageLogger.warn("Dropped message, disconnect happened in the mean time"); + return; + } + + // get all Vars we need var baseRev = message.data.baseRev; var wireApool = (new AttributePool()).fromJsonable(message.data.apool); var changeset = message.data.changeset; + // The client might disconnect between our callbacks. We should still // finish processing the changeset, so keep a reference to the session. var thisSession = sessioninfos[client.id]; - var r, apool, pad; - // Measure time to process edit var stopWatch = stats.timer('edits').start(); - async.series([ - //get the pad - function(callback) - { - padManager.getPad(thisSession.padId, function(err, value) - { - if(ERR(err, callback)) return; - pad = value; - callback(); + // get the pad + let pad = await padManager.getPad(thisSession.padId); + + // create the changeset + try { + try { + // Verify that the changeset has valid syntax and is in canonical form + Changeset.checkRep(changeset); + + // Verify that the attribute indexes used in the changeset are all + // defined in the accompanying attribute pool. + Changeset.eachAttribNumber(changeset, function(n) { + if (!wireApool.getAttrib(n)) { + throw new Error("Attribute pool is missing attribute " + n + " for changeset " + changeset); + } }); - }, - //create the changeset - function(callback) - { - //ex. _checkChangesetAndPool - try - { - // Verify that the changeset has valid syntax and is in canonical form - Changeset.checkRep(changeset); + // Validate all added 'author' attribs to be the same value as the current user + var iterator = Changeset.opIterator(Changeset.unpack(changeset).ops) + , op; - // Verify that the attribute indexes used in the changeset are all - // defined in the accompanying attribute pool. - Changeset.eachAttribNumber(changeset, function(n) { - if (! wireApool.getAttrib(n)) { - throw new Error("Attribute pool is missing attribute "+n+" for changeset "+changeset); + while (iterator.hasNext()) { + op = iterator.next() + + // + can add text with attribs + // = can change or add attribs + // - can have attribs, but they are discarded and don't show up in the attribs - but do show up in the pool + + op.attribs.split('*').forEach(function(attr) { + if (!attr) return; + + attr = wireApool.getAttrib(attr); + if (!attr) return; + + // the empty author is used in the clearAuthorship functionality so this should be the only exception + if ('author' == attr[0] && (attr[1] != thisSession.author && attr[1] != '')) { + throw new Error("Trying to submit changes as another author in changeset " + changeset); } }); + } - // Validate all added 'author' attribs to be the same value as the current user - var iterator = Changeset.opIterator(Changeset.unpack(changeset).ops) - , op - while(iterator.hasNext()) { - op = iterator.next() + // ex. adoptChangesetAttribs - //+ can add text with attribs - //= can change or add attribs - //- can have attribs, but they are discarded and don't show up in the attribs - but do show up in the pool + // Afaik, it copies the new attributes from the changeset, to the global Attribute Pool + changeset = Changeset.moveOpsToNewPool(changeset, wireApool, pad.pool); - op.attribs.split('*').forEach(function(attr) { - if(!attr) return - attr = wireApool.getAttrib(attr) - if(!attr) return - //the empty author is used in the clearAuthorship functionality so this should be the only exception - if('author' == attr[0] && (attr[1] != thisSession.author && attr[1] != '')) throw new Error("Trying to submit changes as another author in changeset "+changeset); - }) + } catch(e) { + // There is an error in this changeset, so just refuse it + client.json.send({ disconnect: "badChangeset" }); + stats.meter('failedChangesets').mark(); + throw new Error("Can't apply USER_CHANGES, because " + e.message); + } + + // ex. applyUserChanges + let apool = pad.pool; + let r = baseRev; + + // The client's changeset might not be based on the latest revision, + // since other clients are sending changes at the same time. + // Update the changeset so that it can be applied to the latest revision. + while (r < pad.getHeadRevisionNumber()) { + r++; + + let c = await pad.getRevisionChangeset(r); + + // At this point, both "c" (from the pad) and "changeset" (from the + // client) are relative to revision r - 1. The follow function + // rebases "changeset" so that it is relative to revision r + // and can be applied after "c". + + try { + // a changeset can be based on an old revision with the same changes in it + // prevent eplite from accepting it TODO: better send the client a NEW_CHANGES + // of that revision + if (baseRev + 1 == r && c == changeset) { + client.json.send({disconnect:"badChangeset"}); + stats.meter('failedChangesets').mark(); + throw new Error("Won't apply USER_CHANGES, because it contains an already accepted changeset"); } - //ex. adoptChangesetAttribs - - //Afaik, it copies the new attributes from the changeset, to the global Attribute Pool - changeset = Changeset.moveOpsToNewPool(changeset, wireApool, pad.pool); - } - catch(e) - { - // There is an error in this changeset, so just refuse it + changeset = Changeset.follow(c, changeset, false, apool); + } catch(e) { client.json.send({disconnect:"badChangeset"}); stats.meter('failedChangesets').mark(); - return callback(new Error("Can't apply USER_CHANGES, because "+e.message)); + throw new Error("Can't apply USER_CHANGES, because " + e.message); } - - //ex. applyUserChanges - apool = pad.pool; - r = baseRev; - - // The client's changeset might not be based on the latest revision, - // since other clients are sending changes at the same time. - // Update the changeset so that it can be applied to the latest revision. - //https://github.com/caolan/async#whilst - async.whilst( - function() { return r < pad.getHeadRevisionNumber(); }, - function(callback) - { - r++; - - pad.getRevisionChangeset(r, function(err, c) - { - if(ERR(err, callback)) return; - - // At this point, both "c" (from the pad) and "changeset" (from the - // client) are relative to revision r - 1. The follow function - // rebases "changeset" so that it is relative to revision r - // and can be applied after "c". - try - { - // a changeset can be based on an old revision with the same changes in it - // prevent eplite from accepting it TODO: better send the client a NEW_CHANGES - // of that revision - if(baseRev+1 == r && c == changeset) { - client.json.send({disconnect:"badChangeset"}); - stats.meter('failedChangesets').mark(); - return callback(new Error("Won't apply USER_CHANGES, because it contains an already accepted changeset")); - } - changeset = Changeset.follow(c, changeset, false, apool); - }catch(e){ - client.json.send({disconnect:"badChangeset"}); - stats.meter('failedChangesets').mark(); - return callback(new Error("Can't apply USER_CHANGES, because "+e.message)); - } - - if ((r - baseRev) % 200 == 0) { // don't let the stack get too deep - async.nextTick(callback); - } else { - callback(null); - } - }); - }, - //use the callback of the series function - callback - ); - }, - //do correction changesets, and send it to all users - function (callback) - { - var prevText = pad.text(); - - if (Changeset.oldLen(changeset) != prevText.length) - { - client.json.send({disconnect:"badChangeset"}); - stats.meter('failedChangesets').mark(); - return callback(new Error("Can't apply USER_CHANGES "+changeset+" with oldLen " + Changeset.oldLen(changeset) + " to document of length " + prevText.length)); - } - - try - { - pad.appendRevision(changeset, thisSession.author); - } - catch(e) - { - client.json.send({disconnect:"badChangeset"}); - stats.meter('failedChangesets').mark(); - return callback(e) - } - - var correctionChangeset = _correctMarkersInPad(pad.atext, pad.pool); - if (correctionChangeset) { - pad.appendRevision(correctionChangeset); - } - - // Make sure the pad always ends with an empty line. - if (pad.text().lastIndexOf("\n") != pad.text().length-1) { - var nlChangeset = Changeset.makeSplice(pad.text(), pad.text().length-1, - 0, "\n"); - pad.appendRevision(nlChangeset); - } - - exports.updatePadClients(pad, function(er) { - ERR(er) - }); - callback(); } - ], function(err) - { - stopWatch.end() - cb(); - if(err) console.warn(err.stack || err) - }); + + let prevText = pad.text(); + + if (Changeset.oldLen(changeset) != prevText.length) { + client.json.send({disconnect:"badChangeset"}); + stats.meter('failedChangesets').mark(); + throw new Error("Can't apply USER_CHANGES "+changeset+" with oldLen " + Changeset.oldLen(changeset) + " to document of length " + prevText.length); + } + + try { + pad.appendRevision(changeset, thisSession.author); + } catch(e) { + client.json.send({ disconnect: "badChangeset" }); + stats.meter('failedChangesets').mark(); + throw e; + } + + let correctionChangeset = _correctMarkersInPad(pad.atext, pad.pool); + if (correctionChangeset) { + pad.appendRevision(correctionChangeset); + } + + // Make sure the pad always ends with an empty line. + if (pad.text().lastIndexOf("\n") != pad.text().length-1) { + var nlChangeset = Changeset.makeSplice(pad.text(), pad.text().length - 1, 0, "\n"); + pad.appendRevision(nlChangeset); + } + + await exports.updatePadClients(pad); + } catch (err) { + console.warn(err.stack || err); + } + + stopWatch.end(); } -exports.updatePadClients = function(pad, callback) +exports.updatePadClients = async function(pad) { - //skip this step if noone is on this pad - var roomClients = _getRoomClients(pad.id); + // skip this if no-one is on this pad + let roomClients = _getRoomClients(pad.id); - if(roomClients.length==0) - return callback(); + if (roomClients.length == 0) { + return; + } // since all clients usually get the same set of changesets, store them in local cache // to remove unnecessary roundtrip to the datalayer + // NB: note below possibly now accommodated via the change to promises/async // TODO: in REAL world, if we're working without datalayer cache, all requests to revisions will be fired // BEFORE first result will be landed to our cache object. The solution is to replace parallel processing // via async.forEach with sequential for() loop. There is no real benefits of running this in parallel, // but benefit of reusing cached revision object is HUGE - var revCache = {}; + let revCache = {}; - //go trough all sessions on this pad - async.forEach(roomClients, function(client, callback){ - var sid = client.id; - //https://github.com/caolan/async#whilst - //send them all new changesets - async.whilst( - function (){ return sessioninfos[sid] && sessioninfos[sid].rev < pad.getHeadRevisionNumber()}, - function(callback) - { - var r = sessioninfos[sid].rev + 1; + // go through all sessions on this pad + for (let client of roomClients) { + let sid = client.id; - async.waterfall([ - function(callback) { - if(revCache[r]) - callback(null, revCache[r]); - else - pad.getRevision(r, callback); - }, - function(revision, callback) - { - revCache[r] = revision; + // send them all new changesets + while (sessioninfos[sid] && sessioninfos[sid].rev < pad.getHeadRevisionNumber()) { + let r = sessioninfos[sid].rev + 1; + let revision = revCache[r]; + if (!revision) { + revision = await pad.getRevision(r); + revCache[r] = revision; + } - var author = revision.meta.author, - revChangeset = revision.changeset, - currentTime = revision.meta.timestamp; + let author = revision.meta.author, + revChangeset = revision.changeset, + currentTime = revision.meta.timestamp; - // next if session has not been deleted - if(sessioninfos[sid] == null) - return callback(null); + // next if session has not been deleted + if (sessioninfos[sid] == null) { + continue; + } - if(author == sessioninfos[sid].author) - { - client.json.send({"type":"COLLABROOM","data":{type:"ACCEPT_COMMIT", newRev:r}}); - } - else - { - var forWire = Changeset.prepareForWire(revChangeset, pad.pool); - var wireMsg = {"type":"COLLABROOM", - "data":{type:"NEW_CHANGES", - newRev:r, - changeset: forWire.translated, - apool: forWire.pool, - author: author, - currentTime: currentTime, - timeDelta: currentTime - sessioninfos[sid].time - }}; + if (author == sessioninfos[sid].author) { + client.json.send({ "type": "COLLABROOM", "data":{ type: "ACCEPT_COMMIT", newRev: r }}); + } else { + let forWire = Changeset.prepareForWire(revChangeset, pad.pool); + let wireMsg = {"type": "COLLABROOM", + "data": { type:"NEW_CHANGES", + newRev:r, + changeset: forWire.translated, + apool: forWire.pool, + author: author, + currentTime: currentTime, + timeDelta: currentTime - sessioninfos[sid].time + }}; - client.json.send(wireMsg); - } - if(sessioninfos[sid]){ - sessioninfos[sid].time = currentTime; - sessioninfos[sid].rev = r; - } - callback(null); - } - ], callback); - }, - callback - ); - },callback); + client.json.send(wireMsg); + } + + if (sessioninfos[sid]) { + sessioninfos[sid].time = currentTime; + sessioninfos[sid].rev = r; + } + } + } } /** @@ -910,19 +790,18 @@ function _correctMarkersInPad(atext, apool) { while (iter.hasNext()) { var op = iter.next(); - var hasMarker = _.find(AttributeManager.lineAttributes, function(attribute){ + var hasMarker = _.find(AttributeManager.lineAttributes, function(attribute) { return Changeset.opAttributeValue(op, attribute, apool); }) !== undefined; if (hasMarker) { - for(var i=0;i 0 && text.charAt(offset-1) != '\n') { badMarkers.push(offset); } offset++; } - } - else { + } else { offset += op.chars; } } @@ -933,25 +812,28 @@ function _correctMarkersInPad(atext, apool) { // 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(); } function handleSwitchToPad(client, message) { // clear the session and leave the room - var currentSession = sessioninfos[client.id]; - var padId = currentSession.padId; - var roomClients = _getRoomClients(padId); + let currentSession = sessioninfos[client.id]; + let padId = currentSession.padId; + let roomClients = _getRoomClients(padId); - async.forEach(roomClients, function(client, callback) { - var sinfo = sessioninfos[client.id]; - if(sinfo && sinfo.author == currentSession.author) { + roomClients.forEach(client => { + let sinfo = sessioninfos[client.id]; + if (sinfo && sinfo.author == currentSession.author) { // fix user's counter, works on page refresh or if user closes browser window and then rejoins sessioninfos[client.id] = {}; client.leave(padId); @@ -985,816 +867,566 @@ function createSessionInfo(client, message) * @param client the client that send this message * @param message the message from the client */ -function handleClientReady(client, message) +async function handleClientReady(client, message) { - //check if all ok - if(!message.token) - { + // check if all ok + if (!message.token) { messageLogger.warn("Dropped message, CLIENT_READY Message has no token!"); return; } - if(!message.padId) - { + + if (!message.padId) { messageLogger.warn("Dropped message, CLIENT_READY Message has no padId!"); return; } - if(!message.protocolVersion) - { + + if (!message.protocolVersion) { messageLogger.warn("Dropped message, CLIENT_READY Message has no protocolVersion!"); return; } - if(message.protocolVersion != 2) - { + + if (message.protocolVersion != 2) { messageLogger.warn("Dropped message, CLIENT_READY Message has a unknown protocolVersion '" + message.protocolVersion + "'!"); return; } - var author; - var authorName; - var authorColorId; - var pad; - var historicalAuthorData = {}; - var currentTime; - var padIds; - hooks.callAll("clientReady", message); - async.series([ - //Get ro/rw id:s - function (callback) - { - readOnlyManager.getIds(message.padId, function(err, value) { - if(ERR(err, callback)) return; - padIds = value; - callback(); - }); - }, - //check permissions - function(callback) - { - // Note: message.sessionID is an entierly different kind of - // session from the sessions we use here! Beware! FIXME: Call - // our "sessions" "connections". - // FIXME: Use a hook instead - // FIXME: Allow to override readwrite access with readonly - securityManager.checkAccess (padIds.padId, message.sessionID, message.token, message.password, function(err, statusObject) - { - if(ERR(err, callback)) return; + // Get ro/rw id:s + let padIds = await readOnlyManager.getIds(message.padId); - //access was granted - if(statusObject.accessStatus == "grant") - { - author = statusObject.authorID; - callback(); - } - //no access, send the client a message that tell him why - else - { - client.json.send({accessStatus: statusObject.accessStatus}) - } - }); - }, - //get all authordata of this new user, and load the pad-object from the database - function(callback) - { - async.parallel([ - //get colorId and name - function(callback) - { - authorManager.getAuthor(author, function(err, value) - { - if(ERR(err, callback)) return; - authorColorId = value.colorId; - authorName = value.name; - callback(); - }); - }, - //get pad - function(callback) - { - padManager.getPad(padIds.padId, function(err, value) - { - if(ERR(err, callback)) return; - pad = value; - callback(); - }); - } - ], callback); - }, - //these db requests all need the pad object (timestamp of latest revission, author data) - function(callback) - { - var authors = pad.getAllAuthors(); + // check permissions - async.parallel([ - //get timestamp of latest revission needed for timeslider - function(callback) - { - pad.getRevisionDate(pad.getHeadRevisionNumber(), function(err, date) - { - if(ERR(err, callback)) return; - currentTime = date; - callback(); - }); - }, - //get all author data out of the database - function(callback) - { - async.forEach(authors, function(authorId, callback) - { - authorManager.getAuthor(authorId, function(err, author) - { - if(!author && !err) - { - messageLogger.error("There is no author for authorId:", authorId); - return callback(); - } - if(ERR(err, callback)) return; - historicalAuthorData[authorId] = {name: author.name, colorId: author.colorId}; // Filter author attribs (e.g. don't send author's pads to all clients) - callback(); - }); - }, callback); - } - ], callback); + // Note: message.sessionID is an entierly different kind of + // session from the sessions we use here! Beware! + // FIXME: Call our "sessions" "connections". + // FIXME: Use a hook instead + // FIXME: Allow to override readwrite access with readonly + let statusObject = await securityManager.checkAccess(padIds.padId, message.sessionID, message.token, message.password); + let accessStatus = statusObject.accessStatus; - }, - //glue the clientVars together, send them and tell the other clients that a new one is there - function(callback) - { - //Check that the client is still here. It might have disconnected between callbacks. - if(sessioninfos[client.id] === undefined) - return callback(); + // no access, send the client a message that tells him why + if (accessStatus !== "grant") { + client.json.send({ accessStatus }); + return; + } - //Check if this author is already on the pad, if yes, kick the other sessions! - var roomClients = _getRoomClients(pad.id); + let author = statusObject.authorID; - async.forEach(roomClients, function(client, callback) { - var sinfo = sessioninfos[client.id]; - if(sinfo && sinfo.author == author) { - // fix user's counter, works on page refresh or if user closes browser window and then rejoins - sessioninfos[client.id] = {}; - client.leave(padIds.padId); - client.json.send({disconnect:"userdup"}); - } - }); + // get all authordata of this new user + let value = await authorManager.getAuthor(author); + let authorColorId = value.colorId; + let authorName = value.name; - //Save in sessioninfos that this session belonges to this pad - sessioninfos[client.id].padId = padIds.padId; - sessioninfos[client.id].readOnlyPadId = padIds.readOnlyPadId; - sessioninfos[client.id].readonly = padIds.readonly; + // load the pad-object from the database + let pad = await padManager.getPad(padIds.padId); - //Log creation/(re-)entering of a pad - var ip = remoteAddress[client.id]; + // these db requests all need the pad object (timestamp of latest revision, author data) + let authors = pad.getAllAuthors(); - //Anonymize the IP address if IP logging is disabled - if(settings.disableIPlogging) { - ip = 'ANONYMOUS'; + // get timestamp of latest revision needed for timeslider + let currentTime = await pad.getRevisionDate(pad.getHeadRevisionNumber()); + + // get all author data out of the database (in parallel) + let historicalAuthorData = {}; + await Promise.all(authors.map(authorId => { + return authorManager.getAuthor(authorId).then(author => { + if (!author) { + messageLogger.error("There is no author for authorId:", authorId); + } else { + historicalAuthorData[authorId] = { name: author.name, colorId: author.colorId }; // Filter author attribs (e.g. don't send author's pads to all clients) } + }); + })); - if(pad.head > 0) { - accessLogger.info('[ENTER] Pad "'+padIds.padId+'": Client '+client.id+' with IP "'+ip+'" entered the pad'); - } - else if(pad.head == 0) { - accessLogger.info('[CREATE] Pad "'+padIds.padId+'": Client '+client.id+' with IP "'+ip+'" created the pad'); - } + // glue the clientVars together, send them and tell the other clients that a new one is there - //If this is a reconnect, we don't have to send the client the ClientVars again - if(message.reconnect == true) - { - //Join the pad and start receiving updates - client.join(padIds.padId); - //Save the revision in sessioninfos, we take the revision from the info the client send to us - sessioninfos[client.id].rev = message.client_rev; + // Check that the client is still here. It might have disconnected between callbacks. + if (sessioninfos[client.id] === undefined) { + return; + } - //During the client reconnect, client might miss some revisions from other clients. By using client revision, - //this below code sends all the revisions missed during the client reconnect - var revisionsNeeded = []; - var changesets = {}; + // Check if this author is already on the pad, if yes, kick the other sessions! + let roomClients = _getRoomClients(pad.id); - var startNum = message.client_rev + 1; - var endNum = pad.getHeadRevisionNumber() + 1; - - async.series([ - //push all the revision numbers needed into revisionsNeeded array - function(callback) - { - var headNum = pad.getHeadRevisionNumber(); - if (endNum > headNum+1) - endNum = headNum+1; - if (startNum < 0) - startNum = 0; - - for(var r=startNum;r 0) { + accessLogger.info('[ENTER] Pad "' + padIds.padId + '": Client ' + client.id + ' with IP "' + ip + '" entered the pad'); + } else if (pad.head == 0) { + accessLogger.info('[CREATE] Pad "' + padIds.padId + '": Client ' + client.id + ' with IP "' + ip + '" created the pad'); + } + + if (message.reconnect) { + // If this is a reconnect, we don't have to send the client the ClientVars again + // Join the pad and start receiving updates + client.join(padIds.padId); + + // Save the revision in sessioninfos, we take the revision from the info the client send to us + sessioninfos[client.id].rev = message.client_rev; + + // During the client reconnect, client might miss some revisions from other clients. By using client revision, + // this below code sends all the revisions missed during the client reconnect + var revisionsNeeded = []; + var changesets = {}; + + var startNum = message.client_rev + 1; + var endNum = pad.getHeadRevisionNumber() + 1; + + var headNum = pad.getHeadRevisionNumber(); + + if (endNum > headNum + 1) { + endNum = headNum + 1; + } + + if (startNum < 0) { + startNum = 0; + } + + for (let r = startNum; r < endNum; r++) { + revisionsNeeded.push(r); + changesets[r] = {}; + } + + // get changesets, author and timestamp needed for pending revisions (in parallel) + let promises = []; + for (let revNum of revisionsNeeded) { + let cs = changesets[revNum]; + promises.push( pad.getRevisionChangeset(revNum).then(result => cs.changeset = result )); + promises.push( pad.getRevisionAuthor(revNum).then(result => cs.author = result )); + promises.push( pad.getRevisionDate(revNum).then(result => cs.timestamp = result )); + } + await Promise.all(promises); + + // return pending changesets + for (let r of revisionsNeeded) { + + let forWire = Changeset.prepareForWire(changesets[r]['changeset'], pad.pool); + let wireMsg = {"type":"COLLABROOM", + "data":{type:"CLIENT_RECONNECT", + headRev:pad.getHeadRevisionNumber(), + newRev:r, + changeset:forWire.translated, + apool: forWire.pool, + author: changesets[r]['author'], + currentTime: changesets[r]['timestamp'] + }}; + client.json.send(wireMsg); + } + + if (startNum == endNum) { + var Msg = {"type":"COLLABROOM", + "data":{type:"CLIENT_RECONNECT", + noChanges: true, + newRev: pad.getHeadRevisionNumber() + }}; + client.json.send(Msg); + } + + } else { + // This is a normal first connect + + // prepare all values for the wire, there's a chance that this throws, if the pad is corrupted + try { + var atext = Changeset.cloneAText(pad.atext); + var attribsForWire = Changeset.prepareForWire(atext.attribs, pad.pool); + var apool = attribsForWire.pool.toJsonable(); + atext.attribs = attribsForWire.translated; + } catch(e) { + console.error(e.stack || e) + client.json.send({ disconnect:"corruptPad" }); // pull the brakes + + return; + } + + // Warning: never ever send padIds.padId to the client. If the + // client is read only you would open a security hole 1 swedish + // mile wide... + var clientVars = { + "skinName": settings.skinName, + "accountPrivs": { + "maxRevisions": 100 + }, + "automaticReconnectionTimeout": settings.automaticReconnectionTimeout, + "initialRevisionList": [], + "initialOptions": { + "guestPolicy": "deny" + }, + "savedRevisions": pad.getSavedRevisions(), + "collab_client_vars": { + "initialAttributedText": atext, + "clientIp": "127.0.0.1", + "padId": message.padId, + "historicalAuthorData": historicalAuthorData, + "apool": apool, + "rev": pad.getHeadRevisionNumber(), + "time": currentTime, + }, + "colorPalette": authorManager.getColorPalette(), + "clientIp": "127.0.0.1", + "userIsGuest": true, + "userColor": authorColorId, + "padId": message.padId, + "padOptions": settings.padOptions, + "padShortcutEnabled": settings.padShortcutEnabled, + "initialTitle": "Pad: " + message.padId, + "opts": {}, + // tell the client the number of the latest chat-message, which will be + // used to request the latest 100 chat-messages later (GET_CHAT_MESSAGES) + "chatHead": pad.chatHead, + "numConnectedUsers": roomClients.length, + "readOnlyId": padIds.readOnlyPadId, + "readonly": padIds.readonly, + "serverTimestamp": Date.now(), + "userId": author, + "abiwordAvailable": settings.abiwordAvailable(), + "sofficeAvailable": settings.sofficeAvailable(), + "exportAvailable": settings.exportAvailable(), + "plugins": { + "plugins": plugins.plugins, + "parts": plugins.parts, + }, + "indentationOnNewLine": settings.indentationOnNewLine, + "scrollWhenFocusLineIsOutOfViewport": { + "percentage" : { + "editionAboveViewport": settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionAboveViewport, + "editionBelowViewport": settings.scrollWhenFocusLineIsOutOfViewport.percentage.editionBelowViewport, + }, + "duration": settings.scrollWhenFocusLineIsOutOfViewport.duration, + "scrollWhenCaretIsInTheLastLineOfViewport": settings.scrollWhenFocusLineIsOutOfViewport.scrollWhenCaretIsInTheLastLineOfViewport, + "percentageToScrollWhenUserPressesArrowUp": settings.scrollWhenFocusLineIsOutOfViewport.percentageToScrollWhenUserPressesArrowUp, + }, + "initialChangesets": [] // FIXME: REMOVE THIS SHIT + } + + // Add a username to the clientVars if one avaiable + if (authorName != null) { + clientVars.userName = authorName; + } + + // call the clientVars-hook so plugins can modify them before they get sent to the client + let messages = await hooks.aCallAll("clientVars", { clientVars: clientVars, pad: pad }); + + // combine our old object with the new attributes from the hook + for (let msg of messages) { + Object.assign(clientVars, msg); + } + + // Join the pad and start receiving updates + client.join(padIds.padId); + + // Send the clientVars to the Client + client.json.send({type: "CLIENT_VARS", data: clientVars}); + + // Save the current revision in sessioninfos, should be the same as in clientVars + sessioninfos[client.id].rev = pad.getHeadRevisionNumber(); + + sessioninfos[client.id].author = author; + + // prepare the notification for the other users on the pad, that this user joined + let messageToTheOtherUsers = { + "type": "COLLABROOM", + "data": { + type: "USER_NEWINFO", + userInfo: { + "ip": "127.0.0.1", + "colorId": authorColorId, + "userAgent": "Anonymous", + "userId": author + } + } + }; + + // Add the authorname of this new User, if avaiable + if (authorName != null) { + messageToTheOtherUsers.data.userInfo.name = authorName; + } + + // notify all existing users about new user + client.broadcast.to(padIds.padId).json.send(messageToTheOtherUsers); + + // Get sessions for this pad and update them (in parallel) + roomClients = _getRoomClients(pad.id); + await Promise.all(_getRoomClients(pad.id).map(async roomClient => { + + // Jump over, if this session is the connection session + if (roomClient.id == client.id) { + return; + } + + // Since sessioninfos might change while being enumerated, check if the + // sessionID is still assigned to a valid session + if (sessioninfos[roomClient.id] === undefined) { + return; + } + + // get the authorname & colorId + let author = sessioninfos[roomClient.id].author; + let cached = historicalAuthorData[author]; + + // reuse previously created cache of author's data + let p = cached ? Promise.resolve(cached) : authorManager.getAuthor(author); + + return p.then(authorInfo => { + // Send the new User a Notification about this other user + let msg = { + "type": "COLLABROOM", + "data": { + type: "USER_NEWINFO", + userInfo: { + "ip": "127.0.0.1", + "colorId": authorInfo.colorId, + "name": authorInfo.name, + "userAgent": "Anonymous", + "userId": author + } + } + }; + + client.json.send(msg); + }); + })); + } } /** * Handles a request for a rough changeset, the timeslider client needs it */ -function handleChangesetRequest(client, message) +async function handleChangesetRequest(client, message) { - //check if all ok - if(message.data == null) - { + // check if all ok + if (message.data == null) { messageLogger.warn("Dropped message, changeset request has no data!"); return; } - if(message.padId == null) - { + + if (message.padId == null) { messageLogger.warn("Dropped message, changeset request has no padId!"); return; } - if(message.data.granularity == null) - { + + if (message.data.granularity == null) { messageLogger.warn("Dropped message, changeset request has no granularity!"); return; } - //https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isInteger#Polyfill - if(Math.floor(message.data.granularity) !== message.data.granularity) - { + + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/isInteger#Polyfill + if (Math.floor(message.data.granularity) !== message.data.granularity) { messageLogger.warn("Dropped message, changeset request granularity is not an integer!"); return; } - if(message.data.start == null) - { + + if (message.data.start == null) { messageLogger.warn("Dropped message, changeset request has no start!"); return; } - if(message.data.requestID == null) - { + + if (message.data.requestID == null) { messageLogger.warn("Dropped message, changeset request has no requestID!"); return; } - var granularity = message.data.granularity; - var start = message.data.start; - var end = start + (100 * granularity); - var padIds; + let granularity = message.data.granularity; + let start = message.data.start; + let end = start + (100 * granularity); - async.series([ - function (callback) { - readOnlyManager.getIds(message.padId, function(err, value) { - if(ERR(err, callback)) return; - padIds = value; - callback(); - }); - }, - function (callback) { - //build the requested rough changesets and send them back - getChangesetInfo(padIds.padId, start, end, granularity, function(err, changesetInfo) - { - if(err) return console.error('Error while handling a changeset request for '+padIds.padId, err, message.data); + let padIds = await readOnlyManager.getIds(message.padId); - var data = changesetInfo; - data.requestID = message.data.requestID; - - client.json.send({type: "CHANGESET_REQ", data: data}); - }); - } - ]); + // build the requested rough changesets and send them back + try { + let data = await getChangesetInfo(padIds.padId, start, end, granularity); + data.requestID = message.data.requestID; + client.json.send({ type: "CHANGESET_REQ", data }); + } catch (err) { + console.error('Error while handling a changeset request for ' + padIds.padId, err, message.data); + } } - /** * Tries to rebuild the getChangestInfo function of the original Etherpad * https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L144 */ -function getChangesetInfo(padId, startNum, endNum, granularity, callback) +async function getChangesetInfo(padId, startNum, endNum, granularity) { - var forwardsChangesets = []; - var backwardsChangesets = []; - var timeDeltas = []; - var apool = new AttributePool(); - var pad; - var composedChangesets = {}; - var revisionDate = []; - var lines; - var head_revision = 0; + let pad = await padManager.getPad(padId); + let head_revision = pad.getHeadRevisionNumber(); - async.series([ - //get the pad from the database - function(callback) - { - padManager.getPad(padId, function(err, _pad) - { - if(ERR(err, callback)) return; - pad = _pad; - head_revision = pad.getHeadRevisionNumber(); - callback(); - }); - }, - function(callback) - { - //calculate the last full endnum - var lastRev = pad.getHeadRevisionNumber(); - if (endNum > lastRev+1) { - endNum = lastRev+1; - } - endNum = Math.floor(endNum / granularity)*granularity; + // calculate the last full endnum + if (endNum > head_revision + 1) { + endNum = head_revision + 1; + } + endNum = Math.floor(endNum / granularity) * granularity; - var compositesChangesetNeeded = []; - var revTimesNeeded = []; + let compositesChangesetNeeded = []; + let revTimesNeeded = []; - //figure out which composite Changeset and revTimes we need, to load them in bulk - var compositeStart = startNum; - while (compositeStart < endNum) - { - var compositeEnd = compositeStart + granularity; + // figure out which composite Changeset and revTimes we need, to load them in bulk + for (let start = startNum; start < endNum; start += granularity) { + let end = start + granularity; - //add the composite Changeset we needed - compositesChangesetNeeded.push({start: compositeStart, end: compositeEnd}); + // add the composite Changeset we needed + compositesChangesetNeeded.push({ start, end }); - //add the t1 time we need - revTimesNeeded.push(compositeStart == 0 ? 0 : compositeStart - 1); - //add the t2 time we need - revTimesNeeded.push(compositeEnd - 1); + // add the t1 time we need + revTimesNeeded.push(start == 0 ? 0 : start - 1); - compositeStart += granularity; - } + // add the t2 time we need + revTimesNeeded.push(end - 1); + } - //get all needed db values parallel - async.parallel([ - function(callback) - { - //get all needed composite Changesets - async.forEach(compositesChangesetNeeded, function(item, callback) - { - composePadChangesets(padId, item.start, item.end, function(err, changeset) - { - if(ERR(err, callback)) return; - composedChangesets[item.start + "/" + item.end] = changeset; - callback(); - }); - }, callback); - }, - function(callback) - { - //get all needed revision Dates - async.forEach(revTimesNeeded, function(revNum, callback) - { - pad.getRevisionDate(revNum, function(err, revDate) - { - if(ERR(err, callback)) return; - revisionDate[revNum] = Math.floor(revDate/1000); - callback(); - }); - }, callback); - }, - //get the lines - function(callback) - { - getPadLines(padId, startNum-1, function(err, _lines) - { - if(ERR(err, callback)) return; - lines = _lines; - callback(); - }); - } - ], callback); - }, - //doesn't know what happens here excatly :/ - function(callback) - { - var compositeStart = startNum; + // get all needed db values parallel - no await here since + // it would make all the lookups run in series - while (compositeStart < endNum) - { - var compositeEnd = compositeStart + granularity; - if (compositeEnd > endNum || compositeEnd > head_revision+1) - { - break; - } + // get all needed composite Changesets + let composedChangesets = {}; + let p1 = Promise.all(compositesChangesetNeeded.map(item => { + return composePadChangesets(padId, item.start, item.end).then(changeset => { + composedChangesets[item.start + "/" + item.end] = changeset; + }); + })); - var forwards = composedChangesets[compositeStart + "/" + compositeEnd]; - var backwards = Changeset.inverse(forwards, lines.textlines, lines.alines, pad.apool()); + // get all needed revision Dates + let revisionDate = []; + let p2 = Promise.all(revTimesNeeded.map(revNum => { + return pad.getRevisionDate(revNum).then(revDate => { + revisionDate[revNum] = Math.floor(revDate / 1000); + }); + })); - Changeset.mutateAttributionLines(forwards, lines.alines, pad.apool()); - Changeset.mutateTextLines(forwards, lines.textlines); - - var forwards2 = Changeset.moveOpsToNewPool(forwards, pad.apool(), apool); - var backwards2 = Changeset.moveOpsToNewPool(backwards, pad.apool(), apool); - - var t1, t2; - if (compositeStart == 0) - { - t1 = revisionDate[0]; - } - else - { - t1 = revisionDate[compositeStart - 1]; - } - - t2 = revisionDate[compositeEnd - 1]; - - timeDeltas.push(t2 - t1); - forwardsChangesets.push(forwards2); - backwardsChangesets.push(backwards2); - - compositeStart += granularity; - } - - callback(); - } - ], function(err) - { - if(ERR(err, callback)) return; - - callback(null, {forwardsChangesets: forwardsChangesets, - backwardsChangesets: backwardsChangesets, - apool: apool.toJsonable(), - actualEndNum: endNum, - timeDeltas: timeDeltas, - start: startNum, - granularity: granularity }); + // get the lines + let lines; + let p3 = getPadLines(padId, startNum - 1).then(_lines => { + lines = _lines; }); + + // wait for all of the above to complete + await Promise.all([p1, p2, p3]); + + // doesn't know what happens here exactly :/ + let timeDeltas = []; + let forwardsChangesets = []; + let backwardsChangesets = []; + let apool = new AttributePool(); + + for (let compositeStart = startNum; compositeStart < endNum; compositeStart += granularity) { + let compositeEnd = compositeStart + granularity; + if (compositeEnd > endNum || compositeEnd > head_revision + 1) { + break; + } + + let forwards = composedChangesets[compositeStart + "/" + compositeEnd]; + let backwards = Changeset.inverse(forwards, lines.textlines, lines.alines, pad.apool()); + + Changeset.mutateAttributionLines(forwards, lines.alines, pad.apool()); + Changeset.mutateTextLines(forwards, lines.textlines); + + let forwards2 = Changeset.moveOpsToNewPool(forwards, pad.apool(), apool); + let backwards2 = Changeset.moveOpsToNewPool(backwards, pad.apool(), apool); + + let t1 = (compositeStart == 0) ? revisionDate[0] : revisionDate[compositeStart - 1]; + let t2 = revisionDate[compositeEnd - 1]; + + timeDeltas.push(t2 - t1); + forwardsChangesets.push(forwards2); + backwardsChangesets.push(backwards2); + } + + return { forwardsChangesets, backwardsChangesets, + apool: apool.toJsonable(), actualEndNum: endNum, + timeDeltas, start: startNum, granularity }; } /** * Tries to rebuild the getPadLines function of the original Etherpad * https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L263 */ -function getPadLines(padId, revNum, callback) +async function getPadLines(padId, revNum) { - var atext; - var result = {}; - var pad; + let pad = await padManager.getPad(padId); - async.series([ - //get the pad from the database - function(callback) - { - padManager.getPad(padId, function(err, _pad) - { - if(ERR(err, callback)) return; - pad = _pad; - callback(); - }); - }, - //get the atext - function(callback) - { - if(revNum >= 0) - { - pad.getInternalRevisionAText(revNum, function(err, _atext) - { - if(ERR(err, callback)) return; - atext = _atext; - callback(); - }); - } - else - { - atext = Changeset.makeAText("\n"); - callback(null); - } - }, - function(callback) - { - result.textlines = Changeset.splitTextLines(atext.text); - result.alines = Changeset.splitAttributionLines(atext.attribs, atext.text); - callback(null); - } - ], function(err) - { - if(ERR(err, callback)) return; - callback(null, result); - }); + // get the atext + let atext; + + if (revNum >= 0) { + atext = await pad.getInternalRevisionAText(revNum); + } else { + atext = Changeset.makeAText("\n"); + } + + return { + textlines: Changeset.splitTextLines(atext.text), + alines: Changeset.splitAttributionLines(atext.attribs, atext.text) + }; } /** * Tries to rebuild the composePadChangeset function of the original Etherpad * https://github.com/ether/pad/blob/master/etherpad/src/etherpad/control/pad/pad_changeset_control.js#L241 */ -function composePadChangesets(padId, startNum, endNum, callback) +async function composePadChangesets (padId, startNum, endNum) { - var pad; - var changesets = {}; - var changeset; + let pad = await padManager.getPad(padId); - async.series([ - //get the pad from the database - function(callback) - { - padManager.getPad(padId, function(err, _pad) - { - if(ERR(err, callback)) return; - pad = _pad; - callback(); - }); - }, - //fetch all changesets we need - function(callback) - { - var changesetsNeeded=[]; + // fetch all changesets we need + let headNum = pad.getHeadRevisionNumber(); + endNum = Math.min(endNum, headNum + 1); + startNum = Math.max(startNum, 0); - var headNum = pad.getHeadRevisionNumber(); - if (endNum > headNum+1) - endNum = headNum+1; - if (startNum < 0) - startNum = 0; - //create a array for all changesets, we will - //replace the values with the changeset later - for(var r=startNum;r { + return pad.getRevisionChangeset(revNum).then(changeset => changesets[revNum] = changeset); + })); - try { - for(var r=startNum+1;r { + let s = sessioninfos[roomClient.id]; + if (s) { + return authorManager.getAuthor(s.author).then(author => { author.id = s.author; - result.push(author); - callback(); + padUsers.push(author); }); - } else { - callback(); } - }, function(err) { - if(ERR(err, callback)) return; + })); - callback(null, {padUsers: result}); - }); + return { padUsers }; } exports.sessioninfos = sessioninfos; diff --git a/src/node/handler/SocketIORouter.js b/src/node/handler/SocketIORouter.js index 0a7361f42..077a62beb 100644 --- a/src/node/handler/SocketIORouter.js +++ b/src/node/handler/SocketIORouter.js @@ -1,5 +1,5 @@ /** - * This is the Socket.IO Router. It routes the Messages between the + * This is the Socket.IO Router. It routes the Messages between the * components of the Server. The components are at the moment: pad and timeslider */ @@ -19,7 +19,6 @@ * limitations under the License. */ -var ERR = require("async-stacktrace"); var log4js = require('log4js'); var messageLogger = log4js.getLogger("message"); var securityManager = require("../db/SecurityManager"); @@ -31,20 +30,20 @@ var settings = require('../utils/Settings'); * Saves all components * key is the component name * value is the component module - */ + */ var components = {}; var socket; - + /** * adds a component */ exports.addComponent = function(moduleName, module) { - //save the component + // save the component components[moduleName] = module; - - //give the module the socket + + // give the module the socket module.setSocketIO(socket); } @@ -52,115 +51,102 @@ exports.addComponent = function(moduleName, module) * sets the socket.io and adds event functions for routing */ exports.setSocketIO = function(_socket) { - //save this socket internaly + // save this socket internaly socket = _socket; - + socket.sockets.on('connection', function(client) { - // Broken: See http://stackoverflow.com/questions/4647348/send-message-to-specific-client-with-socket-io-and-node-js // Fixed by having a persistant object, ideally this would actually be in the database layer // TODO move to database layer - if(settings.trustProxy && client.handshake.headers['x-forwarded-for'] !== undefined){ + if (settings.trustProxy && client.handshake.headers['x-forwarded-for'] !== undefined) { remoteAddress[client.id] = client.handshake.headers['x-forwarded-for']; - } - else{ + } else { remoteAddress[client.id] = client.handshake.address; } + var clientAuthorized = false; - - //wrap the original send function to log the messages + + // wrap the original send function to log the messages client._send = client.send; client.send = function(message) { messageLogger.debug("to " + client.id + ": " + stringifyWithoutPassword(message)); client._send(message); } - - //tell all components about this connect - for(var i in components) { - components[i].handleConnect(client); - } - client.on('message', function(message) - { - if(message.protocolVersion && message.protocolVersion != 2) { + // tell all components about this connect + for (let i in components) { + components[i].handleConnect(client); + } + + client.on('message', async function(message) { + if (message.protocolVersion && message.protocolVersion != 2) { messageLogger.warn("Protocolversion header is not correct:" + stringifyWithoutPassword(message)); return; } - //client is authorized, everything ok - if(clientAuthorized) { + if (clientAuthorized) { + // client is authorized, everything ok handleMessage(client, message); - } else { //try to authorize the client - if(message.padId !== undefined && message.sessionID !== undefined && message.token !== undefined && message.password !== undefined) { - var checkAccessCallback = function(err, statusObject) { - ERR(err); - - //access was granted, mark the client as authorized and handle the message - if(statusObject.accessStatus == "grant") { - clientAuthorized = true; - handleMessage(client, message); - } - //no access, send the client a message that tell him why - else { - messageLogger.warn("Authentication try failed:" + stringifyWithoutPassword(message)); - client.json.send({accessStatus: statusObject.accessStatus}); - } - }; - if (message.padId.indexOf("r.") === 0) { - readOnlyManager.getPadId(message.padId, function(err, value) { - ERR(err); - securityManager.checkAccess (value, message.sessionID, message.token, message.password, checkAccessCallback); - }); - } else { - //this message has everything to try an authorization - securityManager.checkAccess (message.padId, message.sessionID, message.token, message.password, checkAccessCallback); + } else { + // try to authorize the client + if (message.padId !== undefined && message.sessionID !== undefined && message.token !== undefined && message.password !== undefined) { + // check for read-only pads + let padId = message.padId; + if (padId.indexOf("r.") === 0) { + padId = await readOnlyManager.getPadId(message.padId); } - } else { //drop message - messageLogger.warn("Dropped message cause of bad permissions:" + stringifyWithoutPassword(message)); + + let { accessStatus } = await securityManager.checkAccess(padId, message.sessionID, message.token, message.password); + + if (accessStatus === "grant") { + // access was granted, mark the client as authorized and handle the message + clientAuthorized = true; + handleMessage(client, message); + } else { + // no access, send the client a message that tells him why + messageLogger.warn("Authentication try failed:" + stringifyWithoutPassword(message)); + client.json.send({ accessStatus }); + } + } else { + // drop message + messageLogger.warn("Dropped message because of bad permissions:" + stringifyWithoutPassword(message)); } } }); - client.on('disconnect', function() - { - //tell all components about this disconnect - for(var i in components) - { + client.on('disconnect', function() { + // tell all components about this disconnect + for (let i in components) { components[i].handleDisconnect(client); } }); }); } -//try to handle the message of this client +// try to handle the message of this client function handleMessage(client, message) { - - if(message.component && components[message.component]) { - //check if component is registered in the components array - if(components[message.component]) { + if (message.component && components[message.component]) { + // check if component is registered in the components array + if (components[message.component]) { messageLogger.debug("from " + client.id + ": " + stringifyWithoutPassword(message)); components[message.component].handleMessage(client, message); } } else { messageLogger.error("Can't route the message:" + stringifyWithoutPassword(message)); } -} +} -//returns a stringified representation of a message, removes the password -//this ensures there are no passwords in the log +// returns a stringified representation of a message, removes the password +// this ensures there are no passwords in the log function stringifyWithoutPassword(message) { - var newMessage = {}; - - for(var i in message) - { - if(i == "password" && message[i] != null) - newMessage["password"] = "xxx"; - else - newMessage[i]=message[i]; + let newMessage = Object.assign({}, message); + + if (newMessage.password != null) { + newMessage.password = "xxx"; } - + return JSON.stringify(newMessage); } diff --git a/src/node/hooks/express.js b/src/node/hooks/express.js index e7b373805..702214ec8 100644 --- a/src/node/hooks/express.js +++ b/src/node/hooks/express.js @@ -12,27 +12,27 @@ var serverName; exports.createServer = function () { console.log("Report bugs at https://github.com/ether/etherpad-lite/issues") - serverName = `Etherpad ${settings.getGitCommit()} (http://etherpad.org)`; - + serverName = `Etherpad ${settings.getGitCommit()} (https://etherpad.org)`; + console.log(`Your Etherpad version is ${settings.getEpVersion()} (${settings.getGitCommit()})`); exports.restartServer(); console.log(`You can access your Etherpad instance at http://${settings.ip}:${settings.port}/`); - if(!_.isEmpty(settings.users)){ + if (!_.isEmpty(settings.users)) { console.log(`The plugin admin page is at http://${settings.ip}:${settings.port}/admin/plugins`); - } - else{ + } else { console.warn("Admin username and password not set in settings.json. To access admin please uncomment and edit 'users' in settings.json"); } + var env = process.env.NODE_ENV || 'development'; - if(env !== 'production'){ + + if (env !== 'production') { console.warn("Etherpad is running in Development mode. This mode is slower for users and less secure than production mode. You should set the NODE_ENV environment variable to production by using: export NODE_ENV=production"); } } exports.restartServer = function () { - if (server) { console.log("Restarting express server"); server.close(); @@ -41,46 +41,50 @@ exports.restartServer = function () { var app = express(); // New syntax for express v3 if (settings.ssl) { - console.log("SSL -- enabled"); console.log(`SSL -- server key file: ${settings.ssl.key}`); console.log(`SSL -- Certificate Authority's certificate file: ${settings.ssl.cert}`); - + var options = { key: fs.readFileSync( settings.ssl.key ), cert: fs.readFileSync( settings.ssl.cert ) }; + if (settings.ssl.ca) { options.ca = []; - for(var i = 0; i < settings.ssl.ca.length; i++) { + for (var i = 0; i < settings.ssl.ca.length; i++) { var caFileName = settings.ssl.ca[i]; options.ca.push(fs.readFileSync(caFileName)); } } - + var https = require('https'); server = https.createServer(options, app); - } else { - var http = require('http'); server = http.createServer(app); } - app.use(function (req, res, next) { + app.use(function(req, res, next) { // res.header("X-Frame-Options", "deny"); // breaks embedded pads - if(settings.ssl){ // if we use SSL + if (settings.ssl) { + // we use SSL res.header("Strict-Transport-Security", "max-age=31536000; includeSubDomains"); } // Stop IE going into compatability mode // https://github.com/ether/etherpad-lite/issues/2547 res.header("X-UA-Compatible", "IE=Edge,chrome=1"); - res.header("Server", serverName); + + // send git version in the Server response header if exposeVersion is true. + if (settings.exposeVersion) { + res.header("Server", serverName); + } + next(); }); - if(settings.trustProxy){ + if (settings.trustProxy) { app.enable('trust proxy'); } diff --git a/src/node/hooks/express/adminplugins.js b/src/node/hooks/express/adminplugins.js index 1ae8d7b50..7cfb160b9 100644 --- a/src/node/hooks/express/adminplugins.js +++ b/src/node/hooks/express/adminplugins.js @@ -5,7 +5,7 @@ var plugins = require('ep_etherpad-lite/static/js/pluginfw/plugins'); var _ = require('underscore'); var semver = require('semver'); -exports.expressCreateServer = function (hook_name, args, cb) { +exports.expressCreateServer = function(hook_name, args, cb) { args.app.get('/admin/plugins', function(req, res) { var plugins = require("ep_etherpad-lite/static/js/pluginfw/plugins"); var render_args = { @@ -13,91 +13,99 @@ exports.expressCreateServer = function (hook_name, args, cb) { search_results: {}, errors: [], }; - res.send( eejs.require("ep_etherpad-lite/templates/admin/plugins.html", render_args) ); + + res.send(eejs.require("ep_etherpad-lite/templates/admin/plugins.html", render_args)); }); + args.app.get('/admin/plugins/info', function(req, res) { var gitCommit = settings.getGitCommit(); var epVersion = settings.getEpVersion(); - res.send( eejs.require("ep_etherpad-lite/templates/admin/plugins-info.html", - { - gitCommit: gitCommit, - epVersion: epVersion - }) - ); + + res.send(eejs.require("ep_etherpad-lite/templates/admin/plugins-info.html", { + gitCommit: gitCommit, + epVersion: epVersion + })); }); } -exports.socketio = function (hook_name, args, cb) { +exports.socketio = function(hook_name, args, cb) { var io = args.io.of("/pluginfw/installer"); - io.on('connection', function (socket) { - + io.on('connection', function(socket) { if (!socket.conn.request.session || !socket.conn.request.session.user || !socket.conn.request.session.user.is_admin) return; - socket.on("getInstalled", function (query) { + socket.on("getInstalled", function(query) { // send currently installed plugins var installed = Object.keys(plugins.plugins).map(function(plugin) { return plugins.plugins[plugin].package - }) + }); + socket.emit("results:installed", {installed: installed}); }); - - socket.on("checkUpdates", function() { + + socket.on("checkUpdates", async function() { // Check plugins for updates - installer.getAvailablePlugins(/*maxCacheAge:*/60*10, function(er, results) { - if(er) { - console.warn(er); - socket.emit("results:updatable", {updatable: {}}); - return; - } + try { + let results = await installer.getAvailablePlugins(/*maxCacheAge:*/ 60 * 10); + var updatable = _(plugins.plugins).keys().filter(function(plugin) { - if(!results[plugin]) return false; - var latestVersion = results[plugin].version - var currentVersion = plugins.plugins[plugin].package.version - return semver.gt(latestVersion, currentVersion) + if (!results[plugin]) return false; + + var latestVersion = results[plugin].version; + var currentVersion = plugins.plugins[plugin].package.version; + + return semver.gt(latestVersion, currentVersion); }); + socket.emit("results:updatable", {updatable: updatable}); - }); - }) - - socket.on("getAvailable", function (query) { - installer.getAvailablePlugins(/*maxCacheAge:*/false, function (er, results) { - if(er) { - console.error(er) - results = {} - } - socket.emit("results:available", results); - }); + } catch (er) { + console.warn(er); + + socket.emit("results:updatable", {updatable: {}}); + } }); - socket.on("search", function (query) { - installer.search(query.searchTerm, /*maxCacheAge:*/60*10, function (er, results) { - if(er) { - console.error(er) - results = {} - } + socket.on("getAvailable", async function(query) { + try { + let results = await installer.getAvailablePlugins(/*maxCacheAge:*/ false); + socket.emit("results:available", results); + } catch (er) { + console.error(er); + socket.emit("results:available", {}); + } + }); + + socket.on("search", async function(query) { + try { + let results = await installer.search(query.searchTerm, /*maxCacheAge:*/ 60 * 10); var res = Object.keys(results) .map(function(pluginName) { - return results[pluginName] + return results[pluginName]; }) .filter(function(plugin) { - return !plugins.plugins[plugin.name] + return !plugins.plugins[plugin.name]; }); res = sortPluginList(res, query.sortBy, query.sortDir) .slice(query.offset, query.offset+query.limit); socket.emit("results:search", {results: res, query: query}); - }); + } catch (er) { + console.error(er); + + socket.emit("results:search", {results: {}, query: query}); + } }); - socket.on("install", function (plugin_name) { - installer.install(plugin_name, function (er) { - if(er) console.warn(er) + socket.on("install", function(plugin_name) { + installer.install(plugin_name, function(er) { + if (er) console.warn(er); + socket.emit("finished:install", {plugin: plugin_name, code: er? er.code : null, error: er? er.message : null}); }); }); - socket.on("uninstall", function (plugin_name) { - installer.uninstall(plugin_name, function (er) { - if(er) console.warn(er) + socket.on("uninstall", function(plugin_name) { + installer.uninstall(plugin_name, function(er) { + if (er) console.warn(er); + socket.emit("finished:uninstall", {plugin: plugin_name, error: er? er.message : null}); }); }); @@ -106,11 +114,15 @@ exports.socketio = function (hook_name, args, cb) { function sortPluginList(plugins, property, /*ASC?*/dir) { return plugins.sort(function(a, b) { - if (a[property] < b[property]) - return dir? -1 : 1; - if (a[property] > b[property]) - return dir? 1 : -1; + if (a[property] < b[property]) { + return dir? -1 : 1; + } + + if (a[property] > b[property]) { + return dir? 1 : -1; + } + // a must be equal to b return 0; - }) + }); } diff --git a/src/node/hooks/express/apicalls.js b/src/node/hooks/express/apicalls.js index d6011c97f..cf4507480 100644 --- a/src/node/hooks/express/apicalls.js +++ b/src/node/hooks/express/apicalls.js @@ -40,7 +40,7 @@ exports.expressCreateServer = function (hook_name, args, cb) { //This is a api POST call, collect all post informations and pass it to the apiHandler args.app.post('/api/:version/:func', function(req, res) { new formidable.IncomingForm().parse(req, function (err, fields, files) { - apiCaller(req, res, fields) + apiCaller(req, res, Object.assign(req.query, fields)) }); }); diff --git a/src/node/hooks/express/errorhandling.js b/src/node/hooks/express/errorhandling.js index c36595bdd..66553621c 100644 --- a/src/node/hooks/express/errorhandling.js +++ b/src/node/hooks/express/errorhandling.js @@ -11,20 +11,23 @@ exports.gracefulShutdown = function(err) { console.error(err); } - //ensure there is only one graceful shutdown running - if(exports.onShutdown) return; + // ensure there is only one graceful shutdown running + if (exports.onShutdown) { + return; + } + exports.onShutdown = true; console.log("graceful shutdown..."); - //do the db shutdown - db.db.doShutdown(function() { + // do the db shutdown + db.doShutdown().then(function() { console.log("db sucessfully closed."); process.exit(0); }); - setTimeout(function(){ + setTimeout(function() { process.exit(1); }, 3000); } @@ -35,22 +38,36 @@ exports.expressCreateServer = function (hook_name, args, cb) { exports.app = args.app; // Handle errors - args.app.use(function(err, req, res, next){ + args.app.use(function(err, req, res, next) { // if an error occurs Connect will pass it down // through these "error-handling" middleware // allowing you to respond however you like res.status(500).send({ error: 'Sorry, something bad happened!' }); console.error(err.stack? err.stack : err.toString()); stats.meter('http500').mark() - }) + }); - //connect graceful shutdown with sigint and uncaughtexception - if(os.type().indexOf("Windows") == -1) { - //sigint is so far not working on windows - //https://github.com/joyent/node/issues/1553 - process.on('SIGINT', exports.gracefulShutdown); - // when running as PID1 (e.g. in docker container) - // allow graceful shutdown on SIGTERM c.f. #3265 - process.on('SIGTERM', exports.gracefulShutdown); - } + /* + * Connect graceful shutdown with sigint and uncaught exception + * + * Until Etherpad 1.7.5, process.on('SIGTERM') and process.on('SIGINT') were + * not hooked up under Windows, because old nodejs versions did not support + * them. + * + * According to nodejs 6.x documentation, it is now safe to do so. This + * allows to gracefully close the DB connection when hitting CTRL+C under + * Windows, for example. + * + * Source: https://nodejs.org/docs/latest-v6.x/api/process.html#process_signal_events + * + * - SIGTERM is not supported on Windows, it can be listened on. + * - SIGINT from the terminal is supported on all platforms, and can usually + * be generated with +C (though this may be configurable). It is not + * generated when terminal raw mode is enabled. + */ + process.on('SIGINT', exports.gracefulShutdown); + + // when running as PID1 (e.g. in docker container) + // allow graceful shutdown on SIGTERM c.f. #3265 + process.on('SIGTERM', exports.gracefulShutdown); } diff --git a/src/node/hooks/express/importexport.js b/src/node/hooks/express/importexport.js index a62942cc0..ef10e0145 100644 --- a/src/node/hooks/express/importexport.js +++ b/src/node/hooks/express/importexport.js @@ -5,15 +5,14 @@ var importHandler = require('../../handler/ImportHandler'); var padManager = require("../../db/PadManager"); exports.expressCreateServer = function (hook_name, args, cb) { - args.app.get('/p/:pad/:rev?/export/:type', function(req, res, next) { + args.app.get('/p/:pad/:rev?/export/:type', async function(req, res, next) { var types = ["pdf", "doc", "txt", "html", "odt", "etherpad"]; //send a 404 if we don't support this filetype if (types.indexOf(req.params.type) == -1) { - next(); - return; + return next(); } - //if abiword is disabled, and this is a format we only support with abiword, output a message + // if abiword is disabled, and this is a format we only support with abiword, output a message if (settings.exportAvailable() == "no" && ["odt", "pdf", "doc"].indexOf(req.params.type) !== -1) { res.send("This export is not enabled at this Etherpad instance. Set the path to Abiword or SOffice in settings.json to enable this feature"); @@ -22,30 +21,26 @@ exports.expressCreateServer = function (hook_name, args, cb) { res.header("Access-Control-Allow-Origin", "*"); - hasPadAccess(req, res, function() { + if (await hasPadAccess(req, res)) { console.log('req.params.pad', req.params.pad); - padManager.doesPadExists(req.params.pad, function(err, exists) - { - if(!exists) { - return next(); - } + let exists = await padManager.doesPadExists(req.params.pad); + if (!exists) { + return next(); + } - exportHandler.doExport(req, res, req.params.pad, req.params.type); - }); - }); + exportHandler.doExport(req, res, req.params.pad, req.params.type); + } }); - //handle import requests - args.app.post('/p/:pad/import', function(req, res, next) { - hasPadAccess(req, res, function() { - padManager.doesPadExists(req.params.pad, function(err, exists) - { - if(!exists) { - return next(); - } + // handle import requests + args.app.post('/p/:pad/import', async function(req, res, next) { + if (await hasPadAccess(req, res)) { + let exists = await padManager.doesPadExists(req.params.pad); + if (!exists) { + return next(); + } - importHandler.doImport(req, res, req.params.pad); - }); - }); + importHandler.doImport(req, res, req.params.pad); + } }); } diff --git a/src/node/hooks/express/padreadonly.js b/src/node/hooks/express/padreadonly.js index bff8adf7b..5264c17cd 100644 --- a/src/node/hooks/express/padreadonly.js +++ b/src/node/hooks/express/padreadonly.js @@ -1,64 +1,26 @@ -var async = require('async'); -var ERR = require("async-stacktrace"); var readOnlyManager = require("../../db/ReadOnlyManager"); var hasPadAccess = require("../../padaccess"); var exporthtml = require("../../utils/ExportHtml"); exports.expressCreateServer = function (hook_name, args, cb) { - //serve read only pad - args.app.get('/ro/:id', function(req, res) - { - var html; - var padId; + // serve read only pad + args.app.get('/ro/:id', async function(req, res) { - async.series([ - //translate the read only pad to a padId - function(callback) - { - readOnlyManager.getPadId(req.params.id, function(err, _padId) - { - if(ERR(err, callback)) return; + // translate the read only pad to a padId + let padId = await readOnlyManager.getPadId(req.params.id); + if (padId == null) { + res.status(404).send('404 - Not Found'); + return; + } - padId = _padId; + // we need that to tell hasPadAcess about the pad + req.params.pad = padId; - //we need that to tell hasPadAcess about the pad - req.params.pad = padId; - - callback(); - }); - }, - //render the html document - function(callback) - { - //return if the there is no padId - if(padId == null) - { - callback("notfound"); - return; - } - - hasPadAccess(req, res, function() - { - //render the html document - exporthtml.getPadHTMLDocument(padId, null, function(err, _html) - { - if(ERR(err, callback)) return; - html = _html; - callback(); - }); - }); - } - ], function(err) - { - //throw any unexpected error - if(err && err != "notfound") - ERR(err); - - if(err == "notfound") - res.status(404).send('404 - Not Found'); - else - res.send(html); - }); + if (await hasPadAccess(req, res)) { + // render the html document + let html = await exporthtml.getPadHTMLDocument(padId, null); + res.send(html); + } }); } diff --git a/src/node/hooks/express/padurlsanitize.js b/src/node/hooks/express/padurlsanitize.js index be3ffb1b4..ad8d3c431 100644 --- a/src/node/hooks/express/padurlsanitize.js +++ b/src/node/hooks/express/padurlsanitize.js @@ -2,31 +2,28 @@ var padManager = require('../../db/PadManager'); var url = require('url'); exports.expressCreateServer = function (hook_name, args, cb) { - //redirects browser to the pad's sanitized url if needed. otherwise, renders the html - args.app.param('pad', function (req, res, next, padId) { - //ensure the padname is valid and the url doesn't end with a / - if(!padManager.isValidPadId(padId) || /\/$/.test(req.url)) - { + + // redirects browser to the pad's sanitized url if needed. otherwise, renders the html + args.app.param('pad', async function (req, res, next, padId) { + // ensure the padname is valid and the url doesn't end with a / + if (!padManager.isValidPadId(padId) || /\/$/.test(req.url)) { res.status(404).send('Such a padname is forbidden'); return; } - padManager.sanitizePadId(padId, function(sanitizedPadId) { - //the pad id was sanitized, so we redirect to the sanitized version - if(sanitizedPadId != padId) - { - var real_url = sanitizedPadId; - real_url = encodeURIComponent(real_url); - var query = url.parse(req.url).query; - if ( query ) real_url += '?' + query; - res.header('Location', real_url); - res.status(302).send('You should be redirected to ' + real_url + ''); - } - //the pad id was fine, so just render it - else - { - next(); - } - }); + let sanitizedPadId = await padManager.sanitizePadId(padId); + + if (sanitizedPadId === padId) { + // the pad id was fine, so just render it + next(); + } else { + // the pad id was sanitized, so we redirect to the sanitized version + var real_url = sanitizedPadId; + real_url = encodeURIComponent(real_url); + var query = url.parse(req.url).query; + if ( query ) real_url += '?' + query; + res.header('Location', real_url); + res.status(302).send('You should be redirected to ' + real_url + ''); + } }); } diff --git a/src/node/hooks/express/socketio.js b/src/node/hooks/express/socketio.js index 23622f3af..de94e9fbb 100644 --- a/src/node/hooks/express/socketio.js +++ b/src/node/hooks/express/socketio.js @@ -8,7 +8,7 @@ var padMessageHandler = require("../../handler/PadMessageHandler"); var cookieParser = require('cookie-parser'); var sessionModule = require('express-session'); - + exports.expressCreateServer = function (hook_name, args, cb) { //init socket.io and redirect all requests to the MessageHandler // there shouldn't be a browser that isn't compatible to all @@ -57,7 +57,7 @@ exports.expressCreateServer = function (hook_name, args, cb) { // no longer available, details available at: // http://stackoverflow.com/questions/23981741/minify-socket-io-socket-io-js-with-1-0 // if(settings.minify) io.enable('browser client minification'); - + //Initalize the Socket.IO Router socketIORouter.setSocketIO(io); socketIORouter.addComponent("pad", padMessageHandler); diff --git a/src/node/hooks/express/static.js b/src/node/hooks/express/static.js index ef41865e3..4c17fbe3b 100644 --- a/src/node/hooks/express/static.js +++ b/src/node/hooks/express/static.js @@ -40,9 +40,9 @@ exports.expressCreateServer = function (hook_name, args, cb) { var clientParts = _(plugins.parts) .filter(function(part){ return _(part).has('client_hooks') }); - + var clientPlugins = {}; - + _(clientParts).chain() .map(function(part){ return part.plugin }) .uniq() @@ -50,7 +50,7 @@ exports.expressCreateServer = function (hook_name, args, cb) { clientPlugins[name] = _(plugins.plugins[name]).clone(); delete clientPlugins[name]['package']; }); - + res.header("Content-Type","application/json; charset=utf-8"); res.write(JSON.stringify({"plugins": clientPlugins, "parts": clientParts})); res.end(); diff --git a/src/node/hooks/express/swagger.js b/src/node/hooks/express/swagger.js index f606eb882..f3f07cd01 100644 --- a/src/node/hooks/express/swagger.js +++ b/src/node/hooks/express/swagger.js @@ -113,7 +113,7 @@ var API = { "response": {"groupIDs":{"type":"List", "items":{"type":"string"}}} }, }, - + // Author "author": { "create" : { @@ -298,7 +298,7 @@ function capitalise(string){ for (var resource in API) { for (var func in API[resource]) { - + // The base response model var responseModel = { "properties": { @@ -350,7 +350,7 @@ function newSwagger() { exports.expressCreateServer = function (hook_name, args, cb) { for (var version in apiHandler.version) { - + var swagger = newSwagger(); var basePath = "/rest/" + version; @@ -437,7 +437,7 @@ exports.expressCreateServer = function (hook_name, args, cb) { }; swagger.configureSwaggerPaths("", "/api" , ""); - + swagger.configure("http://" + settings.ip + ":" + settings.port + basePath, version); } }; diff --git a/src/node/hooks/express/tests.js b/src/node/hooks/express/tests.js index d0dcc0cc6..443e9f685 100644 --- a/src/node/hooks/express/tests.js +++ b/src/node/hooks/express/tests.js @@ -1,40 +1,33 @@ var path = require("path") , npm = require("npm") , fs = require("fs") - , async = require("async"); + , util = require("util"); exports.expressCreateServer = function (hook_name, args, cb) { - args.app.get('/tests/frontend/specs_list.js', function(req, res){ - - async.parallel({ - coreSpecs: function(callback){ - exports.getCoreTests(callback); - }, - pluginSpecs: function(callback){ - exports.getPluginTests(callback); - } - }, - function(err, results){ - var files = results.coreSpecs; // push the core specs to a file object - files = files.concat(results.pluginSpecs); // add the plugin Specs to the core specs - console.debug("Sent browser the following test specs:", files.sort()); - res.send("var specs_list = " + JSON.stringify(files.sort()) + ";\n"); - }); + args.app.get('/tests/frontend/specs_list.js', async function(req, res) { + let [coreTests, pluginTests] = await Promise.all([ + exports.getCoreTests(), + exports.getPluginTests() + ]); + // merge the two sets of results + let files = [].concat(coreTests, pluginTests).sort(); + console.debug("Sent browser the following test specs:", files); + res.send("var specs_list = " + JSON.stringify(files) + ";\n"); }); - // path.join seems to normalize by default, but we'll just be explicit var rootTestFolder = path.normalize(path.join(npm.root, "../tests/frontend/")); - var url2FilePath = function(url){ + var url2FilePath = function(url) { var subPath = url.substr("/tests/frontend".length); - if (subPath == ""){ + if (subPath == "") { subPath = "index.html" } subPath = subPath.split("?")[0]; var filePath = path.normalize(path.join(rootTestFolder, subPath)); + // make sure we jail the paths to the test folder, otherwise serve index if (filePath.indexOf(rootTestFolder) !== 0) { filePath = path.join(rootTestFolder, "index.html"); @@ -46,13 +39,13 @@ exports.expressCreateServer = function (hook_name, args, cb) { var specFilePath = url2FilePath(req.url); var specFileName = path.basename(specFilePath); - fs.readFile(specFilePath, function(err, content){ - if(err){ return res.send(500); } - + fs.readFile(specFilePath, function(err, content) { + if (err) { return res.send(500); } + content = "describe(" + JSON.stringify(specFileName) + ", function(){ " + content + " });"; res.send(content); - }); + }); }); args.app.get('/tests/frontend/*', function (req, res) { @@ -62,30 +55,33 @@ exports.expressCreateServer = function (hook_name, args, cb) { args.app.get('/tests/frontend', function (req, res) { res.redirect('/tests/frontend/'); - }); -} - -exports.getPluginTests = function(callback){ - var pluginSpecs = []; - var plugins = fs.readdirSync('node_modules'); - plugins.forEach(function(plugin){ - if(fs.existsSync("node_modules/"+plugin+"/static/tests/frontend/specs")){ // if plugins exists - var specFiles = fs.readdirSync("node_modules/"+plugin+"/static/tests/frontend/specs/"); - async.forEach(specFiles, function(spec){ // for each specFile push it to pluginSpecs - pluginSpecs.push("/static/plugins/"+plugin+"/static/tests/frontend/specs/" + spec); - }, - function(err){ - // blow up if something bad happens! - }); - } - }); - callback(null, pluginSpecs); -} - -exports.getCoreTests = function(callback){ - fs.readdir('tests/frontend/specs', function(err, coreSpecs){ // get the core test specs - if(err){ return res.send(500); } - callback(null, coreSpecs); }); } +const readdir = util.promisify(fs.readdir); + +exports.getPluginTests = async function(callback) { + const moduleDir = "node_modules/"; + const specPath = "/static/tests/frontend/specs/"; + const staticDir = "/static/plugins/"; + + let pluginSpecs = []; + + let plugins = await readdir(moduleDir); + let promises = plugins + .map(plugin => [ plugin, moduleDir + plugin + specPath] ) + .filter(([plugin, specDir]) => fs.existsSync(specDir)) // check plugin exists + .map(([plugin, specDir]) => { + return readdir(specDir) + .then(specFiles => specFiles.map(spec => { + pluginSpecs.push(staticDir + plugin + specPath + spec); + })); + }); + + return Promise.all(promises).then(() => pluginSpecs); +} + +exports.getCoreTests = function() { + // get the core test specs + return readdir('tests/frontend/specs'); +} diff --git a/src/node/hooks/i18n.js b/src/node/hooks/i18n.js index 1b5b354d0..122efdd38 100644 --- a/src/node/hooks/i18n.js +++ b/src/node/hooks/i18n.js @@ -39,7 +39,7 @@ function getAllLocales() { //add core supported languages first extractLangs(npm.root+"/ep_etherpad-lite/locales"); - + //add plugins languages (if any) for(var pluginName in plugins) extractLangs(path.join(npm.root, pluginName, 'locales')); @@ -94,11 +94,11 @@ exports.expressCreateServer = function(n, args) { res.status(404).send('Language not available'); } }) - + args.app.get('/locales.json', function(req, res) { res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.send(localeIndex); }) - + } diff --git a/src/node/padaccess.js b/src/node/padaccess.js index 1f2e8834b..3449f7d16 100644 --- a/src/node/padaccess.js +++ b/src/node/padaccess.js @@ -1,17 +1,20 @@ -var ERR = require("async-stacktrace"); var securityManager = require('./db/SecurityManager'); -//checks for padAccess -module.exports = function (req, res, callback) { - securityManager.checkAccess(req.params.pad, req.cookies.sessionID, req.cookies.token, req.cookies.password, function(err, accessObj) { - if(ERR(err, callback)) return; +// checks for padAccess +module.exports = async function (req, res) { + try { + let accessObj = await securityManager.checkAccess(req.params.pad, req.cookies.sessionID, req.cookies.token, req.cookies.password); - //there is access, continue - if(accessObj.accessStatus == "grant") { - callback(); - //no access + if (accessObj.accessStatus === "grant") { + // there is access, continue + return true; } else { + // no access res.status(403).send("403 - Can't touch this"); + return false; } - }); + } catch (err) { + // @TODO - send internal server error here? + throw err; + } } diff --git a/src/node/server.js b/src/node/server.js index 9ccedfa60..8e57b3997 100755 --- a/src/node/server.js +++ b/src/node/server.js @@ -1,7 +1,7 @@ #!/usr/bin/env node /** - * This module is started with bin/run.sh. It sets up a Express HTTP and a Socket.IO Server. - * Static file Requests are answered directly from this module, Socket.IO messages are passed + * This module is started with bin/run.sh. It sets up a Express HTTP and a Socket.IO Server. + * Static file Requests are answered directly from this module, Socket.IO messages are passed * to MessageHandler and minfied requests are passed to minified. */ @@ -22,75 +22,61 @@ */ var log4js = require('log4js') - , async = require('async') - , stats = require('./stats') , NodeVersion = require('./utils/NodeVersion') ; log4js.replaceConsole(); -stats.gauge('memoryUsage', function() { - return process.memoryUsage().rss -}) +/* + * early check for version compatibility before calling + * any modules that require newer versions of NodeJS + */ +NodeVersion.enforceMinNodeVersion('8.9.0'); -var settings - , db - , plugins - , hooks; +/* + * Etherpad 1.8.3 will require at least nodejs 10.13.0. + */ +NodeVersion.checkDeprecationStatus('10.13.0', '1.8.3'); + +/* + * start up stats counting system + */ +var stats = require('./stats'); +stats.gauge('memoryUsage', function() { + return process.memoryUsage().rss; +}); + +/* + * no use of let or await here because it would cause startup + * to fail completely on very early versions of NodeJS + */ var npm = require("npm/lib/npm.js"); -async.waterfall([ - function(callback) - { - NodeVersion.enforceMinNodeVersion('6.9.0', callback); - }, +npm.load({}, function() { + var settings = require('./utils/Settings'); + var db = require('./db/DB'); + var plugins = require("ep_etherpad-lite/static/js/pluginfw/plugins"); + var hooks = require("ep_etherpad-lite/static/js/pluginfw/hooks"); + hooks.plugins = plugins; - function(callback) - { - NodeVersion.checkDeprecationStatus('8.9.0', '1.8.0', callback); - }, + db.init() + .then(plugins.update) + .then(function() { + console.info("Installed plugins: " + plugins.formatPluginsWithVersion()); + console.debug("Installed parts:\n" + plugins.formatParts()); + console.debug("Installed hooks:\n" + plugins.formatHooks()); - // load npm - function(callback) { - npm.load({}, function(er) { - callback(er) + // Call loadSettings hook + hooks.aCallAll("loadSettings", { settings: settings }); + + // initalize the http server + hooks.callAll("createServer", {}); }) - }, - - // load everything - function(callback) { - settings = require('./utils/Settings'); - db = require('./db/DB'); - plugins = require("ep_etherpad-lite/static/js/pluginfw/plugins"); - hooks = require("ep_etherpad-lite/static/js/pluginfw/hooks"); - hooks.plugins = plugins; - callback(); - }, - - //initalize the database - function (callback) - { - db.init(callback); - }, - - function(callback) { - plugins.update(callback) - }, - - function (callback) { - console.info("Installed plugins: " + plugins.formatPluginsWithVersion()); - console.debug("Installed parts:\n" + plugins.formatParts()); - console.debug("Installed hooks:\n" + plugins.formatHooks()); - - // Call loadSettings hook - hooks.aCallAll("loadSettings", { settings: settings }); - callback(); - }, - - //initalize the http server - function (callback) - { - hooks.callAll("createServer", {}); - callback(null); - } -]); + .catch(function(e) { + console.error("exception thrown: " + e.message); + if (e.stack) { + console.log(e.stack); + } + process.exit(1); + }); +}); diff --git a/src/node/stats.js b/src/node/stats.js index 5ffa8a07b..ff1752fe9 100644 --- a/src/node/stats.js +++ b/src/node/stats.js @@ -1,19 +1,3 @@ -/* - * TODO: this polyfill is needed for Node 6.9 support. - * - * Once minimum supported Node version is raised to 8.9.0, it will be removed. - */ -if (!Object.values) { - var log4js = require('log4js'); - var statsLogger = log4js.getLogger("stats"); - - statsLogger.warn(`Enabling a polyfill to run on this Node version (${process.version}). Next Etherpad version will remove support for Node version < 8.9.0. Please update your runtime.`); - - var values = require('object.values'); - - values.shim(); -} - var measured = require('measured-core') module.exports = measured.createCollection(); diff --git a/src/node/utils/Abiword.js b/src/node/utils/Abiword.js index 2aae5a8ac..eed844e73 100644 --- a/src/node/utils/Abiword.js +++ b/src/node/utils/Abiword.js @@ -17,7 +17,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - + var spawn = require('child_process').spawn; var async = require("async"); var settings = require("./Settings"); @@ -34,7 +34,7 @@ if(os.type().indexOf("Windows") > -1) { //span an abiword process to perform the conversion var abiword = spawn(settings.abiword, ["--to=" + task.destFile, task.srcFile]); - + //delegate the processing of stdout to another function abiword.stdout.on('data', function (data) { @@ -43,7 +43,7 @@ if(os.type().indexOf("Windows") > -1) }); //append error messages to the buffer - abiword.stderr.on('data', function (data) + abiword.stderr.on('data', function (data) { stdoutBuffer += data.toString(); }); @@ -63,7 +63,7 @@ if(os.type().indexOf("Windows") > -1) callback(); }); }; - + exports.convertFile = function(srcFile, destFile, type, callback) { doConvertTask({"srcFile": srcFile, "destFile": destFile, "type": type}, callback); @@ -79,16 +79,16 @@ else var spawnAbiword = function (){ abiword = spawn(settings.abiword, ["--plugin", "AbiCommand"]); var stdoutBuffer = ""; - var firstPrompt = true; + var firstPrompt = true; //append error messages to the buffer - abiword.stderr.on('data', function (data) + abiword.stderr.on('data', function (data) { stdoutBuffer += data.toString(); }); //abiword died, let's restart abiword and return an error with the callback - abiword.on('exit', function (code) + abiword.on('exit', function (code) { spawnAbiword(); stdoutCallback(`Abiword died with exit code ${code}`); @@ -105,10 +105,10 @@ else { //filter the feedback message var err = stdoutBuffer.search("OK") != -1 ? null : stdoutBuffer; - + //reset the buffer stdoutBuffer = ""; - + //call the callback with the error message //skip the first prompt if(stdoutCallback != null && !firstPrompt) @@ -116,7 +116,7 @@ else stdoutCallback(err); stdoutCallback = null; } - + firstPrompt = false; } }); @@ -138,7 +138,7 @@ else } }; }; - + //Queue with the converts we have to do var queue = async.queue(doConvertTask, 1); exports.convertFile = function(srcFile, destFile, type, callback) diff --git a/src/node/utils/AbsolutePaths.js b/src/node/utils/AbsolutePaths.js index 55dfc98e3..9d864c474 100644 --- a/src/node/utils/AbsolutePaths.js +++ b/src/node/utils/AbsolutePaths.js @@ -61,8 +61,8 @@ var popIfEndsWith = function(stringArray, lastDesiredElements) { * Heuristically computes the directory in which Etherpad is installed. * * All the relative paths have to be interpreted against this absolute base - * path. Since the Unix and Windows install have a different layout on disk, - * they are treated as two special cases. + * path. Since the Windows package install has a different layout on disk, it is + * dealt with as a special case. * * The path is computed only on first invocation. Subsequent invocations return * a cached value. @@ -79,26 +79,27 @@ exports.findEtherpadRoot = function() { const findRoot = require('find-root'); const foundRoot = findRoot(__dirname); + const splitFoundRoot = foundRoot.split(path.sep); - var directoriesToStrip; - if (process.platform === 'win32') { + /* + * On Unix platforms and on Windows manual installs, foundRoot's value will + * be: + * + * \src + */ + var maybeEtherpadRoot = popIfEndsWith(splitFoundRoot, ['src']); + + if ((maybeEtherpadRoot === false) && (process.platform === 'win32')) { /* - * Given the structure of our Windows package, foundRoot's value - * will be the following on win32: + * If we did not find the path we are expecting, and we are running under + * Windows, we may still be running from a prebuilt package, whose directory + * structure is different: * * \node_modules\ep_etherpad-lite */ - directoriesToStrip = ['node_modules', 'ep_etherpad-lite']; - } else { - /* - * On Unix platforms, foundRoot's value will be: - * - * \src - */ - directoriesToStrip = ['src']; + maybeEtherpadRoot = popIfEndsWith(splitFoundRoot, ['node_modules', 'ep_etherpad-lite']); } - const maybeEtherpadRoot = popIfEndsWith(foundRoot.split(path.sep), directoriesToStrip); if (maybeEtherpadRoot === false) { absPathLogger.error(`Could not identity Etherpad base path in this ${process.platform} installation in "${foundRoot}"`); process.exit(1); diff --git a/src/node/utils/ExportEtherpad.js b/src/node/utils/ExportEtherpad.js index a68ab0b2a..0e8ef3bf1 100644 --- a/src/node/utils/ExportEtherpad.js +++ b/src/node/utils/ExportEtherpad.js @@ -15,58 +15,48 @@ */ -var async = require("async"); -var db = require("../db/DB").db; -var ERR = require("async-stacktrace"); +let db = require("../db/DB"); -exports.getPadRaw = function(padId, callback){ - async.waterfall([ - function(cb){ - db.get("pad:"+padId, cb); - }, - function(padcontent,cb){ - var records = ["pad:"+padId]; - for (var i = 0; i <= padcontent.head; i++) { - records.push("pad:"+padId+":revs:" + i); - } +exports.getPadRaw = async function(padId) { - for (var i = 0; i <= padcontent.chatHead; i++) { - records.push("pad:"+padId+":chat:" + i); - } + let padKey = "pad:" + padId; + let padcontent = await db.get(padKey); - var data = {}; - - async.forEachSeries(Object.keys(records), function(key, r){ - - // For each piece of info about a pad. - db.get(records[key], function(err, entry){ - data[records[key]] = entry; - - // Get the Pad Authors - if(entry.pool && entry.pool.numToAttrib){ - var authors = entry.pool.numToAttrib; - async.forEachSeries(Object.keys(authors), function(k, c){ - if(authors[k][0] === "author"){ - var authorId = authors[k][1]; - - // Get the author info - db.get("globalAuthor:"+authorId, function(e, authorEntry){ - if(authorEntry && authorEntry.padIDs) authorEntry.padIDs = padId; - if(!e) data["globalAuthor:"+authorId] = authorEntry; - }); - - } - // console.log("authorsK", authors[k]); - c(null); - }); - } - r(null); // callback; - }); - }, function(err){ - cb(err, data); - }) + let records = [ padKey ]; + for (let i = 0; i <= padcontent.head; i++) { + records.push(padKey + ":revs:" + i); } - ], function(err, data){ - callback(null, data); - }); + + for (let i = 0; i <= padcontent.chatHead; i++) { + records.push(padKey + ":chat:" + i); + } + + let data = {}; + for (let key of records) { + + // For each piece of info about a pad. + let entry = data[key] = await db.get(key); + + // Get the Pad Authors + if (entry.pool && entry.pool.numToAttrib) { + let authors = entry.pool.numToAttrib; + + for (let k of Object.keys(authors)) { + if (authors[k][0] === "author") { + let authorId = authors[k][1]; + + // Get the author info + let authorEntry = await db.get("globalAuthor:" + authorId); + if (authorEntry) { + data["globalAuthor:" + authorId] = authorEntry; + if (authorEntry.padIDs) { + authorEntry.padIDs = padId; + } + } + } + } + } + } + + return data; } diff --git a/src/node/utils/ExportHelper.js b/src/node/utils/ExportHelper.js index e8d47fb2a..99921aa3b 100644 --- a/src/node/utils/ExportHelper.js +++ b/src/node/utils/ExportHelper.js @@ -77,7 +77,7 @@ exports._analyzeLine = function(text, aline, apool){ exports._encodeWhitespace = function(s){ - return s.replace(/[^\x21-\x7E\s\t\n\r]/g, function(c){ - return "&#" +c.charCodeAt(0) + ";"; + return s.replace(/[^\x21-\x7E\s\t\n\r]/gu, function(c){ + return "&#" +c.codePointAt(0) + ";"; }); }; diff --git a/src/node/utils/ExportHtml.js b/src/node/utils/ExportHtml.js index f001fe452..18b32d247 100644 --- a/src/node/utils/ExportHtml.js +++ b/src/node/utils/ExportHtml.js @@ -14,11 +14,8 @@ * limitations under the License. */ - -var async = require("async"); var Changeset = require("ep_etherpad-lite/static/js/Changeset"); var padManager = require("../db/PadManager"); -var ERR = require("async-stacktrace"); var _ = require('underscore'); var Security = require('ep_etherpad-lite/static/js/security'); var hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks'); @@ -26,45 +23,17 @@ var eejs = require('ep_etherpad-lite/node/eejs'); var _analyzeLine = require('./ExportHelper')._analyzeLine; var _encodeWhitespace = require('./ExportHelper')._encodeWhitespace; -function getPadHTML(pad, revNum, callback) +async function getPadHTML(pad, revNum) { - var atext = pad.atext; - var html; - async.waterfall([ + let atext = pad.atext; + // fetch revision atext - function (callback) - { - if (revNum != undefined) - { - pad.getInternalRevisionAText(revNum, function (err, revisionAtext) - { - if(ERR(err, callback)) return; - atext = revisionAtext; - callback(); - }); - } - else - { - callback(null); - } - }, + if (revNum != undefined) { + atext = await pad.getInternalRevisionAText(revNum); + } // convert atext to html - - - function (callback) - { - html = getHTMLFromAtext(pad, atext); - callback(null); - }], - // run final callback - - - function (err) - { - if(ERR(err, callback)) return; - callback(null, html); - }); + return getHTMLFromAtext(pad, atext); } exports.getPadHTML = getPadHTML; @@ -81,15 +50,16 @@ function getHTMLFromAtext(pad, atext, authorColors) // prepare tags stored as ['tag', true] to be exported hooks.aCallAll("exportHtmlAdditionalTags", pad, function(err, newProps){ - newProps.forEach(function (propName, i){ + newProps.forEach(function (propName, i) { tags.push(propName); props.push(propName); }); }); + // prepare tags stored as ['tag', 'value'] to be exported. This will generate HTML // with tags like hooks.aCallAll("exportHtmlAdditionalTagsWithData", pad, function(err, newProps){ - newProps.forEach(function (propName, i){ + newProps.forEach(function (propName, i) { tags.push('span data-' + propName[0] + '="' + propName[1] + '"'); props.push(propName); }); @@ -361,12 +331,12 @@ function getHTMLFromAtext(pad, atext, authorColors) nextLine = _analyzeLine(textLines[i + 1], attribLines[i + 1], apool); } hooks.aCallAll('getLineHTMLForExport', context); - //To create list parent elements + //To create list parent elements if ((!prevLine || prevLine.listLevel !== line.listLevel) || (prevLine && line.listTypeName !== prevLine.listTypeName)) { var exists = _.find(openLists, function (item) { - return (item.level === line.listLevel && item.type === line.listTypeName); + return (item.level === line.listLevel && item.type === line.listTypeName); }); if (!exists) { var prevLevel = 0; @@ -395,7 +365,7 @@ function getHTMLFromAtext(pad, atext, authorColors) { pieces.push("
    "); } - } + } } } @@ -428,7 +398,7 @@ function getHTMLFromAtext(pad, atext, authorColors) { pieces.push(""); } - + if (line.listTypeName === "number") { pieces.push(""); @@ -437,7 +407,7 @@ function getHTMLFromAtext(pad, atext, authorColors) { pieces.push("
"); } - } + } } } else//outside any list, need to close line.listLevel of lists @@ -453,38 +423,31 @@ function getHTMLFromAtext(pad, atext, authorColors) hooks.aCallAll("getLineHTMLForExport", context); pieces.push(context.lineContent, "
"); - } } + } return pieces.join(''); } -exports.getPadHTMLDocument = function (padId, revNum, callback) +exports.getPadHTMLDocument = async function (padId, revNum) { - padManager.getPad(padId, function (err, pad) - { - if(ERR(err, callback)) return; + let pad = await padManager.getPad(padId); - var stylesForExportCSS = ""; - // Include some Styles into the Head for Export - hooks.aCallAll("stylesForExport", padId, function(err, stylesForExport){ - stylesForExport.forEach(function(css){ - stylesForExportCSS += css; - }); - - getPadHTML(pad, revNum, function (err, html) - { - if(ERR(err, callback)) return; - var exportedDoc = eejs.require("ep_etherpad-lite/templates/export_html.html", { - body: html, - padId: Security.escapeHTML(padId), - extraCSS: stylesForExportCSS - }); - callback(null, exportedDoc); - }); - }); + // Include some Styles into the Head for Export + let stylesForExportCSS = ""; + let stylesForExport = await hooks.aCallAll("stylesForExport", padId); + stylesForExport.forEach(function(css){ + stylesForExportCSS += css; }); -}; + + let html = await getPadHTML(pad, revNum); + + return eejs.require("ep_etherpad-lite/templates/export_html.html", { + body: html, + padId: Security.escapeHTML(padId), + extraCSS: stylesForExportCSS + }); +} // 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]/; diff --git a/src/node/utils/ExportTxt.js b/src/node/utils/ExportTxt.js index e3ce01520..304f77b8a 100644 --- a/src/node/utils/ExportTxt.js +++ b/src/node/utils/ExportTxt.js @@ -18,59 +18,24 @@ * limitations under the License. */ -var async = require("async"); var Changeset = require("ep_etherpad-lite/static/js/Changeset"); var padManager = require("../db/PadManager"); -var ERR = require("async-stacktrace"); var _analyzeLine = require('./ExportHelper')._analyzeLine; // This is slightly different than the HTML method as it passes the output to getTXTFromAText -function getPadTXT(pad, revNum, callback) +var getPadTXT = async function(pad, revNum) { - var atext = pad.atext; - var html; - async.waterfall([ - // fetch revision atext + let atext = pad.atext; - - function (callback) - { - if (revNum != undefined) - { - pad.getInternalRevisionAText(revNum, function (err, revisionAtext) - { - if(ERR(err, callback)) return; - atext = revisionAtext; - callback(); - }); - } - else - { - callback(null); - } - }, + if (revNum != undefined) { + // fetch revision atext + atext = await pad.getInternalRevisionAText(revNum); + } // convert atext to html - - - function (callback) - { - html = getTXTFromAtext(pad, atext); // only this line is different to the HTML function - callback(null); - }], - // run final callback - - - function (err) - { - if(ERR(err, callback)) return; - callback(null, html); - }); + return getTXTFromAtext(pad, atext); } -exports.getPadTXT = getPadTXT; - - // This is different than the functionality provided in ExportHtml as it provides formatting // functionality that is designed specifically for TXT exports function getTXTFromAtext(pad, atext, authorColors) @@ -83,17 +48,14 @@ function getTXTFromAtext(pad, atext, authorColors) var anumMap = {}; var css = ""; - props.forEach(function (propName, i) - { + props.forEach(function(propName, i) { var propTrueNum = apool.putAttrib([propName, true], true); - if (propTrueNum >= 0) - { + if (propTrueNum >= 0) { anumMap[propTrueNum] = i; } }); - function getLineTXT(text, attribs) - { + function getLineTXT(text, attribs) { var propVals = [false, false, false]; var ENTER = 1; var STAY = 2; @@ -109,94 +71,77 @@ function getTXTFromAtext(pad, atext, authorColors) var idx = 0; - function processNextChars(numChars) - { - if (numChars <= 0) - { + function processNextChars(numChars) { + if (numChars <= 0) { return; } var iter = Changeset.opIterator(Changeset.subattribution(attribs, idx, idx + numChars)); idx += numChars; - while (iter.hasNext()) - { + while (iter.hasNext()) { var o = iter.next(); var propChanged = false; - Changeset.eachAttribNumber(o.attribs, function (a) - { - if (a in anumMap) - { + + Changeset.eachAttribNumber(o.attribs, function(a) { + if (a in anumMap) { var i = anumMap[a]; // i = 0 => bold, etc. - if (!propVals[i]) - { + + if (!propVals[i]) { propVals[i] = ENTER; propChanged = true; - } - else - { + } else { propVals[i] = STAY; } } }); - for (var i = 0; i < propVals.length; i++) - { - if (propVals[i] === true) - { + + 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 + } else if (propVals[i] === STAY) { + // set it back + propVals[i] = true; } } + // now each member of propVal is in {false,LEAVE,ENTER,true} // according to what happens at start of span - if (propChanged) - { + if (propChanged) { // leaving bold (e.g.) also leaves italics, etc. var left = false; - for (var i = 0; i < propVals.length; i++) - { + + for (var i = 0; i < propVals.length; i++) { var v = propVals[i]; - if (!left) - { - if (v === LEAVE) - { + + if (!left) { + if (v === LEAVE) { left = true; } - } - else - { - if (v === true) - { - propVals[i] = STAY; // tag will be closed and re-opened + } else { + if (v === true) { + // tag will be closed and re-opened + propVals[i] = STAY; } } } var tags2close = []; - for (var i = propVals.length - 1; i >= 0; i--) - { - if (propVals[i] === LEAVE) - { + for (var i = propVals.length - 1; i >= 0; i--) { + if (propVals[i] === LEAVE) { //emitCloseTag(i); tags2close.push(i); propVals[i] = false; - } - else if (propVals[i] === STAY) - { + } else if (propVals[i] === STAY) { //emitCloseTag(i); tags2close.push(i); } } - for (var i = 0; i < propVals.length; i++) - { - if (propVals[i] === ENTER || propVals[i] === STAY) - { + for (var i = 0; i < propVals.length; i++) { + if (propVals[i] === ENTER || propVals[i] === STAY) { propVals[i] = true; } } @@ -204,9 +149,9 @@ function getTXTFromAtext(pad, atext, authorColors) } // end if (propChanged) var chars = o.chars; - if (o.lines) - { - chars--; // exclude newline at end of line, if present + if (o.lines) { + // exclude newline at end of line, if present + chars--; } var s = taker.take(chars); @@ -223,19 +168,19 @@ function getTXTFromAtext(pad, atext, authorColors) } // end iteration over spans in line var tags2close = []; - for (var i = propVals.length - 1; i >= 0; i--) - { - if (propVals[i]) - { + for (var i = propVals.length - 1; i >= 0; i--) { + if (propVals[i]) { tags2close.push(i); propVals[i] = false; } } } // end processNextChars + processNextChars(text.length - idx); return(assem.toString()); } // end getLineHTML + var pieces = [css]; // Need to deal with constraints imposed on HTML lists; can @@ -245,42 +190,38 @@ function getTXTFromAtext(pad, atext, authorColors) // so we want to do something reasonable there. We also // want to deal gracefully with blank lines. // => keeps track of the parents level of indentation - for (var i = 0; i < textLines.length; i++) - { + for (var i = 0; i < textLines.length; i++) { var line = _analyzeLine(textLines[i], attribLines[i], apool); var lineContent = getLineTXT(line.text, line.aline); - if(line.listTypeName == "bullet"){ + + if (line.listTypeName == "bullet") { lineContent = "* " + lineContent; // add a bullet } - if(line.listLevel > 0){ - for (var j = line.listLevel - 1; j >= 0; j--){ + + if (line.listLevel > 0) { + for (var j = line.listLevel - 1; j >= 0; j--) { pieces.push('\t'); } - if(line.listTypeName == "number"){ + + if (line.listTypeName == "number") { pieces.push(line.listLevel + ". "); // This is bad because it doesn't truly reflect what the user // sees because browsers do magic on nested
  1. s } + pieces.push(lineContent, '\n'); - }else{ + } else { pieces.push(lineContent, '\n'); } } return pieces.join(''); } + exports.getTXTFromAtext = getTXTFromAtext; -exports.getPadTXTDocument = function (padId, revNum, callback) +exports.getPadTXTDocument = async function(padId, revNum) { - padManager.getPad(padId, function (err, pad) - { - if(ERR(err, callback)) return; - - getPadTXT(pad, revNum, function (err, html) - { - if(ERR(err, callback)) return; - callback(null, html); - }); - }); -}; + let pad = await padManager.getPad(padId); + return getPadTXT(pad, revNum); +} diff --git a/src/node/utils/ImportEtherpad.js b/src/node/utils/ImportEtherpad.js index bf1129cb9..a5b1074e6 100644 --- a/src/node/utils/ImportEtherpad.js +++ b/src/node/utils/ImportEtherpad.js @@ -15,60 +15,56 @@ */ var log4js = require('log4js'); -var async = require("async"); -var db = require("../db/DB").db; +const db = require("../db/DB"); -exports.setPadRaw = function(padId, records, callback){ +exports.setPadRaw = function(padId, records) +{ records = JSON.parse(records); - async.eachSeries(Object.keys(records), function(key, cb){ - var value = records[key] + Object.keys(records).forEach(async function(key) { + let value = records[key]; - if(!value){ - return setImmediate(cb); + if (!value) { + return; } - // Author data - if(value.padIDs){ - // rewrite author pad ids + let newKey; + + if (value.padIDs) { + // Author data - rewrite author pad ids value.padIDs[padId] = 1; - var newKey = key; + newKey = key; // Does this author already exist? - db.get(key, function(err, author){ - if(author){ - // Yes, add the padID to the author.. - if( Object.prototype.toString.call(author) === '[object Array]'){ - author.padIDs.push(padId); - } - value = author; - }else{ - // No, create a new array with the author info in - value.padIDs = [padId]; + let author = await db.get(key); + + if (author) { + // Yes, add the padID to the author + if (Object.prototype.toString.call(author) === '[object Array]') { + author.padIDs.push(padId); } - }); - // Not author data, probably pad data - }else{ - // we can split it to look to see if its pad data - var oldPadId = key.split(":"); - - // we know its pad data.. - if(oldPadId[0] === "pad"){ + value = author; + } else { + // No, create a new array with the author info in + value.padIDs = [ padId ]; + } + } else { + // Not author data, probably pad data + // we can split it to look to see if it's pad data + let oldPadId = key.split(":"); + // we know it's pad data + if (oldPadId[0] === "pad") { // so set the new pad id for the author oldPadId[1] = padId; - + // and create the value - var newKey = oldPadId.join(":"); // create the new key + newKey = oldPadId.join(":"); // create the new key } - } - // Write the value to the server - db.set(newKey, value); - setImmediate(cb); - }, function(){ - callback(null, true); + // Write the value to the server + await db.set(newKey, value); }); } diff --git a/src/node/utils/ImportHtml.js b/src/node/utils/ImportHtml.js index d71e27201..63b35fa75 100644 --- a/src/node/utils/ImportHtml.js +++ b/src/node/utils/ImportHtml.js @@ -19,7 +19,7 @@ var Changeset = require("ep_etherpad-lite/static/js/Changeset"); var contentcollector = require("ep_etherpad-lite/static/js/contentcollector"); var cheerio = require("cheerio"); -function setPadHTML(pad, html, callback) +exports.setPadHTML = function(pad, html) { var apiLogger = log4js.getLogger("ImportHtml"); @@ -36,19 +36,22 @@ function setPadHTML(pad, html, callback) // Convert a dom tree into a list of lines and attribute liens // using the content collector object var cc = contentcollector.makeContentCollector(true, null, pad.pool); - try{ // we use a try here because if the HTML is bad it will blow up + try { + // we use a try here because if the HTML is bad it will blow up cc.collectContent(doc); - }catch(e){ + } catch(e) { apiLogger.warn("HTML was not properly formed", e); - return callback(e); // We don't process the HTML because it was bad.. + + // don't process the HTML because it was bad + throw e; } var result = cc.finish(); apiLogger.debug('Lines:'); + var i; - for (i = 0; i < result.lines.length; i += 1) - { + for (i = 0; i < result.lines.length; i++) { apiLogger.debug('Line ' + (i + 1) + ' text: ' + result.lines[i]); apiLogger.debug('Line ' + (i + 1) + ' attributes: ' + result.lineAttribs[i]); } @@ -59,18 +62,15 @@ function setPadHTML(pad, html, callback) apiLogger.debug(newText); var newAttribs = result.lineAttribs.join('|1+1') + '|1+1'; - function eachAttribRun(attribs, func /*(startInNewText, endInNewText, attribs)*/ ) - { + function eachAttribRun(attribs, func /*(startInNewText, endInNewText, attribs)*/ ) { var attribsIter = Changeset.opIterator(attribs); var textIndex = 0; var newTextStart = 0; var newTextEnd = newText.length; - while (attribsIter.hasNext()) - { + while (attribsIter.hasNext()) { var op = attribsIter.next(); var nextIndex = textIndex + op.chars; - if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) - { + if (!(nextIndex <= newTextStart || textIndex >= newTextEnd)) { func(Math.max(newTextStart, textIndex), Math.min(newTextEnd, nextIndex), op.attribs); } textIndex = nextIndex; @@ -81,17 +81,14 @@ function setPadHTML(pad, html, callback) var builder = Changeset.builder(1); // assemble each line into the builder - eachAttribRun(newAttribs, function(start, end, attribs) - { + eachAttribRun(newAttribs, function(start, end, attribs) { builder.insert(newText.substring(start, end), attribs); }); // the changeset is ready! var theChangeset = builder.toString(); + apiLogger.debug('The changeset: ' + theChangeset); pad.setText("\n"); pad.appendRevision(theChangeset); - callback(null); } - -exports.setPadHTML = setPadHTML; diff --git a/src/node/utils/Minify.js b/src/node/utils/Minify.js index 4596f404c..d70c835c6 100644 --- a/src/node/utils/Minify.js +++ b/src/node/utils/Minify.js @@ -70,7 +70,7 @@ for (var key in tar) { // What follows is a terrible hack to avoid loop-back within the server. // TODO: Serve files from another service, or directly from the file system. -function requestURI(url, method, headers, callback, redirectCount) { +function requestURI(url, method, headers, callback) { var parsedURL = urlutil.parse(url); var status = 500, headers = {}, content = []; @@ -137,7 +137,7 @@ function requestURIs(locations, method, headers, callback) { * @param req the Express request * @param res the Express response */ -function minify(req, res, next) +function minify(req, res) { var filename = req.params['filename']; @@ -204,7 +204,7 @@ function minify(req, res, next) res.setHeader('last-modified', date.toUTCString()); res.setHeader('date', (new Date()).toUTCString()); if (settings.maxAge !== undefined) { - var expiresDate = new Date((new Date()).getTime()+settings.maxAge*1000); + var expiresDate = new Date(Date.now()+settings.maxAge*1000); res.setHeader('expires', expiresDate.toUTCString()); res.setHeader('cache-control', 'max-age=' + settings.maxAge); } @@ -240,7 +240,7 @@ function minify(req, res, next) res.end(); } } - }); + }, 3); } // find all includes in ace.js and embed them. @@ -287,6 +287,10 @@ function getAceFile(callback) { // Check for the existance of the file and get the last modification date. function statFile(filename, callback, dirStatLimit) { + /* + * The only external call to this function provides an explicit value for + * dirStatLimit: this check could be removed. + */ if (typeof dirStatLimit === 'undefined') { dirStatLimit = 3; } diff --git a/src/node/utils/NodeVersion.js b/src/node/utils/NodeVersion.js index 2909d5bc9..1ebbcbca0 100644 --- a/src/node/utils/NodeVersion.js +++ b/src/node/utils/NodeVersion.js @@ -18,24 +18,24 @@ * limitations under the License. */ +const semver = require('semver'); + /** * Quits if Etherpad is not running on a given minimum Node version * * @param {String} minNodeVersion Minimum required Node version - * @param {Function} callback Standard callback function */ -exports.enforceMinNodeVersion = function(minNodeVersion, callback) { - const semver = require('semver'); +exports.enforceMinNodeVersion = function(minNodeVersion) { const currentNodeVersion = process.version; // we cannot use template literals, since we still do not know if we are // running under Node >= 4.0 if (semver.lt(currentNodeVersion, minNodeVersion)) { console.error('Running Etherpad on Node ' + currentNodeVersion + ' is not supported. Please upgrade at least to Node ' + minNodeVersion); - } else { - console.debug('Running on Node ' + currentNodeVersion + ' (minimum required Node version: ' + minNodeVersion + ')'); - callback(); + process.exit(1); } + + console.debug('Running on Node ' + currentNodeVersion + ' (minimum required Node version: ' + minNodeVersion + ')'); }; /** @@ -44,13 +44,10 @@ exports.enforceMinNodeVersion = function(minNodeVersion, callback) { * @param {String} lowestNonDeprecatedNodeVersion all Node version less than this one are deprecated * @param {Function} epRemovalVersion Etherpad version that will remove support for deprecated Node releases */ -exports.checkDeprecationStatus = function(lowestNonDeprecatedNodeVersion, epRemovalVersion, callback) { - const semver = require('semver'); +exports.checkDeprecationStatus = function(lowestNonDeprecatedNodeVersion, epRemovalVersion) { const currentNodeVersion = process.version; if (semver.lt(currentNodeVersion, lowestNonDeprecatedNodeVersion)) { console.warn(`Support for Node ${currentNodeVersion} will be removed in Etherpad ${epRemovalVersion}. Please consider updating at least to Node ${lowestNonDeprecatedNodeVersion}`); } - - callback(); }; diff --git a/src/node/utils/Settings.js b/src/node/utils/Settings.js index 508e6148d..1b2c22109 100644 --- a/src/node/utils/Settings.js +++ b/src/node/utils/Settings.js @@ -231,85 +231,96 @@ exports.loadTest = false; exports.indentationOnNewLine = true; /* -* log4js appender configuration -*/ + * log4js appender configuration + */ exports.logconfig = { appenders: [{ type: "console" }]}; /* -* Session Key, do not sure this. -*/ + * Session Key, do not sure this. + */ exports.sessionKey = false; /* -* Trust Proxy, whether or not trust the x-forwarded-for header. -*/ + * Trust Proxy, whether or not trust the x-forwarded-for header. + */ exports.trustProxy = false; -/* This setting is used if you need authentication and/or +/* + * This setting is used if you need authentication and/or * authorization. Note: /admin always requires authentication, and - * either authorization by a module, or a user with is_admin set */ + * either authorization by a module, or a user with is_admin set + */ exports.requireAuthentication = false; exports.requireAuthorization = false; exports.users = {}; /* -* Show settings in admin page, by default it is true -*/ + * Show settings in admin page, by default it is true + */ exports.showSettingsInAdminPage = true; /* -* By default, when caret is moved out of viewport, it scrolls the minimum height needed to make this -* line visible. -*/ + * By default, when caret is moved out of viewport, it scrolls the minimum + * height needed to make this line visible. + */ exports.scrollWhenFocusLineIsOutOfViewport = { /* - * Percentage of viewport height to be additionally scrolled. - */ + * Percentage of viewport height to be additionally scrolled. + */ "percentage": { "editionAboveViewport": 0, "editionBelowViewport": 0 }, + /* - * Time (in milliseconds) used to animate the scroll transition. Set to 0 to disable animation - */ + * Time (in milliseconds) used to animate the scroll transition. Set to 0 to + * disable animation + */ "duration": 0, + /* - * Flag to control if it should scroll when user places the caret in the last line of the viewport - */ - /* - * Percentage of viewport height to be additionally scrolled when user presses arrow up - * in the line of the top of the viewport. + * Percentage of viewport height to be additionally scrolled when user presses arrow up + * in the line of the top of the viewport. */ "percentageToScrollWhenUserPressesArrowUp": 0, + + /* + * Flag to control if it should scroll when user places the caret in the last + * line of the viewport + */ "scrollWhenCaretIsInTheLastLineOfViewport": false }; -//checks if abiword is avaiable +/* + * Expose Etherpad version in the web interface and in the Server http header. + * + * Do not enable on production machines. + */ +exports.exposeVersion = false; + +// checks if abiword is avaiable exports.abiwordAvailable = function() { - if(exports.abiword != null) - { + if (exports.abiword != null) { return os.type().indexOf("Windows") != -1 ? "withoutPDF" : "yes"; - } - else - { + } else { return "no"; } }; -exports.sofficeAvailable = function () { - if(exports.soffice != null) { +exports.sofficeAvailable = function() { + if (exports.soffice != null) { return os.type().indexOf("Windows") != -1 ? "withoutPDF": "yes"; } else { return "no"; } }; -exports.exportAvailable = function () { +exports.exportAvailable = function() { var abiword = exports.abiwordAvailable(); var soffice = exports.sofficeAvailable(); - if(abiword == "no" && soffice == "no") { + if (abiword == "no" && soffice == "no") { return "no"; } else if ((abiword == "withoutPDF" && soffice == "no") || (abiword == "no" && soffice == "withoutPDF")) { return "withoutPDF"; @@ -321,8 +332,7 @@ exports.exportAvailable = function () { // Provide git version if available exports.getGitCommit = function() { var version = ""; - try - { + try { var rootPath = path.resolve(npm.dir, '..'); if (fs.lstatSync(rootPath + '/.git').isFile()) { rootPath = fs.readFileSync(rootPath + '/.git', "utf8"); @@ -334,9 +344,7 @@ exports.getGitCommit = function() { var refPath = rootPath + "/" + ref.substring(5, ref.indexOf("\n")); version = fs.readFileSync(refPath, "utf-8"); version = version.substring(0, 7); - } - catch(e) - { + } catch(e) { console.warn("Can't get git version for server header\n" + e.message) } return version; @@ -347,100 +355,238 @@ exports.getEpVersion = function() { return require('ep_etherpad-lite/package.json').version; } +/** + * Receives a settingsObj and, if the property name is a valid configuration + * item, stores it in the module's exported properties via a side effect. + * + * This code refactors a previous version that copied & pasted the same code for + * both "settings.json" and "credentials.json". + */ +function storeSettings(settingsObj) { + for (var i in settingsObj) { + // test if the setting starts with a lowercase character + if (i.charAt(0).search("[a-z]") !== 0) { + console.warn(`Settings should start with a lowercase character: '${i}'`); + } + + // we know this setting, so we overwrite it + // or it's a settings hash, specific to a plugin + if (exports[i] !== undefined || i.indexOf('ep_') == 0) { + if (_.isObject(settingsObj[i]) && !_.isArray(settingsObj[i])) { + exports[i] = _.defaults(settingsObj[i], exports[i]); + } else { + exports[i] = settingsObj[i]; + } + } else { + // this setting is unknown, output a warning and throw it away + console.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`); + } + } +} + +/* + * If stringValue is a numeric string, or its value is "true" or "false", coerce + * them to appropriate JS types. Otherwise return stringValue as-is. + */ +function coerceValue(stringValue) { + // cooked from https://stackoverflow.com/questions/175739/built-in-way-in-javascript-to-check-if-a-string-is-a-valid-number + const isNumeric = !isNaN(stringValue) && !isNaN(parseFloat(stringValue) && isFinite(stringValue)); + + if (isNumeric) { + // detected numeric string. Coerce to a number + + return +stringValue; + } + + // the boolean literal case is easy. + if (stringValue === "true" ) { + return true; + } + + if (stringValue === "false") { + return false; + } + + // otherwise, return this value as-is + return stringValue; +} + +/** + * Takes a javascript object containing Etherpad's configuration, and returns + * another object, in which all the string properties whose value is of the form + * "${ENV_VAR}" or "${ENV_VAR:default_value}" got their value replaced with the + * contents of the given environment variable, or with a default value. + * + * By definition, an environment variable's value is always a string. However, + * the code base makes use of the various json types. To maintain compatiblity, + * some heuristics is applied: + * + * - if ENV_VAR does not exist in the environment, null is returned; + * - if ENV_VAR's value is "true" or "false", it is converted to the js boolean + * values true or false; + * - if ENV_VAR's value looks like a number, it is converted to a js number + * (details in the code). + * + * The following is a scheme of the behaviour of this function: + * + * +---------------------------+---------------+------------------+ + * | Configuration string in | Value of | Resulting confi- | + * | settings.json | ENV_VAR | guration value | + * |---------------------------|---------------|------------------| + * | "${ENV_VAR}" | "some_string" | "some_string" | + * | "${ENV_VAR}" | "9001" | 9001 | + * | "${ENV_VAR}" | undefined | null | + * | "${ENV_VAR:some_default}" | "some_string" | "some_string" | + * | "${ENV_VAR:some_default}" | undefined | "some_default" | + * +---------------------------+---------------+------------------+ + * + * IMPLEMENTATION NOTE: variable substitution is performed doing a round trip + * conversion to/from json, using a custom replacer parameter in + * JSON.stringify(), and parsing the JSON back again. This ensures that + * environment variable replacement is performed even on nested objects. + * + * see: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify#The_replacer_parameter + */ +function lookupEnvironmentVariables(obj) { + const stringifiedAndReplaced = JSON.stringify(obj, (key, value) => { + /* + * the first invocation of replacer() is with an empty key. Just go on, or + * we would zap the entire object. + */ + if (key === '') { + return value; + } + + /* + * If we received from the configuration file a number, a boolean or + * something that is not a string, we can be sure that it was a literal + * value. No need to perform any variable substitution. + * + * The environment variable expansion syntax "${ENV_VAR}" is just a string + * of specific form, after all. + */ + if (typeof value !== 'string') { + return value; + } + + /* + * Let's check if the string value looks like a variable expansion (e.g.: + * "${ENV_VAR}" or "${ENV_VAR:default_value}") + */ + // MUXATOR 2019-03-21: we could use named capture groups here once we migrate to nodejs v10 + const match = value.match(/^\$\{([^:]*)(:(.*))?\}$/); + + if (match === null) { + // no match: use the value literally, without any substitution + + return value; + } + + /* + * We found the name of an environment variable. Let's read its actual value + * and its default value, if given + */ + const envVarName = match[1]; + const envVarValue = process.env[envVarName]; + const defaultValue = match[3]; + + if ((envVarValue === undefined) && (defaultValue === undefined)) { + console.warn(`Environment variable "${envVarName}" does not contain any value for configuration key "${key}", and no default was given. Returning null. Please check your configuration and environment settings.`); + + /* + * We have to return null, because if we just returned undefined, the + * configuration item "key" would be stripped from the returned object. + */ + return null; + } + + if ((envVarValue === undefined) && (defaultValue !== undefined)) { + console.debug(`Environment variable "${envVarName}" not found for configuration key "${key}". Falling back to default value.`); + + return coerceValue(defaultValue); + } + + // envVarName contained some value. + + /* + * For numeric and boolean strings let's convert it to proper types before + * returning it, in order to maintain backward compatibility. + */ + console.debug(`Configuration key "${key}" will be read from environment variable "${envVarName}"`); + + return coerceValue(envVarValue); + }); + + const newSettings = JSON.parse(stringifiedAndReplaced); + + return newSettings; +} + +/** + * - reads the JSON configuration file settingsFilename from disk + * - strips the comments + * - replaces environment variables calling lookupEnvironmentVariables() + * - returns a parsed Javascript object + * + * The isSettings variable only controls the error logging. + */ +function parseSettings(settingsFilename, isSettings) { + let settingsStr = ""; + + let settingsType, notFoundMessage, notFoundFunction; + + if (isSettings) { + settingsType = "settings"; + notFoundMessage = "Continuing using defaults!"; + notFoundFunction = console.warn; + } else { + settingsType = "credentials"; + notFoundMessage = "Ignoring."; + notFoundFunction = console.info; + } + + try { + //read the settings file + settingsStr = fs.readFileSync(settingsFilename).toString(); + } catch(e) { + notFoundFunction(`No ${settingsType} file found in ${settingsFilename}. ${notFoundMessage}`); + + // or maybe undefined! + return null; + } + + try { + settingsStr = jsonminify(settingsStr).replace(",]","]").replace(",}","}"); + + const settings = JSON.parse(settingsStr); + + console.info(`${settingsType} loaded from: ${settingsFilename}`); + + const replacedSettings = lookupEnvironmentVariables(settings); + + return replacedSettings; + } catch(e) { + console.error(`There was an error processing your ${settingsType} file from ${settingsFilename}: ${e.message}`); + + process.exit(1); + } +} + exports.reloadSettings = function reloadSettings() { // Discover where the settings file lives var settingsFilename = absolutePaths.makeAbsolute(argv.settings || "settings.json"); - + // Discover if a credential file exists var credentialsFilename = absolutePaths.makeAbsolute(argv.credentials || "credentials.json"); - var settingsStr, credentialsStr; - try{ - //read the settings sync - settingsStr = fs.readFileSync(settingsFilename).toString(); - console.info(`Settings loaded from: ${settingsFilename}`); - } catch(e){ - console.warn(`No settings file found in ${settingsFilename}. Continuing using defaults!`); - } - - try{ - //read the credentials sync - credentialsStr = fs.readFileSync(credentialsFilename).toString(); - console.info(`Credentials file read from: ${credentialsFilename}`); - } catch(e){ - // Doesn't matter if no credentials file found.. - console.info(`No credentials file found in ${credentialsFilename}. Ignoring.`); - } - // try to parse the settings - var settings; - var credentials; - try { - if(settingsStr) { - settingsStr = jsonminify(settingsStr).replace(",]","]").replace(",}","}"); - settings = JSON.parse(settingsStr); - } - }catch(e){ - console.error(`There was an error processing your settings file from ${settingsFilename}:` + e.message); - process.exit(1); - } + var settings = parseSettings(settingsFilename, true); - if(credentialsStr) { - credentialsStr = jsonminify(credentialsStr).replace(",]","]").replace(",}","}"); - credentials = JSON.parse(credentialsStr); - } + // try to parse the credentials + var credentials = parseSettings(credentialsFilename, false); - //loop trough the settings - for(var i in settings) - { - //test if the setting start with a lowercase character - if(i.charAt(0).search("[a-z]") !== 0) - { - console.warn(`Settings should start with a lowercase character: '${i}'`); - } - - //we know this setting, so we overwrite it - //or it's a settings hash, specific to a plugin - if(exports[i] !== undefined || i.indexOf('ep_')==0) - { - if (_.isObject(settings[i]) && !_.isArray(settings[i])) { - exports[i] = _.defaults(settings[i], exports[i]); - } else { - exports[i] = settings[i]; - } - } - //this setting is unkown, output a warning and throw it away - else - { - console.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`); - } - } - - //loop trough the settings - for(var i in credentials) - { - //test if the setting start with a lowercase character - if(i.charAt(0).search("[a-z]") !== 0) - { - console.warn(`Settings should start with a lowercase character: '${i}'`); - } - - //we know this setting, so we overwrite it - //or it's a settings hash, specific to a plugin - if(exports[i] !== undefined || i.indexOf('ep_')==0) - { - if (_.isObject(credentials[i]) && !_.isArray(credentials[i])) { - exports[i] = _.defaults(credentials[i], exports[i]); - } else { - exports[i] = credentials[i]; - } - } - //this setting is unkown, output a warning and throw it away - else - { - console.warn(`Unknown Setting: '${i}'. This setting doesn't exist or it was removed`); - } - } + storeSettings(settings); + storeSettings(credentials); log4js.configure(exports.logconfig);//Configure the logging appenders log4js.setGlobalLogLevel(exports.loglevel);//set loglevel @@ -483,32 +629,52 @@ exports.reloadSettings = function reloadSettings() { console.info(`Using skin "${exports.skinName}" in dir: ${skinPath}`); } - if(exports.abiword){ + if (exports.users) { + /* + * Prune from export.users any user that has no password attribute, or whose + * password attribute is "null". + * + * This is used by the settings.json in the default Dockerfile to eschew + * creating an admin user if no password is set. + */ + var filteredUsers = _.filter(exports.users, function(user, username) { + if ((user.hasOwnProperty("password")) || user.password !== null) { + return true; + } + + console.warn(`The password for ${username} is null. This means the user must not be created. Removing it.`); + + return false; + }); + + exports.users = filteredUsers; + } + + if (exports.abiword) { // Check abiword actually exists - if(exports.abiword != null) - { + if (exports.abiword != null) { fs.exists(exports.abiword, function(exists) { if (!exists) { - var abiwordError = "Abiword does not exist at this path, check your settings file"; - if(!exports.suppressErrorsInPadText){ + var abiwordError = "Abiword does not exist at this path, check your settings file."; + if (!exports.suppressErrorsInPadText) { exports.defaultPadText = exports.defaultPadText + "\nError: " + abiwordError + suppressDisableMsg; } - console.error(abiwordError); + console.error(abiwordError + ` File location: ${exports.abiword}`); exports.abiword = null; } }); } } - if(exports.soffice) { - fs.exists(exports.soffice, function (exists) { - if(!exists) { - var sofficeError = "SOffice does not exist at this path, check your settings file"; + if (exports.soffice) { + fs.exists(exports.soffice, function(exists) { + if (!exists) { + var sofficeError = "soffice (libreoffice) does not exist at this path, check your settings file."; - if(!exports.suppressErrorsInPadText) { + if (!exports.suppressErrorsInPadText) { exports.defaultPadText = exports.defaultPadText + "\nError: " + sofficeError + suppressDisableMsg; } - console.error(sofficeError); + console.error(sofficeError + ` File location: ${exports.soffice}`); exports.soffice = null; } }); @@ -528,9 +694,9 @@ exports.reloadSettings = function reloadSettings() { console.warn("Declaring the sessionKey in the settings.json is deprecated. This value is auto-generated now. Please remove the setting from the file."); } - if(exports.dbType === "dirty"){ + if (exports.dbType === "dirty") { var dirtyWarning = "DirtyDB is used. This is fine for testing but not recommended for production."; - if(!exports.suppressErrorsInPadText){ + if (!exports.suppressErrorsInPadText) { exports.defaultPadText = exports.defaultPadText + "\nWarning: " + dirtyWarning + suppressDisableMsg; } @@ -541,5 +707,3 @@ exports.reloadSettings = function reloadSettings() { // initially load settings exports.reloadSettings(); - - diff --git a/src/node/utils/TidyHtml.js b/src/node/utils/TidyHtml.js index 5d4e6ed75..26d48a62f 100644 --- a/src/node/utils/TidyHtml.js +++ b/src/node/utils/TidyHtml.js @@ -6,36 +6,38 @@ var log4js = require('log4js'); var settings = require('./Settings'); var spawn = require('child_process').spawn; -exports.tidy = function(srcFile, callback) { +exports.tidy = function(srcFile) { var logger = log4js.getLogger('TidyHtml'); - // Don't do anything if Tidy hasn't been enabled - if (!settings.tidyHtml) { - logger.debug('tidyHtml has not been configured yet, ignoring tidy request'); - return callback(null); - } + return new Promise((resolve, reject) => { - var errMessage = ''; - - // Spawn a new tidy instance that cleans up the file inline - logger.debug('Tidying ' + srcFile); - var tidy = spawn(settings.tidyHtml, ['-modify', srcFile]); - - // Keep track of any error messages - tidy.stderr.on('data', function (data) { - errMessage += data.toString(); - }); - - // Wait until Tidy is done - tidy.on('close', function(code) { - // Tidy returns a 0 when no errors occur and a 1 exit code when - // the file could be tidied but a few warnings were generated - if (code === 0 || code === 1) { - logger.debug('Tidied ' + srcFile + ' successfully'); - return callback(null); - } else { - logger.error('Failed to tidy ' + srcFile + '\n' + errMessage); - return callback('Tidy died with exit code ' + code); + // Don't do anything if Tidy hasn't been enabled + if (!settings.tidyHtml) { + logger.debug('tidyHtml has not been configured yet, ignoring tidy request'); + return resolve(null); } + + var errMessage = ''; + + // Spawn a new tidy instance that cleans up the file inline + logger.debug('Tidying ' + srcFile); + var tidy = spawn(settings.tidyHtml, ['-modify', srcFile]); + + // Keep track of any error messages + tidy.stderr.on('data', function (data) { + errMessage += data.toString(); + }); + + tidy.on('close', function(code) { + // Tidy returns a 0 when no errors occur and a 1 exit code when + // the file could be tidied but a few warnings were generated + if (code === 0 || code === 1) { + logger.debug('Tidied ' + srcFile + ' successfully'); + resolve(null); + } else { + logger.error('Failed to tidy ' + srcFile + '\n' + errMessage); + reject('Tidy died with exit code ' + code); + } + }); }); -}; +} diff --git a/src/node/utils/customError.js b/src/node/utils/customError.js index 5ca7a7a41..c18743485 100644 --- a/src/node/utils/customError.js +++ b/src/node/utils/customError.js @@ -5,11 +5,11 @@ function customError(message, errorName) { this.name = errorName || "Error"; this.message = message; - + var stackParts = new Error().stack.split("\n"); stackParts.splice(0,2); stackParts.unshift(this.name + ": " + message); - + this.stack = stackParts.join("\n"); } customError.prototype = Error.prototype; diff --git a/src/node/utils/padDiff.js b/src/node/utils/padDiff.js index 24d5bb0c2..7cf29aba4 100644 --- a/src/node/utils/padDiff.js +++ b/src/node/utils/padDiff.js @@ -1,336 +1,267 @@ var Changeset = require("../../static/js/Changeset"); -var async = require("async"); var exportHtml = require('./ExportHtml'); - -function PadDiff (pad, fromRev, toRev){ - //check parameters - if(!pad || !pad.id || !pad.atext || !pad.pool) - { + +function PadDiff (pad, fromRev, toRev) { + // check parameters + if (!pad || !pad.id || !pad.atext || !pad.pool) { throw new Error('Invalid pad'); } - + var range = pad.getValidRevisionRange(fromRev, toRev); - if(!range) { throw new Error('Invalid revision range.' + + if (!range) { + throw new Error('Invalid revision range.' + ' startRev: ' + fromRev + - ' endRev: ' + toRev); } - + ' endRev: ' + toRev); + } + this._pad = pad; this._fromRev = range.startRev; this._toRev = range.endRev; this._html = null; this._authors = []; } - -PadDiff.prototype._isClearAuthorship = function(changeset){ - //unpack + +PadDiff.prototype._isClearAuthorship = function(changeset) { + // unpack var unpacked = Changeset.unpack(changeset); - - //check if there is nothing in the charBank - if(unpacked.charBank !== "") + + // check if there is nothing in the charBank + if (unpacked.charBank !== "") { return false; - - //check if oldLength == newLength - if(unpacked.oldLen !== unpacked.newLen) + } + + // check if oldLength == newLength + if (unpacked.oldLen !== unpacked.newLen) { return false; - - //lets iterator over the operators + } + + // lets iterator over the operators var iterator = Changeset.opIterator(unpacked.ops); - - //get the first operator, this should be a clear operator + + // get the first operator, this should be a clear operator var clearOperator = iterator.next(); - - //check if there is only one operator - if(iterator.hasNext() === true) + + // check if there is only one operator + if (iterator.hasNext() === true) { return false; - - //check if this operator doesn't change text - if(clearOperator.opcode !== "=") + } + + // check if this operator doesn't change text + if (clearOperator.opcode !== "=") { return false; - - //check that this operator applys to the complete text - //if the text ends with a new line, its exactly one character less, else it has the same length - if(clearOperator.chars !== unpacked.oldLen-1 && clearOperator.chars !== unpacked.oldLen) + } + + // check that this operator applys to the complete text + // if the text ends with a new line, its exactly one character less, else it has the same length + if (clearOperator.chars !== unpacked.oldLen-1 && clearOperator.chars !== unpacked.oldLen) { return false; - + } + var attributes = []; - Changeset.eachAttribNumber(changeset, function(attrNum){ + Changeset.eachAttribNumber(changeset, function(attrNum) { attributes.push(attrNum); }); - - //check that this changeset uses only one attribute - if(attributes.length !== 1) + + // check that this changeset uses only one attribute + if (attributes.length !== 1) { return false; - + } + var appliedAttribute = this._pad.pool.getAttrib(attributes[0]); - - //check if the applied attribute is an anonymous author attribute - if(appliedAttribute[0] !== "author" || appliedAttribute[1] !== "") + + // check if the applied attribute is an anonymous author attribute + if (appliedAttribute[0] !== "author" || appliedAttribute[1] !== "") { return false; - + } + return true; }; - -PadDiff.prototype._createClearAuthorship = function(rev, callback){ - var self = this; - this._pad.getInternalRevisionAText(rev, function(err, atext){ - if(err){ - return callback(err); - } - - //build clearAuthorship changeset - var builder = Changeset.builder(atext.text.length); - builder.keepText(atext.text, [['author','']], self._pad.pool); - var changeset = builder.toString(); - - callback(null, changeset); - }); -}; - -PadDiff.prototype._createClearStartAtext = function(rev, callback){ - var self = this; - - //get the atext of this revision - this._pad.getInternalRevisionAText(rev, function(err, atext){ - if(err){ - return callback(err); - } - - //create the clearAuthorship changeset - self._createClearAuthorship(rev, function(err, changeset){ - if(err){ - return callback(err); - } - - try { - //apply the clearAuthorship changeset - var newAText = Changeset.applyToAText(changeset, atext, self._pad.pool); - } catch(err) { - return callback(err) - } - - callback(null, newAText); - }); - }); -}; - -PadDiff.prototype._getChangesetsInBulk = function(startRev, count, callback) { - var self = this; - - //find out which revisions we need - var revisions = []; - for(var i=startRev;i<(startRev+count) && i<=this._pad.head;i++){ + +PadDiff.prototype._createClearAuthorship = async function(rev) { + + let atext = await this._pad.getInternalRevisionAText(rev); + + // build clearAuthorship changeset + var builder = Changeset.builder(atext.text.length); + builder.keepText(atext.text, [['author','']], this._pad.pool); + var changeset = builder.toString(); + + return changeset; +} + +PadDiff.prototype._createClearStartAtext = async function(rev) { + + // get the atext of this revision + let atext = this._pad.getInternalRevisionAText(rev); + + // create the clearAuthorship changeset + let changeset = await this._createClearAuthorship(rev); + + // apply the clearAuthorship changeset + let newAText = Changeset.applyToAText(changeset, atext, this._pad.pool); + + return newAText; +} + +PadDiff.prototype._getChangesetsInBulk = async function(startRev, count) { + + // find out which revisions we need + let revisions = []; + for (let i = startRev; i < (startRev + count) && i <= this._pad.head; i++) { revisions.push(i); } - - var changesets = [], authors = []; - - //get all needed revisions - async.forEach(revisions, function(rev, callback){ - self._pad.getRevision(rev, function(err, revision){ - if(err){ - return callback(err); - } - - var arrayNum = rev-startRev; - + + // get all needed revisions (in parallel) + let changesets = [], authors = []; + await Promise.all(revisions.map(rev => { + return this._pad.getRevision(rev).then(revision => { + let arrayNum = rev - startRev; changesets[arrayNum] = revision.changeset; authors[arrayNum] = revision.meta.author; - - callback(); }); - }, function(err){ - callback(err, changesets, authors); - }); -}; - + })); + + return { changesets, authors }; +} + PadDiff.prototype._addAuthors = function(authors) { var self = this; - //add to array if not in the array - authors.forEach(function(author){ - if(self._authors.indexOf(author) == -1){ + + // add to array if not in the array + authors.forEach(function(author) { + if (self._authors.indexOf(author) == -1) { self._authors.push(author); } }); }; - -PadDiff.prototype._createDiffAtext = function(callback) { - var self = this; - var bulkSize = 100; - - //get the cleaned startAText - self._createClearStartAtext(self._fromRev, function(err, atext){ - if(err) { return callback(err); } - - var superChangeset = null; - - var rev = self._fromRev + 1; - - //async while loop - async.whilst( - //loop condition - function () { return rev <= self._toRev; }, - - //loop body - function (callback) { - //get the bulk - self._getChangesetsInBulk(rev,bulkSize,function(err, changesets, authors){ - var addedAuthors = []; - - //run trough all changesets - for(var i=0;i 0) { if ((!curLineNextOp.chars) && (!curLineOpIter.hasNext())) { curLine++; @@ -384,22 +315,25 @@ PadDiff.prototype._createDeletionChangeset = function(cs, startAText, apool) { 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; @@ -412,27 +346,29 @@ PadDiff.prototype._createDeletionChangeset = function(cs, startAText, apool) { } } } - + 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); @@ -440,57 +376,59 @@ PadDiff.prototype._createDeletionChangeset = function(cs, startAText, apool) { return cache[s]; }; } - + var attribKeys = []; var attribValues = []; - - //iterate over all operators of this changeset + + // iterate over all operators of this changeset while (csIter.hasNext()) { var csOp = csIter.next(); - - if (csOp.opcode == '=') { + + if (csOp.opcode == '=') { var textBank = nextText(csOp.chars); - + // decide if this equal operator is an attribution change or not. We can see this by checkinf if attribs is set. // If the text this operator applies to is only a star, than this is a false positive and should be ignored if (csOp.attribs && textBank != "*") { var deletedAttrib = apool.putAttrib(["removed", true]); var authorAttrib = apool.putAttrib(["author", ""]); - + attribKeys.length = 0; attribValues.length = 0; Changeset.eachAttribNumber(csOp.attribs, function (n) { attribKeys.push(apool.getAttribKey(n)); attribValues.push(apool.getAttribValue(n)); - - if(apool.getAttribKey(n) === "author"){ + + if (apool.getAttribKey(n) === "author") { authorAttrib = 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, apool); + if (appliedValue != oldValue) { backAttribs.push([appliedKey, oldValue]); } } + return Changeset.makeAttribsString('=', backAttribs, apool); }); - + var oldAttribsAddition = "*" + Changeset.numToString(deletedAttrib) + "*" + Changeset.numToString(authorAttrib); - + var textLeftToProcess = textBank; - - while(textLeftToProcess.length > 0){ - //process till the next line break or process only one line break + + while(textLeftToProcess.length > 0) { + // process till the next line break or process only one line break var lengthToProcess = textLeftToProcess.indexOf("\n"); var lineBreak = false; - switch(lengthToProcess){ - case -1: + switch(lengthToProcess) { + case -1: lengthToProcess=textLeftToProcess.length; break; case 0: @@ -498,27 +436,28 @@ PadDiff.prototype._createDeletionChangeset = function(cs, startAText, apool) { lengthToProcess=1; break; } - - //get the text we want to procceed in this step + + // get the text we want to procceed in this step var processText = textLeftToProcess.substr(0, lengthToProcess); + textLeftToProcess = textLeftToProcess.substr(lengthToProcess); - - if(lineBreak){ - builder.keep(1, 1); //just skip linebreaks, don't do a insert + keep for a linebreak - - //consume the attributes of this linebreak - consumeAttribRuns(1, function(){}); + + if (lineBreak) { + builder.keep(1, 1); // just skip linebreaks, don't do a insert + keep for a linebreak + + // consume the attributes of this linebreak + consumeAttribRuns(1, function() {}); } else { - //add the old text via an insert, but add a deletion attribute + the author attribute of the author who deleted it + // add the old text via an insert, but add a deletion attribute + the author attribute of the author who deleted it var textBankIndex = 0; consumeAttribRuns(lengthToProcess, function (len, attribs, endsLine) { - //get the old attributes back + // get the old attributes back var attribs = (undoBackToAttribs(attribs) || "") + oldAttribsAddition; - + builder.insert(processText.substr(textBankIndex, len), attribs); textBankIndex += len; }); - + builder.keep(lengthToProcess, 0); } } @@ -531,16 +470,16 @@ PadDiff.prototype._createDeletionChangeset = function(cs, startAText, apool) { } 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 + csOp.attribs); textBankIndex += len; }); } } - + return Changeset.checkRep(builder.toString()); }; - -//export the constructor + +// export the constructor module.exports = PadDiff; diff --git a/src/package.json b/src/package.json index 1b9060942..961d58bd0 100644 --- a/src/package.json +++ b/src/package.json @@ -1,7 +1,7 @@ { "name": "ep_etherpad-lite", - "description": "A Etherpad based on node.js", - "homepage": "http://etherpad.org", + "description": "A free and open source realtime collaborative editor", + "homepage": "https://etherpad.org", "keywords": [ "etherpad", "realtime", @@ -35,20 +35,21 @@ "channels": "0.0.4", "cheerio": "0.20.0", "clean-css": "3.4.19", - "cookie-parser": "1.4.3", + "cookie-parser": "1.4.4", "ejs": "2.6.1", "etherpad-require-kernel": "1.0.9", "etherpad-yajsml": "0.0.2", - "express": "4.16.4", - "express-session": "1.15.6", + "express": "4.17.1", + "express-session": "1.17.0", "find-root": "1.1.0", "formidable": "1.2.1", - "graceful-fs": "4.1.11", + "graceful-fs": "4.1.15", "jsonminify": "0.4.1", "languages4translatewiki": "0.1.3", "log4js": "0.6.35", "measured-core": "1.11.2", - "npm": "6.4.1", + "nodeify": "^1.0.1", + "npm": "6.12.0", "object.values": "^1.0.4", "request": "2.88.0", "resolve": "1.1.7", @@ -68,13 +69,13 @@ }, "devDependencies": { "mocha": "5.2.0", - "nyc": "^12.0.2", + "nyc": "14.1.0", "supertest": "3.0.0", - "wd": "1.11.1" + "wd": "1.11.4" }, "engines": { - "node": ">=6.9.0", - "npm": ">=3.10.8" + "node": ">=8.9.0", + "npm": ">=5.5.1" }, "repository": { "type": "git", @@ -83,7 +84,6 @@ "scripts": { "test": "nyc mocha --timeout 5000 ../tests/backend/specs/api" }, - "version": "1.7.5", + "version": "1.8.0", "license": "Apache-2.0" } - diff --git a/src/static/css/admin.css b/src/static/css/admin.css index e9ba6014b..c44ce3983 100644 --- a/src/static/css/admin.css +++ b/src/static/css/admin.css @@ -1,287 +1,287 @@ -html, body { - height: 100%; - box-sizing: border-box; -} - -body { - margin: 0; - color: #333; - font: 14px helvetica, sans-serif; - background: #eee; -} - -div.menu { - height: 100%; - padding: 15px; - width: 220px; - border-right: 1px solid #ccc; - position: fixed; -} - -div.menu ul { - padding: 0; -} - -div.menu li { - list-style: none; - margin-left: 3px; - line-height: 3; - border-top: 1px solid #ccc; -} - -div.menu li:last-child { - border-bottom: 1px solid #ccc; -} - -div.innerwrapper { - padding: 15px; - padding-left: 265px; -} - -div.innerwrapper-err { - padding: 15px; - padding-left: 265px; - display: none; -} - -#wrapper { - background: none repeat scroll 0px 0px #FFFFFF; - box-shadow: 0px 1px 10px rgba(0, 0, 0, 0.2); - margin: auto; - max-width: 1150px; - min-height: 101%;/*always display a scrollbar*/ -} - -h1 { - font-size: 29px; -} - -h2 { - font-size: 24px; -} - -.separator { - margin: 10px 0; - height: 1px; - background: #aaa; - background: -webkit-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff); - background: -moz-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff); - background: -ms-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff); - background: -o-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff); -} - -form { - margin-bottom: 0; -} - -#inner { - width: 300px; - margin: 0 auto; -} - -input { - font-weight: bold; - font-size: 15px; -} - -input[type="button"] { - padding: 4px 6px; - margin: 0; -} - -table input[type="button"] { - float: right; - width: 100px; -} - -input[type="text"] { - border-radius: 3px; - box-sizing: border-box; - -moz-box-sizing: border-box; - padding: 10px; - *padding: 0; - /* IE7 hack */ - width: 100%; - outline: none; - border: 1px solid #ddd; - margin: 0 0 5px 0; - max-width: 500px; -} - -.sort { - cursor: pointer; -} -.sort:after { - content: '▲▼' -} -.sort.up:after { - content:'▲' -} -.sort.down:after { - content:'▼' -} - -table { - border: 1px solid #ddd; - border-radius: 3px; - border-spacing: 0; - width: 100%; - margin: 20px 0; - position:relative; /* Allows us to position the loading indicator relative to the table */ -} - -table thead tr { - background: #eee; -} - -td, th { - padding: 5px; -} - -.template { - display: none; -} - -#installed-plugins td>div { - position: relative;/* Allows us to position the loading indicator relative to this row */ - display: inline-block; /*make this fill the whole cell*/ - width:100%; -} - -.messages { - height: 5em; -} -.messages * { - display: none; - text-align: center; -} -.messages .fetching { - display: block; -} - -.progress { - position: absolute; - top: 0; left: 0; bottom:0; right:0; - padding: auto; - - background: rgb(255,255,255); - display: none; -} - -#search-progress.progress { - padding-top: 20%; - background: rgba(255,255,255,0.3); -} - -.progress * { - display: block; - margin: 0 auto; - text-align: center; - color: #666; -} - -.settings { - outline: none; - width: 100%; - min-height: 500px; -} - -#response { - display: inline; -} - -a:link, a:visited, a:hover, a:focus { - color: #333333; - text-decoration: none; -} - -a:focus, a:hover { - text-decoration: underline; -} - -.installed-results a:link, -.search-results a:link, -.installed-results a:visited, -.search-results a:visited, -.installed-results a:hover, -.search-results a:hover, -.installed-results a:focus, -.search-results a:focus { - text-decoration: underline; -} - -.installed-results a:focus, -.search-results a:focus, -.installed-results a:hover, -.search-results a:hover { - text-decoration: none; -} - -pre { - white-space: pre-wrap; - word-wrap: break-word; -} - -@media (max-width: 720px) { - div.innerwrapper { - padding: 0 15px 15px 15px; - } - - div.menu { - padding: 1px 15px 0 15px; - position: static; - height: auto; - border-right: none; - width: auto; - } - - table { - border: none; - } - - table, thead, tbody, td, tr { - display: block; - } - - thead tr { - display: none; - } - - tr { - border: 1px solid #ccc; - margin-bottom: 5px; - border-radius: 3px; - } - - td { - border: none; - border-bottom: 1px solid #eee; - position: relative; - padding-left: 50%; - white-space: normal; - text-align: left; - } - - td.name { - word-wrap: break-word; - } - - td:before { - position: absolute; - top: 6px; - left: 6px; - text-align: left; - padding-right: 10px; - white-space: nowrap; - font-weight: bold; - content: attr(data-label); - } - - td:last-child { - border-bottom: none; - } - - table input[type="button"] { - float: none; - } +html, body { + height: 100%; + box-sizing: border-box; +} + +body { + margin: 0; + color: #333; + font: 14px helvetica, sans-serif; + background: #eee; +} + +div.menu { + height: 100%; + padding: 15px; + width: 220px; + border-right: 1px solid #ccc; + position: fixed; +} + +div.menu ul { + padding: 0; +} + +div.menu li { + list-style: none; + margin-left: 3px; + line-height: 3; + border-top: 1px solid #ccc; +} + +div.menu li:last-child { + border-bottom: 1px solid #ccc; +} + +div.innerwrapper { + padding: 15px; + padding-left: 265px; +} + +div.innerwrapper-err { + padding: 15px; + padding-left: 265px; + display: none; +} + +#wrapper { + background: none repeat scroll 0px 0px #FFFFFF; + box-shadow: 0px 1px 10px rgba(0, 0, 0, 0.2); + margin: auto; + max-width: 1150px; + min-height: 101%;/*always display a scrollbar*/ +} + +h1 { + font-size: 29px; +} + +h2 { + font-size: 24px; +} + +.separator { + margin: 10px 0; + height: 1px; + background: #aaa; + background: -webkit-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff); + background: -moz-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff); + background: -ms-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff); + background: -o-linear-gradient(left, #fff, #aaa 20%, #aaa 80%, #fff); +} + +form { + margin-bottom: 0; +} + +#inner { + width: 300px; + margin: 0 auto; +} + +input { + font-weight: bold; + font-size: 15px; +} + +input[type="button"] { + padding: 4px 6px; + margin: 0; +} + +table input[type="button"] { + float: right; + width: 100px; +} + +input[type="text"] { + border-radius: 3px; + box-sizing: border-box; + -moz-box-sizing: border-box; + padding: 10px; + *padding: 0; + /* IE7 hack */ + width: 100%; + outline: none; + border: 1px solid #ddd; + margin: 0 0 5px 0; + max-width: 500px; +} + +.sort { + cursor: pointer; +} +.sort:after { + content: '▲▼' +} +.sort.up:after { + content:'▲' +} +.sort.down:after { + content:'▼' +} + +table { + border: 1px solid #ddd; + border-radius: 3px; + border-spacing: 0; + width: 100%; + margin: 20px 0; + position:relative; /* Allows us to position the loading indicator relative to the table */ +} + +table thead tr { + background: #eee; +} + +td, th { + padding: 5px; +} + +.template { + display: none; +} + +#installed-plugins td>div { + position: relative;/* Allows us to position the loading indicator relative to this row */ + display: inline-block; /*make this fill the whole cell*/ + width:100%; +} + +.messages { + height: 5em; +} +.messages * { + display: none; + text-align: center; +} +.messages .fetching { + display: block; +} + +.progress { + position: absolute; + top: 0; left: 0; bottom:0; right:0; + padding: auto; + + background: rgb(255,255,255); + display: none; +} + +#search-progress.progress { + padding-top: 20%; + background: rgba(255,255,255,0.3); +} + +.progress * { + display: block; + margin: 0 auto; + text-align: center; + color: #666; +} + +.settings { + outline: none; + width: 100%; + min-height: 500px; +} + +#response { + display: inline; +} + +a:link, a:visited, a:hover, a:focus { + color: #333333; + text-decoration: none; +} + +a:focus, a:hover { + text-decoration: underline; +} + +.installed-results a:link, +.search-results a:link, +.installed-results a:visited, +.search-results a:visited, +.installed-results a:hover, +.search-results a:hover, +.installed-results a:focus, +.search-results a:focus { + text-decoration: underline; +} + +.installed-results a:focus, +.search-results a:focus, +.installed-results a:hover, +.search-results a:hover { + text-decoration: none; +} + +pre { + white-space: pre-wrap; + word-wrap: break-word; +} + +@media (max-width: 720px) { + div.innerwrapper { + padding: 0 15px 15px 15px; + } + + div.menu { + padding: 1px 15px 0 15px; + position: static; + height: auto; + border-right: none; + width: auto; + } + + table { + border: none; + } + + table, thead, tbody, td, tr { + display: block; + } + + thead tr { + display: none; + } + + tr { + border: 1px solid #ccc; + margin-bottom: 5px; + border-radius: 3px; + } + + td { + border: none; + border-bottom: 1px solid #eee; + position: relative; + padding-left: 50%; + white-space: normal; + text-align: left; + } + + td.name { + word-wrap: break-word; + } + + td:before { + position: absolute; + top: 6px; + left: 6px; + text-align: left; + padding-right: 10px; + white-space: nowrap; + font-weight: bold; + content: attr(data-label); + } + + td:last-child { + border-bottom: none; + } + + table input[type="button"] { + float: none; + } } \ No newline at end of file diff --git a/src/static/css/pad.css b/src/static/css/pad.css index 18b936478..7d5985946 100644 --- a/src/static/css/pad.css +++ b/src/static/css/pad.css @@ -643,6 +643,7 @@ table#otheruserstable { border-top-right-radius: 5px; background-color: #fff; cursor: pointer; + display: none; } #chaticon a { text-decoration: none @@ -937,6 +938,13 @@ input[type=checkbox] { outline: none; width: 120px; } +.row { + float: left; + width: 100%; +} +.row + .row { + margin-top: 15px; +} .column { float: left; width:50%; diff --git a/src/static/js/AttributePool.js b/src/static/js/AttributePool.js index 96ea9b0da..7e7634e42 100644 --- a/src/static/js/AttributePool.js +++ b/src/static/js/AttributePool.js @@ -91,6 +91,6 @@ AttributePool.prototype.fromJsonable = function (obj) { } return this; }; - -module.exports = AttributePool; \ No newline at end of file + +module.exports = AttributePool; diff --git a/src/static/js/Changeset.js b/src/static/js/Changeset.js index 6f6e7d099..2458ae65e 100644 --- a/src/static/js/Changeset.js +++ b/src/static/js/Changeset.js @@ -42,7 +42,7 @@ exports.error = function error(msg) { }; /** - * This method is used for assertions with Messages + * This method is used for assertions with Messages * if assert fails, the error function is called. * @param b {boolean} assertion condition * @param msgParts {string} error to be passed if it fails @@ -76,7 +76,7 @@ exports.numToString = function (num) { * Converts stuff before $ to base 10 * @obsolete not really used anywhere?? * @param cs {string} the string - * @return integer + * @return integer */ exports.toBaseTen = function (cs) { var dollarIndex = cs.indexOf('$'); @@ -93,10 +93,10 @@ exports.toBaseTen = function (cs) { */ /** - * returns the required length of the text before changeset + * returns the required length of the text before changeset * can be applied * @param cs {string} String representation of the Changeset - */ + */ exports.oldLen = function (cs) { return exports.unpack(cs).oldLen; }; @@ -104,16 +104,16 @@ exports.oldLen = function (cs) { /** * returns the length of the text after changeset is applied * @param cs {string} String representation of the Changeset - */ + */ exports.newLen = function (cs) { return exports.unpack(cs).newLen; }; /** * this function creates an iterator which decodes string changeset operations - * @param opsStr {string} String encoding of the change operations to be performed - * @param optStartIndex {int} from where in the string should the iterator start - * @return {Op} type object iterator + * @param opsStr {string} String encoding of the change operations to be performed + * @param optStartIndex {int} from where in the string should the iterator start + * @return {Op} type object iterator */ exports.opIterator = function (opsStr, optStartIndex) { //print(opsStr); @@ -131,7 +131,7 @@ exports.opIterator = function (opsStr, optStartIndex) { if (result[0] == '?') { exports.error("Hit error opcode in op stream"); } - + return result; } var regexResult = nextRegexMatch(); @@ -504,7 +504,7 @@ exports.opAssembler = function () { /** * A custom made String Iterator * @param str {string} String to be iterated over - */ + */ exports.stringIterator = function (str) { var curIndex = 0; // newLines is the number of \n between curIndex and str.length @@ -549,7 +549,7 @@ exports.stringIterator = function (str) { }; /** - * A custom made StringBuffer + * A custom made StringBuffer */ exports.stringAssembler = function () { var pieces = []; @@ -827,12 +827,12 @@ exports.textLinesMutator = function (lines) { }; /** - * Function allowing iterating over two Op strings. + * Function allowing iterating over two Op strings. * @params in1 {string} first Op string * @params idx1 {int} integer where 1st iterator should start * @params in2 {string} second Op string * @params idx2 {int} integer where 2nd iterator should start - * @params func {function} which decides how 1st or 2nd iterator + * @params func {function} which decides how 1st or 2nd iterator * advances. When opX.opcode = 0, iterator X advances to * next element * func has signature f(op1, op2, opOut) @@ -889,7 +889,7 @@ exports.unpack = function (cs) { }; /** - * Packs Changeset object into a string + * Packs Changeset object into a string * @params oldLen {int} Old length of the Changeset * @params newLen {int] New length of the Changeset * @params opsStr {string} String encoding of the changes to be made @@ -980,8 +980,8 @@ exports.mutateTextLines = function (cs, lines) { * Composes two attribute strings (see below) into one. * @param att1 {string} first attribute string * @param att2 {string} second attribue string - * @param resultIsMutaton {boolean} - * @param pool {AttribPool} attribute pool + * @param resultIsMutaton {boolean} + * @param pool {AttribPool} attribute pool */ exports.composeAttributes = function (att1, att2, resultIsMutation, pool) { // att1 and att2 are strings like "*3*f*1c", asMutation is a boolean. @@ -1041,8 +1041,8 @@ exports.composeAttributes = function (att1, att2, resultIsMutation, pool) { }; /** - * Function used as parameter for applyZip to apply a Changeset to an - * attribute + * Function used as parameter for applyZip to apply a Changeset to an + * attribute */ exports._slicerZipperFunc = function (attOp, csOp, opOut, pool) { // attOp is the op from the sequence that is being operated on, either an @@ -1359,7 +1359,7 @@ exports.compose = function (cs1, cs2, 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. - * @param attribPair array [key,value] of the attribute + * @param attribPair array [key,value] of the attribute * @param pool {AttribPool} Attribute pool */ exports.attributeTester = function (attribPair, pool) { @@ -1391,9 +1391,9 @@ exports.identity = function (N) { /** - * creates a Changeset which works on oldFullText and removes text - * from spliceStart to spliceStart+numRemoved and inserts newText - * instead. Also gives possibility to add attributes optNewTextAPairs + * creates a Changeset which works on oldFullText and removes text + * from spliceStart to spliceStart+numRemoved and inserts newText + * instead. Also gives possibility to add attributes optNewTextAPairs * for the new text. * @param oldFullText {string} old text * @param spliecStart {int} where splicing starts @@ -1429,7 +1429,7 @@ exports.makeSplice = function (oldFullText, spliceStart, numRemoved, newText, op * @param cs Changeset */ exports.toSplices = function (cs) { - // + // var unpacked = exports.unpack(cs); var splices = []; @@ -1460,7 +1460,7 @@ exports.toSplices = function (cs) { }; /** - * + * */ exports.characterRangeFollow = function (cs, startChar, endChar, insertionsAfter) { var newStartChar = startChar; @@ -1547,7 +1547,7 @@ exports.makeAttribution = function (text) { * and runs function func on them * @param cs {Changeset} changeset * @param func {function} function to be called - */ + */ exports.eachAttribNumber = function (cs, func) { var dollarPos = cs.indexOf('$'); if (dollarPos < 0) { @@ -1566,16 +1566,16 @@ exports.eachAttribNumber = function (cs, func) { * callable on a exports, attribution string, or attribs property of an op, * though it may easily create adjacent ops that can be merged. * @param cs {Changeset} changeset to be filtered - * @param filter {function} fnc which returns true if an + * @param filter {function} fnc which returns true if an * attribute X (int) should be kept in the Changeset - */ + */ exports.filterAttribNumbers = function (cs, filter) { return exports.mapAttribNumbers(cs, filter); }; /** - * does exactly the same as exports.filterAttribNumbers - */ + * does exactly the same as exports.filterAttribNumbers + */ exports.mapAttribNumbers = function (cs, func) { var dollarPos = cs.indexOf('$'); if (dollarPos < 0) { @@ -1600,7 +1600,7 @@ exports.mapAttribNumbers = function (cs, func) { /** * Create a Changeset going from Identity to a certain state * @params text {string} text of the final change - * @attribs attribs {string} optional, operations which insert + * @attribs attribs {string} optional, operations which insert * the text and also puts the right attributes */ exports.makeAText = function (text, attribs) { @@ -1611,9 +1611,9 @@ exports.makeAText = function (text, attribs) { }; /** - * Apply a Changeset to a AText + * Apply a Changeset to a AText * @param cs {Changeset} Changeset to be applied - * @param atext {AText} + * @param atext {AText} * @param pool {AttribPool} Attribute Pool to add to */ exports.applyToAText = function (cs, atext, pool) { @@ -1625,7 +1625,7 @@ exports.applyToAText = function (cs, atext, pool) { /** * Clones a AText structure - * @param atext {AText} + * @param atext {AText} */ exports.cloneAText = function (atext) { if (atext) { @@ -1638,7 +1638,7 @@ exports.cloneAText = function (atext) { /** * Copies a AText structure from atext1 to atext2 - * @param atext {AText} + * @param atext {AText} */ exports.copyAText = function (atext1, atext2) { atext2.text = atext1.text; @@ -1647,7 +1647,7 @@ exports.copyAText = function (atext1, atext2) { /** * Append the set of operations from atext to an assembler - * @param atext {AText} + * @param atext {AText} * @param assem Assembler like smartOpAssembler */ exports.appendATextToAssembler = function (atext, assem) { @@ -1685,7 +1685,7 @@ exports.appendATextToAssembler = function (atext, assem) { /** * Creates a clone of a Changeset and it's APool - * @param cs {Changeset} + * @param cs {Changeset} * @param pool {AtributePool} */ exports.prepareForWire = function (cs, pool) { @@ -1706,8 +1706,8 @@ exports.isIdentity = function (cs) { }; /** - * returns all the values of attributes with a certain key - * in an Op attribs string + * returns all the values of attributes with a certain key + * in an Op attribs string * @param attribs {string} Attribute string of a Op * @param key {string} string to be seached for * @param pool {AttribPool} attribute pool @@ -1717,8 +1717,8 @@ exports.opAttributeValue = function (op, key, pool) { }; /** - * returns all the values of attributes with a certain key - * in an attribs string + * returns all the values of attributes with a certain key + * in an attribs string * @param attribs {string} Attribute string * @param key {string} string to be seached for * @param pool {AttribPool} attribute pool @@ -1736,7 +1736,7 @@ exports.attribsAttributeValue = function (attribs, key, pool) { }; /** - * Creates a Changeset builder for a string with initial + * Creates a Changeset builder for a string with initial * length oldLen. Allows to add/remove parts of it * @param oldLen {int} Old length */ @@ -2224,7 +2224,7 @@ exports.composeWithDeletions = function (cs1, cs2, pool) { return exports.pack(len1, len3, newOps, bankAssem.toString()); }; -// This function is 95% like _slicerZipperFunc, we just changed two lines to ensure it merges the attribs of deletions properly. +// This function is 95% like _slicerZipperFunc, we just changed two lines to ensure it merges the attribs of deletions properly. // This is necassary for correct paddiff. But to ensure these changes doesn't affect anything else, we've created a seperate function only used for paddiffs exports._slicerZipperFuncWithDeletions= function (attOp, csOp, opOut, pool) { // attOp is the op from the sequence that is being operated on, either an diff --git a/src/static/js/ace2_inner.js b/src/static/js/ace2_inner.js index a34b94e59..597e8451a 100644 --- a/src/static/js/ace2_inner.js +++ b/src/static/js/ace2_inner.js @@ -1071,7 +1071,7 @@ function Ace2Inner(){ function now() { - return (new Date()).getTime(); + return Date.now(); } function newTimeLimit(ms) diff --git a/src/static/js/admin/plugins.js b/src/static/js/admin/plugins.js index c9a244871..3bc0daddc 100644 --- a/src/static/js/admin/plugins.js +++ b/src/static/js/admin/plugins.js @@ -1,5 +1,5 @@ $(document).ready(function () { - + var socket, loc = document.location, port = loc.port == "" ? (loc.protocol == "https:" ? 443 : 80) : loc.port, @@ -23,7 +23,7 @@ $(document).ready(function () { search.searchTerm = searchTerm; socket.emit("search", {searchTerm: searchTerm, offset:search.offset, limit: limit, sortBy: search.sortBy, sortDir: search.sortDir}); search.offset += limit; - + $('#search-progress').show() search.messages.show('fetching') search.searching = true @@ -76,7 +76,7 @@ $(document).ready(function () { function displayPluginList(plugins, container, template) { plugins.forEach(function(plugin) { var row = template.clone(); - + for (attr in plugin) { if(attr == "name"){ // Hack to rewrite URLS into name var link = $(''); @@ -96,7 +96,7 @@ $(document).ready(function () { }) updateHandlers(); } - + function sortPluginList(plugins, property, /*ASC?*/dir) { return plugins.sort(function(a, b) { if (a[property] < b[property]) @@ -113,7 +113,7 @@ $(document).ready(function () { $("#search-query").unbind('keyup').keyup(function () { search($("#search-query").val()); }); - + // Prevent form submit $('#search-query').parent().bind('submit', function() { return false; @@ -167,7 +167,7 @@ $(document).ready(function () { search.messages.hide('nothing-found') search.messages.hide('fetching') $("#search-query").removeAttr('disabled') - + console.log('got search results', data) // add to results @@ -218,7 +218,7 @@ $(document).ready(function () { installed.messages.show("nothing-installed") } }); - + socket.on('results:updatable', function(data) { data.updatable.forEach(function(pluginName) { var $row = $('#installed-plugins > tr.'+pluginName) @@ -250,7 +250,7 @@ $(document).ready(function () { // remove plugin from installed list $('#installed-plugins .'+data.plugin).remove() - + socket.emit("getInstalled"); // update search results diff --git a/src/static/js/admin/settings.js b/src/static/js/admin/settings.js index 6c1f5e236..d95a424e5 100644 --- a/src/static/js/admin/settings.js +++ b/src/static/js/admin/settings.js @@ -31,7 +31,7 @@ $(document).ready(function () { } else{ alert("YOUR JSON IS BAD AND YOU SHOULD FEEL BAD"); - } + } }); /* When the admin clicks save Settings check the JSON then send the JSON back to the server */ diff --git a/src/static/js/broadcast.js b/src/static/js/broadcast.js index 817155b55..1bd547f52 100644 --- a/src/static/js/broadcast.js +++ b/src/static/js/broadcast.js @@ -1,5 +1,5 @@ /** - * This code is mostly from the old Etherpad. Please help us to comment this code. + * This code is mostly from the old Etherpad. Please help us to comment this code. * This helps other people to understand this code better and helps them to improve it. * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ @@ -239,7 +239,7 @@ function loadBroadcastJS(socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro */ function applyChangeset(changeset, revision, preventSliderMovement, timeDelta) - { + { // disable the next 'gotorevision' call handled by a timeslider update if (!preventSliderMovement) { @@ -263,12 +263,12 @@ function loadBroadcastJS(socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro debugLog('Time Delta: ', timeDelta) updateTimer(); - + var authors = _.map(padContents.getActiveAuthors(), function(name) { return authorData[name]; }); - + BroadcastSlider.setAuthors(authors); } @@ -281,7 +281,7 @@ function loadBroadcastJS(socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro str = '0' + str; return str; } - + var date = new Date(padContents.currentTime); var dateFormat = function() { @@ -296,15 +296,15 @@ function loadBroadcastJS(socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro "month": month, "year": year, "hours": hours, - "minutes": minutes, + "minutes": minutes, "seconds": seconds })); } - - - - - + + + + + $('#timer').html(dateFormat()); var revisionDate = html10n.get("timeslider.saved", { "day": date.getDate(), @@ -327,7 +327,7 @@ function loadBroadcastJS(socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro $('#revision_date').html(revisionDate) } - + updateTimer(); function goToRevision(newRevision) @@ -378,13 +378,13 @@ function loadBroadcastJS(socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro // Loading changeset history for old revision (to make diff between old and new revision) loadChangesetsForRevision(padContents.currentRevision - 1); } - + var authors = _.map(padContents.getActiveAuthors(), function(name){ return authorData[name]; }); BroadcastSlider.setAuthors(authors); } - + function loadChangesetsForRevision(revision, callback) { if (BroadcastSlider.getSliderLength() > 10000) { @@ -566,7 +566,7 @@ function loadBroadcastJS(socket, sendSocketMsg, fireWhenAllScriptsAreLoaded, Bro goToRevision.apply(goToRevision, arguments); } } - + BroadcastSlider.onSlider(goToRevisionIfEnabled); var dynamicCSS = makeCSSManager('dynamicsyntax'); diff --git a/src/static/js/broadcast_revisions.js b/src/static/js/broadcast_revisions.js index 1980bdf30..abe3292dc 100644 --- a/src/static/js/broadcast_revisions.js +++ b/src/static/js/broadcast_revisions.js @@ -1,5 +1,5 @@ /** - * This code is mostly from the old Etherpad. Please help us to comment this code. + * This code is mostly from the old Etherpad. Please help us to comment this code. * This helps other people to understand this code better and helps them to improve it. * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ diff --git a/src/static/js/broadcast_slider.js b/src/static/js/broadcast_slider.js index 2299bba32..1893994ef 100644 --- a/src/static/js/broadcast_slider.js +++ b/src/static/js/broadcast_slider.js @@ -1,5 +1,5 @@ /** - * This code is mostly from the old Etherpad. Please help us to comment this code. + * This code is mostly from the old Etherpad. Please help us to comment this code. * This helps other people to understand this code better and helps them to improve it. * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ @@ -59,7 +59,7 @@ function loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded) slidercallbacks[i](newval); } } - + var updateSliderElements = function() { for (var i = 0; i < savedRevisions.length; i++) @@ -68,7 +68,7 @@ function loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded) savedRevisions[i].css('left', (position * ($("#ui-slider-bar").width() - 2) / (sliderLength * 1.0)) - 1); } $("#ui-slider-handle").css('left', sliderPos * ($("#ui-slider-bar").width() - 2) / (sliderLength * 1.0)); - } + } var addSavedRevision = function(position, info) { @@ -171,7 +171,7 @@ function loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded) var height = $('#timeslider-top').height(); $('#editorcontainerbox').css({marginTop: height}); }, 600); - + function setAuthors(authors) { var authorsList = $("#authorsList"); @@ -187,7 +187,7 @@ function loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded) if (author.name) { if (numNamed !== 0) authorsList.append(', '); - + $('') .text(author.name || "unnamed") .css('background-color', authorColor) @@ -206,17 +206,17 @@ function loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded) if (numAnonymous > 0) { var anonymousAuthorString = html10n.get("timeslider.unnamedauthors", { num: numAnonymous }); - + if (numNamed !== 0){ authorsList.append(' + ' + anonymousAuthorString); } else { authorsList.append(anonymousAuthorString); } - + if(colorsAnonymous.length > 0){ authorsList.append(' ('); _.each(colorsAnonymous, function(color, i){ - if( i > 0 ) authorsList.append(' '); + if( i > 0 ) authorsList.append(' '); $(' ') .css('background-color', color) .addClass('author author-anonymous') @@ -224,13 +224,13 @@ function loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded) }); authorsList.append(')'); } - + } if (authors.length == 0) { authorsList.append(html10n.get("timeslider.toolbar.authorsList")); } - + fixPadHeight(); } @@ -288,7 +288,7 @@ function loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded) { disableSelection($("#playpause_button")[0]); disableSelection($("#timeslider")[0]); - + $(document).keyup(function(e) { // If focus is on editbar, don't do anything @@ -337,7 +337,7 @@ function loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded) } else if (code == 32) playpause(); }); - + $(window).resize(function() { updateSliderElements(); @@ -467,7 +467,7 @@ function loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded) if (clientVars) { $("#timeslider").show(); - + var startPos = clientVars.collab_client_vars.rev; if(window.location.hash.length > 1) { @@ -478,15 +478,15 @@ function loadBroadcastSliderJS(fireWhenAllScriptsAreLoaded) setTimeout(function() { setSliderPosition(hashRev); }, 1); } } - + setSliderLength(clientVars.collab_client_vars.rev); setSliderPosition(clientVars.collab_client_vars.rev); - + _.each(clientVars.savedRevisions, function(revision) { addSavedRevision(revision.revNum, revision); }) - + } }); })(); diff --git a/src/static/js/changesettracker.js b/src/static/js/changesettracker.js index fe362c4b7..4e7cd3ed6 100644 --- a/src/static/js/changesettracker.js +++ b/src/static/js/changesettracker.js @@ -1,5 +1,5 @@ /** - * This code is mostly from the old Etherpad. Please help us to comment this code. + * This code is mostly from the old Etherpad. Please help us to comment this code. * This helps other people to understand this code better and helps them to improve it. * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ @@ -163,7 +163,7 @@ function makeChangesetTracker(scheduler, apool, aceCallbacksProvider) else { - // add forEach function to Array.prototype for IE8 + // add forEach function to Array.prototype for IE8 if (!('forEach' in Array.prototype)) { Array.prototype.forEach= function(action, that /*opt*/) { for (var i= 0, n= this.length; i p').eq(-1); } } - }, + }, send: function() { var text = $("#chatinput").val(); @@ -121,7 +121,7 @@ var chat = (function() { //correct the time msg.time += this._pad.clientTimeOffset; - + //create the time string var minutes = "" + new Date(msg.time).getMinutes(); var hours = "" + new Date(msg.time).getHours(); @@ -130,7 +130,7 @@ var chat = (function() if(hours.length == 1) hours = "0" + hours ; var timeStr = hours + ":" + minutes; - + //create the authorclass var authorClass = "author-" + msg.userId.replace(/[^a-y0-9]/g, function(c) { diff --git a/src/static/js/colorutils.js b/src/static/js/colorutils.js index 74a2e4635..af471c453 100644 --- a/src/static/js/colorutils.js +++ b/src/static/js/colorutils.js @@ -1,5 +1,5 @@ /** - * This code is mostly from the old Etherpad. Please help us to comment this code. + * This code is mostly from the old Etherpad. Please help us to comment this code. * This helps other people to understand this code better and helps them to improve it. * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ diff --git a/src/static/js/contentcollector.js b/src/static/js/contentcollector.js index 6820da07c..d3bd73383 100644 --- a/src/static/js/contentcollector.js +++ b/src/static/js/contentcollector.js @@ -1,5 +1,5 @@ /** - * This code is mostly from the old Etherpad. Please help us to comment this code. + * This code is mostly from the old Etherpad. Please help us to comment this code. * This helps other people to understand this code better and helps them to improve it. * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ @@ -252,14 +252,14 @@ function makeContentCollector(collectStyles, abrowser, apool, domInterface, clas { state.listNesting = (state.listNesting || 0) + 1; } - + if(listType === 'none' || !listType ){ - delete state.lineAttributes['list']; + delete state.lineAttributes['list']; } else{ state.lineAttributes['list'] = listType; } - + _recalcAttribString(state); return oldListType; } @@ -303,7 +303,7 @@ function makeContentCollector(collectStyles, abrowser, apool, domInterface, clas // see https://github.com/ether/etherpad-lite/issues/2567 for more information // in long term the contentcollector should be refactored to get rid of this workaround var ATTRIBUTE_SPLIT_STRING = "::"; - + // see if attributeString is splittable var attributeSplits = a.split(ATTRIBUTE_SPLIT_STRING); if (attributeSplits.length > 1) { @@ -410,7 +410,7 @@ function makeContentCollector(collectStyles, abrowser, apool, domInterface, clas text:txt, styl: null, cls: null - }); + }); var txt = (typeof(txtFromHook)=='object'&&txtFromHook.length==0)?dom.nodeValue(node):txtFromHook[0]; var rest = ''; @@ -504,7 +504,7 @@ function makeContentCollector(collectStyles, abrowser, apool, domInterface, clas tvalue:tvalue, styl: null, cls: null - }); + }); var startNewLine= (typeof(induceLineBreak)=='object'&&induceLineBreak.length==0)?true:induceLineBreak[0]; if(startNewLine){ cc.startNewLine(state); diff --git a/src/static/js/domline.js b/src/static/js/domline.js index a7501fcc6..100ce0919 100644 --- a/src/static/js/domline.js +++ b/src/static/js/domline.js @@ -1,5 +1,5 @@ /** - * This code is mostly from the old Etherpad. Please help us to comment this code. + * This code is mostly from the old Etherpad. Please help us to comment this code. * This helps other people to understand this code better and helps them to improve it. * TL;DR COMMENTS ON THIS FILE ARE HIGHLY APPRECIATED */ @@ -135,7 +135,7 @@ domline.createDomLine = function(nonEmpty, doesWrap, optBrowser, optDocument) } postHtml += '
'; } - } + } processedMarker = true; } _.map(hooks.callAll("aceDomLineProcessLineAttributes", { @@ -150,7 +150,7 @@ domline.createDomLine = function(nonEmpty, doesWrap, optBrowser, optDocument) if( processedMarker ){ result.lineMarker += txt.length; return; // don't append any text - } + } } var href = null; var simpleTags = null; diff --git a/src/static/js/html10n.js b/src/static/js/html10n.js index 8dea84c28..8fbc6f643 100644 --- a/src/static/js/html10n.js +++ b/src/static/js/html10n.js @@ -854,7 +854,8 @@ window.html10n = (function(window, document, undefined) { , "value": 1 , "placeholder": 1 } - if (index > 0 && str.id.substr(index + 1) in attrList) { // an attribute has been specified + if (index > 0 && str.id.substr(index + 1) in attrList) { + // an attribute has been specified (example: "my_translation_key.placeholder") prop = str.id.substr(index + 1) } else { // no attribute: assuming text content by default prop = document.body.textContent ? 'textContent' : 'innerText' diff --git a/src/static/js/jquery.js b/src/static/js/jquery.js index e2c203fe9..a384f7d13 100644 --- a/src/static/js/jquery.js +++ b/src/static/js/jquery.js @@ -1,90 +1,84 @@ /*! - * jQuery JavaScript Library v1.9.1 + * jQuery JavaScript Library v1.12.4 * http://jquery.com/ * * Includes Sizzle.js * http://sizzlejs.com/ * - * Copyright 2005, 2012 jQuery Foundation, Inc. and other contributors + * Copyright jQuery Foundation and other contributors * Released under the MIT license * http://jquery.org/license * - * Date: 2013-2-4 + * Date: 2016-05-20T17:17Z */ -(function( window, undefined ) { -// Can't do this because several apps including ASP.NET trace +(function( global, factory ) { + + if ( typeof module === "object" && typeof module.exports === "object" ) { + // For CommonJS and CommonJS-like environments where a proper `window` + // is present, execute the factory and get jQuery. + // For environments that do not have a `window` with a `document` + // (such as Node.js), expose a factory as module.exports. + // This accentuates the need for the creation of a real `window`. + // e.g. var jQuery = require("jquery")(window); + // See ticket #14549 for more info. + module.exports = global.document ? + factory( global, true ) : + function( w ) { + if ( !w.document ) { + throw new Error( "jQuery requires a window with a document" ); + } + return factory( w ); + }; + } else { + factory( global ); + } + +// Pass this if window is not defined yet +}(typeof window !== "undefined" ? window : this, function( window, noGlobal ) { + +// Support: Firefox 18+ +// Can't be in strict mode, several libs including ASP.NET trace // the stack via arguments.caller.callee and Firefox dies if // you try to trace through "use strict" call chains. (#13335) -// Support: Firefox 18+ //"use strict"; +var deletedIds = []; + +var document = window.document; + +var slice = deletedIds.slice; + +var concat = deletedIds.concat; + +var push = deletedIds.push; + +var indexOf = deletedIds.indexOf; + +var class2type = {}; + +var toString = class2type.toString; + +var hasOwn = class2type.hasOwnProperty; + +var support = {}; + + + var - // The deferred used on DOM ready - readyList, - - // A central reference to the root jQuery(document) - rootjQuery, - - // Support: IE<9 - // For `typeof node.method` instead of `node.method !== undefined` - core_strundefined = typeof undefined, - - // Use the correct document accordingly with window argument (sandbox) - document = window.document, - location = window.location, - - // Map over jQuery in case of overwrite - _jQuery = window.jQuery, - - // Map over the $ in case of overwrite - _$ = window.$, - - // [[Class]] -> type pairs - class2type = {}, - - // List of deleted data cache ids, so we can reuse them - core_deletedIds = [], - - core_version = "1.9.1", - - // Save a reference to some core methods - core_concat = core_deletedIds.concat, - core_push = core_deletedIds.push, - core_slice = core_deletedIds.slice, - core_indexOf = core_deletedIds.indexOf, - core_toString = class2type.toString, - core_hasOwn = class2type.hasOwnProperty, - core_trim = core_version.trim, + version = "1.12.4", // Define a local copy of jQuery jQuery = function( selector, context ) { + // The jQuery object is actually just the init constructor 'enhanced' - return new jQuery.fn.init( selector, context, rootjQuery ); + // Need init if jQuery is called (just allow error to be thrown if not included) + return new jQuery.fn.init( selector, context ); }, - // Used for matching numbers - core_pnum = /[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/.source, - - // Used for splitting on whitespace - core_rnotwhite = /\S+/g, - - // Make sure we trim BOM and NBSP (here's looking at you, Safari 5.0 and IE) + // Support: Android<4.1, IE<9 + // Make sure we trim BOM and NBSP rtrim = /^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, - // A simple way to check for HTML strings - // Prioritize #id over to avoid XSS via location.hash (#9521) - // Strict HTML recognition (#11290: must start with <) - rquickExpr = /^(?:(<[\w\W]+>)[^>]*|#([\w-]*))$/, - - // Match a standalone tag - rsingleTag = /^<(\w+)\s*\/?>(?:<\/\1>|)$/, - - // JSON RegExp - rvalidchars = /^[\],:{}\s]*$/, - rvalidbraces = /(?:^|:|,)(?:\s*\[)+/g, - rvalidescape = /\\(?:["\\\/bfnrt]|u[\da-fA-F]{4})/g, - rvalidtokens = /"[^"\\\r\n]*"|true|false|null|-?(?:\d+\.|)\d+(?:[eE][+-]?\d+|)/g, - // Matches dashed string for camelizing rmsPrefix = /^-ms-/, rdashAlpha = /-([\da-z])/gi, @@ -92,134 +86,14 @@ var // Used by jQuery.camelCase as callback to replace() fcamelCase = function( all, letter ) { return letter.toUpperCase(); - }, - - // The ready event handler - completed = function( event ) { - - // readyState === "complete" is good enough for us to call the dom ready in oldIE - if ( document.addEventListener || event.type === "load" || document.readyState === "complete" ) { - detach(); - jQuery.ready(); - } - }, - // Clean-up method for dom ready events - detach = function() { - if ( document.addEventListener ) { - document.removeEventListener( "DOMContentLoaded", completed, false ); - window.removeEventListener( "load", completed, false ); - - } else { - document.detachEvent( "onreadystatechange", completed ); - window.detachEvent( "onload", completed ); - } }; jQuery.fn = jQuery.prototype = { + // The current version of jQuery being used - jquery: core_version, + jquery: version, constructor: jQuery, - init: function( selector, context, rootjQuery ) { - var match, elem; - - // HANDLE: $(""), $(null), $(undefined), $(false) - if ( !selector ) { - return this; - } - - // Handle HTML strings - if ( typeof selector === "string" ) { - if ( selector.charAt(0) === "<" && selector.charAt( selector.length - 1 ) === ">" && selector.length >= 3 ) { - // Assume that strings that start and end with <> are HTML and skip the regex check - match = [ null, selector, null ]; - - } else { - match = rquickExpr.exec( selector ); - } - - // Match html or make sure no context is specified for #id - if ( match && (match[1] || !context) ) { - - // HANDLE: $(html) -> $(array) - if ( match[1] ) { - context = context instanceof jQuery ? context[0] : context; - - // scripts is true for back-compat - jQuery.merge( this, jQuery.parseHTML( - match[1], - context && context.nodeType ? context.ownerDocument || context : document, - true - ) ); - - // HANDLE: $(html, props) - if ( rsingleTag.test( match[1] ) && jQuery.isPlainObject( context ) ) { - for ( match in context ) { - // Properties of context are called as methods if possible - if ( jQuery.isFunction( this[ match ] ) ) { - this[ match ]( context[ match ] ); - - // ...and otherwise set as attributes - } else { - this.attr( match, context[ match ] ); - } - } - } - - return this; - - // HANDLE: $(#id) - } else { - elem = document.getElementById( match[2] ); - - // Check parentNode to catch when Blackberry 4.6 returns - // nodes that are no longer in the document #6963 - if ( elem && elem.parentNode ) { - // Handle the case where IE and Opera return items - // by name instead of ID - if ( elem.id !== match[2] ) { - return rootjQuery.find( selector ); - } - - // Otherwise, we inject the element directly into the jQuery object - this.length = 1; - this[0] = elem; - } - - this.context = document; - this.selector = selector; - return this; - } - - // HANDLE: $(expr, $(...)) - } else if ( !context || context.jquery ) { - return ( context || rootjQuery ).find( selector ); - - // HANDLE: $(expr, context) - // (which is just equivalent to: $(context).find(expr) - } else { - return this.constructor( context ).find( selector ); - } - - // HANDLE: $(DOMElement) - } else if ( selector.nodeType ) { - this.context = this[0] = selector; - this.length = 1; - return this; - - // HANDLE: $(function) - // Shortcut for document ready - } else if ( jQuery.isFunction( selector ) ) { - return rootjQuery.ready( selector ); - } - - if ( selector.selector !== undefined ) { - this.selector = selector.selector; - this.context = selector.context; - } - - return jQuery.makeArray( selector, this ); - }, // Start with an empty selector selector: "", @@ -227,25 +101,20 @@ jQuery.fn = jQuery.prototype = { // The default length of a jQuery object is 0 length: 0, - // The number of elements contained in the matched element set - size: function() { - return this.length; - }, - toArray: function() { - return core_slice.call( this ); + return slice.call( this ); }, // Get the Nth element in the matched element set OR // Get the whole matched element set as a clean array get: function( num ) { - return num == null ? + return num != null ? - // Return a 'clean' array - this.toArray() : + // Return just the one element from the set + ( num < 0 ? this[ num + this.length ] : this[ num ] ) : - // Return just the object - ( num < 0 ? this[ this.length + num ] : this[ num ] ); + // Return all the elements in a clean array + slice.call( this ); }, // Take an array of elements and push it onto the stack @@ -264,21 +133,18 @@ jQuery.fn = jQuery.prototype = { }, // Execute a callback for every element in the matched set. - // (You can seed the arguments with an array of args, but this is - // only used internally.) - each: function( callback, args ) { - return jQuery.each( this, callback, args ); + each: function( callback ) { + return jQuery.each( this, callback ); }, - ready: function( fn ) { - // Add the callback - jQuery.ready.promise().done( fn ); - - return this; + map: function( callback ) { + return this.pushStack( jQuery.map( this, function( elem, i ) { + return callback.call( elem, i, elem ); + } ) ); }, slice: function() { - return this.pushStack( core_slice.apply( this, arguments ) ); + return this.pushStack( slice.apply( this, arguments ) ); }, first: function() { @@ -292,32 +158,23 @@ jQuery.fn = jQuery.prototype = { eq: function( i ) { var len = this.length, j = +i + ( i < 0 ? len : 0 ); - return this.pushStack( j >= 0 && j < len ? [ this[j] ] : [] ); - }, - - map: function( callback ) { - return this.pushStack( jQuery.map(this, function( elem, i ) { - return callback.call( elem, i, elem ); - })); + return this.pushStack( j >= 0 && j < len ? [ this[ j ] ] : [] ); }, end: function() { - return this.prevObject || this.constructor(null); + return this.prevObject || this.constructor(); }, // For internal use only. // Behaves like an Array's method, not like a jQuery method. - push: core_push, - sort: [].sort, - splice: [].splice + push: push, + sort: deletedIds.sort, + splice: deletedIds.splice }; -// Give the init function the jQuery prototype for later instantiation -jQuery.fn.init.prototype = jQuery.fn; - jQuery.extend = jQuery.fn.extend = function() { var src, copyIsArray, copy, name, options, clone, - target = arguments[0] || {}, + target = arguments[ 0 ] || {}, i = 1, length = arguments.length, deep = false; @@ -325,25 +182,28 @@ jQuery.extend = jQuery.fn.extend = function() { // Handle a deep copy situation if ( typeof target === "boolean" ) { deep = target; - target = arguments[1] || {}; + // skip the boolean and the target - i = 2; + target = arguments[ i ] || {}; + i++; } // Handle case when target is a string or something (possible in deep copy) - if ( typeof target !== "object" && !jQuery.isFunction(target) ) { + if ( typeof target !== "object" && !jQuery.isFunction( target ) ) { target = {}; } // extend jQuery itself if only one argument is passed - if ( length === i ) { + if ( i === length ) { target = this; - --i; + i--; } for ( ; i < length; i++ ) { + // Only deal with non-null/undefined values - if ( (options = arguments[ i ]) != null ) { + if ( ( options = arguments[ i ] ) != null ) { + // Extend the base object for ( name in options ) { src = target[ name ]; @@ -355,13 +215,15 @@ jQuery.extend = jQuery.fn.extend = function() { } // Recurse if we're merging plain objects or arrays - if ( deep && copy && ( jQuery.isPlainObject(copy) || (copyIsArray = jQuery.isArray(copy)) ) ) { + if ( deep && copy && ( jQuery.isPlainObject( copy ) || + ( copyIsArray = jQuery.isArray( copy ) ) ) ) { + if ( copyIsArray ) { copyIsArray = false; - clone = src && jQuery.isArray(src) ? src : []; + clone = src && jQuery.isArray( src ) ? src : []; } else { - clone = src && jQuery.isPlainObject(src) ? src : {}; + clone = src && jQuery.isPlainObject( src ) ? src : {}; } // Never move original objects, clone them @@ -379,120 +241,44 @@ jQuery.extend = jQuery.fn.extend = function() { return target; }; -jQuery.extend({ - noConflict: function( deep ) { - if ( window.$ === jQuery ) { - window.$ = _$; - } +jQuery.extend( { - if ( deep && window.jQuery === jQuery ) { - window.jQuery = _jQuery; - } + // Unique for each copy of jQuery on the page + expando: "jQuery" + ( version + Math.random() ).replace( /\D/g, "" ), - return jQuery; + // Assume jQuery is ready without the ready module + isReady: true, + + error: function( msg ) { + throw new Error( msg ); }, - // Is the DOM ready to be used? Set to true once it occurs. - isReady: false, - - // A counter to track how many items to wait for before - // the ready event fires. See #6781 - readyWait: 1, - - // Hold (or release) the ready event - holdReady: function( hold ) { - if ( hold ) { - jQuery.readyWait++; - } else { - jQuery.ready( true ); - } - }, - - // Handle when the DOM is ready - ready: function( wait ) { - - // Abort if there are pending holds or we're already ready - if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) { - return; - } - - // Make sure body exists, at least, in case IE gets a little overzealous (ticket #5443). - if ( !document.body ) { - return setTimeout( jQuery.ready ); - } - - // Remember that the DOM is ready - jQuery.isReady = true; - - // If a normal DOM Ready event fired, decrement, and wait if need be - if ( wait !== true && --jQuery.readyWait > 0 ) { - return; - } - - // If there are functions bound, to execute - readyList.resolveWith( document, [ jQuery ] ); - - // Trigger any bound ready events - if ( jQuery.fn.trigger ) { - jQuery( document ).trigger("ready").off("ready"); - } - }, + noop: function() {}, // See test/unit/core.js for details concerning isFunction. // Since version 1.3, DOM methods and functions like alert // aren't supported. They return false on IE (#2968). isFunction: function( obj ) { - return jQuery.type(obj) === "function"; + return jQuery.type( obj ) === "function"; }, isArray: Array.isArray || function( obj ) { - return jQuery.type(obj) === "array"; + return jQuery.type( obj ) === "array"; }, isWindow: function( obj ) { + /* jshint eqeqeq: false */ return obj != null && obj == obj.window; }, isNumeric: function( obj ) { - return !isNaN( parseFloat(obj) ) && isFinite( obj ); - }, - type: function( obj ) { - if ( obj == null ) { - return String( obj ); - } - return typeof obj === "object" || typeof obj === "function" ? - class2type[ core_toString.call(obj) ] || "object" : - typeof obj; - }, - - isPlainObject: function( obj ) { - // Must be an Object. - // Because of IE, we also have to check the presence of the constructor property. - // Make sure that DOM nodes and window objects don't pass through, as well - if ( !obj || jQuery.type(obj) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) { - return false; - } - - try { - // Not own constructor property must be Object - if ( obj.constructor && - !core_hasOwn.call(obj, "constructor") && - !core_hasOwn.call(obj.constructor.prototype, "isPrototypeOf") ) { - return false; - } - } catch ( e ) { - // IE8,9 Will throw exceptions on certain host objects #9897 - return false; - } - - // Own properties are enumerated firstly, so to speed up, - // if last one is own, then all properties are own. - - var key; - for ( key in obj ) {} - - return key === undefined || core_hasOwn.call( obj, key ); + // parseFloat NaNs numeric-cast false positives (null|true|false|"") + // ...but misinterprets leading-number strings, particularly hex literals ("0x...") + // subtraction forces infinities to NaN + // adding 1 corrects loss of precision from parseFloat (#15100) + var realStringObj = obj && obj.toString(); + return !jQuery.isArray( obj ) && ( realStringObj - parseFloat( realStringObj ) + 1 ) >= 0; }, isEmptyObject: function( obj ) { @@ -503,104 +289,64 @@ jQuery.extend({ return true; }, - error: function( msg ) { - throw new Error( msg ); - }, + isPlainObject: function( obj ) { + var key; - // data: string of html - // context (optional): If specified, the fragment will be created in this context, defaults to document - // keepScripts (optional): If true, will include scripts passed in the html string - parseHTML: function( data, context, keepScripts ) { - if ( !data || typeof data !== "string" ) { - return null; - } - if ( typeof context === "boolean" ) { - keepScripts = context; - context = false; - } - context = context || document; - - var parsed = rsingleTag.exec( data ), - scripts = !keepScripts && []; - - // Single tag - if ( parsed ) { - return [ context.createElement( parsed[1] ) ]; + // Must be an Object. + // Because of IE, we also have to check the presence of the constructor property. + // Make sure that DOM nodes and window objects don't pass through, as well + if ( !obj || jQuery.type( obj ) !== "object" || obj.nodeType || jQuery.isWindow( obj ) ) { + return false; } - parsed = jQuery.buildFragment( [ data ], context, scripts ); - if ( scripts ) { - jQuery( scripts ).remove(); - } - return jQuery.merge( [], parsed.childNodes ); - }, - - parseJSON: function( data ) { - // Attempt to parse using the native JSON parser first - if ( window.JSON && window.JSON.parse ) { - return window.JSON.parse( data ); - } - - if ( data === null ) { - return data; - } - - if ( typeof data === "string" ) { - - // Make sure leading/trailing whitespace is removed (IE can't handle it) - data = jQuery.trim( data ); - - if ( data ) { - // Make sure the incoming data is actual JSON - // Logic borrowed from http://json.org/json2.js - if ( rvalidchars.test( data.replace( rvalidescape, "@" ) - .replace( rvalidtokens, "]" ) - .replace( rvalidbraces, "")) ) { - - return ( new Function( "return " + data ) )(); - } - } - } - - jQuery.error( "Invalid JSON: " + data ); - }, - - // Cross-browser xml parsing - parseXML: function( data ) { - var xml, tmp; - if ( !data || typeof data !== "string" ) { - return null; - } try { - if ( window.DOMParser ) { // Standard - tmp = new DOMParser(); - xml = tmp.parseFromString( data , "text/xml" ); - } else { // IE - xml = new ActiveXObject( "Microsoft.XMLDOM" ); - xml.async = "false"; - xml.loadXML( data ); + + // Not own constructor property must be Object + if ( obj.constructor && + !hasOwn.call( obj, "constructor" ) && + !hasOwn.call( obj.constructor.prototype, "isPrototypeOf" ) ) { + return false; } - } catch( e ) { - xml = undefined; + } catch ( e ) { + + // IE8,9 Will throw exceptions on certain host objects #9897 + return false; } - if ( !xml || !xml.documentElement || xml.getElementsByTagName( "parsererror" ).length ) { - jQuery.error( "Invalid XML: " + data ); + + // Support: IE<9 + // Handle iteration over inherited properties before own properties. + if ( !support.ownFirst ) { + for ( key in obj ) { + return hasOwn.call( obj, key ); + } } - return xml; + + // Own properties are enumerated firstly, so to speed up, + // if last one is own, then all properties are own. + for ( key in obj ) {} + + return key === undefined || hasOwn.call( obj, key ); }, - noop: function() {}, + type: function( obj ) { + if ( obj == null ) { + return obj + ""; + } + return typeof obj === "object" || typeof obj === "function" ? + class2type[ toString.call( obj ) ] || "object" : + typeof obj; + }, - // Evaluates a script in a global context // Workarounds based on findings by Jim Driscoll // http://weblogs.java.net/blog/driscoll/archive/2009/09/08/eval-javascript-global-context globalEval: function( data ) { if ( data && jQuery.trim( data ) ) { + // We use execScript on Internet Explorer // We use an anonymous function so that context is window // rather than jQuery in Firefox ( window.execScript || function( data ) { - window[ "eval" ].call( window, data ); + window[ "eval" ].call( window, data ); // jscs:ignore requireDotNotation } )( data ); } }, @@ -615,49 +361,20 @@ jQuery.extend({ return elem.nodeName && elem.nodeName.toLowerCase() === name.toLowerCase(); }, - // args is for internal usage only - each: function( obj, callback, args ) { - var value, - i = 0, - length = obj.length, - isArray = isArraylike( obj ); + each: function( obj, callback ) { + var length, i = 0; - if ( args ) { - if ( isArray ) { - for ( ; i < length; i++ ) { - value = callback.apply( obj[ i ], args ); - - if ( value === false ) { - break; - } - } - } else { - for ( i in obj ) { - value = callback.apply( obj[ i ], args ); - - if ( value === false ) { - break; - } + if ( isArrayLike( obj ) ) { + length = obj.length; + for ( ; i < length; i++ ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; } } - - // A special, fast, case for the most common use of each } else { - if ( isArray ) { - for ( ; i < length; i++ ) { - value = callback.call( obj[ i ], i, obj[ i ] ); - - if ( value === false ) { - break; - } - } - } else { - for ( i in obj ) { - value = callback.call( obj[ i ], i, obj[ i ] ); - - if ( value === false ) { - break; - } + for ( i in obj ) { + if ( callback.call( obj[ i ], i, obj[ i ] ) === false ) { + break; } } } @@ -665,33 +382,25 @@ jQuery.extend({ return obj; }, - // Use native String.trim function wherever possible - trim: core_trim && !core_trim.call("\uFEFF\xA0") ? - function( text ) { - return text == null ? - "" : - core_trim.call( text ); - } : - - // Otherwise use our own trimming functionality - function( text ) { - return text == null ? - "" : - ( text + "" ).replace( rtrim, "" ); - }, + // Support: Android<4.1, IE<9 + trim: function( text ) { + return text == null ? + "" : + ( text + "" ).replace( rtrim, "" ); + }, // results is for internal usage only makeArray: function( arr, results ) { var ret = results || []; if ( arr != null ) { - if ( isArraylike( Object(arr) ) ) { + if ( isArrayLike( Object( arr ) ) ) { jQuery.merge( ret, typeof arr === "string" ? [ arr ] : arr ); } else { - core_push.call( ret, arr ); + push.call( ret, arr ); } } @@ -702,14 +411,15 @@ jQuery.extend({ var len; if ( arr ) { - if ( core_indexOf ) { - return core_indexOf.call( arr, elem, i ); + if ( indexOf ) { + return indexOf.call( arr, elem, i ); } len = arr.length; i = i ? i < 0 ? Math.max( 0, len + i ) : i : 0; for ( ; i < len; i++ ) { + // Skip accessing in sparse arrays if ( i in arr && arr[ i ] === elem ) { return i; @@ -721,16 +431,18 @@ jQuery.extend({ }, merge: function( first, second ) { - var l = second.length, - i = first.length, - j = 0; + var len = +second.length, + j = 0, + i = first.length; - if ( typeof l === "number" ) { - for ( ; j < l; j++ ) { - first[ i++ ] = second[ j ]; - } - } else { - while ( second[j] !== undefined ) { + while ( j < len ) { + first[ i++ ] = second[ j++ ]; + } + + // Support: IE<9 + // Workaround casting of .length to NaN on otherwise arraylike objects (e.g., NodeLists) + if ( len !== len ) { + while ( second[ j ] !== undefined ) { first[ i++ ] = second[ j++ ]; } } @@ -740,40 +452,39 @@ jQuery.extend({ return first; }, - grep: function( elems, callback, inv ) { - var retVal, - ret = [], + grep: function( elems, callback, invert ) { + var callbackInverse, + matches = [], i = 0, - length = elems.length; - inv = !!inv; + length = elems.length, + callbackExpect = !invert; // Go through the array, only saving the items // that pass the validator function for ( ; i < length; i++ ) { - retVal = !!callback( elems[ i ], i ); - if ( inv !== retVal ) { - ret.push( elems[ i ] ); + callbackInverse = !callback( elems[ i ], i ); + if ( callbackInverse !== callbackExpect ) { + matches.push( elems[ i ] ); } } - return ret; + return matches; }, // arg is for internal usage only map: function( elems, callback, arg ) { - var value, + var length, value, i = 0, - length = elems.length, - isArray = isArraylike( elems ), ret = []; - // Go through the array, translating each of the items to their - if ( isArray ) { + // Go through the array, translating each of the items to their new values + if ( isArrayLike( elems ) ) { + length = elems.length; for ( ; i < length; i++ ) { value = callback( elems[ i ], i, arg ); if ( value != null ) { - ret[ ret.length ] = value; + ret.push( value ); } } @@ -783,13 +494,13 @@ jQuery.extend({ value = callback( elems[ i ], i, arg ); if ( value != null ) { - ret[ ret.length ] = value; + ret.push( value ); } } } // Flatten any nested arrays - return core_concat.apply( [], ret ); + return concat.apply( [], ret ); }, // A global GUID counter for objects @@ -813,9 +524,9 @@ jQuery.extend({ } // Simulated bind - args = core_slice.call( arguments, 2 ); + args = slice.call( arguments, 2 ); proxy = function() { - return fn.apply( context || this, args.concat( core_slice.call( arguments ) ) ); + return fn.apply( context || this, args.concat( slice.call( arguments ) ) ); }; // Set the guid of unique handler to the same of original handler, so it can be removed @@ -824,3042 +535,392 @@ jQuery.extend({ return proxy; }, - // Multifunctional method to get and set values of a collection - // The value/s can optionally be executed if it's a function - access: function( elems, fn, key, value, chainable, emptyGet, raw ) { - var i = 0, - length = elems.length, - bulk = key == null; - - // Sets many values - if ( jQuery.type( key ) === "object" ) { - chainable = true; - for ( i in key ) { - jQuery.access( elems, fn, i, key[i], true, emptyGet, raw ); - } - - // Sets one value - } else if ( value !== undefined ) { - chainable = true; - - if ( !jQuery.isFunction( value ) ) { - raw = true; - } - - if ( bulk ) { - // Bulk operations run against the entire set - if ( raw ) { - fn.call( elems, value ); - fn = null; - - // ...except when executing function values - } else { - bulk = fn; - fn = function( elem, key, value ) { - return bulk.call( jQuery( elem ), value ); - }; - } - } - - if ( fn ) { - for ( ; i < length; i++ ) { - fn( elems[i], key, raw ? value : value.call( elems[i], i, fn( elems[i], key ) ) ); - } - } - } - - return chainable ? - elems : - - // Gets - bulk ? - fn.call( elems ) : - length ? fn( elems[0], key ) : emptyGet; + now: function() { + return +( new Date() ); }, - now: function() { - return ( new Date() ).getTime(); - } -}); + // jQuery.support is not used in Core but other projects attach their + // properties to it so it needs to exist. + support: support +} ); -jQuery.ready.promise = function( obj ) { - if ( !readyList ) { - - readyList = jQuery.Deferred(); - - // Catch cases where $(document).ready() is called after the browser event has already occurred. - // we once tried to use readyState "interactive" here, but it caused issues like the one - // discovered by ChrisS here: http://bugs.jquery.com/ticket/12282#comment:15 - if ( document.readyState === "complete" ) { - // Handle it asynchronously to allow scripts the opportunity to delay ready - setTimeout( jQuery.ready ); - - // Standards-based browsers support DOMContentLoaded - } else if ( document.addEventListener ) { - // Use the handy event callback - document.addEventListener( "DOMContentLoaded", completed, false ); - - // A fallback to window.onload, that will always work - window.addEventListener( "load", completed, false ); - - // If IE event model is used - } else { - // Ensure firing before onload, maybe late but safe also for iframes - document.attachEvent( "onreadystatechange", completed ); - - // A fallback to window.onload, that will always work - window.attachEvent( "onload", completed ); - - // If IE and not a frame - // continually check to see if the document is ready - var top = false; - - try { - top = window.frameElement == null && document.documentElement; - } catch(e) {} - - if ( top && top.doScroll ) { - (function doScrollCheck() { - if ( !jQuery.isReady ) { - - try { - // Use the trick by Diego Perini - // http://javascript.nwbox.com/IEContentLoaded/ - top.doScroll("left"); - } catch(e) { - return setTimeout( doScrollCheck, 50 ); - } - - // detach all dom ready events - detach(); - - // and execute any waiting functions - jQuery.ready(); - } - })(); - } - } - } - return readyList.promise( obj ); -}; +// JSHint would error on this code due to the Symbol not being defined in ES5. +// Defining this global in .jshintrc would create a danger of using the global +// unguarded in another place, it seems safer to just disable JSHint for these +// three lines. +/* jshint ignore: start */ +if ( typeof Symbol === "function" ) { + jQuery.fn[ Symbol.iterator ] = deletedIds[ Symbol.iterator ]; +} +/* jshint ignore: end */ // Populate the class2type map -jQuery.each("Boolean Number String Function Array Date RegExp Object Error".split(" "), function(i, name) { +jQuery.each( "Boolean Number String Function Array Date RegExp Object Error Symbol".split( " " ), +function( i, name ) { class2type[ "[object " + name + "]" ] = name.toLowerCase(); -}); +} ); -function isArraylike( obj ) { - var length = obj.length, +function isArrayLike( obj ) { + + // Support: iOS 8.2 (not reproducible in simulator) + // `in` check used to prevent JIT error (gh-2145) + // hasOwn isn't used here due to false negatives + // regarding Nodelist length in IE + var length = !!obj && "length" in obj && obj.length, type = jQuery.type( obj ); - if ( jQuery.isWindow( obj ) ) { + if ( type === "function" || jQuery.isWindow( obj ) ) { return false; } - if ( obj.nodeType === 1 && length ) { - return true; - } - - return type === "array" || type !== "function" && - ( length === 0 || - typeof length === "number" && length > 0 && ( length - 1 ) in obj ); + return type === "array" || length === 0 || + typeof length === "number" && length > 0 && ( length - 1 ) in obj; } - -// All jQuery objects should point back to these -rootjQuery = jQuery(document); -// String to Object options format cache -var optionsCache = {}; - -// Convert String-formatted options into Object-formatted ones and store in cache -function createOptions( options ) { - var object = optionsCache[ options ] = {}; - jQuery.each( options.match( core_rnotwhite ) || [], function( _, flag ) { - object[ flag ] = true; - }); - return object; -} - -/* - * Create a callback list using the following parameters: - * - * options: an optional list of space-separated options that will change how - * the callback list behaves or a more traditional option object - * - * By default a callback list will act like an event callback list and can be - * "fired" multiple times. - * - * Possible options: - * - * once: will ensure the callback list can only be fired once (like a Deferred) - * - * memory: will keep track of previous values and will call any callback added - * after the list has been fired right away with the latest "memorized" - * values (like a Deferred) - * - * unique: will ensure a callback can only be added once (no duplicate in the list) - * - * stopOnFalse: interrupt callings when a callback returns false - * - */ -jQuery.Callbacks = function( options ) { - - // Convert options from String-formatted to Object-formatted if needed - // (we check in cache first) - options = typeof options === "string" ? - ( optionsCache[ options ] || createOptions( options ) ) : - jQuery.extend( {}, options ); - - var // Flag to know if list is currently firing - firing, - // Last fire value (for non-forgettable lists) - memory, - // Flag to know if list was already fired - fired, - // End of the loop when firing - firingLength, - // Index of currently firing callback (modified by remove if needed) - firingIndex, - // First callback to fire (used internally by add and fireWith) - firingStart, - // Actual callback list - list = [], - // Stack of fire calls for repeatable lists - stack = !options.once && [], - // Fire callbacks - fire = function( data ) { - memory = options.memory && data; - fired = true; - firingIndex = firingStart || 0; - firingStart = 0; - firingLength = list.length; - firing = true; - for ( ; list && firingIndex < firingLength; firingIndex++ ) { - if ( list[ firingIndex ].apply( data[ 0 ], data[ 1 ] ) === false && options.stopOnFalse ) { - memory = false; // To prevent further calls using add - break; - } - } - firing = false; - if ( list ) { - if ( stack ) { - if ( stack.length ) { - fire( stack.shift() ); - } - } else if ( memory ) { - list = []; - } else { - self.disable(); - } - } - }, - // Actual Callbacks object - self = { - // Add a callback or a collection of callbacks to the list - add: function() { - if ( list ) { - // First, we save the current length - var start = list.length; - (function add( args ) { - jQuery.each( args, function( _, arg ) { - var type = jQuery.type( arg ); - if ( type === "function" ) { - if ( !options.unique || !self.has( arg ) ) { - list.push( arg ); - } - } else if ( arg && arg.length && type !== "string" ) { - // Inspect recursively - add( arg ); - } - }); - })( arguments ); - // Do we need to add the callbacks to the - // current firing batch? - if ( firing ) { - firingLength = list.length; - // With memory, if we're not firing then - // we should call right away - } else if ( memory ) { - firingStart = start; - fire( memory ); - } - } - return this; - }, - // Remove a callback from the list - remove: function() { - if ( list ) { - jQuery.each( arguments, function( _, arg ) { - var index; - while( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) { - list.splice( index, 1 ); - // Handle firing indexes - if ( firing ) { - if ( index <= firingLength ) { - firingLength--; - } - if ( index <= firingIndex ) { - firingIndex--; - } - } - } - }); - } - return this; - }, - // Check if a given callback is in the list. - // If no argument is given, return whether or not list has callbacks attached. - has: function( fn ) { - return fn ? jQuery.inArray( fn, list ) > -1 : !!( list && list.length ); - }, - // Remove all callbacks from the list - empty: function() { - list = []; - return this; - }, - // Have the list do nothing anymore - disable: function() { - list = stack = memory = undefined; - return this; - }, - // Is it disabled? - disabled: function() { - return !list; - }, - // Lock the list in its current state - lock: function() { - stack = undefined; - if ( !memory ) { - self.disable(); - } - return this; - }, - // Is it locked? - locked: function() { - return !stack; - }, - // Call all callbacks with the given context and arguments - fireWith: function( context, args ) { - args = args || []; - args = [ context, args.slice ? args.slice() : args ]; - if ( list && ( !fired || stack ) ) { - if ( firing ) { - stack.push( args ); - } else { - fire( args ); - } - } - return this; - }, - // Call all the callbacks with the given arguments - fire: function() { - self.fireWith( this, arguments ); - return this; - }, - // To know if the callbacks have already been called at least once - fired: function() { - return !!fired; - } - }; - - return self; -}; -jQuery.extend({ - - Deferred: function( func ) { - var tuples = [ - // action, add listener, listener list, final state - [ "resolve", "done", jQuery.Callbacks("once memory"), "resolved" ], - [ "reject", "fail", jQuery.Callbacks("once memory"), "rejected" ], - [ "notify", "progress", jQuery.Callbacks("memory") ] - ], - state = "pending", - promise = { - state: function() { - return state; - }, - always: function() { - deferred.done( arguments ).fail( arguments ); - return this; - }, - then: function( /* fnDone, fnFail, fnProgress */ ) { - var fns = arguments; - return jQuery.Deferred(function( newDefer ) { - jQuery.each( tuples, function( i, tuple ) { - var action = tuple[ 0 ], - fn = jQuery.isFunction( fns[ i ] ) && fns[ i ]; - // deferred[ done | fail | progress ] for forwarding actions to newDefer - deferred[ tuple[1] ](function() { - var returned = fn && fn.apply( this, arguments ); - if ( returned && jQuery.isFunction( returned.promise ) ) { - returned.promise() - .done( newDefer.resolve ) - .fail( newDefer.reject ) - .progress( newDefer.notify ); - } else { - newDefer[ action + "With" ]( this === promise ? newDefer.promise() : this, fn ? [ returned ] : arguments ); - } - }); - }); - fns = null; - }).promise(); - }, - // Get a promise for this deferred - // If obj is provided, the promise aspect is added to the object - promise: function( obj ) { - return obj != null ? jQuery.extend( obj, promise ) : promise; - } - }, - deferred = {}; - - // Keep pipe for back-compat - promise.pipe = promise.then; - - // Add list-specific methods - jQuery.each( tuples, function( i, tuple ) { - var list = tuple[ 2 ], - stateString = tuple[ 3 ]; - - // promise[ done | fail | progress ] = list.add - promise[ tuple[1] ] = list.add; - - // Handle state - if ( stateString ) { - list.add(function() { - // state = [ resolved | rejected ] - state = stateString; - - // [ reject_list | resolve_list ].disable; progress_list.lock - }, tuples[ i ^ 1 ][ 2 ].disable, tuples[ 2 ][ 2 ].lock ); - } - - // deferred[ resolve | reject | notify ] - deferred[ tuple[0] ] = function() { - deferred[ tuple[0] + "With" ]( this === deferred ? promise : this, arguments ); - return this; - }; - deferred[ tuple[0] + "With" ] = list.fireWith; - }); - - // Make the deferred a promise - promise.promise( deferred ); - - // Call given func if any - if ( func ) { - func.call( deferred, deferred ); - } - - // All done! - return deferred; - }, - - // Deferred helper - when: function( subordinate /* , ..., subordinateN */ ) { - var i = 0, - resolveValues = core_slice.call( arguments ), - length = resolveValues.length, - - // the count of uncompleted subordinates - remaining = length !== 1 || ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0, - - // the master Deferred. If resolveValues consist of only a single Deferred, just use that. - deferred = remaining === 1 ? subordinate : jQuery.Deferred(), - - // Update function for both resolve and progress values - updateFunc = function( i, contexts, values ) { - return function( value ) { - contexts[ i ] = this; - values[ i ] = arguments.length > 1 ? core_slice.call( arguments ) : value; - if( values === progressValues ) { - deferred.notifyWith( contexts, values ); - } else if ( !( --remaining ) ) { - deferred.resolveWith( contexts, values ); - } - }; - }, - - progressValues, progressContexts, resolveContexts; - - // add listeners to Deferred subordinates; treat others as resolved - if ( length > 1 ) { - progressValues = new Array( length ); - progressContexts = new Array( length ); - resolveContexts = new Array( length ); - for ( ; i < length; i++ ) { - if ( resolveValues[ i ] && jQuery.isFunction( resolveValues[ i ].promise ) ) { - resolveValues[ i ].promise() - .done( updateFunc( i, resolveContexts, resolveValues ) ) - .fail( deferred.reject ) - .progress( updateFunc( i, progressContexts, progressValues ) ); - } else { - --remaining; - } - } - } - - // if we're not waiting on anything, resolve the master - if ( !remaining ) { - deferred.resolveWith( resolveContexts, resolveValues ); - } - - return deferred.promise(); - } -}); -jQuery.support = (function() { - - var support, all, a, - input, select, fragment, - opt, eventName, isSupported, i, - div = document.createElement("div"); - - // Setup - div.setAttribute( "className", "t" ); - div.innerHTML = "
a"; - - // Support tests won't run in some limited or non-browser environments - all = div.getElementsByTagName("*"); - a = div.getElementsByTagName("a")[ 0 ]; - if ( !all || !a || !all.length ) { - return {}; - } - - // First batch of tests - select = document.createElement("select"); - opt = select.appendChild( document.createElement("option") ); - input = div.getElementsByTagName("input")[ 0 ]; - - a.style.cssText = "top:1px;float:left;opacity:.5"; - support = { - // Test setAttribute on camelCase class. If it works, we need attrFixes when doing get/setAttribute (ie6/7) - getSetAttribute: div.className !== "t", - - // IE strips leading whitespace when .innerHTML is used - leadingWhitespace: div.firstChild.nodeType === 3, - - // Make sure that tbody elements aren't automatically inserted - // IE will insert them into empty tables - tbody: !div.getElementsByTagName("tbody").length, - - // Make sure that link elements get serialized correctly by innerHTML - // This requires a wrapper element in IE - htmlSerialize: !!div.getElementsByTagName("link").length, - - // Get the style information from getAttribute - // (IE uses .cssText instead) - style: /top/.test( a.getAttribute("style") ), - - // Make sure that URLs aren't manipulated - // (IE normalizes it by default) - hrefNormalized: a.getAttribute("href") === "/a", - - // Make sure that element opacity exists - // (IE uses filter instead) - // Use a regex to work around a WebKit issue. See #5145 - opacity: /^0.5/.test( a.style.opacity ), - - // Verify style float existence - // (IE uses styleFloat instead of cssFloat) - cssFloat: !!a.style.cssFloat, - - // Check the default checkbox/radio value ("" on WebKit; "on" elsewhere) - checkOn: !!input.value, - - // Make sure that a selected-by-default option has a working selected property. - // (WebKit defaults to false instead of true, IE too, if it's in an optgroup) - optSelected: opt.selected, - - // Tests for enctype support on a form (#6743) - enctype: !!document.createElement("form").enctype, - - // Makes sure cloning an html5 element does not cause problems - // Where outerHTML is undefined, this still works - html5Clone: document.createElement("nav").cloneNode( true ).outerHTML !== "<:nav>", - - // jQuery.support.boxModel DEPRECATED in 1.8 since we don't support Quirks Mode - boxModel: document.compatMode === "CSS1Compat", - - // Will be defined later - deleteExpando: true, - noCloneEvent: true, - inlineBlockNeedsLayout: false, - shrinkWrapBlocks: false, - reliableMarginRight: true, - boxSizingReliable: true, - pixelPosition: false - }; - - // Make sure checked status is properly cloned - input.checked = true; - support.noCloneChecked = input.cloneNode( true ).checked; - - // Make sure that the options inside disabled selects aren't marked as disabled - // (WebKit marks them as disabled) - select.disabled = true; - support.optDisabled = !opt.disabled; - - // Support: IE<9 - try { - delete div.test; - } catch( e ) { - support.deleteExpando = false; - } - - // Check if we can trust getAttribute("value") - input = document.createElement("input"); - input.setAttribute( "value", "" ); - support.input = input.getAttribute( "value" ) === ""; - - // Check if an input maintains its value after becoming a radio - input.value = "t"; - input.setAttribute( "type", "radio" ); - support.radioValue = input.value === "t"; - - // #11217 - WebKit loses check when the name is after the checked attribute - input.setAttribute( "checked", "t" ); - input.setAttribute( "name", "t" ); - - fragment = document.createDocumentFragment(); - fragment.appendChild( input ); - - // Check if a disconnected checkbox will retain its checked - // value of true after appended to the DOM (IE6/7) - support.appendChecked = input.checked; - - // WebKit doesn't clone checked state correctly in fragments - support.checkClone = fragment.cloneNode( true ).cloneNode( true ).lastChild.checked; - - // Support: IE<9 - // Opera does not clone events (and typeof div.attachEvent === undefined). - // IE9-10 clones events bound via attachEvent, but they don't trigger with .click() - if ( div.attachEvent ) { - div.attachEvent( "onclick", function() { - support.noCloneEvent = false; - }); - - div.cloneNode( true ).click(); - } - - // Support: IE<9 (lack submit/change bubble), Firefox 17+ (lack focusin event) - // Beware of CSP restrictions (https://developer.mozilla.org/en/Security/CSP), test/csp.php - for ( i in { submit: true, change: true, focusin: true }) { - div.setAttribute( eventName = "on" + i, "t" ); - - support[ i + "Bubbles" ] = eventName in window || div.attributes[ eventName ].expando === false; - } - - div.style.backgroundClip = "content-box"; - div.cloneNode( true ).style.backgroundClip = ""; - support.clearCloneStyle = div.style.backgroundClip === "content-box"; - - // Run tests that need a body at doc ready - jQuery(function() { - var container, marginDiv, tds, - divReset = "padding:0;margin:0;border:0;display:block;box-sizing:content-box;-moz-box-sizing:content-box;-webkit-box-sizing:content-box;", - body = document.getElementsByTagName("body")[0]; - - if ( !body ) { - // Return for frameset docs that don't have a body - return; - } - - container = document.createElement("div"); - container.style.cssText = "border:0;width:0;height:0;position:absolute;top:0;left:-9999px;margin-top:1px"; - - body.appendChild( container ).appendChild( div ); - - // Support: IE8 - // Check if table cells still have offsetWidth/Height when they are set - // to display:none and there are still other visible table cells in a - // table row; if so, offsetWidth/Height are not reliable for use when - // determining if an element has been hidden directly using - // display:none (it is still safe to use offsets if a parent element is - // hidden; don safety goggles and see bug #4512 for more information). - div.innerHTML = "
t
"; - tds = div.getElementsByTagName("td"); - tds[ 0 ].style.cssText = "padding:0;margin:0;border:0;display:none"; - isSupported = ( tds[ 0 ].offsetHeight === 0 ); - - tds[ 0 ].style.display = ""; - tds[ 1 ].style.display = "none"; - - // Support: IE8 - // Check if empty table cells still have offsetWidth/Height - support.reliableHiddenOffsets = isSupported && ( tds[ 0 ].offsetHeight === 0 ); - - // Check box-sizing and margin behavior - div.innerHTML = ""; - div.style.cssText = "box-sizing:border-box;-moz-box-sizing:border-box;-webkit-box-sizing:border-box;padding:1px;border:1px;display:block;width:4px;margin-top:1%;position:absolute;top:1%;"; - support.boxSizing = ( div.offsetWidth === 4 ); - support.doesNotIncludeMarginInBodyOffset = ( body.offsetTop !== 1 ); - - // Use window.getComputedStyle because jsdom on node.js will break without it. - if ( window.getComputedStyle ) { - support.pixelPosition = ( window.getComputedStyle( div, null ) || {} ).top !== "1%"; - support.boxSizingReliable = ( window.getComputedStyle( div, null ) || { width: "4px" } ).width === "4px"; - - // Check if div with explicit width and no margin-right incorrectly - // gets computed margin-right based on width of container. (#3333) - // Fails in WebKit before Feb 2011 nightlies - // WebKit Bug 13343 - getComputedStyle returns wrong value for margin-right - marginDiv = div.appendChild( document.createElement("div") ); - marginDiv.style.cssText = div.style.cssText = divReset; - marginDiv.style.marginRight = marginDiv.style.width = "0"; - div.style.width = "1px"; - - support.reliableMarginRight = - !parseFloat( ( window.getComputedStyle( marginDiv, null ) || {} ).marginRight ); - } - - if ( typeof div.style.zoom !== core_strundefined ) { - // Support: IE<8 - // Check if natively block-level elements act like inline-block - // elements when setting their display to 'inline' and giving - // them layout - div.innerHTML = ""; - div.style.cssText = divReset + "width:1px;padding:1px;display:inline;zoom:1"; - support.inlineBlockNeedsLayout = ( div.offsetWidth === 3 ); - - // Support: IE6 - // Check if elements with layout shrink-wrap their children - div.style.display = "block"; - div.innerHTML = "
"; - div.firstChild.style.width = "5px"; - support.shrinkWrapBlocks = ( div.offsetWidth !== 3 ); - - if ( support.inlineBlockNeedsLayout ) { - // Prevent IE 6 from affecting layout for positioned elements #11048 - // Prevent IE from shrinking the body in IE 7 mode #12869 - // Support: IE<8 - body.style.zoom = 1; - } - } - - body.removeChild( container ); - - // Null elements to avoid leaks in IE - container = div = tds = marginDiv = null; - }); - - // Null elements to avoid leaks in IE - all = select = fragment = opt = a = input = null; - - return support; -})(); - -var rbrace = /(?:\{[\s\S]*\}|\[[\s\S]*\])$/, - rmultiDash = /([A-Z])/g; - -function internalData( elem, name, data, pvt /* Internal Use Only */ ){ - if ( !jQuery.acceptData( elem ) ) { - return; - } - - var thisCache, ret, - internalKey = jQuery.expando, - getByName = typeof name === "string", - - // We have to handle DOM nodes and JS objects differently because IE6-7 - // can't GC object references properly across the DOM-JS boundary - isNode = elem.nodeType, - - // Only DOM nodes need the global jQuery cache; JS object data is - // attached directly to the object so GC can occur automatically - cache = isNode ? jQuery.cache : elem, - - // Only defining an ID for JS objects if its cache already exists allows - // the code to shortcut on the same path as a DOM node with no cache - id = isNode ? elem[ internalKey ] : elem[ internalKey ] && internalKey; - - // Avoid doing any more work than we need to when trying to get data on an - // object that has no data at all - if ( (!id || !cache[id] || (!pvt && !cache[id].data)) && getByName && data === undefined ) { - return; - } - - if ( !id ) { - // Only DOM nodes need a new unique ID for each element since their data - // ends up in the global cache - if ( isNode ) { - elem[ internalKey ] = id = core_deletedIds.pop() || jQuery.guid++; - } else { - id = internalKey; - } - } - - if ( !cache[ id ] ) { - cache[ id ] = {}; - - // Avoids exposing jQuery metadata on plain JS objects when the object - // is serialized using JSON.stringify - if ( !isNode ) { - cache[ id ].toJSON = jQuery.noop; - } - } - - // An object can be passed to jQuery.data instead of a key/value pair; this gets - // shallow copied over onto the existing cache - if ( typeof name === "object" || typeof name === "function" ) { - if ( pvt ) { - cache[ id ] = jQuery.extend( cache[ id ], name ); - } else { - cache[ id ].data = jQuery.extend( cache[ id ].data, name ); - } - } - - thisCache = cache[ id ]; - - // jQuery data() is stored in a separate object inside the object's internal data - // cache in order to avoid key collisions between internal data and user-defined - // data. - if ( !pvt ) { - if ( !thisCache.data ) { - thisCache.data = {}; - } - - thisCache = thisCache.data; - } - - if ( data !== undefined ) { - thisCache[ jQuery.camelCase( name ) ] = data; - } - - // Check for both converted-to-camel and non-converted data property names - // If a data property was specified - if ( getByName ) { - - // First Try to find as-is property data - ret = thisCache[ name ]; - - // Test for null|undefined property data - if ( ret == null ) { - - // Try to find the camelCased property - ret = thisCache[ jQuery.camelCase( name ) ]; - } - } else { - ret = thisCache; - } - - return ret; -} - -function internalRemoveData( elem, name, pvt ) { - if ( !jQuery.acceptData( elem ) ) { - return; - } - - var i, l, thisCache, - isNode = elem.nodeType, - - // See jQuery.data for more information - cache = isNode ? jQuery.cache : elem, - id = isNode ? elem[ jQuery.expando ] : jQuery.expando; - - // If there is already no cache entry for this object, there is no - // purpose in continuing - if ( !cache[ id ] ) { - return; - } - - if ( name ) { - - thisCache = pvt ? cache[ id ] : cache[ id ].data; - - if ( thisCache ) { - - // Support array or space separated string names for data keys - if ( !jQuery.isArray( name ) ) { - - // try the string as a key before any manipulation - if ( name in thisCache ) { - name = [ name ]; - } else { - - // split the camel cased version by spaces unless a key with the spaces exists - name = jQuery.camelCase( name ); - if ( name in thisCache ) { - name = [ name ]; - } else { - name = name.split(" "); - } - } - } else { - // If "name" is an array of keys... - // When data is initially created, via ("key", "val") signature, - // keys will be converted to camelCase. - // Since there is no way to tell _how_ a key was added, remove - // both plain key and camelCase key. #12786 - // This will only penalize the array argument path. - name = name.concat( jQuery.map( name, jQuery.camelCase ) ); - } - - for ( i = 0, l = name.length; i < l; i++ ) { - delete thisCache[ name[i] ]; - } - - // If there is no data left in the cache, we want to continue - // and let the cache object itself get destroyed - if ( !( pvt ? isEmptyDataObject : jQuery.isEmptyObject )( thisCache ) ) { - return; - } - } - } - - // See jQuery.data for more information - if ( !pvt ) { - delete cache[ id ].data; - - // Don't destroy the parent cache unless the internal data object - // had been the only thing left in it - if ( !isEmptyDataObject( cache[ id ] ) ) { - return; - } - } - - // Destroy the cache - if ( isNode ) { - jQuery.cleanData( [ elem ], true ); - - // Use delete when supported for expandos or `cache` is not a window per isWindow (#10080) - } else if ( jQuery.support.deleteExpando || cache != cache.window ) { - delete cache[ id ]; - - // When all else fails, null - } else { - cache[ id ] = null; - } -} - -jQuery.extend({ - cache: {}, - - // Unique for each copy of jQuery on the page - // Non-digits removed to match rinlinejQuery - expando: "jQuery" + ( core_version + Math.random() ).replace( /\D/g, "" ), - - // The following elements throw uncatchable exceptions if you - // attempt to add expando properties to them. - noData: { - "embed": true, - // Ban all objects except for Flash (which handle expandos) - "object": "clsid:D27CDB6E-AE6D-11cf-96B8-444553540000", - "applet": true - }, - - hasData: function( elem ) { - elem = elem.nodeType ? jQuery.cache[ elem[jQuery.expando] ] : elem[ jQuery.expando ]; - return !!elem && !isEmptyDataObject( elem ); - }, - - data: function( elem, name, data ) { - return internalData( elem, name, data ); - }, - - removeData: function( elem, name ) { - return internalRemoveData( elem, name ); - }, - - // For internal use only. - _data: function( elem, name, data ) { - return internalData( elem, name, data, true ); - }, - - _removeData: function( elem, name ) { - return internalRemoveData( elem, name, true ); - }, - - // A method for determining if a DOM node can handle the data expando - acceptData: function( elem ) { - // Do not set data on non-element because it will not be cleared (#8335). - if ( elem.nodeType && elem.nodeType !== 1 && elem.nodeType !== 9 ) { - return false; - } - - var noData = elem.nodeName && jQuery.noData[ elem.nodeName.toLowerCase() ]; - - // nodes accept data unless otherwise specified; rejection can be conditional - return !noData || noData !== true && elem.getAttribute("classid") === noData; - } -}); - -jQuery.fn.extend({ - data: function( key, value ) { - var attrs, name, - elem = this[0], - i = 0, - data = null; - - // Gets all values - if ( key === undefined ) { - if ( this.length ) { - data = jQuery.data( elem ); - - if ( elem.nodeType === 1 && !jQuery._data( elem, "parsedAttrs" ) ) { - attrs = elem.attributes; - for ( ; i < attrs.length; i++ ) { - name = attrs[i].name; - - if ( !name.indexOf( "data-" ) ) { - name = jQuery.camelCase( name.slice(5) ); - - dataAttr( elem, name, data[ name ] ); - } - } - jQuery._data( elem, "parsedAttrs", true ); - } - } - - return data; - } - - // Sets multiple values - if ( typeof key === "object" ) { - return this.each(function() { - jQuery.data( this, key ); - }); - } - - return jQuery.access( this, function( value ) { - - if ( value === undefined ) { - // Try to fetch any internally stored data first - return elem ? dataAttr( elem, key, jQuery.data( elem, key ) ) : null; - } - - this.each(function() { - jQuery.data( this, key, value ); - }); - }, null, value, arguments.length > 1, null, true ); - }, - - removeData: function( key ) { - return this.each(function() { - jQuery.removeData( this, key ); - }); - } -}); - -function dataAttr( elem, key, data ) { - // If nothing was found internally, try to fetch any - // data from the HTML5 data-* attribute - if ( data === undefined && elem.nodeType === 1 ) { - - var name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase(); - - data = elem.getAttribute( name ); - - if ( typeof data === "string" ) { - try { - data = data === "true" ? true : - data === "false" ? false : - data === "null" ? null : - // Only convert to a number if it doesn't change the string - +data + "" === data ? +data : - rbrace.test( data ) ? jQuery.parseJSON( data ) : - data; - } catch( e ) {} - - // Make sure we set the data so it isn't changed later - jQuery.data( elem, key, data ); - - } else { - data = undefined; - } - } - - return data; -} - -// checks a cache object for emptiness -function isEmptyDataObject( obj ) { - var name; - for ( name in obj ) { - - // if the public data object is empty, the private is still empty - if ( name === "data" && jQuery.isEmptyObject( obj[name] ) ) { - continue; - } - if ( name !== "toJSON" ) { - return false; - } - } - - return true; -} -jQuery.extend({ - queue: function( elem, type, data ) { - var queue; - - if ( elem ) { - type = ( type || "fx" ) + "queue"; - queue = jQuery._data( elem, type ); - - // Speed up dequeue by getting out quickly if this is just a lookup - if ( data ) { - if ( !queue || jQuery.isArray(data) ) { - queue = jQuery._data( elem, type, jQuery.makeArray(data) ); - } else { - queue.push( data ); - } - } - return queue || []; - } - }, - - dequeue: function( elem, type ) { - type = type || "fx"; - - var queue = jQuery.queue( elem, type ), - startLength = queue.length, - fn = queue.shift(), - hooks = jQuery._queueHooks( elem, type ), - next = function() { - jQuery.dequeue( elem, type ); - }; - - // If the fx queue is dequeued, always remove the progress sentinel - if ( fn === "inprogress" ) { - fn = queue.shift(); - startLength--; - } - - hooks.cur = fn; - if ( fn ) { - - // Add a progress sentinel to prevent the fx queue from being - // automatically dequeued - if ( type === "fx" ) { - queue.unshift( "inprogress" ); - } - - // clear up the last queue stop function - delete hooks.stop; - fn.call( elem, next, hooks ); - } - - if ( !startLength && hooks ) { - hooks.empty.fire(); - } - }, - - // not intended for public consumption - generates a queueHooks object, or returns the current one - _queueHooks: function( elem, type ) { - var key = type + "queueHooks"; - return jQuery._data( elem, key ) || jQuery._data( elem, key, { - empty: jQuery.Callbacks("once memory").add(function() { - jQuery._removeData( elem, type + "queue" ); - jQuery._removeData( elem, key ); - }) - }); - } -}); - -jQuery.fn.extend({ - queue: function( type, data ) { - var setter = 2; - - if ( typeof type !== "string" ) { - data = type; - type = "fx"; - setter--; - } - - if ( arguments.length < setter ) { - return jQuery.queue( this[0], type ); - } - - return data === undefined ? - this : - this.each(function() { - var queue = jQuery.queue( this, type, data ); - - // ensure a hooks for this queue - jQuery._queueHooks( this, type ); - - if ( type === "fx" && queue[0] !== "inprogress" ) { - jQuery.dequeue( this, type ); - } - }); - }, - dequeue: function( type ) { - return this.each(function() { - jQuery.dequeue( this, type ); - }); - }, - // Based off of the plugin by Clint Helfers, with permission. - // http://blindsignals.com/index.php/2009/07/jquery-delay/ - delay: function( time, type ) { - time = jQuery.fx ? jQuery.fx.speeds[ time ] || time : time; - type = type || "fx"; - - return this.queue( type, function( next, hooks ) { - var timeout = setTimeout( next, time ); - hooks.stop = function() { - clearTimeout( timeout ); - }; - }); - }, - clearQueue: function( type ) { - return this.queue( type || "fx", [] ); - }, - // Get a promise resolved when queues of a certain type - // are emptied (fx is the type by default) - promise: function( type, obj ) { - var tmp, - count = 1, - defer = jQuery.Deferred(), - elements = this, - i = this.length, - resolve = function() { - if ( !( --count ) ) { - defer.resolveWith( elements, [ elements ] ); - } - }; - - if ( typeof type !== "string" ) { - obj = type; - type = undefined; - } - type = type || "fx"; - - while( i-- ) { - tmp = jQuery._data( elements[ i ], type + "queueHooks" ); - if ( tmp && tmp.empty ) { - count++; - tmp.empty.add( resolve ); - } - } - resolve(); - return defer.promise( obj ); - } -}); -var nodeHook, boolHook, - rclass = /[\t\r\n]/g, - rreturn = /\r/g, - rfocusable = /^(?:input|select|textarea|button|object)$/i, - rclickable = /^(?:a|area)$/i, - rboolean = /^(?:checked|selected|autofocus|autoplay|async|controls|defer|disabled|hidden|loop|multiple|open|readonly|required|scoped)$/i, - ruseDefault = /^(?:checked|selected)$/i, - getSetAttribute = jQuery.support.getSetAttribute, - getSetInput = jQuery.support.input; - -jQuery.fn.extend({ - attr: function( name, value ) { - return jQuery.access( this, jQuery.attr, name, value, arguments.length > 1 ); - }, - - removeAttr: function( name ) { - return this.each(function() { - jQuery.removeAttr( this, name ); - }); - }, - - prop: function( name, value ) { - return jQuery.access( this, jQuery.prop, name, value, arguments.length > 1 ); - }, - - removeProp: function( name ) { - name = jQuery.propFix[ name ] || name; - return this.each(function() { - // try/catch handles cases where IE balks (such as removing a property on window) - try { - this[ name ] = undefined; - delete this[ name ]; - } catch( e ) {} - }); - }, - - addClass: function( value ) { - var classes, elem, cur, clazz, j, - i = 0, - len = this.length, - proceed = typeof value === "string" && value; - - if ( jQuery.isFunction( value ) ) { - return this.each(function( j ) { - jQuery( this ).addClass( value.call( this, j, this.className ) ); - }); - } - - if ( proceed ) { - // The disjunction here is for better compressibility (see removeClass) - classes = ( value || "" ).match( core_rnotwhite ) || []; - - for ( ; i < len; i++ ) { - elem = this[ i ]; - cur = elem.nodeType === 1 && ( elem.className ? - ( " " + elem.className + " " ).replace( rclass, " " ) : - " " - ); - - if ( cur ) { - j = 0; - while ( (clazz = classes[j++]) ) { - if ( cur.indexOf( " " + clazz + " " ) < 0 ) { - cur += clazz + " "; - } - } - elem.className = jQuery.trim( cur ); - - } - } - } - - return this; - }, - - removeClass: function( value ) { - var classes, elem, cur, clazz, j, - i = 0, - len = this.length, - proceed = arguments.length === 0 || typeof value === "string" && value; - - if ( jQuery.isFunction( value ) ) { - return this.each(function( j ) { - jQuery( this ).removeClass( value.call( this, j, this.className ) ); - }); - } - if ( proceed ) { - classes = ( value || "" ).match( core_rnotwhite ) || []; - - for ( ; i < len; i++ ) { - elem = this[ i ]; - // This expression is here for better compressibility (see addClass) - cur = elem.nodeType === 1 && ( elem.className ? - ( " " + elem.className + " " ).replace( rclass, " " ) : - "" - ); - - if ( cur ) { - j = 0; - while ( (clazz = classes[j++]) ) { - // Remove *all* instances - while ( cur.indexOf( " " + clazz + " " ) >= 0 ) { - cur = cur.replace( " " + clazz + " ", " " ); - } - } - elem.className = value ? jQuery.trim( cur ) : ""; - } - } - } - - return this; - }, - - toggleClass: function( value, stateVal ) { - var type = typeof value, - isBool = typeof stateVal === "boolean"; - - if ( jQuery.isFunction( value ) ) { - return this.each(function( i ) { - jQuery( this ).toggleClass( value.call(this, i, this.className, stateVal), stateVal ); - }); - } - - return this.each(function() { - if ( type === "string" ) { - // toggle individual class names - var className, - i = 0, - self = jQuery( this ), - state = stateVal, - classNames = value.match( core_rnotwhite ) || []; - - while ( (className = classNames[ i++ ]) ) { - // check each className given, space separated list - state = isBool ? state : !self.hasClass( className ); - self[ state ? "addClass" : "removeClass" ]( className ); - } - - // Toggle whole class name - } else if ( type === core_strundefined || type === "boolean" ) { - if ( this.className ) { - // store className if set - jQuery._data( this, "__className__", this.className ); - } - - // If the element has a class name or if we're passed "false", - // then remove the whole classname (if there was one, the above saved it). - // Otherwise bring back whatever was previously saved (if anything), - // falling back to the empty string if nothing was stored. - this.className = this.className || value === false ? "" : jQuery._data( this, "__className__" ) || ""; - } - }); - }, - - hasClass: function( selector ) { - var className = " " + selector + " ", - i = 0, - l = this.length; - for ( ; i < l; i++ ) { - if ( this[i].nodeType === 1 && (" " + this[i].className + " ").replace(rclass, " ").indexOf( className ) >= 0 ) { - return true; - } - } - - return false; - }, - - val: function( value ) { - var ret, hooks, isFunction, - elem = this[0]; - - if ( !arguments.length ) { - if ( elem ) { - hooks = jQuery.valHooks[ elem.type ] || jQuery.valHooks[ elem.nodeName.toLowerCase() ]; - - if ( hooks && "get" in hooks && (ret = hooks.get( elem, "value" )) !== undefined ) { - return ret; - } - - ret = elem.value; - - return typeof ret === "string" ? - // handle most common string cases - ret.replace(rreturn, "") : - // handle cases where value is null/undef or number - ret == null ? "" : ret; - } - - return; - } - - isFunction = jQuery.isFunction( value ); - - return this.each(function( i ) { - var val, - self = jQuery(this); - - if ( this.nodeType !== 1 ) { - return; - } - - if ( isFunction ) { - val = value.call( this, i, self.val() ); - } else { - val = value; - } - - // Treat null/undefined as ""; convert numbers to string - if ( val == null ) { - val = ""; - } else if ( typeof val === "number" ) { - val += ""; - } else if ( jQuery.isArray( val ) ) { - val = jQuery.map(val, function ( value ) { - return value == null ? "" : value + ""; - }); - } - - hooks = jQuery.valHooks[ this.type ] || jQuery.valHooks[ this.nodeName.toLowerCase() ]; - - // If set returns undefined, fall back to normal setting - if ( !hooks || !("set" in hooks) || hooks.set( this, val, "value" ) === undefined ) { - this.value = val; - } - }); - } -}); - -jQuery.extend({ - valHooks: { - option: { - get: function( elem ) { - // attributes.value is undefined in Blackberry 4.7 but - // uses .value. See #6932 - var val = elem.attributes.value; - return !val || val.specified ? elem.value : elem.text; - } - }, - select: { - get: function( elem ) { - var value, option, - options = elem.options, - index = elem.selectedIndex, - one = elem.type === "select-one" || index < 0, - values = one ? null : [], - max = one ? index + 1 : options.length, - i = index < 0 ? - max : - one ? index : 0; - - // Loop through all the selected options - for ( ; i < max; i++ ) { - option = options[ i ]; - - // oldIE doesn't update selected after form reset (#2551) - if ( ( option.selected || i === index ) && - // Don't return options that are disabled or in a disabled optgroup - ( jQuery.support.optDisabled ? !option.disabled : option.getAttribute("disabled") === null ) && - ( !option.parentNode.disabled || !jQuery.nodeName( option.parentNode, "optgroup" ) ) ) { - - // Get the specific value for the option - value = jQuery( option ).val(); - - // We don't need an array for one selects - if ( one ) { - return value; - } - - // Multi-Selects return an array - values.push( value ); - } - } - - return values; - }, - - set: function( elem, value ) { - var values = jQuery.makeArray( value ); - - jQuery(elem).find("option").each(function() { - this.selected = jQuery.inArray( jQuery(this).val(), values ) >= 0; - }); - - if ( !values.length ) { - elem.selectedIndex = -1; - } - return values; - } - } - }, - - attr: function( elem, name, value ) { - var hooks, notxml, ret, - nType = elem.nodeType; - - // don't get/set attributes on text, comment and attribute nodes - if ( !elem || nType === 3 || nType === 8 || nType === 2 ) { - return; - } - - // Fallback to prop when attributes are not supported - if ( typeof elem.getAttribute === core_strundefined ) { - return jQuery.prop( elem, name, value ); - } - - notxml = nType !== 1 || !jQuery.isXMLDoc( elem ); - - // All attributes are lowercase - // Grab necessary hook if one is defined - if ( notxml ) { - name = name.toLowerCase(); - hooks = jQuery.attrHooks[ name ] || ( rboolean.test( name ) ? boolHook : nodeHook ); - } - - if ( value !== undefined ) { - - if ( value === null ) { - jQuery.removeAttr( elem, name ); - - } else if ( hooks && notxml && "set" in hooks && (ret = hooks.set( elem, value, name )) !== undefined ) { - return ret; - - } else { - elem.setAttribute( name, value + "" ); - return value; - } - - } else if ( hooks && notxml && "get" in hooks && (ret = hooks.get( elem, name )) !== null ) { - return ret; - - } else { - - // In IE9+, Flash objects don't have .getAttribute (#12945) - // Support: IE9+ - if ( typeof elem.getAttribute !== core_strundefined ) { - ret = elem.getAttribute( name ); - } - - // Non-existent attributes return null, we normalize to undefined - return ret == null ? - undefined : - ret; - } - }, - - removeAttr: function( elem, value ) { - var name, propName, - i = 0, - attrNames = value && value.match( core_rnotwhite ); - - if ( attrNames && elem.nodeType === 1 ) { - while ( (name = attrNames[i++]) ) { - propName = jQuery.propFix[ name ] || name; - - // Boolean attributes get special treatment (#10870) - if ( rboolean.test( name ) ) { - // Set corresponding property to false for boolean attributes - // Also clear defaultChecked/defaultSelected (if appropriate) for IE<8 - if ( !getSetAttribute && ruseDefault.test( name ) ) { - elem[ jQuery.camelCase( "default-" + name ) ] = - elem[ propName ] = false; - } else { - elem[ propName ] = false; - } - - // See #9699 for explanation of this approach (setting first, then removal) - } else { - jQuery.attr( elem, name, "" ); - } - - elem.removeAttribute( getSetAttribute ? name : propName ); - } - } - }, - - attrHooks: { - type: { - set: function( elem, value ) { - if ( !jQuery.support.radioValue && value === "radio" && jQuery.nodeName(elem, "input") ) { - // Setting the type on a radio button after the value resets the value in IE6-9 - // Reset value to default in case type is set after value during creation - var val = elem.value; - elem.setAttribute( "type", value ); - if ( val ) { - elem.value = val; - } - return value; - } - } - } - }, - - propFix: { - tabindex: "tabIndex", - readonly: "readOnly", - "for": "htmlFor", - "class": "className", - maxlength: "maxLength", - cellspacing: "cellSpacing", - cellpadding: "cellPadding", - rowspan: "rowSpan", - colspan: "colSpan", - usemap: "useMap", - frameborder: "frameBorder", - contenteditable: "contentEditable" - }, - - prop: function( elem, name, value ) { - var ret, hooks, notxml, - nType = elem.nodeType; - - // don't get/set properties on text, comment and attribute nodes - if ( !elem || nType === 3 || nType === 8 || nType === 2 ) { - return; - } - - notxml = nType !== 1 || !jQuery.isXMLDoc( elem ); - - if ( notxml ) { - // Fix name and attach hooks - name = jQuery.propFix[ name ] || name; - hooks = jQuery.propHooks[ name ]; - } - - if ( value !== undefined ) { - if ( hooks && "set" in hooks && (ret = hooks.set( elem, value, name )) !== undefined ) { - return ret; - - } else { - return ( elem[ name ] = value ); - } - - } else { - if ( hooks && "get" in hooks && (ret = hooks.get( elem, name )) !== null ) { - return ret; - - } else { - return elem[ name ]; - } - } - }, - - propHooks: { - tabIndex: { - get: function( elem ) { - // elem.tabIndex doesn't always return the correct value when it hasn't been explicitly set - // http://fluidproject.org/blog/2008/01/09/getting-setting-and-removing-tabindex-values-with-javascript/ - var attributeNode = elem.getAttributeNode("tabindex"); - - return attributeNode && attributeNode.specified ? - parseInt( attributeNode.value, 10 ) : - rfocusable.test( elem.nodeName ) || rclickable.test( elem.nodeName ) && elem.href ? - 0 : - undefined; - } - } - } -}); - -// Hook for boolean attributes -boolHook = { - get: function( elem, name ) { - var - // Use .prop to determine if this attribute is understood as boolean - prop = jQuery.prop( elem, name ), - - // Fetch it accordingly - attr = typeof prop === "boolean" && elem.getAttribute( name ), - detail = typeof prop === "boolean" ? - - getSetInput && getSetAttribute ? - attr != null : - // oldIE fabricates an empty string for missing boolean attributes - // and conflates checked/selected into attroperties - ruseDefault.test( name ) ? - elem[ jQuery.camelCase( "default-" + name ) ] : - !!attr : - - // fetch an attribute node for properties not recognized as boolean - elem.getAttributeNode( name ); - - return detail && detail.value !== false ? - name.toLowerCase() : - undefined; - }, - set: function( elem, value, name ) { - if ( value === false ) { - // Remove boolean attributes when set to false - jQuery.removeAttr( elem, name ); - } else if ( getSetInput && getSetAttribute || !ruseDefault.test( name ) ) { - // IE<8 needs the *property* name - elem.setAttribute( !getSetAttribute && jQuery.propFix[ name ] || name, name ); - - // Use defaultChecked and defaultSelected for oldIE - } else { - elem[ jQuery.camelCase( "default-" + name ) ] = elem[ name ] = true; - } - - return name; - } -}; - -// fix oldIE value attroperty -if ( !getSetInput || !getSetAttribute ) { - jQuery.attrHooks.value = { - get: function( elem, name ) { - var ret = elem.getAttributeNode( name ); - return jQuery.nodeName( elem, "input" ) ? - - // Ignore the value *property* by using defaultValue - elem.defaultValue : - - ret && ret.specified ? ret.value : undefined; - }, - set: function( elem, value, name ) { - if ( jQuery.nodeName( elem, "input" ) ) { - // Does not return so that setAttribute is also used - elem.defaultValue = value; - } else { - // Use nodeHook if defined (#1954); otherwise setAttribute is fine - return nodeHook && nodeHook.set( elem, value, name ); - } - } - }; -} - -// IE6/7 do not support getting/setting some attributes with get/setAttribute -if ( !getSetAttribute ) { - - // Use this for any attribute in IE6/7 - // This fixes almost every IE6/7 issue - nodeHook = jQuery.valHooks.button = { - get: function( elem, name ) { - var ret = elem.getAttributeNode( name ); - return ret && ( name === "id" || name === "name" || name === "coords" ? ret.value !== "" : ret.specified ) ? - ret.value : - undefined; - }, - set: function( elem, value, name ) { - // Set the existing or create a new attribute node - var ret = elem.getAttributeNode( name ); - if ( !ret ) { - elem.setAttributeNode( - (ret = elem.ownerDocument.createAttribute( name )) - ); - } - - ret.value = value += ""; - - // Break association with cloned elements by also using setAttribute (#9646) - return name === "value" || value === elem.getAttribute( name ) ? - value : - undefined; - } - }; - - // Set contenteditable to false on removals(#10429) - // Setting to empty string throws an error as an invalid value - jQuery.attrHooks.contenteditable = { - get: nodeHook.get, - set: function( elem, value, name ) { - nodeHook.set( elem, value === "" ? false : value, name ); - } - }; - - // Set width and height to auto instead of 0 on empty string( Bug #8150 ) - // This is for removals - jQuery.each([ "width", "height" ], function( i, name ) { - jQuery.attrHooks[ name ] = jQuery.extend( jQuery.attrHooks[ name ], { - set: function( elem, value ) { - if ( value === "" ) { - elem.setAttribute( name, "auto" ); - return value; - } - } - }); - }); -} - - -// Some attributes require a special call on IE -// http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx -if ( !jQuery.support.hrefNormalized ) { - jQuery.each([ "href", "src", "width", "height" ], function( i, name ) { - jQuery.attrHooks[ name ] = jQuery.extend( jQuery.attrHooks[ name ], { - get: function( elem ) { - var ret = elem.getAttribute( name, 2 ); - return ret == null ? undefined : ret; - } - }); - }); - - // href/src property should get the full normalized URL (#10299/#12915) - jQuery.each([ "href", "src" ], function( i, name ) { - jQuery.propHooks[ name ] = { - get: function( elem ) { - return elem.getAttribute( name, 4 ); - } - }; - }); -} - -if ( !jQuery.support.style ) { - jQuery.attrHooks.style = { - get: function( elem ) { - // Return undefined in the case of empty string - // Note: IE uppercases css property names, but if we were to .toLowerCase() - // .cssText, that would destroy case senstitivity in URL's, like in "background" - return elem.style.cssText || undefined; - }, - set: function( elem, value ) { - return ( elem.style.cssText = value + "" ); - } - }; -} - -// Safari mis-reports the default selected property of an option -// Accessing the parent's selectedIndex property fixes it -if ( !jQuery.support.optSelected ) { - jQuery.propHooks.selected = jQuery.extend( jQuery.propHooks.selected, { - get: function( elem ) { - var parent = elem.parentNode; - - if ( parent ) { - parent.selectedIndex; - - // Make sure that it also works with optgroups, see #5701 - if ( parent.parentNode ) { - parent.parentNode.selectedIndex; - } - } - return null; - } - }); -} - -// IE6/7 call enctype encoding -if ( !jQuery.support.enctype ) { - jQuery.propFix.enctype = "encoding"; -} - -// Radios and checkboxes getter/setter -if ( !jQuery.support.checkOn ) { - jQuery.each([ "radio", "checkbox" ], function() { - jQuery.valHooks[ this ] = { - get: function( elem ) { - // Handle the case where in Webkit "" is returned instead of "on" if a value isn't specified - return elem.getAttribute("value") === null ? "on" : elem.value; - } - }; - }); -} -jQuery.each([ "radio", "checkbox" ], function() { - jQuery.valHooks[ this ] = jQuery.extend( jQuery.valHooks[ this ], { - set: function( elem, value ) { - if ( jQuery.isArray( value ) ) { - return ( elem.checked = jQuery.inArray( jQuery(elem).val(), value ) >= 0 ); - } - } - }); -}); -var rformElems = /^(?:input|select|textarea)$/i, - rkeyEvent = /^key/, - rmouseEvent = /^(?:mouse|contextmenu)|click/, - rfocusMorph = /^(?:focusinfocus|focusoutblur)$/, - rtypenamespace = /^([^.]*)(?:\.(.+)|)$/; - -function returnTrue() { - return true; -} - -function returnFalse() { - return false; -} - -/* - * Helper functions for managing events -- not part of the public interface. - * Props to Dean Edwards' addEvent library for many of the ideas. - */ -jQuery.event = { - - global: {}, - - add: function( elem, types, handler, data, selector ) { - var tmp, events, t, handleObjIn, - special, eventHandle, handleObj, - handlers, type, namespaces, origType, - elemData = jQuery._data( elem ); - - // Don't attach events to noData or text/comment nodes (but allow plain objects) - if ( !elemData ) { - return; - } - - // Caller can pass in an object of custom data in lieu of the handler - if ( handler.handler ) { - handleObjIn = handler; - handler = handleObjIn.handler; - selector = handleObjIn.selector; - } - - // Make sure that the handler has a unique ID, used to find/remove it later - if ( !handler.guid ) { - handler.guid = jQuery.guid++; - } - - // Init the element's event structure and main handler, if this is the first - if ( !(events = elemData.events) ) { - events = elemData.events = {}; - } - if ( !(eventHandle = elemData.handle) ) { - eventHandle = elemData.handle = function( e ) { - // Discard the second event of a jQuery.event.trigger() and - // when an event is called after a page has unloaded - return typeof jQuery !== core_strundefined && (!e || jQuery.event.triggered !== e.type) ? - jQuery.event.dispatch.apply( eventHandle.elem, arguments ) : - undefined; - }; - // Add elem as a property of the handle fn to prevent a memory leak with IE non-native events - eventHandle.elem = elem; - } - - // Handle multiple events separated by a space - // jQuery(...).bind("mouseover mouseout", fn); - types = ( types || "" ).match( core_rnotwhite ) || [""]; - t = types.length; - while ( t-- ) { - tmp = rtypenamespace.exec( types[t] ) || []; - type = origType = tmp[1]; - namespaces = ( tmp[2] || "" ).split( "." ).sort(); - - // If event changes its type, use the special event handlers for the changed type - special = jQuery.event.special[ type ] || {}; - - // If selector defined, determine special event api type, otherwise given type - type = ( selector ? special.delegateType : special.bindType ) || type; - - // Update special based on newly reset type - special = jQuery.event.special[ type ] || {}; - - // handleObj is passed to all event handlers - handleObj = jQuery.extend({ - type: type, - origType: origType, - data: data, - handler: handler, - guid: handler.guid, - selector: selector, - needsContext: selector && jQuery.expr.match.needsContext.test( selector ), - namespace: namespaces.join(".") - }, handleObjIn ); - - // Init the event handler queue if we're the first - if ( !(handlers = events[ type ]) ) { - handlers = events[ type ] = []; - handlers.delegateCount = 0; - - // Only use addEventListener/attachEvent if the special events handler returns false - if ( !special.setup || special.setup.call( elem, data, namespaces, eventHandle ) === false ) { - // Bind the global event handler to the element - if ( elem.addEventListener ) { - elem.addEventListener( type, eventHandle, false ); - - } else if ( elem.attachEvent ) { - elem.attachEvent( "on" + type, eventHandle ); - } - } - } - - if ( special.add ) { - special.add.call( elem, handleObj ); - - if ( !handleObj.handler.guid ) { - handleObj.handler.guid = handler.guid; - } - } - - // Add to the element's handler list, delegates in front - if ( selector ) { - handlers.splice( handlers.delegateCount++, 0, handleObj ); - } else { - handlers.push( handleObj ); - } - - // Keep track of which events have ever been used, for event optimization - jQuery.event.global[ type ] = true; - } - - // Nullify elem to prevent memory leaks in IE - elem = null; - }, - - // Detach an event or set of events from an element - remove: function( elem, types, handler, selector, mappedTypes ) { - var j, handleObj, tmp, - origCount, t, events, - special, handlers, type, - namespaces, origType, - elemData = jQuery.hasData( elem ) && jQuery._data( elem ); - - if ( !elemData || !(events = elemData.events) ) { - return; - } - - // Once for each type.namespace in types; type may be omitted - types = ( types || "" ).match( core_rnotwhite ) || [""]; - t = types.length; - while ( t-- ) { - tmp = rtypenamespace.exec( types[t] ) || []; - type = origType = tmp[1]; - namespaces = ( tmp[2] || "" ).split( "." ).sort(); - - // Unbind all events (on this namespace, if provided) for the element - if ( !type ) { - for ( type in events ) { - jQuery.event.remove( elem, type + types[ t ], handler, selector, true ); - } - continue; - } - - special = jQuery.event.special[ type ] || {}; - type = ( selector ? special.delegateType : special.bindType ) || type; - handlers = events[ type ] || []; - tmp = tmp[2] && new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ); - - // Remove matching events - origCount = j = handlers.length; - while ( j-- ) { - handleObj = handlers[ j ]; - - if ( ( mappedTypes || origType === handleObj.origType ) && - ( !handler || handler.guid === handleObj.guid ) && - ( !tmp || tmp.test( handleObj.namespace ) ) && - ( !selector || selector === handleObj.selector || selector === "**" && handleObj.selector ) ) { - handlers.splice( j, 1 ); - - if ( handleObj.selector ) { - handlers.delegateCount--; - } - if ( special.remove ) { - special.remove.call( elem, handleObj ); - } - } - } - - // Remove generic event handler if we removed something and no more handlers exist - // (avoids potential for endless recursion during removal of special event handlers) - if ( origCount && !handlers.length ) { - if ( !special.teardown || special.teardown.call( elem, namespaces, elemData.handle ) === false ) { - jQuery.removeEvent( elem, type, elemData.handle ); - } - - delete events[ type ]; - } - } - - // Remove the expando if it's no longer used - if ( jQuery.isEmptyObject( events ) ) { - delete elemData.handle; - - // removeData also checks for emptiness and clears the expando if empty - // so use it instead of delete - jQuery._removeData( elem, "events" ); - } - }, - - trigger: function( event, data, elem, onlyHandlers ) { - var handle, ontype, cur, - bubbleType, special, tmp, i, - eventPath = [ elem || document ], - type = core_hasOwn.call( event, "type" ) ? event.type : event, - namespaces = core_hasOwn.call( event, "namespace" ) ? event.namespace.split(".") : []; - - cur = tmp = elem = elem || document; - - // Don't do events on text and comment nodes - if ( elem.nodeType === 3 || elem.nodeType === 8 ) { - return; - } - - // focus/blur morphs to focusin/out; ensure we're not firing them right now - if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { - return; - } - - if ( type.indexOf(".") >= 0 ) { - // Namespaced trigger; create a regexp to match event type in handle() - namespaces = type.split("."); - type = namespaces.shift(); - namespaces.sort(); - } - ontype = type.indexOf(":") < 0 && "on" + type; - - // Caller can pass in a jQuery.Event object, Object, or just an event type string - event = event[ jQuery.expando ] ? - event : - new jQuery.Event( type, typeof event === "object" && event ); - - event.isTrigger = true; - event.namespace = namespaces.join("."); - event.namespace_re = event.namespace ? - new RegExp( "(^|\\.)" + namespaces.join("\\.(?:.*\\.|)") + "(\\.|$)" ) : - null; - - // Clean up the event in case it is being reused - event.result = undefined; - if ( !event.target ) { - event.target = elem; - } - - // Clone any incoming data and prepend the event, creating the handler arg list - data = data == null ? - [ event ] : - jQuery.makeArray( data, [ event ] ); - - // Allow special events to draw outside the lines - special = jQuery.event.special[ type ] || {}; - if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) { - return; - } - - // Determine event propagation path in advance, per W3C events spec (#9951) - // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) - if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) { - - bubbleType = special.delegateType || type; - if ( !rfocusMorph.test( bubbleType + type ) ) { - cur = cur.parentNode; - } - for ( ; cur; cur = cur.parentNode ) { - eventPath.push( cur ); - tmp = cur; - } - - // Only add window if we got to document (e.g., not plain obj or detached DOM) - if ( tmp === (elem.ownerDocument || document) ) { - eventPath.push( tmp.defaultView || tmp.parentWindow || window ); - } - } - - // Fire handlers on the event path - i = 0; - while ( (cur = eventPath[i++]) && !event.isPropagationStopped() ) { - - event.type = i > 1 ? - bubbleType : - special.bindType || type; - - // jQuery handler - handle = ( jQuery._data( cur, "events" ) || {} )[ event.type ] && jQuery._data( cur, "handle" ); - if ( handle ) { - handle.apply( cur, data ); - } - - // Native handler - handle = ontype && cur[ ontype ]; - if ( handle && jQuery.acceptData( cur ) && handle.apply && handle.apply( cur, data ) === false ) { - event.preventDefault(); - } - } - event.type = type; - - // If nobody prevented the default action, do it now - if ( !onlyHandlers && !event.isDefaultPrevented() ) { - - if ( (!special._default || special._default.apply( elem.ownerDocument, data ) === false) && - !(type === "click" && jQuery.nodeName( elem, "a" )) && jQuery.acceptData( elem ) ) { - - // Call a native DOM method on the target with the same name name as the event. - // Can't use an .isFunction() check here because IE6/7 fails that test. - // Don't do default actions on window, that's where global variables be (#6170) - if ( ontype && elem[ type ] && !jQuery.isWindow( elem ) ) { - - // Don't re-trigger an onFOO event when we call its FOO() method - tmp = elem[ ontype ]; - - if ( tmp ) { - elem[ ontype ] = null; - } - - // Prevent re-triggering of the same event, since we already bubbled it above - jQuery.event.triggered = type; - try { - elem[ type ](); - } catch ( e ) { - // IE<9 dies on focus/blur to hidden element (#1486,#12518) - // only reproducible on winXP IE8 native, not IE9 in IE8 mode - } - jQuery.event.triggered = undefined; - - if ( tmp ) { - elem[ ontype ] = tmp; - } - } - } - } - - return event.result; - }, - - dispatch: function( event ) { - - // Make a writable jQuery.Event from the native event object - event = jQuery.event.fix( event ); - - var i, ret, handleObj, matched, j, - handlerQueue = [], - args = core_slice.call( arguments ), - handlers = ( jQuery._data( this, "events" ) || {} )[ event.type ] || [], - special = jQuery.event.special[ event.type ] || {}; - - // Use the fix-ed jQuery.Event rather than the (read-only) native event - args[0] = event; - event.delegateTarget = this; - - // Call the preDispatch hook for the mapped type, and let it bail if desired - if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) { - return; - } - - // Determine handlers - handlerQueue = jQuery.event.handlers.call( this, event, handlers ); - - // Run delegates first; they may want to stop propagation beneath us - i = 0; - while ( (matched = handlerQueue[ i++ ]) && !event.isPropagationStopped() ) { - event.currentTarget = matched.elem; - - j = 0; - while ( (handleObj = matched.handlers[ j++ ]) && !event.isImmediatePropagationStopped() ) { - - // Triggered event must either 1) have no namespace, or - // 2) have namespace(s) a subset or equal to those in the bound event (both can have no namespace). - if ( !event.namespace_re || event.namespace_re.test( handleObj.namespace ) ) { - - event.handleObj = handleObj; - event.data = handleObj.data; - - ret = ( (jQuery.event.special[ handleObj.origType ] || {}).handle || handleObj.handler ) - .apply( matched.elem, args ); - - if ( ret !== undefined ) { - if ( (event.result = ret) === false ) { - event.preventDefault(); - event.stopPropagation(); - } - } - } - } - } - - // Call the postDispatch hook for the mapped type - if ( special.postDispatch ) { - special.postDispatch.call( this, event ); - } - - return event.result; - }, - - handlers: function( event, handlers ) { - var sel, handleObj, matches, i, - handlerQueue = [], - delegateCount = handlers.delegateCount, - cur = event.target; - - // Find delegate handlers - // Black-hole SVG instance trees (#13180) - // Avoid non-left-click bubbling in Firefox (#3861) - if ( delegateCount && cur.nodeType && (!event.button || event.type !== "click") ) { - - for ( ; cur != this; cur = cur.parentNode || this ) { - - // Don't check non-elements (#13208) - // Don't process clicks on disabled elements (#6911, #8165, #11382, #11764) - if ( cur.nodeType === 1 && (cur.disabled !== true || event.type !== "click") ) { - matches = []; - for ( i = 0; i < delegateCount; i++ ) { - handleObj = handlers[ i ]; - - // Don't conflict with Object.prototype properties (#13203) - sel = handleObj.selector + " "; - - if ( matches[ sel ] === undefined ) { - matches[ sel ] = handleObj.needsContext ? - jQuery( sel, this ).index( cur ) >= 0 : - jQuery.find( sel, this, null, [ cur ] ).length; - } - if ( matches[ sel ] ) { - matches.push( handleObj ); - } - } - if ( matches.length ) { - handlerQueue.push({ elem: cur, handlers: matches }); - } - } - } - } - - // Add the remaining (directly-bound) handlers - if ( delegateCount < handlers.length ) { - handlerQueue.push({ elem: this, handlers: handlers.slice( delegateCount ) }); - } - - return handlerQueue; - }, - - fix: function( event ) { - if ( event[ jQuery.expando ] ) { - return event; - } - - // Create a writable copy of the event object and normalize some properties - var i, prop, copy, - type = event.type, - originalEvent = event, - fixHook = this.fixHooks[ type ]; - - if ( !fixHook ) { - this.fixHooks[ type ] = fixHook = - rmouseEvent.test( type ) ? this.mouseHooks : - rkeyEvent.test( type ) ? this.keyHooks : - {}; - } - copy = fixHook.props ? this.props.concat( fixHook.props ) : this.props; - - event = new jQuery.Event( originalEvent ); - - i = copy.length; - while ( i-- ) { - prop = copy[ i ]; - event[ prop ] = originalEvent[ prop ]; - } - - // Support: IE<9 - // Fix target property (#1925) - if ( !event.target ) { - event.target = originalEvent.srcElement || document; - } - - // Support: Chrome 23+, Safari? - // Target should not be a text node (#504, #13143) - if ( event.target.nodeType === 3 ) { - event.target = event.target.parentNode; - } - - // Support: IE<9 - // For mouse/key events, metaKey==false if it's undefined (#3368, #11328) - event.metaKey = !!event.metaKey; - - return fixHook.filter ? fixHook.filter( event, originalEvent ) : event; - }, - - // Includes some event props shared by KeyEvent and MouseEvent - props: "altKey bubbles cancelable ctrlKey currentTarget eventPhase metaKey relatedTarget shiftKey target timeStamp view which".split(" "), - - fixHooks: {}, - - keyHooks: { - props: "char charCode key keyCode".split(" "), - filter: function( event, original ) { - - // Add which for key events - if ( event.which == null ) { - event.which = original.charCode != null ? original.charCode : original.keyCode; - } - - return event; - } - }, - - mouseHooks: { - props: "button buttons clientX clientY fromElement offsetX offsetY pageX pageY screenX screenY toElement".split(" "), - filter: function( event, original ) { - var body, eventDoc, doc, - button = original.button, - fromElement = original.fromElement; - - // Calculate pageX/Y if missing and clientX/Y available - if ( event.pageX == null && original.clientX != null ) { - eventDoc = event.target.ownerDocument || document; - doc = eventDoc.documentElement; - body = eventDoc.body; - - event.pageX = original.clientX + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - ( doc && doc.clientLeft || body && body.clientLeft || 0 ); - event.pageY = original.clientY + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - ( doc && doc.clientTop || body && body.clientTop || 0 ); - } - - // Add relatedTarget, if necessary - if ( !event.relatedTarget && fromElement ) { - event.relatedTarget = fromElement === event.target ? original.toElement : fromElement; - } - - // Add which for click: 1 === left; 2 === middle; 3 === right - // Note: button is not normalized, so don't use it - if ( !event.which && button !== undefined ) { - event.which = ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) ); - } - - return event; - } - }, - - special: { - load: { - // Prevent triggered image.load events from bubbling to window.load - noBubble: true - }, - click: { - // For checkbox, fire native event so checked state will be right - trigger: function() { - if ( jQuery.nodeName( this, "input" ) && this.type === "checkbox" && this.click ) { - this.click(); - return false; - } - } - }, - focus: { - // Fire native event if possible so blur/focus sequence is correct - trigger: function() { - if ( this !== document.activeElement && this.focus ) { - try { - this.focus(); - return false; - } catch ( e ) { - // Support: IE<9 - // If we error on focus to hidden element (#1486, #12518), - // let .trigger() run the handlers - } - } - }, - delegateType: "focusin" - }, - blur: { - trigger: function() { - if ( this === document.activeElement && this.blur ) { - this.blur(); - return false; - } - }, - delegateType: "focusout" - }, - - beforeunload: { - postDispatch: function( event ) { - - // Even when returnValue equals to undefined Firefox will still show alert - if ( event.result !== undefined ) { - event.originalEvent.returnValue = event.result; - } - } - } - }, - - simulate: function( type, elem, event, bubble ) { - // Piggyback on a donor event to simulate a different one. - // Fake originalEvent to avoid donor's stopPropagation, but if the - // simulated event prevents default then we do the same on the donor. - var e = jQuery.extend( - new jQuery.Event(), - event, - { type: type, - isSimulated: true, - originalEvent: {} - } - ); - if ( bubble ) { - jQuery.event.trigger( e, null, elem ); - } else { - jQuery.event.dispatch.call( elem, e ); - } - if ( e.isDefaultPrevented() ) { - event.preventDefault(); - } - } -}; - -jQuery.removeEvent = document.removeEventListener ? - function( elem, type, handle ) { - if ( elem.removeEventListener ) { - elem.removeEventListener( type, handle, false ); - } - } : - function( elem, type, handle ) { - var name = "on" + type; - - if ( elem.detachEvent ) { - - // #8545, #7054, preventing memory leaks for custom events in IE6-8 - // detachEvent needed property on element, by name of that event, to properly expose it to GC - if ( typeof elem[ name ] === core_strundefined ) { - elem[ name ] = null; - } - - elem.detachEvent( name, handle ); - } - }; - -jQuery.Event = function( src, props ) { - // Allow instantiation without the 'new' keyword - if ( !(this instanceof jQuery.Event) ) { - return new jQuery.Event( src, props ); - } - - // Event object - if ( src && src.type ) { - this.originalEvent = src; - this.type = src.type; - - // Events bubbling up the document may have been marked as prevented - // by a handler lower down the tree; reflect the correct value. - this.isDefaultPrevented = ( src.defaultPrevented || src.returnValue === false || - src.getPreventDefault && src.getPreventDefault() ) ? returnTrue : returnFalse; - - // Event type - } else { - this.type = src; - } - - // Put explicitly provided properties onto the event object - if ( props ) { - jQuery.extend( this, props ); - } - - // Create a timestamp if incoming event doesn't have one - this.timeStamp = src && src.timeStamp || jQuery.now(); - - // Mark it as fixed - this[ jQuery.expando ] = true; -}; - -// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding -// http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html -jQuery.Event.prototype = { - isDefaultPrevented: returnFalse, - isPropagationStopped: returnFalse, - isImmediatePropagationStopped: returnFalse, - - preventDefault: function() { - var e = this.originalEvent; - - this.isDefaultPrevented = returnTrue; - if ( !e ) { - return; - } - - // If preventDefault exists, run it on the original event - if ( e.preventDefault ) { - e.preventDefault(); - - // Support: IE - // Otherwise set the returnValue property of the original event to false - } else { - e.returnValue = false; - } - }, - stopPropagation: function() { - var e = this.originalEvent; - - this.isPropagationStopped = returnTrue; - if ( !e ) { - return; - } - // If stopPropagation exists, run it on the original event - if ( e.stopPropagation ) { - e.stopPropagation(); - } - - // Support: IE - // Set the cancelBubble property of the original event to true - e.cancelBubble = true; - }, - stopImmediatePropagation: function() { - this.isImmediatePropagationStopped = returnTrue; - this.stopPropagation(); - } -}; - -// Create mouseenter/leave events using mouseover/out and event-time checks -jQuery.each({ - mouseenter: "mouseover", - mouseleave: "mouseout" -}, function( orig, fix ) { - jQuery.event.special[ orig ] = { - delegateType: fix, - bindType: fix, - - handle: function( event ) { - var ret, - target = this, - related = event.relatedTarget, - handleObj = event.handleObj; - - // For mousenter/leave call the handler if related is outside the target. - // NB: No relatedTarget if the mouse left/entered the browser window - if ( !related || (related !== target && !jQuery.contains( target, related )) ) { - event.type = handleObj.origType; - ret = handleObj.handler.apply( this, arguments ); - event.type = fix; - } - return ret; - } - }; -}); - -// IE submit delegation -if ( !jQuery.support.submitBubbles ) { - - jQuery.event.special.submit = { - setup: function() { - // Only need this for delegated form submit events - if ( jQuery.nodeName( this, "form" ) ) { - return false; - } - - // Lazy-add a submit handler when a descendant form may potentially be submitted - jQuery.event.add( this, "click._submit keypress._submit", function( e ) { - // Node name check avoids a VML-related crash in IE (#9807) - var elem = e.target, - form = jQuery.nodeName( elem, "input" ) || jQuery.nodeName( elem, "button" ) ? elem.form : undefined; - if ( form && !jQuery._data( form, "submitBubbles" ) ) { - jQuery.event.add( form, "submit._submit", function( event ) { - event._submit_bubble = true; - }); - jQuery._data( form, "submitBubbles", true ); - } - }); - // return undefined since we don't need an event listener - }, - - postDispatch: function( event ) { - // If form was submitted by the user, bubble the event up the tree - if ( event._submit_bubble ) { - delete event._submit_bubble; - if ( this.parentNode && !event.isTrigger ) { - jQuery.event.simulate( "submit", this.parentNode, event, true ); - } - } - }, - - teardown: function() { - // Only need this for delegated form submit events - if ( jQuery.nodeName( this, "form" ) ) { - return false; - } - - // Remove delegated handlers; cleanData eventually reaps submit handlers attached above - jQuery.event.remove( this, "._submit" ); - } - }; -} - -// IE change delegation and checkbox/radio fix -if ( !jQuery.support.changeBubbles ) { - - jQuery.event.special.change = { - - setup: function() { - - if ( rformElems.test( this.nodeName ) ) { - // IE doesn't fire change on a check/radio until blur; trigger it on click - // after a propertychange. Eat the blur-change in special.change.handle. - // This still fires onchange a second time for check/radio after blur. - if ( this.type === "checkbox" || this.type === "radio" ) { - jQuery.event.add( this, "propertychange._change", function( event ) { - if ( event.originalEvent.propertyName === "checked" ) { - this._just_changed = true; - } - }); - jQuery.event.add( this, "click._change", function( event ) { - if ( this._just_changed && !event.isTrigger ) { - this._just_changed = false; - } - // Allow triggered, simulated change events (#11500) - jQuery.event.simulate( "change", this, event, true ); - }); - } - return false; - } - // Delegated event; lazy-add a change handler on descendant inputs - jQuery.event.add( this, "beforeactivate._change", function( e ) { - var elem = e.target; - - if ( rformElems.test( elem.nodeName ) && !jQuery._data( elem, "changeBubbles" ) ) { - jQuery.event.add( elem, "change._change", function( event ) { - if ( this.parentNode && !event.isSimulated && !event.isTrigger ) { - jQuery.event.simulate( "change", this.parentNode, event, true ); - } - }); - jQuery._data( elem, "changeBubbles", true ); - } - }); - }, - - handle: function( event ) { - var elem = event.target; - - // Swallow native change events from checkbox/radio, we already triggered them above - if ( this !== elem || event.isSimulated || event.isTrigger || (elem.type !== "radio" && elem.type !== "checkbox") ) { - return event.handleObj.handler.apply( this, arguments ); - } - }, - - teardown: function() { - jQuery.event.remove( this, "._change" ); - - return !rformElems.test( this.nodeName ); - } - }; -} - -// Create "bubbling" focus and blur events -if ( !jQuery.support.focusinBubbles ) { - jQuery.each({ focus: "focusin", blur: "focusout" }, function( orig, fix ) { - - // Attach a single capturing handler while someone wants focusin/focusout - var attaches = 0, - handler = function( event ) { - jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ), true ); - }; - - jQuery.event.special[ fix ] = { - setup: function() { - if ( attaches++ === 0 ) { - document.addEventListener( orig, handler, true ); - } - }, - teardown: function() { - if ( --attaches === 0 ) { - document.removeEventListener( orig, handler, true ); - } - } - }; - }); -} - -jQuery.fn.extend({ - - on: function( types, selector, data, fn, /*INTERNAL*/ one ) { - var type, origFn; - - // Types can be a map of types/handlers - if ( typeof types === "object" ) { - // ( types-Object, selector, data ) - if ( typeof selector !== "string" ) { - // ( types-Object, data ) - data = data || selector; - selector = undefined; - } - for ( type in types ) { - this.on( type, selector, data, types[ type ], one ); - } - return this; - } - - if ( data == null && fn == null ) { - // ( types, fn ) - fn = selector; - data = selector = undefined; - } else if ( fn == null ) { - if ( typeof selector === "string" ) { - // ( types, selector, fn ) - fn = data; - data = undefined; - } else { - // ( types, data, fn ) - fn = data; - data = selector; - selector = undefined; - } - } - if ( fn === false ) { - fn = returnFalse; - } else if ( !fn ) { - return this; - } - - if ( one === 1 ) { - origFn = fn; - fn = function( event ) { - // Can use an empty set, since event contains the info - jQuery().off( event ); - return origFn.apply( this, arguments ); - }; - // Use same guid so caller can remove using origFn - fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); - } - return this.each( function() { - jQuery.event.add( this, types, fn, data, selector ); - }); - }, - one: function( types, selector, data, fn ) { - return this.on( types, selector, data, fn, 1 ); - }, - off: function( types, selector, fn ) { - var handleObj, type; - if ( types && types.preventDefault && types.handleObj ) { - // ( event ) dispatched jQuery.Event - handleObj = types.handleObj; - jQuery( types.delegateTarget ).off( - handleObj.namespace ? handleObj.origType + "." + handleObj.namespace : handleObj.origType, - handleObj.selector, - handleObj.handler - ); - return this; - } - if ( typeof types === "object" ) { - // ( types-object [, selector] ) - for ( type in types ) { - this.off( type, selector, types[ type ] ); - } - return this; - } - if ( selector === false || typeof selector === "function" ) { - // ( types [, fn] ) - fn = selector; - selector = undefined; - } - if ( fn === false ) { - fn = returnFalse; - } - return this.each(function() { - jQuery.event.remove( this, types, fn, selector ); - }); - }, - - bind: function( types, data, fn ) { - return this.on( types, null, data, fn ); - }, - unbind: function( types, fn ) { - return this.off( types, null, fn ); - }, - - delegate: function( selector, types, data, fn ) { - return this.on( types, selector, data, fn ); - }, - undelegate: function( selector, types, fn ) { - // ( namespace ) or ( selector, types [, fn] ) - return arguments.length === 1 ? this.off( selector, "**" ) : this.off( types, selector || "**", fn ); - }, - - trigger: function( type, data ) { - return this.each(function() { - jQuery.event.trigger( type, data, this ); - }); - }, - triggerHandler: function( type, data ) { - var elem = this[0]; - if ( elem ) { - return jQuery.event.trigger( type, data, elem, true ); - } - } -}); +var Sizzle = /*! - * Sizzle CSS Selector Engine - * Copyright 2012 jQuery Foundation and other contributors - * Released under the MIT license + * Sizzle CSS Selector Engine v2.2.1 * http://sizzlejs.com/ + * + * Copyright jQuery Foundation and other contributors + * Released under the MIT license + * http://jquery.org/license + * + * Date: 2015-10-17 */ -(function( window, undefined ) { +(function( window ) { var i, - cachedruns, + support, Expr, getText, isXML, + tokenize, compile, - hasDuplicate, + select, outermostContext, + sortInput, + hasDuplicate, // Local document vars setDocument, document, docElem, - documentIsXML, + documentIsHTML, rbuggyQSA, rbuggyMatches, matches, contains, - sortOrder, // Instance-specific data - expando = "sizzle" + -(new Date()), + expando = "sizzle" + 1 * new Date(), preferredDoc = window.document, - support = {}, dirruns = 0, done = 0, classCache = createCache(), tokenCache = createCache(), compilerCache = createCache(), + sortOrder = function( a, b ) { + if ( a === b ) { + hasDuplicate = true; + } + return 0; + }, // General-purpose constants - strundefined = typeof undefined, MAX_NEGATIVE = 1 << 31, - // Array methods + // Instance methods + hasOwn = ({}).hasOwnProperty, arr = [], pop = arr.pop, + push_native = arr.push, push = arr.push, slice = arr.slice, - // Use a stripped-down indexOf if we can't use a native one - indexOf = arr.indexOf || function( elem ) { + // Use a stripped-down indexOf as it's faster than native + // http://jsperf.com/thor-indexof-vs-for/5 + indexOf = function( list, elem ) { var i = 0, - len = this.length; + len = list.length; for ( ; i < len; i++ ) { - if ( this[i] === elem ) { + if ( list[i] === elem ) { return i; } } return -1; }, + booleans = "checked|selected|async|autofocus|autoplay|controls|defer|disabled|hidden|ismap|loop|multiple|open|readonly|required|scoped", // Regular expressions - // Whitespace characters http://www.w3.org/TR/css3-selectors/#whitespace + // http://www.w3.org/TR/css3-selectors/#whitespace whitespace = "[\\x20\\t\\r\\n\\f]", - // http://www.w3.org/TR/css3-syntax/#characters - characterEncoding = "(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+", - // Loosely modeled on CSS identifier characters - // An unquoted value should be a CSS identifier http://www.w3.org/TR/css3-selectors/#attribute-selectors - // Proper syntax: http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier - identifier = characterEncoding.replace( "w", "w#" ), + // http://www.w3.org/TR/CSS21/syndata.html#value-def-identifier + identifier = "(?:\\\\.|[\\w-]|[^\\x00-\\xa0])+", - // Acceptable operators http://www.w3.org/TR/selectors/#attribute-selectors - operators = "([*^$|!~]?=)", - attributes = "\\[" + whitespace + "*(" + characterEncoding + ")" + whitespace + - "*(?:" + operators + whitespace + "*(?:(['\"])((?:\\\\.|[^\\\\])*?)\\3|(" + identifier + ")|)|)" + whitespace + "*\\]", + // Attribute selectors: http://www.w3.org/TR/selectors/#attribute-selectors + attributes = "\\[" + whitespace + "*(" + identifier + ")(?:" + whitespace + + // Operator (capture 2) + "*([*^$|!~]?=)" + whitespace + + // "Attribute values must be CSS identifiers [capture 5] or strings [capture 3 or capture 4]" + "*(?:'((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\"|(" + identifier + "))|)" + whitespace + + "*\\]", - // Prefer arguments quoted, - // then not containing pseudos/brackets, - // then attribute selectors/non-parenthetical expressions, - // then anything else - // These preferences are here to reduce the number of selectors - // needing tokenize in the PSEUDO preFilter - pseudos = ":(" + characterEncoding + ")(?:\\(((['\"])((?:\\\\.|[^\\\\])*?)\\3|((?:\\\\.|[^\\\\()[\\]]|" + attributes.replace( 3, 8 ) + ")*)|.*)\\)|)", + pseudos = ":(" + identifier + ")(?:\\((" + + // To reduce the number of selectors needing tokenize in the preFilter, prefer arguments: + // 1. quoted (capture 3; capture 4 or capture 5) + "('((?:\\\\.|[^\\\\'])*)'|\"((?:\\\\.|[^\\\\\"])*)\")|" + + // 2. simple (capture 6) + "((?:\\\\.|[^\\\\()[\\]]|" + attributes + ")*)|" + + // 3. anything else (capture 2) + ".*" + + ")\\)|)", // Leading and non-escaped trailing whitespace, capturing some non-whitespace characters preceding the latter + rwhitespace = new RegExp( whitespace + "+", "g" ), rtrim = new RegExp( "^" + whitespace + "+|((?:^|[^\\\\])(?:\\\\.)*)" + whitespace + "+$", "g" ), rcomma = new RegExp( "^" + whitespace + "*," + whitespace + "*" ), - rcombinators = new RegExp( "^" + whitespace + "*([\\x20\\t\\r\\n\\f>+~])" + whitespace + "*" ), + rcombinators = new RegExp( "^" + whitespace + "*([>+~]|" + whitespace + ")" + whitespace + "*" ), + + rattributeQuotes = new RegExp( "=" + whitespace + "*([^\\]'\"]*?)" + whitespace + "*\\]", "g" ), + rpseudo = new RegExp( pseudos ), ridentifier = new RegExp( "^" + identifier + "$" ), matchExpr = { - "ID": new RegExp( "^#(" + characterEncoding + ")" ), - "CLASS": new RegExp( "^\\.(" + characterEncoding + ")" ), - "NAME": new RegExp( "^\\[name=['\"]?(" + characterEncoding + ")['\"]?\\]" ), - "TAG": new RegExp( "^(" + characterEncoding.replace( "w", "w*" ) + ")" ), + "ID": new RegExp( "^#(" + identifier + ")" ), + "CLASS": new RegExp( "^\\.(" + identifier + ")" ), + "TAG": new RegExp( "^(" + identifier + "|[*])" ), "ATTR": new RegExp( "^" + attributes ), "PSEUDO": new RegExp( "^" + pseudos ), "CHILD": new RegExp( "^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\(" + whitespace + "*(even|odd|(([+-]|)(\\d*)n|)" + whitespace + "*(?:([+-]|)" + whitespace + "*(\\d+)|))" + whitespace + "*\\)|)", "i" ), + "bool": new RegExp( "^(?:" + booleans + ")$", "i" ), // For use in libraries implementing .is() // We use this for POS matching in `select` "needsContext": new RegExp( "^" + whitespace + "*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\(" + whitespace + "*((?:-\\d)?\\d*)" + whitespace + "*\\)|)(?=[^-]|$)", "i" ) }, - rsibling = /[\x20\t\r\n\f]*[+~]/, + rinputs = /^(?:input|select|textarea|button)$/i, + rheader = /^h\d$/i, - rnative = /^[^{]+\{\s*\[native code/, + rnative = /^[^{]+\{\s*\[native \w/, // Easily-parseable/retrievable ID or TAG or CLASS selectors rquickExpr = /^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/, - rinputs = /^(?:input|select|textarea|button)$/i, - rheader = /^h\d$/i, - + rsibling = /[+~]/, rescape = /'|\\/g, - rattributeQuotes = /\=[\x20\t\r\n\f]*([^'"\]]*)[\x20\t\r\n\f]*\]/g, // CSS escapes http://www.w3.org/TR/CSS21/syndata.html#escaped-characters - runescape = /\\([\da-fA-F]{1,6}[\x20\t\r\n\f]?|.)/g, - funescape = function( _, escaped ) { + runescape = new RegExp( "\\\\([\\da-f]{1,6}" + whitespace + "?|(" + whitespace + ")|.)", "ig" ), + funescape = function( _, escaped, escapedWhitespace ) { var high = "0x" + escaped - 0x10000; // NaN means non-codepoint - return high !== high ? + // Support: Firefox<24 + // Workaround erroneous numeric interpretation of +"0x" + return high !== high || escapedWhitespace ? escaped : - // BMP codepoint high < 0 ? + // BMP codepoint String.fromCharCode( high + 0x10000 ) : // Supplemental Plane codepoint (surrogate pair) String.fromCharCode( high >> 10 | 0xD800, high & 0x3FF | 0xDC00 ); + }, + + // Used for iframes + // See setDocument() + // Removing the function wrapper causes a "Permission Denied" + // error in IE + unloadHandler = function() { + setDocument(); }; -// Use a stripped-down slice if we can't use a native one +// Optimize for push.apply( _, NodeList ) try { - slice.call( preferredDoc.documentElement.childNodes, 0 )[0].nodeType; + push.apply( + (arr = slice.call( preferredDoc.childNodes )), + preferredDoc.childNodes + ); + // Support: Android<4.0 + // Detect silently failing push.apply + arr[ preferredDoc.childNodes.length ].nodeType; } catch ( e ) { - slice = function( i ) { - var elem, - results = []; - while ( (elem = this[i++]) ) { - results.push( elem ); + push = { apply: arr.length ? + + // Leverage slice if possible + function( target, els ) { + push_native.apply( target, slice.call(els) ); + } : + + // Support: IE<9 + // Otherwise append directly + function( target, els ) { + var j = target.length, + i = 0; + // Can't trust NodeList.length + while ( (target[j++] = els[i++]) ) {} + target.length = j - 1; } - return results; }; } -/** - * For feature detection - * @param {Function} fn The function to test for native support - */ -function isNative( fn ) { - return rnative.test( fn + "" ); +function Sizzle( selector, context, results, seed ) { + var m, i, elem, nid, nidselect, match, groups, newSelector, + newContext = context && context.ownerDocument, + + // nodeType defaults to 9, since context defaults to document + nodeType = context ? context.nodeType : 9; + + results = results || []; + + // Return early from calls with invalid selector or context + if ( typeof selector !== "string" || !selector || + nodeType !== 1 && nodeType !== 9 && nodeType !== 11 ) { + + return results; + } + + // Try to shortcut find operations (as opposed to filters) in HTML documents + if ( !seed ) { + + if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) { + setDocument( context ); + } + context = context || document; + + if ( documentIsHTML ) { + + // If the selector is sufficiently simple, try using a "get*By*" DOM method + // (excepting DocumentFragment context, where the methods don't exist) + if ( nodeType !== 11 && (match = rquickExpr.exec( selector )) ) { + + // ID selector + if ( (m = match[1]) ) { + + // Document context + if ( nodeType === 9 ) { + if ( (elem = context.getElementById( m )) ) { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( elem.id === m ) { + results.push( elem ); + return results; + } + } else { + return results; + } + + // Element context + } else { + + // Support: IE, Opera, Webkit + // TODO: identify versions + // getElementById can match elements by name instead of ID + if ( newContext && (elem = newContext.getElementById( m )) && + contains( context, elem ) && + elem.id === m ) { + + results.push( elem ); + return results; + } + } + + // Type selector + } else if ( match[2] ) { + push.apply( results, context.getElementsByTagName( selector ) ); + return results; + + // Class selector + } else if ( (m = match[3]) && support.getElementsByClassName && + context.getElementsByClassName ) { + + push.apply( results, context.getElementsByClassName( m ) ); + return results; + } + } + + // Take advantage of querySelectorAll + if ( support.qsa && + !compilerCache[ selector + " " ] && + (!rbuggyQSA || !rbuggyQSA.test( selector )) ) { + + if ( nodeType !== 1 ) { + newContext = context; + newSelector = selector; + + // qSA looks outside Element context, which is not what we want + // Thanks to Andrew Dupont for this workaround technique + // Support: IE <=8 + // Exclude object elements + } else if ( context.nodeName.toLowerCase() !== "object" ) { + + // Capture the context ID, setting it first if necessary + if ( (nid = context.getAttribute( "id" )) ) { + nid = nid.replace( rescape, "\\$&" ); + } else { + context.setAttribute( "id", (nid = expando) ); + } + + // Prefix every selector in the list + groups = tokenize( selector ); + i = groups.length; + nidselect = ridentifier.test( nid ) ? "#" + nid : "[id='" + nid + "']"; + while ( i-- ) { + groups[i] = nidselect + " " + toSelector( groups[i] ); + } + newSelector = groups.join( "," ); + + // Expand context for sibling selectors + newContext = rsibling.test( selector ) && testContext( context.parentNode ) || + context; + } + + if ( newSelector ) { + try { + push.apply( results, + newContext.querySelectorAll( newSelector ) + ); + return results; + } catch ( qsaError ) { + } finally { + if ( nid === expando ) { + context.removeAttribute( "id" ); + } + } + } + } + } + } + + // All others + return select( selector.replace( rtrim, "$1" ), context, results, seed ); } /** * Create key-value caches of limited size - * @returns {Function(string, Object)} Returns the Object data after storing it on itself with + * @returns {function(string, object)} Returns the Object data after storing it on itself with * property name the (space-suffixed) string and (if the cache is larger than Expr.cacheLength) * deleting the oldest entry */ function createCache() { - var cache, - keys = []; + var keys = []; - return (cache = function( key, value ) { + function cache( key, value ) { // Use (key + " ") to avoid collision with native prototype properties (see Issue #157) - if ( keys.push( key += " " ) > Expr.cacheLength ) { + if ( keys.push( key + " " ) > Expr.cacheLength ) { // Only keep the most recent entries delete cache[ keys.shift() ]; } - return (cache[ key ] = value); - }); + return (cache[ key + " " ] = value); + } + return cache; } /** @@ -3879,128 +940,122 @@ function assert( fn ) { var div = document.createElement("div"); try { - return fn( div ); + return !!fn( div ); } catch (e) { return false; } finally { + // Remove from its parent by default + if ( div.parentNode ) { + div.parentNode.removeChild( div ); + } // release memory in IE div = null; } } -function Sizzle( selector, context, results, seed ) { - var match, elem, m, nodeType, - // QSA vars - i, groups, old, nid, newContext, newSelector; +/** + * Adds the same handler for all of the specified attrs + * @param {String} attrs Pipe-separated list of attributes + * @param {Function} handler The method that will be applied + */ +function addHandle( attrs, handler ) { + var arr = attrs.split("|"), + i = arr.length; - if ( ( context ? context.ownerDocument || context : preferredDoc ) !== document ) { - setDocument( context ); + while ( i-- ) { + Expr.attrHandle[ arr[i] ] = handler; } - - context = context || document; - results = results || []; - - if ( !selector || typeof selector !== "string" ) { - return results; - } - - if ( (nodeType = context.nodeType) !== 1 && nodeType !== 9 ) { - return []; - } - - if ( !documentIsXML && !seed ) { - - // Shortcuts - if ( (match = rquickExpr.exec( selector )) ) { - // Speed-up: Sizzle("#ID") - if ( (m = match[1]) ) { - if ( nodeType === 9 ) { - elem = context.getElementById( m ); - // Check parentNode to catch when Blackberry 4.6 returns - // nodes that are no longer in the document #6963 - if ( elem && elem.parentNode ) { - // Handle the case where IE, Opera, and Webkit return items - // by name instead of ID - if ( elem.id === m ) { - results.push( elem ); - return results; - } - } else { - return results; - } - } else { - // Context is not a document - if ( context.ownerDocument && (elem = context.ownerDocument.getElementById( m )) && - contains( context, elem ) && elem.id === m ) { - results.push( elem ); - return results; - } - } - - // Speed-up: Sizzle("TAG") - } else if ( match[2] ) { - push.apply( results, slice.call(context.getElementsByTagName( selector ), 0) ); - return results; - - // Speed-up: Sizzle(".CLASS") - } else if ( (m = match[3]) && support.getByClassName && context.getElementsByClassName ) { - push.apply( results, slice.call(context.getElementsByClassName( m ), 0) ); - return results; - } - } - - // QSA path - if ( support.qsa && !rbuggyQSA.test(selector) ) { - old = true; - nid = expando; - newContext = context; - newSelector = nodeType === 9 && selector; - - // qSA works strangely on Element-rooted queries - // We can work around this by specifying an extra ID on the root - // and working up from there (Thanks to Andrew Dupont for the technique) - // IE 8 doesn't work on object elements - if ( nodeType === 1 && context.nodeName.toLowerCase() !== "object" ) { - groups = tokenize( selector ); - - if ( (old = context.getAttribute("id")) ) { - nid = old.replace( rescape, "\\$&" ); - } else { - context.setAttribute( "id", nid ); - } - nid = "[id='" + nid + "'] "; - - i = groups.length; - while ( i-- ) { - groups[i] = nid + toSelector( groups[i] ); - } - newContext = rsibling.test( selector ) && context.parentNode || context; - newSelector = groups.join(","); - } - - if ( newSelector ) { - try { - push.apply( results, slice.call( newContext.querySelectorAll( - newSelector - ), 0 ) ); - return results; - } catch(qsaError) { - } finally { - if ( !old ) { - context.removeAttribute("id"); - } - } - } - } - } - - // All others - return select( selector.replace( rtrim, "$1" ), context, results, seed ); } /** - * Detect xml + * Checks document order of two siblings + * @param {Element} a + * @param {Element} b + * @returns {Number} Returns less than 0 if a precedes b, greater than 0 if a follows b + */ +function siblingCheck( a, b ) { + var cur = b && a, + diff = cur && a.nodeType === 1 && b.nodeType === 1 && + ( ~b.sourceIndex || MAX_NEGATIVE ) - + ( ~a.sourceIndex || MAX_NEGATIVE ); + + // Use IE sourceIndex if available on both nodes + if ( diff ) { + return diff; + } + + // Check if b follows a + if ( cur ) { + while ( (cur = cur.nextSibling) ) { + if ( cur === b ) { + return -1; + } + } + } + + return a ? 1 : -1; +} + +/** + * Returns a function to use in pseudos for input types + * @param {String} type + */ +function createInputPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return name === "input" && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for buttons + * @param {String} type + */ +function createButtonPseudo( type ) { + return function( elem ) { + var name = elem.nodeName.toLowerCase(); + return (name === "input" || name === "button") && elem.type === type; + }; +} + +/** + * Returns a function to use in pseudos for positionals + * @param {Function} fn + */ +function createPositionalPseudo( fn ) { + return markFunction(function( argument ) { + argument = +argument; + return markFunction(function( seed, matches ) { + var j, + matchIndexes = fn( [], seed.length, argument ), + i = matchIndexes.length; + + // Match elements found at the specified indexes + while ( i-- ) { + if ( seed[ (j = matchIndexes[i]) ] ) { + seed[j] = !(matches[j] = seed[j]); + } + } + }); + }); +} + +/** + * Checks a node for validity as a Sizzle context + * @param {Element|Object=} context + * @returns {Element|Object|Boolean} The input node if acceptable, otherwise a falsy value + */ +function testContext( context ) { + return context && typeof context.getElementsByTagName !== "undefined" && context; +} + +// Expose support vars for convenience +support = Sizzle.support = {}; + +/** + * Detects XML nodes * @param {Element|Object} elem An element or a document + * @returns {Boolean} True iff elem is a non-HTML XML node */ isXML = Sizzle.isXML = function( elem ) { // documentElement is verified for cases where it doesn't yet exist @@ -4015,93 +1070,70 @@ isXML = Sizzle.isXML = function( elem ) { * @returns {Object} Returns the current document */ setDocument = Sizzle.setDocument = function( node ) { - var doc = node ? node.ownerDocument || node : preferredDoc; + var hasCompare, parent, + doc = node ? node.ownerDocument || node : preferredDoc; - // If no document and documentElement is available, return + // Return early if doc is invalid or already selected if ( doc === document || doc.nodeType !== 9 || !doc.documentElement ) { return document; } - // Set our document + // Update global variables document = doc; - docElem = doc.documentElement; + docElem = document.documentElement; + documentIsHTML = !isXML( document ); - // Support tests - documentIsXML = isXML( doc ); + // Support: IE 9-11, Edge + // Accessing iframe documents after unload throws "permission denied" errors (jQuery #13936) + if ( (parent = document.defaultView) && parent.top !== parent ) { + // Support: IE 11 + if ( parent.addEventListener ) { + parent.addEventListener( "unload", unloadHandler, false ); + + // Support: IE 9 - 10 only + } else if ( parent.attachEvent ) { + parent.attachEvent( "onunload", unloadHandler ); + } + } + + /* Attributes + ---------------------------------------------------------------------- */ + + // Support: IE<8 + // Verify that getAttribute really returns attributes and not properties + // (excepting IE8 booleans) + support.attributes = assert(function( div ) { + div.className = "i"; + return !div.getAttribute("className"); + }); + + /* getElement(s)By* + ---------------------------------------------------------------------- */ // Check if getElementsByTagName("*") returns only elements - support.tagNameNoComments = assert(function( div ) { - div.appendChild( doc.createComment("") ); + support.getElementsByTagName = assert(function( div ) { + div.appendChild( document.createComment("") ); return !div.getElementsByTagName("*").length; }); - // Check if attributes should be retrieved by attribute nodes - support.attributes = assert(function( div ) { - div.innerHTML = ""; - var type = typeof div.lastChild.getAttribute("multiple"); - // IE8 returns a string for some attributes even when not present - return type !== "boolean" && type !== "string"; - }); - - // Check if getElementsByClassName can be trusted - support.getByClassName = assert(function( div ) { - // Opera can't find a second classname (in 9.6) - div.innerHTML = ""; - if ( !div.getElementsByClassName || !div.getElementsByClassName("e").length ) { - return false; - } - - // Safari 3.2 caches class attributes and doesn't catch changes - div.lastChild.className = "e"; - return div.getElementsByClassName("e").length === 2; - }); + // Support: IE<9 + support.getElementsByClassName = rnative.test( document.getElementsByClassName ); + // Support: IE<10 // Check if getElementById returns elements by name - // Check if getElementsByName privileges form controls or returns elements by ID - support.getByName = assert(function( div ) { - // Inject content - div.id = expando + 0; - div.innerHTML = "
"; - docElem.insertBefore( div, docElem.firstChild ); - - // Test - var pass = doc.getElementsByName && - // buggy browsers will return fewer than the correct 2 - doc.getElementsByName( expando ).length === 2 + - // buggy browsers will return more than the correct 0 - doc.getElementsByName( expando + 0 ).length; - support.getIdNotName = !doc.getElementById( expando ); - - // Cleanup - docElem.removeChild( div ); - - return pass; + // The broken getElementById methods don't pick up programatically-set names, + // so use a roundabout getElementsByName test + support.getById = assert(function( div ) { + docElem.appendChild( div ).id = expando; + return !document.getElementsByName || !document.getElementsByName( expando ).length; }); - // IE6/7 return modified attributes - Expr.attrHandle = assert(function( div ) { - div.innerHTML = ""; - return div.firstChild && typeof div.firstChild.getAttribute !== strundefined && - div.firstChild.getAttribute("href") === "#"; - }) ? - {} : - { - "href": function( elem ) { - return elem.getAttribute( "href", 2 ); - }, - "type": function( elem ) { - return elem.getAttribute("type"); - } - }; - // ID find and filter - if ( support.getIdNotName ) { + if ( support.getById ) { Expr.find["ID"] = function( id, context ) { - if ( typeof context.getElementById !== strundefined && !documentIsXML ) { + if ( typeof context.getElementById !== "undefined" && documentIsHTML ) { var m = context.getElementById( id ); - // Check parentNode to catch when Blackberry 4.6 returns - // nodes that are no longer in the document #6963 - return m && m.parentNode ? [m] : []; + return m ? [ m ] : []; } }; Expr.filter["ID"] = function( id ) { @@ -4111,37 +1143,37 @@ setDocument = Sizzle.setDocument = function( node ) { }; }; } else { - Expr.find["ID"] = function( id, context ) { - if ( typeof context.getElementById !== strundefined && !documentIsXML ) { - var m = context.getElementById( id ); + // Support: IE6/7 + // getElementById is not reliable as a find shortcut + delete Expr.find["ID"]; - return m ? - m.id === id || typeof m.getAttributeNode !== strundefined && m.getAttributeNode("id").value === id ? - [m] : - undefined : - []; - } - }; Expr.filter["ID"] = function( id ) { var attrId = id.replace( runescape, funescape ); return function( elem ) { - var node = typeof elem.getAttributeNode !== strundefined && elem.getAttributeNode("id"); + var node = typeof elem.getAttributeNode !== "undefined" && + elem.getAttributeNode("id"); return node && node.value === attrId; }; }; } // Tag - Expr.find["TAG"] = support.tagNameNoComments ? + Expr.find["TAG"] = support.getElementsByTagName ? function( tag, context ) { - if ( typeof context.getElementsByTagName !== strundefined ) { + if ( typeof context.getElementsByTagName !== "undefined" ) { return context.getElementsByTagName( tag ); + + // DocumentFragment nodes don't have gEBTN + } else if ( support.qsa ) { + return context.querySelectorAll( tag ); } } : + function( tag, context ) { var elem, tmp = [], i = 0, + // By happy coincidence, a (broken) gEBTN appears on DocumentFragment nodes too results = context.getElementsByTagName( tag ); // Filter out possible comments @@ -4157,44 +1189,58 @@ setDocument = Sizzle.setDocument = function( node ) { return results; }; - // Name - Expr.find["NAME"] = support.getByName && function( tag, context ) { - if ( typeof context.getElementsByName !== strundefined ) { - return context.getElementsByName( name ); - } - }; - // Class - Expr.find["CLASS"] = support.getByClassName && function( className, context ) { - if ( typeof context.getElementsByClassName !== strundefined && !documentIsXML ) { + Expr.find["CLASS"] = support.getElementsByClassName && function( className, context ) { + if ( typeof context.getElementsByClassName !== "undefined" && documentIsHTML ) { return context.getElementsByClassName( className ); } }; + /* QSA/matchesSelector + ---------------------------------------------------------------------- */ + // QSA and matchesSelector support // matchesSelector(:active) reports false when true (IE9/Opera 11.5) rbuggyMatches = []; - // qSa(:focus) reports false when true (Chrome 21), - // no need to also add to buggyMatches since matches checks buggyQSA - // A support test would require too much code (would include document ready) - rbuggyQSA = [ ":focus" ]; + // qSa(:focus) reports false when true (Chrome 21) + // We allow this because of a bug in IE8/9 that throws an error + // whenever `document.activeElement` is accessed on an iframe + // So, we allow :focus to pass through QSA all the time to avoid the IE error + // See http://bugs.jquery.com/ticket/13378 + rbuggyQSA = []; - if ( (support.qsa = isNative(doc.querySelectorAll)) ) { + if ( (support.qsa = rnative.test( document.querySelectorAll )) ) { // Build QSA regex // Regex strategy adopted from Diego Perini assert(function( div ) { // Select is set to empty string on purpose - // This is to test IE's treatment of not explictly + // This is to test IE's treatment of not explicitly // setting a boolean content attribute, // since its presence should be enough // http://bugs.jquery.com/ticket/12359 - div.innerHTML = ""; + docElem.appendChild( div ).innerHTML = "" + + ""; - // IE8 - Some boolean attributes are not treated correctly + // Support: IE8, Opera 11-12.16 + // Nothing should be selected when empty strings follow ^= or $= or *= + // The test attribute must be unknown in Opera but "safe" for WinRT + // http://msdn.microsoft.com/en-us/library/ie/hh465388.aspx#attribute_section + if ( div.querySelectorAll("[msallowcapture^='']").length ) { + rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:''|\"\")" ); + } + + // Support: IE8 + // Boolean attributes and "value" are not treated correctly if ( !div.querySelectorAll("[selected]").length ) { - rbuggyQSA.push( "\\[" + whitespace + "*(?:checked|disabled|ismap|multiple|readonly|selected|value)" ); + rbuggyQSA.push( "\\[" + whitespace + "*(?:value|" + booleans + ")" ); + } + + // Support: Chrome<29, Android<4.4, Safari<7.0+, iOS<7.0+, PhantomJS<1.9.8+ + if ( !div.querySelectorAll( "[id~=" + expando + "-]" ).length ) { + rbuggyQSA.push("~="); } // Webkit/Opera - :checked should return selected option elements @@ -4203,15 +1249,26 @@ setDocument = Sizzle.setDocument = function( node ) { if ( !div.querySelectorAll(":checked").length ) { rbuggyQSA.push(":checked"); } + + // Support: Safari 8+, iOS 8+ + // https://bugs.webkit.org/show_bug.cgi?id=136851 + // In-page `selector#id sibing-combinator selector` fails + if ( !div.querySelectorAll( "a#" + expando + "+*" ).length ) { + rbuggyQSA.push(".#.+[+~]"); + } }); assert(function( div ) { + // Support: Windows 8 Native Apps + // The type and name attributes are restricted during .innerHTML assignment + var input = document.createElement("input"); + input.setAttribute( "type", "hidden" ); + div.appendChild( input ).setAttribute( "name", "D" ); - // Opera 10-12/IE8 - ^= $= *= and empty values - // Should not select anything - div.innerHTML = ""; - if ( div.querySelectorAll("[i^='']").length ) { - rbuggyQSA.push( "[*^$]=" + whitespace + "*(?:\"\"|'')" ); + // Support: IE8 + // Enforce case-sensitivity of name attribute + if ( div.querySelectorAll("[name=d]").length ) { + rbuggyQSA.push( "name" + whitespace + "*[*^$|!~]?=" ); } // FF 3.5 - :enabled/:disabled and hidden elements (hidden elements are still enabled) @@ -4226,9 +1283,9 @@ setDocument = Sizzle.setDocument = function( node ) { }); } - if ( (support.matchesSelector = isNative( (matches = docElem.matchesSelector || - docElem.mozMatchesSelector || + if ( (support.matchesSelector = rnative.test( (matches = docElem.matches || docElem.webkitMatchesSelector || + docElem.mozMatchesSelector || docElem.oMatchesSelector || docElem.msMatchesSelector) )) ) { @@ -4244,13 +1301,17 @@ setDocument = Sizzle.setDocument = function( node ) { }); } - rbuggyQSA = new RegExp( rbuggyQSA.join("|") ); - rbuggyMatches = new RegExp( rbuggyMatches.join("|") ); + rbuggyQSA = rbuggyQSA.length && new RegExp( rbuggyQSA.join("|") ); + rbuggyMatches = rbuggyMatches.length && new RegExp( rbuggyMatches.join("|") ); + + /* Contains + ---------------------------------------------------------------------- */ + hasCompare = rnative.test( docElem.compareDocumentPosition ); // Element contains another - // Purposefully does not implement inclusive descendent + // Purposefully self-exclusive // As in, an element does not contain itself - contains = isNative(docElem.contains) || docElem.compareDocumentPosition ? + contains = hasCompare || rnative.test( docElem.contains ) ? function( a, b ) { var adown = a.nodeType === 9 ? a.documentElement : a, bup = b && b.parentNode; @@ -4271,32 +1332,59 @@ setDocument = Sizzle.setDocument = function( node ) { return false; }; - // Document order sorting - sortOrder = docElem.compareDocumentPosition ? - function( a, b ) { - var compare; + /* Sorting + ---------------------------------------------------------------------- */ + // Document order sorting + sortOrder = hasCompare ? + function( a, b ) { + + // Flag for duplicate removal if ( a === b ) { hasDuplicate = true; return 0; } - if ( (compare = b.compareDocumentPosition && a.compareDocumentPosition && a.compareDocumentPosition( b )) ) { - if ( compare & 1 || a.parentNode && a.parentNode.nodeType === 11 ) { - if ( a === doc || contains( preferredDoc, a ) ) { - return -1; - } - if ( b === doc || contains( preferredDoc, b ) ) { - return 1; - } - return 0; - } - return compare & 4 ? -1 : 1; + // Sort on method existence if only one input has compareDocumentPosition + var compare = !a.compareDocumentPosition - !b.compareDocumentPosition; + if ( compare ) { + return compare; } - return a.compareDocumentPosition ? -1 : 1; + // Calculate position if both inputs belong to the same document + compare = ( a.ownerDocument || a ) === ( b.ownerDocument || b ) ? + a.compareDocumentPosition( b ) : + + // Otherwise we know they are disconnected + 1; + + // Disconnected nodes + if ( compare & 1 || + (!support.sortDetached && b.compareDocumentPosition( a ) === compare) ) { + + // Choose the first element that is related to our preferred document + if ( a === document || a.ownerDocument === preferredDoc && contains(preferredDoc, a) ) { + return -1; + } + if ( b === document || b.ownerDocument === preferredDoc && contains(preferredDoc, b) ) { + return 1; + } + + // Maintain original order + return sortInput ? + ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) : + 0; + } + + return compare & 4 ? -1 : 1; } : function( a, b ) { + // Exit early if the nodes are identical + if ( a === b ) { + hasDuplicate = true; + return 0; + } + var cur, i = 0, aup = a.parentNode, @@ -4304,17 +1392,14 @@ setDocument = Sizzle.setDocument = function( node ) { ap = [ a ], bp = [ b ]; - // Exit early if the nodes are identical - if ( a === b ) { - hasDuplicate = true; - return 0; - // Parentless nodes are either documents or disconnected - } else if ( !aup || !bup ) { - return a === doc ? -1 : - b === doc ? 1 : + if ( !aup || !bup ) { + return a === document ? -1 : + b === document ? 1 : aup ? -1 : bup ? 1 : + sortInput ? + ( indexOf( sortInput, a ) - indexOf( sortInput, b ) ) : 0; // If the nodes are siblings, we can do a quick check @@ -4347,12 +1432,6 @@ setDocument = Sizzle.setDocument = function( node ) { 0; }; - // Always assume the presence of duplicates if sort doesn't - // pass them to our comparison function (as in Google Chrome). - hasDuplicate = false; - [0, 0].sort( sortOrder ); - support.detectDuplicates = hasDuplicate; - return document; }; @@ -4369,8 +1448,11 @@ Sizzle.matchesSelector = function( elem, expr ) { // Make sure that attribute selectors are quoted expr = expr.replace( rattributeQuotes, "='$1']" ); - // rbuggyQSA always contains :focus, so no need for an existence check - if ( support.matchesSelector && !documentIsXML && (!rbuggyMatches || !rbuggyMatches.test(expr)) && !rbuggyQSA.test(expr) ) { + if ( support.matchesSelector && documentIsHTML && + !compilerCache[ expr + " " ] && + ( !rbuggyMatches || !rbuggyMatches.test( expr ) ) && + ( !rbuggyQSA || !rbuggyQSA.test( expr ) ) ) { + try { var ret = matches.call( elem, expr ); @@ -4381,10 +1463,10 @@ Sizzle.matchesSelector = function( elem, expr ) { elem.document && elem.document.nodeType !== 11 ) { return ret; } - } catch(e) {} + } catch (e) {} } - return Sizzle( expr, document, null, [elem] ).length > 0; + return Sizzle( expr, document, null, [ elem ] ).length > 0; }; Sizzle.contains = function( context, elem ) { @@ -4396,45 +1478,48 @@ Sizzle.contains = function( context, elem ) { }; Sizzle.attr = function( elem, name ) { - var val; - // Set document vars if needed if ( ( elem.ownerDocument || elem ) !== document ) { setDocument( elem ); } - if ( !documentIsXML ) { - name = name.toLowerCase(); - } - if ( (val = Expr.attrHandle[ name ]) ) { - return val( elem ); - } - if ( documentIsXML || support.attributes ) { - return elem.getAttribute( name ); - } - return ( (val = elem.getAttributeNode( name )) || elem.getAttribute( name ) ) && elem[ name ] === true ? - name : - val && val.specified ? val.value : null; + var fn = Expr.attrHandle[ name.toLowerCase() ], + // Don't get fooled by Object.prototype properties (jQuery #13807) + val = fn && hasOwn.call( Expr.attrHandle, name.toLowerCase() ) ? + fn( elem, name, !documentIsHTML ) : + undefined; + + return val !== undefined ? + val : + support.attributes || !documentIsHTML ? + elem.getAttribute( name ) : + (val = elem.getAttributeNode(name)) && val.specified ? + val.value : + null; }; Sizzle.error = function( msg ) { throw new Error( "Syntax error, unrecognized expression: " + msg ); }; -// Document sorting and removing duplicates +/** + * Document sorting and removing duplicates + * @param {ArrayLike} results + */ Sizzle.uniqueSort = function( results ) { var elem, duplicates = [], - i = 1, - j = 0; + j = 0, + i = 0; // Unless we *know* we can detect duplicates, assume their presence hasDuplicate = !support.detectDuplicates; + sortInput = !support.sortStable && results.slice( 0 ); results.sort( sortOrder ); if ( hasDuplicate ) { - for ( ; (elem = results[i]); i++ ) { - if ( elem === results[ i - 1 ] ) { + while ( (elem = results[i++]) ) { + if ( elem === results[ i ] ) { j = duplicates.push( i ); } } @@ -4443,65 +1528,13 @@ Sizzle.uniqueSort = function( results ) { } } + // Clear input after sorting to release objects + // See https://github.com/jquery/sizzle/pull/225 + sortInput = null; + return results; }; -function siblingCheck( a, b ) { - var cur = b && a, - diff = cur && ( ~b.sourceIndex || MAX_NEGATIVE ) - ( ~a.sourceIndex || MAX_NEGATIVE ); - - // Use IE sourceIndex if available on both nodes - if ( diff ) { - return diff; - } - - // Check if b follows a - if ( cur ) { - while ( (cur = cur.nextSibling) ) { - if ( cur === b ) { - return -1; - } - } - } - - return a ? 1 : -1; -} - -// Returns a function to use in pseudos for input types -function createInputPseudo( type ) { - return function( elem ) { - var name = elem.nodeName.toLowerCase(); - return name === "input" && elem.type === type; - }; -} - -// Returns a function to use in pseudos for buttons -function createButtonPseudo( type ) { - return function( elem ) { - var name = elem.nodeName.toLowerCase(); - return (name === "input" || name === "button") && elem.type === type; - }; -} - -// Returns a function to use in pseudos for positionals -function createPositionalPseudo( fn ) { - return markFunction(function( argument ) { - argument = +argument; - return markFunction(function( seed, matches ) { - var j, - matchIndexes = fn( [], seed.length, argument ), - i = matchIndexes.length; - - // Match elements found at the specified indexes - while ( i-- ) { - if ( seed[ (j = matchIndexes[i]) ] ) { - seed[j] = !(matches[j] = seed[j]); - } - } - }); - }); -} - /** * Utility function for retrieving the text value of an array of DOM nodes * @param {Array|Element} elem @@ -4514,13 +1547,13 @@ getText = Sizzle.getText = function( elem ) { if ( !nodeType ) { // If no nodeType, this is expected to be an array - for ( ; (node = elem[i]); i++ ) { + while ( (node = elem[i++]) ) { // Do not traverse comment nodes ret += getText( node ); } } else if ( nodeType === 1 || nodeType === 9 || nodeType === 11 ) { // Use textContent for elements - // innerText usage removed for consistency of new lines (see #11153) + // innerText usage removed for consistency of new lines (jQuery #11153) if ( typeof elem.textContent === "string" ) { return elem.textContent; } else { @@ -4546,6 +1579,8 @@ Expr = Sizzle.selectors = { match: matchExpr, + attrHandle: {}, + find: {}, relative: { @@ -4560,7 +1595,7 @@ Expr = Sizzle.selectors = { match[1] = match[1].replace( runescape, funescape ); // Move the given value to match[3] whether quoted or unquoted - match[3] = ( match[4] || match[5] || "" ).replace( runescape, funescape ); + match[3] = ( match[3] || match[4] || match[5] || "" ).replace( runescape, funescape ); if ( match[2] === "~=" ) { match[3] = " " + match[3] + " "; @@ -4603,15 +1638,15 @@ Expr = Sizzle.selectors = { "PSEUDO": function( match ) { var excess, - unquoted = !match[5] && match[2]; + unquoted = !match[6] && match[2]; if ( matchExpr["CHILD"].test( match[0] ) ) { return null; } // Accept quoted arguments as-is - if ( match[4] ) { - match[2] = match[4]; + if ( match[3] ) { + match[2] = match[4] || match[5] || ""; // Strip excess characters from unquoted arguments } else if ( unquoted && rpseudo.test( unquoted ) && @@ -4632,15 +1667,13 @@ Expr = Sizzle.selectors = { filter: { - "TAG": function( nodeName ) { - if ( nodeName === "*" ) { - return function() { return true; }; - } - - nodeName = nodeName.replace( runescape, funescape ).toLowerCase(); - return function( elem ) { - return elem.nodeName && elem.nodeName.toLowerCase() === nodeName; - }; + "TAG": function( nodeNameSelector ) { + var nodeName = nodeNameSelector.replace( runescape, funescape ).toLowerCase(); + return nodeNameSelector === "*" ? + function() { return true; } : + function( elem ) { + return elem.nodeName && elem.nodeName.toLowerCase() === nodeName; + }; }, "CLASS": function( className ) { @@ -4649,7 +1682,7 @@ Expr = Sizzle.selectors = { return pattern || (pattern = new RegExp( "(^|" + whitespace + ")" + className + "(" + whitespace + "|$)" )) && classCache( className, function( elem ) { - return pattern.test( elem.className || (typeof elem.getAttribute !== strundefined && elem.getAttribute("class")) || "" ); + return pattern.test( typeof elem.className === "string" && elem.className || typeof elem.getAttribute !== "undefined" && elem.getAttribute("class") || "" ); }); }, @@ -4671,7 +1704,7 @@ Expr = Sizzle.selectors = { operator === "^=" ? check && result.indexOf( check ) === 0 : operator === "*=" ? check && result.indexOf( check ) > -1 : operator === "$=" ? check && result.slice( -check.length ) === check : - operator === "~=" ? ( " " + result + " " ).indexOf( check ) > -1 : + operator === "~=" ? ( " " + result.replace( rwhitespace, " " ) + " " ).indexOf( check ) > -1 : operator === "|=" ? result === check || result.slice( 0, check.length + 1 ) === check + "-" : false; }; @@ -4690,11 +1723,12 @@ Expr = Sizzle.selectors = { } : function( elem, context, xml ) { - var cache, outerCache, node, diff, nodeIndex, start, + var cache, uniqueCache, outerCache, node, nodeIndex, start, dir = simple !== forward ? "nextSibling" : "previousSibling", parent = elem.parentNode, name = ofType && elem.nodeName.toLowerCase(), - useCache = !xml && !ofType; + useCache = !xml && !ofType, + diff = false; if ( parent ) { @@ -4703,7 +1737,10 @@ Expr = Sizzle.selectors = { while ( dir ) { node = elem; while ( (node = node[ dir ]) ) { - if ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) { + if ( ofType ? + node.nodeName.toLowerCase() === name : + node.nodeType === 1 ) { + return false; } } @@ -4717,11 +1754,21 @@ Expr = Sizzle.selectors = { // non-xml :nth-child(...) stores cache data on `parent` if ( forward && useCache ) { + // Seek `elem` from a previously-cached index - outerCache = parent[ expando ] || (parent[ expando ] = {}); - cache = outerCache[ type ] || []; - nodeIndex = cache[0] === dirruns && cache[1]; - diff = cache[0] === dirruns && cache[2]; + + // ...in a gzip-friendly way + node = parent; + outerCache = node[ expando ] || (node[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); + + cache = uniqueCache[ type ] || []; + nodeIndex = cache[ 0 ] === dirruns && cache[ 1 ]; + diff = nodeIndex && cache[ 2 ]; node = nodeIndex && parent.childNodes[ nodeIndex ]; while ( (node = ++nodeIndex && node && node[ dir ] || @@ -4731,29 +1778,55 @@ Expr = Sizzle.selectors = { // When found, cache indexes on `parent` and break if ( node.nodeType === 1 && ++diff && node === elem ) { - outerCache[ type ] = [ dirruns, nodeIndex, diff ]; + uniqueCache[ type ] = [ dirruns, nodeIndex, diff ]; break; } } - // Use previously-cached element index if available - } else if ( useCache && (cache = (elem[ expando ] || (elem[ expando ] = {}))[ type ]) && cache[0] === dirruns ) { - diff = cache[1]; - - // xml :nth-child(...) or :nth-last-child(...) or :nth(-last)?-of-type(...) } else { - // Use the same loop as above to seek `elem` from the start - while ( (node = ++nodeIndex && node && node[ dir ] || - (diff = nodeIndex = 0) || start.pop()) ) { + // Use previously-cached element index if available + if ( useCache ) { + // ...in a gzip-friendly way + node = elem; + outerCache = node[ expando ] || (node[ expando ] = {}); - if ( ( ofType ? node.nodeName.toLowerCase() === name : node.nodeType === 1 ) && ++diff ) { - // Cache the index of each encountered element - if ( useCache ) { - (node[ expando ] || (node[ expando ] = {}))[ type ] = [ dirruns, diff ]; - } + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); - if ( node === elem ) { - break; + cache = uniqueCache[ type ] || []; + nodeIndex = cache[ 0 ] === dirruns && cache[ 1 ]; + diff = nodeIndex; + } + + // xml :nth-child(...) + // or :nth-last-child(...) or :nth(-last)?-of-type(...) + if ( diff === false ) { + // Use the same loop as above to seek `elem` from the start + while ( (node = ++nodeIndex && node && node[ dir ] || + (diff = nodeIndex = 0) || start.pop()) ) { + + if ( ( ofType ? + node.nodeName.toLowerCase() === name : + node.nodeType === 1 ) && + ++diff ) { + + // Cache the index of each encountered element + if ( useCache ) { + outerCache = node[ expando ] || (node[ expando ] = {}); + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ node.uniqueID ] || + (outerCache[ node.uniqueID ] = {}); + + uniqueCache[ type ] = [ dirruns, diff ]; + } + + if ( node === elem ) { + break; + } } } } @@ -4791,7 +1864,7 @@ Expr = Sizzle.selectors = { matched = fn( seed, argument ), i = matched.length; while ( i-- ) { - idx = indexOf.call( seed, matched[i] ); + idx = indexOf( seed, matched[i] ); seed[ idx ] = !( matches[ idx ] = matched[i] ); } }) : @@ -4830,6 +1903,8 @@ Expr = Sizzle.selectors = { function( elem, context, xml ) { input[0] = elem; matcher( input, null, xml, results ); + // Don't keep the element (issue #299) + input[0] = null; return !results.pop(); }; }), @@ -4841,6 +1916,7 @@ Expr = Sizzle.selectors = { }), "contains": markFunction(function( text ) { + text = text.replace( runescape, funescape ); return function( elem ) { return ( elem.textContent || elem.innerText || getText( elem ) ).indexOf( text ) > -1; }; @@ -4854,7 +1930,7 @@ Expr = Sizzle.selectors = { // The identifier C does not have to be a valid language name." // http://www.w3.org/TR/selectors/#lang-pseudo "lang": markFunction( function( lang ) { - // lang value must be a valid identifider + // lang value must be a valid identifier if ( !ridentifier.test(lang || "") ) { Sizzle.error( "unsupported lang: " + lang ); } @@ -4862,9 +1938,9 @@ Expr = Sizzle.selectors = { return function( elem ) { var elemLang; do { - if ( (elemLang = documentIsXML ? - elem.getAttribute("xml:lang") || elem.getAttribute("lang") : - elem.lang) ) { + if ( (elemLang = documentIsHTML ? + elem.lang : + elem.getAttribute("xml:lang") || elem.getAttribute("lang")) ) { elemLang = elemLang.toLowerCase(); return elemLang === lang || elemLang.indexOf( lang + "-" ) === 0; @@ -4917,12 +1993,11 @@ Expr = Sizzle.selectors = { // Contents "empty": function( elem ) { // http://www.w3.org/TR/selectors/#empty-pseudo - // :empty is only affected by element nodes and content nodes(including text(3), cdata(4)), - // not comment, processing instructions, or others - // Thanks to Diego Perini for the nodeName shortcut - // Greater than "@" means alpha characters (specifically not starting with "#" or "?") + // :empty is negated by element (1) or content nodes (text: 3; cdata: 4; entity ref: 5), + // but not by others (comment: 8; processing instruction: 7; etc.) + // nodeType < 6 works because attributes (2) do not appear as children for ( elem = elem.firstChild; elem; elem = elem.nextSibling ) { - if ( elem.nodeName > "@" || elem.nodeType === 3 || elem.nodeType === 4 ) { + if ( elem.nodeType < 6 ) { return false; } } @@ -4949,11 +2024,12 @@ Expr = Sizzle.selectors = { "text": function( elem ) { var attr; - // IE6 and 7 will map elem.type to 'text' for new HTML5 types (search, etc) - // use getAttribute instead to test this case return elem.nodeName.toLowerCase() === "input" && elem.type === "text" && - ( (attr = elem.getAttribute("type")) == null || attr.toLowerCase() === elem.type ); + + // Support: IE<8 + // New HTML5 attribute values (e.g., "search") appear with elem.type === "text" + ( (attr = elem.getAttribute("type")) == null || attr.toLowerCase() === "text" ); }, // Position-in-collection @@ -5003,6 +2079,8 @@ Expr = Sizzle.selectors = { } }; +Expr.pseudos["nth"] = Expr.pseudos["eq"]; + // Add button/input type pseudos for ( i in { radio: true, checkbox: true, file: true, password: true, image: true } ) { Expr.pseudos[ i ] = createInputPseudo( i ); @@ -5011,7 +2089,12 @@ for ( i in { submit: true, reset: true } ) { Expr.pseudos[ i ] = createButtonPseudo( i ); } -function tokenize( selector, parseOnly ) { +// Easy API for creating new setFilters +function setFilters() {} +setFilters.prototype = Expr.filters = Expr.pseudos; +Expr.setFilters = new setFilters(); + +tokenize = Sizzle.tokenize = function( selector, parseOnly ) { var matched, match, tokens, type, soFar, groups, preFilters, cached = tokenCache[ selector + " " ]; @@ -5032,7 +2115,7 @@ function tokenize( selector, parseOnly ) { // Don't consume trailing commas as valid soFar = soFar.slice( match[0].length ) || soFar; } - groups.push( tokens = [] ); + groups.push( (tokens = []) ); } matched = false; @@ -5040,11 +2123,11 @@ function tokenize( selector, parseOnly ) { // Combinators if ( (match = rcombinators.exec( soFar )) ) { matched = match.shift(); - tokens.push( { + tokens.push({ value: matched, // Cast descendant combinators to space type: match[0].replace( rtrim, " " ) - } ); + }); soFar = soFar.slice( matched.length ); } @@ -5053,11 +2136,11 @@ function tokenize( selector, parseOnly ) { if ( (match = matchExpr[ type ].exec( soFar )) && (!preFilters[ type ] || (match = preFilters[ type ]( match ))) ) { matched = match.shift(); - tokens.push( { + tokens.push({ value: matched, type: type, matches: match - } ); + }); soFar = soFar.slice( matched.length ); } } @@ -5076,7 +2159,7 @@ function tokenize( selector, parseOnly ) { Sizzle.error( selector ) : // Cache the tokens tokenCache( selector, groups ).slice( 0 ); -} +}; function toSelector( tokens ) { var i = 0, @@ -5105,10 +2188,10 @@ function addCombinator( matcher, combinator, base ) { // Check against all ancestor/preceding elements function( elem, context, xml ) { - var data, cache, outerCache, - dirkey = dirruns + " " + doneName; + var oldCache, uniqueCache, outerCache, + newCache = [ dirruns, doneName ]; - // We can't set arbitrary data on XML nodes, so they don't benefit from dir caching + // We can't set arbitrary data on XML nodes, so they don't benefit from combinator caching if ( xml ) { while ( (elem = elem[ dir ]) ) { if ( elem.nodeType === 1 || checkNonElements ) { @@ -5121,14 +2204,22 @@ function addCombinator( matcher, combinator, base ) { while ( (elem = elem[ dir ]) ) { if ( elem.nodeType === 1 || checkNonElements ) { outerCache = elem[ expando ] || (elem[ expando ] = {}); - if ( (cache = outerCache[ dir ]) && cache[0] === dirkey ) { - if ( (data = cache[1]) === true || data === cachedruns ) { - return data === true; - } + + // Support: IE <9 only + // Defend against cloned attroperties (jQuery gh-1709) + uniqueCache = outerCache[ elem.uniqueID ] || (outerCache[ elem.uniqueID ] = {}); + + if ( (oldCache = uniqueCache[ dir ]) && + oldCache[ 0 ] === dirruns && oldCache[ 1 ] === doneName ) { + + // Assign to newCache so results back-propagate to previous elements + return (newCache[ 2 ] = oldCache[ 2 ]); } else { - cache = outerCache[ dir ] = [ dirkey ]; - cache[1] = matcher( elem, context, xml ) || cachedruns; - if ( cache[1] === true ) { + // Reuse newcache so results back-propagate to previous elements + uniqueCache[ dir ] = newCache; + + // A match means we're done; a fail means we have to keep checking + if ( (newCache[ 2 ] = matcher( elem, context, xml )) ) { return true; } } @@ -5152,6 +2243,15 @@ function elementMatcher( matchers ) { matchers[0]; } +function multipleContexts( selector, contexts, results ) { + var i = 0, + len = contexts.length; + for ( ; i < len; i++ ) { + Sizzle( selector, contexts[i], results ); + } + return results; +} + function condense( unmatched, map, filter, context, xml ) { var elem, newUnmatched = [], @@ -5243,7 +2343,7 @@ function setMatcher( preFilter, selector, matcher, postFilter, postFinder, postS i = matcherOut.length; while ( i-- ) { if ( (elem = matcherOut[i]) && - (temp = postFinder ? indexOf.call( seed, elem ) : preMap[i]) > -1 ) { + (temp = postFinder ? indexOf( seed, elem ) : preMap[i]) > -1 ) { seed[temp] = !(results[temp] = elem); } @@ -5278,13 +2378,16 @@ function matcherFromTokens( tokens ) { return elem === checkContext; }, implicitRelative, true ), matchAnyContext = addCombinator( function( elem ) { - return indexOf.call( checkContext, elem ) > -1; + return indexOf( checkContext, elem ) > -1; }, implicitRelative, true ), matchers = [ function( elem, context, xml ) { - return ( !leadingRelative && ( xml || context !== outermostContext ) ) || ( + var ret = ( !leadingRelative && ( xml || context !== outermostContext ) ) || ( (checkContext = context).nodeType ? matchContext( elem, context, xml ) : matchAnyContext( elem, context, xml ) ); + // Avoid hanging onto element (issue #299) + checkContext = null; + return ret; } ]; for ( ; i < len; i++ ) { @@ -5304,7 +2407,10 @@ function matcherFromTokens( tokens ) { } return setMatcher( i > 1 && elementMatcher( matchers ), - i > 1 && toSelector( tokens.slice( 0, i - 1 ) ).replace( rtrim, "$1" ), + i > 1 && toSelector( + // If the preceding token was a descendant combinator, insert an implicit any-element `*` + tokens.slice( 0, i - 1 ).concat({ value: tokens[ i - 2 ].type === " " ? "*" : "" }) + ).replace( rtrim, "$1" ), matcher, i < j && matcherFromTokens( tokens.slice( i, j ) ), j < len && matcherFromTokens( (tokens = tokens.slice( j )) ), @@ -5319,42 +2425,43 @@ function matcherFromTokens( tokens ) { } function matcherFromGroupMatchers( elementMatchers, setMatchers ) { - // A counter to specify which element is currently being matched - var matcherCachedRuns = 0, - bySet = setMatchers.length > 0, + var bySet = setMatchers.length > 0, byElement = elementMatchers.length > 0, - superMatcher = function( seed, context, xml, results, expandContext ) { + superMatcher = function( seed, context, xml, results, outermost ) { var elem, j, matcher, - setMatched = [], matchedCount = 0, i = "0", unmatched = seed && [], - outermost = expandContext != null, + setMatched = [], contextBackup = outermostContext, - // We must always have either seed elements or context - elems = seed || byElement && Expr.find["TAG"]( "*", expandContext && context.parentNode || context ), + // We must always have either seed elements or outermost context + elems = seed || byElement && Expr.find["TAG"]( "*", outermost ), // Use integer dirruns iff this is the outermost matcher - dirrunsUnique = (dirruns += contextBackup == null ? 1 : Math.random() || 0.1); + dirrunsUnique = (dirruns += contextBackup == null ? 1 : Math.random() || 0.1), + len = elems.length; if ( outermost ) { - outermostContext = context !== document && context; - cachedruns = matcherCachedRuns; + outermostContext = context === document || context || outermost; } // Add elements passing elementMatchers directly to results - // Keep `i` a string if there are no elements so `matchedCount` will be "00" below - for ( ; (elem = elems[i]) != null; i++ ) { + // Support: IE<9, Safari + // Tolerate NodeList properties (IE: "length"; Safari: ) matching elements by id + for ( ; i !== len && (elem = elems[i]) != null; i++ ) { if ( byElement && elem ) { j = 0; + if ( !context && elem.ownerDocument !== document ) { + setDocument( elem ); + xml = !documentIsHTML; + } while ( (matcher = elementMatchers[j++]) ) { - if ( matcher( elem, context, xml ) ) { + if ( matcher( elem, context || document, xml) ) { results.push( elem ); break; } } if ( outermost ) { dirruns = dirrunsUnique; - cachedruns = ++matcherCachedRuns; } } @@ -5372,8 +2479,17 @@ function matcherFromGroupMatchers( elementMatchers, setMatchers ) { } } - // Apply set filters to unmatched elements + // `i` is now the count of elements visited above, and adding it to `matchedCount` + // makes the latter nonnegative. matchedCount += i; + + // Apply set filters to unmatched elements + // NOTE: This can be skipped if there are no unmatched elements (i.e., `matchedCount` + // equals `i`), unless we didn't visit _any_ elements in the above loop because we have + // no element matchers and no seed. + // Incrementing an initially-string "0" `i` allows `i` to remain a string only in that + // case, which will result in a "00" `matchedCount` that differs from `i` but is also + // numerically zero. if ( bySet && i !== matchedCount ) { j = 0; while ( (matcher = setMatchers[j++]) ) { @@ -5419,7 +2535,7 @@ function matcherFromGroupMatchers( elementMatchers, setMatchers ) { superMatcher; } -compile = Sizzle.compile = function( selector, group /* Internal Use Only */ ) { +compile = Sizzle.compile = function( selector, match /* Internal Use Only */ ) { var i, setMatchers = [], elementMatchers = [], @@ -5427,12 +2543,12 @@ compile = Sizzle.compile = function( selector, group /* Internal Use Only */ ) { if ( !cached ) { // Generate a function of recursive functions that can be used to check each element - if ( !group ) { - group = tokenize( selector ); + if ( !match ) { + match = tokenize( selector ); } - i = group.length; + i = match.length; while ( i-- ) { - cached = matcherFromTokens( group[i] ); + cached = matcherFromTokens( match[i] ); if ( cached[ expando ] ) { setMatchers.push( cached ); } else { @@ -5442,111 +2558,435 @@ compile = Sizzle.compile = function( selector, group /* Internal Use Only */ ) { // Cache the compiled function cached = compilerCache( selector, matcherFromGroupMatchers( elementMatchers, setMatchers ) ); + + // Save selector and tokenization + cached.selector = selector; } return cached; }; -function multipleContexts( selector, contexts, results ) { - var i = 0, - len = contexts.length; - for ( ; i < len; i++ ) { - Sizzle( selector, contexts[i], results ); - } - return results; -} - -function select( selector, context, results, seed ) { +/** + * A low-level selection function that works with Sizzle's compiled + * selector functions + * @param {String|Function} selector A selector or a pre-compiled + * selector function built with Sizzle.compile + * @param {Element} context + * @param {Array} [results] + * @param {Array} [seed] A set of elements to match against + */ +select = Sizzle.select = function( selector, context, results, seed ) { var i, tokens, token, type, find, - match = tokenize( selector ); + compiled = typeof selector === "function" && selector, + match = !seed && tokenize( (selector = compiled.selector || selector) ); - if ( !seed ) { - // Try to minimize operations if there is only one group - if ( match.length === 1 ) { + results = results || []; - // Take a shortcut and set the context if the root selector is an ID - tokens = match[0] = match[0].slice( 0 ); - if ( tokens.length > 2 && (token = tokens[0]).type === "ID" && - context.nodeType === 9 && !documentIsXML && - Expr.relative[ tokens[1].type ] ) { + // Try to minimize operations if there is only one selector in the list and no seed + // (the latter of which guarantees us context) + if ( match.length === 1 ) { - context = Expr.find["ID"]( token.matches[0].replace( runescape, funescape ), context )[0]; - if ( !context ) { - return results; - } + // Reduce context if the leading compound selector is an ID + tokens = match[0] = match[0].slice( 0 ); + if ( tokens.length > 2 && (token = tokens[0]).type === "ID" && + support.getById && context.nodeType === 9 && documentIsHTML && + Expr.relative[ tokens[1].type ] ) { - selector = selector.slice( tokens.shift().value.length ); + context = ( Expr.find["ID"]( token.matches[0].replace(runescape, funescape), context ) || [] )[0]; + if ( !context ) { + return results; + + // Precompiled matchers will still verify ancestry, so step up a level + } else if ( compiled ) { + context = context.parentNode; } - // Fetch a seed set for right-to-left matching - i = matchExpr["needsContext"].test( selector ) ? 0 : tokens.length; - while ( i-- ) { - token = tokens[i]; + selector = selector.slice( tokens.shift().value.length ); + } - // Abort if we hit a combinator - if ( Expr.relative[ (type = token.type) ] ) { - break; - } - if ( (find = Expr.find[ type ]) ) { - // Search, expanding context for leading sibling combinators - if ( (seed = find( - token.matches[0].replace( runescape, funescape ), - rsibling.test( tokens[0].type ) && context.parentNode || context - )) ) { + // Fetch a seed set for right-to-left matching + i = matchExpr["needsContext"].test( selector ) ? 0 : tokens.length; + while ( i-- ) { + token = tokens[i]; - // If seed is empty or no tokens remain, we can return early - tokens.splice( i, 1 ); - selector = seed.length && toSelector( tokens ); - if ( !selector ) { - push.apply( results, slice.call( seed, 0 ) ); - return results; - } + // Abort if we hit a combinator + if ( Expr.relative[ (type = token.type) ] ) { + break; + } + if ( (find = Expr.find[ type ]) ) { + // Search, expanding context for leading sibling combinators + if ( (seed = find( + token.matches[0].replace( runescape, funescape ), + rsibling.test( tokens[0].type ) && testContext( context.parentNode ) || context + )) ) { - break; + // If seed is empty or no tokens remain, we can return early + tokens.splice( i, 1 ); + selector = seed.length && toSelector( tokens ); + if ( !selector ) { + push.apply( results, seed ); + return results; } + + break; } } } } - // Compile and execute a filtering function + // Compile and execute a filtering function if one is not provided // Provide `match` to avoid retokenization if we modified the selector above - compile( selector, match )( + ( compiled || compile( selector, match ) )( seed, context, - documentIsXML, + !documentIsHTML, results, - rsibling.test( selector ) + !context || rsibling.test( selector ) && testContext( context.parentNode ) || context ); return results; -} +}; -// Deprecated -Expr.pseudos["nth"] = Expr.pseudos["eq"]; +// One-time assignments -// Easy API for creating new setFilters -function setFilters() {} -Expr.filters = setFilters.prototype = Expr.pseudos; -Expr.setFilters = new setFilters(); +// Sort stability +support.sortStable = expando.split("").sort( sortOrder ).join("") === expando; -// Initialize with the default document +// Support: Chrome 14-35+ +// Always assume duplicates if they aren't passed to the comparison function +support.detectDuplicates = !!hasDuplicate; + +// Initialize against the default document setDocument(); -// Override sizzle attribute retrieval -Sizzle.attr = jQuery.attr; +// Support: Webkit<537.32 - Safari 6.0.3/Chrome 25 (fixed in Chrome 27) +// Detached nodes confoundingly follow *each other* +support.sortDetached = assert(function( div1 ) { + // Should return 1, but returns 4 (following) + return div1.compareDocumentPosition( document.createElement("div") ) & 1; +}); + +// Support: IE<8 +// Prevent attribute/property "interpolation" +// http://msdn.microsoft.com/en-us/library/ms536429%28VS.85%29.aspx +if ( !assert(function( div ) { + div.innerHTML = ""; + return div.firstChild.getAttribute("href") === "#" ; +}) ) { + addHandle( "type|href|height|width", function( elem, name, isXML ) { + if ( !isXML ) { + return elem.getAttribute( name, name.toLowerCase() === "type" ? 1 : 2 ); + } + }); +} + +// Support: IE<9 +// Use defaultValue in place of getAttribute("value") +if ( !support.attributes || !assert(function( div ) { + div.innerHTML = ""; + div.firstChild.setAttribute( "value", "" ); + return div.firstChild.getAttribute( "value" ) === ""; +}) ) { + addHandle( "value", function( elem, name, isXML ) { + if ( !isXML && elem.nodeName.toLowerCase() === "input" ) { + return elem.defaultValue; + } + }); +} + +// Support: IE<9 +// Use getAttributeNode to fetch booleans when getAttribute lies +if ( !assert(function( div ) { + return div.getAttribute("disabled") == null; +}) ) { + addHandle( booleans, function( elem, name, isXML ) { + var val; + if ( !isXML ) { + return elem[ name ] === true ? name.toLowerCase() : + (val = elem.getAttributeNode( name )) && val.specified ? + val.value : + null; + } + }); +} + +return Sizzle; + +})( window ); + + + jQuery.find = Sizzle; jQuery.expr = Sizzle.selectors; -jQuery.expr[":"] = jQuery.expr.pseudos; -jQuery.unique = Sizzle.uniqueSort; +jQuery.expr[ ":" ] = jQuery.expr.pseudos; +jQuery.uniqueSort = jQuery.unique = Sizzle.uniqueSort; jQuery.text = Sizzle.getText; jQuery.isXMLDoc = Sizzle.isXML; jQuery.contains = Sizzle.contains; -})( window ); -var runtil = /Until$/, - rparentsprev = /^(?:parents|prev(?:Until|All))/, - isSimple = /^.[^:#\[\.,]*$/, - rneedsContext = jQuery.expr.match.needsContext, + +var dir = function( elem, dir, until ) { + var matched = [], + truncate = until !== undefined; + + while ( ( elem = elem[ dir ] ) && elem.nodeType !== 9 ) { + if ( elem.nodeType === 1 ) { + if ( truncate && jQuery( elem ).is( until ) ) { + break; + } + matched.push( elem ); + } + } + return matched; +}; + + +var siblings = function( n, elem ) { + var matched = []; + + for ( ; n; n = n.nextSibling ) { + if ( n.nodeType === 1 && n !== elem ) { + matched.push( n ); + } + } + + return matched; +}; + + +var rneedsContext = jQuery.expr.match.needsContext; + +var rsingleTag = ( /^<([\w-]+)\s*\/?>(?:<\/\1>|)$/ ); + + + +var risSimple = /^.[^:#\[\.,]*$/; + +// Implement the identical functionality for filter and not +function winnow( elements, qualifier, not ) { + if ( jQuery.isFunction( qualifier ) ) { + return jQuery.grep( elements, function( elem, i ) { + /* jshint -W018 */ + return !!qualifier.call( elem, i, elem ) !== not; + } ); + + } + + if ( qualifier.nodeType ) { + return jQuery.grep( elements, function( elem ) { + return ( elem === qualifier ) !== not; + } ); + + } + + if ( typeof qualifier === "string" ) { + if ( risSimple.test( qualifier ) ) { + return jQuery.filter( qualifier, elements, not ); + } + + qualifier = jQuery.filter( qualifier, elements ); + } + + return jQuery.grep( elements, function( elem ) { + return ( jQuery.inArray( elem, qualifier ) > -1 ) !== not; + } ); +} + +jQuery.filter = function( expr, elems, not ) { + var elem = elems[ 0 ]; + + if ( not ) { + expr = ":not(" + expr + ")"; + } + + return elems.length === 1 && elem.nodeType === 1 ? + jQuery.find.matchesSelector( elem, expr ) ? [ elem ] : [] : + jQuery.find.matches( expr, jQuery.grep( elems, function( elem ) { + return elem.nodeType === 1; + } ) ); +}; + +jQuery.fn.extend( { + find: function( selector ) { + var i, + ret = [], + self = this, + len = self.length; + + if ( typeof selector !== "string" ) { + return this.pushStack( jQuery( selector ).filter( function() { + for ( i = 0; i < len; i++ ) { + if ( jQuery.contains( self[ i ], this ) ) { + return true; + } + } + } ) ); + } + + for ( i = 0; i < len; i++ ) { + jQuery.find( selector, self[ i ], ret ); + } + + // Needed because $( selector, context ) becomes $( context ).find( selector ) + ret = this.pushStack( len > 1 ? jQuery.unique( ret ) : ret ); + ret.selector = this.selector ? this.selector + " " + selector : selector; + return ret; + }, + filter: function( selector ) { + return this.pushStack( winnow( this, selector || [], false ) ); + }, + not: function( selector ) { + return this.pushStack( winnow( this, selector || [], true ) ); + }, + is: function( selector ) { + return !!winnow( + this, + + // If this is a positional/relative selector, check membership in the returned set + // so $("p:first").is("p:last") won't return true for a doc with two "p". + typeof selector === "string" && rneedsContext.test( selector ) ? + jQuery( selector ) : + selector || [], + false + ).length; + } +} ); + + +// Initialize a jQuery object + + +// A central reference to the root jQuery(document) +var rootjQuery, + + // A simple way to check for HTML strings + // Prioritize #id over to avoid XSS via location.hash (#9521) + // Strict HTML recognition (#11290: must start with <) + rquickExpr = /^(?:\s*(<[\w\W]+>)[^>]*|#([\w-]*))$/, + + init = jQuery.fn.init = function( selector, context, root ) { + var match, elem; + + // HANDLE: $(""), $(null), $(undefined), $(false) + if ( !selector ) { + return this; + } + + // init accepts an alternate rootjQuery + // so migrate can support jQuery.sub (gh-2101) + root = root || rootjQuery; + + // Handle HTML strings + if ( typeof selector === "string" ) { + if ( selector.charAt( 0 ) === "<" && + selector.charAt( selector.length - 1 ) === ">" && + selector.length >= 3 ) { + + // Assume that strings that start and end with <> are HTML and skip the regex check + match = [ null, selector, null ]; + + } else { + match = rquickExpr.exec( selector ); + } + + // Match html or make sure no context is specified for #id + if ( match && ( match[ 1 ] || !context ) ) { + + // HANDLE: $(html) -> $(array) + if ( match[ 1 ] ) { + context = context instanceof jQuery ? context[ 0 ] : context; + + // scripts is true for back-compat + // Intentionally let the error be thrown if parseHTML is not present + jQuery.merge( this, jQuery.parseHTML( + match[ 1 ], + context && context.nodeType ? context.ownerDocument || context : document, + true + ) ); + + // HANDLE: $(html, props) + if ( rsingleTag.test( match[ 1 ] ) && jQuery.isPlainObject( context ) ) { + for ( match in context ) { + + // Properties of context are called as methods if possible + if ( jQuery.isFunction( this[ match ] ) ) { + this[ match ]( context[ match ] ); + + // ...and otherwise set as attributes + } else { + this.attr( match, context[ match ] ); + } + } + } + + return this; + + // HANDLE: $(#id) + } else { + elem = document.getElementById( match[ 2 ] ); + + // Check parentNode to catch when Blackberry 4.6 returns + // nodes that are no longer in the document #6963 + if ( elem && elem.parentNode ) { + + // Handle the case where IE and Opera return items + // by name instead of ID + if ( elem.id !== match[ 2 ] ) { + return rootjQuery.find( selector ); + } + + // Otherwise, we inject the element directly into the jQuery object + this.length = 1; + this[ 0 ] = elem; + } + + this.context = document; + this.selector = selector; + return this; + } + + // HANDLE: $(expr, $(...)) + } else if ( !context || context.jquery ) { + return ( context || root ).find( selector ); + + // HANDLE: $(expr, context) + // (which is just equivalent to: $(context).find(expr) + } else { + return this.constructor( context ).find( selector ); + } + + // HANDLE: $(DOMElement) + } else if ( selector.nodeType ) { + this.context = this[ 0 ] = selector; + this.length = 1; + return this; + + // HANDLE: $(function) + // Shortcut for document ready + } else if ( jQuery.isFunction( selector ) ) { + return typeof root.ready !== "undefined" ? + root.ready( selector ) : + + // Execute immediately if ready is not present + selector( jQuery ); + } + + if ( selector.selector !== undefined ) { + this.selector = selector.selector; + this.context = selector.context; + } + + return jQuery.makeArray( selector, this ); + }; + +// Give the init function the jQuery prototype for later instantiation +init.prototype = jQuery.fn; + +// Initialize central reference +rootjQuery = jQuery( document ); + + +var rparentsprev = /^(?:parents|prev(?:Until|All))/, + // methods guaranteed to produce a unique set when starting from a unique set guaranteedUnique = { children: true, @@ -5555,88 +2995,48 @@ var runtil = /Until$/, prev: true }; -jQuery.fn.extend({ - find: function( selector ) { - var i, ret, self, - len = this.length; - - if ( typeof selector !== "string" ) { - self = this; - return this.pushStack( jQuery( selector ).filter(function() { - for ( i = 0; i < len; i++ ) { - if ( jQuery.contains( self[ i ], this ) ) { - return true; - } - } - }) ); - } - - ret = []; - for ( i = 0; i < len; i++ ) { - jQuery.find( selector, this[ i ], ret ); - } - - // Needed because $( selector, context ) becomes $( context ).find( selector ) - ret = this.pushStack( len > 1 ? jQuery.unique( ret ) : ret ); - ret.selector = ( this.selector ? this.selector + " " : "" ) + selector; - return ret; - }, - +jQuery.fn.extend( { has: function( target ) { var i, targets = jQuery( target, this ), len = targets.length; - return this.filter(function() { + return this.filter( function() { for ( i = 0; i < len; i++ ) { - if ( jQuery.contains( this, targets[i] ) ) { + if ( jQuery.contains( this, targets[ i ] ) ) { return true; } } - }); - }, - - not: function( selector ) { - return this.pushStack( winnow(this, selector, false) ); - }, - - filter: function( selector ) { - return this.pushStack( winnow(this, selector, true) ); - }, - - is: function( selector ) { - return !!selector && ( - typeof selector === "string" ? - // If this is a positional/relative selector, check membership in the returned set - // so $("p:first").is("p:last") won't return true for a doc with two "p". - rneedsContext.test( selector ) ? - jQuery( selector, this.context ).index( this[0] ) >= 0 : - jQuery.filter( selector, this ).length > 0 : - this.filter( selector ).length > 0 ); + } ); }, closest: function( selectors, context ) { var cur, i = 0, l = this.length, - ret = [], + matched = [], pos = rneedsContext.test( selectors ) || typeof selectors !== "string" ? jQuery( selectors, context || this.context ) : 0; for ( ; i < l; i++ ) { - cur = this[i]; + for ( cur = this[ i ]; cur && cur !== context; cur = cur.parentNode ) { - while ( cur && cur.ownerDocument && cur !== context && cur.nodeType !== 11 ) { - if ( pos ? pos.index(cur) > -1 : jQuery.find.matchesSelector(cur, selectors) ) { - ret.push( cur ); + // Always skip document fragments + if ( cur.nodeType < 11 && ( pos ? + pos.index( cur ) > -1 : + + // Don't pass non-elements to Sizzle + cur.nodeType === 1 && + jQuery.find.matchesSelector( cur, selectors ) ) ) { + + matched.push( cur ); break; } - cur = cur.parentNode; } } - return this.pushStack( ret.length > 1 ? jQuery.unique( ret ) : ret ); + return this.pushStack( matched.length > 1 ? jQuery.uniqueSort( matched ) : matched ); }, // Determine the position of an element within @@ -5645,37 +3045,35 @@ jQuery.fn.extend({ // No argument, return index in parent if ( !elem ) { - return ( this[0] && this[0].parentNode ) ? this.first().prevAll().length : -1; + return ( this[ 0 ] && this[ 0 ].parentNode ) ? this.first().prevAll().length : -1; } // index in selector if ( typeof elem === "string" ) { - return jQuery.inArray( this[0], jQuery( elem ) ); + return jQuery.inArray( this[ 0 ], jQuery( elem ) ); } // Locate the position of the desired element return jQuery.inArray( + // If it receives a jQuery object, the first element is used - elem.jquery ? elem[0] : elem, this ); + elem.jquery ? elem[ 0 ] : elem, this ); }, add: function( selector, context ) { - var set = typeof selector === "string" ? - jQuery( selector, context ) : - jQuery.makeArray( selector && selector.nodeType ? [ selector ] : selector ), - all = jQuery.merge( this.get(), set ); - - return this.pushStack( jQuery.unique(all) ); + return this.pushStack( + jQuery.uniqueSort( + jQuery.merge( this.get(), jQuery( selector, context ) ) + ) + ); }, addBack: function( selector ) { return this.add( selector == null ? - this.prevObject : this.prevObject.filter(selector) + this.prevObject : this.prevObject.filter( selector ) ); } -}); - -jQuery.fn.andSelf = jQuery.fn.addBack; +} ); function sibling( cur, dir ) { do { @@ -5685,16 +3083,16 @@ function sibling( cur, dir ) { return cur; } -jQuery.each({ +jQuery.each( { parent: function( elem ) { var parent = elem.parentNode; return parent && parent.nodeType !== 11 ? parent : null; }, parents: function( elem ) { - return jQuery.dir( elem, "parentNode" ); + return dir( elem, "parentNode" ); }, parentsUntil: function( elem, i, until ) { - return jQuery.dir( elem, "parentNode", until ); + return dir( elem, "parentNode", until ); }, next: function( elem ) { return sibling( elem, "nextSibling" ); @@ -5703,22 +3101,22 @@ jQuery.each({ return sibling( elem, "previousSibling" ); }, nextAll: function( elem ) { - return jQuery.dir( elem, "nextSibling" ); + return dir( elem, "nextSibling" ); }, prevAll: function( elem ) { - return jQuery.dir( elem, "previousSibling" ); + return dir( elem, "previousSibling" ); }, nextUntil: function( elem, i, until ) { - return jQuery.dir( elem, "nextSibling", until ); + return dir( elem, "nextSibling", until ); }, prevUntil: function( elem, i, until ) { - return jQuery.dir( elem, "previousSibling", until ); + return dir( elem, "previousSibling", until ); }, siblings: function( elem ) { - return jQuery.sibling( ( elem.parentNode || {} ).firstChild, elem ); + return siblings( ( elem.parentNode || {} ).firstChild, elem ); }, children: function( elem ) { - return jQuery.sibling( elem.firstChild ); + return siblings( elem.firstChild ); }, contents: function( elem ) { return jQuery.nodeName( elem, "iframe" ) ? @@ -5729,7 +3127,7 @@ jQuery.each({ jQuery.fn[ name ] = function( until, selector ) { var ret = jQuery.map( this, fn, until ); - if ( !runtil.test( name ) ) { + if ( name.slice( -5 ) !== "Until" ) { selector = until; } @@ -5737,87 +3135,1312 @@ jQuery.each({ ret = jQuery.filter( selector, ret ); } - ret = this.length > 1 && !guaranteedUnique[ name ] ? jQuery.unique( ret ) : ret; + if ( this.length > 1 ) { - if ( this.length > 1 && rparentsprev.test( name ) ) { - ret = ret.reverse(); + // Remove duplicates + if ( !guaranteedUnique[ name ] ) { + ret = jQuery.uniqueSort( ret ); + } + + // Reverse order for parents* and prev-derivatives + if ( rparentsprev.test( name ) ) { + ret = ret.reverse(); + } } return this.pushStack( ret ); }; -}); +} ); +var rnotwhite = ( /\S+/g ); -jQuery.extend({ - filter: function( expr, elems, not ) { - if ( not ) { - expr = ":not(" + expr + ")"; - } - return elems.length === 1 ? - jQuery.find.matchesSelector(elems[0], expr) ? [ elems[0] ] : [] : - jQuery.find.matches(expr, elems); - }, - dir: function( elem, dir, until ) { - var matched = [], - cur = elem[ dir ]; - - while ( cur && cur.nodeType !== 9 && (until === undefined || cur.nodeType !== 1 || !jQuery( cur ).is( until )) ) { - if ( cur.nodeType === 1 ) { - matched.push( cur ); - } - cur = cur[dir]; - } - return matched; - }, - - sibling: function( n, elem ) { - var r = []; - - for ( ; n; n = n.nextSibling ) { - if ( n.nodeType === 1 && n !== elem ) { - r.push( n ); - } - } - - return r; - } -}); - -// Implement the identical functionality for filter and not -function winnow( elements, qualifier, keep ) { - - // Can't pass null or undefined to indexOf in Firefox 4 - // Set to 0 to skip string check - qualifier = qualifier || 0; - - if ( jQuery.isFunction( qualifier ) ) { - return jQuery.grep(elements, function( elem, i ) { - var retVal = !!qualifier.call( elem, i, elem ); - return retVal === keep; - }); - - } else if ( qualifier.nodeType ) { - return jQuery.grep(elements, function( elem ) { - return ( elem === qualifier ) === keep; - }); - - } else if ( typeof qualifier === "string" ) { - var filtered = jQuery.grep(elements, function( elem ) { - return elem.nodeType === 1; - }); - - if ( isSimple.test( qualifier ) ) { - return jQuery.filter(qualifier, filtered, !keep); - } else { - qualifier = jQuery.filter( qualifier, filtered ); - } - } - - return jQuery.grep(elements, function( elem ) { - return ( jQuery.inArray( elem, qualifier ) >= 0 ) === keep; - }); +// Convert String-formatted options into Object-formatted ones +function createOptions( options ) { + var object = {}; + jQuery.each( options.match( rnotwhite ) || [], function( _, flag ) { + object[ flag ] = true; + } ); + return object; } + +/* + * Create a callback list using the following parameters: + * + * options: an optional list of space-separated options that will change how + * the callback list behaves or a more traditional option object + * + * By default a callback list will act like an event callback list and can be + * "fired" multiple times. + * + * Possible options: + * + * once: will ensure the callback list can only be fired once (like a Deferred) + * + * memory: will keep track of previous values and will call any callback added + * after the list has been fired right away with the latest "memorized" + * values (like a Deferred) + * + * unique: will ensure a callback can only be added once (no duplicate in the list) + * + * stopOnFalse: interrupt callings when a callback returns false + * + */ +jQuery.Callbacks = function( options ) { + + // Convert options from String-formatted to Object-formatted if needed + // (we check in cache first) + options = typeof options === "string" ? + createOptions( options ) : + jQuery.extend( {}, options ); + + var // Flag to know if list is currently firing + firing, + + // Last fire value for non-forgettable lists + memory, + + // Flag to know if list was already fired + fired, + + // Flag to prevent firing + locked, + + // Actual callback list + list = [], + + // Queue of execution data for repeatable lists + queue = [], + + // Index of currently firing callback (modified by add/remove as needed) + firingIndex = -1, + + // Fire callbacks + fire = function() { + + // Enforce single-firing + locked = options.once; + + // Execute callbacks for all pending executions, + // respecting firingIndex overrides and runtime changes + fired = firing = true; + for ( ; queue.length; firingIndex = -1 ) { + memory = queue.shift(); + while ( ++firingIndex < list.length ) { + + // Run callback and check for early termination + if ( list[ firingIndex ].apply( memory[ 0 ], memory[ 1 ] ) === false && + options.stopOnFalse ) { + + // Jump to end and forget the data so .add doesn't re-fire + firingIndex = list.length; + memory = false; + } + } + } + + // Forget the data if we're done with it + if ( !options.memory ) { + memory = false; + } + + firing = false; + + // Clean up if we're done firing for good + if ( locked ) { + + // Keep an empty list if we have data for future add calls + if ( memory ) { + list = []; + + // Otherwise, this object is spent + } else { + list = ""; + } + } + }, + + // Actual Callbacks object + self = { + + // Add a callback or a collection of callbacks to the list + add: function() { + if ( list ) { + + // If we have memory from a past run, we should fire after adding + if ( memory && !firing ) { + firingIndex = list.length - 1; + queue.push( memory ); + } + + ( function add( args ) { + jQuery.each( args, function( _, arg ) { + if ( jQuery.isFunction( arg ) ) { + if ( !options.unique || !self.has( arg ) ) { + list.push( arg ); + } + } else if ( arg && arg.length && jQuery.type( arg ) !== "string" ) { + + // Inspect recursively + add( arg ); + } + } ); + } )( arguments ); + + if ( memory && !firing ) { + fire(); + } + } + return this; + }, + + // Remove a callback from the list + remove: function() { + jQuery.each( arguments, function( _, arg ) { + var index; + while ( ( index = jQuery.inArray( arg, list, index ) ) > -1 ) { + list.splice( index, 1 ); + + // Handle firing indexes + if ( index <= firingIndex ) { + firingIndex--; + } + } + } ); + return this; + }, + + // Check if a given callback is in the list. + // If no argument is given, return whether or not list has callbacks attached. + has: function( fn ) { + return fn ? + jQuery.inArray( fn, list ) > -1 : + list.length > 0; + }, + + // Remove all callbacks from the list + empty: function() { + if ( list ) { + list = []; + } + return this; + }, + + // Disable .fire and .add + // Abort any current/pending executions + // Clear all callbacks and values + disable: function() { + locked = queue = []; + list = memory = ""; + return this; + }, + disabled: function() { + return !list; + }, + + // Disable .fire + // Also disable .add unless we have memory (since it would have no effect) + // Abort any pending executions + lock: function() { + locked = true; + if ( !memory ) { + self.disable(); + } + return this; + }, + locked: function() { + return !!locked; + }, + + // Call all callbacks with the given context and arguments + fireWith: function( context, args ) { + if ( !locked ) { + args = args || []; + args = [ context, args.slice ? args.slice() : args ]; + queue.push( args ); + if ( !firing ) { + fire(); + } + } + return this; + }, + + // Call all the callbacks with the given arguments + fire: function() { + self.fireWith( this, arguments ); + return this; + }, + + // To know if the callbacks have already been called at least once + fired: function() { + return !!fired; + } + }; + + return self; +}; + + +jQuery.extend( { + + Deferred: function( func ) { + var tuples = [ + + // action, add listener, listener list, final state + [ "resolve", "done", jQuery.Callbacks( "once memory" ), "resolved" ], + [ "reject", "fail", jQuery.Callbacks( "once memory" ), "rejected" ], + [ "notify", "progress", jQuery.Callbacks( "memory" ) ] + ], + state = "pending", + promise = { + state: function() { + return state; + }, + always: function() { + deferred.done( arguments ).fail( arguments ); + return this; + }, + then: function( /* fnDone, fnFail, fnProgress */ ) { + var fns = arguments; + return jQuery.Deferred( function( newDefer ) { + jQuery.each( tuples, function( i, tuple ) { + var fn = jQuery.isFunction( fns[ i ] ) && fns[ i ]; + + // deferred[ done | fail | progress ] for forwarding actions to newDefer + deferred[ tuple[ 1 ] ]( function() { + var returned = fn && fn.apply( this, arguments ); + if ( returned && jQuery.isFunction( returned.promise ) ) { + returned.promise() + .progress( newDefer.notify ) + .done( newDefer.resolve ) + .fail( newDefer.reject ); + } else { + newDefer[ tuple[ 0 ] + "With" ]( + this === promise ? newDefer.promise() : this, + fn ? [ returned ] : arguments + ); + } + } ); + } ); + fns = null; + } ).promise(); + }, + + // Get a promise for this deferred + // If obj is provided, the promise aspect is added to the object + promise: function( obj ) { + return obj != null ? jQuery.extend( obj, promise ) : promise; + } + }, + deferred = {}; + + // Keep pipe for back-compat + promise.pipe = promise.then; + + // Add list-specific methods + jQuery.each( tuples, function( i, tuple ) { + var list = tuple[ 2 ], + stateString = tuple[ 3 ]; + + // promise[ done | fail | progress ] = list.add + promise[ tuple[ 1 ] ] = list.add; + + // Handle state + if ( stateString ) { + list.add( function() { + + // state = [ resolved | rejected ] + state = stateString; + + // [ reject_list | resolve_list ].disable; progress_list.lock + }, tuples[ i ^ 1 ][ 2 ].disable, tuples[ 2 ][ 2 ].lock ); + } + + // deferred[ resolve | reject | notify ] + deferred[ tuple[ 0 ] ] = function() { + deferred[ tuple[ 0 ] + "With" ]( this === deferred ? promise : this, arguments ); + return this; + }; + deferred[ tuple[ 0 ] + "With" ] = list.fireWith; + } ); + + // Make the deferred a promise + promise.promise( deferred ); + + // Call given func if any + if ( func ) { + func.call( deferred, deferred ); + } + + // All done! + return deferred; + }, + + // Deferred helper + when: function( subordinate /* , ..., subordinateN */ ) { + var i = 0, + resolveValues = slice.call( arguments ), + length = resolveValues.length, + + // the count of uncompleted subordinates + remaining = length !== 1 || + ( subordinate && jQuery.isFunction( subordinate.promise ) ) ? length : 0, + + // the master Deferred. + // If resolveValues consist of only a single Deferred, just use that. + deferred = remaining === 1 ? subordinate : jQuery.Deferred(), + + // Update function for both resolve and progress values + updateFunc = function( i, contexts, values ) { + return function( value ) { + contexts[ i ] = this; + values[ i ] = arguments.length > 1 ? slice.call( arguments ) : value; + if ( values === progressValues ) { + deferred.notifyWith( contexts, values ); + + } else if ( !( --remaining ) ) { + deferred.resolveWith( contexts, values ); + } + }; + }, + + progressValues, progressContexts, resolveContexts; + + // add listeners to Deferred subordinates; treat others as resolved + if ( length > 1 ) { + progressValues = new Array( length ); + progressContexts = new Array( length ); + resolveContexts = new Array( length ); + for ( ; i < length; i++ ) { + if ( resolveValues[ i ] && jQuery.isFunction( resolveValues[ i ].promise ) ) { + resolveValues[ i ].promise() + .progress( updateFunc( i, progressContexts, progressValues ) ) + .done( updateFunc( i, resolveContexts, resolveValues ) ) + .fail( deferred.reject ); + } else { + --remaining; + } + } + } + + // if we're not waiting on anything, resolve the master + if ( !remaining ) { + deferred.resolveWith( resolveContexts, resolveValues ); + } + + return deferred.promise(); + } +} ); + + +// The deferred used on DOM ready +var readyList; + +jQuery.fn.ready = function( fn ) { + + // Add the callback + jQuery.ready.promise().done( fn ); + + return this; +}; + +jQuery.extend( { + + // Is the DOM ready to be used? Set to true once it occurs. + isReady: false, + + // A counter to track how many items to wait for before + // the ready event fires. See #6781 + readyWait: 1, + + // Hold (or release) the ready event + holdReady: function( hold ) { + if ( hold ) { + jQuery.readyWait++; + } else { + jQuery.ready( true ); + } + }, + + // Handle when the DOM is ready + ready: function( wait ) { + + // Abort if there are pending holds or we're already ready + if ( wait === true ? --jQuery.readyWait : jQuery.isReady ) { + return; + } + + // Remember that the DOM is ready + jQuery.isReady = true; + + // If a normal DOM Ready event fired, decrement, and wait if need be + if ( wait !== true && --jQuery.readyWait > 0 ) { + return; + } + + // If there are functions bound, to execute + readyList.resolveWith( document, [ jQuery ] ); + + // Trigger any bound ready events + if ( jQuery.fn.triggerHandler ) { + jQuery( document ).triggerHandler( "ready" ); + jQuery( document ).off( "ready" ); + } + } +} ); + +/** + * Clean-up method for dom ready events + */ +function detach() { + if ( document.addEventListener ) { + document.removeEventListener( "DOMContentLoaded", completed ); + window.removeEventListener( "load", completed ); + + } else { + document.detachEvent( "onreadystatechange", completed ); + window.detachEvent( "onload", completed ); + } +} + +/** + * The ready event handler and self cleanup method + */ +function completed() { + + // readyState === "complete" is good enough for us to call the dom ready in oldIE + if ( document.addEventListener || + window.event.type === "load" || + document.readyState === "complete" ) { + + detach(); + jQuery.ready(); + } +} + +jQuery.ready.promise = function( obj ) { + if ( !readyList ) { + + readyList = jQuery.Deferred(); + + // Catch cases where $(document).ready() is called + // after the browser event has already occurred. + // Support: IE6-10 + // Older IE sometimes signals "interactive" too soon + if ( document.readyState === "complete" || + ( document.readyState !== "loading" && !document.documentElement.doScroll ) ) { + + // Handle it asynchronously to allow scripts the opportunity to delay ready + window.setTimeout( jQuery.ready ); + + // Standards-based browsers support DOMContentLoaded + } else if ( document.addEventListener ) { + + // Use the handy event callback + document.addEventListener( "DOMContentLoaded", completed ); + + // A fallback to window.onload, that will always work + window.addEventListener( "load", completed ); + + // If IE event model is used + } else { + + // Ensure firing before onload, maybe late but safe also for iframes + document.attachEvent( "onreadystatechange", completed ); + + // A fallback to window.onload, that will always work + window.attachEvent( "onload", completed ); + + // If IE and not a frame + // continually check to see if the document is ready + var top = false; + + try { + top = window.frameElement == null && document.documentElement; + } catch ( e ) {} + + if ( top && top.doScroll ) { + ( function doScrollCheck() { + if ( !jQuery.isReady ) { + + try { + + // Use the trick by Diego Perini + // http://javascript.nwbox.com/IEContentLoaded/ + top.doScroll( "left" ); + } catch ( e ) { + return window.setTimeout( doScrollCheck, 50 ); + } + + // detach all dom ready events + detach(); + + // and execute any waiting functions + jQuery.ready(); + } + } )(); + } + } + } + return readyList.promise( obj ); +}; + +// Kick off the DOM ready check even if the user does not +jQuery.ready.promise(); + + + + +// Support: IE<9 +// Iteration over object's inherited properties before its own +var i; +for ( i in jQuery( support ) ) { + break; +} +support.ownFirst = i === "0"; + +// Note: most support tests are defined in their respective modules. +// false until the test is run +support.inlineBlockNeedsLayout = false; + +// Execute ASAP in case we need to set body.style.zoom +jQuery( function() { + + // Minified: var a,b,c,d + var val, div, body, container; + + body = document.getElementsByTagName( "body" )[ 0 ]; + if ( !body || !body.style ) { + + // Return for frameset docs that don't have a body + return; + } + + // Setup + div = document.createElement( "div" ); + container = document.createElement( "div" ); + container.style.cssText = "position:absolute;border:0;width:0;height:0;top:0;left:-9999px"; + body.appendChild( container ).appendChild( div ); + + if ( typeof div.style.zoom !== "undefined" ) { + + // Support: IE<8 + // Check if natively block-level elements act like inline-block + // elements when setting their display to 'inline' and giving + // them layout + div.style.cssText = "display:inline;margin:0;border:0;padding:1px;width:1px;zoom:1"; + + support.inlineBlockNeedsLayout = val = div.offsetWidth === 3; + if ( val ) { + + // Prevent IE 6 from affecting layout for positioned elements #11048 + // Prevent IE from shrinking the body in IE 7 mode #12869 + // Support: IE<8 + body.style.zoom = 1; + } + } + + body.removeChild( container ); +} ); + + +( function() { + var div = document.createElement( "div" ); + + // Support: IE<9 + support.deleteExpando = true; + try { + delete div.test; + } catch ( e ) { + support.deleteExpando = false; + } + + // Null elements to avoid leaks in IE. + div = null; +} )(); +var acceptData = function( elem ) { + var noData = jQuery.noData[ ( elem.nodeName + " " ).toLowerCase() ], + nodeType = +elem.nodeType || 1; + + // Do not set data on non-element DOM nodes because it will not be cleared (#8335). + return nodeType !== 1 && nodeType !== 9 ? + false : + + // Nodes accept data unless otherwise specified; rejection can be conditional + !noData || noData !== true && elem.getAttribute( "classid" ) === noData; +}; + + + + +var rbrace = /^(?:\{[\w\W]*\}|\[[\w\W]*\])$/, + rmultiDash = /([A-Z])/g; + +function dataAttr( elem, key, data ) { + + // If nothing was found internally, try to fetch any + // data from the HTML5 data-* attribute + if ( data === undefined && elem.nodeType === 1 ) { + + var name = "data-" + key.replace( rmultiDash, "-$1" ).toLowerCase(); + + data = elem.getAttribute( name ); + + if ( typeof data === "string" ) { + try { + data = data === "true" ? true : + data === "false" ? false : + data === "null" ? null : + + // Only convert to a number if it doesn't change the string + +data + "" === data ? +data : + rbrace.test( data ) ? jQuery.parseJSON( data ) : + data; + } catch ( e ) {} + + // Make sure we set the data so it isn't changed later + jQuery.data( elem, key, data ); + + } else { + data = undefined; + } + } + + return data; +} + +// checks a cache object for emptiness +function isEmptyDataObject( obj ) { + var name; + for ( name in obj ) { + + // if the public data object is empty, the private is still empty + if ( name === "data" && jQuery.isEmptyObject( obj[ name ] ) ) { + continue; + } + if ( name !== "toJSON" ) { + return false; + } + } + + return true; +} + +function internalData( elem, name, data, pvt /* Internal Use Only */ ) { + if ( !acceptData( elem ) ) { + return; + } + + var ret, thisCache, + internalKey = jQuery.expando, + + // We have to handle DOM nodes and JS objects differently because IE6-7 + // can't GC object references properly across the DOM-JS boundary + isNode = elem.nodeType, + + // Only DOM nodes need the global jQuery cache; JS object data is + // attached directly to the object so GC can occur automatically + cache = isNode ? jQuery.cache : elem, + + // Only defining an ID for JS objects if its cache already exists allows + // the code to shortcut on the same path as a DOM node with no cache + id = isNode ? elem[ internalKey ] : elem[ internalKey ] && internalKey; + + // Avoid doing any more work than we need to when trying to get data on an + // object that has no data at all + if ( ( !id || !cache[ id ] || ( !pvt && !cache[ id ].data ) ) && + data === undefined && typeof name === "string" ) { + return; + } + + if ( !id ) { + + // Only DOM nodes need a new unique ID for each element since their data + // ends up in the global cache + if ( isNode ) { + id = elem[ internalKey ] = deletedIds.pop() || jQuery.guid++; + } else { + id = internalKey; + } + } + + if ( !cache[ id ] ) { + + // Avoid exposing jQuery metadata on plain JS objects when the object + // is serialized using JSON.stringify + cache[ id ] = isNode ? {} : { toJSON: jQuery.noop }; + } + + // An object can be passed to jQuery.data instead of a key/value pair; this gets + // shallow copied over onto the existing cache + if ( typeof name === "object" || typeof name === "function" ) { + if ( pvt ) { + cache[ id ] = jQuery.extend( cache[ id ], name ); + } else { + cache[ id ].data = jQuery.extend( cache[ id ].data, name ); + } + } + + thisCache = cache[ id ]; + + // jQuery data() is stored in a separate object inside the object's internal data + // cache in order to avoid key collisions between internal data and user-defined + // data. + if ( !pvt ) { + if ( !thisCache.data ) { + thisCache.data = {}; + } + + thisCache = thisCache.data; + } + + if ( data !== undefined ) { + thisCache[ jQuery.camelCase( name ) ] = data; + } + + // Check for both converted-to-camel and non-converted data property names + // If a data property was specified + if ( typeof name === "string" ) { + + // First Try to find as-is property data + ret = thisCache[ name ]; + + // Test for null|undefined property data + if ( ret == null ) { + + // Try to find the camelCased property + ret = thisCache[ jQuery.camelCase( name ) ]; + } + } else { + ret = thisCache; + } + + return ret; +} + +function internalRemoveData( elem, name, pvt ) { + if ( !acceptData( elem ) ) { + return; + } + + var thisCache, i, + isNode = elem.nodeType, + + // See jQuery.data for more information + cache = isNode ? jQuery.cache : elem, + id = isNode ? elem[ jQuery.expando ] : jQuery.expando; + + // If there is already no cache entry for this object, there is no + // purpose in continuing + if ( !cache[ id ] ) { + return; + } + + if ( name ) { + + thisCache = pvt ? cache[ id ] : cache[ id ].data; + + if ( thisCache ) { + + // Support array or space separated string names for data keys + if ( !jQuery.isArray( name ) ) { + + // try the string as a key before any manipulation + if ( name in thisCache ) { + name = [ name ]; + } else { + + // split the camel cased version by spaces unless a key with the spaces exists + name = jQuery.camelCase( name ); + if ( name in thisCache ) { + name = [ name ]; + } else { + name = name.split( " " ); + } + } + } else { + + // If "name" is an array of keys... + // When data is initially created, via ("key", "val") signature, + // keys will be converted to camelCase. + // Since there is no way to tell _how_ a key was added, remove + // both plain key and camelCase key. #12786 + // This will only penalize the array argument path. + name = name.concat( jQuery.map( name, jQuery.camelCase ) ); + } + + i = name.length; + while ( i-- ) { + delete thisCache[ name[ i ] ]; + } + + // If there is no data left in the cache, we want to continue + // and let the cache object itself get destroyed + if ( pvt ? !isEmptyDataObject( thisCache ) : !jQuery.isEmptyObject( thisCache ) ) { + return; + } + } + } + + // See jQuery.data for more information + if ( !pvt ) { + delete cache[ id ].data; + + // Don't destroy the parent cache unless the internal data object + // had been the only thing left in it + if ( !isEmptyDataObject( cache[ id ] ) ) { + return; + } + } + + // Destroy the cache + if ( isNode ) { + jQuery.cleanData( [ elem ], true ); + + // Use delete when supported for expandos or `cache` is not a window per isWindow (#10080) + /* jshint eqeqeq: false */ + } else if ( support.deleteExpando || cache != cache.window ) { + /* jshint eqeqeq: true */ + delete cache[ id ]; + + // When all else fails, undefined + } else { + cache[ id ] = undefined; + } +} + +jQuery.extend( { + cache: {}, + + // The following elements (space-suffixed to avoid Object.prototype collisions) + // throw uncatchable exceptions if you attempt to set expando properties + noData: { + "applet ": true, + "embed ": true, + + // ...but Flash objects (which have this classid) *can* handle expandos + "object ": "clsid:D27CDB6E-AE6D-11cf-96B8-444553540000" + }, + + hasData: function( elem ) { + elem = elem.nodeType ? jQuery.cache[ elem[ jQuery.expando ] ] : elem[ jQuery.expando ]; + return !!elem && !isEmptyDataObject( elem ); + }, + + data: function( elem, name, data ) { + return internalData( elem, name, data ); + }, + + removeData: function( elem, name ) { + return internalRemoveData( elem, name ); + }, + + // For internal use only. + _data: function( elem, name, data ) { + return internalData( elem, name, data, true ); + }, + + _removeData: function( elem, name ) { + return internalRemoveData( elem, name, true ); + } +} ); + +jQuery.fn.extend( { + data: function( key, value ) { + var i, name, data, + elem = this[ 0 ], + attrs = elem && elem.attributes; + + // Special expections of .data basically thwart jQuery.access, + // so implement the relevant behavior ourselves + + // Gets all values + if ( key === undefined ) { + if ( this.length ) { + data = jQuery.data( elem ); + + if ( elem.nodeType === 1 && !jQuery._data( elem, "parsedAttrs" ) ) { + i = attrs.length; + while ( i-- ) { + + // Support: IE11+ + // The attrs elements can be null (#14894) + if ( attrs[ i ] ) { + name = attrs[ i ].name; + if ( name.indexOf( "data-" ) === 0 ) { + name = jQuery.camelCase( name.slice( 5 ) ); + dataAttr( elem, name, data[ name ] ); + } + } + } + jQuery._data( elem, "parsedAttrs", true ); + } + } + + return data; + } + + // Sets multiple values + if ( typeof key === "object" ) { + return this.each( function() { + jQuery.data( this, key ); + } ); + } + + return arguments.length > 1 ? + + // Sets one value + this.each( function() { + jQuery.data( this, key, value ); + } ) : + + // Gets one value + // Try to fetch any internally stored data first + elem ? dataAttr( elem, key, jQuery.data( elem, key ) ) : undefined; + }, + + removeData: function( key ) { + return this.each( function() { + jQuery.removeData( this, key ); + } ); + } +} ); + + +jQuery.extend( { + queue: function( elem, type, data ) { + var queue; + + if ( elem ) { + type = ( type || "fx" ) + "queue"; + queue = jQuery._data( elem, type ); + + // Speed up dequeue by getting out quickly if this is just a lookup + if ( data ) { + if ( !queue || jQuery.isArray( data ) ) { + queue = jQuery._data( elem, type, jQuery.makeArray( data ) ); + } else { + queue.push( data ); + } + } + return queue || []; + } + }, + + dequeue: function( elem, type ) { + type = type || "fx"; + + var queue = jQuery.queue( elem, type ), + startLength = queue.length, + fn = queue.shift(), + hooks = jQuery._queueHooks( elem, type ), + next = function() { + jQuery.dequeue( elem, type ); + }; + + // If the fx queue is dequeued, always remove the progress sentinel + if ( fn === "inprogress" ) { + fn = queue.shift(); + startLength--; + } + + if ( fn ) { + + // Add a progress sentinel to prevent the fx queue from being + // automatically dequeued + if ( type === "fx" ) { + queue.unshift( "inprogress" ); + } + + // clear up the last queue stop function + delete hooks.stop; + fn.call( elem, next, hooks ); + } + + if ( !startLength && hooks ) { + hooks.empty.fire(); + } + }, + + // not intended for public consumption - generates a queueHooks object, + // or returns the current one + _queueHooks: function( elem, type ) { + var key = type + "queueHooks"; + return jQuery._data( elem, key ) || jQuery._data( elem, key, { + empty: jQuery.Callbacks( "once memory" ).add( function() { + jQuery._removeData( elem, type + "queue" ); + jQuery._removeData( elem, key ); + } ) + } ); + } +} ); + +jQuery.fn.extend( { + queue: function( type, data ) { + var setter = 2; + + if ( typeof type !== "string" ) { + data = type; + type = "fx"; + setter--; + } + + if ( arguments.length < setter ) { + return jQuery.queue( this[ 0 ], type ); + } + + return data === undefined ? + this : + this.each( function() { + var queue = jQuery.queue( this, type, data ); + + // ensure a hooks for this queue + jQuery._queueHooks( this, type ); + + if ( type === "fx" && queue[ 0 ] !== "inprogress" ) { + jQuery.dequeue( this, type ); + } + } ); + }, + dequeue: function( type ) { + return this.each( function() { + jQuery.dequeue( this, type ); + } ); + }, + clearQueue: function( type ) { + return this.queue( type || "fx", [] ); + }, + + // Get a promise resolved when queues of a certain type + // are emptied (fx is the type by default) + promise: function( type, obj ) { + var tmp, + count = 1, + defer = jQuery.Deferred(), + elements = this, + i = this.length, + resolve = function() { + if ( !( --count ) ) { + defer.resolveWith( elements, [ elements ] ); + } + }; + + if ( typeof type !== "string" ) { + obj = type; + type = undefined; + } + type = type || "fx"; + + while ( i-- ) { + tmp = jQuery._data( elements[ i ], type + "queueHooks" ); + if ( tmp && tmp.empty ) { + count++; + tmp.empty.add( resolve ); + } + } + resolve(); + return defer.promise( obj ); + } +} ); + + +( function() { + var shrinkWrapBlocksVal; + + support.shrinkWrapBlocks = function() { + if ( shrinkWrapBlocksVal != null ) { + return shrinkWrapBlocksVal; + } + + // Will be changed later if needed. + shrinkWrapBlocksVal = false; + + // Minified: var b,c,d + var div, body, container; + + body = document.getElementsByTagName( "body" )[ 0 ]; + if ( !body || !body.style ) { + + // Test fired too early or in an unsupported environment, exit. + return; + } + + // Setup + div = document.createElement( "div" ); + container = document.createElement( "div" ); + container.style.cssText = "position:absolute;border:0;width:0;height:0;top:0;left:-9999px"; + body.appendChild( container ).appendChild( div ); + + // Support: IE6 + // Check if elements with layout shrink-wrap their children + if ( typeof div.style.zoom !== "undefined" ) { + + // Reset CSS: box-sizing; display; margin; border + div.style.cssText = + + // Support: Firefox<29, Android 2.3 + // Vendor-prefix box-sizing + "-webkit-box-sizing:content-box;-moz-box-sizing:content-box;" + + "box-sizing:content-box;display:block;margin:0;border:0;" + + "padding:1px;width:1px;zoom:1"; + div.appendChild( document.createElement( "div" ) ).style.width = "5px"; + shrinkWrapBlocksVal = div.offsetWidth !== 3; + } + + body.removeChild( container ); + + return shrinkWrapBlocksVal; + }; + +} )(); +var pnum = ( /[+-]?(?:\d*\.|)\d+(?:[eE][+-]?\d+|)/ ).source; + +var rcssNum = new RegExp( "^(?:([+-])=|)(" + pnum + ")([a-z%]*)$", "i" ); + + +var cssExpand = [ "Top", "Right", "Bottom", "Left" ]; + +var isHidden = function( elem, el ) { + + // isHidden might be called from jQuery#filter function; + // in that case, element will be second argument + elem = el || elem; + return jQuery.css( elem, "display" ) === "none" || + !jQuery.contains( elem.ownerDocument, elem ); + }; + + + +function adjustCSS( elem, prop, valueParts, tween ) { + var adjusted, + scale = 1, + maxIterations = 20, + currentValue = tween ? + function() { return tween.cur(); } : + function() { return jQuery.css( elem, prop, "" ); }, + initial = currentValue(), + unit = valueParts && valueParts[ 3 ] || ( jQuery.cssNumber[ prop ] ? "" : "px" ), + + // Starting value computation is required for potential unit mismatches + initialInUnit = ( jQuery.cssNumber[ prop ] || unit !== "px" && +initial ) && + rcssNum.exec( jQuery.css( elem, prop ) ); + + if ( initialInUnit && initialInUnit[ 3 ] !== unit ) { + + // Trust units reported by jQuery.css + unit = unit || initialInUnit[ 3 ]; + + // Make sure we update the tween properties later on + valueParts = valueParts || []; + + // Iteratively approximate from a nonzero starting point + initialInUnit = +initial || 1; + + do { + + // If previous iteration zeroed out, double until we get *something*. + // Use string for doubling so we don't accidentally see scale as unchanged below + scale = scale || ".5"; + + // Adjust and apply + initialInUnit = initialInUnit / scale; + jQuery.style( elem, prop, initialInUnit + unit ); + + // Update scale, tolerating zero or NaN from tween.cur() + // Break the loop if scale is unchanged or perfect, or if we've just had enough. + } while ( + scale !== ( scale = currentValue() / initial ) && scale !== 1 && --maxIterations + ); + } + + if ( valueParts ) { + initialInUnit = +initialInUnit || +initial || 0; + + // Apply relative offset (+=/-=) if specified + adjusted = valueParts[ 1 ] ? + initialInUnit + ( valueParts[ 1 ] + 1 ) * valueParts[ 2 ] : + +valueParts[ 2 ]; + if ( tween ) { + tween.unit = unit; + tween.start = initialInUnit; + tween.end = adjusted; + } + } + return adjusted; +} + + +// Multifunctional method to get and set values of a collection +// The value/s can optionally be executed if it's a function +var access = function( elems, fn, key, value, chainable, emptyGet, raw ) { + var i = 0, + length = elems.length, + bulk = key == null; + + // Sets many values + if ( jQuery.type( key ) === "object" ) { + chainable = true; + for ( i in key ) { + access( elems, fn, i, key[ i ], true, emptyGet, raw ); + } + + // Sets one value + } else if ( value !== undefined ) { + chainable = true; + + if ( !jQuery.isFunction( value ) ) { + raw = true; + } + + if ( bulk ) { + + // Bulk operations run against the entire set + if ( raw ) { + fn.call( elems, value ); + fn = null; + + // ...except when executing function values + } else { + bulk = fn; + fn = function( elem, key, value ) { + return bulk.call( jQuery( elem ), value ); + }; + } + } + + if ( fn ) { + for ( ; i < length; i++ ) { + fn( + elems[ i ], + key, + raw ? value : value.call( elems[ i ], i, fn( elems[ i ], key ) ) + ); + } + } + } + + return chainable ? + elems : + + // Gets + bulk ? + fn.call( elems ) : + length ? fn( elems[ 0 ], key ) : emptyGet; +}; +var rcheckableType = ( /^(?:checkbox|radio)$/i ); + +var rtagName = ( /<([\w:-]+)/ ); + +var rscriptType = ( /^$|\/(?:java|ecma)script/i ); + +var rleadingWhitespace = ( /^\s+/ ); + +var nodeNames = "abbr|article|aside|audio|bdi|canvas|data|datalist|" + + "details|dialog|figcaption|figure|footer|header|hgroup|main|" + + "mark|meter|nav|output|picture|progress|section|summary|template|time|video"; + + + function createSafeFragment( document ) { var list = nodeNames.split( "|" ), safeFrag = document.createDocumentFragment(); @@ -5832,404 +4455,1463 @@ function createSafeFragment( document ) { return safeFrag; } -var nodeNames = "abbr|article|aside|audio|bdi|canvas|data|datalist|details|figcaption|figure|footer|" + - "header|hgroup|mark|meter|nav|output|progress|section|summary|time|video", - rinlinejQuery = / jQuery\d+="(?:null|\d+)"/g, - rnoshimcache = new RegExp("<(?:" + nodeNames + ")[\\s/>]", "i"), - rleadingWhitespace = /^\s+/, - rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:]+)[^>]*)\/>/gi, - rtagName = /<([\w:]+)/, - rtbody = /\s*$/g, - // We have to close these tags to support XHTML (#13200) - wrapMap = { - option: [ 1, "" ], - legend: [ 1, "
", "
" ], - area: [ 1, "", "" ], - param: [ 1, "", "" ], - thead: [ 1, "", "
" ], - tr: [ 2, "", "
" ], - col: [ 2, "", "
" ], - td: [ 3, "", "
" ], +( function() { + var div = document.createElement( "div" ), + fragment = document.createDocumentFragment(), + input = document.createElement( "input" ); - // IE6-8 can't serialize link, script, style, or any html5 (NoScope) tags, - // unless wrapped in a div with non-breaking characters in front of it. - _default: jQuery.support.htmlSerialize ? [ 0, "", "" ] : [ 1, "X
", "
" ] - }, - safeFragment = createSafeFragment( document ), - fragmentDiv = safeFragment.appendChild( document.createElement("div") ); + // Setup + div.innerHTML = "
a"; + // IE strips leading whitespace when .innerHTML is used + support.leadingWhitespace = div.firstChild.nodeType === 3; + + // Make sure that tbody elements aren't automatically inserted + // IE will insert them into empty tables + support.tbody = !div.getElementsByTagName( "tbody" ).length; + + // Make sure that link elements get serialized correctly by innerHTML + // This requires a wrapper element in IE + support.htmlSerialize = !!div.getElementsByTagName( "link" ).length; + + // Makes sure cloning an html5 element does not cause problems + // Where outerHTML is undefined, this still works + support.html5Clone = + document.createElement( "nav" ).cloneNode( true ).outerHTML !== "<:nav>"; + + // Check if a disconnected checkbox will retain its checked + // value of true after appended to the DOM (IE6/7) + input.type = "checkbox"; + input.checked = true; + fragment.appendChild( input ); + support.appendChecked = input.checked; + + // Make sure textarea (and checkbox) defaultValue is properly cloned + // Support: IE6-IE11+ + div.innerHTML = ""; + support.noCloneChecked = !!div.cloneNode( true ).lastChild.defaultValue; + + // #11217 - WebKit loses check when the name is after the checked attribute + fragment.appendChild( div ); + + // Support: Windows Web Apps (WWA) + // `name` and `type` must use .setAttribute for WWA (#14901) + input = document.createElement( "input" ); + input.setAttribute( "type", "radio" ); + input.setAttribute( "checked", "checked" ); + input.setAttribute( "name", "t" ); + + div.appendChild( input ); + + // Support: Safari 5.1, iOS 5.1, Android 4.x, Android 2.3 + // old WebKit doesn't clone checked state correctly in fragments + support.checkClone = div.cloneNode( true ).cloneNode( true ).lastChild.checked; + + // Support: IE<9 + // Cloned elements keep attachEvent handlers, we use addEventListener on IE9+ + support.noCloneEvent = !!div.addEventListener; + + // Support: IE<9 + // Since attributes and properties are the same in IE, + // cleanData must set properties to undefined rather than use removeAttribute + div[ jQuery.expando ] = 1; + support.attributes = !div.getAttribute( jQuery.expando ); +} )(); + + +// We have to close these tags to support XHTML (#13200) +var wrapMap = { + option: [ 1, "" ], + legend: [ 1, "
", "
" ], + area: [ 1, "", "" ], + + // Support: IE8 + param: [ 1, "", "" ], + thead: [ 1, "", "
" ], + tr: [ 2, "", "
" ], + col: [ 2, "", "
" ], + td: [ 3, "", "
" ], + + // IE6-8 can't serialize link, script, style, or any html5 (NoScope) tags, + // unless wrapped in a div with non-breaking characters in front of it. + _default: support.htmlSerialize ? [ 0, "", "" ] : [ 1, "X
", "
" ] +}; + +// Support: IE8-IE9 wrapMap.optgroup = wrapMap.option; + wrapMap.tbody = wrapMap.tfoot = wrapMap.colgroup = wrapMap.caption = wrapMap.thead; wrapMap.th = wrapMap.td; -jQuery.fn.extend({ - text: function( value ) { - return jQuery.access( this, function( value ) { - return value === undefined ? - jQuery.text( this ) : - this.empty().append( ( this[0] && this[0].ownerDocument || document ).createTextNode( value ) ); - }, null, value, arguments.length ); - }, - wrapAll: function( html ) { - if ( jQuery.isFunction( html ) ) { - return this.each(function(i) { - jQuery(this).wrapAll( html.call(this, i) ); - }); - } - - if ( this[0] ) { - // The elements to wrap the target around - var wrap = jQuery( html, this[0].ownerDocument ).eq(0).clone(true); - - if ( this[0].parentNode ) { - wrap.insertBefore( this[0] ); - } - - wrap.map(function() { - var elem = this; - - while ( elem.firstChild && elem.firstChild.nodeType === 1 ) { - elem = elem.firstChild; - } - - return elem; - }).append( this ); - } - - return this; - }, - - wrapInner: function( html ) { - if ( jQuery.isFunction( html ) ) { - return this.each(function(i) { - jQuery(this).wrapInner( html.call(this, i) ); - }); - } - - return this.each(function() { - var self = jQuery( this ), - contents = self.contents(); - - if ( contents.length ) { - contents.wrapAll( html ); +function getAll( context, tag ) { + var elems, elem, + i = 0, + found = typeof context.getElementsByTagName !== "undefined" ? + context.getElementsByTagName( tag || "*" ) : + typeof context.querySelectorAll !== "undefined" ? + context.querySelectorAll( tag || "*" ) : + undefined; + if ( !found ) { + for ( found = [], elems = context.childNodes || context; + ( elem = elems[ i ] ) != null; + i++ + ) { + if ( !tag || jQuery.nodeName( elem, tag ) ) { + found.push( elem ); } else { - self.append( html ); - } - }); - }, - - wrap: function( html ) { - var isFunction = jQuery.isFunction( html ); - - return this.each(function(i) { - jQuery( this ).wrapAll( isFunction ? html.call(this, i) : html ); - }); - }, - - unwrap: function() { - return this.parent().each(function() { - if ( !jQuery.nodeName( this, "body" ) ) { - jQuery( this ).replaceWith( this.childNodes ); - } - }).end(); - }, - - append: function() { - return this.domManip(arguments, true, function( elem ) { - if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { - this.appendChild( elem ); - } - }); - }, - - prepend: function() { - return this.domManip(arguments, true, function( elem ) { - if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { - this.insertBefore( elem, this.firstChild ); - } - }); - }, - - before: function() { - return this.domManip( arguments, false, function( elem ) { - if ( this.parentNode ) { - this.parentNode.insertBefore( elem, this ); - } - }); - }, - - after: function() { - return this.domManip( arguments, false, function( elem ) { - if ( this.parentNode ) { - this.parentNode.insertBefore( elem, this.nextSibling ); - } - }); - }, - - // keepData is for internal use only--do not document - remove: function( selector, keepData ) { - var elem, - i = 0; - - for ( ; (elem = this[i]) != null; i++ ) { - if ( !selector || jQuery.filter( selector, [ elem ] ).length > 0 ) { - if ( !keepData && elem.nodeType === 1 ) { - jQuery.cleanData( getAll( elem ) ); - } - - if ( elem.parentNode ) { - if ( keepData && jQuery.contains( elem.ownerDocument, elem ) ) { - setGlobalEval( getAll( elem, "script" ) ); - } - elem.parentNode.removeChild( elem ); - } + jQuery.merge( found, getAll( elem, tag ) ); } } - - return this; - }, - - empty: function() { - var elem, - i = 0; - - for ( ; (elem = this[i]) != null; i++ ) { - // Remove element nodes and prevent memory leaks - if ( elem.nodeType === 1 ) { - jQuery.cleanData( getAll( elem, false ) ); - } - - // Remove any remaining nodes - while ( elem.firstChild ) { - elem.removeChild( elem.firstChild ); - } - - // If this is a select, ensure that it displays empty (#12336) - // Support: IE<9 - if ( elem.options && jQuery.nodeName( elem, "select" ) ) { - elem.options.length = 0; - } - } - - return this; - }, - - clone: function( dataAndEvents, deepDataAndEvents ) { - dataAndEvents = dataAndEvents == null ? false : dataAndEvents; - deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; - - return this.map( function () { - return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); - }); - }, - - html: function( value ) { - return jQuery.access( this, function( value ) { - var elem = this[0] || {}, - i = 0, - l = this.length; - - if ( value === undefined ) { - return elem.nodeType === 1 ? - elem.innerHTML.replace( rinlinejQuery, "" ) : - undefined; - } - - // See if we can take a shortcut and just use innerHTML - if ( typeof value === "string" && !rnoInnerhtml.test( value ) && - ( jQuery.support.htmlSerialize || !rnoshimcache.test( value ) ) && - ( jQuery.support.leadingWhitespace || !rleadingWhitespace.test( value ) ) && - !wrapMap[ ( rtagName.exec( value ) || ["", ""] )[1].toLowerCase() ] ) { - - value = value.replace( rxhtmlTag, "<$1>" ); - - try { - for (; i < l; i++ ) { - // Remove element nodes and prevent memory leaks - elem = this[i] || {}; - if ( elem.nodeType === 1 ) { - jQuery.cleanData( getAll( elem, false ) ); - elem.innerHTML = value; - } - } - - elem = 0; - - // If using innerHTML throws an exception, use the fallback method - } catch(e) {} - } - - if ( elem ) { - this.empty().append( value ); - } - }, null, value, arguments.length ); - }, - - replaceWith: function( value ) { - var isFunc = jQuery.isFunction( value ); - - // Make sure that the elements are removed from the DOM before they are inserted - // this can help fix replacing a parent with child elements - if ( !isFunc && typeof value !== "string" ) { - value = jQuery( value ).not( this ).detach(); - } - - return this.domManip( [ value ], true, function( elem ) { - var next = this.nextSibling, - parent = this.parentNode; - - if ( parent ) { - jQuery( this ).remove(); - parent.insertBefore( elem, next ); - } - }); - }, - - detach: function( selector ) { - return this.remove( selector, true ); - }, - - domManip: function( args, table, callback ) { - - // Flatten any nested arrays - args = core_concat.apply( [], args ); - - var first, node, hasScripts, - scripts, doc, fragment, - i = 0, - l = this.length, - set = this, - iNoClone = l - 1, - value = args[0], - isFunction = jQuery.isFunction( value ); - - // We can't cloneNode fragments that contain checked, in WebKit - if ( isFunction || !( l <= 1 || typeof value !== "string" || jQuery.support.checkClone || !rchecked.test( value ) ) ) { - return this.each(function( index ) { - var self = set.eq( index ); - if ( isFunction ) { - args[0] = value.call( this, index, table ? self.html() : undefined ); - } - self.domManip( args, table, callback ); - }); - } - - if ( l ) { - fragment = jQuery.buildFragment( args, this[ 0 ].ownerDocument, false, this ); - first = fragment.firstChild; - - if ( fragment.childNodes.length === 1 ) { - fragment = first; - } - - if ( first ) { - table = table && jQuery.nodeName( first, "tr" ); - scripts = jQuery.map( getAll( fragment, "script" ), disableScript ); - hasScripts = scripts.length; - - // Use the original fragment for the last item instead of the first because it can end up - // being emptied incorrectly in certain situations (#8070). - for ( ; i < l; i++ ) { - node = fragment; - - if ( i !== iNoClone ) { - node = jQuery.clone( node, true, true ); - - // Keep references to cloned scripts for later restoration - if ( hasScripts ) { - jQuery.merge( scripts, getAll( node, "script" ) ); - } - } - - callback.call( - table && jQuery.nodeName( this[i], "table" ) ? - findOrAppend( this[i], "tbody" ) : - this[i], - node, - i - ); - } - - if ( hasScripts ) { - doc = scripts[ scripts.length - 1 ].ownerDocument; - - // Reenable scripts - jQuery.map( scripts, restoreScript ); - - // Evaluate executable scripts on first document insertion - for ( i = 0; i < hasScripts; i++ ) { - node = scripts[ i ]; - if ( rscriptType.test( node.type || "" ) && - !jQuery._data( node, "globalEval" ) && jQuery.contains( doc, node ) ) { - - if ( node.src ) { - // Hope ajax is available... - jQuery.ajax({ - url: node.src, - type: "GET", - dataType: "script", - async: false, - global: false, - "throws": true - }); - } else { - jQuery.globalEval( ( node.text || node.textContent || node.innerHTML || "" ).replace( rcleanScript, "" ) ); - } - } - } - } - - // Fix #11809: Avoid leaking memory - fragment = first = null; - } - } - - return this; } -}); -function findOrAppend( elem, tag ) { - return elem.getElementsByTagName( tag )[0] || elem.appendChild( elem.ownerDocument.createElement( tag ) ); + return tag === undefined || tag && jQuery.nodeName( context, tag ) ? + jQuery.merge( [ context ], found ) : + found; } -// Replace/restore the type attribute of script elements for safe DOM manipulation -function disableScript( elem ) { - var attr = elem.getAttributeNode("type"); - elem.type = ( attr && attr.specified ) + "/" + elem.type; - return elem; -} -function restoreScript( elem ) { - var match = rscriptTypeMasked.exec( elem.type ); - if ( match ) { - elem.type = match[1]; - } else { - elem.removeAttribute("type"); - } - return elem; -} // Mark scripts as having already been evaluated function setGlobalEval( elems, refElements ) { var elem, i = 0; - for ( ; (elem = elems[i]) != null; i++ ) { - jQuery._data( elem, "globalEval", !refElements || jQuery._data( refElements[i], "globalEval" ) ); + for ( ; ( elem = elems[ i ] ) != null; i++ ) { + jQuery._data( + elem, + "globalEval", + !refElements || jQuery._data( refElements[ i ], "globalEval" ) + ); } } -function cloneCopyEvent( src, dest ) { +var rhtml = /<|&#?\w+;/, + rtbody = / from table fragments + if ( !support.tbody ) { + + // String was a , *may* have spurious + elem = tag === "table" && !rtbody.test( elem ) ? + tmp.firstChild : + + // String was a bare or + wrap[ 1 ] === "
" && !rtbody.test( elem ) ? + tmp : + 0; + + j = elem && elem.childNodes.length; + while ( j-- ) { + if ( jQuery.nodeName( ( tbody = elem.childNodes[ j ] ), "tbody" ) && + !tbody.childNodes.length ) { + + elem.removeChild( tbody ); + } + } + } + + jQuery.merge( nodes, tmp.childNodes ); + + // Fix #12392 for WebKit and IE > 9 + tmp.textContent = ""; + + // Fix #12392 for oldIE + while ( tmp.firstChild ) { + tmp.removeChild( tmp.firstChild ); + } + + // Remember the top-level container for proper cleanup + tmp = safe.lastChild; + } + } + } + + // Fix #11356: Clear elements from fragment + if ( tmp ) { + safe.removeChild( tmp ); + } + + // Reset defaultChecked for any radios and checkboxes + // about to be appended to the DOM in IE 6/7 (#8060) + if ( !support.appendChecked ) { + jQuery.grep( getAll( nodes, "input" ), fixDefaultChecked ); + } + + i = 0; + while ( ( elem = nodes[ i++ ] ) ) { + + // Skip elements already in the context collection (trac-4087) + if ( selection && jQuery.inArray( elem, selection ) > -1 ) { + if ( ignored ) { + ignored.push( elem ); + } + + continue; + } + + contains = jQuery.contains( elem.ownerDocument, elem ); + + // Append to fragment + tmp = getAll( safe.appendChild( elem ), "script" ); + + // Preserve script evaluation history + if ( contains ) { + setGlobalEval( tmp ); + } + + // Capture executables + if ( scripts ) { + j = 0; + while ( ( elem = tmp[ j++ ] ) ) { + if ( rscriptType.test( elem.type || "" ) ) { + scripts.push( elem ); + } + } + } + } + + tmp = null; + + return safe; +} + + +( function() { + var i, eventName, + div = document.createElement( "div" ); + + // Support: IE<9 (lack submit/change bubble), Firefox (lack focus(in | out) events) + for ( i in { submit: true, change: true, focusin: true } ) { + eventName = "on" + i; + + if ( !( support[ i ] = eventName in window ) ) { + + // Beware of CSP restrictions (https://developer.mozilla.org/en/Security/CSP) + div.setAttribute( eventName, "t" ); + support[ i ] = div.attributes[ eventName ].expando === false; + } + } + + // Null elements to avoid leaks in IE. + div = null; +} )(); + + +var rformElems = /^(?:input|select|textarea)$/i, + rkeyEvent = /^key/, + rmouseEvent = /^(?:mouse|pointer|contextmenu|drag|drop)|click/, + rfocusMorph = /^(?:focusinfocus|focusoutblur)$/, + rtypenamespace = /^([^.]*)(?:\.(.+)|)/; + +function returnTrue() { + return true; +} + +function returnFalse() { + return false; +} + +// Support: IE9 +// See #13393 for more info +function safeActiveElement() { + try { + return document.activeElement; + } catch ( err ) { } +} + +function on( elem, types, selector, data, fn, one ) { + var origFn, type; + + // Types can be a map of types/handlers + if ( typeof types === "object" ) { + + // ( types-Object, selector, data ) + if ( typeof selector !== "string" ) { + + // ( types-Object, data ) + data = data || selector; + selector = undefined; + } + for ( type in types ) { + on( elem, type, selector, data, types[ type ], one ); + } + return elem; + } + + if ( data == null && fn == null ) { + + // ( types, fn ) + fn = selector; + data = selector = undefined; + } else if ( fn == null ) { + if ( typeof selector === "string" ) { + + // ( types, selector, fn ) + fn = data; + data = undefined; + } else { + + // ( types, data, fn ) + fn = data; + data = selector; + selector = undefined; + } + } + if ( fn === false ) { + fn = returnFalse; + } else if ( !fn ) { + return elem; + } + + if ( one === 1 ) { + origFn = fn; + fn = function( event ) { + + // Can use an empty set, since event contains the info + jQuery().off( event ); + return origFn.apply( this, arguments ); + }; + + // Use same guid so caller can remove using origFn + fn.guid = origFn.guid || ( origFn.guid = jQuery.guid++ ); + } + return elem.each( function() { + jQuery.event.add( this, types, fn, data, selector ); + } ); +} + +/* + * Helper functions for managing events -- not part of the public interface. + * Props to Dean Edwards' addEvent library for many of the ideas. + */ +jQuery.event = { + + global: {}, + + add: function( elem, types, handler, data, selector ) { + var tmp, events, t, handleObjIn, + special, eventHandle, handleObj, + handlers, type, namespaces, origType, + elemData = jQuery._data( elem ); + + // Don't attach events to noData or text/comment nodes (but allow plain objects) + if ( !elemData ) { + return; + } + + // Caller can pass in an object of custom data in lieu of the handler + if ( handler.handler ) { + handleObjIn = handler; + handler = handleObjIn.handler; + selector = handleObjIn.selector; + } + + // Make sure that the handler has a unique ID, used to find/remove it later + if ( !handler.guid ) { + handler.guid = jQuery.guid++; + } + + // Init the element's event structure and main handler, if this is the first + if ( !( events = elemData.events ) ) { + events = elemData.events = {}; + } + if ( !( eventHandle = elemData.handle ) ) { + eventHandle = elemData.handle = function( e ) { + + // Discard the second event of a jQuery.event.trigger() and + // when an event is called after a page has unloaded + return typeof jQuery !== "undefined" && + ( !e || jQuery.event.triggered !== e.type ) ? + jQuery.event.dispatch.apply( eventHandle.elem, arguments ) : + undefined; + }; + + // Add elem as a property of the handle fn to prevent a memory leak + // with IE non-native events + eventHandle.elem = elem; + } + + // Handle multiple events separated by a space + types = ( types || "" ).match( rnotwhite ) || [ "" ]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[ t ] ) || []; + type = origType = tmp[ 1 ]; + namespaces = ( tmp[ 2 ] || "" ).split( "." ).sort(); + + // There *must* be a type, no attaching namespace-only handlers + if ( !type ) { + continue; + } + + // If event changes its type, use the special event handlers for the changed type + special = jQuery.event.special[ type ] || {}; + + // If selector defined, determine special event api type, otherwise given type + type = ( selector ? special.delegateType : special.bindType ) || type; + + // Update special based on newly reset type + special = jQuery.event.special[ type ] || {}; + + // handleObj is passed to all event handlers + handleObj = jQuery.extend( { + type: type, + origType: origType, + data: data, + handler: handler, + guid: handler.guid, + selector: selector, + needsContext: selector && jQuery.expr.match.needsContext.test( selector ), + namespace: namespaces.join( "." ) + }, handleObjIn ); + + // Init the event handler queue if we're the first + if ( !( handlers = events[ type ] ) ) { + handlers = events[ type ] = []; + handlers.delegateCount = 0; + + // Only use addEventListener/attachEvent if the special events handler returns false + if ( !special.setup || + special.setup.call( elem, data, namespaces, eventHandle ) === false ) { + + // Bind the global event handler to the element + if ( elem.addEventListener ) { + elem.addEventListener( type, eventHandle, false ); + + } else if ( elem.attachEvent ) { + elem.attachEvent( "on" + type, eventHandle ); + } + } + } + + if ( special.add ) { + special.add.call( elem, handleObj ); + + if ( !handleObj.handler.guid ) { + handleObj.handler.guid = handler.guid; + } + } + + // Add to the element's handler list, delegates in front + if ( selector ) { + handlers.splice( handlers.delegateCount++, 0, handleObj ); + } else { + handlers.push( handleObj ); + } + + // Keep track of which events have ever been used, for event optimization + jQuery.event.global[ type ] = true; + } + + // Nullify elem to prevent memory leaks in IE + elem = null; + }, + + // Detach an event or set of events from an element + remove: function( elem, types, handler, selector, mappedTypes ) { + var j, handleObj, tmp, + origCount, t, events, + special, handlers, type, + namespaces, origType, + elemData = jQuery.hasData( elem ) && jQuery._data( elem ); + + if ( !elemData || !( events = elemData.events ) ) { + return; + } + + // Once for each type.namespace in types; type may be omitted + types = ( types || "" ).match( rnotwhite ) || [ "" ]; + t = types.length; + while ( t-- ) { + tmp = rtypenamespace.exec( types[ t ] ) || []; + type = origType = tmp[ 1 ]; + namespaces = ( tmp[ 2 ] || "" ).split( "." ).sort(); + + // Unbind all events (on this namespace, if provided) for the element + if ( !type ) { + for ( type in events ) { + jQuery.event.remove( elem, type + types[ t ], handler, selector, true ); + } + continue; + } + + special = jQuery.event.special[ type ] || {}; + type = ( selector ? special.delegateType : special.bindType ) || type; + handlers = events[ type ] || []; + tmp = tmp[ 2 ] && + new RegExp( "(^|\\.)" + namespaces.join( "\\.(?:.*\\.|)" ) + "(\\.|$)" ); + + // Remove matching events + origCount = j = handlers.length; + while ( j-- ) { + handleObj = handlers[ j ]; + + if ( ( mappedTypes || origType === handleObj.origType ) && + ( !handler || handler.guid === handleObj.guid ) && + ( !tmp || tmp.test( handleObj.namespace ) ) && + ( !selector || selector === handleObj.selector || + selector === "**" && handleObj.selector ) ) { + handlers.splice( j, 1 ); + + if ( handleObj.selector ) { + handlers.delegateCount--; + } + if ( special.remove ) { + special.remove.call( elem, handleObj ); + } + } + } + + // Remove generic event handler if we removed something and no more handlers exist + // (avoids potential for endless recursion during removal of special event handlers) + if ( origCount && !handlers.length ) { + if ( !special.teardown || + special.teardown.call( elem, namespaces, elemData.handle ) === false ) { + + jQuery.removeEvent( elem, type, elemData.handle ); + } + + delete events[ type ]; + } + } + + // Remove the expando if it's no longer used + if ( jQuery.isEmptyObject( events ) ) { + delete elemData.handle; + + // removeData also checks for emptiness and clears the expando if empty + // so use it instead of delete + jQuery._removeData( elem, "events" ); + } + }, + + trigger: function( event, data, elem, onlyHandlers ) { + var handle, ontype, cur, + bubbleType, special, tmp, i, + eventPath = [ elem || document ], + type = hasOwn.call( event, "type" ) ? event.type : event, + namespaces = hasOwn.call( event, "namespace" ) ? event.namespace.split( "." ) : []; + + cur = tmp = elem = elem || document; + + // Don't do events on text and comment nodes + if ( elem.nodeType === 3 || elem.nodeType === 8 ) { + return; + } + + // focus/blur morphs to focusin/out; ensure we're not firing them right now + if ( rfocusMorph.test( type + jQuery.event.triggered ) ) { + return; + } + + if ( type.indexOf( "." ) > -1 ) { + + // Namespaced trigger; create a regexp to match event type in handle() + namespaces = type.split( "." ); + type = namespaces.shift(); + namespaces.sort(); + } + ontype = type.indexOf( ":" ) < 0 && "on" + type; + + // Caller can pass in a jQuery.Event object, Object, or just an event type string + event = event[ jQuery.expando ] ? + event : + new jQuery.Event( type, typeof event === "object" && event ); + + // Trigger bitmask: & 1 for native handlers; & 2 for jQuery (always true) + event.isTrigger = onlyHandlers ? 2 : 3; + event.namespace = namespaces.join( "." ); + event.rnamespace = event.namespace ? + new RegExp( "(^|\\.)" + namespaces.join( "\\.(?:.*\\.|)" ) + "(\\.|$)" ) : + null; + + // Clean up the event in case it is being reused + event.result = undefined; + if ( !event.target ) { + event.target = elem; + } + + // Clone any incoming data and prepend the event, creating the handler arg list + data = data == null ? + [ event ] : + jQuery.makeArray( data, [ event ] ); + + // Allow special events to draw outside the lines + special = jQuery.event.special[ type ] || {}; + if ( !onlyHandlers && special.trigger && special.trigger.apply( elem, data ) === false ) { + return; + } + + // Determine event propagation path in advance, per W3C events spec (#9951) + // Bubble up to document, then to window; watch for a global ownerDocument var (#9724) + if ( !onlyHandlers && !special.noBubble && !jQuery.isWindow( elem ) ) { + + bubbleType = special.delegateType || type; + if ( !rfocusMorph.test( bubbleType + type ) ) { + cur = cur.parentNode; + } + for ( ; cur; cur = cur.parentNode ) { + eventPath.push( cur ); + tmp = cur; + } + + // Only add window if we got to document (e.g., not plain obj or detached DOM) + if ( tmp === ( elem.ownerDocument || document ) ) { + eventPath.push( tmp.defaultView || tmp.parentWindow || window ); + } + } + + // Fire handlers on the event path + i = 0; + while ( ( cur = eventPath[ i++ ] ) && !event.isPropagationStopped() ) { + + event.type = i > 1 ? + bubbleType : + special.bindType || type; + + // jQuery handler + handle = ( jQuery._data( cur, "events" ) || {} )[ event.type ] && + jQuery._data( cur, "handle" ); + + if ( handle ) { + handle.apply( cur, data ); + } + + // Native handler + handle = ontype && cur[ ontype ]; + if ( handle && handle.apply && acceptData( cur ) ) { + event.result = handle.apply( cur, data ); + if ( event.result === false ) { + event.preventDefault(); + } + } + } + event.type = type; + + // If nobody prevented the default action, do it now + if ( !onlyHandlers && !event.isDefaultPrevented() ) { + + if ( + ( !special._default || + special._default.apply( eventPath.pop(), data ) === false + ) && acceptData( elem ) + ) { + + // Call a native DOM method on the target with the same name name as the event. + // Can't use an .isFunction() check here because IE6/7 fails that test. + // Don't do default actions on window, that's where global variables be (#6170) + if ( ontype && elem[ type ] && !jQuery.isWindow( elem ) ) { + + // Don't re-trigger an onFOO event when we call its FOO() method + tmp = elem[ ontype ]; + + if ( tmp ) { + elem[ ontype ] = null; + } + + // Prevent re-triggering of the same event, since we already bubbled it above + jQuery.event.triggered = type; + try { + elem[ type ](); + } catch ( e ) { + + // IE<9 dies on focus/blur to hidden element (#1486,#12518) + // only reproducible on winXP IE8 native, not IE9 in IE8 mode + } + jQuery.event.triggered = undefined; + + if ( tmp ) { + elem[ ontype ] = tmp; + } + } + } + } + + return event.result; + }, + + dispatch: function( event ) { + + // Make a writable jQuery.Event from the native event object + event = jQuery.event.fix( event ); + + var i, j, ret, matched, handleObj, + handlerQueue = [], + args = slice.call( arguments ), + handlers = ( jQuery._data( this, "events" ) || {} )[ event.type ] || [], + special = jQuery.event.special[ event.type ] || {}; + + // Use the fix-ed jQuery.Event rather than the (read-only) native event + args[ 0 ] = event; + event.delegateTarget = this; + + // Call the preDispatch hook for the mapped type, and let it bail if desired + if ( special.preDispatch && special.preDispatch.call( this, event ) === false ) { + return; + } + + // Determine handlers + handlerQueue = jQuery.event.handlers.call( this, event, handlers ); + + // Run delegates first; they may want to stop propagation beneath us + i = 0; + while ( ( matched = handlerQueue[ i++ ] ) && !event.isPropagationStopped() ) { + event.currentTarget = matched.elem; + + j = 0; + while ( ( handleObj = matched.handlers[ j++ ] ) && + !event.isImmediatePropagationStopped() ) { + + // Triggered event must either 1) have no namespace, or 2) have namespace(s) + // a subset or equal to those in the bound event (both can have no namespace). + if ( !event.rnamespace || event.rnamespace.test( handleObj.namespace ) ) { + + event.handleObj = handleObj; + event.data = handleObj.data; + + ret = ( ( jQuery.event.special[ handleObj.origType ] || {} ).handle || + handleObj.handler ).apply( matched.elem, args ); + + if ( ret !== undefined ) { + if ( ( event.result = ret ) === false ) { + event.preventDefault(); + event.stopPropagation(); + } + } + } + } + } + + // Call the postDispatch hook for the mapped type + if ( special.postDispatch ) { + special.postDispatch.call( this, event ); + } + + return event.result; + }, + + handlers: function( event, handlers ) { + var i, matches, sel, handleObj, + handlerQueue = [], + delegateCount = handlers.delegateCount, + cur = event.target; + + // Support (at least): Chrome, IE9 + // Find delegate handlers + // Black-hole SVG instance trees (#13180) + // + // Support: Firefox<=42+ + // Avoid non-left-click in FF but don't block IE radio events (#3861, gh-2343) + if ( delegateCount && cur.nodeType && + ( event.type !== "click" || isNaN( event.button ) || event.button < 1 ) ) { + + /* jshint eqeqeq: false */ + for ( ; cur != this; cur = cur.parentNode || this ) { + /* jshint eqeqeq: true */ + + // Don't check non-elements (#13208) + // Don't process clicks on disabled elements (#6911, #8165, #11382, #11764) + if ( cur.nodeType === 1 && ( cur.disabled !== true || event.type !== "click" ) ) { + matches = []; + for ( i = 0; i < delegateCount; i++ ) { + handleObj = handlers[ i ]; + + // Don't conflict with Object.prototype properties (#13203) + sel = handleObj.selector + " "; + + if ( matches[ sel ] === undefined ) { + matches[ sel ] = handleObj.needsContext ? + jQuery( sel, this ).index( cur ) > -1 : + jQuery.find( sel, this, null, [ cur ] ).length; + } + if ( matches[ sel ] ) { + matches.push( handleObj ); + } + } + if ( matches.length ) { + handlerQueue.push( { elem: cur, handlers: matches } ); + } + } + } + } + + // Add the remaining (directly-bound) handlers + if ( delegateCount < handlers.length ) { + handlerQueue.push( { elem: this, handlers: handlers.slice( delegateCount ) } ); + } + + return handlerQueue; + }, + + fix: function( event ) { + if ( event[ jQuery.expando ] ) { + return event; + } + + // Create a writable copy of the event object and normalize some properties + var i, prop, copy, + type = event.type, + originalEvent = event, + fixHook = this.fixHooks[ type ]; + + if ( !fixHook ) { + this.fixHooks[ type ] = fixHook = + rmouseEvent.test( type ) ? this.mouseHooks : + rkeyEvent.test( type ) ? this.keyHooks : + {}; + } + copy = fixHook.props ? this.props.concat( fixHook.props ) : this.props; + + event = new jQuery.Event( originalEvent ); + + i = copy.length; + while ( i-- ) { + prop = copy[ i ]; + event[ prop ] = originalEvent[ prop ]; + } + + // Support: IE<9 + // Fix target property (#1925) + if ( !event.target ) { + event.target = originalEvent.srcElement || document; + } + + // Support: Safari 6-8+ + // Target should not be a text node (#504, #13143) + if ( event.target.nodeType === 3 ) { + event.target = event.target.parentNode; + } + + // Support: IE<9 + // For mouse/key events, metaKey==false if it's undefined (#3368, #11328) + event.metaKey = !!event.metaKey; + + return fixHook.filter ? fixHook.filter( event, originalEvent ) : event; + }, + + // Includes some event props shared by KeyEvent and MouseEvent + props: ( "altKey bubbles cancelable ctrlKey currentTarget detail eventPhase " + + "metaKey relatedTarget shiftKey target timeStamp view which" ).split( " " ), + + fixHooks: {}, + + keyHooks: { + props: "char charCode key keyCode".split( " " ), + filter: function( event, original ) { + + // Add which for key events + if ( event.which == null ) { + event.which = original.charCode != null ? original.charCode : original.keyCode; + } + + return event; + } + }, + + mouseHooks: { + props: ( "button buttons clientX clientY fromElement offsetX offsetY " + + "pageX pageY screenX screenY toElement" ).split( " " ), + filter: function( event, original ) { + var body, eventDoc, doc, + button = original.button, + fromElement = original.fromElement; + + // Calculate pageX/Y if missing and clientX/Y available + if ( event.pageX == null && original.clientX != null ) { + eventDoc = event.target.ownerDocument || document; + doc = eventDoc.documentElement; + body = eventDoc.body; + + event.pageX = original.clientX + + ( doc && doc.scrollLeft || body && body.scrollLeft || 0 ) - + ( doc && doc.clientLeft || body && body.clientLeft || 0 ); + event.pageY = original.clientY + + ( doc && doc.scrollTop || body && body.scrollTop || 0 ) - + ( doc && doc.clientTop || body && body.clientTop || 0 ); + } + + // Add relatedTarget, if necessary + if ( !event.relatedTarget && fromElement ) { + event.relatedTarget = fromElement === event.target ? + original.toElement : + fromElement; + } + + // Add which for click: 1 === left; 2 === middle; 3 === right + // Note: button is not normalized, so don't use it + if ( !event.which && button !== undefined ) { + event.which = ( button & 1 ? 1 : ( button & 2 ? 3 : ( button & 4 ? 2 : 0 ) ) ); + } + + return event; + } + }, + + special: { + load: { + + // Prevent triggered image.load events from bubbling to window.load + noBubble: true + }, + focus: { + + // Fire native event if possible so blur/focus sequence is correct + trigger: function() { + if ( this !== safeActiveElement() && this.focus ) { + try { + this.focus(); + return false; + } catch ( e ) { + + // Support: IE<9 + // If we error on focus to hidden element (#1486, #12518), + // let .trigger() run the handlers + } + } + }, + delegateType: "focusin" + }, + blur: { + trigger: function() { + if ( this === safeActiveElement() && this.blur ) { + this.blur(); + return false; + } + }, + delegateType: "focusout" + }, + click: { + + // For checkbox, fire native event so checked state will be right + trigger: function() { + if ( jQuery.nodeName( this, "input" ) && this.type === "checkbox" && this.click ) { + this.click(); + return false; + } + }, + + // For cross-browser consistency, don't fire native .click() on links + _default: function( event ) { + return jQuery.nodeName( event.target, "a" ); + } + }, + + beforeunload: { + postDispatch: function( event ) { + + // Support: Firefox 20+ + // Firefox doesn't alert if the returnValue field is not set. + if ( event.result !== undefined && event.originalEvent ) { + event.originalEvent.returnValue = event.result; + } + } + } + }, + + // Piggyback on a donor event to simulate a different one + simulate: function( type, elem, event ) { + var e = jQuery.extend( + new jQuery.Event(), + event, + { + type: type, + isSimulated: true + + // Previously, `originalEvent: {}` was set here, so stopPropagation call + // would not be triggered on donor event, since in our own + // jQuery.event.stopPropagation function we had a check for existence of + // originalEvent.stopPropagation method, so, consequently it would be a noop. + // + // Guard for simulated events was moved to jQuery.event.stopPropagation function + // since `originalEvent` should point to the original event for the + // constancy with other events and for more focused logic + } + ); + + jQuery.event.trigger( e, null, elem ); + + if ( e.isDefaultPrevented() ) { + event.preventDefault(); + } + } +}; + +jQuery.removeEvent = document.removeEventListener ? + function( elem, type, handle ) { + + // This "if" is needed for plain objects + if ( elem.removeEventListener ) { + elem.removeEventListener( type, handle ); + } + } : + function( elem, type, handle ) { + var name = "on" + type; + + if ( elem.detachEvent ) { + + // #8545, #7054, preventing memory leaks for custom events in IE6-8 + // detachEvent needed property on element, by name of that event, + // to properly expose it to GC + if ( typeof elem[ name ] === "undefined" ) { + elem[ name ] = null; + } + + elem.detachEvent( name, handle ); + } + }; + +jQuery.Event = function( src, props ) { + + // Allow instantiation without the 'new' keyword + if ( !( this instanceof jQuery.Event ) ) { + return new jQuery.Event( src, props ); + } + + // Event object + if ( src && src.type ) { + this.originalEvent = src; + this.type = src.type; + + // Events bubbling up the document may have been marked as prevented + // by a handler lower down the tree; reflect the correct value. + this.isDefaultPrevented = src.defaultPrevented || + src.defaultPrevented === undefined && + + // Support: IE < 9, Android < 4.0 + src.returnValue === false ? + returnTrue : + returnFalse; + + // Event type + } else { + this.type = src; + } + + // Put explicitly provided properties onto the event object + if ( props ) { + jQuery.extend( this, props ); + } + + // Create a timestamp if incoming event doesn't have one + this.timeStamp = src && src.timeStamp || jQuery.now(); + + // Mark it as fixed + this[ jQuery.expando ] = true; +}; + +// jQuery.Event is based on DOM3 Events as specified by the ECMAScript Language Binding +// http://www.w3.org/TR/2003/WD-DOM-Level-3-Events-20030331/ecma-script-binding.html +jQuery.Event.prototype = { + constructor: jQuery.Event, + isDefaultPrevented: returnFalse, + isPropagationStopped: returnFalse, + isImmediatePropagationStopped: returnFalse, + + preventDefault: function() { + var e = this.originalEvent; + + this.isDefaultPrevented = returnTrue; + if ( !e ) { + return; + } + + // If preventDefault exists, run it on the original event + if ( e.preventDefault ) { + e.preventDefault(); + + // Support: IE + // Otherwise set the returnValue property of the original event to false + } else { + e.returnValue = false; + } + }, + stopPropagation: function() { + var e = this.originalEvent; + + this.isPropagationStopped = returnTrue; + + if ( !e || this.isSimulated ) { + return; + } + + // If stopPropagation exists, run it on the original event + if ( e.stopPropagation ) { + e.stopPropagation(); + } + + // Support: IE + // Set the cancelBubble property of the original event to true + e.cancelBubble = true; + }, + stopImmediatePropagation: function() { + var e = this.originalEvent; + + this.isImmediatePropagationStopped = returnTrue; + + if ( e && e.stopImmediatePropagation ) { + e.stopImmediatePropagation(); + } + + this.stopPropagation(); + } +}; + +// Create mouseenter/leave events using mouseover/out and event-time checks +// so that event delegation works in jQuery. +// Do the same for pointerenter/pointerleave and pointerover/pointerout +// +// Support: Safari 7 only +// Safari sends mouseenter too often; see: +// https://code.google.com/p/chromium/issues/detail?id=470258 +// for the description of the bug (it existed in older Chrome versions as well). +jQuery.each( { + mouseenter: "mouseover", + mouseleave: "mouseout", + pointerenter: "pointerover", + pointerleave: "pointerout" +}, function( orig, fix ) { + jQuery.event.special[ orig ] = { + delegateType: fix, + bindType: fix, + + handle: function( event ) { + var ret, + target = this, + related = event.relatedTarget, + handleObj = event.handleObj; + + // For mouseenter/leave call the handler if related is outside the target. + // NB: No relatedTarget if the mouse left/entered the browser window + if ( !related || ( related !== target && !jQuery.contains( target, related ) ) ) { + event.type = handleObj.origType; + ret = handleObj.handler.apply( this, arguments ); + event.type = fix; + } + return ret; + } + }; +} ); + +// IE submit delegation +if ( !support.submit ) { + + jQuery.event.special.submit = { + setup: function() { + + // Only need this for delegated form submit events + if ( jQuery.nodeName( this, "form" ) ) { + return false; + } + + // Lazy-add a submit handler when a descendant form may potentially be submitted + jQuery.event.add( this, "click._submit keypress._submit", function( e ) { + + // Node name check avoids a VML-related crash in IE (#9807) + var elem = e.target, + form = jQuery.nodeName( elem, "input" ) || jQuery.nodeName( elem, "button" ) ? + + // Support: IE <=8 + // We use jQuery.prop instead of elem.form + // to allow fixing the IE8 delegated submit issue (gh-2332) + // by 3rd party polyfills/workarounds. + jQuery.prop( elem, "form" ) : + undefined; + + if ( form && !jQuery._data( form, "submit" ) ) { + jQuery.event.add( form, "submit._submit", function( event ) { + event._submitBubble = true; + } ); + jQuery._data( form, "submit", true ); + } + } ); + + // return undefined since we don't need an event listener + }, + + postDispatch: function( event ) { + + // If form was submitted by the user, bubble the event up the tree + if ( event._submitBubble ) { + delete event._submitBubble; + if ( this.parentNode && !event.isTrigger ) { + jQuery.event.simulate( "submit", this.parentNode, event ); + } + } + }, + + teardown: function() { + + // Only need this for delegated form submit events + if ( jQuery.nodeName( this, "form" ) ) { + return false; + } + + // Remove delegated handlers; cleanData eventually reaps submit handlers attached above + jQuery.event.remove( this, "._submit" ); + } + }; +} + +// IE change delegation and checkbox/radio fix +if ( !support.change ) { + + jQuery.event.special.change = { + + setup: function() { + + if ( rformElems.test( this.nodeName ) ) { + + // IE doesn't fire change on a check/radio until blur; trigger it on click + // after a propertychange. Eat the blur-change in special.change.handle. + // This still fires onchange a second time for check/radio after blur. + if ( this.type === "checkbox" || this.type === "radio" ) { + jQuery.event.add( this, "propertychange._change", function( event ) { + if ( event.originalEvent.propertyName === "checked" ) { + this._justChanged = true; + } + } ); + jQuery.event.add( this, "click._change", function( event ) { + if ( this._justChanged && !event.isTrigger ) { + this._justChanged = false; + } + + // Allow triggered, simulated change events (#11500) + jQuery.event.simulate( "change", this, event ); + } ); + } + return false; + } + + // Delegated event; lazy-add a change handler on descendant inputs + jQuery.event.add( this, "beforeactivate._change", function( e ) { + var elem = e.target; + + if ( rformElems.test( elem.nodeName ) && !jQuery._data( elem, "change" ) ) { + jQuery.event.add( elem, "change._change", function( event ) { + if ( this.parentNode && !event.isSimulated && !event.isTrigger ) { + jQuery.event.simulate( "change", this.parentNode, event ); + } + } ); + jQuery._data( elem, "change", true ); + } + } ); + }, + + handle: function( event ) { + var elem = event.target; + + // Swallow native change events from checkbox/radio, we already triggered them above + if ( this !== elem || event.isSimulated || event.isTrigger || + ( elem.type !== "radio" && elem.type !== "checkbox" ) ) { + + return event.handleObj.handler.apply( this, arguments ); + } + }, + + teardown: function() { + jQuery.event.remove( this, "._change" ); + + return !rformElems.test( this.nodeName ); + } + }; +} + +// Support: Firefox +// Firefox doesn't have focus(in | out) events +// Related ticket - https://bugzilla.mozilla.org/show_bug.cgi?id=687787 +// +// Support: Chrome, Safari +// focus(in | out) events fire after focus & blur events, +// which is spec violation - http://www.w3.org/TR/DOM-Level-3-Events/#events-focusevent-event-order +// Related ticket - https://code.google.com/p/chromium/issues/detail?id=449857 +if ( !support.focusin ) { + jQuery.each( { focus: "focusin", blur: "focusout" }, function( orig, fix ) { + + // Attach a single capturing handler on the document while someone wants focusin/focusout + var handler = function( event ) { + jQuery.event.simulate( fix, event.target, jQuery.event.fix( event ) ); + }; + + jQuery.event.special[ fix ] = { + setup: function() { + var doc = this.ownerDocument || this, + attaches = jQuery._data( doc, fix ); + + if ( !attaches ) { + doc.addEventListener( orig, handler, true ); + } + jQuery._data( doc, fix, ( attaches || 0 ) + 1 ); + }, + teardown: function() { + var doc = this.ownerDocument || this, + attaches = jQuery._data( doc, fix ) - 1; + + if ( !attaches ) { + doc.removeEventListener( orig, handler, true ); + jQuery._removeData( doc, fix ); + } else { + jQuery._data( doc, fix, attaches ); + } + } + }; + } ); +} + +jQuery.fn.extend( { + + on: function( types, selector, data, fn ) { + return on( this, types, selector, data, fn ); + }, + one: function( types, selector, data, fn ) { + return on( this, types, selector, data, fn, 1 ); + }, + off: function( types, selector, fn ) { + var handleObj, type; + if ( types && types.preventDefault && types.handleObj ) { + + // ( event ) dispatched jQuery.Event + handleObj = types.handleObj; + jQuery( types.delegateTarget ).off( + handleObj.namespace ? + handleObj.origType + "." + handleObj.namespace : + handleObj.origType, + handleObj.selector, + handleObj.handler + ); + return this; + } + if ( typeof types === "object" ) { + + // ( types-object [, selector] ) + for ( type in types ) { + this.off( type, selector, types[ type ] ); + } + return this; + } + if ( selector === false || typeof selector === "function" ) { + + // ( types [, fn] ) + fn = selector; + selector = undefined; + } + if ( fn === false ) { + fn = returnFalse; + } + return this.each( function() { + jQuery.event.remove( this, types, fn, selector ); + } ); + }, + + trigger: function( type, data ) { + return this.each( function() { + jQuery.event.trigger( type, data, this ); + } ); + }, + triggerHandler: function( type, data ) { + var elem = this[ 0 ]; + if ( elem ) { + return jQuery.event.trigger( type, data, elem, true ); + } + } +} ); + + +var rinlinejQuery = / jQuery\d+="(?:null|\d+)"/g, + rnoshimcache = new RegExp( "<(?:" + nodeNames + ")[\\s/>]", "i" ), + rxhtmlTag = /<(?!area|br|col|embed|hr|img|input|link|meta|param)(([\w:-]+)[^>]*)\/>/gi, + + // Support: IE 10-11, Edge 10240+ + // In IE/Edge using regex groups here causes severe slowdowns. + // See https://connect.microsoft.com/IE/feedback/details/1736512/ + rnoInnerhtml = /\s*$/g, + safeFragment = createSafeFragment( document ), + fragmentDiv = safeFragment.appendChild( document.createElement( "div" ) ); + +// Support: IE<8 +// Manipulating tables requires a tbody +function manipulationTarget( elem, content ) { + return jQuery.nodeName( elem, "table" ) && + jQuery.nodeName( content.nodeType !== 11 ? content : content.firstChild, "tr" ) ? + + elem.getElementsByTagName( "tbody" )[ 0 ] || + elem.appendChild( elem.ownerDocument.createElement( "tbody" ) ) : + elem; +} + +// Replace/restore the type attribute of script elements for safe DOM manipulation +function disableScript( elem ) { + elem.type = ( jQuery.find.attr( elem, "type" ) !== null ) + "/" + elem.type; + return elem; +} +function restoreScript( elem ) { + var match = rscriptTypeMasked.exec( elem.type ); + if ( match ) { + elem.type = match[ 1 ]; + } else { + elem.removeAttribute( "type" ); + } + return elem; +} + +function cloneCopyEvent( src, dest ) { if ( dest.nodeType !== 1 || !jQuery.hasData( src ) ) { return; } @@ -6267,7 +5949,7 @@ function fixCloneNodeIssues( src, dest ) { nodeName = dest.nodeName.toLowerCase(); // IE6-8 copies events bound via attachEvent when using cloneNode. - if ( !jQuery.support.noCloneEvent && dest[ jQuery.expando ] ) { + if ( !support.noCloneEvent && dest[ jQuery.expando ] ) { data = jQuery._data( dest ); for ( e in data.events ) { @@ -6294,11 +5976,12 @@ function fixCloneNodeIssues( src, dest ) { // element in IE9, the outerHTML strategy above is not sufficient. // If the src has innerHTML and the destination does not, // copy the src.innerHTML into the dest.innerHTML. #10324 - if ( jQuery.support.html5Clone && ( src.innerHTML && !jQuery.trim(dest.innerHTML) ) ) { + if ( support.html5Clone && ( src.innerHTML && !jQuery.trim( dest.innerHTML ) ) ) { dest.innerHTML = src.innerHTML; } - } else if ( nodeName === "input" && manipulation_rcheckableType.test( src.type ) ) { + } else if ( nodeName === "input" && rcheckableType.test( src.type ) ) { + // IE6-8 fails to persist the checked state of a cloned checkbox // or radio button. Worse, IE6-7 fail to give the cloned element // a checked appearance if the defaultChecked value isn't also set @@ -6323,67 +6006,137 @@ function fixCloneNodeIssues( src, dest ) { } } -jQuery.each({ - appendTo: "append", - prependTo: "prepend", - insertBefore: "before", - insertAfter: "after", - replaceAll: "replaceWith" -}, function( name, original ) { - jQuery.fn[ name ] = function( selector ) { - var elems, - i = 0, - ret = [], - insert = jQuery( selector ), - last = insert.length - 1; +function domManip( collection, args, callback, ignored ) { - for ( ; i <= last; i++ ) { - elems = i === last ? this : this.clone(true); - jQuery( insert[i] )[ original ]( elems ); + // Flatten any nested arrays + args = concat.apply( [], args ); - // Modern browsers can apply jQuery collections as arrays, but oldIE needs a .get() - core_push.apply( ret, elems.get() ); - } - - return this.pushStack( ret ); - }; -}); - -function getAll( context, tag ) { - var elems, elem, + var first, node, hasScripts, + scripts, doc, fragment, i = 0, - found = typeof context.getElementsByTagName !== core_strundefined ? context.getElementsByTagName( tag || "*" ) : - typeof context.querySelectorAll !== core_strundefined ? context.querySelectorAll( tag || "*" ) : - undefined; + l = collection.length, + iNoClone = l - 1, + value = args[ 0 ], + isFunction = jQuery.isFunction( value ); - if ( !found ) { - for ( found = [], elems = context.childNodes || context; (elem = elems[i]) != null; i++ ) { - if ( !tag || jQuery.nodeName( elem, tag ) ) { - found.push( elem ); - } else { - jQuery.merge( found, getAll( elem, tag ) ); + // We can't cloneNode fragments that contain checked, in WebKit + if ( isFunction || + ( l > 1 && typeof value === "string" && + !support.checkClone && rchecked.test( value ) ) ) { + return collection.each( function( index ) { + var self = collection.eq( index ); + if ( isFunction ) { + args[ 0 ] = value.call( this, index, self.html() ); } + domManip( self, args, callback, ignored ); + } ); + } + + if ( l ) { + fragment = buildFragment( args, collection[ 0 ].ownerDocument, false, collection, ignored ); + first = fragment.firstChild; + + if ( fragment.childNodes.length === 1 ) { + fragment = first; + } + + // Require either new content or an interest in ignored elements to invoke the callback + if ( first || ignored ) { + scripts = jQuery.map( getAll( fragment, "script" ), disableScript ); + hasScripts = scripts.length; + + // Use the original fragment for the last item + // instead of the first because it can end up + // being emptied incorrectly in certain situations (#8070). + for ( ; i < l; i++ ) { + node = fragment; + + if ( i !== iNoClone ) { + node = jQuery.clone( node, true, true ); + + // Keep references to cloned scripts for later restoration + if ( hasScripts ) { + + // Support: Android<4.1, PhantomJS<2 + // push.apply(_, arraylike) throws on ancient WebKit + jQuery.merge( scripts, getAll( node, "script" ) ); + } + } + + callback.call( collection[ i ], node, i ); + } + + if ( hasScripts ) { + doc = scripts[ scripts.length - 1 ].ownerDocument; + + // Reenable scripts + jQuery.map( scripts, restoreScript ); + + // Evaluate executable scripts on first document insertion + for ( i = 0; i < hasScripts; i++ ) { + node = scripts[ i ]; + if ( rscriptType.test( node.type || "" ) && + !jQuery._data( node, "globalEval" ) && + jQuery.contains( doc, node ) ) { + + if ( node.src ) { + + // Optional AJAX dependency, but won't run scripts if not present + if ( jQuery._evalUrl ) { + jQuery._evalUrl( node.src ); + } + } else { + jQuery.globalEval( + ( node.text || node.textContent || node.innerHTML || "" ) + .replace( rcleanScript, "" ) + ); + } + } + } + } + + // Fix #11809: Avoid leaking memory + fragment = first = null; } } - return tag === undefined || tag && jQuery.nodeName( context, tag ) ? - jQuery.merge( [ context ], found ) : - found; + return collection; } -// Used in buildFragment, fixes the defaultChecked property -function fixDefaultChecked( elem ) { - if ( manipulation_rcheckableType.test( elem.type ) ) { - elem.defaultChecked = elem.checked; +function remove( elem, selector, keepData ) { + var node, + elems = selector ? jQuery.filter( selector, elem ) : elem, + i = 0; + + for ( ; ( node = elems[ i ] ) != null; i++ ) { + + if ( !keepData && node.nodeType === 1 ) { + jQuery.cleanData( getAll( node ) ); + } + + if ( node.parentNode ) { + if ( keepData && jQuery.contains( node.ownerDocument, node ) ) { + setGlobalEval( getAll( node, "script" ) ); + } + node.parentNode.removeChild( node ); + } } + + return elem; } -jQuery.extend({ +jQuery.extend( { + htmlPrefilter: function( html ) { + return html.replace( rxhtmlTag, "<$1>" ); + }, + clone: function( elem, dataAndEvents, deepDataAndEvents ) { var destElements, node, clone, i, srcElements, inPage = jQuery.contains( elem.ownerDocument, elem ); - if ( jQuery.support.html5Clone || jQuery.isXMLDoc(elem) || !rnoshimcache.test( "<" + elem.nodeName + ">" ) ) { + if ( support.html5Clone || jQuery.isXMLDoc( elem ) || + !rnoshimcache.test( "<" + elem.nodeName + ">" ) ) { + clone = elem.cloneNode( true ); // IE<=8 does not properly clone detached, unknown element nodes @@ -6392,18 +6145,19 @@ jQuery.extend({ fragmentDiv.removeChild( clone = fragmentDiv.firstChild ); } - if ( (!jQuery.support.noCloneEvent || !jQuery.support.noCloneChecked) && - (elem.nodeType === 1 || elem.nodeType === 11) && !jQuery.isXMLDoc(elem) ) { + if ( ( !support.noCloneEvent || !support.noCloneChecked ) && + ( elem.nodeType === 1 || elem.nodeType === 11 ) && !jQuery.isXMLDoc( elem ) ) { // We eschew Sizzle here for performance reasons: http://jsperf.com/getall-vs-sizzle/2 destElements = getAll( clone ); srcElements = getAll( elem ); // Fix all IE cloning issues - for ( i = 0; (node = srcElements[i]) != null; ++i ) { + for ( i = 0; ( node = srcElements[ i ] ) != null; ++i ) { + // Ensure that the destination node is not null; Fixes #9587 - if ( destElements[i] ) { - fixCloneNodeIssues( node, destElements[i] ); + if ( destElements[ i ] ) { + fixCloneNodeIssues( node, destElements[ i ] ); } } } @@ -6414,8 +6168,8 @@ jQuery.extend({ srcElements = srcElements || getAll( elem ); destElements = destElements || getAll( clone ); - for ( i = 0; (node = srcElements[i]) != null; i++ ) { - cloneCopyEvent( node, destElements[i] ); + for ( i = 0; ( node = srcElements[ i ] ) != null; i++ ) { + cloneCopyEvent( node, destElements[ i ] ); } } else { cloneCopyEvent( elem, clone ); @@ -6434,144 +6188,16 @@ jQuery.extend({ return clone; }, - buildFragment: function( elems, context, scripts, selection ) { - var j, elem, contains, - tmp, tag, tbody, wrap, - l = elems.length, - - // Ensure a safe fragment - safe = createSafeFragment( context ), - - nodes = [], - i = 0; - - for ( ; i < l; i++ ) { - elem = elems[ i ]; - - if ( elem || elem === 0 ) { - - // Add nodes directly - if ( jQuery.type( elem ) === "object" ) { - jQuery.merge( nodes, elem.nodeType ? [ elem ] : elem ); - - // Convert non-html into a text node - } else if ( !rhtml.test( elem ) ) { - nodes.push( context.createTextNode( elem ) ); - - // Convert html into DOM nodes - } else { - tmp = tmp || safe.appendChild( context.createElement("div") ); - - // Deserialize a standard representation - tag = ( rtagName.exec( elem ) || ["", ""] )[1].toLowerCase(); - wrap = wrapMap[ tag ] || wrapMap._default; - - tmp.innerHTML = wrap[1] + elem.replace( rxhtmlTag, "<$1>" ) + wrap[2]; - - // Descend through wrappers to the right content - j = wrap[0]; - while ( j-- ) { - tmp = tmp.lastChild; - } - - // Manually add leading whitespace removed by IE - if ( !jQuery.support.leadingWhitespace && rleadingWhitespace.test( elem ) ) { - nodes.push( context.createTextNode( rleadingWhitespace.exec( elem )[0] ) ); - } - - // Remove IE's autoinserted from table fragments - if ( !jQuery.support.tbody ) { - - // String was a
, *may* have spurious - elem = tag === "table" && !rtbody.test( elem ) ? - tmp.firstChild : - - // String was a bare or - wrap[1] === "
" && !rtbody.test( elem ) ? - tmp : - 0; - - j = elem && elem.childNodes.length; - while ( j-- ) { - if ( jQuery.nodeName( (tbody = elem.childNodes[j]), "tbody" ) && !tbody.childNodes.length ) { - elem.removeChild( tbody ); - } - } - } - - jQuery.merge( nodes, tmp.childNodes ); - - // Fix #12392 for WebKit and IE > 9 - tmp.textContent = ""; - - // Fix #12392 for oldIE - while ( tmp.firstChild ) { - tmp.removeChild( tmp.firstChild ); - } - - // Remember the top-level container for proper cleanup - tmp = safe.lastChild; - } - } - } - - // Fix #11356: Clear elements from fragment - if ( tmp ) { - safe.removeChild( tmp ); - } - - // Reset defaultChecked for any radios and checkboxes - // about to be appended to the DOM in IE 6/7 (#8060) - if ( !jQuery.support.appendChecked ) { - jQuery.grep( getAll( nodes, "input" ), fixDefaultChecked ); - } - - i = 0; - while ( (elem = nodes[ i++ ]) ) { - - // #4087 - If origin and destination elements are the same, and this is - // that element, do not do anything - if ( selection && jQuery.inArray( elem, selection ) !== -1 ) { - continue; - } - - contains = jQuery.contains( elem.ownerDocument, elem ); - - // Append to fragment - tmp = getAll( safe.appendChild( elem ), "script" ); - - // Preserve script evaluation history - if ( contains ) { - setGlobalEval( tmp ); - } - - // Capture executables - if ( scripts ) { - j = 0; - while ( (elem = tmp[ j++ ]) ) { - if ( rscriptType.test( elem.type || "" ) ) { - scripts.push( elem ); - } - } - } - } - - tmp = null; - - return safe; - }, - - cleanData: function( elems, /* internal */ acceptData ) { + cleanData: function( elems, /* internal */ forceAcceptData ) { var elem, type, id, data, i = 0, internalKey = jQuery.expando, cache = jQuery.cache, - deleteExpando = jQuery.support.deleteExpando, + attributes = support.attributes, special = jQuery.event.special; - for ( ; (elem = elems[i]) != null; i++ ) { - - if ( acceptData || jQuery.acceptData( elem ) ) { + for ( ; ( elem = elems[ i ] ) != null; i++ ) { + if ( forceAcceptData || acceptData( elem ) ) { id = elem[ internalKey ]; data = id && cache[ id ]; @@ -6594,76 +6220,662 @@ jQuery.extend({ delete cache[ id ]; - // IE does not allow us to delete expando properties from nodes, - // nor does it have a removeAttribute function on Document nodes; - // we must handle all of these cases - if ( deleteExpando ) { - delete elem[ internalKey ]; - - } else if ( typeof elem.removeAttribute !== core_strundefined ) { + // Support: IE<9 + // IE does not allow us to delete expando properties from nodes + // IE creates expando attributes along with the property + // IE does not have a removeAttribute function on Document nodes + if ( !attributes && typeof elem.removeAttribute !== "undefined" ) { elem.removeAttribute( internalKey ); + // Webkit & Blink performance suffers when deleting properties + // from DOM nodes, so set to undefined instead + // https://code.google.com/p/chromium/issues/detail?id=378607 } else { - elem[ internalKey ] = null; + elem[ internalKey ] = undefined; } - core_deletedIds.push( id ); + deletedIds.push( id ); } } } } } -}); -var iframe, getStyles, curCSS, - ralpha = /alpha\([^)]*\)/i, - ropacity = /opacity\s*=\s*([^)]*)/, - rposition = /^(top|right|bottom|left)$/, - // swappable if display is none or starts with table except "table", "table-cell", or "table-caption" - // see here for display values: https://developer.mozilla.org/en-US/docs/CSS/display +} ); + +jQuery.fn.extend( { + + // Keep domManip exposed until 3.0 (gh-2225) + domManip: domManip, + + detach: function( selector ) { + return remove( this, selector, true ); + }, + + remove: function( selector ) { + return remove( this, selector ); + }, + + text: function( value ) { + return access( this, function( value ) { + return value === undefined ? + jQuery.text( this ) : + this.empty().append( + ( this[ 0 ] && this[ 0 ].ownerDocument || document ).createTextNode( value ) + ); + }, null, value, arguments.length ); + }, + + append: function() { + return domManip( this, arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.appendChild( elem ); + } + } ); + }, + + prepend: function() { + return domManip( this, arguments, function( elem ) { + if ( this.nodeType === 1 || this.nodeType === 11 || this.nodeType === 9 ) { + var target = manipulationTarget( this, elem ); + target.insertBefore( elem, target.firstChild ); + } + } ); + }, + + before: function() { + return domManip( this, arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this ); + } + } ); + }, + + after: function() { + return domManip( this, arguments, function( elem ) { + if ( this.parentNode ) { + this.parentNode.insertBefore( elem, this.nextSibling ); + } + } ); + }, + + empty: function() { + var elem, + i = 0; + + for ( ; ( elem = this[ i ] ) != null; i++ ) { + + // Remove element nodes and prevent memory leaks + if ( elem.nodeType === 1 ) { + jQuery.cleanData( getAll( elem, false ) ); + } + + // Remove any remaining nodes + while ( elem.firstChild ) { + elem.removeChild( elem.firstChild ); + } + + // If this is a select, ensure that it displays empty (#12336) + // Support: IE<9 + if ( elem.options && jQuery.nodeName( elem, "select" ) ) { + elem.options.length = 0; + } + } + + return this; + }, + + clone: function( dataAndEvents, deepDataAndEvents ) { + dataAndEvents = dataAndEvents == null ? false : dataAndEvents; + deepDataAndEvents = deepDataAndEvents == null ? dataAndEvents : deepDataAndEvents; + + return this.map( function() { + return jQuery.clone( this, dataAndEvents, deepDataAndEvents ); + } ); + }, + + html: function( value ) { + return access( this, function( value ) { + var elem = this[ 0 ] || {}, + i = 0, + l = this.length; + + if ( value === undefined ) { + return elem.nodeType === 1 ? + elem.innerHTML.replace( rinlinejQuery, "" ) : + undefined; + } + + // See if we can take a shortcut and just use innerHTML + if ( typeof value === "string" && !rnoInnerhtml.test( value ) && + ( support.htmlSerialize || !rnoshimcache.test( value ) ) && + ( support.leadingWhitespace || !rleadingWhitespace.test( value ) ) && + !wrapMap[ ( rtagName.exec( value ) || [ "", "" ] )[ 1 ].toLowerCase() ] ) { + + value = jQuery.htmlPrefilter( value ); + + try { + for ( ; i < l; i++ ) { + + // Remove element nodes and prevent memory leaks + elem = this[ i ] || {}; + if ( elem.nodeType === 1 ) { + jQuery.cleanData( getAll( elem, false ) ); + elem.innerHTML = value; + } + } + + elem = 0; + + // If using innerHTML throws an exception, use the fallback method + } catch ( e ) {} + } + + if ( elem ) { + this.empty().append( value ); + } + }, null, value, arguments.length ); + }, + + replaceWith: function() { + var ignored = []; + + // Make the changes, replacing each non-ignored context element with the new content + return domManip( this, arguments, function( elem ) { + var parent = this.parentNode; + + if ( jQuery.inArray( this, ignored ) < 0 ) { + jQuery.cleanData( getAll( this ) ); + if ( parent ) { + parent.replaceChild( elem, this ); + } + } + + // Force callback invocation + }, ignored ); + } +} ); + +jQuery.each( { + appendTo: "append", + prependTo: "prepend", + insertBefore: "before", + insertAfter: "after", + replaceAll: "replaceWith" +}, function( name, original ) { + jQuery.fn[ name ] = function( selector ) { + var elems, + i = 0, + ret = [], + insert = jQuery( selector ), + last = insert.length - 1; + + for ( ; i <= last; i++ ) { + elems = i === last ? this : this.clone( true ); + jQuery( insert[ i ] )[ original ]( elems ); + + // Modern browsers can apply jQuery collections as arrays, but oldIE needs a .get() + push.apply( ret, elems.get() ); + } + + return this.pushStack( ret ); + }; +} ); + + +var iframe, + elemdisplay = { + + // Support: Firefox + // We have to pre-define these values for FF (#10227) + HTML: "block", + BODY: "block" + }; + +/** + * Retrieve the actual display of a element + * @param {String} name nodeName of the element + * @param {Object} doc Document object + */ + +// Called only from within defaultDisplay +function actualDisplay( name, doc ) { + var elem = jQuery( doc.createElement( name ) ).appendTo( doc.body ), + + display = jQuery.css( elem[ 0 ], "display" ); + + // We don't have any data stored on the element, + // so use "detach" method as fast way to get rid of the element + elem.detach(); + + return display; +} + +/** + * Try to determine the default display value of an element + * @param {String} nodeName + */ +function defaultDisplay( nodeName ) { + var doc = document, + display = elemdisplay[ nodeName ]; + + if ( !display ) { + display = actualDisplay( nodeName, doc ); + + // If the simple way fails, read from inside an iframe + if ( display === "none" || !display ) { + + // Use the already-created iframe if possible + iframe = ( iframe || jQuery( "