From 282bcffb62d1f2426f39b9023295efd338d7d434 Mon Sep 17 00:00:00 2001 From: Gedion Date: Thu, 6 Sep 2012 16:32:41 -0500 Subject: [PATCH] reset --- CHANGELOG.md | 13 + LICENSE | 202 + Makefile | 13 + README.md | 130 + README.plugins | 16 + available_plugins/ep_fintest/.npmignore | 7 + available_plugins/ep_fintest/ep.json | 36 + available_plugins/ep_fintest/otherpart.js | 25 + available_plugins/ep_fintest/package.json | 9 + available_plugins/ep_fintest/partlast.js | 3 + .../ep_fintest/static/js/test.js | 5 + available_plugins/ep_fintest/static/test.html | 1 + bin/buildForWindows.sh | 63 + bin/checkPad.js | 134 + bin/convert.js | 454 ++ bin/convertSettings.json.template | 10 + bin/debugRun.sh | 28 + bin/extractPadData.js | 89 + bin/installDeps.sh | 102 + bin/installOnWindows.bat | 39 + bin/jshint.sh | 9 + bin/loadTesting/README | 75 + bin/loadTesting/launcher.sh | 16 + bin/loadTesting/loader.js | 20 + bin/run.sh | 28 + bin/safeRun.sh | 66 + doc/all.md | 3 + doc/api/api.md | 7 + doc/api/changeset_library.md | 151 + doc/api/editorInfo.md | 47 + doc/api/embed_parameters.md | 47 + doc/api/hooks_client-side.md | 181 + doc/api/hooks_server-side.md | 143 + doc/api/http_api.md | 250 + doc/database.md | 64 + doc/documentation.md | 15 + doc/easysync/README.md | 2 + doc/easysync/easysync-full-description.pdf | Bin 0 -> 129342 bytes doc/easysync/easysync-full-description.tex | 372 ++ doc/easysync/easysync-notes.pdf | Bin 0 -> 87447 bytes doc/easysync/easysync-notes.tex | 200 + doc/easysync/easysync-notes.txt | 133 + doc/template.html | 23 + settings.json.template | 74 + src/ep.json | 20 + src/node/README.md | 13 + src/node/db/API.js | 605 ++ src/node/db/AuthorManager.js | 287 + src/node/db/DB.js | 57 + src/node/db/GroupManager.js | 263 + src/node/db/Pad.js | 539 ++ src/node/db/PadManager.js | 171 + src/node/db/ReadOnlyManager.js | 104 + src/node/db/SecurityManager.js | 278 + src/node/db/SessionManager.js | 367 ++ src/node/easysync_tests.js | 949 +++ src/node/eejs/examples/bar.ejs | 9 + src/node/eejs/examples/foo.ejs | 7 + src/node/eejs/index.js | 131 + src/node/handler/APIHandler.js | 168 + src/node/handler/ExportHandler.js | 165 + src/node/handler/ImportHandler.js | 201 + src/node/handler/PadMessageHandler.js | 1435 +++++ src/node/handler/SocketIORouter.js | 163 + src/node/hooks/express.js | 63 + src/node/hooks/express/adminplugins.js | 58 + src/node/hooks/express/apicalls.js | 60 + src/node/hooks/express/errorhandling.js | 52 + src/node/hooks/express/importexport.js | 41 + src/node/hooks/express/padreadonly.js | 65 + src/node/hooks/express/padurlsanitize.js | 32 + src/node/hooks/express/socketio.js | 66 + src/node/hooks/express/specialpages.js | 46 + src/node/hooks/express/static.js | 59 + src/node/hooks/express/webaccess.js | 116 + src/node/padaccess.js | 21 + src/node/server.js | 62 + src/node/utils/Abiword.js | 147 + src/node/utils/Cli.js | 38 + src/node/utils/ExportDokuWiki.js | 350 ++ src/node/utils/ExportHtml.js | 610 ++ src/node/utils/ImportHtml.js | 90 + src/node/utils/Minify.js | 392 ++ src/node/utils/Settings.js | 151 + src/node/utils/caching_middleware.js | 183 + src/node/utils/customError.js | 17 + src/node/utils/randomstring.js | 16 + src/node/utils/tar.json | 69 + src/package.json | 43 + src/static/css/admin.css | 122 + src/static/css/iframe_editor.css | 179 + src/static/css/pad.css | 854 +++ src/static/css/timeslider.css | 295 + src/static/custom/.gitignore | 3 + src/static/custom/css.template | 8 + src/static/custom/js.template | 6 + src/static/favicon.ico | Bin 0 -> 1150 bytes src/static/img/backgrad.gif | Bin 0 -> 697 bytes src/static/img/connectingbar.gif | Bin 0 -> 10932 bytes src/static/img/crushed_button_depressed.png | Bin 0 -> 4134 bytes src/static/img/crushed_button_undepressed.png | Bin 0 -> 4166 bytes src/static/img/crushed_current_location.png | Bin 0 -> 1009 bytes src/static/img/etherpad_lite_icons.png | Bin 0 -> 8318 bytes src/static/img/fileicons.gif | Bin 0 -> 1649 bytes src/static/img/leftarrow.png | Bin 0 -> 494 bytes src/static/img/loading.gif | Bin 0 -> 658 bytes src/static/img/pause.png | Bin 0 -> 2883 bytes src/static/img/play.png | Bin 0 -> 3017 bytes src/static/img/roundcorner_left.gif | Bin 0 -> 123 bytes src/static/img/roundcorner_right.gif | Bin 0 -> 131 bytes src/static/img/star.png | Bin 0 -> 3241 bytes src/static/img/stepper_buttons.png | Bin 0 -> 4858 bytes src/static/img/timeslider_background.png | Bin 0 -> 182 bytes src/static/img/timeslider_left.png | Bin 0 -> 686 bytes src/static/img/timeslider_right.png | Bin 0 -> 517 bytes src/static/js/AttributeManager.js | 164 + src/static/js/AttributePool.js | 96 + src/static/js/Changeset.js | 2184 +++++++ src/static/js/ChangesetUtils.js | 60 + src/static/js/ace.js | 323 + src/static/js/ace2_common.js | 106 + src/static/js/ace2_inner.js | 5454 +++++++++++++++++ src/static/js/admin/plugins.js | 144 + src/static/js/broadcast.js | 579 ++ src/static/js/broadcast_revisions.js | 128 + src/static/js/broadcast_slider.js | 495 ++ src/static/js/changesettracker.js | 213 + src/static/js/chat.js | 173 + src/static/js/collab_client.js | 595 ++ src/static/js/colorutils.js | 138 + src/static/js/contentcollector.js | 707 +++ src/static/js/cssmanager.js | 106 + src/static/js/domline.js | 309 + src/static/js/draggable.js | 197 + src/static/js/excanvas.js | 35 + src/static/js/farbtastic.js | 524 ++ src/static/js/json2.js | 473 ++ src/static/js/linestylefilter.js | 352 ++ src/static/js/pad.js | 1028 ++++ src/static/js/pad_connectionstatus.js | 90 + src/static/js/pad_cookie.js | 133 + src/static/js/pad_docbar.js | 466 ++ src/static/js/pad_editbar.js | 266 + src/static/js/pad_editor.js | 133 + src/static/js/pad_impexp.js | 287 + src/static/js/pad_modals.js | 64 + src/static/js/pad_savedrevs.js | 26 + src/static/js/pad_userlist.js | 820 +++ src/static/js/pad_utils.js | 537 ++ src/static/js/pluginfw/async.js | 690 +++ src/static/js/pluginfw/hooks.js | 134 + src/static/js/pluginfw/installer.js | 106 + src/static/js/pluginfw/parent_require.js | 39 + src/static/js/pluginfw/plugins.js | 259 + src/static/js/pluginfw/read-installed.js | 324 + src/static/js/pluginfw/tsort.js | 112 + src/static/js/rjquery.js | 5 + src/static/js/security.js | 54 + src/static/js/skiplist.js | 472 ++ src/static/js/timeslider.js | 165 + src/static/js/tinycon.js | 237 + src/static/js/underscore.js | 999 +++ src/static/js/undomodule.js | 335 + src/static/js/unorm.js | 404 ++ src/static/js/virtual_lines.js | 388 ++ src/static/robots.txt | 3 + src/static/tests.html | 167 + src/templates/admin/plugins-info.html | 30 + src/templates/admin/plugins.html | 90 + src/templates/index.html | 164 + src/templates/pad.html | 395 ++ src/templates/timeslider.html | 194 + start.bat | 1 + tools/doc/LICENSE | 18 + tools/doc/README.md | 76 + tools/doc/generate.js | 120 + tools/doc/html.js | 174 + tools/doc/json.js | 557 ++ tools/doc/package.json | 15 + var/.gitignore | 2 + 180 files changed, 37685 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 README.plugins create mode 100644 available_plugins/ep_fintest/.npmignore create mode 100644 available_plugins/ep_fintest/ep.json create mode 100644 available_plugins/ep_fintest/otherpart.js create mode 100644 available_plugins/ep_fintest/package.json create mode 100644 available_plugins/ep_fintest/partlast.js create mode 100644 available_plugins/ep_fintest/static/js/test.js create mode 100644 available_plugins/ep_fintest/static/test.html create mode 100755 bin/buildForWindows.sh create mode 100644 bin/checkPad.js create mode 100644 bin/convert.js create mode 100644 bin/convertSettings.json.template create mode 100755 bin/debugRun.sh create mode 100644 bin/extractPadData.js create mode 100755 bin/installDeps.sh create mode 100644 bin/installOnWindows.bat create mode 100755 bin/jshint.sh create mode 100644 bin/loadTesting/README create mode 100755 bin/loadTesting/launcher.sh create mode 100644 bin/loadTesting/loader.js create mode 100755 bin/run.sh create mode 100755 bin/safeRun.sh create mode 100644 doc/all.md create mode 100644 doc/api/api.md create mode 100644 doc/api/changeset_library.md create mode 100644 doc/api/editorInfo.md create mode 100644 doc/api/embed_parameters.md create mode 100644 doc/api/hooks_client-side.md create mode 100644 doc/api/hooks_server-side.md create mode 100644 doc/api/http_api.md create mode 100644 doc/database.md create mode 100644 doc/documentation.md create mode 100644 doc/easysync/README.md create mode 100644 doc/easysync/easysync-full-description.pdf create mode 100644 doc/easysync/easysync-full-description.tex create mode 100644 doc/easysync/easysync-notes.pdf create mode 100644 doc/easysync/easysync-notes.tex create mode 100644 doc/easysync/easysync-notes.txt create mode 100644 doc/template.html create mode 100644 settings.json.template create mode 100644 src/ep.json create mode 100644 src/node/README.md create mode 100644 src/node/db/API.js create mode 100644 src/node/db/AuthorManager.js create mode 100644 src/node/db/DB.js create mode 100644 src/node/db/GroupManager.js create mode 100644 src/node/db/Pad.js create mode 100644 src/node/db/PadManager.js create mode 100644 src/node/db/ReadOnlyManager.js create mode 100644 src/node/db/SecurityManager.js create mode 100644 src/node/db/SessionManager.js create mode 100644 src/node/easysync_tests.js create mode 100644 src/node/eejs/examples/bar.ejs create mode 100644 src/node/eejs/examples/foo.ejs create mode 100644 src/node/eejs/index.js create mode 100644 src/node/handler/APIHandler.js create mode 100644 src/node/handler/ExportHandler.js create mode 100644 src/node/handler/ImportHandler.js create mode 100644 src/node/handler/PadMessageHandler.js create mode 100644 src/node/handler/SocketIORouter.js create mode 100644 src/node/hooks/express.js create mode 100644 src/node/hooks/express/adminplugins.js create mode 100644 src/node/hooks/express/apicalls.js create mode 100644 src/node/hooks/express/errorhandling.js create mode 100644 src/node/hooks/express/importexport.js create mode 100644 src/node/hooks/express/padreadonly.js create mode 100644 src/node/hooks/express/padurlsanitize.js create mode 100644 src/node/hooks/express/socketio.js create mode 100644 src/node/hooks/express/specialpages.js create mode 100644 src/node/hooks/express/static.js create mode 100644 src/node/hooks/express/webaccess.js create mode 100644 src/node/padaccess.js create mode 100755 src/node/server.js create mode 100644 src/node/utils/Abiword.js create mode 100644 src/node/utils/Cli.js create mode 100644 src/node/utils/ExportDokuWiki.js create mode 100644 src/node/utils/ExportHtml.js create mode 100644 src/node/utils/ImportHtml.js create mode 100644 src/node/utils/Minify.js create mode 100644 src/node/utils/Settings.js create mode 100644 src/node/utils/caching_middleware.js create mode 100644 src/node/utils/customError.js create mode 100644 src/node/utils/randomstring.js create mode 100644 src/node/utils/tar.json create mode 100644 src/package.json create mode 100644 src/static/css/admin.css create mode 100644 src/static/css/iframe_editor.css create mode 100644 src/static/css/pad.css create mode 100644 src/static/css/timeslider.css create mode 100644 src/static/custom/.gitignore create mode 100644 src/static/custom/css.template create mode 100644 src/static/custom/js.template create mode 100644 src/static/favicon.ico create mode 100644 src/static/img/backgrad.gif create mode 100644 src/static/img/connectingbar.gif create mode 100644 src/static/img/crushed_button_depressed.png create mode 100644 src/static/img/crushed_button_undepressed.png create mode 100644 src/static/img/crushed_current_location.png create mode 100644 src/static/img/etherpad_lite_icons.png create mode 100644 src/static/img/fileicons.gif create mode 100644 src/static/img/leftarrow.png create mode 100644 src/static/img/loading.gif create mode 100644 src/static/img/pause.png create mode 100644 src/static/img/play.png create mode 100644 src/static/img/roundcorner_left.gif create mode 100644 src/static/img/roundcorner_right.gif create mode 100644 src/static/img/star.png create mode 100644 src/static/img/stepper_buttons.png create mode 100644 src/static/img/timeslider_background.png create mode 100644 src/static/img/timeslider_left.png create mode 100644 src/static/img/timeslider_right.png create mode 100644 src/static/js/AttributeManager.js create mode 100644 src/static/js/AttributePool.js create mode 100644 src/static/js/Changeset.js create mode 100644 src/static/js/ChangesetUtils.js create mode 100644 src/static/js/ace.js create mode 100644 src/static/js/ace2_common.js create mode 100644 src/static/js/ace2_inner.js create mode 100644 src/static/js/admin/plugins.js create mode 100644 src/static/js/broadcast.js create mode 100644 src/static/js/broadcast_revisions.js create mode 100644 src/static/js/broadcast_slider.js create mode 100644 src/static/js/changesettracker.js create mode 100644 src/static/js/chat.js create mode 100644 src/static/js/collab_client.js create mode 100644 src/static/js/colorutils.js create mode 100644 src/static/js/contentcollector.js create mode 100644 src/static/js/cssmanager.js create mode 100644 src/static/js/domline.js create mode 100644 src/static/js/draggable.js create mode 100644 src/static/js/excanvas.js create mode 100644 src/static/js/farbtastic.js create mode 100644 src/static/js/json2.js create mode 100644 src/static/js/linestylefilter.js create mode 100644 src/static/js/pad.js create mode 100644 src/static/js/pad_connectionstatus.js create mode 100644 src/static/js/pad_cookie.js create mode 100644 src/static/js/pad_docbar.js create mode 100644 src/static/js/pad_editbar.js create mode 100644 src/static/js/pad_editor.js create mode 100644 src/static/js/pad_impexp.js create mode 100644 src/static/js/pad_modals.js create mode 100644 src/static/js/pad_savedrevs.js create mode 100644 src/static/js/pad_userlist.js create mode 100644 src/static/js/pad_utils.js create mode 100644 src/static/js/pluginfw/async.js create mode 100644 src/static/js/pluginfw/hooks.js create mode 100644 src/static/js/pluginfw/installer.js create mode 100644 src/static/js/pluginfw/parent_require.js create mode 100644 src/static/js/pluginfw/plugins.js create mode 100644 src/static/js/pluginfw/read-installed.js create mode 100644 src/static/js/pluginfw/tsort.js create mode 100644 src/static/js/rjquery.js create mode 100644 src/static/js/security.js create mode 100644 src/static/js/skiplist.js create mode 100644 src/static/js/timeslider.js create mode 100644 src/static/js/tinycon.js create mode 100644 src/static/js/underscore.js create mode 100644 src/static/js/undomodule.js create mode 100644 src/static/js/unorm.js create mode 100644 src/static/js/virtual_lines.js create mode 100644 src/static/robots.txt create mode 100644 src/static/tests.html create mode 100644 src/templates/admin/plugins-info.html create mode 100644 src/templates/admin/plugins.html create mode 100644 src/templates/index.html create mode 100644 src/templates/pad.html create mode 100644 src/templates/timeslider.html create mode 100644 start.bat create mode 100644 tools/doc/LICENSE create mode 100644 tools/doc/README.md create mode 100644 tools/doc/generate.js create mode 100644 tools/doc/html.js create mode 100644 tools/doc/json.js create mode 100644 tools/doc/package.json create mode 100644 var/.gitignore diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..5810ed255 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# v1.1 +* Introduced Plugin framework +* Many bugfixes +* Faster page loading +* Various UI polishes +* Saved Revisions +* Read only Real time view +* More API functionality + +# v 1.0.1 + +* Updated MySQL driver, this fixes some problems with mysql +* Fixed export,import and timeslider link when embed parameters are used diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..a35c25535 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2012 THE ETHERPAD FOUNDATION + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..01f30701b --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +doc_dirs = doc $(wildcard doc/*/) +outdoc_dirs = out $(addprefix out/,$(doc_dirs)) +doc_sources = $(wildcard doc/*/*.md) $(wildcard doc/*.md) +outdoc_files = $(addprefix out/,$(doc_sources:.md=.html)) + +docs: $(outdoc_files) + +out/doc/%.html: doc/%.md + mkdir -p $(@D) + node tools/doc/generate.js --format=html --template=doc/template.html $< > $@ + +clean: + rm -rf out/ diff --git a/README.md b/README.md new file mode 100644 index 000000000..6cdb623f3 --- /dev/null +++ b/README.md @@ -0,0 +1,130 @@ +# Our goal is to make collaborative editing the standard on the web + +# About +Etherpad lite is a really-real time collaborative editor spawned from the Hell fire of Etherpad. +We're reusing the well tested Etherpad easysync library to make it really realtime. Etherpad Lite +is based on node.js ergo is much lighter and more stable than the original Etherpad. Our hope +is that this will encourage more users to use and install a realtime collaborative editor. A smaller, manageable and well +documented codebase makes it easier for developers to improve the code and contribute towards the project. + +Etherpad Lite is optimized to be easy embeddable. It provides a [HTTP API](https://github.com/Pita/etherpad-lite/wiki/HTTP-API) +that allows your web application to manage pads, users and groups. +There are several clients in for this API: + +* [PHP](https://github.com/TomNomNom/etherpad-lite-client), thx to [TomNomNom](https://github.com/TomNomNom) +* [.Net](https://github.com/ja-jo/EtherpadLiteDotNet), thx to [ja-jo](https://github.com/ja-jo) +* [Node.js](https://github.com/tomassedovic/etherpad-lite-client-js), thx to [tomassedovic](https://github.com/tomassedovic) +* [Ruby](https://github.com/jhollinger/ruby-etherpad-lite), thx to [jhollinger](https://github.com/jhollinger) +* [Python](https://github.com/devjones/PyEtherpadLite), thx to [devjones](https://github.com/devjones) + +There is also a [jQuery plugin](https://github.com/johnyma22/etherpad-lite-jquery-plugin) that helps you to embed Pads into your website + +**Online demo**
+Visit to test it live + +Here is the **[FAQ](https://github.com/Pita/etherpad-lite/wiki/FAQ)** + +# Etherpad vs Etherpad Lite + + + + + + + + + + + + + + + + +
 EtherpadEtherpad Lite
Size of the folder (without git history)30 MB1.5 MB
Languages used server sideJavascript (Rhino), Java, ScalaJavascript (node.js)
Lines of server side Javascript code~101k~9k
RAM Usage immediately after start257 MB (grows to ~1GB)16 MB (grows to ~30MB)
+ +# Installation + +## Windows + +1. Download +2. Extract the file +3. Open the extracted folder and double click `start.bat` +4. Open your web browser and browse to . You like it? Look at the 'Next Steps' section below + +## Linux + +**As root:** + +
    +
  1. Install the dependencies. We need gzip, git, curl, libssl develop libraries, python and gcc.
    For Debian/Ubuntu apt-get install gzip git-core curl python libssl-dev pkg-config build-essential
    + For Fedora/CentOS yum install gzip git-core curl python openssl-devel && yum groupinstall "Development Tools" +

  2. +
  3. Install node.js +
      +
    1. Download the latest 0.6.x node.js release from http://nodejs.org/#download
    2. +
    3. Extract it with tar xf node-v0.6*
    4. +
    5. Move into the node folder cd node-v0.6* and build node with ./configure && make && make install
    6. +
    +
  4. +
+ +**As any user (we recommend creating a separate user called etherpad-lite):** + +
    +
  1. Move to a folder where you want to install Etherpad Lite. Clone the git repository git clone 'git://github.com/Pita/etherpad-lite.git'
  2. +
  3. Change into the directory containing the Etherpad Lite source code clone with cd etherpad-lite
  4. +
  5. Start it with bin/run.sh
     
  6. +
  7. Open your web browser and visit http://localhost:9001. You like it? Look at the 'Next Steps' section below
  8. +
+ +## Next Steps +You can modify the settings in the file `settings.json` + +If you have multiple settings files, you may pass one to `bin/run.sh` using the `-s|--settings` option. This allows you to run multiple Etherpad Lite instances from the same installation. + +You should use a dedicated database such as "mysql" if you are planning on using etherpad-lite in a production environment, the "dirty" database driver is only for testing and/or development purposes. + +You can update to the latest version with `git pull origin`. The next start with bin/run.sh will update the dependencies + +Look at this wiki pages: + +* [How to deploy Etherpad Lite as a service](https://github.com/Pita/etherpad-lite/wiki/How-to-deploy-Etherpad-Lite-as-a-service) +* [How to put Etherpad Lite behind a reverse Proxy](https://github.com/Pita/etherpad-lite/wiki/How-to-put-Etherpad-Lite-behind-a-reverse-Proxy) +* [How to customize your Etherpad Lite installation](https://github.com/Pita/etherpad-lite/wiki/How-to-customize-your-Etherpad-Lite-installation) +* [How to use Etherpad-Lite with jQuery](https://github.com/Pita/etherpad-lite/wiki/How-to-use-Etherpad-Lite-with-jQuery) +* [How to use Etherpad Lite with MySQL](https://github.com/Pita/etherpad-lite/wiki/How-to-use-Etherpad-Lite-with-MySQL) +* [Sites that run Etherpad Lite](https://github.com/Pita/etherpad-lite/wiki/Sites-that-run-Etherpad-Lite) +* [How to migrate the database from Etherpad to Etherpad Lite](https://github.com/Pita/etherpad-lite/wiki/How-to-migrate-the-database-from-Etherpad-to-Etherpad-Lite) + +You can find more information in the [wiki](https://github.com/Pita/etherpad-lite/wiki). Feel free to improve these wiki pages + +# Develop +If you're new to git and github, start by watching [this video](http://youtu.be/67-Q26YH97E) then read this [git guide](http://learn.github.com/p/intro.html). + +If you're new to node.js, start with this video . + +You can debug with `bin/debugRun.sh` + +If you want to find out how Etherpads Easysync works (the library that makes it really realtime), start with this [PDF](https://github.com/Pita/etherpad-lite/raw/master/doc/easysync/easysync-full-description.pdf) (complex, but worth reading). + +You know all this and just want to know how you can help? Look at the [TODO list](https://github.com/Pita/etherpad-lite/wiki/TODO). +You can join the [mailinglist](http://groups.google.com/group/etherpad-lite-dev) or go to the freenode irc channel [#etherpad-lite-dev](http://webchat.freenode.net?channels=#etherpad-lite-dev) + +You also help the project, if you only host a Etherpad Lite instance and share your experience with us. + +Please consider using [jshint](http://www.jshint.com/about/) if you plan to +contribute to Etherpad Lite. + +# Modules created for this project + +* [ueberDB](https://github.com/Pita/ueberDB) "transforms every database into a object key value store" - manages all database access +* [channels](https://github.com/Pita/channels) "Event channels in node.js" - ensures that ueberDB operations are atomic and in series for each key +* [async-stacktrace](https://github.com/Pita/async-stacktrace) "Improves node.js stacktraces and makes it easier to handle errors" + +# Donations +* [Etherpad Foundation Flattr] (http://flattr.com/thing/71378/Etherpad-Foundation) +* [Paypal] (http://etherpad.org) <-- Click the donate button + +# License +[Apache License v2](http://www.apache.org/licenses/LICENSE-2.0.html) \ No newline at end of file diff --git a/README.plugins b/README.plugins new file mode 100644 index 000000000..72c456447 --- /dev/null +++ b/README.plugins @@ -0,0 +1,16 @@ +So, a plugin is an npm package whose name starts with ep_ and that contains a file ep.json +require("ep_etherpad-lite/static/js/plugingfw/plugins").update() will use npm to list all installed modules and read their ep.json files. These will contain registrations for hooks which are loaded +A hook registration is a pairs of a hook name and a function reference (filename for require() plus function name) +require("ep_etherpad-lite/static/js/plugingfw/hooks").callAll("hook_name", {argname:value}) will call all hook functions registered for hook_name +That is the basis. +Ok, so that was a slight simplification: inside ep.json, hook registrations are grouped into groups called "parts". Parts from all plugins are ordered using a topological sort according to "pre" and "post" pointers to other plugins/parts (just like dependencies, but non-installed plugins are silently ignored). +This ordering is honored when you do callAll(hook_name) - hook functions for that hook_name are called in that order +Ordering between plugins is undefined, only parts are ordered. + +A plugin usually has one part, but it van have multiple. +This is so that it can insert some hook registration before that of another plugin, and another one after. +This is important for e.g. registering URL-handlers for the express webserver, if you have some very generic and some very specific url-regexps +So, that's basically it... apart from client-side hooks +which works the same way, but uses a separate member of the part (part.client_hooks vs part.hooks), and where the hook function must obviously reside in a file require():able from the client... +One thing more: The main etherpad tree is actually a plugin itself, called ep_etherpad-lite, and it has it's own ep.json... +was that clear? \ No newline at end of file diff --git a/available_plugins/ep_fintest/.npmignore b/available_plugins/ep_fintest/.npmignore new file mode 100644 index 000000000..74bd365b4 --- /dev/null +++ b/available_plugins/ep_fintest/.npmignore @@ -0,0 +1,7 @@ +.git* +docs/ +examples/ +support/ +test/ +testing.js +.DS_Store diff --git a/available_plugins/ep_fintest/ep.json b/available_plugins/ep_fintest/ep.json new file mode 100644 index 000000000..4ec8e3924 --- /dev/null +++ b/available_plugins/ep_fintest/ep.json @@ -0,0 +1,36 @@ +{ + "parts": [ + { + "name": "somepart", + "pre": [], + "post": ["ep_onemoreplugin/partone"] + }, + { + "name": "partlast", + "pre": ["ep_fintest/otherpart"], + "post": [], + "hooks": { + "somehookname": "ep_fintest/partlast:somehook" + } + }, + { + "name": "partfirst", + "pre": [], + "post": ["ep_onemoreplugin/somepart"] + }, + { + "name": "otherpart", + "pre": ["ep_fintest/somepart", "ep_otherplugin/main"], + "post": [], + "hooks": { + "somehookname": "ep_fintest/otherpart:somehook", + "morehook": "ep_fintest/otherpart:morehook", + "expressCreateServer": "ep_fintest/otherpart:expressServer", + "eejsBlock_editbarMenuLeft": "ep_fintest/otherpart:eejsBlock_editbarMenuLeft" + }, + "client_hooks": { + "somehookname": "ep_fintest/static/js/test:bar" + } + } + ] +} diff --git a/available_plugins/ep_fintest/otherpart.js b/available_plugins/ep_fintest/otherpart.js new file mode 100644 index 000000000..718fb095c --- /dev/null +++ b/available_plugins/ep_fintest/otherpart.js @@ -0,0 +1,25 @@ +test = require("ep_fintest/static/js/test.js"); +console.log("FOOO:", test.foo); + +exports.somehook = function (hook_name, args, cb) { + return cb(["otherpart:somehook was here"]); +} + +exports.morehook = function (hook_name, args, cb) { + return cb(["otherpart:morehook was here"]); +} + +exports.expressServer = function (hook_name, args, cb) { + args.app.get('/otherpart', function(req, res) { + res.send("Abra cadabra"); + }); +} + +exports.eejsBlock_editbarMenuLeft = function (hook_name, args, cb) { + args.content = args.content + '\ +
  • \ + \ +
  • \ + '; + return cb(); +} diff --git a/available_plugins/ep_fintest/package.json b/available_plugins/ep_fintest/package.json new file mode 100644 index 000000000..e221b5c18 --- /dev/null +++ b/available_plugins/ep_fintest/package.json @@ -0,0 +1,9 @@ +{ + "name": "ep_fintest", + "description": "A test plugin", + "version": "0.0.1", + "author": "RedHog (Egil Moeller) ", + "contributors": [], + "dependencies": {}, + "engines": { "node": ">= 0.4.1 < 0.7.0" } +} diff --git a/available_plugins/ep_fintest/partlast.js b/available_plugins/ep_fintest/partlast.js new file mode 100644 index 000000000..c3f1fc3eb --- /dev/null +++ b/available_plugins/ep_fintest/partlast.js @@ -0,0 +1,3 @@ +exports.somehook = function (hook_name, args, cb) { + return cb(["partlast:somehook was here"]); +} diff --git a/available_plugins/ep_fintest/static/js/test.js b/available_plugins/ep_fintest/static/js/test.js new file mode 100644 index 000000000..22d58cc2f --- /dev/null +++ b/available_plugins/ep_fintest/static/js/test.js @@ -0,0 +1,5 @@ +exports.foo = 42; + +exports.bar = function (hook_name, args, cb) { + return cb(["FOOOO"]); +} \ No newline at end of file diff --git a/available_plugins/ep_fintest/static/test.html b/available_plugins/ep_fintest/static/test.html new file mode 100644 index 000000000..9e7fc5511 --- /dev/null +++ b/available_plugins/ep_fintest/static/test.html @@ -0,0 +1 @@ +Test bla bla diff --git a/bin/buildForWindows.sh b/bin/buildForWindows.sh new file mode 100755 index 000000000..1d47bff1b --- /dev/null +++ b/bin/buildForWindows.sh @@ -0,0 +1,63 @@ +#!/bin/sh + +NODE_VERSION="0.8.4" + +#Move to the folder where ep-lite is installed +cd `dirname $0` + +#Was this script started in the bin folder? if yes move out +if [ -d "../bin" ]; then + cd "../" +fi + +#Is wget installed? +hash wget > /dev/null 2>&1 || { + echo "Please install wget" >&2 + exit 1 +} + +#Is zip installed? +hash zip > /dev/null 2>&1 || { + echo "Please install zip" >&2 + exit 1 +} + +#Is zip installed? +hash unzip > /dev/null 2>&1 || { + echo "Please install unzip" >&2 + exit 1 +} + +START_FOLDER=$(pwd); + +echo "create a clean environment in /tmp/etherpad-lite-win..." +rm -rf /tmp/etherpad-lite-win +cp -ar . /tmp/etherpad-lite-win +cd /tmp/etherpad-lite-win +rm -rf node_modules +rm -f etherpad-lite-win.zip + +echo "do a normal unix install first..." +bin/installDeps.sh || exit 1 + +echo "copy the windows settings template..." +cp settings.json.template settings.json + +echo "resolve symbolic links..." +cp -rL node_modules node_modules_resolved +rm -rf node_modules +mv node_modules_resolved node_modules + +echo "download windows node..." +cd bin +wget "http://nodejs.org/dist/v$NODE_VERSION/node.exe" -O ../node.exe + +echo "create the zip..." +cd /tmp +zip -9 -r etherpad-lite-win.zip etherpad-lite-win +mv etherpad-lite-win.zip $START_FOLDER + +echo "clean up..." +rm -rf /tmp/etherpad-lite-win + +echo "Finished. You can find the zip in the Etherpad Lite root folder, it's called etherpad-lite-win.zip" diff --git a/bin/checkPad.js b/bin/checkPad.js new file mode 100644 index 000000000..a46c18140 --- /dev/null +++ b/bin/checkPad.js @@ -0,0 +1,134 @@ +/* + This is a debug tool. It checks all revisions for data corruption +*/ + +if(process.argv.length != 3) +{ + console.error("Use: node bin/checkPad.js $PADID"); + process.exit(1); +} +//get the padID +var padId = process.argv[2]; + +//initalize the database +var log4js = require("../src/node_modules/log4js"); +log4js.setGlobalLogLevel("INFO"); +var async = require("../src/node_modules/async"); +var db = require('../src/node/db/DB'); + +var Changeset = require("ep_etherpad-lite/static/js/Changeset"); +var padManager; + +async.series([ + //intallize the database + function (callback) + { + db.init(callback); + }, + //get the pad + function (callback) + { + padManager = require('../src/node/db/PadManager'); + + padManager.doesPadExists(padId, function(err, exists) + { + if(!exists) + { + console.error("Pad does not exist"); + process.exit(1); + } + + padManager.getPad(padId, function(err, _pad) + { + pad = _pad; + callback(err); + }); + }); + }, + function (callback) + { + //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 /dev/null 2>&1 || { + echo "You need to install node-inspector to run the tests!" >&2 + echo "You can install it with npm" >&2 + echo "Run: npm install -g node-inspector" >&2 + exit 1 +} + +node-inspector & + +echo "If you are new to node-inspector, take a look at this video: http://youtu.be/AOnK3NVnxL8" + +node --debug node_modules/ep_etherpad-lite/node/server.js $* + +#kill node-inspector before ending +kill $! diff --git a/bin/extractPadData.js b/bin/extractPadData.js new file mode 100644 index 000000000..061a2e3f9 --- /dev/null +++ b/bin/extractPadData.js @@ -0,0 +1,89 @@ +/* + This is a debug tool. It helps to extract all datas of a pad and move it from an productive enviroment and to a develop enviroment to reproduce bugs there. It outputs a dirtydb file +*/ + +if(process.argv.length != 3) +{ + console.error("Use: node extractPadData.js $PADID"); + process.exit(1); +} +//get the padID +var padId = process.argv[2]; + +//initalize the database +var log4js = require("log4js"); +log4js.setGlobalLogLevel("INFO"); +var async = require("async"); +var db = require('../node/db/DB'); +var dirty = require("dirty")(padId + ".db"); +var padManager; +var pad; +var neededDBValues = ["pad:"+padId]; + +async.series([ + //intallize the database + function (callback) + { + db.init(callback); + }, + //get the pad + function (callback) + { + padManager = require('../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 /dev/null 2>&1 || { + echo "Please install curl" >&2 + exit 1 +} + +#Is node installed? +hash node > /dev/null 2>&1 || { + echo "Please install node.js ( http://nodejs.org )" >&2 + exit 1 +} + +#Is npm installed? +hash npm > /dev/null 2>&1 || { + echo "Please install npm ( http://npmjs.org )" >&2 + exit 1 +} + +#check npm version +NPM_VERSION=$(npm --version) +if [ ! $(echo $NPM_VERSION | cut -d "." -f 1) = "1" ]; then + echo "You're running a wrong version of npm, you're using $NPM_VERSION, we need 1.x" >&2 + exit 1 +fi + +#check node version +NODE_VERSION=$(node --version) +NODE_V_MINOR=$(echo $NODE_VERSION | cut -d "." -f 1-2) +if [ ! $NODE_V_MINOR = "v0.8" ] && [ ! $NODE_V_MINOR = "v0.6" ]; then + echo "You're running a wrong version of node, you're using $NODE_VERSION, we need v0.6.x or v0.8.x" >&2 + exit 1 +fi + +#Get the name of the settings file +settings="settings.json" +a=''; +for arg in $*; do + if [ "$a" = "--settings" ] || [ "$a" = "-s" ]; then settings=$arg; fi + a=$arg +done + +#Does a $settings exist? if no copy the template +if [ ! -f $settings ]; then + echo "Copy the settings template to $settings..." + cp -v settings.json.template $settings || exit 1 +fi + +echo "Ensure that all dependencies are up to date..." +( + mkdir -p node_modules + cd node_modules + [ -e ep_etherpad-lite ] || ln -s ../src ep_etherpad-lite + cd ep_etherpad-lite + npm install +) || { + rm -rf node_modules + exit 1 +} + +echo "Ensure jQuery is downloaded and up to date..." +DOWNLOAD_JQUERY="true" +NEEDED_VERSION="1.7.1" +if [ -f "src/static/js/jquery.js" ]; then + VERSION=$(cat src/static/js/jquery.js | head -n 3 | grep -o "v[0-9]\.[0-9]\(\.[0-9]\)\?"); + + if [ ${VERSION#v} = $NEEDED_VERSION ]; then + DOWNLOAD_JQUERY="false" + fi +fi + +if [ $DOWNLOAD_JQUERY = "true" ]; then + curl -lo src/static/js/jquery.js http://code.jquery.com/jquery-$NEEDED_VERSION.js || exit 1 +fi + +#Remove all minified data to force node creating it new +echo "Clear minfified cache..." +rm -f var/minified* + +echo "ensure custom css/js files are created..." + +for f in "index" "pad" "timeslider" +do + if [ ! -f "src/static/custom/$f.js" ]; then + cp -v "src/static/custom/js.template" "src/static/custom/$f.js" || exit 1 + fi + + if [ ! -f "src/static/custom/$f.css" ]; then + cp -v "src/static/custom/css.template" "src/static/custom/$f.css" || exit 1 + fi +done + +exit 0 diff --git a/bin/installOnWindows.bat b/bin/installOnWindows.bat new file mode 100644 index 000000000..c46ac0340 --- /dev/null +++ b/bin/installOnWindows.bat @@ -0,0 +1,39 @@ +@echo off + +:: change directory to etherpad-lite root +cd /D "%~dp0\.." + +:: Is node installed? +cmd /C node -e "" || ( echo "Please install node.js ( http://nodejs.org )" && exit /B 1 ) + +echo _ +echo Checking node version... +set check_version="if(['6','8'].indexOf(process.version.split('.')[1].toString()) === -1) { console.log('You are running a wrong version of Node. Etherpad Lite requires v0.6.x or v0.8.x'); process.exit(1) }" +cmd /C node -e %check_version% || exit /B 1 + +echo _ +echo Installing etherpad-lite and dependencies... +cmd /C npm install src/ || exit /B 1 + +echo _ +echo Copying custom templates... +set custom_dir=node_modules\ep_etherpad-lite\static\custom +FOR %%f IN (index pad timeslider) DO ( + if NOT EXIST "%custom_dir%\%%f.js" copy "%custom_dir%\js.template" "%custom_dir%\%%f.js" + if NOT EXIST "%custom_dir%\%%f.css" copy "%custom_dir%\css.template" "%custom_dir%\%%f.css" +) + +echo _ +echo Clearing cache... +del /S var\minified* + +echo _ +echo Setting up settings.json... +IF NOT EXIST settings.json ( + echo Can't find settings.json. + echo Copying settings.json.template... + cmd /C copy settings.json.template settings.json || exit /B 1 +) + +echo _ +echo Installed Etherpad-lite! \ No newline at end of file diff --git a/bin/jshint.sh b/bin/jshint.sh new file mode 100755 index 000000000..4dea73961 --- /dev/null +++ b/bin/jshint.sh @@ -0,0 +1,9 @@ +#!/bin/sh + +if [ -d "../bin" ]; then + cd "../" +fi + +JSHINT=./node_modules/jshint/bin/hint + +$JSHINT ./node/ diff --git a/bin/loadTesting/README b/bin/loadTesting/README new file mode 100644 index 000000000..297756f9d --- /dev/null +++ b/bin/loadTesting/README @@ -0,0 +1,75 @@ +This load tester is extremely useful for testing how many dormant clients can connect to etherpad lite. + +TODO: +Emulate characters being typed into a pad + +HOW TO USE (from @mjd75) proper formatting at: https://github.com/Pita/etherpad-lite/issues/360 + +Server 1: +Installed Node.js (etc), EtherPad Lite and MySQL + +Server 2: +Installed Xvfb and PhantomJS + +I installed Xvfb following (roughly) this guide: http://blog.martin-lyness.com/archives/installing-xvfb-on-ubuntu-9-10-karmic-koala + + #sudo apt-get install xvfb + #sudo apt-get install xfonts-100dpi xfonts-75dpi xfonts-scalable xfonts-cyrillic + +Launched two instances of Xvfb directly from the terminal: + + #Xvfb :0 -ac + #Xvfb :1 -ac + +I installed PhantomJS following this guide: http://code.google.com/p/phantomjs/wiki/Installation + + #sudo add-apt-repository ppa:jerome-etienne/neoip + #sudo apt-get update + #sudo apt-get install phantomjs + +I created a small JavaScript file for PhatomJS to use to control the browser instances: + +### BEGIN JAVASCRIPT ### + +var page = new WebPage(), + t, address; + +if (phantom.args.length === 0) { + console.log('Usage: loader.js '); + phantom.exit(); +} else { + t = Date.now(); + address = phantom.args[0]; + + var page = new WebPage(); + page.onResourceRequested = function (request) { + console.log('Request ' + JSON.stringify(request, undefined, 4)); + }; + page.onResourceReceived = function (response) { + console.log('Receive ' + JSON.stringify(response, undefined, 4)); + }; + page.open(address); + +} + +### END JAVASCRIPT ### + +And finally a launcher script that uses screen to run 400 instances of PhantomJS with the above script: + +### BEGIN SHELL SCRIPT ### + +#!/bin/bash + +# connect 200 instances to display :0 +for i in {1..200} +do + DISPLAY=:0 screen -d -m phantomjs loader.js http://ec2-50-17-168-xx.compute-1.amazonaws.com:9001/p/pad2 && sleep 2 +done + +# connect 200 instances to display :1 +for i in {1..200} +do + DISPLAY=:1 screen -d -m phantomjs loader.js http://ec2-50-17-168-xx.compute-1.amazonaws.com:9001/p/pad2 && sleep 2 +done + +### END SHELL SCRIPT ### diff --git a/bin/loadTesting/launcher.sh b/bin/loadTesting/launcher.sh new file mode 100755 index 000000000..375b15444 --- /dev/null +++ b/bin/loadTesting/launcher.sh @@ -0,0 +1,16 @@ +#!/bin/bash + +# connect 500 instances to display :0 +for i in {1..500} +do + echo $i + echo "Displaying Some shit" + DISPLAY=:0 screen -d -m /home/phantomjs/bin/phantomjs loader.js http://10.0.0.55:9001/p/pad2 && sleep 2 +done + +# connect 500 instances to display :1 +for i in {1..500} +do + echo $i + DISPLAY=:1 screen -d -m /home/phantomjs/bin/phantomjs loader.js http://10.0.0.55:9001/p/pad2 && sleep 2 +done diff --git a/bin/loadTesting/loader.js b/bin/loadTesting/loader.js new file mode 100644 index 000000000..ddcd0572d --- /dev/null +++ b/bin/loadTesting/loader.js @@ -0,0 +1,20 @@ +var page = new WebPage(), + t, address; + +if (phantom.args.length === 0) { + console.log('Usage: loader.js '); + phantom.exit(); +} else { + t = Date.now(); + address = phantom.args[0]; + + var page = new WebPage(); + page.onResourceRequested = function (request) { + console.log('Request ' + JSON.stringify(request, undefined, 4)); + }; + page.onResourceReceived = function (response) { + console.log('Receive ' + JSON.stringify(response, undefined, 4)); + }; + page.open(address); + +} diff --git a/bin/run.sh b/bin/run.sh new file mode 100755 index 000000000..82e89a946 --- /dev/null +++ b/bin/run.sh @@ -0,0 +1,28 @@ +#!/bin/sh + +#Move to the folder where ep-lite is installed +cd `dirname $0` + +#Was this script started in the bin folder? if yes move out +if [ -d "../bin" ]; then + cd "../" +fi + +#Stop the script if its started as root +if [ "$(id -u)" -eq 0 ]; then + echo "You shouldn't start Etherpad-Lite as root!" + echo "Please type 'Etherpad Lite rocks my socks' if you still want to start it as root" + read rocks + if [ ! $rocks = "Etherpad Lite rocks my socks" ] + then + echo "Your input was incorrect" + exit 1 + fi +fi + +#prepare the enviroment +bin/installDeps.sh $* || exit 1 + +#Move to the node folder and start +echo "start..." +node node_modules/ep_etherpad-lite/node/server.js $* diff --git a/bin/safeRun.sh b/bin/safeRun.sh new file mode 100755 index 000000000..b060f5d19 --- /dev/null +++ b/bin/safeRun.sh @@ -0,0 +1,66 @@ +#!/bin/sh + +#This script ensures that ep-lite is automatically restarting after an error happens + +#Handling Errors +# 0 silent +# 1 email +ERROR_HANDLING=0 +# Your email address which should recieve the error messages +EMAIL_ADDRESS="no-reply@example.com" +# Sets the minimun amount of time betweens the sending of error emails. +# This ensures you not get spamed while a endless reboot loop +# It's the time in seconds +TIME_BETWEEN_EMAILS=600 # 10 minutes + +# DON'T EDIT AFTER THIS LINE + +LAST_EMAIL_SEND=0 + +#Move to the folder where ep-lite is installed +cd `dirname $0` + +#Was this script started in the bin folder? if yes move out +if [ -d "../bin" ]; then + cd "../" +fi + +#check if a logfile parameter is set +if [ -z "$1" ]; then + echo "Set a logfile as the first parameter" + exit 1 +fi + +while [ 1 ] +do + #try to touch the file if it doesn't exist + if [ ! -f $1 ]; then + touch $1 || ( echo "Logfile '$1' is not writeable" && exit 1 ) + fi + + #check if the file is writeable + if [ ! -w $1 ]; then + echo "Logfile '$1' is not writeable" + exit 1 + fi + + #start the application + bin/run.sh >>$1 2>>$1 + + #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 restared at: $(date)\nThe last 50 lines of the log before the error happens:\n $(tail -n 50 $1)" | mail -s "Pad Server was restarted" $EMAIL_ADDRESS + + LAST_EMAIL_SEND=$TIME_NOW + fi + fi + + echo "RESTART!" >>$1 + + #Sleep 10 seconds before restart + sleep 10 +done diff --git a/doc/all.md b/doc/all.md new file mode 100644 index 000000000..c0cbf369f --- /dev/null +++ b/doc/all.md @@ -0,0 +1,3 @@ +@include documentation +@include api/api +@include database diff --git a/doc/api/api.md b/doc/api/api.md new file mode 100644 index 000000000..830e5f4c4 --- /dev/null +++ b/doc/api/api.md @@ -0,0 +1,7 @@ +# API +@include embed_parameters +@include http_api +@include hooks_client-side +@include hooks_server-side +@include editorInfo +@include changeset_library \ No newline at end of file diff --git a/doc/api/changeset_library.md b/doc/api/changeset_library.md new file mode 100644 index 000000000..2dce55aa3 --- /dev/null +++ b/doc/api/changeset_library.md @@ -0,0 +1,151 @@ +# Changeset Library + +``` +"Z:z>1|2=m=b*0|1+1$\n" +``` + +This is a Changeset. Its just a string and its very difficult to read in this form. But the Changeset Library gives us some tools to read it. + +A changeset describes the diff between two revisions of the document. The Browser sends changesets to the server and the server sends them to the clients to update them. This Changesets gets also saved into the history of a pad. Which allows us to go back to every revision from the past. + +## Changeset.unpack(changeset) + + * `changeset` {String} + +This functions returns an object representaion of the changeset, similar to this: + +``` +{ oldLen: 35, newLen: 36, ops: '|2=m=b*0|1+1', charBank: '\n' } +``` + + * `oldLen` {Number} the original length of the document. + * `newLen` {Number} the length of the document after the changeset is applied. + * `ops` {String} the actual changes, introduced by this changeset. + * `charBank` {String} All characters that are added by this changeset. + +## Changeset.opIterator(ops) + + * `ops` {String} The operators, returned by `Changeset.unpack()` + +Returns an operator iterator. This iterator allows us to iterate over all operators that are in the changeset. + +You can iterate with an opIterator using its `next()` and `hasNext()` methods. Next returns the `next()` operator object and `hasNext()` indicates, whether there are any operators left. + +## The Operator object +There are 3 types of operators: `+`,`-` and `=`. These operators describe different changes to the document, beginning with the first character of the document. A `=` operator doesn't change the text, but it may add or remove text attributes. A `-` operator removes text. And a `+` Operator adds text and optionally adds some attributes to it. + + * `opcode` {String} the operator type + * `chars` {Number} the length of the text changed by this operator. + * `lines` {Number} the number of lines changed by this operator. + * `attribs` {attribs} attributes set on this text. + +### Example +``` +{ opcode: '+', + chars: 1, + lines: 1, + attribs: '*0' } +``` + +## APool + +``` +> var AttributePoolFactory = require("./utils/AttributePoolFactory"); +> var apool = AttributePoolFactory.createAttributePool(); +> console.log(apool) +{ numToAttrib: {}, + attribToNum: {}, + nextNum: 0, + putAttrib: [Function], + getAttrib: [Function], + getAttribKey: [Function], + getAttribValue: [Function], + eachAttrib: [Function], + toJsonable: [Function], + fromJsonable: [Function] } +``` + +This creates an empty apool. A apool saves which attributes were used during the history of a pad. There is one apool for each pad. It only saves the attributes that were really used, it doesn't save unused attributes. Lets fill this apool with some values + +``` +> apool.fromJsonable({"numToAttrib":{"0":["author","a.kVnWeomPADAT2pn9"],"1":["bold","true"],"2":["italic","true"]},"nextNum":3}); +> console.log(apool) +{ numToAttrib: + { '0': [ 'author', 'a.kVnWeomPADAT2pn9' ], + '1': [ 'bold', 'true' ], + '2': [ 'italic', 'true' ] }, + attribToNum: + { 'author,a.kVnWeomPADAT2pn9': 0, + 'bold,true': 1, + 'italic,true': 2 }, + nextNum: 3, + putAttrib: [Function], + getAttrib: [Function], + getAttribKey: [Function], + getAttribValue: [Function], + eachAttrib: [Function], + toJsonable: [Function], + fromJsonable: [Function] } +``` + +We used the fromJsonable function to fill the empty apool with values. the fromJsonable and toJsonable functions are used to serialize and deserialize an apool. You can see that it stores the relation between numbers and attributes. So for example the attribute 1 is the attribute bold and vise versa. A attribute is always a key value pair. For stuff like bold and italic its just 'italic':'true'. For authors its author:$AUTHORID. So a character can be bold and italic. But it can't belong to multiple authors + +``` +> apool.getAttrib(1) +[ 'bold', 'true' ] +``` + +Simple example of how to get the key value pair for the attribute 1 + +## AText + +``` +> var atext = {"text":"bold text\nitalic text\nnormal text\n\n","attribs":"*0*1+9*0|1+1*0*1*2+b|1+1*0+b|2+2"}; +> console.log(atext) +{ text: 'bold text\nitalic text\nnormal text\n\n', + attribs: '*0*1+9*0|1+1*0*1*2+b|1+1*0+b|2+2' } +``` + +This is an atext. An atext has two parts: text and attribs. The text is just the text of the pad as a string. We will look closer at the attribs at the next steps + +``` +> var opiterator = Changeset.opIterator(atext.attribs) +> console.log(opiterator) +{ next: [Function: next], + hasNext: [Function: hasNext], + lastIndex: [Function: lastIndex] } +> opiterator.next() +{ opcode: '+', + chars: 9, + lines: 0, + attribs: '*0*1' } +> opiterator.next() +{ opcode: '+', + chars: 1, + lines: 1, + attribs: '*0' } +> opiterator.next() +{ opcode: '+', + chars: 11, + lines: 0, + attribs: '*0*1*2' } +> opiterator.next() +{ opcode: '+', + chars: 1, + lines: 1, + attribs: '' } +> opiterator.next() +{ opcode: '+', + chars: 11, + lines: 0, + attribs: '*0' } +> opiterator.next() +{ opcode: '+', + chars: 2, + lines: 2, + attribs: '' } +``` + +The attribs are again a bunch of operators like .ops in the changeset was. But these operators are only + operators. They describe which part of the text has which attributes + +For more information see /doc/easysync/easysync-notes.txt in the source. diff --git a/doc/api/editorInfo.md b/doc/api/editorInfo.md new file mode 100644 index 000000000..e4322e9e1 --- /dev/null +++ b/doc/api/editorInfo.md @@ -0,0 +1,47 @@ +# editorInfo + +## editorInfo.ace_replaceRange(start, end, text) +This function replaces a range (from `start` to `end`) with `text`. + +## editorInfo.ace_getRep() +Returns the `rep` object. + +## editorInfo.ace_getAuthor() +## editorInfo.ace_inCallStack() +## editorInfo.ace_inCallStackIfNecessary(?) +## editorInfo.ace_focus(?) +## editorInfo.ace_importText(?) +## editorInfo.ace_importAText(?) +## editorInfo.ace_exportText(?) +## editorInfo.ace_editorChangedSize(?) +## editorInfo.ace_setOnKeyPress(?) +## editorInfo.ace_setOnKeyDown(?) +## editorInfo.ace_setNotifyDirty(?) +## editorInfo.ace_dispose(?) +## editorInfo.ace_getFormattedCode(?) +## editorInfo.ace_setEditable(bool) +## editorInfo.ace_execCommand(?) +## editorInfo.ace_callWithAce(fn, callStack, normalize) +## editorInfo.ace_setProperty(key, value) +## editorInfo.ace_setBaseText(txt) +## editorInfo.ace_setBaseAttributedText(atxt, apoolJsonObj) +## editorInfo.ace_applyChangesToBase(c, optAuthor, apoolJsonObj) +## editorInfo.ace_prepareUserChangeset() +## editorInfo.ace_applyPreparedChangesetToBase() +## editorInfo.ace_setUserChangeNotificationCallback(f) +## editorInfo.ace_setAuthorInfo(author, info) +## editorInfo.ace_setAuthorSelectionRange(author, start, end) +## editorInfo.ace_getUnhandledErrors() +## editorInfo.ace_getDebugProperty(prop) +## editorInfo.ace_fastIncorp(?) +## editorInfo.ace_isCaret(?) +## editorInfo.ace_getLineAndCharForPoint(?) +## editorInfo.ace_performDocumentApplyAttributesToCharRange(?) +## editorInfo.ace_setAttributeOnSelection(?) +## editorInfo.ace_toggleAttributeOnSelection(?) +## editorInfo.ace_performSelectionChange(?) +## editorInfo.ace_doIndentOutdent(?) +## editorInfo.ace_doUndoRedo(?) +## editorInfo.ace_doInsertUnorderedList(?) +## editorInfo.ace_doInsertOrderedList(?) +## editorInfo.ace_performDocumentApplyAttributesToRange() diff --git a/doc/api/embed_parameters.md b/doc/api/embed_parameters.md new file mode 100644 index 000000000..587305665 --- /dev/null +++ b/doc/api/embed_parameters.md @@ -0,0 +1,47 @@ +# Embed parameters +You can easily embed your etherpad-lite into any webpage by using iframes. You can configure the embedded pad using embed paramters. + +Example: + +Cut and paste the following code into any webpage to embed a pad. The parameters below will hide the chat and the line numbers. + +``` + +``` + +## showLineNumbers + * Boolean + +Default: true + +## showControls + * Boolean + +Default: true + +## showChat + * Boolean + +Default: true + +## useMonospaceFont + * Boolean + +Default: false + +## userName + * String + +Default: "unnamed" + +Example: `userName=Etherpad%20User` + +## noColors + * Boolean + +Default: false + +## alwaysShowChat + * Boolean + +Default: false diff --git a/doc/api/hooks_client-side.md b/doc/api/hooks_client-side.md new file mode 100644 index 000000000..50ec3f989 --- /dev/null +++ b/doc/api/hooks_client-side.md @@ -0,0 +1,181 @@ +# Client-side hooks +Most of these hooks are called during or in order to set up the formatting process. + +All hooks registered to these events are called with two arguments: + +1. name - the name of the hook being called +2. context - an object with some relevant information about the context of the call + +## documentReady +Called from: src/templates/pad.html + +Things in context: + +nothing + +This hook proxies the functionality of jQuery's `$(document).ready` event. + +## aceDomLineProcessLineAttributes +Called from: src/static/js/domline.js + +Things in context: + +1. domline - The current DOM line being processed +2. cls - The class of the current block element (useful for styling) + +This hook is called for elements in the DOM that have the "lineMarkerAttribute" set. You can add elements into this category with the aceRegisterBlockElements hook above. + +The return value of this hook should have the following structure: + +`{ preHtml: String, postHtml: String, processedMarker: Boolean }` + +The preHtml and postHtml values will be added to the HTML display of the element, and if processedMarker is true, the engine won't try to process it any more. + +## aceCreateDomLine +Called from: src/static/js/domline.js + +Things in context: + +1. domline - the current DOM line being processed +2. cls - The class of the current element (useful for styling) + +This hook is called for any line being processed by the formatting engine, unless the aceDomLineProcessLineAttributes hook from above returned true, in which case this hook is skipped. + +The return value of this hook should have the following structure: + +`{ extraOpenTags: String, extraCloseTags: String, cls: String }` + +extraOpenTags and extraCloseTags will be added before and after the element in question, and cls will be the new class of the element going forward. + +## acePostWriteDomLineHTML +Called from: src/static/js/domline.js + +Things in context: + +1. node - the DOM node that just got written to the page + +This hook is for right after a node has been fully formatted and written to the page. + +## aceAttribsToClasses +Called from: src/static/js/linestylefilter.js + +Things in context: + +1. linestylefilter - the JavaScript object that's currently processing the ace attributes +2. key - the current attribute being processed +3. value - the value of the attribute being processed + +This hook is called during the attribute processing procedure, and should be used to translate key, value pairs into valid HTML classes that can be inserted into the DOM. + +The return value for this function should be a list of classes, which will then be parsed into a valid class string. + +## aceGetFilterStack +Called from: src/static/js/linestylefilter.js + +Things in context: + +1. linestylefilter - the JavaScript object that's currently processing the ace attributes +2. browser - an object indicating which browser is accessing the page + +This hook is called to apply custom regular expression filters to a set of styles. The one example available is the ep_linkify plugin, which adds internal links. They use it to find the telltale `[[ ]]` syntax that signifies internal links, and finding that syntax, they add in the internalHref attribute to be later used by the aceCreateDomLine hook (documented above). + +## aceEditorCSS +Called from: src/static/js/ace.js + +Things in context: None + +This hook is provided to allow custom CSS files to be loaded. The return value should be an array of paths relative to the plugins directory. + +## aceInitInnerdocbodyHead +Called from: src/static/js/ace.js + +Things in context: + +1. iframeHTML - the HTML of the editor iframe up to this point, in array format + +This hook is called during the creation of the editor HTML. The array should have lines of HTML added to it, giving the plugin author a chance to add in meta, script, link, and other tags that go into the `` element of the editor HTML document. + +## aceEditEvent +Called from: src/static/js/ace2_inner.js + +Things in context: + +1. callstack - a bunch of information about the current action +2. editorInfo - information about the user who is making the change +3. rep - information about where the change is being made +4. documentAttributeManager - information about attributes in the document (this is a mystery to me) + +This hook is made available to edit the edit events that might occur when changes are made. Currently you can change the editor information, some of the meanings of the edit, and so on. You can also make internal changes (internal to your plugin) that use the information provided by the edit event. + +## aceRegisterBlockElements +Called from: src/static/js/ace2_inner.js + +Things in context: None + +The return value of this hook will add elements into the "lineMarkerAttribute" category, making the aceDomLineProcessLineAttributes hook (documented below) call for those elements. + +## aceInitialized +Called from: src/static/js/ace2_inner.js + +Things in context: + +1. editorInfo - information about the user who will be making changes through the interface, and a way to insert functions into the main ace object (see ep_headings) +2. rep - information about where the user's cursor is +3. documentAttributeManager - some kind of magic + +This hook is for inserting further information into the ace engine, for later use in formatting hooks. + +## postAceInit +Called from: src/static/js/pad.js + +Things in context: + +1. ace - the ace object that is applied to this editor. + +There doesn't appear to be any example available of this particular hook being used, but it gets fired after the editor is all set up. + +## userJoinOrUpdate +Called from: src/static/js/pad_userlist.js + +Things in context: + +1. info - the user information + +This hook is called on the client side whenever a user joins or changes. This can be used to create notifications or an alternate user list. + +## collectContentPre +Called from: src/static/js/contentcollector.js + +Things in context: + +1. cc - the contentcollector object +2. state - the current state of the change being made +3. tname - the tag name of this node currently being processed +4. style - the style applied to the node (probably CSS) +5. cls - the HTML class string of the node + +This hook is called before the content of a node is collected by the usual methods. The cc object can be used to do a bunch of things that modify the content of the pad. See, for example, the heading1 plugin for etherpad original. + +## collectContentPost +Called from: src/static/js/contentcollector.js + +Things in context: + +1. cc - the contentcollector object +2. state - the current state of the change being made +3. tname - the tag name of this node currently being processed +4. style - the style applied to the node (probably CSS) +5. cls - the HTML class string of the node + +This hook is called after the content of a node is collected by the usual methods. The cc object can be used to do a bunch of things that modify the content of the pad. See, for example, the heading1 plugin for etherpad original. + +## handleClientMessage_`name` +Called from: `src/static/js/collab_client.js` + +Things in context: + +1. payload - the data that got sent with the message (use it for custom message content) + +This hook gets called every time the client receives a message of type `name`. This can most notably be used with the new HTTP API call, "sendClientsMessage", which sends a custom message type to all clients connected to a pad. You can also use this to handle existing types. + +`collab_client.js` has a pretty extensive list of message types, if you want to take a look. diff --git a/doc/api/hooks_server-side.md b/doc/api/hooks_server-side.md new file mode 100644 index 000000000..518e12130 --- /dev/null +++ b/doc/api/hooks_server-side.md @@ -0,0 +1,143 @@ +# Server-side hooks +These hooks are called on server-side. + +All hooks registered to these events are called with two arguments: + +1. name - the name of the hook being called +2. context - an object with some relevant information about the context of the call + +## loadSettings +Called from: src/node/server.js + +Things in context: + +1. settings - the settings object + +Use this hook to receive the global settings in your plugin. + +## pluginUninstall +Called from: src/static/js/pluginfw/installer.js + +Things in context: + +1. plugin_name - self-explanatory + +If this hook returns an error, the callback to the uninstall function gets an error as well. This mostly seems useful for handling additional features added in based on the installation of other plugins, which is pretty cool! + +## pluginInstall +Called from: src/static/js/pluginfw/installer.js + +Things in context: + +1. plugin_name - self-explanatory + +If this hook returns an error, the callback to the install function gets an error, too. This seems useful for adding in features when a particular plugin is installed. + +## init_`` +Called from: src/static/js/pluginfw/plugins.js + +Things in context: None + +This function is called after a specific plugin is initialized. This would probably be more useful than the previous two functions if you only wanted to add in features to one specific plugin. + +## expressConfigure +Called from: src/node/server.js + +Things in context: + +1. app - the main application object + +This is a helpful hook for changing the behavior and configuration of the application. It's called right after the application gets configured. + +## expressCreateServer +Called from: src/node/server.js + +Things in context: + +1. app - the main application object (helpful for adding new paths and such) + +This hook gets called after the application object has been created, but before it starts listening. This is similar to the expressConfigure hook, but it's not guaranteed that the application object will have all relevant configuration variables. + +## eejsBlock_`` +Called from: src/node/eejs/index.js + +Things in context: + +1. content - the content of the block + +This hook gets called upon the rendering of an ejs template block. For any specific kind of block, you can change how that block gets rendered by modifying the content object passed in. + +Have a look at `src/templates/pad.html` and `src/templates/timeslider.html` to see which blocks are available. + +## socketio +Called from: src/node/hooks/express/socketio.js + +Things in context: + +1. app - the application object +2. io - the socketio object + +I have no idea what this is useful for, someone else will have to add this description. + +## authorize +Called from: src/node/hooks/express/webaccess.js + +Things in context: + +1. req - the request object +2. res - the response object +3. next - ? +4. resource - the path being accessed + +This is useful for modifying the way authentication is done, especially for specific paths. + +## authenticate +Called from: src/node/hooks/express/webaccess.js + +Things in context: + +1. req - the request object +2. res - the response object +3. next - ? +4. username - the username used (optional) +5. password - the password used (optional) + +This is useful for modifying the way authentication is done. + +## authFailure +Called from: src/node/hooks/express/webaccess.js + +Things in context: + +1. req - the request object +2. res - the response object +3. next - ? + +This is useful for modifying the way authentication is done. + +## handleMessage +Called from: src/node/handler/PadMessageHandler.js + +Things in context: + +1. message - the message being handled +2. client - the client object from socket.io + +This hook will be called once a message arrive. If a plugin calls `callback(null)` the message will be dropped. However it is not possible to modify the message. + +Plugins may also decide to implement custom behavior once a message arrives. + +**WARNING**: handleMessage will be called, even if the client is not authorized to send this message. It's up to the plugin to check permissions. + +Example: + +``` +function handleMessage ( hook, context, callback ) { + if ( context.message.type == 'USERINFO_UPDATE' ) { + // If the message type is USERINFO_UPDATE, drop the message + callback(null); + }else{ + callback(); + } +}; +``` diff --git a/doc/api/http_api.md b/doc/api/http_api.md new file mode 100644 index 000000000..058a2ba6c --- /dev/null +++ b/doc/api/http_api.md @@ -0,0 +1,250 @@ +# HTTP API + +## What can I do with this API? +The API gives another web application control of the pads. The basic functions are + +* create/delete pads +* grant/forbid access to pads +* get/set pad content + +The API is designed in a way, so you can reuse your existing user system with their permissions, and map it to etherpad lite. Means: Your web application still has to do authentication, but you can tell etherpad lite via the api, which visitors should get which permissions. This allows etherpad lite to fit into any web application and extend it with real-time functionality. You can embed the pads via an iframe into your website. + +Take a look at [HTTP API client libraries](https://github.com/Pita/etherpad-lite/wiki/HTTP-API-client-libraries) to see if a library in your favorite language. + +## Examples + +### Example 1 + +A portal (such as WordPress) wants to give a user access to a new pad. Let's assume the user have the internal id 7 and his name is michael. + +Portal maps the internal userid to an etherpad author. + +> Request: `http://pad.domain/api/1/createAuthorIfNotExistsFor?apikey=secret&name=Michael&authorMapper=7` +> +> Response: `{code: 0, message:"ok", data: {authorID: "a.s8oes9dhwrvt0zif"}}` + +Portal maps the internal userid to an etherpad group: + +> Request: `http://pad.domain/api/1/createGroupIfNotExistsFor?apikey=secret&groupMapper=7` +> +> Response: `{code: 0, message:"ok", data: {groupID: "g.s8oes9dhwrvt0zif"}}` + +Portal creates a pad in the userGroup + +> Request: `http://pad.domain/api/1/createGroupPad?apikey=secret&groupID=g.s8oes9dhwrvt0zif&padName=samplePad&text=This is the first sentence in the pad` +> +> Response: `{code: 0, message:"ok", data: null}` + +Portal starts the session for the user on the group: + +> Request: `http://pad.domain/api/1/createSession?apikey=secret&groupID=g.s8oes9dhwrvt0zif&authorID=a.s8oes9dhwrvt0zif&validUntil=1312201246` +> +> Response: `{"data":{"sessionID": "s.s8oes9dhwrvt0zif"}}` + +Portal places the cookie "sessionID" with the given value on the client and creates an iframe including the pad. + +### Example 2 + +A portal (such as WordPress) wants to transform the contents of a pad that multiple admins edited into a blog post. + +Portal retrieves the contents of the pad for entry into the db as a blog post: + +> Request: `http://pad.domain/api/1/getText?apikey=secret&padID=g.s8oes9dhwrvt0zif$123` +> +> Response: `{code: 0, message:"ok", data: {text:"Welcome Text"}}` + +Portal submits content into new blog post + +> Portal.AddNewBlog(content) +> + +## Usage + +### Request Format + +The API is accessible via HTTP. HTTP Requests are in the format /api/$APIVERSION/$FUNCTIONNAME. Parameters are transmitted via HTTP GET. $APIVERSION is 1 + +### Response Format +Responses are valid JSON in the following format: + +```js +{ + "code": number, + "message": string, + "data": obj +} +``` + +* **code** a return code + * **0** everything ok + * **1** wrong parameters + * **2** internal error + * **3** no such function + * **4** no or wrong API Key +* **message** a status message. Its ok if everything is fine, else it contains an error message +* **data** the payload + +### Overview + +![API Overview](http://i.imgur.com/d0nWp.png) + +## Data Types + +* **groupID** a string, the unique id of a group. Format is g.16RANDOMCHARS, for example g.s8oes9dhwrvt0zif +* **sessionID** a string, the unique id of a session. Format is s.16RANDOMCHARS, for example s.s8oes9dhwrvt0zif +* **authorID** a string, the unique id of an author. Format is a.16RANDOMCHARS, for example a.s8oes9dhwrvt0zif +* **readOnlyID** a string, the unique id of an readonly relation to a pad. Format is r.16RANDOMCHARS, for example r.s8oes9dhwrvt0zif +* **padID** a string, format is GROUPID$PADNAME, for example the pad test of group g.s8oes9dhwrvt0zif has padID g.s8oes9dhwrvt0zif$test + +### Authentication + +Authentication works via a token that is sent with each request as a post parameter. There is a single token per Etherpad-Lite deployment. This token will be random string, generated by Etherpad-Lite at the first start. It will be saved in APIKEY.txt in the root folder of Etherpad Lite. Only Etherpad Lite and the requesting application knows this key. Token management will not be exposed through this API. + +### Node Interoperability + +All functions will also be available through a node module accessable from other node.js applications. + +### JSONP + +The API provides _JSONP_ support to allow requests from a server in a different domain. +Simply add `&jsonp=?` to the API call. + +Example usage: http://api.jquery.com/jQuery.getJSON/ + +## API Methods + +### Groups +Pads can belong to a group. The padID of grouppads is starting with a groupID like g.asdfasdfasdfasdf$test + +* **createGroup()** creates a new group

    *Example returns:* + * `{code: 0, message:"ok", data: {groupID: g.s8oes9dhwrvt0zif}}` + +* **createGroupIfNotExistsFor(groupMapper)** this functions helps you to map your application group ids to etherpad lite group ids

    *Example returns:* + * `{code: 0, message:"ok", data: {groupID: g.s8oes9dhwrvt0zif}}` + +* **deleteGroup(groupID)** deletes a group

    *Example returns:* + * `{code: 0, message:"ok", data: null}` + * `{code: 1, message:"groupID does not exist", data: null}` + +* **listPads(groupID)** returns all pads of this group

    *Example returns:* + * `{code: 0, message:"ok", data: {padIDs : ["g.s8oes9dhwrvt0zif$test", "g.s8oes9dhwrvt0zif$test2"]}` + * `{code: 1, message:"groupID does not exist", data: null}` + +* **createGroupPad(groupID, 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}` + * `{code: 1, message:"groupID does not exist", data: null}` + +### Author +These authors are bound to the attributes the users choose (color and name). + +* **createAuthor([name])** creates a new author

    *Example returns:* + * `{code: 0, message:"ok", data: {authorID: "a.s8oes9dhwrvt0zif"}}` + +* **createAuthorIfNotExistsFor(authorMapper [, name])** this functions helps you to map your application author ids to etherpad lite author ids

    *Example returns:* + * `{code: 0, message:"ok", data: {authorID: "a.s8oes9dhwrvt0zif"}}` + +* **listPadsOfAuthor(authorID)** returns an array of all pads this author contributed to

    *Example returns:* + * `{code: 0, message:"ok", data: {padIDs: ["g.s8oes9dhwrvt0zif$test", "g.s8oejklhwrvt0zif$foo"]}}` + * `{code: 1, message:"authorID does not exist", data: null}` + +* **getAuthorName(authorID)** Returns the Author Name of the author

    *Example returns:* + * `{code: 0, message:"ok", data: {authorName: "John McLear"}}` + +-> can't be deleted cause this would involve scanning all the pads where this author was + +### Session +Sessions can be created between a group and an author. This allows an author to access more than one group. The sessionID will be set as a cookie to the client and is valid until a certain date. The session cookie can also contain multiple comma-seperated sessionIDs, allowing a user to edit pads in different groups at the same time. Only users with a valid session for this group, can access group pads. You can create a session after you authenticated the user at your web application, to give them access to the pads. You should save the sessionID of this session and delete it after the user logged out. + +* **createSession(groupID, authorID, validUntil)** creates a new session. validUntil is an unix timestamp in seconds

    *Example returns:* + * `{code: 0, message:"ok", data: {sessionID: "s.s8oes9dhwrvt0zif"}}` + * `{code: 1, message:"groupID doesn't exist", data: null}` + * `{code: 1, message:"authorID doesn't exist", data: null}` + * `{code: 1, message:"validUntil is in the past", data: null}` + +* **deleteSession(sessionID)** deletes a session

    *Example returns:* + * `{code: 1, message:"ok", data: null}` + * `{code: 1, message:"sessionID does not exist", data: null}` + +* **getSessionInfo(sessionID)** returns informations about a session

    *Example returns:* + * `{code: 0, message:"ok", data: {authorID: "a.s8oes9dhwrvt0zif", groupID: g.s8oes9dhwrvt0zif, validUntil: 1312201246}}` + * `{code: 1, message:"sessionID does not exist", data: null}` + +* **listSessionsOfGroup(groupID)** returns all sessions of a group

    *Example returns:* + * `{"code":0,"message":"ok","data":{"s.oxf2ras6lvhv2132":{"groupID":"g.s8oes9dhwrvt0zif","authorID":"a.akf8finncvomlqva","validUntil":2312905480}}}` + * `{code: 1, message:"groupID does not exist", data: null}` + +* **listSessionsOfAuthor(authorID)** returns all sessions of an author

    *Example returns:* + * `{"code":0,"message":"ok","data":{"s.oxf2ras6lvhv2132":{"groupID":"g.s8oes9dhwrvt0zif","authorID":"a.akf8finncvomlqva","validUntil":2312905480}}}` + * `{code: 1, message:"authorID does not exist", data: null}` + +### Pad Content + +Pad content can be updated and retrieved through the API + +* **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}` + +* **setText(padID, text)** sets the text of a pad

    *Example returns:* + * `{code: 0, message:"ok", data: null}` + * `{code: 1, message:"padID does not exist", data: null}` + * `{code: 1, message:"text too long", data: null}` + +* **getHTML(padID, [rev])** returns the text of a pad formatted as HTML

    *Example returns:* + * `{code: 0, message:"ok", data: {html:"Welcome Text
    More Text"}}` + * `{code: 1, message:"padID does not exist", data: null}` + +### Pad +Group pads are normal pads, but with the name schema GROUPID$PADNAME. A security manager controls access of them and its forbidden for normal pads to include a $ in the name. + +* **createPad(padID [, text])** creates a new (non-group) pad. Note that if you need to create a group Pad, you should call **createGroupPad**.

    *Example returns:* + * `{code: 0, message:"ok", data: null}` + * `{code: 1, message:"pad does already exist", data: null}` + +* **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}` + +* **padUsersCount(padID)** returns the number of user that are currently editing this pad

    *Example returns:* + * `{code: 0, message:"ok", data: {padUsersCount: 5}}` + +* **padUsers(padID)** returns the list of users that are currently editing this pad

    *Example returns:* + * `{code: 0, message:"ok", data: {padUsers: [{colorId:"#c1a9d9","name":"username1","timestamp":1345228793126},{"colorId":"#d9a9cd","name":"Hmmm","timestamp":1345228796042}]}}` + * `{code: 0, message:"ok", data: {padUsers: []}}` + +* **deletePad(padID)** deletes a pad

    *Example returns:* + * `{code: 0, message:"ok", data: null}` + * `{code: 1, message:"padID does not exist", data: null}` + +* **getReadOnlyID(padID)** returns the read only link of a pad

    *Example returns:* + * `{code: 0, message:"ok", data: {readOnlyID: "r.s8oes9dhwrvt0zif"}}` + * `{code: 1, message:"padID does not exist", data: null}` + +* **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}` + +* **getPublicStatus(padID)** return true of false

    *Example returns:* + * `{code: 0, message:"ok", data: {publicStatus: true}}` + * `{code: 1, message:"padID does not exist", data: null}` + +* **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}` + +* **isPasswordProtected(padID)** returns true or false

    *Example returns:* + * `{code: 0, message:"ok", data: {passwordProtection: true}}` + * `{code: 1, message:"padID does not exist", data: null}` + +* **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}` + +* **getLastEdited(padID)** returns the timestamp of the last revision of the pad

    *Example returns:* + * `{code: 0, message:"ok", data: {lastEdited: 1340815946602}}` + * `{code: 1, message:"padID does not exist", data: null}` + +* **sendClientsMessage(padID, msg)** sends a custom message of type `msg` to the pad

    *Example returns:* + * `{code: 0, message:"ok", data: {}}` + * `{code: 1, message:"padID does not exist", data: null}` diff --git a/doc/database.md b/doc/database.md new file mode 100644 index 000000000..2e06e2064 --- /dev/null +++ b/doc/database.md @@ -0,0 +1,64 @@ +# Database structure + +## Keys and their values + +### pad:$PADID +Saves all informations about pads + +* **atext** - the latest attributed text +* **pool** - the attribute pool +* **head** - the number of the latest revision +* **chatHead** - the number of the latest chat entry +* **public** - flag that disables security for this pad +* **passwordHash** - string that contains a bcrypt hashed password for this pad + +### pad:$PADID:revs:$REVNUM +Saves a revision $REVNUM of pad $PADID + +* **meta** + * **author** - the autorID of this revision + * **timestamp** - the timestamp of when this revision was created +* **changeset** - the changeset of this revision + +### pad:$PADID:chat:$CHATNUM +Saves a chatentry with num $CHATNUM of pad $PADID + +* **text** - the text of this chat entry +* **userId** - the autorID of this chat entry +* **time** - the timestamp of this chat entry + +### pad2readonly:$PADID +Translates a padID to a readonlyID +### readonly2pad:$READONLYID +Translates a readonlyID to a padID +### token2author:$TOKENID +Translates a token to an authorID +### globalAuthor:$AUTHORID +Information about an author + +* **name** - the name of this author as shown in the pad +* **colorID** - the colorID of this author as shown in the pad + +### mapper2group:$MAPPER +Maps an external application identifier to an internal group +### mapper2author:$MAPPER +Maps an external application identifier to an internal author +### group:$GROUPID +a group of pads + +* **pads** - object with pad names in it, values are 1 +### session:$SESSIONID +a session between an author and a group + +* **groupID** - the groupID the session belongs too +* **authorID** - the authorID the session belongs too +* **validUntil** - the timestamp until this session is valid + +### author2sessions:$AUTHORID +saves the sessions of an author + +* **sessionsIDs** - object with sessionIDs in it, values are 1 + +### group2sessions:$GROUPID + +* **sessionsIDs** - object with sessionIDs in it, values are 1 diff --git a/doc/documentation.md b/doc/documentation.md new file mode 100644 index 000000000..0542c4360 --- /dev/null +++ b/doc/documentation.md @@ -0,0 +1,15 @@ +# About this Documentation + + + +The goal of this documentation is to comprehensively explain Etherpad-Lite, +both from a reference as well as a conceptual point of view. + +Where appropriate, property types, method arguments, and the arguments +provided to event handlers are detailed in a list underneath the topic +heading. + +Every `.html` file is generated based on the corresponding +`.markdown` file in the `doc/api/` folder in the source tree. The +documentation is generated using the `tools/doc/generate.js` program. +The HTML template is located at `doc/template.html`. \ No newline at end of file diff --git a/doc/easysync/README.md b/doc/easysync/README.md new file mode 100644 index 000000000..551487544 --- /dev/null +++ b/doc/easysync/README.md @@ -0,0 +1,2 @@ +# About this folder +We put all documentations we found about the old Etherpad together in this folder. Most of this is still valid for Etherpad Lite \ No newline at end of file diff --git a/doc/easysync/easysync-full-description.pdf b/doc/easysync/easysync-full-description.pdf new file mode 100644 index 0000000000000000000000000000000000000000..170b6ccd43928090de7659d94ba7c87a30f393cd GIT binary patch literal 129342 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 Crc$B+ literal 0 HcmV?d00001 diff --git a/doc/easysync/easysync-full-description.tex b/doc/easysync/easysync-full-description.tex new file mode 100644 index 000000000..a831da647 --- /dev/null +++ b/doc/easysync/easysync-full-description.tex @@ -0,0 +1,372 @@ +\documentclass{article} +\usepackage{hyperref} + +\begin{document} + +\title{Etherpad and EasySync Technical Manual} +\author{AppJet, Inc., with modifications by the Etherpad Foundation} +\date{\today} + +\maketitle + +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% +\tableofcontents +%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% + +\section{Documents} +\begin{itemize} +\item A document is a list of characters, or a string. +\item A document can also be represented as a list of \emph{changesets}. +\end{itemize} + +\section{Changesets} + +\begin{itemize} +\item A changeset represents a change to a document. +\item A changeset can be applied to a document to produce a new document. +\item When a document is represented as a list of changesets, it is assumed that the first changeset applies to the empty document, []. +\end{itemize} + + +\section{Changeset representation} \label{representation} + +$$(\ell \rightarrow \ell')[c_1,c_2,c_3,...]$$ + +where + +\begin{itemize} +\item[] $\ell$ is the length of the document before the change, +\item[] $\ell'$ is the length of the document after the change, +\item[] $[c_1,c_2,c_3,...]$ is an array of $\ell'$ characters that described the document after the change. +\end{itemize} + +Note that $\forall c_i : 0 \leq i \leq \ell'$ is either an integer or a character. + +\begin{itemize} +\item Integers represent retained characters in the original document. +\item Characters represent insertions. +\end{itemize} + +\section{Constraints on Changesets} + +\begin{itemize} +\item Changesets are canonical and therefor comparable. When represented in computer memory, we always use the same representation for the same changeset. If the memory representation of two changesets differ, they must be different changesets. +\item Changesets are compact. Thus, if there are two ways to represent a changeset in computer memory, then we always use the representation that takes up the fewest bytes. +\end{itemize} + +Later we will discuss optimizations to changeset +representation (using ``strips'' and other such +techniques). The two constraints must apply to any +representation of changesets. + +\section{Notation} + +\begin{itemize} +\item We use the algebraic multiplication notation to represent changeset application. +\item While changesets are defined as operations on documents, documents themselves are represented as a list of changesets, initially applying to the empty document. +\end{itemize} + +\paragraph{Example} +$A=(0\rightarrow 5)[``hello"]$ +$B=(5\rightarrow 11)[0-4, ``\ world"]$ + +We can write the document ``hello world'' as $A\cdot B$ or +just $AB$. Note that the ``initial document'' can be made +into the changeset $(0\rightarrow +N)[``<\mathit{the\ document\ text}>"]$. + +When $A$ and $B$ are changesets, we can also refer to $(AB)$ as ``the composition'' of $A$ and $B$. Changesets are closed under composition. + +\section{Composition of Changesets} + +For any two changesets $A$, $B$ such that + +\begin{itemize} +\item[] $A=(n_1\rightarrow n_2)[\cdots]$ +\item[] $A=(n_2\rightarrow n_3)[\cdots]$ +\end{itemize} +it is clear that there is a third changeset $C=(n_1\rightarrow n_3)[\cdots]$ such that applying $C$ to a document $X$ yeilds the same resulting document as does applying $A$ and then $B$. In this case, we write $AB=C$. + +Given the representation from Section \ref{representation}, it is straightforward to compute the composition of two changesets. + +\section{Changeset Merging} + +Now we come to realtime document editing. Suppose two different users make two different changes to the same document at the same time. It is impossible to compose these changes. For example, if we have the document $X$ of length $n$, we may have $A=(n\rightarrow n_a)[\ldots n_a \mathrm{characters}]$, $B=(n\rightarrow n_b)[\ldots n_b \mathrm{characters}]$ where $n\neq n_a\neq n_b$. + +It is impossible to compute $(XA)B$ because $B$ can only be applied to a document of length $n$, and $(XA)$ has length $n_a$. Similarly, $A$ cannot be appliet to $(XB)$ because $(XB)$ has length $n_b$. + +This is where \emph{merging} comes in. Merging takes two changesets that apply to the same initial document (and that cannot be composed), and computes a single new changeset that presevers the intent of both changes. The merge of $A$ and $B$ is written as $m(A,B)$. For the Etherpad system to work, we require that $m(A,B)=m(B,A)$. + +Aside from what we have said so far about merging, there aremany different implementations that will lead to a workable system. We have created one implementation for text that has the following constraints. + +\section{Follows} \label{follows} + +When users $A$ and $B$ have the same document $X$ on their screen, and they proceed to make respective changesets $A$ and $B$, it is no use to compute $m(A,B)$, because $m(A,B)$ applies to document $X$, but the users are already looking at document $XA$ and $XB$. What we really want is to compute $B'$ and $A'$ such that +$$XAB' = XBA' = Xm(A,B)$$ + +``Following'' computes these $B'$ and $A'$ changesets. The definition of the ``follow'' function $f$ is such that $Af(A,B)=Bf(B,A)=m(A,B)=m(B,A)$. When we computer $f(A,B)$. +\begin{itemize} +\item Insertions in $A$ become retained characters in $f(A,B)$ +\item Insertions in $B$ become insertions in $f(A,B)$ +\item Retain whatever characters are retained in \emph{both} $A$ and $B$ +\end{itemize} + +\paragraph{Example} + +Suppose we have the initial document $X=(0\rightarrow 8)[``\mathit{baseball}"]$ and user $A$ changes it to ``basil'' with changeset $A$, and user $B$ changes it to ``below'' with changeset $B$. + +We have +$X=(0\rightarrow 8)[``\mathit{baseball}"]$ \\ +$A=(8\rightarrow 5)[0-1, ``\mathit{si}", 7]$ \\ +$B=(8\rightarrow 5)[0, ``\mathit{e}", 6, ``\mathit{ow}"]$ \\ + +First we compute the merge $m(A,B)=m(B,A)$ according to the constraints + +$$m(A,B)=(8\rightarrow 6)[0, "e", "si", "ow"] = (8\rightarrow 6)[0, ``\mathit{esiow}"]$$ + +Then we need to compute the follows $B'=f(A,B)$ and $A'=f(B,A)$. + +$$B'=f(A,B)=(5\rightarrow 6)[0,``\mathit{e}",2,3,``\mathit{ow}"]$$ + +Note that the numbers $0$, $2$, and $3$ are indices into $A=(8\rightarrow 5)[0,1,``\mathit{si}",7]$ + +\begin{tabular}{ccccc} +0 & 1 & 2 & 3 & 4 \\ +0 & 1 & s & i & 7 +\end{tabular} + +$A'=f(B,A)=(5\rightarrow 6)[0,1,"si",3,4]$ + +We can now double check that $AB'=BA'=m(A,B)=(8\rightarrow 6)[0,``\mathit{esiow}"]$. + +Now that we have made the mathematical meaning of the +preceding pages complete, we can build a client/server +system to support realtime editing by multiple users. + +\section{System Overview} + +There is a server that holds the current state of a +document. Clients (users) can connect to the server from +their web browsers. The clients and server maintain state +and can send messages to one another in real-time, but +because we are in a web browser scenario, clients cannot +send each other messages directly, and must go through the +server always. (This may distinguish from prior art?) + +The other critical design feature of the system is that +\emph{A client must always be able to edit their local + copy of the document, so the user is never blocked from + typing because of waiting to to send or receive data.} + +\section{Client State} + +At any moment in time, a client maintains its state in the +form of 3 changesets. The client document looks like +$A\cdot X \cdot Y$, where + +$A$ is the latest server version, the composition of all +changesets committed to the server, from this client or +from others, that the server has informed this client +about. Initially $A=(0\rightarrow N)[<\mathit{initial\ document\ text}>]$. + +$X$ is the composition of all changesets this client has +submitted to the server but has not heard back about yet. +Initially $X=(N\rightarrow N)[0,1,2,\ldots, N-1]$, in +other words, the identity, henceforth denoted $I_N$. + +$Y$ is the composition of all changesets this client has +made but has not yet submitted to the server yet. +Initially $Y=(N\rightarrow N)[0,1,2,\ldots, N-1]$. + +\section{Client Operations} + +A client can do 5 things. + +\begin{enumerate} +\item Incorporate new typing into local state +\item Submit a changeset to the server +\item Hear back acknowledgement of a submitted changeset +\item Hear from the server about other clients' changesets +\item Connect to the server and request the initial document +\end{enumerate} + +As these 5 events happen, the client updates its +representation $A\cdot X \cdot Y$ according to the +relations that follow. Changes ``move left'' as time goes +by: into $Y$ when the user types, into $X$ when change +sets are submitted to the server, and into $A$ when the +server acknowledges changesets. + +\subsection{New local typing} + +When a user makes an edit $E$ to the document, the client +computes the composition $(Y\cdot E)$ and updates its local +state, i.e. $Y \leftarrow Y\cdot E$. I.e., if $Y$ is the +variable holding local unsubmitted changes, it will be +assigned the new value $(Y\cdot E)$. + +\subsection{Submitting changesets to server} + +When a client submit its local changes to the server, it +transmits a copy of $Y$ and then assigns $Y$ to $X$, and +assigns the identity to $Y$. I.e., + +\begin{enumerate} +\item Send $Y$ to server, +\item $X \leftarrow Y$ +\item $Y \leftarrow I_N$ + (the identity). +\end{enumerate} + +This happens every 500ms as long as it receives an +acknowledgement. Must receive ACK before submitting +again. Note that $X$ is always equal to the identity +before the second step occurs, so no information is lost. + +\subsection{Hear ACK from server} + +When the client hears ACK from server, + +$A \leftarrow A\cdot X$ \\ +$X \leftarrow I_N$ + +\subsection{Hear about another client's changeset} + +When a client hears about another client's changeset $B$, +it computes a new $A$, $X$, and $Y$, which we will call +$A'$, $X'$, and $Y'$ respectively. It also computes a +changeset $D$ which is applied to the current text view on +the client, $V$. Because $AXY$ must always equal the +current view, $AXY=V$ before the client hears about $B$, +and $A'X'Y'=VD$ after the computation is performed. + +The steps are: + +\begin{enumerate} +\item Compute $A' = AB$ +\item Compute $X' = f(B,X)$ +\item Compute $Y' = f(f(X,B), Y)$ +\item Compute $D=f(Y,f(X,B))$ +\item Assign $A \leftarrow A'$, $X \leftarrow X'$, $Y \leftarrow Y'$. +\item Apply $D$ to the current view of the document + displayed on the user's screen. +\end{enumerate} + +In steps 2,3, and 4, $f$ is the follow operation described +in Section \ref{follows}. + +\paragraph{Proof that $\mathbf{AXY=V \Rightarrow A'X'Y'=VD}$.} +Substituting $A'X'Y'=(AB)(f(B,X))(f(f(X,B),Y))$, we +recall that merges are commutative. So for any two +changesets $P$ and $Q$, +$$m(P,Q)=m(Q,P)=Qf(Q,P)=Pf(P,Q)$$ + +Applying this to the relation above, we see +\begin{eqnarray*} +A'X'Y'&=& AB f(B,X) f(f(X,B),Y) \\ + &=&AX f(X,B) f(f(X,B),Y) \\ + &=&A X Y f(Y, f(X,B)) \\ + &=&A X Y D \\ + &=&V D +\end{eqnarray*} +As claimed. + +\subsection{Connect to server} + +When a client connects to the server for the first time, +it first generates a random unique ID and sends this to +the server. The client remembers this ID and sends it +with each changeset to the server. + +The client receives the latest version of the document +from the server, called HEADTEXT. The client then sets + +\begin{itemize} +\item[] $A \leftarrow \mathrm{HEADTEXT}$ +\item[] $X \leftarrow I_N$ +\item[] $Y \leftarrow I_N$ +\end{itemize} + +And finally, the client displays HEADTEXT on the screen. + +\section{Server Overview} + +Like the client(s), the server has state and performs +operations. Operations are only performed in response to +messages from clients. + +\section{Server State} + +The server maintains a document as an ordered list of +\emph{revision records}. A revision record is a data +structure that contains a changeset and authorship +information. + +\begin{verbatim} +RevisionRecord = { + ChangeSet, + Source (unique ID), + Revision Number (consecutive order, starting at 0) +} +\end{verbatim} + +For efficiency, the server may also store a variable +called HEADTEXT, which is the composition of all +changesets in the list of revision records. This is an +optimization, because clearly this can be computed from +the set of revision records. + +\section{Server Operations Overview} + +The server does two things in addition to maintaining +state representing the set of connected clients and +remembering what revision number each client is up to date +with: + +\begin{enumerate} +\item Respond to a client's connection requesting the initial document. +\item Respond to a client's submission of a new changeset. +\end{enumerate} + +\subsection{Respond to client connect} +When a server recieves a connection request from a client, +it receives the client's unique ID and stores that in the +server's set of connected clients. It then sends the +client the contents of HEADTEXT, and the corresponding +revision number. Finally the server notes that this +client is up to date with that revision number. + +\subsection{Respond to client changeset} + +When the server receives information from a client about +the client's changeset $C$, it does five things: + +\begin{enumerate} +\item Notes that this change applies to revision number + $r_c$ (the client's latest revision). +\item Creates a new changeset $C'$ that is relative to the + server's most recent revision number, which we call + $r_H$ ($H$ for HEAD). $C'$ can be computed using + follows (Section \ref{follows}). Remember that the server has a series of + changesets, +$$S_0\rightarrow S_1\rightarrow \ldots S_{r_c}\rightarrow S_{r_c+1} \rightarrow \ldots \rightarrow S_{r_H} $$ +$C$ is relative to $S_{r_c}$, but we need to compute $C'$ relative to $S_{r_H}$. +We can compute a new $C$ relative to $S_{r_c+1}$ by computing $f(S_{r_c+1},C)$. Similarly we can repeat for +$S_{r_c+2}$ and so forth until we have $C'$ represented relative to $S_{r_H}$. +\item Send $C'$ to all other clients +\item Send ACK back to original client +\item Add $C'$ to the server's list of revision records by creating a new revision record out of this and the client's ID. + +\appendix + +\section*{Additional topics} +\begin{enumerate} +\item Optimizations (strips, more caching, etc.) +\item Pseudocode for composition, merge, and follow +\item How authorship information is used to color-code the document based on who typed what +\item How persistent connections are maintained between client and server +\end{enumerate} +\end{enumerate} + + +\end{document} diff --git a/doc/easysync/easysync-notes.pdf b/doc/easysync/easysync-notes.pdf new file mode 100644 index 0000000000000000000000000000000000000000..d0af379d1756602be3ac49c9643a859d181ca590 GIT binary patch literal 87447 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 F{{euTS?vG- literal 0 HcmV?d00001 diff --git a/doc/easysync/easysync-notes.tex b/doc/easysync/easysync-notes.tex new file mode 100644 index 000000000..b2c432073 --- /dev/null +++ b/doc/easysync/easysync-notes.tex @@ -0,0 +1,200 @@ +\documentclass[12pt]{article} + +\usepackage[T1]{fontenc} +\usepackage[USenglish]{babel} + + +\begin{document} + +\title{Easysync Protocol} +\author{AppJet, Inc., with modifications by the Etherpad Foundation} +\date{\today} + +\maketitle + +\section{Attributes} + +An ``attribute'' is a (key,value) pair such as +\verb|(author,abc123)| or \verb|(bold,true)|. +Sometimes an attribute is treated as an instruction to add +that attribute, in which case an empty value means to +remove it. So \verb|(bold,)| removes the ``bold'' +attribute. Attributes are interned and given numeric IDs, +so the number ``\verb|6|'' could represent +``\verb|(bold,true)|'', for example. This mapping is +stored in an attribute pool which may be shared by +multiple changesets. + +Entries in the pool must be unique, so that attributes can +be compared by their IDs. Attribute names cannot contain +commas. + +A changeset looks something like the following: + +\begin{verbatim} +Z:5g>1|5=2p=v*4*5+1$x +\end{verbatim} + +With the corresponding pool containing these entries (among others): + +\begin{itemize} +\item[] \verb|4| $\rightarrow$ \verb|(author,1059348573)| +\item[] \verb|5| $\rightarrow$ \verb|(bold,true)| +\end{itemize} + +This changeset, together with the attribute pool, +represents inserting a bold letter ``x'' into the middle +of a line. + +The string consists of: + +\begin{itemize} +\item a letter \verb|Z| (the ``magic character'' and + format version identifier) +\item a series punctuation marks (operation codes or + ``opcodes'' for short), together with alphanumerics + (numeric values in base 36). +\item a dollar sign (\verb|$|) +\item a string of characters used for insertion operations + (the ``char bank'') +\end{itemize} + +In the example above, if we separate out the operations +and convert the numbers to base 10, then we get: +\begin{verbatim} +Z :196 >1 |5=97 =31 *4 *5 +1 $x +\end{verbatim} +Here are descriptions of the operations, where capital +letters are variables: + +\begin{description} +\item{{\bf :N}} \quad \\ +Source text has length $N$ (must be first op) +\item{{\bf >N}} \quad \\ +Final text is $N$ (positive) characters longer than source +text (must be second op) +\item{{\bf 0 }} \quad \\ +Final text is same length as source text +\item{{\bf +N }} \quad \\ +Insert $N$ characters from the bank, none of them newlines +\item{{\bf -N}} \quad \\ +Skip over (delete) $N$ characters from the source text, +none of them newlines +\item{{\bf =N}} \quad \\ +Keep $N$ characters from the source text, none of them newlines +\item{{\bf |L+N}} \quad \\ +Insert $N$ characters from the source text, containing $L$ +newlines. The last character inserted MUST be a newline, +but not the (new) document's final newline. +\item{{\bf |L-N}} \quad \\ +Delete $N$ characters from the source text, containing $L$ +newlines. The last character inserted MUST be a newline, +but not the (old) document's final newline. +\item{{\bf |L=N}} \quad \\ +Keep $N$ characters from the source text, containing L +newlines. The last character kept MUST be a newline, and +the final newline of the document is allowed. +\item{{\bf *I}} \quad \\ +Apply attribute $I$ from the pool to the following +\verb|+|, \verb|=|, \verb_|+_, or \verb_|=_ command. In +other words, any number of \verb|*| ops can come before a +\verb_+_, \verb_=_, or \verb_|_ but not between a \verb_|_ +and the corresponding \verb_+_ or \verb_=_. If \verb_+_, +text is inserted having this attribute. If \verb_=_, text +is kept but with the attribute applied as an attribute +addition or removal. Consecutive attributes must be sorted +lexically by (key,value) with key and value taken as +strings. It's illegal to have duplicate keys for +(key,value) pairs that apply to the same text. It's +illegal to have an empty value for a key in the case of an +insertion (\verb_+_), the pair should just be omitted. +\end{description} + +Characters from the source text that aren't accounted for +are assumed to be kept with the same attributes. + +\paragraph{Additional Constraints} + +\begin{itemize} +\item Consecutive \verb_+_, \verb_-_, and \verb_=_ ops of + the same type that could be combined are not allowed. + Whether combination is possible depends on the + attributes of the ops and whether each is multiline or + not. For example, two multiline deletions can never be + consecutive, nor can any insertion come after a + non-multiline insertion with the same attributes. +\item ``No-op'' ops are not allowed, such as deleting 0 + characters. However, attribute applications that don't + have any effect are allowed. +\item Characters at the end of the source text cannot be + explicitly kept with no changes; if the change doesn't + affect the last $N$ characters, those ``keep'' ops must + be left off. +\item In any consecutive sequence of insertions (\verb_+_) + and deletions (\verb_-_) with no keeps (\verb_=_), the + deletions must come before the insertions. +\item The document text before and after will always end + with a newline. This policy avoids a lot of + special-casing of the end of the document. If a final + newline is always added when importing text and removed + when exporting text, then the changeset representation + can be used to process text files that may or may not + have a final newline. +\end{itemize} + +\paragraph{Attribution string} + +An \emph{attribution string} is a series of inserts with +no deletions or keeps. For example, ``\verb_*3+8|1+5_'' +describes the attributes of a string of length 13, where +the first 8 chars have attribute 3 and the next 5 chars +have no attributes, with the last of these 5 chars being a +newline. Constraints apply similar to those affecting +changesets, but the restriction about the final newline of +the new document being added doesn't apply. + +Attributes in an attribution string cannot be empty, like +``\verb|(bold,)|'', they should instead be absent. + + +\section{Further Considerations} + +\begin{itemize} +\item composing changesets/attributions with different + pools. +\item generalizing ``applyToAttribution'' to make + ``mutateAttributionLines'' and ``compose'' +\end{itemize} + +\section{Using Unicode?} + +\begin{itemize} +\item no unicode (for efficient escaping, sightliness) +\item efficient operations for ACE and collab (attributed text, etc.) +\item good for time-slider +\item good for API +\item line-ending aware +X more coherent (deleting or styling text merging with insertion) +\item server-side syntax highlighting? +\item unify author map with attribute pool +\item unify attributed text with changeset rep +\item not: reversible +\item force final newline of document to be preserved +\end{itemize} + +\paragraph{Unicode bad!} + +\begin{itemize} +\item ugly (hard to read) +\item more complex to parse +\item harder to store and transmit correctly +\item doesn't save all that much space anyway +\item blows up in size when string-escaped +\item embarrassing for API +\end{itemize} + + +\end{document} diff --git a/doc/easysync/easysync-notes.txt b/doc/easysync/easysync-notes.txt new file mode 100644 index 000000000..d3b3dc558 --- /dev/null +++ b/doc/easysync/easysync-notes.txt @@ -0,0 +1,133 @@ + + +Copied from the old Etherpad. Found in /infrastructure/ace/ + +Goals: + +- no unicode (for efficient escaping, sightliness) +- efficient operations for ACE and collab (attributed text, etc.) +- good for time-slider +- good for API +- line-ending aware +X more coherent (deleting or styling text merging with insertion) +- server-side syntax highlighting? +- unify author map with attribute pool +- unify attributed text with changeset rep +- not: reversible +- force final newline of document to be preserved + +- Unicode bad: + - ugly (hard to read) + - more complex to parse + - harder to store and transmit correctly + - doesn't save all that much space anyway + - blows up in size when string-escaped + - embarrassing for API + + +# Attributes: + +An "attribute" is a (key,value) pair such as (author,abc123456) or +(bold,true). Sometimes an attribute is treated as an instruction to +add that attribute, in which case an empty value means to remove it. +So (bold,) removes the "bold" attribute. Attributes are interned and +given numeric IDs, so the number "6" could represent "(bold,true)", +for example. This mapping is stored in an attribute "pool" which may +be shared by multiple changesets. + +Entries in the pool must be unique, so that attributes can be compared +by their IDs. Attribute names cannot contain commas. + +A changeset looks something like the following: + +Z:5g>1|5=2p=v*4*5+1$x + +With the corresponding pool containing these entries: + +... +4 -> (author,1059348573) +5 -> (bold,true) +... + +This changeset, together with the pool, represents inserting +a bold letter "x" into the middle of a line. The string consists of: + +- a letter Z (the "magic character" and format version identifier) +- a series of opcodes (punctuation) and numeric values in base 36 (the + alphanumerics) +- a dollar sign ($) +- a string of characters used by insertion operations (the "char bank") + +If we separate out the operations and convert the numbers to base 10, we get: + +Z :196 >1 |5=97 =31 *4 *5 +1 $"x" + +Here are descriptions of the operations, where capital letters are variables: + +":N" : Source text has length N (must be first op) +">N" : Final text is N (positive) characters longer than source text (must be second op) +"0" : Final text is same length as source text +"+N" : Insert N characters from the bank, none of them newlines +"-N" : Skip over (delete) N characters from the source text, none of them newlines +"=N" : Keep N characters from the source text, none of them newlines +"|L+N" : Insert N characters from the source text, containing L newlines. The last + character inserted MUST be a newline, but not the (new) document's final newline. +"|L-N" : Delete N characters from the source text, containing L newlines. The last + character inserted MUST be a newline, but not the (old) document's final newline. +"|L=N" : Keep N characters from the source text, containing L newlines. The last character + kept MUST be a newline, and the final newline of the document is allowed. +"*I" : Apply attribute I from the pool to the following +, =, |+, or |= command. + In other words, any number of * ops can come before a +, =, or | but not + between a | and the corresponding + or =. + If +, text is inserted having this attribute. If =, text is kept but with + the attribute applied as an attribute addition or removal. + Consecutive attributes must be sorted lexically by (key,value) with key + and value taken as strings. It's illegal to have duplicate keys + for (key,value) pairs that apply to the same text. It's illegal to + have an empty value for a key in the case of an insertion (+), the + pair should just be omitted. + +Characters from the source text that aren't accounted for are assumed to be kept +with the same attributes. + +Additional Constraints: + +- Consecutive +, -, and = ops of the same type that could be combined are not allowed. + Whether combination is possible depends on the attributes of the ops and whether + each is multiline or not. For example, two multiline deletions can never be + consecutive, nor can any insertion come after a non-multiline insertion with the + same attributes. +- "No-op" ops are not allowed, such as deleting 0 characters. However, attribute + applications that don't have any effect are allowed. +- Characters at the end of the source text cannot be explicitly kept with no changes; + if the change doesn't affect the last N characters, those "keep" ops must be left off. +- In any consecutive sequence of insertions (+) and deletions (-) with no keeps (=), + the deletions must come before the insertions. +- The document text before and after will always end with a newline. This policy avoids + a lot of special-casing of the end of the document. If a final newline is + always added when importing text and removed when exporting text, then the + changeset representation can be used to process text files that may or may not + have a final newline. + +Attribution string: + +An "attribution string" is a series of inserts with no deletions or keeps. +For example, "*3+8|1+5" describes the attributes of a string of length 13, +where the first 8 chars have attribute 3 and the next 5 chars have no +attributes, with the last of these 5 chars being a newline. Constraints +apply similar to those affecting changesets, but the restriction about +the final newline of the new document being added doesn't apply. + +Attributes in an attribution string cannot be empty, like "(bold,)", they should +instead be absent. + + + + + +------- +Considerations: + +- composing changesets/attributions with different pools +- generalizing "applyToAttribution" to make "mutateAttributionLines" and "compose" diff --git a/doc/template.html b/doc/template.html new file mode 100644 index 000000000..2eb939872 --- /dev/null +++ b/doc/template.html @@ -0,0 +1,23 @@ + + + + + __SECTION__ Etherpad-Lite Manual & Documentation + + + + + +
    +

    Table of Contents

    + __TOC__ +
    + +
    + __CONTENT__ +
    + + + diff --git a/settings.json.template b/settings.json.template new file mode 100644 index 000000000..7d175a34e --- /dev/null +++ b/settings.json.template @@ -0,0 +1,74 @@ +/* + This file must be valid JSON. But comments are allowed + + Please edit settings.json, not settings.json.template +*/ +{ + //Ip and port which etherpad should bind at + "ip": "0.0.0.0", + "port" : 9001, + + //The Type of the database. You can choose between dirty, postgres, sqlite and mysql + //You shouldn't use "dirty" for for anything else than testing or development + "dbType" : "dirty", + //the database specific settings + "dbSettings" : { + "filename" : "var/dirty.db" + }, + + /* An Example of MySQL Configuration + "dbType" : "mysql", + "dbSettings" : { + "user" : "root", + "host" : "localhost", + "password": "", + "database": "store" + }, + */ + + //the default text of a pad + "defaultPadText" : "Welcome to Etherpad Lite!\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\nEtherpad Lite on Github: http:\/\/j.mp/ep-lite\n", + + /* Users 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 true, all css & js will be minified before sending to the client. This will improve the loading performance massivly, + but makes it impossible to debug the javascript/css */ + "minify" : true, + + /* How long may clients use served javascript code (in seconds)? Without versioning this + may cause problems during deployment. Set to 0 to disable caching */ + "maxAge" : 21600, // 60 * 60 * 6 = 6 hours + + /* This is the path to the Abiword executable. Setting it to null, disables abiword. + Abiword is needed to enable the import/export of pads*/ + "abiword" : null, + + /* 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, + + /* Users for basic authentication. is_admin = true gives access to /admin. + If you do not uncomment this, /admin will not be available! */ + /* + "users": { + "admin": { + "password": "changeme1", + "is_admin": true + }, + "user": { + "password": "changeme1", + "is_admin": false + } + }, + */ + + /* The log level we are using, can be: DEBUG, INFO, WARN, ERROR */ + "loglevel": "INFO" +} diff --git a/src/ep.json b/src/ep.json new file mode 100644 index 000000000..ce6d3a00f --- /dev/null +++ b/src/ep.json @@ -0,0 +1,20 @@ +{ + "parts": [ + { "name": "express", "hooks": { + "createServer": "ep_etherpad-lite/node/hooks/express:createServer", + "restartServer": "ep_etherpad-lite/node/hooks/express:restartServer" + } }, + { "name": "static", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/static:expressCreateServer" } }, + { "name": "specialpages", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/specialpages:expressCreateServer" } }, + { "name": "padurlsanitize", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/padurlsanitize:expressCreateServer" } }, + { "name": "padreadonly", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/padreadonly:expressCreateServer" } }, + { "name": "webaccess", "hooks": { "expressConfigure": "ep_etherpad-lite/node/hooks/express/webaccess:expressConfigure" } }, + { "name": "apicalls", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/apicalls:expressCreateServer" } }, + { "name": "importexport", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/importexport:expressCreateServer" } }, + { "name": "errorhandling", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/errorhandling:expressCreateServer" } }, + { "name": "socketio", "hooks": { "expressCreateServer": "ep_etherpad-lite/node/hooks/express/socketio:expressCreateServer" } }, + { "name": "adminplugins", "hooks": { + "expressCreateServer": "ep_etherpad-lite/node/hooks/express/adminplugins:expressCreateServer", + "socketio": "ep_etherpad-lite/node/hooks/express/adminplugins:socketio" } } + ] +} diff --git a/src/node/README.md b/src/node/README.md new file mode 100644 index 000000000..4b443289e --- /dev/null +++ b/src/node/README.md @@ -0,0 +1,13 @@ +# About the folder structure + +* **db** - all modules that are accesing the data structure and are communicating directly to the database +* **handler** - all modules that responds directly to requests/messages of the browser +* **utils** - helper modules + +# Module name conventions + +Module file names start with a capital letter and uses camelCase + +# Where does it start? + +server.js is started directly diff --git a/src/node/db/API.js b/src/node/db/API.js new file mode 100644 index 000000000..c5caae0be --- /dev/null +++ b/src/node/db/API.js @@ -0,0 +1,605 @@ +/** + * This module provides all API functions + */ + +/* + * 2011 Peter 'Pita' Martischka (Primary Technology Ltd) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var ERR = require("async-stacktrace"); +var customError = require("../utils/customError"); +var padManager = require("./PadManager"); +var padMessageHandler = require("../handler/PadMessageHandler"); +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 importHtml = require("../utils/ImportHtml"); +var cleanText = require("./Pad").cleanText; + +/**********************/ +/**GROUP FUNCTIONS*****/ +/**********************/ + +exports.createGroup = groupManager.createGroup; +exports.createGroupIfNotExistsFor = groupManager.createGroupIfNotExistsFor; +exports.deleteGroup = groupManager.deleteGroup; +exports.listPads = groupManager.listPads; +exports.createGroupPad = groupManager.createGroupPad; + +/**********************/ +/**AUTHOR FUNCTIONS****/ +/**********************/ + +exports.createAuthor = authorManager.createAuthor; +exports.createAuthorIfNotExistsFor = authorManager.createAuthorIfNotExistsFor; +exports.getAuthorName = authorManager.getAuthorName; +exports.listPadsOfAuthor = authorManager.listPadsOfAuthor; +exports.padUsers = padMessageHandler.padUsers; +exports.padUsersCount = padMessageHandler.padUsersCount; + +/**********************/ +/**SESSION FUNCTIONS***/ +/**********************/ + +exports.createSession = sessionManager.createSession; +exports.deleteSession = sessionManager.deleteSession; +exports.getSessionInfo = sessionManager.getSessionInfo; +exports.listSessionsOfGroup = sessionManager.listSessionsOfGroup; +exports.listSessionsOfAuthor = sessionManager.listSessionsOfAuthor; + +/************************/ +/**PAD CONTENT FUNCTIONS*/ +/************************/ + +/** +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) +{ + //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))) + { + rev = parseInt(rev); + } + else + { + callback(new customError("rev is not a number", "apierror")); + return; + } + } + + //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; + + data = {text: atext.text}; + + callback(null, data); + }) + } + //the client wants the latest text, lets return it to him + else + { + callback(null, {"text": pad.text()}); + } + }); +} + +/** +setText(padID, text) sets the text of a pad + +Example returns: + +{code: 0, message:"ok", data: null} +{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; + } + + //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); + }); +} + +/** +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) +{ + if(typeof rev == "function") + { + callback = rev; + rev = undefined; + } + + if (rev !== undefined && typeof rev != "number") + { + if (!isNaN(parseInt(rev))) + { + rev = parseInt(rev); + } + else + { + callback(new customError("rev is not a number","apierror")); + return; + } + } + + if(rev !== undefined && rev < 0) + { + callback(new customError("rev is a negative number","apierror")); + return; + } + + 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; + data = {html: html}; + callback(null, data); + }); + } + //the client wants the latest text, lets return it to him + else + { + exportHtml.getPadHTML(pad, undefined, function (err, html) + { + if(ERR(err, callback)) return; + + data = {html: html}; + + callback(null, data); + }); + } + }); +} + +exports.setHTML = function(padID, html, callback) +{ + //get the pad + getPadSafe(padID, true, function(err, pad) + { + if(ERR(err, callback)) return; + + // add a new changeset with the new html to the pad + importHtml.setPadHTML(pad, cleanText(html)); + + //update the clients on the pad + padMessageHandler.updatePadClients(pad, callback); + + }); +} + +/*****************/ +/**PAD FUNCTIONS */ +/*****************/ + +/** +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) +{ + //get the pad + getPadSafe(padID, true, function(err, pad) + { + if(ERR(err, callback)) return; + + callback(null, {revisions: pad.getHeadRevisionNumber()}); + }); +} + +/** +getLastEdited(padID) returns the timestamp of the last revision of the pad + +Example returns: + +{code: 0, message:"ok", data: {lastEdited: 1340815946602}} +{code: 1, message:"padID does not exist", data: null} +*/ +exports.getLastEdited = function(padID, callback) +{ + //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}); + }); + }); +} + +/** +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 && padID.indexOf("$") != -1) + { + callback(new customError("createPad can't create group pads","apierror")); + return; + } + + //create pad + getPadSafe(padID, false, text, function(err) + { + if(ERR(err, callback)) return; + callback(); + }); +} + +/** +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) +{ + getPadSafe(padID, true, function(err, pad) + { + if(ERR(err, callback)) return; + + pad.remove(callback); + }); +} + +/** +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) +{ + //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}); + }); + }); +} + +/** +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) +{ + //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; + + //convert string to boolean + if(typeof publicStatus == "string") + publicStatus = publicStatus == "true" ? true : false; + + //set the password + pad.setPublicStatus(publicStatus); + + callback(); + }); +} + +/** +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) +{ + //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()}); + }); +} + +/** +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) +{ + //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(); + }); +} + +/** +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) +{ + //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; + + callback(null, {isPasswordProtected: pad.isPasswordProtected()}); + }); +} + +/** +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) +{ + //get the pad + getPadSafe(padID, true, function(err, pad) + { + if(ERR(err, callback)) return; + + callback(null, {authorIDs: pad.getAllAuthors()}); + }); +} + +/** +sendClientsMessage(padID, msg) sends a message to all clients connected to the +pad, possibly for the purpose of signalling a plugin. + +Note, this will only accept strings from the HTTP API, so sending bogus changes +or chat messages will probably not be possible. + +The resulting message will be structured like so: + +{ + type: 'COLLABROOM', + data: { + type: , + time:
    ', 302); + } + //the pad id was fine, so just render it + else + { + next(); + } + }); + } + }); +} diff --git a/src/node/hooks/express/socketio.js b/src/node/hooks/express/socketio.js new file mode 100644 index 000000000..4f780cb0b --- /dev/null +++ b/src/node/hooks/express/socketio.js @@ -0,0 +1,66 @@ +var log4js = require('log4js'); +var socketio = require('socket.io'); +var settings = require('../../utils/Settings'); +var socketIORouter = require("../../handler/SocketIORouter"); +var hooks = require("ep_etherpad-lite/static/js/pluginfw/hooks"); + +var padMessageHandler = require("../../handler/PadMessageHandler"); + +var connect = require('connect'); + +exports.expressCreateServer = function (hook_name, args, cb) { + //init socket.io and redirect all requests to the MessageHandler + var io = socketio.listen(args.app); + + /* Require an express session cookie to be present, and load the + * session. See http://www.danielbaulig.de/socket-ioexpress for more + * info */ + io.set('authorization', function (data, accept) { + if (!data.headers.cookie) return accept('No session cookie transmitted.', false); + data.cookie = connect.utils.parseCookie(data.headers.cookie); + data.sessionID = data.cookie.express_sid; + args.app.sessionStore.get(data.sessionID, function (err, session) { + if (err || !session) return accept('Bad session / session has expired', false); + data.session = new connect.middleware.session.Session(data, session); + accept(null, true); + }); + }); + + // the following has been successfully tested with the following browsers + // works also behind reverse proxy + // Firefox 14.0.1 + // IE8 with Native XMLHTTP support + // IE8 without Native XMLHTTP support + // Chrome 21.0.1180.79 + io.set('transports', ['jsonp-polling']); + + var socketIOLogger = log4js.getLogger("socket.io"); + io.set('logger', { + debug: function (str) + { + socketIOLogger.debug.apply(socketIOLogger, arguments); + }, + info: function (str) + { + socketIOLogger.info.apply(socketIOLogger, arguments); + }, + warn: function (str) + { + socketIOLogger.warn.apply(socketIOLogger, arguments); + }, + error: function (str) + { + socketIOLogger.error.apply(socketIOLogger, arguments); + }, + }); + + //minify socket.io javascript + if(settings.minify) + io.enable('browser client minification'); + + //Initalize the Socket.IO Router + socketIORouter.setSocketIO(io); + socketIORouter.addComponent("pad", padMessageHandler); + + hooks.callAll("socketio", {"app": args.app, "io": io}); +} diff --git a/src/node/hooks/express/specialpages.js b/src/node/hooks/express/specialpages.js new file mode 100644 index 000000000..474f475ec --- /dev/null +++ b/src/node/hooks/express/specialpages.js @@ -0,0 +1,46 @@ +var path = require('path'); +var eejs = require('ep_etherpad-lite/node/eejs'); + +exports.expressCreateServer = function (hook_name, args, cb) { + + //serve index.html under / + args.app.get('/', function(req, res) + { + res.send(eejs.require("ep_etherpad-lite/templates/index.html")); + }); + + //serve robots.txt + args.app.get('/robots.txt', function(req, res) + { + var filePath = path.normalize(__dirname + "/../../../static/robots.txt"); + res.sendfile(filePath); + }); + + //serve favicon.ico + args.app.get('/favicon.ico', function(req, res) + { + var filePath = path.normalize(__dirname + "/../../../static/custom/favicon.ico"); + res.sendfile(filePath, function(err) + { + //there is no custom favicon, send the default favicon + if(err) + { + filePath = path.normalize(__dirname + "/../../../static/favicon.ico"); + res.sendfile(filePath); + } + }); + }); + + //serve pad.html under /p + args.app.get('/p/:pad', function(req, res, next) + { + res.send(eejs.require("ep_etherpad-lite/templates/pad.html")); + }); + + //serve timeslider.html under /p/$padname/timeslider + args.app.get('/p/:pad/timeslider', function(req, res, next) + { + res.send(eejs.require("ep_etherpad-lite/templates/timeslider.html")); + }); + +} \ No newline at end of file diff --git a/src/node/hooks/express/static.js b/src/node/hooks/express/static.js new file mode 100644 index 000000000..7d654c1b2 --- /dev/null +++ b/src/node/hooks/express/static.js @@ -0,0 +1,59 @@ +var path = require('path'); +var minify = require('../../utils/Minify'); +var plugins = require("ep_etherpad-lite/static/js/pluginfw/plugins"); +var CachingMiddleware = require('../../utils/caching_middleware'); +var settings = require("../../utils/Settings"); +var Yajsml = require('yajsml'); +var fs = require("fs"); +var ERR = require("async-stacktrace"); +var _ = require("underscore"); + +exports.expressCreateServer = function (hook_name, args, cb) { + + // Cache both minified and static. + var assetCache = new CachingMiddleware; + args.app.all('/(javascripts|static)/*', assetCache.handle); + + // Minify will serve static files compressed (minify enabled). It also has + // file-specific hacks for ace/require-kernel/etc. + args.app.all('/static/:filename(*)', minify.minify); + + // Setup middleware that will package JavaScript files served by minify for + // CommonJS loader on the client-side. + var jsServer = new (Yajsml.Server)({ + rootPath: 'javascripts/src/' + , rootURI: 'http://localhost:' + settings.port + '/static/js/' + , libraryPath: 'javascripts/lib/' + , libraryURI: 'http://localhost:' + settings.port + '/static/plugins/' + , requestURIs: minify.requestURIs // Loop-back is causing problems, this is a workaround. + }); + + var StaticAssociator = Yajsml.associators.StaticAssociator; + var associations = + Yajsml.associators.associationsForSimpleMapping(minify.tar); + var associator = new StaticAssociator(associations); + jsServer.setAssociator(associator); + args.app.use(jsServer); + + // serve plugin definitions + // not very static, but served here so that client can do require("pluginfw/static/js/plugin-definitions.js"); + args.app.get('/pluginfw/plugin-definitions.json', function (req, res, next) { + + var clientParts = _(plugins.parts) + .filter(function(part){ return _(part).has('client_hooks') }); + + var clientPlugins = {}; + + _(clientParts).chain() + .map(function(part){ return part.plugin }) + .uniq() + .each(function(name){ + 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/webaccess.js b/src/node/hooks/express/webaccess.js new file mode 100644 index 000000000..ffced0476 --- /dev/null +++ b/src/node/hooks/express/webaccess.js @@ -0,0 +1,116 @@ +var express = require('express'); +var log4js = require('log4js'); +var httpLogger = log4js.getLogger("http"); +var settings = require('../../utils/Settings'); +var randomString = require('ep_etherpad-lite/static/js/pad_utils').randomString; +var hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks'); + + +//checks for basic http auth +exports.basicAuth = function (req, res, next) { + var hookResultMangle = function (cb) { + return function (err, data) { + return cb(!err && data.length && data[0]); + } + } + + var authorize = function (cb) { + // Do not require auth for static paths...this could be a bit brittle + if (req.path.match(/^\/(static|javascripts|pluginfw)/)) return cb(true); + + if (req.path.indexOf('/admin') != 0) { + if (!settings.requireAuthentication) return cb(true); + if (!settings.requireAuthorization && req.session && req.session.user) return cb(true); + } + + if (req.session && req.session.user && req.session.user.is_admin) return cb(true); + + hooks.aCallFirst("authorize", {req: req, res:res, next:next, resource: req.path}, hookResultMangle(cb)); + } + + var authenticate = function (cb) { + // If auth headers are present use them to authenticate... + if (req.headers.authorization && req.headers.authorization.search('Basic ') === 0) { + var userpass = new Buffer(req.headers.authorization.split(' ')[1], 'base64').toString().split(":") + var username = userpass[0]; + var password = userpass[1]; + + if (settings.users[username] != undefined && settings.users[username].password == password) { + settings.users[username].username = username; + req.session.user = settings.users[username]; + return cb(true); + } + return hooks.aCallFirst("authenticate", {req: req, res:res, next:next, username: username, password: password}, hookResultMangle(cb)); + } + hooks.aCallFirst("authenticate", {req: req, res:res, next:next}, hookResultMangle(cb)); + } + + + /* Authentication OR authorization failed. */ + var failure = function () { + return hooks.aCallFirst("authFailure", {req: req, res:res, next:next}, hookResultMangle(function (ok) { + if (ok) return; + /* No plugin handler for invalid auth. Return Auth required + * Headers, delayed for 1 second, if authentication failed + * before. */ + res.header('WWW-Authenticate', 'Basic realm="Protected Area"'); + if (req.headers.authorization) { + setTimeout(function () { + res.send('Authentication required', 401); + }, 1000); + } else { + res.send('Authentication required', 401); + } + })); + } + + + /* This is the actual authentication/authorization hoop. It is done in four steps: + + 1) Try to just access the thing + 2) If not allowed using whatever creds are in the current session already, try to authenticate + 3) If authentication using already supplied credentials succeeds, try to access the thing again + 4) If all els fails, give the user a 401 to request new credentials + + Note that the process could stop already in step 3 with a redirect to login page. + + */ + + authorize(function (ok) { + if (ok) return next(); + authenticate(function (ok) { + if (!ok) return failure(); + authorize(function (ok) { + if (ok) return next(); + failure(); + }); + }); + }); +} + +var secret = null; + +exports.expressConfigure = function (hook_name, args, cb) { + // If the log level specified in the config file is WARN or ERROR the application server never starts listening to requests as reported in issue #158. + // Not installing the log4js connect logger when the log level has a higher severity than INFO since it would not log at that level anyway. + if (!(settings.loglevel === "WARN" || settings.loglevel == "ERROR")) + args.app.use(log4js.connectLogger(httpLogger, { level: log4js.levels.INFO, format: ':status, :method :url'})); + args.app.use(express.cookieParser()); + + /* Do not let express create the session, so that we can retain a + * reference to it for socket.io to use. Also, set the key (cookie + * name) to a javascript identifier compatible string. Makes code + * handling it cleaner :) */ + + if (!exports.sessionStore) { + exports.sessionStore = new express.session.MemoryStore(); + secret = randomString(32); + } + + args.app.sessionStore = exports.sessionStore; + args.app.use(express.session({store: args.app.sessionStore, + key: 'express_sid', + secret: secret})); + + args.app.use(exports.basicAuth); +} diff --git a/src/node/padaccess.js b/src/node/padaccess.js new file mode 100644 index 000000000..a3d1df332 --- /dev/null +++ b/src/node/padaccess.js @@ -0,0 +1,21 @@ +var ERR = require("async-stacktrace"); +var securityManager = require('./db/SecurityManager'); + +//checks for padAccess +module.exports = function (req, res, callback) { + + // FIXME: Why is this ever undefined?? + if (req.cookies === undefined) req.cookies = {}; + + securityManager.checkAccess(req.params.pad, req.cookies.sessionid, req.cookies.token, req.cookies.password, function(err, accessObj) { + if(ERR(err, callback)) return; + + //there is access, continue + if(accessObj.accessStatus == "grant") { + callback(); + //no access + } else { + res.send("403 - Can't touch this", 403); + } + }); +} diff --git a/src/node/server.js b/src/node/server.js new file mode 100755 index 000000000..d2ef93343 --- /dev/null +++ b/src/node/server.js @@ -0,0 +1,62 @@ +#!/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 + * to MessageHandler and minfied requests are passed to minified. + */ + +/* + * 2011 Peter 'Pita' Martischka (Primary Technology Ltd) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var log4js = require('log4js'); +var settings = require('./utils/Settings'); +var db = require('./db/DB'); +var async = require('async'); +var plugins = require("ep_etherpad-lite/static/js/pluginfw/plugins"); +var hooks = require("ep_etherpad-lite/static/js/pluginfw/hooks"); +var npm = require("npm/lib/npm.js"); + + +//set loglevel +log4js.setGlobalLogLevel(settings.loglevel); + +async.waterfall([ + //initalize the database + function (callback) + { + db.init(callback); + }, + + plugins.update, + + function (callback) { + console.info("Installed plugins: " + plugins.formatPlugins()); + 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); + } +]); diff --git a/src/node/utils/Abiword.js b/src/node/utils/Abiword.js new file mode 100644 index 000000000..27138e648 --- /dev/null +++ b/src/node/utils/Abiword.js @@ -0,0 +1,147 @@ +/** + * Controls the communication with the Abiword application + */ + +/* + * 2011 Peter 'Pita' Martischka (Primary Technology Ltd) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var util = require('util'); +var spawn = require('child_process').spawn; +var async = require("async"); +var settings = require("./Settings"); +var os = require('os'); + +var doConvertTask; + +//on windows we have to spawn a process for each convertion, cause the plugin abicommand doesn't exist on this platform +if(os.type().indexOf("Windows") > -1) +{ + var stdoutBuffer = ""; + + doConvertTask = function(task, callback) + { + //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) + { + //add data to buffer + stdoutBuffer+=data.toString(); + }); + + //append error messages to the buffer + abiword.stderr.on('data', function (data) + { + stdoutBuffer += data.toString(); + }); + + //throw exceptions if abiword is dieing + abiword.on('exit', function (code) + { + if(code != 0) { + return callback("Abiword died with exit code " + code); + } + + if(stdoutBuffer != "") + { + console.log(stdoutBuffer); + } + + callback(); + }); + } + + exports.convertFile = function(srcFile, destFile, type, callback) + { + doConvertTask({"srcFile": srcFile, "destFile": destFile, "type": type}, callback); + }; +} +//on unix operating systems, we can start abiword with abicommand and communicate with it via stdin/stdout +//thats much faster, about factor 10 +else +{ + //spawn the abiword process + var abiword; + var stdoutCallback = null; + var spawnAbiword = function (){ + abiword = spawn(settings.abiword, ["--plugin", "AbiCommand"]); + var stdoutBuffer = ""; + var firstPrompt = true; + + //append error messages to the buffer + 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) + { + spawnAbiword(); + stdoutCallback("Abiword died with exit code " + code); + }); + + //delegate the processing of stdout to a other function + abiword.stdout.on('data',function (data) + { + //add data to buffer + stdoutBuffer+=data.toString(); + + //we're searching for the prompt, cause this means everything we need is in the buffer + if(stdoutBuffer.search("AbiWord:>") != -1) + { + //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) + { + stdoutCallback(err); + stdoutCallback = null; + } + + firstPrompt = false; + } + }); + } + spawnAbiword(); + + doConvertTask = function(task, callback) + { + abiword.stdin.write("convert " + task.srcFile + " " + task.destFile + " " + task.type + "\n"); + + //create a callback that calls the task callback and the caller callback + stdoutCallback = function (err) + { + callback(); + console.log("queue continue"); + task.callback(err); + }; + } + + //Queue with the converts we have to do + var queue = async.queue(doConvertTask, 1); + + exports.convertFile = function(srcFile, destFile, type, callback) + { + queue.push({"srcFile": srcFile, "destFile": destFile, "type": type, "callback": callback}); + }; +} diff --git a/src/node/utils/Cli.js b/src/node/utils/Cli.js new file mode 100644 index 000000000..0c7947e91 --- /dev/null +++ b/src/node/utils/Cli.js @@ -0,0 +1,38 @@ +/** + * The CLI module handles command line parameters + */ + +/* + * 2012 Jordan Hollinger + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an + "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// An object containing the parsed command-line options +exports.argv = {}; + +var argv = process.argv.slice(2); +var arg, prevArg; + +// Loop through args +for ( var i = 0; i < argv.length; i++ ) { + arg = argv[i]; + + // Override location of settings.json file + if ( prevArg == '--settings' || prevArg == '-s' ) { + exports.argv.settings = arg; + } + + prevArg = arg; +} diff --git a/src/node/utils/ExportDokuWiki.js b/src/node/utils/ExportDokuWiki.js new file mode 100644 index 000000000..d2f71236b --- /dev/null +++ b/src/node/utils/ExportDokuWiki.js @@ -0,0 +1,350 @@ +/** + * Copyright 2011 Adrian Lang + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var async = require("async"); + +var Changeset = require("ep_etherpad-lite/static/js/Changeset"); +var padManager = require("../db/PadManager"); + +function getPadDokuWiki(pad, revNum, callback) +{ + var atext = pad.atext; + var dokuwiki; + async.waterfall([ + // fetch revision atext + + + function (callback) + { + if (revNum != undefined) + { + pad.getInternalRevisionAText(revNum, function (err, revisionAtext) + { + atext = revisionAtext; + callback(err); + }); + } + else + { + callback(null); + } + }, + + // convert atext to dokuwiki text + + function (callback) + { + dokuwiki = getDokuWikiFromAtext(pad, atext); + callback(null); + }], + // run final callback + + + function (err) + { + callback(err, dokuwiki); + }); +} + +function getDokuWikiFromAtext(pad, atext) +{ + var apool = pad.apool(); + var textLines = atext.text.slice(0, -1).split('\n'); + var attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text); + + var tags = ['======', '=====', '**', '//', '__', 'del>']; + var props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough']; + var anumMap = {}; + + props.forEach(function (propName, i) + { + var propTrueNum = apool.putAttrib([propName, true], true); + if (propTrueNum >= 0) + { + anumMap[propTrueNum] = i; + } + }); + + function getLineDokuWiki(text, attribs) + { + var propVals = [false, false, false]; + var ENTER = 1; + var STAY = 2; + var LEAVE = 0; + + // Use order of tags (b/i/u) as order of nesting, for simplicity + // and decent nesting. For example, + // Just bold Bold and italics Just italics + // becomes + // Just bold Bold and italics Just italics + var taker = Changeset.stringIterator(text); + var assem = Changeset.stringAssembler(); + + function emitOpenTag(i) + { + if (tags[i].indexOf('>') !== -1) { + assem.append('<'); + } + assem.append(tags[i]); + } + + function emitCloseTag(i) + { + if (tags[i].indexOf('>') !== -1) { + assem.append(' bold, etc. + if (!propVals[i]) + { + propVals[i] = ENTER; + propChanged = true; + } + else + { + propVals[i] = STAY; + } + } + }); + for (var i = 0; i < propVals.length; i++) + { + if (propVals[i] === true) + { + propVals[i] = LEAVE; + propChanged = true; + } + else if (propVals[i] === STAY) + { + propVals[i] = true; // set it back + } + } + // now each member of propVal is in {false,LEAVE,ENTER,true} + // according to what happens at start of span + if (propChanged) + { + // leaving bold (e.g.) also leaves italics, etc. + var left = false; + for (var i = 0; i < propVals.length; i++) + { + var v = propVals[i]; + if (!left) + { + if (v === LEAVE) + { + left = true; + } + } + else + { + if (v === true) + { + propVals[i] = STAY; // tag will be closed and re-opened + } + } + } + + for (var i = propVals.length - 1; i >= 0; i--) + { + if (propVals[i] === LEAVE) + { + emitCloseTag(i); + propVals[i] = false; + } + else if (propVals[i] === STAY) + { + emitCloseTag(i); + } + } + for (var i = 0; i < propVals.length; i++) + { + if (propVals[i] === ENTER || propVals[i] === STAY) + { + emitOpenTag(i); + propVals[i] = true; + } + } + // propVals is now all {true,false} again + } // end if (propChanged) + var chars = o.chars; + if (o.lines) + { + chars--; // exclude newline at end of line, if present + } + var s = taker.take(chars); + + assem.append(_escapeDokuWiki(s)); + } // end iteration over spans in line + for (var i = propVals.length - 1; i >= 0; i--) + { + if (propVals[i]) + { + emitCloseTag(i); + propVals[i] = false; + } + } + } // end processNextChars + if (urls) + { + urls.forEach(function (urlData) + { + var startIndex = urlData[0]; + var url = urlData[1]; + var urlLength = url.length; + processNextChars(startIndex - idx); + assem.append('[['); + + // Do not use processNextChars since a link does not contain syntax and + // needs no escaping + var iter = Changeset.opIterator(Changeset.subattribution(attribs, idx, idx + urlLength)); + idx += urlLength; + assem.append(taker.take(iter.next().chars)); + + assem.append(']]'); + }); + } + processNextChars(text.length - idx); + + return assem.toString() + "\n"; + } // end getLineDokuWiki + var pieces = []; + + for (var i = 0; i < textLines.length; i++) + { + var line = _analyzeLine(textLines[i], attribLines[i], apool); + var lineContent = getLineDokuWiki(line.text, line.aline); + + if (line.listLevel && lineContent) + { + if (line.listTypeName == "number") + { + pieces.push(new Array(line.listLevel + 1).join(' ') + ' - '); + } else { + pieces.push(new Array(line.listLevel + 1).join(' ') + '* '); + } + } + pieces.push(lineContent); + } + + return pieces.join(''); +} + +function _analyzeLine(text, aline, apool) +{ + var line = {}; + + // identify list + var lineMarker = 0; + line.listLevel = 0; + if (aline) + { + var opIter = Changeset.opIterator(aline); + if (opIter.hasNext()) + { + var listType = Changeset.opAttributeValue(opIter.next(), 'list', apool); + if (listType) + { + lineMarker = 1; + listType = /([a-z]+)([12345678])/.exec(listType); + if (listType) + { + line.listTypeName = listType[1]; + line.listLevel = Number(listType[2]); + } + } + } + } + if (lineMarker) + { + line.text = text.substring(1); + line.aline = Changeset.subattribution(aline, 1); + } + else + { + line.text = text; + line.aline = aline; + } + + return line; +} + +exports.getPadDokuWikiDocument = function (padId, revNum, callback) +{ + padManager.getPad(padId, function (err, pad) + { + if (err) + { + callback(err); + return; + } + + getPadDokuWiki(pad, revNum, callback); + }); +} + +function _escapeDokuWiki(s) +{ + s = s.replace(/(\/\/|\*\*|__)/g, '%%$1%%'); + return s; +} + +// copied from ACE +var _REGEX_WORDCHAR = /[\u0030-\u0039\u0041-\u005A\u0061-\u007A\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF\u0100-\u1FFF\u3040-\u9FFF\uF900-\uFDFF\uFE70-\uFEFE\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFDC]/; +var _REGEX_SPACE = /\s/; +var _REGEX_URLCHAR = new RegExp('(' + /[-:@a-zA-Z0-9_.,~%+\/\\?=&#;()$]/.source + '|' + _REGEX_WORDCHAR.source + ')'); +var _REGEX_URL = new RegExp(/(?:(?:https?|s?ftp|ftps|file|smb|afp|nfs|(x-)?man|gopher|txmt):\/\/|mailto:)/.source + _REGEX_URLCHAR.source + '*(?![:.,;])' + _REGEX_URLCHAR.source, 'g'); + +// returns null if no URLs, or [[startIndex1, url1], [startIndex2, url2], ...] + + +function _findURLs(text) +{ + _REGEX_URL.lastIndex = 0; + var urls = null; + var execResult; + while ((execResult = _REGEX_URL.exec(text))) + { + urls = (urls || []); + var startIndex = execResult.index; + var url = execResult[0]; + urls.push([startIndex, url]); + } + + return urls; +} diff --git a/src/node/utils/ExportHtml.js b/src/node/utils/ExportHtml.js new file mode 100644 index 000000000..354030133 --- /dev/null +++ b/src/node/utils/ExportHtml.js @@ -0,0 +1,610 @@ +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +var async = require("async"); +var Changeset = require("ep_etherpad-lite/static/js/Changeset"); +var padManager = require("../db/PadManager"); +var ERR = require("async-stacktrace"); +var Security = require('ep_etherpad-lite/static/js/security'); +var hooks = require('ep_etherpad-lite/static/js/pluginfw/hooks'); +function getPadPlainText(pad, revNum) +{ + var atext = ((revNum !== undefined) ? pad.getInternalRevisionAText(revNum) : pad.atext()); + var textLines = atext.text.slice(0, -1).split('\n'); + var attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text); + var apool = pad.pool(); + + var pieces = []; + for (var i = 0; i < textLines.length; i++) + { + var line = _analyzeLine(textLines[i], attribLines[i], apool); + if (line.listLevel) + { + var numSpaces = line.listLevel * 2 - 1; + var bullet = '*'; + pieces.push(new Array(numSpaces + 1).join(' '), bullet, ' ', line.text, '\n'); + } + else + { + pieces.push(line.text, '\n'); + } + } + + return pieces.join(''); +} + +function getPadHTML(pad, revNum, callback) +{ + var atext = pad.atext; + var html; + async.waterfall([ + // 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); + } + }, + + // 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); + }); +} + +exports.getPadHTML = getPadHTML; + +function getHTMLFromAtext(pad, atext) +{ + var apool = pad.apool(); + var textLines = atext.text.slice(0, -1).split('\n'); + var attribLines = Changeset.splitAttributionLines(atext.attribs, atext.text); + + var tags = ['h1', 'h2', 'strong', 'em', 'u', 's']; + var props = ['heading1', 'heading2', 'bold', 'italic', 'underline', 'strikethrough']; + var anumMap = {}; + + props.forEach(function (propName, i) + { + var propTrueNum = apool.putAttrib([propName, true], true); + if (propTrueNum >= 0) + { + anumMap[propTrueNum] = i; + } + }); + + function getLineHTML(text, attribs) + { + var propVals = [false, false, false]; + var ENTER = 1; + var STAY = 2; + var LEAVE = 0; + + // Use order of tags (b/i/u) as order of nesting, for simplicity + // and decent nesting. For example, + // Just bold Bold and italics Just italics + // becomes + // Just bold Bold and italics Just italics + var taker = Changeset.stringIterator(text); + var assem = Changeset.stringAssembler(); + + var openTags = []; + function emitOpenTag(i) + { + openTags.unshift(i); + assem.append('<'); + assem.append(tags[i]); + assem.append('>'); + } + + function emitCloseTag(i) + { + openTags.shift(); + assem.append(''); + } + + function orderdCloseTags(tags2close) + { + for(var i=0;i bold, etc. + if (!propVals[i]) + { + propVals[i] = ENTER; + propChanged = true; + } + else + { + propVals[i] = STAY; + } + } + }); + for (var i = 0; i < propVals.length; i++) + { + if (propVals[i] === true) + { + propVals[i] = LEAVE; + propChanged = true; + } + else if (propVals[i] === STAY) + { + propVals[i] = true; // set it back + } + } + // now each member of propVal is in {false,LEAVE,ENTER,true} + // according to what happens at start of span + if (propChanged) + { + // leaving bold (e.g.) also leaves italics, etc. + var left = false; + for (var i = 0; i < propVals.length; i++) + { + var v = propVals[i]; + if (!left) + { + if (v === LEAVE) + { + left = true; + } + } + else + { + if (v === true) + { + propVals[i] = STAY; // tag will be closed and re-opened + } + } + } + + var tags2close = []; + + 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) + { + //emitCloseTag(i); + tags2close.push(i); + } + } + + orderdCloseTags(tags2close); + + for (var i = 0; i < propVals.length; i++) + { + if (propVals[i] === ENTER || propVals[i] === STAY) + { + emitOpenTag(i); + propVals[i] = true; + } + } + // propVals is now all {true,false} again + } // end if (propChanged) + var chars = o.chars; + if (o.lines) + { + chars--; // exclude newline at end of line, if present + } + + var s = taker.take(chars); + + //removes the characters with the code 12. Don't know where they come + //from but they break the abiword parser and are completly useless + s = s.replace(String.fromCharCode(12), ""); + + assem.append(_encodeWhitespace(Security.escapeHTML(s))); + } // end iteration over spans in line + + var tags2close = []; + for (var i = propVals.length - 1; i >= 0; i--) + { + if (propVals[i]) + { + tags2close.push(i); + propVals[i] = false; + } + } + + orderdCloseTags(tags2close); + } // end processNextChars + if (urls) + { + urls.forEach(function (urlData) + { + var startIndex = urlData[0]; + var url = urlData[1]; + var urlLength = url.length; + processNextChars(startIndex - idx); + assem.append(''); + processNextChars(urlLength); + assem.append(''); + }); + } + processNextChars(text.length - idx); + + return _processSpaces(assem.toString()); + } // end getLineHTML + var pieces = []; + + // Need to deal with constraints imposed on HTML lists; can + // only gain one level of nesting at once, can't change type + // mid-list, etc. + // People might use weird indenting, e.g. skip a level, + // so we want to do something reasonable there. We also + // want to deal gracefully with blank lines. + // => keeps track of the parents level of indentation + var lists = []; // e.g. [[1,'bullet'], [3,'bullet'], ...] + for (var i = 0; i < textLines.length; i++) + { + var line = _analyzeLine(textLines[i], attribLines[i], apool); + var lineContent = getLineHTML(line.text, line.aline); + + if (line.listLevel)//If we are inside a list + { + // do list stuff + var whichList = -1; // index into lists or -1 + if (line.listLevel) + { + whichList = lists.length; + for (var j = lists.length - 1; j >= 0; j--) + { + if (line.listLevel <= lists[j][0]) + { + whichList = j; + } + } + } + + if (whichList >= lists.length)//means we are on a deeper level of indentation than the previous line + { + lists.push([line.listLevel, line.listTypeName]); + if(line.listTypeName == "number") + { + pieces.push('
    1. ', lineContent || '
      '); + } + else + { + pieces.push('
      • ', lineContent || '
        '); + } + } + //the following code *seems* dead after my patch. + //I keep it just in case I'm wrong... + /*else if (whichList == -1)//means we are not inside a list + { + if (line.text) + { + console.log('trace 1'); + // non-blank line, end all lists + if(line.listTypeName == "number") + { + pieces.push(new Array(lists.length + 1).join('
    ')); + } + else + { + pieces.push(new Array(lists.length + 1).join('')); + } + lists.length = 0; + pieces.push(lineContent, '
    '); + } + else + { + console.log('trace 2'); + pieces.push('

    '); + } + }*/ + else//means we are getting closer to the lowest level of indentation + { + while (whichList < lists.length - 1) + { + if(lists[lists.length - 1][1] == "number") + { + pieces.push(''); + } + else + { + pieces.push(''); + } + lists.length--; + } + pieces.push('
  • ', lineContent || '
    '); + } + } + else//outside any list + { + while (lists.length > 0)//if was in a list: close it before + { + if(lists[lists.length - 1][1] == "number") + { + pieces.push('
  • '); + } + else + { + pieces.push(''); + } + lists.length--; + } + var lineContentFromHook = hooks.callAllStr("getLineHTMLForExport", + { + line: line, + apool: apool, + attribLine: attribLines[i], + text: textLines[i] + }, " ", " ", ""); + if (lineContentFromHook) + { + pieces.push(lineContentFromHook, ''); + } + else + { + pieces.push(lineContent, '
    '); + } + } + } + + for (var k = lists.length - 1; k >= 0; k--) + { + if(lists[k][1] == "number") + { + pieces.push(''); + } + else + { + pieces.push(''); + } + } + + return pieces.join(''); +} + +function _analyzeLine(text, aline, apool) +{ + var line = {}; + + // identify list + var lineMarker = 0; + line.listLevel = 0; + if (aline) + { + var opIter = Changeset.opIterator(aline); + if (opIter.hasNext()) + { + var listType = Changeset.opAttributeValue(opIter.next(), 'list', apool); + if (listType) + { + lineMarker = 1; + listType = /([a-z]+)([12345678])/.exec(listType); + if (listType) + { + line.listTypeName = listType[1]; + line.listLevel = Number(listType[2]); + } + } + } + } + if (lineMarker) + { + line.text = text.substring(1); + line.aline = Changeset.subattribution(aline, 1); + } + else + { + line.text = text; + line.aline = aline; + } + + return line; +} + +exports.getPadHTMLDocument = function (padId, revNum, noDocType, callback) +{ + padManager.getPad(padId, function (err, pad) + { + if(ERR(err, callback)) return; + + var head = + (noDocType ? '' : '\n') + + '\n' + (noDocType ? '' : '\n' + + '' + Security.escapeHTML(padId) + '\n' + + '\n' + + '\n' + '\n') + + ''; + + var foot = '\n\n'; + + getPadHTML(pad, revNum, function (err, html) + { + if(ERR(err, callback)) return; + callback(null, head + html + foot); + }); + }); +} + +function _encodeWhitespace(s) { + return s.replace(/[^\x21-\x7E\s\t\n\r]/g, function(c) + { + return "&#" +c.charCodeAt(0) + ";" + }); +} + +// copied from ACE + + +function _processSpaces(s) +{ + var doesWrap = true; + if (s.indexOf("<") < 0 && !doesWrap) + { + // short-cut + return s.replace(/ /g, ' '); + } + var parts = []; + s.replace(/<[^>]*>?| |[^ <]+/g, function (m) + { + parts.push(m); + }); + if (doesWrap) + { + var endOfLine = true; + var beforeSpace = false; + // last space in a run is normal, others are nbsp, + // end of line is nbsp + for (var i = parts.length - 1; i >= 0; i--) + { + var p = parts[i]; + if (p == " ") + { + if (endOfLine || beforeSpace) parts[i] = ' '; + endOfLine = false; + beforeSpace = true; + } + else if (p.charAt(0) != "<") + { + endOfLine = false; + beforeSpace = false; + } + } + // beginning of line is nbsp + for (var i = 0; i < parts.length; i++) + { + var p = parts[i]; + if (p == " ") + { + parts[i] = ' '; + break; + } + else if (p.charAt(0) != "<") + { + break; + } + } + } + else + { + for (var i = 0; i < parts.length; i++) + { + var p = parts[i]; + if (p == " ") + { + parts[i] = ' '; + } + } + } + return parts.join(''); +} + + +// copied from ACE +var _REGEX_WORDCHAR = /[\u0030-\u0039\u0041-\u005A\u0061-\u007A\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u00FF\u0100-\u1FFF\u3040-\u9FFF\uF900-\uFDFF\uFE70-\uFEFE\uFF10-\uFF19\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFDC]/; +var _REGEX_SPACE = /\s/; +var _REGEX_URLCHAR = new RegExp('(' + /[-:@a-zA-Z0-9_.,~%+\/\\?=&#;()$]/.source + '|' + _REGEX_WORDCHAR.source + ')'); +var _REGEX_URL = new RegExp(/(?:(?:https?|s?ftp|ftps|file|smb|afp|nfs|(x-)?man|gopher|txmt):\/\/|mailto:)/.source + _REGEX_URLCHAR.source + '*(?![:.,;])' + _REGEX_URLCHAR.source, 'g'); + +// returns null if no URLs, or [[startIndex1, url1], [startIndex2, url2], ...] + + +function _findURLs(text) +{ + _REGEX_URL.lastIndex = 0; + var urls = null; + var execResult; + while ((execResult = _REGEX_URL.exec(text))) + { + urls = (urls || []); + var startIndex = execResult.index; + var url = execResult[0]; + urls.push([startIndex, url]); + } + + return urls; +} diff --git a/src/node/utils/ImportHtml.js b/src/node/utils/ImportHtml.js new file mode 100644 index 000000000..7c638fb8c --- /dev/null +++ b/src/node/utils/ImportHtml.js @@ -0,0 +1,90 @@ +/** + * Copyright Yaco Sistemas S.L. 2011. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var jsdom = require('jsdom-nocontextifiy').jsdom; +var log4js = require('log4js'); + + +var Changeset = require("ep_etherpad-lite/static/js/Changeset"); +var contentcollector = require("ep_etherpad-lite/static/js/contentcollector"); + +function setPadHTML(pad, html, callback) +{ + var apiLogger = log4js.getLogger("ImportHtml"); + + // Clean the pad. This makes the rest of the code easier + // by several orders of magnitude. + pad.setText(""); + var padText = pad.text(); + + // Parse the incoming HTML with jsdom + var doc = jsdom(html.replace(/>\n+<')); + apiLogger.debug('html:'); + apiLogger.debug(html); + + // 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); + cc.collectContent(doc.childNodes[0]); + var result = cc.finish(); + apiLogger.debug('Lines:'); + var i; + for (i = 0; i < result.lines.length; i += 1) + { + apiLogger.debug('Line ' + (i + 1) + ' text: ' + result.lines[i]); + apiLogger.debug('Line ' + (i + 1) + ' attributes: ' + result.lineAttribs[i]); + } + + // Get the new plain text and its attributes + var newText = result.lines.join('\n'); + apiLogger.debug('newText:'); + apiLogger.debug(newText); + var newAttribs = result.lineAttribs.join('|1+1') + '|1+1'; + + 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()) + { + 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(1); + + // assemble each line into the builder + 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.appendRevision(theChangeset); +} + +exports.setPadHTML = setPadHTML; diff --git a/src/node/utils/Minify.js b/src/node/utils/Minify.js new file mode 100644 index 000000000..21a663c0e --- /dev/null +++ b/src/node/utils/Minify.js @@ -0,0 +1,392 @@ +/** + * This Module manages all /minified/* requests. It controls the + * minification && compression of Javascript and CSS. + */ + +/* + * 2011 Peter 'Pita' Martischka (Primary Technology Ltd) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var ERR = require("async-stacktrace"); +var settings = require('./Settings'); +var async = require('async'); +var fs = require('fs'); +var cleanCSS = require('clean-css'); +var jsp = require("uglify-js").parser; +var pro = require("uglify-js").uglify; +var path = require('path'); +var plugins = require("ep_etherpad-lite/static/js/pluginfw/plugins"); +var RequireKernel = require('require-kernel'); +var urlutil = require('url'); + +var ROOT_DIR = path.normalize(__dirname + "/../../static/"); +var TAR_PATH = path.join(__dirname, 'tar.json'); +var tar = JSON.parse(fs.readFileSync(TAR_PATH, 'utf8')); + +// Rewrite tar to include modules with no extensions and proper rooted paths. +var LIBRARY_PREFIX = 'ep_etherpad-lite/static/js'; +exports.tar = {}; +for (var key in tar) { + exports.tar[LIBRARY_PREFIX + '/' + key] = + tar[key].map(function (p) {return LIBRARY_PREFIX + '/' + p}).concat( + tar[key].map(function (p) { + return LIBRARY_PREFIX + '/' + p.replace(/\.js$/, '') + }) + ); +} + +// 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) { + var parsedURL = urlutil.parse(url); + + var status = 500, headers = {}, content = []; + + var mockRequest = { + url: url + , method: method + , params: {filename: parsedURL.path.replace(/^\/static\//, '')} + , headers: headers + }; + var mockResponse = { + writeHead: function (_status, _headers) { + status = _status; + for (var header in _headers) { + if (Object.prototype.hasOwnProperty.call(_headers, header)) { + headers[header] = _headers[header]; + } + } + } + , setHeader: function (header, value) { + headers[header.toLowerCase()] = value.toString(); + } + , header: function (header, value) { + headers[header.toLowerCase()] = value.toString(); + } + , write: function (_content) { + _content && content.push(_content); + } + , end: function (_content) { + _content && content.push(_content); + callback(status, headers, content.join('')); + } + }; + + minify(mockRequest, mockResponse); +} +function requestURIs(locations, method, headers, callback) { + var pendingRequests = locations.length; + var responses = []; + + function respondFor(i) { + return function (status, headers, content) { + responses[i] = [status, headers, content]; + if (--pendingRequests == 0) { + completed(); + } + }; + } + + for (var i = 0, ii = locations.length; i < ii; i++) { + requestURI(locations[i], method, headers, respondFor(i)); + } + + function completed() { + var statuss = responses.map(function (x) {return x[0]}); + var headerss = responses.map(function (x) {return x[1]}); + var contentss = responses.map(function (x) {return x[2]}); + callback(statuss, headerss, contentss); + }; +} + +/** + * creates the minifed javascript for the given minified name + * @param req the Express request + * @param res the Express response + */ +function minify(req, res, next) +{ + var filename = req.params['filename']; + + // No relative paths, especially if they may go up the file hierarchy. + filename = path.normalize(path.join(ROOT_DIR, filename)); + if (filename.indexOf(ROOT_DIR) == 0) { + filename = filename.slice(ROOT_DIR.length); + filename = filename.replace(/\\/g, '/'); // Windows (safe generally?) + } else { + res.writeHead(404, {}); + res.end(); + return; + } + + /* Handle static files for plugins: + paths like "plugins/ep_myplugin/static/js/test.js" + are rewritten into ROOT_PATH_OF_MYPLUGIN/static/js/test.js, + commonly ETHERPAD_ROOT/node_modules/ep_myplugin/static/js/test.js + */ + var match = filename.match(/^plugins\/([^\/]+)\/static\/(.*)/); + if (match) { + var pluginName = match[1]; + var resourcePath = match[2]; + var plugin = plugins.plugins[pluginName]; + if (plugin) { + var pluginPath = plugin.package.realPath; + filename = path.relative(ROOT_DIR, pluginPath + '/static/' + resourcePath); + } + } + + // What content type should this be? + // TODO: This should use a MIME module. + var contentType; + if (filename.match(/\.js$/)) { + contentType = "text/javascript"; + } else if (filename.match(/\.css$/)) { + contentType = "text/css"; + } else if (filename.match(/\.html$/)) { + contentType = "text/html"; + } else if (filename.match(/\.txt$/)) { + contentType = "text/plain"; + } else if (filename.match(/\.png$/)) { + contentType = "image/png"; + } else if (filename.match(/\.gif$/)) { + contentType = "image/gif"; + } else if (filename.match(/\.ico$/)) { + contentType = "image/x-icon"; + } else { + contentType = "application/octet-stream"; + } + + statFile(filename, function (error, date, exists) { + if (date) { + date = new Date(date); + 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); + res.setHeader('expires', expiresDate.toUTCString()); + res.setHeader('cache-control', 'max-age=' + settings.maxAge); + } + } + + if (error) { + res.writeHead(500, {}); + res.end(); + } else if (!exists) { + res.writeHead(404, {}); + res.end(); + } else if (new Date(req.headers['if-modified-since']) >= date) { + res.writeHead(304, {}); + res.end(); + } else { + if (req.method == 'HEAD') { + res.header("Content-Type", contentType); + res.writeHead(200, {}); + res.end(); + } else if (req.method == 'GET') { + getFileCompressed(filename, contentType, function (error, content) { + if(ERR(error, function(){ + res.writeHead(500, {}); + res.end(); + })) return; + res.header("Content-Type", contentType); + res.writeHead(200, {}); + res.write(content); + res.end(); + }); + } else { + res.writeHead(405, {'allow': 'HEAD, GET'}); + res.end(); + } + } + }); +} + +// find all includes in ace.js and embed them. +function getAceFile(callback) { + fs.readFile(ROOT_DIR + 'js/ace.js', "utf8", function(err, data) { + if(ERR(err, callback)) return; + + // Find all includes in ace.js and embed them + var founds = data.match(/\$\$INCLUDE_[a-zA-Z_]+\("[^"]*"\)/gi); + if (!settings.minify) { + founds = []; + } + // Always include the require kernel. + founds.push('$$INCLUDE_JS("../static/js/require-kernel.js")'); + + data += ';\n'; + data += 'Ace2Editor.EMBEDED = Ace2Editor.EMBEDED || {};\n'; + + // Request the contents of the included file on the server-side and write + // them into the file. + async.forEach(founds, function (item, callback) { + var filename = item.match(/"([^"]*)"/)[1]; + var request = require('request'); + + var baseURI = 'http://localhost:' + settings.port + var resourceURI = baseURI + path.normalize(path.join('/static/', filename)); + resourceURI = resourceURI.replace(/\\/g, '/'); // Windows (safe generally?) + + requestURI(resourceURI, 'GET', {}, function (status, headers, body) { + var error = !(status == 200 || status == 404); + if (!error) { + data += 'Ace2Editor.EMBEDED[' + JSON.stringify(filename) + '] = ' + + JSON.stringify(status == 200 ? body || '' : null) + ';\n'; + } else { + // Silence? + } + callback(); + }); + }, function(error) { + callback(error, data); + }); + }); +} + +// Check for the existance of the file and get the last modification date. +function statFile(filename, callback) { + if (filename == 'js/ace.js') { + // Sometimes static assets are inlined into this file, so we have to stat + // everything. + lastModifiedDateOfEverything(function (error, date) { + callback(error, date, !error); + }); + } else if (filename == 'js/require-kernel.js') { + callback(null, requireLastModified(), true); + } else { + fs.stat(ROOT_DIR + filename, function (error, stats) { + if (error) { + if (error.code == "ENOENT") { + // Stat the directory instead. + fs.stat(path.dirname(ROOT_DIR + filename), function (error, stats) { + if (error) { + if (error.code == "ENOENT") { + callback(null, null, false); + } else { + callback(error); + } + } else { + callback(null, stats.mtime.getTime(), false); + } + }); + } else { + callback(error); + } + } else { + callback(null, stats.mtime.getTime(), true); + } + }); + } +} +function lastModifiedDateOfEverything(callback) { + var folders2check = [ROOT_DIR + 'js/', ROOT_DIR + 'css/']; + var latestModification = 0; + //go trough this two folders + async.forEach(folders2check, function(path, callback) + { + //read the files in the folder + fs.readdir(path, function(err, files) + { + if(ERR(err, callback)) return; + + //we wanna check the directory itself for changes too + files.push("."); + + //go trough all files in this folder + async.forEach(files, function(filename, callback) + { + //get the stat data of this file + fs.stat(path + "/" + filename, function(err, stats) + { + if(ERR(err, callback)) return; + + //get the modification time + var modificationTime = stats.mtime.getTime(); + + //compare the modification time to the highest found + if(modificationTime > latestModification) + { + latestModification = modificationTime; + } + + callback(); + }); + }, callback); + }); + }, function () { + callback(null, latestModification); + }); +} + +// This should be provided by the module, but until then, just use startup +// time. +var _requireLastModified = new Date(); +function requireLastModified() { + return _requireLastModified.toUTCString(); +} +function requireDefinition() { + return 'var require = ' + RequireKernel.kernelSource + ';\n'; +} + +function getFileCompressed(filename, contentType, callback) { + getFile(filename, function (error, content) { + if (error || !content) { + callback(error, content); + } else { + if (settings.minify) { + if (contentType == 'text/javascript') { + try { + content = compressJS([content]); + } catch (error) { + // silence + } + } else if (contentType == 'text/css') { + content = compressCSS([content]); + } + } + callback(null, content); + } + }); +} + +function getFile(filename, callback) { + if (filename == 'js/ace.js') { + getAceFile(callback); + } else if (filename == 'js/require-kernel.js') { + callback(undefined, requireDefinition()); + } else { + fs.readFile(ROOT_DIR + filename, callback); + } +} + +function compressJS(values) +{ + var complete = values.join("\n"); + var ast = jsp.parse(complete); // parse code and get the initial AST + ast = pro.ast_mangle(ast); // get a new AST with mangled names + ast = pro.ast_squeeze(ast); // get an AST with compression optimizations + return pro.gen_code(ast); // compressed code here +} + +function compressCSS(values) +{ + var complete = values.join("\n"); + return cleanCSS.process(complete); +} + +exports.minify = minify; + +exports.requestURI = requestURI; +exports.requestURIs = requestURIs; diff --git a/src/node/utils/Settings.js b/src/node/utils/Settings.js new file mode 100644 index 000000000..dd34ac5ee --- /dev/null +++ b/src/node/utils/Settings.js @@ -0,0 +1,151 @@ +/** + * The Settings Modul reads the settings out of settings.json and provides + * this information to the other modules + */ + +/* + * 2011 Peter 'Pita' Martischka (Primary Technology Ltd) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var fs = require("fs"); +var os = require("os"); +var path = require('path'); +var argv = require('./Cli').argv; +var npm = require("npm/lib/npm.js"); +var vm = require('vm'); + +/* Root path of the installation */ +exports.root = path.normalize(path.join(npm.dir, "..")); + +/** + * The IP ep-lite should listen to + */ +exports.ip = "0.0.0.0"; + +/** + * The Port ep-lite should listen to + */ +exports.port = 9001; +/* + * The Type of the database + */ +exports.dbType = "dirty"; +/** + * This setting is passed with dbType to ueberDB to set up the database + */ +exports.dbSettings = { "filename" : path.join(exports.root, "dirty.db") }; + +/** + * The default Text of a new pad + */ +exports.defaultPadText = "Welcome to Etherpad Lite!\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\nEtherpad Lite on Github: http:\/\/j.mp/ep-lite\n"; + +/** + * A flag that requires any user to have a valid session (via the api) before accessing a pad + */ +exports.requireSession = false; + +/** + * A flag that prevents users from creating new pads + */ +exports.editOnly = false; + +/** + * Max age that responses will have (affects caching layer). + */ +exports.maxAge = 1000*60*60*6; // 6 hours + +/** + * A flag that shows if minification is enabled or not + */ +exports.minify = true; + +/** + * The path of the abiword executable + */ +exports.abiword = null; + +/** + * The log level of log4js + */ +exports.loglevel = "INFO"; + +/* 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 */ +exports.requireAuthentication = false; +exports.requireAuthorization = false; +exports.users = {}; + +//checks if abiword is avaiable +exports.abiwordAvailable = function() +{ + if(exports.abiword != null) + { + return os.type().indexOf("Windows") != -1 ? "withoutPDF" : "yes"; + } + else + { + return "no"; + } +} + +// Discover where the settings file lives +var settingsFilename = argv.settings || "settings.json"; +settingsFilename = path.resolve(path.join(root, settingsFilename)); + +var settingsStr; +try{ + //read the settings sync + settingsStr = fs.readFileSync(settingsFilename).toString(); +} catch(e){ + console.warn('No settings file found. Continuing using defaults!'); +} + +// try to parse the settings +var settings; +try { + if(settingsStr) { + settings = vm.runInContext('exports = '+settingsStr, vm.createContext(), "settings.json"); + } +}catch(e){ + console.error('There was an error processing your settings.json file: '+e.message); + process.exit(1); +} + +//loop trough the settings +for(var i in settings) +{ + //test if the setting start with a low character + if(i.charAt(0).search("[a-z]") !== 0) + { + console.warn("Settings should start with a low character: '" + i + "'"); + } + + //we know this setting, so we overwrite it + if(exports[i] !== undefined) + { + 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"); + } +} + +if(exports.dbType === "dirty"){ + console.warn("DirtyDB is used. This is fine for testing but not recommended for production.") +} diff --git a/src/node/utils/caching_middleware.js b/src/node/utils/caching_middleware.js new file mode 100644 index 000000000..1f5336733 --- /dev/null +++ b/src/node/utils/caching_middleware.js @@ -0,0 +1,183 @@ +/* + * 2011 Peter 'Pita' Martischka (Primary Technology Ltd) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var async = require('async'); +var Buffer = require('buffer').Buffer; +var fs = require('fs'); +var path = require('path'); +var zlib = require('zlib'); +var util = require('util'); +var settings = require('./Settings'); +var semver = require('semver'); + +var existsSync = (semver.satisfies(process.version, '>=0.8.0')) ? fs.existsSync : path.existsSync + +var CACHE_DIR = path.normalize(path.join(settings.root, 'var/')); +CACHE_DIR = existsSync(CACHE_DIR) ? CACHE_DIR : undefined; + +var responseCache = {}; + +/* + This caches and compresses 200 and 404 responses to GET and HEAD requests. + TODO: Caching and compressing are solved problems, a middleware configuration + should replace this. +*/ + +function CachingMiddleware() { +} +CachingMiddleware.prototype = new function () { + function handle(req, res, next) { + if (!(req.method == "GET" || req.method == "HEAD") || !CACHE_DIR) { + return next(undefined, req, res); + } + + var old_req = {}; + var old_res = {}; + + var supportsGzip = + req.header('Accept-Encoding', '').indexOf('gzip') != -1; + + var path = require('url').parse(req.url).path; + var cacheKey = (new Buffer(path)).toString('base64').replace(/[\/\+=]/g, ''); + + fs.stat(CACHE_DIR + 'minified_' + cacheKey, function (error, stats) { + var modifiedSince = (req.headers['if-modified-since'] + && new Date(req.headers['if-modified-since'])); + var lastModifiedCache = !error && stats.mtime; + if (lastModifiedCache && responseCache[cacheKey]) { + req.headers['if-modified-since'] = lastModifiedCache.toUTCString(); + } else { + delete req.headers['if-modified-since']; + } + + // Always issue get to downstream. + old_req.method = req.method; + req.method = 'GET'; + + var expirationDate = new Date(((responseCache[cacheKey] || {}).headers || {})['expires']); + if (expirationDate > new Date()) { + // Our cached version is still valid. + return respond(); + } + + var _headers = {}; + old_res.setHeader = res.setHeader; + res.setHeader = function (key, value) { + // Don't set cookies, see issue #707 + if (key.toLowerCase() === 'set-cookie') return; + + _headers[key.toLowerCase()] = value; + old_res.setHeader.call(res, key, value); + }; + + old_res.writeHead = res.writeHead; + res.writeHead = function (status, headers) { + var lastModified = (res.getHeader('last-modified') + && new Date(res.getHeader('last-modified'))); + + res.writeHead = old_res.writeHead; + if (status == 200) { + // Update cache + var buffer = ''; + + Object.keys(headers || {}).forEach(function (key) { + res.setHeader(key, headers[key]); + }); + headers = _headers; + + old_res.write = res.write; + old_res.end = res.end; + res.write = function(data, encoding) { + buffer += data.toString(encoding); + }; + res.end = function(data, encoding) { + async.parallel([ + function (callback) { + var path = CACHE_DIR + 'minified_' + cacheKey; + fs.writeFile(path, buffer, function (error, stats) { + callback(); + }); + } + , function (callback) { + var path = CACHE_DIR + 'minified_' + cacheKey + '.gz'; + zlib.gzip(buffer, function(error, content) { + if (error) { + callback(); + } else { + fs.writeFile(path, content, function (error, stats) { + callback(); + }); + } + }); + } + ], function () { + responseCache[cacheKey] = {statusCode: status, headers: headers}; + respond(); + }); + }; + } else if (status == 304) { + // Nothing new changed from the cached version. + old_res.write = res.write; + old_res.end = res.end; + res.write = function(data, encoding) {}; + res.end = function(data, encoding) { respond() }; + } else { + res.writeHead(status, headers); + } + }; + + next(undefined, req, res); + + // This handles read/write synchronization as well as its predecessor, + // which is to say, not at all. + // TODO: Implement locking on write or ditch caching of gzip and use + // existing middlewares. + function respond() { + req.method = old_req.method || req.method; + res.write = old_res.write || res.write; + res.end = old_res.end || res.end; + + var headers = responseCache[cacheKey].headers; + var statusCode = responseCache[cacheKey].statusCode; + + var pathStr = CACHE_DIR + 'minified_' + cacheKey; + if (supportsGzip && (headers['content-type'] || '').match(/^text\//)) { + pathStr = pathStr + '.gz'; + headers['content-encoding'] = 'gzip'; + } + + var lastModified = (headers['last-modified'] + && new Date(headers['last-modified'])); + + if (statusCode == 200 && lastModified <= modifiedSince) { + res.writeHead(304, headers); + res.end(); + } else if (req.method == 'GET') { + var readStream = fs.createReadStream(pathStr); + res.writeHead(statusCode, headers); + util.pump(readStream, res); + } else { + res.writeHead(statusCode, headers); + res.end(); + } + } + }); + } + + this.handle = handle; +}(); + +module.exports = CachingMiddleware; diff --git a/src/node/utils/customError.js b/src/node/utils/customError.js new file mode 100644 index 000000000..5ca7a7a41 --- /dev/null +++ b/src/node/utils/customError.js @@ -0,0 +1,17 @@ +/* + This helper modules allows us to create different type of errors we can throw +*/ +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; + +module.exports = customError; diff --git a/src/node/utils/randomstring.js b/src/node/utils/randomstring.js new file mode 100644 index 000000000..4c1bba244 --- /dev/null +++ b/src/node/utils/randomstring.js @@ -0,0 +1,16 @@ +/** + * Generates a random String with the given length. Is needed to generate the Author, Group, readonly, session Ids + */ +var randomString = function randomString(len) +{ + var chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + var randomstring = ''; + for (var i = 0; i < len; i++) + { + var rnum = Math.floor(Math.random() * chars.length); + randomstring += chars.substring(rnum, rnum + 1); + } + return randomstring; +}; + +module.exports = randomString; diff --git a/src/node/utils/tar.json b/src/node/utils/tar.json new file mode 100644 index 000000000..15ce68e27 --- /dev/null +++ b/src/node/utils/tar.json @@ -0,0 +1,69 @@ +{ + "pad.js": [ + "pad.js" + , "pad_utils.js" + , "pad_cookie.js" + , "pad_editor.js" + , "pad_editbar.js" + , "pad_docbar.js" + , "pad_modals.js" + , "ace.js" + , "collab_client.js" + , "pad_userlist.js" + , "pad_impexp.js" + , "pad_savedrevs.js" + , "pad_connectionstatus.js" + , "chat.js" + , "excanvas.js" + , "farbtastic.js" + ] +, "timeslider.js": [ + "timeslider.js" + , "colorutils.js" + , "draggable.js" + , "pad_utils.js" + , "pad_cookie.js" + , "pad_editor.js" + , "pad_editbar.js" + , "pad_docbar.js" + , "pad_modals.js" + , "pad_savedrevs.js" + , "pad_impexp.js" + , "AttributePool.js" + , "Changeset.js" + , "domline.js" + , "linestylefilter.js" + , "cssmanager.js" + , "broadcast.js" + , "broadcast_slider.js" + , "broadcast_revisions.js" + ] +, "ace2_inner.js": [ + "ace2_inner.js" + , "AttributePool.js" + , "Changeset.js" + , "ChangesetUtils.js" + , "skiplist.js" + , "virtual_lines.js" + , "cssmanager.js" + , "colorutils.js" + , "undomodule.js" + , "contentcollector.js" + , "changesettracker.js" + , "linestylefilter.js" + , "domline.js" + , "AttributeManager.js" + ] +, "ace2_common.js": [ + "ace2_common.js" + , "jquery.js" + , "rjquery.js" + , "underscore.js" + , "security.js" + , "json2.js" + , "pluginfw/plugins.js" + , "pluginfw/hooks.js" + , "pluginfw/async.js" + , "pluginfw/parent_require.js" + ] +} diff --git a/src/package.json b/src/package.json new file mode 100644 index 000000000..52a5d8404 --- /dev/null +++ b/src/package.json @@ -0,0 +1,43 @@ +{ + "name" : "ep_etherpad-lite", + "description" : "A Etherpad based on node.js", + "homepage" : "https://github.com/Pita/etherpad-lite", + "keywords" : ["etherpad", "realtime", "collaborative", "editor"], + "author" : "Peter 'Pita' Martischka - Primary Technology Ltd", + "contributors" : [ + { "name": "John McLear", + "name": "Hans Pinckaers", + "name": "Robin Buse" } + ], + "dependencies" : { + "yajsml" : "1.1.4", + "request" : "2.9.100", + "require-kernel" : "1.0.5", + "resolve" : "0.2.x", + "socket.io" : "0.9.x", + "ueberDB" : "0.1.7", + "async" : "0.1.x", + "express" : "2.5.x", + "connect" : "1.x", + "clean-css" : "0.3.2", + "uglify-js" : "1.2.5", + "formidable" : "1.0.9", + "log4js" : "0.4.1", + "jsdom-nocontextifiy" : "0.2.10", + "async-stacktrace" : "0.0.2", + "npm" : "1.1.24", + "ejs" : "0.6.1", + "graceful-fs" : "1.1.5", + "slide" : "1.1.3", + "semver" : "1.0.13", + "underscore" : "1.3.1" + }, + "bin": { "etherpad-lite": "./node/server.js" }, + "devDependencies": { + "jshint" : "*" + }, + "engines" : { "node" : ">=0.6.0", + "npm" : ">=1.0" + }, + "version" : "1.0.0" +} diff --git a/src/static/css/admin.css b/src/static/css/admin.css new file mode 100644 index 000000000..5eb008fa0 --- /dev/null +++ b/src/static/css/admin.css @@ -0,0 +1,122 @@ +body { + margin: 0; + color: #333; + font: 14px helvetica, sans-serif; + background: #ddd; + background: -webkit-radial-gradient(circle,#aaa,#eee 60%) center fixed; + background: -moz-radial-gradient(circle,#aaa,#eee 60%) center fixed; + background: -ms-radial-gradient(circle,#aaa,#eee 60%) center fixed; + background: -o-radial-gradient(circle,#aaa,#eee 60%) center fixed; + border-top: 8px solid rgba(51,51,51,.8); +} +#wrapper { + margin-top: 160px; + padding: 15px; + background: #fff; + opacity: .9; + box-shadow: 0px 1px 8px rgba(0,0,0,0.3); + max-width: 700px; + margin: auto; + border-radius: 0 0 7px 7px; +} +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; +} +input[type="button"].do-install, input[type="button"].do-uninstall { + float: right; + width: 100px; +} +input[type="button"]#do-search { + display: block; +} +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; +} +table { + border: 1px solid #ddd; + border-radius: 3px; + border-spacing: 0; + width: 100%; + margin: 20px 0; +} +table thead tr { + background: #eee; +} +td, th { + padding: 5px; +} +.template { + display: none; +} +.dialog { + display: none; + position: absolute; + left: 50%; + top: 50%; + width: 700px; + height: 500px; + margin-left: -350px; + margin-top: -250px; + border: 3px solid #999; + background: #eee; +} +.dialog .title { + margin: 0; + padding: 2px; + border-bottom: 3px solid #999; + font-size: 24px; + line-height: 24px; + height: 24px; + overflow: hidden; +} +.dialog .title .close { + float: right; + padding: 1px 10px; +} +.dialog .history { + background: #222; + color: #eee; + position: absolute; + top: 41px; + bottom: 10px; + left: 10px; + right: 10px; + padding: 2px; + overflow: auto; +} \ No newline at end of file diff --git a/src/static/css/iframe_editor.css b/src/static/css/iframe_editor.css new file mode 100644 index 000000000..4fcd955f3 --- /dev/null +++ b/src/static/css/iframe_editor.css @@ -0,0 +1,179 @@ + +/* These CSS rules are included in both the outer and inner ACE iframe. + Also see inner.css, included only in the inner one. +*/ +html { cursor: text; } /* in Safari, produces text cursor for whole doc (inc. below body) */ +span { cursor: auto; } + +::selection { + background: #acf; +} +::-moz-selection { + background: #acf; +} + +a { cursor: pointer !important; } + +ul, ol, li { + padding: 0; + margin: 0; +} +ul { margin-left: 1.5em; } +ul ul { margin-left: 0 !important; } +ul.list-bullet1 { margin-left: 1.5em; } +ul.list-bullet2 { margin-left: 3em; } +ul.list-bullet3 { margin-left: 4.5em; } +ul.list-bullet4 { margin-left: 6em; } +ul.list-bullet5 { margin-left: 7.5em; } +ul.list-bullet6 { margin-left: 9em; } +ul.list-bullet7 { margin-left: 10.5em; } +ul.list-bullet8 { margin-left: 12em; } + +ul { list-style-type: disc; } +ul.list-bullet1 { list-style-type: disc; } +ul.list-bullet2 { list-style-type: circle; } +ul.list-bullet3 { list-style-type: square; } +ul.list-bullet4 { list-style-type: disc; } +ul.list-bullet5 { list-style-type: circle; } +ul.list-bullet6 { list-style-type: square; } +ul.list-bullet7 { list-style-type: disc; } +ul.list-bullet8 { list-style-type: circle; } + +ol.list-number1 { margin-left: 1.5em; } +ol.list-number2 { margin-left: 3em; } +ol.list-number3 { margin-left: 4.5em; } +ol.list-number4 { margin-left: 6em; } +ol.list-number5 { margin-left: 7.5em; } +ol.list-number6 { margin-left: 9em; } +ol.list-number7 { margin-left: 10.5em; } +ol.list-number8 { margin-left: 12em; } + +ol { list-style-type: decimal; } +ol.list-number1 { list-style-type: decimal; } +ol.list-number2 { list-style-type: lower-latin; } +ol.list-number3 { list-style-type: lower-roman; } +ol.list-number4 { list-style-type: decimal; } +ol.list-number5 { list-style-type: lower-latin; } +ol.list-number6 { list-style-type: lower-roman; } +ol.list-number7 { list-style-type: decimal; } +ol.list-number8 { list-style-type: lower-latin; } + +ul.list-indent1 { margin-left: 1.5em; } +ul.list-indent2 { margin-left: 3em; } +ul.list-indent3 { margin-left: 4.5em; } +ul.list-indent4 { margin-left: 6em; } +ul.list-indent5 { margin-left: 7.5em; } +ul.list-indent6 { margin-left: 9em; } +ul.list-indent7 { margin-left: 10.5em; } +ul.list-indent8 { margin-left: 12em; } + +ul.list-indent1 { list-style-type: none; } +ul.list-indent2 { list-style-type: none; } +ul.list-indent3 { list-style-type: none; } +ul.list-indent4 { list-style-type: none; } +ul.list-indent5 { list-style-type: none; } +ul.list-indent6 { list-style-type: none; } +ul.list-indent7 { list-style-type: none; } +ul.list-indent8 { list-style-type: none; } + +body { + margin: 0; + white-space: nowrap; +} + +#outerdocbody { + background-color: #fff; +} +body.grayedout { background-color: #eee !important } + +#innerdocbody { + font-size: 12px; /* overridden by body.style */ + font-family: monospace; /* overridden by body.style */ + line-height: 16px; /* overridden by body.style */ +} + +body.doesWrap { + white-space: normal; +} + +#innerdocbody { + padding-top: 1px; /* important for some reason? */ + padding-right: 10px; + padding-bottom: 8px; + padding-left: 1px /* prevents characters from looking chopped off in FF3 -- Removed because it added too much whitespace */; + overflow: hidden; + /* blank 1x1 gif, so that IE8 doesn't consider the body transparent */ + background-image: url(data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==); +} + +#sidediv { + font-size: 11px; + font-family: monospace; + line-height: 16px; /* overridden by sideDiv.style */ + padding-top: 8px; /* EDIT_BODY_PADDING_TOP */ + padding-right: 3px; /* LINE_NUMBER_PADDING_RIGHT - 1 */ + position: absolute; + width: 20px; /* MIN_LINEDIV_WIDTH */ + top: 0; + left: 0; + cursor: default; + color: white; +} + +#sidedivinner { + text-align: right; +} + +.sidedivdelayed { /* class set after sizes are set */ + background-color: #eee; + color: #888 !important; + border-right: 1px solid #999; +} +.sidedivhidden { + display: none; +} + +#outerdocbody iframe { + display: block; /* codemirror says it suppresses bugs */ + position: relative; + left: 32px; /* MIN_LINEDIV_WIDTH + LINE_NUMBER_PADDING_RIGHT + EDIT_BODY_PADDING_LEFT */ + top: 7px; /* EDIT_BODY_PADDING_TOP - 1*/ + border: 0; + width: 1px; /* changed programmatically */ + height: 1px; /* changed programmatically */ +} + +#outerdocbody .hotrect { + border: 1px solid #999; + position: absolute; +} + +/* cause "body" area (e.g. where clicks are heard) to grow horizontally with text */ +body.mozilla, body.safari { + display: table-cell; +} + +body.doesWrap { + display: block !important; +} + +.safari div { + /* prevents the caret from disappearing on the longest line of the doc */ + padding-right: 1px; +} + +p { + margin: 0; +} + +#linemetricsdiv { + position: absolute; + left: -1000px; + top: -1000px; + color: white; + z-index: -1; + font-size: 12px; /* overridden by lineMetricsDiv.style */ + font-family: monospace; /* overridden by lineMetricsDiv.style */ +} + +#overlaysdiv { position: absolute; left: -1000px; top: -1000px; } diff --git a/src/static/css/pad.css b/src/static/css/pad.css new file mode 100644 index 000000000..df9dde143 --- /dev/null +++ b/src/static/css/pad.css @@ -0,0 +1,854 @@ +*, +html, +body, +p { + margin: 0; + padding: 0; +} +.clear { + clear: both +} +html { + font-size: 62.5%; + width: 100%; +} +body, +textarea { + font-family: Helvetica, Arial, sans-serif +} +iframe { + position: absolute +} +.readonly .acl-write { + display: none; +} +#users { + background: #f7f7f7; + background: -webkit-linear-gradient( #F7F7F7,#EEE); + background: -moz-linear-gradient( #F7F7F7,#EEE); + background: -ms-linear-gradient( #F7F7F7,#EEE); + background: -o-linear-gradient( #F7F7F7,#EEE); + background: linear-gradient( #F7F7F7,#EEE); + width: 160px; + color: #fff; + padding: 5px; + border-radius: 0 0 6px 6px; + border: 1px solid #ccc; +} +#otherusers { + max-height: 400px; + overflow: auto; +} +a img { + border: 0 +} +/* menu */ +.toolbar { + background: #f7f7f7; + background: -webkit-linear-gradient(#f7f7f7, #f1f1f1 80%); + background: -moz-linear-gradient(#f7f7f7, #f1f1f1 80%); + background: -o-linear-gradient(#f7f7f7, #f1f1f1 80%); + background: -ms-linear-gradient(#f7f7f7, #f1f1f1 80%); + background: linear-gradient(#f7f7f7, #f1f1f1 80%); + border-bottom: 1px solid #ccc; + overflow: hidden; + padding-top: 4px; + width: 100%; + white-space: nowrap; + height: 32px; +} +.toolbar ul { + position: relative; + list-style: none; + padding-right: 3px; + padding-left: 1px; + z-index: 2; + overflow: hidden; + float: left +} +.toolbar ul.menu_right { + float: right +} +.toolbar ul li { + float: left; + margin-left: 2px; +} +.toolbar ul li.separator { + border: inherit; + background: inherit; + visibility: hidden; + width: 0px; + padding: 5px; +} +.toolbar ul li a:hover { + text-decoration: none; +} +.toolbar ul li a:hover { + background: #fff; + background: -webkit-linear-gradient(#f4f4f4, #e4e4e4); + background: -moz-linear-gradient(#f4f4f4, #e4e4e4); + background: -o-linear-gradient(#f4f4f4, #e4e4e4); + background: -ms-linear-gradient(#f4f4f4, #e4e4e4); + background: linear-gradient(#f4f4f4, #e4e4e4); +} +.toolbar ul li a:active { + background: #eee; + background: -webkit-linear-gradient(#ddd, #fff); + background: -moz-linear-gradient(#ddd, #fff); + background: -o-linear-gradient(#ddd, #fff); + background: -ms-linear-gradient(#ddd, #fff); + background: linear-gradient(#ddd, #fff); + -webkit-box-shadow: 0 0 8px rgba(0,0,0,.1) inset; + -moz-box-shadow: 0 0 8px rgba(0,0,0,.1) inset; + box-shadow: 0 0 8px rgba(0,0,0,.1) inset; +} +.toolbar ul li a { + background: #fff; + background: -webkit-linear-gradient(#fff, #f0f0f0); + background: -moz-linear-gradient(#fff, #f0f0f0); + background: -o-linear-gradient(#fff, #f0f0f0); + background: -ms-linear-gradient(#fff, #f0f0f0); + background: linear-gradient(#fff, #f0f0f0); + border: 1px solid #ccc; + border-radius: 3px; + color: #ccc; + cursor: pointer; + display: inline-block; + min-height: 18px; + overflow: hidden; + padding: 4px 5px; + text-align: center; + text-decoration: none; + min-width: 18px; +} +.toolbar ul li a .buttonicon { + position: relative; + top: 1px; +} +.toolbar ul li a.grouped-left { + border-radius: 3px 0 0 3px; +} +.toolbar ul li a.grouped-middle { + border-radius: 0; + margin-left: -2px; + border-left: 0; +} +.toolbar ul li a.grouped-right { + border-radius: 0 3px 3px 0; + margin-left: -2px; + border-left: 0; +} +.toolbar ul li a.selected { + background: #eee !important; + background: -webkit-linear-gradient(#EEE, #F0F0F0) !important; + background: -moz-linear-gradient(#EEE, #F0F0F0) !important; + background: -o-linear-gradient(#EEE, #F0F0F0) !important; + background: -ms-linear-gradient(#EEE, #F0F0F0) !important; + background: linear-gradient(#EEE, #F0F0F0) !important; +} +.toolbar ul li select { + background: #fff; + padding: 4px; + line-height: 22px; /* fix for safari (win/mac) */ + height: 28px; /* fix for chrome (mac) */ + border-radius: 3px; + border: 1px solid #ccc; + outline: none; +} +#usericon a { + min-width: 30px; + text-align: left; +} +#usericon a #online_count { + color: #777; + font-size: 10px; + position: relative; + top: 2px; +} +#editorcontainer { + position: absolute; + width: 100%; + top: 37px; /* + 1px border */ + left: 0px; + bottom: 0px; + z-index: 1; +} +#editorcontainer iframe { + height: 100%; + width: 100%; + padding: 0; + margin: 0; +} +#editorloadingbox { + padding-top: 100px; + padding-bottom: 100px; + font-size: 2.5em; + color: #aaa; + text-align: center; + position: absolute; + width: 100%; + height: 30px; + z-index: 100; +} +#editorcontainerbox { + position: absolute; + bottom: 0; + top: 0; + width: 100%; +} +#padpage { + position: absolute; + top: 0px; + bottom: 0px; + width: 100%; +} +#padmain { + margin-top: 0px; + position: absolute; + top: 63px !important; + left: 0px; + right: 0px; + bottom: 0px; + zoom: 1; +} +#padeditor { + bottom: 0px; + left: 0; + position: absolute; + right: 0px; + top: 0; + zoom: 1; +} +#myswatchbox { + position: absolute; + left: 5px; + top: 5px; + width: 24px; + height: 24px; + border: 1px solid #000; + background: transparent; + cursor: pointer; +} +#myswatch { + width: 100%; + height: 100%; + background: transparent; /*...initially*/ +} +#mycolorpicker { + width: 232px; + height: 265px; + position: absolute; + left: -250px; + top: 0px; + z-index: 101; + display: none; + border-radius: 0 0 6px 6px; + background: #f7f7f7; + border: 1px solid #ccc; + border-top: 0; + padding-left: 10px; + padding-top: 10px; +} +#mycolorpickersave { + left: 10px; + font-weight: bold; +} +#mycolorpickercancel { + left: 85px +} +#mycolorpickersave, +#mycolorpickercancel { + background: #fff; + background: -webkit-linear-gradient(#fff, #ccc); + background: -moz-linear-gradient(#fff, #ccc); + background: -o-linear-gradient(#fff, #ccc); + background: -ms-linear-gradient(#fff, #ccc); + background: linear-gradient(#fff, #ccc); + border: 1px solid #ccc; + -webkit-border-radius: 4px; + -moz-border-radius: 4px; + border-radius: 4px; + font-size: 12px; + cursor: pointer; + color: #000; + overflow: hidden; + padding: 4px; + top: 240px; + text-align: center; + position: absolute; + width: 60px; +} +#mycolorpickerpreview { + position: absolute; + left: 207px; + top: 240px; + width: 16px; + height: 16px; + padding: 4px; + overflow: hidden; + color: #fff; + -webkit-border-radius: 5px; + -moz-border-radius: 5px; + border-radius: 5px; +} +#myusernameform { + margin-left: 35px +} +#myusernameedit { + font-size: 1.3em; + color: #fff; + padding: 3px; + height: 18px; + margin: 0; + border: 0; + width: 117px; + background: transparent; +} +#myusernameform input.editable { + border: 1px solid #444 +} +#myuser .myusernameedithoverable:hover { + background: white; + color: black; +} +#mystatusform { + margin-left: 35px; + margin-top: 5px; +} +#mystatusedit { + font-size: 1.2em; + color: #777; + font-style: italic; + display: none; + padding: 2px; + height: 14px; + margin: 0; + border: 1px solid #bbb; + width: 199px; + background: transparent; +} +#myusernameform .editactive, +#myusernameform .editempty { + background: white; + border-left: 1px solid #c3c3c3; + border-top: 1px solid #c3c3c3; + border-right: 1px solid #e6e6e6; + border-bottom: 1px solid #e6e6e6; + color: #000; +} +#myusernameform .editempty { + color: #333 +} +#myswatchbox, #myusernameedit, #otheruserstable .swatch { + border: 1px solid #ccc !important; + color: #333; +} +table#otheruserstable { + display: none +} +#nootherusers { + padding: 10px; + font-size: 1.2em; + color: #eee; + font-weight: bold; +} +#nootherusers a { + color: #3C88FF +} +#otheruserstable td { + height: 26px; + vertical-align: middle; + padding: 0 2px; + color: #333; +} +#otheruserstable .swatch { + border: 1px solid #000; + width: 13px; + height: 13px; + overflow: hidden; + margin: 0 4px; +} +.usertdswatch { + width: 1% +} +.usertdname { + font-size: 1.3em; + color: #444; +} +.usertdstatus { + font-size: 1.1em; + font-style: italic; + color: #999; +} +.usertdactivity { + font-size: 1.1em; + color: #777; +} +.usertdname input { + border: 1px solid #bbb; + width: 80px; + padding: 2px; +} +.usertdname input.editactive, +.usertdname input.editempty { + background: white; + border-left: 1px solid #c3c3c3; + border-top: 1px solid #c3c3c3; + border-right: 1px solid #e6e6e6; + border-bottom: 1px solid #e6e6e6; +} +.usertdname input.editempty { + color: #888; + font-style: italic; +} + +#connectivity { + z-index: 600 !important; +} + +#connectivity * { + display: none; +} + +#connectivity .visible, +#connectivity .visible * { + display: block; +} + +#reconnect_form button { + font-size: 12pt; + padding: 5px; +} + +.toolbar #overlay { + z-index: 500; + display: none; + background-repeat: repeat-both; + width: 100%; + position: absolute; + height: inherit; + left: 0; + top: 0; +} +* html #overlay { + /* for IE 6+ */ + -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=100)"; + filter: alpha(opacity=100); + opacity: 1; /* in case this is looked at */ + background-image: none; + background-repeat: no-repeat; /* scale the image */ +} + +#chatbox { + position: absolute; + bottom: 0px; + right: 20px; + width: 180px; + height: 200px; + z-index: 400; + background-color: #f7f7f7; + border-left: 1px solid #999; + border-right: 1px solid #999; + border-top: 1px solid #999; + padding: 3px; + padding-bottom: 10px; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + display: none; +} +#chattext { + background-color: white; + border: 1px solid white; + -ms-overflow-y: scroll; + overflow-y: scroll; + font-size: 12px; + position: absolute; + right: 0px; + left: 0px; + top: 25px; + bottom: 25px; + z-index: 1002; +} +#chattext p { + padding: 3px; + -ms-overflow-x: hidden; + overflow-x: hidden; +} +#chatinputbox { + padding: 3px 2px; + position: absolute; + bottom: 0px; + right: 0px; + left: 3px; +} +#chatlabel { + font-size: 13px; + font-weight: bold; + color: #555; + text-decoration: none; + margin-right: 3px; + vertical-align: middle; +} +#chatinput { + border: 1px solid #BBBBBB; + width: 100%; + float: right; +} +#chaticon { + z-index: 400; + position: fixed; + bottom: 0px; + right: 20px; + padding: 5px; + border-left: 1px solid #999; + border-right: 1px solid #999; + border-top: 1px solid #999; + border-top-left-radius: 5px; + border-top-right-radius: 5px; + background-color: #fff; + cursor: pointer; +} +#chaticon a { + text-decoration: none +} +#chatcounter { + color: #777; + font-size: 10px; + vertical-align: middle; +} +#titlebar { + line-height: 16px; + font-weight: bold; + color: #555; + position: relative; + bottom: 2px; +} +#titlelabel { + font-size: 13px; + margin: 4px 0 0 4px; + position: absolute; +} +#titlecross { + font-size: 25px; + float: right; + text-align: right; + text-decoration: none; + cursor: pointer; + color: #555; +} +.time { + float: right; + color: #333; + font-style: italic; + font-size: 10px; + margin-left: 3px; + margin-right: 3px; + margin-top: 2px; +} +.exporttype { + margin-top: 4px; + background-repeat: no-repeat; + padding-left: 25px; + background-image: url("../../static/img/etherpad_lite_icons.png"); + color: #333; + text-decoration: none; +} +#exporthtml { + background-position: 0px -299px +} +#exportplain { + background-position: 0px -395px +} +#exportword { + background-position: 0px -275px +} +#exportpdf { + background-position: 0px -371px +} +#exportopen { + background-position: 0px -347px +} +#exportdokuwiki { + background-position: 0px -459px +} +#importstatusball { + display: none +} +#importarrow { + display: none +} +#importmessagesuccess { + display: none +} +#importsubmitinput { + height: 25px; + width: 85px; + margin-top: 12px; +} +#importstatusball { + height: 50px +} +#chatthrob { + display: none; + position: absolute; + bottom: 40px; + font-size: 14px; + width: 150px; + height: 40px; + right: 20px; + z-index: 200; + background-color: #000; + color: white; + background-color: rgb(0,0,0); + background-color: rgba(0,0,0,0.7); + padding: 10px; + -webkit-border-radius: 6px; + -moz-border-radius: 6px; + border-radius: 6px; + -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=80)"; + filter: alpha(opacity=80); + opacity: .8; +} +.buttonicon { + width: 16px; + height: 16px; + background-image: url('../../static/img/etherpad_lite_icons.png'); + background-repeat: no-repeat; + display: inline-block; + vertical-align: middle; +} +.buttonicon-bold { + background-position: 0px -116px +} +.buttonicon-italic { + background-position: 0px 0px +} +.buttonicon-underline { + background-position: 0px -236px +} +.buttonicon-strikethrough { + background-position: 0px -200px +} +.buttonicon-insertorderedlist { + background-position: 0px -477px +} +.buttonicon-insertunorderedlist { + background-position: 0px -34px +} +.buttonicon-indent { + background-position: 0px -52px +} +.buttonicon-outdent { + background-position: 0px -134px +} +.buttonicon-undo { + background-position: 0px -255px +} +.buttonicon-redo { + background-position: 0px -166px +} +.buttonicon-clearauthorship { + background-position: 0px -86px +} +.buttonicon-settings { + background-position: 0px -436px +} +.buttonicon-import_export { + background-position: 0px -68px +} +.buttonicon-embed { + background-position: 0px -18px +} +.buttonicon-history { + background-position: 0px -218px +} +.buttonicon-chat { + background-position: 0px -102px; +} +.buttonicon-showusers { + background-position: 0px -183px; +} +.buttonicon-savedRevision { + background-position: 0px -493px +} +#focusprotector { + z-index: 100; + position: absolute; + bottom: 0px; + top: 0px; + left: 0px; + right: 0px; + background-color: white; + -ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=1)"; + filter: alpha(opacity=1); + opacity: 0.01; + display: none; +} +#online_count { + color: #888; +} +.rtl { + direction: RTL +} +#chattext p { + word-wrap: break-word +} +/* fix for misaligned checkboxes */ +input[type=checkbox] { + vertical-align: -1px +} +.right { + float: right +} +.popup { + font-size: 14px; + width: 450px; + padding: 10px; + border-radius: 0 0 6px 6px; + border: 1px solid #ccc; + background: #f7f7f7; + background: -webkit-linear-gradient(#F7F7F7, #EEE); + background: -moz-linear-gradient(#F7F7F7, #EEE); + background: -ms-linear-gradient(#F7F7F7, #EEE); + background: -o-linear-gradient(#F7F7F7, #EEE); + background: linear-gradient(#F7F7F7, #EEE); + -webkit-box-shadow: 0 0 8px #888; + -moz-box-shadow: 0 0 8px #888; + box-shadow: 0 2px 4px #ddd; + color: #222; +} +.popup input[type=text] { + width: 100%; + padding: 5px; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + display: block; + margin-top: 10px; +} +.popup input[type=text], #users input[type=text] { + outline: none; +} +.popup button { + padding: 5px; + font-size: 14px; +} +.popup a { + text-decoration: none +} +.popup h1 { + color: #555; + font-size: 18px +} +.popup h2 { + color: #777; + font-size: 15px +} +.popup p { + margin: 5px 0 +} +.column { + float: left; + width: 50%; +} +#settings, +#importexport, +#embed, +#connectivity, +#users { + position: absolute; + top: 36px; + right: 20px; + display: none; + z-index: 500; +} +.stickyChat { + background-color: #f1f1f1 !important; + right: 0px !important; + top: 37px; + -webkit-border-radius: 0px !important; + -moz-border-radius: 0px !important; + border-radius: 0px !important; + height: auto !important; + border: none !important; + border-left: 1px solid #ccc !important; + width: 185px !important; +} +@media screen and (max-width: 600px) { + .toolbar ul li.separator { + display: none; + } + .toolbar ul li a { + padding: 4px 1px + } +} +@media only screen and (min-device-width: 320px) and (max-device-width: 720px) { + #users { + top: 36px; + bottom: 40px; + border-radius: none; + } + #mycolorpicker { + left: -73px; + /* #mycolorpicker: width -#users: width */; + } + #editorcontainer { + margin-bottom: 33px + } + .toolbar ul.menu_right { + background: #f7f7f7; + background: -webkit-linear-gradient(#f7f7f7, #f1f1f1 80%); + background: -moz-linear-gradient(#f7f7f7, #f1f1f1 80%); + background: -o-linear-gradient(#f7f7f7, #f1f1f1 80%); + background: -ms-linear-gradient(#f7f7f7, #f1f1f1 80%); + background: linear-gradient(#f7f7f7, #f1f1f1 80%); + width: 100%; + overflow: hidden; + height: 32px; + position: fixed; + bottom: 0; + border-top: 1px solid #ccc; + } + .toolbar ul.menu_right > li:last-child { + float: right; + } + .toolbar ul.menu_right > li a { + border-radius: 0; + border: none; + background: none; + margin: 0; + padding: 8px; + } + .toolbar ul li a.selected { + background: none !important + } + #chaticon, #timesliderlink { + display: none !important + } + .popup { + -webkit-border-radius: 0; + -moz-border-radius: 0; + border-radius: 0; + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + -ms-box-sizing: border-box; + box-sizing: border-box; + width: 100%; + } + #settings, + #importexport, + #connectivity, + #embed { + left: 0; + top: 0; + bottom: 33px; + right: 0; + } + .toolbar ul li .separator { + display: none + } + #online_count { + line-height: 24px + } +} diff --git a/src/static/css/timeslider.css b/src/static/css/timeslider.css new file mode 100644 index 000000000..5f24a4cf3 --- /dev/null +++ b/src/static/css/timeslider.css @@ -0,0 +1,295 @@ +#editorcontainerbox { + overflow: auto; + top: 40px; + position: static; +} +#padcontent { + font-size: 12px; + padding: 10px; +} +#timeslider-wrapper { + left: 0; + position: relative; + right: 0; + top: 0; +} +#timeslider-left { + background-image: url(../../static/img/timeslider_left.png); + height: 63px; + left: 0; + position: absolute; + width: 134px; +} +#timeslider-right { + background-image: url(../../static/img/timeslider_right.png); + height: 63px; + position: absolute; + right: 0; + top: 0; + width: 155px; +} +#timeslider { + background-image: url(../../static/img/timeslider_background.png); + height: 63px; + margin: 0 9px; +} +#timeslider #timeslider-slider { + height: 61px; + left: 0; + position: absolute; + top: 1px; + width: 100%; +} +#ui-slider-handle { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + background-image: url(../../static/img/crushed_current_location.png); + cursor: pointer; + height: 61px; + left: 0; + position: absolute; + top: 0; + width: 13px; +} +#ui-slider-bar { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + cursor: pointer; + height: 35px; + margin-left: 5px; + margin-right: 148px; + position: relative; + top: 20px; +} +#playpause_button, +#playpause_button_icon { + height: 47px; + position: absolute; + width: 47px; +} +#playpause_button { + background-image: url(../../static/img/crushed_button_undepressed.png); + right: 77px; + top: 9px; +} +#playpause_button_icon { + background-image: url(../../static/img/play.png); + left: 0; + top: 0; +} +.pause#playpause_button_icon { + background-image: url(../../static/img/pause.png) +} +#leftstar, +#rightstar, +#leftstep, +#rightstep { + background: url(../../static/img/stepper_buttons.png) 0 0 no-repeat; + height: 21px; + overflow: hidden; + position: absolute; +} +#leftstar { + background-position: 0 -44px; + right: 34px; + top: 8px; + width: 30px; +} +#rightstar { + background-position: -29px -44px; + right: 5px; + top: 8px; + width: 29px; +} +#leftstep { + background-position: 0 -22px; + right: 34px; + top: 20px; + width: 30px; +} +#rightstep { + background-position: -29px -22px; + right: 5px; + top: 20px; + width: 29px; +} +#timeslider .star { + background-image: url(../../static/img/star.png); + cursor: pointer; + height: 16px; + position: absolute; + top: 40px; + width: 15px; +} +#timeslider #timer { + color: #fff; + font-family: Arial, sans-serif; + font-size: 11px; + left: 7px; + position: absolute; + text-align: center; + top: 9px; + width: 122px; +} +.topbarcenter, +#docbar { + display: none +} +#padmain { + top: 0px !important +} +#editbarright { + float: right +} +#returnbutton { + color: #222; + font-size: 16px; + line-height: 29px; + margin-top: 0; + padding-right: 6px; +} +#settings, +#importexport, +#embed, +#connectivity, +#users { + top: 62px; +} +#importexport .popup { + width: 185px +} +#importexport { + top: 118px; + width: 185px; +} +.timeslider-bar { + background: #f7f7f7; + background: -webkit-linear-gradient(#f7f7f7, #f1f1f1 80%); + background: -moz-linear-gradient(#f7f7f7, #f1f1f1 80%); + background: -o-linear-gradient(#f7f7f7, #f1f1f1 80%); + background: -ms-linear-gradient(#f7f7f7, #f1f1f1 80%); + background: linear-gradient(#f7f7f7, #f1f1f1 80%); + overflow: hidden; + padding-top: 3px; + width: 100%; +} +.timeslider-bar #editbar { + border-bottom: none; + float: right; + width: 170px; + width: initial; +} +.timeslider-bar h1 { + margin: 5px +} +.timeslider-bar p { + margin: 5px +} +#timeslider-top { + width: 100%; + position: fixed; + z-index: 1; +} +#authorsList .author { + padding-left: 0.4em; + padding-right: 0.4em; +} +#authorsList .author-anonymous { + padding-left: 0.6em; + padding-right: 0.6em; +} +#padeditor { + position: static +} +/* lists */ +.list-bullet2, +.list-indent2, +.list-number2 { + margin-left: 3em +} +.list-bullet3, +.list-indent3, +.list-number3 { + margin-left: 4.5em +} +.list-bullet4, +.list-indent4, +.list-number4 { + margin-left: 6em +} +.list-bullet5, +.list-indent5, +.list-number5 { + margin-left: 7.5em +} +.list-bullet6, +.list-indent6, +.list-number6 { + margin-left: 9em +} +.list-bullet7, +.list-indent7, +.list-number7 { + margin-left: 10.5em +} +.list-bullet8, +.list-indent8, +.list-number8 { + margin-left: 12em +} +/* unordered lists */ +UL { + list-style-type: disc; + margin-left: 1.5em; +} +UL UL { + margin-left: 0 !important +} +.list-bullet2, +.list-bullet5, +.list-bullet8 { + list-style-type: circle +} +.list-bullet3, +.list-bullet6 { + list-style-type: square +} +.list-indent1, +.list-indent2, +.list-indent3, +.list-indent5, +.list-indent5, +.list-indent6, +.list-indent7, +.list-indent8 { + list-style-type: none +} +/* ordered lists */ +OL { + list-style-type: decimal; + margin-left: 1.5em; +} +.list-number2, +.list-number5, +.list-number8 { + list-style-type: lower-latin +} +.list-number3, +.list-number6 { + list-style-type: lower-roman +} +/* IE 6/7 fixes */ +* HTML #ui-slider-handle { + background-image: url(../../static/img/current_location.gif) +} +* HTML #timeslider .star { + background-image: url(../../static/img/star.gif) +} +* HTML #playpause_button_icon { + background-image: url(../../static/img/play.gif) +} +* HTML .pause#playpause_button_icon { + background-image: url(../../static/img/pause.gif) +} \ No newline at end of file diff --git a/src/static/custom/.gitignore b/src/static/custom/.gitignore new file mode 100644 index 000000000..aae16bb24 --- /dev/null +++ b/src/static/custom/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!*.template diff --git a/src/static/custom/css.template b/src/static/custom/css.template new file mode 100644 index 000000000..236251d9c --- /dev/null +++ b/src/static/custom/css.template @@ -0,0 +1,8 @@ +/* + custom css files are loaded after core css files. Simply use the same selector to override a style. + Example: + #editbar LI {border:1px solid #000;} + overrides + #editbar LI {border:1px solid #d5d5d5;} + from pad.css +*/ diff --git a/src/static/custom/js.template b/src/static/custom/js.template new file mode 100644 index 000000000..152c3d5d7 --- /dev/null +++ b/src/static/custom/js.template @@ -0,0 +1,6 @@ +function customStart() +{ + //define your javascript here + //jquery is available - except index.js + //you can load extra scripts with $.getScript http://api.jquery.com/jQuery.getScript/ +} diff --git a/src/static/favicon.ico b/src/static/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..df7b628996cd0a205dc5df5b1188342a80f34e90 GIT binary patch literal 1150 zcma)$F-pWx6h*Hnh}cXe+F7~`t7HM$ft71WKvM)#SlOlx!c=yeQn9nrg(Q_#aE9-H z3c+u}iw z_j`i}x#|5VEQK8&r1ugJ;^}P@mckAX(tC*q@xFV7rLe<;^j_jYy!*=#mckAX(tC*q zy&gL}Xnk3h&(k!W(xtSUZETy~tQ*JitZAAxzmc-8>qp!C<+pA7V9&Ho>haxfwqcw8 x2fokqe3<9?b{K{$OHmXPH!r)cyR&Cl`o6#U&wJgMy!rT&K96_VzDS!}=?C51Xs7@H literal 0 HcmV?d00001 diff --git a/src/static/img/backgrad.gif b/src/static/img/backgrad.gif new file mode 100644 index 0000000000000000000000000000000000000000..8fee1a5b721cb0c0b0814d75dcc6798d373556dc GIT binary patch literal 697 zcmcJKTTfB}0Dw-FmOdbiu{ za=9E1hsWaqLC|0@Xti1dL2Nc#AQ15T{SXAn)K7z%}yN+s|gIJOb{ubQ##?I&RO z7OtrxHzTP9?0+ENUqe<8J;bwavdzPfq2$U*C#SODR1Z=Xz6;-%po^mCg=}khz3n9u zj&4;|*N|$T0`;$6H#9aiziDZ0t510QuCuHA{fEv&ePjytBaPnwX<)E#2ZK4x8ewy| zUwC{*oKPellSs$KNmJ8bWi#Kt&w_Hr)GkmI{K;}Ig z$feJ=9L;W+0jd4+jx++J=t(;X>N-=Z=*{B|z&%;T$IWuyAY!E8NUMAte@H{m?MN?H z@IMo1@icrNUcfLg346=z3IWr^zAbA=&c9w>$1Bq@ilI@KRak+MAK`=}c1dLt<8 literal 0 HcmV?d00001 diff --git a/src/static/img/connectingbar.gif b/src/static/img/connectingbar.gif new file mode 100644 index 0000000000000000000000000000000000000000..5e54d6946960f0389f56639ebbc5bd41ab23caaa GIT binary patch literal 10932 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 literal 0 HcmV?d00001 diff --git a/src/static/img/crushed_button_undepressed.png b/src/static/img/crushed_button_undepressed.png new file mode 100644 index 0000000000000000000000000000000000000000..d86e3f39743bc7b5334c58442a000bd39f772b09 GIT binary patch literal 4166 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 QNB{r;07*qoM6N<$f)X$A_5c6? literal 0 HcmV?d00001 diff --git a/src/static/img/crushed_current_location.png b/src/static/img/crushed_current_location.png new file mode 100644 index 0000000000000000000000000000000000000000..76e08359e3cdef6b81bf1799de7d9d1333ced9e6 GIT binary patch literal 1009 zcmV4Tx07%D;lubw!VHn5%vyBL$L1eZG#n8bdR3?p50&~}|Y-L^6T}=^UoqczA za-5xJcGnb%5TaXI$LL~7bg+mH;h~^Y9U=tH>L5XMvIruGP=a!lflfp+G*2MVV=={I+u3*z&b%+znSNh? z@6wl(2Or&d>ig(^5$5psQ?64%fTIpj5x4n-$s;zOHF-RxrHF@3mJ*RDu|{00hk|Xy zcZv7J9X#UTC^ICWq?I@+X*%h+Du|mhQry&>Cl=MHtP-!9_NXC^Q4G!N`f2XMZZqWr zS`Gob-V|*^1~C5sI8a@*)wqHE1HeLK(Y7A6_QO>!8nMO(%fyv=si#lb?0Pj&dK=ih zmCgRTm(6b8ryA79OGQfSncR*z?k!;VYd@2IFi$(NJ(G2sfLYq(z;3{I4Y>0eIQkga z_Y4S>1EFTPt|EV>*k!9uTT+|Q<#-|`G>GCU!9%Z>3EiqxUn@iuMX+3kpfiK%85XU_ zn39%q_4d+L#?){h-Dy786=rngn46A9+G&@lC;l2`?H%TOBz_o^JG;#V(zP1XJHs|} z$K+IZ$Yxi+s`z_y>{dtoy*W)@OL>ENeIwJ?k+)xroC^?F5eG+;J~O70a$6@8p#hsY zWirv`&+$7w9W?76p_n;MvHtVao9h(@p@2b#O1~-u1knXA>YzbK5;5|~6d{w9Sur}X zfzdxkDhN1OApaWWtXVmikfv-is~=uJrfUldg7`uFF0P2P;+*(xrYc=i^i1m)WNGo{ z57IR&=XG=5Tu-*XM0GvnRS+lZSYal^(W#@mm^xEcR5EL@>;4VLl+0174kMP*s8uOr z`3(B?II8WmJ&Juj_1s;owZbn9FPFWZ+On(Uwy9WQs;$q(IA=cN5A(6$#NkC3M6+2gxWKJ~}`KYMUz8EMX|Kk@(Bqq`;~X>zMQMY^9n>Q4V> f7zLwX6p#V{MAqJ+{L9Q100000NkvXXu0mjf5AW6a literal 0 HcmV?d00001 diff --git a/src/static/img/etherpad_lite_icons.png b/src/static/img/etherpad_lite_icons.png new file mode 100644 index 0000000000000000000000000000000000000000..27867d428d6dcdb62a1271f691094f59333c02bc GIT binary patch literal 8318 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 literal 0 HcmV?d00001 diff --git a/src/static/img/fileicons.gif b/src/static/img/fileicons.gif new file mode 100644 index 0000000000000000000000000000000000000000..c03b6031a62780479212fe50240ccb60dd3750b1 GIT binary patch literal 1649 zcmV-%29EhhNk%w1VG#hQ0LFg+N=i!c@$svxtN8f%?Ck87m6fH(lWA#bk>88L!^5H0 zkZhRMh{M*(%FAi5&)DhJ@UW|TdV1ENII*#@+}zw~iqb`iz>v zf~|G_{{L2K$m;6q^z-xg+Sq8atmy3bRMTo|S1^Qxgj#dVR;1GS>F8CtoVe@D)85Wm zebCj_)SjN7e3;j!+1Po*$&9wKinoi9rQ7nuzvO&%eWq>QR88e;V~mZB=jZ3( zdUlW1tH+_Iwc_h`Z02WOZJt~9_xfah(8$`t?eF;7-}3tU`uqI;&e72{G&SI;IqlGZ z`1A9#zpu-QhV7!9-oAgUgfr;PKU`c~`}_WqawOU~EzCkajOU@7!GiFCb+Eza-*Ii` zUQ=L%(ERu8XOqyCkY>5DU+(Pa!j(Fhl8RTV-r>@!$j8Tg#dN>dy29eFdU0Rt+`#nC z%lG;GPL;ljjgelWhk&eXK{ywRxouLVWc&O3Zf41egav$kkdcrRhI|8iewmsB2b6?;dmtO50RbO=Je`6I z3uLThWEB-+ryC%u3v)}mydy>gntpwM3rjF$H9s&lEiC~AA3VauORQgFH83qF8>PU* zWX2UgEk8yzCj+(t(-lkPMn)$_NheC`8|_#XEkWc#NIdKrKyaFv4n}?yF*hQhl6wQD z8EJ^5V#OpL9zL9Q;)Dl}9zurPLlV<4L4AU$)O7NXrIo=3@EPPtlFdGR3NYHqv!~CW zK!f(Id9#lvLY6S8a!+>;I0au2I3<&N3@ivWv0xl~Y$RNSE3x{>nh$zS)1BHwXD)bm2 zg0HZG3l7wvut3L-V}cR$`e-8pg__bnhyZ~&?E!}_pdEsgf%-)g+#e|g10Z4jtpp%_0~V;#J)<<32?!eO@WKuh5MZH&gT(hj0X$HU zKmi3%AYuV8pyY=J5inr_00$Hh!2tl6kU#_~?qC8F8vvjI6A1_azy=AJprQbLxc&&h zl1$n-lS>0o5aR>_I1)ht0!&cA0fN*t!4(Ja!N&_o@^Qcw6ojYdnryD=h?sD~SwIJc zoVQd?bPmFY5);%|ihFqKNk9{XHc(0}8}M-y9|V+PMhOjcbBZQUx^#vWRzRxGGXhr1 zM*?ZM@InL-66Wa(9dtlJ6DE|(&XY=9;XY0?k_FFvJZIq}JgL zJwPyp4I%WP!wnY?D22|Mkn~UrP-Hud4Lxijhk?p12SyH3(5set^HOK;9{?A%oxg0i z-EZ2Gln13!F%|3-dkRklU%jZ&SBrfd50HQc6HqBYFbV#*smKZX!N%Z(8~zQ)DlN!P z<4iR0IOH1S(|F~mjqHL8nQP7l8$zq7iPYGRY$64t$G)8EVoO&xOlQMw)7ozL?)!JJ zColmJFT4N*5Nikk2O4P(AOH|?M4!U+)Mt?c8fc)s#`ZuIF2D@Z2_#_s6gj~0#|tR{ zA$*qKi+_Fj1N v0ZdU3Bme>lvQU7+4Wfq=KpzEOu){IBpb!E`00punN5K){iN9k+AOHY6T+#W% literal 0 HcmV?d00001 diff --git a/src/static/img/leftarrow.png b/src/static/img/leftarrow.png new file mode 100644 index 0000000000000000000000000000000000000000..1bec1288271826468e736f7b62acda6d0f622efa GIT binary patch literal 494 zcmVEF;N?0BC*%=0f<)+3lojLfpj;s=GHkBLvw|~A)CxE z*}J{lkIVn&YlUDKhG7_nVHk#CMnZsyhqHm)5VZ$u)} zrfzH!7MU6MhV_$16cyGGFlVf5U7TO05U~je^WcI&1VL`qZ}i>)*R_70VK9c&+`$~- zucP(-@u!#P`-xhl-M)z`rN{~pYz3_obhX3!(IW}Dy|aA*7P*lnClm`~71HTE%#j=9 z0DS=;|0>|mLb~0@15~OYqzpHAhn{|=OiyD${IwA6d07hF0(IYw77~vy^peR$ad~NR zRtxQCsaG>yYlw7e<;?TEgeD8=f40!bbtIEn>(Q;)YF#zSLN)4V1E%^y9b*oSjr!?& zzq->wak#P@cWB()%D44b6?cq@ksx+C>kvQ>N_Ox9`D&2L%NMD^{$ycJ12Rw{QRd|IdI8DE{a6 za}5c0b_{Se(lcOY1gcT|$-*hZz`>vcQV%kgfyGqdr043r7O&6l{=a~|=W;^ZwS|6e z-C?f|?s_kB-Ys{1(CpV8(f!MWm1-Q0a0FPX&cCptM2HcaNhS!BA{Ck>61-fz;@rDe zWnY!k7d)*u>DG6X>$dTE;#2+zEOHP`WYU>`A;W|Tn?XoUnyKLD$QkEqCHN+D>Cs!e zM0A8p)_Up8^ESKpM%?8~ozTRF6ela7lR_X)Vny?uGr~!C6#SelSv5K&-h}KCR$RU2 z;h{-B7uJ3e+bFrBjQLrM{38DL3mIeskIJRFR0<^mRA9QZj|vA!wWGg;B!)mCr^xA!K;+d?vqBEj1zxyuWxJC~S2ZTKeY+!q& zV??VrQJU=09J Cwd;@o literal 0 HcmV?d00001 diff --git a/src/static/img/pause.png b/src/static/img/pause.png new file mode 100644 index 0000000000000000000000000000000000000000..657782c0fac9f6e6058803bd276a36b73b83d1de GIT binary patch literal 2883 zcmV-J3%vA+P)KLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&neh&#Q1i z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4 zfg=2N-7=cNnjjOr{yriy6mMFgG#l znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya? z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0 z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$ z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4 z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i& z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01 z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3| zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F} z0001PNklm6?7}BBU{Tad=7-*xa#;!TW#({^2hlhuU zhri|QG-u}HRX691POY_phlhuUhlhuUhlht>m1zFYzcoj*cKsXnKLZ*U+IBfRsybQWXdwQbLP>6pAqfylh#{fb6;Z(vMMVS~$e@S=j*ftg6;Uhf59&ghTmgWD0l;*T zI709Y^p6lP1rIRMx#05C~cW=H_Aw*bJ-5DT&Z2n+x)QHX^p z00esgV8|mQcmRZ%02D^@S3L16t`O%c004NIvOKvYIYoh62rY33S640`D9%Y2D-rV&neh&#Q1i z007~1e$oCcFS8neI|hJl{-P!B1ZZ9hpmq0)X0i`JwE&>$+E?>%_LC6RbVIkUx0b+_+BaR3cnT7Zv!AJxW zizFb)h!jyGOOZ85F;a?DAXP{m@;!0_IfqH8(HlgRxt7s3}k3K`kFu>>-2Q$QMFfPW!La{h336o>X zu_CMttHv6zR;&ZNiS=X8v3CR#fknUxHUxJ0uoBa_M6WNWeqIg~6QE69c9o#eyhGvpiOA@W-aonk<7r1(?fC{oI5N*U!4 zfg=2N-7=cNnjjOr{yriy6mMFgG#l znCF=fnQv8CDz++o6_Lscl}eQ+l^ZHARH>?_s@|##Rr6KLRFA1%Q+=*RRWnoLsR`7U zt5vFIcfW3@?wFpwUVxrVZ>QdQz32KIeJ}k~{cZZE^+ya? z2D1z#2HOnI7(B%_ac?{wFUQ;QQA1tBKtrWrm0_3Rgps+?Jfqb{jYbcQX~taRB;#$y zZN{S}1|}gUOHJxc?wV3fxuz+mJ4`!F$IZ;mqRrNsHJd##*D~ju=bP7?-?v~|cv>vB zsJ6IeNwVZxrdjT`yl#bBIa#GxRa#xMMy;K#CDyyGyQdMSxlWT#tDe?p!?5wT$+oGt z8L;Kp2HUQ-ZMJ=3XJQv;x5ci*?vuTfeY$;({XGW_huIFR9a(?@3)XSs8O^N5RyOM=TTmp(3=8^+zpz2r)C z^>JO{deZfso3oq3?Wo(Y?l$ge?uXo;%ru`Vo>?<<(8I_>;8Eq#KMS9gFl*neeosSB zfoHYnBQIkwkyowPu(zdms`p{<7e4kra-ZWq<2*OsGTvEV%s0Td$hXT+!*8Bnh2KMe zBmZRodjHV?r+_5^X9J0WL4jKW`}lf%A-|44I@@LTvf1rHjG(ze6+w@Jt%Bvjts!X0 z?2xS?_ve_-kiKB_KiJlZ$9G`c^=E@oNG)mWWaNo-3TIW8)$Hg0Ub-~8?KhvJ>$ z3*&nim@mj(aCxE5!t{lw7O5^0EIO7zOo&c6l<+|iDySBWCGrz@C5{St!X3hAA}`T4 z(TLbXTq+(;@<=L8dXnssyft|w#WSTW<++3>sgS%(4NTpeI-VAqb|7ssJvzNHgOZVu zaYCvgO_R1~>SyL=cFU|~g|hy|Zi}}s9+d~lYqOB71z9Z$wnC=pR9Yz4DhIM>Wmjgu z&56o6maCpC&F##y%G;1PobR9i?GnNg;gYtchD%p19a!eQtZF&3JaKv33gZ<8D~47E ztUS1iwkmDaPpj=$m#%)jCVEY4fnLGNg2A-`YwHVD3gv};>)hAvT~AmqS>Lr``i7kw zJ{5_It`yrBmlc25DBO7E8;5VoznR>Ww5hAaxn$2~(q`%A-YuS64wkBy=9dm`4cXeX z4c}I@?e+FW+b@^RDBHV(wnMq2zdX3SWv9u`%{xC-q*U}&`cyXV(%rRT*Z6MH?i+i& z_B8C(+grT%{XWUQ+f@NoP1R=AW&26{v-dx)iK^-Nmiuj8txj!m?Z*Ss1N{dh4z}01 z)YTo*JycSU)+_5r4#yw9{+;i4Ee$peRgIj+;v;ZGdF1K$3E%e~4LaI(jC-u%2h$&R z9cLXcYC@Xwnns&bn)_Q~Te?roKGD|d-g^8;+aC{{G(1^(O7m37Y1-+6)01cN&y1aw zoqc{T`P^XJqPBbIW6s}d4{z_f5Om?vMgNQEJG?v2T=KYd^0M3I6IZxbny)%vZR&LD zJpPl@Psh8QyPB@KTx+@RdcC!KX7}kEo;S|j^u2lU7XQ}Oo;f|;z4Ll+_r>@1-xl3| zawq-H%e&ckC+@AhPrP6BKT#_XdT7&;F71j}Joy zkC~6lh7E@6o;W@^IpRNZ{ptLtL(gQ-CY~4mqW;US7Zxvm_|@yz&e53Bp_lTPlfP|z zrTyx_>lv@x#=^!PzR7qqF<$gm`|ZJZ+;<)Cqu&ot2z=0000WV@Og>004R=004l4008;_004mL004C`008P>0026e000+nl3&F} z0002=Nkld`Kvb@Q+ zrtH4Jg{e2a&CWv9_dQ;t;s!#|a1xld%_1U5*R(C!JYT)cO zHZgDq9(M0C^G92S)awhovnV~S8JD|FY%waVA(va_)m9<(2>%(S8c4hjsl*z{$3$uE z37c9IF5z#$VfEv|k7|HmS2vmOfXv)(#ui)B`O?6deVYS1w+@dgrUkyL0dMvlmZazJBrH)BCSKzWn_C z_laey( U&g~@{0(_l^4=OOYFfdpH0MXt-F#rGn literal 0 HcmV?d00001 diff --git a/src/static/img/roundcorner_right.gif b/src/static/img/roundcorner_right.gif new file mode 100644 index 0000000000000000000000000000000000000000..97acfbf2e6b0d7a6876999603877bbdb61736e7e GIT binary patch literal 131 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 literal 0 HcmV?d00001 diff --git a/src/static/img/timeslider_background.png b/src/static/img/timeslider_background.png new file mode 100644 index 0000000000000000000000000000000000000000..851af4e8000a0de07f0bb2248eb73c0c817f0b17 GIT binary patch literal 182 zcmeAS@N?(olHy`uVBq!ia0vp^Oh9bU!OXzG*wuaWGms-$;u=vBoS#-wo>-L1;Fyx1 zl&avFo0y&&l$w}QS$Hzl2B?TXz$e7@-@kwV|Nl=*OFMJs%=ho#xni=90i|p`T^vIy zZY3q8B{VuKNGmi;efqJv}@+I#ZO?)Y#I} dG@OLk8TK(UUcVv{dJ$+4gQu&X%Q~loCIDe5JEZ^s literal 0 HcmV?d00001 diff --git a/src/static/img/timeslider_left.png b/src/static/img/timeslider_left.png new file mode 100644 index 0000000000000000000000000000000000000000..48a9b0e1797c61874ac301fc2c518c3bc575b693 GIT binary patch literal 686 zcmeAS@N?(olHy`uVBq!ia0vp^Z9r_#!OXzGn3`J90pv)QxJHx&=ckpFCl;kLIHu$$ zr7C#lCZ?wbr6#6S7M@JC0V=8v@CkAK_wV22$&=I4(&FRemn~cN_3PJp^X7f{@L|P@ z6+eIeym8~k^5x6duV4TB_wQS`Zmn9iYQu&NFJ8P@vSi8m^XIQ$zkcrAxuZvqo;Y#h z-Me=`e*F0L>(|AL7oRva#N!CE=vZ+BMQ9>-DJ-emF6f(JQGlD$`rR^;80*oEOlad!TIIG!Gig# ztoRdET_Tov$*Qt2Fk3rrXJ8QkQqCN+7p~K5(f6@%(Bfc~E)5YVdwC(y(IQTx-?Gw4 zi-D=5F?Le$Tqg&YX$t3fm~IC1)+;T!!(*y7>!6myKEYKXJi%83jaTHer84BOF}FI* z;CkuY96QBn{R$=)fo8V(T1V3~PIv{q5!cjeX}6ksCgHJy^=s}<=d%wMEZ|6LoVCy_ zi|y)}2SA3`K0&56AwJ2A(|TqZ2Ax^3rB2c(Eg#0phTQN0!Mlk<>LSn-SP!miR^E*q3WToHk{**(JN!lZD$1S`>R? z+D@&l+1SnKEAjpQt^ILddQ%T}EZ%H9XCrf>@wTJIrPkfRNM-PJ^>bP0l+XkKhssVM literal 0 HcmV?d00001 diff --git a/src/static/img/timeslider_right.png b/src/static/img/timeslider_right.png new file mode 100644 index 0000000000000000000000000000000000000000..1a1b2685b24a2e620a33d95f45ff6fe6867d20c3 GIT binary patch literal 517 zcmeAS@N?(olHy`uVBq!ia0vp^vw_&2gPDPWal2{N1t3SV#5JNMI6tkVJh3R1!7(L2 zDOJHUH!(dmC^a#qvhZZ84N#F^fKQ0)@87@w{{8#o$B&;sf2O6SefaR z@80?I=dWMC{`KqEix)4xeEIU%uV0@(e}4Dw-K|@<&Ye5=@87>ur%v6zef$6a|7Xse z`TqSokg;8a>pak8sgfYSU?3MeU=UGaxCL~px~Gd{NX4xrf#WP|c!VMtd|8fmhH?rx z@I>4>>98b?O(|C`il@7hfwAT4#b&R$7Nu7^yMwRR0{OaihK&6x6Q30dt@Nu2k)3;` zbMn%@nSSq***O%FE^8V#%zLsC?!d<{lIHwaQP_OgqVUgD0pZSLhDJs~$2Ddx zu`bkNit}t>ShTFLX;R1+ZQ;cnq0W8%yFMCZ-CB{kcv_bUT3g62(&S+u-Lk3y2GiT|Lvn#Y}u76H&*X|u)%QKOlHHA zIfYueyDw~7sCejBh|xM<#$4?UzL|@A&2IfmEVeECIaxfKZJl)PmWyuDYtP0yZoOOo us$!eM0<(?x&oJdLY0Rl}-1@UpUwsi{&#~=IeanCW%i!ti=d#Wzp$PzhYXgh` literal 0 HcmV?d00001 diff --git a/src/static/js/AttributeManager.js b/src/static/js/AttributeManager.js new file mode 100644 index 000000000..2d523f6aa --- /dev/null +++ b/src/static/js/AttributeManager.js @@ -0,0 +1,164 @@ +var Changeset = require('./Changeset'); +var ChangesetUtils = require('./ChangesetUtils'); +var _ = require('./underscore'); + +var lineMarkerAttribute = 'lmkr'; + +// If one of these attributes are set to the first character of a +// line it is considered as a line attribute marker i.e. attributes +// set on this marker are applied to the whole line. +// The list attribute is only maintained for compatibility reasons +var lineAttributes = [lineMarkerAttribute,'list']; + +/* + The Attribute manager builds changesets based on a document + representation for setting and removing range or line-based attributes. + + @param rep the document representation to be used + @param applyChangesetCallback this callback will be called + once a changeset has been built. + + + A document representation contains + - an array `alines` containing 1 attributes string for each line + - an Attribute pool `apool` + - a SkipList `lines` containing the text lines of the document. +*/ + +var AttributeManager = function(rep, applyChangesetCallback) +{ + this.rep = rep; + this.applyChangesetCallback = applyChangesetCallback; + this.author = ''; + + // If the first char in a line has one of the following attributes + // it will be considered as a line marker +}; + +AttributeManager.lineAttributes = lineAttributes; + +AttributeManager.prototype = _(AttributeManager.prototype).extend({ + + applyChangeset: function(changeset){ + if(!this.applyChangesetCallback) return changeset; + + var cs = changeset.toString(); + if (!Changeset.isIdentity(cs)) + { + this.applyChangesetCallback(cs); + } + + return changeset; + }, + + /* + Sets attributes on a range + @param start [row, col] tuple pointing to the start of the range + @param end [row, col] tuple pointing to the end of the range + @param attribute: an array of attributes + */ + setAttributesOnRange: function(start, end, attribs) + { + var builder = Changeset.builder(this.rep.lines.totalWidth()); + ChangesetUtils.buildKeepToStartOfRange(this.rep, builder, start); + ChangesetUtils.buildKeepRange(this.rep, builder, start, end, attribs, this.rep.apool); + return this.applyChangeset(builder); + }, + + /* + Returns if the line already has a line marker + @param lineNum: the number of the line + */ + lineHasMarker: function(lineNum){ + var that = this; + + return _.find(lineAttributes, function(attribute){ + return that.getAttributeOnLine(lineNum, attribute) != ''; + }) !== undefined; + }, + + /* + Gets a specified attribute on a line + @param lineNum: the number of the line to set the attribute for + @param attributeKey: the name of the attribute to get, e.g. list + */ + getAttributeOnLine: function(lineNum, attributeName){ + // get `attributeName` attribute of first char of line + var aline = this.rep.alines[lineNum]; + if (aline) + { + var opIter = Changeset.opIterator(aline); + if (opIter.hasNext()) + { + return Changeset.opAttributeValue(opIter.next(), attributeName, this.rep.apool) || ''; + } + } + return ''; + }, + + /* + Sets a specified attribute on a line + @param lineNum: the number of the line to set the attribute for + @param attributeKey: the name of the attribute to set, e.g. list + @param attributeValue: an optional parameter to pass to the attribute (e.g. indention level) + + */ + setAttributeOnLine: function(lineNum, attributeName, attributeValue){ + var loc = [0,0]; + var builder = Changeset.builder(this.rep.lines.totalWidth()); + var hasMarker = this.lineHasMarker(lineNum); + + ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 0])); + + if(hasMarker){ + ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 1]), [ + [attributeName, attributeValue] + ], this.rep.apool); + }else{ + // add a line marker + builder.insert('*', [ + ['author', this.author], + ['insertorder', 'first'], + [lineMarkerAttribute, '1'], + [attributeName, attributeValue] + ], this.rep.apool); + } + + return this.applyChangeset(builder); + }, + + /* + Removes a specified attribute on a line + @param lineNum: the number of the affected line + @param attributeKey: the name of the attribute to remove, e.g. list + + */ + removeAttributeOnLine: function(lineNum, attributeName, attributeValue){ + + var loc = [0,0]; + var builder = Changeset.builder(this.rep.lines.totalWidth()); + var hasMarker = this.lineHasMarker(lineNum); + + if(hasMarker){ + ChangesetUtils.buildKeepRange(this.rep, builder, loc, (loc = [lineNum, 0])); + ChangesetUtils.buildRemoveRange(this.rep, builder, loc, (loc = [lineNum, 1])); + } + + return this.applyChangeset(builder); + }, + + /* + Sets a specified attribute on a line + @param lineNum: the number of the line to set the attribute for + @param attributeKey: the name of the attribute to set, e.g. list + @param attributeValue: an optional parameter to pass to the attribute (e.g. indention level) + */ + toggleAttributeOnLine: function(lineNum, attributeName, attributeValue) { + return this.getAttributeOnLine(attributeName) ? + this.removeAttributeOnLine(lineNum, attributeName) : + this.setAttributeOnLine(lineNum, attributeName, attributeValue); + + } +}); + +module.exports = AttributeManager; \ No newline at end of file diff --git a/src/static/js/AttributePool.js b/src/static/js/AttributePool.js new file mode 100644 index 000000000..f5990c07d --- /dev/null +++ b/src/static/js/AttributePool.js @@ -0,0 +1,96 @@ +/** + * This code represents the Attribute Pool Object of the original Etherpad. + * 90% of the code is still like in the original Etherpad + * Look at https://github.com/ether/pad/blob/master/infrastructure/ace/www/easysync2.js + * You can find a explanation what a attribute pool is here: + * https://github.com/Pita/etherpad-lite/blob/master/doc/easysync/easysync-notes.txt + */ + +/* + * Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/* + An AttributePool maintains a mapping from [key,value] Pairs called + Attributes to Numbers (unsigened integers) and vice versa. These numbers are + used to reference Attributes in Changesets. +*/ + +var AttributePool = function () { + this.numToAttrib = {}; // e.g. {0: ['foo','bar']} + this.attribToNum = {}; // e.g. {'foo,bar': 0} + this.nextNum = 0; +}; + +AttributePool.prototype.putAttrib = function (attrib, dontAddIfAbsent) { + var str = String(attrib); + if (str in this.attribToNum) { + return this.attribToNum[str]; + } + if (dontAddIfAbsent) { + return -1; + } + var num = this.nextNum++; + this.attribToNum[str] = num; + this.numToAttrib[num] = [String(attrib[0] || ''), String(attrib[1] || '')]; + return num; +}; + +AttributePool.prototype.getAttrib = function (num) { + var pair = this.numToAttrib[num]; + if (!pair) { + return pair; + } + return [pair[0], pair[1]]; // return a mutable copy +}; + +AttributePool.prototype.getAttribKey = function (num) { + var pair = this.numToAttrib[num]; + if (!pair) return ''; + return pair[0]; +}; + +AttributePool.prototype.getAttribValue = function (num) { + var pair = this.numToAttrib[num]; + if (!pair) return ''; + return pair[1]; +}; + +AttributePool.prototype.eachAttrib = function (func) { + for (var n in this.numToAttrib) { + var pair = this.numToAttrib[n]; + func(pair[0], pair[1]); + } +}; + +AttributePool.prototype.toJsonable = function () { + return { + numToAttrib: this.numToAttrib, + nextNum: this.nextNum + }; +}; + +AttributePool.prototype.fromJsonable = function (obj) { + this.numToAttrib = obj.numToAttrib; + this.nextNum = obj.nextNum; + this.attribToNum = {}; + for (var n in this.numToAttrib) { + this.attribToNum[String(this.numToAttrib[n])] = Number(n); + } + return this; +}; + + +module.exports = AttributePool; \ No newline at end of file diff --git a/src/static/js/Changeset.js b/src/static/js/Changeset.js new file mode 100644 index 000000000..cfea43625 --- /dev/null +++ b/src/static/js/Changeset.js @@ -0,0 +1,2184 @@ +/* + * This is the Changeset library copied from the old Etherpad with some modifications to use it in node.js + * Can be found in https://github.com/ether/pad/blob/master/infrastructure/ace/www/easysync2.js + */ + +/** + * 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 + */ + +/* + * Copyright 2009 Google Inc., 2011 Peter 'Pita' Martischka (Primary Technology Ltd) + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +var AttributePool = require("./AttributePool"); + +/** + * ==================== General Util Functions ======================= + */ + +/** + * This method is called whenever there is an error in the sync process + * @param msg {string} Just some message + */ +exports.error = function error(msg) { + var e = new Error(msg); + e.easysync = true; + throw e; +}; + +/** + * This method is user for assertions with Messages + * if assert fails, the error function called. + * @param b {boolean} assertion condition + * @param msgParts {string} error to be passed if it fails + */ +exports.assert = function assert(b, msgParts) { + if (!b) { + var msg = Array.prototype.slice.call(arguments, 1).join(''); + exports.error("exports: " + msg); + } +}; + +/** + * Parses a number from string base 36 + * @param str {string} string of the number in base 36 + * @returns {int} number + */ +exports.parseNum = function (str) { + return parseInt(str, 36); +}; + +/** + * Writes a number in base 36 and puts it in a string + * @param num {int} number + * @returns {string} string + */ +exports.numToString = function (num) { + return num.toString(36).toLowerCase(); +}; + +/** + * Converts stuff before $ to base 10 + * @obsolete not really used anywhere?? + * @param cs {string} the string + * @return integer + */ +exports.toBaseTen = function (cs) { + var dollarIndex = cs.indexOf('$'); + var beforeDollar = cs.substring(0, dollarIndex); + var fromDollar = cs.substring(dollarIndex); + return beforeDollar.replace(/[0-9a-z]+/g, function (s) { + return String(exports.parseNum(s)); + }) + fromDollar; +}; + + +/** + * ==================== Changeset Functions ======================= + */ + +/** + * 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; +}; + +/** + * 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 + */ +exports.opIterator = function (opsStr, optStartIndex) { + //print(opsStr); + var regex = /((?:\*[0-9a-z]+)*)(?:\|([0-9a-z]+))?([-+=])([0-9a-z]+)|\?|/g; + var startIndex = (optStartIndex || 0); + var curIndex = startIndex; + var prevIndex = curIndex; + + function nextRegexMatch() { + prevIndex = curIndex; + var result; + regex.lastIndex = curIndex; + result = regex.exec(opsStr); + curIndex = regex.lastIndex; + if (result[0] == '?') { + exports.error("Hit error opcode in op stream"); + } + + return result; + } + var regexResult = nextRegexMatch(); + var obj = exports.newOp(); + + function next(optObj) { + var op = (optObj || obj); + if (regexResult[0]) { + op.attribs = regexResult[1]; + op.lines = exports.parseNum(regexResult[2] || 0); + op.opcode = regexResult[3]; + op.chars = exports.parseNum(regexResult[4]); + regexResult = nextRegexMatch(); + } else { + exports.clearOp(op); + } + return op; + } + + function hasNext() { + return !!(regexResult[0]); + } + + function lastIndex() { + return prevIndex; + } + return { + next: next, + hasNext: hasNext, + lastIndex: lastIndex + }; +}; + +/** + * Cleans an Op object + * @param {Op} object to be cleared + */ +exports.clearOp = function (op) { + op.opcode = ''; + op.chars = 0; + op.lines = 0; + op.attribs = ''; +}; + +/** + * Creates a new Op object + * @param optOpcode the type operation of the Op object + */ +exports.newOp = function (optOpcode) { + return { + opcode: (optOpcode || ''), + chars: 0, + lines: 0, + attribs: '' + }; +}; + +/** + * Clones an Op + * @param op Op to be cloned + */ +exports.cloneOp = function (op) { + return { + opcode: op.opcode, + chars: op.chars, + lines: op.lines, + attribs: op.attribs + }; +}; + +/** + * Copies op1 to op2 + * @param op1 src Op + * @param op2 dest Op + */ +exports.copyOp = function (op1, op2) { + op2.opcode = op1.opcode; + op2.chars = op1.chars; + op2.lines = op1.lines; + op2.attribs = op1.attribs; +}; + +/** + * Writes the Op in a string the way that changesets need it + */ +exports.opString = function (op) { + // just for debugging + if (!op.opcode) return 'null'; + var assem = exports.opAssembler(); + assem.append(op); + return assem.toString(); +}; + +/** + * Used just for debugging + */ +exports.stringOp = function (str) { + // just for debugging + return exports.opIterator(str).next(); +}; + +/** + * Used to check if a Changeset if valid + * @param cs {Changeset} Changeset to be checked + */ +exports.checkRep = function (cs) { + // doesn't check things that require access to attrib pool (e.g. attribute order) + // or original string (e.g. newline positions) + var unpacked = exports.unpack(cs); + var oldLen = unpacked.oldLen; + var newLen = unpacked.newLen; + var ops = unpacked.ops; + var charBank = unpacked.charBank; + + var assem = exports.smartOpAssembler(); + var oldPos = 0; + var calcNewLen = 0; + var numInserted = 0; + var iter = exports.opIterator(ops); + while (iter.hasNext()) { + var o = iter.next(); + switch (o.opcode) { + case '=': + oldPos += o.chars; + calcNewLen += o.chars; + break; + case '-': + oldPos += o.chars; + exports.assert(oldPos < oldLen, oldPos, " >= ", oldLen, " in ", cs); + break; + case '+': + { + calcNewLen += o.chars; + numInserted += o.chars; + exports.assert(calcNewLen < newLen, calcNewLen, " >= ", newLen, " in ", cs); + break; + } + } + assem.append(o); + } + + calcNewLen += oldLen - oldPos; + charBank = charBank.substring(0, numInserted); + while (charBank.length < numInserted) { + charBank += "?"; + } + + assem.endDocument(); + var normalized = exports.pack(oldLen, calcNewLen, assem.toString(), charBank); + exports.assert(normalized == cs, normalized, ' != ', cs); + + return cs; +} + + +/** + * ==================== Util Functions ======================= + */ + +/** + * creates an object that allows you to append operations (type Op) and also + * compresses them if possible + */ +exports.smartOpAssembler = function () { + // Like opAssembler but able to produce conforming exportss + // from slightly looser input, at the cost of speed. + // Specifically: + // - merges consecutive operations that can be merged + // - strips final "=" + // - ignores 0-length changes + // - reorders consecutive + and - (which margingOpAssembler doesn't do) + var minusAssem = exports.mergingOpAssembler(); + var plusAssem = exports.mergingOpAssembler(); + var keepAssem = exports.mergingOpAssembler(); + var assem = exports.stringAssembler(); + var lastOpcode = ''; + var lengthChange = 0; + + function flushKeeps() { + assem.append(keepAssem.toString()); + keepAssem.clear(); + } + + function flushPlusMinus() { + assem.append(minusAssem.toString()); + minusAssem.clear(); + assem.append(plusAssem.toString()); + plusAssem.clear(); + } + + function append(op) { + if (!op.opcode) return; + if (!op.chars) return; + + if (op.opcode == '-') { + if (lastOpcode == '=') { + flushKeeps(); + } + minusAssem.append(op); + lengthChange -= op.chars; + } else if (op.opcode == '+') { + if (lastOpcode == '=') { + flushKeeps(); + } + plusAssem.append(op); + lengthChange += op.chars; + } else if (op.opcode == '=') { + if (lastOpcode != '=') { + flushPlusMinus(); + } + keepAssem.append(op); + } + lastOpcode = op.opcode; + } + + function appendOpWithText(opcode, text, attribs, pool) { + var op = exports.newOp(opcode); + op.attribs = exports.makeAttribsString(opcode, attribs, pool); + var lastNewlinePos = text.lastIndexOf('\n'); + if (lastNewlinePos < 0) { + op.chars = text.length; + op.lines = 0; + append(op); + } else { + op.chars = lastNewlinePos + 1; + op.lines = text.match(/\n/g).length; + append(op); + op.chars = text.length - (lastNewlinePos + 1); + op.lines = 0; + append(op); + } + } + + function toString() { + flushPlusMinus(); + flushKeeps(); + return assem.toString(); + } + + function clear() { + minusAssem.clear(); + plusAssem.clear(); + keepAssem.clear(); + assem.clear(); + lengthChange = 0; + } + + function endDocument() { + keepAssem.endDocument(); + } + + function getLengthChange() { + return lengthChange; + } + + return { + append: append, + toString: toString, + clear: clear, + endDocument: endDocument, + appendOpWithText: appendOpWithText, + getLengthChange: getLengthChange + }; +}; + + +exports.mergingOpAssembler = function () { + // This assembler can be used in production; it efficiently + // merges consecutive operations that are mergeable, ignores + // no-ops, and drops final pure "keeps". It does not re-order + // operations. + var assem = exports.opAssembler(); + var bufOp = exports.newOp(); + + // If we get, for example, insertions [xxx\n,yyy], those don't merge, + // but if we get [xxx\n,yyy,zzz\n], that merges to [xxx\nyyyzzz\n]. + // This variable stores the length of yyy and any other newline-less + // ops immediately after it. + var bufOpAdditionalCharsAfterNewline = 0; + + function flush(isEndDocument) { + if (bufOp.opcode) { + if (isEndDocument && bufOp.opcode == '=' && !bufOp.attribs) { + // final merged keep, leave it implicit + } else { + assem.append(bufOp); + if (bufOpAdditionalCharsAfterNewline) { + bufOp.chars = bufOpAdditionalCharsAfterNewline; + bufOp.lines = 0; + assem.append(bufOp); + bufOpAdditionalCharsAfterNewline = 0; + } + } + bufOp.opcode = ''; + } + } + + function append(op) { + if (op.chars > 0) { + if (bufOp.opcode == op.opcode && bufOp.attribs == op.attribs) { + if (op.lines > 0) { + // bufOp and additional chars are all mergeable into a multi-line op + bufOp.chars += bufOpAdditionalCharsAfterNewline + op.chars; + bufOp.lines += op.lines; + bufOpAdditionalCharsAfterNewline = 0; + } else if (bufOp.lines == 0) { + // both bufOp and op are in-line + bufOp.chars += op.chars; + } else { + // append in-line text to multi-line bufOp + bufOpAdditionalCharsAfterNewline += op.chars; + } + } else { + flush(); + exports.copyOp(op, bufOp); + } + } + } + + function endDocument() { + flush(true); + } + + function toString() { + flush(); + return assem.toString(); + } + + function clear() { + assem.clear(); + exports.clearOp(bufOp); + } + return { + append: append, + toString: toString, + clear: clear, + endDocument: endDocument + }; +}; + + + +exports.opAssembler = function () { + var pieces = []; + // this function allows op to be mutated later (doesn't keep a ref) + + function append(op) { + pieces.push(op.attribs); + if (op.lines) { + pieces.push('|', exports.numToString(op.lines)); + } + pieces.push(op.opcode); + pieces.push(exports.numToString(op.chars)); + } + + function toString() { + return pieces.join(''); + } + + function clear() { + pieces.length = 0; + } + return { + append: append, + toString: toString, + clear: clear + }; +}; + +/** + * A custom made String Iterator + * @param str {string} String to be iterated over + */ +exports.stringIterator = function (str) { + var curIndex = 0; + + function assertRemaining(n) { + exports.assert(n <= remaining(), "!(", n, " <= ", remaining(), ")"); + } + + function take(n) { + assertRemaining(n); + var s = str.substr(curIndex, n); + curIndex += n; + return s; + } + + function peek(n) { + assertRemaining(n); + var s = str.substr(curIndex, n); + return s; + } + + function skip(n) { + assertRemaining(n); + curIndex += n; + } + + function remaining() { + return str.length - curIndex; + } + return { + take: take, + skip: skip, + remaining: remaining, + peek: peek + }; +}; + +/** + * A custom made StringBuffer + */ +exports.stringAssembler = function () { + var pieces = []; + + function append(x) { + pieces.push(String(x)); + } + + function toString() { + return pieces.join(''); + } + return { + append: append, + toString: toString + }; +}; + +/** + * This class allows to iterate and modify texts which have several lines + * It is used for applying Changesets on arrays of lines + * Note from prev docs: "lines" need not be an array as long as it supports certain calls (lines_foo inside). + */ +exports.textLinesMutator = function (lines) { + // Mutates lines, an array of strings, in place. + // Mutation operations have the same constraints as exports operations + // with respect to newlines, but not the other additional constraints + // (i.e. ins/del ordering, forbidden no-ops, non-mergeability, final newline). + // Can be used to mutate lists of strings where the last char of each string + // is not actually a newline, but for the purposes of N and L values, + // the caller should pretend it is, and for things to work right in that case, the input + // to insert() should be a single line with no newlines. + var curSplice = [0, 0]; + var inSplice = false; + // position in document after curSplice is applied: + var curLine = 0, + curCol = 0; + // invariant: if (inSplice) then (curLine is in curSplice[0] + curSplice.length - {2,3}) && + // curLine >= curSplice[0] + // invariant: if (inSplice && (curLine >= curSplice[0] + curSplice.length - 2)) then + // curCol == 0 + + function lines_applySplice(s) { + lines.splice.apply(lines, s); + } + + function lines_toSource() { + return lines.toSource(); + } + + function lines_get(idx) { + if (lines.get) { + return lines.get(idx); + } else { + return lines[idx]; + } + } + // can be unimplemented if removeLines's return value not needed + + function lines_slice(start, end) { + if (lines.slice) { + return lines.slice(start, end); + } else { + return []; + } + } + + function lines_length() { + if ((typeof lines.length) == "number") { + return lines.length; + } else { + return lines.length(); + } + } + + function enterSplice() { + curSplice[0] = curLine; + curSplice[1] = 0; + if (curCol > 0) { + putCurLineInSplice(); + } + inSplice = true; + } + + function leaveSplice() { + lines_applySplice(curSplice); + curSplice.length = 2; + curSplice[0] = curSplice[1] = 0; + inSplice = false; + } + + function isCurLineInSplice() { + return (curLine - curSplice[0] < (curSplice.length - 2)); + } + + function debugPrint(typ) { + print(typ + ": " + curSplice.toSource() + " / " + curLine + "," + curCol + " / " + lines_toSource()); + } + + function putCurLineInSplice() { + if (!isCurLineInSplice()) { + curSplice.push(lines_get(curSplice[0] + curSplice[1])); + curSplice[1]++; + } + return 2 + curLine - curSplice[0]; + } + + function skipLines(L, includeInSplice) { + if (L) { + if (includeInSplice) { + if (!inSplice) { + enterSplice(); + } + for (var i = 0; i < L; i++) { + curCol = 0; + putCurLineInSplice(); + curLine++; + } + } else { + if (inSplice) { + if (L > 1) { + leaveSplice(); + } else { + putCurLineInSplice(); + } + } + curLine += L; + curCol = 0; + } + //print(inSplice+" / "+isCurLineInSplice()+" / "+curSplice[0]+" / "+curSplice[1]+" / "+lines.length); +/*if (inSplice && (! isCurLineInSplice()) && (curSplice[0] + curSplice[1] < lines.length)) { + print("BLAH"); + putCurLineInSplice(); + }*/ + // tests case foo in remove(), which isn't otherwise covered in current impl + } + //debugPrint("skip"); + } + + function skip(N, L, includeInSplice) { + if (N) { + if (L) { + skipLines(L, includeInSplice); + } else { + if (includeInSplice && !inSplice) { + enterSplice(); + } + if (inSplice) { + putCurLineInSplice(); + } + curCol += N; + //debugPrint("skip"); + } + } + } + + function removeLines(L) { + var removed = ''; + if (L) { + if (!inSplice) { + enterSplice(); + } + + function nextKLinesText(k) { + var m = curSplice[0] + curSplice[1]; + return lines_slice(m, m + k).join(''); + } + if (isCurLineInSplice()) { + //print(curCol); + if (curCol == 0) { + removed = curSplice[curSplice.length - 1]; + // print("FOO"); // case foo + curSplice.length--; + removed += nextKLinesText(L - 1); + curSplice[1] += L - 1; + } else { + removed = nextKLinesText(L - 1); + curSplice[1] += L - 1; + var sline = curSplice.length - 1; + removed = curSplice[sline].substring(curCol) + removed; + curSplice[sline] = curSplice[sline].substring(0, curCol) + lines_get(curSplice[0] + curSplice[1]); + curSplice[1] += 1; + } + } else { + removed = nextKLinesText(L); + curSplice[1] += L; + } + //debugPrint("remove"); + } + return removed; + } + + function remove(N, L) { + var removed = ''; + if (N) { + if (L) { + return removeLines(L); + } else { + if (!inSplice) { + enterSplice(); + } + var sline = putCurLineInSplice(); + removed = curSplice[sline].substring(curCol, curCol + N); + curSplice[sline] = curSplice[sline].substring(0, curCol) + curSplice[sline].substring(curCol + N); + //debugPrint("remove"); + } + } + return removed; + } + + function insert(text, L) { + if (text) { + if (!inSplice) { + enterSplice(); + } + if (L) { + var newLines = exports.splitTextLines(text); + if (isCurLineInSplice()) { + //if (curCol == 0) { + //curSplice.length--; + //curSplice[1]--; + //Array.prototype.push.apply(curSplice, newLines); + //curLine += newLines.length; + //} + //else { + var sline = curSplice.length - 1; + var theLine = curSplice[sline]; + var lineCol = curCol; + curSplice[sline] = theLine.substring(0, lineCol) + newLines[0]; + curLine++; + newLines.splice(0, 1); + Array.prototype.push.apply(curSplice, newLines); + curLine += newLines.length; + curSplice.push(theLine.substring(lineCol)); + curCol = 0; + //} + } else { + Array.prototype.push.apply(curSplice, newLines); + curLine += newLines.length; + } + } else { + var sline = putCurLineInSplice(); + curSplice[sline] = curSplice[sline].substring(0, curCol) + text + curSplice[sline].substring(curCol); + curCol += text.length; + } + //debugPrint("insert"); + } + } + + function hasMore() { + //print(lines.length+" / "+inSplice+" / "+(curSplice.length - 2)+" / "+curSplice[1]); + var docLines = lines_length(); + if (inSplice) { + docLines += curSplice.length - 2 - curSplice[1]; + } + return curLine < docLines; + } + + function close() { + if (inSplice) { + leaveSplice(); + } + //debugPrint("close"); + } + + var self = { + skip: skip, + remove: remove, + insert: insert, + close: close, + hasMore: hasMore, + removeLines: removeLines, + skipLines: skipLines + }; + return self; +}; + +/** + * 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 + * advances. When opX.opcode = 0, iterator X advances to + * next element + * func has signature f(op1, op2, opOut) + * op1 - current operation of the first iterator + * op2 - current operation of the second iterator + * opOut - result operator to be put into Changeset + * @return {string} the integrated changeset + */ +exports.applyZip = function (in1, idx1, in2, idx2, func) { + var iter1 = exports.opIterator(in1, idx1); + var iter2 = exports.opIterator(in2, idx2); + var assem = exports.smartOpAssembler(); + var op1 = exports.newOp(); + var op2 = exports.newOp(); + var opOut = exports.newOp(); + while (op1.opcode || iter1.hasNext() || op2.opcode || iter2.hasNext()) { + if ((!op1.opcode) && iter1.hasNext()) iter1.next(op1); + if ((!op2.opcode) && iter2.hasNext()) iter2.next(op2); + func(op1, op2, opOut); + if (opOut.opcode) { + //print(opOut.toSource()); + assem.append(opOut); + opOut.opcode = ''; + } + } + assem.endDocument(); + return assem.toString(); +}; + +/** + * Unpacks a string encoded Changeset into a proper Changeset object + * @params cs {string} String encoded Changeset + * @returns {Changeset} a Changeset class + */ +exports.unpack = function (cs) { + var headerRegex = /Z:([0-9a-z]+)([><])([0-9a-z]+)|/; + var headerMatch = headerRegex.exec(cs); + if ((!headerMatch) || (!headerMatch[0])) { + exports.error("Not a exports: " + cs); + } + var oldLen = exports.parseNum(headerMatch[1]); + var changeSign = (headerMatch[2] == '>') ? 1 : -1; + var changeMag = exports.parseNum(headerMatch[3]); + var newLen = oldLen + changeSign * changeMag; + var opsStart = headerMatch[0].length; + var opsEnd = cs.indexOf("$"); + if (opsEnd < 0) opsEnd = cs.length; + return { + oldLen: oldLen, + newLen: newLen, + ops: cs.substring(opsStart, opsEnd), + charBank: cs.substring(opsEnd + 1) + }; +}; + +/** + * 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 + * @params bank {string} Charbank of the Changeset + * @returns {Changeset} a Changeset class + */ +exports.pack = function (oldLen, newLen, opsStr, bank) { + var lenDiff = newLen - oldLen; + var lenDiffStr = (lenDiff >= 0 ? '>' + exports.numToString(lenDiff) : '<' + exports.numToString(-lenDiff)); + var a = []; + a.push('Z:', exports.numToString(oldLen), lenDiffStr, opsStr, '$', bank); + return a.join(''); +}; + +/** + * Applies a Changeset to a string + * @params cs {string} String encoded Changeset + * @params str {string} String to which a Changeset should be applied + */ +exports.applyToText = function (cs, str) { + var unpacked = exports.unpack(cs); + exports.assert(str.length == unpacked.oldLen, "mismatched apply: ", str.length, " / ", unpacked.oldLen); + var csIter = exports.opIterator(unpacked.ops); + var bankIter = exports.stringIterator(unpacked.charBank); + var strIter = exports.stringIterator(str); + var assem = exports.stringAssembler(); + while (csIter.hasNext()) { + var op = csIter.next(); + switch (op.opcode) { + case '+': + assem.append(bankIter.take(op.chars)); + break; + case '-': + strIter.skip(op.chars); + break; + case '=': + assem.append(strIter.take(op.chars)); + break; + } + } + assem.append(strIter.take(strIter.remaining())); + return assem.toString(); +}; + +/** + * applies a changeset on an array of lines + * @param CS {Changeset} the changeset to be applied + * @param lines The lines to which the changeset needs to be applied + */ +exports.mutateTextLines = function (cs, lines) { + var unpacked = exports.unpack(cs); + var csIter = exports.opIterator(unpacked.ops); + var bankIter = exports.stringIterator(unpacked.charBank); + var mut = exports.textLinesMutator(lines); + while (csIter.hasNext()) { + var op = csIter.next(); + switch (op.opcode) { + case '+': + mut.insert(bankIter.take(op.chars), op.lines); + break; + case '-': + mut.remove(op.chars, op.lines); + break; + case '=': + mut.skip(op.chars, op.lines, ( !! op.attribs)); + break; + } + } + mut.close(); +}; + +/** + * 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 + */ +exports.composeAttributes = function (att1, att2, resultIsMutation, pool) { + // att1 and att2 are strings like "*3*f*1c", asMutation is a boolean. + // Sometimes attribute (key,value) pairs are treated as attribute presence + // information, while other times they are treated as operations that + // mutate a set of attributes, and this affects whether an empty value + // is a deletion or a change. + // Examples, of the form (att1Items, att2Items, resultIsMutation) -> result + // ([], [(bold, )], true) -> [(bold, )] + // ([], [(bold, )], false) -> [] + // ([], [(bold, true)], true) -> [(bold, true)] + // ([], [(bold, true)], false) -> [(bold, true)] + // ([(bold, true)], [(bold, )], true) -> [(bold, )] + // ([(bold, true)], [(bold, )], false) -> [] + // pool can be null if att2 has no attributes. + if ((!att1) && resultIsMutation) { + // In the case of a mutation (i.e. composing two exportss), + // an att2 composed with an empy att1 is just att2. If att1 + // is part of an attribution string, then att2 may remove + // attributes that are already gone, so don't do this optimization. + return att2; + } + if (!att2) return att1; + var atts = []; + att1.replace(/\*([0-9a-z]+)/g, function (_, a) { + atts.push(pool.getAttrib(exports.parseNum(a))); + return ''; + }); + att2.replace(/\*([0-9a-z]+)/g, function (_, a) { + var pair = pool.getAttrib(exports.parseNum(a)); + var found = false; + for (var i = 0; i < atts.length; i++) { + var oldPair = atts[i]; + if (oldPair[0] == pair[0]) { + if (pair[1] || resultIsMutation) { + oldPair[1] = pair[1]; + } else { + atts.splice(i, 1); + } + found = true; + break; + } + } + if ((!found) && (pair[1] || resultIsMutation)) { + atts.push(pair); + } + return ''; + }); + atts.sort(); + var buf = exports.stringAssembler(); + for (var i = 0; i < atts.length; i++) { + buf.append('*'); + buf.append(exports.numToString(pool.putAttrib(atts[i]))); + } + //print(att1+" / "+att2+" / "+buf.toString()); + return buf.toString(); +}; + +/** + * 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 + // attribution string or the earlier of two exportss being composed. + // pool can be null if definitely not needed. + //print(csOp.toSource()+" "+attOp.toSource()+" "+opOut.toSource()); + if (attOp.opcode == '-') { + exports.copyOp(attOp, opOut); + attOp.opcode = ''; + } else if (!attOp.opcode) { + exports.copyOp(csOp, opOut); + csOp.opcode = ''; + } else { + switch (csOp.opcode) { + case '-': + { + if (csOp.chars <= attOp.chars) { + // delete or delete part + if (attOp.opcode == '=') { + opOut.opcode = '-'; + opOut.chars = csOp.chars; + opOut.lines = csOp.lines; + opOut.attribs = ''; + } + attOp.chars -= csOp.chars; + attOp.lines -= csOp.lines; + csOp.opcode = ''; + if (!attOp.chars) { + attOp.opcode = ''; + } + } else { + // delete and keep going + if (attOp.opcode == '=') { + opOut.opcode = '-'; + opOut.chars = attOp.chars; + opOut.lines = attOp.lines; + opOut.attribs = ''; + } + csOp.chars -= attOp.chars; + csOp.lines -= attOp.lines; + attOp.opcode = ''; + } + break; + } + case '+': + { + // insert + exports.copyOp(csOp, opOut); + csOp.opcode = ''; + break; + } + case '=': + { + if (csOp.chars <= attOp.chars) { + // keep or keep part + opOut.opcode = attOp.opcode; + opOut.chars = csOp.chars; + opOut.lines = csOp.lines; + opOut.attribs = exports.composeAttributes(attOp.attribs, csOp.attribs, attOp.opcode == '=', pool); + csOp.opcode = ''; + attOp.chars -= csOp.chars; + attOp.lines -= csOp.lines; + if (!attOp.chars) { + attOp.opcode = ''; + } + } else { + // keep and keep going + opOut.opcode = attOp.opcode; + opOut.chars = attOp.chars; + opOut.lines = attOp.lines; + opOut.attribs = exports.composeAttributes(attOp.attribs, csOp.attribs, attOp.opcode == '=', pool); + attOp.opcode = ''; + csOp.chars -= attOp.chars; + csOp.lines -= attOp.lines; + } + break; + } + case '': + { + exports.copyOp(attOp, opOut); + attOp.opcode = ''; + break; + } + } + } +}; + +/** + * Applies a Changeset to the attribs string of a AText. + * @param cs {string} Changeset + * @param astr {string} the attribs string of a AText + * @param pool {AttribsPool} the attibutes pool + */ +exports.applyToAttribution = function (cs, astr, pool) { + var unpacked = exports.unpack(cs); + + return exports.applyZip(astr, 0, unpacked.ops, 0, function (op1, op2, opOut) { + return exports._slicerZipperFunc(op1, op2, opOut, pool); + }); +}; + +/*exports.oneInsertedLineAtATimeOpIterator = function(opsStr, optStartIndex, charBank) { + var iter = exports.opIterator(opsStr, optStartIndex); + var bankIndex = 0; + +};*/ + +exports.mutateAttributionLines = function (cs, lines, pool) { + //dmesg(cs); + //dmesg(lines.toSource()+" ->"); + var unpacked = exports.unpack(cs); + var csIter = exports.opIterator(unpacked.ops); + var csBank = unpacked.charBank; + var csBankIndex = 0; + // treat the attribution lines as text lines, mutating a line at a time + var mut = exports.textLinesMutator(lines); + + var lineIter = null; + + function isNextMutOp() { + return (lineIter && lineIter.hasNext()) || mut.hasMore(); + } + + function nextMutOp(destOp) { + if ((!(lineIter && lineIter.hasNext())) && mut.hasMore()) { + var line = mut.removeLines(1); + lineIter = exports.opIterator(line); + } + if (lineIter && lineIter.hasNext()) { + lineIter.next(destOp); + } else { + destOp.opcode = ''; + } + } + var lineAssem = null; + + function outputMutOp(op) { + //print("outputMutOp: "+op.toSource()); + if (!lineAssem) { + lineAssem = exports.mergingOpAssembler(); + } + lineAssem.append(op); + if (op.lines > 0) { + exports.assert(op.lines == 1, "Can't have op.lines of ", op.lines, " in attribution lines"); + // ship it to the mut + mut.insert(lineAssem.toString(), 1); + lineAssem = null; + } + } + + var csOp = exports.newOp(); + var attOp = exports.newOp(); + var opOut = exports.newOp(); + while (csOp.opcode || csIter.hasNext() || attOp.opcode || isNextMutOp()) { + if ((!csOp.opcode) && csIter.hasNext()) { + csIter.next(csOp); + } + //print(csOp.toSource()+" "+attOp.toSource()+" "+opOut.toSource()); + //print(csOp.opcode+"/"+csOp.lines+"/"+csOp.attribs+"/"+lineAssem+"/"+lineIter+"/"+(lineIter?lineIter.hasNext():null)); + //print("csOp: "+csOp.toSource()); + if ((!csOp.opcode) && (!attOp.opcode) && (!lineAssem) && (!(lineIter && lineIter.hasNext()))) { + break; // done + } else if (csOp.opcode == '=' && csOp.lines > 0 && (!csOp.attribs) && (!attOp.opcode) && (!lineAssem) && (!(lineIter && lineIter.hasNext()))) { + // skip multiple lines; this is what makes small changes not order of the document size + mut.skipLines(csOp.lines); + //print("skipped: "+csOp.lines); + csOp.opcode = ''; + } else if (csOp.opcode == '+') { + if (csOp.lines > 1) { + var firstLineLen = csBank.indexOf('\n', csBankIndex) + 1 - csBankIndex; + exports.copyOp(csOp, opOut); + csOp.chars -= firstLineLen; + csOp.lines--; + opOut.lines = 1; + opOut.chars = firstLineLen; + } else { + exports.copyOp(csOp, opOut); + csOp.opcode = ''; + } + outputMutOp(opOut); + csBankIndex += opOut.chars; + opOut.opcode = ''; + } else { + if ((!attOp.opcode) && isNextMutOp()) { + nextMutOp(attOp); + } + //print("attOp: "+attOp.toSource()); + exports._slicerZipperFunc(attOp, csOp, opOut, pool); + if (opOut.opcode) { + outputMutOp(opOut); + opOut.opcode = ''; + } + } + } + + exports.assert(!lineAssem, "line assembler not finished"); + mut.close(); + + //dmesg("-> "+lines.toSource()); +}; + +/** + * joins several Attribution lines + * @param theAlines collection of Attribution lines + * @returns {string} joined Attribution lines + */ +exports.joinAttributionLines = function (theAlines) { + var assem = exports.mergingOpAssembler(); + for (var i = 0; i < theAlines.length; i++) { + var aline = theAlines[i]; + var iter = exports.opIterator(aline); + while (iter.hasNext()) { + assem.append(iter.next()); + } + } + return assem.toString(); +}; + +exports.splitAttributionLines = function (attrOps, text) { + var iter = exports.opIterator(attrOps); + var assem = exports.mergingOpAssembler(); + var lines = []; + var pos = 0; + + function appendOp(op) { + assem.append(op); + if (op.lines > 0) { + lines.push(assem.toString()); + assem.clear(); + } + pos += op.chars; + } + + while (iter.hasNext()) { + var op = iter.next(); + var numChars = op.chars; + var numLines = op.lines; + while (numLines > 1) { + var newlineEnd = text.indexOf('\n', pos) + 1; + exports.assert(newlineEnd > 0, "newlineEnd <= 0 in splitAttributionLines"); + op.chars = newlineEnd - pos; + op.lines = 1; + appendOp(op); + numChars -= op.chars; + numLines -= op.lines; + } + if (numLines == 1) { + op.chars = numChars; + op.lines = 1; + } + appendOp(op); + } + + return lines; +}; + +/** + * splits text into lines + * @param {string} text to be splitted + */ +exports.splitTextLines = function (text) { + return text.match(/[^\n]*(?:\n|[^\n]$)/g); +}; + +/** + * compose two Changesets + * @param cs1 {Changeset} first Changeset + * @param cs2 {Changeset} second Changeset + * @param pool {AtribsPool} Attribs pool + */ +exports.compose = function (cs1, cs2, pool) { + var unpacked1 = exports.unpack(cs1); + var unpacked2 = exports.unpack(cs2); + var len1 = unpacked1.oldLen; + var len2 = unpacked1.newLen; + exports.assert(len2 == unpacked2.oldLen, "mismatched composition"); + var len3 = unpacked2.newLen; + var bankIter1 = exports.stringIterator(unpacked1.charBank); + var bankIter2 = exports.stringIterator(unpacked2.charBank); + var bankAssem = exports.stringAssembler(); + + var newOps = exports.applyZip(unpacked1.ops, 0, unpacked2.ops, 0, function (op1, op2, opOut) { + //var debugBuilder = exports.stringAssembler(); + //debugBuilder.append(exports.opString(op1)); + //debugBuilder.append(','); + //debugBuilder.append(exports.opString(op2)); + //debugBuilder.append(' / '); + var op1code = op1.opcode; + var op2code = op2.opcode; + if (op1code == '+' && op2code == '-') { + bankIter1.skip(Math.min(op1.chars, op2.chars)); + } + exports._slicerZipperFunc(op1, op2, opOut, pool); + if (opOut.opcode == '+') { + if (op2code == '+') { + bankAssem.append(bankIter2.take(opOut.chars)); + } else { + bankAssem.append(bankIter1.take(opOut.chars)); + } + } + + //debugBuilder.append(exports.opString(op1)); + //debugBuilder.append(','); + //debugBuilder.append(exports.opString(op2)); + //debugBuilder.append(' -> '); + //debugBuilder.append(exports.opString(opOut)); + //print(debugBuilder.toString()); + }); + + return exports.pack(len1, len3, newOps, bankAssem.toString()); +}; + +/** + * 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 pool {AttribPool} Attribute pool + */ +exports.attributeTester = function (attribPair, pool) { + if (!pool) { + return never; + } + var attribNum = pool.putAttrib(attribPair, true); + if (attribNum < 0) { + return never; + } else { + var re = new RegExp('\\*' + exports.numToString(attribNum) + '(?!\\w)'); + return function (attribs) { + return re.test(attribs); + }; + } + + function never(attribs) { + return false; + } +}; + +/** + * creates the identity Changeset of length N + * @param N {int} length of the identity changeset + */ +exports.identity = function (N) { + return exports.pack(N, 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 + * for the new text. + * @param oldFullText {string} old text + * @param spliecStart {int} where splicing starts + * @param numRemoved {int} number of characters to be removed + * @param newText {string} string to be inserted + * @param optNewTextAPairs {string} new pairs to be inserted + * @param pool {AttribPool} Attribution Pool + */ +exports.makeSplice = function (oldFullText, spliceStart, numRemoved, newText, optNewTextAPairs, pool) { + var oldLen = oldFullText.length; + + if (spliceStart >= oldLen) { + spliceStart = oldLen - 1; + } + if (numRemoved > oldFullText.length - spliceStart - 1) { + numRemoved = oldFullText.length - spliceStart - 1; + } + var oldText = oldFullText.substring(spliceStart, spliceStart + numRemoved); + var newLen = oldLen + newText.length - oldText.length; + + var assem = exports.smartOpAssembler(); + assem.appendOpWithText('=', oldFullText.substring(0, spliceStart)); + assem.appendOpWithText('-', oldText); + assem.appendOpWithText('+', newText, optNewTextAPairs, pool); + assem.endDocument(); + return exports.pack(oldLen, newLen, assem.toString(), newText); +}; + +/** + * Transforms a changeset into a list of splices in the form + * [startChar, endChar, newText] meaning replace text from + * startChar to endChar with newText + * @param cs Changeset + */ +exports.toSplices = function (cs) { + // + var unpacked = exports.unpack(cs); + var splices = []; + + var oldPos = 0; + var iter = exports.opIterator(unpacked.ops); + var charIter = exports.stringIterator(unpacked.charBank); + var inSplice = false; + while (iter.hasNext()) { + var op = iter.next(); + if (op.opcode == '=') { + oldPos += op.chars; + inSplice = false; + } else { + if (!inSplice) { + splices.push([oldPos, oldPos, ""]); + inSplice = true; + } + if (op.opcode == '-') { + oldPos += op.chars; + splices[splices.length - 1][1] += op.chars; + } else if (op.opcode == '+') { + splices[splices.length - 1][2] += charIter.take(op.chars); + } + } + } + + return splices; +}; + +/** + * + */ +exports.characterRangeFollow = function (cs, startChar, endChar, insertionsAfter) { + var newStartChar = startChar; + var newEndChar = endChar; + var splices = exports.toSplices(cs); + var lengthChangeSoFar = 0; + for (var i = 0; i < splices.length; i++) { + var splice = splices[i]; + var spliceStart = splice[0] + lengthChangeSoFar; + var spliceEnd = splice[1] + lengthChangeSoFar; + var newTextLength = splice[2].length; + var thisLengthChange = newTextLength - (spliceEnd - spliceStart); + + if (spliceStart <= newStartChar && spliceEnd >= newEndChar) { + // splice fully replaces/deletes range + // (also case that handles insertion at a collapsed selection) + if (insertionsAfter) { + newStartChar = newEndChar = spliceStart; + } else { + newStartChar = newEndChar = spliceStart + newTextLength; + } + } else if (spliceEnd <= newStartChar) { + // splice is before range + newStartChar += thisLengthChange; + newEndChar += thisLengthChange; + } else if (spliceStart >= newEndChar) { + // splice is after range + } else if (spliceStart >= newStartChar && spliceEnd <= newEndChar) { + // splice is inside range + newEndChar += thisLengthChange; + } else if (spliceEnd < newEndChar) { + // splice overlaps beginning of range + newStartChar = spliceStart + newTextLength; + newEndChar += thisLengthChange; + } else { + // splice overlaps end of range + newEndChar = spliceStart; + } + + lengthChangeSoFar += thisLengthChange; + } + + return [newStartChar, newEndChar]; +}; + +/** + * Iterate over attributes in a changeset and move them from + * oldPool to newPool + * @param cs {Changeset} Chageset/attribution string to be iterated over + * @param oldPool {AttribPool} old attributes pool + * @param newPool {AttribPool} new attributes pool + * @return {string} the new Changeset + */ +exports.moveOpsToNewPool = function (cs, oldPool, newPool) { + // works on exports or attribution string + var dollarPos = cs.indexOf('$'); + if (dollarPos < 0) { + dollarPos = cs.length; + } + var upToDollar = cs.substring(0, dollarPos); + var fromDollar = cs.substring(dollarPos); + // order of attribs stays the same + return upToDollar.replace(/\*([0-9a-z]+)/g, function (_, a) { + var oldNum = exports.parseNum(a); + var pair = oldPool.getAttrib(oldNum); + var newNum = newPool.putAttrib(pair); + return '*' + exports.numToString(newNum); + }) + fromDollar; +}; + +/** + * create an attribution inserting a text + * @param text {string} text to be inserted + */ +exports.makeAttribution = function (text) { + var assem = exports.smartOpAssembler(); + assem.appendOpWithText('+', text); + return assem.toString(); +}; + +/** + * Iterates over attributes in exports, attribution string, or attribs property of an op + * 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) { + dollarPos = cs.length; + } + var upToDollar = cs.substring(0, dollarPos); + + upToDollar.replace(/\*([0-9a-z]+)/g, function (_, a) { + func(exports.parseNum(a)); + return ''; + }); +}; + +/** + * Filter attributes which should remain in a Changeset + * 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 + * 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 + */ +exports.mapAttribNumbers = function (cs, func) { + var dollarPos = cs.indexOf('$'); + if (dollarPos < 0) { + dollarPos = cs.length; + } + var upToDollar = cs.substring(0, dollarPos); + + var newUpToDollar = upToDollar.replace(/\*([0-9a-z]+)/g, function (s, a) { + var n = func(exports.parseNum(a)); + if (n === true) { + return s; + } else if ((typeof n) === "number") { + return '*' + exports.numToString(n); + } else { + return ''; + } + }); + + return newUpToDollar + cs.substring(dollarPos); +}; + +/** + * 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 + * the text and also puts the right attributes + */ +exports.makeAText = function (text, attribs) { + return { + text: text, + attribs: (attribs || exports.makeAttribution(text)) + }; +}; + +/** + * Apply a Changeset to a AText + * @param cs {Changeset} Changeset to be applied + * @param atext {AText} + * @param pool {AttribPool} Attribute Pool to add to + */ +exports.applyToAText = function (cs, atext, pool) { + return { + text: exports.applyToText(cs, atext.text), + attribs: exports.applyToAttribution(cs, atext.attribs, pool) + }; +}; + +/** + * Clones a AText structure + * @param atext {AText} + */ +exports.cloneAText = function (atext) { + return { + text: atext.text, + attribs: atext.attribs + }; +}; + +/** + * Copies a AText structure from atext1 to atext2 + * @param atext {AText} + */ +exports.copyAText = function (atext1, atext2) { + atext2.text = atext1.text; + atext2.attribs = atext1.attribs; +}; + +/** + * Append the set of operations from atext to an assembler + * @param atext {AText} + * @param assem Assembler like smartOpAssembler + */ +exports.appendATextToAssembler = function (atext, assem) { + // intentionally skips last newline char of atext + var iter = exports.opIterator(atext.attribs); + var op = exports.newOp(); + while (iter.hasNext()) { + iter.next(op); + if (!iter.hasNext()) { + // last op, exclude final newline + if (op.lines <= 1) { + op.lines = 0; + op.chars--; + if (op.chars) { + assem.append(op); + } + } else { + var nextToLastNewlineEnd = + atext.text.lastIndexOf('\n', atext.text.length - 2) + 1; + var lastLineLength = atext.text.length - nextToLastNewlineEnd - 1; + op.lines--; + op.chars -= (lastLineLength + 1); + assem.append(op); + op.lines = 0; + op.chars = lastLineLength; + if (op.chars) { + assem.append(op); + } + } + } else { + assem.append(op); + } + } +}; + +/** + * Creates a clone of a Changeset and it's APool + * @param cs {Changeset} + * @param pool {AtributePool} + */ +exports.prepareForWire = function (cs, pool) { + var newPool = new AttributePool(); + var newCs = exports.moveOpsToNewPool(cs, pool, newPool); + return { + translated: newCs, + pool: newPool + }; +}; + +/** + * Checks if a changeset s the identity changeset + */ +exports.isIdentity = function (cs) { + var unpacked = exports.unpack(cs); + return unpacked.ops == "" && unpacked.oldLen == unpacked.newLen; +}; + +/** + * 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 + */ +exports.opAttributeValue = function (op, key, pool) { + return exports.attribsAttributeValue(op.attribs, key, pool); +}; + +/** + * 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 + */ +exports.attribsAttributeValue = function (attribs, key, pool) { + var value = ''; + if (attribs) { + exports.eachAttribNumber(attribs, function (n) { + if (pool.getAttribKey(n) == key) { + value = pool.getAttribValue(n); + } + }); + } + return value; +}; + +/** + * Creates a Changeset builder for a string with initial + * length oldLen. Allows to add/remove parts of it + * @param oldLen {int} Old length + */ +exports.builder = function (oldLen) { + var assem = exports.smartOpAssembler(); + var o = exports.newOp(); + var charBank = exports.stringAssembler(); + + var self = { + // attribs are [[key1,value1],[key2,value2],...] or '*0*1...' (no pool needed in latter case) + keep: function (N, L, attribs, pool) { + o.opcode = '='; + o.attribs = (attribs && exports.makeAttribsString('=', attribs, pool)) || ''; + o.chars = N; + o.lines = (L || 0); + assem.append(o); + return self; + }, + keepText: function (text, attribs, pool) { + assem.appendOpWithText('=', text, attribs, pool); + return self; + }, + insert: function (text, attribs, pool) { + assem.appendOpWithText('+', text, attribs, pool); + charBank.append(text); + return self; + }, + remove: function (N, L) { + o.opcode = '-'; + o.attribs = ''; + o.chars = N; + o.lines = (L || 0); + assem.append(o); + return self; + }, + toString: function () { + assem.endDocument(); + var newLen = oldLen + assem.getLengthChange(); + return exports.pack(oldLen, newLen, assem.toString(), charBank.toString()); + } + }; + + return self; +}; + +exports.makeAttribsString = function (opcode, attribs, pool) { + // makeAttribsString(opcode, '*3') or makeAttribsString(opcode, [['foo','bar']], myPool) work + if (!attribs) { + return ''; + } else if ((typeof attribs) == "string") { + return attribs; + } else if (pool && attribs && attribs.length) { + if (attribs.length > 1) { + attribs = attribs.slice(); + attribs.sort(); + } + var result = []; + for (var i = 0; i < attribs.length; i++) { + var pair = attribs[i]; + if (opcode == '=' || (opcode == '+' && pair[1])) { + result.push('*' + exports.numToString(pool.putAttrib(pair))); + } + } + return result.join(''); + } +}; + +// like "substring" but on a single-line attribution string +exports.subattribution = function (astr, start, optEnd) { + var iter = exports.opIterator(astr, 0); + var assem = exports.smartOpAssembler(); + var attOp = exports.newOp(); + var csOp = exports.newOp(); + var opOut = exports.newOp(); + + function doCsOp() { + if (csOp.chars) { + while (csOp.opcode && (attOp.opcode || iter.hasNext())) { + if (!attOp.opcode) iter.next(attOp); + + if (csOp.opcode && attOp.opcode && csOp.chars >= attOp.chars && attOp.lines > 0 && csOp.lines <= 0) { + csOp.lines++; + } + + exports._slicerZipperFunc(attOp, csOp, opOut, null); + if (opOut.opcode) { + assem.append(opOut); + opOut.opcode = ''; + } + } + } + } + + csOp.opcode = '-'; + csOp.chars = start; + + doCsOp(); + + if (optEnd === undefined) { + if (attOp.opcode) { + assem.append(attOp); + } + while (iter.hasNext()) { + iter.next(attOp); + assem.append(attOp); + } + } else { + csOp.opcode = '='; + csOp.chars = optEnd - start; + doCsOp(); + } + + return assem.toString(); +}; + +exports.inverse = function (cs, lines, alines, pool) { + // lines and alines are what the exports is meant to apply to. + // They may be arrays or objects with .get(i) and .length methods. + // They include final newlines on lines. + + function lines_get(idx) { + if (lines.get) { + return lines.get(idx); + } else { + return lines[idx]; + } + } + + function lines_length() { + if ((typeof lines.length) == "number") { + return lines.length; + } else { + return lines.length(); + } + } + + function alines_get(idx) { + if (alines.get) { + return alines.get(idx); + } else { + return alines[idx]; + } + } + + function alines_length() { + if ((typeof alines.length) == "number") { + return alines.length; + } else { + return alines.length(); + } + } + + var curLine = 0; + var curChar = 0; + var curLineOpIter = null; + var curLineOpIterLine; + var curLineNextOp = exports.newOp('+'); + + var unpacked = exports.unpack(cs); + var csIter = exports.opIterator(unpacked.ops); + var builder = exports.builder(unpacked.newLen); + + function consumeAttribRuns(numChars, func /*(len, attribs, endsLine)*/ ) { + + if ((!curLineOpIter) || (curLineOpIterLine != curLine)) { + // create curLineOpIter and advance it to curChar + curLineOpIter = exports.opIterator(alines_get(curLine)); + curLineOpIterLine = curLine; + var indexIntoLine = 0; + var done = false; + while (!done) { + curLineOpIter.next(curLineNextOp); + if (indexIntoLine + curLineNextOp.chars >= curChar) { + curLineNextOp.chars -= (curChar - indexIntoLine); + done = true; + } else { + indexIntoLine += curLineNextOp.chars; + } + } + } + + while (numChars > 0) { + if ((!curLineNextOp.chars) && (!curLineOpIter.hasNext())) { + curLine++; + curChar = 0; + curLineOpIterLine = curLine; + curLineNextOp.chars = 0; + curLineOpIter = exports.opIterator(alines_get(curLine)); + } + if (!curLineNextOp.chars) { + curLineOpIter.next(curLineNextOp); + } + var charsToUse = Math.min(numChars, curLineNextOp.chars); + func(charsToUse, curLineNextOp.attribs, charsToUse == curLineNextOp.chars && curLineNextOp.lines > 0); + numChars -= charsToUse; + curLineNextOp.chars -= charsToUse; + curChar += charsToUse; + } + + if ((!curLineNextOp.chars) && (!curLineOpIter.hasNext())) { + curLine++; + curChar = 0; + } + } + + function skip(N, L) { + if (L) { + curLine += L; + curChar = 0; + } else { + if (curLineOpIter && curLineOpIterLine == curLine) { + consumeAttribRuns(N, function () {}); + } else { + curChar += N; + } + } + } + + function nextText(numChars) { + var len = 0; + var assem = exports.stringAssembler(); + var firstString = lines_get(curLine).substring(curChar); + len += firstString.length; + assem.append(firstString); + + var lineNum = curLine + 1; + while (len < numChars) { + var nextString = lines_get(lineNum); + len += nextString.length; + assem.append(nextString); + lineNum++; + } + + return assem.toString().substring(0, numChars); + } + + function cachedStrFunc(func) { + var cache = {}; + return function (s) { + if (!cache[s]) { + cache[s] = func(s); + } + return cache[s]; + }; + } + + var attribKeys = []; + var attribValues = []; + while (csIter.hasNext()) { + var csOp = csIter.next(); + if (csOp.opcode == '=') { + if (csOp.attribs) { + attribKeys.length = 0; + attribValues.length = 0; + exports.eachAttribNumber(csOp.attribs, function (n) { + attribKeys.push(pool.getAttribKey(n)); + attribValues.push(pool.getAttribValue(n)); + }); + var undoBackToAttribs = cachedStrFunc(function (attribs) { + var backAttribs = []; + for (var i = 0; i < attribKeys.length; i++) { + var appliedKey = attribKeys[i]; + var appliedValue = attribValues[i]; + var oldValue = exports.attribsAttributeValue(attribs, appliedKey, pool); + if (appliedValue != oldValue) { + backAttribs.push([appliedKey, oldValue]); + } + } + return exports.makeAttribsString('=', backAttribs, pool); + }); + consumeAttribRuns(csOp.chars, function (len, attribs, endsLine) { + builder.keep(len, endsLine ? 1 : 0, undoBackToAttribs(attribs)); + }); + } else { + skip(csOp.chars, csOp.lines); + builder.keep(csOp.chars, csOp.lines); + } + } else if (csOp.opcode == '+') { + builder.remove(csOp.chars, csOp.lines); + } else if (csOp.opcode == '-') { + var textBank = nextText(csOp.chars); + var textBankIndex = 0; + consumeAttribRuns(csOp.chars, function (len, attribs, endsLine) { + builder.insert(textBank.substr(textBankIndex, len), attribs); + textBankIndex += len; + }); + } + } + + return exports.checkRep(builder.toString()); +}; + +// %CLIENT FILE ENDS HERE% +exports.follow = function (cs1, cs2, reverseInsertOrder, pool) { + var unpacked1 = exports.unpack(cs1); + var unpacked2 = exports.unpack(cs2); + var len1 = unpacked1.oldLen; + var len2 = unpacked2.oldLen; + exports.assert(len1 == len2, "mismatched follow"); + var chars1 = exports.stringIterator(unpacked1.charBank); + var chars2 = exports.stringIterator(unpacked2.charBank); + + var oldLen = unpacked1.newLen; + var oldPos = 0; + var newLen = 0; + + var hasInsertFirst = exports.attributeTester(['insertorder', 'first'], pool); + + var newOps = exports.applyZip(unpacked1.ops, 0, unpacked2.ops, 0, function (op1, op2, opOut) { + if (op1.opcode == '+' || op2.opcode == '+') { + var whichToDo; + if (op2.opcode != '+') { + whichToDo = 1; + } else if (op1.opcode != '+') { + whichToDo = 2; + } else { + // both + + var firstChar1 = chars1.peek(1); + var firstChar2 = chars2.peek(1); + var insertFirst1 = hasInsertFirst(op1.attribs); + var insertFirst2 = hasInsertFirst(op2.attribs); + if (insertFirst1 && !insertFirst2) { + whichToDo = 1; + } else if (insertFirst2 && !insertFirst1) { + whichToDo = 2; + } + // insert string that doesn't start with a newline first so as not to break up lines + else if (firstChar1 == '\n' && firstChar2 != '\n') { + whichToDo = 2; + } else if (firstChar1 != '\n' && firstChar2 == '\n') { + whichToDo = 1; + } + // break symmetry: + else if (reverseInsertOrder) { + whichToDo = 2; + } else { + whichToDo = 1; + } + } + if (whichToDo == 1) { + chars1.skip(op1.chars); + opOut.opcode = '='; + opOut.lines = op1.lines; + opOut.chars = op1.chars; + opOut.attribs = ''; + op1.opcode = ''; + } else { + // whichToDo == 2 + chars2.skip(op2.chars); + exports.copyOp(op2, opOut); + op2.opcode = ''; + } + } else if (op1.opcode == '-') { + if (!op2.opcode) { + op1.opcode = ''; + } else { + if (op1.chars <= op2.chars) { + op2.chars -= op1.chars; + op2.lines -= op1.lines; + op1.opcode = ''; + if (!op2.chars) { + op2.opcode = ''; + } + } else { + op1.chars -= op2.chars; + op1.lines -= op2.lines; + op2.opcode = ''; + } + } + } else if (op2.opcode == '-') { + exports.copyOp(op2, opOut); + if (!op1.opcode) { + op2.opcode = ''; + } else if (op2.chars <= op1.chars) { + // delete part or all of a keep + op1.chars -= op2.chars; + op1.lines -= op2.lines; + op2.opcode = ''; + if (!op1.chars) { + op1.opcode = ''; + } + } else { + // delete all of a keep, and keep going + opOut.lines = op1.lines; + opOut.chars = op1.chars; + op2.lines -= op1.lines; + op2.chars -= op1.chars; + op1.opcode = ''; + } + } else if (!op1.opcode) { + exports.copyOp(op2, opOut); + op2.opcode = ''; + } else if (!op2.opcode) { + exports.copyOp(op1, opOut); + op1.opcode = ''; + } else { + // both keeps + opOut.opcode = '='; + opOut.attribs = exports.followAttributes(op1.attribs, op2.attribs, pool); + if (op1.chars <= op2.chars) { + opOut.chars = op1.chars; + opOut.lines = op1.lines; + op2.chars -= op1.chars; + op2.lines -= op1.lines; + op1.opcode = ''; + if (!op2.chars) { + op2.opcode = ''; + } + } else { + opOut.chars = op2.chars; + opOut.lines = op2.lines; + op1.chars -= op2.chars; + op1.lines -= op2.lines; + op2.opcode = ''; + } + } + switch (opOut.opcode) { + case '=': + oldPos += opOut.chars; + newLen += opOut.chars; + break; + case '-': + oldPos += opOut.chars; + break; + case '+': + newLen += opOut.chars; + break; + } + }); + newLen += oldLen - oldPos; + + return exports.pack(oldLen, newLen, newOps, unpacked2.charBank); +}; + +exports.followAttributes = function (att1, att2, pool) { + // The merge of two sets of attribute changes to the same text + // takes the lexically-earlier value if there are two values + // for the same key. Otherwise, all key/value changes from + // both attribute sets are taken. This operation is the "follow", + // so a set of changes is produced that can be applied to att1 + // to produce the merged set. + if ((!att2) || (!pool)) return ''; + if (!att1) return att2; + var atts = []; + att2.replace(/\*([0-9a-z]+)/g, function (_, a) { + atts.push(pool.getAttrib(exports.parseNum(a))); + return ''; + }); + att1.replace(/\*([0-9a-z]+)/g, function (_, a) { + var pair1 = pool.getAttrib(exports.parseNum(a)); + for (var i = 0; i < atts.length; i++) { + var pair2 = atts[i]; + if (pair1[0] == pair2[0]) { + if (pair1[1] <= pair2[1]) { + // winner of merge is pair1, delete this attribute + atts.splice(i, 1); + } + break; + } + } + return ''; + }); + // we've only removed attributes, so they're already sorted + var buf = exports.stringAssembler(); + for (var i = 0; i < atts.length; i++) { + buf.append('*'); + buf.append(exports.numToString(pool.putAttrib(atts[i]))); + } + return buf.toString(); +}; diff --git a/src/static/js/ChangesetUtils.js b/src/static/js/ChangesetUtils.js new file mode 100644 index 000000000..e0b67881f --- /dev/null +++ b/src/static/js/ChangesetUtils.js @@ -0,0 +1,60 @@ +/** + * This module contains several helper Functions to build Changesets + * based on a SkipList + */ + +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +exports.buildRemoveRange = function(rep, builder, start, end) +{ + var startLineOffset = rep.lines.offsetOfIndex(start[0]); + var endLineOffset = rep.lines.offsetOfIndex(end[0]); + + if (end[0] > start[0]) + { + builder.remove(endLineOffset - startLineOffset - start[1], end[0] - start[0]); + builder.remove(end[1]); + } + else + { + builder.remove(end[1] - start[1]); + } +} + +exports.buildKeepRange = function(rep, builder, start, end, attribs, pool) +{ + var startLineOffset = rep.lines.offsetOfIndex(start[0]); + var endLineOffset = rep.lines.offsetOfIndex(end[0]); + + if (end[0] > start[0]) + { + builder.keep(endLineOffset - startLineOffset - start[1], end[0] - start[0], attribs, pool); + builder.keep(end[1], 0, attribs, pool); + } + else + { + builder.keep(end[1] - start[1], 0, attribs, pool); + } +} + +exports.buildKeepToStartOfRange = function(rep, builder, start) +{ + var startLineOffset = rep.lines.offsetOfIndex(start[0]); + + builder.keep(startLineOffset, start[0]); + builder.keep(start[1]); +} + diff --git a/src/static/js/ace.js b/src/static/js/ace.js new file mode 100644 index 000000000..d7414c17d --- /dev/null +++ b/src/static/js/ace.js @@ -0,0 +1,323 @@ +/** + * 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 + */ + +/** + * Copyright 2009 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS-IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// requires: top +// requires: plugins +// requires: undefined + +Ace2Editor.registry = { + nextId: 1 +}; + +var hooks = require('./pluginfw/hooks'); +var _ = require('./underscore'); + +function Ace2Editor() +{ + var ace2 = Ace2Editor; + + var editor = {}; + var info = { + editor: editor, + id: (ace2.registry.nextId++) + }; + var loaded = false; + + var actionsPendingInit = []; + + function pendingInit(func, optDoNow) + { + return function() + { + var that = this; + var args = arguments; + var action = function() + { + func.apply(that, args); + } + if (optDoNow) + { + optDoNow.apply(that, args); + } + if (loaded) + { + action(); + } + else + { + actionsPendingInit.push(action); + } + }; + } + + function doActionsPendingInit() + { + _.each(actionsPendingInit, function(fn,i){ + fn() + }); + actionsPendingInit = []; + } + + ace2.registry[info.id] = info; + + // The following functions (prefixed by 'ace_') are exposed by editor, but + // execution is delayed until init is complete + var aceFunctionsPendingInit = ['importText', 'importAText', 'focus', + 'setEditable', 'getFormattedCode', 'setOnKeyPress', 'setOnKeyDown', + 'setNotifyDirty', 'setProperty', 'setBaseText', 'setBaseAttributedText', + 'applyChangesToBase', 'applyPreparedChangesetToBase', + 'setUserChangeNotificationCallback', 'setAuthorInfo', + 'setAuthorSelectionRange', 'callWithAce', 'execCommand', 'replaceRange']; + + _.each(aceFunctionsPendingInit, function(fnName,i){ + var prefix = 'ace_'; + var name = prefix + fnName; + editor[fnName] = pendingInit(function(){ + info[prefix + fnName].apply(this, arguments); + }); + }); + + editor.exportText = function() + { + if (!loaded) return "(awaiting init)\n"; + return info.ace_exportText(); + }; + + editor.getFrame = function() + { + return info.frame || null; + }; + + editor.getDebugProperty = function(prop) + { + return info.ace_getDebugProperty(prop); + }; + + // prepareUserChangeset: + // Returns null if no new changes or ACE not ready. Otherwise, bundles up all user changes + // to the latest base text into a Changeset, which is returned (as a string if encodeAsString). + // If this method returns a truthy value, then applyPreparedChangesetToBase can be called + // at some later point to consider these changes part of the base, after which prepareUserChangeset + // must be called again before applyPreparedChangesetToBase. Multiple consecutive calls + // to prepareUserChangeset will return an updated changeset that takes into account the + // latest user changes, and modify the changeset to be applied by applyPreparedChangesetToBase + // accordingly. + editor.prepareUserChangeset = function() + { + if (!loaded) return null; + return info.ace_prepareUserChangeset(); + }; + + editor.getUnhandledErrors = function() + { + if (!loaded) return []; + // returns array of {error: , time: +new Date()} + return info.ace_getUnhandledErrors(); + }; + + + + function sortFilesByEmbeded(files) { + var embededFiles = []; + var remoteFiles = []; + + if (Ace2Editor.EMBEDED) { + for (var i = 0, ii = files.length; i < ii; i++) { + var file = files[i]; + if (Object.prototype.hasOwnProperty.call(Ace2Editor.EMBEDED, file)) { + embededFiles.push(file); + } else { + remoteFiles.push(file); + } + } + } else { + remoteFiles = files; + } + + return {embeded: embededFiles, remote: remoteFiles}; + } + function pushRequireScriptTo(buffer) { + var KERNEL_SOURCE = '../static/js/require-kernel.js'; + var KERNEL_BOOT = '\ +require.setRootURI("../javascripts/src");\n\ +require.setLibraryURI("../javascripts/lib");\n\ +require.setGlobalKeyPath("require");\n\ +'; + if (Ace2Editor.EMBEDED && Ace2Editor.EMBEDED[KERNEL_SOURCE]) { + buffer.push('\ +'); + + iframeHTML.push(' + + + + +
    APIKEY:
    +
    +

    createGroup()

    + + + + +
    +
    + +
    +
    createGroup()
    +
    deleteGroup(groupID)
    +
    createGroupIfNotExistsFor(groupMapper)
    +
    listPads(groupID)
    +
    createPad(padID,text)
    +
    createGroupPad(groupID,padName,text)
    +
    createAuthor(name)
    +
    createAuthorIfNotExistsFor(authorMapper,name)
    +
    createSession(groupID,authorID,validUntil)
    +
    deleteSession(sessionID)
    +
    getSessionInfo(sessionID)
    +
    listSessionsOfGroup(groupID)
    +
    listSessionsOfAuthor(authorID)
    +
    getText(padID,rev)
    +
    setText(padID,text)
    +
    getRevisionsCount(padID)
    +
    getLastEdited(padID)
    +
    deletePad(padID)
    +
    getReadOnlyID(padID)
    +
    setPublicStatus(padID,publicStatus)
    +
    getPublicStatus(padID)
    +
    setPassword(padID,password)
    +
    isPasswordProtected(padID)
    + + diff --git a/src/templates/admin/plugins-info.html b/src/templates/admin/plugins-info.html new file mode 100644 index 000000000..22f87073e --- /dev/null +++ b/src/templates/admin/plugins-info.html @@ -0,0 +1,30 @@ +<% + var plugins = require("ep_etherpad-lite/static/js/pluginfw/plugins"); +%> + + + + Plugin information + + + + +
    +

    Etherpad Lite

    +
    + +

    Installed plugins

    +
    <%= plugins.formatPlugins() %>
    + +

    Installed parts

    +
    <%= plugins.formatParts() %>
    + +

    Installed hooks

    +

    Server side hooks

    +
    <%= plugins.formatHooks() %>
    + +

    Client side hooks

    +
    <%= plugins.formatHooks("client_hooks") %>
    +
    + + diff --git a/src/templates/admin/plugins.html b/src/templates/admin/plugins.html new file mode 100644 index 000000000..3dad3bd01 --- /dev/null +++ b/src/templates/admin/plugins.html @@ -0,0 +1,90 @@ + + + Plugin manager + + + + + + + +
    + + <% if (errors.length) { %> +
    + <% errors.forEach(function (item) { %> +
    <%= item.toString() %>
    + <% }) %> +
    + <% } %> + + +

    Etherpad Lite

    + + Technical information on installed plugins + +
    +

    Installed plugins

    + + + + + + + + + + + + + + + + + +
    NameDescription
    + +
    + +
    +
    +

    Search for plugins to install

    +
    + + +
    + + + + + + + + + + + + + + + + + +
    NameDescription
    + +
    + + .. of . + +
    + +
    +

    + Please wait: + +

    +
    +
    +
    + + diff --git a/src/templates/index.html b/src/templates/index.html new file mode 100644 index 000000000..4a45d6a54 --- /dev/null +++ b/src/templates/index.html @@ -0,0 +1,164 @@ + + + + Etherpad Lite + + + + + + + + + +
    +
    +
    New Pad
    +
    or create/open a Pad with the name
    +
    + + +
    +
    +
    + + + + + diff --git a/src/templates/pad.html b/src/templates/pad.html new file mode 100644 index 000000000..1f179785a --- /dev/null +++ b/src/templates/pad.html @@ -0,0 +1,395 @@ +<% + var settings = require("ep_etherpad-lite/node/utils/Settings"); +%> + + + + Etherpad Lite + + + + + + + + <% e.begin_block("styles"); %> + + + + <% e.end_block(); %> + + + + <% e.begin_block("body"); %> +
    +
    +
    +
    + + +
    + +
    +
    +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    + +
    +
    +
    +

    Loading...

    + +
    +
    + + + + + + + + + +
    + +
    + Chat + + 0 +
    + +
    +
    Chat
    +
    +
    +
    + +
    +
    +
    + +
     
    + + <% e.end_block(); %> + + <% e.begin_block("scripts"); %> + + + + + + + + + + + + + + <% e.end_block(); %> + diff --git a/src/templates/timeslider.html b/src/templates/timeslider.html new file mode 100644 index 000000000..02af9f6cc --- /dev/null +++ b/src/templates/timeslider.html @@ -0,0 +1,194 @@ + + + + + + + Etherpad Lite Timeslider + + + + + + + + + +
    +
    +
    + + +
    +
    + +
    +
    +
    + +
    +
    + + Return to pad +
    + +
    +

    + + +

    +

    Authors: + + No Authors +

    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + + + +
    + + +
    + + + + + + + + + + + + + + + diff --git a/start.bat b/start.bat new file mode 100644 index 000000000..0f6fc5535 --- /dev/null +++ b/start.bat @@ -0,0 +1 @@ +node node_modules\ep_etherpad-lite\node\server.js \ No newline at end of file diff --git a/tools/doc/LICENSE b/tools/doc/LICENSE new file mode 100644 index 000000000..e3d4e695a --- /dev/null +++ b/tools/doc/LICENSE @@ -0,0 +1,18 @@ +Copyright Joyent, Inc. and other Node contributors. All rights reserved. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. diff --git a/tools/doc/README.md b/tools/doc/README.md new file mode 100644 index 000000000..743bfaae1 --- /dev/null +++ b/tools/doc/README.md @@ -0,0 +1,76 @@ +Here's how the node docs work. + +Each type of heading has a description block. + + + ## module + + Stability: 3 - Stable + + description and examples. + + ### module.property + + * Type + + description of the property. + + ### module.someFunction(x, y, [z=100]) + + * `x` {String} the description of the string + * `y` {Boolean} Should I stay or should I go? + * `z` {Number} How many zebras to bring. + + A description of the function. + + ### Event: 'blerg' + + * Argument: SomeClass object. + + Modules don't usually raise events on themselves. `cluster` is the + only exception. + + ## Class: SomeClass + + description of the class. + + ### Class Method: SomeClass.classMethod(anArg) + + * `anArg` {Object} Just an argument + * `field` {String} anArg can have this field. + * `field2` {Boolean} Another field. Default: `false`. + * Return: {Boolean} `true` if it worked. + + Description of the method for humans. + + ### someClass.nextSibling() + + * Return: {SomeClass object | null} The next someClass in line. + + ### someClass.someProperty + + * String + + The indication of what someProperty is. + + ### Event: 'grelb' + + * `isBlerg` {Boolean} + + This event is emitted on instances of SomeClass, not on the module itself. + + +* Modules have (description, Properties, Functions, Classes, Examples) +* Properties have (type, description) +* Functions have (list of arguments, description) +* Classes have (description, Properties, Methods, Events) +* Events have (list of arguments, description) +* Methods have (list of arguments, description) +* Properties have (type, description) + +# CLI usage + +Run the following from the etherpad-lite root directory: +```sh +$ node tools/doc/generate doc/all.md --format=html --template=doc/template.html > out.htm +``` \ No newline at end of file diff --git a/tools/doc/generate.js b/tools/doc/generate.js new file mode 100644 index 000000000..8d52e101e --- /dev/null +++ b/tools/doc/generate.js @@ -0,0 +1,120 @@ +#!/usr/bin/env node +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +var marked = require('marked'); +var fs = require('fs'); +var path = require('path'); + +// parse the args. +// Don't use nopt or whatever for this. It's simple enough. + +var args = process.argv.slice(2); +var format = 'json'; +var template = null; +var inputFile = null; + +args.forEach(function (arg) { + if (!arg.match(/^\-\-/)) { + inputFile = arg; + } else if (arg.match(/^\-\-format=/)) { + format = arg.replace(/^\-\-format=/, ''); + } else if (arg.match(/^\-\-template=/)) { + template = arg.replace(/^\-\-template=/, ''); + } +}) + + +if (!inputFile) { + throw new Error('No input file specified'); +} + + +console.error('Input file = %s', inputFile); +fs.readFile(inputFile, 'utf8', function(er, input) { + if (er) throw er; + // process the input for @include lines + processIncludes(inputFile, input, next); +}); + + +var includeExpr = /^@include\s+([A-Za-z0-9-_\/]+)(?:\.)?([a-zA-Z]*)$/gmi; +var includeData = {}; +function processIncludes(inputFile, input, cb) { + var includes = input.match(includeExpr); + if (includes === null) return cb(null, input); + var errState = null; + console.error(includes); + var incCount = includes.length; + if (incCount === 0) cb(null, input); + + includes.forEach(function(include) { + var fname = include.replace(/^@include\s+/, ''); + if (!fname.match(/\.md$/)) fname += '.md'; + + if (includeData.hasOwnProperty(fname)) { + input = input.split(include).join(includeData[fname]); + incCount--; + if (incCount === 0) { + return cb(null, input); + } + } + + var fullFname = path.resolve(path.dirname(inputFile), fname); + fs.readFile(fullFname, 'utf8', function(er, inc) { + if (errState) return; + if (er) return cb(errState = er); + processIncludes(fullFname, inc, function(er, inc) { + if (errState) return; + if (er) return cb(errState = er); + incCount--; + includeData[fname] = inc; + input = input.split(include).join(includeData[fname]); + if (incCount === 0) { + return cb(null, input); + } + }); + }); + }); +} + + +function next(er, input) { + if (er) throw er; + switch (format) { + case 'json': + require('./json.js')(input, inputFile, function(er, obj) { + console.log(JSON.stringify(obj, null, 2)); + if (er) throw er; + }); + break; + + case 'html': + require('./html.js')(input, inputFile, template, function(er, html) { + if (er) throw er; + console.log(html); + }); + break; + + default: + throw new Error('Invalid format: ' + format); + } +} diff --git a/tools/doc/html.js b/tools/doc/html.js new file mode 100644 index 000000000..c52fff70a --- /dev/null +++ b/tools/doc/html.js @@ -0,0 +1,174 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +var fs = require('fs'); +var marked = require('marked'); +var path = require('path'); + +module.exports = toHTML; + +function toHTML(input, filename, template, cb) { + var lexed = marked.lexer(input); + fs.readFile(template, 'utf8', function(er, template) { + if (er) return cb(er); + render(lexed, filename, template, cb); + }); +} + +function render(lexed, filename, template, cb) { + // get the section + var section = getSection(lexed); + + filename = path.basename(filename, '.md'); + + lexed = parseLists(lexed); + + // generate the table of contents. + // this mutates the lexed contents in-place. + buildToc(lexed, filename, function(er, toc) { + if (er) return cb(er); + + template = template.replace(/__FILENAME__/g, filename); + template = template.replace(/__SECTION__/g, section); + template = template.replace(/__VERSION__/g, process.version); + template = template.replace(/__TOC__/g, toc); + + // content has to be the last thing we do with + // the lexed tokens, because it's destructive. + content = marked.parser(lexed); + template = template.replace(/__CONTENT__/g, content); + + cb(null, template); + }); +} + + +// just update the list item text in-place. +// lists that come right after a heading are what we're after. +function parseLists(input) { + var state = null; + var depth = 0; + var output = []; + output.links = input.links; + input.forEach(function(tok) { + if (state === null) { + if (tok.type === 'heading') { + state = 'AFTERHEADING'; + } + output.push(tok); + return; + } + if (state === 'AFTERHEADING') { + if (tok.type === 'list_start') { + state = 'LIST'; + if (depth === 0) { + output.push({ type:'html', text: '
    ' }); + } + depth++; + output.push(tok); + return; + } + state = null; + output.push(tok); + return; + } + if (state === 'LIST') { + if (tok.type === 'list_start') { + depth++; + output.push(tok); + return; + } + if (tok.type === 'list_end') { + depth--; + if (depth === 0) { + state = null; + output.push({ type:'html', text: '
    ' }); + } + output.push(tok); + return; + } + if (tok.text) { + tok.text = parseListItem(tok.text); + } + } + output.push(tok); + }); + + return output; +} + + +function parseListItem(text) { + text = text.replace(/\{([^\}]+)\}/, '$1'); + //XXX maybe put more stuff here? + return text; +} + + +// section is just the first heading +function getSection(lexed) { + var section = ''; + for (var i = 0, l = lexed.length; i < l; i++) { + var tok = lexed[i]; + if (tok.type === 'heading') return tok.text; + } + return ''; +} + + +function buildToc(lexed, filename, cb) { + var indent = 0; + var toc = []; + var depth = 0; + lexed.forEach(function(tok) { + if (tok.type !== 'heading') return; + if (tok.depth - depth > 1) { + return cb(new Error('Inappropriate heading level\n' + + JSON.stringify(tok))); + } + + depth = tok.depth; + var id = getId(filename + '_' + tok.text.trim()); + toc.push(new Array((depth - 1) * 2 + 1).join(' ') + + '* ' + + tok.text + ''); + tok.text += '#'; + }); + + toc = marked.parse(toc.join('\n')); + cb(null, toc); +} + +var idCounters = {}; +function getId(text) { + text = text.toLowerCase(); + text = text.replace(/[^a-z0-9]+/g, '_'); + text = text.replace(/^_+|_+$/, ''); + text = text.replace(/^([^a-z])/, '_$1'); + if (idCounters.hasOwnProperty(text)) { + text += '_' + (++idCounters[text]); + } else { + idCounters[text] = 0; + } + return text; +} + diff --git a/tools/doc/json.js b/tools/doc/json.js new file mode 100644 index 000000000..2459bc26a --- /dev/null +++ b/tools/doc/json.js @@ -0,0 +1,557 @@ +// Copyright Joyent, Inc. and other Node contributors. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to permit +// persons to whom the Software is furnished to do so, subject to the +// following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN +// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +// USE OR OTHER DEALINGS IN THE SOFTWARE. + +module.exports = doJSON; + +// Take the lexed input, and return a JSON-encoded object +// A module looks like this: https://gist.github.com/1777387 + +var marked = require('marked'); + +function doJSON(input, filename, cb) { + var root = {source: filename}; + var stack = [root]; + var depth = 0; + var current = root; + var state = null; + var lexed = marked.lexer(input); + lexed.forEach(function (tok) { + var type = tok.type; + var text = tok.text; + + // + // This is for cases where the markdown semantic structure is lacking. + if (type === 'paragraph' || type === 'html') { + var metaExpr = /\n*/g; + text = text.replace(metaExpr, function(_0, k, v) { + current[k.trim()] = v.trim(); + return ''; + }); + text = text.trim(); + if (!text) return; + } + + if (type === 'heading' && + !text.trim().match(/^example/i)) { + if (tok.depth - depth > 1) { + return cb(new Error('Inappropriate heading level\n'+ + JSON.stringify(tok))); + } + + // Sometimes we have two headings with a single + // blob of description. Treat as a clone. + if (current && + state === 'AFTERHEADING' && + depth === tok.depth) { + var clone = current; + current = newSection(tok); + current.clone = clone; + // don't keep it around on the stack. + stack.pop(); + } else { + // if the level is greater than the current depth, + // then it's a child, so we should just leave the stack + // as it is. + // However, if it's a sibling or higher, then it implies + // the closure of the other sections that came before. + // root is always considered the level=0 section, + // and the lowest heading is 1, so this should always + // result in having a valid parent node. + var d = tok.depth; + while (d <= depth) { + finishSection(stack.pop(), stack[stack.length - 1]); + d++; + } + current = newSection(tok); + } + + depth = tok.depth; + stack.push(current); + state = 'AFTERHEADING'; + return; + } // heading + + // Immediately after a heading, we can expect the following + // + // { type: 'code', text: 'Stability: ...' }, + // + // a list: starting with list_start, ending with list_end, + // maybe containing other nested lists in each item. + // + // If one of these isnt' found, then anything that comes between + // here and the next heading should be parsed as the desc. + var stability + if (state === 'AFTERHEADING') { + if (type === 'code' && + (stability = text.match(/^Stability: ([0-5])(?:\s*-\s*)?(.*)$/))) { + current.stability = parseInt(stability[1], 10); + current.stabilityText = stability[2].trim(); + return; + } else if (type === 'list_start' && !tok.ordered) { + state = 'AFTERHEADING_LIST'; + current.list = current.list || []; + current.list.push(tok); + current.list.level = 1; + } else { + current.desc = current.desc || []; + if (!Array.isArray(current.desc)) { + current.shortDesc = current.desc; + current.desc = []; + } + current.desc.push(tok); + state = 'DESC'; + } + return; + } + + if (state === 'AFTERHEADING_LIST') { + current.list.push(tok); + if (type === 'list_start') { + current.list.level++; + } else if (type === 'list_end') { + current.list.level--; + } + if (current.list.level === 0) { + state = 'AFTERHEADING'; + processList(current); + } + return; + } + + current.desc = current.desc || []; + current.desc.push(tok); + + }); + + // finish any sections left open + while (root !== (current = stack.pop())) { + finishSection(current, stack[stack.length - 1]); + } + + return cb(null, root) +} + + +// go from something like this: +// [ { type: 'list_item_start' }, +// { type: 'text', +// text: '`settings` Object, Optional' }, +// { type: 'list_start', ordered: false }, +// { type: 'list_item_start' }, +// { type: 'text', +// text: 'exec: String, file path to worker file. Default: `__filename`' }, +// { type: 'list_item_end' }, +// { type: 'list_item_start' }, +// { type: 'text', +// text: 'args: Array, string arguments passed to worker.' }, +// { type: 'text', +// text: 'Default: `process.argv.slice(2)`' }, +// { type: 'list_item_end' }, +// { type: 'list_item_start' }, +// { type: 'text', +// text: 'silent: Boolean, whether or not to send output to parent\'s stdio.' }, +// { type: 'text', text: 'Default: `false`' }, +// { type: 'space' }, +// { type: 'list_item_end' }, +// { type: 'list_end' }, +// { type: 'list_item_end' }, +// { type: 'list_end' } ] +// to something like: +// [ { name: 'settings', +// type: 'object', +// optional: true, +// settings: +// [ { name: 'exec', +// type: 'string', +// desc: 'file path to worker file', +// default: '__filename' }, +// { name: 'args', +// type: 'array', +// default: 'process.argv.slice(2)', +// desc: 'string arguments passed to worker.' }, +// { name: 'silent', +// type: 'boolean', +// desc: 'whether or not to send output to parent\'s stdio.', +// default: 'false' } ] } ] + +function processList(section) { + var list = section.list; + var values = []; + var current; + var stack = []; + + // for now, *just* build the heirarchical list + list.forEach(function(tok) { + var type = tok.type; + if (type === 'space') return; + if (type === 'list_item_start') { + if (!current) { + var n = {}; + values.push(n); + current = n; + } else { + current.options = current.options || []; + stack.push(current); + var n = {}; + current.options.push(n); + current = n; + } + return; + } else if (type === 'list_item_end') { + if (!current) { + throw new Error('invalid list - end without current item\n' + + JSON.stringify(tok) + '\n' + + JSON.stringify(list)); + } + current = stack.pop(); + } else if (type === 'text') { + if (!current) { + throw new Error('invalid list - text without current item\n' + + JSON.stringify(tok) + '\n' + + JSON.stringify(list)); + } + current.textRaw = current.textRaw || ''; + current.textRaw += tok.text + ' '; + } + }); + + // shove the name in there for properties, since they are always + // just going to be the value etc. + if (section.type === 'property' && values[0]) { + values[0].textRaw = '`' + section.name + '` ' + values[0].textRaw; + } + + // now pull the actual values out of the text bits. + values.forEach(parseListItem); + + // Now figure out what this list actually means. + // depending on the section type, the list could be different things. + + switch (section.type) { + case 'ctor': + case 'classMethod': + case 'method': + // each item is an argument, unless the name is 'return', + // in which case it's the return value. + section.signatures = section.signatures || []; + var sig = {} + section.signatures.push(sig); + sig.params = values.filter(function(v) { + if (v.name === 'return') { + sig.return = v; + return false; + } + return true; + }); + parseSignature(section.textRaw, sig); + break; + + case 'property': + // there should be only one item, which is the value. + // copy the data up to the section. + var value = values[0] || {}; + delete value.name; + section.typeof = value.type; + delete value.type; + Object.keys(value).forEach(function(k) { + section[k] = value[k]; + }); + break; + + case 'event': + // event: each item is an argument. + section.params = values; + break; + } + + // section.listParsed = values; + delete section.list; +} + + +// textRaw = "someobject.someMethod(a, [b=100], [c])" +function parseSignature(text, sig) { + var params = text.match(paramExpr); + if (!params) return; + params = params[1]; + // the ] is irrelevant. [ indicates optionalness. + params = params.replace(/\]/g, ''); + params = params.split(/,/) + params.forEach(function(p, i, _) { + p = p.trim(); + if (!p) return; + var param = sig.params[i]; + var optional = false; + var def; + // [foo] -> optional + if (p.charAt(0) === '[') { + optional = true; + p = p.substr(1); + } + var eq = p.indexOf('='); + if (eq !== -1) { + def = p.substr(eq + 1); + p = p.substr(0, eq); + } + if (!param) { + param = sig.params[i] = { name: p }; + } + // at this point, the name should match. + if (p !== param.name) { + console.error('Warning: invalid param "%s"', p); + console.error(' > ' + JSON.stringify(param)); + console.error(' > ' + text); + } + if (optional) param.optional = true; + if (def !== undefined) param.default = def; + }); +} + + +function parseListItem(item) { + if (item.options) item.options.forEach(parseListItem); + if (!item.textRaw) return; + + // the goal here is to find the name, type, default, and optional. + // anything left over is 'desc' + var text = item.textRaw.trim(); + // text = text.replace(/^(Argument|Param)s?\s*:?\s*/i, ''); + + text = text.replace(/^, /, '').trim(); + var retExpr = /^returns?\s*:?\s*/i; + var ret = text.match(retExpr); + if (ret) { + item.name = 'return'; + text = text.replace(retExpr, ''); + } else { + var nameExpr = /^['`"]?([^'`": \{]+)['`"]?\s*:?\s*/; + var name = text.match(nameExpr); + if (name) { + item.name = name[1]; + text = text.replace(nameExpr, ''); + } + } + + text = text.trim(); + var defaultExpr = /\(default\s*[:=]?\s*['"`]?([^, '"`]*)['"`]?\)/i; + var def = text.match(defaultExpr); + if (def) { + item.default = def[1]; + text = text.replace(defaultExpr, ''); + } + + text = text.trim(); + var typeExpr = /^\{([^\}]+)\}/; + var type = text.match(typeExpr); + if (type) { + item.type = type[1]; + text = text.replace(typeExpr, ''); + } + + text = text.trim(); + var optExpr = /^Optional\.|(?:, )?Optional$/; + var optional = text.match(optExpr); + if (optional) { + item.optional = true; + text = text.replace(optExpr, ''); + } + + text = text.replace(/^\s*-\s*/, ''); + text = text.trim(); + if (text) item.desc = text; +} + + +function finishSection(section, parent) { + if (!section || !parent) { + throw new Error('Invalid finishSection call\n'+ + JSON.stringify(section) + '\n' + + JSON.stringify(parent)); + } + + if (!section.type) { + section.type = 'module'; + if (parent && (parent.type === 'misc')) { + section.type = 'misc'; + } + section.displayName = section.name; + section.name = section.name.toLowerCase() + .trim().replace(/\s+/g, '_'); + } + + if (section.desc && Array.isArray(section.desc)) { + section.desc.links = section.desc.links || []; + section.desc = marked.parser(section.desc); + } + + if (!section.list) section.list = []; + processList(section); + + // classes sometimes have various 'ctor' children + // which are actually just descriptions of a constructor + // class signature. + // Merge them into the parent. + if (section.type === 'class' && section.ctors) { + section.signatures = section.signatures || []; + var sigs = section.signatures; + section.ctors.forEach(function(ctor) { + ctor.signatures = ctor.signatures || [{}]; + ctor.signatures.forEach(function(sig) { + sig.desc = ctor.desc; + }); + sigs.push.apply(sigs, ctor.signatures); + }); + delete section.ctors; + } + + // properties are a bit special. + // their "type" is the type of object, not "property" + if (section.properties) { + section.properties.forEach(function (p) { + if (p.typeof) p.type = p.typeof; + else delete p.type; + delete p.typeof; + }); + } + + // handle clones + if (section.clone) { + var clone = section.clone; + delete section.clone; + delete clone.clone; + deepCopy(section, clone); + finishSection(clone, parent); + } + + var plur; + if (section.type.slice(-1) === 's') { + plur = section.type + 'es'; + } else if (section.type.slice(-1) === 'y') { + plur = section.type.replace(/y$/, 'ies'); + } else { + plur = section.type + 's'; + } + + // if the parent's type is 'misc', then it's just a random + // collection of stuff, like the "globals" section. + // Make the children top-level items. + if (section.type === 'misc') { + Object.keys(section).forEach(function(k) { + switch (k) { + case 'textRaw': + case 'name': + case 'type': + case 'desc': + case 'miscs': + return; + default: + if (parent.type === 'misc') { + return; + } + if (Array.isArray(k) && parent[k]) { + parent[k] = parent[k].concat(section[k]); + } else if (!parent[k]) { + parent[k] = section[k]; + } else { + // parent already has, and it's not an array. + return; + } + } + }); + } + + parent[plur] = parent[plur] || []; + parent[plur].push(section); +} + + +// Not a general purpose deep copy. +// But sufficient for these basic things. +function deepCopy(src, dest) { + Object.keys(src).filter(function(k) { + return !dest.hasOwnProperty(k); + }).forEach(function(k) { + dest[k] = deepCopy_(src[k]); + }); +} + +function deepCopy_(src) { + if (!src) return src; + if (Array.isArray(src)) { + var c = new Array(src.length); + src.forEach(function(v, i) { + c[i] = deepCopy_(v); + }); + return c; + } + if (typeof src === 'object') { + var c = {}; + Object.keys(src).forEach(function(k) { + c[k] = deepCopy_(src[k]); + }); + return c; + } + return src; +} + + +// these parse out the contents of an H# tag +var eventExpr = /^Event(?::|\s)+['"]?([^"']+).*$/i; +var classExpr = /^Class:\s*([^ ]+).*?$/i; +var propExpr = /^(?:property:?\s*)?[^\.]+\.([^ \.\(\)]+)\s*?$/i; +var braceExpr = /^(?:property:?\s*)?[^\.\[]+(\[[^\]]+\])\s*?$/i; +var classMethExpr = + /^class\s*method\s*:?[^\.]+\.([^ \.\(\)]+)\([^\)]*\)\s*?$/i; +var methExpr = + /^(?:method:?\s*)?(?:[^\.]+\.)?([^ \.\(\)]+)\([^\)]*\)\s*?$/i; +var newExpr = /^new ([A-Z][a-z]+)\([^\)]*\)\s*?$/; +var paramExpr = /\((.*)\);?$/; + +function newSection(tok) { + var section = {}; + // infer the type from the text. + var text = section.textRaw = tok.text; + if (text.match(eventExpr)) { + section.type = 'event'; + section.name = text.replace(eventExpr, '$1'); + } else if (text.match(classExpr)) { + section.type = 'class'; + section.name = text.replace(classExpr, '$1'); + } else if (text.match(braceExpr)) { + section.type = 'property'; + section.name = text.replace(braceExpr, '$1'); + } else if (text.match(propExpr)) { + section.type = 'property'; + section.name = text.replace(propExpr, '$1'); + } else if (text.match(classMethExpr)) { + section.type = 'classMethod'; + section.name = text.replace(classMethExpr, '$1'); + } else if (text.match(methExpr)) { + section.type = 'method'; + section.name = text.replace(methExpr, '$1'); + } else if (text.match(newExpr)) { + section.type = 'ctor'; + section.name = text.replace(newExpr, '$1'); + } else { + section.name = text; + } + return section; +} diff --git a/tools/doc/package.json b/tools/doc/package.json new file mode 100644 index 000000000..d87c9345b --- /dev/null +++ b/tools/doc/package.json @@ -0,0 +1,15 @@ +{ + "author": "Isaac Z. Schlueter (http://blog.izs.me/)", + "name": "node-doc-generator", + "description": "Internal tool for generating Node.js API docs", + "version": "0.0.0", + "engines": { + "node": ">=0.6.10" + }, + "dependencies": { + "marked": "~0.1.9" + }, + "devDependencies": {}, + "optionalDependencies": {}, + "bin": "./generate.js" +} diff --git a/var/.gitignore b/var/.gitignore new file mode 100644 index 000000000..91f8c0959 --- /dev/null +++ b/var/.gitignore @@ -0,0 +1,2 @@ +sqlite.db +minified*