diff --git a/.gitignore b/.gitignore index 5756bac73..c66419bdf 100755 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ -.* *.diff *.err *.orig @@ -12,21 +11,36 @@ # OS or Editor folders .DS_Store +.idea .cache .project .settings .tmproj -nbproject +.nvmrc +sftp-config.json Thumbs.db +# private downloads +download/ + +# database dump for tutorial export +dump/ + +# extra handlers are not in the repo +extra + # NPM packages folder. node_modules/ -# Brunch folder for temporary files. +# TMP folder (run-time tmp) tmp/ -# Brunch output folder. -www/ +# Manifest (build-generated content, versions) +manifest/ + + +# contains v8 executable for linux-tick-processor (run from project root) +out/* -# Bower stuff. -bower_components/ +# Generated content +public/* diff --git a/.jshintrc b/.jshintrc new file mode 100755 index 000000000..2b7bd7696 --- /dev/null +++ b/.jshintrc @@ -0,0 +1,19 @@ +{ + "maxerr": 25, + "latedef": "nofunc", + "browser": true, + "node": true, // for browserify require etc + "globals": ["$", "Prism"], + "indent": 2, + "camelcase": true, + "newcap": true, + "undef": true, + "multistr": true, + "esnext": true, + "noyield": true, + "devel": true, + "loopfunc": true, + "-W004": true, + "-W030": true, // for yield* ... + "-W078": true // allow setter w/o getter +} \ No newline at end of file diff --git a/.travis.yml b/.travis.yml old mode 100644 new mode 100755 index db6bc0c1f..cef82271b --- a/.travis.yml +++ b/.travis.yml @@ -1,6 +1,68 @@ language: node_js node_js: - - 0.10 - - 0.11 -services: - - mongodb + - iojs +git: + # javascipt-turorial submodule is checked out manually with token, + # in install hook + submodules: false + # Don't need history to do tests + # But depth: 1 would be too small + # The problem with using git clone depth 1 is that if the contributer + # pushes multiple commits in short succession before Travis is able to + # kick off a build. The build will fail due to being unable to find the + #commit. This is because Travis checks out a branch and then checks out + # the exact commit. So it is possible to get into a situation where the + # latest branch is newer than the commit Travis is trying to build + # against. + depth: 10 + +install: + - source ./scripts/travis/install.sh + +script: + - source ./scripts/travis/test.sh + +env: + global: # not a build matrix, but variables for every build + #- TRAVIS_DEBUG=1 + - NODE_ENV=test + - E2E_BROWSER=firefox + # id_rsa (by travis_key.sh) + - secure: "jXxCMfeJGuoQomJr/BzXtAnpjz6DVBRHsQqGLS5jky5KvQAaxwsC4MSOMCEPiTCUSRZ0/dTBhwzHeiFQ9c3m83NIwBmb27b/IkTbVUz8g515kHu1greDFWtdlVCwUOyTlc6bET84QDjMHnwmSTXHJw02j7D44mbOyvP07P1akAE=" + - secure: "JzmDqycU+E+3MFG6iSW5+yZ9wPns4a1vC3i2/SVhLUvOAQpi/NNiNZQjcRkLmOiiV2Z9ykm4Tw0inYRmGR1LyXTFDyukjrfd9SiZ2o8iSuP3bIACyB2uGwJcRdTt0RFXEFvjE9l7O8fiQxPo8Fa3ZMF5Vx9zDGV+JGudycbGfMQ=" + - secure: "cdqo9zt5EfTnEfHTl4BZi9eI3/V3F+piYS0W1yCe3YazgfOL4dk6SMm3nPvfrH1GafbKWDWH+PP79btqKuS27cBycRyBFyE9cOJ5KMqQZOxgtlwvoPRToxNuGkMf+K2mIaL3xLt+ydphWsoOndhBvuef5U9viGhZGiSy+v4AWGE=" + - secure: "YvntZ1dqChna300Q2M4eHArwfxC7r5Ds+dGb3rokigszU8+orpKzNj9Ryv2v5w5TwtYV/IfdR1q+oabD0nrJzZ8iiQZVMjktfK9wWAIM7LqSN08vWeZVEV8XC9dLAjsMFAyLzm7+f3ReDcxyWeRIiq8HWdGEGqZS7adhvJhl18w=" + - secure: "Sa2gtWs+1It2txzWkqJybNh/4q8l/owYuJgph40axFtUJt34XDJGVWIIr2uo4DUhyDt7pwI+UuiyLBC031kUNogKKm3k8W/Y3dUQUoTWTSxM9mdtMmirg8RS/C0HLp7w5d81zyJj8hzT5C0axSvNAsCLu9aXn9L4mgLlL9yb7o0=" + - secure: "Fzy6mZQcLbsYEKiPCpTPGSfzn0eJ/ma5Bjw4t0/po3PHjnX9TTqryMp68ca7yMN7cXVE2cEgjXL1uE3DJygF+8BVmx7bt3vfHqxqhxjVTBvOcD64IPXOjoH0jkj0kX1kP72Cu7IQelPqpDToKn8HzlGwTayGG6HYcoMK6Vksd5g=" + - secure: "c3sWfOvzmhfaaVqBc8MO2I0j2sjEB7yCtDmejza5NM/fh8NtOOmC58dEbaGmjTTdPmH5Ci0rwXBLm9JCZq62/PrLao2pLjIqGJLnEcmV51iwO0NREmX98xIjb5jWPMbppgJzf9huZfaKaC2opYzWtH5/oX2ti7mUGwBGysUM+mM=" + - secure: "G7GYb5ARiMm8lOsvub3CGUqsLuWCg55Dh+c5CW971TXV26ZNi1l2ubLf5bdnoRueSvOMo3EmzhCBGXoXrnHnCuai5Z4srvtqTgPAf1k/9gfPAugMEf/SO13lazJs1aJsDaOtTmVf5j6yJBRDbLJexvBigsHPbTYA6qL+g6lo4Ug=" + - secure: "B6qRlAwxo55nDxNDhIkTaPx6tgWK8wXjUK32kETQ/LZ5RWs1eb0TuvHon53WOhVYg+Sc6etbQw/Hf9w0dVTCZtKXrmCMLOjLpawnFWmmhGNQHrrXMXVsYYZZ2s1JNZYENi6m/KD1d4keL6ZRcxIY9UBJ28hwPGw7DLlaIZbh2ZM=" + - secure: "fl65/grTfH39r9/N73yA8lByYQS/m6LUYRCCF+Y13/U7A9q2JzRhSbVkDqosT69cuvJAsGyyqyGstQQkldf9uGw7egwMepwRp4AWY/eV1jSsQYIlpCyl50BlC7FhNvVJmiXepo3yEgJGYhIlKNU9SkkLbJr4sIh3qgsRFz5Lpa8=" + - secure: "c24KmkcbSbtc3WD6qSzU++oI+GItEFd1sjKydxTA7ZpIHQDtZADNh5XUByt1mbJ8a11xqPihzMIi3dulrVLMQM2L42oGxxDabOf7Y8qY362m32efrodvMyLf0rpHa4eJbYOn2nfxWS0HsQUt9HntES6yk15YLUzwxbzYKx7wpHc=" + - secure: "aW9Y8WyAAj2Z+Xn1uy/wKSwDmf2G4XEOFibXzAAZFs0MwsNQHcMjY7+2UKD0BIio14XpQIgxxlxNbNZrSb/ushx3ON3SP+hzOA+tybO0a65PD1vpqUIyz5lHCY9/H7SCxdrBFqDT3pkAaQnCejvUKnQWyR4GKJtwyzZrVhuUj8A=" + - secure: "pbQRrvcJjqfDr6XNR87XQ+KCnqhhFA1/sojHjgUziNQBf24pVHyBadGVwi243iiqTfWspDklLT8VSZe+Q/HYGm/aINLEs4w5jJ3cu3MzlWRn4kjE7/5K0QE+ZnbfCdgolIkTmxECVBFrqqMmDl/p2oys3PM3cTLjNvPGv6c5JqE=" + - secure: "Rb3VPzv8zfSsmLXJuZpwRNiYop4Iz7wgS/Cu96oM1840LiKSoufFtKpB6Y2yoAYhkyDYdgOP9LgYQxT6P9n8PYpaN38l7JIewnkM4tfB0SU0wo5nauMiIUE1Y58QLebe0xvnzk1GvPm76SuZRPU3rg1XmCxT5M7ovGI5Zrd2cXM=" + - secure: "Vt4Lclq4fAWRf1nkeY54osLKasJvCC6GKDvSFP7lFKcHCmArw9yvkVhlH/w2iegV1iO/Hqqggmxtq0EIu+Tqfmkkjvd1wMsH5+iOJMXvP9soINuN+wwDJwsn93mriJGaNwv2z4tRMl5klPFPgFDEZkqKDbBiuMUyG8XtWsmcAR0=" + - secure: "OMIGENvQus8ukVIekuhK768gx/8P3xbmY2kCaDIEndo2sRNriZynsJ4WkXbM/Ov9jxPb1kmdHpjv6ChOoIjcComclld+lKMoh6AAR8dcz3PZfT0FEY7a6N0XKel6V2stF7LXgJlDbKZvCuylEESjbVRIYuZBRwni9+UmUpxcI7w=" + - secure: "Wsg2SsZN7wsJ6FnytVZIM/+iz0Ufr96A39x9D07OjjYTdLowYxE1jU1b/25uc5Wy6V5cWddkCjjiECyKtK3qe+OVPDYDR1R+W1QvLC2XLy/PmGGhuf8EBRBaVrwgUgqAcjB53UPviVBgOpMTuu/qVzQHpehpAcJ0DDbYMBzyG/A=" + - secure: "glTBrJyC6Trrj17ud2wmjj4pDDBEyKWEgz+m4e2xRWZ+/Y3PAf43XBWTrp3UpS0isADvoxhsN+IzYoyGfW66X7+KNS4spT43wbaT/1GrNUZDQxNP1k7rLC++tk3/6QMvUFtn/+wkCCMtQS70cZVhSTynxNa51fiXACl0oxJcUNU=" + - secure: "DPN/qQ58H9qH/rX4YZlVUUzoK4wKTBE1dW9ROE164kazZ2DLfbLAyuUn54buFB2Xh+i2vufzvGpsUNqCh4Rk7cqznmD5YeR0Z3bp57bbFHUolxqzdg4X5Tu7IbplU4YElc12775doKviDFI5LVRjHtkbLDqVXb5u9rOQCiwLbxQ=" + - secure: "CmpJyG4xjIPQCxdfrsl8hZ3lRyAoAk9U3WekNqcu5m85EyLbnUuC8IRdrIJ8WPTpEqO8JA6loavAGuc8Ek/NgaMKNFq/NLS7eVe9XfHvOrMHl7YplmwtTcy+a7pMyP8mHUcWZddIQO1VBG2lmEg0/mI2fVWGDhIO02/Ma2XqJtE=" + - secure: "Uq2jBQj3+X3uec5Y5TIsZqwKOQ90xAAtwaZBEM6aSd8d6Mpo9CmmT7xtiBViZkbmAHhXYybKI9j5S5TtbGfbd66pYIDQWu5aOQq+2yIVHQnt4TsyLxDYlEW9263rdklKFcfvc3Q8aMggz8ShKCLyCPeCDm9LCSCRGsPFgYAzdM4=" + - secure: "o6HD1f3TS/OFmAA4nuCeo9UqeZfDLcxXgI7aUu8kUe3WB1RqiFscZmi8oP4c+x+nf6D3CRq9W5t0UrifSSP0HLHumf9SJsJW8NAZuEBSBMtAlLxN4SlsE6Q65hJGIy5/VxqYbfA6nfmWf6Hzanxd5cb+4nD8nP8Rb5yhUK/WnLg=" + - secure: "l6IO9z+QLwR8jSp8qkx72ZIpOzcGwLJopYgsPkipT/RhmDCRZiLZtqQ6+kohG1IDt0XFc9ZkyK3f5IeFA6laLrCb/6GuWCyXNtBGrP8jhOO52k2iCHrTJcr4e9NR0wldOGbuhY57ipFVs2xmFCJjIjOBEwsBNwmBxeApYlZeHxg=" + # id_rsa.pub (by travis_key.sh) + - secure: "U3hGgFIvhaBxRkFTsFvOdIcUYKJHpXd6J8RWf5PrdfKJ26XDwDCrS9cYahPiBZjYzB7NhMbhfIMFxKzujxeDWx86Utw8jNpQ69U1ge+C2zhRezzDzBECLE2AAh3EvLOI6DZa5r2tWkeBUoBeGuZlBXASGLpje2kzZT3r3ggGBbA=" + - secure: "H0xIXyYrVkLXMlH6OAkIdudwA+aaj5N+YNNruhR3WqKFaiLQkiU2OptpuN2jL+bMHyOxGGSxbHMoweVUryjX3DcBsfrNbhsRccryBC35MDrZACB2lUnwh+VJUgoHTqhVAcdIeQz1ALVlhvEgTx2+Xwe05fLjtrvkdOPtj2KZ9Ck=" + - secure: "LlUZS83kfwVWaDD1ahw/z8hYKVvRF1lp3Y3BTCH1qPHBluqrBmg1+/kbcVAzmIaBe3/f4M3fQgQvt7rlwgKzkDfS22Z52bMqj9cSv5Am1XdDAYlomldwJpV291Rt/qG7VajlTYqKXCua2zCSGgnlybIIshca5K0OxYGLwKjHO+0=" + - secure: "ns1sdmT8ZYr2w55/6n7AWgs0yK9XsaLPvmwYuPVcubnA12BdobtehYUNCqyjfIz6wtsUmiQHLDvFFOq5tic4c9qD217B2c8mP5fxuf9v5V2TBX8fL2RxmvPYDxi9nl5i4tSoNJjyfMWzlQLDaBiGw0Q2X6XKHZDqjg1JBWJK3Ww=" + - secure: "PFlzNIBId1aCj6htuWqnklVPqZzc7Kb50ubpwZSXjD08txj6y9WwKyI/4aT/Qc4/g+xedBP8t6xal4qP4yd2GqWA6T3IoFusHGyjguqu08BvYad+O6ri92ZcnegmGkfAMiIlbtBjdPD3JkbtEr9+opVGfPFfd+56smZEzBZWO/M=" + - secure: "P4Tg2Sb/TYwXx9rQsDbQ38f8bgjjNog7/6qsT2KfZsCrATk5g1vd4BaGS8G3GyVINFQTy1ozISfwEgM/SZEeDIUx9hkr9aAKqIwnQROn8FS+CIP2YO6vPHRIxBwCA49+e6wCkeeu7GMM/aHxH7O9JU9E73Dr03QRlQ2b0OQJ3SE=" + +notifications: + email: false + +# blacklist +branches: + except: + - production diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100755 index 000000000..1ea41ccec --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,13 @@ + +В этом файле перечислены (в алфавитном порядке) люди, которые внесли весомый вклад в учебник или код проекта: + + + +Проект существует давно, а список создан совсем недавно. Наверняка я кого-то забыл. Если вы должны быть в этом списке, но вас нет -- обязательно напишите мне на mk@javascript.ru. diff --git a/Install.md b/Install.md new file mode 100755 index 000000000..532a5aee0 --- /dev/null +++ b/Install.md @@ -0,0 +1,119 @@ + +# Как поднять сайт локально + +## 0. Операционная система + +Сайт работает под MacOS, Unix (протестировано на Ubuntu, Debian), но не Windows. Сам код сайта написан более-менее универсально, но под Windows криво работают некоторые сторонние модули. + +## 1. Поставьте IO.JS 0.12 + +Нужна именно последняя версия [IO.JS](https://iojs.org/en/index.html). + +## 2. Поставьте и запустите MongoDB. + +Если у вас Mac, то проще всего сделать это через [MacPorts](http://www.macports.org/install.php) или [Homebrew](http://brew.sh), чтобы было проще ставить дополнительные пакеты. + +Если через MacPorts, то: +``` +sudo port install mongodb +sudo port load mondogb +``` + +## 3. Клонируйте репозитарий + +Предположу, что Git у вас уже стоит и вы умеете им пользоваться. + +Клонируйте только ветку `master`: +``` +git clone -b master --single-branch https://github.com/iliakan/javascript-nodejs +``` + +## 4. Глобальные модули + +Поставьте глобальные модули: + +``` +npm install -g mocha bunyan gulp nodemon +``` + +## 5. Системные пакеты + +Для работы нужны Nginx, GraphicsMagick, ImageMagick (обычно используется GM, он лучше, но иногда IM). + +``` +sudo port install ImageMagick GraphicsMagick +sudo port install nginx +debug+gzip_static+realip + +sudo port load nginx +``` + +## 6. Конфигурация Nginx + +Если в системе ранее не стоял nginx, то ставим настройки для сайта: + +Например: +``` +gulp config:nginx --prefix /opt/local/etc/nginx --root /js/javascript-nodejs --env development --clear +``` + +Здесь `--prefix` -- место для конфигов nginx, обычно `/etc/nginx`, в случае MacPorts это `/opt/local/etc/nginx`. +В параметр `--root` запишите место установки сайта. + +Опция `--clear` полностью удалит старые конфиги nginx. + +Если уже есть nginx, то можно без `--clear`. Тогда команда только скопирует файлы из директории nginx (с минимальной шаблонизацией) в указанную директорию. +Основные конфиги будут перезаписаны, но в `sites-enabled` останутся и будут подключены и другие сайты. + +Также рекомендуется в `/etc/hosts` добавить строку: +``` +127.0.0.1 javascript.in +``` + +Такое имя хоста стоит в конфигурации Nginx. + +## 7. `npm install` + +В директории, в которую клонировали, запустите: + +``` +npm install +``` + +## 8. База + +Инициализуйте базу сайта командой: + +``` +gulp db:load --from fixture/init --reset +``` + + +Репозитарий с учебником до окончания работы над первым релизом сайта приватный, можно импортировать "заглушки": +``` +gulp db:load --from fixture/tutorial --reset +``` + +Если есть доступ к учебнику, то клонируйте его, например, в `/js/javascript-tutorial` и импортируйте командой: + +``` +gulp tutorial:import --root /js/javascript-tutorial +``` + +## 9. Запуск сайта + +Запуск сайта в режиме разработки: +``` +./dev +``` + +Это поднимет сразу и сайт и механизмы автосборки стилей-скриптов и livereload. + +Обратите внимание: ходить на сайт нужно через Nginx (обычно порт 80), не напрямую в IO.JS (не будет статики). + +Если в `/etc/hosts` есть строка `127.0.0.1 javascript.in`, то адрес будет http://javascript.in/ + +# TroubleShooting + +Если что-то не работает -- [пишите issue](https://github.com/iliakan/javascript-nodejs/issues/new). + + diff --git a/LICENSE.md b/LICENSE.md new file mode 100755 index 000000000..8dcd2c89d --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,12 @@ +Код публикуется под открытой лицензией CC-BY-NC-SA. + +Это означает, что вы можете свободно распространять, использовать и адаптировать этот код при выполнении следующих условий: + + - Аттрибуция: дать ссылку на эту лицензию, указать автора и наличие изменений с вашей стороны. + - Некоммерчески: вы не можете использовать этот код в коммерческих целях. Если есть такое желание -- свяжитесь с разработчиком: iliakan@javascript.ru. + - На тех же условиях: если вы перерабатываете, преобразовываете код или берёте его за основу для вашего, вы должны распространять переделанные вами части на условиях этой лицензии. + +Это было совсем краткое изложение лицензии, более полный текст которой находится на https://creativecommons.org/licenses/by-nc-sa/3.0/, а юридически оформленный -- на https://creativecommons.org/licenses/by-nc-sa/3.0/legalcode. + + + diff --git a/README.md b/README.md index 83b578c01..1a78215f7 100755 --- a/README.md +++ b/README.md @@ -1,46 +1,37 @@ -**Всем желающим предлагается поучаствовать в разработке новой версии сайта http://javascript.ru на Node.JS, Open Source on GitHub.** - -О проекте: - -* Это сайт по JavaScript и смежным технологиям (AJAX, COMET, Browser APIs...) -* Сайт достаточно большой и сложный. В новом проекте предусмотрены разделы: - * учебник (с генерацией PDF) - * вопрос-ответ - * тесты знаний - * онлайн-курсы - * справочник - * события - * работа -* Логин через соц. сети в том числе, личные сообщения и профиль. -* Сайт достаточно посещаемый: порядка 1-1.5 млн просмотров в месяц, и их станет больше при успешной реализации. -* Планируется перевод учебника на английский, после реализации на русском. -* Основная аудитория - разработчики, так что поддержка старых IE не нужна. Совсем. +# Новый javascript.ru + +[![Join the chat at https://gitter.im/iliakan/javascript-nodejs](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/iliakan/javascript-nodejs) -Так как сайт должен хорошо индексироваться поисковиками, он будет состоять из страниц с переходом между ними, не SPA. Хотя в различных интерфейсах элементы SPA приветствуются. +## Powered by IO.js -Мы будем стараться, чтобы сайт работал как можно быстрее. Это означает параллельные запросы к БД и кеширование на сервере и, по возможности, плавную инициализацию на клиенте. +Всем привет! -Сейчас есть существенная часть дизайна и его вёрстка в HTML/SASS. +А это исходный код для нового движка сайта [https://learn.javascript.ru](https://learn.javascript.ru). -Общий стиль вы можете посмотреть здесь: https://www.dropbox.com/s/mo6yx0ct9rrzic4/Learn_Home.png. + -RoadMap: +## Что делаем? -* Определиться с архитектурой проекта, технологиями. -* Реализовать профиль посетителя, логин через соц. сети, с заглушкой на title-page. -* Реализовать показ учебника и навигацию по нему, древовидные комментарии с оценками, подгрузкой. -* Сделать покупку PDF учебника (оформление, приём оплаты, почтовое уведомление, скачивание). +* Сайт по JavaScript и смежным технологиям (AJAX, COMET, Browser APIs...) +* Сайт достаточно посещаемый: порядка 1-1.5 млн просмотров в месяц, и их станет больше при успешной реализации. +* Сайт быстрый, генерация страницы до 100мс, лучше до 50мс. +* Сайт пока на русском, на английском сделаем потом. +* Сайт для разработчиков, да, кстати, они не пользуются старыми и страшными IE. -Это примерно соответствует текущему http://learn.javascript.ru. Когда закончим -- будет первый релиз, вместо старого learn.javascript.ru. +С элементами SPA, но не SPA, потому что нафига сове биплан. Она и так летает. -Далее или, если будет возможность, параллельно, реализуем вопрос-ответ, справочник, тесты знаний. +## Что в опен-сорсе? -Обсуждение происходит в чате Node.JS (Skype), собрание сегодня 24.06.2014 в 11:00 **GMT+2**. +В опен-сорсе весь код, который будет заставлять двигаться эту штуку. +Многие модули из него можно взять и выделить в отдельные проекты, было бы желание. -Если не можете войти - напишите мне в Skype, ник: "ilya.a.kantor". +Также в опен-сорсе - учебник JavaScript. Правда, это в другом репозитарии, здесь только код. -Code Style: - * https://google-styleguide.googlecode.com/svn/trunk/javascriptguide.xml - * `use strict` +Для установки dev-среды см. [INSTALL.md](https://github.com/iliakan/javascript-nodejs/blob/master/Install.md). +## ♡ + +Пишите в issues, если есть о чём. diff --git a/app.js b/app.js deleted file mode 100755 index 0c6865fac..000000000 --- a/app.js +++ /dev/null @@ -1,24 +0,0 @@ -"use strict"; - -const log = require('lib/log')(module); -const config = require('config'); - -const koa = require('koa'); - -require('models'); - -const app = koa(); - -require('setup/static')(app); - -require('setup/errors')(app); - -require('setup/logger')(app); -require('setup/bodyParser')(app); -require('setup/session')(app); -require('setup/render')(app); -require('setup/router')(app); - -require('./routes')(app); - -module.exports = app; \ No newline at end of file diff --git a/app/stylesheets/main.styl b/app/stylesheets/main.styl deleted file mode 100755 index 6eec76731..000000000 --- a/app/stylesheets/main.styl +++ /dev/null @@ -1,4 +0,0 @@ -/* :-) */ -h1 - font-style italic - diff --git a/assets/about/amax.jpg b/assets/about/amax.jpg new file mode 100755 index 000000000..f6ba0fc75 Binary files /dev/null and b/assets/about/amax.jpg differ diff --git a/assets/about/bezart.jpg b/assets/about/bezart.jpg new file mode 100755 index 000000000..3d629a077 Binary files /dev/null and b/assets/about/bezart.jpg differ diff --git a/assets/about/iliakan.jpg b/assets/about/iliakan.jpg new file mode 100755 index 000000000..fd52ed4c1 Binary files /dev/null and b/assets/about/iliakan.jpg differ diff --git a/assets/about/tyv.jpg b/assets/about/tyv.jpg new file mode 100755 index 000000000..5d8eba4e9 Binary files /dev/null and b/assets/about/tyv.jpg differ diff --git a/assets/bookify/blank.html b/assets/bookify/blank.html new file mode 100755 index 000000000..e69de29bb diff --git a/assets/browsers/chrome.svg b/assets/browsers/chrome.svg new file mode 100755 index 000000000..65cee5a6b --- /dev/null +++ b/assets/browsers/chrome.svg @@ -0,0 +1,192 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/browsers/firefox.svg b/assets/browsers/firefox.svg new file mode 100755 index 000000000..68c9532c7 --- /dev/null +++ b/assets/browsers/firefox.svg @@ -0,0 +1,1561 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/browsers/ie.svg b/assets/browsers/ie.svg new file mode 100755 index 000000000..b8855de77 --- /dev/null +++ b/assets/browsers/ie.svg @@ -0,0 +1,65 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/assets/browsers/opera.svg b/assets/browsers/opera.svg new file mode 100755 index 000000000..7eccf5e95 --- /dev/null +++ b/assets/browsers/opera.svg @@ -0,0 +1,408 @@ + +image/svg+xml \ No newline at end of file diff --git a/assets/browsers/safari.svg b/assets/browsers/safari.svg new file mode 100755 index 000000000..c27418f49 --- /dev/null +++ b/assets/browsers/safari.svg @@ -0,0 +1,29 @@ + + + + Safari + Created with Sketch. + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/carousel/1.png b/assets/carousel/1.png new file mode 100755 index 000000000..62c91a8ac Binary files /dev/null and b/assets/carousel/1.png differ diff --git a/assets/carousel/10.png b/assets/carousel/10.png new file mode 100755 index 000000000..c604c9dc0 Binary files /dev/null and b/assets/carousel/10.png differ diff --git a/assets/carousel/2.png b/assets/carousel/2.png new file mode 100755 index 000000000..44d07b296 Binary files /dev/null and b/assets/carousel/2.png differ diff --git a/assets/carousel/3.png b/assets/carousel/3.png new file mode 100755 index 000000000..8c42d030b Binary files /dev/null and b/assets/carousel/3.png differ diff --git a/assets/carousel/4.png b/assets/carousel/4.png new file mode 100755 index 000000000..54bb57d8e Binary files /dev/null and b/assets/carousel/4.png differ diff --git a/assets/carousel/5.png b/assets/carousel/5.png new file mode 100755 index 000000000..b3fa4724b Binary files /dev/null and b/assets/carousel/5.png differ diff --git a/assets/carousel/6.png b/assets/carousel/6.png new file mode 100755 index 000000000..e3b25489f Binary files /dev/null and b/assets/carousel/6.png differ diff --git a/assets/carousel/7.png b/assets/carousel/7.png new file mode 100755 index 000000000..dde832ee7 Binary files /dev/null and b/assets/carousel/7.png differ diff --git a/assets/carousel/8.png b/assets/carousel/8.png new file mode 100755 index 000000000..54f782ec8 Binary files /dev/null and b/assets/carousel/8.png differ diff --git a/assets/carousel/9.png b/assets/carousel/9.png new file mode 100755 index 000000000..72af497ea Binary files /dev/null and b/assets/carousel/9.png differ diff --git a/assets/clickjacking/facebook.html b/assets/clickjacking/facebook.html new file mode 100755 index 000000000..6e2c39bee --- /dev/null +++ b/assets/clickjacking/facebook.html @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/clipart/30.png b/assets/clipart/30.png new file mode 100755 index 000000000..f71263982 Binary files /dev/null and b/assets/clipart/30.png differ diff --git a/assets/clipart/angry_dog.png b/assets/clipart/angry_dog.png new file mode 100755 index 000000000..5dadb8bd5 Binary files /dev/null and b/assets/clipart/angry_dog.png differ diff --git a/assets/clipart/arrow-down.png b/assets/clipart/arrow-down.png new file mode 100755 index 000000000..49f76f5cd Binary files /dev/null and b/assets/clipart/arrow-down.png differ diff --git a/assets/clipart/arrow-left.jpg b/assets/clipart/arrow-left.jpg new file mode 100755 index 000000000..e9d29b874 Binary files /dev/null and b/assets/clipart/arrow-left.jpg differ diff --git a/assets/clipart/arrow-right.jpg b/assets/clipart/arrow-right.jpg new file mode 100755 index 000000000..964f5acb7 Binary files /dev/null and b/assets/clipart/arrow-right.jpg differ diff --git a/assets/clipart/arrow-right.png b/assets/clipart/arrow-right.png new file mode 100755 index 000000000..4f05ea367 Binary files /dev/null and b/assets/clipart/arrow-right.png differ diff --git a/assets/clipart/ball.svg b/assets/clipart/ball.svg new file mode 100755 index 000000000..b9203d0ac --- /dev/null +++ b/assets/clipart/ball.svg @@ -0,0 +1,17 @@ + + + + ball + Created with Sketch. + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/clipart/boat.png b/assets/clipart/boat.png new file mode 100755 index 000000000..ec40c993e Binary files /dev/null and b/assets/clipart/boat.png differ diff --git a/assets/clipart/computer-smile.gif b/assets/clipart/computer-smile.gif new file mode 100755 index 000000000..fa8ff50bd Binary files /dev/null and b/assets/clipart/computer-smile.gif differ diff --git a/assets/clipart/computer.gif b/assets/clipart/computer.gif new file mode 100755 index 000000000..a845b6fbc Binary files /dev/null and b/assets/clipart/computer.gif differ diff --git a/assets/clipart/delete.gif b/assets/clipart/delete.gif new file mode 100755 index 000000000..6d4d1b72f Binary files /dev/null and b/assets/clipart/delete.gif differ diff --git a/assets/clipart/download.png b/assets/clipart/download.png new file mode 100755 index 000000000..f12e6b6e0 Binary files /dev/null and b/assets/clipart/download.png differ diff --git a/assets/clipart/download_lq.png b/assets/clipart/download_lq.png new file mode 100755 index 000000000..4d621a118 Binary files /dev/null and b/assets/clipart/download_lq.png differ diff --git a/assets/clipart/flyjet.jpg b/assets/clipart/flyjet.jpg new file mode 100755 index 000000000..d2d635523 Binary files /dev/null and b/assets/clipart/flyjet.jpg differ diff --git a/assets/clipart/handle-se.png b/assets/clipart/handle-se.png new file mode 100755 index 000000000..408b01a9e Binary files /dev/null and b/assets/clipart/handle-se.png differ diff --git a/assets/clipart/hedgehog_nocache.jpg b/assets/clipart/hedgehog_nocache.jpg new file mode 100755 index 000000000..8e54148a8 Binary files /dev/null and b/assets/clipart/hedgehog_nocache.jpg differ diff --git a/assets/clipart/heroes.jpg b/assets/clipart/heroes.jpg new file mode 100755 index 000000000..94d57766d Binary files /dev/null and b/assets/clipart/heroes.jpg differ diff --git a/assets/clipart/key.png b/assets/clipart/key.png new file mode 100755 index 000000000..6ea44aa3c Binary files /dev/null and b/assets/clipart/key.png differ diff --git a/assets/clipart/mousie.gif b/assets/clipart/mousie.gif new file mode 100755 index 000000000..f1698fed1 Binary files /dev/null and b/assets/clipart/mousie.gif differ diff --git a/assets/clipart/owl-mult.jpg b/assets/clipart/owl-mult.jpg new file mode 100755 index 000000000..9327a4dee Binary files /dev/null and b/assets/clipart/owl-mult.jpg differ diff --git a/assets/clipart/prompt.png b/assets/clipart/prompt.png new file mode 100755 index 000000000..b8d6190d9 Binary files /dev/null and b/assets/clipart/prompt.png differ diff --git a/assets/clipart/recyclebin.png b/assets/clipart/recyclebin.png new file mode 100755 index 000000000..2f0f21cc5 Binary files /dev/null and b/assets/clipart/recyclebin.png differ diff --git a/assets/clipart/select-button.gif b/assets/clipart/select-button.gif new file mode 100755 index 000000000..12654f99e Binary files /dev/null and b/assets/clipart/select-button.gif differ diff --git a/assets/clipart/square_50.png b/assets/clipart/square_50.png new file mode 100755 index 000000000..2b83c561c Binary files /dev/null and b/assets/clipart/square_50.png differ diff --git a/assets/clipart/train.gif b/assets/clipart/train.gif new file mode 100755 index 000000000..9cbc9884d Binary files /dev/null and b/assets/clipart/train.gif differ diff --git a/assets/clipart/updown.gif b/assets/clipart/updown.gif new file mode 100755 index 000000000..0d02d8935 Binary files /dev/null and b/assets/clipart/updown.gif differ diff --git a/assets/clipart/web.jpg b/assets/clipart/web.jpg new file mode 100755 index 000000000..a74cd073b Binary files /dev/null and b/assets/clipart/web.jpg differ diff --git a/assets/clipart/winnie-mult.jpg b/assets/clipart/winnie-mult.jpg new file mode 100755 index 000000000..5ef65e1f5 Binary files /dev/null and b/assets/clipart/winnie-mult.jpg differ diff --git a/assets/clipart/winnie-nazarov.jpg b/assets/clipart/winnie-nazarov.jpg new file mode 100755 index 000000000..7944f4d23 Binary files /dev/null and b/assets/clipart/winnie-nazarov.jpg differ diff --git a/assets/clipart/winnie-stamp.jpg b/assets/clipart/winnie-stamp.jpg new file mode 100755 index 000000000..e69de29bb diff --git a/assets/clipart/yozhik.jpg b/assets/clipart/yozhik.jpg new file mode 100755 index 000000000..8e54148a8 Binary files /dev/null and b/assets/clipart/yozhik.jpg differ diff --git a/assets/cloudflare/10xx-errors.html b/assets/cloudflare/10xx-errors.html new file mode 100644 index 000000000..132153fc4 --- /dev/null +++ b/assets/cloudflare/10xx-errors.html @@ -0,0 +1,70 @@ + + + + + + JavaScript.ru + + + +
+ + +
+
+ ::CLOUDFLARE_ERROR_1000S_BOX:: +
+
+
+ + diff --git a/assets/cloudflare/5xx-errors.html b/assets/cloudflare/5xx-errors.html new file mode 100644 index 000000000..71a36868f --- /dev/null +++ b/assets/cloudflare/5xx-errors.html @@ -0,0 +1,78 @@ + + + + + + JavaScript.ru + + + + +
+ +
+
+ ::CLOUDFLARE_ERROR_500S_BOX:: +
+
+
+ + diff --git a/assets/cloudflare/advanced-security.html b/assets/cloudflare/advanced-security.html new file mode 100644 index 000000000..a05575c4b --- /dev/null +++ b/assets/cloudflare/advanced-security.html @@ -0,0 +1,84 @@ + + + + + + JavaScript.ru + + + +
+ + +
+
+

Была запущена система безопасности сайта

+

Скорее всего, вы пытаетесь совершить действие, которое вызвало подозрение.

+

Для получения доступа введите символы с картинки и нажмите кнопку:

+ ::CAPTCHA_BOX:: +
+
+
+ + diff --git a/assets/cloudflare/always-online.html b/assets/cloudflare/always-online.html new file mode 100644 index 000000000..3979fd33b --- /dev/null +++ b/assets/cloudflare/always-online.html @@ -0,0 +1,115 @@ + + + + + + JavaScript.ru + + + + +
+ + +
+
+ ::ALWAYS_ONLINE_NO_COPY_BOX:: +
+
+
+ + diff --git a/assets/cloudflare/basic-security.html b/assets/cloudflare/basic-security.html new file mode 100644 index 000000000..b3920322c --- /dev/null +++ b/assets/cloudflare/basic-security.html @@ -0,0 +1,75 @@ + + + + + + JavaScript.ru + + + +
+ + +
+
+

Была запущена система безопасности сайта

+

Скорее всего, вы пытаетесь совершить действие, которое вызвало подозрение.

+

Для получения доступа введите символы с картинки и нажмите кнопку:

+ ::CAPTCHA_BOX:: +
+
+
+ + diff --git a/assets/cloudflare/country-challenge.html b/assets/cloudflare/country-challenge.html new file mode 100644 index 000000000..ec77e4ee1 --- /dev/null +++ b/assets/cloudflare/country-challenge.html @@ -0,0 +1,75 @@ + + + + + + JavaScript.ru + + + +
+ + +
+
+

Была запущена система безопасности сайта

+

Ваш IP-адрес находится в черном списке. Обратитесь к администратору сайта.

+

Для получения доступа введите символы с картинки и нажмите кнопку:

+ ::CAPTCHA_BOX:: +
+
+
+ + diff --git a/assets/cloudflare/im-under-attack-mode.html b/assets/cloudflare/im-under-attack-mode.html new file mode 100644 index 000000000..f9da69ea6 --- /dev/null +++ b/assets/cloudflare/im-under-attack-mode.html @@ -0,0 +1,73 @@ + + + + + + JavaScript.ru + + + +
+ + +
+
+

Пожалуйста, подождите...

+ ::IM_UNDER_ATTACK_BOX:: +
+
+
+ + diff --git a/assets/cloudflare/ip-or-ip-range-block.html b/assets/cloudflare/ip-or-ip-range-block.html new file mode 100644 index 000000000..bbe76b6ea --- /dev/null +++ b/assets/cloudflare/ip-or-ip-range-block.html @@ -0,0 +1,73 @@ + + + + + + JavaScript.ru + + + +
+ + +
+
+

Доступ заблокирован

+

Ваш IP-адрес находится в черном списке. Обратитесь к администратору сайта.

+
+
+
+ + diff --git a/assets/cloudflare/urls.txt b/assets/cloudflare/urls.txt new file mode 100644 index 000000000..06d46ee37 --- /dev/null +++ b/assets/cloudflare/urls.txt @@ -0,0 +1,8 @@ +https://learn.javascript.ru/cloudflare/basic-security.html +https://learn.javascript.ru/cloudflare/advanced-security.html +https://learn.javascript.ru/cloudflare/country-challenge.html +https://learn.javascript.ru/cloudflare/ip-or-ip-range-block.html +https://learn.javascript.ru/cloudflare/im-under-attack-mode.html +https://learn.javascript.ru/cloudflare/5xx-errors.html +https://learn.javascript.ru/cloudflare/10xx-errors.html +https://learn.javascript.ru/cloudflare/always-online.html diff --git a/assets/courses/adium.mp4 b/assets/courses/adium.mp4 new file mode 100644 index 000000000..460c1db07 Binary files /dev/null and b/assets/courses/adium.mp4 differ diff --git a/assets/courses/pidgin.mp4 b/assets/courses/pidgin.mp4 new file mode 100644 index 000000000..ab03f9edd Binary files /dev/null and b/assets/courses/pidgin.mp4 differ diff --git a/assets/courses/pyramid.png b/assets/courses/pyramid.png new file mode 100644 index 000000000..8a249eeac Binary files /dev/null and b/assets/courses/pyramid.png differ diff --git a/assets/devtools/bug.html b/assets/devtools/bug.html new file mode 100755 index 000000000..e922e0d46 --- /dev/null +++ b/assets/devtools/bug.html @@ -0,0 +1,16 @@ + + + + + + + +На этой странице есть скрипт с ошибкой. + + + + diff --git a/assets/drag-heroes/ball.png b/assets/drag-heroes/ball.png new file mode 100755 index 000000000..834bea209 Binary files /dev/null and b/assets/drag-heroes/ball.png differ diff --git a/assets/drag-heroes/field.png b/assets/drag-heroes/field.png new file mode 100755 index 000000000..ebabc14af Binary files /dev/null and b/assets/drag-heroes/field.png differ diff --git a/assets/drag-heroes/heroes.png b/assets/drag-heroes/heroes.png new file mode 100755 index 000000000..9566b5017 Binary files /dev/null and b/assets/drag-heroes/heroes.png differ diff --git a/assets/drag-heroes/soccer_ball.png b/assets/drag-heroes/soccer_ball.png new file mode 100755 index 000000000..4aa55877a Binary files /dev/null and b/assets/drag-heroes/soccer_ball.png differ diff --git a/assets/gallery/img1-lg.jpg b/assets/gallery/img1-lg.jpg new file mode 100755 index 000000000..b0719d5a8 Binary files /dev/null and b/assets/gallery/img1-lg.jpg differ diff --git a/assets/gallery/img2-lg.jpg b/assets/gallery/img2-lg.jpg new file mode 100755 index 000000000..08df6db2e Binary files /dev/null and b/assets/gallery/img2-lg.jpg differ diff --git a/assets/gallery/img2-thumb.jpg b/assets/gallery/img2-thumb.jpg new file mode 100755 index 000000000..a388d3bfb Binary files /dev/null and b/assets/gallery/img2-thumb.jpg differ diff --git a/assets/gallery/img3-lg.jpg b/assets/gallery/img3-lg.jpg new file mode 100755 index 000000000..a08bf36eb Binary files /dev/null and b/assets/gallery/img3-lg.jpg differ diff --git a/assets/gallery/img3-thumb.jpg b/assets/gallery/img3-thumb.jpg new file mode 100755 index 000000000..744b89a4f Binary files /dev/null and b/assets/gallery/img3-thumb.jpg differ diff --git a/assets/gallery/img4-lg.jpg b/assets/gallery/img4-lg.jpg new file mode 100755 index 000000000..1a16b4e10 Binary files /dev/null and b/assets/gallery/img4-lg.jpg differ diff --git a/assets/gallery/img4-thumb.jpg b/assets/gallery/img4-thumb.jpg new file mode 100755 index 000000000..f24fa2497 Binary files /dev/null and b/assets/gallery/img4-thumb.jpg differ diff --git a/assets/gallery/img5-lg.jpg b/assets/gallery/img5-lg.jpg new file mode 100755 index 000000000..945e39805 Binary files /dev/null and b/assets/gallery/img5-lg.jpg differ diff --git a/assets/gallery/img5-thumb.jpg b/assets/gallery/img5-thumb.jpg new file mode 100755 index 000000000..b6704ef92 Binary files /dev/null and b/assets/gallery/img5-thumb.jpg differ diff --git a/assets/gallery/img6-lg.jpg b/assets/gallery/img6-lg.jpg new file mode 100755 index 000000000..3213ab5bd Binary files /dev/null and b/assets/gallery/img6-lg.jpg differ diff --git a/assets/gallery/img6-thumb.jpg b/assets/gallery/img6-thumb.jpg new file mode 100755 index 000000000..c1ee1d565 Binary files /dev/null and b/assets/gallery/img6-thumb.jpg differ diff --git a/assets/height-percent/arrow_left.png b/assets/height-percent/arrow_left.png new file mode 100755 index 000000000..3399acc6b Binary files /dev/null and b/assets/height-percent/arrow_left.png differ diff --git a/assets/hello/ads.js b/assets/hello/ads.js new file mode 100755 index 000000000..bddc4f5ec --- /dev/null +++ b/assets/hello/ads.js @@ -0,0 +1 @@ +alert("Реклама загружена!"); diff --git a/assets/images-load/1.jpg b/assets/images-load/1.jpg new file mode 100755 index 000000000..f0b9ab25b Binary files /dev/null and b/assets/images-load/1.jpg differ diff --git a/assets/images-load/2.jpg b/assets/images-load/2.jpg new file mode 100755 index 000000000..1f2fb3f6d Binary files /dev/null and b/assets/images-load/2.jpg differ diff --git a/assets/images-load/3.jpg b/assets/images-load/3.jpg new file mode 100755 index 000000000..100530c91 Binary files /dev/null and b/assets/images-load/3.jpg differ diff --git a/assets/img/ball.gif b/assets/img/ball.gif new file mode 100755 index 000000000..0b2c177f1 Binary files /dev/null and b/assets/img/ball.gif differ diff --git a/assets/img/close-button.png b/assets/img/close-button.png new file mode 100755 index 000000000..591dafb8f Binary files /dev/null and b/assets/img/close-button.png differ diff --git a/assets/img/email__logo.png b/assets/img/email__logo.png new file mode 100755 index 000000000..e5f8a0695 Binary files /dev/null and b/assets/img/email__logo.png differ diff --git a/assets/img/favicon/apple-touch-icon-precomposed.png b/assets/img/favicon/apple-touch-icon-precomposed.png new file mode 100755 index 000000000..8139bf08c Binary files /dev/null and b/assets/img/favicon/apple-touch-icon-precomposed.png differ diff --git a/assets/img/favicon/favicon.ico b/assets/img/favicon/favicon.ico new file mode 100755 index 000000000..283184a19 Binary files /dev/null and b/assets/img/favicon/favicon.ico differ diff --git a/assets/img/favicon/favicon.png b/assets/img/favicon/favicon.png new file mode 100755 index 000000000..a664829d9 Binary files /dev/null and b/assets/img/favicon/favicon.png differ diff --git a/assets/img/favicon/tileicon.png b/assets/img/favicon/tileicon.png new file mode 100755 index 000000000..bb4cfb4f3 Binary files /dev/null and b/assets/img/favicon/tileicon.png differ diff --git a/assets/img/flags/ru.svg b/assets/img/flags/ru.svg new file mode 100644 index 000000000..f36c83395 --- /dev/null +++ b/assets/img/flags/ru.svg @@ -0,0 +1,17 @@ + + + Slice 1 Copy + Created with Sketch Beta. + + Layer 1 + + + + + + + + + + + diff --git a/assets/img/highlights-icons/distance.png b/assets/img/highlights-icons/distance.png new file mode 100755 index 000000000..8e8f25a85 Binary files /dev/null and b/assets/img/highlights-icons/distance.png differ diff --git a/assets/img/highlights-icons/quality.png b/assets/img/highlights-icons/quality.png new file mode 100755 index 000000000..2e7aa4ea1 Binary files /dev/null and b/assets/img/highlights-icons/quality.png differ diff --git a/assets/img/highlights-icons/result.png b/assets/img/highlights-icons/result.png new file mode 100755 index 000000000..237fa79a6 Binary files /dev/null and b/assets/img/highlights-icons/result.png differ diff --git a/assets/img/highlights-icons/support.png b/assets/img/highlights-icons/support.png new file mode 100755 index 000000000..93d181f06 Binary files /dev/null and b/assets/img/highlights-icons/support.png differ diff --git a/assets/img/highlights-icons/warranty.png b/assets/img/highlights-icons/warranty.png new file mode 100755 index 000000000..3fdfe7a68 Binary files /dev/null and b/assets/img/highlights-icons/warranty.png differ diff --git a/assets/img/important-icons-s32ff0be2d7.png b/assets/img/important-icons-s32ff0be2d7.png new file mode 100755 index 000000000..ccdac4cf0 Binary files /dev/null and b/assets/img/important-icons-s32ff0be2d7.png differ diff --git a/assets/img/interkassa.png b/assets/img/interkassa.png new file mode 100755 index 000000000..5db8ac111 Binary files /dev/null and b/assets/img/interkassa.png differ diff --git a/assets/img/invoice.png b/assets/img/invoice.png new file mode 100755 index 000000000..ebeffa6b7 Binary files /dev/null and b/assets/img/invoice.png differ diff --git a/assets/img/learning-pyramide.jpg b/assets/img/learning-pyramide.jpg new file mode 100755 index 000000000..c2a157187 Binary files /dev/null and b/assets/img/learning-pyramide.jpg differ diff --git a/assets/img/linked-upic.png b/assets/img/linked-upic.png new file mode 100755 index 000000000..bdf15fc06 Binary files /dev/null and b/assets/img/linked-upic.png differ diff --git a/assets/img/linked-userpic.gif b/assets/img/linked-userpic.gif new file mode 100755 index 000000000..366c28b3f Binary files /dev/null and b/assets/img/linked-userpic.gif differ diff --git a/assets/img/logo.png b/assets/img/logo.png new file mode 100755 index 000000000..4e8ac5356 Binary files /dev/null and b/assets/img/logo.png differ diff --git a/assets/img/logo.svg b/assets/img/logo.svg new file mode 100755 index 000000000..9c7678f1e --- /dev/null +++ b/assets/img/logo.svg @@ -0,0 +1 @@ +GroupCreated with Sketch. \ No newline at end of file diff --git a/assets/img/markup/sitetoolbar-userpic.png b/assets/img/markup/sitetoolbar-userpic.png new file mode 100755 index 000000000..80036691d Binary files /dev/null and b/assets/img/markup/sitetoolbar-userpic.png differ diff --git a/assets/img/noisy.png b/assets/img/noisy.png new file mode 100755 index 000000000..e429e4099 Binary files /dev/null and b/assets/img/noisy.png differ diff --git a/assets/img/page-footer.png b/assets/img/page-footer.png new file mode 100755 index 000000000..c2345babf Binary files /dev/null and b/assets/img/page-footer.png differ diff --git a/assets/img/pager-scroll.png b/assets/img/pager-scroll.png new file mode 100755 index 000000000..4285dfadb Binary files /dev/null and b/assets/img/pager-scroll.png differ diff --git a/assets/img/pay-method__bank-bill.png b/assets/img/pay-method__bank-bill.png new file mode 100755 index 000000000..6375f6d2e Binary files /dev/null and b/assets/img/pay-method__bank-bill.png differ diff --git a/assets/img/pay-method__interkassa.png b/assets/img/pay-method__interkassa.png new file mode 100755 index 000000000..de6959585 Binary files /dev/null and b/assets/img/pay-method__interkassa.png differ diff --git a/assets/img/pay-method__payanyway.png b/assets/img/pay-method__payanyway.png new file mode 100755 index 000000000..db07649e0 Binary files /dev/null and b/assets/img/pay-method__payanyway.png differ diff --git a/assets/img/pay-method__paypal.png b/assets/img/pay-method__paypal.png new file mode 100755 index 000000000..1335e9446 Binary files /dev/null and b/assets/img/pay-method__paypal.png differ diff --git a/assets/img/pay-method__webmoney.png b/assets/img/pay-method__webmoney.png new file mode 100755 index 000000000..b9f1aab43 Binary files /dev/null and b/assets/img/pay-method__webmoney.png differ diff --git a/assets/img/pay-method__yandexmoney.png b/assets/img/pay-method__yandexmoney.png new file mode 100755 index 000000000..949a2b50c Binary files /dev/null and b/assets/img/pay-method__yandexmoney.png differ diff --git a/assets/img/payanyway.png b/assets/img/payanyway.png new file mode 100755 index 000000000..fea4e2489 Binary files /dev/null and b/assets/img/payanyway.png differ diff --git a/assets/img/paypal.png b/assets/img/paypal.png new file mode 100755 index 000000000..a8b101b02 Binary files /dev/null and b/assets/img/paypal.png differ diff --git a/assets/img/presenter.jpg b/assets/img/presenter.jpg new file mode 100755 index 000000000..8267e5e42 Binary files /dev/null and b/assets/img/presenter.jpg differ diff --git a/assets/img/profile__confirmed.png b/assets/img/profile__confirmed.png new file mode 100755 index 000000000..8fc1b03a3 Binary files /dev/null and b/assets/img/profile__confirmed.png differ diff --git a/assets/img/receipts__separator.png b/assets/img/receipts__separator.png new file mode 100755 index 000000000..4603047b5 Binary files /dev/null and b/assets/img/receipts__separator.png differ diff --git a/assets/img/reviewer.jpg b/assets/img/reviewer.jpg new file mode 100755 index 000000000..63ce87fb7 Binary files /dev/null and b/assets/img/reviewer.jpg differ diff --git a/assets/img/reviews-arrows-sea1675148f.png b/assets/img/reviews-arrows-sea1675148f.png new file mode 100755 index 000000000..489c9b272 Binary files /dev/null and b/assets/img/reviews-arrows-sea1675148f.png differ diff --git a/assets/img/reviews-arrows/next.png b/assets/img/reviews-arrows/next.png new file mode 100755 index 000000000..eb5ba02c9 Binary files /dev/null and b/assets/img/reviews-arrows/next.png differ diff --git a/assets/img/reviews-arrows/prev.png b/assets/img/reviews-arrows/prev.png new file mode 100755 index 000000000..70c170dbb Binary files /dev/null and b/assets/img/reviews-arrows/prev.png differ diff --git a/assets/img/reviews-speech.gif b/assets/img/reviews-speech.gif new file mode 100755 index 000000000..5b6001690 Binary files /dev/null and b/assets/img/reviews-speech.gif differ diff --git a/assets/img/sberbank.png b/assets/img/sberbank.png new file mode 100755 index 000000000..dc7275fa8 Binary files /dev/null and b/assets/img/sberbank.png differ diff --git a/assets/img/sidebar-bg.png b/assets/img/sidebar-bg.png new file mode 100755 index 000000000..5f48419d5 Binary files /dev/null and b/assets/img/sidebar-bg.png differ diff --git a/assets/img/sitetoolbar__logo.svg b/assets/img/sitetoolbar__logo.svg new file mode 100755 index 000000000..a13df52c2 --- /dev/null +++ b/assets/img/sitetoolbar__logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/img/sitetoolbar__logo_small.svg b/assets/img/sitetoolbar__logo_small.svg new file mode 100755 index 000000000..330869802 --- /dev/null +++ b/assets/img/sitetoolbar__logo_small.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/img/userpic/userpic-deleted.png b/assets/img/userpic/userpic-deleted.png new file mode 100755 index 000000000..3f49322e5 Binary files /dev/null and b/assets/img/userpic/userpic-deleted.png differ diff --git a/assets/img/userpic/userpic-deleted.svg b/assets/img/userpic/userpic-deleted.svg new file mode 100755 index 000000000..0bd3b21d2 --- /dev/null +++ b/assets/img/userpic/userpic-deleted.svg @@ -0,0 +1 @@ +GroupCreated with Sketch. \ No newline at end of file diff --git a/assets/img/userpic/userpic.png b/assets/img/userpic/userpic.png new file mode 100755 index 000000000..d2f85b2ee Binary files /dev/null and b/assets/img/userpic/userpic.png differ diff --git a/assets/img/userpic/userpic.svg b/assets/img/userpic/userpic.svg new file mode 100755 index 000000000..d9573eaeb --- /dev/null +++ b/assets/img/userpic/userpic.svg @@ -0,0 +1 @@ +Userpic Default RoundedCreated with Sketch. \ No newline at end of file diff --git a/assets/img/webmoney.png b/assets/img/webmoney.png new file mode 100755 index 000000000..ee7bb9492 Binary files /dev/null and b/assets/img/webmoney.png differ diff --git a/assets/img/x.gif b/assets/img/x.gif new file mode 100755 index 000000000..7c8e9e98f Binary files /dev/null and b/assets/img/x.gif differ diff --git a/assets/img/yamoney.png b/assets/img/yamoney.png new file mode 100755 index 000000000..07d531a7f Binary files /dev/null and b/assets/img/yamoney.png differ diff --git a/assets/lazyimg/1.gif b/assets/lazyimg/1.gif new file mode 100755 index 000000000..a13b1e82e Binary files /dev/null and b/assets/lazyimg/1.gif differ diff --git a/assets/lazyimg/1.jpg b/assets/lazyimg/1.jpg new file mode 100755 index 000000000..f84536a89 Binary files /dev/null and b/assets/lazyimg/1.jpg differ diff --git a/assets/lazyimg/2-1.jpg b/assets/lazyimg/2-1.jpg new file mode 100755 index 000000000..25e08843a Binary files /dev/null and b/assets/lazyimg/2-1.jpg differ diff --git a/assets/lazyimg/2-2.jpg b/assets/lazyimg/2-2.jpg new file mode 100755 index 000000000..44ba334e5 Binary files /dev/null and b/assets/lazyimg/2-2.jpg differ diff --git a/assets/lazyimg/3-1.jpg b/assets/lazyimg/3-1.jpg new file mode 100755 index 000000000..93a1c1f1d Binary files /dev/null and b/assets/lazyimg/3-1.jpg differ diff --git a/assets/lazyimg/3-2.jpg b/assets/lazyimg/3-2.jpg new file mode 100755 index 000000000..1b0987bca Binary files /dev/null and b/assets/lazyimg/3-2.jpg differ diff --git a/assets/lazyimg/4.jpg b/assets/lazyimg/4.jpg new file mode 100755 index 000000000..6c619fd76 Binary files /dev/null and b/assets/lazyimg/4.jpg differ diff --git a/assets/lazyimg/5.jpg b/assets/lazyimg/5.jpg new file mode 100755 index 000000000..ad753f6b1 Binary files /dev/null and b/assets/lazyimg/5.jpg differ diff --git a/assets/lazyimg/6.jpg b/assets/lazyimg/6.jpg new file mode 100755 index 000000000..8100fca48 Binary files /dev/null and b/assets/lazyimg/6.jpg differ diff --git a/assets/lazyimg/7.jpg b/assets/lazyimg/7.jpg new file mode 100755 index 000000000..5a9d056dd Binary files /dev/null and b/assets/lazyimg/7.jpg differ diff --git a/assets/lazyimg/8.jpg b/assets/lazyimg/8.jpg new file mode 100755 index 000000000..a4bdb2a4d Binary files /dev/null and b/assets/lazyimg/8.jpg differ diff --git a/assets/libs/animate.js b/assets/libs/animate.js new file mode 100755 index 000000000..d2ecc4546 --- /dev/null +++ b/assets/libs/animate.js @@ -0,0 +1,20 @@ +function animate(options) { + + var start = performance.now(); + + requestAnimationFrame(function animate(time) { + // timeFraction от 0 до 1 + var timeFraction = (time - start) / options.duration; + if (timeFraction > 1) timeFraction = 1; + + // текущее состояние анимации + var progress = options.timing(timeFraction) + + options.draw(progress); + + if (timeFraction < 1) { + requestAnimationFrame(animate); + } + + }); +} \ No newline at end of file diff --git a/assets/libs/class-extend.js b/assets/libs/class-extend.js new file mode 100755 index 000000000..dba244525 --- /dev/null +++ b/assets/libs/class-extend.js @@ -0,0 +1,108 @@ +/** + * Синтаксис: + * Class.extend(props) + * Class.extend(props, staticProps) + * Class.extend([mixins], props) + * Class.extend([mixins], props, staticProps) +*/ +!function() { + + window.Class = function() { /* вся магия - в Class.extend */ }; + + + Class.extend = function(props, staticProps) { + + var mixins = []; + + // если первый аргумент -- массив, то переназначить аргументы + if ({}.toString.apply(arguments[0]) == "[object Array]") { + mixins = arguments[0]; + props = arguments[1]; + staticProps = arguments[2]; + } + + // эта функция будет возвращена как результат работы extend + function Constructor() { + this.init && this.init.apply(this, arguments); + } + + // this -- это класс "перед точкой", для которого вызван extend (Animal.extend) + // наследуем от него: + Constructor.prototype = Class.inherit(this.prototype); + + // constructor был затёрт вызовом inherit + Constructor.prototype.constructor = Constructor; + + // добавим возможность наследовать дальше + Constructor.extend = Class.extend; + + // скопировать в Constructor статические свойства + copyWrappedProps(staticProps, Constructor, this); + + // скопировать в Constructor.prototype свойства из примесей и props + for (var i = 0; i < mixins.length; i++) { + copyWrappedProps(mixins[i], Constructor.prototype, this.prototype); + } + copyWrappedProps(props, Constructor.prototype, this.prototype); + + return Constructor; + }; + + + //---------- вспомогательные методы ---------- + + // fnTest -- регулярное выражение, + // которое проверяет функцию на то, есть ли в её коде вызов _super + // + // для его объявления мы проверяем, поддерживает ли функция преобразование + // в код вызовом toString: /xyz/.test(function() {xyz}) + // в редких мобильных браузерах -- не поддерживает, поэтому регэксп будет /./ + var fnTest = /xyz/.test(function() {xyz}) ? /\b_super\b/ : /./; + + + // копирует свойства из props в targetPropsObj + // третий аргумент -- это свойства родителя + // + // при копировании, если выясняется что свойство есть и в родителе тоже, + // и является функцией -- его вызов оборачивается в обёртку, + // которая ставит this._super на метод родителя, + // затем вызывает его, затем возвращает this._super + function copyWrappedProps(props, targetPropsObj, parentPropsObj) { + if (!props) return; + + for (var name in props) { + if (typeof props[name] == "function" + && typeof parentPropsObj[name] == "function" + && fnTest.test(props[name])) { + // скопировать, завернув в обёртку + targetPropsObj[name] = wrap(props[name], parentPropsObj[name]); + } else { + targetPropsObj[name] = props[name]; + } + } + + } + + // возвращает обёртку вокруг method, которая ставит this._super на родителя + // и возвращает его потом + function wrap(method, parentMethod) { + return function() { + var backup = this._super; + + this._super = parentMethod; + + try { + return method.apply(this, arguments); + } finally { + this._super = backup; + } + } + } + + // эмуляция Object.create для старых IE + Class.inherit = Object.create || function(proto) { + function F() {} + F.prototype = proto; + return new F; + }; +}(); diff --git a/assets/libs/compareDocumentPosition.js b/assets/libs/compareDocumentPosition.js new file mode 100755 index 000000000..0feca5b45 --- /dev/null +++ b/assets/libs/compareDocumentPosition.js @@ -0,0 +1,28 @@ + +// полифилл для compareDocumentPosition в ie8 + +!function(){ + var el = document.documentElement; + if( !el.compareDocumentPosition && el.sourceIndex !== undefined ){ + + /* ?? + Node = Element; + Node.DOCUMENT_POSITION_DISCONNECTED = 1; + Node.DOCUMENT_POSITION_PRECEDING = 2 + Node.DOCUMENT_POSITION_FOLLOWING = 4; + Node.DOCUMENT_POSITION_CONTAINS = 8; + Node.DOCUMENT_POSITION_CONTAINED_BY = 16; + Node.DOCUMENT_POSITION_IMPLEMENTATION_SPECIFIC = 32; + */ + + Element.prototype.compareDocumentPosition = function(other){ + return (this != other && this.contains(other) && 16) + + (this != other && other.contains(this) && 8) + + (this.sourceIndex >= 0 && other.sourceIndex >= 0 ? + (this.sourceIndex < other.sourceIndex && 4) + + (this.sourceIndex > other.sourceIndex && 2) + : 1 + ) + 0; + } + } +}(); diff --git a/assets/libs/d3.js b/assets/libs/d3.js new file mode 100755 index 000000000..d4cd8bef7 --- /dev/null +++ b/assets/libs/d3.js @@ -0,0 +1,5 @@ +!function(){function n(n){return null!=n&&!isNaN(n)}function t(n){return n.length}function e(n){for(var t=1;n*t%1;)t*=10;return t}function r(n,t){try{for(var e in t)Object.defineProperty(n.prototype,e,{value:t[e],enumerable:!1})}catch(r){n.prototype=t}}function u(){}function i(n){return aa+n in this}function o(n){return n=aa+n,n in this&&delete this[n]}function a(){var n=[];return this.forEach(function(t){n.push(t)}),n}function c(){var n=0;for(var t in this)t.charCodeAt(0)===ca&&++n;return n}function s(){for(var n in this)if(n.charCodeAt(0)===ca)return!1;return!0}function l(){}function f(n,t,e){return function(){var r=e.apply(t,arguments);return r===t?n:r}}function h(n,t){if(t in n)return t;t=t.charAt(0).toUpperCase()+t.substring(1);for(var e=0,r=sa.length;r>e;++e){var u=sa[e]+t;if(u in n)return u}}function g(){}function p(){}function v(n){function t(){for(var t,r=e,u=-1,i=r.length;++ue;e++)for(var u,i=n[e],o=0,a=i.length;a>o;o++)(u=i[o])&&t(u,o,e);return n}function D(n){return fa(n,ya),n}function P(n){var t,e;return function(r,u,i){var o,a=n[i].update,c=a.length;for(i!=e&&(e=i,t=0),u>=t&&(t=u+1);!(o=a[t])&&++t0&&(n=n.substring(0,a));var s=Ma.get(n);return s&&(n=s,c=F),a?t?u:r:t?g:i}function H(n,t){return function(e){var r=Xo.event;Xo.event=e,t[0]=this.__data__;try{n.apply(this,t)}finally{Xo.event=r}}}function F(n,t){var e=H(n,t);return function(n){var t=this,r=n.relatedTarget;r&&(r===t||8&r.compareDocumentPosition(t))||e.call(t,n)}}function O(){var n=".dragsuppress-"+ ++ba,t="click"+n,e=Xo.select(Go).on("touchmove"+n,d).on("dragstart"+n,d).on("selectstart"+n,d);if(_a){var r=Jo.style,u=r[_a];r[_a]="none"}return function(i){function o(){e.on(t,null)}e.on(n,null),_a&&(r[_a]=u),i&&(e.on(t,function(){d(),o()},!0),setTimeout(o,0))}}function Y(n,t){t.changedTouches&&(t=t.changedTouches[0]);var e=n.ownerSVGElement||n;if(e.createSVGPoint){var r=e.createSVGPoint();if(0>wa&&(Go.scrollX||Go.scrollY)){e=Xo.select("body").append("svg").style({position:"absolute",top:0,left:0,margin:0,padding:0,border:"none"},"important");var u=e[0][0].getScreenCTM();wa=!(u.f||u.e),e.remove()}return wa?(r.x=t.pageX,r.y=t.pageY):(r.x=t.clientX,r.y=t.clientY),r=r.matrixTransform(n.getScreenCTM().inverse()),[r.x,r.y]}var i=n.getBoundingClientRect();return[t.clientX-i.left-n.clientLeft,t.clientY-i.top-n.clientTop]}function I(n){return n>0?1:0>n?-1:0}function Z(n,t,e){return(t[0]-n[0])*(e[1]-n[1])-(t[1]-n[1])*(e[0]-n[0])}function V(n){return n>1?0:-1>n?Sa:Math.acos(n)}function X(n){return n>1?Ea:-1>n?-Ea:Math.asin(n)}function $(n){return((n=Math.exp(n))-1/n)/2}function B(n){return((n=Math.exp(n))+1/n)/2}function W(n){return((n=Math.exp(2*n))-1)/(n+1)}function J(n){return(n=Math.sin(n/2))*n}function G(){}function K(n,t,e){return new Q(n,t,e)}function Q(n,t,e){this.h=n,this.s=t,this.l=e}function nt(n,t,e){function r(n){return n>360?n-=360:0>n&&(n+=360),60>n?i+(o-i)*n/60:180>n?o:240>n?i+(o-i)*(240-n)/60:i}function u(n){return Math.round(255*r(n))}var i,o;return n=isNaN(n)?0:(n%=360)<0?n+360:n,t=isNaN(t)?0:0>t?0:t>1?1:t,e=0>e?0:e>1?1:e,o=.5>=e?e*(1+t):e+t-e*t,i=2*e-o,gt(u(n+120),u(n),u(n-120))}function tt(n,t,e){return new et(n,t,e)}function et(n,t,e){this.h=n,this.c=t,this.l=e}function rt(n,t,e){return isNaN(n)&&(n=0),isNaN(t)&&(t=0),ut(e,Math.cos(n*=Na)*t,Math.sin(n)*t)}function ut(n,t,e){return new it(n,t,e)}function it(n,t,e){this.l=n,this.a=t,this.b=e}function ot(n,t,e){var r=(n+16)/116,u=r+t/500,i=r-e/200;return u=ct(u)*Fa,r=ct(r)*Oa,i=ct(i)*Ya,gt(lt(3.2404542*u-1.5371385*r-.4985314*i),lt(-.969266*u+1.8760108*r+.041556*i),lt(.0556434*u-.2040259*r+1.0572252*i))}function at(n,t,e){return n>0?tt(Math.atan2(e,t)*La,Math.sqrt(t*t+e*e),n):tt(0/0,0/0,n)}function ct(n){return n>.206893034?n*n*n:(n-4/29)/7.787037}function st(n){return n>.008856?Math.pow(n,1/3):7.787037*n+4/29}function lt(n){return Math.round(255*(.00304>=n?12.92*n:1.055*Math.pow(n,1/2.4)-.055))}function ft(n){return gt(n>>16,255&n>>8,255&n)}function ht(n){return ft(n)+""}function gt(n,t,e){return new pt(n,t,e)}function pt(n,t,e){this.r=n,this.g=t,this.b=e}function vt(n){return 16>n?"0"+Math.max(0,n).toString(16):Math.min(255,n).toString(16)}function dt(n,t,e){var r,u,i,o=0,a=0,c=0;if(r=/([a-z]+)\((.*)\)/i.exec(n))switch(u=r[2].split(","),r[1]){case"hsl":return e(parseFloat(u[0]),parseFloat(u[1])/100,parseFloat(u[2])/100);case"rgb":return t(Mt(u[0]),Mt(u[1]),Mt(u[2]))}return(i=Va.get(n))?t(i.r,i.g,i.b):(null!=n&&"#"===n.charAt(0)&&(4===n.length?(o=n.charAt(1),o+=o,a=n.charAt(2),a+=a,c=n.charAt(3),c+=c):7===n.length&&(o=n.substring(1,3),a=n.substring(3,5),c=n.substring(5,7)),o=parseInt(o,16),a=parseInt(a,16),c=parseInt(c,16)),t(o,a,c))}function mt(n,t,e){var r,u,i=Math.min(n/=255,t/=255,e/=255),o=Math.max(n,t,e),a=o-i,c=(o+i)/2;return a?(u=.5>c?a/(o+i):a/(2-o-i),r=n==o?(t-e)/a+(e>t?6:0):t==o?(e-n)/a+2:(n-t)/a+4,r*=60):(r=0/0,u=c>0&&1>c?0:r),K(r,u,c)}function yt(n,t,e){n=xt(n),t=xt(t),e=xt(e);var r=st((.4124564*n+.3575761*t+.1804375*e)/Fa),u=st((.2126729*n+.7151522*t+.072175*e)/Oa),i=st((.0193339*n+.119192*t+.9503041*e)/Ya);return ut(116*u-16,500*(r-u),200*(u-i))}function xt(n){return(n/=255)<=.04045?n/12.92:Math.pow((n+.055)/1.055,2.4)}function Mt(n){var t=parseFloat(n);return"%"===n.charAt(n.length-1)?Math.round(2.55*t):t}function _t(n){return"function"==typeof n?n:function(){return n}}function bt(n){return n}function wt(n){return function(t,e,r){return 2===arguments.length&&"function"==typeof e&&(r=e,e=null),St(t,e,n,r)}}function St(n,t,e,r){function u(){var n,t=c.status;if(!t&&c.responseText||t>=200&&300>t||304===t){try{n=e.call(i,c)}catch(r){return o.error.call(i,r),void 0}o.load.call(i,n)}else o.error.call(i,c)}var i={},o=Xo.dispatch("beforesend","progress","load","error"),a={},c=new XMLHttpRequest,s=null;return!Go.XDomainRequest||"withCredentials"in c||!/^(http(s)?:)?\/\//.test(n)||(c=new XDomainRequest),"onload"in c?c.onload=c.onerror=u:c.onreadystatechange=function(){c.readyState>3&&u()},c.onprogress=function(n){var t=Xo.event;Xo.event=n;try{o.progress.call(i,c)}finally{Xo.event=t}},i.header=function(n,t){return n=(n+"").toLowerCase(),arguments.length<2?a[n]:(null==t?delete a[n]:a[n]=t+"",i)},i.mimeType=function(n){return arguments.length?(t=null==n?null:n+"",i):t},i.responseType=function(n){return arguments.length?(s=n,i):s},i.response=function(n){return e=n,i},["get","post"].forEach(function(n){i[n]=function(){return i.send.apply(i,[n].concat(Bo(arguments)))}}),i.send=function(e,r,u){if(2===arguments.length&&"function"==typeof r&&(u=r,r=null),c.open(e,n,!0),null==t||"accept"in a||(a.accept=t+",*/*"),c.setRequestHeader)for(var l in a)c.setRequestHeader(l,a[l]);return null!=t&&c.overrideMimeType&&c.overrideMimeType(t),null!=s&&(c.responseType=s),null!=u&&i.on("error",u).on("load",function(n){u(null,n)}),o.beforesend.call(i,c),c.send(null==r?null:r),i},i.abort=function(){return c.abort(),i},Xo.rebind(i,o,"on"),null==r?i:i.get(kt(r))}function kt(n){return 1===n.length?function(t,e){n(null==t?e:null)}:n}function Et(){var n=At(),t=Ct()-n;t>24?(isFinite(t)&&(clearTimeout(Wa),Wa=setTimeout(Et,t)),Ba=0):(Ba=1,Ga(Et))}function At(){var n=Date.now();for(Ja=Xa;Ja;)n>=Ja.t&&(Ja.f=Ja.c(n-Ja.t)),Ja=Ja.n;return n}function Ct(){for(var n,t=Xa,e=1/0;t;)t.f?t=n?n.n=t.n:Xa=t.n:(t.t8?function(n){return n/e}:function(n){return n*e},symbol:n}}function zt(n){var t=n.decimal,e=n.thousands,r=n.grouping,u=n.currency,i=r?function(n){for(var t=n.length,u=[],i=0,o=r[0];t>0&&o>0;)u.push(n.substring(t-=o,t+o)),o=r[i=(i+1)%r.length];return u.reverse().join(e)}:bt;return function(n){var e=Qa.exec(n),r=e[1]||" ",o=e[2]||">",a=e[3]||"",c=e[4]||"",s=e[5],l=+e[6],f=e[7],h=e[8],g=e[9],p=1,v="",d="",m=!1;switch(h&&(h=+h.substring(1)),(s||"0"===r&&"="===o)&&(s=r="0",o="=",f&&(l-=Math.floor((l-1)/4))),g){case"n":f=!0,g="g";break;case"%":p=100,d="%",g="f";break;case"p":p=100,d="%",g="r";break;case"b":case"o":case"x":case"X":"#"===c&&(v="0"+g.toLowerCase());case"c":case"d":m=!0,h=0;break;case"s":p=-1,g="r"}"$"===c&&(v=u[0],d=u[1]),"r"!=g||h||(g="g"),null!=h&&("g"==g?h=Math.max(1,Math.min(21,h)):("e"==g||"f"==g)&&(h=Math.max(0,Math.min(20,h)))),g=nc.get(g)||qt;var y=s&&f;return function(n){var e=d;if(m&&n%1)return"";var u=0>n||0===n&&0>1/n?(n=-n,"-"):a;if(0>p){var c=Xo.formatPrefix(n,h);n=c.scale(n),e=c.symbol+d}else n*=p;n=g(n,h);var x=n.lastIndexOf("."),M=0>x?n:n.substring(0,x),_=0>x?"":t+n.substring(x+1);!s&&f&&(M=i(M));var b=v.length+M.length+_.length+(y?0:u.length),w=l>b?new Array(b=l-b+1).join(r):"";return y&&(M=i(w+M)),u+=v,n=M+_,("<"===o?u+n+w:">"===o?w+u+n:"^"===o?w.substring(0,b>>=1)+u+n+w.substring(b):u+(y?n:w+n))+e}}}function qt(n){return n+""}function Tt(){this._=new Date(arguments.length>1?Date.UTC.apply(this,arguments):arguments[0])}function Rt(n,t,e){function r(t){var e=n(t),r=i(e,1);return r-t>t-e?e:r}function u(e){return t(e=n(new ec(e-1)),1),e}function i(n,e){return t(n=new ec(+n),e),n}function o(n,r,i){var o=u(n),a=[];if(i>1)for(;r>o;)e(o)%i||a.push(new Date(+o)),t(o,1);else for(;r>o;)a.push(new Date(+o)),t(o,1);return a}function a(n,t,e){try{ec=Tt;var r=new Tt;return r._=n,o(r,t,e)}finally{ec=Date}}n.floor=n,n.round=r,n.ceil=u,n.offset=i,n.range=o;var c=n.utc=Dt(n);return c.floor=c,c.round=Dt(r),c.ceil=Dt(u),c.offset=Dt(i),c.range=a,n}function Dt(n){return function(t,e){try{ec=Tt;var r=new Tt;return r._=t,n(r,e)._}finally{ec=Date}}}function Pt(n){function t(n){function t(t){for(var e,u,i,o=[],a=-1,c=0;++aa;){if(r>=s)return-1;if(u=t.charCodeAt(a++),37===u){if(o=t.charAt(a++),i=N[o in uc?t.charAt(a++):o],!i||(r=i(n,e,r))<0)return-1}else if(u!=e.charCodeAt(r++))return-1}return r}function r(n,t,e){b.lastIndex=0;var r=b.exec(t.substring(e));return r?(n.w=w.get(r[0].toLowerCase()),e+r[0].length):-1}function u(n,t,e){M.lastIndex=0;var r=M.exec(t.substring(e));return r?(n.w=_.get(r[0].toLowerCase()),e+r[0].length):-1}function i(n,t,e){E.lastIndex=0;var r=E.exec(t.substring(e));return r?(n.m=A.get(r[0].toLowerCase()),e+r[0].length):-1}function o(n,t,e){S.lastIndex=0;var r=S.exec(t.substring(e));return r?(n.m=k.get(r[0].toLowerCase()),e+r[0].length):-1}function a(n,t,r){return e(n,C.c.toString(),t,r)}function c(n,t,r){return e(n,C.x.toString(),t,r)}function s(n,t,r){return e(n,C.X.toString(),t,r)}function l(n,t,e){var r=x.get(t.substring(e,e+=2).toLowerCase());return null==r?-1:(n.p=r,e)}var f=n.dateTime,h=n.date,g=n.time,p=n.periods,v=n.days,d=n.shortDays,m=n.months,y=n.shortMonths;t.utc=function(n){function e(n){try{ec=Tt;var t=new ec;return t._=n,r(t)}finally{ec=Date}}var r=t(n);return e.parse=function(n){try{ec=Tt;var t=r.parse(n);return t&&t._}finally{ec=Date}},e.toString=r.toString,e},t.multi=t.utc.multi=ee;var x=Xo.map(),M=jt(v),_=Ht(v),b=jt(d),w=Ht(d),S=jt(m),k=Ht(m),E=jt(y),A=Ht(y);p.forEach(function(n,t){x.set(n.toLowerCase(),t)});var C={a:function(n){return d[n.getDay()]},A:function(n){return v[n.getDay()]},b:function(n){return y[n.getMonth()]},B:function(n){return m[n.getMonth()]},c:t(f),d:function(n,t){return Ut(n.getDate(),t,2)},e:function(n,t){return Ut(n.getDate(),t,2)},H:function(n,t){return Ut(n.getHours(),t,2)},I:function(n,t){return Ut(n.getHours()%12||12,t,2)},j:function(n,t){return Ut(1+tc.dayOfYear(n),t,3)},L:function(n,t){return Ut(n.getMilliseconds(),t,3)},m:function(n,t){return Ut(n.getMonth()+1,t,2)},M:function(n,t){return Ut(n.getMinutes(),t,2)},p:function(n){return p[+(n.getHours()>=12)]},S:function(n,t){return Ut(n.getSeconds(),t,2)},U:function(n,t){return Ut(tc.sundayOfYear(n),t,2)},w:function(n){return n.getDay()},W:function(n,t){return Ut(tc.mondayOfYear(n),t,2)},x:t(h),X:t(g),y:function(n,t){return Ut(n.getFullYear()%100,t,2)},Y:function(n,t){return Ut(n.getFullYear()%1e4,t,4)},Z:ne,"%":function(){return"%"}},N={a:r,A:u,b:i,B:o,c:a,d:Bt,e:Bt,H:Jt,I:Jt,j:Wt,L:Qt,m:$t,M:Gt,p:l,S:Kt,U:Ot,w:Ft,W:Yt,x:c,X:s,y:Zt,Y:It,Z:Vt,"%":te};return t}function Ut(n,t,e){var r=0>n?"-":"",u=(r?-n:n)+"",i=u.length;return r+(e>i?new Array(e-i+1).join(t)+u:u)}function jt(n){return new RegExp("^(?:"+n.map(Xo.requote).join("|")+")","i")}function Ht(n){for(var t=new u,e=-1,r=n.length;++e68?1900:2e3)}function $t(n,t,e){ic.lastIndex=0;var r=ic.exec(t.substring(e,e+2));return r?(n.m=r[0]-1,e+r[0].length):-1}function Bt(n,t,e){ic.lastIndex=0;var r=ic.exec(t.substring(e,e+2));return r?(n.d=+r[0],e+r[0].length):-1}function Wt(n,t,e){ic.lastIndex=0;var r=ic.exec(t.substring(e,e+3));return r?(n.j=+r[0],e+r[0].length):-1}function Jt(n,t,e){ic.lastIndex=0;var r=ic.exec(t.substring(e,e+2));return r?(n.H=+r[0],e+r[0].length):-1}function Gt(n,t,e){ic.lastIndex=0;var r=ic.exec(t.substring(e,e+2));return r?(n.M=+r[0],e+r[0].length):-1}function Kt(n,t,e){ic.lastIndex=0;var r=ic.exec(t.substring(e,e+2));return r?(n.S=+r[0],e+r[0].length):-1}function Qt(n,t,e){ic.lastIndex=0;var r=ic.exec(t.substring(e,e+3));return r?(n.L=+r[0],e+r[0].length):-1}function ne(n){var t=n.getTimezoneOffset(),e=t>0?"-":"+",r=~~(oa(t)/60),u=oa(t)%60;return e+Ut(r,"0",2)+Ut(u,"0",2)}function te(n,t,e){oc.lastIndex=0;var r=oc.exec(t.substring(e,e+1));return r?e+r[0].length:-1}function ee(n){for(var t=n.length,e=-1;++e=0?1:-1,a=o*e,c=Math.cos(t),s=Math.sin(t),l=i*s,f=u*c+l*Math.cos(a),h=l*o*Math.sin(a);hc.add(Math.atan2(h,f)),r=n,u=c,i=s}var t,e,r,u,i;gc.point=function(o,a){gc.point=n,r=(t=o)*Na,u=Math.cos(a=(e=a)*Na/2+Sa/4),i=Math.sin(a)},gc.lineEnd=function(){n(t,e)}}function se(n){var t=n[0],e=n[1],r=Math.cos(e);return[r*Math.cos(t),r*Math.sin(t),Math.sin(e)]}function le(n,t){return n[0]*t[0]+n[1]*t[1]+n[2]*t[2]}function fe(n,t){return[n[1]*t[2]-n[2]*t[1],n[2]*t[0]-n[0]*t[2],n[0]*t[1]-n[1]*t[0]]}function he(n,t){n[0]+=t[0],n[1]+=t[1],n[2]+=t[2]}function ge(n,t){return[n[0]*t,n[1]*t,n[2]*t]}function pe(n){var t=Math.sqrt(n[0]*n[0]+n[1]*n[1]+n[2]*n[2]);n[0]/=t,n[1]/=t,n[2]/=t}function ve(n){return[Math.atan2(n[1],n[0]),X(n[2])]}function de(n,t){return oa(n[0]-t[0])a;++a)u.point((e=n[a])[0],e[1]);return u.lineEnd(),void 0}var c=new ke(e,n,null,!0),s=new ke(e,null,c,!1);c.o=s,i.push(c),o.push(s),c=new ke(r,n,null,!1),s=new ke(r,null,c,!0),c.o=s,i.push(c),o.push(s)}}),o.sort(t),Se(i),Se(o),i.length){for(var a=0,c=e,s=o.length;s>a;++a)o[a].e=c=!c;for(var l,f,h=i[0];;){for(var g=h,p=!0;g.v;)if((g=g.n)===h)return;l=g.z,u.lineStart();do{if(g.v=g.o.v=!0,g.e){if(p)for(var a=0,s=l.length;s>a;++a)u.point((f=l[a])[0],f[1]);else r(g.x,g.n.x,1,u);g=g.n}else{if(p){l=g.p.z;for(var a=l.length-1;a>=0;--a)u.point((f=l[a])[0],f[1])}else r(g.x,g.p.x,-1,u);g=g.p}g=g.o,l=g.z,p=!p}while(!g.v);u.lineEnd()}}}function Se(n){if(t=n.length){for(var t,e,r=0,u=n[0];++r1&&2&t&&e.push(e.pop().concat(e.shift())),g.push(e.filter(Ae))}}var g,p,v,d=t(i),m=u.invert(r[0],r[1]),y={point:o,lineStart:c,lineEnd:s,polygonStart:function(){y.point=l,y.lineStart=f,y.lineEnd=h,g=[],p=[],i.polygonStart()},polygonEnd:function(){y.point=o,y.lineStart=c,y.lineEnd=s,g=Xo.merge(g);var n=Le(m,p);g.length?we(g,Ne,n,e,i):n&&(i.lineStart(),e(null,null,1,i),i.lineEnd()),i.polygonEnd(),g=p=null},sphere:function(){i.polygonStart(),i.lineStart(),e(null,null,1,i),i.lineEnd(),i.polygonEnd()}},x=Ce(),M=t(x);return y}}function Ae(n){return n.length>1}function Ce(){var n,t=[];return{lineStart:function(){t.push(n=[])},point:function(t,e){n.push([t,e])},lineEnd:g,buffer:function(){var e=t;return t=[],n=null,e},rejoin:function(){t.length>1&&t.push(t.pop().concat(t.shift()))}}}function Ne(n,t){return((n=n.x)[0]<0?n[1]-Ea-Aa:Ea-n[1])-((t=t.x)[0]<0?t[1]-Ea-Aa:Ea-t[1])}function Le(n,t){var e=n[0],r=n[1],u=[Math.sin(e),-Math.cos(e),0],i=0,o=0;hc.reset();for(var a=0,c=t.length;c>a;++a){var s=t[a],l=s.length;if(l)for(var f=s[0],h=f[0],g=f[1]/2+Sa/4,p=Math.sin(g),v=Math.cos(g),d=1;;){d===l&&(d=0),n=s[d];var m=n[0],y=n[1]/2+Sa/4,x=Math.sin(y),M=Math.cos(y),_=m-h,b=_>=0?1:-1,w=b*_,S=w>Sa,k=p*x;if(hc.add(Math.atan2(k*b*Math.sin(w),v*M+k*Math.cos(w))),i+=S?_+b*ka:_,S^h>=e^m>=e){var E=fe(se(f),se(n));pe(E);var A=fe(u,E);pe(A);var C=(S^_>=0?-1:1)*X(A[2]);(r>C||r===C&&(E[0]||E[1]))&&(o+=S^_>=0?1:-1)}if(!d++)break;h=m,p=x,v=M,f=n}}return(-Aa>i||Aa>i&&0>hc)^1&o}function ze(n){var t,e=0/0,r=0/0,u=0/0;return{lineStart:function(){n.lineStart(),t=1},point:function(i,o){var a=i>0?Sa:-Sa,c=oa(i-e);oa(c-Sa)0?Ea:-Ea),n.point(u,r),n.lineEnd(),n.lineStart(),n.point(a,r),n.point(i,r),t=0):u!==a&&c>=Sa&&(oa(e-u)Aa?Math.atan((Math.sin(t)*(i=Math.cos(r))*Math.sin(e)-Math.sin(r)*(u=Math.cos(t))*Math.sin(n))/(u*i*o)):(t+r)/2}function Te(n,t,e,r){var u;if(null==n)u=e*Ea,r.point(-Sa,u),r.point(0,u),r.point(Sa,u),r.point(Sa,0),r.point(Sa,-u),r.point(0,-u),r.point(-Sa,-u),r.point(-Sa,0),r.point(-Sa,u);else if(oa(n[0]-t[0])>Aa){var i=n[0]i}function e(n){var e,i,c,s,l;return{lineStart:function(){s=c=!1,l=1},point:function(f,h){var g,p=[f,h],v=t(f,h),d=o?v?0:u(f,h):v?u(f+(0>f?Sa:-Sa),h):0;if(!e&&(s=c=v)&&n.lineStart(),v!==c&&(g=r(e,p),(de(e,g)||de(p,g))&&(p[0]+=Aa,p[1]+=Aa,v=t(p[0],p[1]))),v!==c)l=0,v?(n.lineStart(),g=r(p,e),n.point(g[0],g[1])):(g=r(e,p),n.point(g[0],g[1]),n.lineEnd()),e=g;else if(a&&e&&o^v){var m;d&i||!(m=r(p,e,!0))||(l=0,o?(n.lineStart(),n.point(m[0][0],m[0][1]),n.point(m[1][0],m[1][1]),n.lineEnd()):(n.point(m[1][0],m[1][1]),n.lineEnd(),n.lineStart(),n.point(m[0][0],m[0][1])))}!v||e&&de(e,p)||n.point(p[0],p[1]),e=p,c=v,i=d},lineEnd:function(){c&&n.lineEnd(),e=null},clean:function(){return l|(s&&c)<<1}}}function r(n,t,e){var r=se(n),u=se(t),o=[1,0,0],a=fe(r,u),c=le(a,a),s=a[0],l=c-s*s;if(!l)return!e&&n;var f=i*c/l,h=-i*s/l,g=fe(o,a),p=ge(o,f),v=ge(a,h);he(p,v);var d=g,m=le(p,d),y=le(d,d),x=m*m-y*(le(p,p)-1);if(!(0>x)){var M=Math.sqrt(x),_=ge(d,(-m-M)/y);if(he(_,p),_=ve(_),!e)return _;var b,w=n[0],S=t[0],k=n[1],E=t[1];w>S&&(b=w,w=S,S=b);var A=S-w,C=oa(A-Sa)A;if(!C&&k>E&&(b=k,k=E,E=b),N?C?k+E>0^_[1]<(oa(_[0]-w)Sa^(w<=_[0]&&_[0]<=S)){var L=ge(d,(-m+M)/y);return he(L,p),[_,ve(L)]}}}function u(t,e){var r=o?n:Sa-n,u=0;return-r>t?u|=1:t>r&&(u|=2),-r>e?u|=4:e>r&&(u|=8),u}var i=Math.cos(n),o=i>0,a=oa(i)>Aa,c=cr(n,6*Na);return Ee(t,e,c,o?[0,-n]:[-Sa,n-Sa])}function De(n,t,e,r){return function(u){var i,o=u.a,a=u.b,c=o.x,s=o.y,l=a.x,f=a.y,h=0,g=1,p=l-c,v=f-s;if(i=n-c,p||!(i>0)){if(i/=p,0>p){if(h>i)return;g>i&&(g=i)}else if(p>0){if(i>g)return;i>h&&(h=i)}if(i=e-c,p||!(0>i)){if(i/=p,0>p){if(i>g)return;i>h&&(h=i)}else if(p>0){if(h>i)return;g>i&&(g=i)}if(i=t-s,v||!(i>0)){if(i/=v,0>v){if(h>i)return;g>i&&(g=i)}else if(v>0){if(i>g)return;i>h&&(h=i)}if(i=r-s,v||!(0>i)){if(i/=v,0>v){if(i>g)return;i>h&&(h=i)}else if(v>0){if(h>i)return;g>i&&(g=i)}return h>0&&(u.a={x:c+h*p,y:s+h*v}),1>g&&(u.b={x:c+g*p,y:s+g*v}),u}}}}}}function Pe(n,t,e,r){function u(r,u){return oa(r[0]-n)0?0:3:oa(r[0]-e)0?2:1:oa(r[1]-t)0?1:0:u>0?3:2}function i(n,t){return o(n.x,t.x)}function o(n,t){var e=u(n,1),r=u(t,1);return e!==r?e-r:0===e?t[1]-n[1]:1===e?n[0]-t[0]:2===e?n[1]-t[1]:t[0]-n[0]}return function(a){function c(n){for(var t=0,e=d.length,r=n[1],u=0;e>u;++u)for(var i,o=1,a=d[u],c=a.length,s=a[0];c>o;++o)i=a[o],s[1]<=r?i[1]>r&&Z(s,i,n)>0&&++t:i[1]<=r&&Z(s,i,n)<0&&--t,s=i;return 0!==t}function s(i,a,c,s){var l=0,f=0;if(null==i||(l=u(i,c))!==(f=u(a,c))||o(i,a)<0^c>0){do s.point(0===l||3===l?n:e,l>1?r:t);while((l=(l+c+4)%4)!==f)}else s.point(a[0],a[1])}function l(u,i){return u>=n&&e>=u&&i>=t&&r>=i}function f(n,t){l(n,t)&&a.point(n,t)}function h(){N.point=p,d&&d.push(m=[]),S=!0,w=!1,_=b=0/0}function g(){v&&(p(y,x),M&&w&&A.rejoin(),v.push(A.buffer())),N.point=f,w&&a.lineEnd()}function p(n,t){n=Math.max(-Ac,Math.min(Ac,n)),t=Math.max(-Ac,Math.min(Ac,t));var e=l(n,t);if(d&&m.push([n,t]),S)y=n,x=t,M=e,S=!1,e&&(a.lineStart(),a.point(n,t));else if(e&&w)a.point(n,t);else{var r={a:{x:_,y:b},b:{x:n,y:t}};C(r)?(w||(a.lineStart(),a.point(r.a.x,r.a.y)),a.point(r.b.x,r.b.y),e||a.lineEnd(),k=!1):e&&(a.lineStart(),a.point(n,t),k=!1)}_=n,b=t,w=e}var v,d,m,y,x,M,_,b,w,S,k,E=a,A=Ce(),C=De(n,t,e,r),N={point:f,lineStart:h,lineEnd:g,polygonStart:function(){a=A,v=[],d=[],k=!0},polygonEnd:function(){a=E,v=Xo.merge(v);var t=c([n,r]),e=k&&t,u=v.length;(e||u)&&(a.polygonStart(),e&&(a.lineStart(),s(null,null,1,a),a.lineEnd()),u&&we(v,i,t,s,a),a.polygonEnd()),v=d=m=null}};return N}}function Ue(n,t){function e(e,r){return e=n(e,r),t(e[0],e[1])}return n.invert&&t.invert&&(e.invert=function(e,r){return e=t.invert(e,r),e&&n.invert(e[0],e[1])}),e}function je(n){var t=0,e=Sa/3,r=nr(n),u=r(t,e);return u.parallels=function(n){return arguments.length?r(t=n[0]*Sa/180,e=n[1]*Sa/180):[180*(t/Sa),180*(e/Sa)]},u}function He(n,t){function e(n,t){var e=Math.sqrt(i-2*u*Math.sin(t))/u;return[e*Math.sin(n*=u),o-e*Math.cos(n)]}var r=Math.sin(n),u=(r+Math.sin(t))/2,i=1+r*(2*u-r),o=Math.sqrt(i)/u;return e.invert=function(n,t){var e=o-t;return[Math.atan2(n,e)/u,X((i-(n*n+e*e)*u*u)/(2*u))]},e}function Fe(){function n(n,t){Nc+=u*n-r*t,r=n,u=t}var t,e,r,u;Rc.point=function(i,o){Rc.point=n,t=r=i,e=u=o},Rc.lineEnd=function(){n(t,e)}}function Oe(n,t){Lc>n&&(Lc=n),n>qc&&(qc=n),zc>t&&(zc=t),t>Tc&&(Tc=t)}function Ye(){function n(n,t){o.push("M",n,",",t,i)}function t(n,t){o.push("M",n,",",t),a.point=e}function e(n,t){o.push("L",n,",",t)}function r(){a.point=n}function u(){o.push("Z")}var i=Ie(4.5),o=[],a={point:n,lineStart:function(){a.point=t},lineEnd:r,polygonStart:function(){a.lineEnd=u},polygonEnd:function(){a.lineEnd=r,a.point=n},pointRadius:function(n){return i=Ie(n),a},result:function(){if(o.length){var n=o.join("");return o=[],n}}};return a}function Ie(n){return"m0,"+n+"a"+n+","+n+" 0 1,1 0,"+-2*n+"a"+n+","+n+" 0 1,1 0,"+2*n+"z"}function Ze(n,t){dc+=n,mc+=t,++yc}function Ve(){function n(n,r){var u=n-t,i=r-e,o=Math.sqrt(u*u+i*i);xc+=o*(t+n)/2,Mc+=o*(e+r)/2,_c+=o,Ze(t=n,e=r)}var t,e;Pc.point=function(r,u){Pc.point=n,Ze(t=r,e=u)}}function Xe(){Pc.point=Ze}function $e(){function n(n,t){var e=n-r,i=t-u,o=Math.sqrt(e*e+i*i);xc+=o*(r+n)/2,Mc+=o*(u+t)/2,_c+=o,o=u*n-r*t,bc+=o*(r+n),wc+=o*(u+t),Sc+=3*o,Ze(r=n,u=t)}var t,e,r,u;Pc.point=function(i,o){Pc.point=n,Ze(t=r=i,e=u=o)},Pc.lineEnd=function(){n(t,e)}}function Be(n){function t(t,e){n.moveTo(t,e),n.arc(t,e,o,0,ka)}function e(t,e){n.moveTo(t,e),a.point=r}function r(t,e){n.lineTo(t,e)}function u(){a.point=t}function i(){n.closePath()}var o=4.5,a={point:t,lineStart:function(){a.point=e},lineEnd:u,polygonStart:function(){a.lineEnd=i},polygonEnd:function(){a.lineEnd=u,a.point=t},pointRadius:function(n){return o=n,a},result:g};return a}function We(n){function t(n){return(a?r:e)(n)}function e(t){return Ke(t,function(e,r){e=n(e,r),t.point(e[0],e[1])})}function r(t){function e(e,r){e=n(e,r),t.point(e[0],e[1])}function r(){x=0/0,S.point=i,t.lineStart()}function i(e,r){var i=se([e,r]),o=n(e,r);u(x,M,y,_,b,w,x=o[0],M=o[1],y=e,_=i[0],b=i[1],w=i[2],a,t),t.point(x,M)}function o(){S.point=e,t.lineEnd()}function c(){r(),S.point=s,S.lineEnd=l}function s(n,t){i(f=n,h=t),g=x,p=M,v=_,d=b,m=w,S.point=i}function l(){u(x,M,y,_,b,w,g,p,f,v,d,m,a,t),S.lineEnd=o,o()}var f,h,g,p,v,d,m,y,x,M,_,b,w,S={point:e,lineStart:r,lineEnd:o,polygonStart:function(){t.polygonStart(),S.lineStart=c},polygonEnd:function(){t.polygonEnd(),S.lineStart=r}};return S}function u(t,e,r,a,c,s,l,f,h,g,p,v,d,m){var y=l-t,x=f-e,M=y*y+x*x;if(M>4*i&&d--){var _=a+g,b=c+p,w=s+v,S=Math.sqrt(_*_+b*b+w*w),k=Math.asin(w/=S),E=oa(oa(w)-1)i||oa((y*L+x*z)/M-.5)>.3||o>a*g+c*p+s*v)&&(u(t,e,r,a,c,s,C,N,E,_/=S,b/=S,w,d,m),m.point(C,N),u(C,N,E,_,b,w,l,f,h,g,p,v,d,m))}}var i=.5,o=Math.cos(30*Na),a=16;return t.precision=function(n){return arguments.length?(a=(i=n*n)>0&&16,t):Math.sqrt(i)},t}function Je(n){var t=We(function(t,e){return n([t*La,e*La])});return function(n){return tr(t(n))}}function Ge(n){this.stream=n}function Ke(n,t){return{point:t,sphere:function(){n.sphere()},lineStart:function(){n.lineStart()},lineEnd:function(){n.lineEnd()},polygonStart:function(){n.polygonStart()},polygonEnd:function(){n.polygonEnd()}}}function Qe(n){return nr(function(){return n})()}function nr(n){function t(n){return n=a(n[0]*Na,n[1]*Na),[n[0]*h+c,s-n[1]*h]}function e(n){return n=a.invert((n[0]-c)/h,(s-n[1])/h),n&&[n[0]*La,n[1]*La]}function r(){a=Ue(o=ur(m,y,x),i);var n=i(v,d);return c=g-n[0]*h,s=p+n[1]*h,u()}function u(){return l&&(l.valid=!1,l=null),t}var i,o,a,c,s,l,f=We(function(n,t){return n=i(n,t),[n[0]*h+c,s-n[1]*h]}),h=150,g=480,p=250,v=0,d=0,m=0,y=0,x=0,M=Ec,_=bt,b=null,w=null;return t.stream=function(n){return l&&(l.valid=!1),l=tr(M(o,f(_(n)))),l.valid=!0,l +},t.clipAngle=function(n){return arguments.length?(M=null==n?(b=n,Ec):Re((b=+n)*Na),u()):b},t.clipExtent=function(n){return arguments.length?(w=n,_=n?Pe(n[0][0],n[0][1],n[1][0],n[1][1]):bt,u()):w},t.scale=function(n){return arguments.length?(h=+n,r()):h},t.translate=function(n){return arguments.length?(g=+n[0],p=+n[1],r()):[g,p]},t.center=function(n){return arguments.length?(v=n[0]%360*Na,d=n[1]%360*Na,r()):[v*La,d*La]},t.rotate=function(n){return arguments.length?(m=n[0]%360*Na,y=n[1]%360*Na,x=n.length>2?n[2]%360*Na:0,r()):[m*La,y*La,x*La]},Xo.rebind(t,f,"precision"),function(){return i=n.apply(this,arguments),t.invert=i.invert&&e,r()}}function tr(n){return Ke(n,function(t,e){n.point(t*Na,e*Na)})}function er(n,t){return[n,t]}function rr(n,t){return[n>Sa?n-ka:-Sa>n?n+ka:n,t]}function ur(n,t,e){return n?t||e?Ue(or(n),ar(t,e)):or(n):t||e?ar(t,e):rr}function ir(n){return function(t,e){return t+=n,[t>Sa?t-ka:-Sa>t?t+ka:t,e]}}function or(n){var t=ir(n);return t.invert=ir(-n),t}function ar(n,t){function e(n,t){var e=Math.cos(t),a=Math.cos(n)*e,c=Math.sin(n)*e,s=Math.sin(t),l=s*r+a*u;return[Math.atan2(c*i-l*o,a*r-s*u),X(l*i+c*o)]}var r=Math.cos(n),u=Math.sin(n),i=Math.cos(t),o=Math.sin(t);return e.invert=function(n,t){var e=Math.cos(t),a=Math.cos(n)*e,c=Math.sin(n)*e,s=Math.sin(t),l=s*i-c*o;return[Math.atan2(c*i+s*o,a*r+l*u),X(l*r-a*u)]},e}function cr(n,t){var e=Math.cos(n),r=Math.sin(n);return function(u,i,o,a){var c=o*t;null!=u?(u=sr(e,u),i=sr(e,i),(o>0?i>u:u>i)&&(u+=o*ka)):(u=n+o*ka,i=n-.5*c);for(var s,l=u;o>0?l>i:i>l;l-=c)a.point((s=ve([e,-r*Math.cos(l),-r*Math.sin(l)]))[0],s[1])}}function sr(n,t){var e=se(t);e[0]-=n,pe(e);var r=V(-e[1]);return((-e[2]<0?-r:r)+2*Math.PI-Aa)%(2*Math.PI)}function lr(n,t,e){var r=Xo.range(n,t-Aa,e).concat(t);return function(n){return r.map(function(t){return[n,t]})}}function fr(n,t,e){var r=Xo.range(n,t-Aa,e).concat(t);return function(n){return r.map(function(t){return[t,n]})}}function hr(n){return n.source}function gr(n){return n.target}function pr(n,t,e,r){var u=Math.cos(t),i=Math.sin(t),o=Math.cos(r),a=Math.sin(r),c=u*Math.cos(n),s=u*Math.sin(n),l=o*Math.cos(e),f=o*Math.sin(e),h=2*Math.asin(Math.sqrt(J(r-t)+u*o*J(e-n))),g=1/Math.sin(h),p=h?function(n){var t=Math.sin(n*=h)*g,e=Math.sin(h-n)*g,r=e*c+t*l,u=e*s+t*f,o=e*i+t*a;return[Math.atan2(u,r)*La,Math.atan2(o,Math.sqrt(r*r+u*u))*La]}:function(){return[n*La,t*La]};return p.distance=h,p}function vr(){function n(n,u){var i=Math.sin(u*=Na),o=Math.cos(u),a=oa((n*=Na)-t),c=Math.cos(a);Uc+=Math.atan2(Math.sqrt((a=o*Math.sin(a))*a+(a=r*i-e*o*c)*a),e*i+r*o*c),t=n,e=i,r=o}var t,e,r;jc.point=function(u,i){t=u*Na,e=Math.sin(i*=Na),r=Math.cos(i),jc.point=n},jc.lineEnd=function(){jc.point=jc.lineEnd=g}}function dr(n,t){function e(t,e){var r=Math.cos(t),u=Math.cos(e),i=n(r*u);return[i*u*Math.sin(t),i*Math.sin(e)]}return e.invert=function(n,e){var r=Math.sqrt(n*n+e*e),u=t(r),i=Math.sin(u),o=Math.cos(u);return[Math.atan2(n*i,r*o),Math.asin(r&&e*i/r)]},e}function mr(n,t){function e(n,t){var e=oa(oa(t)-Ea)u;u++){for(;r>1&&Z(n[e[r-2]],n[e[r-1]],n[u])<=0;)--r;e[r++]=u}return e.slice(0,r)}function kr(n,t){return n[0]-t[0]||n[1]-t[1]}function Er(n,t,e){return(e[0]-t[0])*(n[1]-t[1])<(e[1]-t[1])*(n[0]-t[0])}function Ar(n,t,e,r){var u=n[0],i=e[0],o=t[0]-u,a=r[0]-i,c=n[1],s=e[1],l=t[1]-c,f=r[1]-s,h=(a*(c-s)-f*(u-i))/(f*o-a*l);return[u+h*o,c+h*l]}function Cr(n){var t=n[0],e=n[n.length-1];return!(t[0]-e[0]||t[1]-e[1])}function Nr(){Jr(this),this.edge=this.site=this.circle=null}function Lr(n){var t=Jc.pop()||new Nr;return t.site=n,t}function zr(n){Or(n),$c.remove(n),Jc.push(n),Jr(n)}function qr(n){var t=n.circle,e=t.x,r=t.cy,u={x:e,y:r},i=n.P,o=n.N,a=[n];zr(n);for(var c=i;c.circle&&oa(e-c.circle.x)l;++l)s=a[l],c=a[l-1],$r(s.edge,c.site,s.site,u);c=a[0],s=a[f-1],s.edge=Vr(c.site,s.site,null,u),Fr(c),Fr(s)}function Tr(n){for(var t,e,r,u,i=n.x,o=n.y,a=$c._;a;)if(r=Rr(a,o)-i,r>Aa)a=a.L;else{if(u=i-Dr(a,o),!(u>Aa)){r>-Aa?(t=a.P,e=a):u>-Aa?(t=a,e=a.N):t=e=a;break}if(!a.R){t=a;break}a=a.R}var c=Lr(n);if($c.insert(t,c),t||e){if(t===e)return Or(t),e=Lr(t.site),$c.insert(c,e),c.edge=e.edge=Vr(t.site,c.site),Fr(t),Fr(e),void 0;if(!e)return c.edge=Vr(t.site,c.site),void 0;Or(t),Or(e);var s=t.site,l=s.x,f=s.y,h=n.x-l,g=n.y-f,p=e.site,v=p.x-l,d=p.y-f,m=2*(h*d-g*v),y=h*h+g*g,x=v*v+d*d,M={x:(d*y-g*x)/m+l,y:(h*x-v*y)/m+f};$r(e.edge,s,p,M),c.edge=Vr(s,n,null,M),e.edge=Vr(n,p,null,M),Fr(t),Fr(e)}}function Rr(n,t){var e=n.site,r=e.x,u=e.y,i=u-t;if(!i)return r;var o=n.P;if(!o)return-1/0;e=o.site;var a=e.x,c=e.y,s=c-t;if(!s)return a;var l=a-r,f=1/i-1/s,h=l/s;return f?(-h+Math.sqrt(h*h-2*f*(l*l/(-2*s)-c+s/2+u-i/2)))/f+r:(r+a)/2}function Dr(n,t){var e=n.N;if(e)return Rr(e,t);var r=n.site;return r.y===t?r.x:1/0}function Pr(n){this.site=n,this.edges=[]}function Ur(n){for(var t,e,r,u,i,o,a,c,s,l,f=n[0][0],h=n[1][0],g=n[0][1],p=n[1][1],v=Xc,d=v.length;d--;)if(i=v[d],i&&i.prepare())for(a=i.edges,c=a.length,o=0;c>o;)l=a[o].end(),r=l.x,u=l.y,s=a[++o%c].start(),t=s.x,e=s.y,(oa(r-t)>Aa||oa(u-e)>Aa)&&(a.splice(o,0,new Br(Xr(i.site,l,oa(r-f)Aa?{x:f,y:oa(t-f)Aa?{x:oa(e-p)Aa?{x:h,y:oa(t-h)Aa?{x:oa(e-g)=-Ca)){var g=c*c+s*s,p=l*l+f*f,v=(f*g-s*p)/h,d=(c*p-l*g)/h,f=d+a,m=Gc.pop()||new Hr;m.arc=n,m.site=u,m.x=v+o,m.y=f+Math.sqrt(v*v+d*d),m.cy=f,n.circle=m;for(var y=null,x=Wc._;x;)if(m.yd||d>=a)return;if(h>p){if(i){if(i.y>=s)return}else i={x:d,y:c};e={x:d,y:s}}else{if(i){if(i.yr||r>1)if(h>p){if(i){if(i.y>=s)return}else i={x:(c-u)/r,y:c};e={x:(s-u)/r,y:s}}else{if(i){if(i.yg){if(i){if(i.x>=a)return}else i={x:o,y:r*o+u};e={x:a,y:r*a+u}}else{if(i){if(i.xr;++r)if(o=l[r],o.x==e[0]){if(o.i)if(null==s[o.i+1])for(s[o.i-1]+=o.x,s.splice(o.i,1),u=r+1;i>u;++u)l[u].i--;else for(s[o.i-1]+=o.x+s[o.i+1],s.splice(o.i,2),u=r+1;i>u;++u)l[u].i-=2;else if(null==s[o.i+1])s[o.i]=o.x;else for(s[o.i]=o.x+s[o.i+1],s.splice(o.i+1,1),u=r+1;i>u;++u)l[u].i--;l.splice(r,1),i--,r--}else o.x=su(parseFloat(e[0]),parseFloat(o.x));for(;i>r;)o=l.pop(),null==s[o.i+1]?s[o.i]=o.x:(s[o.i]=o.x+s[o.i+1],s.splice(o.i+1,1)),i--;return 1===s.length?null==s[0]?(o=l[0].x,function(n){return o(n)+""}):function(){return t}:function(n){for(r=0;i>r;++r)s[(o=l[r]).i]=o.x(n);return s.join("")}}function fu(n,t){for(var e,r=Xo.interpolators.length;--r>=0&&!(e=Xo.interpolators[r](n,t)););return e}function hu(n,t){var e,r=[],u=[],i=n.length,o=t.length,a=Math.min(n.length,t.length);for(e=0;a>e;++e)r.push(fu(n[e],t[e]));for(;i>e;++e)u[e]=n[e];for(;o>e;++e)u[e]=t[e];return function(n){for(e=0;a>e;++e)u[e]=r[e](n);return u}}function gu(n){return function(t){return 0>=t?0:t>=1?1:n(t)}}function pu(n){return function(t){return 1-n(1-t)}}function vu(n){return function(t){return.5*(.5>t?n(2*t):2-n(2-2*t))}}function du(n){return n*n}function mu(n){return n*n*n}function yu(n){if(0>=n)return 0;if(n>=1)return 1;var t=n*n,e=t*n;return 4*(.5>n?e:3*(n-t)+e-.75)}function xu(n){return function(t){return Math.pow(t,n)}}function Mu(n){return 1-Math.cos(n*Ea)}function _u(n){return Math.pow(2,10*(n-1))}function bu(n){return 1-Math.sqrt(1-n*n)}function wu(n,t){var e;return arguments.length<2&&(t=.45),arguments.length?e=t/ka*Math.asin(1/n):(n=1,e=t/4),function(r){return 1+n*Math.pow(2,-10*r)*Math.sin((r-e)*ka/t)}}function Su(n){return n||(n=1.70158),function(t){return t*t*((n+1)*t-n)}}function ku(n){return 1/2.75>n?7.5625*n*n:2/2.75>n?7.5625*(n-=1.5/2.75)*n+.75:2.5/2.75>n?7.5625*(n-=2.25/2.75)*n+.9375:7.5625*(n-=2.625/2.75)*n+.984375}function Eu(n,t){n=Xo.hcl(n),t=Xo.hcl(t);var e=n.h,r=n.c,u=n.l,i=t.h-e,o=t.c-r,a=t.l-u;return isNaN(o)&&(o=0,r=isNaN(r)?t.c:r),isNaN(i)?(i=0,e=isNaN(e)?t.h:e):i>180?i-=360:-180>i&&(i+=360),function(n){return rt(e+i*n,r+o*n,u+a*n)+""}}function Au(n,t){n=Xo.hsl(n),t=Xo.hsl(t);var e=n.h,r=n.s,u=n.l,i=t.h-e,o=t.s-r,a=t.l-u;return isNaN(o)&&(o=0,r=isNaN(r)?t.s:r),isNaN(i)?(i=0,e=isNaN(e)?t.h:e):i>180?i-=360:-180>i&&(i+=360),function(n){return nt(e+i*n,r+o*n,u+a*n)+""}}function Cu(n,t){n=Xo.lab(n),t=Xo.lab(t);var e=n.l,r=n.a,u=n.b,i=t.l-e,o=t.a-r,a=t.b-u;return function(n){return ot(e+i*n,r+o*n,u+a*n)+""}}function Nu(n,t){return t-=n,function(e){return Math.round(n+t*e)}}function Lu(n){var t=[n.a,n.b],e=[n.c,n.d],r=qu(t),u=zu(t,e),i=qu(Tu(e,t,-u))||0;t[0]*e[1]180?l+=360:l-s>180&&(s+=360),u.push({i:r.push(r.pop()+"rotate(",null,")")-2,x:su(s,l)})):l&&r.push(r.pop()+"rotate("+l+")"),f!=h?u.push({i:r.push(r.pop()+"skewX(",null,")")-2,x:su(f,h)}):h&&r.push(r.pop()+"skewX("+h+")"),g[0]!=p[0]||g[1]!=p[1]?(e=r.push(r.pop()+"scale(",null,",",null,")"),u.push({i:e-4,x:su(g[0],p[0])},{i:e-2,x:su(g[1],p[1])})):(1!=p[0]||1!=p[1])&&r.push(r.pop()+"scale("+p+")"),e=u.length,function(n){for(var t,i=-1;++ie;++e)(t=n[e][1])>u&&(r=e,u=t);return r}function ei(n){return n.reduce(ri,0)}function ri(n,t){return n+t[1]}function ui(n,t){return ii(n,Math.ceil(Math.log(t.length)/Math.LN2+1))}function ii(n,t){for(var e=-1,r=+n[0],u=(n[1]-r)/t,i=[];++e<=t;)i[e]=u*e+r;return i}function oi(n){return[Xo.min(n),Xo.max(n)]}function ai(n,t){return n.parent==t.parent?1:2}function ci(n){var t=n.children;return t&&t.length?t[0]:n._tree.thread}function si(n){var t,e=n.children;return e&&(t=e.length)?e[t-1]:n._tree.thread}function li(n,t){var e=n.children;if(e&&(u=e.length))for(var r,u,i=-1;++i0&&(n=r);return n}function fi(n,t){return n.x-t.x}function hi(n,t){return t.x-n.x}function gi(n,t){return n.depth-t.depth}function pi(n,t){function e(n,r){var u=n.children;if(u&&(o=u.length))for(var i,o,a=null,c=-1;++c=0;)t=u[i]._tree,t.prelim+=e,t.mod+=e,e+=t.shift+(r+=t.change)}function di(n,t,e){n=n._tree,t=t._tree;var r=e/(t.number-n.number);n.change+=r,t.change-=r,t.shift+=e,t.prelim+=e,t.mod+=e}function mi(n,t,e){return n._tree.ancestor.parent==t.parent?n._tree.ancestor:e}function yi(n,t){return n.value-t.value}function xi(n,t){var e=n._pack_next;n._pack_next=t,t._pack_prev=n,t._pack_next=e,e._pack_prev=t}function Mi(n,t){n._pack_next=t,t._pack_prev=n}function _i(n,t){var e=t.x-n.x,r=t.y-n.y,u=n.r+t.r;return.999*u*u>e*e+r*r}function bi(n){function t(n){l=Math.min(n.x-n.r,l),f=Math.max(n.x+n.r,f),h=Math.min(n.y-n.r,h),g=Math.max(n.y+n.r,g)}if((e=n.children)&&(s=e.length)){var e,r,u,i,o,a,c,s,l=1/0,f=-1/0,h=1/0,g=-1/0;if(e.forEach(wi),r=e[0],r.x=-r.r,r.y=0,t(r),s>1&&(u=e[1],u.x=u.r,u.y=0,t(u),s>2))for(i=e[2],Ei(r,u,i),t(i),xi(r,i),r._pack_prev=i,xi(i,u),u=r._pack_next,o=3;s>o;o++){Ei(r,u,i=e[o]);var p=0,v=1,d=1;for(a=u._pack_next;a!==u;a=a._pack_next,v++)if(_i(a,i)){p=1;break}if(1==p)for(c=r._pack_prev;c!==a._pack_prev&&!_i(c,i);c=c._pack_prev,d++);p?(d>v||v==d&&u.ro;o++)i=e[o],i.x-=m,i.y-=y,x=Math.max(x,i.r+Math.sqrt(i.x*i.x+i.y*i.y));n.r=x,e.forEach(Si)}}function wi(n){n._pack_next=n._pack_prev=n}function Si(n){delete n._pack_next,delete n._pack_prev}function ki(n,t,e,r){var u=n.children;if(n.x=t+=r*n.x,n.y=e+=r*n.y,n.r*=r,u)for(var i=-1,o=u.length;++iu&&(e+=u/2,u=0),0>i&&(r+=i/2,i=0),{x:e,y:r,dx:u,dy:i}}function Ti(n){var t=n[0],e=n[n.length-1];return e>t?[t,e]:[e,t]}function Ri(n){return n.rangeExtent?n.rangeExtent():Ti(n.range())}function Di(n,t,e,r){var u=e(n[0],n[1]),i=r(t[0],t[1]);return function(n){return i(u(n))}}function Pi(n,t){var e,r=0,u=n.length-1,i=n[r],o=n[u];return i>o&&(e=r,r=u,u=e,e=i,i=o,o=e),n[r]=t.floor(i),n[u]=t.ceil(o),n}function Ui(n){return n?{floor:function(t){return Math.floor(t/n)*n},ceil:function(t){return Math.ceil(t/n)*n}}:ls}function ji(n,t,e,r){var u=[],i=[],o=0,a=Math.min(n.length,t.length)-1;for(n[a]2?ji:Di,c=r?Pu:Du;return o=u(n,t,c,e),a=u(t,n,c,fu),i}function i(n){return o(n)}var o,a;return i.invert=function(n){return a(n)},i.domain=function(t){return arguments.length?(n=t.map(Number),u()):n},i.range=function(n){return arguments.length?(t=n,u()):t},i.rangeRound=function(n){return i.range(n).interpolate(Nu)},i.clamp=function(n){return arguments.length?(r=n,u()):r},i.interpolate=function(n){return arguments.length?(e=n,u()):e},i.ticks=function(t){return Ii(n,t)},i.tickFormat=function(t,e){return Zi(n,t,e)},i.nice=function(t){return Oi(n,t),u()},i.copy=function(){return Hi(n,t,e,r)},u()}function Fi(n,t){return Xo.rebind(n,t,"range","rangeRound","interpolate","clamp")}function Oi(n,t){return Pi(n,Ui(Yi(n,t)[2]))}function Yi(n,t){null==t&&(t=10);var e=Ti(n),r=e[1]-e[0],u=Math.pow(10,Math.floor(Math.log(r/t)/Math.LN10)),i=t/r*u;return.15>=i?u*=10:.35>=i?u*=5:.75>=i&&(u*=2),e[0]=Math.ceil(e[0]/u)*u,e[1]=Math.floor(e[1]/u)*u+.5*u,e[2]=u,e}function Ii(n,t){return Xo.range.apply(Xo,Yi(n,t))}function Zi(n,t,e){var r=Yi(n,t);return Xo.format(e?e.replace(Qa,function(n,t,e,u,i,o,a,c,s,l){return[t,e,u,i,o,a,c,s||"."+Xi(l,r),l].join("")}):",."+Vi(r[2])+"f")}function Vi(n){return-Math.floor(Math.log(n)/Math.LN10+.01)}function Xi(n,t){var e=Vi(t[2]);return n in fs?Math.abs(e-Vi(Math.max(Math.abs(t[0]),Math.abs(t[1]))))+ +("e"!==n):e-2*("%"===n)}function $i(n,t,e,r){function u(n){return(e?Math.log(0>n?0:n):-Math.log(n>0?0:-n))/Math.log(t)}function i(n){return e?Math.pow(t,n):-Math.pow(t,-n)}function o(t){return n(u(t))}return o.invert=function(t){return i(n.invert(t))},o.domain=function(t){return arguments.length?(e=t[0]>=0,n.domain((r=t.map(Number)).map(u)),o):r},o.base=function(e){return arguments.length?(t=+e,n.domain(r.map(u)),o):t},o.nice=function(){var t=Pi(r.map(u),e?Math:gs);return n.domain(t),r=t.map(i),o},o.ticks=function(){var n=Ti(r),o=[],a=n[0],c=n[1],s=Math.floor(u(a)),l=Math.ceil(u(c)),f=t%1?2:t;if(isFinite(l-s)){if(e){for(;l>s;s++)for(var h=1;f>h;h++)o.push(i(s)*h);o.push(i(s))}else for(o.push(i(s));s++0;h--)o.push(i(s)*h);for(s=0;o[s]c;l--);o=o.slice(s,l)}return o},o.tickFormat=function(n,t){if(!arguments.length)return hs;arguments.length<2?t=hs:"function"!=typeof t&&(t=Xo.format(t));var r,a=Math.max(.1,n/o.ticks().length),c=e?(r=1e-12,Math.ceil):(r=-1e-12,Math.floor);return function(n){return n/i(c(u(n)+r))<=a?t(n):""}},o.copy=function(){return $i(n.copy(),t,e,r)},Fi(o,n)}function Bi(n,t,e){function r(t){return n(u(t))}var u=Wi(t),i=Wi(1/t);return r.invert=function(t){return i(n.invert(t))},r.domain=function(t){return arguments.length?(n.domain((e=t.map(Number)).map(u)),r):e},r.ticks=function(n){return Ii(e,n)},r.tickFormat=function(n,t){return Zi(e,n,t)},r.nice=function(n){return r.domain(Oi(e,n))},r.exponent=function(o){return arguments.length?(u=Wi(t=o),i=Wi(1/t),n.domain(e.map(u)),r):t},r.copy=function(){return Bi(n.copy(),t,e)},Fi(r,n)}function Wi(n){return function(t){return 0>t?-Math.pow(-t,n):Math.pow(t,n)}}function Ji(n,t){function e(e){return o[((i.get(e)||"range"===t.t&&i.set(e,n.push(e)))-1)%o.length]}function r(t,e){return Xo.range(n.length).map(function(n){return t+e*n})}var i,o,a;return e.domain=function(r){if(!arguments.length)return n;n=[],i=new u;for(var o,a=-1,c=r.length;++ae?[0/0,0/0]:[e>0?u[e-1]:n[0],et?0/0:t/i+n,[t,t+1/i]},r.copy=function(){return Ki(n,t,e)},u()}function Qi(n,t){function e(e){return e>=e?t[Xo.bisect(n,e)]:void 0}return e.domain=function(t){return arguments.length?(n=t,e):n},e.range=function(n){return arguments.length?(t=n,e):t},e.invertExtent=function(e){return e=t.indexOf(e),[n[e-1],n[e]]},e.copy=function(){return Qi(n,t)},e}function no(n){function t(n){return+n}return t.invert=t,t.domain=t.range=function(e){return arguments.length?(n=e.map(t),t):n},t.ticks=function(t){return Ii(n,t)},t.tickFormat=function(t,e){return Zi(n,t,e)},t.copy=function(){return no(n)},t}function to(n){return n.innerRadius}function eo(n){return n.outerRadius}function ro(n){return n.startAngle}function uo(n){return n.endAngle}function io(n){function t(t){function o(){s.push("M",i(n(l),a))}for(var c,s=[],l=[],f=-1,h=t.length,g=_t(e),p=_t(r);++f1&&u.push("H",r[0]),u.join("")}function so(n){for(var t=0,e=n.length,r=n[0],u=[r[0],",",r[1]];++t1){a=t[1],i=n[c],c++,r+="C"+(u[0]+o[0])+","+(u[1]+o[1])+","+(i[0]-a[0])+","+(i[1]-a[1])+","+i[0]+","+i[1];for(var s=2;s9&&(u=3*t/Math.sqrt(u),o[a]=u*e,o[a+1]=u*r));for(a=-1;++a<=c;)u=(n[Math.min(c,a+1)][0]-n[Math.max(0,a-1)][0])/(6*(1+o[a]*o[a])),i.push([u||0,o[a]*u||0]);return i}function Eo(n){return n.length<3?oo(n):n[0]+po(n,ko(n))}function Ao(n){for(var t,e,r,u=-1,i=n.length;++ue?s():(i.active=e,o.event&&o.event.start.call(n,l,t),o.tween.forEach(function(e,r){(r=r.call(n,l,t))&&v.push(r)}),Xo.timer(function(){return p.c=c(r||1)?be:c,1},0,a),void 0)}function c(r){if(i.active!==e)return s();for(var u=r/g,a=f(u),c=v.length;c>0;)v[--c].call(n,a);return u>=1?(o.event&&o.event.end.call(n,l,t),s()):void 0}function s(){return--i.count?delete i[e]:delete n.__transition__,1}var l=n.__data__,f=o.ease,h=o.delay,g=o.duration,p=Ja,v=[];return p.t=h+a,r>=h?u(r-h):(p.c=u,void 0)},0,a)}}function Ho(n,t){n.attr("transform",function(n){return"translate("+t(n)+",0)"})}function Fo(n,t){n.attr("transform",function(n){return"translate(0,"+t(n)+")"})}function Oo(n){return n.toISOString()}function Yo(n,t,e){function r(t){return n(t)}function u(n,e){var r=n[1]-n[0],u=r/e,i=Xo.bisect(js,u); +return i==js.length?[t.year,Yi(n.map(function(n){return n/31536e6}),e)[2]]:i?t[u/js[i-1]1?{floor:function(t){for(;e(t=n.floor(t));)t=Io(t-1);return t},ceil:function(t){for(;e(t=n.ceil(t));)t=Io(+t+1);return t}}:n))},r.ticks=function(n,t){var e=Ti(r.domain()),i=null==n?u(e,10):"number"==typeof n?u(e,n):!n.range&&[{range:n},t];return i&&(n=i[0],t=i[1]),n.range(e[0],Io(+e[1]+1),1>t?1:t)},r.tickFormat=function(){return e},r.copy=function(){return Yo(n.copy(),t,e)},Fi(r,n)}function Io(n){return new Date(n)}function Zo(n){return JSON.parse(n.responseText)}function Vo(n){var t=Wo.createRange();return t.selectNode(Wo.body),t.createContextualFragment(n.responseText)}var Xo={version:"3.4.3"};Date.now||(Date.now=function(){return+new Date});var $o=[].slice,Bo=function(n){return $o.call(n)},Wo=document,Jo=Wo.documentElement,Go=window;try{Bo(Jo.childNodes)[0].nodeType}catch(Ko){Bo=function(n){for(var t=n.length,e=new Array(t);t--;)e[t]=n[t];return e}}try{Wo.createElement("div").style.setProperty("opacity",0,"")}catch(Qo){var na=Go.Element.prototype,ta=na.setAttribute,ea=na.setAttributeNS,ra=Go.CSSStyleDeclaration.prototype,ua=ra.setProperty;na.setAttribute=function(n,t){ta.call(this,n,t+"")},na.setAttributeNS=function(n,t,e){ea.call(this,n,t,e+"")},ra.setProperty=function(n,t,e){ua.call(this,n,t+"",e)}}Xo.ascending=function(n,t){return t>n?-1:n>t?1:n>=t?0:0/0},Xo.descending=function(n,t){return n>t?-1:t>n?1:t>=n?0:0/0},Xo.min=function(n,t){var e,r,u=-1,i=n.length;if(1===arguments.length){for(;++u=e);)e=void 0;for(;++ur&&(e=r)}else{for(;++u=e);)e=void 0;for(;++ur&&(e=r)}return e},Xo.max=function(n,t){var e,r,u=-1,i=n.length;if(1===arguments.length){for(;++u=e);)e=void 0;for(;++ue&&(e=r)}else{for(;++u=e);)e=void 0;for(;++ue&&(e=r)}return e},Xo.extent=function(n,t){var e,r,u,i=-1,o=n.length;if(1===arguments.length){for(;++i=e);)e=u=void 0;for(;++ir&&(e=r),r>u&&(u=r))}else{for(;++i=e);)e=void 0;for(;++ir&&(e=r),r>u&&(u=r))}return[e,u]},Xo.sum=function(n,t){var e,r=0,u=n.length,i=-1;if(1===arguments.length)for(;++i1&&(t=t.map(e)),t=t.filter(n),t.length?Xo.quantile(t.sort(Xo.ascending),.5):void 0},Xo.bisector=function(n){return{left:function(t,e,r,u){for(arguments.length<3&&(r=0),arguments.length<4&&(u=t.length);u>r;){var i=r+u>>>1;n.call(t,t[i],i)r;){var i=r+u>>>1;er?0:r);r>e;)i[e]=[t=u,u=n[++e]];return i},Xo.zip=function(){if(!(u=arguments.length))return[];for(var n=-1,e=Xo.min(arguments,t),r=new Array(e);++n=0;)for(r=n[u],t=r.length;--t>=0;)e[--o]=r[t];return e};var oa=Math.abs;Xo.range=function(n,t,r){if(arguments.length<3&&(r=1,arguments.length<2&&(t=n,n=0)),1/0===(t-n)/r)throw new Error("infinite range");var u,i=[],o=e(oa(r)),a=-1;if(n*=o,t*=o,r*=o,0>r)for(;(u=n+r*++a)>t;)i.push(u/o);else for(;(u=n+r*++a)=o.length)return r?r.call(i,a):e?a.sort(e):a;for(var s,l,f,h,g=-1,p=a.length,v=o[c++],d=new u;++g=o.length)return n;var r=[],u=a[e++];return n.forEach(function(n,u){r.push({key:n,values:t(u,e)})}),u?r.sort(function(n,t){return u(n.key,t.key)}):r}var e,r,i={},o=[],a=[];return i.map=function(t,e){return n(e,t,0)},i.entries=function(e){return t(n(Xo.map,e,0),0)},i.key=function(n){return o.push(n),i},i.sortKeys=function(n){return a[o.length-1]=n,i},i.sortValues=function(n){return e=n,i},i.rollup=function(n){return r=n,i},i},Xo.set=function(n){var t=new l;if(n)for(var e=0,r=n.length;r>e;++e)t.add(n[e]);return t},r(l,{has:i,add:function(n){return this[aa+n]=!0,n},remove:function(n){return n=aa+n,n in this&&delete this[n]},values:a,size:c,empty:s,forEach:function(n){for(var t in this)t.charCodeAt(0)===ca&&n.call(this,t.substring(1))}}),Xo.behavior={},Xo.rebind=function(n,t){for(var e,r=1,u=arguments.length;++r=0&&(r=n.substring(e+1),n=n.substring(0,e)),n)return arguments.length<2?this[n].on(r):this[n].on(r,t);if(2===arguments.length){if(null==t)for(n in this)this.hasOwnProperty(n)&&this[n].on(r,null);return this}},Xo.event=null,Xo.requote=function(n){return n.replace(la,"\\$&")};var la=/[\\\^\$\*\+\?\|\[\]\(\)\.\{\}]/g,fa={}.__proto__?function(n,t){n.__proto__=t}:function(n,t){for(var e in t)n[e]=t[e]},ha=function(n,t){return t.querySelector(n)},ga=function(n,t){return t.querySelectorAll(n)},pa=Jo[h(Jo,"matchesSelector")],va=function(n,t){return pa.call(n,t)};"function"==typeof Sizzle&&(ha=function(n,t){return Sizzle(n,t)[0]||null},ga=function(n,t){return Sizzle.uniqueSort(Sizzle(n,t))},va=Sizzle.matchesSelector),Xo.selection=function(){return xa};var da=Xo.selection.prototype=[];da.select=function(n){var t,e,r,u,i=[];n=M(n);for(var o=-1,a=this.length;++o=0&&(e=n.substring(0,t),n=n.substring(t+1)),ma.hasOwnProperty(e)?{space:ma[e],local:n}:n}},da.attr=function(n,t){if(arguments.length<2){if("string"==typeof n){var e=this.node();return n=Xo.ns.qualify(n),n.local?e.getAttributeNS(n.space,n.local):e.getAttribute(n)}for(t in n)this.each(b(t,n[t]));return this}return this.each(b(n,t))},da.classed=function(n,t){if(arguments.length<2){if("string"==typeof n){var e=this.node(),r=(n=k(n)).length,u=-1;if(t=e.classList){for(;++ur){if("string"!=typeof n){2>r&&(t="");for(e in n)this.each(C(e,n[e],t));return this}if(2>r)return Go.getComputedStyle(this.node(),null).getPropertyValue(n);e=""}return this.each(C(n,t,e))},da.property=function(n,t){if(arguments.length<2){if("string"==typeof n)return this.node()[n];for(t in n)this.each(N(t,n[t]));return this}return this.each(N(n,t))},da.text=function(n){return arguments.length?this.each("function"==typeof n?function(){var t=n.apply(this,arguments);this.textContent=null==t?"":t}:null==n?function(){this.textContent=""}:function(){this.textContent=n}):this.node().textContent},da.html=function(n){return arguments.length?this.each("function"==typeof n?function(){var t=n.apply(this,arguments);this.innerHTML=null==t?"":t}:null==n?function(){this.innerHTML=""}:function(){this.innerHTML=n}):this.node().innerHTML},da.append=function(n){return n=L(n),this.select(function(){return this.appendChild(n.apply(this,arguments))})},da.insert=function(n,t){return n=L(n),t=M(t),this.select(function(){return this.insertBefore(n.apply(this,arguments),t.apply(this,arguments)||null)})},da.remove=function(){return this.each(function(){var n=this.parentNode;n&&n.removeChild(this)})},da.data=function(n,t){function e(n,e){var r,i,o,a=n.length,f=e.length,h=Math.min(a,f),g=new Array(f),p=new Array(f),v=new Array(a);if(t){var d,m=new u,y=new u,x=[];for(r=-1;++rr;++r)p[r]=z(e[r]);for(;a>r;++r)v[r]=n[r]}p.update=g,p.parentNode=g.parentNode=v.parentNode=n.parentNode,c.push(p),s.push(g),l.push(v)}var r,i,o=-1,a=this.length;if(!arguments.length){for(n=new Array(a=(r=this[0]).length);++oi;i++){u.push(t=[]),t.parentNode=(e=this[i]).parentNode;for(var a=0,c=e.length;c>a;a++)(r=e[a])&&n.call(r,r.__data__,a,i)&&t.push(r)}return x(u)},da.order=function(){for(var n=-1,t=this.length;++n=0;)(e=r[u])&&(i&&i!==e.nextSibling&&i.parentNode.insertBefore(e,i),i=e);return this},da.sort=function(n){n=T.apply(this,arguments);for(var t=-1,e=this.length;++tn;n++)for(var e=this[n],r=0,u=e.length;u>r;r++){var i=e[r];if(i)return i}return null},da.size=function(){var n=0;return this.each(function(){++n}),n};var ya=[];Xo.selection.enter=D,Xo.selection.enter.prototype=ya,ya.append=da.append,ya.empty=da.empty,ya.node=da.node,ya.call=da.call,ya.size=da.size,ya.select=function(n){for(var t,e,r,u,i,o=[],a=-1,c=this.length;++ar){if("string"!=typeof n){2>r&&(t=!1);for(e in n)this.each(j(e,n[e],t));return this}if(2>r)return(r=this.node()["__on"+n])&&r._;e=!1}return this.each(j(n,t,e))};var Ma=Xo.map({mouseenter:"mouseover",mouseleave:"mouseout"});Ma.forEach(function(n){"on"+n in Wo&&Ma.remove(n)});var _a="onselectstart"in Wo?null:h(Jo.style,"userSelect"),ba=0;Xo.mouse=function(n){return Y(n,m())};var wa=/WebKit/.test(Go.navigator.userAgent)?-1:0;Xo.touches=function(n,t){return arguments.length<2&&(t=m().touches),t?Bo(t).map(function(t){var e=Y(n,t);return e.identifier=t.identifier,e}):[]},Xo.behavior.drag=function(){function n(){this.on("mousedown.drag",o).on("touchstart.drag",a)}function t(){return Xo.event.changedTouches[0].identifier}function e(n,t){return Xo.touches(n).filter(function(n){return n.identifier===t})[0]}function r(n,t,e,r){return function(){function o(){var n=t(l,g),e=n[0]-v[0],r=n[1]-v[1];d|=e|r,v=n,f({type:"drag",x:n[0]+c[0],y:n[1]+c[1],dx:e,dy:r})}function a(){m.on(e+"."+p,null).on(r+"."+p,null),y(d&&Xo.event.target===h),f({type:"dragend"})}var c,s=this,l=s.parentNode,f=u.of(s,arguments),h=Xo.event.target,g=n(),p=null==g?"drag":"drag-"+g,v=t(l,g),d=0,m=Xo.select(Go).on(e+"."+p,o).on(r+"."+p,a),y=O();i?(c=i.apply(s,arguments),c=[c.x-v[0],c.y-v[1]]):c=[0,0],f({type:"dragstart"})}}var u=y(n,"drag","dragstart","dragend"),i=null,o=r(g,Xo.mouse,"mousemove","mouseup"),a=r(t,e,"touchmove","touchend");return n.origin=function(t){return arguments.length?(i=t,n):i},Xo.rebind(n,u,"on")};var Sa=Math.PI,ka=2*Sa,Ea=Sa/2,Aa=1e-6,Ca=Aa*Aa,Na=Sa/180,La=180/Sa,za=Math.SQRT2,qa=2,Ta=4;Xo.interpolateZoom=function(n,t){function e(n){var t=n*y;if(m){var e=B(v),o=i/(qa*h)*(e*W(za*t+v)-$(v));return[r+o*s,u+o*l,i*e/B(za*t+v)]}return[r+n*s,u+n*l,i*Math.exp(za*t)]}var r=n[0],u=n[1],i=n[2],o=t[0],a=t[1],c=t[2],s=o-r,l=a-u,f=s*s+l*l,h=Math.sqrt(f),g=(c*c-i*i+Ta*f)/(2*i*qa*h),p=(c*c-i*i-Ta*f)/(2*c*qa*h),v=Math.log(Math.sqrt(g*g+1)-g),d=Math.log(Math.sqrt(p*p+1)-p),m=d-v,y=(m||Math.log(c/i))/za;return e.duration=1e3*y,e},Xo.behavior.zoom=function(){function n(n){n.on(A,s).on(Pa+".zoom",f).on(C,h).on("dblclick.zoom",g).on(L,l)}function t(n){return[(n[0]-S.x)/S.k,(n[1]-S.y)/S.k]}function e(n){return[n[0]*S.k+S.x,n[1]*S.k+S.y]}function r(n){S.k=Math.max(E[0],Math.min(E[1],n))}function u(n,t){t=e(t),S.x+=n[0]-t[0],S.y+=n[1]-t[1]}function i(){_&&_.domain(M.range().map(function(n){return(n-S.x)/S.k}).map(M.invert)),w&&w.domain(b.range().map(function(n){return(n-S.y)/S.k}).map(b.invert))}function o(n){n({type:"zoomstart"})}function a(n){i(),n({type:"zoom",scale:S.k,translate:[S.x,S.y]})}function c(n){n({type:"zoomend"})}function s(){function n(){l=1,u(Xo.mouse(r),g),a(i)}function e(){f.on(C,Go===r?h:null).on(N,null),p(l&&Xo.event.target===s),c(i)}var r=this,i=z.of(r,arguments),s=Xo.event.target,l=0,f=Xo.select(Go).on(C,n).on(N,e),g=t(Xo.mouse(r)),p=O();U.call(r),o(i)}function l(){function n(){var n=Xo.touches(g);return h=S.k,n.forEach(function(n){n.identifier in v&&(v[n.identifier]=t(n))}),n}function e(){for(var t=Xo.event.changedTouches,e=0,i=t.length;i>e;++e)v[t[e].identifier]=null;var o=n(),c=Date.now();if(1===o.length){if(500>c-x){var s=o[0],l=v[s.identifier];r(2*S.k),u(s,l),d(),a(p)}x=c}else if(o.length>1){var s=o[0],f=o[1],h=s[0]-f[0],g=s[1]-f[1];m=h*h+g*g}}function i(){for(var n,t,e,i,o=Xo.touches(g),c=0,s=o.length;s>c;++c,i=null)if(e=o[c],i=v[e.identifier]){if(t)break;n=e,t=i}if(i){var l=(l=e[0]-n[0])*l+(l=e[1]-n[1])*l,f=m&&Math.sqrt(l/m);n=[(n[0]+e[0])/2,(n[1]+e[1])/2],t=[(t[0]+i[0])/2,(t[1]+i[1])/2],r(f*h)}x=null,u(n,t),a(p)}function f(){if(Xo.event.touches.length){for(var t=Xo.event.changedTouches,e=0,r=t.length;r>e;++e)delete v[t[e].identifier];for(var u in v)return void n()}b.on(M,null).on(_,null),w.on(A,s).on(L,l),k(),c(p)}var h,g=this,p=z.of(g,arguments),v={},m=0,y=Xo.event.changedTouches[0].identifier,M="touchmove.zoom-"+y,_="touchend.zoom-"+y,b=Xo.select(Go).on(M,i).on(_,f),w=Xo.select(g).on(A,null).on(L,e),k=O();U.call(g),e(),o(p)}function f(){var n=z.of(this,arguments);m?clearTimeout(m):(U.call(this),o(n)),m=setTimeout(function(){m=null,c(n)},50),d();var e=v||Xo.mouse(this);p||(p=t(e)),r(Math.pow(2,.002*Ra())*S.k),u(e,p),a(n)}function h(){p=null}function g(){var n=z.of(this,arguments),e=Xo.mouse(this),i=t(e),s=Math.log(S.k)/Math.LN2;o(n),r(Math.pow(2,Xo.event.shiftKey?Math.ceil(s)-1:Math.floor(s)+1)),u(e,i),a(n),c(n)}var p,v,m,x,M,_,b,w,S={x:0,y:0,k:1},k=[960,500],E=Da,A="mousedown.zoom",C="mousemove.zoom",N="mouseup.zoom",L="touchstart.zoom",z=y(n,"zoomstart","zoom","zoomend");return n.event=function(n){n.each(function(){var n=z.of(this,arguments),t=S;ks?Xo.select(this).transition().each("start.zoom",function(){S=this.__chart__||{x:0,y:0,k:1},o(n)}).tween("zoom:zoom",function(){var e=k[0],r=k[1],u=e/2,i=r/2,o=Xo.interpolateZoom([(u-S.x)/S.k,(i-S.y)/S.k,e/S.k],[(u-t.x)/t.k,(i-t.y)/t.k,e/t.k]);return function(t){var r=o(t),c=e/r[2];this.__chart__=S={x:u-r[0]*c,y:i-r[1]*c,k:c},a(n)}}).each("end.zoom",function(){c(n)}):(this.__chart__=S,o(n),a(n),c(n))})},n.translate=function(t){return arguments.length?(S={x:+t[0],y:+t[1],k:S.k},i(),n):[S.x,S.y]},n.scale=function(t){return arguments.length?(S={x:S.x,y:S.y,k:+t},i(),n):S.k},n.scaleExtent=function(t){return arguments.length?(E=null==t?Da:[+t[0],+t[1]],n):E},n.center=function(t){return arguments.length?(v=t&&[+t[0],+t[1]],n):v},n.size=function(t){return arguments.length?(k=t&&[+t[0],+t[1]],n):k},n.x=function(t){return arguments.length?(_=t,M=t.copy(),S={x:0,y:0,k:1},n):_},n.y=function(t){return arguments.length?(w=t,b=t.copy(),S={x:0,y:0,k:1},n):w},Xo.rebind(n,z,"on")};var Ra,Da=[0,1/0],Pa="onwheel"in Wo?(Ra=function(){return-Xo.event.deltaY*(Xo.event.deltaMode?120:1)},"wheel"):"onmousewheel"in Wo?(Ra=function(){return Xo.event.wheelDelta},"mousewheel"):(Ra=function(){return-Xo.event.detail},"MozMousePixelScroll");G.prototype.toString=function(){return this.rgb()+""},Xo.hsl=function(n,t,e){return 1===arguments.length?n instanceof Q?K(n.h,n.s,n.l):dt(""+n,mt,K):K(+n,+t,+e)};var Ua=Q.prototype=new G;Ua.brighter=function(n){return n=Math.pow(.7,arguments.length?n:1),K(this.h,this.s,this.l/n)},Ua.darker=function(n){return n=Math.pow(.7,arguments.length?n:1),K(this.h,this.s,n*this.l)},Ua.rgb=function(){return nt(this.h,this.s,this.l)},Xo.hcl=function(n,t,e){return 1===arguments.length?n instanceof et?tt(n.h,n.c,n.l):n instanceof it?at(n.l,n.a,n.b):at((n=yt((n=Xo.rgb(n)).r,n.g,n.b)).l,n.a,n.b):tt(+n,+t,+e)};var ja=et.prototype=new G;ja.brighter=function(n){return tt(this.h,this.c,Math.min(100,this.l+Ha*(arguments.length?n:1)))},ja.darker=function(n){return tt(this.h,this.c,Math.max(0,this.l-Ha*(arguments.length?n:1)))},ja.rgb=function(){return rt(this.h,this.c,this.l).rgb()},Xo.lab=function(n,t,e){return 1===arguments.length?n instanceof it?ut(n.l,n.a,n.b):n instanceof et?rt(n.l,n.c,n.h):yt((n=Xo.rgb(n)).r,n.g,n.b):ut(+n,+t,+e)};var Ha=18,Fa=.95047,Oa=1,Ya=1.08883,Ia=it.prototype=new G;Ia.brighter=function(n){return ut(Math.min(100,this.l+Ha*(arguments.length?n:1)),this.a,this.b)},Ia.darker=function(n){return ut(Math.max(0,this.l-Ha*(arguments.length?n:1)),this.a,this.b)},Ia.rgb=function(){return ot(this.l,this.a,this.b)},Xo.rgb=function(n,t,e){return 1===arguments.length?n instanceof pt?gt(n.r,n.g,n.b):dt(""+n,gt,nt):gt(~~n,~~t,~~e)};var Za=pt.prototype=new G;Za.brighter=function(n){n=Math.pow(.7,arguments.length?n:1);var t=this.r,e=this.g,r=this.b,u=30;return t||e||r?(t&&u>t&&(t=u),e&&u>e&&(e=u),r&&u>r&&(r=u),gt(Math.min(255,~~(t/n)),Math.min(255,~~(e/n)),Math.min(255,~~(r/n)))):gt(u,u,u)},Za.darker=function(n){return n=Math.pow(.7,arguments.length?n:1),gt(~~(n*this.r),~~(n*this.g),~~(n*this.b))},Za.hsl=function(){return mt(this.r,this.g,this.b)},Za.toString=function(){return"#"+vt(this.r)+vt(this.g)+vt(this.b)};var Va=Xo.map({aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074});Va.forEach(function(n,t){Va.set(n,ft(t))}),Xo.functor=_t,Xo.xhr=wt(bt),Xo.dsv=function(n,t){function e(n,e,i){arguments.length<3&&(i=e,e=null);var o=St(n,t,null==e?r:u(e),i);return o.row=function(n){return arguments.length?o.response(null==(e=n)?r:u(n)):e},o}function r(n){return e.parse(n.responseText)}function u(n){return function(t){return e.parse(t.responseText,n)}}function i(t){return t.map(o).join(n)}function o(n){return a.test(n)?'"'+n.replace(/\"/g,'""')+'"':n}var a=new RegExp('["'+n+"\n]"),c=n.charCodeAt(0);return e.parse=function(n,t){var r;return e.parseRows(n,function(n,e){if(r)return r(n,e-1);var u=new Function("d","return {"+n.map(function(n,t){return JSON.stringify(n)+": d["+t+"]"}).join(",")+"}");r=t?function(n,e){return t(u(n),e)}:u})},e.parseRows=function(n,t){function e(){if(l>=s)return o;if(u)return u=!1,i;var t=l;if(34===n.charCodeAt(t)){for(var e=t;e++l;){var r=n.charCodeAt(l++),a=1;if(10===r)u=!0;else if(13===r)u=!0,10===n.charCodeAt(l)&&(++l,++a);else if(r!==c)continue;return n.substring(t,l-a)}return n.substring(t)}for(var r,u,i={},o={},a=[],s=n.length,l=0,f=0;(r=e())!==o;){for(var h=[];r!==i&&r!==o;)h.push(r),r=e();(!t||(h=t(h,f++)))&&a.push(h)}return a},e.format=function(t){if(Array.isArray(t[0]))return e.formatRows(t);var r=new l,u=[];return t.forEach(function(n){for(var t in n)r.has(t)||u.push(r.add(t))}),[u.map(o).join(n)].concat(t.map(function(t){return u.map(function(n){return o(t[n])}).join(n)})).join("\n")},e.formatRows=function(n){return n.map(i).join("\n")},e},Xo.csv=Xo.dsv(",","text/csv"),Xo.tsv=Xo.dsv(" ","text/tab-separated-values");var Xa,$a,Ba,Wa,Ja,Ga=Go[h(Go,"requestAnimationFrame")]||function(n){setTimeout(n,17)};Xo.timer=function(n,t,e){var r=arguments.length;2>r&&(t=0),3>r&&(e=Date.now());var u=e+t,i={c:n,t:u,f:!1,n:null};$a?$a.n=i:Xa=i,$a=i,Ba||(Wa=clearTimeout(Wa),Ba=1,Ga(Et))},Xo.timer.flush=function(){At(),Ct()},Xo.round=function(n,t){return t?Math.round(n*(t=Math.pow(10,t)))/t:Math.round(n)};var Ka=["y","z","a","f","p","n","\xb5","m","","k","M","G","T","P","E","Z","Y"].map(Lt);Xo.formatPrefix=function(n,t){var e=0;return n&&(0>n&&(n*=-1),t&&(n=Xo.round(n,Nt(n,t))),e=1+Math.floor(1e-12+Math.log(n)/Math.LN10),e=Math.max(-24,Math.min(24,3*Math.floor((0>=e?e+1:e-1)/3)))),Ka[8+e/3]};var Qa=/(?:([^{])?([<>=^]))?([+\- ])?([$#])?(0)?(\d+)?(,)?(\.-?\d+)?([a-z%])?/i,nc=Xo.map({b:function(n){return n.toString(2)},c:function(n){return String.fromCharCode(n)},o:function(n){return n.toString(8)},x:function(n){return n.toString(16)},X:function(n){return n.toString(16).toUpperCase()},g:function(n,t){return n.toPrecision(t)},e:function(n,t){return n.toExponential(t)},f:function(n,t){return n.toFixed(t)},r:function(n,t){return(n=Xo.round(n,Nt(n,t))).toFixed(Math.max(0,Math.min(20,Nt(n*(1+1e-15),t))))}}),tc=Xo.time={},ec=Date;Tt.prototype={getDate:function(){return this._.getUTCDate()},getDay:function(){return this._.getUTCDay()},getFullYear:function(){return this._.getUTCFullYear()},getHours:function(){return this._.getUTCHours()},getMilliseconds:function(){return this._.getUTCMilliseconds()},getMinutes:function(){return this._.getUTCMinutes()},getMonth:function(){return this._.getUTCMonth()},getSeconds:function(){return this._.getUTCSeconds()},getTime:function(){return this._.getTime()},getTimezoneOffset:function(){return 0},valueOf:function(){return this._.valueOf()},setDate:function(){rc.setUTCDate.apply(this._,arguments)},setDay:function(){rc.setUTCDay.apply(this._,arguments)},setFullYear:function(){rc.setUTCFullYear.apply(this._,arguments)},setHours:function(){rc.setUTCHours.apply(this._,arguments)},setMilliseconds:function(){rc.setUTCMilliseconds.apply(this._,arguments)},setMinutes:function(){rc.setUTCMinutes.apply(this._,arguments)},setMonth:function(){rc.setUTCMonth.apply(this._,arguments)},setSeconds:function(){rc.setUTCSeconds.apply(this._,arguments)},setTime:function(){rc.setTime.apply(this._,arguments)}};var rc=Date.prototype;tc.year=Rt(function(n){return n=tc.day(n),n.setMonth(0,1),n},function(n,t){n.setFullYear(n.getFullYear()+t)},function(n){return n.getFullYear()}),tc.years=tc.year.range,tc.years.utc=tc.year.utc.range,tc.day=Rt(function(n){var t=new ec(2e3,0);return t.setFullYear(n.getFullYear(),n.getMonth(),n.getDate()),t},function(n,t){n.setDate(n.getDate()+t)},function(n){return n.getDate()-1}),tc.days=tc.day.range,tc.days.utc=tc.day.utc.range,tc.dayOfYear=function(n){var t=tc.year(n);return Math.floor((n-t-6e4*(n.getTimezoneOffset()-t.getTimezoneOffset()))/864e5)},["sunday","monday","tuesday","wednesday","thursday","friday","saturday"].forEach(function(n,t){t=7-t;var e=tc[n]=Rt(function(n){return(n=tc.day(n)).setDate(n.getDate()-(n.getDay()+t)%7),n},function(n,t){n.setDate(n.getDate()+7*Math.floor(t))},function(n){var e=tc.year(n).getDay();return Math.floor((tc.dayOfYear(n)+(e+t)%7)/7)-(e!==t)});tc[n+"s"]=e.range,tc[n+"s"].utc=e.utc.range,tc[n+"OfYear"]=function(n){var e=tc.year(n).getDay();return Math.floor((tc.dayOfYear(n)+(e+t)%7)/7)}}),tc.week=tc.sunday,tc.weeks=tc.sunday.range,tc.weeks.utc=tc.sunday.utc.range,tc.weekOfYear=tc.sundayOfYear;var uc={"-":"",_:" ",0:"0"},ic=/^\s*\d+/,oc=/^%/;Xo.locale=function(n){return{numberFormat:zt(n),timeFormat:Pt(n)}};var ac=Xo.locale({decimal:".",thousands:",",grouping:[3],currency:["$",""],dateTime:"%a %b %e %X %Y",date:"%m/%d/%Y",time:"%H:%M:%S",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});Xo.format=ac.numberFormat,Xo.geo={},re.prototype={s:0,t:0,add:function(n){ue(n,this.t,cc),ue(cc.s,this.s,this),this.s?this.t+=cc.t:this.s=cc.t},reset:function(){this.s=this.t=0},valueOf:function(){return this.s}};var cc=new re;Xo.geo.stream=function(n,t){n&&sc.hasOwnProperty(n.type)?sc[n.type](n,t):ie(n,t)};var sc={Feature:function(n,t){ie(n.geometry,t)},FeatureCollection:function(n,t){for(var e=n.features,r=-1,u=e.length;++rn?4*Sa+n:n,gc.lineStart=gc.lineEnd=gc.point=g}};Xo.geo.bounds=function(){function n(n,t){x.push(M=[l=n,h=n]),f>t&&(f=t),t>g&&(g=t)}function t(t,e){var r=se([t*Na,e*Na]);if(m){var u=fe(m,r),i=[u[1],-u[0],0],o=fe(i,u);pe(o),o=ve(o);var c=t-p,s=c>0?1:-1,v=o[0]*La*s,d=oa(c)>180;if(d^(v>s*p&&s*t>v)){var y=o[1]*La;y>g&&(g=y)}else if(v=(v+360)%360-180,d^(v>s*p&&s*t>v)){var y=-o[1]*La;f>y&&(f=y)}else f>e&&(f=e),e>g&&(g=e);d?p>t?a(l,t)>a(l,h)&&(h=t):a(t,h)>a(l,h)&&(l=t):h>=l?(l>t&&(l=t),t>h&&(h=t)):t>p?a(l,t)>a(l,h)&&(h=t):a(t,h)>a(l,h)&&(l=t)}else n(t,e);m=r,p=t}function e(){_.point=t}function r(){M[0]=l,M[1]=h,_.point=n,m=null}function u(n,e){if(m){var r=n-p;y+=oa(r)>180?r+(r>0?360:-360):r}else v=n,d=e;gc.point(n,e),t(n,e)}function i(){gc.lineStart()}function o(){u(v,d),gc.lineEnd(),oa(y)>Aa&&(l=-(h=180)),M[0]=l,M[1]=h,m=null}function a(n,t){return(t-=n)<0?t+360:t}function c(n,t){return n[0]-t[0]}function s(n,t){return t[0]<=t[1]?t[0]<=n&&n<=t[1]:nhc?(l=-(h=180),f=-(g=90)):y>Aa?g=90:-Aa>y&&(f=-90),M[0]=l,M[1]=h +}};return function(n){g=h=-(l=f=1/0),x=[],Xo.geo.stream(n,_);var t=x.length;if(t){x.sort(c);for(var e,r=1,u=x[0],i=[u];t>r;++r)e=x[r],s(e[0],u)||s(e[1],u)?(a(u[0],e[1])>a(u[0],u[1])&&(u[1]=e[1]),a(e[0],u[1])>a(u[0],u[1])&&(u[0]=e[0])):i.push(u=e);for(var o,e,p=-1/0,t=i.length-1,r=0,u=i[t];t>=r;u=e,++r)e=i[r],(o=a(u[1],e[0]))>p&&(p=o,l=e[0],h=u[1])}return x=M=null,1/0===l||1/0===f?[[0/0,0/0],[0/0,0/0]]:[[l,f],[h,g]]}}(),Xo.geo.centroid=function(n){pc=vc=dc=mc=yc=xc=Mc=_c=bc=wc=Sc=0,Xo.geo.stream(n,kc);var t=bc,e=wc,r=Sc,u=t*t+e*e+r*r;return Ca>u&&(t=xc,e=Mc,r=_c,Aa>vc&&(t=dc,e=mc,r=yc),u=t*t+e*e+r*r,Ca>u)?[0/0,0/0]:[Math.atan2(e,t)*La,X(r/Math.sqrt(u))*La]};var pc,vc,dc,mc,yc,xc,Mc,_c,bc,wc,Sc,kc={sphere:g,point:me,lineStart:xe,lineEnd:Me,polygonStart:function(){kc.lineStart=_e},polygonEnd:function(){kc.lineStart=xe}},Ec=Ee(be,ze,Te,[-Sa,-Sa/2]),Ac=1e9;Xo.geo.clipExtent=function(){var n,t,e,r,u,i,o={stream:function(n){return u&&(u.valid=!1),u=i(n),u.valid=!0,u},extent:function(a){return arguments.length?(i=Pe(n=+a[0][0],t=+a[0][1],e=+a[1][0],r=+a[1][1]),u&&(u.valid=!1,u=null),o):[[n,t],[e,r]]}};return o.extent([[0,0],[960,500]])},(Xo.geo.conicEqualArea=function(){return je(He)}).raw=He,Xo.geo.albers=function(){return Xo.geo.conicEqualArea().rotate([96,0]).center([-.6,38.7]).parallels([29.5,45.5]).scale(1070)},Xo.geo.albersUsa=function(){function n(n){var i=n[0],o=n[1];return t=null,e(i,o),t||(r(i,o),t)||u(i,o),t}var t,e,r,u,i=Xo.geo.albers(),o=Xo.geo.conicEqualArea().rotate([154,0]).center([-2,58.5]).parallels([55,65]),a=Xo.geo.conicEqualArea().rotate([157,0]).center([-3,19.9]).parallels([8,18]),c={point:function(n,e){t=[n,e]}};return n.invert=function(n){var t=i.scale(),e=i.translate(),r=(n[0]-e[0])/t,u=(n[1]-e[1])/t;return(u>=.12&&.234>u&&r>=-.425&&-.214>r?o:u>=.166&&.234>u&&r>=-.214&&-.115>r?a:i).invert(n)},n.stream=function(n){var t=i.stream(n),e=o.stream(n),r=a.stream(n);return{point:function(n,u){t.point(n,u),e.point(n,u),r.point(n,u)},sphere:function(){t.sphere(),e.sphere(),r.sphere()},lineStart:function(){t.lineStart(),e.lineStart(),r.lineStart()},lineEnd:function(){t.lineEnd(),e.lineEnd(),r.lineEnd()},polygonStart:function(){t.polygonStart(),e.polygonStart(),r.polygonStart()},polygonEnd:function(){t.polygonEnd(),e.polygonEnd(),r.polygonEnd()}}},n.precision=function(t){return arguments.length?(i.precision(t),o.precision(t),a.precision(t),n):i.precision()},n.scale=function(t){return arguments.length?(i.scale(t),o.scale(.35*t),a.scale(t),n.translate(i.translate())):i.scale()},n.translate=function(t){if(!arguments.length)return i.translate();var s=i.scale(),l=+t[0],f=+t[1];return e=i.translate(t).clipExtent([[l-.455*s,f-.238*s],[l+.455*s,f+.238*s]]).stream(c).point,r=o.translate([l-.307*s,f+.201*s]).clipExtent([[l-.425*s+Aa,f+.12*s+Aa],[l-.214*s-Aa,f+.234*s-Aa]]).stream(c).point,u=a.translate([l-.205*s,f+.212*s]).clipExtent([[l-.214*s+Aa,f+.166*s+Aa],[l-.115*s-Aa,f+.234*s-Aa]]).stream(c).point,n},n.scale(1070)};var Cc,Nc,Lc,zc,qc,Tc,Rc={point:g,lineStart:g,lineEnd:g,polygonStart:function(){Nc=0,Rc.lineStart=Fe},polygonEnd:function(){Rc.lineStart=Rc.lineEnd=Rc.point=g,Cc+=oa(Nc/2)}},Dc={point:Oe,lineStart:g,lineEnd:g,polygonStart:g,polygonEnd:g},Pc={point:Ze,lineStart:Ve,lineEnd:Xe,polygonStart:function(){Pc.lineStart=$e},polygonEnd:function(){Pc.point=Ze,Pc.lineStart=Ve,Pc.lineEnd=Xe}};Xo.geo.path=function(){function n(n){return n&&("function"==typeof a&&i.pointRadius(+a.apply(this,arguments)),o&&o.valid||(o=u(i)),Xo.geo.stream(n,o)),i.result()}function t(){return o=null,n}var e,r,u,i,o,a=4.5;return n.area=function(n){return Cc=0,Xo.geo.stream(n,u(Rc)),Cc},n.centroid=function(n){return dc=mc=yc=xc=Mc=_c=bc=wc=Sc=0,Xo.geo.stream(n,u(Pc)),Sc?[bc/Sc,wc/Sc]:_c?[xc/_c,Mc/_c]:yc?[dc/yc,mc/yc]:[0/0,0/0]},n.bounds=function(n){return qc=Tc=-(Lc=zc=1/0),Xo.geo.stream(n,u(Dc)),[[Lc,zc],[qc,Tc]]},n.projection=function(n){return arguments.length?(u=(e=n)?n.stream||Je(n):bt,t()):e},n.context=function(n){return arguments.length?(i=null==(r=n)?new Ye:new Be(n),"function"!=typeof a&&i.pointRadius(a),t()):r},n.pointRadius=function(t){return arguments.length?(a="function"==typeof t?t:(i.pointRadius(+t),+t),n):a},n.projection(Xo.geo.albersUsa()).context(null)},Xo.geo.transform=function(n){return{stream:function(t){var e=new Ge(t);for(var r in n)e[r]=n[r];return e}}},Ge.prototype={point:function(n,t){this.stream.point(n,t)},sphere:function(){this.stream.sphere()},lineStart:function(){this.stream.lineStart()},lineEnd:function(){this.stream.lineEnd()},polygonStart:function(){this.stream.polygonStart()},polygonEnd:function(){this.stream.polygonEnd()}},Xo.geo.projection=Qe,Xo.geo.projectionMutator=nr,(Xo.geo.equirectangular=function(){return Qe(er)}).raw=er.invert=er,Xo.geo.rotation=function(n){function t(t){return t=n(t[0]*Na,t[1]*Na),t[0]*=La,t[1]*=La,t}return n=ur(n[0]%360*Na,n[1]*Na,n.length>2?n[2]*Na:0),t.invert=function(t){return t=n.invert(t[0]*Na,t[1]*Na),t[0]*=La,t[1]*=La,t},t},rr.invert=er,Xo.geo.circle=function(){function n(){var n="function"==typeof r?r.apply(this,arguments):r,t=ur(-n[0]*Na,-n[1]*Na,0).invert,u=[];return e(null,null,1,{point:function(n,e){u.push(n=t(n,e)),n[0]*=La,n[1]*=La}}),{type:"Polygon",coordinates:[u]}}var t,e,r=[0,0],u=6;return n.origin=function(t){return arguments.length?(r=t,n):r},n.angle=function(r){return arguments.length?(e=cr((t=+r)*Na,u*Na),n):t},n.precision=function(r){return arguments.length?(e=cr(t*Na,(u=+r)*Na),n):u},n.angle(90)},Xo.geo.distance=function(n,t){var e,r=(t[0]-n[0])*Na,u=n[1]*Na,i=t[1]*Na,o=Math.sin(r),a=Math.cos(r),c=Math.sin(u),s=Math.cos(u),l=Math.sin(i),f=Math.cos(i);return Math.atan2(Math.sqrt((e=f*o)*e+(e=s*l-c*f*a)*e),c*l+s*f*a)},Xo.geo.graticule=function(){function n(){return{type:"MultiLineString",coordinates:t()}}function t(){return Xo.range(Math.ceil(i/d)*d,u,d).map(h).concat(Xo.range(Math.ceil(s/m)*m,c,m).map(g)).concat(Xo.range(Math.ceil(r/p)*p,e,p).filter(function(n){return oa(n%d)>Aa}).map(l)).concat(Xo.range(Math.ceil(a/v)*v,o,v).filter(function(n){return oa(n%m)>Aa}).map(f))}var e,r,u,i,o,a,c,s,l,f,h,g,p=10,v=p,d=90,m=360,y=2.5;return n.lines=function(){return t().map(function(n){return{type:"LineString",coordinates:n}})},n.outline=function(){return{type:"Polygon",coordinates:[h(i).concat(g(c).slice(1),h(u).reverse().slice(1),g(s).reverse().slice(1))]}},n.extent=function(t){return arguments.length?n.majorExtent(t).minorExtent(t):n.minorExtent()},n.majorExtent=function(t){return arguments.length?(i=+t[0][0],u=+t[1][0],s=+t[0][1],c=+t[1][1],i>u&&(t=i,i=u,u=t),s>c&&(t=s,s=c,c=t),n.precision(y)):[[i,s],[u,c]]},n.minorExtent=function(t){return arguments.length?(r=+t[0][0],e=+t[1][0],a=+t[0][1],o=+t[1][1],r>e&&(t=r,r=e,e=t),a>o&&(t=a,a=o,o=t),n.precision(y)):[[r,a],[e,o]]},n.step=function(t){return arguments.length?n.majorStep(t).minorStep(t):n.minorStep()},n.majorStep=function(t){return arguments.length?(d=+t[0],m=+t[1],n):[d,m]},n.minorStep=function(t){return arguments.length?(p=+t[0],v=+t[1],n):[p,v]},n.precision=function(t){return arguments.length?(y=+t,l=lr(a,o,90),f=fr(r,e,y),h=lr(s,c,90),g=fr(i,u,y),n):y},n.majorExtent([[-180,-90+Aa],[180,90-Aa]]).minorExtent([[-180,-80-Aa],[180,80+Aa]])},Xo.geo.greatArc=function(){function n(){return{type:"LineString",coordinates:[t||r.apply(this,arguments),e||u.apply(this,arguments)]}}var t,e,r=hr,u=gr;return n.distance=function(){return Xo.geo.distance(t||r.apply(this,arguments),e||u.apply(this,arguments))},n.source=function(e){return arguments.length?(r=e,t="function"==typeof e?null:e,n):r},n.target=function(t){return arguments.length?(u=t,e="function"==typeof t?null:t,n):u},n.precision=function(){return arguments.length?n:0},n},Xo.geo.interpolate=function(n,t){return pr(n[0]*Na,n[1]*Na,t[0]*Na,t[1]*Na)},Xo.geo.length=function(n){return Uc=0,Xo.geo.stream(n,jc),Uc};var Uc,jc={sphere:g,point:g,lineStart:vr,lineEnd:g,polygonStart:g,polygonEnd:g},Hc=dr(function(n){return Math.sqrt(2/(1+n))},function(n){return 2*Math.asin(n/2)});(Xo.geo.azimuthalEqualArea=function(){return Qe(Hc)}).raw=Hc;var Fc=dr(function(n){var t=Math.acos(n);return t&&t/Math.sin(t)},bt);(Xo.geo.azimuthalEquidistant=function(){return Qe(Fc)}).raw=Fc,(Xo.geo.conicConformal=function(){return je(mr)}).raw=mr,(Xo.geo.conicEquidistant=function(){return je(yr)}).raw=yr;var Oc=dr(function(n){return 1/n},Math.atan);(Xo.geo.gnomonic=function(){return Qe(Oc)}).raw=Oc,xr.invert=function(n,t){return[n,2*Math.atan(Math.exp(t))-Ea]},(Xo.geo.mercator=function(){return Mr(xr)}).raw=xr;var Yc=dr(function(){return 1},Math.asin);(Xo.geo.orthographic=function(){return Qe(Yc)}).raw=Yc;var Ic=dr(function(n){return 1/(1+n)},function(n){return 2*Math.atan(n)});(Xo.geo.stereographic=function(){return Qe(Ic)}).raw=Ic,_r.invert=function(n,t){return[-t,2*Math.atan(Math.exp(n))-Ea]},(Xo.geo.transverseMercator=function(){var n=Mr(_r),t=n.center,e=n.rotate;return n.center=function(n){return n?t([-n[1],n[0]]):(n=t(),[-n[1],n[0]])},n.rotate=function(n){return n?e([n[0],n[1],n.length>2?n[2]+90:90]):(n=e(),[n[0],n[1],n[2]-90])},n.rotate([0,0])}).raw=_r,Xo.geom={},Xo.geom.hull=function(n){function t(n){if(n.length<3)return[];var t,u=_t(e),i=_t(r),o=n.length,a=[],c=[];for(t=0;o>t;t++)a.push([+u.call(this,n[t],t),+i.call(this,n[t],t),t]);for(a.sort(kr),t=0;o>t;t++)c.push([a[t][0],-a[t][1]]);var s=Sr(a),l=Sr(c),f=l[0]===s[0],h=l[l.length-1]===s[s.length-1],g=[];for(t=s.length-1;t>=0;--t)g.push(n[a[s[t]][2]]);for(t=+f;t=r&&s.x<=i&&s.y>=u&&s.y<=o?[[r,o],[i,o],[i,u],[r,u]]:[];l.point=n[a]}),t}function e(n){return n.map(function(n,t){return{x:Math.round(i(n,t)/Aa)*Aa,y:Math.round(o(n,t)/Aa)*Aa,i:t}})}var r=br,u=wr,i=r,o=u,a=Kc;return n?t(n):(t.links=function(n){return nu(e(n)).edges.filter(function(n){return n.l&&n.r}).map(function(t){return{source:n[t.l.i],target:n[t.r.i]}})},t.triangles=function(n){var t=[];return nu(e(n)).cells.forEach(function(e,r){for(var u,i,o=e.site,a=e.edges.sort(jr),c=-1,s=a.length,l=a[s-1].edge,f=l.l===o?l.r:l.l;++c=s,h=r>=l,g=(h<<1)+f;n.leaf=!1,n=n.nodes[g]||(n.nodes[g]=iu()),f?u=s:a=s,h?o=l:c=l,i(n,t,e,r,u,o,a,c)}var l,f,h,g,p,v,d,m,y,x=_t(a),M=_t(c);if(null!=t)v=t,d=e,m=r,y=u;else if(m=y=-(v=d=1/0),f=[],h=[],p=n.length,o)for(g=0;p>g;++g)l=n[g],l.xm&&(m=l.x),l.y>y&&(y=l.y),f.push(l.x),h.push(l.y);else for(g=0;p>g;++g){var _=+x(l=n[g],g),b=+M(l,g);v>_&&(v=_),d>b&&(d=b),_>m&&(m=_),b>y&&(y=b),f.push(_),h.push(b)}var w=m-v,S=y-d;w>S?y=d+w:m=v+S;var k=iu();if(k.add=function(n){i(k,n,+x(n,++g),+M(n,g),v,d,m,y)},k.visit=function(n){ou(n,k,v,d,m,y)},g=-1,null==t){for(;++g=0?n.substring(0,t):n,r=t>=0?n.substring(t+1):"in";return e=ts.get(e)||ns,r=es.get(r)||bt,gu(r(e.apply(null,$o.call(arguments,1))))},Xo.interpolateHcl=Eu,Xo.interpolateHsl=Au,Xo.interpolateLab=Cu,Xo.interpolateRound=Nu,Xo.transform=function(n){var t=Wo.createElementNS(Xo.ns.prefix.svg,"g");return(Xo.transform=function(n){if(null!=n){t.setAttribute("transform",n);var e=t.transform.baseVal.consolidate()}return new Lu(e?e.matrix:rs)})(n)},Lu.prototype.toString=function(){return"translate("+this.translate+")rotate("+this.rotate+")skewX("+this.skew+")scale("+this.scale+")"};var rs={a:1,b:0,c:0,d:1,e:0,f:0};Xo.interpolateTransform=Ru,Xo.layout={},Xo.layout.bundle=function(){return function(n){for(var t=[],e=-1,r=n.length;++ea*a/d){if(p>c){var s=t.charge/c;n.px-=i*s,n.py-=o*s}return!0}if(t.point&&c&&p>c){var s=t.pointCharge/c;n.px-=i*s,n.py-=o*s}}return!t.charge}}function t(n){n.px=Xo.event.x,n.py=Xo.event.y,a.resume()}var e,r,u,i,o,a={},c=Xo.dispatch("start","tick","end"),s=[1,1],l=.9,f=us,h=is,g=-30,p=os,v=.1,d=.64,m=[],y=[];return a.tick=function(){if((r*=.99)<.005)return c.end({type:"end",alpha:r=0}),!0;var t,e,a,f,h,p,d,x,M,_=m.length,b=y.length;for(e=0;b>e;++e)a=y[e],f=a.source,h=a.target,x=h.x-f.x,M=h.y-f.y,(p=x*x+M*M)&&(p=r*i[e]*((p=Math.sqrt(p))-u[e])/p,x*=p,M*=p,h.x-=x*(d=f.weight/(h.weight+f.weight)),h.y-=M*d,f.x+=x*(d=1-d),f.y+=M*d);if((d=r*v)&&(x=s[0]/2,M=s[1]/2,e=-1,d))for(;++e<_;)a=m[e],a.x+=(x-a.x)*d,a.y+=(M-a.y)*d;if(g)for(Zu(t=Xo.geom.quadtree(m),r,o),e=-1;++e<_;)(a=m[e]).fixed||t.visit(n(a));for(e=-1;++e<_;)a=m[e],a.fixed?(a.x=a.px,a.y=a.py):(a.x-=(a.px-(a.px=a.x))*l,a.y-=(a.py-(a.py=a.y))*l);c.tick({type:"tick",alpha:r})},a.nodes=function(n){return arguments.length?(m=n,a):m},a.links=function(n){return arguments.length?(y=n,a):y},a.size=function(n){return arguments.length?(s=n,a):s},a.linkDistance=function(n){return arguments.length?(f="function"==typeof n?n:+n,a):f},a.distance=a.linkDistance,a.linkStrength=function(n){return arguments.length?(h="function"==typeof n?n:+n,a):h},a.friction=function(n){return arguments.length?(l=+n,a):l},a.charge=function(n){return arguments.length?(g="function"==typeof n?n:+n,a):g},a.chargeDistance=function(n){return arguments.length?(p=n*n,a):Math.sqrt(p)},a.gravity=function(n){return arguments.length?(v=+n,a):v},a.theta=function(n){return arguments.length?(d=n*n,a):Math.sqrt(d)},a.alpha=function(n){return arguments.length?(n=+n,r?r=n>0?n:0:n>0&&(c.start({type:"start",alpha:r=n}),Xo.timer(a.tick)),a):r},a.start=function(){function n(n,r){if(!e){for(e=new Array(c),a=0;c>a;++a)e[a]=[];for(a=0;s>a;++a){var u=y[a];e[u.source.index].push(u.target),e[u.target.index].push(u.source)}}for(var i,o=e[t],a=-1,s=o.length;++at;++t)(r=m[t]).index=t,r.weight=0;for(t=0;l>t;++t)r=y[t],"number"==typeof r.source&&(r.source=m[r.source]),"number"==typeof r.target&&(r.target=m[r.target]),++r.source.weight,++r.target.weight;for(t=0;c>t;++t)r=m[t],isNaN(r.x)&&(r.x=n("x",p)),isNaN(r.y)&&(r.y=n("y",v)),isNaN(r.px)&&(r.px=r.x),isNaN(r.py)&&(r.py=r.y);if(u=[],"function"==typeof f)for(t=0;l>t;++t)u[t]=+f.call(this,y[t],t);else for(t=0;l>t;++t)u[t]=f;if(i=[],"function"==typeof h)for(t=0;l>t;++t)i[t]=+h.call(this,y[t],t);else for(t=0;l>t;++t)i[t]=h;if(o=[],"function"==typeof g)for(t=0;c>t;++t)o[t]=+g.call(this,m[t],t);else for(t=0;c>t;++t)o[t]=g;return a.resume()},a.resume=function(){return a.alpha(.1)},a.stop=function(){return a.alpha(0)},a.drag=function(){return e||(e=Xo.behavior.drag().origin(bt).on("dragstart.force",Fu).on("drag.force",t).on("dragend.force",Ou)),arguments.length?(this.on("mouseover.force",Yu).on("mouseout.force",Iu).call(e),void 0):e},Xo.rebind(a,c,"on")};var us=20,is=1,os=1/0;Xo.layout.hierarchy=function(){function n(t,o,a){var c=u.call(e,t,o);if(t.depth=o,a.push(t),c&&(s=c.length)){for(var s,l,f=-1,h=t.children=new Array(s),g=0,p=o+1;++fg;++g)for(u.call(n,s[0][g],p=v[g],l[0][g][1]),h=1;d>h;++h)u.call(n,s[h][g],p+=l[h-1][g][1],l[h][g][1]);return a}var t=bt,e=Qu,r=ni,u=Ku,i=Ju,o=Gu;return n.values=function(e){return arguments.length?(t=e,n):t},n.order=function(t){return arguments.length?(e="function"==typeof t?t:cs.get(t)||Qu,n):e},n.offset=function(t){return arguments.length?(r="function"==typeof t?t:ss.get(t)||ni,n):r},n.x=function(t){return arguments.length?(i=t,n):i},n.y=function(t){return arguments.length?(o=t,n):o},n.out=function(t){return arguments.length?(u=t,n):u},n};var cs=Xo.map({"inside-out":function(n){var t,e,r=n.length,u=n.map(ti),i=n.map(ei),o=Xo.range(r).sort(function(n,t){return u[n]-u[t]}),a=0,c=0,s=[],l=[];for(t=0;r>t;++t)e=o[t],c>a?(a+=i[e],s.push(e)):(c+=i[e],l.push(e));return l.reverse().concat(s)},reverse:function(n){return Xo.range(n.length).reverse()},"default":Qu}),ss=Xo.map({silhouette:function(n){var t,e,r,u=n.length,i=n[0].length,o=[],a=0,c=[];for(e=0;i>e;++e){for(t=0,r=0;u>t;t++)r+=n[t][e][1];r>a&&(a=r),o.push(r)}for(e=0;i>e;++e)c[e]=(a-o[e])/2;return c},wiggle:function(n){var t,e,r,u,i,o,a,c,s,l=n.length,f=n[0],h=f.length,g=[];for(g[0]=c=s=0,e=1;h>e;++e){for(t=0,u=0;l>t;++t)u+=n[t][e][1];for(t=0,i=0,a=f[e][0]-f[e-1][0];l>t;++t){for(r=0,o=(n[t][e][1]-n[t][e-1][1])/(2*a);t>r;++r)o+=(n[r][e][1]-n[r][e-1][1])/a;i+=o*n[t][e][1]}g[e]=c-=u?i/u*a:0,s>c&&(s=c)}for(e=0;h>e;++e)g[e]-=s;return g},expand:function(n){var t,e,r,u=n.length,i=n[0].length,o=1/u,a=[];for(e=0;i>e;++e){for(t=0,r=0;u>t;t++)r+=n[t][e][1];if(r)for(t=0;u>t;t++)n[t][e][1]/=r;else for(t=0;u>t;t++)n[t][e][1]=o}for(e=0;i>e;++e)a[e]=0;return a},zero:ni});Xo.layout.histogram=function(){function n(n,i){for(var o,a,c=[],s=n.map(e,this),l=r.call(this,s,i),f=u.call(this,l,s,i),i=-1,h=s.length,g=f.length-1,p=t?1:1/h;++i0)for(i=-1;++i=l[0]&&a<=l[1]&&(o=c[Xo.bisect(f,a,1,g)-1],o.y+=p,o.push(n[i]));return c}var t=!0,e=Number,r=oi,u=ui;return n.value=function(t){return arguments.length?(e=t,n):e},n.range=function(t){return arguments.length?(r=_t(t),n):r},n.bins=function(t){return arguments.length?(u="number"==typeof t?function(n){return ii(n,t)}:_t(t),n):u},n.frequency=function(e){return arguments.length?(t=!!e,n):t},n},Xo.layout.tree=function(){function n(n,i){function o(n,t){var r=n.children,u=n._tree;if(r&&(i=r.length)){for(var i,a,s,l=r[0],f=l,h=-1;++h0&&(di(mi(a,n,r),n,u),s+=u,l+=u),f+=a._tree.mod,s+=i._tree.mod,h+=c._tree.mod,l+=o._tree.mod;a&&!si(o)&&(o._tree.thread=a,o._tree.mod+=f-l),i&&!ci(c)&&(c._tree.thread=i,c._tree.mod+=s-h,r=n)}return r}var s=t.call(this,n,i),l=s[0];pi(l,function(n,t){n._tree={ancestor:n,prelim:0,mod:0,change:0,shift:0,number:t?t._tree.number+1:0}}),o(l),a(l,-l._tree.prelim);var f=li(l,hi),h=li(l,fi),g=li(l,gi),p=f.x-e(f,h)/2,v=h.x+e(h,f)/2,d=g.depth||1;return pi(l,u?function(n){n.x*=r[0],n.y=n.depth*r[1],delete n._tree}:function(n){n.x=(n.x-p)/(v-p)*r[0],n.y=n.depth/d*r[1],delete n._tree}),s}var t=Xo.layout.hierarchy().sort(null).value(null),e=ai,r=[1,1],u=!1;return n.separation=function(t){return arguments.length?(e=t,n):e},n.size=function(t){return arguments.length?(u=null==(r=t),n):u?null:r},n.nodeSize=function(t){return arguments.length?(u=null!=(r=t),n):u?r:null},Vu(n,t)},Xo.layout.pack=function(){function n(n,i){var o=e.call(this,n,i),a=o[0],c=u[0],s=u[1],l=null==t?Math.sqrt:"function"==typeof t?t:function(){return t};if(a.x=a.y=0,pi(a,function(n){n.r=+l(n.value)}),pi(a,bi),r){var f=r*(t?1:Math.max(2*a.r/c,2*a.r/s))/2;pi(a,function(n){n.r+=f}),pi(a,bi),pi(a,function(n){n.r-=f})}return ki(a,c/2,s/2,t?1:1/Math.max(2*a.r/c,2*a.r/s)),o}var t,e=Xo.layout.hierarchy().sort(yi),r=0,u=[1,1];return n.size=function(t){return arguments.length?(u=t,n):u},n.radius=function(e){return arguments.length?(t=null==e||"function"==typeof e?e:+e,n):t},n.padding=function(t){return arguments.length?(r=+t,n):r},Vu(n,e)},Xo.layout.cluster=function(){function n(n,i){var o,a=t.call(this,n,i),c=a[0],s=0;pi(c,function(n){var t=n.children;t&&t.length?(n.x=Ci(t),n.y=Ai(t)):(n.x=o?s+=e(n,o):0,n.y=0,o=n)});var l=Ni(c),f=Li(c),h=l.x-e(l,f)/2,g=f.x+e(f,l)/2;return pi(c,u?function(n){n.x=(n.x-c.x)*r[0],n.y=(c.y-n.y)*r[1]}:function(n){n.x=(n.x-h)/(g-h)*r[0],n.y=(1-(c.y?n.y/c.y:1))*r[1]}),a}var t=Xo.layout.hierarchy().sort(null).value(null),e=ai,r=[1,1],u=!1;return n.separation=function(t){return arguments.length?(e=t,n):e},n.size=function(t){return arguments.length?(u=null==(r=t),n):u?null:r},n.nodeSize=function(t){return arguments.length?(u=null!=(r=t),n):u?r:null},Vu(n,t)},Xo.layout.treemap=function(){function n(n,t){for(var e,r,u=-1,i=n.length;++ut?0:t),e.area=isNaN(r)||0>=r?0:r}function t(e){var i=e.children;if(i&&i.length){var o,a,c,s=f(e),l=[],h=i.slice(),p=1/0,v="slice"===g?s.dx:"dice"===g?s.dy:"slice-dice"===g?1&e.depth?s.dy:s.dx:Math.min(s.dx,s.dy);for(n(h,s.dx*s.dy/e.value),l.area=0;(c=h.length)>0;)l.push(o=h[c-1]),l.area+=o.area,"squarify"!==g||(a=r(l,v))<=p?(h.pop(),p=a):(l.area-=l.pop().area,u(l,v,s,!1),v=Math.min(s.dx,s.dy),l.length=l.area=0,p=1/0);l.length&&(u(l,v,s,!0),l.length=l.area=0),i.forEach(t)}}function e(t){var r=t.children;if(r&&r.length){var i,o=f(t),a=r.slice(),c=[];for(n(a,o.dx*o.dy/t.value),c.area=0;i=a.pop();)c.push(i),c.area+=i.area,null!=i.z&&(u(c,i.z?o.dx:o.dy,o,!a.length),c.length=c.area=0);r.forEach(e)}}function r(n,t){for(var e,r=n.area,u=0,i=1/0,o=-1,a=n.length;++oe&&(i=e),e>u&&(u=e));return r*=r,t*=t,r?Math.max(t*u*p/r,r/(t*i*p)):1/0}function u(n,t,e,r){var u,i=-1,o=n.length,a=e.x,s=e.y,l=t?c(n.area/t):0;if(t==e.dx){for((r||l>e.dy)&&(l=e.dy);++ie.dx)&&(l=e.dx);++ie&&(t=1),1>e&&(n=0),function(){var e,r,u;do e=2*Math.random()-1,r=2*Math.random()-1,u=e*e+r*r;while(!u||u>1);return n+t*e*Math.sqrt(-2*Math.log(u)/u)}},logNormal:function(){var n=Xo.random.normal.apply(Xo,arguments);return function(){return Math.exp(n())}},bates:function(n){var t=Xo.random.irwinHall(n);return function(){return t()/n}},irwinHall:function(n){return function(){for(var t=0,e=0;n>e;e++)t+=Math.random();return t}}},Xo.scale={};var ls={floor:bt,ceil:bt};Xo.scale.linear=function(){return Hi([0,1],[0,1],fu,!1)};var fs={s:1,g:1,p:1,r:1,e:1};Xo.scale.log=function(){return $i(Xo.scale.linear().domain([0,1]),10,!0,[1,10])};var hs=Xo.format(".0e"),gs={floor:function(n){return-Math.ceil(-n)},ceil:function(n){return-Math.floor(-n)}};Xo.scale.pow=function(){return Bi(Xo.scale.linear(),1,[0,1])},Xo.scale.sqrt=function(){return Xo.scale.pow().exponent(.5)},Xo.scale.ordinal=function(){return Ji([],{t:"range",a:[[]]})},Xo.scale.category10=function(){return Xo.scale.ordinal().range(ps)},Xo.scale.category20=function(){return Xo.scale.ordinal().range(vs)},Xo.scale.category20b=function(){return Xo.scale.ordinal().range(ds)},Xo.scale.category20c=function(){return Xo.scale.ordinal().range(ms)};var ps=[2062260,16744206,2924588,14034728,9725885,9197131,14907330,8355711,12369186,1556175].map(ht),vs=[2062260,11454440,16744206,16759672,2924588,10018698,14034728,16750742,9725885,12955861,9197131,12885140,14907330,16234194,8355711,13092807,12369186,14408589,1556175,10410725].map(ht),ds=[3750777,5395619,7040719,10264286,6519097,9216594,11915115,13556636,9202993,12426809,15186514,15190932,8666169,11356490,14049643,15177372,8077683,10834324,13528509,14589654].map(ht),ms=[3244733,7057110,10406625,13032431,15095053,16616764,16625259,16634018,3253076,7652470,10607003,13101504,7695281,10394312,12369372,14342891,6513507,9868950,12434877,14277081].map(ht);Xo.scale.quantile=function(){return Gi([],[]) +},Xo.scale.quantize=function(){return Ki(0,1,[0,1])},Xo.scale.threshold=function(){return Qi([.5],[0,1])},Xo.scale.identity=function(){return no([0,1])},Xo.svg={},Xo.svg.arc=function(){function n(){var n=t.apply(this,arguments),i=e.apply(this,arguments),o=r.apply(this,arguments)+ys,a=u.apply(this,arguments)+ys,c=(o>a&&(c=o,o=a,a=c),a-o),s=Sa>c?"0":"1",l=Math.cos(o),f=Math.sin(o),h=Math.cos(a),g=Math.sin(a);return c>=xs?n?"M0,"+i+"A"+i+","+i+" 0 1,1 0,"+-i+"A"+i+","+i+" 0 1,1 0,"+i+"M0,"+n+"A"+n+","+n+" 0 1,0 0,"+-n+"A"+n+","+n+" 0 1,0 0,"+n+"Z":"M0,"+i+"A"+i+","+i+" 0 1,1 0,"+-i+"A"+i+","+i+" 0 1,1 0,"+i+"Z":n?"M"+i*l+","+i*f+"A"+i+","+i+" 0 "+s+",1 "+i*h+","+i*g+"L"+n*h+","+n*g+"A"+n+","+n+" 0 "+s+",0 "+n*l+","+n*f+"Z":"M"+i*l+","+i*f+"A"+i+","+i+" 0 "+s+",1 "+i*h+","+i*g+"L0,0"+"Z"}var t=to,e=eo,r=ro,u=uo;return n.innerRadius=function(e){return arguments.length?(t=_t(e),n):t},n.outerRadius=function(t){return arguments.length?(e=_t(t),n):e},n.startAngle=function(t){return arguments.length?(r=_t(t),n):r},n.endAngle=function(t){return arguments.length?(u=_t(t),n):u},n.centroid=function(){var n=(t.apply(this,arguments)+e.apply(this,arguments))/2,i=(r.apply(this,arguments)+u.apply(this,arguments))/2+ys;return[Math.cos(i)*n,Math.sin(i)*n]},n};var ys=-Ea,xs=ka-Aa;Xo.svg.line=function(){return io(bt)};var Ms=Xo.map({linear:oo,"linear-closed":ao,step:co,"step-before":so,"step-after":lo,basis:mo,"basis-open":yo,"basis-closed":xo,bundle:Mo,cardinal:go,"cardinal-open":fo,"cardinal-closed":ho,monotone:Eo});Ms.forEach(function(n,t){t.key=n,t.closed=/-closed$/.test(n)});var _s=[0,2/3,1/3,0],bs=[0,1/3,2/3,0],ws=[0,1/6,2/3,1/6];Xo.svg.line.radial=function(){var n=io(Ao);return n.radius=n.x,delete n.x,n.angle=n.y,delete n.y,n},so.reverse=lo,lo.reverse=so,Xo.svg.area=function(){return Co(bt)},Xo.svg.area.radial=function(){var n=Co(Ao);return n.radius=n.x,delete n.x,n.innerRadius=n.x0,delete n.x0,n.outerRadius=n.x1,delete n.x1,n.angle=n.y,delete n.y,n.startAngle=n.y0,delete n.y0,n.endAngle=n.y1,delete n.y1,n},Xo.svg.chord=function(){function n(n,a){var c=t(this,i,n,a),s=t(this,o,n,a);return"M"+c.p0+r(c.r,c.p1,c.a1-c.a0)+(e(c,s)?u(c.r,c.p1,c.r,c.p0):u(c.r,c.p1,s.r,s.p0)+r(s.r,s.p1,s.a1-s.a0)+u(s.r,s.p1,c.r,c.p0))+"Z"}function t(n,t,e,r){var u=t.call(n,e,r),i=a.call(n,u,r),o=c.call(n,u,r)+ys,l=s.call(n,u,r)+ys;return{r:i,a0:o,a1:l,p0:[i*Math.cos(o),i*Math.sin(o)],p1:[i*Math.cos(l),i*Math.sin(l)]}}function e(n,t){return n.a0==t.a0&&n.a1==t.a1}function r(n,t,e){return"A"+n+","+n+" 0 "+ +(e>Sa)+",1 "+t}function u(n,t,e,r){return"Q 0,0 "+r}var i=hr,o=gr,a=No,c=ro,s=uo;return n.radius=function(t){return arguments.length?(a=_t(t),n):a},n.source=function(t){return arguments.length?(i=_t(t),n):i},n.target=function(t){return arguments.length?(o=_t(t),n):o},n.startAngle=function(t){return arguments.length?(c=_t(t),n):c},n.endAngle=function(t){return arguments.length?(s=_t(t),n):s},n},Xo.svg.diagonal=function(){function n(n,u){var i=t.call(this,n,u),o=e.call(this,n,u),a=(i.y+o.y)/2,c=[i,{x:i.x,y:a},{x:o.x,y:a},o];return c=c.map(r),"M"+c[0]+"C"+c[1]+" "+c[2]+" "+c[3]}var t=hr,e=gr,r=Lo;return n.source=function(e){return arguments.length?(t=_t(e),n):t},n.target=function(t){return arguments.length?(e=_t(t),n):e},n.projection=function(t){return arguments.length?(r=t,n):r},n},Xo.svg.diagonal.radial=function(){var n=Xo.svg.diagonal(),t=Lo,e=n.projection;return n.projection=function(n){return arguments.length?e(zo(t=n)):t},n},Xo.svg.symbol=function(){function n(n,r){return(Ss.get(t.call(this,n,r))||Ro)(e.call(this,n,r))}var t=To,e=qo;return n.type=function(e){return arguments.length?(t=_t(e),n):t},n.size=function(t){return arguments.length?(e=_t(t),n):e},n};var Ss=Xo.map({circle:Ro,cross:function(n){var t=Math.sqrt(n/5)/2;return"M"+-3*t+","+-t+"H"+-t+"V"+-3*t+"H"+t+"V"+-t+"H"+3*t+"V"+t+"H"+t+"V"+3*t+"H"+-t+"V"+t+"H"+-3*t+"Z"},diamond:function(n){var t=Math.sqrt(n/(2*Cs)),e=t*Cs;return"M0,"+-t+"L"+e+",0"+" 0,"+t+" "+-e+",0"+"Z"},square:function(n){var t=Math.sqrt(n)/2;return"M"+-t+","+-t+"L"+t+","+-t+" "+t+","+t+" "+-t+","+t+"Z"},"triangle-down":function(n){var t=Math.sqrt(n/As),e=t*As/2;return"M0,"+e+"L"+t+","+-e+" "+-t+","+-e+"Z"},"triangle-up":function(n){var t=Math.sqrt(n/As),e=t*As/2;return"M0,"+-e+"L"+t+","+e+" "+-t+","+e+"Z"}});Xo.svg.symbolTypes=Ss.keys();var ks,Es,As=Math.sqrt(3),Cs=Math.tan(30*Na),Ns=[],Ls=0;Ns.call=da.call,Ns.empty=da.empty,Ns.node=da.node,Ns.size=da.size,Xo.transition=function(n){return arguments.length?ks?n.transition():n:xa.transition()},Xo.transition.prototype=Ns,Ns.select=function(n){var t,e,r,u=this.id,i=[];n=M(n);for(var o=-1,a=this.length;++oi;i++){u.push(t=[]);for(var e=this[i],a=0,c=e.length;c>a;a++)(r=e[a])&&n.call(r,r.__data__,a,i)&&t.push(r)}return Do(u,this.id)},Ns.tween=function(n,t){var e=this.id;return arguments.length<2?this.node().__transition__[e].tween.get(n):R(this,null==t?function(t){t.__transition__[e].tween.remove(n)}:function(r){r.__transition__[e].tween.set(n,t)})},Ns.attr=function(n,t){function e(){this.removeAttribute(a)}function r(){this.removeAttributeNS(a.space,a.local)}function u(n){return null==n?e:(n+="",function(){var t,e=this.getAttribute(a);return e!==n&&(t=o(e,n),function(n){this.setAttribute(a,t(n))})})}function i(n){return null==n?r:(n+="",function(){var t,e=this.getAttributeNS(a.space,a.local);return e!==n&&(t=o(e,n),function(n){this.setAttributeNS(a.space,a.local,t(n))})})}if(arguments.length<2){for(t in n)this.attr(t,n[t]);return this}var o="transform"==n?Ru:fu,a=Xo.ns.qualify(n);return Po(this,"attr."+n,t,a.local?i:u)},Ns.attrTween=function(n,t){function e(n,e){var r=t.call(this,n,e,this.getAttribute(u));return r&&function(n){this.setAttribute(u,r(n))}}function r(n,e){var r=t.call(this,n,e,this.getAttributeNS(u.space,u.local));return r&&function(n){this.setAttributeNS(u.space,u.local,r(n))}}var u=Xo.ns.qualify(n);return this.tween("attr."+n,u.local?r:e)},Ns.style=function(n,t,e){function r(){this.style.removeProperty(n)}function u(t){return null==t?r:(t+="",function(){var r,u=Go.getComputedStyle(this,null).getPropertyValue(n);return u!==t&&(r=fu(u,t),function(t){this.style.setProperty(n,r(t),e)})})}var i=arguments.length;if(3>i){if("string"!=typeof n){2>i&&(t="");for(e in n)this.style(e,n[e],t);return this}e=""}return Po(this,"style."+n,t,u)},Ns.styleTween=function(n,t,e){function r(r,u){var i=t.call(this,r,u,Go.getComputedStyle(this,null).getPropertyValue(n));return i&&function(t){this.style.setProperty(n,i(t),e)}}return arguments.length<3&&(e=""),this.tween("style."+n,r)},Ns.text=function(n){return Po(this,"text",n,Uo)},Ns.remove=function(){return this.each("end.transition",function(){var n;this.__transition__.count<2&&(n=this.parentNode)&&n.removeChild(this)})},Ns.ease=function(n){var t=this.id;return arguments.length<1?this.node().__transition__[t].ease:("function"!=typeof n&&(n=Xo.ease.apply(Xo,arguments)),R(this,function(e){e.__transition__[t].ease=n}))},Ns.delay=function(n){var t=this.id;return R(this,"function"==typeof n?function(e,r,u){e.__transition__[t].delay=+n.call(e,e.__data__,r,u)}:(n=+n,function(e){e.__transition__[t].delay=n}))},Ns.duration=function(n){var t=this.id;return R(this,"function"==typeof n?function(e,r,u){e.__transition__[t].duration=Math.max(1,n.call(e,e.__data__,r,u))}:(n=Math.max(1,n),function(e){e.__transition__[t].duration=n}))},Ns.each=function(n,t){var e=this.id;if(arguments.length<2){var r=Es,u=ks;ks=e,R(this,function(t,r,u){Es=t.__transition__[e],n.call(t,t.__data__,r,u)}),Es=r,ks=u}else R(this,function(r){var u=r.__transition__[e];(u.event||(u.event=Xo.dispatch("start","end"))).on(n,t)});return this},Ns.transition=function(){for(var n,t,e,r,u=this.id,i=++Ls,o=[],a=0,c=this.length;c>a;a++){o.push(n=[]);for(var t=this[a],s=0,l=t.length;l>s;s++)(e=t[s])&&(r=Object.create(e.__transition__[u]),r.delay+=r.duration,jo(e,s,i,r)),n.push(e)}return Do(o,i)},Xo.svg.axis=function(){function n(n){n.each(function(){var n,s=Xo.select(this),l=this.__chart__||e,f=this.__chart__=e.copy(),h=null==c?f.ticks?f.ticks.apply(f,a):f.domain():c,g=null==t?f.tickFormat?f.tickFormat.apply(f,a):bt:t,p=s.selectAll(".tick").data(h,f),v=p.enter().insert("g",".domain").attr("class","tick").style("opacity",Aa),d=Xo.transition(p.exit()).style("opacity",Aa).remove(),m=Xo.transition(p).style("opacity",1),y=Ri(f),x=s.selectAll(".domain").data([0]),M=(x.enter().append("path").attr("class","domain"),Xo.transition(x));v.append("line"),v.append("text");var _=v.select("line"),b=m.select("line"),w=p.select("text").text(g),S=v.select("text"),k=m.select("text");switch(r){case"bottom":n=Ho,_.attr("y2",u),S.attr("y",Math.max(u,0)+o),b.attr("x2",0).attr("y2",u),k.attr("x",0).attr("y",Math.max(u,0)+o),w.attr("dy",".71em").style("text-anchor","middle"),M.attr("d","M"+y[0]+","+i+"V0H"+y[1]+"V"+i);break;case"top":n=Ho,_.attr("y2",-u),S.attr("y",-(Math.max(u,0)+o)),b.attr("x2",0).attr("y2",-u),k.attr("x",0).attr("y",-(Math.max(u,0)+o)),w.attr("dy","0em").style("text-anchor","middle"),M.attr("d","M"+y[0]+","+-i+"V0H"+y[1]+"V"+-i);break;case"left":n=Fo,_.attr("x2",-u),S.attr("x",-(Math.max(u,0)+o)),b.attr("x2",-u).attr("y2",0),k.attr("x",-(Math.max(u,0)+o)).attr("y",0),w.attr("dy",".32em").style("text-anchor","end"),M.attr("d","M"+-i+","+y[0]+"H0V"+y[1]+"H"+-i);break;case"right":n=Fo,_.attr("x2",u),S.attr("x",Math.max(u,0)+o),b.attr("x2",u).attr("y2",0),k.attr("x",Math.max(u,0)+o).attr("y",0),w.attr("dy",".32em").style("text-anchor","start"),M.attr("d","M"+i+","+y[0]+"H0V"+y[1]+"H"+i)}if(f.rangeBand){var E=f,A=E.rangeBand()/2;l=f=function(n){return E(n)+A}}else l.rangeBand?l=f:d.call(n,f);v.call(n,l),m.call(n,f)})}var t,e=Xo.scale.linear(),r=zs,u=6,i=6,o=3,a=[10],c=null;return n.scale=function(t){return arguments.length?(e=t,n):e},n.orient=function(t){return arguments.length?(r=t in qs?t+"":zs,n):r},n.ticks=function(){return arguments.length?(a=arguments,n):a},n.tickValues=function(t){return arguments.length?(c=t,n):c},n.tickFormat=function(e){return arguments.length?(t=e,n):t},n.tickSize=function(t){var e=arguments.length;return e?(u=+t,i=+arguments[e-1],n):u},n.innerTickSize=function(t){return arguments.length?(u=+t,n):u},n.outerTickSize=function(t){return arguments.length?(i=+t,n):i},n.tickPadding=function(t){return arguments.length?(o=+t,n):o},n.tickSubdivide=function(){return arguments.length&&n},n};var zs="bottom",qs={top:1,right:1,bottom:1,left:1};Xo.svg.brush=function(){function n(i){i.each(function(){var i=Xo.select(this).style("pointer-events","all").style("-webkit-tap-highlight-color","rgba(0,0,0,0)").on("mousedown.brush",u).on("touchstart.brush",u),o=i.selectAll(".background").data([0]);o.enter().append("rect").attr("class","background").style("visibility","hidden").style("cursor","crosshair"),i.selectAll(".extent").data([0]).enter().append("rect").attr("class","extent").style("cursor","move");var a=i.selectAll(".resize").data(p,bt);a.exit().remove(),a.enter().append("g").attr("class",function(n){return"resize "+n}).style("cursor",function(n){return Ts[n]}).append("rect").attr("x",function(n){return/[ew]$/.test(n)?-3:null}).attr("y",function(n){return/^[ns]/.test(n)?-3:null}).attr("width",6).attr("height",6).style("visibility","hidden"),a.style("display",n.empty()?"none":null);var l,f=Xo.transition(i),h=Xo.transition(o);c&&(l=Ri(c),h.attr("x",l[0]).attr("width",l[1]-l[0]),e(f)),s&&(l=Ri(s),h.attr("y",l[0]).attr("height",l[1]-l[0]),r(f)),t(f)})}function t(n){n.selectAll(".resize").attr("transform",function(n){return"translate("+l[+/e$/.test(n)]+","+f[+/^s/.test(n)]+")"})}function e(n){n.select(".extent").attr("x",l[0]),n.selectAll(".extent,.n>rect,.s>rect").attr("width",l[1]-l[0])}function r(n){n.select(".extent").attr("y",f[0]),n.selectAll(".extent,.e>rect,.w>rect").attr("height",f[1]-f[0])}function u(){function u(){32==Xo.event.keyCode&&(C||(x=null,L[0]-=l[1],L[1]-=f[1],C=2),d())}function p(){32==Xo.event.keyCode&&2==C&&(L[0]+=l[1],L[1]+=f[1],C=0,d())}function v(){var n=Xo.mouse(_),u=!1;M&&(n[0]+=M[0],n[1]+=M[1]),C||(Xo.event.altKey?(x||(x=[(l[0]+l[1])/2,(f[0]+f[1])/2]),L[0]=l[+(n[0]p?(u=r,r=p):u=p),v[0]!=r||v[1]!=u?(e?o=null:i=null,v[0]=r,v[1]=u,!0):void 0}function y(){v(),S.style("pointer-events","all").selectAll(".resize").style("display",n.empty()?"none":null),Xo.select("body").style("cursor",null),z.on("mousemove.brush",null).on("mouseup.brush",null).on("touchmove.brush",null).on("touchend.brush",null).on("keydown.brush",null).on("keyup.brush",null),N(),w({type:"brushend"})}var x,M,_=this,b=Xo.select(Xo.event.target),w=a.of(_,arguments),S=Xo.select(_),k=b.datum(),E=!/^(n|s)$/.test(k)&&c,A=!/^(e|w)$/.test(k)&&s,C=b.classed("extent"),N=O(),L=Xo.mouse(_),z=Xo.select(Go).on("keydown.brush",u).on("keyup.brush",p);if(Xo.event.changedTouches?z.on("touchmove.brush",v).on("touchend.brush",y):z.on("mousemove.brush",v).on("mouseup.brush",y),S.interrupt().selectAll("*").interrupt(),C)L[0]=l[0]-L[0],L[1]=f[0]-L[1];else if(k){var q=+/w$/.test(k),T=+/^n/.test(k);M=[l[1-q]-L[0],f[1-T]-L[1]],L[0]=l[q],L[1]=f[T]}else Xo.event.altKey&&(x=L.slice());S.style("pointer-events","none").selectAll(".resize").style("display",null),Xo.select("body").style("cursor",b.style("cursor")),w({type:"brushstart"}),v()}var i,o,a=y(n,"brushstart","brush","brushend"),c=null,s=null,l=[0,0],f=[0,0],h=!0,g=!0,p=Rs[0];return n.event=function(n){n.each(function(){var n=a.of(this,arguments),t={x:l,y:f,i:i,j:o},e=this.__chart__||t;this.__chart__=t,ks?Xo.select(this).transition().each("start.brush",function(){i=e.i,o=e.j,l=e.x,f=e.y,n({type:"brushstart"})}).tween("brush:brush",function(){var e=hu(l,t.x),r=hu(f,t.y);return i=o=null,function(u){l=t.x=e(u),f=t.y=r(u),n({type:"brush",mode:"resize"})}}).each("end.brush",function(){i=t.i,o=t.j,n({type:"brush",mode:"resize"}),n({type:"brushend"})}):(n({type:"brushstart"}),n({type:"brush",mode:"resize"}),n({type:"brushend"}))})},n.x=function(t){return arguments.length?(c=t,p=Rs[!c<<1|!s],n):c},n.y=function(t){return arguments.length?(s=t,p=Rs[!c<<1|!s],n):s},n.clamp=function(t){return arguments.length?(c&&s?(h=!!t[0],g=!!t[1]):c?h=!!t:s&&(g=!!t),n):c&&s?[h,g]:c?h:s?g:null},n.extent=function(t){var e,r,u,a,h;return arguments.length?(c&&(e=t[0],r=t[1],s&&(e=e[0],r=r[0]),i=[e,r],c.invert&&(e=c(e),r=c(r)),e>r&&(h=e,e=r,r=h),(e!=l[0]||r!=l[1])&&(l=[e,r])),s&&(u=t[0],a=t[1],c&&(u=u[1],a=a[1]),o=[u,a],s.invert&&(u=s(u),a=s(a)),u>a&&(h=u,u=a,a=h),(u!=f[0]||a!=f[1])&&(f=[u,a])),n):(c&&(i?(e=i[0],r=i[1]):(e=l[0],r=l[1],c.invert&&(e=c.invert(e),r=c.invert(r)),e>r&&(h=e,e=r,r=h))),s&&(o?(u=o[0],a=o[1]):(u=f[0],a=f[1],s.invert&&(u=s.invert(u),a=s.invert(a)),u>a&&(h=u,u=a,a=h))),c&&s?[[e,u],[r,a]]:c?[e,r]:s&&[u,a])},n.clear=function(){return n.empty()||(l=[0,0],f=[0,0],i=o=null),n},n.empty=function(){return!!c&&l[0]==l[1]||!!s&&f[0]==f[1]},Xo.rebind(n,a,"on")};var Ts={n:"ns-resize",e:"ew-resize",s:"ns-resize",w:"ew-resize",nw:"nwse-resize",ne:"nesw-resize",se:"nwse-resize",sw:"nesw-resize"},Rs=[["n","e","s","w","nw","ne","se","sw"],["e","w"],["n","s"],[]],Ds=tc.format=ac.timeFormat,Ps=Ds.utc,Us=Ps("%Y-%m-%dT%H:%M:%S.%LZ");Ds.iso=Date.prototype.toISOString&&+new Date("2000-01-01T00:00:00.000Z")?Oo:Us,Oo.parse=function(n){var t=new Date(n);return isNaN(t)?null:t},Oo.toString=Us.toString,tc.second=Rt(function(n){return new ec(1e3*Math.floor(n/1e3))},function(n,t){n.setTime(n.getTime()+1e3*Math.floor(t))},function(n){return n.getSeconds()}),tc.seconds=tc.second.range,tc.seconds.utc=tc.second.utc.range,tc.minute=Rt(function(n){return new ec(6e4*Math.floor(n/6e4))},function(n,t){n.setTime(n.getTime()+6e4*Math.floor(t))},function(n){return n.getMinutes()}),tc.minutes=tc.minute.range,tc.minutes.utc=tc.minute.utc.range,tc.hour=Rt(function(n){var t=n.getTimezoneOffset()/60;return new ec(36e5*(Math.floor(n/36e5-t)+t))},function(n,t){n.setTime(n.getTime()+36e5*Math.floor(t))},function(n){return n.getHours()}),tc.hours=tc.hour.range,tc.hours.utc=tc.hour.utc.range,tc.month=Rt(function(n){return n=tc.day(n),n.setDate(1),n},function(n,t){n.setMonth(n.getMonth()+t)},function(n){return n.getMonth()}),tc.months=tc.month.range,tc.months.utc=tc.month.utc.range;var js=[1e3,5e3,15e3,3e4,6e4,3e5,9e5,18e5,36e5,108e5,216e5,432e5,864e5,1728e5,6048e5,2592e6,7776e6,31536e6],Hs=[[tc.second,1],[tc.second,5],[tc.second,15],[tc.second,30],[tc.minute,1],[tc.minute,5],[tc.minute,15],[tc.minute,30],[tc.hour,1],[tc.hour,3],[tc.hour,6],[tc.hour,12],[tc.day,1],[tc.day,2],[tc.week,1],[tc.month,1],[tc.month,3],[tc.year,1]],Fs=Ds.multi([[".%L",function(n){return n.getMilliseconds()}],[":%S",function(n){return n.getSeconds()}],["%I:%M",function(n){return n.getMinutes()}],["%I %p",function(n){return n.getHours()}],["%a %d",function(n){return n.getDay()&&1!=n.getDate()}],["%b %d",function(n){return 1!=n.getDate()}],["%B",function(n){return n.getMonth()}],["%Y",be]]),Os={range:function(n,t,e){return Xo.range(+n,+t,e).map(Io)},floor:bt,ceil:bt};Hs.year=tc.year,tc.scale=function(){return Yo(Xo.scale.linear(),Hs,Fs)};var Ys=Hs.map(function(n){return[n[0].utc,n[1]]}),Is=Ps.multi([[".%L",function(n){return n.getUTCMilliseconds()}],[":%S",function(n){return n.getUTCSeconds()}],["%I:%M",function(n){return n.getUTCMinutes()}],["%I %p",function(n){return n.getUTCHours()}],["%a %d",function(n){return n.getUTCDay()&&1!=n.getUTCDate()}],["%b %d",function(n){return 1!=n.getUTCDate()}],["%B",function(n){return n.getUTCMonth()}],["%Y",be]]);Ys.year=tc.year.utc,tc.scale.utc=function(){return Yo(Xo.scale.linear(),Ys,Is)},Xo.text=wt(function(n){return n.responseText}),Xo.json=function(n,t){return St(n,"application/json",Zo,t)},Xo.html=function(n,t){return St(n,"text/html",Vo,t)},Xo.xml=wt(function(n){return n.responseXML}),"function"==typeof define&&define.amd?define(Xo):"object"==typeof module&&module.exports?module.exports=Xo:this.d3=Xo}(); \ No newline at end of file diff --git a/assets/libs/documentScroll.js b/assets/libs/documentScroll.js new file mode 100755 index 000000000..c780c289e --- /dev/null +++ b/assets/libs/documentScroll.js @@ -0,0 +1,39 @@ +/** + * Объект с информацией о прокрутке в документе + * @return {object} + * top: сколько пикселей прокручено сверху, верхняя граница видимой части + * bottom: top + высота окна, то есть нижняя граница видимой части пикселей низ, + * height: полная высота страницы + */ +function getDocumentScroll() { + return { + top: getDocumentScrollTop(), + bottom: getDocumentScrollBottom(), + height: getDocumentScrollHeight() + }; +} + + +function getDocumentScrollTop() { + var html = document.documentElement; + var body = document.body; + + var scrollTop = html.scrollTop || body && body.scrollTop || 0; + scrollTop -= html.clientTop; // IE<8 + + return scrollTop; +} + +function getDocumentScrollHeight() { + var scrollHeight = document.documentElement.scrollHeight; + var clientHeight = document.documentElement.clientHeight; + + scrollHeight = Math.max(scrollHeight, clientHeight); + + return scrollHeight; +} + +function getDocumentScrollBottom() { + return getDocumentScrollTop() + document.documentElement.clientHeight; +} + diff --git a/assets/libs/domtree.js b/assets/libs/domtree.js new file mode 100755 index 000000000..721490a3f --- /dev/null +++ b/assets/libs/domtree.js @@ -0,0 +1,241 @@ +// ebook-converter removes CSS which styles SVG +// that's why I style here in JS + +function drawHtmlTree(json, nodeTarget, w, h) { + + if (typeof nodeTarget == 'string') { + nodeTarget = document.querySelectorAll(nodeTarget); + nodeTarget = nodeTarget[nodeTarget.length - 1]; + } + + w = w || 960; + h = h || 800; + + var i = 0, + barHeight = 30, + barWidth = 250, + barMargin = 2.5, + barRadius = 4, + duration = 400, + root; + + var tree, diagonal, vis; + + function update(source) { + // Compute the flattened node list. TODO use d3.layout.hierarchy. + var nodes = tree.nodes(root); + // Compute the "layout". + nodes.forEach(function(n, i) { + n.x = i * barHeight; + }); + + // Update the nodes… + var node = vis.selectAll("g.node") + .data(nodes, function(d) { + return d.id || (d.id = ++i); + }); + + var nodeEnter = node.enter().append("svg:g") + .attr("class", "node") + .attr("transform", function(d) { + return "translate(" + (source.y0) + "," + (source.x0) + ")"; + }) + .style("opacity", 1e-6); + + // Enter any new nodes at the parent's previous position. + nodeEnter.append("svg:rect") + .attr("y", function () { return -barHeight / 2 + barMargin; }) + .attr("x", -5) + .attr("rx",barRadius) + .attr("ry",barRadius) + .attr("height", barHeight-barMargin*2) + .attr("width", barWidth) + .style("fill", color) + .style("cursor", "pointer") + .on("click", click); + + + nodeEnter.append("svg:text") + .attr("dy", 4.5) + .attr("dx", 3.5) + .style('fill', 'black') + .style("pointer-events", "none"); + + + nodeEnter.append("svg:text") + .attr("dy", 4.5) + .attr("dx", function(d) { + return d.content ? 5.5 : 16.5; + }) + .style('font', '14px Consolas, monospace') + .style('fill', '#333') + .style("pointer-events", "none") + .text(function(d) { + var text = d.name; + if (d.content) { + if (/^\s*$/.test(d.content)) { + text += " " + d.content.replace(/\n/g, "↵").replace(/ /g, '␣'); + } else { + text += " " + d.content; + } + } + return text; + }); + + // Transition nodes to their new position. + nodeEnter.transition() + .duration(duration) + .attr("transform", function(d, i) { + return "translate(" + (d.y) + "," + (d.x) + ")"; + }) + .style("opacity", 1); + + node.transition() + .duration(duration) + .attr("transform", function(d) { + return "translate(" + d.y + "," + (d.x) + ")"; + }) + .style("opacity", 1) + .select("text") + .text(function(d) { + if (d.content) return ""; + if (d._children) { + return "▸ "; + } else { + return "▾ "; + } + }); + + // Transition exiting nodes to the parent's new position. + node.exit().transition() + .duration(duration) + .attr("transform", function(d) { + return "translate(" + source.y + "," + source.x + ")"; + }) + .style("opacity", 1e-6) + .remove(); + + // Update the links… + var link = vis.selectAll("path.link") + .data(tree.links(nodes), function(d) { + return d.target.id; + }); + + // Enter any new links at the parent's previous position. + link.enter().insert("svg:path", "g") + .attr("class", "link") + .style('fill', 'none') + .style('stroke', '#BEC3C7') + .style('stroke-width', '1px') + .attr("d", function(d) { + var o = { + x: source.x0, + y: source.y0 + }; + return diagonal({ + source: o, + target: o + }); + }) + .transition() + .duration(duration) + .attr("d", diagonal); + + // Transition links to their new position. + link.transition() + .duration(duration) + .attr("d", diagonal); + + // Transition exiting nodes to the parent's new position. + link.exit().transition() + .duration(duration) + .attr("d", function(d) { + var o = { + x: source.x, + y: source.y + }; + return diagonal({ + source: o, + target: o + }); + }) + .remove(); + + // Stash the old positions for transition. + nodes.forEach(function(d) { + d.x0 = d.x; + d.y0 = d.y; + }); + } + + // Toggle children on click. + + function click(d) { + if (d.children) { + d._children = d.children; + d.children = null; + } else { + d.children = d._children; + d._children = null; + } + update(d); + } + + function color(d) { + return d.nodeType == 1 ? "#CEE0F4" : + d.nodeType == 3 ? '#FFDE99' : '#CFCE95'; + } + + function drawTree(json) { + + tree = d3.layout.tree() + .size([h, 100]); + + diagonal = function(d){ + var deltaX = 7; + var deltaY = 0; + var points = [ + "M", [d.source.y+deltaX, d.source.x+deltaY].join(","), + "L", [d.source.y+deltaX, d.target.x+deltaY].join(","), + "L", [d.target.y+deltaX, d.target.x+deltaY].join(","), + ]; + return points.join(""); + }; + + + vis = d3.select(nodeTarget).append("svg:svg") + .attr("width", w) + .attr("height", h) + .append("svg:g") + .attr("transform", "translate(20,30)"); + + json.x0 = 0; + json.y0 = 0; + update(root = json); + } + + nodeTarget.innerHTML = ""; + + drawTree(json); + +} + + +function node2json(node) { + var obj = { + name: node.nodeName, + nodeType: node.nodeType + }; + + if (node.nodeType != 1) { + obj.content = node.data; + return obj; + } + + obj.children = []; + for(var i=0; i + * Build: `lodash modern -o ./dist/lodash.js` + * Copyright 2012-2013 The Dojo Foundation + * Based on Underscore.js 1.5.2 + * Copyright 2009-2013 Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors + * Available under MIT license + */ +;(function() { + + /** Used as a safe reference for `undefined` in pre ES5 environments */ + var undefined; + + /** Used to pool arrays and objects used internally */ + var arrayPool = [], + objectPool = []; + + /** Used to generate unique IDs */ + var idCounter = 0; + + /** Used to prefix keys to avoid issues with `__proto__` and properties on `Object.prototype` */ + var keyPrefix = +new Date + ''; + + /** Used as the size when optimizations are enabled for large arrays */ + var largeArraySize = 75; + + /** Used as the max size of the `arrayPool` and `objectPool` */ + var maxPoolSize = 40; + + /** Used to detect and test whitespace */ + var whitespace = ( + // whitespace + ' \t\x0B\f\xA0\ufeff' + + + // line terminators + '\n\r\u2028\u2029' + + + // unicode category "Zs" space separators + '\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000' + ); + + /** Used to match empty string literals in compiled template source */ + var reEmptyStringLeading = /\b__p \+= '';/g, + reEmptyStringMiddle = /\b(__p \+=) '' \+/g, + reEmptyStringTrailing = /(__e\(.*?\)|\b__t\)) \+\n'';/g; + + /** + * Used to match ES6 template delimiters + * http://people.mozilla.org/~jorendorff/es6-draft.html#sec-literals-string-literals + */ + var reEsTemplate = /\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g; + + /** Used to match regexp flags from their coerced string values */ + var reFlags = /\w*$/; + + /** Used to detected named functions */ + var reFuncName = /^\s*function[ \n\r\t]+\w/; + + /** Used to match "interpolate" template delimiters */ + var reInterpolate = /<%=([\s\S]+?)%>/g; + + /** Used to match leading whitespace and zeros to be removed */ + var reLeadingSpacesAndZeros = RegExp('^[' + whitespace + ']*0+(?=.$)'); + + /** Used to ensure capturing order of template delimiters */ + var reNoMatch = /($^)/; + + /** Used to detect functions containing a `this` reference */ + var reThis = /\bthis\b/; + + /** Used to match unescaped characters in compiled string literals */ + var reUnescapedString = /['\n\r\t\u2028\u2029\\]/g; + + /** Used to assign default `context` object properties */ + var contextProps = [ + 'Array', 'Boolean', 'Date', 'Function', 'Math', 'Number', 'Object', + 'RegExp', 'String', '_', 'attachEvent', 'clearTimeout', 'isFinite', 'isNaN', + 'parseInt', 'setTimeout' + ]; + + /** Used to make template sourceURLs easier to identify */ + var templateCounter = 0; + + /** `Object#toString` result shortcuts */ + var argsClass = '[object Arguments]', + arrayClass = '[object Array]', + boolClass = '[object Boolean]', + dateClass = '[object Date]', + funcClass = '[object Function]', + numberClass = '[object Number]', + objectClass = '[object Object]', + regexpClass = '[object RegExp]', + stringClass = '[object String]'; + + /** Used to identify object classifications that `_.clone` supports */ + var cloneableClasses = {}; + cloneableClasses[funcClass] = false; + cloneableClasses[argsClass] = cloneableClasses[arrayClass] = + cloneableClasses[boolClass] = cloneableClasses[dateClass] = + cloneableClasses[numberClass] = cloneableClasses[objectClass] = + cloneableClasses[regexpClass] = cloneableClasses[stringClass] = true; + + /** Used as an internal `_.debounce` options object */ + var debounceOptions = { + 'leading': false, + 'maxWait': 0, + 'trailing': false + }; + + /** Used as the property descriptor for `__bindData__` */ + var descriptor = { + 'configurable': false, + 'enumerable': false, + 'value': null, + 'writable': false + }; + + /** Used to determine if values are of the language type Object */ + var objectTypes = { + 'boolean': false, + 'function': true, + 'object': true, + 'number': false, + 'string': false, + 'undefined': false + }; + + /** Used to escape characters for inclusion in compiled string literals */ + var stringEscapes = { + '\\': '\\', + "'": "'", + '\n': 'n', + '\r': 'r', + '\t': 't', + '\u2028': 'u2028', + '\u2029': 'u2029' + }; + + /** Used as a reference to the global object */ + var root = (objectTypes[typeof window] && window) || this; + + /** Detect free variable `exports` */ + var freeExports = objectTypes[typeof exports] && exports && !exports.nodeType && exports; + + /** Detect free variable `module` */ + var freeModule = objectTypes[typeof module] && module && !module.nodeType && module; + + /** Detect the popular CommonJS extension `module.exports` */ + var moduleExports = freeModule && freeModule.exports === freeExports && freeExports; + + /** Detect free variable `global` from Node.js or Browserified code and use it as `root` */ + var freeGlobal = objectTypes[typeof global] && global; + if (freeGlobal && (freeGlobal.global === freeGlobal || freeGlobal.window === freeGlobal)) { + root = freeGlobal; + } + + /*--------------------------------------------------------------------------*/ + + /** + * The base implementation of `_.indexOf` without support for binary searches + * or `fromIndex` constraints. + * + * @private + * @param {Array} array The array to search. + * @param {*} value The value to search for. + * @param {number} [fromIndex=0] The index to search from. + * @returns {number} Returns the index of the matched value or `-1`. + */ + function baseIndexOf(array, value, fromIndex) { + var index = (fromIndex || 0) - 1, + length = array ? array.length : 0; + + while (++index < length) { + if (array[index] === value) { + return index; + } + } + return -1; + } + + /** + * An implementation of `_.contains` for cache objects that mimics the return + * signature of `_.indexOf` by returning `0` if the value is found, else `-1`. + * + * @private + * @param {Object} cache The cache object to inspect. + * @param {*} value The value to search for. + * @returns {number} Returns `0` if `value` is found, else `-1`. + */ + function cacheIndexOf(cache, value) { + var type = typeof value; + cache = cache.cache; + + if (type == 'boolean' || value == null) { + return cache[value] ? 0 : -1; + } + if (type != 'number' && type != 'string') { + type = 'object'; + } + var key = type == 'number' ? value : keyPrefix + value; + cache = (cache = cache[type]) && cache[key]; + + return type == 'object' + ? (cache && baseIndexOf(cache, value) > -1 ? 0 : -1) + : (cache ? 0 : -1); + } + + /** + * Adds a given value to the corresponding cache object. + * + * @private + * @param {*} value The value to add to the cache. + */ + function cachePush(value) { + var cache = this.cache, + type = typeof value; + + if (type == 'boolean' || value == null) { + cache[value] = true; + } else { + if (type != 'number' && type != 'string') { + type = 'object'; + } + var key = type == 'number' ? value : keyPrefix + value, + typeCache = cache[type] || (cache[type] = {}); + + if (type == 'object') { + (typeCache[key] || (typeCache[key] = [])).push(value); + } else { + typeCache[key] = true; + } + } + } + + /** + * Used by `_.max` and `_.min` as the default callback when a given + * collection is a string value. + * + * @private + * @param {string} value The character to inspect. + * @returns {number} Returns the code unit of given character. + */ + function charAtCallback(value) { + return value.charCodeAt(0); + } + + /** + * Used by `sortBy` to compare transformed `collection` elements, stable sorting + * them in ascending order. + * + * @private + * @param {Object} a The object to compare to `b`. + * @param {Object} b The object to compare to `a`. + * @returns {number} Returns the sort order indicator of `1` or `-1`. + */ + function compareAscending(a, b) { + var ac = a.criteria, + bc = b.criteria, + index = -1, + length = ac.length; + + while (++index < length) { + var value = ac[index], + other = bc[index]; + + if (value !== other) { + if (value > other || typeof value == 'undefined') { + return 1; + } + if (value < other || typeof other == 'undefined') { + return -1; + } + } + } + // Fixes an `Array#sort` bug in the JS engine embedded in Adobe applications + // that causes it, under certain circumstances, to return the same value for + // `a` and `b`. See https://github.com/jashkenas/underscore/pull/1247 + // + // This also ensures a stable sort in V8 and other engines. + // See http://code.google.com/p/v8/issues/detail?id=90 + return a.index - b.index; + } + + /** + * Creates a cache object to optimize linear searches of large arrays. + * + * @private + * @param {Array} [array=[]] The array to search. + * @returns {null|Object} Returns the cache object or `null` if caching should not be used. + */ + function createCache(array) { + var index = -1, + length = array.length, + first = array[0], + mid = array[(length / 2) | 0], + last = array[length - 1]; + + if (first && typeof first == 'object' && + mid && typeof mid == 'object' && last && typeof last == 'object') { + return false; + } + var cache = getObject(); + cache['false'] = cache['null'] = cache['true'] = cache['undefined'] = false; + + var result = getObject(); + result.array = array; + result.cache = cache; + result.push = cachePush; + + while (++index < length) { + result.push(array[index]); + } + return result; + } + + /** + * Used by `template` to escape characters for inclusion in compiled + * string literals. + * + * @private + * @param {string} match The matched character to escape. + * @returns {string} Returns the escaped character. + */ + function escapeStringChar(match) { + return '\\' + stringEscapes[match]; + } + + /** + * Gets an array from the array pool or creates a new one if the pool is empty. + * + * @private + * @returns {Array} The array from the pool. + */ + function getArray() { + return arrayPool.pop() || []; + } + + /** + * Gets an object from the object pool or creates a new one if the pool is empty. + * + * @private + * @returns {Object} The object from the pool. + */ + function getObject() { + return objectPool.pop() || { + 'array': null, + 'cache': null, + 'criteria': null, + 'false': false, + 'index': 0, + 'null': false, + 'number': null, + 'object': null, + 'push': null, + 'string': null, + 'true': false, + 'undefined': false, + 'value': null + }; + } + + /** + * Releases the given array back to the array pool. + * + * @private + * @param {Array} [array] The array to release. + */ + function releaseArray(array) { + array.length = 0; + if (arrayPool.length < maxPoolSize) { + arrayPool.push(array); + } + } + + /** + * Releases the given object back to the object pool. + * + * @private + * @param {Object} [object] The object to release. + */ + function releaseObject(object) { + var cache = object.cache; + if (cache) { + releaseObject(cache); + } + object.array = object.cache = object.criteria = object.object = object.number = object.string = object.value = null; + if (objectPool.length < maxPoolSize) { + objectPool.push(object); + } + } + + /** + * Slices the `collection` from the `start` index up to, but not including, + * the `end` index. + * + * Note: This function is used instead of `Array#slice` to support node lists + * in IE < 9 and to ensure dense arrays are returned. + * + * @private + * @param {Array|Object|string} collection The collection to slice. + * @param {number} start The start index. + * @param {number} end The end index. + * @returns {Array} Returns the new array. + */ + function slice(array, start, end) { + start || (start = 0); + if (typeof end == 'undefined') { + end = array ? array.length : 0; + } + var index = -1, + length = end - start || 0, + result = Array(length < 0 ? 0 : length); + + while (++index < length) { + result[index] = array[start + index]; + } + return result; + } + + /*--------------------------------------------------------------------------*/ + + /** + * Create a new `lodash` function using the given context object. + * + * @static + * @memberOf _ + * @category Utilities + * @param {Object} [context=root] The context object. + * @returns {Function} Returns the `lodash` function. + */ + function runInContext(context) { + // Avoid issues with some ES3 environments that attempt to use values, named + // after built-in constructors like `Object`, for the creation of literals. + // ES5 clears this up by stating that literals must use built-in constructors. + // See http://es5.github.io/#x11.1.5. + context = context ? _.defaults(root.Object(), context, _.pick(root, contextProps)) : root; + + /** Native constructor references */ + var Array = context.Array, + Boolean = context.Boolean, + Date = context.Date, + Function = context.Function, + Math = context.Math, + Number = context.Number, + Object = context.Object, + RegExp = context.RegExp, + String = context.String, + TypeError = context.TypeError; + + /** + * Used for `Array` method references. + * + * Normally `Array.prototype` would suffice, however, using an array literal + * avoids issues in Narwhal. + */ + var arrayRef = []; + + /** Used for native method references */ + var objectProto = Object.prototype; + + /** Used to restore the original `_` reference in `noConflict` */ + var oldDash = context._; + + /** Used to resolve the internal [[Class]] of values */ + var toString = objectProto.toString; + + /** Used to detect if a method is native */ + var reNative = RegExp('^' + + String(toString) + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/toString| for [^\]]+/g, '.*?') + '$' + ); + + /** Native method shortcuts */ + var ceil = Math.ceil, + clearTimeout = context.clearTimeout, + floor = Math.floor, + fnToString = Function.prototype.toString, + getPrototypeOf = isNative(getPrototypeOf = Object.getPrototypeOf) && getPrototypeOf, + hasOwnProperty = objectProto.hasOwnProperty, + push = arrayRef.push, + setTimeout = context.setTimeout, + splice = arrayRef.splice, + unshift = arrayRef.unshift; + + /** Used to set meta data on functions */ + var defineProperty = (function() { + // IE 8 only accepts DOM elements + try { + var o = {}, + func = isNative(func = Object.defineProperty) && func, + result = func(o, o, o) && func; + } catch(e) { } + return result; + }()); + + /* Native method shortcuts for methods with the same name as other `lodash` methods */ + var nativeCreate = isNative(nativeCreate = Object.create) && nativeCreate, + nativeIsArray = isNative(nativeIsArray = Array.isArray) && nativeIsArray, + nativeIsFinite = context.isFinite, + nativeIsNaN = context.isNaN, + nativeKeys = isNative(nativeKeys = Object.keys) && nativeKeys, + nativeMax = Math.max, + nativeMin = Math.min, + nativeParseInt = context.parseInt, + nativeRandom = Math.random; + + /** Used to lookup a built-in constructor by [[Class]] */ + var ctorByClass = {}; + ctorByClass[arrayClass] = Array; + ctorByClass[boolClass] = Boolean; + ctorByClass[dateClass] = Date; + ctorByClass[funcClass] = Function; + ctorByClass[objectClass] = Object; + ctorByClass[numberClass] = Number; + ctorByClass[regexpClass] = RegExp; + ctorByClass[stringClass] = String; + + /*--------------------------------------------------------------------------*/ + + /** + * Creates a `lodash` object which wraps the given value to enable intuitive + * method chaining. + * + * In addition to Lo-Dash methods, wrappers also have the following `Array` methods: + * `concat`, `join`, `pop`, `push`, `reverse`, `shift`, `slice`, `sort`, `splice`, + * and `unshift` + * + * Chaining is supported in custom builds as long as the `value` method is + * implicitly or explicitly included in the build. + * + * The chainable wrapper functions are: + * `after`, `assign`, `bind`, `bindAll`, `bindKey`, `chain`, `compact`, + * `compose`, `concat`, `countBy`, `create`, `createCallback`, `curry`, + * `debounce`, `defaults`, `defer`, `delay`, `difference`, `filter`, `flatten`, + * `forEach`, `forEachRight`, `forIn`, `forInRight`, `forOwn`, `forOwnRight`, + * `functions`, `groupBy`, `indexBy`, `initial`, `intersection`, `invert`, + * `invoke`, `keys`, `map`, `max`, `memoize`, `merge`, `min`, `object`, `omit`, + * `once`, `pairs`, `partial`, `partialRight`, `pick`, `pluck`, `pull`, `push`, + * `range`, `reject`, `remove`, `rest`, `reverse`, `shuffle`, `slice`, `sort`, + * `sortBy`, `splice`, `tap`, `throttle`, `times`, `toArray`, `transform`, + * `union`, `uniq`, `unshift`, `unzip`, `values`, `where`, `without`, `wrap`, + * and `zip` + * + * The non-chainable wrapper functions are: + * `clone`, `cloneDeep`, `contains`, `escape`, `every`, `find`, `findIndex`, + * `findKey`, `findLast`, `findLastIndex`, `findLastKey`, `has`, `identity`, + * `indexOf`, `isArguments`, `isArray`, `isBoolean`, `isDate`, `isElement`, + * `isEmpty`, `isEqual`, `isFinite`, `isFunction`, `isNaN`, `isNull`, `isNumber`, + * `isObject`, `isPlainObject`, `isRegExp`, `isString`, `isUndefined`, `join`, + * `lastIndexOf`, `mixin`, `noConflict`, `parseInt`, `pop`, `random`, `reduce`, + * `reduceRight`, `result`, `shift`, `size`, `some`, `sortedIndex`, `runInContext`, + * `template`, `unescape`, `uniqueId`, and `value` + * + * The wrapper functions `first` and `last` return wrapped values when `n` is + * provided, otherwise they return unwrapped values. + * + * Explicit chaining can be enabled by using the `_.chain` method. + * + * @name _ + * @constructor + * @category Chaining + * @param {*} value The value to wrap in a `lodash` instance. + * @returns {Object} Returns a `lodash` instance. + * @example + * + * var wrapped = _([1, 2, 3]); + * + * // returns an unwrapped value + * wrapped.reduce(function(sum, num) { + * return sum + num; + * }); + * // => 6 + * + * // returns a wrapped value + * var squares = wrapped.map(function(num) { + * return num * num; + * }); + * + * _.isArray(squares); + * // => false + * + * _.isArray(squares.value()); + * // => true + */ + function lodash(value) { + // don't wrap if already wrapped, even if wrapped by a different `lodash` constructor + return (value && typeof value == 'object' && !isArray(value) && hasOwnProperty.call(value, '__wrapped__')) + ? value + : new lodashWrapper(value); + } + + /** + * A fast path for creating `lodash` wrapper objects. + * + * @private + * @param {*} value The value to wrap in a `lodash` instance. + * @param {boolean} chainAll A flag to enable chaining for all methods + * @returns {Object} Returns a `lodash` instance. + */ + function lodashWrapper(value, chainAll) { + this.__chain__ = !!chainAll; + this.__wrapped__ = value; + } + // ensure `new lodashWrapper` is an instance of `lodash` + lodashWrapper.prototype = lodash.prototype; + + /** + * An object used to flag environments features. + * + * @static + * @memberOf _ + * @type Object + */ + var support = lodash.support = {}; + + /** + * Detect if functions can be decompiled by `Function#toString` + * (all but PS3 and older Opera mobile browsers & avoided in Windows 8 apps). + * + * @memberOf _.support + * @type boolean + */ + support.funcDecomp = !isNative(context.WinRTError) && reThis.test(runInContext); + + /** + * Detect if `Function#name` is supported (all but IE). + * + * @memberOf _.support + * @type boolean + */ + support.funcNames = typeof Function.name == 'string'; + + /** + * By default, the template delimiters used by Lo-Dash are similar to those in + * embedded Ruby (ERB). Change the following template settings to use alternative + * delimiters. + * + * @static + * @memberOf _ + * @type Object + */ + lodash.templateSettings = { + + /** + * Used to detect `data` property values to be HTML-escaped. + * + * @memberOf _.templateSettings + * @type RegExp + */ + 'escape': /<%-([\s\S]+?)%>/g, + + /** + * Used to detect code to be evaluated. + * + * @memberOf _.templateSettings + * @type RegExp + */ + 'evaluate': /<%([\s\S]+?)%>/g, + + /** + * Used to detect `data` property values to inject. + * + * @memberOf _.templateSettings + * @type RegExp + */ + 'interpolate': reInterpolate, + + /** + * Used to reference the data object in the template text. + * + * @memberOf _.templateSettings + * @type string + */ + 'variable': '', + + /** + * Used to import variables into the compiled template. + * + * @memberOf _.templateSettings + * @type Object + */ + 'imports': { + + /** + * A reference to the `lodash` function. + * + * @memberOf _.templateSettings.imports + * @type Function + */ + '_': lodash + } + }; + + /*--------------------------------------------------------------------------*/ + + /** + * The base implementation of `_.bind` that creates the bound function and + * sets its meta data. + * + * @private + * @param {Array} bindData The bind data array. + * @returns {Function} Returns the new bound function. + */ + function baseBind(bindData) { + var func = bindData[0], + partialArgs = bindData[2], + thisArg = bindData[4]; + + function bound() { + // `Function#bind` spec + // http://es5.github.io/#x15.3.4.5 + if (partialArgs) { + // avoid `arguments` object deoptimizations by using `slice` instead + // of `Array.prototype.slice.call` and not assigning `arguments` to a + // variable as a ternary expression + var args = slice(partialArgs); + push.apply(args, arguments); + } + // mimic the constructor's `return` behavior + // http://es5.github.io/#x13.2.2 + if (this instanceof bound) { + // ensure `new bound` is an instance of `func` + var thisBinding = baseCreate(func.prototype), + result = func.apply(thisBinding, args || arguments); + return isObject(result) ? result : thisBinding; + } + return func.apply(thisArg, args || arguments); + } + setBindData(bound, bindData); + return bound; + } + + /** + * The base implementation of `_.clone` without argument juggling or support + * for `thisArg` binding. + * + * @private + * @param {*} value The value to clone. + * @param {boolean} [isDeep=false] Specify a deep clone. + * @param {Function} [callback] The function to customize cloning values. + * @param {Array} [stackA=[]] Tracks traversed source objects. + * @param {Array} [stackB=[]] Associates clones with source counterparts. + * @returns {*} Returns the cloned value. + */ + function baseClone(value, isDeep, callback, stackA, stackB) { + if (callback) { + var result = callback(value); + if (typeof result != 'undefined') { + return result; + } + } + // inspect [[Class]] + var isObj = isObject(value); + if (isObj) { + var className = toString.call(value); + if (!cloneableClasses[className]) { + return value; + } + var ctor = ctorByClass[className]; + switch (className) { + case boolClass: + case dateClass: + return new ctor(+value); + + case numberClass: + case stringClass: + return new ctor(value); + + case regexpClass: + result = ctor(value.source, reFlags.exec(value)); + result.lastIndex = value.lastIndex; + return result; + } + } else { + return value; + } + var isArr = isArray(value); + if (isDeep) { + // check for circular references and return corresponding clone + var initedStack = !stackA; + stackA || (stackA = getArray()); + stackB || (stackB = getArray()); + + var length = stackA.length; + while (length--) { + if (stackA[length] == value) { + return stackB[length]; + } + } + result = isArr ? ctor(value.length) : {}; + } + else { + result = isArr ? slice(value) : assign({}, value); + } + // add array properties assigned by `RegExp#exec` + if (isArr) { + if (hasOwnProperty.call(value, 'index')) { + result.index = value.index; + } + if (hasOwnProperty.call(value, 'input')) { + result.input = value.input; + } + } + // exit for shallow clone + if (!isDeep) { + return result; + } + // add the source value to the stack of traversed objects + // and associate it with its clone + stackA.push(value); + stackB.push(result); + + // recursively populate clone (susceptible to call stack limits) + (isArr ? forEach : forOwn)(value, function(objValue, key) { + result[key] = baseClone(objValue, isDeep, callback, stackA, stackB); + }); + + if (initedStack) { + releaseArray(stackA); + releaseArray(stackB); + } + return result; + } + + /** + * The base implementation of `_.create` without support for assigning + * properties to the created object. + * + * @private + * @param {Object} prototype The object to inherit from. + * @returns {Object} Returns the new object. + */ + function baseCreate(prototype, properties) { + return isObject(prototype) ? nativeCreate(prototype) : {}; + } + // fallback for browsers without `Object.create` + if (!nativeCreate) { + baseCreate = (function() { + function Object() {} + return function(prototype) { + if (isObject(prototype)) { + Object.prototype = prototype; + var result = new Object; + Object.prototype = null; + } + return result || context.Object(); + }; + }()); + } + + /** + * The base implementation of `_.createCallback` without support for creating + * "_.pluck" or "_.where" style callbacks. + * + * @private + * @param {*} [func=identity] The value to convert to a callback. + * @param {*} [thisArg] The `this` binding of the created callback. + * @param {number} [argCount] The number of arguments the callback accepts. + * @returns {Function} Returns a callback function. + */ + function baseCreateCallback(func, thisArg, argCount) { + if (typeof func != 'function') { + return identity; + } + // exit early for no `thisArg` or already bound by `Function#bind` + if (typeof thisArg == 'undefined' || !('prototype' in func)) { + return func; + } + var bindData = func.__bindData__; + if (typeof bindData == 'undefined') { + if (support.funcNames) { + bindData = !func.name; + } + bindData = bindData || !support.funcDecomp; + if (!bindData) { + var source = fnToString.call(func); + if (!support.funcNames) { + bindData = !reFuncName.test(source); + } + if (!bindData) { + // checks if `func` references the `this` keyword and stores the result + bindData = reThis.test(source); + setBindData(func, bindData); + } + } + } + // exit early if there are no `this` references or `func` is bound + if (bindData === false || (bindData !== true && bindData[1] & 1)) { + return func; + } + switch (argCount) { + case 1: return function(value) { + return func.call(thisArg, value); + }; + case 2: return function(a, b) { + return func.call(thisArg, a, b); + }; + case 3: return function(value, index, collection) { + return func.call(thisArg, value, index, collection); + }; + case 4: return function(accumulator, value, index, collection) { + return func.call(thisArg, accumulator, value, index, collection); + }; + } + return bind(func, thisArg); + } + + /** + * The base implementation of `createWrapper` that creates the wrapper and + * sets its meta data. + * + * @private + * @param {Array} bindData The bind data array. + * @returns {Function} Returns the new function. + */ + function baseCreateWrapper(bindData) { + var func = bindData[0], + bitmask = bindData[1], + partialArgs = bindData[2], + partialRightArgs = bindData[3], + thisArg = bindData[4], + arity = bindData[5]; + + var isBind = bitmask & 1, + isBindKey = bitmask & 2, + isCurry = bitmask & 4, + isCurryBound = bitmask & 8, + key = func; + + function bound() { + var thisBinding = isBind ? thisArg : this; + if (partialArgs) { + var args = slice(partialArgs); + push.apply(args, arguments); + } + if (partialRightArgs || isCurry) { + args || (args = slice(arguments)); + if (partialRightArgs) { + push.apply(args, partialRightArgs); + } + if (isCurry && args.length < arity) { + bitmask |= 16 & ~32; + return baseCreateWrapper([func, (isCurryBound ? bitmask : bitmask & ~3), args, null, thisArg, arity]); + } + } + args || (args = arguments); + if (isBindKey) { + func = thisBinding[key]; + } + if (this instanceof bound) { + thisBinding = baseCreate(func.prototype); + var result = func.apply(thisBinding, args); + return isObject(result) ? result : thisBinding; + } + return func.apply(thisBinding, args); + } + setBindData(bound, bindData); + return bound; + } + + /** + * The base implementation of `_.difference` that accepts a single array + * of values to exclude. + * + * @private + * @param {Array} array The array to process. + * @param {Array} [values] The array of values to exclude. + * @returns {Array} Returns a new array of filtered values. + */ + function baseDifference(array, values) { + var index = -1, + indexOf = getIndexOf(), + length = array ? array.length : 0, + isLarge = length >= largeArraySize && indexOf === baseIndexOf, + result = []; + + if (isLarge) { + var cache = createCache(values); + if (cache) { + indexOf = cacheIndexOf; + values = cache; + } else { + isLarge = false; + } + } + while (++index < length) { + var value = array[index]; + if (indexOf(values, value) < 0) { + result.push(value); + } + } + if (isLarge) { + releaseObject(values); + } + return result; + } + + /** + * The base implementation of `_.flatten` without support for callback + * shorthands or `thisArg` binding. + * + * @private + * @param {Array} array The array to flatten. + * @param {boolean} [isShallow=false] A flag to restrict flattening to a single level. + * @param {boolean} [isStrict=false] A flag to restrict flattening to arrays and `arguments` objects. + * @param {number} [fromIndex=0] The index to start from. + * @returns {Array} Returns a new flattened array. + */ + function baseFlatten(array, isShallow, isStrict, fromIndex) { + var index = (fromIndex || 0) - 1, + length = array ? array.length : 0, + result = []; + + while (++index < length) { + var value = array[index]; + + if (value && typeof value == 'object' && typeof value.length == 'number' + && (isArray(value) || isArguments(value))) { + // recursively flatten arrays (susceptible to call stack limits) + if (!isShallow) { + value = baseFlatten(value, isShallow, isStrict); + } + var valIndex = -1, + valLength = value.length, + resIndex = result.length; + + result.length += valLength; + while (++valIndex < valLength) { + result[resIndex++] = value[valIndex]; + } + } else if (!isStrict) { + result.push(value); + } + } + return result; + } + + /** + * The base implementation of `_.isEqual`, without support for `thisArg` binding, + * that allows partial "_.where" style comparisons. + * + * @private + * @param {*} a The value to compare. + * @param {*} b The other value to compare. + * @param {Function} [callback] The function to customize comparing values. + * @param {Function} [isWhere=false] A flag to indicate performing partial comparisons. + * @param {Array} [stackA=[]] Tracks traversed `a` objects. + * @param {Array} [stackB=[]] Tracks traversed `b` objects. + * @returns {boolean} Returns `true` if the values are equivalent, else `false`. + */ + function baseIsEqual(a, b, callback, isWhere, stackA, stackB) { + // used to indicate that when comparing objects, `a` has at least the properties of `b` + if (callback) { + var result = callback(a, b); + if (typeof result != 'undefined') { + return !!result; + } + } + // exit early for identical values + if (a === b) { + // treat `+0` vs. `-0` as not equal + return a !== 0 || (1 / a == 1 / b); + } + var type = typeof a, + otherType = typeof b; + + // exit early for unlike primitive values + if (a === a && + !(a && objectTypes[type]) && + !(b && objectTypes[otherType])) { + return false; + } + // exit early for `null` and `undefined` avoiding ES3's Function#call behavior + // http://es5.github.io/#x15.3.4.4 + if (a == null || b == null) { + return a === b; + } + // compare [[Class]] names + var className = toString.call(a), + otherClass = toString.call(b); + + if (className == argsClass) { + className = objectClass; + } + if (otherClass == argsClass) { + otherClass = objectClass; + } + if (className != otherClass) { + return false; + } + switch (className) { + case boolClass: + case dateClass: + // coerce dates and booleans to numbers, dates to milliseconds and booleans + // to `1` or `0` treating invalid dates coerced to `NaN` as not equal + return +a == +b; + + case numberClass: + // treat `NaN` vs. `NaN` as equal + return (a != +a) + ? b != +b + // but treat `+0` vs. `-0` as not equal + : (a == 0 ? (1 / a == 1 / b) : a == +b); + + case regexpClass: + case stringClass: + // coerce regexes to strings (http://es5.github.io/#x15.10.6.4) + // treat string primitives and their corresponding object instances as equal + return a == String(b); + } + var isArr = className == arrayClass; + if (!isArr) { + // unwrap any `lodash` wrapped values + var aWrapped = hasOwnProperty.call(a, '__wrapped__'), + bWrapped = hasOwnProperty.call(b, '__wrapped__'); + + if (aWrapped || bWrapped) { + return baseIsEqual(aWrapped ? a.__wrapped__ : a, bWrapped ? b.__wrapped__ : b, callback, isWhere, stackA, stackB); + } + // exit for functions and DOM nodes + if (className != objectClass) { + return false; + } + // in older versions of Opera, `arguments` objects have `Array` constructors + var ctorA = a.constructor, + ctorB = b.constructor; + + // non `Object` object instances with different constructors are not equal + if (ctorA != ctorB && + !(isFunction(ctorA) && ctorA instanceof ctorA && isFunction(ctorB) && ctorB instanceof ctorB) && + ('constructor' in a && 'constructor' in b) + ) { + return false; + } + } + // assume cyclic structures are equal + // the algorithm for detecting cyclic structures is adapted from ES 5.1 + // section 15.12.3, abstract operation `JO` (http://es5.github.io/#x15.12.3) + var initedStack = !stackA; + stackA || (stackA = getArray()); + stackB || (stackB = getArray()); + + var length = stackA.length; + while (length--) { + if (stackA[length] == a) { + return stackB[length] == b; + } + } + var size = 0; + result = true; + + // add `a` and `b` to the stack of traversed objects + stackA.push(a); + stackB.push(b); + + // recursively compare objects and arrays (susceptible to call stack limits) + if (isArr) { + // compare lengths to determine if a deep comparison is necessary + length = a.length; + size = b.length; + result = size == length; + + if (result || isWhere) { + // deep compare the contents, ignoring non-numeric properties + while (size--) { + var index = length, + value = b[size]; + + if (isWhere) { + while (index--) { + if ((result = baseIsEqual(a[index], value, callback, isWhere, stackA, stackB))) { + break; + } + } + } else if (!(result = baseIsEqual(a[size], value, callback, isWhere, stackA, stackB))) { + break; + } + } + } + } + else { + // deep compare objects using `forIn`, instead of `forOwn`, to avoid `Object.keys` + // which, in this case, is more costly + forIn(b, function(value, key, b) { + if (hasOwnProperty.call(b, key)) { + // count the number of properties. + size++; + // deep compare each property value. + return (result = hasOwnProperty.call(a, key) && baseIsEqual(a[key], value, callback, isWhere, stackA, stackB)); + } + }); + + if (result && !isWhere) { + // ensure both objects have the same number of properties + forIn(a, function(value, key, a) { + if (hasOwnProperty.call(a, key)) { + // `size` will be `-1` if `a` has more properties than `b` + return (result = --size > -1); + } + }); + } + } + stackA.pop(); + stackB.pop(); + + if (initedStack) { + releaseArray(stackA); + releaseArray(stackB); + } + return result; + } + + /** + * The base implementation of `_.merge` without argument juggling or support + * for `thisArg` binding. + * + * @private + * @param {Object} object The destination object. + * @param {Object} source The source object. + * @param {Function} [callback] The function to customize merging properties. + * @param {Array} [stackA=[]] Tracks traversed source objects. + * @param {Array} [stackB=[]] Associates values with source counterparts. + */ + function baseMerge(object, source, callback, stackA, stackB) { + (isArray(source) ? forEach : forOwn)(source, function(source, key) { + var found, + isArr, + result = source, + value = object[key]; + + if (source && ((isArr = isArray(source)) || isPlainObject(source))) { + // avoid merging previously merged cyclic sources + var stackLength = stackA.length; + while (stackLength--) { + if ((found = stackA[stackLength] == source)) { + value = stackB[stackLength]; + break; + } + } + if (!found) { + var isShallow; + if (callback) { + result = callback(value, source); + if ((isShallow = typeof result != 'undefined')) { + value = result; + } + } + if (!isShallow) { + value = isArr + ? (isArray(value) ? value : []) + : (isPlainObject(value) ? value : {}); + } + // add `source` and associated `value` to the stack of traversed objects + stackA.push(source); + stackB.push(value); + + // recursively merge objects and arrays (susceptible to call stack limits) + if (!isShallow) { + baseMerge(value, source, callback, stackA, stackB); + } + } + } + else { + if (callback) { + result = callback(value, source); + if (typeof result == 'undefined') { + result = source; + } + } + if (typeof result != 'undefined') { + value = result; + } + } + object[key] = value; + }); + } + + /** + * The base implementation of `_.random` without argument juggling or support + * for returning floating-point numbers. + * + * @private + * @param {number} min The minimum possible value. + * @param {number} max The maximum possible value. + * @returns {number} Returns a random number. + */ + function baseRandom(min, max) { + return min + floor(nativeRandom() * (max - min + 1)); + } + + /** + * The base implementation of `_.uniq` without support for callback shorthands + * or `thisArg` binding. + * + * @private + * @param {Array} array The array to process. + * @param {boolean} [isSorted=false] A flag to indicate that `array` is sorted. + * @param {Function} [callback] The function called per iteration. + * @returns {Array} Returns a duplicate-value-free array. + */ + function baseUniq(array, isSorted, callback) { + var index = -1, + indexOf = getIndexOf(), + length = array ? array.length : 0, + result = []; + + var isLarge = !isSorted && length >= largeArraySize && indexOf === baseIndexOf, + seen = (callback || isLarge) ? getArray() : result; + + if (isLarge) { + var cache = createCache(seen); + indexOf = cacheIndexOf; + seen = cache; + } + while (++index < length) { + var value = array[index], + computed = callback ? callback(value, index, array) : value; + + if (isSorted + ? !index || seen[seen.length - 1] !== computed + : indexOf(seen, computed) < 0 + ) { + if (callback || isLarge) { + seen.push(computed); + } + result.push(value); + } + } + if (isLarge) { + releaseArray(seen.array); + releaseObject(seen); + } else if (callback) { + releaseArray(seen); + } + return result; + } + + /** + * Creates a function that aggregates a collection, creating an object composed + * of keys generated from the results of running each element of the collection + * through a callback. The given `setter` function sets the keys and values + * of the composed object. + * + * @private + * @param {Function} setter The setter function. + * @returns {Function} Returns the new aggregator function. + */ + function createAggregator(setter) { + return function(collection, callback, thisArg) { + var result = {}; + callback = lodash.createCallback(callback, thisArg, 3); + + var index = -1, + length = collection ? collection.length : 0; + + if (typeof length == 'number') { + while (++index < length) { + var value = collection[index]; + setter(result, value, callback(value, index, collection), collection); + } + } else { + forOwn(collection, function(value, key, collection) { + setter(result, value, callback(value, key, collection), collection); + }); + } + return result; + }; + } + + /** + * Creates a function that, when called, either curries or invokes `func` + * with an optional `this` binding and partially applied arguments. + * + * @private + * @param {Function|string} func The function or method name to reference. + * @param {number} bitmask The bitmask of method flags to compose. + * The bitmask may be composed of the following flags: + * 1 - `_.bind` + * 2 - `_.bindKey` + * 4 - `_.curry` + * 8 - `_.curry` (bound) + * 16 - `_.partial` + * 32 - `_.partialRight` + * @param {Array} [partialArgs] An array of arguments to prepend to those + * provided to the new function. + * @param {Array} [partialRightArgs] An array of arguments to append to those + * provided to the new function. + * @param {*} [thisArg] The `this` binding of `func`. + * @param {number} [arity] The arity of `func`. + * @returns {Function} Returns the new function. + */ + function createWrapper(func, bitmask, partialArgs, partialRightArgs, thisArg, arity) { + var isBind = bitmask & 1, + isBindKey = bitmask & 2, + isCurry = bitmask & 4, + isCurryBound = bitmask & 8, + isPartial = bitmask & 16, + isPartialRight = bitmask & 32; + + if (!isBindKey && !isFunction(func)) { + throw new TypeError; + } + if (isPartial && !partialArgs.length) { + bitmask &= ~16; + isPartial = partialArgs = false; + } + if (isPartialRight && !partialRightArgs.length) { + bitmask &= ~32; + isPartialRight = partialRightArgs = false; + } + var bindData = func && func.__bindData__; + if (bindData && bindData !== true) { + // clone `bindData` + bindData = slice(bindData); + if (bindData[2]) { + bindData[2] = slice(bindData[2]); + } + if (bindData[3]) { + bindData[3] = slice(bindData[3]); + } + // set `thisBinding` is not previously bound + if (isBind && !(bindData[1] & 1)) { + bindData[4] = thisArg; + } + // set if previously bound but not currently (subsequent curried functions) + if (!isBind && bindData[1] & 1) { + bitmask |= 8; + } + // set curried arity if not yet set + if (isCurry && !(bindData[1] & 4)) { + bindData[5] = arity; + } + // append partial left arguments + if (isPartial) { + push.apply(bindData[2] || (bindData[2] = []), partialArgs); + } + // append partial right arguments + if (isPartialRight) { + unshift.apply(bindData[3] || (bindData[3] = []), partialRightArgs); + } + // merge flags + bindData[1] |= bitmask; + return createWrapper.apply(null, bindData); + } + // fast path for `_.bind` + var creater = (bitmask == 1 || bitmask === 17) ? baseBind : baseCreateWrapper; + return creater([func, bitmask, partialArgs, partialRightArgs, thisArg, arity]); + } + + /** + * Used by `escape` to convert characters to HTML entities. + * + * @private + * @param {string} match The matched character to escape. + * @returns {string} Returns the escaped character. + */ + function escapeHtmlChar(match) { + return htmlEscapes[match]; + } + + /** + * Gets the appropriate "indexOf" function. If the `_.indexOf` method is + * customized, this method returns the custom method, otherwise it returns + * the `baseIndexOf` function. + * + * @private + * @returns {Function} Returns the "indexOf" function. + */ + function getIndexOf() { + var result = (result = lodash.indexOf) === indexOf ? baseIndexOf : result; + return result; + } + + /** + * Checks if `value` is a native function. + * + * @private + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a native function, else `false`. + */ + function isNative(value) { + return typeof value == 'function' && reNative.test(value); + } + + /** + * Sets `this` binding data on a given function. + * + * @private + * @param {Function} func The function to set data on. + * @param {Array} value The data array to set. + */ + var setBindData = !defineProperty ? noop : function(func, value) { + descriptor.value = value; + defineProperty(func, '__bindData__', descriptor); + }; + + /** + * A fallback implementation of `isPlainObject` which checks if a given value + * is an object created by the `Object` constructor, assuming objects created + * by the `Object` constructor have no inherited enumerable properties and that + * there are no `Object.prototype` extensions. + * + * @private + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a plain object, else `false`. + */ + function shimIsPlainObject(value) { + var ctor, + result; + + // avoid non Object objects, `arguments` objects, and DOM elements + if (!(value && toString.call(value) == objectClass) || + (ctor = value.constructor, isFunction(ctor) && !(ctor instanceof ctor))) { + return false; + } + // In most environments an object's own properties are iterated before + // its inherited properties. If the last iterated property is an object's + // own property then there are no inherited enumerable properties. + forIn(value, function(value, key) { + result = key; + }); + return typeof result == 'undefined' || hasOwnProperty.call(value, result); + } + + /** + * Used by `unescape` to convert HTML entities to characters. + * + * @private + * @param {string} match The matched character to unescape. + * @returns {string} Returns the unescaped character. + */ + function unescapeHtmlChar(match) { + return htmlUnescapes[match]; + } + + /*--------------------------------------------------------------------------*/ + + /** + * Checks if `value` is an `arguments` object. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is an `arguments` object, else `false`. + * @example + * + * (function() { return _.isArguments(arguments); })(1, 2, 3); + * // => true + * + * _.isArguments([1, 2, 3]); + * // => false + */ + function isArguments(value) { + return value && typeof value == 'object' && typeof value.length == 'number' && + toString.call(value) == argsClass || false; + } + + /** + * Checks if `value` is an array. + * + * @static + * @memberOf _ + * @type Function + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is an array, else `false`. + * @example + * + * (function() { return _.isArray(arguments); })(); + * // => false + * + * _.isArray([1, 2, 3]); + * // => true + */ + var isArray = nativeIsArray || function(value) { + return value && typeof value == 'object' && typeof value.length == 'number' && + toString.call(value) == arrayClass || false; + }; + + /** + * A fallback implementation of `Object.keys` which produces an array of the + * given object's own enumerable property names. + * + * @private + * @type Function + * @param {Object} object The object to inspect. + * @returns {Array} Returns an array of property names. + */ + var shimKeys = function(object) { + var index, iterable = object, result = []; + if (!iterable) return result; + if (!(objectTypes[typeof object])) return result; + for (index in iterable) { + if (hasOwnProperty.call(iterable, index)) { + result.push(index); + } + } + return result + }; + + /** + * Creates an array composed of the own enumerable property names of an object. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to inspect. + * @returns {Array} Returns an array of property names. + * @example + * + * _.keys({ 'one': 1, 'two': 2, 'three': 3 }); + * // => ['one', 'two', 'three'] (property order is not guaranteed across environments) + */ + var keys = !nativeKeys ? shimKeys : function(object) { + if (!isObject(object)) { + return []; + } + return nativeKeys(object); + }; + + /** + * Used to convert characters to HTML entities: + * + * Though the `>` character is escaped for symmetry, characters like `>` and `/` + * don't require escaping in HTML and have no special meaning unless they're part + * of a tag or an unquoted attribute value. + * http://mathiasbynens.be/notes/ambiguous-ampersands (under "semi-related fun fact") + */ + var htmlEscapes = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + + /** Used to convert HTML entities to characters */ + var htmlUnescapes = invert(htmlEscapes); + + /** Used to match HTML entities and HTML characters */ + var reEscapedHtml = RegExp('(' + keys(htmlUnescapes).join('|') + ')', 'g'), + reUnescapedHtml = RegExp('[' + keys(htmlEscapes).join('') + ']', 'g'); + + /*--------------------------------------------------------------------------*/ + + /** + * Assigns own enumerable properties of source object(s) to the destination + * object. Subsequent sources will overwrite property assignments of previous + * sources. If a callback is provided it will be executed to produce the + * assigned values. The callback is bound to `thisArg` and invoked with two + * arguments; (objectValue, sourceValue). + * + * @static + * @memberOf _ + * @type Function + * @alias extend + * @category Objects + * @param {Object} object The destination object. + * @param {...Object} [source] The source objects. + * @param {Function} [callback] The function to customize assigning values. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns the destination object. + * @example + * + * _.assign({ 'name': 'fred' }, { 'employer': 'slate' }); + * // => { 'name': 'fred', 'employer': 'slate' } + * + * var defaults = _.partialRight(_.assign, function(a, b) { + * return typeof a == 'undefined' ? b : a; + * }); + * + * var object = { 'name': 'barney' }; + * defaults(object, { 'name': 'fred', 'employer': 'slate' }); + * // => { 'name': 'barney', 'employer': 'slate' } + */ + var assign = function(object, source, guard) { + var index, iterable = object, result = iterable; + if (!iterable) return result; + var args = arguments, + argsIndex = 0, + argsLength = typeof guard == 'number' ? 2 : args.length; + if (argsLength > 3 && typeof args[argsLength - 2] == 'function') { + var callback = baseCreateCallback(args[--argsLength - 1], args[argsLength--], 2); + } else if (argsLength > 2 && typeof args[argsLength - 1] == 'function') { + callback = args[--argsLength]; + } + while (++argsIndex < argsLength) { + iterable = args[argsIndex]; + if (iterable && objectTypes[typeof iterable]) { + var ownIndex = -1, + ownProps = objectTypes[typeof iterable] && keys(iterable), + length = ownProps ? ownProps.length : 0; + + while (++ownIndex < length) { + index = ownProps[ownIndex]; + result[index] = callback ? callback(result[index], iterable[index]) : iterable[index]; + } + } + } + return result + }; + + /** + * Creates a clone of `value`. If `isDeep` is `true` nested objects will also + * be cloned, otherwise they will be assigned by reference. If a callback + * is provided it will be executed to produce the cloned values. If the + * callback returns `undefined` cloning will be handled by the method instead. + * The callback is bound to `thisArg` and invoked with one argument; (value). + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to clone. + * @param {boolean} [isDeep=false] Specify a deep clone. + * @param {Function} [callback] The function to customize cloning values. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the cloned value. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * var shallow = _.clone(characters); + * shallow[0] === characters[0]; + * // => true + * + * var deep = _.clone(characters, true); + * deep[0] === characters[0]; + * // => false + * + * _.mixin({ + * 'clone': _.partialRight(_.clone, function(value) { + * return _.isElement(value) ? value.cloneNode(false) : undefined; + * }) + * }); + * + * var clone = _.clone(document.body); + * clone.childNodes.length; + * // => 0 + */ + function clone(value, isDeep, callback, thisArg) { + // allows working with "Collections" methods without using their `index` + // and `collection` arguments for `isDeep` and `callback` + if (typeof isDeep != 'boolean' && isDeep != null) { + thisArg = callback; + callback = isDeep; + isDeep = false; + } + return baseClone(value, isDeep, typeof callback == 'function' && baseCreateCallback(callback, thisArg, 1)); + } + + /** + * Creates a deep clone of `value`. If a callback is provided it will be + * executed to produce the cloned values. If the callback returns `undefined` + * cloning will be handled by the method instead. The callback is bound to + * `thisArg` and invoked with one argument; (value). + * + * Note: This method is loosely based on the structured clone algorithm. Functions + * and DOM nodes are **not** cloned. The enumerable properties of `arguments` objects and + * objects created by constructors other than `Object` are cloned to plain `Object` objects. + * See http://www.w3.org/TR/html5/infrastructure.html#internal-structured-cloning-algorithm. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to deep clone. + * @param {Function} [callback] The function to customize cloning values. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the deep cloned value. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * var deep = _.cloneDeep(characters); + * deep[0] === characters[0]; + * // => false + * + * var view = { + * 'label': 'docs', + * 'node': element + * }; + * + * var clone = _.cloneDeep(view, function(value) { + * return _.isElement(value) ? value.cloneNode(true) : undefined; + * }); + * + * clone.node == view.node; + * // => false + */ + function cloneDeep(value, callback, thisArg) { + return baseClone(value, true, typeof callback == 'function' && baseCreateCallback(callback, thisArg, 1)); + } + + /** + * Creates an object that inherits from the given `prototype` object. If a + * `properties` object is provided its own enumerable properties are assigned + * to the created object. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} prototype The object to inherit from. + * @param {Object} [properties] The properties to assign to the object. + * @returns {Object} Returns the new object. + * @example + * + * function Shape() { + * this.x = 0; + * this.y = 0; + * } + * + * function Circle() { + * Shape.call(this); + * } + * + * Circle.prototype = _.create(Shape.prototype, { 'constructor': Circle }); + * + * var circle = new Circle; + * circle instanceof Circle; + * // => true + * + * circle instanceof Shape; + * // => true + */ + function create(prototype, properties) { + var result = baseCreate(prototype); + return properties ? assign(result, properties) : result; + } + + /** + * Assigns own enumerable properties of source object(s) to the destination + * object for all destination properties that resolve to `undefined`. Once a + * property is set, additional defaults of the same property will be ignored. + * + * @static + * @memberOf _ + * @type Function + * @category Objects + * @param {Object} object The destination object. + * @param {...Object} [source] The source objects. + * @param- {Object} [guard] Allows working with `_.reduce` without using its + * `key` and `object` arguments as sources. + * @returns {Object} Returns the destination object. + * @example + * + * var object = { 'name': 'barney' }; + * _.defaults(object, { 'name': 'fred', 'employer': 'slate' }); + * // => { 'name': 'barney', 'employer': 'slate' } + */ + var defaults = function(object, source, guard) { + var index, iterable = object, result = iterable; + if (!iterable) return result; + var args = arguments, + argsIndex = 0, + argsLength = typeof guard == 'number' ? 2 : args.length; + while (++argsIndex < argsLength) { + iterable = args[argsIndex]; + if (iterable && objectTypes[typeof iterable]) { + var ownIndex = -1, + ownProps = objectTypes[typeof iterable] && keys(iterable), + length = ownProps ? ownProps.length : 0; + + while (++ownIndex < length) { + index = ownProps[ownIndex]; + if (typeof result[index] == 'undefined') result[index] = iterable[index]; + } + } + } + return result + }; + + /** + * This method is like `_.findIndex` except that it returns the key of the + * first element that passes the callback check, instead of the element itself. + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to search. + * @param {Function|Object|string} [callback=identity] The function called per + * iteration. If a property name or object is provided it will be used to + * create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {string|undefined} Returns the key of the found element, else `undefined`. + * @example + * + * var characters = { + * 'barney': { 'age': 36, 'blocked': false }, + * 'fred': { 'age': 40, 'blocked': true }, + * 'pebbles': { 'age': 1, 'blocked': false } + * }; + * + * _.findKey(characters, function(chr) { + * return chr.age < 40; + * }); + * // => 'barney' (property order is not guaranteed across environments) + * + * // using "_.where" callback shorthand + * _.findKey(characters, { 'age': 1 }); + * // => 'pebbles' + * + * // using "_.pluck" callback shorthand + * _.findKey(characters, 'blocked'); + * // => 'fred' + */ + function findKey(object, callback, thisArg) { + var result; + callback = lodash.createCallback(callback, thisArg, 3); + forOwn(object, function(value, key, object) { + if (callback(value, key, object)) { + result = key; + return false; + } + }); + return result; + } + + /** + * This method is like `_.findKey` except that it iterates over elements + * of a `collection` in the opposite order. + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to search. + * @param {Function|Object|string} [callback=identity] The function called per + * iteration. If a property name or object is provided it will be used to + * create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {string|undefined} Returns the key of the found element, else `undefined`. + * @example + * + * var characters = { + * 'barney': { 'age': 36, 'blocked': true }, + * 'fred': { 'age': 40, 'blocked': false }, + * 'pebbles': { 'age': 1, 'blocked': true } + * }; + * + * _.findLastKey(characters, function(chr) { + * return chr.age < 40; + * }); + * // => returns `pebbles`, assuming `_.findKey` returns `barney` + * + * // using "_.where" callback shorthand + * _.findLastKey(characters, { 'age': 40 }); + * // => 'fred' + * + * // using "_.pluck" callback shorthand + * _.findLastKey(characters, 'blocked'); + * // => 'pebbles' + */ + function findLastKey(object, callback, thisArg) { + var result; + callback = lodash.createCallback(callback, thisArg, 3); + forOwnRight(object, function(value, key, object) { + if (callback(value, key, object)) { + result = key; + return false; + } + }); + return result; + } + + /** + * Iterates over own and inherited enumerable properties of an object, + * executing the callback for each property. The callback is bound to `thisArg` + * and invoked with three arguments; (value, key, object). Callbacks may exit + * iteration early by explicitly returning `false`. + * + * @static + * @memberOf _ + * @type Function + * @category Objects + * @param {Object} object The object to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns `object`. + * @example + * + * function Shape() { + * this.x = 0; + * this.y = 0; + * } + * + * Shape.prototype.move = function(x, y) { + * this.x += x; + * this.y += y; + * }; + * + * _.forIn(new Shape, function(value, key) { + * console.log(key); + * }); + * // => logs 'x', 'y', and 'move' (property order is not guaranteed across environments) + */ + var forIn = function(collection, callback, thisArg) { + var index, iterable = collection, result = iterable; + if (!iterable) return result; + if (!objectTypes[typeof iterable]) return result; + callback = callback && typeof thisArg == 'undefined' ? callback : baseCreateCallback(callback, thisArg, 3); + for (index in iterable) { + if (callback(iterable[index], index, collection) === false) return result; + } + return result + }; + + /** + * This method is like `_.forIn` except that it iterates over elements + * of a `collection` in the opposite order. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns `object`. + * @example + * + * function Shape() { + * this.x = 0; + * this.y = 0; + * } + * + * Shape.prototype.move = function(x, y) { + * this.x += x; + * this.y += y; + * }; + * + * _.forInRight(new Shape, function(value, key) { + * console.log(key); + * }); + * // => logs 'move', 'y', and 'x' assuming `_.forIn ` logs 'x', 'y', and 'move' + */ + function forInRight(object, callback, thisArg) { + var pairs = []; + + forIn(object, function(value, key) { + pairs.push(key, value); + }); + + var length = pairs.length; + callback = baseCreateCallback(callback, thisArg, 3); + while (length--) { + if (callback(pairs[length--], pairs[length], object) === false) { + break; + } + } + return object; + } + + /** + * Iterates over own enumerable properties of an object, executing the callback + * for each property. The callback is bound to `thisArg` and invoked with three + * arguments; (value, key, object). Callbacks may exit iteration early by + * explicitly returning `false`. + * + * @static + * @memberOf _ + * @type Function + * @category Objects + * @param {Object} object The object to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns `object`. + * @example + * + * _.forOwn({ '0': 'zero', '1': 'one', 'length': 2 }, function(num, key) { + * console.log(key); + * }); + * // => logs '0', '1', and 'length' (property order is not guaranteed across environments) + */ + var forOwn = function(collection, callback, thisArg) { + var index, iterable = collection, result = iterable; + if (!iterable) return result; + if (!objectTypes[typeof iterable]) return result; + callback = callback && typeof thisArg == 'undefined' ? callback : baseCreateCallback(callback, thisArg, 3); + var ownIndex = -1, + ownProps = objectTypes[typeof iterable] && keys(iterable), + length = ownProps ? ownProps.length : 0; + + while (++ownIndex < length) { + index = ownProps[ownIndex]; + if (callback(iterable[index], index, collection) === false) return result; + } + return result + }; + + /** + * This method is like `_.forOwn` except that it iterates over elements + * of a `collection` in the opposite order. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns `object`. + * @example + * + * _.forOwnRight({ '0': 'zero', '1': 'one', 'length': 2 }, function(num, key) { + * console.log(key); + * }); + * // => logs 'length', '1', and '0' assuming `_.forOwn` logs '0', '1', and 'length' + */ + function forOwnRight(object, callback, thisArg) { + var props = keys(object), + length = props.length; + + callback = baseCreateCallback(callback, thisArg, 3); + while (length--) { + var key = props[length]; + if (callback(object[key], key, object) === false) { + break; + } + } + return object; + } + + /** + * Creates a sorted array of property names of all enumerable properties, + * own and inherited, of `object` that have function values. + * + * @static + * @memberOf _ + * @alias methods + * @category Objects + * @param {Object} object The object to inspect. + * @returns {Array} Returns an array of property names that have function values. + * @example + * + * _.functions(_); + * // => ['all', 'any', 'bind', 'bindAll', 'clone', 'compact', 'compose', ...] + */ + function functions(object) { + var result = []; + forIn(object, function(value, key) { + if (isFunction(value)) { + result.push(key); + } + }); + return result.sort(); + } + + /** + * Checks if the specified property name exists as a direct property of `object`, + * instead of an inherited property. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to inspect. + * @param {string} key The name of the property to check. + * @returns {boolean} Returns `true` if key is a direct property, else `false`. + * @example + * + * _.has({ 'a': 1, 'b': 2, 'c': 3 }, 'b'); + * // => true + */ + function has(object, key) { + return object ? hasOwnProperty.call(object, key) : false; + } + + /** + * Creates an object composed of the inverted keys and values of the given object. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to invert. + * @returns {Object} Returns the created inverted object. + * @example + * + * _.invert({ 'first': 'fred', 'second': 'barney' }); + * // => { 'fred': 'first', 'barney': 'second' } + */ + function invert(object) { + var index = -1, + props = keys(object), + length = props.length, + result = {}; + + while (++index < length) { + var key = props[index]; + result[object[key]] = key; + } + return result; + } + + /** + * Checks if `value` is a boolean value. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a boolean value, else `false`. + * @example + * + * _.isBoolean(null); + * // => false + */ + function isBoolean(value) { + return value === true || value === false || + value && typeof value == 'object' && toString.call(value) == boolClass || false; + } + + /** + * Checks if `value` is a date. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a date, else `false`. + * @example + * + * _.isDate(new Date); + * // => true + */ + function isDate(value) { + return value && typeof value == 'object' && toString.call(value) == dateClass || false; + } + + /** + * Checks if `value` is a DOM element. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a DOM element, else `false`. + * @example + * + * _.isElement(document.body); + * // => true + */ + function isElement(value) { + return value && value.nodeType === 1 || false; + } + + /** + * Checks if `value` is empty. Arrays, strings, or `arguments` objects with a + * length of `0` and objects with no own enumerable properties are considered + * "empty". + * + * @static + * @memberOf _ + * @category Objects + * @param {Array|Object|string} value The value to inspect. + * @returns {boolean} Returns `true` if the `value` is empty, else `false`. + * @example + * + * _.isEmpty([1, 2, 3]); + * // => false + * + * _.isEmpty({}); + * // => true + * + * _.isEmpty(''); + * // => true + */ + function isEmpty(value) { + var result = true; + if (!value) { + return result; + } + var className = toString.call(value), + length = value.length; + + if ((className == arrayClass || className == stringClass || className == argsClass ) || + (className == objectClass && typeof length == 'number' && isFunction(value.splice))) { + return !length; + } + forOwn(value, function() { + return (result = false); + }); + return result; + } + + /** + * Performs a deep comparison between two values to determine if they are + * equivalent to each other. If a callback is provided it will be executed + * to compare values. If the callback returns `undefined` comparisons will + * be handled by the method instead. The callback is bound to `thisArg` and + * invoked with two arguments; (a, b). + * + * @static + * @memberOf _ + * @category Objects + * @param {*} a The value to compare. + * @param {*} b The other value to compare. + * @param {Function} [callback] The function to customize comparing values. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {boolean} Returns `true` if the values are equivalent, else `false`. + * @example + * + * var object = { 'name': 'fred' }; + * var copy = { 'name': 'fred' }; + * + * object == copy; + * // => false + * + * _.isEqual(object, copy); + * // => true + * + * var words = ['hello', 'goodbye']; + * var otherWords = ['hi', 'goodbye']; + * + * _.isEqual(words, otherWords, function(a, b) { + * var reGreet = /^(?:hello|hi)$/i, + * aGreet = _.isString(a) && reGreet.test(a), + * bGreet = _.isString(b) && reGreet.test(b); + * + * return (aGreet || bGreet) ? (aGreet == bGreet) : undefined; + * }); + * // => true + */ + function isEqual(a, b, callback, thisArg) { + return baseIsEqual(a, b, typeof callback == 'function' && baseCreateCallback(callback, thisArg, 2)); + } + + /** + * Checks if `value` is, or can be coerced to, a finite number. + * + * Note: This is not the same as native `isFinite` which will return true for + * booleans and empty strings. See http://es5.github.io/#x15.1.2.5. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is finite, else `false`. + * @example + * + * _.isFinite(-101); + * // => true + * + * _.isFinite('10'); + * // => true + * + * _.isFinite(true); + * // => false + * + * _.isFinite(''); + * // => false + * + * _.isFinite(Infinity); + * // => false + */ + function isFinite(value) { + return nativeIsFinite(value) && !nativeIsNaN(parseFloat(value)); + } + + /** + * Checks if `value` is a function. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a function, else `false`. + * @example + * + * _.isFunction(_); + * // => true + */ + function isFunction(value) { + return typeof value == 'function'; + } + + /** + * Checks if `value` is the language type of Object. + * (e.g. arrays, functions, objects, regexes, `new Number(0)`, and `new String('')`) + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is an object, else `false`. + * @example + * + * _.isObject({}); + * // => true + * + * _.isObject([1, 2, 3]); + * // => true + * + * _.isObject(1); + * // => false + */ + function isObject(value) { + // check if the value is the ECMAScript language type of Object + // http://es5.github.io/#x8 + // and avoid a V8 bug + // http://code.google.com/p/v8/issues/detail?id=2291 + return !!(value && objectTypes[typeof value]); + } + + /** + * Checks if `value` is `NaN`. + * + * Note: This is not the same as native `isNaN` which will return `true` for + * `undefined` and other non-numeric values. See http://es5.github.io/#x15.1.2.4. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is `NaN`, else `false`. + * @example + * + * _.isNaN(NaN); + * // => true + * + * _.isNaN(new Number(NaN)); + * // => true + * + * isNaN(undefined); + * // => true + * + * _.isNaN(undefined); + * // => false + */ + function isNaN(value) { + // `NaN` as a primitive is the only value that is not equal to itself + // (perform the [[Class]] check first to avoid errors with some host objects in IE) + return isNumber(value) && value != +value; + } + + /** + * Checks if `value` is `null`. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is `null`, else `false`. + * @example + * + * _.isNull(null); + * // => true + * + * _.isNull(undefined); + * // => false + */ + function isNull(value) { + return value === null; + } + + /** + * Checks if `value` is a number. + * + * Note: `NaN` is considered a number. See http://es5.github.io/#x8.5. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a number, else `false`. + * @example + * + * _.isNumber(8.4 * 5); + * // => true + */ + function isNumber(value) { + return typeof value == 'number' || + value && typeof value == 'object' && toString.call(value) == numberClass || false; + } + + /** + * Checks if `value` is an object created by the `Object` constructor. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if `value` is a plain object, else `false`. + * @example + * + * function Shape() { + * this.x = 0; + * this.y = 0; + * } + * + * _.isPlainObject(new Shape); + * // => false + * + * _.isPlainObject([1, 2, 3]); + * // => false + * + * _.isPlainObject({ 'x': 0, 'y': 0 }); + * // => true + */ + var isPlainObject = !getPrototypeOf ? shimIsPlainObject : function(value) { + if (!(value && toString.call(value) == objectClass)) { + return false; + } + var valueOf = value.valueOf, + objProto = isNative(valueOf) && (objProto = getPrototypeOf(valueOf)) && getPrototypeOf(objProto); + + return objProto + ? (value == objProto || getPrototypeOf(value) == objProto) + : shimIsPlainObject(value); + }; + + /** + * Checks if `value` is a regular expression. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a regular expression, else `false`. + * @example + * + * _.isRegExp(/fred/); + * // => true + */ + function isRegExp(value) { + return value && typeof value == 'object' && toString.call(value) == regexpClass || false; + } + + /** + * Checks if `value` is a string. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is a string, else `false`. + * @example + * + * _.isString('fred'); + * // => true + */ + function isString(value) { + return typeof value == 'string' || + value && typeof value == 'object' && toString.call(value) == stringClass || false; + } + + /** + * Checks if `value` is `undefined`. + * + * @static + * @memberOf _ + * @category Objects + * @param {*} value The value to check. + * @returns {boolean} Returns `true` if the `value` is `undefined`, else `false`. + * @example + * + * _.isUndefined(void 0); + * // => true + */ + function isUndefined(value) { + return typeof value == 'undefined'; + } + + /** + * Creates an object with the same keys as `object` and values generated by + * running each own enumerable property of `object` through the callback. + * The callback is bound to `thisArg` and invoked with three arguments; + * (value, key, object). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a new object with values of the results of each `callback` execution. + * @example + * + * _.mapValues({ 'a': 1, 'b': 2, 'c': 3} , function(num) { return num * 3; }); + * // => { 'a': 3, 'b': 6, 'c': 9 } + * + * var characters = { + * 'fred': { 'name': 'fred', 'age': 40 }, + * 'pebbles': { 'name': 'pebbles', 'age': 1 } + * }; + * + * // using "_.pluck" callback shorthand + * _.mapValues(characters, 'age'); + * // => { 'fred': 40, 'pebbles': 1 } + */ + function mapValues(object, callback, thisArg) { + var result = {}; + callback = lodash.createCallback(callback, thisArg, 3); + + forOwn(object, function(value, key, object) { + result[key] = callback(value, key, object); + }); + return result; + } + + /** + * Recursively merges own enumerable properties of the source object(s), that + * don't resolve to `undefined` into the destination object. Subsequent sources + * will overwrite property assignments of previous sources. If a callback is + * provided it will be executed to produce the merged values of the destination + * and source properties. If the callback returns `undefined` merging will + * be handled by the method instead. The callback is bound to `thisArg` and + * invoked with two arguments; (objectValue, sourceValue). + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The destination object. + * @param {...Object} [source] The source objects. + * @param {Function} [callback] The function to customize merging properties. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns the destination object. + * @example + * + * var names = { + * 'characters': [ + * { 'name': 'barney' }, + * { 'name': 'fred' } + * ] + * }; + * + * var ages = { + * 'characters': [ + * { 'age': 36 }, + * { 'age': 40 } + * ] + * }; + * + * _.merge(names, ages); + * // => { 'characters': [{ 'name': 'barney', 'age': 36 }, { 'name': 'fred', 'age': 40 }] } + * + * var food = { + * 'fruits': ['apple'], + * 'vegetables': ['beet'] + * }; + * + * var otherFood = { + * 'fruits': ['banana'], + * 'vegetables': ['carrot'] + * }; + * + * _.merge(food, otherFood, function(a, b) { + * return _.isArray(a) ? a.concat(b) : undefined; + * }); + * // => { 'fruits': ['apple', 'banana'], 'vegetables': ['beet', 'carrot] } + */ + function merge(object) { + var args = arguments, + length = 2; + + if (!isObject(object)) { + return object; + } + // allows working with `_.reduce` and `_.reduceRight` without using + // their `index` and `collection` arguments + if (typeof args[2] != 'number') { + length = args.length; + } + if (length > 3 && typeof args[length - 2] == 'function') { + var callback = baseCreateCallback(args[--length - 1], args[length--], 2); + } else if (length > 2 && typeof args[length - 1] == 'function') { + callback = args[--length]; + } + var sources = slice(arguments, 1, length), + index = -1, + stackA = getArray(), + stackB = getArray(); + + while (++index < length) { + baseMerge(object, sources[index], callback, stackA, stackB); + } + releaseArray(stackA); + releaseArray(stackB); + return object; + } + + /** + * Creates a shallow clone of `object` excluding the specified properties. + * Property names may be specified as individual arguments or as arrays of + * property names. If a callback is provided it will be executed for each + * property of `object` omitting the properties the callback returns truey + * for. The callback is bound to `thisArg` and invoked with three arguments; + * (value, key, object). + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The source object. + * @param {Function|...string|string[]} [callback] The properties to omit or the + * function called per iteration. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns an object without the omitted properties. + * @example + * + * _.omit({ 'name': 'fred', 'age': 40 }, 'age'); + * // => { 'name': 'fred' } + * + * _.omit({ 'name': 'fred', 'age': 40 }, function(value) { + * return typeof value == 'number'; + * }); + * // => { 'name': 'fred' } + */ + function omit(object, callback, thisArg) { + var result = {}; + if (typeof callback != 'function') { + var props = []; + forIn(object, function(value, key) { + props.push(key); + }); + props = baseDifference(props, baseFlatten(arguments, true, false, 1)); + + var index = -1, + length = props.length; + + while (++index < length) { + var key = props[index]; + result[key] = object[key]; + } + } else { + callback = lodash.createCallback(callback, thisArg, 3); + forIn(object, function(value, key, object) { + if (!callback(value, key, object)) { + result[key] = value; + } + }); + } + return result; + } + + /** + * Creates a two dimensional array of an object's key-value pairs, + * i.e. `[[key1, value1], [key2, value2]]`. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to inspect. + * @returns {Array} Returns new array of key-value pairs. + * @example + * + * _.pairs({ 'barney': 36, 'fred': 40 }); + * // => [['barney', 36], ['fred', 40]] (property order is not guaranteed across environments) + */ + function pairs(object) { + var index = -1, + props = keys(object), + length = props.length, + result = Array(length); + + while (++index < length) { + var key = props[index]; + result[index] = [key, object[key]]; + } + return result; + } + + /** + * Creates a shallow clone of `object` composed of the specified properties. + * Property names may be specified as individual arguments or as arrays of + * property names. If a callback is provided it will be executed for each + * property of `object` picking the properties the callback returns truey + * for. The callback is bound to `thisArg` and invoked with three arguments; + * (value, key, object). + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The source object. + * @param {Function|...string|string[]} [callback] The function called per + * iteration or property names to pick, specified as individual property + * names or arrays of property names. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns an object composed of the picked properties. + * @example + * + * _.pick({ 'name': 'fred', '_userid': 'fred1' }, 'name'); + * // => { 'name': 'fred' } + * + * _.pick({ 'name': 'fred', '_userid': 'fred1' }, function(value, key) { + * return key.charAt(0) != '_'; + * }); + * // => { 'name': 'fred' } + */ + function pick(object, callback, thisArg) { + var result = {}; + if (typeof callback != 'function') { + var index = -1, + props = baseFlatten(arguments, true, false, 1), + length = isObject(object) ? props.length : 0; + + while (++index < length) { + var key = props[index]; + if (key in object) { + result[key] = object[key]; + } + } + } else { + callback = lodash.createCallback(callback, thisArg, 3); + forIn(object, function(value, key, object) { + if (callback(value, key, object)) { + result[key] = value; + } + }); + } + return result; + } + + /** + * An alternative to `_.reduce` this method transforms `object` to a new + * `accumulator` object which is the result of running each of its own + * enumerable properties through a callback, with each callback execution + * potentially mutating the `accumulator` object. The callback is bound to + * `thisArg` and invoked with four arguments; (accumulator, value, key, object). + * Callbacks may exit iteration early by explicitly returning `false`. + * + * @static + * @memberOf _ + * @category Objects + * @param {Array|Object} object The object to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [accumulator] The custom accumulator value. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the accumulated value. + * @example + * + * var squares = _.transform([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], function(result, num) { + * num *= num; + * if (num % 2) { + * return result.push(num) < 3; + * } + * }); + * // => [1, 9, 25] + * + * var mapped = _.transform({ 'a': 1, 'b': 2, 'c': 3 }, function(result, num, key) { + * result[key] = num * 3; + * }); + * // => { 'a': 3, 'b': 6, 'c': 9 } + */ + function transform(object, callback, accumulator, thisArg) { + var isArr = isArray(object); + if (accumulator == null) { + if (isArr) { + accumulator = []; + } else { + var ctor = object && object.constructor, + proto = ctor && ctor.prototype; + + accumulator = baseCreate(proto); + } + } + if (callback) { + callback = lodash.createCallback(callback, thisArg, 4); + (isArr ? forEach : forOwn)(object, function(value, index, object) { + return callback(accumulator, value, index, object); + }); + } + return accumulator; + } + + /** + * Creates an array composed of the own enumerable property values of `object`. + * + * @static + * @memberOf _ + * @category Objects + * @param {Object} object The object to inspect. + * @returns {Array} Returns an array of property values. + * @example + * + * _.values({ 'one': 1, 'two': 2, 'three': 3 }); + * // => [1, 2, 3] (property order is not guaranteed across environments) + */ + function values(object) { + var index = -1, + props = keys(object), + length = props.length, + result = Array(length); + + while (++index < length) { + result[index] = object[props[index]]; + } + return result; + } + + /*--------------------------------------------------------------------------*/ + + /** + * Creates an array of elements from the specified indexes, or keys, of the + * `collection`. Indexes may be specified as individual arguments or as arrays + * of indexes. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {...(number|number[]|string|string[])} [index] The indexes of `collection` + * to retrieve, specified as individual indexes or arrays of indexes. + * @returns {Array} Returns a new array of elements corresponding to the + * provided indexes. + * @example + * + * _.at(['a', 'b', 'c', 'd', 'e'], [0, 2, 4]); + * // => ['a', 'c', 'e'] + * + * _.at(['fred', 'barney', 'pebbles'], 0, 2); + * // => ['fred', 'pebbles'] + */ + function at(collection) { + var args = arguments, + index = -1, + props = baseFlatten(args, true, false, 1), + length = (args[2] && args[2][args[1]] === collection) ? 1 : props.length, + result = Array(length); + + while(++index < length) { + result[index] = collection[props[index]]; + } + return result; + } + + /** + * Checks if a given value is present in a collection using strict equality + * for comparisons, i.e. `===`. If `fromIndex` is negative, it is used as the + * offset from the end of the collection. + * + * @static + * @memberOf _ + * @alias include + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {*} target The value to check for. + * @param {number} [fromIndex=0] The index to search from. + * @returns {boolean} Returns `true` if the `target` element is found, else `false`. + * @example + * + * _.contains([1, 2, 3], 1); + * // => true + * + * _.contains([1, 2, 3], 1, 2); + * // => false + * + * _.contains({ 'name': 'fred', 'age': 40 }, 'fred'); + * // => true + * + * _.contains('pebbles', 'eb'); + * // => true + */ + function contains(collection, target, fromIndex) { + var index = -1, + indexOf = getIndexOf(), + length = collection ? collection.length : 0, + result = false; + + fromIndex = (fromIndex < 0 ? nativeMax(0, length + fromIndex) : fromIndex) || 0; + if (isArray(collection)) { + result = indexOf(collection, target, fromIndex) > -1; + } else if (typeof length == 'number') { + result = (isString(collection) ? collection.indexOf(target, fromIndex) : indexOf(collection, target, fromIndex)) > -1; + } else { + forOwn(collection, function(value) { + if (++index >= fromIndex) { + return !(result = value === target); + } + }); + } + return result; + } + + /** + * Creates an object composed of keys generated from the results of running + * each element of `collection` through the callback. The corresponding value + * of each key is the number of times the key was returned by the callback. + * The callback is bound to `thisArg` and invoked with three arguments; + * (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns the composed aggregate object. + * @example + * + * _.countBy([4.3, 6.1, 6.4], function(num) { return Math.floor(num); }); + * // => { '4': 1, '6': 2 } + * + * _.countBy([4.3, 6.1, 6.4], function(num) { return this.floor(num); }, Math); + * // => { '4': 1, '6': 2 } + * + * _.countBy(['one', 'two', 'three'], 'length'); + * // => { '3': 2, '5': 1 } + */ + var countBy = createAggregator(function(result, value, key) { + (hasOwnProperty.call(result, key) ? result[key]++ : result[key] = 1); + }); + + /** + * Checks if the given callback returns truey value for **all** elements of + * a collection. The callback is bound to `thisArg` and invoked with three + * arguments; (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias all + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {boolean} Returns `true` if all elements passed the callback check, + * else `false`. + * @example + * + * _.every([true, 1, null, 'yes']); + * // => false + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * // using "_.pluck" callback shorthand + * _.every(characters, 'age'); + * // => true + * + * // using "_.where" callback shorthand + * _.every(characters, { 'age': 36 }); + * // => false + */ + function every(collection, callback, thisArg) { + var result = true; + callback = lodash.createCallback(callback, thisArg, 3); + + var index = -1, + length = collection ? collection.length : 0; + + if (typeof length == 'number') { + while (++index < length) { + if (!(result = !!callback(collection[index], index, collection))) { + break; + } + } + } else { + forOwn(collection, function(value, index, collection) { + return (result = !!callback(value, index, collection)); + }); + } + return result; + } + + /** + * Iterates over elements of a collection, returning an array of all elements + * the callback returns truey for. The callback is bound to `thisArg` and + * invoked with three arguments; (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias select + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a new array of elements that passed the callback check. + * @example + * + * var evens = _.filter([1, 2, 3, 4, 5, 6], function(num) { return num % 2 == 0; }); + * // => [2, 4, 6] + * + * var characters = [ + * { 'name': 'barney', 'age': 36, 'blocked': false }, + * { 'name': 'fred', 'age': 40, 'blocked': true } + * ]; + * + * // using "_.pluck" callback shorthand + * _.filter(characters, 'blocked'); + * // => [{ 'name': 'fred', 'age': 40, 'blocked': true }] + * + * // using "_.where" callback shorthand + * _.filter(characters, { 'age': 36 }); + * // => [{ 'name': 'barney', 'age': 36, 'blocked': false }] + */ + function filter(collection, callback, thisArg) { + var result = []; + callback = lodash.createCallback(callback, thisArg, 3); + + var index = -1, + length = collection ? collection.length : 0; + + if (typeof length == 'number') { + while (++index < length) { + var value = collection[index]; + if (callback(value, index, collection)) { + result.push(value); + } + } + } else { + forOwn(collection, function(value, index, collection) { + if (callback(value, index, collection)) { + result.push(value); + } + }); + } + return result; + } + + /** + * Iterates over elements of a collection, returning the first element that + * the callback returns truey for. The callback is bound to `thisArg` and + * invoked with three arguments; (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias detect, findWhere + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the found element, else `undefined`. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36, 'blocked': false }, + * { 'name': 'fred', 'age': 40, 'blocked': true }, + * { 'name': 'pebbles', 'age': 1, 'blocked': false } + * ]; + * + * _.find(characters, function(chr) { + * return chr.age < 40; + * }); + * // => { 'name': 'barney', 'age': 36, 'blocked': false } + * + * // using "_.where" callback shorthand + * _.find(characters, { 'age': 1 }); + * // => { 'name': 'pebbles', 'age': 1, 'blocked': false } + * + * // using "_.pluck" callback shorthand + * _.find(characters, 'blocked'); + * // => { 'name': 'fred', 'age': 40, 'blocked': true } + */ + function find(collection, callback, thisArg) { + callback = lodash.createCallback(callback, thisArg, 3); + + var index = -1, + length = collection ? collection.length : 0; + + if (typeof length == 'number') { + while (++index < length) { + var value = collection[index]; + if (callback(value, index, collection)) { + return value; + } + } + } else { + var result; + forOwn(collection, function(value, index, collection) { + if (callback(value, index, collection)) { + result = value; + return false; + } + }); + return result; + } + } + + /** + * This method is like `_.find` except that it iterates over elements + * of a `collection` from right to left. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the found element, else `undefined`. + * @example + * + * _.findLast([1, 2, 3, 4], function(num) { + * return num % 2 == 1; + * }); + * // => 3 + */ + function findLast(collection, callback, thisArg) { + var result; + callback = lodash.createCallback(callback, thisArg, 3); + forEachRight(collection, function(value, index, collection) { + if (callback(value, index, collection)) { + result = value; + return false; + } + }); + return result; + } + + /** + * Iterates over elements of a collection, executing the callback for each + * element. The callback is bound to `thisArg` and invoked with three arguments; + * (value, index|key, collection). Callbacks may exit iteration early by + * explicitly returning `false`. + * + * Note: As with other "Collections" methods, objects with a `length` property + * are iterated like arrays. To avoid this behavior `_.forIn` or `_.forOwn` + * may be used for object iteration. + * + * @static + * @memberOf _ + * @alias each + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array|Object|string} Returns `collection`. + * @example + * + * _([1, 2, 3]).forEach(function(num) { console.log(num); }).join(','); + * // => logs each number and returns '1,2,3' + * + * _.forEach({ 'one': 1, 'two': 2, 'three': 3 }, function(num) { console.log(num); }); + * // => logs each number and returns the object (property order is not guaranteed across environments) + */ + function forEach(collection, callback, thisArg) { + var index = -1, + length = collection ? collection.length : 0; + + callback = callback && typeof thisArg == 'undefined' ? callback : baseCreateCallback(callback, thisArg, 3); + if (typeof length == 'number') { + while (++index < length) { + if (callback(collection[index], index, collection) === false) { + break; + } + } + } else { + forOwn(collection, callback); + } + return collection; + } + + /** + * This method is like `_.forEach` except that it iterates over elements + * of a `collection` from right to left. + * + * @static + * @memberOf _ + * @alias eachRight + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array|Object|string} Returns `collection`. + * @example + * + * _([1, 2, 3]).forEachRight(function(num) { console.log(num); }).join(','); + * // => logs each number from right to left and returns '3,2,1' + */ + function forEachRight(collection, callback, thisArg) { + var length = collection ? collection.length : 0; + callback = callback && typeof thisArg == 'undefined' ? callback : baseCreateCallback(callback, thisArg, 3); + if (typeof length == 'number') { + while (length--) { + if (callback(collection[length], length, collection) === false) { + break; + } + } + } else { + var props = keys(collection); + length = props.length; + forOwn(collection, function(value, key, collection) { + key = props ? props[--length] : --length; + return callback(collection[key], key, collection); + }); + } + return collection; + } + + /** + * Creates an object composed of keys generated from the results of running + * each element of a collection through the callback. The corresponding value + * of each key is an array of the elements responsible for generating the key. + * The callback is bound to `thisArg` and invoked with three arguments; + * (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false` + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns the composed aggregate object. + * @example + * + * _.groupBy([4.2, 6.1, 6.4], function(num) { return Math.floor(num); }); + * // => { '4': [4.2], '6': [6.1, 6.4] } + * + * _.groupBy([4.2, 6.1, 6.4], function(num) { return this.floor(num); }, Math); + * // => { '4': [4.2], '6': [6.1, 6.4] } + * + * // using "_.pluck" callback shorthand + * _.groupBy(['one', 'two', 'three'], 'length'); + * // => { '3': ['one', 'two'], '5': ['three'] } + */ + var groupBy = createAggregator(function(result, value, key) { + (hasOwnProperty.call(result, key) ? result[key] : result[key] = []).push(value); + }); + + /** + * Creates an object composed of keys generated from the results of running + * each element of the collection through the given callback. The corresponding + * value of each key is the last element responsible for generating the key. + * The callback is bound to `thisArg` and invoked with three arguments; + * (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Object} Returns the composed aggregate object. + * @example + * + * var keys = [ + * { 'dir': 'left', 'code': 97 }, + * { 'dir': 'right', 'code': 100 } + * ]; + * + * _.indexBy(keys, 'dir'); + * // => { 'left': { 'dir': 'left', 'code': 97 }, 'right': { 'dir': 'right', 'code': 100 } } + * + * _.indexBy(keys, function(key) { return String.fromCharCode(key.code); }); + * // => { 'a': { 'dir': 'left', 'code': 97 }, 'd': { 'dir': 'right', 'code': 100 } } + * + * _.indexBy(characters, function(key) { this.fromCharCode(key.code); }, String); + * // => { 'a': { 'dir': 'left', 'code': 97 }, 'd': { 'dir': 'right', 'code': 100 } } + */ + var indexBy = createAggregator(function(result, value, key) { + result[key] = value; + }); + + /** + * Invokes the method named by `methodName` on each element in the `collection` + * returning an array of the results of each invoked method. Additional arguments + * will be provided to each invoked method. If `methodName` is a function it + * will be invoked for, and `this` bound to, each element in the `collection`. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|string} methodName The name of the method to invoke or + * the function invoked per iteration. + * @param {...*} [arg] Arguments to invoke the method with. + * @returns {Array} Returns a new array of the results of each invoked method. + * @example + * + * _.invoke([[5, 1, 7], [3, 2, 1]], 'sort'); + * // => [[1, 5, 7], [1, 2, 3]] + * + * _.invoke([123, 456], String.prototype.split, ''); + * // => [['1', '2', '3'], ['4', '5', '6']] + */ + function invoke(collection, methodName) { + var args = slice(arguments, 2), + index = -1, + isFunc = typeof methodName == 'function', + length = collection ? collection.length : 0, + result = Array(typeof length == 'number' ? length : 0); + + forEach(collection, function(value) { + result[++index] = (isFunc ? methodName : value[methodName]).apply(value, args); + }); + return result; + } + + /** + * Creates an array of values by running each element in the collection + * through the callback. The callback is bound to `thisArg` and invoked with + * three arguments; (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias collect + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a new array of the results of each `callback` execution. + * @example + * + * _.map([1, 2, 3], function(num) { return num * 3; }); + * // => [3, 6, 9] + * + * _.map({ 'one': 1, 'two': 2, 'three': 3 }, function(num) { return num * 3; }); + * // => [3, 6, 9] (property order is not guaranteed across environments) + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * // using "_.pluck" callback shorthand + * _.map(characters, 'name'); + * // => ['barney', 'fred'] + */ + function map(collection, callback, thisArg) { + var index = -1, + length = collection ? collection.length : 0; + + callback = lodash.createCallback(callback, thisArg, 3); + if (typeof length == 'number') { + var result = Array(length); + while (++index < length) { + result[index] = callback(collection[index], index, collection); + } + } else { + result = []; + forOwn(collection, function(value, key, collection) { + result[++index] = callback(value, key, collection); + }); + } + return result; + } + + /** + * Retrieves the maximum value of a collection. If the collection is empty or + * falsey `-Infinity` is returned. If a callback is provided it will be executed + * for each value in the collection to generate the criterion by which the value + * is ranked. The callback is bound to `thisArg` and invoked with three + * arguments; (value, index, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the maximum value. + * @example + * + * _.max([4, 2, 8, 6]); + * // => 8 + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * _.max(characters, function(chr) { return chr.age; }); + * // => { 'name': 'fred', 'age': 40 }; + * + * // using "_.pluck" callback shorthand + * _.max(characters, 'age'); + * // => { 'name': 'fred', 'age': 40 }; + */ + function max(collection, callback, thisArg) { + var computed = -Infinity, + result = computed; + + // allows working with functions like `_.map` without using + // their `index` argument as a callback + if (typeof callback != 'function' && thisArg && thisArg[callback] === collection) { + callback = null; + } + if (callback == null && isArray(collection)) { + var index = -1, + length = collection.length; + + while (++index < length) { + var value = collection[index]; + if (value > result) { + result = value; + } + } + } else { + callback = (callback == null && isString(collection)) + ? charAtCallback + : lodash.createCallback(callback, thisArg, 3); + + forEach(collection, function(value, index, collection) { + var current = callback(value, index, collection); + if (current > computed) { + computed = current; + result = value; + } + }); + } + return result; + } + + /** + * Retrieves the minimum value of a collection. If the collection is empty or + * falsey `Infinity` is returned. If a callback is provided it will be executed + * for each value in the collection to generate the criterion by which the value + * is ranked. The callback is bound to `thisArg` and invoked with three + * arguments; (value, index, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the minimum value. + * @example + * + * _.min([4, 2, 8, 6]); + * // => 2 + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * _.min(characters, function(chr) { return chr.age; }); + * // => { 'name': 'barney', 'age': 36 }; + * + * // using "_.pluck" callback shorthand + * _.min(characters, 'age'); + * // => { 'name': 'barney', 'age': 36 }; + */ + function min(collection, callback, thisArg) { + var computed = Infinity, + result = computed; + + // allows working with functions like `_.map` without using + // their `index` argument as a callback + if (typeof callback != 'function' && thisArg && thisArg[callback] === collection) { + callback = null; + } + if (callback == null && isArray(collection)) { + var index = -1, + length = collection.length; + + while (++index < length) { + var value = collection[index]; + if (value < result) { + result = value; + } + } + } else { + callback = (callback == null && isString(collection)) + ? charAtCallback + : lodash.createCallback(callback, thisArg, 3); + + forEach(collection, function(value, index, collection) { + var current = callback(value, index, collection); + if (current < computed) { + computed = current; + result = value; + } + }); + } + return result; + } + + /** + * Retrieves the value of a specified property from all elements in the collection. + * + * @static + * @memberOf _ + * @type Function + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {string} property The name of the property to pluck. + * @returns {Array} Returns a new array of property values. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * _.pluck(characters, 'name'); + * // => ['barney', 'fred'] + */ + var pluck = map; + + /** + * Reduces a collection to a value which is the accumulated result of running + * each element in the collection through the callback, where each successive + * callback execution consumes the return value of the previous execution. If + * `accumulator` is not provided the first element of the collection will be + * used as the initial `accumulator` value. The callback is bound to `thisArg` + * and invoked with four arguments; (accumulator, value, index|key, collection). + * + * @static + * @memberOf _ + * @alias foldl, inject + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [accumulator] Initial value of the accumulator. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the accumulated value. + * @example + * + * var sum = _.reduce([1, 2, 3], function(sum, num) { + * return sum + num; + * }); + * // => 6 + * + * var mapped = _.reduce({ 'a': 1, 'b': 2, 'c': 3 }, function(result, num, key) { + * result[key] = num * 3; + * return result; + * }, {}); + * // => { 'a': 3, 'b': 6, 'c': 9 } + */ + function reduce(collection, callback, accumulator, thisArg) { + if (!collection) return accumulator; + var noaccum = arguments.length < 3; + callback = lodash.createCallback(callback, thisArg, 4); + + var index = -1, + length = collection.length; + + if (typeof length == 'number') { + if (noaccum) { + accumulator = collection[++index]; + } + while (++index < length) { + accumulator = callback(accumulator, collection[index], index, collection); + } + } else { + forOwn(collection, function(value, index, collection) { + accumulator = noaccum + ? (noaccum = false, value) + : callback(accumulator, value, index, collection) + }); + } + return accumulator; + } + + /** + * This method is like `_.reduce` except that it iterates over elements + * of a `collection` from right to left. + * + * @static + * @memberOf _ + * @alias foldr + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function} [callback=identity] The function called per iteration. + * @param {*} [accumulator] Initial value of the accumulator. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the accumulated value. + * @example + * + * var list = [[0, 1], [2, 3], [4, 5]]; + * var flat = _.reduceRight(list, function(a, b) { return a.concat(b); }, []); + * // => [4, 5, 2, 3, 0, 1] + */ + function reduceRight(collection, callback, accumulator, thisArg) { + var noaccum = arguments.length < 3; + callback = lodash.createCallback(callback, thisArg, 4); + forEachRight(collection, function(value, index, collection) { + accumulator = noaccum + ? (noaccum = false, value) + : callback(accumulator, value, index, collection); + }); + return accumulator; + } + + /** + * The opposite of `_.filter` this method returns the elements of a + * collection that the callback does **not** return truey for. + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a new array of elements that failed the callback check. + * @example + * + * var odds = _.reject([1, 2, 3, 4, 5, 6], function(num) { return num % 2 == 0; }); + * // => [1, 3, 5] + * + * var characters = [ + * { 'name': 'barney', 'age': 36, 'blocked': false }, + * { 'name': 'fred', 'age': 40, 'blocked': true } + * ]; + * + * // using "_.pluck" callback shorthand + * _.reject(characters, 'blocked'); + * // => [{ 'name': 'barney', 'age': 36, 'blocked': false }] + * + * // using "_.where" callback shorthand + * _.reject(characters, { 'age': 36 }); + * // => [{ 'name': 'fred', 'age': 40, 'blocked': true }] + */ + function reject(collection, callback, thisArg) { + callback = lodash.createCallback(callback, thisArg, 3); + return filter(collection, function(value, index, collection) { + return !callback(value, index, collection); + }); + } + + /** + * Retrieves a random element or `n` random elements from a collection. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to sample. + * @param {number} [n] The number of elements to sample. + * @param- {Object} [guard] Allows working with functions like `_.map` + * without using their `index` arguments as `n`. + * @returns {Array} Returns the random sample(s) of `collection`. + * @example + * + * _.sample([1, 2, 3, 4]); + * // => 2 + * + * _.sample([1, 2, 3, 4], 2); + * // => [3, 1] + */ + function sample(collection, n, guard) { + if (collection && typeof collection.length != 'number') { + collection = values(collection); + } + if (n == null || guard) { + return collection ? collection[baseRandom(0, collection.length - 1)] : undefined; + } + var result = shuffle(collection); + result.length = nativeMin(nativeMax(0, n), result.length); + return result; + } + + /** + * Creates an array of shuffled values, using a version of the Fisher-Yates + * shuffle. See http://en.wikipedia.org/wiki/Fisher-Yates_shuffle. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to shuffle. + * @returns {Array} Returns a new shuffled collection. + * @example + * + * _.shuffle([1, 2, 3, 4, 5, 6]); + * // => [4, 1, 6, 3, 5, 2] + */ + function shuffle(collection) { + var index = -1, + length = collection ? collection.length : 0, + result = Array(typeof length == 'number' ? length : 0); + + forEach(collection, function(value) { + var rand = baseRandom(0, ++index); + result[index] = result[rand]; + result[rand] = value; + }); + return result; + } + + /** + * Gets the size of the `collection` by returning `collection.length` for arrays + * and array-like objects or the number of own enumerable properties for objects. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to inspect. + * @returns {number} Returns `collection.length` or number of own enumerable properties. + * @example + * + * _.size([1, 2]); + * // => 2 + * + * _.size({ 'one': 1, 'two': 2, 'three': 3 }); + * // => 3 + * + * _.size('pebbles'); + * // => 7 + */ + function size(collection) { + var length = collection ? collection.length : 0; + return typeof length == 'number' ? length : keys(collection).length; + } + + /** + * Checks if the callback returns a truey value for **any** element of a + * collection. The function returns as soon as it finds a passing value and + * does not iterate over the entire collection. The callback is bound to + * `thisArg` and invoked with three arguments; (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias any + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {boolean} Returns `true` if any element passed the callback check, + * else `false`. + * @example + * + * _.some([null, 0, 'yes', false], Boolean); + * // => true + * + * var characters = [ + * { 'name': 'barney', 'age': 36, 'blocked': false }, + * { 'name': 'fred', 'age': 40, 'blocked': true } + * ]; + * + * // using "_.pluck" callback shorthand + * _.some(characters, 'blocked'); + * // => true + * + * // using "_.where" callback shorthand + * _.some(characters, { 'age': 1 }); + * // => false + */ + function some(collection, callback, thisArg) { + var result; + callback = lodash.createCallback(callback, thisArg, 3); + + var index = -1, + length = collection ? collection.length : 0; + + if (typeof length == 'number') { + while (++index < length) { + if ((result = callback(collection[index], index, collection))) { + break; + } + } + } else { + forOwn(collection, function(value, index, collection) { + return !(result = callback(value, index, collection)); + }); + } + return !!result; + } + + /** + * Creates an array of elements, sorted in ascending order by the results of + * running each element in a collection through the callback. This method + * performs a stable sort, that is, it will preserve the original sort order + * of equal elements. The callback is bound to `thisArg` and invoked with + * three arguments; (value, index|key, collection). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an array of property names is provided for `callback` the collection + * will be sorted by each property value. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Array|Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a new array of sorted elements. + * @example + * + * _.sortBy([1, 2, 3], function(num) { return Math.sin(num); }); + * // => [3, 1, 2] + * + * _.sortBy([1, 2, 3], function(num) { return this.sin(num); }, Math); + * // => [3, 1, 2] + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 }, + * { 'name': 'barney', 'age': 26 }, + * { 'name': 'fred', 'age': 30 } + * ]; + * + * // using "_.pluck" callback shorthand + * _.map(_.sortBy(characters, 'age'), _.values); + * // => [['barney', 26], ['fred', 30], ['barney', 36], ['fred', 40]] + * + * // sorting by multiple properties + * _.map(_.sortBy(characters, ['name', 'age']), _.values); + * // = > [['barney', 26], ['barney', 36], ['fred', 30], ['fred', 40]] + */ + function sortBy(collection, callback, thisArg) { + var index = -1, + isArr = isArray(callback), + length = collection ? collection.length : 0, + result = Array(typeof length == 'number' ? length : 0); + + if (!isArr) { + callback = lodash.createCallback(callback, thisArg, 3); + } + forEach(collection, function(value, key, collection) { + var object = result[++index] = getObject(); + if (isArr) { + object.criteria = map(callback, function(key) { return value[key]; }); + } else { + (object.criteria = getArray())[0] = callback(value, key, collection); + } + object.index = index; + object.value = value; + }); + + length = result.length; + result.sort(compareAscending); + while (length--) { + var object = result[length]; + result[length] = object.value; + if (!isArr) { + releaseArray(object.criteria); + } + releaseObject(object); + } + return result; + } + + /** + * Converts the `collection` to an array. + * + * @static + * @memberOf _ + * @category Collections + * @param {Array|Object|string} collection The collection to convert. + * @returns {Array} Returns the new converted array. + * @example + * + * (function() { return _.toArray(arguments).slice(1); })(1, 2, 3, 4); + * // => [2, 3, 4] + */ + function toArray(collection) { + if (collection && typeof collection.length == 'number') { + return slice(collection); + } + return values(collection); + } + + /** + * Performs a deep comparison of each element in a `collection` to the given + * `properties` object, returning an array of all elements that have equivalent + * property values. + * + * @static + * @memberOf _ + * @type Function + * @category Collections + * @param {Array|Object|string} collection The collection to iterate over. + * @param {Object} props The object of property values to filter by. + * @returns {Array} Returns a new array of elements that have the given properties. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36, 'pets': ['hoppy'] }, + * { 'name': 'fred', 'age': 40, 'pets': ['baby puss', 'dino'] } + * ]; + * + * _.where(characters, { 'age': 36 }); + * // => [{ 'name': 'barney', 'age': 36, 'pets': ['hoppy'] }] + * + * _.where(characters, { 'pets': ['dino'] }); + * // => [{ 'name': 'fred', 'age': 40, 'pets': ['baby puss', 'dino'] }] + */ + var where = filter; + + /*--------------------------------------------------------------------------*/ + + /** + * Creates an array with all falsey values removed. The values `false`, `null`, + * `0`, `""`, `undefined`, and `NaN` are all falsey. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to compact. + * @returns {Array} Returns a new array of filtered values. + * @example + * + * _.compact([0, 1, false, 2, '', 3]); + * // => [1, 2, 3] + */ + function compact(array) { + var index = -1, + length = array ? array.length : 0, + result = []; + + while (++index < length) { + var value = array[index]; + if (value) { + result.push(value); + } + } + return result; + } + + /** + * Creates an array excluding all values of the provided arrays using strict + * equality for comparisons, i.e. `===`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to process. + * @param {...Array} [values] The arrays of values to exclude. + * @returns {Array} Returns a new array of filtered values. + * @example + * + * _.difference([1, 2, 3, 4, 5], [5, 2, 10]); + * // => [1, 3, 4] + */ + function difference(array) { + return baseDifference(array, baseFlatten(arguments, true, true, 1)); + } + + /** + * This method is like `_.find` except that it returns the index of the first + * element that passes the callback check, instead of the element itself. + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to search. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {number} Returns the index of the found element, else `-1`. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36, 'blocked': false }, + * { 'name': 'fred', 'age': 40, 'blocked': true }, + * { 'name': 'pebbles', 'age': 1, 'blocked': false } + * ]; + * + * _.findIndex(characters, function(chr) { + * return chr.age < 20; + * }); + * // => 2 + * + * // using "_.where" callback shorthand + * _.findIndex(characters, { 'age': 36 }); + * // => 0 + * + * // using "_.pluck" callback shorthand + * _.findIndex(characters, 'blocked'); + * // => 1 + */ + function findIndex(array, callback, thisArg) { + var index = -1, + length = array ? array.length : 0; + + callback = lodash.createCallback(callback, thisArg, 3); + while (++index < length) { + if (callback(array[index], index, array)) { + return index; + } + } + return -1; + } + + /** + * This method is like `_.findIndex` except that it iterates over elements + * of a `collection` from right to left. + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to search. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {number} Returns the index of the found element, else `-1`. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36, 'blocked': true }, + * { 'name': 'fred', 'age': 40, 'blocked': false }, + * { 'name': 'pebbles', 'age': 1, 'blocked': true } + * ]; + * + * _.findLastIndex(characters, function(chr) { + * return chr.age > 30; + * }); + * // => 1 + * + * // using "_.where" callback shorthand + * _.findLastIndex(characters, { 'age': 36 }); + * // => 0 + * + * // using "_.pluck" callback shorthand + * _.findLastIndex(characters, 'blocked'); + * // => 2 + */ + function findLastIndex(array, callback, thisArg) { + var length = array ? array.length : 0; + callback = lodash.createCallback(callback, thisArg, 3); + while (length--) { + if (callback(array[length], length, array)) { + return length; + } + } + return -1; + } + + /** + * Gets the first element or first `n` elements of an array. If a callback + * is provided elements at the beginning of the array are returned as long + * as the callback returns truey. The callback is bound to `thisArg` and + * invoked with three arguments; (value, index, array). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias head, take + * @category Arrays + * @param {Array} array The array to query. + * @param {Function|Object|number|string} [callback] The function called + * per element or the number of elements to return. If a property name or + * object is provided it will be used to create a "_.pluck" or "_.where" + * style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the first element(s) of `array`. + * @example + * + * _.first([1, 2, 3]); + * // => 1 + * + * _.first([1, 2, 3], 2); + * // => [1, 2] + * + * _.first([1, 2, 3], function(num) { + * return num < 3; + * }); + * // => [1, 2] + * + * var characters = [ + * { 'name': 'barney', 'blocked': true, 'employer': 'slate' }, + * { 'name': 'fred', 'blocked': false, 'employer': 'slate' }, + * { 'name': 'pebbles', 'blocked': true, 'employer': 'na' } + * ]; + * + * // using "_.pluck" callback shorthand + * _.first(characters, 'blocked'); + * // => [{ 'name': 'barney', 'blocked': true, 'employer': 'slate' }] + * + * // using "_.where" callback shorthand + * _.pluck(_.first(characters, { 'employer': 'slate' }), 'name'); + * // => ['barney', 'fred'] + */ + function first(array, callback, thisArg) { + var n = 0, + length = array ? array.length : 0; + + if (typeof callback != 'number' && callback != null) { + var index = -1; + callback = lodash.createCallback(callback, thisArg, 3); + while (++index < length && callback(array[index], index, array)) { + n++; + } + } else { + n = callback; + if (n == null || thisArg) { + return array ? array[0] : undefined; + } + } + return slice(array, 0, nativeMin(nativeMax(0, n), length)); + } + + /** + * Flattens a nested array (the nesting can be to any depth). If `isShallow` + * is truey, the array will only be flattened a single level. If a callback + * is provided each element of the array is passed through the callback before + * flattening. The callback is bound to `thisArg` and invoked with three + * arguments; (value, index, array). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to flatten. + * @param {boolean} [isShallow=false] A flag to restrict flattening to a single level. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a new flattened array. + * @example + * + * _.flatten([1, [2], [3, [[4]]]]); + * // => [1, 2, 3, 4]; + * + * _.flatten([1, [2], [3, [[4]]]], true); + * // => [1, 2, 3, [[4]]]; + * + * var characters = [ + * { 'name': 'barney', 'age': 30, 'pets': ['hoppy'] }, + * { 'name': 'fred', 'age': 40, 'pets': ['baby puss', 'dino'] } + * ]; + * + * // using "_.pluck" callback shorthand + * _.flatten(characters, 'pets'); + * // => ['hoppy', 'baby puss', 'dino'] + */ + function flatten(array, isShallow, callback, thisArg) { + // juggle arguments + if (typeof isShallow != 'boolean' && isShallow != null) { + thisArg = callback; + callback = (typeof isShallow != 'function' && thisArg && thisArg[isShallow] === array) ? null : isShallow; + isShallow = false; + } + if (callback != null) { + array = map(array, callback, thisArg); + } + return baseFlatten(array, isShallow); + } + + /** + * Gets the index at which the first occurrence of `value` is found using + * strict equality for comparisons, i.e. `===`. If the array is already sorted + * providing `true` for `fromIndex` will run a faster binary search. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to search. + * @param {*} value The value to search for. + * @param {boolean|number} [fromIndex=0] The index to search from or `true` + * to perform a binary search on a sorted array. + * @returns {number} Returns the index of the matched value or `-1`. + * @example + * + * _.indexOf([1, 2, 3, 1, 2, 3], 2); + * // => 1 + * + * _.indexOf([1, 2, 3, 1, 2, 3], 2, 3); + * // => 4 + * + * _.indexOf([1, 1, 2, 2, 3, 3], 2, true); + * // => 2 + */ + function indexOf(array, value, fromIndex) { + if (typeof fromIndex == 'number') { + var length = array ? array.length : 0; + fromIndex = (fromIndex < 0 ? nativeMax(0, length + fromIndex) : fromIndex || 0); + } else if (fromIndex) { + var index = sortedIndex(array, value); + return array[index] === value ? index : -1; + } + return baseIndexOf(array, value, fromIndex); + } + + /** + * Gets all but the last element or last `n` elements of an array. If a + * callback is provided elements at the end of the array are excluded from + * the result as long as the callback returns truey. The callback is bound + * to `thisArg` and invoked with three arguments; (value, index, array). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to query. + * @param {Function|Object|number|string} [callback=1] The function called + * per element or the number of elements to exclude. If a property name or + * object is provided it will be used to create a "_.pluck" or "_.where" + * style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a slice of `array`. + * @example + * + * _.initial([1, 2, 3]); + * // => [1, 2] + * + * _.initial([1, 2, 3], 2); + * // => [1] + * + * _.initial([1, 2, 3], function(num) { + * return num > 1; + * }); + * // => [1] + * + * var characters = [ + * { 'name': 'barney', 'blocked': false, 'employer': 'slate' }, + * { 'name': 'fred', 'blocked': true, 'employer': 'slate' }, + * { 'name': 'pebbles', 'blocked': true, 'employer': 'na' } + * ]; + * + * // using "_.pluck" callback shorthand + * _.initial(characters, 'blocked'); + * // => [{ 'name': 'barney', 'blocked': false, 'employer': 'slate' }] + * + * // using "_.where" callback shorthand + * _.pluck(_.initial(characters, { 'employer': 'na' }), 'name'); + * // => ['barney', 'fred'] + */ + function initial(array, callback, thisArg) { + var n = 0, + length = array ? array.length : 0; + + if (typeof callback != 'number' && callback != null) { + var index = length; + callback = lodash.createCallback(callback, thisArg, 3); + while (index-- && callback(array[index], index, array)) { + n++; + } + } else { + n = (callback == null || thisArg) ? 1 : callback || n; + } + return slice(array, 0, nativeMin(nativeMax(0, length - n), length)); + } + + /** + * Creates an array of unique values present in all provided arrays using + * strict equality for comparisons, i.e. `===`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {...Array} [array] The arrays to inspect. + * @returns {Array} Returns an array of shared values. + * @example + * + * _.intersection([1, 2, 3], [5, 2, 1, 4], [2, 1]); + * // => [1, 2] + */ + function intersection() { + var args = [], + argsIndex = -1, + argsLength = arguments.length, + caches = getArray(), + indexOf = getIndexOf(), + trustIndexOf = indexOf === baseIndexOf, + seen = getArray(); + + while (++argsIndex < argsLength) { + var value = arguments[argsIndex]; + if (isArray(value) || isArguments(value)) { + args.push(value); + caches.push(trustIndexOf && value.length >= largeArraySize && + createCache(argsIndex ? args[argsIndex] : seen)); + } + } + var array = args[0], + index = -1, + length = array ? array.length : 0, + result = []; + + outer: + while (++index < length) { + var cache = caches[0]; + value = array[index]; + + if ((cache ? cacheIndexOf(cache, value) : indexOf(seen, value)) < 0) { + argsIndex = argsLength; + (cache || seen).push(value); + while (--argsIndex) { + cache = caches[argsIndex]; + if ((cache ? cacheIndexOf(cache, value) : indexOf(args[argsIndex], value)) < 0) { + continue outer; + } + } + result.push(value); + } + } + while (argsLength--) { + cache = caches[argsLength]; + if (cache) { + releaseObject(cache); + } + } + releaseArray(caches); + releaseArray(seen); + return result; + } + + /** + * Gets the last element or last `n` elements of an array. If a callback is + * provided elements at the end of the array are returned as long as the + * callback returns truey. The callback is bound to `thisArg` and invoked + * with three arguments; (value, index, array). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to query. + * @param {Function|Object|number|string} [callback] The function called + * per element or the number of elements to return. If a property name or + * object is provided it will be used to create a "_.pluck" or "_.where" + * style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {*} Returns the last element(s) of `array`. + * @example + * + * _.last([1, 2, 3]); + * // => 3 + * + * _.last([1, 2, 3], 2); + * // => [2, 3] + * + * _.last([1, 2, 3], function(num) { + * return num > 1; + * }); + * // => [2, 3] + * + * var characters = [ + * { 'name': 'barney', 'blocked': false, 'employer': 'slate' }, + * { 'name': 'fred', 'blocked': true, 'employer': 'slate' }, + * { 'name': 'pebbles', 'blocked': true, 'employer': 'na' } + * ]; + * + * // using "_.pluck" callback shorthand + * _.pluck(_.last(characters, 'blocked'), 'name'); + * // => ['fred', 'pebbles'] + * + * // using "_.where" callback shorthand + * _.last(characters, { 'employer': 'na' }); + * // => [{ 'name': 'pebbles', 'blocked': true, 'employer': 'na' }] + */ + function last(array, callback, thisArg) { + var n = 0, + length = array ? array.length : 0; + + if (typeof callback != 'number' && callback != null) { + var index = length; + callback = lodash.createCallback(callback, thisArg, 3); + while (index-- && callback(array[index], index, array)) { + n++; + } + } else { + n = callback; + if (n == null || thisArg) { + return array ? array[length - 1] : undefined; + } + } + return slice(array, nativeMax(0, length - n)); + } + + /** + * Gets the index at which the last occurrence of `value` is found using strict + * equality for comparisons, i.e. `===`. If `fromIndex` is negative, it is used + * as the offset from the end of the collection. + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to search. + * @param {*} value The value to search for. + * @param {number} [fromIndex=array.length-1] The index to search from. + * @returns {number} Returns the index of the matched value or `-1`. + * @example + * + * _.lastIndexOf([1, 2, 3, 1, 2, 3], 2); + * // => 4 + * + * _.lastIndexOf([1, 2, 3, 1, 2, 3], 2, 3); + * // => 1 + */ + function lastIndexOf(array, value, fromIndex) { + var index = array ? array.length : 0; + if (typeof fromIndex == 'number') { + index = (fromIndex < 0 ? nativeMax(0, index + fromIndex) : nativeMin(fromIndex, index - 1)) + 1; + } + while (index--) { + if (array[index] === value) { + return index; + } + } + return -1; + } + + /** + * Removes all provided values from the given array using strict equality for + * comparisons, i.e. `===`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to modify. + * @param {...*} [value] The values to remove. + * @returns {Array} Returns `array`. + * @example + * + * var array = [1, 2, 3, 1, 2, 3]; + * _.pull(array, 2, 3); + * console.log(array); + * // => [1, 1] + */ + function pull(array) { + var args = arguments, + argsIndex = 0, + argsLength = args.length, + length = array ? array.length : 0; + + while (++argsIndex < argsLength) { + var index = -1, + value = args[argsIndex]; + while (++index < length) { + if (array[index] === value) { + splice.call(array, index--, 1); + length--; + } + } + } + return array; + } + + /** + * Creates an array of numbers (positive and/or negative) progressing from + * `start` up to but not including `end`. If `start` is less than `stop` a + * zero-length range is created unless a negative `step` is specified. + * + * @static + * @memberOf _ + * @category Arrays + * @param {number} [start=0] The start of the range. + * @param {number} end The end of the range. + * @param {number} [step=1] The value to increment or decrement by. + * @returns {Array} Returns a new range array. + * @example + * + * _.range(4); + * // => [0, 1, 2, 3] + * + * _.range(1, 5); + * // => [1, 2, 3, 4] + * + * _.range(0, 20, 5); + * // => [0, 5, 10, 15] + * + * _.range(0, -4, -1); + * // => [0, -1, -2, -3] + * + * _.range(1, 4, 0); + * // => [1, 1, 1] + * + * _.range(0); + * // => [] + */ + function range(start, end, step) { + start = +start || 0; + step = typeof step == 'number' ? step : (+step || 1); + + if (end == null) { + end = start; + start = 0; + } + // use `Array(length)` so engines like Chakra and V8 avoid slower modes + // http://youtu.be/XAqIpGU8ZZk#t=17m25s + var index = -1, + length = nativeMax(0, ceil((end - start) / (step || 1))), + result = Array(length); + + while (++index < length) { + result[index] = start; + start += step; + } + return result; + } + + /** + * Removes all elements from an array that the callback returns truey for + * and returns an array of removed elements. The callback is bound to `thisArg` + * and invoked with three arguments; (value, index, array). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to modify. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a new array of removed elements. + * @example + * + * var array = [1, 2, 3, 4, 5, 6]; + * var evens = _.remove(array, function(num) { return num % 2 == 0; }); + * + * console.log(array); + * // => [1, 3, 5] + * + * console.log(evens); + * // => [2, 4, 6] + */ + function remove(array, callback, thisArg) { + var index = -1, + length = array ? array.length : 0, + result = []; + + callback = lodash.createCallback(callback, thisArg, 3); + while (++index < length) { + var value = array[index]; + if (callback(value, index, array)) { + result.push(value); + splice.call(array, index--, 1); + length--; + } + } + return result; + } + + /** + * The opposite of `_.initial` this method gets all but the first element or + * first `n` elements of an array. If a callback function is provided elements + * at the beginning of the array are excluded from the result as long as the + * callback returns truey. The callback is bound to `thisArg` and invoked + * with three arguments; (value, index, array). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias drop, tail + * @category Arrays + * @param {Array} array The array to query. + * @param {Function|Object|number|string} [callback=1] The function called + * per element or the number of elements to exclude. If a property name or + * object is provided it will be used to create a "_.pluck" or "_.where" + * style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a slice of `array`. + * @example + * + * _.rest([1, 2, 3]); + * // => [2, 3] + * + * _.rest([1, 2, 3], 2); + * // => [3] + * + * _.rest([1, 2, 3], function(num) { + * return num < 3; + * }); + * // => [3] + * + * var characters = [ + * { 'name': 'barney', 'blocked': true, 'employer': 'slate' }, + * { 'name': 'fred', 'blocked': false, 'employer': 'slate' }, + * { 'name': 'pebbles', 'blocked': true, 'employer': 'na' } + * ]; + * + * // using "_.pluck" callback shorthand + * _.pluck(_.rest(characters, 'blocked'), 'name'); + * // => ['fred', 'pebbles'] + * + * // using "_.where" callback shorthand + * _.rest(characters, { 'employer': 'slate' }); + * // => [{ 'name': 'pebbles', 'blocked': true, 'employer': 'na' }] + */ + function rest(array, callback, thisArg) { + if (typeof callback != 'number' && callback != null) { + var n = 0, + index = -1, + length = array ? array.length : 0; + + callback = lodash.createCallback(callback, thisArg, 3); + while (++index < length && callback(array[index], index, array)) { + n++; + } + } else { + n = (callback == null || thisArg) ? 1 : nativeMax(0, callback); + } + return slice(array, n); + } + + /** + * Uses a binary search to determine the smallest index at which a value + * should be inserted into a given sorted array in order to maintain the sort + * order of the array. If a callback is provided it will be executed for + * `value` and each element of `array` to compute their sort ranking. The + * callback is bound to `thisArg` and invoked with one argument; (value). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to inspect. + * @param {*} value The value to evaluate. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {number} Returns the index at which `value` should be inserted + * into `array`. + * @example + * + * _.sortedIndex([20, 30, 50], 40); + * // => 2 + * + * // using "_.pluck" callback shorthand + * _.sortedIndex([{ 'x': 20 }, { 'x': 30 }, { 'x': 50 }], { 'x': 40 }, 'x'); + * // => 2 + * + * var dict = { + * 'wordToNumber': { 'twenty': 20, 'thirty': 30, 'fourty': 40, 'fifty': 50 } + * }; + * + * _.sortedIndex(['twenty', 'thirty', 'fifty'], 'fourty', function(word) { + * return dict.wordToNumber[word]; + * }); + * // => 2 + * + * _.sortedIndex(['twenty', 'thirty', 'fifty'], 'fourty', function(word) { + * return this.wordToNumber[word]; + * }, dict); + * // => 2 + */ + function sortedIndex(array, value, callback, thisArg) { + var low = 0, + high = array ? array.length : low; + + // explicitly reference `identity` for better inlining in Firefox + callback = callback ? lodash.createCallback(callback, thisArg, 1) : identity; + value = callback(value); + + while (low < high) { + var mid = (low + high) >>> 1; + (callback(array[mid]) < value) + ? low = mid + 1 + : high = mid; + } + return low; + } + + /** + * Creates an array of unique values, in order, of the provided arrays using + * strict equality for comparisons, i.e. `===`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {...Array} [array] The arrays to inspect. + * @returns {Array} Returns an array of combined values. + * @example + * + * _.union([1, 2, 3], [5, 2, 1, 4], [2, 1]); + * // => [1, 2, 3, 5, 4] + */ + function union() { + return baseUniq(baseFlatten(arguments, true, true)); + } + + /** + * Creates a duplicate-value-free version of an array using strict equality + * for comparisons, i.e. `===`. If the array is sorted, providing + * `true` for `isSorted` will use a faster algorithm. If a callback is provided + * each element of `array` is passed through the callback before uniqueness + * is computed. The callback is bound to `thisArg` and invoked with three + * arguments; (value, index, array). + * + * If a property name is provided for `callback` the created "_.pluck" style + * callback will return the property value of the given element. + * + * If an object is provided for `callback` the created "_.where" style callback + * will return `true` for elements that have the properties of the given object, + * else `false`. + * + * @static + * @memberOf _ + * @alias unique + * @category Arrays + * @param {Array} array The array to process. + * @param {boolean} [isSorted=false] A flag to indicate that `array` is sorted. + * @param {Function|Object|string} [callback=identity] The function called + * per iteration. If a property name or object is provided it will be used + * to create a "_.pluck" or "_.where" style callback, respectively. + * @param {*} [thisArg] The `this` binding of `callback`. + * @returns {Array} Returns a duplicate-value-free array. + * @example + * + * _.uniq([1, 2, 1, 3, 1]); + * // => [1, 2, 3] + * + * _.uniq([1, 1, 2, 2, 3], true); + * // => [1, 2, 3] + * + * _.uniq(['A', 'b', 'C', 'a', 'B', 'c'], function(letter) { return letter.toLowerCase(); }); + * // => ['A', 'b', 'C'] + * + * _.uniq([1, 2.5, 3, 1.5, 2, 3.5], function(num) { return this.floor(num); }, Math); + * // => [1, 2.5, 3] + * + * // using "_.pluck" callback shorthand + * _.uniq([{ 'x': 1 }, { 'x': 2 }, { 'x': 1 }], 'x'); + * // => [{ 'x': 1 }, { 'x': 2 }] + */ + function uniq(array, isSorted, callback, thisArg) { + // juggle arguments + if (typeof isSorted != 'boolean' && isSorted != null) { + thisArg = callback; + callback = (typeof isSorted != 'function' && thisArg && thisArg[isSorted] === array) ? null : isSorted; + isSorted = false; + } + if (callback != null) { + callback = lodash.createCallback(callback, thisArg, 3); + } + return baseUniq(array, isSorted, callback); + } + + /** + * Creates an array excluding all provided values using strict equality for + * comparisons, i.e. `===`. + * + * @static + * @memberOf _ + * @category Arrays + * @param {Array} array The array to filter. + * @param {...*} [value] The values to exclude. + * @returns {Array} Returns a new array of filtered values. + * @example + * + * _.without([1, 2, 1, 0, 3, 1, 4], 0, 1); + * // => [2, 3, 4] + */ + function without(array) { + return baseDifference(array, slice(arguments, 1)); + } + + /** + * Creates an array that is the symmetric difference of the provided arrays. + * See http://en.wikipedia.org/wiki/Symmetric_difference. + * + * @static + * @memberOf _ + * @category Arrays + * @param {...Array} [array] The arrays to inspect. + * @returns {Array} Returns an array of values. + * @example + * + * _.xor([1, 2, 3], [5, 2, 1, 4]); + * // => [3, 5, 4] + * + * _.xor([1, 2, 5], [2, 3, 5], [3, 4, 5]); + * // => [1, 4, 5] + */ + function xor() { + var index = -1, + length = arguments.length; + + while (++index < length) { + var array = arguments[index]; + if (isArray(array) || isArguments(array)) { + var result = result + ? baseUniq(baseDifference(result, array).concat(baseDifference(array, result))) + : array; + } + } + return result || []; + } + + /** + * Creates an array of grouped elements, the first of which contains the first + * elements of the given arrays, the second of which contains the second + * elements of the given arrays, and so on. + * + * @static + * @memberOf _ + * @alias unzip + * @category Arrays + * @param {...Array} [array] Arrays to process. + * @returns {Array} Returns a new array of grouped elements. + * @example + * + * _.zip(['fred', 'barney'], [30, 40], [true, false]); + * // => [['fred', 30, true], ['barney', 40, false]] + */ + function zip() { + var array = arguments.length > 1 ? arguments : arguments[0], + index = -1, + length = array ? max(pluck(array, 'length')) : 0, + result = Array(length < 0 ? 0 : length); + + while (++index < length) { + result[index] = pluck(array, index); + } + return result; + } + + /** + * Creates an object composed from arrays of `keys` and `values`. Provide + * either a single two dimensional array, i.e. `[[key1, value1], [key2, value2]]` + * or two arrays, one of `keys` and one of corresponding `values`. + * + * @static + * @memberOf _ + * @alias object + * @category Arrays + * @param {Array} keys The array of keys. + * @param {Array} [values=[]] The array of values. + * @returns {Object} Returns an object composed of the given keys and + * corresponding values. + * @example + * + * _.zipObject(['fred', 'barney'], [30, 40]); + * // => { 'fred': 30, 'barney': 40 } + */ + function zipObject(keys, values) { + var index = -1, + length = keys ? keys.length : 0, + result = {}; + + if (!values && length && !isArray(keys[0])) { + values = []; + } + while (++index < length) { + var key = keys[index]; + if (values) { + result[key] = values[index]; + } else if (key) { + result[key[0]] = key[1]; + } + } + return result; + } + + /*--------------------------------------------------------------------------*/ + + /** + * Creates a function that executes `func`, with the `this` binding and + * arguments of the created function, only after being called `n` times. + * + * @static + * @memberOf _ + * @category Functions + * @param {number} n The number of times the function must be called before + * `func` is executed. + * @param {Function} func The function to restrict. + * @returns {Function} Returns the new restricted function. + * @example + * + * var saves = ['profile', 'settings']; + * + * var done = _.after(saves.length, function() { + * console.log('Done saving!'); + * }); + * + * _.forEach(saves, function(type) { + * asyncSave({ 'type': type, 'complete': done }); + * }); + * // => logs 'Done saving!', after all saves have completed + */ + function after(n, func) { + if (!isFunction(func)) { + throw new TypeError; + } + return function() { + if (--n < 1) { + return func.apply(this, arguments); + } + }; + } + + /** + * Creates a function that, when called, invokes `func` with the `this` + * binding of `thisArg` and prepends any additional `bind` arguments to those + * provided to the bound function. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to bind. + * @param {*} [thisArg] The `this` binding of `func`. + * @param {...*} [arg] Arguments to be partially applied. + * @returns {Function} Returns the new bound function. + * @example + * + * var func = function(greeting) { + * return greeting + ' ' + this.name; + * }; + * + * func = _.bind(func, { 'name': 'fred' }, 'hi'); + * func(); + * // => 'hi fred' + */ + function bind(func, thisArg) { + return arguments.length > 2 + ? createWrapper(func, 17, slice(arguments, 2), null, thisArg) + : createWrapper(func, 1, null, null, thisArg); + } + + /** + * Binds methods of an object to the object itself, overwriting the existing + * method. Method names may be specified as individual arguments or as arrays + * of method names. If no method names are provided all the function properties + * of `object` will be bound. + * + * @static + * @memberOf _ + * @category Functions + * @param {Object} object The object to bind and assign the bound methods to. + * @param {...string} [methodName] The object method names to + * bind, specified as individual method names or arrays of method names. + * @returns {Object} Returns `object`. + * @example + * + * var view = { + * 'label': 'docs', + * 'onClick': function() { console.log('clicked ' + this.label); } + * }; + * + * _.bindAll(view); + * jQuery('#docs').on('click', view.onClick); + * // => logs 'clicked docs', when the button is clicked + */ + function bindAll(object) { + var funcs = arguments.length > 1 ? baseFlatten(arguments, true, false, 1) : functions(object), + index = -1, + length = funcs.length; + + while (++index < length) { + var key = funcs[index]; + object[key] = createWrapper(object[key], 1, null, null, object); + } + return object; + } + + /** + * Creates a function that, when called, invokes the method at `object[key]` + * and prepends any additional `bindKey` arguments to those provided to the bound + * function. This method differs from `_.bind` by allowing bound functions to + * reference methods that will be redefined or don't yet exist. + * See http://michaux.ca/articles/lazy-function-definition-pattern. + * + * @static + * @memberOf _ + * @category Functions + * @param {Object} object The object the method belongs to. + * @param {string} key The key of the method. + * @param {...*} [arg] Arguments to be partially applied. + * @returns {Function} Returns the new bound function. + * @example + * + * var object = { + * 'name': 'fred', + * 'greet': function(greeting) { + * return greeting + ' ' + this.name; + * } + * }; + * + * var func = _.bindKey(object, 'greet', 'hi'); + * func(); + * // => 'hi fred' + * + * object.greet = function(greeting) { + * return greeting + 'ya ' + this.name + '!'; + * }; + * + * func(); + * // => 'hiya fred!' + */ + function bindKey(object, key) { + return arguments.length > 2 + ? createWrapper(key, 19, slice(arguments, 2), null, object) + : createWrapper(key, 3, null, null, object); + } + + /** + * Creates a function that is the composition of the provided functions, + * where each function consumes the return value of the function that follows. + * For example, composing the functions `f()`, `g()`, and `h()` produces `f(g(h()))`. + * Each function is executed with the `this` binding of the composed function. + * + * @static + * @memberOf _ + * @category Functions + * @param {...Function} [func] Functions to compose. + * @returns {Function} Returns the new composed function. + * @example + * + * var realNameMap = { + * 'pebbles': 'penelope' + * }; + * + * var format = function(name) { + * name = realNameMap[name.toLowerCase()] || name; + * return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); + * }; + * + * var greet = function(formatted) { + * return 'Hiya ' + formatted + '!'; + * }; + * + * var welcome = _.compose(greet, format); + * welcome('pebbles'); + * // => 'Hiya Penelope!' + */ + function compose() { + var funcs = arguments, + length = funcs.length; + + while (length--) { + if (!isFunction(funcs[length])) { + throw new TypeError; + } + } + return function() { + var args = arguments, + length = funcs.length; + + while (length--) { + args = [funcs[length].apply(this, args)]; + } + return args[0]; + }; + } + + /** + * Creates a function which accepts one or more arguments of `func` that when + * invoked either executes `func` returning its result, if all `func` arguments + * have been provided, or returns a function that accepts one or more of the + * remaining `func` arguments, and so on. The arity of `func` can be specified + * if `func.length` is not sufficient. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to curry. + * @param {number} [arity=func.length] The arity of `func`. + * @returns {Function} Returns the new curried function. + * @example + * + * var curried = _.curry(function(a, b, c) { + * console.log(a + b + c); + * }); + * + * curried(1)(2)(3); + * // => 6 + * + * curried(1, 2)(3); + * // => 6 + * + * curried(1, 2, 3); + * // => 6 + */ + function curry(func, arity) { + arity = typeof arity == 'number' ? arity : (+arity || func.length); + return createWrapper(func, 4, null, null, null, arity); + } + + /** + * Creates a function that will delay the execution of `func` until after + * `wait` milliseconds have elapsed since the last time it was invoked. + * Provide an options object to indicate that `func` should be invoked on + * the leading and/or trailing edge of the `wait` timeout. Subsequent calls + * to the debounced function will return the result of the last `func` call. + * + * Note: If `leading` and `trailing` options are `true` `func` will be called + * on the trailing edge of the timeout only if the the debounced function is + * invoked more than once during the `wait` timeout. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to debounce. + * @param {number} wait The number of milliseconds to delay. + * @param {Object} [options] The options object. + * @param {boolean} [options.leading=false] Specify execution on the leading edge of the timeout. + * @param {number} [options.maxWait] The maximum time `func` is allowed to be delayed before it's called. + * @param {boolean} [options.trailing=true] Specify execution on the trailing edge of the timeout. + * @returns {Function} Returns the new debounced function. + * @example + * + * // avoid costly calculations while the window size is in flux + * var lazyLayout = _.debounce(calculateLayout, 150); + * jQuery(window).on('resize', lazyLayout); + * + * // execute `sendMail` when the click event is fired, debouncing subsequent calls + * jQuery('#postbox').on('click', _.debounce(sendMail, 300, { + * 'leading': true, + * 'trailing': false + * }); + * + * // ensure `batchLog` is executed once after 1 second of debounced calls + * var source = new EventSource('/stream'); + * source.addEventListener('message', _.debounce(batchLog, 250, { + * 'maxWait': 1000 + * }, false); + */ + function debounce(func, wait, options) { + var args, + maxTimeoutId, + result, + stamp, + thisArg, + timeoutId, + trailingCall, + lastCalled = 0, + maxWait = false, + trailing = true; + + if (!isFunction(func)) { + throw new TypeError; + } + wait = nativeMax(0, wait) || 0; + if (options === true) { + var leading = true; + trailing = false; + } else if (isObject(options)) { + leading = options.leading; + maxWait = 'maxWait' in options && (nativeMax(wait, options.maxWait) || 0); + trailing = 'trailing' in options ? options.trailing : trailing; + } + var delayed = function() { + var remaining = wait - (now() - stamp); + if (remaining <= 0) { + if (maxTimeoutId) { + clearTimeout(maxTimeoutId); + } + var isCalled = trailingCall; + maxTimeoutId = timeoutId = trailingCall = undefined; + if (isCalled) { + lastCalled = now(); + result = func.apply(thisArg, args); + if (!timeoutId && !maxTimeoutId) { + args = thisArg = null; + } + } + } else { + timeoutId = setTimeout(delayed, remaining); + } + }; + + var maxDelayed = function() { + if (timeoutId) { + clearTimeout(timeoutId); + } + maxTimeoutId = timeoutId = trailingCall = undefined; + if (trailing || (maxWait !== wait)) { + lastCalled = now(); + result = func.apply(thisArg, args); + if (!timeoutId && !maxTimeoutId) { + args = thisArg = null; + } + } + }; + + return function() { + args = arguments; + stamp = now(); + thisArg = this; + trailingCall = trailing && (timeoutId || !leading); + + if (maxWait === false) { + var leadingCall = leading && !timeoutId; + } else { + if (!maxTimeoutId && !leading) { + lastCalled = stamp; + } + var remaining = maxWait - (stamp - lastCalled), + isCalled = remaining <= 0; + + if (isCalled) { + if (maxTimeoutId) { + maxTimeoutId = clearTimeout(maxTimeoutId); + } + lastCalled = stamp; + result = func.apply(thisArg, args); + } + else if (!maxTimeoutId) { + maxTimeoutId = setTimeout(maxDelayed, remaining); + } + } + if (isCalled && timeoutId) { + timeoutId = clearTimeout(timeoutId); + } + else if (!timeoutId && wait !== maxWait) { + timeoutId = setTimeout(delayed, wait); + } + if (leadingCall) { + isCalled = true; + result = func.apply(thisArg, args); + } + if (isCalled && !timeoutId && !maxTimeoutId) { + args = thisArg = null; + } + return result; + }; + } + + /** + * Defers executing the `func` function until the current call stack has cleared. + * Additional arguments will be provided to `func` when it is invoked. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to defer. + * @param {...*} [arg] Arguments to invoke the function with. + * @returns {number} Returns the timer id. + * @example + * + * _.defer(function(text) { console.log(text); }, 'deferred'); + * // logs 'deferred' after one or more milliseconds + */ + function defer(func) { + if (!isFunction(func)) { + throw new TypeError; + } + var args = slice(arguments, 1); + return setTimeout(function() { func.apply(undefined, args); }, 1); + } + + /** + * Executes the `func` function after `wait` milliseconds. Additional arguments + * will be provided to `func` when it is invoked. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to delay. + * @param {number} wait The number of milliseconds to delay execution. + * @param {...*} [arg] Arguments to invoke the function with. + * @returns {number} Returns the timer id. + * @example + * + * _.delay(function(text) { console.log(text); }, 1000, 'later'); + * // => logs 'later' after one second + */ + function delay(func, wait) { + if (!isFunction(func)) { + throw new TypeError; + } + var args = slice(arguments, 2); + return setTimeout(function() { func.apply(undefined, args); }, wait); + } + + /** + * Creates a function that memoizes the result of `func`. If `resolver` is + * provided it will be used to determine the cache key for storing the result + * based on the arguments provided to the memoized function. By default, the + * first argument provided to the memoized function is used as the cache key. + * The `func` is executed with the `this` binding of the memoized function. + * The result cache is exposed as the `cache` property on the memoized function. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to have its output memoized. + * @param {Function} [resolver] A function used to resolve the cache key. + * @returns {Function} Returns the new memoizing function. + * @example + * + * var fibonacci = _.memoize(function(n) { + * return n < 2 ? n : fibonacci(n - 1) + fibonacci(n - 2); + * }); + * + * fibonacci(9) + * // => 34 + * + * var data = { + * 'fred': { 'name': 'fred', 'age': 40 }, + * 'pebbles': { 'name': 'pebbles', 'age': 1 } + * }; + * + * // modifying the result cache + * var get = _.memoize(function(name) { return data[name]; }, _.identity); + * get('pebbles'); + * // => { 'name': 'pebbles', 'age': 1 } + * + * get.cache.pebbles.name = 'penelope'; + * get('pebbles'); + * // => { 'name': 'penelope', 'age': 1 } + */ + function memoize(func, resolver) { + if (!isFunction(func)) { + throw new TypeError; + } + var memoized = function() { + var cache = memoized.cache, + key = resolver ? resolver.apply(this, arguments) : keyPrefix + arguments[0]; + + return hasOwnProperty.call(cache, key) + ? cache[key] + : (cache[key] = func.apply(this, arguments)); + } + memoized.cache = {}; + return memoized; + } + + /** + * Creates a function that is restricted to execute `func` once. Repeat calls to + * the function will return the value of the first call. The `func` is executed + * with the `this` binding of the created function. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to restrict. + * @returns {Function} Returns the new restricted function. + * @example + * + * var initialize = _.once(createApplication); + * initialize(); + * initialize(); + * // `initialize` executes `createApplication` once + */ + function once(func) { + var ran, + result; + + if (!isFunction(func)) { + throw new TypeError; + } + return function() { + if (ran) { + return result; + } + ran = true; + result = func.apply(this, arguments); + + // clear the `func` variable so the function may be garbage collected + func = null; + return result; + }; + } + + /** + * Creates a function that, when called, invokes `func` with any additional + * `partial` arguments prepended to those provided to the new function. This + * method is similar to `_.bind` except it does **not** alter the `this` binding. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to partially apply arguments to. + * @param {...*} [arg] Arguments to be partially applied. + * @returns {Function} Returns the new partially applied function. + * @example + * + * var greet = function(greeting, name) { return greeting + ' ' + name; }; + * var hi = _.partial(greet, 'hi'); + * hi('fred'); + * // => 'hi fred' + */ + function partial(func) { + return createWrapper(func, 16, slice(arguments, 1)); + } + + /** + * This method is like `_.partial` except that `partial` arguments are + * appended to those provided to the new function. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to partially apply arguments to. + * @param {...*} [arg] Arguments to be partially applied. + * @returns {Function} Returns the new partially applied function. + * @example + * + * var defaultsDeep = _.partialRight(_.merge, _.defaults); + * + * var options = { + * 'variable': 'data', + * 'imports': { 'jq': $ } + * }; + * + * defaultsDeep(options, _.templateSettings); + * + * options.variable + * // => 'data' + * + * options.imports + * // => { '_': _, 'jq': $ } + */ + function partialRight(func) { + return createWrapper(func, 32, null, slice(arguments, 1)); + } + + /** + * Creates a function that, when executed, will only call the `func` function + * at most once per every `wait` milliseconds. Provide an options object to + * indicate that `func` should be invoked on the leading and/or trailing edge + * of the `wait` timeout. Subsequent calls to the throttled function will + * return the result of the last `func` call. + * + * Note: If `leading` and `trailing` options are `true` `func` will be called + * on the trailing edge of the timeout only if the the throttled function is + * invoked more than once during the `wait` timeout. + * + * @static + * @memberOf _ + * @category Functions + * @param {Function} func The function to throttle. + * @param {number} wait The number of milliseconds to throttle executions to. + * @param {Object} [options] The options object. + * @param {boolean} [options.leading=true] Specify execution on the leading edge of the timeout. + * @param {boolean} [options.trailing=true] Specify execution on the trailing edge of the timeout. + * @returns {Function} Returns the new throttled function. + * @example + * + * // avoid excessively updating the position while scrolling + * var throttled = _.throttle(updatePosition, 100); + * jQuery(window).on('scroll', throttled); + * + * // execute `renewToken` when the click event is fired, but not more than once every 5 minutes + * jQuery('.interactive').on('click', _.throttle(renewToken, 300000, { + * 'trailing': false + * })); + */ + function throttle(func, wait, options) { + var leading = true, + trailing = true; + + if (!isFunction(func)) { + throw new TypeError; + } + if (options === false) { + leading = false; + } else if (isObject(options)) { + leading = 'leading' in options ? options.leading : leading; + trailing = 'trailing' in options ? options.trailing : trailing; + } + debounceOptions.leading = leading; + debounceOptions.maxWait = wait; + debounceOptions.trailing = trailing; + + return debounce(func, wait, debounceOptions); + } + + /** + * Creates a function that provides `value` to the wrapper function as its + * first argument. Additional arguments provided to the function are appended + * to those provided to the wrapper function. The wrapper is executed with + * the `this` binding of the created function. + * + * @static + * @memberOf _ + * @category Functions + * @param {*} value The value to wrap. + * @param {Function} wrapper The wrapper function. + * @returns {Function} Returns the new function. + * @example + * + * var p = _.wrap(_.escape, function(func, text) { + * return '

' + func(text) + '

'; + * }); + * + * p('Fred, Wilma, & Pebbles'); + * // => '

Fred, Wilma, & Pebbles

' + */ + function wrap(value, wrapper) { + return createWrapper(wrapper, 16, [value]); + } + + /*--------------------------------------------------------------------------*/ + + /** + * Creates a function that returns `value`. + * + * @static + * @memberOf _ + * @category Utilities + * @param {*} value The value to return from the new function. + * @returns {Function} Returns the new function. + * @example + * + * var object = { 'name': 'fred' }; + * var getter = _.constant(object); + * getter() === object; + * // => true + */ + function constant(value) { + return function() { + return value; + }; + } + + /** + * Produces a callback bound to an optional `thisArg`. If `func` is a property + * name the created callback will return the property value for a given element. + * If `func` is an object the created callback will return `true` for elements + * that contain the equivalent object properties, otherwise it will return `false`. + * + * @static + * @memberOf _ + * @category Utilities + * @param {*} [func=identity] The value to convert to a callback. + * @param {*} [thisArg] The `this` binding of the created callback. + * @param {number} [argCount] The number of arguments the callback accepts. + * @returns {Function} Returns a callback function. + * @example + * + * var characters = [ + * { 'name': 'barney', 'age': 36 }, + * { 'name': 'fred', 'age': 40 } + * ]; + * + * // wrap to create custom callback shorthands + * _.createCallback = _.wrap(_.createCallback, function(func, callback, thisArg) { + * var match = /^(.+?)__([gl]t)(.+)$/.exec(callback); + * return !match ? func(callback, thisArg) : function(object) { + * return match[2] == 'gt' ? object[match[1]] > match[3] : object[match[1]] < match[3]; + * }; + * }); + * + * _.filter(characters, 'age__gt38'); + * // => [{ 'name': 'fred', 'age': 40 }] + */ + function createCallback(func, thisArg, argCount) { + var type = typeof func; + if (func == null || type == 'function') { + return baseCreateCallback(func, thisArg, argCount); + } + // handle "_.pluck" style callback shorthands + if (type != 'object') { + return property(func); + } + var props = keys(func), + key = props[0], + a = func[key]; + + // handle "_.where" style callback shorthands + if (props.length == 1 && a === a && !isObject(a)) { + // fast path the common case of providing an object with a single + // property containing a primitive value + return function(object) { + var b = object[key]; + return a === b && (a !== 0 || (1 / a == 1 / b)); + }; + } + return function(object) { + var length = props.length, + result = false; + + while (length--) { + if (!(result = baseIsEqual(object[props[length]], func[props[length]], null, true))) { + break; + } + } + return result; + }; + } + + /** + * Converts the characters `&`, `<`, `>`, `"`, and `'` in `string` to their + * corresponding HTML entities. + * + * @static + * @memberOf _ + * @category Utilities + * @param {string} string The string to escape. + * @returns {string} Returns the escaped string. + * @example + * + * _.escape('Fred, Wilma, & Pebbles'); + * // => 'Fred, Wilma, & Pebbles' + */ + function escape(string) { + return string == null ? '' : String(string).replace(reUnescapedHtml, escapeHtmlChar); + } + + /** + * This method returns the first argument provided to it. + * + * @static + * @memberOf _ + * @category Utilities + * @param {*} value Any value. + * @returns {*} Returns `value`. + * @example + * + * var object = { 'name': 'fred' }; + * _.identity(object) === object; + * // => true + */ + function identity(value) { + return value; + } + + /** + * Adds function properties of a source object to the destination object. + * If `object` is a function methods will be added to its prototype as well. + * + * @static + * @memberOf _ + * @category Utilities + * @param {Function|Object} [object=lodash] object The destination object. + * @param {Object} source The object of functions to add. + * @param {Object} [options] The options object. + * @param {boolean} [options.chain=true] Specify whether the functions added are chainable. + * @example + * + * function capitalize(string) { + * return string.charAt(0).toUpperCase() + string.slice(1).toLowerCase(); + * } + * + * _.mixin({ 'capitalize': capitalize }); + * _.capitalize('fred'); + * // => 'Fred' + * + * _('fred').capitalize().value(); + * // => 'Fred' + * + * _.mixin({ 'capitalize': capitalize }, { 'chain': false }); + * _('fred').capitalize(); + * // => 'Fred' + */ + function mixin(object, source, options) { + var chain = true, + methodNames = source && functions(source); + + if (!source || (!options && !methodNames.length)) { + if (options == null) { + options = source; + } + ctor = lodashWrapper; + source = object; + object = lodash; + methodNames = functions(source); + } + if (options === false) { + chain = false; + } else if (isObject(options) && 'chain' in options) { + chain = options.chain; + } + var ctor = object, + isFunc = isFunction(ctor); + + forEach(methodNames, function(methodName) { + var func = object[methodName] = source[methodName]; + if (isFunc) { + ctor.prototype[methodName] = function() { + var chainAll = this.__chain__, + value = this.__wrapped__, + args = [value]; + + push.apply(args, arguments); + var result = func.apply(object, args); + if (chain || chainAll) { + if (value === result && isObject(result)) { + return this; + } + result = new ctor(result); + result.__chain__ = chainAll; + } + return result; + }; + } + }); + } + + /** + * Reverts the '_' variable to its previous value and returns a reference to + * the `lodash` function. + * + * @static + * @memberOf _ + * @category Utilities + * @returns {Function} Returns the `lodash` function. + * @example + * + * var lodash = _.noConflict(); + */ + function noConflict() { + context._ = oldDash; + return this; + } + + /** + * A no-operation function. + * + * @static + * @memberOf _ + * @category Utilities + * @example + * + * var object = { 'name': 'fred' }; + * _.noop(object) === undefined; + * // => true + */ + function noop() { + // no operation performed + } + + /** + * Gets the number of milliseconds that have elapsed since the Unix epoch + * (1 January 1970 00:00:00 UTC). + * + * @static + * @memberOf _ + * @category Utilities + * @example + * + * var stamp = _.now(); + * _.defer(function() { console.log(_.now() - stamp); }); + * // => logs the number of milliseconds it took for the deferred function to be called + */ + var now = isNative(now = Date.now) && now || function() { + return new Date().getTime(); + }; + + /** + * Converts the given value into an integer of the specified radix. + * If `radix` is `undefined` or `0` a `radix` of `10` is used unless the + * `value` is a hexadecimal, in which case a `radix` of `16` is used. + * + * Note: This method avoids differences in native ES3 and ES5 `parseInt` + * implementations. See http://es5.github.io/#E. + * + * @static + * @memberOf _ + * @category Utilities + * @param {string} value The value to parse. + * @param {number} [radix] The radix used to interpret the value to parse. + * @returns {number} Returns the new integer value. + * @example + * + * _.parseInt('08'); + * // => 8 + */ + var parseInt = nativeParseInt(whitespace + '08') == 8 ? nativeParseInt : function(value, radix) { + // Firefox < 21 and Opera < 15 follow the ES3 specified implementation of `parseInt` + return nativeParseInt(isString(value) ? value.replace(reLeadingSpacesAndZeros, '') : value, radix || 0); + }; + + /** + * Creates a "_.pluck" style function, which returns the `key` value of a + * given object. + * + * @static + * @memberOf _ + * @category Utilities + * @param {string} key The name of the property to retrieve. + * @returns {Function} Returns the new function. + * @example + * + * var characters = [ + * { 'name': 'fred', 'age': 40 }, + * { 'name': 'barney', 'age': 36 } + * ]; + * + * var getName = _.property('name'); + * + * _.map(characters, getName); + * // => ['barney', 'fred'] + * + * _.sortBy(characters, getName); + * // => [{ 'name': 'barney', 'age': 36 }, { 'name': 'fred', 'age': 40 }] + */ + function property(key) { + return function(object) { + return object[key]; + }; + } + + /** + * Produces a random number between `min` and `max` (inclusive). If only one + * argument is provided a number between `0` and the given number will be + * returned. If `floating` is truey or either `min` or `max` are floats a + * floating-point number will be returned instead of an integer. + * + * @static + * @memberOf _ + * @category Utilities + * @param {number} [min=0] The minimum possible value. + * @param {number} [max=1] The maximum possible value. + * @param {boolean} [floating=false] Specify returning a floating-point number. + * @returns {number} Returns a random number. + * @example + * + * _.random(0, 5); + * // => an integer between 0 and 5 + * + * _.random(5); + * // => also an integer between 0 and 5 + * + * _.random(5, true); + * // => a floating-point number between 0 and 5 + * + * _.random(1.2, 5.2); + * // => a floating-point number between 1.2 and 5.2 + */ + function random(min, max, floating) { + var noMin = min == null, + noMax = max == null; + + if (floating == null) { + if (typeof min == 'boolean' && noMax) { + floating = min; + min = 1; + } + else if (!noMax && typeof max == 'boolean') { + floating = max; + noMax = true; + } + } + if (noMin && noMax) { + max = 1; + } + min = +min || 0; + if (noMax) { + max = min; + min = 0; + } else { + max = +max || 0; + } + if (floating || min % 1 || max % 1) { + var rand = nativeRandom(); + return nativeMin(min + (rand * (max - min + parseFloat('1e-' + ((rand +'').length - 1)))), max); + } + return baseRandom(min, max); + } + + /** + * Resolves the value of property `key` on `object`. If `key` is a function + * it will be invoked with the `this` binding of `object` and its result returned, + * else the property value is returned. If `object` is falsey then `undefined` + * is returned. + * + * @static + * @memberOf _ + * @category Utilities + * @param {Object} object The object to inspect. + * @param {string} key The name of the property to resolve. + * @returns {*} Returns the resolved value. + * @example + * + * var object = { + * 'cheese': 'crumpets', + * 'stuff': function() { + * return 'nonsense'; + * } + * }; + * + * _.result(object, 'cheese'); + * // => 'crumpets' + * + * _.result(object, 'stuff'); + * // => 'nonsense' + */ + function result(object, key) { + if (object) { + var value = object[key]; + return isFunction(value) ? object[key]() : value; + } + } + + /** + * A micro-templating method that handles arbitrary delimiters, preserves + * whitespace, and correctly escapes quotes within interpolated code. + * + * Note: In the development build, `_.template` utilizes sourceURLs for easier + * debugging. See http://www.html5rocks.com/en/tutorials/developertools/sourcemaps/#toc-sourceurl + * + * For more information on precompiling templates see: + * http://lodash.com/custom-builds + * + * For more information on Chrome extension sandboxes see: + * http://developer.chrome.com/stable/extensions/sandboxingEval.html + * + * @static + * @memberOf _ + * @category Utilities + * @param {string} text The template text. + * @param {Object} data The data object used to populate the text. + * @param {Object} [options] The options object. + * @param {RegExp} [options.escape] The "escape" delimiter. + * @param {RegExp} [options.evaluate] The "evaluate" delimiter. + * @param {Object} [options.imports] An object to import into the template as local variables. + * @param {RegExp} [options.interpolate] The "interpolate" delimiter. + * @param {string} [sourceURL] The sourceURL of the template's compiled source. + * @param {string} [variable] The data object variable name. + * @returns {Function|string} Returns a compiled function when no `data` object + * is given, else it returns the interpolated text. + * @example + * + * // using the "interpolate" delimiter to create a compiled template + * var compiled = _.template('hello <%= name %>'); + * compiled({ 'name': 'fred' }); + * // => 'hello fred' + * + * // using the "escape" delimiter to escape HTML in data property values + * _.template('<%- value %>', { 'value': '\n\n\n'; + } + + var form = document.createElement('form'); + form.action = "http://plnkr.co/edit/?p=preview"; + form.method = "POST"; + form.target = "_blank"; + + document.body.appendChild(form); + + var textarea = document.createElement('textarea'); + textarea.name = "files[index.html]"; + textarea.value = html; + form.appendChild(textarea); + + var input = document.createElement('input'); + input.name = "description"; + input.value = "Fork from " + window.location; + form.appendChild(input); + + form.submit(); + form.remove(); + } + + + function normalizeHtml() { + var codeLc = code.toLowerCase(); + var hasBodyStart = codeLc.match(''); + var hasBodyEnd = codeLc.match(''); + var hasHtmlStart = codeLc.match(''); + var hasHtmlEnd = codeLc.match(''); + + var hasDocType = codeLc.match(/^\s*\n' + result; + } + + if (!hasHtmlEnd) { + result = result + '\n'; + } + + if (!hasBodyStart) { + result = result.replace('', '\n\n \n\n'); + } + + if (!hasBodyEnd) { + result = result.replace('', '\n\n'); + } + + result = '\n' + result; + + return result; + } + + + function run() { + if (isJS) { + runJS(); + } else { + runHTML(); + } + isFirstRun = false; + } + + +} + + +function addBlockHighlight(pre, lines) { + + if (!lines) { + return; + } + + var ranges = lines.replace(/\s+/g, '').split(','); + + /*jshint -W084 */ + for (var i = 0, range; range = ranges[i++];) { + range = range.split('-'); + + var start = +range[0], + end = +range[1] || start; + + + var mask = '' + + new Array(start + 1).join('\n') + + '' + new Array(end - start + 2).join('\n') + ''; + + pre.insertAdjacentHTML("afterBegin", mask); + } + +} + + +function addInlineHighlight(pre, ranges) { + + // select code with the language text, not block-highlighter + var codeElem = pre.querySelector('code[class*="language-"]'); + + ranges = ranges ? ranges.split(",") : []; + + for (var i = 0; i < ranges.length; i++) { + var piece = ranges[i].split(':'); + var lineNum = +piece[0], strRange = piece[1].split('-'); + var start = +strRange[0], end = +strRange[1]; + var mask = '' + + new Array(lineNum + 1).join('\n') + + new Array(start + 1).join(' ') + + '' + new Array(end - start + 1).join(' ') + ''; + + codeElem.insertAdjacentHTML("afterBegin", mask); + } +} + + +module.exports = CodeBox; diff --git a/client/prism/codeTabsBox.js b/client/prism/codeTabsBox.js new file mode 100755 index 000000000..cd561eb78 --- /dev/null +++ b/client/prism/codeTabsBox.js @@ -0,0 +1,97 @@ +var delegate = require('client/delegate'); +var addLineNumbers = require('./addLineNumbers'); + +function CodeTabsBox(elem) { + if (window.ebookType) { + return; + } + + this.elem = elem; + this.translateX = 0; + + this.switchesElem = elem.querySelector('[data-code-tabs-switches]'); + this.switchesElemItems = this.switchesElem.firstElementChild; + this.arrowLeft = elem.querySelector('[data-code-tabs-left]'); + this.arrowRight = elem.querySelector('[data-code-tabs-right]'); + + + this.arrowLeft.onclick = function(e) { + e.preventDefault(); + + this.translateX = Math.max(0, this.translateX - this.switchesElem.offsetWidth); + this.renderTranslate(); + }.bind(this); + + + this.arrowRight.onclick = function(e) { + e.preventDefault(); + + this.translateX = Math.min(this.translateX +this.switchesElem.offsetWidth, this.switchesElemItems.offsetWidth - this.switchesElem.offsetWidth); + this.renderTranslate(); + }.bind(this); + + this.delegate('.code-tabs__switch', 'click', this.onSwitchClick); +} + +CodeTabsBox.prototype.onSwitchClick = function(e) { + e.preventDefault(); + + var siblings = e.delegateTarget.parentNode.children; + var tabs = this.elem.querySelector('[data-code-tabs-content]').children; + + + var selectedIndex; + for(var i=0; i start/stop() +// 2) new Spinner() -> somewhere.append(spinner.elem) -> start/stop +function Spinner(options) { + options = options || {}; + this.elem = options.elem; + + this.size = options.size || 'medium'; + // any class to add to spinner (make spinner special here) + this.class = options.class ? (' ' + options.class) : ''; + + // any class to add to element (to hide it's content for instance) + this.elemClass = options.elemClass; + + if (this.size != 'medium' && this.size != 'small' && this.size != 'large') { + throw new Error("Unsupported size: " + this.size); + } + + if (!this.elem) { + this.elem = document.createElement('div'); + } +} + +Spinner.prototype.start = function() { + if (this.elemClass) { + this.elem.classList.toggle(this.elemClass); + } + + this.elem.insertAdjacentHTML('beforeend', ''); +}; + +Spinner.prototype.stop = function() { + var spinnerElem = this.elem.querySelector('.spinner'); + if (!spinnerElem) return; // already stopped or never started + + spinnerElem.remove(); + + if (this.elemClass) { + this.elem.classList.toggle(this.elemClass); + } +}; + +module.exports = Spinner; diff --git a/client/trackSticky.js b/client/trackSticky.js new file mode 100755 index 000000000..18a8179d6 --- /dev/null +++ b/client/trackSticky.js @@ -0,0 +1,67 @@ +module.exports = trackSticky; + + +function trackSticky() { + + var stickyElems = document.querySelectorAll('[data-sticky]'); + + for (var i = 0; i < stickyElems.length; i++) { + var stickyElem = stickyElems[i]; + var container = stickyElem.dataset.sticky ? + document.querySelector(stickyElem.dataset.sticky) : document.body; + + if (stickyElem.getBoundingClientRect().top < 0) { + // become fixed + if (stickyElem.style.cssText) { + // already fixed + // inertia: happens when scrolled fast too much to bottom + // http://ilyakantor.ru/screen/2015-02-24_1555.swf + return; + } + + var savedLeft = stickyElem.getBoundingClientRect().left; + var placeholder = createPlaceholder(stickyElem); + + stickyElem.parentNode.insertBefore(placeholder, stickyElem); + + container.appendChild(stickyElem); + stickyElem.classList.add('sticky'); + stickyElem.style.position = 'fixed'; + stickyElem.style.top = 0; + stickyElem.style.left = savedLeft + 'px'; + // zIndex < 1000, because it must be under an overlay, + // e.g. sitemap must show over the progress bar + stickyElem.style.zIndex = 101; + stickyElem.style.background = 'white'; // non-transparent to cover the text + stickyElem.style.margin = 0; + stickyElem.style.width = placeholder.offsetWidth + 'px'; // keep same width as before + stickyElem.placeholder = placeholder; + } else if (stickyElem.placeholder && stickyElem.placeholder.getBoundingClientRect().top > 0) { + // become non-fixed + stickyElem.style.cssText = ''; + stickyElem.classList.remove('sticky'); + stickyElem.placeholder.parentNode.insertBefore(stickyElem, stickyElem.placeholder); + stickyElem.placeholder.remove(); + + stickyElem.placeholder = null; + } + } + +} + +/** + * Creates a placeholder w/ same size & margin + * @param elem + * @returns {*|!HTMLElement} + */ +function createPlaceholder(elem) { + var placeholder = document.createElement('div'); + var style = getComputedStyle(elem); + placeholder.style.width = elem.offsetWidth + 'px'; + placeholder.style.marginLeft = style.marginLeft; + placeholder.style.marginRight = style.marginRight; + placeholder.style.height = elem.offsetHeight + 'px'; + placeholder.style.marginBottom = style.marginBottom; + placeholder.style.marginTop = style.marginTop; + return placeholder; +} \ No newline at end of file diff --git a/client/trackjs.js b/client/trackjs.js new file mode 100755 index 000000000..8d4c3f25b --- /dev/null +++ b/client/trackjs.js @@ -0,0 +1,43 @@ + +window._trackJs = { token: '8d286dd1cbf744b987a7226ee9a09324' }; +// COPYRIGHT (c) 2015 TrackJS LLC ALL RIGHTS RESERVED +(function(h,p,k){"use awesome";if(h.trackJs)h.console&&h.console.warn&&h.console.warn("TrackJS global conflict");else{var l=function(a,b,c,d,e){this.util=a;this.onError=b;this.onFault=c;this.options=e;e.enabled&&this.initialize(d)};l.prototype={initialize:function(a){a.addEventListener&&(this.wrapAndCatch(a.Element.prototype,"addEventListener",1),this.wrapAndCatch(a.XMLHttpRequest.prototype,"addEventListener",1),this.wrapRemoveEventListener(a.Element.prototype),this.wrapRemoveEventListener(a.XMLHttpRequest.prototype)); + this.wrapAndCatch(a,"setTimeout",0);this.wrapAndCatch(a,"setInterval",0)},wrapAndCatch:function(a,b,c){var d=this,e=a[b];d.util.hasFunction(e,"apply")&&(a[b]=function(){try{var f=Array.prototype.slice.call(arguments),g=f[c],u,h;if(d.options.bindStack)try{throw Error();}catch(k){h=k.stack,u=d.util.isoNow()}if("addEventListener"===b&&(this._trackJsEvt||(this._trackJsEvt=new m),this._trackJsEvt.getWrapped(f[0],g,f[2])))return;g&&d.util.hasFunction(g,"apply")&&(f[c]=function(){try{return g.apply(this, + arguments)}catch(a){throw d.onError("catch",a,{bindTime:u,bindStack:h}),d.util.wrapError(a);}},"addEventListener"===b&&this._trackJsEvt.add(f[0],g,f[2],f[c]));return e.apply(this,f)}catch(l){a[b]=e,d.onFault(l)}})},wrapRemoveEventListener:function(a){if(a&&a.removeEventListener&&this.util.hasFunction(a.removeEventListener,"call")){var b=a.removeEventListener;a.removeEventListener=function(a,d,e){if(this._trackJsEvt){var f=this._trackJsEvt.getWrapped(a,d,e);f&&this._trackJsEvt.remove(a,d,e);return b.call(this, + a,f,e)}return b.call(this,a,d,e)}}}};var m=function(){this.events=[]};m.prototype={add:function(a,b,c,d){-1>=this.indexOf(a,b,c)&&this.events.push([a,b,!!c,d])},remove:function(a,b,c){a=this.indexOf(a,b,!!c);0<=a&&this.events.splice(a,1)},getWrapped:function(a,b,c){a=this.indexOf(a,b,!!c);return 0<=a?this.events[a][3]:k},indexOf:function(a,b,c){for(var d=0;dthis.maxLength&&(this.appender=this.appender.slice(Math.max(this.appender.length-this.maxLength,0)))},add:function(a,b){var c=this.util.uuid();this.appender.push({key:c,category:a,value:b});this.truncate();return c},get:function(a,b){var c,d;for(d=0;db.indexOf("localhost:0")&&(this._trackJs={method:a,url:b});return c.apply(this,arguments)};a.prototype.send=function(){try{if(!this._trackJs)return d.apply(this,arguments);this._trackJs.logId=b.log.add("n",{startedOn:b.util.isoNow(),method:this._trackJs.method,url:this._trackJs.url});b.listenForNetworkComplete(this)}catch(a){b.onFault(a)}return d.apply(this,arguments)};return a},listenForNetworkComplete:function(a){var b=this; + b.window.ProgressEvent&&a.addEventListener&&a.addEventListener("readystatechange",function(){4===a.readyState&&b.finalizeNetworkEvent(a)},!0);a.addEventListener?a.addEventListener("load",function(){b.finalizeNetworkEvent(a);b.checkNetworkFault(a)},!0):setTimeout(function(){try{var c=a.onload;a.onload=function(){b.finalizeNetworkEvent(a);b.checkNetworkFault(a);"function"===typeof c&&b.util.hasFunction(c,"apply")&&c.apply(a,arguments)};var d=a.onerror;a.onerror=function(){b.finalizeNetworkEvent(a); + b.checkNetworkFault(a);"function"===typeof oldOnError&&d.apply(a,arguments)}}catch(e){b.onFault(e)}},0)},finalizeNetworkEvent:function(a){if(a._trackJs){var b=this.log.get("n",a._trackJs.logId);b&&(b.completedOn=this.util.isoNow(),b.statusCode=1223==a.status?204:a.status,b.statusText=1223==a.status?"No Content":a.statusText)}},checkNetworkFault:function(a){if(this.options.error&&400<=a.status&&1223!=a.status){var b=a._trackJs||{};this.onError("ajax",a.status+" "+a.statusText+": "+b.method+" "+b.url)}}, + report:function(){return this.log.all("n")}};var n=function(a){this.util=a;this.disabled=!1;this.throttleStats={attemptCount:0,throttledCount:0,lastAttempt:(new Date).getTime()};h.JSON&&h.JSON.stringify||(this.disabled=!0)};n.prototype={errorEndpoint:function(a,b){b=(b||"https://capture.trackjs.com/capture")+("?token="+a);return this.util.isBrowserIE()?"//"+b.split("://")[1]:b},usageEndpoint:function(a){return this.appendObjectAsQuery(a,"https://usage.trackjs.com/usage.gif")},trackerFaultEndpoint:function(a){return this.appendObjectAsQuery(a, + "https://usage.trackjs.com/fault.gif")},appendObjectAsQuery:function(a,b){b+="?";for(var c in a)a.hasOwnProperty(c)&&(b+=encodeURIComponent(c)+"="+encodeURIComponent(a[c])+"&");return b},getCORSRequest:function(a,b){var c=new h.XMLHttpRequest;"withCredentials"in c?(c.open(a,b),c.setRequestHeader("Content-Type","text/plain")):"undefined"!==typeof h.XDomainRequest?(c=new h.XDomainRequest,c.open(a,b)):c=null;return c},sendTrackerFault:function(a){this.throttle(a)||((new Image).src=this.trackerFaultEndpoint(a))}, + sendUsage:function(a){(new Image).src=this.usageEndpoint(a)},sendError:function(a,b){var c=this;if(!this.disabled&&!this.throttle(a))try{var d=this.getCORSRequest("POST",this.errorEndpoint(b));d.onreadystatechange=function(){4===d.readyState&&200!==d.status&&(c.disabled=!0)};d._trackJs=k;d.send(h.JSON.stringify(a))}catch(e){throw this.disabled=!0,e;}},throttle:function(a){var b=(new Date).getTime();this.throttleStats.attemptCount++;if(this.throttleStats.lastAttempt+1E3>=b){if(this.throttleStats.lastAttempt= + b,10 unless options.noGlobalEvents is set +// +// # Events +// triggers fail/success on load end: +// --> by default status=200 is ok, the others are failures +// --> options.normalStatuses = [201,409] allow given statuses +// --> fail event has .reason field +// --> success event has .result field +// +// # JSON +// --> send(object) calls JSON.stringify +// --> adds Accept: json (we want json) by default, unless options.raw +// if options.json or server returned json content type +// --> autoparse json +// --> fail if error +// +// # CSRF +// --> requests sends header X-XSRF-TOKEN from cookie + +function xhr(options) { + + var request = new XMLHttpRequest(); + + var method = options.method || 'GET'; + + var body = options.body; + var url = options.url; + + request.open(method, url, options.sync ? false : true); + + request.method = method; + + // token/header names same as angular $http for easier interop + var csrfCookie = getCsrfCookie(); + if (csrfCookie && !options.skipCsrf) { + request.setRequestHeader("X-XSRF-TOKEN", csrfCookie); + } + + if ({}.toString.call(body) == '[object Object]') { + // must be OPENed to setRequestHeader + request.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + body = JSON.stringify(body); + } + + if (!options.noDocumentEvents) { + request.addEventListener('loadstart', event => { + request.timeStart = Date.now(); + var e = wrapEvent('xhrstart', event); + document.dispatchEvent(e); + }); + request.addEventListener('loadend', event => { + var e = wrapEvent('xhrend', event); + document.dispatchEvent(e); + }); + request.addEventListener('success', event => { + var e = wrapEvent('xhrsuccess', event); + e.result = event.result; + document.dispatchEvent(e); + }); + request.addEventListener('fail', event => { + var e = wrapEvent('xhrfail', event); + e.reason = event.reason; + document.dispatchEvent(e); + }); + } + + if (!options.raw) { // means we want json + request.setRequestHeader("Accept", "application/json"); + } + + request.setRequestHeader('X-Requested-With', "XMLHttpRequest"); + + var normalStatuses = options.normalStatuses || [200]; + + function wrapEvent(name, e) { + var event = new CustomEvent(name); + event.originalEvent = e; + return event; + } + + function fail(reason, originalEvent) { + var e = wrapEvent("fail", originalEvent); + e.reason = reason; + request.dispatchEvent(e); + } + + function success(result, originalEvent) { + var e = wrapEvent("success", originalEvent); + e.result = result; + request.dispatchEvent(e); + } + + request.addEventListener("error", e => { + fail("Ошибка связи с сервером.", e); + }); + + request.addEventListener("timeout", e => { + fail("Превышено максимально допустимое время ожидания ответа от сервера.", e); + }); + + request.addEventListener("abort", e => { + fail("Запрос был прерван.", e); + }); + + request.addEventListener("load", e => { + if (!request.status) { // does that ever happen? + fail("Не получен ответ от сервера.", e); + return; + } + + if (normalStatuses.indexOf(request.status) == -1) { + fail("Ошибка на стороне сервера (код " + request.status + "), попытайтесь позднее", e); + return; + } + + var result = request.responseText; + var contentType = request.getResponseHeader("Content-Type"); + if (contentType.match(/^application\/json/) || options.json) { // autoparse json if WANT or RECEIVED json + try { + result = JSON.parse(result); + } catch (e) { + fail("Некорректный формат ответа от сервера", e); + return; + } + } + + success(result, e); + }); + + // defer to let other handlers be assigned + setTimeout(function() { + request.send(body); + }, 0); + + return request; + +} + + +document.addEventListener('xhrfail', function(event) { + new notification.Error(event.reason); +}); + + +module.exports = xhr; diff --git a/config/base.js b/config/base.js deleted file mode 100755 index 5e59fe976..000000000 --- a/config/base.js +++ /dev/null @@ -1,27 +0,0 @@ -module.exports = function() { - return { - "port": process.env.PORT || 3000, - "host": process.env.HOST || '0.0.0.0', - "mongoose": { - "uri": "mongodb://localhost/javascript", - "options": { - "server": { - "socketOptions": { - "keepAlive": 1 - }, - "poolSize": 5 - } - } - }, - "session": { - "keys": ["KillerIsJim"] - }, - template: { - path: process.cwd() + '/views', - options: { - 'default': 'jade', - 'cache': true - } - } - } -}; \ No newline at end of file diff --git a/config/env/development.js b/config/env/development.js deleted file mode 100755 index 188b92380..000000000 --- a/config/env/development.js +++ /dev/null @@ -1,5 +0,0 @@ -var _ = require('lodash'); - -module.exports = function(config) { - return config; -}; \ No newline at end of file diff --git a/config/env/production.js b/config/env/production.js deleted file mode 100755 index 188b92380..000000000 --- a/config/env/production.js +++ /dev/null @@ -1,5 +0,0 @@ -var _ = require('lodash'); - -module.exports = function(config) { - return config; -}; \ No newline at end of file diff --git a/config/env/test.js b/config/env/test.js deleted file mode 100755 index 10dfe4268..000000000 --- a/config/env/test.js +++ /dev/null @@ -1,9 +0,0 @@ -var _ = require('lodash'); - -module.exports = function(config) { - return _.merge(config, { - "mongoose": { - "uri": "mongodb://localhost/javascript_test" - } - }); -}; diff --git a/config/index.js b/config/index.js deleted file mode 100755 index defd7abe4..000000000 --- a/config/index.js +++ /dev/null @@ -1,10 +0,0 @@ - -if (!process.env.NODE_ENV) { - throw new Error("NODE_ENV environment variable is required"); -} - - -var base = require('./base')(); -var env = require('./env/' + process.env.NODE_ENV)(base); - -module.exports = env; diff --git a/controllers/frontpage.js b/controllers/frontpage.js deleted file mode 100644 index 8782b0332..000000000 --- a/controllers/frontpage.js +++ /dev/null @@ -1,7 +0,0 @@ - -exports.get = function *get (next) { - yield this.render('index', { - title: 'Hello, world' - }); -}; - diff --git a/course b/course new file mode 120000 index 000000000..5a0d9288a --- /dev/null +++ b/course @@ -0,0 +1 @@ +/js/course \ No newline at end of file diff --git a/dev b/dev new file mode 100755 index 000000000..c6d4a4497 --- /dev/null +++ b/dev @@ -0,0 +1,6 @@ +#!/bin/bash + +#export SITE_HOST=http://javascript.in +#export STATIC_HOST=http://javascript.in + +ASSET_VERSIONING=query NODE_ENV=development WATCH=1 gulp dev | bunyan -o short -l debug diff --git a/docs/fontello.zip b/docs/fontello.zip new file mode 100644 index 000000000..fd041490f Binary files /dev/null and b/docs/fontello.zip differ diff --git a/docs/logo/logo.171x60.2x.png b/docs/logo/logo.171x60.2x.png new file mode 100644 index 000000000..b67ea9df8 Binary files /dev/null and b/docs/logo/logo.171x60.2x.png differ diff --git a/docs/logo/logo.171x60.png b/docs/logo/logo.171x60.png new file mode 100644 index 000000000..4fb9ddf49 Binary files /dev/null and b/docs/logo/logo.171x60.png differ diff --git a/docs/logo/logo.gotowebinar.400x200.gif b/docs/logo/logo.gotowebinar.400x200.gif new file mode 100755 index 000000000..8d1246dc4 Binary files /dev/null and b/docs/logo/logo.gotowebinar.400x200.gif differ diff --git a/docs/logo/logo_big.png b/docs/logo/logo_big.png new file mode 100755 index 000000000..569314a0f Binary files /dev/null and b/docs/logo/logo_big.png differ diff --git a/docs/logo/logo_big_square.png b/docs/logo/logo_big_square.png new file mode 100755 index 000000000..3e40cb367 Binary files /dev/null and b/docs/logo/logo_big_square.png differ diff --git a/docs/logo/logo_interkassa.png b/docs/logo/logo_interkassa.png new file mode 100755 index 000000000..6dd717aac Binary files /dev/null and b/docs/logo/logo_interkassa.png differ diff --git a/docs/logo/logo_paypal_90x250.png b/docs/logo/logo_paypal_90x250.png new file mode 100755 index 000000000..e3c4366be Binary files /dev/null and b/docs/logo/logo_paypal_90x250.png differ diff --git a/docs/logo/logo_vk_150x150.png b/docs/logo/logo_vk_150x150.png new file mode 100755 index 000000000..4fc94534f Binary files /dev/null and b/docs/logo/logo_vk_150x150.png differ diff --git a/docs/logo/logo_vk_16x16.png b/docs/logo/logo_vk_16x16.png new file mode 100755 index 000000000..c9713943d Binary files /dev/null and b/docs/logo/logo_vk_16x16.png differ diff --git a/docs/payment.sketch b/docs/payment.sketch new file mode 100755 index 000000000..91fdb89e4 Binary files /dev/null and b/docs/payment.sketch differ diff --git a/docs/sitetoolbar__logo.nominify.svg b/docs/sitetoolbar__logo.nominify.svg new file mode 100755 index 000000000..1601d742c --- /dev/null +++ b/docs/sitetoolbar__logo.nominify.svg @@ -0,0 +1,87 @@ + + + + + + + + + diff --git a/docs/sitetoolbar__logo_small.nominify.svg b/docs/sitetoolbar__logo_small.nominify.svg new file mode 100755 index 000000000..252d811b3 --- /dev/null +++ b/docs/sitetoolbar__logo_small.nominify.svg @@ -0,0 +1,67 @@ + + + + + + + + + + diff --git a/download/.gitkeep b/download/.gitkeep new file mode 100755 index 000000000..e69de29bb diff --git a/ecosystem.json b/ecosystem.json new file mode 100755 index 000000000..01c73a146 --- /dev/null +++ b/ecosystem.json @@ -0,0 +1,59 @@ +{ + "apps": [ + { + "name": "Javascript.ru", + "script": "bin/server", + "instances": "2", + "node_args": "--harmony_classes", + "exec_mode": "cluster_mode", + "max_memory_restart": "2G", + "log_file": "/var/log/node/javascript.log", + "error_file": "/var/log/node/javascript-err.log", + "out_file": "/var/log/node/javascript-out.log", + "env": { + "HOST": "127.0.0.1", + "PORT": "3000", + "PM2_GRACEFUL_LISTEN_TIMEOUT": 1000, + "PM2_GRACEFUL_TIMEOUT": 5000 + }, + "env_production": { + "NODE_ENV": "production", + "SITE_HOST": "https://learn.javascript.ru", + "STATIC_HOST": "https://js.cx", + "ASSET_VERSIONING": "file" + } + } + ], + "deploy": { + "nightly": { + "user": "root", + "host": "nightly", + "pre-deploy": "cd /root/javascript-nodejs; bash ./pm2/pre_deploy.sh", + "path": "/js/javascript-nodejs", + "ref": "origin/production", + "repo": "https://github.com/iliakan/javascript-nodejs", + "test": "echo 'no test on deploy right now'", + "post-deploy": "NODE_LANG=ru bash ./pm2/post_deploy.sh" + }, + "yuri": { + "user": "root", + "host": "yuri", + "pre-deploy": "cd /root/javascript-nodejs; bash ./pm2/pre_deploy.sh", + "path": "/js/javascript-nodejs", + "ref": "origin/production", + "repo": "https://github.com/iliakan/javascript-nodejs", + "test": "echo 'no test on deploy right now'", + "post-deploy": "NODE_LANG=ru bash ./pm2/post_deploy.sh" + }, + "evolution": { + "user": "root", + "host": "evolution", + "pre-deploy": "cd /root/javascript-nodejs; bash ./pm2/pre_deploy.sh", + "path": "/js/javascript-nodejs", + "ref": "origin/production", + "repo": "https://github.com/iliakan/javascript-nodejs", + "test": "echo 'no test on deploy right now'", + "post-deploy": "NODE_LANG=en bash ./pm2/post_deploy.sh" + } + } +} diff --git a/edit b/edit new file mode 100755 index 000000000..e93f88173 --- /dev/null +++ b/edit @@ -0,0 +1,4 @@ +#!/bin/bash + +TUTORIAL_ROOT=/js/javascript-tutorial gulp --harmony_classes edit + diff --git a/error/httpError.js b/error/httpError.js deleted file mode 100755 index cd5a3dd78..000000000 --- a/error/httpError.js +++ /dev/null @@ -1,19 +0,0 @@ -var util = require('util'); -var http = require('http'); - -// ошибки для выдачи посетителю -function HttpError(status, message) { - Error.apply(this, arguments); - Error.captureStackTrace(this, HttpError); - - this.status = status; - this.message = message || http.STATUS_CODES[status] || "Error"; -} - -util.inherits(HttpError, Error); -module.exports = HttpError; - -HttpError.prototype.name = 'HttpError'; - - - diff --git a/error/index.js b/error/index.js deleted file mode 100755 index 3522cd29c..000000000 --- a/error/index.js +++ /dev/null @@ -1,9 +0,0 @@ -var fs = require('fs'); -var path = require('path'); - -var files = fs.readdirSync(__dirname); -files.forEach(function(file) { - if (file == 'index.js') return; - var errorClass = require(path.join(__dirname, file)); - module.exports[errorClass.prototype.name] = errorClass; -}); diff --git a/files/Readme.md b/files/Readme.md new file mode 100755 index 000000000..484c968f8 --- /dev/null +++ b/files/Readme.md @@ -0,0 +1,3 @@ +Restricted files, not directly accessible from outside. + +A user can download these using ExpiringDownloadLink or by other non-direct means. diff --git a/fixture/init/course.js b/fixture/init/course.js new file mode 100644 index 000000000..24b3cae61 --- /dev/null +++ b/fixture/init/course.js @@ -0,0 +1,46 @@ +const mongoose = require('mongoose'); + +var Course = require('courses').Course; +var CourseGroup = require('courses').CourseGroup; + +exports.Course = [ + { + "_id": "543250000000000000000002", + slug: "js", + videoKeyTag: "js", + title: "Курс JavaScript/DOM/интерфейсы", + isListed: true, + weight: 1 + } +]; + + +exports.CourseGroup = [ + { + course: '543250000000000000000002', + dateStart: new Date(2016, 0, 1), + dateEnd: new Date(2016, 10, 10), + timeDesc: "пн/чт 19:30 - 21:00 GMT+3", + slug: 'js-1', + price: 1, + participantsLimit: 30, + webinarId: '123', + isListed: true, + isOpenForSignup: true, + title: "Курс JavaScript/DOM/интерфейсы (01.01)" + }, + { + course: '543250000000000000000002', + dateStart: new Date(2016, 5, 1), + dateEnd: new Date(2016, 11, 10), + timeDesc: "пн/чт 21:30 - 23:00 GMT+3", + slug: 'js-2', + price: 1, + webinarId: '456', + participantsLimit: 30, + isListed: true, + isOpenForSignup: true, + title: "Курс JavaScript/DOM/интерфейсы (06.01)" + } +]; + diff --git a/fixture/init/index.js b/fixture/init/index.js new file mode 100755 index 000000000..d96bed52f --- /dev/null +++ b/fixture/init/index.js @@ -0,0 +1,8 @@ +const _ = require('lodash'); + +module.exports = _.merge( + require('./user'), + require('./newsletter'), + require('./orderTemplate'), + require('./course') +); diff --git a/fixture/init/newsletter.js b/fixture/init/newsletter.js new file mode 100755 index 000000000..7aef0a0b5 --- /dev/null +++ b/fixture/init/newsletter.js @@ -0,0 +1,25 @@ +const mongoose = require('mongoose'); + +var Newsletter = require('newsletter').Newsletter; + +exports.Newsletter = [ + { + title: "Курс и скринкасты по Node.JS / IO.JS", + slug: "nodejs", + period: "несколько раз в год", + weight: 1 + }, + { + title: "Курс JavaScript/DOM/интерфейсы", + period: "раз в 1.5-2 месяца", + weight: 0, + slug: "js" + }, + { + title: "Продвинутые курсы, мастер-классы и конференции по JavaScript", + period: "редко", + weight: 2, + slug: "advanced" + } +]; + diff --git a/fixture/init/orderTemplate.js b/fixture/init/orderTemplate.js new file mode 100644 index 000000000..95fc502f3 --- /dev/null +++ b/fixture/init/orderTemplate.js @@ -0,0 +1,44 @@ +const mongoose = require('mongoose'); + +var OrderTemplate = require('payments').OrderTemplate; + +exports.OrderTemplate = [ + { + title: "Язык JavaScript", + description: "600+ стр, PDF + EPUB (10Mb)", + slug: "js", + module: 'ebook', + weight: 1, + amount: 1, + data: { + file: "tutorial/js.zip" + } + }, + { + title: "Документ, события, интерфейсы", + description: "380+ стр, PDF + EPUB (8Mb)", + slug: "ui", + module: 'ebook', + weight: 2, + amount: 1, + data: { + file: "tutorial/ui.zip" + } + }, + { + title: "Две книги сразу", + description: "2xPDF + 2xEPUB, (18Mb)", + slug: "js-ui", + module: 'ebook', + weight: 3, + amount: 1, + data: { + file: "tutorial/js-ui.zip" + } + }, + { + module: 'courses', + slug: 'course' + } +]; + diff --git a/fixture/init/user.js b/fixture/init/user.js new file mode 100755 index 000000000..2c699e3f9 --- /dev/null +++ b/fixture/init/user.js @@ -0,0 +1,18 @@ +const mongoose = require('mongoose'); + +var User = require('users').User; + +exports.User = [{ + email: "mk@javascript.ru", + displayName: "Tester", + profileName: 'tester', + password: "123456", + verifiedEmail: true +}, { + email: "iliakan@javascript.ru", + displayName: "Ilya Kantor", + profileName: 'iliakan', + password: "123456", + verifiedEmail: true +}]; + diff --git a/fixture/tutorial.js b/fixture/tutorial.js new file mode 100755 index 000000000..6085bd5cc --- /dev/null +++ b/fixture/tutorial.js @@ -0,0 +1,78 @@ +require('users').User; +require('tutorial').Article; + +exports.User = [ + { "_id": "000000000000000000000001", + "created": new Date(2014,0,1), + "displayName": "ilya kantor", + "email": "iliakan@gmail.com", + "password": "1234", + "verifiedEmail": true + }, + { "_id": "000000000000000000000002", + "created": new Date(2014,0,1), + "displayName": "tester", + "email": "tester@mail.com", + "password": "1234", + "verifiedEmail": true + }, + { "_id": "000000000000000000000003", + "created": new Date(2014,0,1), + "displayName": "vasya", + "email": "vasya@mail.com", + "password": "1234", + "verifiedEmail": false + } +]; + + +exports.Article = [ + { + "_id": '000000000000000000000010', + "isFolder" : true, + "content" : "# JS", + "weight" : 1, + "slug" : "js", + "title" : "JavaScript" + }, + { + "_id": '000000000000000000000011', + "isFolder" : true, + "content" : "# UI", + "weight" : 2, + "slug" : "ui", + "title" : "Интерфейсы" + }, + { + "_id": '000000000000000000000012', + "isFolder" : true, + "content" : "# UI", + "weight" : 3, + "slug" : "more", + "title" : "Дополнительно" + }, + { + "parent" : '000000000000000000000010', + "_id": '000000000000000000000013', + "content" : "# Введение в JavaScript\n\nДавайте посмотрим, что такого особенного в JavaScript, почему именно он, и какие еще технологии существуют, кроме JavaScript.\n[cut]\n## Что такое JavaScript? \n\n*JavaScript* изначально создавался для того, чтобы сделать web-странички \"живыми\". \nПрограммы на этом языке называются *скриптами*. Они подключаются напрямую к HTML и, как только загружается страничка -- тут же выполняются.\n\n**Программы на JavaScript -- обычный текст**. Они не требуют какой-то специальной подготовки.\n\nВ этом плане JavaScript сильно отличается от другого языка, который называется Java.\n\n[smart header=\"Почему JavaScript?\"]\nКогда создавался язык JavaScript, у него изначально было другое название: \"LiveScript\". Но тогда был очень популярен язык Java, и маркетологи решили, что схожее название сделает новый язык более популярным.\n\nПланировалось, что JavaScript будет эдаким \"младшим братом\" Java. Однако, история распорядилась по-своему, JavaScript сильно вырос, и сейчас это совершенно независимый язык, со своей спецификацией, которая называется ECMAScript, и к Java не имеет никакого отношения.\n\nУ него много особенностей, которые усложняют освоение, но по ходу учебника мы с ними разберемся.\n[/smart]\n\nЧтобы читать и выполнять текст на JavaScript, нужна специальная программа -- [интерпретатор](http://ru.wikipedia.org/wiki/%D0%98%D0%BD%D1%82%D0%B5%D1%80%D0%BF%D1%80%D0%B5%D1%82%D0%B0%D1%82%D0%BE%D1%80). Процесс выполнения скрипта называют *\"интерпретацией\"*.\n\n[smart header=\"Компиляция и интерпретация, для программистов\"]\nСтрого говоря, для выполнения программ существуют \"компиляторы\" и \"интерпретаторы\". \n\nКомпиляторы преобразуют программу в машинный код. Этот машинный код затем распространяется и запускается. \n\nА интерпретаторы, в частности, встроенный JS-интерпретатор браузера -- получают программу в виде исходного кода. При этом распространяется именно сам исходный код (скрипт).\n\nСовременные интерпретаторы перед выполнением преобразуют JavaScript в машинный код или близко к нему, а уже затем выполняют. \n[/smart]\n\nВо все основные браузеры встроен интерпретатор JavaScript, именно поэтому они могут выполнять скрипты на странице.\n\nНо, разумеется, этим возможности JavaScript не ограничены. Это полноценный язык, программы на котором можно запускать и на сервере, и даже в стиральной машинке, если в ней установлен соответствующий интерпретатор.\n\n## Что умеет JavaScript? \n\nСовременный JavaScript -- это \"безопасный\" язык программирования общего назначения. Он не предоставляет низкоуровневых средств работы с памятью, процессором, так как изначально был ориентирован на браузеры, в которых это не требуется.\n\nЧто же касается остальных возможностей -- они зависят от окружения, в котором запущен JavaScript. \n\nВ браузере JavaScript умеет делать все, что относится к манипуляции со страницей, взаимодействию с посетителем и, в какой-то мере, с сервером: \n\n
    \n
  • Создавать новые HTML-теги, удалять существующие, менять стили элементов, прятать, показывать элементы и т.п.
  • \n
  • Реагировать на действия посетителя, обрабатывать клики мыши, перемещение курсора, нажатие на клавиатуру и т.п.
  • \n
  • Посылать запросы на сервер и загружать данные без перезагрузки страницы(эта технология называется "AJAX").
  • \n
  • Получать и устанавливать cookie, запрашивать данные, выводить сообщения...
  • \n
  • ...и многое, многое другое!
  • \n
\n\n## Что НЕ умеет JavaScript? \n\nJavaScript -- быстрый и мощный язык, но браузер накладывает на его исполнение некоторые ограничения.. \n\nЭто сделано для безопасности пользователей, чтобы злоумышленник не мог с помощью JavaScript получить личные данные или как-то навредить компьютеру пользователя. \n\nЭтих ограничений нет там, где JavaScript используется вне браузера, например на сервере. Кроме того, различные браузеры предоставляют свои механизмы по установке плагинов и расширений, которые обладают расширенными возможностями, но требуют специальных действий по установке от пользователя\n\n**Большинство возможностей JavaScript в браузере ограничено текущим окном и страницей.**\n\n\n\n
    \n
  • JavaScript не может читать/записывать произвольные файлы на жесткий диск, копировать их или вызывать программы. Он не имеет прямого доступа к операционной системе.\n\nСовременные браузеры могут работать с файлами, но эта возможность ограничена специально выделенной директорией -- *\"песочницей\"*. Возможности по доступу к устройствам также прорабатываются в современных стандартах и частично доступны в некоторых браузерах.\n
  • \n
  • JavaScript, работающий в одной вкладке, не может общаться с другими вкладками и окнами, за исключением случая, когда он сам открыл это окно или несколько вкладок из одного источника (одинаковый домен, порт, протокол).\n\nЕсть способы это обойти, и они раскрыты в учебнике, но они требуют внедрения специального кода на оба документа, которые находятся в разных вкладках или окнах. Без него, из соображений безопасности, залезть из одной вкладки в другую при помощи JavaScript нельзя. \n
  • \n
  • Из JavaScript можно легко посылать запросы на сервер, с которого пришла страница. Запрос на другой домен тоже возможен, но менее удобен, т.к. и здесь есть ограничения безопасности. \n
  • \n
\n\n## В чем уникальность JavaScript? \n\nЕсть как минимум *три* замечательных особенности JavaScript:\n\n[compare]\n+Полная интеграция с HTML/CSS.\n+Простые вещи делаются просто.\n+Поддерживается всеми распространенными браузерами и включен по умолчанию.\n[/compare]\n\n**Этих трёх вещей одновременно нет больше ни в одной браузерной технологии.** Поэтому JavaScript и является самым распространенным средством создания браузерных интерфейсов.\n\n## Тенденции развития. \n\nПеред тем, как вы планируете изучить новую технологию, полезно ознакомиться с ее развитием и перспективами. Здесь в JavaScript всё более чем хорошо.\n\n### HTML 5\n\n*HTML 5* -- эволюция стандарта HTML, добавляющая новые теги и, что более важно, ряд новых возможностей браузерам.\n\nВот несколько примеров:\n
    \n
  • Чтение/запись файлов на диск (в специальной \"песочнице\", то есть не любые).
  • \n
  • Встроенная в браузер база данных, которая позволяет хранить данные на компьютере пользователя.
  • \n
  • Многозадачность с одновременным использованием нескольких ядер процессора.
  • \n
  • Проигрывание видео/аудио, без Flash.
  • \n
  • 2d и 3d-рисование с аппаратной поддержкой, как в современных играх.
  • \n
\n\nМногие возможности HTML5 всё ещё в разработке, но браузеры постепенно начинают их поддерживать.\n\n[summary]Тенденция: JavaScript становится всё более и более мощным и возможности браузера растут в сторону десктопных приложений.[/summary]\n\n### EcmaScript 6\n\nСам язык JavaScript улучшается. Современный стандарт EcmaScript 5 включает в себя новые возможности для разработки, EcmaScript 6 будет шагом вперёд в улучшении синтаксиса языка.\n\nСовременные браузеры улучшают свои движки, чтобы увеличить скорость исполнения JavaScript, исправляют баги и стараются следовать стандартам.\n\n[summary]Тенденция: JavaScript становится всё быстрее и стабильнее.[/summary]\n\nОчень важно то, что новые стандарты HTML5 и ECMAScript сохраняют максимальную совместимость с предыдущими версиями. Это позволяет избежать неприятностей с уже существующими приложениями.\n\nВпрочем, небольшая проблема с HTML5 всё же есть. Иногда браузеры стараются включить новые возможности, которые еще не полностью описаны в стандарте, но настолько интересны, что разработчики просто не могут ждать. \n\n...Однако, со временем стандарт меняется и браузерам приходится подстраиваться к нему, что может привести к ошибкам в уже написанном (старом) коде. Поэтому следует дважды подумать перед тем, как применять на практике такие \"супер-новые\" решения.\n\nПри этом все браузеры сходятся к стандарту, и различий между ними уже гораздо меньше, чем всего лишь несколько лет назад.\n\n[summary]Тенденция: всё идет к полной совместимости со стандартом.[/summary]\n\n## Недостатки JavaScript\n\nЗачастую, недостатки подходов и технологий -- это обратная сторона их полезности. Стоит ли упрекать молоток в том, что он -- тяжелый? Да, неудобно, зато гвозди забиваются лучше.\n\nВ JavaScript, однако, есть вполне объективные недоработки, связанные с тем, что язык, по выражению его автора (Brendan Eich) делался \"за 10 бессонных дней и ночей\". Поэтому некоторые моменты продуманы плохо, есть и откровенные ошибки (которые признает тот же Brendan). \n\nКонкретные примеры мы увидим в дальнейшем, т.к. их удобнее обсуждать в процессе освоения языка.\n\nПока что нам важно знать, что некоторые \"странности\" языка не являются чем-то очень умным, а просто не были достаточно хорошо продуманы в своё время. В этом учебнике мы будем обращать особое внимание на основные недоработки и \"грабли\". Ничего критичного в них нет, если знаешь -- не наступишь.\n\n**В новых версиях JavaScript (ECMAScript) эти недостатки постепенно убирают.** \n\nПроцесс внедрения небыстрый, в первую очередь из-за старых версий IE, но они постепенно вымирают. Современный IE в этом отношении несравнимо лучше.", "isFolder" : false, + "weight" : 1, + "slug" : "article", + "title" : "Введение в JavaScript" + }, + { + "parent" : '000000000000000000000011', + "_id": '000000000000000000000014', + "content" : "# Введение в JavaScript\n\nДавайте посмотрим, что такого особенного в JavaScript, почему именно он, и какие еще технологии существуют, кроме JavaScript.\n[cut]\n## Что такое JavaScript? \n\n*JavaScript* изначально создавался для того, чтобы сделать web-странички \"живыми\". \nПрограммы на этом языке называются *скриптами*. Они подключаются напрямую к HTML и, как только загружается страничка -- тут же выполняются.\n\n**Программы на JavaScript -- обычный текст**. Они не требуют какой-то специальной подготовки.\n\nВ этом плане JavaScript сильно отличается от другого языка, который называется Java.\n\n[smart header=\"Почему JavaScript?\"]\nКогда создавался язык JavaScript, у него изначально было другое название: \"LiveScript\". Но тогда был очень популярен язык Java, и маркетологи решили, что схожее название сделает новый язык более популярным.\n\nПланировалось, что JavaScript будет эдаким \"младшим братом\" Java. Однако, история распорядилась по-своему, JavaScript сильно вырос, и сейчас это совершенно независимый язык, со своей спецификацией, которая называется ECMAScript, и к Java не имеет никакого отношения.\n\nУ него много особенностей, которые усложняют освоение, но по ходу учебника мы с ними разберемся.\n[/smart]\n\nЧтобы читать и выполнять текст на JavaScript, нужна специальная программа -- [интерпретатор](http://ru.wikipedia.org/wiki/%D0%98%D0%BD%D1%82%D0%B5%D1%80%D0%BF%D1%80%D0%B5%D1%82%D0%B0%D1%82%D0%BE%D1%80). Процесс выполнения скрипта называют *\"интерпретацией\"*.\n\n[smart header=\"Компиляция и интерпретация, для программистов\"]\nСтрого говоря, для выполнения программ существуют \"компиляторы\" и \"интерпретаторы\". \n\nКомпиляторы преобразуют программу в машинный код. Этот машинный код затем распространяется и запускается. \n\nА интерпретаторы, в частности, встроенный JS-интерпретатор браузера -- получают программу в виде исходного кода. При этом распространяется именно сам исходный код (скрипт).\n\nСовременные интерпретаторы перед выполнением преобразуют JavaScript в машинный код или близко к нему, а уже затем выполняют. \n[/smart]\n\nВо все основные браузеры встроен интерпретатор JavaScript, именно поэтому они могут выполнять скрипты на странице.\n\nНо, разумеется, этим возможности JavaScript не ограничены. Это полноценный язык, программы на котором можно запускать и на сервере, и даже в стиральной машинке, если в ней установлен соответствующий интерпретатор.\n\n## Что умеет JavaScript? \n\nСовременный JavaScript -- это \"безопасный\" язык программирования общего назначения. Он не предоставляет низкоуровневых средств работы с памятью, процессором, так как изначально был ориентирован на браузеры, в которых это не требуется.\n\nЧто же касается остальных возможностей -- они зависят от окружения, в котором запущен JavaScript. \n\nВ браузере JavaScript умеет делать все, что относится к манипуляции со страницей, взаимодействию с посетителем и, в какой-то мере, с сервером: \n\n
    \n
  • Создавать новые HTML-теги, удалять существующие, менять стили элементов, прятать, показывать элементы и т.п.
  • \n
  • Реагировать на действия посетителя, обрабатывать клики мыши, перемещение курсора, нажатие на клавиатуру и т.п.
  • \n
  • Посылать запросы на сервер и загружать данные без перезагрузки страницы(эта технология называется "AJAX").
  • \n
  • Получать и устанавливать cookie, запрашивать данные, выводить сообщения...
  • \n
  • ...и многое, многое другое!
  • \n
\n\n## Что НЕ умеет JavaScript? \n\nJavaScript -- быстрый и мощный язык, но браузер накладывает на его исполнение некоторые ограничения.. \n\nЭто сделано для безопасности пользователей, чтобы злоумышленник не мог с помощью JavaScript получить личные данные или как-то навредить компьютеру пользователя. \n\nЭтих ограничений нет там, где JavaScript используется вне браузера, например на сервере. Кроме того, различные браузеры предоставляют свои механизмы по установке плагинов и расширений, которые обладают расширенными возможностями, но требуют специальных действий по установке от пользователя\n\n**Большинство возможностей JavaScript в браузере ограничено текущим окном и страницей.**\n\n\n\n
    \n
  • JavaScript не может читать/записывать произвольные файлы на жесткий диск, копировать их или вызывать программы. Он не имеет прямого доступа к операционной системе.\n\nСовременные браузеры могут работать с файлами, но эта возможность ограничена специально выделенной директорией -- *\"песочницей\"*. Возможности по доступу к устройствам также прорабатываются в современных стандартах и частично доступны в некоторых браузерах.\n
  • \n
  • JavaScript, работающий в одной вкладке, не может общаться с другими вкладками и окнами, за исключением случая, когда он сам открыл это окно или несколько вкладок из одного источника (одинаковый домен, порт, протокол).\n\nЕсть способы это обойти, и они раскрыты в учебнике, но они требуют внедрения специального кода на оба документа, которые находятся в разных вкладках или окнах. Без него, из соображений безопасности, залезть из одной вкладки в другую при помощи JavaScript нельзя. \n
  • \n
  • Из JavaScript можно легко посылать запросы на сервер, с которого пришла страница. Запрос на другой домен тоже возможен, но менее удобен, т.к. и здесь есть ограничения безопасности. \n
  • \n
\n\n## В чем уникальность JavaScript? \n\nЕсть как минимум *три* замечательных особенности JavaScript:\n\n[compare]\n+Полная интеграция с HTML/CSS.\n+Простые вещи делаются просто.\n+Поддерживается всеми распространенными браузерами и включен по умолчанию.\n[/compare]\n\n**Этих трёх вещей одновременно нет больше ни в одной браузерной технологии.** Поэтому JavaScript и является самым распространенным средством создания браузерных интерфейсов.\n\n## Тенденции развития. \n\nПеред тем, как вы планируете изучить новую технологию, полезно ознакомиться с ее развитием и перспективами. Здесь в JavaScript всё более чем хорошо.\n\n### HTML 5\n\n*HTML 5* -- эволюция стандарта HTML, добавляющая новые теги и, что более важно, ряд новых возможностей браузерам.\n\nВот несколько примеров:\n
    \n
  • Чтение/запись файлов на диск (в специальной \"песочнице\", то есть не любые).
  • \n
  • Встроенная в браузер база данных, которая позволяет хранить данные на компьютере пользователя.
  • \n
  • Многозадачность с одновременным использованием нескольких ядер процессора.
  • \n
  • Проигрывание видео/аудио, без Flash.
  • \n
  • 2d и 3d-рисование с аппаратной поддержкой, как в современных играх.
  • \n
\n\nМногие возможности HTML5 всё ещё в разработке, но браузеры постепенно начинают их поддерживать.\n\n[summary]Тенденция: JavaScript становится всё более и более мощным и возможности браузера растут в сторону десктопных приложений.[/summary]\n\n### EcmaScript 6\n\nСам язык JavaScript улучшается. Современный стандарт EcmaScript 5 включает в себя новые возможности для разработки, EcmaScript 6 будет шагом вперёд в улучшении синтаксиса языка.\n\nСовременные браузеры улучшают свои движки, чтобы увеличить скорость исполнения JavaScript, исправляют баги и стараются следовать стандартам.\n\n[summary]Тенденция: JavaScript становится всё быстрее и стабильнее.[/summary]\n\nОчень важно то, что новые стандарты HTML5 и ECMAScript сохраняют максимальную совместимость с предыдущими версиями. Это позволяет избежать неприятностей с уже существующими приложениями.\n\nВпрочем, небольшая проблема с HTML5 всё же есть. Иногда браузеры стараются включить новые возможности, которые еще не полностью описаны в стандарте, но настолько интересны, что разработчики просто не могут ждать. \n\n...Однако, со временем стандарт меняется и браузерам приходится подстраиваться к нему, что может привести к ошибкам в уже написанном (старом) коде. Поэтому следует дважды подумать перед тем, как применять на практике такие \"супер-новые\" решения.\n\nПри этом все браузеры сходятся к стандарту, и различий между ними уже гораздо меньше, чем всего лишь несколько лет назад.\n\n[summary]Тенденция: всё идет к полной совместимости со стандартом.[/summary]\n\n## Недостатки JavaScript\n\nЗачастую, недостатки подходов и технологий -- это обратная сторона их полезности. Стоит ли упрекать молоток в том, что он -- тяжелый? Да, неудобно, зато гвозди забиваются лучше.\n\nВ JavaScript, однако, есть вполне объективные недоработки, связанные с тем, что язык, по выражению его автора (Brendan Eich) делался \"за 10 бессонных дней и ночей\". Поэтому некоторые моменты продуманы плохо, есть и откровенные ошибки (которые признает тот же Brendan). \n\nКонкретные примеры мы увидим в дальнейшем, т.к. их удобнее обсуждать в процессе освоения языка.\n\nПока что нам важно знать, что некоторые \"странности\" языка не являются чем-то очень умным, а просто не были достаточно хорошо продуманы в своё время. В этом учебнике мы будем обращать особое внимание на основные недоработки и \"грабли\". Ничего критичного в них нет, если знаешь -- не наступишь.\n\n**В новых версиях JavaScript (ECMAScript) эти недостатки постепенно убирают.** \n\nПроцесс внедрения небыстрый, в первую очередь из-за старых версий IE, но они постепенно вымирают. Современный IE в этом отношении несравнимо лучше.", "isFolder" : false, + "weight" : 2, + "slug" : "article2", + "title" : "Введение в JavaScript" + }, + { + "parent" : '000000000000000000000012', + "_id": '000000000000000000000015', + "content" : "# И ещё про JavaScript\n\n ...", "isFolder" : false, + "weight" : 3, + "slug" : "article3", + "title" : "И ещё про JavaScript" + } +]; diff --git a/gulpfile.js b/gulpfile.js new file mode 100755 index 000000000..d8d81b077 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,186 @@ +/** + * NB: All tasks are initialized lazily, even plugins are required lazily, + * running 1 task does not require all tasks' files + */ + +const gulp = require('gulp'); +const path = require('path'); +const fs = require('fs'); +const assert = require('assert'); +const runSequence = require('run-sequence'); + +const linkModules = require('./modules/linkModules'); + +linkModules({ + src: ['client', 'styles', 'modules/*', 'handlers/*', 'extra/handlers/*'] +}); + +require('cls'); // init CLS namespace once + +const config = require('config'); +const mongoose = require('lib/mongoose'); + +process.on('uncaughtException', function(err) { + console.error(err.message, err.stack, err.errors); + process.exit(255); +}); + +const jsSources = [ + 'handlers/**/*.js', 'modules/**/*.js', 'tasks/**/*.js', '*.js' +]; + +function lazyRequireTask(path) { + var args = [].slice.call(arguments, 1); + return function(callback) { + var task = require(path).apply(this, args); + + return task(callback); + }; +} + +/* the task does nothing, used to run linkModules only */ +gulp.task('init'); + +gulp.task('lint-once', lazyRequireTask('./tasks/lint', { src: jsSources })); +gulp.task('lint-or-die', lazyRequireTask('./tasks/lint', { src: jsSources, dieOnError: true })); + +// usage: gulp db:load --from fixture/init --harmony +gulp.task('db:load', lazyRequireTask('./tasks/dbLoad')); +gulp.task('db:clear', lazyRequireTask('./tasks/dbClear')); +gulp.task('migrate:play', lazyRequireTask('./tasks/migratePlay')); + + +gulp.task("nodemon", lazyRequireTask('./tasks/nodemon', { + // shared client/server code has require('template.jade) which precompiles template on run + // so I have to restart server to pickup the template change + ext: "js,jade", + + nodeArgs: ['--debug', '--harmony_classes'], + script: "./bin/server", + ignore: '**/client/', // ignore handlers' client code + watch: ["handlers", "modules"] +})); + +gulp.task("client:livereload", lazyRequireTask("./tasks/livereload", { + // watch files *.*, not directories, no need to reload for new/removed files, + // we're only interested in changes + + watch: [ + "public/pack/**/*.*", + // not using this file, using only styles.css (extracttextplugin) + "!public/pack/styles.js", + // this file changes every time we update styles + // don't watch it, so that the page won't reload fully on style change + "!public/pack/head.js" + ] +})); + +gulp.task("tutorial:import:watch", lazyRequireTask('tutorial/tasks/importWatch', { + root: process.env.TUTORIAL_ROOT +})); + +gulp.task("tutorial:beautify", lazyRequireTask('tutorial/tasks/beautify', { + root: process.env.TUTORIAL_ROOT +})); + +gulp.task("tutorial:edit", lazyRequireTask('tutorial/tasks/edit')); + +gulp.task("newsletter:send", lazyRequireTask('newsletter/tasks/send')); +gulp.task("newsletter:createLetters", lazyRequireTask('newsletter/tasks/createLetters')); + +var testSrcs = ['{handlers,modules}/**/test/**/*.js']; +// on Travis, keys are required for E2E Selenium tests +// for PRs there are no keys, so we disable E2E +if (!process.env.TEST_E2E || process.env.CI && process.env.TRAVIS_SECURE_ENV_VARS=="false") { + testSrcs.push('!{handlers,modules}/**/test/e2e/*.js'); +} + +gulp.task("test", lazyRequireTask('./tasks/test', { + src: testSrcs, + reporter: 'spec', + timeout: 100000 // big timeout for webdriver e2e tests +})); + + +gulp.task('watch', lazyRequireTask('./tasks/watch', { + root: __dirname, + // for performance, watch only these dirs under root + dirs: ['assets', 'styles'], + taskMapping: [ + { + watch: 'assets/**', + task: 'client:sync-resources' + } + ] +})); + +gulp.task("client:sync-resources", lazyRequireTask('./tasks/syncResources', { + assets: 'public' +})); + +gulp.task("videoKey:load", lazyRequireTask('videoKey/tasks/load')); + +// Show errors if encountered +gulp.task('client:compile-css', + lazyRequireTask('./tasks/compileCss', { + src: './styles/base.styl', + dst: './public/styles', + publicDst: process.env.STATIC_HOST + '/styles/', // from browser point of view + manifest: path.join(config.manifestRoot, 'styles.versions.json'), + assetVersioning: config.assetVersioning + }) +); + + +gulp.task('client:minify', lazyRequireTask('./tasks/minify')); +gulp.task('client:resize-retina-images', lazyRequireTask('./tasks/resizeRetinaImages')); + +gulp.task('client:webpack', lazyRequireTask('./tasks/webpack')); +//gulp.task('client:webpack-dev-server', lazyRequireTask('./tasks/webpackDevServer')); + + +gulp.task('build', function(callback) { + runSequence("client:sync-resources", 'client:webpack', callback); +}); + +gulp.task('server', lazyRequireTask('./tasks/server')); + +gulp.task('edit', ['build', 'tutorial:import:watch', "client:sync-resources", 'client:livereload', 'server']); + + +gulp.task('dev', function(callback) { + runSequence("client:sync-resources", ['nodemon', 'client:livereload', 'client:webpack', 'watch'], callback); +}); + +gulp.task('tutorial:import', ['cache:clean'], lazyRequireTask('tutorial/tasks/tutorialImport')); + +gulp.task('quiz:import', ['cache:clean'], lazyRequireTask('quiz/tasks/quizImport')); + + +gulp.task('tutorial:remote:update', lazyRequireTask('tutorial/tasks/remoteUpdate')); + +gulp.task('figures:import', lazyRequireTask('tutorial/tasks/figuresImport')); + +gulp.task('tutorial:kill:content', ['cache:clean'], lazyRequireTask('tutorial/tasks/killContent')); + +gulp.task('tutorial:cache:regenerate', lazyRequireTask('tutorial/tasks/cacheRegenerate')); + +gulp.task('cloudflare:clean', lazyRequireTask('./tasks/cloudflareClean', { + domains: ['javascript.ru', 'js.cx'] +})); + +gulp.task('cache:clean', lazyRequireTask('./tasks/cacheClean')); + +gulp.task('config:nginx', lazyRequireTask('./tasks/configNginx')); + +// when queue finished successfully or aborted, close db +// orchestrator events (sic!) +gulp.on('stop', function() { + mongoose.disconnect(); +}); + +gulp.on('err', function() { + mongoose.disconnect(); +}); + + diff --git a/handlers/404.js b/handlers/404.js new file mode 100755 index 000000000..4792ca496 --- /dev/null +++ b/handlers/404.js @@ -0,0 +1,15 @@ + +exports.init = function(app) { + // by default if the router didn't find anything => it yields to next middleware + // so I throw error here manually + app.use(function* (next) { + yield* next; + + if (this.status == 404) { + // still nothing found? let default errorHandler show 404 + this.throw(404); + } + }); + + +}; \ No newline at end of file diff --git a/handlers/about/client/citymap.js b/handlers/about/client/citymap.js new file mode 100755 index 000000000..55a0ca9bf --- /dev/null +++ b/handlers/about/client/citymap.js @@ -0,0 +1,173 @@ +// an object containing LatLng and population for each city. +module.exports = { + "Москва": { + "location": { + "lat": 55.755826, + "lng": 37.6173 + }, + radius: 30000 + }, + "Екатеринбург": { + "location": { + "lat": 56.83892609999999, + "lng": 60.6057025 + }, + + radius: 20000 + }, + "Ярославль": { + "location": { + "lat": 57.62607440000001, + "lng": 39.8844708 + }, + + radius: 18000 + }, + "Новосибирск": { + "location": { + "lat": 55.00835259999999, + "lng": 82.9357327 + }, + + radius: 18000 + }, + "Казань": { + "location": { + "lat": 55.790278, + "lng": 49.134722 + }, + + radius: 18000 + }, + "Самара": { + "location": { + "lat": 53.202778, + "lng": 50.140833 + }, + + radius: 18000 + }, + "Пермь": { + "location": { + "lat": 58.00000000000001, + "lng": 56.316667 + }, + + radius: 20000 + }, + "Белгород": { + + "location": { + "lat": 50.5997134, + "lng": 36.5982621 + }, + + radius: 18000 + }, + "Ростов-на-Дону": { + "location": { + "lat": 47.23333299999999, + "lng": 39.7 + }, + + radius: 18000 + }, + "Санкт-Петербург": { + "location": { + "lat": 59.9342802, + "lng": 30.3350986 + }, + radius: 20000 + }, + "Калининград": { + + "location": { + "lat": 54.716667, + "lng": 20.516667 + }, + + radius: 18000 + }, + "Киев": { + + "location": { + "lat": 50.4501, + "lng": 30.5234 + }, + radius: 30000 + }, + "Харьков": { + + "location": { + "lat": 49.9935, + "lng": 36.230383 + }, + radius: 30000 + }, + "Днепропетровск": { + + "location": { + "lat": 48.464717, + "lng": 35.046183 + }, + + radius: 25000 + }, + "Одесса": { + + "location": { + "lat": 46.482526, + "lng": 30.7233095 + }, + + radius: 22000 + }, + "Львов": { + + "location": { + "lat": 49.839683, + "lng": 24.029717 + }, + + radius: 18000 + }, + "Херсон": { + + "location": { + "lat": 46.635417, + "lng": 32.616867 + }, + + radius: 18000 + }, + "Донецк": { + + "location": { + "lat": 48.015883, + "lng": 37.80285 + }, + + radius: 18000 + }, + "Винница": { + + "location": { + "lat": 49.233083, + "lng": 28.468217 + }, + + radius: 22000 + }, + "Минск": { + + "location": { + "lat": 53.90453979999999, + "lng": 27.5615244 + }, + + radius: 20000 + } + + +}; + diff --git a/handlers/about/client/index.js b/handlers/about/client/index.js new file mode 100755 index 000000000..ace3ba474 --- /dev/null +++ b/handlers/about/client/index.js @@ -0,0 +1,193 @@ +require('./styles'); +var citymap = require('./citymap'); + +/* + * L.TileLayer is used for standard xyz-numbered tile layers. + * @see https://gist.github.com/crofty/2197042 + */ +L.Google = L.Class.extend({ + includes: L.Mixin.Events, + + options: { + minZoom: 0, + maxZoom: 18, + tileSize: 256, + subdomains: 'abc', + errorTileUrl: '', + attribution: '', + opacity: 1, + continuousWorld: false, + noWrap: false, + }, + + // Possible types: SATELLITE, ROADMAP, HYBRID + initialize: function(type, options) { + L.Util.setOptions(this, options); + + this._type = google.maps.MapTypeId[type || 'SATELLITE']; + }, + + onAdd: function(map, insertAtTheBottom) { + this._map = map; + this._insertAtTheBottom = insertAtTheBottom; + + // create a container div for tiles + this._initContainer(); + this._initMapObject(); + + // set up events + map.on('viewreset', this._resetCallback, this); + + this._limitedUpdate = L.Util.limitExecByInterval(this._update, 150, this); + map.on('move', this._update, this); + //map.on('moveend', this._update, this); + + this._reset(); + this._update(); + }, + + onRemove: function(map) { + this._map._container.removeChild(this._container); + //this._container = null; + + this._map.off('viewreset', this._resetCallback, this); + + this._map.off('move', this._update, this); + //this._map.off('moveend', this._update, this); + }, + + getAttribution: function() { + return this.options.attribution; + }, + + setOpacity: function(opacity) { + this.options.opacity = opacity; + if (opacity < 1) { + L.DomUtil.setOpacity(this._container, opacity); + } + }, + + _initContainer: function() { + var tilePane = this._map._container + var first = tilePane.firstChild; + + if (!this._container) { + this._container = L.DomUtil.create('div', 'leaflet-google-layer leaflet-top leaflet-left'); + this._container.id = "_GMapContainer"; + } + + if (true) { + tilePane.insertBefore(this._container, first); + + this.setOpacity(this.options.opacity); + var size = this._map.getSize(); + this._container.style.width = size.x + 'px'; + this._container.style.height = size.y + 'px'; + } + }, + + _initMapObject: function() { + this._google_center = new google.maps.LatLng(0, 0); + var map = new google.maps.Map(this._container, { + center: this._google_center, + zoom: 0, + mapTypeId: this._type, + disableDefaultUI: true, + keyboardShortcuts: false, + draggable: false, + disableDoubleClickZoom: true, + scrollwheel: false, + streetViewControl: false, + styles: [{ + "featureType": "all", + "elementType": "all", + "stylers": [{"weight": 0.1}, {"hue": "#a39b00"}, {"saturation": -85}, {"lightness": 0}, {"gamma": 1.1}] + }, { + "featureType": "water", + "elementType": "geometry.fill", + "stylers": [{"hue": "#226c94"}, {"saturation": 8}, {"lightness": -10}] + }] + }); + + var _this = this; + this._reposition = google.maps.event.addListenerOnce(map, "center_changed", + function() { _this.onReposition(); }); + + map.backgroundColor = '#ff0000'; + this._google = map; + }, + + _resetCallback: function(e) { + this._reset(e.hard); + }, + + _reset: function(clearOldContainer) { + this._initContainer(); + }, + + _update: function() { + this._resize(); + + var bounds = this._map.getBounds(); + var ne = bounds.getNorthEast(); + var sw = bounds.getSouthWest(); + var google_bounds = new google.maps.LatLngBounds( + new google.maps.LatLng(sw.lat, sw.lng), + new google.maps.LatLng(ne.lat, ne.lng) + ); + var center = this._map.getCenter(); + var _center = new google.maps.LatLng(center.lat, center.lng); + + this._google.setCenter(_center); + this._google.setZoom(this._map.getZoom()); + //this._google.fitBounds(google_bounds); + }, + + _resize: function() { + var size = this._map.getSize(); + if (this._container.style.width == size.x && + this._container.style.height == size.y) + return; + this._container.style.width = size.x + 'px'; + this._container.style.height = size.y + 'px'; + google.maps.event.trigger(this._google, "resize"); + }, + + onReposition: function() { + //google.maps.event.trigger(this._google, "resize"); + } +}); + + + +// ==================================================== + +exports.init = function() { + + var map = new L.Map('map', { + center: new L.LatLng(54.231473, 37.734144), + zoom: 5, + attributionControl: false, + scrollWheelZoom: false, + markerZoomAnimation: false + }); + var googleLayer = new L.Google('TERRAIN'); + map.addLayer(googleLayer); + + // Construct the circle for each value in citymap. + // Note: We scale the area of the circle based on the population. + for (var city in citymap) (function(city) { + var marker = L.circleMarker([citymap[city].location.lat-0.01, citymap[city].location.lng], { + radius: citymap[city].radius / 3000, + stroke: false, + opacity: 1, + fill: true, + clickable: false, + fillColor: '#C13335', + fillOpacity: 1 + }); + map.addLayer(marker); + + }(city)); + +}; diff --git a/handlers/about/client/styles/about-banner/header.jpg b/handlers/about/client/styles/about-banner/header.jpg new file mode 100755 index 000000000..e46f79c32 Binary files /dev/null and b/handlers/about/client/styles/about-banner/header.jpg differ diff --git a/handlers/about/client/styles/about-banner/index.styl b/handlers/about/client/styles/about-banner/index.styl new file mode 100755 index 000000000..945a35ab7 --- /dev/null +++ b/handlers/about/client/styles/about-banner/index.styl @@ -0,0 +1,50 @@ +.about-banner + position relative + + min-height 380px + + color #fff + background url("about-banner/header.jpg") no-repeat; + background-size cover + + + &__header + position absolute + top 75px + + width 100% + + text-align center + + &__title + font-size 24px + font-weight normal + line-height initial + + &__name + font-size 51px + font-weight bold + line-height 51px + + &__line + width 110px + height 2px + margin-top 42px + + border none + background rgba(255,255,255,0.2) + + @media (max-width: 690px) + &__header + top: 35px + + &__title + font-size 18px + + &__name + font-size 40px + line-height 40px + + @media (max-width: 480px) + &__line + display none diff --git a/handlers/about/client/styles/about-layout/index.styl b/handlers/about/client/styles/about-layout/index.styl new file mode 100755 index 000000000..172606370 --- /dev/null +++ b/handlers/about/client/styles/about-layout/index.styl @@ -0,0 +1,19 @@ +.about-layout + max-width 1200px + margin 0 auto + + & .columns__col + padding 40px 30px + + &__left.columns__col + padding-left 85px + + border-right 1px solid #eee + + @media tablet + & .columns__col + display block + + width auto + margin-top 20px + border none \ No newline at end of file diff --git a/handlers/about/client/styles/about-list/index.styl b/handlers/about/client/styles/about-list/index.styl new file mode 100755 index 000000000..643525384 --- /dev/null +++ b/handlers/about/client/styles/about-list/index.styl @@ -0,0 +1,52 @@ +.about-list + position absolute + bottom 30px + + width 100% + + text-align center + + &__list + display inline-table + width 100% + max-width 1200px + + &__item + display table-cell + + padding 0 10px + + text-align center + + &__num + font-size 2.8vw + line-height 2.8vw + font-weight bold + + &__description + margin 10px 0 0 0 + + opacity 0.6 + + @media (min-width: largescreen) + &__num + font-size 42px + line-height 42px + + @media tablet + &__num + font-size 16px + + &__description + font-size 12px + + @media (max-width: 690px) + &__list + display block + + text-align center + + &__item + display inline-block + + margin-top 20px diff --git a/handlers/about/client/styles/about-map/index.styl b/handlers/about/client/styles/about-map/index.styl new file mode 100755 index 000000000..786360ef0 --- /dev/null +++ b/handlers/about/client/styles/about-map/index.styl @@ -0,0 +1,12 @@ +.about-map + background #fbfafa + + &__title + line-height initial + padding 25px + + text-align center + + &__map-container + height 500px + diff --git a/handlers/about/client/styles/about-text/index.styl b/handlers/about/client/styles/about-text/index.styl new file mode 100755 index 000000000..4a7b8e16e --- /dev/null +++ b/handlers/about/client/styles/about-text/index.styl @@ -0,0 +1,59 @@ +.about-text + font-size 16px + line-height initial + + counter-reset items + + &__body_center + text-align center + + &__title + text-align center + + margin-bottom 40px + + &__list + list-style none + + &__item + margin-top 15px + padding-left 1.6em + + &__item:before + position absolute + + margin-left -1.6em + + color #999 + text-align right + + counter-increment items + content counter(items) "." + + &__human + min-height 66px + margin-top 20px + + padding-left 78px + + &__human-title a:visited + color link_color + + &__human-userpic + position absolute + overflow hidden + + width 65px + height 65px + margin -10px 0 0 -78px + + border-radius 50% + box-shadow: 0 0 1px rgba(0,0,0,0.3) + + &__human-userpic-i + width 65px + + &__human-role + margin 10px 0 0 0 + + color #999 diff --git a/handlers/about/client/styles/index.styl b/handlers/about/client/styles/index.styl new file mode 100755 index 000000000..b1b4801bb --- /dev/null +++ b/handlers/about/client/styles/index.styl @@ -0,0 +1,8 @@ + +@require "~styles/blocks/variables/variables" + +@require "about-banner" +@require "about-list" +@require "about-layout" +@require "about-text" +@require "about-map" diff --git a/handlers/about/controllers/index.js b/handlers/about/controllers/index.js new file mode 100755 index 000000000..df0287960 --- /dev/null +++ b/handlers/about/controllers/index.js @@ -0,0 +1,8 @@ +var sendMail = require('mailer').send; +var path = require('path'); +var config = require('config'); + +exports.get = function*() { + this.locals.siteToolbarCurrentSection = "about"; + this.body = this.render('index'); +}; diff --git a/handlers/about/index.js b/handlers/about/index.js new file mode 100755 index 000000000..29a10188c --- /dev/null +++ b/handlers/about/index.js @@ -0,0 +1,7 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/about', __dirname)); +}; + diff --git a/handlers/about/router.js b/handlers/about/router.js new file mode 100755 index 000000000..e0509cf7f --- /dev/null +++ b/handlers/about/router.js @@ -0,0 +1,7 @@ +var Router = require('koa-router'); + +var index = require('./controllers/index'); + +var router = module.exports = new Router(); + +router.get('/', index.get); diff --git a/handlers/about/templates/index.jade b/handlers/about/templates/index.jade new file mode 100755 index 000000000..e9d2d3bce --- /dev/null +++ b/handlers/about/templates/index.jade @@ -0,0 +1,158 @@ + +extends /layouts/main + +block append head + link(href=pack('about', 'css') rel='stylesheet') + +block append variables + + - var headTitle = 'Современный учебник Javascript'; + - var sitetoolbar = true + - var header = false + - var layout_page_class = "page_contains_header" + - var mainclass = "main-headered" + + //- var layout_main_class = "main_width-limit" +block append head + link(rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.css") + style. + .leaflet-map-pane { + z-index: 2 !important; + } + .leaflet-google-layer { + z-index: 1 !important; + } + +block content + +b.about-banner + +e("header").header + +e("h1").title проект + br + +e("span").name Javascript.ru + + +e("hr").line + + +b.about-list + +e("ul").list + +e("li").item + +e("h2").num 2 + +e("p").description + | конференции + br + | JS. Talks + + +e("li").item + +e("h2").num 4940 + +e("p").description + | участников очных + br + | мастер-классов + + +e("li").item + +e("h2").num 1255 + +e("p").description + | участников + br + | дистанционного обучения + + +e("li").item + +e("h2").num >282000 + +e("p").description + | посетителей в месяц + br + | (на основе последнего года) + + +e("li").item + +e("h2").num >24000 + +e("p").description + | строк на js в + br + | open-source коде сайта + + +e("li").item + +e("h2").num >93000 + +e("p").description + | строк в учебнике + br + | Javascript + + +b.about-layout.columns.columns_2 + + +b.about-text.about-layout__left.columns__col + +e('h1').title О проекте + +e.body + p Javascript.ru был запущен в 2007 году и с тех пор стал одним из крупнейших русскоязычных порталов по JavaScript. Сегодня основные цели проекта это: + +e('ol').list + +e('li').item Предоставлять грамотную и актуальную информацию по JavaScript и смежным технологиям. + +e('li').item Популяризировать современные фронтенд-технологии. + +e('li').item Проводить онлайн и оффлайн-мероприятия по обучению JavaScript. + +e('li').item Создание сообщества JS-разработчиков и обмен знаниями. + p + | Код этого сайта и содержимое учебника по Javascript находиться в open-source доступе и его можно посмотреть на  + a(href="https://github.com/iliakan/javascript-nodejs") github + | . + + +b.about-text.about-layout__right.columns__col + +e('h1').title Люди + +e.body + +e('ul').humans + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/iliakan.jpg") + +e('h3').human-title + +e('a')(href="https://ikantor.moikrug.ru/") Илья Кантор + +e('p').human-role Координатор, тренер, JS-разработчик + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/bezart.jpg") + +e('h3').human-title + +e('a')(href="http://bezart.me/") Артем Безценный + +e('p').human-role UX-дизайнер + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/tyv.jpg") + +e('h3').human-title + +e('a')(href="https://ua.linkedin.com/in/tkachenkoyuri") Юрий Ткаченко + +e('p').human-role На дуде игрец + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/amax.jpg") + +e('h3').human-title + +e('a')(href="https://www.linkedin.com/pub/aleksey-maximov/54/b76/215") Алексей Максимов + +e('p').human-role Админ + + +e('p') А также другие контрибьюторы. + + +b.about-map + +e('h1').title География офлайн событий + +e('h1').map-container#map + + +b.about-layout.columns.columns_2 + + +b.about-text.about-layout__left.columns__col + +e('h1').title Принять участие в проекте + +e.body + p + | Если у вас есть идеи по улучшению работы сайта либо содержимого учебника по Javascript, не стесняйтесь присылать их нам либо заходите на наш  + a(href="https://github.com/iliakan/javascript-nodejs") github + | . + + +b.about-text.about-layout__right.columns__col + +e('h1').title#contact-us Связаться с нами + +e.body._center + p + | Илья Кантор + p + a(href="mailto:iliakan@javascript.ru") iliakan@javascript.ru + br + | +79035419441 + + script(src="https://maps.googleapis.com/maps/api/js?v=3.exp") + script(src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.js") + script(src=pack("about", "js")) + script about.init(); + diff --git a/handlers/accessLogger.js b/handlers/accessLogger.js new file mode 100755 index 000000000..bbd411844 --- /dev/null +++ b/handlers/accessLogger.js @@ -0,0 +1,70 @@ +// adapted koa-logger for bunyan +const Counter = require('passthrough-counter'); +const clsNamespace = require("continuation-local-storage").getNamespace("app"); + +// binds onfinish to current context +// bindEmitter didn't work here +exports.init = function(app) { + app.use(function *logger(next) { + // request + var req = this.req; + + var start = Date.now(); + this.log.info("--> %s %s", req.method, req.url, { + event: "request-start", + method: req.method, + url: req.url, + referer: this.request.get('referer'), + ua: this.request.get('user-agent') + }); + + try { + yield next; + } catch (err) { + // log uncaught downstream errors + log(this, start, err); + throw err; + } + + // log when the response is finished or closed, + // whichever happens first. + var ctx = this; + var res = this.res; + + var onfinish = done.bind(null, 'finish'); + var onclose = done.bind(null, 'close'); + + res.once('finish', clsNamespace.bind(onfinish)); + res.once('close', clsNamespace.bind(onclose)); + + function done(event) { + res.removeListener('finish', onfinish); + res.removeListener('close', onclose); + log(ctx, start, null, event); + } + + /** + * Log helper. + */ + + function log(ctx, start, err, event) { + // get the status code of the response + var status = err ? (err.status || 500) : (ctx.status || 404); + + // set the color of the status code; + var s = status / 100 | 0; + + ctx.log[err ? 'error' : 'info']( + "<-- %s %s", ctx.method, ctx.url, { + event: "request-end", + method: ctx.method, + // not url, because mount middleware changes it + // request to /payments/common/order in case of error is logged as /order + url: ctx.originalUrl, + status: status, + timeDuration: Date.now() - start + }); + } + + }); +}; diff --git a/handlers/auth/client/authModal.js b/handlers/auth/client/authModal.js new file mode 100755 index 000000000..173edfdd7 --- /dev/null +++ b/handlers/auth/client/authModal.js @@ -0,0 +1,388 @@ +var xhr = require('client/xhr'); + +var delegate = require('client/delegate'); +var Modal = require('client/head/modal'); +var Spinner = require('client/spinner'); + + +var loginForm = require('../templates/login-form.jade'); +var registerForm = require('../templates/register-form.jade'); +var forgotForm = require('../templates/forgot-form.jade'); + +var clientRender = require('client/clientRender'); + +/** + * Options: + * - callback: function to be called after successful login (by default - go to successRedirect) + * - message: form message to be shown when the login form appears ("Log in to leave the comment") + * - successRedirect: the page to redirect (current page by default) + * - after immediate login + * - after registration for "confirm email" link + */ +function AuthModal(options) { + Modal.apply(this, arguments); + options = options || {}; + + if (!options.successRedirect) { + options.successRedirect = window.location.href; + } + + var self = this; + + this.options = options; + this.setContent(clientRender(loginForm)); + + if (options.message) { + this.showFormMessage(options.message, 'info'); + } + + this.initEventHandlers(); +} +AuthModal.prototype = Object.create(Modal.prototype); + + +delegate.delegateMixin(AuthModal.prototype); + +AuthModal.prototype.render = function() { + Modal.prototype.render.apply(this, arguments); + this.elem.classList.add('login-form-modal'); +}; + +AuthModal.prototype.successRedirect = function() { + if (window.location.href == this.options.successRedirect) { + window.location.reload(); + } else { + window.location.href = this.options.successRedirect; + } +}; + +AuthModal.prototype.clearFormMessages = function() { + /* + remove error for this notation: + span.text-input.text-input_invalid.login-form__input + input.text-input__control#password(type="password", name="password") + span.text-inpuxt__err Пароли не совпадают + */ + [].forEach.call(this.elem.querySelectorAll('.text-input_invalid'), function(elem) { + elem.classList.remove('text-input_invalid'); + }); + + [].forEach.call(this.elem.querySelectorAll('.text-input__err'), function(elem) { + elem.remove(); + }); + + // clear form-wide notification + this.elem.querySelector('[data-notification]').innerHTML = ''; +}; + +AuthModal.prototype.request = function(options) { + var request = xhr(options); + + request.addEventListener('loadstart', function() { + var onEnd = this.startRequestIndication(); + request.addEventListener('loadend', onEnd); + }.bind(this)); + + return request; +}; + +AuthModal.prototype.startRequestIndication = function() { + this.showOverlay(); + var self = this; + + var submitButton = this.elem.querySelector('[type="submit"]'); + + if (submitButton) { + var spinner = new Spinner({ + elem: submitButton, + size: 'small', + class: '', + elemClass: 'button_loading' + }); + spinner.start(); + } + + return function onEnd() { + self.hideOverlay(); + if (spinner) spinner.stop(); + }; + +}; + +AuthModal.prototype.initEventHandlers = function() { + + this.delegate('[data-switch="register-form"]', 'click', function(e) { + e.preventDefault(); + this.setContent(clientRender(registerForm)); + }); + + this.delegate('[data-switch="login-form"]', 'click', function(e) { + e.preventDefault(); + this.setContent(clientRender(loginForm)); + }); + + this.delegate('[data-switch="forgot-form"]', 'click', function(e) { + e.preventDefault(); + + // move currently entered email into forgotForm + var oldEmailInput = this.elem.querySelector('[type="email"]'); + this.setContent(clientRender(forgotForm)); + var newEmailInput = this.elem.querySelector('[type="email"]'); + newEmailInput.value = oldEmailInput.value; + }); + + + this.delegate('[data-form="login"]', 'submit', function(event) { + event.preventDefault(); + this.submitLoginForm(event.target); + }); + + + this.delegate('[data-form="register"]', 'submit', function(event) { + event.preventDefault(); + this.submitRegisterForm(event.target); + }); + + this.delegate('[data-form="forgot"]', 'submit', function(event) { + event.preventDefault(); + this.submitForgotForm(event.target); + }); + + this.delegate("[data-provider]", "click", function(event) { + event.preventDefault(); + this.openAuthPopup('/auth/login/' + event.delegateTarget.dataset.provider); + }); + + this.delegate('[data-action-verify-email]', 'click', function(event) { + event.preventDefault(); + + var payload = new FormData(); + var email = event.delegateTarget.dataset.actionVerifyEmail; + payload.append("email", email); + + var request = this.request({ + method: 'POST', + url: '/auth/reverify', + body: payload + }); + + var self = this; + request.addEventListener('success', function(event) { + + if (this.status == 200) { + self.showFormMessage(` +

Письмо-подтверждение отправлено ещё раз.

+

перезапросить подтверждение.

+ `, 'success'); + } else { + self.showFormMessage(event.result, 'error'); + } + + }); + + }); +}; + +AuthModal.prototype.submitRegisterForm = function(form) { + + this.clearFormMessages(); + + var hasErrors = false; + if (!form.elements.email.value) { + hasErrors = true; + this.showInputError(form.elements.email, 'Введите, пожалуста, email.'); + } + + if (!form.elements.displayName.value) { + hasErrors = true; + this.showInputError(form.elements.displayName, 'Введите, пожалуста, имя пользователя.'); + } + + if (!form.elements.password.value) { + hasErrors = true; + this.showInputError(form.elements.password, 'Введите, пожалуста, пароль.'); + } + + if (hasErrors) return; + + var payload = new FormData(form); + payload.append("successRedirect", this.options.successRedirect); + + var request = this.request({ + method: 'POST', + url: '/auth/register', + normalStatuses: [201, 400], + body: payload + }); + + var self = this; + request.addEventListener('success', function(event) { + + if (this.status == 201) { + self.setContent(clientRender(loginForm)); + self.showFormMessage( + "

С адреса notify@javascript.ru отправлено письмо со ссылкой-подтверждением.

" + + "

перезапросить подтверждение.

", + 'success' + ); + return; + } + + if (this.status == 400) { + for (var field in event.result.errors) { + self.showInputError(form.elements[field], event.result.errors[field]); + } + return; + } + + self.showFormMessage("Неизвестный статус ответа сервера", 'error'); + }); + +}; + + +AuthModal.prototype.submitForgotForm = function(form) { + + this.clearFormMessages(); + + var hasErrors = false; + if (!form.elements.email.value) { + hasErrors = true; + this.showInputError(form.elements.email, 'Введите, пожалуста, email.'); + } + + if (hasErrors) return; + + var payload = new FormData(form); + payload.append("successRedirect", this.options.successRedirect); + + var request = this.request({ + method: 'POST', + url: '/auth/forgot', + normalStatuses: [200, 404, 403], + body: payload + }); + + var self = this; + request.addEventListener('success', function(event) { + + if (this.status == 200) { + self.setContent(clientRender(loginForm)); + self.showFormMessage(event.result, 'success'); + } else if (this.status == 404) { + self.showFormMessage(event.result, 'error'); + } else if (this.status == 403) { + self.showFormMessage(event.result.message || "Действие запрещено.", 'error'); + } + }); + +}; + + +AuthModal.prototype.showInputError = function(input, error) { + input.parentNode.classList.add('text-input_invalid'); + var errorSpan = document.createElement('span'); + errorSpan.className = 'text-input__err'; + errorSpan.innerHTML = error; + input.parentNode.appendChild(errorSpan); +}; + +AuthModal.prototype.showFormMessage = function(message, type) { + if (message.indexOf('

') !== 0) { + message = '

' + message + '

'; + } + + if (['info', 'error', 'warning', 'success'].indexOf(type) == -1) { + throw new Error("Unsupported type: " + type); + } + + var container = document.createElement('div'); + container.className = 'login-form__' + type; + container.innerHTML = message; + + this.elem.querySelector('[data-notification]').innerHTML = ''; + this.elem.querySelector('[data-notification]').appendChild(container); +}; + +AuthModal.prototype.submitLoginForm = function(form) { + + this.clearFormMessages(); + + var hasErrors = false; + if (!form.elements.email.value) { + hasErrors = true; + this.showInputError(form.elements.email, 'Введите, пожалуста, email.'); + } + + if (!form.elements.password.value) { + hasErrors = true; + this.showInputError(form.elements.password, 'Введите, пожалуста, пароль.'); + } + + if (hasErrors) return; + + var request = xhr({ + method: 'POST', + url: '/auth/login/local', + noDocumentEvents: true, // we handle all events/errors in this code + normalStatuses: [200, 401], + body: new FormData(form) + }); + + var onEnd = this.startRequestIndication(); + + request.addEventListener('success', (event) => { + + if (request.status == 401) { + onEnd(); + this.onAuthFailure(event.result.message); + return; + } + + // don't stop progress indication if login successful && we're making redirect + if (!this.options.callback) { + this.onAuthSuccess(event.result.user); + } else { + onEnd(); + this.onAuthSuccess(event.result.user); + } + }); + + request.addEventListener('fail', (event) => { + onEnd(); + this.onAuthFailure(event.reason); + }); + +}; + +AuthModal.prototype.openAuthPopup = function(url) { + if (this.authPopup && !this.authPopup.closed) { + this.authPopup.close(); // close old popup if any + } + var width = 800, height = 600; + var top = (window.outerHeight - height) / 2; + var left = (window.outerWidth - width) / 2; + window.authModal = this; + this.authPopup = window.open(url, 'authModal', 'width=' + width + ',height=' + height + ',scrollbars=0,top=' + top + ',left=' + left); +}; + +/* + все обработчики авторизации (включая Facebook из popup-а и локальный) + в итоге триггерят один из этих каллбэков + */ +AuthModal.prototype.onAuthSuccess = function(user) { + window.currentUser = user; + if (this.options.callback) { + this.options.callback(); + } else { + this.successRedirect(); + } +}; + + +AuthModal.prototype.onAuthFailure = function(errorMessage) { + this.showFormMessage(errorMessage || "Отказ в авторизации.", 'error'); +}; + + +module.exports = AuthModal; diff --git a/handlers/auth/client/index.js b/handlers/auth/client/index.js new file mode 100755 index 000000000..9c18f167a --- /dev/null +++ b/handlers/auth/client/index.js @@ -0,0 +1 @@ +exports.AuthModal = require('./authModal'); diff --git a/handlers/auth/controller/disconnect.js b/handlers/auth/controller/disconnect.js new file mode 100755 index 000000000..af8a972d3 --- /dev/null +++ b/handlers/auth/controller/disconnect.js @@ -0,0 +1,22 @@ +var User = require('users').User; +var jade = require('lib/serverJade'); +var path = require('path'); +var config = require('config'); + +// Remove provider profile from the user +exports.post = function* (next) { + + var user = this.user; + + for (var i = 0; i < user.providers.length; i++) { + var provider = user.providers[i]; + if (provider.name == this.params.providerName) { + provider.remove(); + i--; + } + } + + yield user.persist(); + + this.body = ''; +}; diff --git a/handlers/auth/controller/forgot.js b/handlers/auth/controller/forgot.js new file mode 100755 index 000000000..3cf913954 --- /dev/null +++ b/handlers/auth/controller/forgot.js @@ -0,0 +1,42 @@ +var User = require('users').User; +var jade = require('lib/serverJade'); +var path = require('path'); +var config = require('config'); +var sendMail = require('mailer').send; + +exports.post = function* (next) { + + var user = yield User.findOne({ + email: this.request.body.email + }).exec(); + + if (!user) { + this.status = 404; + this.body = 'Нет такого пользователя.'; + return; + } + + user.passwordResetToken = Math.random().toString(36).slice(2, 10); + user.passwordResetTokenExpires = new Date(Date.now() + 86400*1e3); + user.passwordResetRedirect = this.request.body.successRedirect; + + yield user.persist(); + + try { + + yield sendMail({ + templatePath: path.join(this.templateDir, 'forgot-email'), + to: user.email, + subject: "Восстановление доступа", + link: config.server.siteHost + '/auth/forgot-recover/' + user.passwordResetToken + }); + + } catch(e) { + this.log.error({err: e}, "Mail send failed"); + this.throw(500, "На сервере ошибка отправки email."); + } + + this.status = 200; + this.body = 'На вашу почту отправлено письмо со ссылкой на смену пароля.'; + +}; diff --git a/handlers/auth/controller/forgotRecover.js b/handlers/auth/controller/forgotRecover.js new file mode 100755 index 000000000..947b10593 --- /dev/null +++ b/handlers/auth/controller/forgotRecover.js @@ -0,0 +1,72 @@ +var User = require('users').User; +var jade = require('lib/serverJade'); +var path = require('path'); +var config = require('config'); + +exports.get = function* (next) { + + var passwordResetToken = this.params.passwordResetToken; + + var user = yield User.findOne({ + passwordResetToken: passwordResetToken, + passwordResetTokenExpires: { + $gt: new Date() + } + }).exec(); + + if (!user) { + this.throw(404, 'Вы перешли по устаревшей или недействительной ссылке на восстановление.'); + } + + this.body = this.render('forgot-recover', { + passwordResetToken: passwordResetToken + }); + +}; + +exports.post = function* (next) { + + var passwordResetToken = this.request.body.passwordResetToken; + + var user = yield User.findOne({ + passwordResetToken: passwordResetToken, + passwordResetTokenExpires: { + $gt: new Date() + } + }).exec(); + + if (!user) { + this.throw(404, 'Ваша ссылка на восстановление недействительна или устарела.'); + } + + var error = ""; + if (!this.request.body.password) { + error = "Пароль не должен быть пустым."; + } + if (this.request.body.password.length < 4) { + error = "Пароль должен содержать минимум 4 символа."; + } + + if (error) { + this.body = this.render('forgot-recover', { + passwordResetToken: passwordResetToken, + error: error + }); + + return; + } + + var redirect = user.passwordResetRedirect; + + delete user.passwordResetToken; + delete user.passwordResetTokenExpires; + delete user.passwordResetRedirect; + + user.password = this.request.body.password; + + yield user.persist(); + + yield this.login(user); + + this.redirect(redirect); +}; diff --git a/handlers/auth/controller/logout.js b/handlers/auth/controller/logout.js new file mode 100755 index 000000000..b8dd22418 --- /dev/null +++ b/handlers/auth/controller/logout.js @@ -0,0 +1,13 @@ + +exports.post = function*(next) { + this.cookies.set('remember'); // remove "remember me" cookie + this.cookies.set('remember.sig'); + + this.cookies.set('sid'); // logout removes sid, but not sid.sig (3rd party bug?) + this.cookies.set('sid.sig'); + + this.logout(); + this.session = null; + this.redirect('/'); +}; + diff --git a/handlers/auth/controller/register.js b/handlers/auth/controller/register.js new file mode 100644 index 000000000..df0b631d4 --- /dev/null +++ b/handlers/auth/controller/register.js @@ -0,0 +1,61 @@ +var User = require('users').User; +var jade = require('lib/serverJade'); +var path = require('path'); +var config = require('config'); +var sendMail = require('mailer').send; + +// Регистрация пользователя. +exports.post = function* (next) { + +// yield function(callback) {}; + + var verifyEmailToken = Math.random().toString(36).slice(2, 10); + var user = new User({ + email: this.request.body.email, + displayName: this.request.body.displayName, + password: this.request.body.password, + verifiedEmail: false, + verifyEmailToken: verifyEmailToken, + verifyEmailRedirect: this.request.body.successRedirect + }); + + //yield user.generateProfileName(); + + try { + yield user.persist(); + } catch(e) { + if (e.name == 'ValidationError') { + try { + if (e.errors.email.type == "notunique") { + e.errors.email.message += ' Если он ваш, то можно войти или восстановить пароль.'; + } + } catch (ex) { /* e.errors.email is undefined, that's ok */ } + this.renderError(e); + return; + } else { + this.throw(e); + } + } + + + // We're here if no errors happened + + try { + + yield sendMail({ + templatePath: path.join(this.templateDir, 'verify-registration-email'), + to: user.email, + subject: "Подтверждение email", + link: config.server.siteHost + '/auth/verify/' + verifyEmailToken + }); + + } catch(e) { + this.log.error("Registration failed", {err: e}); + this.throw(500, "Ошибка отправки email."); + } + + + this.status = 201; + this.body = ''; //Вы зарегистрированы. Пожалуйста, загляните в почтовый ящик, там письмо с Email-подтверждением.'; + +}; diff --git a/handlers/auth/controller/reverify.js b/handlers/auth/controller/reverify.js new file mode 100755 index 000000000..cdac698a3 --- /dev/null +++ b/handlers/auth/controller/reverify.js @@ -0,0 +1,42 @@ +var User = require('users').User; +var jade = require('lib/serverJade'); +var path = require('path'); +var config = require('config'); +var sendMail = require('mailer').send; + +// Регистрация пользователя. +exports.post = function* (next) { + + var email = this.request.body.email; + if (!email) { + this.throw(404, 'Не указан email пользователя.'); + } + + var user = yield User.findOne({ + email: email + }).exec(); + + if (!user) { + this.throw(404, 'Нет такого пользователя.'); + } + + if (user.verifiedEmail) { + this.throw(403, 'Ваш Email уже подтверждён.'); + } + + try { + + yield sendMail({ + templatePath: path.join(this.templateDir, 'verify-registration-email'), + to: user.email, + subject: "Подтверждение email", + link: config.server.siteHost + '/auth/verify/' + user.verifyEmailToken + }); + + } catch(e) { + this.log.error({err: e}, "Reverify failed"); + this.throw(500, "На сервере ошибка отправки email."); + } + + this.body = ''; +}; diff --git a/handlers/auth/controller/verify.js b/handlers/auth/controller/verify.js new file mode 100755 index 000000000..88a03d150 --- /dev/null +++ b/handlers/auth/controller/verify.js @@ -0,0 +1,47 @@ +var User = require('users').User; +var jade = require('lib/serverJade'); +var path = require('path'); +var config = require('config'); + +exports.get = function* (next) { + + var user = yield User.findOne({ + verifyEmailToken: this.params.verifyEmailToken + }).exec(); + + if (!user) { + this.throw(404, 'Ссылка подтверждения недействительна или устарела.'); + } + + var redirect = user.verifyEmailRedirect; + delete user.verifyEmailRedirect; + + if (!user.verifiedEmail) { + user.verifiedEmail = true; + user.verifiedEmailsHistory.push({date: new Date(), email: user.email}); + yield user.persist(); + + } else if (user.pendingVerifyEmail) { + user.email = user.pendingVerifyEmail; + + user.verifiedEmailsHistory.push({date: new Date(), email: user.email}); + try { + yield user.persist(); + } catch (e) { + if (e.name != 'ValidationError') { + throw e; + } else { + this.throw(400, 'Изменение email невозможно, адрес уже занят.'); + } + } + + } else { + this.throw(404, 'Изменений не произведено: ваш email и так верифицирован, его смена не запрашивалась.'); + } + + delete user.verifyEmailToken; + + yield this.login(user); + + this.redirect(redirect); +}; diff --git a/handlers/auth/controller/xmpp.js b/handlers/auth/controller/xmpp.js new file mode 100644 index 000000000..1e29e29c6 --- /dev/null +++ b/handlers/auth/controller/xmpp.js @@ -0,0 +1,33 @@ +var User = require('users').User; +var path = require('path'); +var config = require('config'); + +// Remove provider profile from the user +exports.post = function* (next) { + + // anti-bruteforce pause + yield function(callback) { + setTimeout(callback, 100); + }; + + var user = yield User.findOne({ + profileName: this.request.body.user + }).exec(); + + if (!user) { + this.log.error("No such user", this.request.body.user); + this.body = "0"; + return; + } + + switch(this.request.body.command) { + case 'auth': + this.body = user.checkPassword(this.request.body.password) ? "1" : "0"; + return; + default: + // do not support other requests yet + this.log.debug("Command not supported", this.request.body.command); + this.body = "0"; + } + +}; diff --git a/handlers/auth/forms.js b/handlers/auth/forms.js new file mode 100755 index 000000000..e2d3f5601 --- /dev/null +++ b/handlers/auth/forms.js @@ -0,0 +1,2 @@ +exports.forgot = require('./templates/forgot-form.jade'); + diff --git a/handlers/auth/index.js b/handlers/auth/index.js new file mode 100755 index 000000000..53f8c3ddb --- /dev/null +++ b/handlers/auth/index.js @@ -0,0 +1,23 @@ +exports.mustBeAuthenticated = require('./lib/mustBeAuthenticated'); +exports.mustNotBeAuthenticated = require('./lib/mustNotBeAuthenticated'); +exports.mustBeAdmin = require('./lib/mustBeAdmin'); + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + + require('./strategies'); + + app.use( mountHandlerMiddleware('/auth', __dirname) ); + + // no csrf check for guest endpoints (no generation of csrf for anon) + app.csrfChecker.ignore.add('/auth/login/:any*'); + app.csrfChecker.ignore.add('/auth/register'); + app.csrfChecker.ignore.add('/auth/reverify'); + app.csrfChecker.ignore.add('/auth/forgot'); + app.csrfChecker.ignore.add('/auth/forgot-recover'); + +}; + + + diff --git a/handlers/auth/lib/authenticateByProfile.js b/handlers/auth/lib/authenticateByProfile.js new file mode 100755 index 000000000..9c17c2c43 --- /dev/null +++ b/handlers/auth/lib/authenticateByProfile.js @@ -0,0 +1,150 @@ +const User = require('users').User; +const config = require('config'); +const co = require('co'); +const _ = require('lodash'); +const request = require('koa-request'); +const imgur = require('imgur'); + +function UserAuthError(message) { + this.message = message; +} + +function* mergeProfile(user, profile) { + if (!user.photo && profile.photos && profile.photos.length && profile.photos[0].type != 'default') { + // assign an avatar unless it's default + var photoUrl = profile.photos[0].value; + user.photo = yield* imgur.transload(photoUrl); + } + + if (!user.email && profile.emails && profile.emails.length) { + user.email = profile.emails[0].value; + } + + if (!user.displayName && profile.displayName) { + user.displayName = profile.displayName; + } + + if (!user.realName && profile.realName) { + user.realName = profile.realName; + } + + if (!user.gender && profile.gender) { + user.gender = profile.gender; + } + + // remove previous profile from the same provider, replace by the new one + var nameId = makeProviderId(profile); + for (var i = 0; i < user.providers.length; i++) { + var provider = user.providers[i]; + if (provider.nameId == nameId) { + provider.remove(); + i--; + } + } + + user.providers.push({ + name: profile.provider, + nameId: makeProviderId(profile), + profile: profile + }); + + user.verifiedEmail = true; +} + +function makeProviderId(profile) { + return profile.provider + ":" + profile.id; +} + +module.exports = function(req, profile, done) { + // profile = the data returned by the facebook graph api + + req.log.debug({profile: profile}, "profile"); + + var userToConnect = req.user; + + co(function*() { + var providerNameId = makeProviderId(profile); + + var user; + + if (userToConnect) { + // merge auth result with the user profile if it is not bound anywhere yet + + // look for another user already using this profile + var alreadyConnectedUser = yield User.findOne({ + "providers.nameId": providerNameId, + _id: {$ne: userToConnect._id} + }).exec(); + + if (alreadyConnectedUser) { + // if old user is in read-only, + // I can't just reattach the profile to the new user and keep logging in w/ it + if (alreadyConnectedUser.readOnly) { + throw new UserAuthError("Вход по этому профилю не разрешён, извините."); + } + + // before this social login was used by alreadyConnectedUser + // now we clean the connection to make a new one + for (var i = 0; i < alreadyConnectedUser.providers.length; i++) { + var provider = alreadyConnectedUser.providers[i]; + if (provider.nameId == providerNameId) { + provider.remove(); + i--; + } + } + yield alreadyConnectedUser.persist(); + } + + user = userToConnect; + + } else { + user = yield User.findOne({"providers.nameId": providerNameId}).exec(); + + if (!user) { + // if we have user with same email, assume it's exactly the same person as the new man + user = yield User.findOne({email: profile.emails[0].value}).exec(); + + if (!user) { + // auto-register + user = new User(); + } + } + } + + + try { + yield* mergeProfile(user, profile); + } catch (e) { + if (e.name == 'BadImageError') { // image too big or kind of + throw new UserAuthError(e.message); + } else { + throw e; + } + } + + try { + yield function(callback) { + user.validate(callback); + }; + } catch (e) { + // there's a required field + // maybe, when the user was on the remote social login screen, he disallowed something? + throw new UserAuthError("Недостаточно данных, разрешите их передачу, пожалуйста."); + } + + yield user.persist(); + + + return user; + + }).then(function(user) { + done(null, user); + }, function(err) { + if (err instanceof UserAuthError) { + done(null, false, {message: err.message}); + } else { + done(err); + } + }); + +}; diff --git a/handlers/auth/lib/mustBeAdmin.js b/handlers/auth/lib/mustBeAdmin.js new file mode 100755 index 000000000..8cfa7fdf9 --- /dev/null +++ b/handlers/auth/lib/mustBeAdmin.js @@ -0,0 +1,10 @@ +var config = require('config'); + +module.exports = function*(next) { + + if (process.env.NODE_ENV == 'development' || this.isAdmin) { + yield* next; + } else { + this.throw(403); + } +}; diff --git a/handlers/auth/lib/mustBeAuthenticated.js b/handlers/auth/lib/mustBeAuthenticated.js new file mode 100755 index 000000000..2fe11b889 --- /dev/null +++ b/handlers/auth/lib/mustBeAuthenticated.js @@ -0,0 +1,8 @@ + +module.exports = function*(next) { + if (this.isAuthenticated()) { + yield* next; + } else { + this.throw(401); + } +}; diff --git a/handlers/auth/lib/mustNotBeAuthenticated.js b/handlers/auth/lib/mustNotBeAuthenticated.js new file mode 100755 index 000000000..888c57453 --- /dev/null +++ b/handlers/auth/lib/mustNotBeAuthenticated.js @@ -0,0 +1,8 @@ + +module.exports = function*(next) { + if (!this.isAuthenticated()) { + yield* next; + } else { + this.throw(403, 'Это действие доступно только для неавторизованных посетителей.'); + } +}; diff --git a/handlers/auth/out.js b/handlers/auth/out.js new file mode 100755 index 000000000..497d97a54 --- /dev/null +++ b/handlers/auth/out.js @@ -0,0 +1,5 @@ +// deprecated, not used +exports.get = function*(next) { + this.logout(); + this.redirect('/'); +}; diff --git a/handlers/auth/router.js b/handlers/auth/router.js new file mode 100755 index 000000000..1c8856107 --- /dev/null +++ b/handlers/auth/router.js @@ -0,0 +1,119 @@ +var Router = require('koa-router'); +var config = require('config'); +var register = require('./controller/register'); +var verify = require('./controller/verify'); +var reverify = require('./controller/reverify'); +var disconnect = require('./controller/disconnect'); +var forgot = require('./controller/forgot'); +var forgotRecover = require('./controller/forgotRecover'); +var logout = require('./controller/logout'); +var xmpp = require('./controller/xmpp'); +var mustBeAuthenticated = require('./lib/mustBeAuthenticated'); +var mustNotBeAuthenticated = require('./lib/mustNotBeAuthenticated'); +var passport = require('koa-passport'); + +require('./strategies'); + +var router = module.exports = new Router(); + +router.post('/login/local', function*(next) { + var ctx = this; + + // only callback-form of authenticate allows to assign ctx.body=info if 401 + yield passport.authenticate('local', function*(err, user, info) { + if (err) throw err; + if (user === false) { + ctx.status = 401; + ctx.body = info; + } else { + yield ctx.login(user); + yield ctx.rememberMe(); + ctx.body = {user: user.getInfoFields() }; + } + }).call(this, next); + +}); + +router.post('/logout', mustBeAuthenticated, logout.post); + +if (process.env.NODE_ENV == 'development') { + router.get('/out', require('./out').get); // GET logout for DEV +} + +router.post('/register', mustNotBeAuthenticated, register.post); +router.post('/forgot', mustNotBeAuthenticated, forgot.post); + +router.get('/verify/:verifyEmailToken', verify.get); +router.get('/forgot-recover/:passwordResetToken?', mustNotBeAuthenticated, forgotRecover.get); +router.post('/forgot-recover', forgotRecover.post); + +router.post('/reverify', reverify.post); + +Object.keys(config.auth.providers).forEach(addProviderRoute); + +function addProviderRoute(providerName) { + var provider = config.auth.providers[providerName]; + + // login + router.get('/login/' + providerName, passport.authenticate(providerName, provider.passportOptions)); + + // connect with existing profile + router.get('/connect/' + providerName, mustBeAuthenticated, passport.authorize(providerName, provider.passportOptions)); + + + // http://stage.javascript.ru/auth/callback/facebook?error=access_denied&error_code=200&error_description=Permissions+error&error_reason=user_denied#_=_ + + router.get('/callback/' + providerName, function*(next) { + var ctx = this; + this.nocache(); + + yield passport.authenticate(providerName, function*(err, user, info) { + if (err) { + // throw err would get swallowed (!!!) + // so I must render error here + ctx.renderError(err); + return; + } + + if (user) { + yield ctx.login(user); + yield ctx.rememberMe(); + ctx.body = ctx.render('popup-success'); + return; + } + + var reason = info.message || info; + + ctx.body = ctx.render('popup-failure', { reason: reason }); + + }).call(this, next); + + yield* next; + }); + /* + router.get('/callback/' + providerName, passport.authenticate(providerName, { + failureMessage: true, + successRedirect: '/auth/popup-success', + failureRedirect: '/auth/popup-failure' + }) + + );*/ +} + +// these pages are not used if https site and https auth, because of direct opener<->popup communication +// but when site is http and popup is https, it redirects here +router.get('/popup-success', mustBeAuthenticated, function*() { + this.nocache(); + this.body = this.render('popup-success'); +}); +router.post('/popup-failure', mustNotBeAuthenticated, function*() { + this.nocache(); + this.body = this.render('popup-failure', { + reason: this.request.body.reason + }); +}); + +// disconnect with existing profile +router.post('/disconnect/:providerName', mustBeAuthenticated, disconnect.post); + +router.post('/xmpp', xmpp.post); diff --git a/handlers/auth/strategies/facebookStrategy.js b/handlers/auth/strategies/facebookStrategy.js new file mode 100755 index 000000000..a8dc2a4e5 --- /dev/null +++ b/handlers/auth/strategies/facebookStrategy.js @@ -0,0 +1,121 @@ +var User = require('users').User; +const FacebookStrategy = require('passport-facebook').Strategy; +const authenticateByProfile = require('../lib/authenticateByProfile'); +const config = require('config'); +const request = require('koa-request'); +const co = require('co'); + +/* + Returns fields: +{ + "id": "765813916814019", + "email": "login\u0040mail.ru", + "gender": "male", + "link": "https:\/\/www.facebook.com\/app_scoped_user_id\/765813916814019\/", + "locale": "ru_RU", + "timezone": 4, + "verified": true, + "name": "Ilya Kantor", + "last_name": "Kantor", + "first_name": "Ilya" +} + + If I add "picture" to profileURL?fields, I get a *small* picture. + + Real picture is (public): + (76581...19 is user id) + http://graph.facebook.com/v2.1/765813916814019/picture?redirect=0&width=1000&height=1000 + + redirect=0 means to get meta info, not picture + then check is_silhouette (if true, no avatar) + + then if is_silhouette = false, go URL + (P.S. width/height are unreliable, not sure which exactly size we get) + +*/ + +function UserAuthError(message) { + this.message = message; +} + +module.exports = new FacebookStrategy({ + clientID: config.auth.providers.facebook.appId, + clientSecret: config.auth.providers.facebook.appSecret, + callbackURL: config.server.siteHost + "/auth/callback/facebook", + // fields are described here: + // https://developers.facebook.com/docs/graph-api/reference/v2.1/user + profileURL: 'https://graph.facebook.com/me?fields=id,about,email,gender,link,locale,timezone,verified,name,last_name,first_name,middle_name', + passReqToCallback: true + }, + function(req, accessToken, refreshToken, profile, done) { + + // req example: + // '/callback/facebook?code=...', + + // accessToken: + // ... (from ?code) + + // refreshToken: + // undefined + + + co(function*() { + + var permissionError = null; + // I guess, facebook won't allow to use an email w/o verification, but still... + if (!profile._json.verified) { + permissionError = "Почта на facebook должна быть подтверждена"; + } + + if (!profile.emails || !profile.emails[0]) { // user may allow authentication, but disable email access (e.g in fb) + permissionError = "При входе разрешите доступ к email. Он используется для идентификации пользователя."; + } + + if (permissionError) { + // revoke facebook auth, so that next time facebook will ask it again (otherwise it won't) + var response = yield request({ + method: 'DELETE', + url: "https://graph.facebook.com/me/permissions?access_token=" + accessToken + }); + + if (response.body != 'true') { + req.log.error("Unexpected facebook response", {res: response, body: response.body}); + throw new Error("Facebook auth delete call after successful auth must return true"); + } + + throw new UserAuthError(permissionError); + } + + var response = yield request.get({ + url: 'http://graph.facebook.com/v2.1/' + profile.id + '/picture?redirect=0&width=1000&height=1000', + json: true + }); + + if (response.statusCode != 200) { + throw new UserAuthError("Ошибка в запросе к Facebook"); + } + + var photoData = response.body.data; + /* jshint -W106 */ + profile.photos = [{ + value: photoData.url, + type: photoData.is_silhouette ? 'default' : 'photo' + }]; + + profile.realName = profile._json.name; + + }).then(function() { + authenticateByProfile(req, profile, done); + }, function(err) { + if (err instanceof UserAuthError) { + done(null, false, {message: err.message}); + } else { + done(err); + } + }); + +// http://graph.facebook.com/v2.1/765813916814019/picture?redirect=0&width=1000&height=1000 + + + } +); diff --git a/handlers/auth/strategies/githubStrategy.js b/handlers/auth/strategies/githubStrategy.js new file mode 100755 index 000000000..262e58350 --- /dev/null +++ b/handlers/auth/strategies/githubStrategy.js @@ -0,0 +1,125 @@ +var User = require('users').User; +const GithubStrategy = require('passport-github').Strategy; +const authenticateByProfile = require('./../lib/authenticateByProfile'); +const config = require('config'); +const request = require('request'); + +/* +Minimal result example: +{ + login: 'a1109126', + id: 8511653, + avatar_url: 'https://avatars.githubusercontent.com/u/8511653?v=2', + gravatar_id: '93243a77b75990dc8a614056ab9e4f65', + url: 'https://api.github.com/users/a1109126', + html_url: 'https://github.com/a1109126', + followers_url: 'https://api.github.com/users/a1109126/followers', + following_url: 'https://api.github.com/users/a1109126/following{/other_user}', + gists_url: 'https://api.github.com/users/a1109126/gists{/gist_id}', + starred_url: 'https://api.github.com/users/a1109126/starred{/owner}{/repo}', + subscriptions_url: 'https://api.github.com/users/a1109126/subscriptions', + organizations_url: 'https://api.github.com/users/a1109126/orgs', + repos_url: 'https://api.github.com/users/a1109126/repos', + events_url: 'https://api.github.com/users/a1109126/events{/privacy}', + received_events_url: 'https://api.github.com/users/a1109126/received_events', + type: 'User', + site_admin: false, + public_repos: 0, + public_gists: 0, + followers: 0, + following: 0, + created_at: '2014-08-21T08:48:48Z', + updated_at: '2014-08-21T08:48:48Z' +} + +Result example: +{ + login: 'iliakan', + id: 349336, + avatar_url: 'https://avatars.githubusercontent.com/u/349336?v=2', + gravatar_id: '0cfca32a200bbd63e41058ec5b8e51ed', + url: 'https://api.github.com/users/iliakan', + html_url: 'https://github.com/iliakan', + followers_url: 'https://api.github.com/users/iliakan/followers', + following_url: 'https://api.github.com/users/iliakan/following{/other_user}', + gists_url: 'https://api.github.com/users/iliakan/gists{/gist_id}', + starred_url: 'https://api.github.com/users/iliakan/starred{/owner}{/repo}', + subscriptions_url: 'https://api.github.com/users/iliakan/subscriptions', + organizations_url: 'https://api.github.com/users/iliakan/orgs', + repos_url: 'https://api.github.com/users/iliakan/repos', + events_url: 'https://api.github.com/users/iliakan/events{/privacy}', + received_events_url: 'https://api.github.com/users/iliakan/received_events', + type: 'User', + site_admin: false, + name: 'Ilya Kantor', + company: '', + blog: 'http://javascript.ru', + location: '', + email: '', + hireable: false, + bio: null, + public_repos: 37, + public_gists: 663, + followers: 85, + following: 0, + created_at: '2010-07-30T14:31:35Z', + updated_at: '2014-08-20T14:48:14Z' } +} + +*/ + +module.exports = new GithubStrategy({ + clientID: config.auth.providers.github.appId, + clientSecret: config.auth.providers.github.appSecret, + callbackURL: config.server.siteHost + "/auth/callback/github", + passReqToCallback: true + }, + function(req, accessToken, refreshToken, profile, done) { + + // this may be a default avatar, or a real user pic, can't be sure + /* jshint -W106 */ + profile.photos = [ + { + value: profile._json.avatar_url + } + ]; + + var options = { + headers: { + 'User-Agent': 'JavaScript.ru', + 'Authorization': 'token ' + accessToken + }, + json: true, + url: 'https://api.github.com/user/emails' + }; + + // get emails using oauth token + request(options, function(error, response, body) { + if (error || response.statusCode != 200) { + req.log.error(error, body); + done(null, false, {message: "Ошибка связи с сервером github."}); + return; + } + +// [ { email: 'iliakan@gmail.com', primary: true, verified: true } ], + + var emails = body.filter(function(email) { + return email.verified; + }); + + if (!emails.length) { + return done(null, false, {message: "Почта на github должна быть подтверждена."}); + } + + profile.emails = [ + {value: emails[0].email } + ]; + + profile.realName = profile.displayName; + + authenticateByProfile(req, profile, done); + }); + + + } +); diff --git a/handlers/auth/strategies/googleStrategy.js b/handlers/auth/strategies/googleStrategy.js new file mode 100755 index 000000000..6805cce42 --- /dev/null +++ b/handlers/auth/strategies/googleStrategy.js @@ -0,0 +1,62 @@ +var User = require('users').User; +const GoogleStrategy = require('passport-google-oauth').Strategy; +const authenticateByProfile = require('./../lib/authenticateByProfile'); +const config = require('config'); + +// Doesn't work: error when denied access, +// maybe https://www.npmjs.org/package/passport-google-plus ? +// should not require G+ + +/* Result example: + var result = { + "kind": "plus#person", + "etag": "\"pNz5TVTpPz2Rn5Xw8UrubkkbOJ0/79ehDjWVUdPtREa5lO-8QSWwSUQ\"", + "emails": [ + { + "value": "julia.b.kantor@gmail.com", + "type": "account" + } + ], + "objectType": "person", + "id": "104971107141139955646", + "displayName": "Юлия Кантор", + "name": { + "familyName": "Кантор", + "givenName": "Юлия" + }, + "image": { + "url": "https://lh3.googleusercontent.com/-XdUIqdMkCWA/AAAAAAAAAAI/AAAAAAAAAAA/4252rscbv5M/photo.jpg?sz=50", + "isDefault": true + }, + "isPlusUser": false, + "language": "ru", + "verified": false + } + */ + +/* + + For image: + ?sz=SIZE, large picture without sz! + isDefault: true if no picture + + */ + +/* + // revoke permission: https://security.google.com/settings/security/permissions?pli=1 + */ + +module.exports = new GoogleStrategy({ + clientID: config.auth.providers.google.appId, + clientSecret: config.auth.providers.google.appSecret, + callbackURL: config.server.siteHost + "/auth/callback/google", + passReqToCallback: true + }, + function(req, token, tokenSecret, profile, done) { + + profile.realName = profile._json.nickname; + + authenticateByProfile(req, profile, done); + } +); + diff --git a/handlers/auth/strategies/index.js b/handlers/auth/strategies/index.js new file mode 100755 index 000000000..80e3097be --- /dev/null +++ b/handlers/auth/strategies/index.js @@ -0,0 +1,12 @@ +var passport = require('koa-passport'); + + +passport.use(require('./localStrategy')); + +passport.use(require('./facebookStrategy')); +passport.use(require('./googleStrategy')); +passport.use(require('./yandexStrategy')); +passport.use(require('./githubStrategy')); +passport.use(require('./vkontakteStrategy')); + + diff --git a/handlers/auth/strategies/localStrategy.js b/handlers/auth/strategies/localStrategy.js new file mode 100755 index 000000000..9c5826fa8 --- /dev/null +++ b/handlers/auth/strategies/localStrategy.js @@ -0,0 +1,54 @@ +var User = require('users').User; + +const LocalStrategy = require('passport-local').Strategy; +const co = require('co'); + +function UserAuthError(message) { + this.message = message; +} + + +// done(null, user) +// OR +// done(null, false, { message: }) <- 3rd arg format is from built-in messages of strategies +module.exports = new LocalStrategy({ + usernameField: 'email', + passwordField: 'password' +}, function(email, password, done) { + + co(function*() { + + if (!email) throw new UserAuthError('Укажите email.'); + if (!password) throw new UserAuthError('Укажите пароль.'); + + // anti-bruteforce pause + yield function(callback) { + setTimeout(callback, 100); + }; + + var user = yield User.findOne({email: email}).exec(); + + if (!user) { + throw new UserAuthError('Нет такого пользователя.'); + } + + if (!user.checkPassword(password)) { + throw new UserAuthError('Пароль неверен.'); + } + + if (!user.verifiedEmail) { + throw new UserAuthError('Ваш email не подтверждён, проверьте почту. Также можно запросить подтверждение заново.'); + } + + return user; + }).then(function(user) { + done(null, user); + }, function(err) { + if (err instanceof UserAuthError) { + done(null, false, {message: err.message}); + } else { + done(err); + } + }); + +}); diff --git a/handlers/auth/strategies/vkontakteStrategy.js b/handlers/auth/strategies/vkontakteStrategy.js new file mode 100755 index 000000000..bf52af178 --- /dev/null +++ b/handlers/auth/strategies/vkontakteStrategy.js @@ -0,0 +1,42 @@ +var User = require('users').User; +const VkontakteStrategy = require('passport-vkontakte').Strategy; +const authenticateByProfile = require('../lib/authenticateByProfile'); +const config = require('config'); + +/* +result: +{ id: 1818925, + first_name: 'Юля', + last_name: 'Дубовик', + sex: 1, + screen_name: 'id1818925', + photo: 'http://cs5475.vk.me/u1818925/e_974b5ece.jpg' +} +(email in oauthResponse) +*/ + +module.exports = new VkontakteStrategy({ + clientID: config.auth.providers.vkontakte.appId, + clientSecret: config.auth.providers.vkontakte.appSecret, + callbackURL: config.server.siteHost + "/auth/callback/vkontakte", + passReqToCallback: true + }, + function(req, accessToken, refreshToken, oauthResponse, profile, done) { + + // Vkontakte gives email in oauthResponse, not in profile (which is 1 more request) + if (!oauthResponse.email) { + return done(null, false, {message: "При входе разрешите доступ к email. Он используется для идентификации пользователя."}); + } + + + profile.emails = [ + {value: oauthResponse.email} + ]; + + // vkontakte assumes this to be a real name + profile.realName = profile.displayName; + + authenticateByProfile(req, profile, done); + } +); + diff --git a/handlers/auth/strategies/yandexStrategy.js b/handlers/auth/strategies/yandexStrategy.js new file mode 100755 index 000000000..e9d356531 --- /dev/null +++ b/handlers/auth/strategies/yandexStrategy.js @@ -0,0 +1,60 @@ +var User = require('users').User; +const YandexStrategy = require('passport-yandex').Strategy; +const authenticateByProfile = require('../lib/authenticateByProfile'); +const config = require('config'); + +/* + profile: { + "provider": "yandex", + "id": "11111", + "username": "iliakan", + "displayName": "iliakan", + "name": { + "familyName": "Ilya", + "givenName": "Kantor" + }, + "gender": "male", + "emails": [ + { + "value": "login@yandex.ru" + } + ], + "_raw": "{\"first_name\": \"Ilya\", \"last_name\": \"Kantor\", \"display_name\": \"iliakan\", \"emails\": [\"login@yandex.ru\"], \"default_email\": \"login@yandex.ru\", \"real_name\": \"Ilya Kantor\", \"default_avatar_id\": \"11111\", \"login\": \"login\", \"sex\": \"male\", \"id\": \"11111\"}", + "_json": { + "first_name": "Ilya", + "last_name": "Kantor", + "display_name": "iliakan", + "emails": [ + "login@yandex.ru" + ], + "default_email": "login@yandex.ru", + "real_name": "Ilya Kantor", + "default_avatar_id": "11111", + "login": "login", + "sex": "male", + "id": "11111" + }, + "realName": "Ilya Kantor" + } +*/ + +module.exports = new YandexStrategy({ + clientID: config.auth.providers.yandex.appId, + clientSecret: config.auth.providers.yandex.appSecret, + callbackURL: config.server.siteHost + "/auth/callback/yandex", + passReqToCallback: true + }, + function(req, accessToken, refreshToken, profile, done) { + /* jshint -W106 */ + profile.realName = profile._json.real_name; + + // there is no way to know if it is a default avatar or not + // if user has no avatar, this gives us the "default yandex blank avatar" + profile.photos = [{ + value: `https://avatars.yandex.net/get-yapic/${profile._json.default_avatar_id}/islands-200` + }]; + + authenticateByProfile(req, profile, done); + } +); + diff --git a/handlers/auth/templates/forgot-email.jade b/handlers/auth/templates/forgot-email.jade new file mode 100755 index 000000000..d89e96bfa --- /dev/null +++ b/handlers/auth/templates/forgot-email.jade @@ -0,0 +1,8 @@ +extends /layouts/email + +block body + h1 Восстановление доступа на javascript.ru + p Для восстановления доступа перейдите, пожалуйста, по ссылке + =' ' + a(href=link) #{link} + | . diff --git a/handlers/auth/templates/forgot-form.jade b/handlers/auth/templates/forgot-form.jade new file mode 100644 index 000000000..48acf4c4b --- /dev/null +++ b/handlers/auth/templates/forgot-form.jade @@ -0,0 +1,23 @@ +include /bem + ++b.login-form + +e('form').form(action="#" data-form="forgot") + +e.line.__header + +e('h4').title Восстановление пароля + +e.line.__notification(data-notification) + +e.line + +e('label').label(for="forgot-email") Email: + +b('span').text-input.__input + +e('input').control#forgot-email(name="email" type="email" autofocus) + +e.line + +b('button').button._action.__submit(type="submit") + +e('span').text Восстановить пароль + +e.line.__footer + +e('button').button-link(type="button" data-switch="login-form") Вход + =" " + +e('span').separator / + =" " + +e('button').button-link(data-switch="register-form") Регистрация + +e('a').close-link.tablet-only.modal__close Отмена + include login-form-footer + diff --git a/handlers/auth/templates/forgot-recover.jade b/handlers/auth/templates/forgot-recover.jade new file mode 100644 index 000000000..2ed50c865 --- /dev/null +++ b/handlers/auth/templates/forgot-recover.jade @@ -0,0 +1,31 @@ +extends /layouts/body + +block append variables + - var title = "Восстановление пароля" + +block body + +b.page + +e.inner + +b.main._width-limit + + +b.recover + +e('h1').title Восстановление пароля + + if error + //- __message - на будущее, даёт класс recover__message для стилизации в контексте recover + +b.notification._message._error.__message + +e.content= error + +e('button').close(title="Закрыть") + + +e('form')(action="/auth/forgot-recover" method="POST").content + input(type="hidden" name="passwordResetToken" value=passwordResetToken) + +e.controls + +e.label-wrap + +e('label').label(for="newpass") Новый пароль + +e.input-wrap + +b.text-input._small.__input + +e('input').control#newpass(type="password" name="password" autofocus) + +e.save-wrap + +b('button').button._action.__save + +e('text') Сохранить пароль + diff --git a/handlers/auth/templates/login-form-footer.jade b/handlers/auth/templates/login-form-footer.jade new file mode 100755 index 000000000..57a13832a --- /dev/null +++ b/handlers/auth/templates/login-form-footer.jade @@ -0,0 +1,5 @@ + ++e.line.__social-logins + +e('h5').social-logins-title Вход через социальные сети + =" " + include providers diff --git a/handlers/auth/templates/login-form.jade b/handlers/auth/templates/login-form.jade new file mode 100755 index 000000000..fcb48aa42 --- /dev/null +++ b/handlers/auth/templates/login-form.jade @@ -0,0 +1,31 @@ +include /bem + ++b.login-form(data-form="login") + +e('form').form(action="#") + +e.line.__header + +e('h4').title Вход в систему + +e.header-aside + +e('button').button-link.__register(type="button" data-switch="register-form") регистрация + + //- + +e.line.__notification + +e.info + | Авторизация работает в тестовом режиме. О любых проблемах и странностях сообщайте, пожалуйста, на github. + + +e.line.__notification(data-notification) + + +e.line + +e('label').label(for="auth-email") Email: + +b('span').text-input.__input + +e('input').control#auth-email(name="email" type="email" autofocus) + +e.line + +e('label').label(for="auth-password") Пароль: + +b('span').text-input._with-aside.__input + +e('input').control#auth-password(type="password", name="password") + +e('button').aside.__forgot.__button-link(type="button" data-switch="forgot-form") Забыли? + +e.line.__footer + +b('button').button._action(type="submit") + +e('span').text Войти + +e('a').close-link.tablet-only.modal__close Отмена + include login-form-footer + diff --git a/handlers/auth/templates/popup-failure.jade b/handlers/auth/templates/popup-failure.jade new file mode 100755 index 000000000..3e25082c4 --- /dev/null +++ b/handlers/auth/templates/popup-failure.jade @@ -0,0 +1,49 @@ +doctype html +html + title Отказ в авторизации +body + script var reason = !{JSON.stringify(reason)} + + //- window.authProvider for login is AuthModal, + //- for managing providers AuthProvidersManager + script. + + !(function() { + if (!window.opener) { + window.close(); + return; + } + var accessAllowed = true; + try { + window.opener.document.documentElement; + } catch (e) { + accessAllowed = false; + } + if (accessAllowed) { + // https and same domain in both windows + if (window.opener.authModal) { + window.opener.focus(); + window.opener.authModal.onAuthFailure(reason); + } + window.close(); + } else { + // probably the opener is http:// and we're https:// ? + if (location.protocol == 'https:') { + var form = document.createElement('form'); + form.method = 'POST'; + form.action = 'http://' + location.host + '/auth/popup-failure'; + var input = document.createElement('input'); + input.name = 'reason'; + input.value = reason; + form.appendChild(input); + document.body.appendChild(form); + form.submit(); + } else { + // we're http and access disallowed, probably the opener window navigated away from the site + window.close(); + } + } + })(); + + + diff --git a/handlers/auth/templates/popup-success.jade b/handlers/auth/templates/popup-success.jade new file mode 100755 index 000000000..b1416a674 --- /dev/null +++ b/handlers/auth/templates/popup-success.jade @@ -0,0 +1,39 @@ +doctype html +html + title Успешная авторизация +body + script var user = !{JSON.stringify(user.getInfoFields())}; + + script. + + !(function() { + if (!window.opener) { + window.close(); + return; + } + + var accessAllowed = true; + try { + window.opener.document.documentElement; + } catch(e) { + accessAllowed = false; + } + + if (accessAllowed) { + // https and same domain in both windows + if (window.opener.authModal) { + window.opener.focus(); + window.opener.authModal.onAuthSuccess(user); + } + window.close(); + } else { + // probably the opener is http:// and we're https:// ? + if (location.protocol == 'https:') { + window.location.replace('http://' + location.host + '/auth/popup-success'); + } else { + // we're http and access disallowed, probably the opener window navigated away from the site + window.close(); + } + } + + })(); diff --git a/handlers/auth/templates/providers.jade b/handlers/auth/templates/providers.jade new file mode 100755 index 000000000..e79b6519d --- /dev/null +++ b/handlers/auth/templates/providers.jade @@ -0,0 +1,10 @@ + ++b('button').social-login._facebook.__social-login(data-provider="facebook") Facebook +=" " ++b('button').social-login._google.__social-login(data-provider="google") Google+ +=" " ++b('button').social-login._vkontakte.__social-login(data-provider="vkontakte") Вконтакте +=" " ++b('button').social-login._github.__social-login(data-provider="github") Github +=" " ++b('button').social-login._yandex.__social-login(data-provider="yandex") Яндекс diff --git a/handlers/auth/templates/register-form.jade b/handlers/auth/templates/register-form.jade new file mode 100644 index 000000000..597b1468b --- /dev/null +++ b/handlers/auth/templates/register-form.jade @@ -0,0 +1,42 @@ +include /bem + ++b.login-form + +e('form').form(action="#" data-form="register") + +e.line.__header + +e('h4').title Регистрация + +e.header-aside + +e('button').button-link(type="button" data-switch="login-form") вход + + //- + +e.line.__notification + +e.info + | Регистрация работает в тестовом режиме. О любых проблемах и странностях сообщайте, пожалуйста, на github. + + +e.line.__notification(data-notification) + + +e.line + +e('label').label(for="register-email") Email: + +b('span').text-input.__input + +e('input').control#register-email(name="email" type="email" required autofocus) + +e.line + +e('label').label(for="register-displayName") Имя пользователя: + +b('span').text-input.__input + +e('input').control#register-displayName(name="displayName" required) + +e.line + +e('label').label(for="register-password") Пароль: + +b('span').text-input.__input + +e('input').control#register-password(type="password" name="password" required minlength="4") + +e.line.__footer + +b('button').button._action.submit(type="submit") + +e('span').text Зарегистрироваться + + +e('a').close-link.tablet-only.modal__close Отмена + + +e.line.__agreement + | Регистрируясь, вы принимаете + = ' ' + a(href="/agreement") пользовательское соглашение + | . + include login-form-footer + + diff --git a/handlers/auth/templates/verify-registration-email.jade b/handlers/auth/templates/verify-registration-email.jade new file mode 100755 index 000000000..48506f022 --- /dev/null +++ b/handlers/auth/templates/verify-registration-email.jade @@ -0,0 +1,8 @@ +extends /layouts/email + +block body + h1 Подтверждение email на javascript.ru + p Для завершения регистрации перейдите, пожалуйста, по ссылке + =' ' + a(href=link) #{link} + | . diff --git a/handlers/auth/test/.jshintrc b/handlers/auth/test/.jshintrc new file mode 100755 index 000000000..077663629 --- /dev/null +++ b/handlers/auth/test/.jshintrc @@ -0,0 +1,23 @@ +{ + "maxerr": 25, + "latedef": "nofunc", + "browser": false, + "node": true, + "indent": 2, + "camelcase": true, + "newcap": true, + "undef": true, + "esnext": true, + "multistr": true, + "noyield": true, + "expr": true, + "-W004": true, + "globals" : { + "describe" : false, + "it" : false, + "before" : false, + "beforeEach" : false, + "after" : false, + "afterEach" : false + } +} diff --git a/handlers/auth/test/e2e/facebook.js b/handlers/auth/test/e2e/facebook.js new file mode 100755 index 000000000..0a4e1f22e --- /dev/null +++ b/handlers/auth/test/e2e/facebook.js @@ -0,0 +1,100 @@ +const webdriver = require('selenium-webdriver'); +const path = require('path'); +const app = require('app'); +const db = require('lib/dataUtil'); +const config = require('config'); +const By = require('selenium-webdriver').By; +const until = require('selenium-webdriver').until; +const tunnel = require('lib/e2eTunnel'); +const browser = require('lib/selenium/browser'); +const fixtures = require(path.join(__dirname, '../fixtures/db')); + + +// disabled until fixed +describe('facebook', function() { + + var driver, server; + + before(function*() { + yield* db.loadModels(fixtures, {reset: true}); + + yield* tunnel(); + + driver = browser(); + + server = app.listen(config.server.port); + server.unref(); + }); + + it('logs in', function*() { + + var i = 0; + driver.get(config.test.e2e.siteHost + '/folder'); + + driver.findElement(By.css('button.sitetoolbar__login')).click(); + + var btn = By.css('button[data-provider="facebook"]'); + driver.wait(until.elementLocated(btn)); + driver.findElement(btn).click(); + + driver.getAllWindowHandles().then(function(handles) { + driver.switchTo().window(handles[1]); // new window + }); + + driver.wait(until.elementLocated(By.id('pass'))); + + driver.findElement(By.id('email')).sendKeys(config.auth.providers.facebook.testCredentials.email); + driver.findElement(By.id('pass')).sendKeys(config.auth.providers.facebook.testCredentials.pass); + driver.findElement(By.id('pass')).sendKeys(webdriver.Key.RETURN); + + // after login there are 2 possibilities + // 1) First time login to facebook => need to click __CONFIRM__ + // 2) Already authorized app => will proceed (usually this is the case) + + // if 1) is correct, the window will close, + // driver.wait(until.elementLocated(By.name('__CONFIRM__')) will not work + + driver.wait(function() { + return driver.getAllWindowHandles().then(function(handles) { + return handles.length == 1; // wait to see if only 1 window left (works) + }); + }, 5000).then(function() { + // only 1 window means we're done w/ logging in + return webdriver.promise.fulfilled(); + + }, function() { + // 2 windows means we need to press __CONFIRM__ (or something went wrong?) + + // if 2 windows => confirm and wait again + driver.findElement(By.name('__CONFIRM__')).click(); + + return driver.wait(function() { + return driver.getAllWindowHandles().then(function(handles) { + return handles.length == 1; // wait to see if only 1 window left (works) + }); + }, 5000); + + }); + + driver.getAllWindowHandles().then(function(handles) { + driver.switchTo().window(handles[0]); // main window + }); + + + // wait for either success or throw + yield function(callback) { + driver.wait(until.elementLocated(By.css('.sitetoolbar__user'))).then(function() { + callback(); + }, function(err) { + throw err; + }); + }; + + }); + + // callback after makes sure that the browser actually closed + after(function(callback) { + driver.quit().then(callback); + }); + +}); diff --git a/handlers/auth/test/fixtures/db.js b/handlers/auth/test/fixtures/db.js new file mode 100755 index 000000000..c91de9820 --- /dev/null +++ b/handlers/auth/test/fixtures/db.js @@ -0,0 +1,50 @@ +require('users').User; +require('tutorial').Article; + + +exports.User = [ + { "_id": "000000000000000000000001", + "created": new Date(2014,0,1), + "displayName": "ilya kantor", + "email": "iliakan@gmail.com", + "profileName": "iliakan", + "password": "1234", + "verifiedEmail": true + }, + { "_id": "000000000000000000000002", + "created": new Date(2014,0,1), + "displayName": "tester", + "email": "tester@mail.com", + "profileName": "tester", + "password": "1234", + "verifiedEmail": true + }, + { "_id": "000000000000000000000003", + "created": new Date(2014,0,1), + "displayName": "vasya", + "profileName": "vasya", + "email": "vasya@mail.com", + "password": "1234", + "verifiedEmail": false + } +]; + + +exports.Article = [ + { + "_id": '000000000000000000000010', + "isFolder" : true, + "content" : "# Введение\n\nПро язык JavaScript и окружение для разработки на нём.", + "weight" : 1, + "slug" : "folder", + "title" : "Введение" + }, + { + "parent" : '000000000000000000000010', + "_id": '000000000000000000000011', + "content" : "# Введение в JavaScript\n\nДавайте посмотрим, что такого особенного в JavaScript, почему именно он, и какие еще технологии существуют, кроме JavaScript.\n[cut]\n## Что такое JavaScript? \n\n*JavaScript* изначально создавался для того, чтобы сделать web-странички \"живыми\". \nПрограммы на этом языке называются *скриптами*. Они подключаются напрямую к HTML и, как только загружается страничка -- тут же выполняются.\n\n**Программы на JavaScript -- обычный текст**. Они не требуют какой-то специальной подготовки.\n\nВ этом плане JavaScript сильно отличается от другого языка, который называется Java.\n\n[smart header=\"Почему JavaScript?\"]\nКогда создавался язык JavaScript, у него изначально было другое название: \"LiveScript\". Но тогда был очень популярен язык Java, и маркетологи решили, что схожее название сделает новый язык более популярным.\n\nПланировалось, что JavaScript будет эдаким \"младшим братом\" Java. Однако, история распорядилась по-своему, JavaScript сильно вырос, и сейчас это совершенно независимый язык, со своей спецификацией, которая называется ECMAScript, и к Java не имеет никакого отношения.\n\nУ него много особенностей, которые усложняют освоение, но по ходу учебника мы с ними разберемся.\n[/smart]\n\nЧтобы читать и выполнять текст на JavaScript, нужна специальная программа -- [интерпретатор](http://ru.wikipedia.org/wiki/%D0%98%D0%BD%D1%82%D0%B5%D1%80%D0%BF%D1%80%D0%B5%D1%82%D0%B0%D1%82%D0%BE%D1%80). Процесс выполнения скрипта называют *\"интерпретацией\"*.\n\n[smart header=\"Компиляция и интерпретация, для программистов\"]\nСтрого говоря, для выполнения программ существуют \"компиляторы\" и \"интерпретаторы\". \n\nКомпиляторы преобразуют программу в машинный код. Этот машинный код затем распространяется и запускается. \n\nА интерпретаторы, в частности, встроенный JS-интерпретатор браузера -- получают программу в виде исходного кода. При этом распространяется именно сам исходный код (скрипт).\n\nСовременные интерпретаторы перед выполнением преобразуют JavaScript в машинный код или близко к нему, а уже затем выполняют. \n[/smart]\n\nВо все основные браузеры встроен интерпретатор JavaScript, именно поэтому они могут выполнять скрипты на странице.\n\nНо, разумеется, этим возможности JavaScript не ограничены. Это полноценный язык, программы на котором можно запускать и на сервере, и даже в стиральной машинке, если в ней установлен соответствующий интерпретатор.\n\n## Что умеет JavaScript? \n\nСовременный JavaScript -- это \"безопасный\" язык программирования общего назначения. Он не предоставляет низкоуровневых средств работы с памятью, процессором, так как изначально был ориентирован на браузеры, в которых это не требуется.\n\nЧто же касается остальных возможностей -- они зависят от окружения, в котором запущен JavaScript. \n\nВ браузере JavaScript умеет делать все, что относится к манипуляции со страницей, взаимодействию с посетителем и, в какой-то мере, с сервером: \n\n
    \n
  • Создавать новые HTML-теги, удалять существующие, менять стили элементов, прятать, показывать элементы и т.п.
  • \n
  • Реагировать на действия посетителя, обрабатывать клики мыши, перемещение курсора, нажатие на клавиатуру и т.п.
  • \n
  • Посылать запросы на сервер и загружать данные без перезагрузки страницы(эта технология называется "AJAX").
  • \n
  • Получать и устанавливать cookie, запрашивать данные, выводить сообщения...
  • \n
  • ...и многое, многое другое!
  • \n
\n\n## Что НЕ умеет JavaScript? \n\nJavaScript -- быстрый и мощный язык, но браузер накладывает на его исполнение некоторые ограничения.. \n\nЭто сделано для безопасности пользователей, чтобы злоумышленник не мог с помощью JavaScript получить личные данные или как-то навредить компьютеру пользователя. \n\nЭтих ограничений нет там, где JavaScript используется вне браузера, например на сервере. Кроме того, различные браузеры предоставляют свои механизмы по установке плагинов и расширений, которые обладают расширенными возможностями, но требуют специальных действий по установке от пользователя\n\n**Большинство возможностей JavaScript в браузере ограничено текущим окном и страницей.**\n\n\n\n
    \n
  • JavaScript не может читать/записывать произвольные файлы на жесткий диск, копировать их или вызывать программы. Он не имеет прямого доступа к операционной системе.\n\nСовременные браузеры могут работать с файлами, но эта возможность ограничена специально выделенной директорией -- *\"песочницей\"*. Возможности по доступу к устройствам также прорабатываются в современных стандартах и частично доступны в некоторых браузерах.\n
  • \n
  • JavaScript, работающий в одной вкладке, не может общаться с другими вкладками и окнами, за исключением случая, когда он сам открыл это окно или несколько вкладок из одного источника (одинаковый домен, порт, протокол).\n\nЕсть способы это обойти, и они раскрыты в учебнике, но они требуют внедрения специального кода на оба документа, которые находятся в разных вкладках или окнах. Без него, из соображений безопасности, залезть из одной вкладки в другую при помощи JavaScript нельзя. \n
  • \n
  • Из JavaScript можно легко посылать запросы на сервер, с которого пришла страница. Запрос на другой домен тоже возможен, но менее удобен, т.к. и здесь есть ограничения безопасности. \n
  • \n
\n\n## В чем уникальность JavaScript? \n\nЕсть как минимум *три* замечательных особенности JavaScript:\n\n[compare]\n+Полная интеграция с HTML/CSS.\n+Простые вещи делаются просто.\n+Поддерживается всеми распространенными браузерами и включен по умолчанию.\n[/compare]\n\n**Этих трёх вещей одновременно нет больше ни в одной браузерной технологии.** Поэтому JavaScript и является самым распространенным средством создания браузерных интерфейсов.\n\n## Тенденции развития. \n\nПеред тем, как вы планируете изучить новую технологию, полезно ознакомиться с ее развитием и перспективами. Здесь в JavaScript всё более чем хорошо.\n\n### HTML 5\n\n*HTML 5* -- эволюция стандарта HTML, добавляющая новые теги и, что более важно, ряд новых возможностей браузерам.\n\nВот несколько примеров:\n
    \n
  • Чтение/запись файлов на диск (в специальной \"песочнице\", то есть не любые).
  • \n
  • Встроенная в браузер база данных, которая позволяет хранить данные на компьютере пользователя.
  • \n
  • Многозадачность с одновременным использованием нескольких ядер процессора.
  • \n
  • Проигрывание видео/аудио, без Flash.
  • \n
  • 2d и 3d-рисование с аппаратной поддержкой, как в современных играх.
  • \n
\n\nМногие возможности HTML5 всё ещё в разработке, но браузеры постепенно начинают их поддерживать.\n\n[summary]Тенденция: JavaScript становится всё более и более мощным и возможности браузера растут в сторону десктопных приложений.[/summary]\n\n### EcmaScript 6\n\nСам язык JavaScript улучшается. Современный стандарт EcmaScript 5 включает в себя новые возможности для разработки, EcmaScript 6 будет шагом вперёд в улучшении синтаксиса языка.\n\nСовременные браузеры улучшают свои движки, чтобы увеличить скорость исполнения JavaScript, исправляют баги и стараются следовать стандартам.\n\n[summary]Тенденция: JavaScript становится всё быстрее и стабильнее.[/summary]\n\nОчень важно то, что новые стандарты HTML5 и ECMAScript сохраняют максимальную совместимость с предыдущими версиями. Это позволяет избежать неприятностей с уже существующими приложениями.\n\nВпрочем, небольшая проблема с HTML5 всё же есть. Иногда браузеры стараются включить новые возможности, которые еще не полностью описаны в стандарте, но настолько интересны, что разработчики просто не могут ждать. \n\n...Однако, со временем стандарт меняется и браузерам приходится подстраиваться к нему, что может привести к ошибкам в уже написанном (старом) коде. Поэтому следует дважды подумать перед тем, как применять на практике такие \"супер-новые\" решения.\n\nПри этом все браузеры сходятся к стандарту, и различий между ними уже гораздо меньше, чем всего лишь несколько лет назад.\n\n[summary]Тенденция: всё идет к полной совместимости со стандартом.[/summary]\n\n## Недостатки JavaScript\n\nЗачастую, недостатки подходов и технологий -- это обратная сторона их полезности. Стоит ли упрекать молоток в том, что он -- тяжелый? Да, неудобно, зато гвозди забиваются лучше.\n\nВ JavaScript, однако, есть вполне объективные недоработки, связанные с тем, что язык, по выражению его автора (Brendan Eich) делался \"за 10 бессонных дней и ночей\". Поэтому некоторые моменты продуманы плохо, есть и откровенные ошибки (которые признает тот же Brendan). \n\nКонкретные примеры мы увидим в дальнейшем, т.к. их удобнее обсуждать в процессе освоения языка.\n\nПока что нам важно знать, что некоторые \"странности\" языка не являются чем-то очень умным, а просто не были достаточно хорошо продуманы в своё время. В этом учебнике мы будем обращать особое внимание на основные недоработки и \"грабли\". Ничего критичного в них нет, если знаешь -- не наступишь.\n\n**В новых версиях JavaScript (ECMAScript) эти недостатки постепенно убирают.** \n\nПроцесс внедрения небыстрый, в первую очередь из-за старых версий IE, но они постепенно вымирают. Современный IE в этом отношении несравнимо лучше.", "isFolder" : false, + "weight" : 1, + "slug" : "article", + "title" : "Введение в JavaScript" + } +]; diff --git a/handlers/auth/test/server/local.js b/handlers/auth/test/server/local.js new file mode 100755 index 000000000..8167045d9 --- /dev/null +++ b/handlers/auth/test/server/local.js @@ -0,0 +1,126 @@ +/* globals describe, it, before */ + +const db = require('lib/dataUtil'); +const mongoose = require('mongoose'); +const path = require('path'); +const request = require('supertest'); +const fixtures = require(path.join(__dirname, '../fixtures/db')); +const app = require('app'); +const assert = require('better-assert'); + +describe('Authorization', function() { + + var server; + before(function* () { + + yield* db.loadModels(fixtures); + + // APP.LISTEN() USES A RANDOM PORT, + // which superagent gets as server.address().port + // so that every run will get it's own port + server = app.listen(); + server.unref(); + }); + + describe('login', function() { + + it('should require verified email', function(done) { + request(server) + .post('/auth/login/local') + .send({ + email: fixtures.User[2].email, + password: fixtures.User[2].password + }) + .expect(401, done); + }); + }); + + describe('login flow', function() { + var agent; + + before(function() { + agent = request.agent(server); + }); + + it('should log in when email is verified', function(done) { + agent + .post('/auth/login/local') + .send({ + email: fixtures.User[0].email, + password: fixtures.User[0].password + }) + .expect(200, done); + }); + + it('should log out', function(done) { + agent + .post('/auth/logout') + .send('') + .expect(302, done); + }); + + it('should return error because session is incorrect', function(done) { + agent + .post('/auth/logout') + .send('') + .expect(401, done); + }); + }); + + describe("register", function() { + var agent; + + before(function() { + agent = request.agent(server); + }); + + var userData = { + email: Math.random() + "@gmail.com", + displayName: "Random guy", + password: "somepass" + }; + + it('should create a new user', function(done) { + agent + .post('/auth/register') + .send(userData) + .expect(201, done); + }); + + it('should not be logged in', function(done) { + agent + .post('/auth/logout') + .send('') + .expect(401, done); + }); + /* + + .end(function(err, res) { + res.body.email.should.be.eql(userData.email); + res.body.displayName.should.be.eql(userData.displayName); + done(err); + }); + it('should be log in the new user', function(done) { + request(server) + .post('/auth/login/local') + .send({email: userData.email, password: userData.password}) + .expect(200, done); + }); + */ + + it('should fail to create a new user with same email', function(done) { + request(server) + .post('/auth/register') + .send(userData) + .set('Accept', 'application/json') + .expect(400) + .end(function(err, res) { + if (err) return done(err); + res.body.errors.email.should.exist; + done(); + }); + }); + + }); + +}); diff --git a/handlers/bodyParser.js b/handlers/bodyParser.js new file mode 100755 index 000000000..c536bbe70 --- /dev/null +++ b/handlers/bodyParser.js @@ -0,0 +1,42 @@ +const bodyParser = require('koa-bodyparser'); +const PathListCheck = require('pathListCheck'); + +function BodyParser() { + this.ignore = new PathListCheck(); + + // default limits are: + // formLimit: limit of the urlencoded body. If the body ends up being larger than this limit, a 413 error code is returned. + // Default is 56kb + // jsonLimit: limit of the json body. + // Default is 1mb + this.parser = bodyParser({ + formLimit: '1mb', // 56kb is not enough for mandrill webhook which is urlencoded + jsonLimit: '1mb' + }); +} + +BodyParser.prototype.middleware = function() { + var self = this; + + return function*(next) { + + if (!self.ignore.check(this.path)) { + this.log.debug("bodyParser will parse"); + + yield* self.parser.call(this, next); + + this.log.debug("bodyParser done parse"); + } else { + this.log.debug("bodyParser skip"); + } + + yield* next; + }; +}; + + +exports.init = function(app) { + + app.bodyParser = new BodyParser(); + app.use(app.bodyParser.middleware()); +}; diff --git a/handlers/cache/index.js b/handlers/cache/index.js new file mode 100755 index 000000000..8d398505a --- /dev/null +++ b/handlers/cache/index.js @@ -0,0 +1,7 @@ +exports.CacheEntry = require('./models/cacheEntry'); + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/cache', __dirname)); +}; diff --git a/handlers/cache/models/cacheEntry.js b/handlers/cache/models/cacheEntry.js new file mode 100755 index 000000000..3ebac1334 --- /dev/null +++ b/handlers/cache/models/cacheEntry.js @@ -0,0 +1,182 @@ +// A mongoose entry for generic tagged caching + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +// by default a generation may took that long ms maximally +const GENERATING_TIME_LIMIT_DEFAULT = 3000; + +const schema = new Schema({ + key: { + type: String, + required: true, + unique: true + }, + + tags: { + type: [String], + index: true + }, + + value: { + type: {}, + validate: [ + { + validator: function(value) { + return !!(this.generatingStartTimestamp || value !== undefined); + }, + msg: "Must have value." + } + ] + + }, + + generatingStartTimestamp: Date, + generatingTimeLimit: Number, + + // when to expire? + // no expireAt means it won't expire + // mongo autoclears the documents every minute + expireAt: { + type: Date + } + +}); + +schema.index({ "expireAt": 1 }, { expireAfterSeconds: 0 }); + + +// get value in a non-waiting way +// skip generating values +schema.statics.get = function* (key) { + // try to find it + var result = yield this.findOne({key: key}).exec(); + + // no value - fine.. + if (!result) return result; + + // if it's actually a generating value, consider that as no-value (yet) + if (result.generatingStartTimestamp) return null; + + return result.value; +}; + +// generate the value using *generator +// or get it from db (if someone else has generated it) +// --> never runs generators in parallel +// --> never returns stale values +schema.statics.getOrGenerate = function* (doc, generator) { + var CacheEntry = this; + // try to find it + var result; + + // disable cache for development + if (process.env.NODE_ENV == 'development') { + return yield generator(); + } + + result = yield CacheEntry.findOne({key: doc.key}).exec(); + + var generatingStartTimestamp; + + // no value - fine.. + if (!result) { + generatingStartTimestamp = Date.now(); + try { + yield new CacheEntry({ key: doc.key, generatingStartTimestamp: generatingStartTimestamp }).persist(); + } catch (e) { + // lost the race, someone has already persisted it and started generating + if (e.code == 11000) { + // let's try again + return yield CacheEntry.getOrGenerate(doc, generator); + } else { + throw e; + } + } + + var value = yield generator(); + + // in the case + // -> we started to generate + // -> set or remove is called for the key (!) + // -> we finished generating + // we consider set/remove here to be more important because this decision is taken LATER than the generation + // maybe something important has changed + // so we ditch the generated value and retry + var old = yield this.findOneAndUpdate( + // replace the very exact record we've made + // it's possible that someone called set(doc, value) and replaced it while we were generating + { key: doc.key, generatingStartTimestamp: generatingStartTimestamp }, + // $set every field of the document (to fully replace, not update) + // setting to undefined doesn't work here (mongoose bug?) + { + key: doc.key, + tags: doc.tags || [], + value: value, + expireAt: doc.expireAt || null, + generatingStartTimestamp: null, + generatingTimeLimit: null + }, + // don't generate a new document, return the old one + { new: false, upsert: false } + ).exec(); + + if (!old) { + // while we were generating, someone called set on the value (ouch!) or removed it (ouch ouch!) + // that's because something has changed. + // let's regenerate the value + return yield CacheEntry.getOrGenerate(doc, generator); + } + + return value; + } + + // otherwise check if it's actually a generating value + generatingStartTimestamp = result.generatingStartTimestamp; + + // not generating - fine.. + if (!generatingStartTimestamp) return result.value; + + // now check if we're waiting for too long + var timeLimit = result.generatingTimeLimit || GENERATING_TIME_LIMIT_DEFAULT; + +// console.log("Compare", +Date.now(), +generatingStartTimestamp + timeLimit, Date.now() > generatingStartTimestamp + timeLimit); + if (Date.now() > +generatingStartTimestamp + timeLimit) { + // too long wait, consider the value absent + // delete this very record: not just any of this key, but actually the outdated one + // (maybe someone else has done that already) + yield CacheEntry.destroy({key: doc.key, generatingStartTimestamp: result.generatingStartTimestamp}); + // ...and try again + return yield CacheEntry.getOrGenerate(doc, generator); + } + + // waiting for not very long, someone is working on it, + // let's pause a little bit + yield function(callback) { + setTimeout(callback, 100); + }; + + // ...and retry + return yield CacheEntry.getOrGenerate(doc, generator); +}; + + +// cache set, replaces and returns the old value if exists +schema.statics.set = function* (doc) { + return yield this.findOneAndUpdate( + { key: doc.key }, + { + key: doc.key, + tags: doc.tags || [], + value: doc.value, + expireAt: doc.expireAt || null, + generatingStartTimestamp: null, + generatingTimeLimit: null + }, + {new: false, upsert: true} + ).exec(); +}; + + +module.exports = mongoose.model('CacheEntry', schema); + diff --git a/handlers/cache/router.js b/handlers/cache/router.js new file mode 100755 index 000000000..0e82792f8 --- /dev/null +++ b/handlers/cache/router.js @@ -0,0 +1,14 @@ +var Router = require('koa-router'); +var mongoose = require('mongoose'); +var CacheEntry = require('./models/cacheEntry'); +var mustBeAdmin = require('auth').mustBeAdmin; +var _ = require('lodash'); + +var router = module.exports = new Router(); + +router.get('/destroy', mustBeAdmin, function*() { + yield CacheEntry.destroy(); + + this.body = 'done ' + new Date(); +}); + diff --git a/handlers/cache/test/.jshintrc b/handlers/cache/test/.jshintrc new file mode 100755 index 000000000..077663629 --- /dev/null +++ b/handlers/cache/test/.jshintrc @@ -0,0 +1,23 @@ +{ + "maxerr": 25, + "latedef": "nofunc", + "browser": false, + "node": true, + "indent": 2, + "camelcase": true, + "newcap": true, + "undef": true, + "esnext": true, + "multistr": true, + "noyield": true, + "expr": true, + "-W004": true, + "globals" : { + "describe" : false, + "it" : false, + "before" : false, + "beforeEach" : false, + "after" : false, + "afterEach" : false + } +} diff --git a/handlers/cache/test/cacheEntry.js b/handlers/cache/test/cacheEntry.js new file mode 100755 index 000000000..7a60cb5a9 --- /dev/null +++ b/handlers/cache/test/cacheEntry.js @@ -0,0 +1,97 @@ +var CacheEntry = require('../models/cacheEntry'); +var co = require('co'); + +describe('CacheEntry', function() { + + describe('getOrGenerate', function() { + + before(function*() { + yield CacheEntry.destroy({}); + }); + + var value = Math.random(); + + var called = 0; + + function* generateLong() { + yield function(callback) { + setTimeout(callback, 150); + }; + + called++; + + return value; + } + + it("Inserts the new value instantly", function*() { + var result = yield CacheEntry.getOrGenerate({ key: 'test' }, generateLong); + called.should.be.eql(1); + result.should.be.eql(value); + }); + + it("Can find it", function*() { + var result = yield CacheEntry.getOrGenerate({ key: 'test' }, generateLong); + called.should.be.eql(1); + result.should.be.eql(value); + }); + + + }); + + + describe('getOrGenerate', function() { + + beforeEach(function*() { + yield CacheEntry.destroy({}); + }); + + var value = Math.random(); + + var called = 0; + + function* generateLong() { + yield function(callback) { + setTimeout(callback, 150); + }; + + called++; + + return value; + } + + describe("when many cache requests", function() { + it("Should run the generator only once", function*() { + + var results = yield [ + CacheEntry.getOrGenerate({ key: 'test' }, generateLong), + CacheEntry.getOrGenerate({ key: 'test' }, generateLong), + CacheEntry.getOrGenerate({ key: 'test' }, generateLong) + ]; + called.should.be.eql(1); + results.forEach(function(result) { + result.should.be.eql(value); + }); + + }); + }); + + describe("when set while generating", function() { + it("Should use the latest (generated) value", function*() { + var result = yield [ + CacheEntry.getOrGenerate({key: 'test'}, generateLong), + co(function*() { + yield function(callback) { + setTimeout(callback, 50); + }; + + yield* CacheEntry.set({key: 'test', value: 'set'}); + }) + ]; + result[0].should.be.eql('set'); + }); + }); + + }); + + +}); diff --git a/handlers/conditional.js b/handlers/conditional.js new file mode 100755 index 000000000..70b19f766 --- /dev/null +++ b/handlers/conditional.js @@ -0,0 +1,27 @@ +var conditional = require('koa-conditional-get'); +var etag = require('koa-etag'); + +exports.init = function(app) { + // use it upstream from etag so + // that they are present + + // conditional triggers AFTER other middleware and returns 304/empty body if etag/modified matches + app.use(conditional()); + + // add etags AFTER every request (even POST), using file/body content and crc32 + app.use(etag()); + + // set expires to this.expires + app.use(function* (next) { + yield *next; + + if (!this.expires) return; + + if (process.env.NODE_ENV == 'development') { + // override any value with 2 secs, we don't need long expires to block our changes in dev + this.expires = 2; + } + this.set('Expires', new Date(Date.now() + this.expires*1e3).toUTCString()); + }); +}; + diff --git a/handlers/courses/client/contactForm.js b/handlers/courses/client/contactForm.js new file mode 100644 index 000000000..0c75248df --- /dev/null +++ b/handlers/courses/client/contactForm.js @@ -0,0 +1,34 @@ +var delegate = require('client/delegate'); + +class ContactForm { + constructor(options) { + this.elem = options.elem; + + this.elems = {}; + [].forEach.call(this.elem.querySelectorAll('[data-elem]'), (el) => { + this.elems[el.getAttribute('data-elem')] = el; + }); + + this.elem.onsubmit = this.onSubmit.bind(this); + + } + + focus() { + this.elems.contactName.focus(); + } + + onSubmit(event) { + event.preventDefault(); + + this.elem.dispatchEvent(new CustomEvent('select', { + detail: { + name: this.elems.contactName.value, + phone: this.elems.contactPhone.value + } + })); + + } + +} + +module.exports = ContactForm; diff --git a/handlers/courses/client/index.js b/handlers/courses/client/index.js new file mode 100644 index 000000000..a8831e4b2 --- /dev/null +++ b/handlers/courses/client/index.js @@ -0,0 +1,15 @@ +var SignupWidget = require('./signupWidget'); +var prism = require('client/prism'); + +exports.init = function() { + + var signupWidget = document.querySelector('[data-elem="signup"]'); + if (signupWidget) { + new SignupWidget({ + elem: signupWidget + }); + } + + prism.init(); + +}; diff --git a/handlers/courses/client/participantsForm.js b/handlers/courses/client/participantsForm.js new file mode 100644 index 000000000..f8fe3758a --- /dev/null +++ b/handlers/courses/client/participantsForm.js @@ -0,0 +1,183 @@ +var delegate = require('client/delegate'); +var participantsItem = require('../templates/blocks/participantsItem.jade'); +var notification = require('client/notification'); + +var clientRender = require('client/clientRender'); + +class ParticipantsForm { + constructor(options) { + this.elem = options.elem; + + this.elems = {}; + [].forEach.call(this.elem.querySelectorAll('[data-elem]'), (el) => { + this.elems[el.getAttribute('data-elem')] = el; + }); + + this.elem.onsubmit = this.onSubmit.bind(this); + + this.elems.participantsDecreaseButton.onclick = this.onParticipantsDecreaseButtonClick.bind(this); + this.elems.participantsDecreaseButton.onmousedown = () => { return false; }; + this.elems.participantsIncreaseButton.onclick = this.onParticipantsIncreaseButtonClick.bind(this); + this.elems.participantsIncreaseButton.onmousedown = () => { return false; }; + + this.elems.participantsCountInput.onkeydown = (e) => { + // Enter does not submit the form + if (e.keyCode == 13 && e.target.tagName == 'INPUT') { + e.preventDefault(); + e.target.blur(); + } + }; + + this.elems.participantsCountInput.onchange = this.onParticipantsCountInputChange.bind(this); + this.elems.participantsIsSelf.onchange = this.onParticipantsIsSelfChange.bind(this); + + this.elems.participantsAddList.onchange = (e) => { + this.validateParticipantItemInput(e.target); + }; + + + this.elems.participantsAddList.onkeydown = (e) => { + // Enter does not submit the form + if (e.keyCode == 13 && e.target.tagName == 'INPUT') { + e.preventDefault(); + e.target.blur(); + } + }; + + } + + validateParticipantItemInput(input) { + var valid = /^[-.\w]+@([\w-]+\.)+[\w-]{2,12}$/.test(input.value); + if (valid) { + input.parentNode.classList.remove('text-input_invalid'); + } else { + input.parentNode.classList.add('text-input_invalid'); + } + } + + + onParticipantsDecreaseButtonClick(event) { + this.setCount(this.elems.participantsCountInput.value - 1); + } + + onParticipantsIncreaseButtonClick(event) { + this.setCount(+this.elems.participantsCountInput.value + 1); + } + + onParticipantsCountInputChange(event) { + this.setCount(this.elems.participantsCountInput.value); + } + + onParticipantsIsSelfChange(event) { + this.setCount(this.elems.participantsCountInput.value); + } + + setCount(count) { + count = parseInt(count) || 0; + + var max = +this.elems.participantsCountInput.getAttribute('max'); + this.elems.participantsDecreaseButton.disabled = (count <= 1); + this.elems.participantsIncreaseButton.disabled = (count >= max); + + this.elems.participantsCountInput.value = count; + + var invalid = count < 1 || count > max; + if (invalid) { + this.elems.participantsCountInput.parentNode.classList.add('text-input_invalid'); + return; + } + + // render price + this.elems.participantsAmount.innerHTML = window.groupInfo.price * count; + this.elems.participantsAmountUsd.innerHTML = Math.round(window.groupInfo.price * count / window.rateUsdRub); + this.elems.participantsCountInput.parentNode.classList.remove('text-input_invalid'); + + // show/hide participants box + if (!this.elems.participantsIsSelf.checked || count > 1) { + this.elems.participantsAddBox.classList.add('course-add-participants_visible'); + } else { + this.elems.participantsAddBox.classList.remove('course-add-participants_visible'); + } + + // add/remove participant items + while(this.elems.participantsAddList.children.length > count) { + this.elems.participantsAddList.lastElementChild.remove(); + } + + while(this.elems.participantsAddList.children.length < count) { + var item = clientRender(participantsItem); + this.elems.participantsAddList.insertAdjacentHTML("beforeEnd", item); + } + + // current visitor is the first item + let firstParticipantItem = this.elems.participantsAddList.firstElementChild.querySelector('input'); + + if (this.elems.participantsIsSelf.checked) { + firstParticipantItem.disabled = true; + firstParticipantItem.value = window.currentUser.email; + } else { + firstParticipantItem.disabled = false; + firstParticipantItem.value = ''; + } + + + } + + onSubmit(event) { + event.preventDefault(); + + try { + if (this.elems.participantsCountInput.parentNode.classList.contains('text-input_invalid')) { + throw new InvalidError(); + } + + var count = +this.elems.participantsCountInput.value; + + var emails = []; + if (this.elems.participantsListEnabled.checked) { + [].forEach.call(this.elems.participantsAddList.querySelectorAll('input'), function(input) { + if (!input.value) return; + if (input.parentNode.classList.contains('text-input_invalid')) { + throw new InvalidError(); + } + emails.push(input.value); + }); + } else { + if (this.elems.participantsIsSelf.checked) { + emails.push(window.currentUser.email); + } + } + + + this.elem.dispatchEvent(new CustomEvent('select', { + detail: { + count: count, + emails: emails + } + })); + + + } catch(e) { + if (e instanceof InvalidError) { + new notification.Error("Исправьте, пожалуйста, ошибки."); + } else { + throw e; + } + + } + } + +} + +function InvalidError(message) { + this.name = "InvalidError"; + this.message = message; +} + +InvalidError.prototype = Object.create(Error.prototype); +InvalidError.prototype.constructor = InvalidError; + + +delegate.delegateMixin(ParticipantsForm.prototype); + +module.exports = ParticipantsForm; diff --git a/handlers/courses/client/signupWidget.js b/handlers/courses/client/signupWidget.js new file mode 100755 index 000000000..95c196db4 --- /dev/null +++ b/handlers/courses/client/signupWidget.js @@ -0,0 +1,162 @@ +var xhr = require('client/xhr'); +var notification = require('client/notification'); +var delegate = require('client/delegate'); +var FormPayment = require('payments/common/client').FormPayment; +var Spinner = require('client/spinner'); +var Modal = require('client/head/modal'); +var ParticipantsForm = require('./participantsForm'); +var ContactForm = require('./contactForm'); +var pluralize = require('textUtil/pluralize'); + +class SignupWidget { + + constructor(options) { + this.elem = options.elem; + + this.product = 'course'; + + this.elems = {}; + + [].forEach.call(this.elem.querySelectorAll('[data-elem]'), (el) => { + this.elems[el.getAttribute('data-elem')] = el; + }); + + if (this.elems.participants) { + var participantsForm = new ParticipantsForm({ + elem: this.elems.participants + }); + + participantsForm.elem.addEventListener('select', this.onParticipantsFormSelect.bind(this)); + + this.elems.receiptParticipantsEditLink.onclick = (e) => { + e.preventDefault(); + this.goStep1(); + }; + } + + if (this.elems.contact) { + + var contactForm = this.contactForm = new ContactForm({ + elem: this.elems.contact + }); + + contactForm.elem.addEventListener('select', this.onContactFormSelect.bind(this)); + + this.elems.receiptContactEditLink.onclick = (e) => { + e.preventDefault(); + this.goStep2(); + }; + + } + + this.elems.payment.onsubmit = this.onPaymentSubmit.bind(this); + + /* + this.delegate('[data-order-payment-change]', 'click', (e) => { + e.preventDefault(); + this.elem.className = this.elem.className.replace(/courses-register_step_\d/, ''); + this.elem.classList.add('courses-register_step_3'); + }); + */ + + } + + onPaymentSubmit() { + event.preventDefault(); + new FormPayment(this, this.elem.querySelector('.pay-method')).submit(); + } + + goStep1() { + this.elem.className = this.elem.className.replace(/courses-register_step_\d/, ''); + this.elem.classList.add('courses-register_step_1'); + } + + goStep2() { + this.elem.className = this.elem.className.replace(/courses-register_step_\d/, ''); + this.elem.classList.add('courses-register_step_2'); + + this.elems.receiptTitle.innerHTML = `Участие в курсе для ${this.participantsInfo.count} + ${pluralize(this.participantsInfo.count, 'человека', 'человек', 'человек')}`; + + this.elems.receiptAmount.innerHTML = window.groupInfo.price * this.participantsInfo.count; + + this.contactForm.focus(); + } + + goStep3() { + this.elem.className = this.elem.className.replace(/courses-register_step_\d/, ''); + this.elem.classList.add('courses-register_step_3'); + + this.elems.receiptContactName.innerHTML = this.contactInfo.name; + this.elems.receiptContactPhone.innerHTML = this.contactInfo.phone; + } + + onParticipantsFormSelect(event) { + this.participantsInfo = event.detail; + this.goStep2(); + } + + onContactFormSelect(event) { + this.contactInfo = event.detail; + this.goStep3(); + } + + + // return orderData or nothing if validation failed + getOrderData() { + + var orderData = { }; + + if (window.orderNumber) { + orderData.orderNumber = window.orderNumber; + } else { + orderData.slug = window.groupInfo.slug; + orderData.orderTemplate = 'course'; + orderData.contactName = this.contactInfo.name; + orderData.contactPhone = this.contactInfo.phone; + orderData.count = this.participantsInfo.count; + orderData.emails = this.participantsInfo.emails; + } + + + return orderData; + } + + + request(options) { + var request = xhr(options); + + request.addEventListener('loadstart', function() { + var onEnd = this.startRequestIndication(); + request.addEventListener('loadend', onEnd); + }.bind(this)); + + return request; + } + + startRequestIndication() { + + var paymentMethodElem = this.elem.querySelector('.pay-method'); + paymentMethodElem.classList.add('modal-overlay_light'); + + var spinner = new Spinner({ + elem: paymentMethodElem, + size: 'medium', + class: 'pay-method__spinner' + }); + spinner.start(); + + return function onEnd() { + paymentMethodElem.classList.remove('modal-overlay_light'); + if (spinner) spinner.stop(); + }; + + } + + +} + + +delegate.delegateMixin(SignupWidget.prototype); + +module.exports = SignupWidget; diff --git a/handlers/courses/controller/course.js b/handlers/courses/controller/course.js new file mode 100644 index 000000000..6780ca6ba --- /dev/null +++ b/handlers/courses/controller/course.js @@ -0,0 +1,25 @@ +var Course = require('../models/course'); +var CourseGroup = require('../models/courseGroup'); + +exports.get = function*() { + + this.locals.course = yield Course.findOne({ + slug: this.params.course + }).exec(); + + if (!this.locals.course) { + this.throw(404); + } + + this.locals.groups = yield CourseGroup.find({ + isListed: true, + dateStart: { + $gt: new Date() + }, + course: this.locals.course._id + }).sort({ + dateStart: 1 + }).exec(); + + this.body = this.render('courses/' + this.locals.course.slug); +}; diff --git a/handlers/courses/controller/coursesByUser.js b/handlers/courses/controller/coursesByUser.js new file mode 100644 index 000000000..a186223ac --- /dev/null +++ b/handlers/courses/controller/coursesByUser.js @@ -0,0 +1,86 @@ +"use strict"; + +const CourseInvite = require('../models/courseInvite'); +const CourseGroup = require('../models/courseGroup'); + +/** + * The order form is sent to checkout when it's 100% valid (client-side code validated it) + * It uses order.module.createOrderFromTemplate to create an order, it can throw if something's wrong + * the order CANNOT be changed after submitting to payment + * @param next + */ +exports.get = function*(next) { + + var user = this.userById; + + if (String(this.user._id) != String(user._id)) { + this.throw(403); + } + + // active invites + var invites = yield CourseInvite.find({ + email: user.email, + accepted: false + }).populate('group').exec(); + + // plus groups where participates + var groups = yield CourseGroup.find({ + 'participants.user': user._id + }).exec(); + + this.body = []; + + for (let i = 0; i < invites.length; i++) { + + let group = invites[i].group; + yield CourseGroup.populate(group, {path: 'course'}); + let groupInfo = formatGroup(group); + groupInfo.links = [{ + url: group.course.getUrl(), + title: 'Описание курса' + }]; + groupInfo.inviteUrl = '/courses/invite/' + invites[i].token; + this.body.push(groupInfo); + } + + for (let i = 0; i < groups.length; i++) { + let group = groups[i]; + yield CourseGroup.populate(group, {path: 'course'}); + + let groupInfo = formatGroup(group); + groupInfo.links = [{ + url: group.course.getUrl(), + title: 'Описание курса' + }, { + url: `/courses/groups/${group.slug}/info`, + title: 'Инструкции по настройке окружения' + }]; + + var materials = yield groups[i].readMaterials(); + if (materials.length) { + groupInfo.links.push({ + url: `/courses/groups/${group.slug}/materials`, + title: 'Материалы для обучения' + }); + } + this.body.push(groupInfo); + } + + for (var i = 0; i < this.body.length; i++) { + var groupInfo = this.body[i]; + groupInfo.status = groupInfo.inviteUrl ? 'invite' : + (groupInfo.dateStart > new Date()) ? 'accepted' : + (groupInfo.dateEnd > new Date()) ? 'started' : 'ended'; + } + + +}; + +function formatGroup(group) { + return { + title: group.title, + dateStart: group.dateStart, + dateEnd: group.dateEnd, + timeDesc: group.timeDesc + }; +} diff --git a/handlers/courses/controller/frontpage.js b/handlers/courses/controller/frontpage.js new file mode 100644 index 000000000..14791610d --- /dev/null +++ b/handlers/courses/controller/frontpage.js @@ -0,0 +1,10 @@ +var Course = require('../models/course'); + +exports.get = function*() { + + this.locals.courses = yield Course.find({ + isListed: true + }).sort({weight: 1}).exec(); + + this.body = this.render('frontpage'); +}; diff --git a/handlers/courses/controller/groupInfo.js b/handlers/courses/controller/groupInfo.js new file mode 100644 index 000000000..f6763d2f0 --- /dev/null +++ b/handlers/courses/controller/groupInfo.js @@ -0,0 +1,20 @@ +var Course = require('../models/course'); +var CourseGroup = require('../models/courseGroup'); +var _ = require('lodash'); + +// Group info for a participant, with user instructions on how to login +exports.get = function*() { + + var group = this.locals.group = this.groupBySlug; + + if (!this.user) { + this.throw(401); + } + + var participantIds = _.pluck(group.participants, 'user').map(String); + if (!~participantIds.indexOf(String(this.user._id))) { + this.throw(403, "Вы не являетесь участником этой группы."); + } + + this.body = this.render('groupInfo/' + group.course.slug); +}; diff --git a/handlers/courses/controller/groupMaterials.js b/handlers/courses/controller/groupMaterials.js new file mode 100644 index 000000000..b97734b46 --- /dev/null +++ b/handlers/courses/controller/groupMaterials.js @@ -0,0 +1,24 @@ +var Course = require('../models/course'); +var CourseGroup = require('../models/courseGroup'); +var _ = require('lodash'); + +// Group info for a participant, with user instructions on how to login +exports.get = function*() { + + var group = this.locals.group = this.groupBySlug; + + if (!this.user) { + this.throw(401); + } + + var participantsById = _.indexBy(group.participants, 'user'); + + var participant = participantsById[this.user._id]; + if (!participant) { + this.throw(403, "Вы не являетесь участником этой группы."); + } + + this.body = this.render('groupMaterials', { + videoKey: participant.videoKey + }); +}; diff --git a/handlers/courses/controller/invite.js b/handlers/courses/controller/invite.js new file mode 100644 index 000000000..591c8ac96 --- /dev/null +++ b/handlers/courses/controller/invite.js @@ -0,0 +1,172 @@ +const Course = require('../models/course'); +const CourseInvite = require('../models/courseInvite'); +const CourseGroup = require('../models/courseGroup'); +const User = require('users').User; +const _ = require('lodash'); + +exports.all = function*() { + + if (this.method != 'POST' && this.method != 'GET') { + this.throw(404); + } + + var invite = yield CourseInvite.findOne({ + token: this.params.inviteToken || this.request.body && this.request.body.inviteToken + }).populate('group').populate('order').exec(); + + this.locals.mailto = "mailto:orders@javascript.ru"; + if (invite.order) this.locals.mailto += "?subject=" + encodeURIComponent('Заказ ' + invite.order.number); + + if (!invite) { + this.throw(404); + } + + if (invite.accepted) { + this.body = this.render("invite/accepted"); + return; + } + + // invite is also a login token, so we limit it's validity + if (invite.validUntil < Date.now()) { + this.body = this.render("invite/outdated"); + return; + } + + yield CourseGroup.populate(invite.group, {path: 'participants.user'}); + + // invite was NOT accepted, but this guy is a participant, + // so show the same as accepted + var participantsByEmail = _.indexBy(_.pluck(invite.group.participants, 'user'), 'email'); + + if (participantsByEmail[invite.email]) { + this.body = this.render("invite/accepted"); + return; + } + + // invalid invite, person not in list + if (!~invite.order.data.emails.indexOf(invite.email)) { + this.body = this.render('invite/deny', { + email: invite.email, + contactName: invite.order.data.contactName, + orderNumber: invite.order.number + }); + + return; + } + + // ----------- INVITE IS VALID ---------------- + + this.locals.title = invite.group.title; + this.locals.invite = invite; + + var isLoggedIn = yield* loginByInvite.call(this, invite); + + if (isLoggedIn) { + yield* askCourseName.call(this, invite); + } else { + if (this.user) this.logout(); + yield* register.call(this, invite); + } + +}; + +function* askCourseName(invite) { + + // NB: this.user is the right user, guaranteed by loginByInvite + + if (this.method == 'POST') { + yield acceptParticipant.call(this, invite); + } else { + this.body = this.render('invite/askCourseName', { + errors: {}, + form: {} + }); + } + +} + + +function* register(invite) { + + if (this.method == 'POST') { + + // do register the man, email is verified + var user = new User({ + email: invite.email, + displayName: this.request.body.displayName, + password: this.request.body.password, + verifiedEmail: true + }); + + try { + yield user.persist(); + } catch(e) { + var errors = {}; + for (var key in e.errors) { + errors[key] = e.errors[key].message; + } + + this.body = this.render('invite/register', { + errors: e.errors, + form: { + displayName: this.request.body.displayName, + courseName: this.request.body.courseName, + password: this.request.body.password + } + }); + return; + } + + yield this.login(user); + yield acceptParticipant.call(this, invite); + + + } else { + this.body = this.render('invite/register', { + errors: {}, + form: {} + }); + } +} + +function* acceptParticipant(invite) { + invite.group.participants.push({ + user: this.user._id, + courseName: this.request.body.courseName || this.user.displayName + }); + + // esp. important for newly regged users, they don't have this tab by default or invite + this.user.profileTabsEnabled.addToSet('courses'); + yield this.user.persist(); + + yield invite.accept(); + + invite.group.decreaseParticipantsLimit(); + + yield invite.group.persist(); + this.redirect('/courses/invite/' + invite.token); +} + +function* loginByInvite(invite) { + + if (this.user && this.user.email == invite.email) { + return true; + } + + var userByEmail = yield User.findOne({ + email: invite.email + }).exec(); + + if (!userByEmail) return false; + + if (!userByEmail.verifiedEmail) { + // if pending verification => invite token confirms email + yield userByEmail.persist({ + verifiedEmail: true + }); + } + + this.locals.wasLoggedIn = true; + yield this.login(userByEmail); + return true; +} diff --git a/handlers/courses/controller/signup.js b/handlers/courses/controller/signup.js new file mode 100644 index 000000000..f0c8ebdb9 --- /dev/null +++ b/handlers/courses/controller/signup.js @@ -0,0 +1,93 @@ +const payments = require('payments'); +var getOrderInfo = payments.getOrderInfo; +var Course = require('../models/course'); +var CourseGroup = require('../models/courseGroup'); +var config = require('config'); +var moment = require('momentWithLocale'); +var money = require('money'); +var pluralize = require('textUtil/pluralize'); + + +exports.get = function*() { + this.nocache(); + + this.locals.sitetoolbar = true; + + var group; + + if (this.params.orderNumber) { + yield* this.loadOrder({ + ensureSuccessTimeout: 10000 + }); + + this.locals.order = this.order; + this.locals.title = 'Заказ №' + this.order.number; + + this.locals.changePaymentRequested = Boolean(this.query.changePayment); + + group = this.locals.group = yield CourseGroup.findById(this.order.data.group).populate('course').exec(); + + if (!group) { + this.throw(404, "Нет такой группы."); + } + + } else { + + var group = this.locals.group = this.groupBySlug; + + // a visitor can't reach this page through UI, only by direct link + // if the group is full + if (!group.isOpenForSignup) { + this.throw(403, "Запись в эту группу завершена."); + } + + if (!this.isAuthenticated()) { + this.redirect(group.course.getUrl()); + return; + } + + this.locals.title = group.course.title; + } + + this.locals.paymentMethods = require('../lib/paymentMethods'); + + this.locals.breadcrumbs = [ + {title: 'JavaScript.ru', url: 'http://javascript.ru'}, + {title: 'Курсы', url: '/courses'} + ]; + + if (this.order) { + this.locals.orderInfo = yield* getOrderInfo(this.order); + this.locals.receiptTitle = `Участие в курсе для ${this.order.data.count} + ${pluralize(this.order.data.count, 'человека', 'человек', 'человек')}`; + + this.locals.receiptAmount = this.order.amount; + this.locals.receiptContactPhone = this.order.data.contactPhone; + this.locals.receiptContactName = this.order.data.contactName; + + } else { + this.locals.orderInfo = {}; + } + + this.locals.mailto = "mailto:orders@javascript.ru"; + if (this.order) { + this.locals.mailto += '?subject=' + encodeURIComponent('Заказ ' + this.order.number); + } + + + this.locals.formatGroupDate = function(date) { + return moment(date).format('D MMM YY').replace(/[а-я]/, function(letter) { + return letter.toUpperCase(); + }); + }; + + this.locals.rateUsdRub = money.convert(1, {from: 'USD', to: 'RUB'}); + + this.locals.groupInfo = { + price: group.price, + participantsMax: group.participantsLimit, + slug: group.slug + }; + + this.body = this.render('signup'); +}; diff --git a/handlers/courses/index.js b/handlers/courses/index.js new file mode 100755 index 000000000..51420843a --- /dev/null +++ b/handlers/courses/index.js @@ -0,0 +1,18 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/courses', __dirname)); +}; + +exports.Course = require('./models/course'); +exports.CourseGroup = require('./models/courseGroup'); + +exports.onPaid = require('./lib/onPaid'); + +exports.getAgreement = require('./lib/getAgreement'); + +exports.createOrderFromTemplate = require('./lib/createOrderFromTemplate'); + +exports.patch = require('./lib/patch'); +exports.formatOrderForProfile = require('./lib/formatOrderForProfile'); diff --git a/handlers/courses/lib/createOrderFromTemplate.js b/handlers/courses/lib/createOrderFromTemplate.js new file mode 100755 index 000000000..816347714 --- /dev/null +++ b/handlers/courses/lib/createOrderFromTemplate.js @@ -0,0 +1,60 @@ +var Order = require('payments').Order; +var OrderCreateError = require('payments').OrderCreateError; +var CourseGroup = require('../models/courseGroup'); +var pluralize = require('textUtil/pluralize'); +var _ = require('lodash'); + +// middleware +// create order from template, +// use the incoming data if needed +module.exports = function*(orderTemplate, user, requestBody) { + + var group = yield CourseGroup.findOne({slug: requestBody.slug}).exec(); + + var orderData = { + group: group._id + }; + orderData.count = +requestBody.count; + + if (group.participantsLimit === 0) { + throw new OrderCreateError("Извините, в этой группе уже нет мест."); + } + + if (orderData.count > group.participantsLimit) { + throw new OrderCreateError("Извините, уже нет такого количества мест. Уменьшите количество участников до " + group.participantsLimit + '.'); + } + + orderData.contactName = String(requestBody.contactName); + + if (!orderData.contactName) { + throw new OrderCreateError("Не указано контактное лицо."); + } + + orderData.contactPhone = String(requestBody.contactPhone || ''); + + var emails = requestBody.emails; + if (!Array.isArray(emails)) { + throw new OrderCreateError("Отсутствуют участники."); + } + orderData.emails = _.unique(emails.filter(Boolean).map(String)); + + if (!user) { + throw new OrderCreateError("Вы не авторизованы."); + } + + var order = new Order({ + title: group.title, + amount: orderData.count * group.price, + module: orderTemplate.module, + data: orderData, + email: user.email, + user: user._id + }); + + yield order.persist(); + + return order; + +}; + + diff --git a/handlers/courses/lib/doc/agreement.docx b/handlers/courses/lib/doc/agreement.docx new file mode 100644 index 000000000..c747f33f2 Binary files /dev/null and b/handlers/courses/lib/doc/agreement.docx differ diff --git a/handlers/courses/lib/formatOrderForProfile.js b/handlers/courses/lib/formatOrderForProfile.js new file mode 100644 index 000000000..09f9ca594 --- /dev/null +++ b/handlers/courses/lib/formatOrderForProfile.js @@ -0,0 +1,54 @@ +var CourseGroup = require('courses').CourseGroup; +var User = require('users').User; +var _ = require('lodash'); +var getOrderInfo = require('payments').getOrderInfo; +var paymentMethods = require('./paymentMethods'); + +module.exports = function* formatCourseOrder(order) { + + var group = yield CourseGroup.findById(order.data.group).populate('course').exec(); + + if (!group) { + this.log.error("Not found group for order", order.toObject()); + this.throw(404); + } + + var users = yield User.find({ + email: { + $in: order.data.emails + } + }).exec(); + + var usersByEmail = _.indexBy(users, 'email'); + + var groupParticipantsByUser = _.indexBy(group.participants, 'user'); + + var orderToShow = { + created: order.created, + title: group.title, + number: order.number, + module: order.module, + amount: order.amount, + count: order.data.count, + contactName: order.data.contactName, + contactPhone: order.data.contactPhone, + courseUrl: group.course.getUrl(), + participants: order.data.emails.map(function(email) { + return { + email: email, + inGroup: Boolean(usersByEmail[email] && groupParticipantsByUser[usersByEmail[email]._id]) + }; + }) + + }; + + var orderInfo = yield* getOrderInfo(order); + + orderToShow.orderInfo = _.pick(orderInfo, ['status', 'statusText', 'descriptionProfile']); + + if (orderInfo.transaction) { + orderToShow.paymentMethod = paymentMethods[orderInfo.transaction.paymentMethod].title; + } + + return orderToShow; +}; diff --git a/handlers/courses/lib/getAgreement.js b/handlers/courses/lib/getAgreement.js new file mode 100644 index 000000000..f0982d1c6 --- /dev/null +++ b/handlers/courses/lib/getAgreement.js @@ -0,0 +1,55 @@ +var fs = require('fs'); +var Docxtemplater = require('docxtemplater'); +var path = require('path'); +var invoiceConfig = require('config').payments.modules.invoice; +const moment = require('moment'); +const CourseGroup = require('../models/courseGroup'); + +// Load the docx file as a binary +// @see https://github.com/open-xml-templating/docxtemplater +var docContent = fs.readFileSync(path.join(__dirname, "doc/agreement.docx"), "binary"); + +// this.transaction exists +module.exports = function*(transaction) { + + var invoiceDoc = new Docxtemplater(docContent); + + var group = yield CourseGroup.findById(transaction.order.data.group).exec(); + + if (!group) { + this.throw(400, "Нет группы"); + } + + invoiceDoc.setData({ + COMPANY_NAME: invoiceConfig.COMPANY_NAME, + INN: invoiceConfig.INN, + ACCOUNT: invoiceConfig.ACCOUNT, + BANK: invoiceConfig.BANK, + CORR_ACC: invoiceConfig.CORR_ACC, + BIK: invoiceConfig.BIK, + OGRNIP: invoiceConfig.OGRNIP, + PHONE: invoiceConfig.PHONE, + SIGN_TITLE: invoiceConfig.SIGN_TITLE, + SIGN_NAME: invoiceConfig.SIGN_NAME, + SIGN_SHORT_NAME: invoiceConfig.SIGN_SHORT_NAME, + ORDER_NUMBER: String(transaction.order.number), + ORDER_DATE: moment(transaction.order.created).format('DD.MM.YYYY'), + INVOICE_CONTRACT_HEAD: transaction.paymentDetails.contractHead || "... В ЛИЦЕ ... НА ОСНОВАНИИ ...", + COMPANY_INVOICE_HEAD: invoiceConfig.COMPANY_INVOICE_HEAD, + GROUP_DURATION_DATE: moment(group.dateStart).format('DD.MM.YYYY') + ' - ' + moment(group.dateEnd).format('DD.MM.YYYY'), + END_DATE: moment(group.dateEnd).format('DD.MM.YYYY'), + GROUP_TIME: group.timeDesc, + TRANSACTION_NUMBER: String(transaction.number), + TRANSACTION_DATE: moment(transaction.created).format('DD.MM.YYYY'), + INVOICE_COMPANY_NAME: transaction.paymentDetails.companyName, + INVOICE_COMPANY_ADDRESS: transaction.paymentDetails.companyAddress, + INVOICE_BANK_DETAILS: transaction.paymentDetails.bankDetails, + AMOUNT: transaction.amount + }); + + // apply replacements + invoiceDoc.render(); + + return invoiceDoc; +}; + diff --git a/handlers/courses/lib/onPaid.js b/handlers/courses/lib/onPaid.js new file mode 100755 index 000000000..c3b07e7aa --- /dev/null +++ b/handlers/courses/lib/onPaid.js @@ -0,0 +1,130 @@ +const Order = require('payments').Order; +const path = require('path'); +const log = require('log')(); +const config = require('config'); +const sendMail = require('mailer').send; +const CourseInvite = require('../models/courseInvite'); +const CourseGroup = require('../models/courseGroup'); +const sendOrderInvites = require('./sendOrderInvites'); +const xmppClient = require('xmppClient'); +const VideoKey = require('videoKey').VideoKey; + +// not a middleware +// can be called from CRON +module.exports = function* (order) { + + yield Order.populate(order, {path: 'user'}); + + var group = yield CourseGroup.findById(order.data.group).exec(); + + var emails = order.data.emails; + + // order.user is the only one registered person, we know all about him + var orderUserIsParticipant = ~emails.indexOf(order.user.email); + + // is there anyone except the user? + var orderHasParticipantsExceptUser = emails.length > 1 || emails[0] != order.user.email; + + yield sendMail({ + templatePath: path.join(__dirname, '..', 'templates', 'success-email'), + from: 'orders', + to: order.email, + orderNumber: order.number, + subject: "Подтверждение оплаты за курс, заказ " + order.number, + orderUserIsParticipant: orderUserIsParticipant, + orderHasOtherParticipants: orderHasParticipantsExceptUser + }); + + + if (orderUserIsParticipant) { + group.participants.push({ + user: order.user._id, + courseName: order.data.contactName + }); + group.decreaseParticipantsLimit(); + order.user.profileTabsEnabled.addToSet('courses'); + yield order.user.persist(); + } + yield group.persist(); + + yield* sendOrderInvites(order); + + yield CourseGroup.populate(group,[{path: 'participants.user'}, {path: 'course'}]); + + yield* grantXmppChatMemberships(group); + + if (group.course.videoKeyTag) { + yield *grantVideoKeys(group); + } + + + order.status = Order.STATUS_SUCCESS; + + yield order.persist(); + + log.debug("Order success: " + order.number); +}; + + +function* grantXmppChatMemberships(group) { + log.debug("Grant xmpp chat membership"); + // grant membership in chat + var client = new xmppClient({ + jid: config.xmpp.admin.login + '/host', + password: config.xmpp.admin.password + }); + + yield client.connect(); + + var roomJid = yield client.createRoom({ + roomName: group.webinarId, + membersOnly: 1 + }); + + + var jobs = []; + for (var i = 0; i < group.participants.length; i++) { + var participant = group.participants[i]; + + log.debug("grant " + roomJid + " to", participant.user.profileName, participant.courseName); + + jobs.push(client.grantMember(roomJid, participant.user.profileName, participant.courseName)); + } + + // grant all in parallel + yield jobs; + + client.disconnect(); +} + +function* grantVideoKeys(group) { + + var participants = group.participants.filter(function(participant) { + return !participant.videoKey; + }); + + + var videoKeys = yield VideoKey.find({ + tag: group.course.videoKeyTag, + used: false + }).limit(participants.length).exec(); + + log.debug("Keys selected", videoKeys && videoKeys.toArray()); + + if (!videoKeys || videoKeys.length != participants.length) { + throw new Error("Недостаточно серийных номеров " + participants.length); + } + + for (var i = 0; i < participants.length; i++) { + var participant = participants[i]; + participant.videoKey = videoKeys[i].key; + videoKeys[i].used = true; + } + + yield group.persist(); + + var jobs = videoKeys.map(function(videoKey) { + return videoKey.persist(); + }); + yield jobs; +} diff --git a/handlers/courses/lib/patch.js b/handlers/courses/lib/patch.js new file mode 100644 index 000000000..716a532ad --- /dev/null +++ b/handlers/courses/lib/patch.js @@ -0,0 +1,70 @@ +"use strict"; + +const CourseGroup = require('../models/courseGroup'); +const _ = require('lodash'); +const sendOrderInvites = require('./sendOrderInvites'); +const Order = require('payments').Order; + +// called by payments/common/order +module.exports = function*() { + + var group = yield CourseGroup.findById(this.order.data.group).populate('participants.user').exec(); + + var groupParticipantsByEmail = _.indexBy(group.participants, function(participant) { + return participant.user.email; + }); + + if ("emails" in this.request.body) { + + let emails = _.unique(this.request.body.emails.split(',').filter(Boolean)); + + this.log.debug("Incoming emails", emails); + + emails = emails.filter(function throwAwayParticipantsInSubmitted(email) { + return !(email in groupParticipantsByEmail); + }); + + this.log.debug("Incoming emails except participants", emails); + + var newEmails = this.order.data.emails.filter(function keepParticipantsInOrder(email) { + return email in groupParticipantsByEmail; + }); + + this.log.debug("Order participant emails", newEmails); + + newEmails = newEmails.concat(emails); + + this.log.debug("Order new emails", newEmails); + + if (newEmails.length > this.order.data.count) { + this.throw(400, "Too many emails."); + } + + this.order.data.emails = newEmails; + } + + if ("contactName" in this.request.body) { + this.order.data.contactName = this.request.body.contactName; + } + + if ("contactPhone" in this.request.body) { + this.order.data.contactPhone = this.request.body.contactPhone; + } + + this.order.markModified('data'); + yield this.order.persist(); + + + var invites = []; + if (this.order.status == Order.STATUS_SUCCESS) { + invites = yield* sendOrderInvites(this.order); + } + + if (invites.length) { + let emails = _.pluck(invites, 'email'); + this.body = 'Информация обновлена, приглашения высланы на адреса: ' + emails.join(", ") + '.'; + } else { + this.body = 'Информация об участниках обновлена.'; + } +}; + diff --git a/handlers/courses/lib/paymentMethods.js b/handlers/courses/lib/paymentMethods.js new file mode 100755 index 000000000..a3c4ab8cb --- /dev/null +++ b/handlers/courses/lib/paymentMethods.js @@ -0,0 +1,11 @@ +const payments = require('payments'); + +var paymentMethods = {}; + +var methodsEnabled = ['webmoney', 'yandexmoney', 'payanyway', 'paypal', 'interkassa', 'banksimple', 'invoice']; + +methodsEnabled.forEach(function(key) { + paymentMethods[key] = payments.methods[key].info; +}); + +module.exports = paymentMethods; diff --git a/handlers/courses/lib/routeGroupBySlug.js b/handlers/courses/lib/routeGroupBySlug.js new file mode 100644 index 000000000..3c051ed24 --- /dev/null +++ b/handlers/courses/lib/routeGroupBySlug.js @@ -0,0 +1,17 @@ +const CourseGroup = require('../models/courseGroup'); + +module.exports = function*(slug, next) { + + var group = yield CourseGroup.findOne({ + slug: slug + }).populate('course').exec(); + + if (!group) { + this.throw(404, "Нет такой группы."); + } + + this.groupBySlug = group; + + yield* next; + +}; diff --git a/handlers/courses/lib/sendInvite.js b/handlers/courses/lib/sendInvite.js new file mode 100644 index 000000000..5bc80ac64 --- /dev/null +++ b/handlers/courses/lib/sendInvite.js @@ -0,0 +1,28 @@ +const sendMail = require('mailer').send; +const path = require('path'); +const CourseInvite = require('../models/courseInvite'); +const config = require('config'); +const User = require('users').User; + +module.exports = function*(invite) { + + yield CourseInvite.populate(invite, [{path: 'order'}, {path: 'group'}]); + + var userExists = yield User.findOne({ + email: invite.email + }).exec(); + + yield sendMail({ + templatePath: path.join(__dirname, '../templates/invite/email'), + from: 'orders', + to: invite.email, + contactName: invite.order.data.contactName, + subject: "Приглашение на курс, в группу " + invite.group.title, + order: invite.order, + group: invite.group, + userExists: userExists, + link: config.server.siteHost + '/courses/invite/' + invite.token + }); + + +}; diff --git a/handlers/courses/lib/sendOrderInvites.js b/handlers/courses/lib/sendOrderInvites.js new file mode 100644 index 000000000..cf6659e70 --- /dev/null +++ b/handlers/courses/lib/sendOrderInvites.js @@ -0,0 +1,79 @@ +const sendMail = require('mailer').send; +const CourseInvite = require('../models/courseInvite'); +const _ = require('lodash'); +const log = require('log')(); +const sendInvite = require('./sendInvite'); +const CourseGroup = require('../models/courseGroup'); +const User = require('users').User; + +/** + * create and send invites for the order + * except those that already exist + * @param order + */ +module.exports = function*(order) { + + // first create invites, (in case if mailer dies we have them all) + var invites = yield createInvites(order); + + yield sendInvites(invites); + + return invites; + +}; + +function* createInvites(order) { + + var emails = order.data.emails; + + // get existing invites, so that we won't recreate them + var existingInvites = yield CourseInvite.find({ order: order._id }).exec(); + var existingInviteByEmails = _.indexBy(existingInvites, 'email'); + + log.debug("existing invites", existingInviteByEmails); + + // get existing participants, they don't need invites + var group = yield CourseGroup.findById(order.data.group).exec(); + yield CourseGroup.populate(group, {path: 'participants.user'}); + var participantsByEmail = _.indexBy(_.pluck(group.participants, 'user'), 'email'); + + var invites = []; + for (var i = 0; i < emails.length; i++) { + var email = emails[i]; + if (participantsByEmail[email]) continue; // in group already + if (existingInviteByEmails[email]) continue; // invite exists already + + log.debug("create invite for email", email); + + var invite = new CourseInvite({ + order: order._id, + group: group._id, + // max(now + 7 days, course start + 7 days) + validUntil: new Date( Math.max(Date.now(), group.dateStart) + 7 * 24 * 86400 * 1e3), + email: email + }); + invites.push(invite); + + yield invite.persist(); + + // not only send invite, but enable the tab so that the user can manually accept it + yield User.update({ + email: email + }, { + $addToSet: {profileTabsEnabled: 'courses'} + }); + + } + + return invites; +} + +function* sendInvites(invites) { + + // send invites in parallel, for speed + yield invites.map(function(invite) { + return sendInvite(invite); + }); + +} + diff --git a/handlers/courses/models/course.js b/handlers/courses/models/course.js new file mode 100755 index 000000000..540e9deb9 --- /dev/null +++ b/handlers/courses/models/course.js @@ -0,0 +1,49 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; + +var schema = new Schema({ + // like "nodejs", same as template + slug: { + type: String, + unique: true, + required: true + }, + + // "Курс JavaScript/DOM/интерфейсы" + title: { + type: String, + required: true + }, + + videoKeyTag: { + // may be 2 adjacent courses have same video tag + type: String + }, + + weight: { + type: Number, + required: true + }, + + // is this course in the open course list (otherwise hidden)? + // even if not, the course is accessible by a direct link + isListed: { + type: Boolean, + required: true, + default: false + }, + + + created: { + type: Date, + default: Date.now + } +}); + + +schema.methods.getUrl = function() { + return '/courses/' + this.slug; +}; + +module.exports = mongoose.model('Course', schema); + diff --git a/handlers/courses/models/courseGroup.js b/handlers/courses/models/courseGroup.js new file mode 100644 index 000000000..29518c27d --- /dev/null +++ b/handlers/courses/models/courseGroup.js @@ -0,0 +1,136 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var config = require('config'); +var fs = require('mz/fs'); +var path = require('path'); +var log = require('log')(); + +// the schema follows http://openexchangerates.org/api/latest.json response +var schema = new Schema({ + // 01.01.2015 + dateStart: { + type: Date, + required: true + }, + // 05.05.2015 + dateEnd: { + type: Date, + required: true + }, + + // like "nodejs-0402", for urls + slug: { + type: String, + required: true, + unique: true + }, + + price: { + type: Number, + required: true + }, + + // Every mon and thu at 19:00 GMT+3 + timeDesc: { + type: String, + required: true + }, + + // currently available places + // decrease onPaid + participantsLimit: { + type: Number, + required: true + }, + + // is this group in the open course list (otherwise hidden)? + // even if not, the group is accessible by a direct link + isListed: { + type: Boolean, + required: true, + default: false + }, + + // is it possible to register? + isOpenForSignup: { + type: Boolean, + required: true, + default: false + }, + + participants: [{ + user: { + type: Schema.Types.ObjectId, + ref: 'User', + index: true, + required: true + }, + courseName: { // how to call this user in-course? + type: String, + required: true + }, + videoKey: { + type: String + // there may be groups without video & keys + } + }], + + // room jid AND gotowebinar id + // an offline group may not have this + webinarId: { + type: String + }, + + + course: { + type: Schema.Types.ObjectId, + ref: 'Course', + required: true + }, + + // JS/UI 10.01 + // a user-friendly group title + title: { + type: String, + required: true + }, + + created: { + type: Date, + default: Date.now + } +}); + +schema.methods.readMaterials = function*() { + var groupDir = path.join(config.courseRoot, this.slug); + + + try { + var files = yield fs.readdir(groupDir); + return files.map(function(file) { + if (file[0] == '.') return null; + return { + path: path.join(groupDir, file), + url: `/courses/groups/${this.slug}/download/${file}`, + title: file + }; + }).filter(Boolean); + } catch (e) { + log.error("Group dir must be a directory", groupDir); + + return []; + } + +}; + +schema.methods.decreaseParticipantsLimit = function(count) { + count = count === undefined ? 1 : count; + this.participantsLimit -= count; + if (this.participantsLimit < 0) this.participantsLimit = 0; + if (this.participantsLimit === 0) { + this.isOpenForSignup = false; // we're full! + } +}; + +module.exports = mongoose.model('CourseGroup', schema); + diff --git a/handlers/courses/models/courseInvite.js b/handlers/courses/models/courseInvite.js new file mode 100644 index 000000000..001948a0e --- /dev/null +++ b/handlers/courses/models/courseInvite.js @@ -0,0 +1,65 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; + +var schema = new Schema({ + + // invite page visited + // -> check order if the person is in the list (not removed) + // -> add to participants and accept + // invite belongs to the order, not group, + // so we can check it agains order actual participants + order: { + type: Schema.Types.ObjectId, + ref: 'Order' + // not required, invite may exist without an order ("free second time" for people who had problems) + }, + + // when order is null, + // this field is the only way to get the group to join + group: { + type: Schema.Types.ObjectId, + ref: 'CourseGroup', + required: true + }, + + token: { + type: String, + required: true, + default: function() { + return Math.random().toString(36).slice(2, 10); + } + }, + + email: { + type: String, + required: true + }, + + accepted: { + type: Boolean, + required: true, + default: false + }, + + validUntil: { + type: Date, + required: true + // invite is also a login token, so limit it + // max(group + 7 days, created + 7 days) + }, + + created: { + type: Date, + default: Date.now + } +}); + + +schema.methods.accept = function*() { + yield this.persist({ + accepted: true + }); +}; + +module.exports = mongoose.model('CourseInvite', schema); + diff --git a/handlers/courses/router.js b/handlers/courses/router.js new file mode 100755 index 000000000..f477465f0 --- /dev/null +++ b/handlers/courses/router.js @@ -0,0 +1,20 @@ +var Router = require('router'); +var mustBeAuthenticated = require('auth').mustBeAuthenticated; +var router = module.exports = new Router(); + +router.param('userById', require('users').routeUserById); +router.param('groupBySlug', require('./lib/routeGroupBySlug')); + +router.get('/', require('./controller/frontpage').get); +router.get('/:course', require('./controller/course').get); + +// same controller for new signups & existing orders +router.get('/groups/:groupBySlug/signup', require('./controller/signup').get); +router.get('/orders/:orderNumber(\\d+)', require('./controller/signup').get); + +router.get('/groups/:groupBySlug/info', require('./controller/groupInfo').get); +router.get('/groups/:groupBySlug/materials', require('./controller/groupMaterials').get); +router.all('/invite/:inviteToken?', require('./controller/invite').all); + +// for profile +router.get('/profile/:userById', mustBeAuthenticated, require('./controller/coursesByUser').get); diff --git a/handlers/courses/templates/blocks/contacts.jade b/handlers/courses/templates/blocks/contacts.jade new file mode 100644 index 000000000..43de4dd28 --- /dev/null +++ b/handlers/courses/templates/blocks/contacts.jade @@ -0,0 +1,34 @@ ++b('form')(data-elem="contact").complex-form._step_2 + +e.step._current + +b.course-register-contacts.courses-register-common + +e('h2').title.courses-register-common__title Контактная информация + +e('p').note + | Оставьте ваши контактные данные, чтобы мы могли связаться с вами + | в случае необходимости + + +e.body + +b.contact-form + +e.content + +e.fields + +e.name + label(for="contact-name") Имя и Фамилия: + +b.text-input._small.__name-input + +e('input')(data-elem="contactName", required, value="Tester").control#contact-name + +e.tel + label(for="contact-phone") Телефон: + +b.full-phone.__full-phone + +e.tel-wrap + +b.text-input._small.__tel + +e('input').control#contact-phone(data-elem="contactPhone" type='tel', pattern='[+0-9()# ]{6,}', placeholder='+X (XXX) XXX-XXXX') + +e.note + +e('h5').note-title Ваши данные в безопасности + p + | Никакие ваши личные данные + | не будут переданы третьим лицам, кроме как по вашему желанию или для + | целей выполнения заключенного с вами договора. + + +e.next.courses-register-common__next + +b('button')(type="submit").button._action + +e('span').text Сохранить и продолжить + + diff --git a/handlers/courses/templates/blocks/grayed-list.jade b/handlers/courses/templates/blocks/grayed-list.jade new file mode 100644 index 000000000..be5e674cc --- /dev/null +++ b/handlers/courses/templates/blocks/grayed-list.jade @@ -0,0 +1,4 @@ ++b('ul').grayed-list + +e('li').item._step_2 Контактная информация + +e('li').item._step_3 Оплата + +e('li').item._step_4 Подтверждение diff --git a/handlers/courses/templates/blocks/participants.jade b/handlers/courses/templates/blocks/participants.jade new file mode 100644 index 000000000..012940792 --- /dev/null +++ b/handlers/courses/templates/blocks/participants.jade @@ -0,0 +1,56 @@ ++b('form')(data-elem="participants").complex-form._step_1 + +e.step._current + +b.courses-register-participants.courses-register-common + +e('h2').title.courses-register-common__title Места и участники + + +b.course-register-info + +e('p').info._length + +e('time')(datetime=moment(group.dateStart).format("YYYY-MM-DD HH:mm")).time= formatGroupDate(group.dateStart) + | — + +e('time')(datetime=moment(group.dateStart).format("YYYY-MM-DD HH:mm")).time= formatGroupDate(group.dateEnd) + +e('p').info!= group.timeDesc + + +b.course-register-settings + +e.number.course-register-settings__cell + +e('h3').title Количество мест + +e.body + +b.number-input + +e('button')(disabled data-elem="participantsDecreaseButton" type="button").btn._dec − + +b.text-input._small.__text + +e('input')(type="number", value="1", min="1", required, max=groupInfo.participantsMax, data-elem="participantsCountInput").control.__input + +e('span').err введите значение от 1 до #{groupInfo.participantsMax} + +e('button')(data-elem="participantsIncreaseButton" type="button").btn._inc + + + +e.is-participant.course-register-settings__cell + +e('h3').title Я являюсь участником + +e.body + +b.switch-input + +e('input').checkbox#request-participant(data-elem="participantsIsSelf" type='checkbox' checked) + +e('i').bg + +e('label').label(for="request-participant") + +e('span').off НЕТ + +e('span').on ДА + + +e.price.course-register-settings__cell + +e('h3').title Стоимость + +e.body + +b.price + +e('span')(data-elem="participantsAmount")= groupInfo.price + +e('span').secondary + | (≈  + span(data-elem="participantsAmountUsd")= Math.round(groupInfo.price / rateUsdRub) + | $) + + +e.add-participants + +b(data-elem="participantsAddBox").course-add-participants + +e('input')(type="checkbox" data-elem="participantsListEnabled" id="add-participants").checkbox + +e('label').add(for="add-participants") Указать участников + +e('p').note (это можно сделать позже) + +e.dropdown + +e('label')(for="add-participants").dropdown-close.close-button + +e('ul')(data-elem="participantsAddList").dropdown-list + + +e.next.courses-register-common__next + +b('button')(type="submit").button._action + +e('span').text Сохранить и продолжить + diff --git a/handlers/courses/templates/blocks/participantsItem.jade b/handlers/courses/templates/blocks/participantsItem.jade new file mode 100644 index 000000000..5fafb3c8c --- /dev/null +++ b/handlers/courses/templates/blocks/participantsItem.jade @@ -0,0 +1,8 @@ +include /bem + ++b('li').course-add-participants-item + +e('label').participant + +e('span').participant-n Участник + +b('span').text-input + +e('input').control(placeholder="email", name="email", type="email") + +e('span').err введите корректный email diff --git a/handlers/courses/templates/blocks/payment.jade b/handlers/courses/templates/blocks/payment.jade new file mode 100644 index 000000000..a680f5ba2 --- /dev/null +++ b/handlers/courses/templates/blocks/payment.jade @@ -0,0 +1,21 @@ + ++b('form')(data-elem="payment").complex-form._step_3 + +e.step._current + +b.course-register-payment.courses-register-common + +e('h2').title.courses-register-common__title Оплата + if orderInfo.status == 'pending' + p Не оплачивайте дважды. Меняйте метод оплаты лишь если уверены, что оплата не произошла. + + +e.body + include ../../../payments/common/templates/payment-methods + + p Регистрируясь на курсы, вы соглашаетесь с договором оферты. + + if orderInfo.status + p Если у вас возникли какие-либо вопросы, присылайте их на orders@javascript.ru. + + +e.next.courses-register-common__next + +b('button')(type="submit").button._action + +e('span').text Перейти к оплате + + diff --git a/handlers/courses/templates/blocks/receipts.jade b/handlers/courses/templates/blocks/receipts.jade new file mode 100644 index 000000000..1cea9fe84 --- /dev/null +++ b/handlers/courses/templates/blocks/receipts.jade @@ -0,0 +1,43 @@ ++b.receipts._register + + +e.receipt._step_1 + +e.receipt-body + +e.receipt-content + +e.type Заказ: + +e.title(data-elem="receiptTitle")= receiptTitle + +b.course-register-info + +e('p').info._length + +e('time')(datetime=moment(group.dateStart).format("YYYY-MM-DD HH:mm")).time= formatGroupDate(group.dateStart) + | — + +e('time')(datetime=moment(group.dateStart).format("YYYY-MM-DD HH:mm")).time= formatGroupDate(group.dateEnd) + +e('p').info!= group.timeDesc + + +e.receipt-aside + +e.price + +b('span').price + span(data-elem="receiptAmount")= receiptAmount + |  RUB + if !order + +e('a').edit(href="#" data-elem="receiptParticipantsEditLink") + + +e.receipt._step_2 + +e.receipt-body + +e.receipt-content + +e.type Контактная информация: + +e.title(data-elem="receiptContactName")= receiptContactName + +e.receipt-aside._center + +e('span')(data-elem="receiptContactPhone").title= receiptContactPhone + if !order + +e('a').edit(href="#" data-elem="receiptContactEditLink") + + if ~['paid', 'success', 'pending'].indexOf(orderInfo.status) + +e.receipt._step_3 + +e.receipt-body + +e.receipt-content + +e.type Оплата: + if (orderInfo.status == 'paid' || orderInfo.status == 'success') + +e.status._ok Осуществлена успешно + else if (orderInfo.status == 'pending') + +e.status._ok Ожидается подтверждение + +e.receipt-aside + +e(class=["pay-method", "_" + paymentMethods[orderInfo.transaction.paymentMethod].name]) diff --git a/handlers/courses/templates/blocks/register-form-participants.jade b/handlers/courses/templates/blocks/register-form-participants.jade new file mode 100644 index 000000000..ea0f502a4 --- /dev/null +++ b/handlers/courses/templates/blocks/register-form-participants.jade @@ -0,0 +1,70 @@ ++b('form').complex-form._cources + +e.step._current + +b.courses-register-participants.courses-register-common + +e('h2').title.courses-register-common__title Места и участники + + +b.course-register-info + +e('h2').group Группа 10 + +e('p').info._length + +e('time')(datetime="2014-03-15 17:00").time 15 Мар 2014 + | — + +e('time')(datetime="2014-05-15 17:00").time 15 Май 2014 + +e('p').info + | Каждый Пн и Ср в  + +e('time').time 17:00  + | (UTC+4) + + +b.course-register-settings + +e.number.course-register-settings__cell + +e('h3').title Количество мест + +e.body + +b.number-input + +e('button')(disabled).btn._dec − + +b.text-input._small.__text + +e('input')(type="number" value="1").control.__input + +e('span').err Укажите поменьше людей + +e('button').btn._inc + + + +e.is-participant.course-register-settings__cell + +e('h3').title Я являюсь участником + +e.body + +b.switch-input + +e('input').checkbox#request-participant(type='checkbox') + +e('i').bg + +e('label').label(for="request-participant") + +e('span').off НЕТ + +e('span').on ДА + + +e.price.course-register-settings__cell + +e('h3').title Стоимость + +e.body + +b.price 2400 RUB + +e('span').secondary (≈ 200$) + + +e.add-participants + +b.course-add-participants + +e('input')(type="checkbox" id="add-participants").checkbox + +e('label').add(for="add-participants") Укзать участников + +e('p').note (это можно сделать позже) + +e.dropdown + +e('label')(for="add-participants").dropdown-close.close-button + +e('ul').dropdown-list + - var n = 0 + while n < 6 + +e('li').dropdown-item + +e('label').participant + +e('span').participant-n Участник + +b('span').text-input + +e('input').control(placeholder="email", name="email", type="email") + - n++ + + +e.next.courses-register-common__next + +b('button')(type="submit").button._action + +e('span').text Сохранить и продолжить + + + +e('ul').next.grayed-list + +e('li').next-item.grayed-list__item Контактная информация + +e('li').next-item.grayed-list__item Оплата + +e('li').next-item.grayed-list__item Подтверждение + diff --git a/handlers/courses/templates/blocks/result.jade b/handlers/courses/templates/blocks/result.jade new file mode 100644 index 000000000..a61a0d7e3 --- /dev/null +++ b/handlers/courses/templates/blocks/result.jade @@ -0,0 +1,34 @@ + + ++b('form').complex-form._step_4 + +e.step._current + + if orderInfo.status == 'success' + + +b.course-register-success.courses-register-common + +e('h2').title.courses-register-common__title Спасибо за заказ! + +e('h3').title.courses-register-common__title В ближайшее время вам придёт уведомление на адрес #{order.email} + + +e.body + p + | FIXME: replace with real instructions + | Вы можете отредактировать детали своего заказа + | в любое время до начала проведения курсов в + | соответствующем разделе вашей учетной записи. + | В случаях каких-либо изменений мы обязательно с вами свяжемся. + + p Если у вас возникли какие-либо вопросы, присылайте их на orders@javascript.ru. + + else + + +b.course-register-success.courses-register-common + +e('h2').title.courses-register-common__title!= orderInfo.title + if orderInfo.accent + +e('h3').title.courses-register-common__title!= orderInfo.accent + if orderInfo.description + +e.body + != orderInfo.description + if orderInfo.linkToProfile + != orderInfo.linkToProfile + + diff --git a/handlers/courses/templates/courses/js.jade b/handlers/courses/templates/courses/js.jade new file mode 100644 index 000000000..838e4a0b6 --- /dev/null +++ b/handlers/courses/templates/courses/js.jade @@ -0,0 +1,6 @@ +h1= course.title + +ul + each group in groups + li + a(href='/courses/groups/' + group.slug + '/signup')= group.title diff --git a/handlers/courses/templates/frontpage.jade b/handlers/courses/templates/frontpage.jade new file mode 100644 index 000000000..2b2d89268 --- /dev/null +++ b/handlers/courses/templates/frontpage.jade @@ -0,0 +1,8 @@ + +h1 Online курсы JavaScript + +ul + each course in courses + li + a(href=course.getUrl())= course.title + diff --git a/handlers/courses/templates/groupInfo/js.jade b/handlers/courses/templates/groupInfo/js.jade new file mode 100644 index 000000000..79e0e3249 --- /dev/null +++ b/handlers/courses/templates/groupInfo/js.jade @@ -0,0 +1,87 @@ +extends /layouts/main + + +block append variables + + - var layout_main_class = "main_width-limit" + - var title = 'Онлайн-курс: настройка окружения'; + - var sitetoolbar = true + + +block content + + p. + Эта инструкция – о том, как настроить у себя окружение для обучения. + Прочитайте, пожалуйста, ее полностью. Настройте всё и, желательно, протестируйте на собрании. + Это важно, чтобы вы могли сразу же полноценно принимать участие в процессе. + + p Для общения используется одновременно видео, аудио и чат. + + + h2 Общение в чате + + p Для общения в чате используется Jabber-клиент. + + p Самые популярные клиенты: + + ul + li Для Windows и Linux: Pidgin. + li Для MacOS: Adium. + + p Для настройки вам понадобятся: + + ul + li Имя страницы профиля в качестве логина – #{user.profileName}. + li + | Пароль для входа в сайт learn.javascript.ru (не javascript.ru). + | Если вы всегда входили через внешний сервис авторизации – его может не быть, + | тогда создайте его во вкладке профиля Аккаунт. + li Название комнаты: #{group.webinarId}. + + p Обратим внимание: никакие другие настройки, в частности ваш email, вам не нужны. Только то, что указано выше. + + p Если вы раньше настраивали Jabber-клиент, то вот настройки: + + ul + li Сервер: javascript.ru. + li Сервер конференций: conference.javascript.ru (стандартный). + li В качестве ника(псевдонима) для комнаты укажите свое имя и фамилию в формате "Имя Фамилия" (учитывая регистр). + + p Если нет, то вот короткое видео по установке и настройке Pidgin и Adium. + + h2 Система для разделения экрана и общения + + p. + Для использования этой системы в браузере должна быть установлена и включена java. + Если у вас ее нет – скачать можно здесь: + http://java.com/ru/download/. + + p Для захода в систему зайдите на http://joingotowebinar.com и введите: + + ul + li Webinar ID: #{group.webinarId}. + li Email: #{user.profileName}@javascript.ru. + + p Для запуска системы отвечайте Yes на вопросы. Если ничего не запускается – возможно, нужно нажать кнопку Launch или Download Software и запустить скачанную программу вручную. + + p Когда программа поставится, при следующем заходе скачивать или запускать ее заново будет не нужно. + + :simpledown + [warn] + До собрания попробуйте войти, как указано выше, чтобы вас "зарегистрировало", но работать видео будет только во время онлайн-встречи. + + При попытке зайти во "внеурочное" время видео не запустится. Это нормально. + [/warn] + + p + | Заранее, для проверки, можно подключиться по адресу https://www3.gotomeeting.com/join/406552062, + | людей там нет, но программа должна поставиться и запуститься. + + p Полная инструкция по тестированию входа находится на http://support.citrixonline.com/en_US/GoToMeeting/help_files/GTM140010?title=Test+Your+GoToMeeting+Connection. + + p Практика показывает, что если у вас стоит Java и работает Skype, то и видео тоже нормально заработает. + + include:simpledown ./js.md + + script(src=pack("courses", "js")) + script courses.init(); diff --git a/handlers/courses/templates/groupInfo/js.md b/handlers/courses/templates/groupInfo/js.md new file mode 100644 index 000000000..f3fd82478 --- /dev/null +++ b/handlers/courses/templates/groupInfo/js.md @@ -0,0 +1,125 @@ + +## Общение голосом + +Для общения голосом служит та же система, что и для видео. + +Желательно иметь микрофон неподалёку, хотя он и не обязателен для участия, задавать вопросы можно и текстом в чате. + +### Как задавать вопросы? + +Если вдруг вам что-то непонятно в материале или решении задачи -- обязательно говорите об этом, задавайте вопросы. + +Бывает и так, что вроде бы не всё понятно, но что конкретно -- сформулировать сложно. +В этом случае отличным выходом является вопрос "с этого момента поподробнее". Главное - спрашивайте, участвуйте в занятии. + +Ответы на ваши вопросы могут содержать дополнительные интересные сведения, которые помогут не только вам, но и другим участникам. +Поэтому задавайте, все вам скажут только спасибо. + +Задавать вопросы можно двумя способами. + +
    +
  1. Первый -- написать в общем чате. Настройки чата описаны выше.
  2. +
  3. Второй -- спросить голосом. +Для этого нужно нажать на кнопку "ладонь со стрелкой вверх" ("Raise hand"), которая находится на мини-пульте управления системой разделения экрана. +Обычно он справа-сверху. В этом случае, когда ведущий увидит вашу руку -- он передаст вам "микрофон". + +При получении микрофона значок микрофона на мини-пульте изменится и раздастся голосовое оповещение на английском "unmuted", и вас будет слышно.
  4. +
+ +Бывает, что поднятая рука заметна ведущему не сразу, тогда можно написать об этом в чате -- "вопрос голосом". + +## Решение задач + +Для обмена решениями задач используется онлайн-песочница. Для учебника взят Plunker, + но вы можете использовать и jsbin и CodePen и любую другую. + +Все решения просьба подписывать сверху своим именем, можно комментарий под <html>: + +```html + + +*!* + +*/!* +... +``` + +...И, конечно, решения нужно не только делать, но и показывать их. Но показать не все, а только те, которые отличаются от приведённых в учебнике. + +Рекомендуемый алгоритм действий при решении задачи: + +
+
Если вы решили задачу сами...
+
В этом случае нужно посмотреть решение из учебника -- вдруг там подводные камни где-то, и просто чтобы увидеть альтернативный вариант. + +Если ваше решение чем-то отличается от данного в учебнике -- покажите его на занятии. +
+
Если вы не решили, но разобрались в решении...
+
Включать решение из учебника в домашнюю работу не надо, оно не ваше.
+
Если вы не решили и не понятны какие-то моменты в решении. +
Обязательно спросите на занятии! Любые ваши вопросы определённо стоят того, чтобы их обсудить на занятии.
+
+ +## Дополнительно + +Вам также может понадобиться просмотр PDF. Как правило, для этого используют Acrobat Reader. Скачать можно, например, здесь (выберите OS, язык и уберите галочку Free McAfee). + +Ну и, конечно же, нужны будут браузеры, которые вы собираетесь поддерживать. Обычно это Chrome, Internet Explorer и Firefox. Настройте свое рабочее место. Поставьте редакторы -- я использую Webstorm и Sublime, +но есть и много других, выбор целиком ваш. + +Обязательно выставьте точное время на часах (свериться можно с [google](https://www.google.ru/search?q=время)). Это нужно для координации времени на перерывы и решение задач. + +Все эти приготовления и система задуманы так, чтобы сделать процесс обучения максимально комфортным и эффективным. + +Если что-то из этой инструкции непонятно -- задавайте вопросы на mk@javascript.ru, я на них отвечу. + +## Возможные проблемы и их решения + + +### Если чат не работает + +
    +
  1. Во-первых, проверьте, что вы вводите в качестве логина именно то, что указано выше. Некоторые клиенты требуют указать логин вместе с сервером: `логин@javascript.ru`.
  2. +
  3. Во-вторых, проверьте пароль -- это должен быть пароль для входа на сайт learn.javascript.ru.
  4. +
  5. Это бывает весьма редко, но некоторые провайдеры имеют сложности с правильным разрешением особых ДНС-записей для Jabber. +В результате аккаунт не может подключиться. +Попробуйте поставить DNS-сервер `8.8.8.8` (это открытый сервер от Google), если заработает, значит дело в этом.
  6. +
  7. Если и это не помогло -- проверьте, открыт ли порт 5222, это можно сделать командой `telnet jabber.javascript.ru 5222` или другой аналогичной. Иногда администраторы в офисах его закрывают.
  8. +
+ +Если всё ещё не работает -- напишите мне на mk@javascript.ru, постараюсь помочь. + +**Чат должен работать в любое время, проверьте его заранее.** + + +### Если не работает видео + +Видео, в отличие от чата, работает только во время занятий. Совершенно нормально, что до занятий вас "не пускает" в вебинар. + +Как правило, оно стартует в течение 1-2 минут после захода ведущего в чат. Ведущий пишет о том, что видео запущено, в чате. + +
    +
  1. Если вход не удаётся -- проверьте, правильные ли данные вы вводите, в точности ли то, что указано выше?
  2. +
  3. Если вебинар пишет "not approved" -- возможно, вы не пробовали войти до собрания, и ведущий не имел возможности подтвердить вашу регистрацию, попросите его об этом в чате.
  4. +
  5. Если всё ещё не работает -- посмотрите системные требования. Операционная система: Windows или MacOS. Нужна Java.
  6. +
  7. Бывает так, что автоматически система не стартует, на этот случай при входе есть предложение скачать (download) программу и запустить её вручную.
  8. +
  9. Если никак не запускается -- убейте процессы с названиями вида `g2*` (то есть, начинающиеся с `g2`) или перезарузитесь и попробуйте ещё раз. Это сценарий бывает редко.
  10. +
+ +Если всё ещё не работает -- во время онлайн-собрания можно задавать вопросы по Skype, ник: `javascript.ru`. + + +### Форс-мажор: если нет ведущего + +Если вдруг случится что-то непредвиденное (на линии электропередач упало дерево, интернет-провода погрыз ополоумевший барсук, ведущего переехал самосвал) -- занятия всё равно будут, +но, возможно, с опозданием или переносом. + +Подобное бывает очень редко. + +Обычно занятия начинаются по расписанию. Максимально возможное опоздание ведущего -- 15 минут. +Если его нет дольше и нет информации, значит произошло что-то серьезное. + +Можно попытаться узнать, что именно, позвонив по телефону +7(903)541-94-41. Не стесняйтесь -- звоните. +В качестве финального порога отмены занятия устанавливается задержка на 30 минут. + +Разъяснения и соответствующее обновление расписания в этом случае будут в ближайшее возможное время. diff --git a/handlers/courses/templates/groupMaterials.jade b/handlers/courses/templates/groupMaterials.jade new file mode 100644 index 000000000..cd3051979 --- /dev/null +++ b/handlers/courses/templates/groupMaterials.jade @@ -0,0 +1,40 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var content_class = '_center' + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + - var title = "Материалы для группы" + +block content + + - var data = []; + - data.push({ url: '/123', name: 'Вводный курс JS', size: '345 Mb', date: moment(new Date()).locale('ru').format('DD MMM YYYY') }); + - data.push({ url: '/123', name: 'Материалы, 2013 08 22 2130', size: '1.3 Gb', date: moment(new Date()).locale('ru').format('DD MMM YYYY') }); + - data.push({ url: '/123', name: 'Материалы, 2013 09 22 2130', size: '2 Gb', date: moment(new Date()).locale('ru').format('DD MMM YYYY') }); + - data.push({ url: '/123', name: 'Материалы, 2013 09 26 2130', size: '2 Gb', date: moment(new Date()).locale('ru').format('DD MMM YYYY') }); + + h2= group.title + + // TODO! + +b.courses-materials + + +e('p').video-key + | Серийный номер для видео: + = ' ' + +e('span')= videoKey + + +e('table').table + +e('tr').line + +e('th').num # + +e('th').name Название + +e('th').size Размер + +e('th').added Добавлено + for material in data + +e('tr').line + +e('td').num + +e('td').name + +e('a').link(href=material.url) !{ material.name } + +e('td').size !{ material.size } + +e('td').added !{ material.date } diff --git a/handlers/courses/templates/invite/accepted.jade b/handlers/courses/templates/invite/accepted.jade new file mode 100644 index 000000000..079c2a59c --- /dev/null +++ b/handlers/courses/templates/invite/accepted.jade @@ -0,0 +1,13 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var sitetoolbar = true + - var title = "Приглашение принято" + - var layout_main_class = "main_width-limit" + +block content + + p Поздравляем, вы присоединились к курсу. + + p Информацию вы можете получить на странице TODO diff --git a/handlers/courses/templates/invite/askCourseName.jade b/handlers/courses/templates/invite/askCourseName.jade new file mode 100644 index 000000000..6c2dc8ae1 --- /dev/null +++ b/handlers/courses/templates/invite/askCourseName.jade @@ -0,0 +1,38 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + - var content_class = '_center' + +block content + + if wasLoggedIn + +b.notification._message._info + +e.content Вы вошли на сайт под пользователем #{user.displayName} + + p Укажите ФИО для участия в курсе + + +b.login-form.complex-form.complex-form_step_4 + +e.step.complex-form__step.complex-form__step_current + +e('form').form(method="POST" action="/courses/invite") + input(type="hidden", name="_csrf", value=csrf()) + input(type="hidden" name="inviteToken" value=invite.token) + + +e.line + +e('label').label(for="invite-email") Email: + +b('span').text-input.__input + +e('input').control#invite-email(name="email" type="email" value=invite.email disabled) + + +e.line + +e('label').label(for="invite-courseName") ФИО (только для курса, на сайте не отображается) + +b('span')(class=["text-input", "__input", errors.courseName ? '_invalid' : '' ]) + +e('input').control#invite-courseName(type="text", name="courseName" required value=form.courseName placeholder="Пушкин Александр Сергеевич" autofocus) + if errors.courseName + +e.err= errors.courseName + + +e.line.__footer + +b('button').button._action(type="submit") + +e('span').text Подтвердить участие + diff --git a/handlers/courses/templates/invite/deny.jade b/handlers/courses/templates/invite/deny.jade new file mode 100644 index 000000000..c15c4282a --- /dev/null +++ b/handlers/courses/templates/invite/deny.jade @@ -0,0 +1,15 @@ +extends /layouts/main + +block append variables + + - var sitetoolbar = true + - var title = "Приглашение отменено" + - var layout_main_class = "main_width-limit" + +block content + + p Извините, это приглашение было отменено, адрес #{email} был удалён из списка к заказу #{orderNumber}. + + p Контактное лицо по этому заказу: #{contactName}. + + p По техническим вопросам, если ситуация не соответствует действительности, пишите на адрес orders@javascript.ru. diff --git a/handlers/courses/templates/invite/email.jade b/handlers/courses/templates/invite/email.jade new file mode 100644 index 000000000..b02711282 --- /dev/null +++ b/handlers/courses/templates/invite/email.jade @@ -0,0 +1,16 @@ +extends /layouts/email + +block body + + h2 Приглашение на курс + + p На сайте javascript.ru была оформлена запись для вас на курс #{group.title}. + + p Для подтверждения участия перейдите по ссылке #{link}. + + if userExists + p При этом вы автоматически будете залогинены на сайте. + + p Контактное лицо, указанное в записи: #{contactName}. + + p Если возникнут какие-либо вопросы - вы всегда можете ответить на это письмо. diff --git a/handlers/courses/templates/invite/outdated.jade b/handlers/courses/templates/invite/outdated.jade new file mode 100644 index 000000000..3ff05bc35 --- /dev/null +++ b/handlers/courses/templates/invite/outdated.jade @@ -0,0 +1,13 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var sitetoolbar = true + - var title = "Ссылка устарела" + - var layout_main_class = "main_width-limit" + +block content + + p Извините, ссылка по которой вы перешли, устарела. + + p Если у вас возникли какие-либо вопросы - пишите на orders@javascript.ru. diff --git a/handlers/courses/templates/invite/register.jade b/handlers/courses/templates/invite/register.jade new file mode 100644 index 000000000..aeec3bea7 --- /dev/null +++ b/handlers/courses/templates/invite/register.jade @@ -0,0 +1,47 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + - var content_class = '_center' + +block content + + p Для подтверждения вам необходимо зарегистрироваться. + + +b.login-form.complex-form.complex-form_step_4 + +e.step.complex-form__step.complex-form__step_current + +e('form').form(method="POST" action="/courses/invite") + input(type="hidden" name="inviteToken" value=invite.token) + + +e.line + +e('label').label(for="invite-email") Email: + +b('span').text-input.__input + +e('input').control#invite-email(name="email" type="email" value=invite.email disabled) + + +e.line + +e('label').label(for="invite-displayName") Имя пользователя: + +b('span')(class=["text-input", "__input", errors.displayName ? '_invalid' : '' ]) + +e('input').control#invite-displayName(name="displayName" type="text" required value=form.displayName autofocus) + if errors.displayName + +e.err= errors.displayName + + +e.line + +e('label').label(for="invite-courseName") ФИО (только для курса, на сайте не отображается) + +b('span')(class=["text-input", "__input", errors.courseName ? '_invalid' : '' ]) + +e('input').control#invite-courseName(type="text", name="courseName" required value=form.courseName placeholder="Пушкин Александр Сергеевич") + if errors.courseName + +e.err= errors.courseName + + +e.line + +e('label').label(for="invite-password") Пароль + +b('span')(class=["text-input", "__input", errors.password ? '_invalid' : '' ]) + +e('input').control#invite-password(type="password", name="password" required value=form.password minlength="4") + if errors.password + +e.err= errors.password + + +e.line.__footer + +b('button').button._action(type="submit") + +e('span').text Подтвердить участие + diff --git a/handlers/courses/templates/new-order.jade b/handlers/courses/templates/new-order.jade new file mode 100755 index 000000000..f5d882088 --- /dev/null +++ b/handlers/courses/templates/new-order.jade @@ -0,0 +1,52 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + +block append ga + script. + ga('require', 'ec') + ga('set', '&cu', 'RUB'); + + ga('ec:addProduct', { + id: 'invoice' + }); + ga('ec:setAction', 'click'); + +block content + + script var status = "#{status}"; + + +b("form").complex-form(data-order-form) + +e.step._current + +e.step-content + +b.extract._small.__extract + + if (!user) + +e('h2').alternate-title Укажите свой email + +b.text-input.__email + +e('input').control(placeholder="my@email.com", name="email", required, type="email") + +e.email-note Тот же, что в заказе на javascript.ru. + + +e('h2').alternate-title Укажите номер заказа + +b.text-input.__email + +e('input').control(placeholder="1234", name="invoiceNumber", required, type="number") + + +e('h2').alternate-title Укажите сумму оплаты + +b.text-input.__email + +e('input').control(placeholder="21000", name="amount", required, type="number") + + + +e('h2').alternate-title Выберите метод оплаты + +b.pay-method.__pay-method + +e.methods + each paymentMethod in paymentMethods + +e.method(data-add-class-on-hover) + // classes get prefix automatically from dynamic mixins + +e('button')(class=["send", "_"+paymentMethod.name] name="paymentMethod" type="button" value=paymentMethod.name) + + +b('ul').grayed-list.__next + +e('li').item.__next-item Подтверждение + + script(src=pack("invoice", "js")) + script invoice.init(); diff --git a/handlers/courses/templates/order.jade b/handlers/courses/templates/order.jade new file mode 100755 index 000000000..2ac670071 --- /dev/null +++ b/handlers/courses/templates/order.jade @@ -0,0 +1,42 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var content_class = '_center' + - var sitetoolbar = true + +block append ga + script ga('require', 'ec'); + + if orderInfo.status == 'fail' + script window.ga('ec:setAction', 'refund', { id: #{order.number} }); + + script window.ga('send', 'event', 'payment', 'return-#{orderInfo.status}', 'course'); + +block content + - var mailto = "mailto:orders@javascript.ru?subject=" + encodeURIComponent('Заказ ' + order.number); + + script var orderNumber = #{order.number}; + + script window.metrika.reachGoal('ORDER', { product: 'ebook', status: '#{orderInfo.status}', number: '#{orderInfo.number}' }); + + + if orderInfo.status == 'fail' + +b.notification._error._message.__error + +e.content + p Оплата не прошла, попробуйте ещё раз. + if orderInfo.transaction && orderInfo.transaction.statusMessage + div + +e('span').cause= orderInfo.transaction.statusMessage + p По вопросам, касающимся оплаты, пишите на orders@javascript.ru. + + +b(data-elem="signup").courses-register._step_1 + + include blocks/receipts + include blocks/payment + include blocks/result + + script(src=pack("courses", "js")) + script courses.init(); + diff --git a/handlers/courses/templates/signup.jade b/handlers/courses/templates/signup.jade new file mode 100644 index 000000000..3c6187a27 --- /dev/null +++ b/handlers/courses/templates/signup.jade @@ -0,0 +1,43 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var content_class = '_center' + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + + script var groupInfo = !{JSON.stringify(groupInfo)}; + script var rateUsdRub = #{rateUsdRub}; + script var orderNumber = #{order ? order.number : null}; + + - var step = !order ? '1' : (order.status != 'success' && order.status != 'pending' || changePaymentRequested) ? '3' : '4'; + +b(data-elem="signup" class=['courses-register', '_step_' + step]) + + if orderInfo.status == 'fail' + +b.notification._error._message.__error + +e.content + p Оплата не прошла, попробуйте ещё раз. + if orderInfo.transaction && orderInfo.transaction.statusMessage + div + +e('span').cause= orderInfo.transaction.statusMessage + p По вопросам, касающимся оплаты, пишите на orders@javascript.ru. + + include blocks/receipts + + if !order + include blocks/participants + include blocks/contacts + + include blocks/payment + + include blocks/result + + include blocks/grayed-list + + + script(src=pack("courses", "js")) + script courses.init(); + diff --git a/handlers/courses/templates/success-email.jade b/handlers/courses/templates/success-email.jade new file mode 100755 index 000000000..246fb3401 --- /dev/null +++ b/handlers/courses/templates/success-email.jade @@ -0,0 +1,27 @@ +extends /layouts/email + +block body + + h2 Подтверждение оплаты + + p Подтверждаем получение оплаты за заказ #{orderNumber}. + + if orderUserIsParticipant && !orderHasOtherParticipants + //- registration complete for single-user + p Инструкции вы можете найти на странице курса или перейти по ссылкам TODO. + + if orderUserIsParticipant && orderHasOtherParticipants + + p Вы зарегистрированы на курс, инструкции вы можете найти на странице курса или перейти по ссылкам TODO. + + p. + Другие приглашённые вами участники получат письмо на электронную почту с предложением присоединиться. + Письмо придёт с адреса orders@javascript.ru. + + if !orderUserIsParticipant + + p. + Приглашённые вами участники получат письмо на электронную почту с предложением присоединиться. + Письмо придёт с адреса orders@javascript.ru. + + p Если возникнут какие-либо вопросы - вы всегда можете ответить на это письмо. diff --git a/handlers/csrf.js b/handlers/csrf.js new file mode 100755 index 000000000..39a5e71b9 --- /dev/null +++ b/handlers/csrf.js @@ -0,0 +1,83 @@ +const koaCsrf = require('koa-csrf'); +const PathListCheck = require('pathListCheck'); + +function CsrfChecker() { + this.ignore = new PathListCheck(); +} + + +CsrfChecker.prototype.middleware = function() { + var self = this; + + return function*(next) { + // skip these methods + if (this.method === 'GET' || this.method === 'HEAD' || this.method === 'OPTIONS') { + return yield* next; + } + + var checkCsrf = true; + + if (!this.user) { + checkCsrf = false; + } + + if (self.ignore.check(this.path)) { + checkCsrf = false; + } + + // If test check CSRF only when "X-Test-Csrf" header is set + if (process.env.NODE_ENV == 'test') { + if (!this.get('X-Test-Csrf')) { + checkCsrf = false; + } + } + + if (checkCsrf) { + this.assertCSRF(this.request.body); + } else { + this.log.debug("csrf skip"); + } + + yield* next; + }; +}; + + +// every request gets different this._csrf to use in POST +// but ALL tokens are valid +exports.init = function(app) { + koaCsrf(app); + + app.use(function*(next) { + + try { + // first, do the middleware, maybe authorize user in the process + yield* next; + } finally { + // then if we have a user, set XSRF token + if (this.req.user) { + setCsrfCookie.call(this); + } + } + + }); + + app.csrfChecker = new CsrfChecker(); + + app.use(app.csrfChecker.middleware()); +}; + + +// XSRF-TOKEN cookie name is used in angular by default +function setCsrfCookie() { + + try { + // if this doesn't throw, the user has a valid token in cookie already + this.assertCsrf({_csrf: this.cookies.get('XSRF-TOKEN') }); + } catch(e) { + // error occurs if no token or invalid token (old session) + // then we set a new (valid) one + this.cookies.set('XSRF-TOKEN', this.csrf, { httpOnly: false, signed: false }); + } + +} diff --git a/handlers/currencyRate/currencies.json b/handlers/currencyRate/currencies.json new file mode 100755 index 000000000..07a938354 --- /dev/null +++ b/handlers/currencyRate/currencies.json @@ -0,0 +1,170 @@ +{ + "AED": "United Arab Emirates Dirham", + "AFN": "Afghan Afghani", + "ALL": "Albanian Lek", + "AMD": "Armenian Dram", + "ANG": "Netherlands Antillean Guilder", + "AOA": "Angolan Kwanza", + "ARS": "Argentine Peso", + "AUD": "Australian Dollar", + "AWG": "Aruban Florin", + "AZN": "Azerbaijani Manat", + "BAM": "Bosnia-Herzegovina Convertible Mark", + "BBD": "Barbadian Dollar", + "BDT": "Bangladeshi Taka", + "BGN": "Bulgarian Lev", + "BHD": "Bahraini Dinar", + "BIF": "Burundian Franc", + "BMD": "Bermudan Dollar", + "BND": "Brunei Dollar", + "BOB": "Bolivian Boliviano", + "BRL": "Brazilian Real", + "BSD": "Bahamian Dollar", + "BTC": "Bitcoin", + "BTN": "Bhutanese Ngultrum", + "BWP": "Botswanan Pula", + "BYR": "Belarusian Ruble", + "BZD": "Belize Dollar", + "CAD": "Canadian Dollar", + "CDF": "Congolese Franc", + "CHF": "Swiss Franc", + "CLF": "Chilean Unit of Account (UF)", + "CLP": "Chilean Peso", + "CNY": "Chinese Yuan", + "COP": "Colombian Peso", + "CRC": "Costa Rican Colón", + "CUP": "Cuban Peso", + "CVE": "Cape Verdean Escudo", + "CZK": "Czech Republic Koruna", + "DJF": "Djiboutian Franc", + "DKK": "Danish Krone", + "DOP": "Dominican Peso", + "DZD": "Algerian Dinar", + "EEK": "Estonian Kroon", + "EGP": "Egyptian Pound", + "ERN": "Eritrean Nakfa", + "ETB": "Ethiopian Birr", + "EUR": "Euro", + "FJD": "Fijian Dollar", + "FKP": "Falkland Islands Pound", + "GBP": "British Pound Sterling", + "GEL": "Georgian Lari", + "GGP": "Guernsey Pound", + "GHS": "Ghanaian Cedi", + "GIP": "Gibraltar Pound", + "GMD": "Gambian Dalasi", + "GNF": "Guinean Franc", + "GTQ": "Guatemalan Quetzal", + "GYD": "Guyanaese Dollar", + "HKD": "Hong Kong Dollar", + "HNL": "Honduran Lempira", + "HRK": "Croatian Kuna", + "HTG": "Haitian Gourde", + "HUF": "Hungarian Forint", + "IDR": "Indonesian Rupiah", + "ILS": "Israeli New Sheqel", + "IMP": "Manx pound", + "INR": "Indian Rupee", + "IQD": "Iraqi Dinar", + "IRR": "Iranian Rial", + "ISK": "Icelandic Króna", + "JEP": "Jersey Pound", + "JMD": "Jamaican Dollar", + "JOD": "Jordanian Dinar", + "JPY": "Japanese Yen", + "KES": "Kenyan Shilling", + "KGS": "Kyrgystani Som", + "KHR": "Cambodian Riel", + "KMF": "Comorian Franc", + "KPW": "North Korean Won", + "KRW": "South Korean Won", + "KWD": "Kuwaiti Dinar", + "KYD": "Cayman Islands Dollar", + "KZT": "Kazakhstani Tenge", + "LAK": "Laotian Kip", + "LBP": "Lebanese Pound", + "LKR": "Sri Lankan Rupee", + "LRD": "Liberian Dollar", + "LSL": "Lesotho Loti", + "LTL": "Lithuanian Litas", + "LVL": "Latvian Lats", + "LYD": "Libyan Dinar", + "MAD": "Moroccan Dirham", + "MDL": "Moldovan Leu", + "MGA": "Malagasy Ariary", + "MKD": "Macedonian Denar", + "MMK": "Myanma Kyat", + "MNT": "Mongolian Tugrik", + "MOP": "Macanese Pataca", + "MRO": "Mauritanian Ouguiya", + "MTL": "Maltese Lira", + "MUR": "Mauritian Rupee", + "MVR": "Maldivian Rufiyaa", + "MWK": "Malawian Kwacha", + "MXN": "Mexican Peso", + "MYR": "Malaysian Ringgit", + "MZN": "Mozambican Metical", + "NAD": "Namibian Dollar", + "NGN": "Nigerian Naira", + "NIO": "Nicaraguan Córdoba", + "NOK": "Norwegian Krone", + "NPR": "Nepalese Rupee", + "NZD": "New Zealand Dollar", + "OMR": "Omani Rial", + "PAB": "Panamanian Balboa", + "PEN": "Peruvian Nuevo Sol", + "PGK": "Papua New Guinean Kina", + "PHP": "Philippine Peso", + "PKR": "Pakistani Rupee", + "PLN": "Polish Zloty", + "PYG": "Paraguayan Guarani", + "QAR": "Qatari Rial", + "RON": "Romanian Leu", + "RSD": "Serbian Dinar", + "RUB": "Russian Ruble", + "RWF": "Rwandan Franc", + "SAR": "Saudi Riyal", + "SBD": "Solomon Islands Dollar", + "SCR": "Seychellois Rupee", + "SDG": "Sudanese Pound", + "SEK": "Swedish Krona", + "SGD": "Singapore Dollar", + "SHP": "Saint Helena Pound", + "SLL": "Sierra Leonean Leone", + "SOS": "Somali Shilling", + "SRD": "Surinamese Dollar", + "STD": "São Tomé and Príncipe Dobra", + "SVC": "Salvadoran Colón", + "SYP": "Syrian Pound", + "SZL": "Swazi Lilangeni", + "THB": "Thai Baht", + "TJS": "Tajikistani Somoni", + "TMT": "Turkmenistani Manat", + "TND": "Tunisian Dinar", + "TOP": "Tongan Paʻanga", + "TRY": "Turkish Lira", + "TTD": "Trinidad and Tobago Dollar", + "TWD": "New Taiwan Dollar", + "TZS": "Tanzanian Shilling", + "UAH": "Ukrainian Hryvnia", + "UGX": "Ugandan Shilling", + "USD": "United States Dollar", + "UYU": "Uruguayan Peso", + "UZS": "Uzbekistan Som", + "VEF": "Venezuelan Bolívar Fuerte", + "VND": "Vietnamese Dong", + "VUV": "Vanuatu Vatu", + "WST": "Samoan Tala", + "XAF": "CFA Franc BEAC", + "XAG": "Silver (troy ounce)", + "XAU": "Gold (troy ounce)", + "XCD": "East Caribbean Dollar", + "XDR": "Special Drawing Rights", + "XOF": "CFA Franc BCEAO", + "XPF": "CFP Franc", + "YER": "Yemeni Rial", + "ZAR": "South African Rand", + "ZMK": "Zambian Kwacha (pre-2013)", + "ZMW": "Zambian Kwacha", + "ZWL": "Zimbabwean Dollar" +} \ No newline at end of file diff --git a/handlers/currencyRate/index.js b/handlers/currencyRate/index.js new file mode 100755 index 000000000..145f31f18 --- /dev/null +++ b/handlers/currencyRate/index.js @@ -0,0 +1,45 @@ + +// - Initialize money module for sync conversion +// - Load rates from DB on boot +// - provide /currency-rate/update url to update money rates + +var config = require('config'); +var CurrencyRate = require('./models/currencyRate'); + +var currencyRate; + +var request = require('koa-request'); +var fetchLatest = require('./lib/fetchLatest'); + +// all supported currencies +// http://openexchangerates.org/api/currencies.json?app_id=APP_ID +var currencies = require('./currencies'); + +var money = require('money'); + + +exports.boot = function*() { + // load from db into memory + currencyRate = yield CurrencyRate.findOne().sort({timestamp: -1}).limit(1).exec(); + + if (!currencyRate) { + currencyRate = yield* fetchLatest(); + } + + if (!currencyRate) { + throw new Error("Unable to get latest currency rate"); + } + + money.rates = currencyRate.rates; + money.base = currencyRate.base; + + // updated asynchronously +}; + + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/currency-rate', __dirname)); +}; + diff --git a/handlers/currencyRate/lib/fetchLatest.js b/handlers/currencyRate/lib/fetchLatest.js new file mode 100755 index 000000000..131daadc7 --- /dev/null +++ b/handlers/currencyRate/lib/fetchLatest.js @@ -0,0 +1,35 @@ + +var request = require('koa-request'); +var CurrencyRate = require('../models/currencyRate'); + +var config = require('config'); +var log = require('log')(); + + +module.exports = function*() { + + var result = yield request({ + url: 'http://openexchangerates.org/api/latest.json?app_id=' + config.openexchangerates.appId, + json: true + }); + + if (!result.body) { + log.error(result); + return; + } + + if (!result.body.rates.RUB) { + // something's wrong + log.error(result); + return; + } + + var currencyRate = yield CurrencyRate.findOneAndUpdate( + { timestamp: result.body.timestamp }, + result.body, + {upsert: true} + ).exec(); + + return currencyRate; + +}; \ No newline at end of file diff --git a/handlers/currencyRate/models/currencyRate.js b/handlers/currencyRate/models/currencyRate.js new file mode 100755 index 000000000..feeb0d4be --- /dev/null +++ b/handlers/currencyRate/models/currencyRate.js @@ -0,0 +1,28 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; + +// the schema follows http://openexchangerates.org/api/latest.json response +var schema = new Schema({ + timestamp: { + type: Number, + required: true, + unique: true + }, + base: { + type: String, + required: true + }, + + rates: { + type: Schema.Types.Mixed, + required: true + }, + + created: { + type: Date, + default: Date.now + } +}); + +module.exports = mongoose.model('CurrencyRate', schema); + diff --git a/handlers/currencyRate/router.js b/handlers/currencyRate/router.js new file mode 100755 index 000000000..c5c1ebea7 --- /dev/null +++ b/handlers/currencyRate/router.js @@ -0,0 +1,27 @@ +// CRONTAB: run me daily +var Router = require('koa-router'); +var mongoose = require('mongoose'); +var CurrencyRate = require('./models/currencyRate'); +var fetchLatest = require('./lib/fetchLatest'); +var mustBeAdmin = require('auth').mustBeAdmin; + +var router = module.exports = new Router(); + +var money = require('money'); + +router.get('/update', mustBeAdmin, function*() { + var currencyRate = yield* fetchLatest(); + + if (!currencyRate) { + return; + } + + money.rates = currencyRate.rates; + money.base = currencyRate.base; + + this.body = { + status: "ok", + time: new Date() + }; +}); + diff --git a/handlers/dev/index.js b/handlers/dev/index.js new file mode 100755 index 000000000..cec3cee47 --- /dev/null +++ b/handlers/dev/index.js @@ -0,0 +1,6 @@ +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use( mountHandlerMiddleware('/dev', __dirname) ); +}; + diff --git a/handlers/dev/router.js b/handlers/dev/router.js new file mode 100755 index 000000000..ec7bd9703 --- /dev/null +++ b/handlers/dev/router.js @@ -0,0 +1,42 @@ +var Router = require('koa-router'); +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var Article = require('tutorial').Article; +var _ = require('lodash'); + +var router = module.exports = new Router(); + +router.get('/user', function*() { + var User = require('users').User; + + var user = new User({ + email: 'mk@abc.ru', + gender: 'male', + displayName: 'Tester2' + }); + + try { + mongoose.Types.ObjectId("51bb793aca2ab77a3200000d"); + + var user = yield User.findById('blabla', function(err, res) { + console.log(arguments); + }); + + } catch (e) { + console.log(e.errors); + } + +}); + +router.get('/die', function*() { + setTimeout(function() { + throw new Error("die"); + }, 10); +}); + +var d = new Date() + ''; + +router.get('/test', function*() { + this.body = 'gbkjgjf'; +}); + diff --git a/handlers/download/controllers/download.js b/handlers/download/controllers/download.js new file mode 100755 index 000000000..6a2b21b78 --- /dev/null +++ b/handlers/download/controllers/download.js @@ -0,0 +1,24 @@ +var path = require('path'); + +var ExpiringDownloadLink = require('../models/expiringDownloadLink'); + +exports.get = function*() { + + var linkId = this.params.linkId; + + var downloadLink = yield ExpiringDownloadLink.findOne({ + linkId: linkId + }).exec(); + + if (!downloadLink) { + this.throw(404); + } + + this.set({ + 'Content-Type': 'application/octet-stream', + 'Content-Disposition': 'attachment; filename=' + path.basename(downloadLink.relativePath), + 'X-Accel-Redirect': '/_download/' + downloadLink.relativePath + }); + + this.body = ''; +}; \ No newline at end of file diff --git a/handlers/download/index.js b/handlers/download/index.js new file mode 100755 index 000000000..d3b102254 --- /dev/null +++ b/handlers/download/index.js @@ -0,0 +1,10 @@ + + +exports.ExpiringDownloadLink = require('./models/expiringDownloadLink'); + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/download', __dirname)); +}; + diff --git a/handlers/download/models/expiringDownloadLink.js b/handlers/download/models/expiringDownloadLink.js new file mode 100755 index 000000000..ed7aa144f --- /dev/null +++ b/handlers/download/models/expiringDownloadLink.js @@ -0,0 +1,36 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var crypto = require('crypto'); +var config = require('config'); + +// files use /files/ dir +var schema = new Schema({ + + linkId: { + type: String, + default: function() { + // 6-7 random alphanumeric chars + return parseInt(crypto.randomBytes(4).toString('hex'), 16).toString(36); + }, + required: true, + unique: true + }, + + relativePath: { + type: String, + required: true + }, + + created: { + type: Date, + default: Date.now, + expires: '3d' // link must die in 3 days + } +}); + +schema.methods.getUrl = function() { + return config.server.siteHost + '/download/' + this.linkId; +}; + +module.exports = mongoose.model('ExpiringDownloadLink', schema); + diff --git a/handlers/download/router.js b/handlers/download/router.js new file mode 100755 index 000000000..c30020e7e --- /dev/null +++ b/handlers/download/router.js @@ -0,0 +1,7 @@ +var Router = require('koa-router'); + +var download = require('./controllers/download'); + +var router = module.exports = new Router(); + +router.get('/:linkId*', download.get); diff --git a/handlers/ebook/client/index.js b/handlers/ebook/client/index.js new file mode 100755 index 000000000..9f5317031 --- /dev/null +++ b/handlers/ebook/client/index.js @@ -0,0 +1,13 @@ +var OrderForm = require('./orderForm'); + +exports.init = function() { + + + var orderForm = document.querySelector('[data-order-form]'); + if (orderForm) { + new OrderForm({ + elem: orderForm + }); + } + +}; \ No newline at end of file diff --git a/handlers/ebook/client/orderForm.js b/handlers/ebook/client/orderForm.js new file mode 100755 index 000000000..d9fe56c61 --- /dev/null +++ b/handlers/ebook/client/orderForm.js @@ -0,0 +1,75 @@ +var xhr = require('client/xhr'); +var notification = require('client/notification'); +var delegate = require('client/delegate'); +var FormPayment = require('payments/common/client').FormPayment; +var Spinner = require('client/spinner'); +var Modal = require('client/head/modal'); + +class OrderForm { + + constructor(options) { + this.elem = options.elem; + + this.product = 'ebook'; + + this.elem.addEventListener('submit', (e) => this.onSubmit(e)); + + + this.delegate('[data-order-payment-change]', 'click', function(e) { + e.preventDefault(); + this.elem.querySelector('[data-order-form-step-payment]').style.display = 'block'; + this.elem.querySelector('[data-order-form-step-confirm]').style.display = 'none'; + this.elem.querySelector('[data-order-form-step-receipt]').style.display = 'none'; + }); + + this.delegate('.complex-form__extract .extract__item', 'click', function(e) { + e.delegateTarget.querySelector('[type="radio"]').checked = true; + }); + } + + + onSubmit(event) { + event.preventDefault(); + new FormPayment(this, this.elem.querySelector('.pay-method')).submit(); + } + + + // return orderData or nothing if validation failed + getOrderData() { + var orderData = { }; + + if (window.orderNumber) { + orderData.orderNumber = window.orderNumber; + } else { + var chooser = this.elem.querySelector('input[name="orderTemplate"]:checked'); + orderData.orderTemplate = chooser.value; + orderData.amount = chooser.dataset.amount; // for stats + } + + if (this.elem.elements.email) { + if (!this.elem.elements.email.value) { + window.ga('send', 'event', 'payment', 'checkout-no-email', 'ebook'); + window.metrika.reachGoal('CHECKOUT-NO-EMAIL', {product: 'ebook'}); + new notification.Error("Введите email."); + this.elem.elements.email.scrollIntoView(); + setTimeout(function() { + window.scrollBy(0, -200); + }, 0); + this.elem.elements.email.focus(); + return; + } else { + orderData.email = this.elem.elements.email.value; + } + } + + return orderData; + } + + + +} + + +delegate.delegateMixin(OrderForm.prototype); + +module.exports = OrderForm; diff --git a/handlers/ebook/controller/newOrder.js b/handlers/ebook/controller/newOrder.js new file mode 100755 index 000000000..f8ca0fc06 --- /dev/null +++ b/handlers/ebook/controller/newOrder.js @@ -0,0 +1,20 @@ +const payments = require('payments'); +var OrderTemplate = payments.OrderTemplate; + +exports.get = function*() { + this.nocache(); + + var orderTemplates = yield OrderTemplate.find({ + module: 'ebook' + }).sort({weight: 1}).exec(); + + this.locals.orderTemplates = orderTemplates; + + this.locals.sitetoolbar = true; + this.locals.title = "Покупка учебника JavaScript"; + + this.locals.paymentMethods = require('../lib/paymentMethods'); + + + this.body = this.render('new-order'); +}; diff --git a/handlers/ebook/controller/orders.js b/handlers/ebook/controller/orders.js new file mode 100755 index 000000000..453983e66 --- /dev/null +++ b/handlers/ebook/controller/orders.js @@ -0,0 +1,30 @@ +const payments = require('payments'); +var Order = payments.Order; +var getOrderInfo = payments.getOrderInfo; +var OrderTemplate = payments.OrderTemplate; +var Transaction = payments.Transaction; +var assert = require('assert'); + +// Existing order page +exports.get = function*() { + + yield* this.loadOrder({ + ensureSuccessTimeout: 10000 + }); + + this.nocache(); + + this.locals.sitetoolbar = true; + this.locals.title = 'Заказ №' + this.order.number; + + this.locals.order = this.order; + + this.locals.user = this.req.user; + + this.locals.paymentMethods = require('../lib/paymentMethods'); + + this.locals.orderInfo = yield* getOrderInfo(this.order); + + this.body = this.render('order'); + +}; diff --git a/handlers/ebook/index.js b/handlers/ebook/index.js new file mode 100755 index 000000000..3921c687c --- /dev/null +++ b/handlers/ebook/index.js @@ -0,0 +1,13 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/ebook', __dirname)); + + // anon can do anything here + app.csrfChecker.ignore.add('/ebook/:any*'); + +}; + +exports.onPaid = require('./lib/onPaid'); +exports.createOrderFromTemplate = require('./lib/createOrderFromTemplate'); diff --git a/handlers/ebook/lib/createOrderFromTemplate.js b/handlers/ebook/lib/createOrderFromTemplate.js new file mode 100755 index 000000000..131b3136f --- /dev/null +++ b/handlers/ebook/lib/createOrderFromTemplate.js @@ -0,0 +1,27 @@ +var Order = require('payments').Order; + +// middleware +// create order from template, +// use the incoming data if needed +module.exports = function* (orderTemplate, user, requestBody) { + + var order = new Order({ + title: orderTemplate.title, + description: orderTemplate.description, + amount: orderTemplate.amount, + module: orderTemplate.module, + data: orderTemplate.data + }); + + if (user) { + order.user = user._id; + order.email = user.email; + } else { + order.email = requestBody.email; + } + + yield order.persist(); + + return order; + +}; diff --git a/handlers/ebook/lib/onPaid.js b/handlers/ebook/lib/onPaid.js new file mode 100755 index 000000000..6a74ccb27 --- /dev/null +++ b/handlers/ebook/lib/onPaid.js @@ -0,0 +1,33 @@ +const Order = require('payments').Order; +const sendMail = require('mailer').send; +const ExpiringDownloadLink = require('download').ExpiringDownloadLink; +const path = require('path'); +const log = require('log')(); + +// not a middleware +// can be called from CRON +module.exports = function* (order) { + + var downloadLink = new ExpiringDownloadLink({ + relativePath: order.data.file + }); + + downloadLink.linkId += "/" + path.basename(order.data.file); + + yield downloadLink.persist(); + + yield sendMail({ + templatePath: path.join(__dirname, '..', 'templates', 'success-email'), + to: order.email, + subject: "Учебник для чтения оффлайн", + link: downloadLink.getUrl() + }); + + order.data.downloadLink = downloadLink.getUrl(); + order.markModified('data'); + order.status = Order.STATUS_SUCCESS; + + yield order.persist(); + + log.debug("Order success: " + order.number); +}; diff --git a/handlers/ebook/lib/paymentMethods.js b/handlers/ebook/lib/paymentMethods.js new file mode 100755 index 000000000..457ee629e --- /dev/null +++ b/handlers/ebook/lib/paymentMethods.js @@ -0,0 +1,11 @@ +const payments = require('payments'); + +var paymentMethods = {}; + +var methodsEnabled = ['webmoney', 'yandexmoney', 'paypal', 'payanyway']; + +methodsEnabled.forEach(function(key) { + paymentMethods[key] = payments.methods[key].info; +}); + +module.exports = paymentMethods; diff --git a/handlers/ebook/router.js b/handlers/ebook/router.js new file mode 100755 index 000000000..169cb105c --- /dev/null +++ b/handlers/ebook/router.js @@ -0,0 +1,10 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); + +var newOrder = require('./controller/newOrder'); +var orders = require('./controller/orders'); + +router.get('/', newOrder.get); +router.get('/orders/:orderNumber(\\d+)', orders.get); + diff --git a/handlers/ebook/templates/new-order.jade b/handlers/ebook/templates/new-order.jade new file mode 100644 index 000000000..8991ce8dd --- /dev/null +++ b/handlers/ebook/templates/new-order.jade @@ -0,0 +1,58 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + +block append ga + script. + ga('require', 'ec') + ga('set', '&cu', 'RUB'); + + ga('ec:addProduct', { + id: 'ebook' + }); + ga('ec:setAction', 'click'); + +block content + + script var status = "#{status}"; + + +b("form").complex-form(data-order-form) + +e.step._current + +e('h2').alternate-title Выберите курс + +b.extract._small.__extract + each orderTemplate, i in orderTemplates + +e.item(data-add-class-on-hover) + +e('input').input(type="radio" name="orderTemplate" value=orderTemplate.slug data-price=orderTemplate.amount checked=(i==0) id=('book-' + i) ) + +e.wrap + +e.input-wrap + +e.content + +e('h3').title + label(for=('book-' + i))!= orderTemplate.title + +e.info!= orderTemplate.description + +e.aside._price._center + | Стоимость + +b.price.__price + | #{orderTemplate.amount + ' RUB'} + +e('span').secondary (≈ #{currencyConvertRound(orderTemplate.amount, "RUB", "USD")}$) + + if (!user) + +e('h2').alternate-title Укажите свой email + +b.text-input.__email + +e('input').control(placeholder="my@email.com", name="email", required, type="email") + +e.email-note После оплаты ссылка на скачивание учебника придет на этот адрес. + + +e('h2').alternate-title Выберите метод оплаты + +e.body + +b.extract._small.__extract + include ../../payments/common/templates/payment-methods + + +e.submit-line + +b('button')(type="submit").button._action + +e('span').text Продолжить + + +b('ul').grayed-list + +e('li').item Подтверждение + + script(src=pack("ebook", "js")) + script ebook.init(); diff --git a/handlers/ebook/templates/order.jade b/handlers/ebook/templates/order.jade new file mode 100755 index 000000000..f0e33986e --- /dev/null +++ b/handlers/ebook/templates/order.jade @@ -0,0 +1,106 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + +block append ga + script ga('require', 'ec'); + + if orderInfo.status == 'fail' + script window.ga('ec:setAction', 'refund', { id: #{order.number} }); + + script window.ga('send', 'event', 'payment', 'return-#{orderInfo.status}', 'ebook'); + +block content + + script var orderNumber = #{order.number}; + + script window.metrika.reachGoal('ORDER', { product: 'ebook', status: '#{orderInfo.status}', number: '#{orderInfo.number}' }); + + - var mailto = "mailto:orders@javascript.ru?subject=" + encodeURIComponent('Заказ ' + order.number); + + +b('form').complex-form(data-order-form data-order-info-status=orderInfo.status) + + if orderInfo.status == 'fail' + +b.notification._error._message.__error + +e.content + p Оплата не прошла, попробуйте ещё раз. + if orderInfo.transaction && orderInfo.transaction.statusMessage + div + +e('span').cause= orderInfo.transaction.statusMessage + p По вопросам, касающимся оплаты, пишите на orders@javascript.ru. + + if !~['fail'].indexOf(orderInfo.status) + +b.receipts.__receipts(data-order-form-step-receipt) + +e.receipt + +e.receipt-body + +e.receipt-content + +e.type Заказ: + +e.title!= order.title + +e.note!= order.description + +e.receipt-aside + +e.price #{order.amount + 'р.'} + if ~['paid', 'success', 'pending'].indexOf(orderInfo.status) + +e.receipt + +e.receipt-body + +e.receipt-content + +e.type Оплата: + if (orderInfo.status == 'paid' || orderInfo.status == 'success') + +e.status._ok Осуществлена успешно + else if (orderInfo.status == 'pending') + +e.status._ok Ожидается подтверждение + +e.receipt-aside + +e(class=["pay-method", "_" + paymentMethods[orderInfo.transaction.paymentMethod].name]) + + + if ~['fail', 'pending'].indexOf(orderInfo.status) + +e.step._current(data-order-form-step-payment) + +e.step-content + +b.extract._small.__extract + +e.wrap + +e.content + +e('h5').title!= order.title + +e.info!= order.description + +e.aside._price._center + | Стоимость + +b.price.__price + | #{order.amount + ' RUB'} + +e('span').secondary (≈ #{currencyConvertRound(order.amount, "RUB", "USD")}$) + + if orderInfo.status == 'fail' + +e('h2').alternate-title Выберите метод оплаты + else if orderInfo.status == 'pending' + +e('h2').alternate-title Выберите другой метод оплаты + p Не оплачивайте дважды. Меняйте метод оплаты лишь если уверены, что оплата не произошла. + + include ../../payments/common/templates/payment-methods + + p Если у вас возникли какие-либо вопросы, присылайте их на orders@javascript.ru. + + + if orderInfo.status == 'success' + +e.step._current(data-order-form-step-confirm) + +b.order-confirm + +e('h2').title__step-title Спасибо за покупку! + +e.accent._ok В ближайшее время на электронный адрес #{order.email} придёт подтверждение. + +e.content + +e.text + p Вы можете скачать учебник прямо сейчас, по ссылке #{order.data.downloadLink}. + p Если у вас возникли какие-либо вопросы, присылайте их на orders@javascript.ru. + + else if ~['error', 'cancel', 'pending', 'paid'].indexOf(orderInfo.status) + +e.step._current(data-order-form-step-confirm) + +b.order-confirm + +e('h2').title__step-title!= orderInfo.title + if orderInfo.accent + +e.accent!= orderInfo.accent + if orderInfo.description + +e.content + +e.text!= orderInfo.description + + if ~['fail'].indexOf(orderInfo.status) + +b('ul').grayed-list.__next + +e('li').item.__next-item Подтверждение + + script(src=pack("ebook", "js")) + script ebook.init(); diff --git a/handlers/ebook/templates/success-email.jade b/handlers/ebook/templates/success-email.jade new file mode 100755 index 000000000..721670662 --- /dev/null +++ b/handlers/ebook/templates/success-email.jade @@ -0,0 +1,8 @@ +extends /layouts/email + +block body + h1 Спасибо за покупку! + p Вы можете скачать учебник по ссылке + =' ' + a(href=link) #{link} + | . diff --git a/handlers/errorHandler/index.js b/handlers/errorHandler/index.js new file mode 100755 index 000000000..323988b13 --- /dev/null +++ b/handlers/errorHandler/index.js @@ -0,0 +1,129 @@ +const config = require('config'); +const escapeHtml = require('escape-html'); +const _ = require('lodash'); +const path = require('path'); + +var isDevelopment = process.env.NODE_ENV == 'development'; + + +function renderError(err) { + /*jshint -W040 */ + + // don't pass just err, because for "stack too deep" errors it leads to logging problems + var report = { + message: err.message, + stack: err.stack, + errors: err.errors, // for validation errors + status: err.status, + referer: this.get('referer'), + cookie: this.get('cookie') + }; + if (!err.expose) { // dev error + report.requestVerbose = this.request; + } + + this.log.error(report); + + // may be error if headers are already sent! + this.set('X-Content-Type-Options', 'nosniff'); + + var preferredType = this.accepts('html', 'json'); + + if (err.name == 'ValidationError') { + this.status = 400; + + if (preferredType == 'json') { + var errors = {}; + + for (var field in err.errors) { + errors[field] = err.errors[field].message; + } + + this.body = { + errors: errors + }; + } else { + this.body = this.render("400", {error: err}); + } + + return; + } + + if (isDevelopment) { + this.status = err.status || 500; + + var stack = (err.stack || '') + .split('\n').slice(1) + .map(function(v) { + return '
  • ' + escapeHtml(v).replace(/ /g, '  ') + '
  • '; + }).join(''); + + if (preferredType == 'json') { + this.body = { + message: err.message, + stack: stack + }; + this.body.statusCode = err.statusCode || err.status; + } else { + this.type = 'text/html; charset=utf-8'; + this.body = "

    " + err.message + "

      " + stack + "
    "; + } + + return; + } + + this.status = err.expose ? err.status : 500; + + if (preferredType == 'json') { + this.body = { + message: err.message, + statusCode: err.status || err.statusCode + }; + if (err.description) { + this.body.description = err.description; + } + } else { + var templateName = ~[500, 401, 404, 403].indexOf(this.status) ? this.status : 500; + this.body = this.render(String(templateName), {error: err, requestId: this.requestId}); + } + +} + + +exports.init = function(app) { + + app.use(function*(next) { + this.renderError = renderError; + + try { + yield* next; + } catch (err) { + // this middleware is not like others, it is not endpoint + // so wrapHmvcMiddleware is of little use + this.templateDir = path.join(__dirname, 'templates'); + this.renderError(err); + delete this.templateDir; + } + }); + + // this middleware handles error BEFORE ^^^ + // rewrite mongoose wrong mongoose parameter -> 400 (not 500) + app.use(function* rewriteCastError(next) { + + try { + yield next; + } catch (err) { + + if (err.name == 'CastError') { + // malformed or absent mongoose params + if (!isDevelopment) { + this.throw(400); + } + } + + throw err; + } + + }); + +}; diff --git a/handlers/errorHandler/templates/400.jade b/handlers/errorHandler/templates/400.jade new file mode 100755 index 000000000..7ac017355 --- /dev/null +++ b/handlers/errorHandler/templates/400.jade @@ -0,0 +1,17 @@ +extends /layouts/main + +block append variables + - var headTitle = 'Некорректный запрос'; + - var sitetoolbar = true; + +block content + +b.error + +e('h1').type Некорректный запрос + +e.code 400 + if error.errors + + +e.text + dl + each value, key in error.errors + dt= key + dd= value.message diff --git a/handlers/errorHandler/templates/401.jade b/handlers/errorHandler/templates/401.jade new file mode 100755 index 000000000..a9a194117 --- /dev/null +++ b/handlers/errorHandler/templates/401.jade @@ -0,0 +1,15 @@ +extends /layouts/main + +block append variables + - var headTitle = 'Требуется авторизация'; + - var sitetoolbar = true; + +block content + +b.error + +e('h1').type Доступ к этой странице закрыт + +e.code 401 + +e.text + if error.description + != error.description + else + | Возможно, вам нужно войти в сайт? \ No newline at end of file diff --git a/handlers/errorHandler/templates/403.jade b/handlers/errorHandler/templates/403.jade new file mode 100755 index 000000000..220e558b8 --- /dev/null +++ b/handlers/errorHandler/templates/403.jade @@ -0,0 +1,15 @@ +extends /layouts/main + +block append variables + - var headTitle = 'Доступ запрещён'; + - var sitetoolbar = true; + +block content + +b.error + +e('h1').type Доступ запрещён + +e.code 403 + +e.text + if error.description + != error.description + else + | Возможно, вам нужно войти под нужным пользователем? \ No newline at end of file diff --git a/handlers/errorHandler/templates/404.jade b/handlers/errorHandler/templates/404.jade new file mode 100755 index 000000000..b284da6e8 --- /dev/null +++ b/handlers/errorHandler/templates/404.jade @@ -0,0 +1,23 @@ +extends /layouts/main + +block append variables + - var headTitle = 'Страница не найдена'; + - var sitetoolbar = true; + +block content + +b.error + +e('h1').type Страница не найдена + +e.code 404 + +e.text + p Возможно, это произошло из-за большого обновления сайта, многие материалы были обновлены и реорганизованы. + p Для того, чтобы найти нужную вам страницу, вы можете воспользоваться поиском: + +e.text + +e('form').search(action="/search") + +e.search-query-wrap + +b('span').text-input.__search-query + +e('input').control(type="text", name="query") + +e.search-submit-wrap + +b('button').button._action.__search-submit + +e('span').text Найти + +e.text + | или верхней навигацией. diff --git a/handlers/errorHandler/templates/500.jade b/handlers/errorHandler/templates/500.jade new file mode 100755 index 000000000..ded02bac5 --- /dev/null +++ b/handlers/errorHandler/templates/500.jade @@ -0,0 +1,14 @@ +extends /layouts/main + +block append variables + - var headTitle = 'Ошибка на сервере'; + - var sitetoolbar = true; + +block content + +b.error + +e('h1').type Ошибка на сервере + +e.code 500 + - var subject = encodeURIComponent("Ошибка 500 на " + url.href) + +e.text Мы анализируем и исправляем возникающие ошибки, но вы можете ускорить этот процесс, сообщив подробности на mk@javascript.ru. + +e.text + +e('span').request RequestId: #{requestId} diff --git a/handlers/flash/index.js b/handlers/flash/index.js new file mode 100755 index 000000000..47ab92b66 --- /dev/null +++ b/handlers/flash/index.js @@ -0,0 +1,62 @@ + +exports.init = function(app) { + // koa-flash is broken + // reading from one object, writing to another object + // occasionally writing to default + app.use(function *flash(next) { + this.flash = this.session.flash || {}; + + this.session.flash = {}; + + Object.defineProperty(this, 'newFlash', { + get: function() { + return this.session.flash; + }, + set: function(val) { + this.session.flash = val; + } + }); + + yield *next; + + // now this.session can be null + // (logout does that) + + if (this.session && Object.keys(this.session.flash).length === 0) { + // don't write empty flash + delete this.session.flash; + } + + if (this.status == 302 && this.session && !this.session.flash) { + // pass on the flash over a redirect + this.session.flash = this.flash; + } + }); + + app.use(function*(next) { + + var notificationTypes = ["error", "warning", "info", "success"]; + + // by default koa-flash uses same defaultValue object for all flashes, + // this.flash.message writes to defaultValue! + + this.addFlashMessage = function(type, html) { + // split this.flash from defaultValue (fix bug in koa-flash!) + if (!this.newFlash.messages) { + this.newFlash.messages = []; + } + + if (!~notificationTypes.indexOf(type)) { + throw new Error("Unknown flash type: " + type); + } + + this.newFlash.messages.push({ + type: type, + html: html + }); + }; + + yield* next; + + }); +}; diff --git a/handlers/jb/controllers/index.js b/handlers/jb/controllers/index.js new file mode 100755 index 000000000..2033961a3 --- /dev/null +++ b/handlers/jb/controllers/index.js @@ -0,0 +1,81 @@ +var JbRequest = require('../models/jbRequest'); +var sendMail = require('mailer').send; +var path = require('path'); +var config = require('config'); + +var products = { + "WebStorm": "WebStorm (JavaScript/HTML/CSS)", + "PhpStorm": "PhpStorm (PHP)", + "RubyMine": "RubyMine (Ruby)", + "IntelliJ IDEA": "IntelliJ IDEA Ultimate (Java)", + "ReSharper": "ReSharper (C#)", + "PyCharm": "PyCharm (Python)", + "AppCode": "AppCode (OSX, Objective-C, C/C++)", + "CLion": "CLion (C/C++)" +}; + + +exports.get = function*() { + this.locals.products = products; + this.body = this.render('index'); +}; + +exports.post = function*() { + this.locals.products = products; + + var fewDaysAgo = new Date(); + fewDaysAgo.setDate( fewDaysAgo.getDate() - 3 ); + + var form = this.locals.form = { + email: this.request.body.email, + name: this.request.body.name, + product: this.request.body.product + }; + + var existingRequest = yield JbRequest.findOne({ + email: this.request.body.email, + product: this.request.body.product, + name: this.request.body.name, + created: { + $gt: fewDaysAgo + } + }).exec(); + + if (existingRequest) { + this.locals.error = ` +

    Вы уже делали запрос на этот продукт с этими данными менее чем 3 дня назад.

    +

    Если вы не получили ответа, напишите на orders@javascript.ru.

    `; + this.body = this.render('index'); + return; + } + + var jbRequest = new JbRequest({ + email: this.request.body.email, + product: this.request.body.product, + name: this.request.body.name + }); + + try { + + yield sendMail({ + templatePath: path.join(this.templateDir, 'mail'), + to: 'iliakan@gmail.com',//config.jb.email, + subject: "Запрос лицензии", + form: form + }); + + yield jbRequest.persist(); + + this.body = this.render('success'); + return; + } catch(e) { + if (e.name != 'ValidationError') throw e; + var errors = this.locals.errors = {}; + for(var key in e.errors) { + errors[key] = e.errors[key].message; + } + this.body = this.render('index'); + return; + } + +}; diff --git a/handlers/jb/index.js b/handlers/jb/index.js new file mode 100755 index 000000000..53e1523ce --- /dev/null +++ b/handlers/jb/index.js @@ -0,0 +1,7 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/jb', __dirname)); +}; + diff --git a/handlers/jb/models/jbGoStat.js b/handlers/jb/models/jbGoStat.js new file mode 100755 index 000000000..23f4daab6 --- /dev/null +++ b/handlers/jb/models/jbGoStat.js @@ -0,0 +1,21 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const schema = new Schema({ + ip: { + type: String + }, + referer: { + type: String + }, + cookie: { + type: String + }, + + created: { + type: Date, + default: Date.now + } +}); + +module.exports = mongoose.model('JbGoStat', schema); diff --git a/handlers/jb/models/jbRequest.js b/handlers/jb/models/jbRequest.js new file mode 100755 index 000000000..81aa7bcca --- /dev/null +++ b/handlers/jb/models/jbRequest.js @@ -0,0 +1,35 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const schema = new Schema({ + product: { + type: String, + enum: { + values: ["WebStorm", "PhpStorm", "RubyMine", "IntelliJ IDEA", "ReSharper", "PyCharm", "AppCode", "CLion"], + message: 'Такой продукт недоступен' + }, + required: 'Укажите продукт' + }, + email: { + type: String, + required: 'Укажите email' + }, + name: { + type: String, + validate: [ + { + validator: function(value) { + return /^\s*[a-z]+\s*[a-z]+\s*$/i.test(value); + }, + msg: 'Имя и фамилия должны быть на английском, например: ILYA KANTOR' + } + ], + required: 'Укажите имя и фамилию' + }, + created: { + type: Date, + default: Date.now + } +}); + +module.exports = mongoose.model('JbRequest', schema); \ No newline at end of file diff --git a/handlers/jb/router.js b/handlers/jb/router.js new file mode 100755 index 000000000..1b8788ec1 --- /dev/null +++ b/handlers/jb/router.js @@ -0,0 +1,21 @@ +var Router = require('koa-router'); + +var index = require('./controllers/index'); + +var router = module.exports = new Router(); + +router.get('/', index.get); +router.post('/', index.post); + +var JbGoStat = require('./models/jbGoStat'); +router.get('/go', function*() { + + yield JbGoStat.create({ + ip: this.request.ip, + referer: this.get('referer'), + cookie: this.get('cookie') + }); + + this.status = 301; + this.redirect('https://www.jetbrains.com/webstorm/?utm_source=javascript.ru&utm_medium=banner2&utm_campaign=webstorm'); +}); diff --git a/handlers/jb/templates/index.jade b/handlers/jb/templates/index.jade new file mode 100755 index 000000000..8abc97ff8 --- /dev/null +++ b/handlers/jb/templates/index.jade @@ -0,0 +1,60 @@ + +extends /layouts/main + +block append variables + - var sitetoolbar = true + - var title = "Jetbrains для участников курса" + - var layout_main_class = "main_width-limit" + - var layout_header_class = "main__header_center" + + - var content_class = '_center' + + +block content + + if error + +b.notification._message._error + +e.content!= error + +e('button').close(title="Закрыть") + + + p Участники курсов имеют 30% скидку на персональную лицензию Jetbrains. + + +b("form").jetbrains-form(method="POST" action="/jb" name="jb") + input(type="hidden", name="_csrf", value=csrf()) + + p.note + | Если вам нужен только JavaScript/HTML/CSS – выбирайте WebStorm. + br + | Другие редакторы в дополнение к JS/HTML/CSS поддерживают язык, указанный в списке. + +e.form + +e.line + +e("label").label(for="name") Имя и фамилия (англ.): + +b("span")(class=['text-input', errors && errors.name && '_invalid']) + +e("input").control(type="text", pattern="^\\s*[a-zA-Z ]+\\s+[a-zA-Z ]+\\s*$", required, name="name", id="name", value=form && form.name, placeholder="John Smith" autofocus) + + +e.line + +e("label").label(for="email") Email: + +b("span")(class=['text-input', errors && errors.email && '_invalid']) + +e("input").control(type="email", name="email", id="email", required, value=form && form.email, placeholder="my@mail.com") + + +e.line + +e("label").label(for="product") Редактор: + +b('select').input-select(name="product" id="product") + each title, value in products + +e('option')(value=value selected=(form && form.product == value))= title + + +e.line + +b("button").button._action(type="submit") + +e("span").text Отправить + + script. + document.forms.jb.onsubmit = function() { + return confirm('Проверьте запрос на лицензию:' + + '\nРедактор: ' + this.elements.product.value + + '\nEmail: ' + this.elements.email.value + + '\nИмя: ' + this.elements.name.value + + '\nВсё верно, отправлять?'); + }; + + diff --git a/handlers/jb/templates/mail.jade b/handlers/jb/templates/mail.jade new file mode 100755 index 000000000..6f3b63413 --- /dev/null +++ b/handlers/jb/templates/mail.jade @@ -0,0 +1,9 @@ +extends /layouts/email + +block body + h2 Запрос скидки от javascript.ru + + p Продукт: #{form.product} + p Email: #{form.email} + p Имя: #{form.name} + diff --git a/handlers/jb/templates/success.jade b/handlers/jb/templates/success.jade new file mode 100755 index 000000000..0a9a43d1e --- /dev/null +++ b/handlers/jb/templates/success.jade @@ -0,0 +1,15 @@ + +extends /layouts/main + +block append variables + - var sitetoolbar = true + - var title = "Запрос отправлен!" + - var layout_main_class = "main_width-limit" + +block content + + +b.notification._message._success + +e.content + p Ссылка на покупку со скидкой придёт на указанный вами email. + p По вопросам пишите orders@javascript.ru. + diff --git a/handlers/lastActivity.js b/handlers/lastActivity.js new file mode 100755 index 000000000..b19166b53 --- /dev/null +++ b/handlers/lastActivity.js @@ -0,0 +1,17 @@ +exports.init = function(app) { + + app.use(function* saveLastUserActivityOncePerMinute(next) { + + var minuteAgo = new Date(); + minuteAgo.setMinutes(minuteAgo.getMinutes() - 1); + + if (this.user && (!this.user.lastActivity || this.user.lastActivity < minuteAgo)) { + this.user.lastActivity = new Date(); + yield this.user.persist(); + } + + yield* next; + }); + +}; + diff --git a/handlers/mailer/controllers/webhook.js b/handlers/mailer/controllers/webhook.js new file mode 100755 index 000000000..668a238dc --- /dev/null +++ b/handlers/mailer/controllers/webhook.js @@ -0,0 +1,49 @@ +var path = require('path'); +var MandrillEvent = require('../models/mandrillEvent'); +var config = require('config'); +var crypto = require('crypto'); +var capitalizeKeys = require('lib/capitalizeKeys'); + +exports.post = function*() { + + var signature = this.get('X-Mandrill-Signature'); + + if (generateMandrillSignature(this.request.body) != signature) { + this.throw(401, "Wrong signature"); + } + + var mandrillEvents; + try { + mandrillEvents = JSON.parse(this.request.body.mandrill_events); + } catch(e) {} + + if (!Array.isArray(mandrillEvents)) { + this.throw(400); + } + + mandrillEvents = capitalizeKeys(mandrillEvents); + + for (var i = 0; i < mandrillEvents.length; i++) { + var event = mandrillEvents[i]; + yield MandrillEvent.create({payload: event}); + } + + this.body = ''; +}; + + +function generateMandrillSignature(body) { + var signedData = config.mailer.mandrill.webhookUrl; + + var keys = Object.keys(body); + + keys.sort(); + + for (var i = 0; i < keys.length; i++) { + signedData += keys[i] + body[keys[i]]; + } + + return crypto.createHmac('sha1', config.mailer.mandrill.webhookKey).update(signedData, 'utf8', 'binary').digest('base64'); +} + + diff --git a/handlers/mailer/index.js b/handlers/mailer/index.js new file mode 100755 index 000000000..bb4adf153 --- /dev/null +++ b/handlers/mailer/index.js @@ -0,0 +1,123 @@ +var inlineCss = require('./inlineCss'); +var config = require('config'); +var fs = require('fs'); +var path = require('path'); +var _ = require('lodash'); +var jade = require('lib/serverJade'); +var mandrill = require('./mandrill'); +var logoBase64 = fs.readFileSync(path.join(config.projectRoot, 'assets/img/logo.png')).toString('base64'); +var log = require('log')(); +var Letter = require('./models/letter'); +var capitalizeKeys = require('lib/capitalizeKeys'); + +// some clients don't allow svg +// var logoSrc = yield fs.readFile(path.join(config.projectRoot, 'assets/img/logo.svg')); + +// not middleware, cause can be used in CRON-based runs, from onPaid callback +// mail can be sent outside of request context + +/** + * create & save a letter object + * we save it to db to track delivery status + * + * Doesn't send the letter + * Can use to send it letter + * @param options + * @returns {Letter} + */ +function* createLetter(options) { + var message = {}; + + var sender = config.mailer.senders[options.from || 'default']; + if (!sender) { + throw new Error("Unknown sender:" + options.from); + } + + var locals = Object.create(options); + _.assign(locals, config.jade); + + locals.logoBase64 = logoBase64; + locals.signature = sender.signature; + + var templatePath = options.templatePath; + if (!templatePath.endsWith('.jade')) templatePath += '.jade'; + + var letterHtml = jade.renderFile(templatePath, locals); + letterHtml = yield inlineCss(letterHtml); + + message.html = letterHtml; + message.subject = options.subject; + message.from_email = sender.fromEmail; + message.from_name = sender.fromName; + + message.to = (typeof options.to == 'string') ? [{email: options.to}] : options.to; + message.headers = options.headers; + + // auto generate text by default (spamassassin wants that) + message.auto_text = "auto_text" in options ? options.auto_text : true; + + message.track_opens = options.track_opens; + message.track_clicks = options.track_clicks; + + var letter = new Letter({ + message: message, + newsletterRelease: options.newsletterRelease + }); + + yield letter.persist(); + + return letter; +} + +/** + * A shortcut to send a letter + * E.g send({to: ..., subject: ..., templatePath: ... }) + * @param options + * @returns {*} + */ +function* send(options) { + + var letter = yield* createLetter(options); + + return yield* sendLetter(letter); +} + +/** + * Send an existing letter + * @param letter + * @returns {*} + */ +function* sendLetter(letter) { + + if (process.env.NODE_ENV == 'test' || process.env.MAILER_DISABLED) { + letter.transportResponse = []; + } else { + letter.transportResponse = yield mandrill.messages.send({ + message: letter.message + }); + + letter.transportResponse = capitalizeKeys(letter.transportResponse); + } + + letter.sent = true; + + log.debug("sent ", letter.toObject()); + + yield letter.persist(); + + return letter; +} + + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.verboseLogger.logPaths.add('/mailer/:any*'); + app.use(mountHandlerMiddleware('/mailer', __dirname)); +}; + + +exports.Letter = require('./models/letter'); +exports.send = send; +exports.createLetter = createLetter; +exports.sendLetter = sendLetter; diff --git a/handlers/mailer/inlineCss.js b/handlers/mailer/inlineCss.js new file mode 100755 index 000000000..56b7b529c --- /dev/null +++ b/handlers/mailer/inlineCss.js @@ -0,0 +1,15 @@ +var fs = require('fs'); + +// juice does not work w/ node 0.11 + +var Styliner = require('styliner'); + +var path = require('path'); + +module.exports = function*(html) { + + var styliner = new Styliner('.', { compact: false }); + var result = yield styliner.processHTML(html, '.'); + + return result; +}; diff --git a/handlers/mailer/mandrill.js b/handlers/mailer/mandrill.js new file mode 100755 index 000000000..518da475b --- /dev/null +++ b/handlers/mailer/mandrill.js @@ -0,0 +1,35 @@ +var config = require('config'); +var mandrill = require('mandrill-api/mandrill'); + +var mandrillClient = new mandrill.Mandrill(config.mailer.mandrill.apiKey); + +//console.log(require('util').inspect(mandrillClient)); + +for(var key in mandrillClient) { + if (mandrillClient[key].master) { + promisifyMandrillApi(mandrillClient[key]); + } +} + +function promisifyMandrillApi(api) { + for(var key in Object.getPrototypeOf(api)) { + var value = api[key]; + + if (typeof value != 'function') { + return; + } + + promisifyMandrillApiMethod(api, key); + } +} + +function promisifyMandrillApiMethod(api, methodName) { + var prev = api[methodName]; + api[methodName] = function(opts) { + return new Promise(function(resolve, reject) { + prev.call(api, opts, resolve, reject); + }); + }; +} + +module.exports = mandrillClient; \ No newline at end of file diff --git a/handlers/mailer/models/letter.js b/handlers/mailer/models/letter.js new file mode 100755 index 000000000..0cbc844f2 --- /dev/null +++ b/handlers/mailer/models/letter.js @@ -0,0 +1,34 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const schema = new Schema({ + sent: { + type: Boolean, + required: true, + default: false + }, + created: { + type: Date, + default: Date.now + }, + + newsletterRelease: { + type: Schema.Types.ObjectId + }, + + message: {}, + // Transport responds with that + transportResponse: {}, + // SQS notifies of that + notification: { + type: { + delivery: {}, + bounce: {}, + complaint: {} + } + } +}); + +schema.index({ 'message.to': 1 }); +schema.index({ 'info.messageId': 1 }); +var Letter = module.exports = mongoose.model('Letter', schema); diff --git a/handlers/mailer/models/mandrillEvent.js b/handlers/mailer/models/mandrillEvent.js new file mode 100755 index 000000000..8d355cf2f --- /dev/null +++ b/handlers/mailer/models/mandrillEvent.js @@ -0,0 +1,15 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const schema = new Schema({ + created: { + type: Date, + default: Date.now + }, + + // freeform + // so that any changes in the schema will not affect the store + payload: {} +}); + +module.exports = mongoose.model('MandrillEvent', schema); \ No newline at end of file diff --git a/handlers/mailer/router.js b/handlers/mailer/router.js new file mode 100755 index 000000000..f8322e944 --- /dev/null +++ b/handlers/mailer/router.js @@ -0,0 +1,7 @@ +var Router = require('koa-router'); + +var webhook = require('./controllers/webhook'); + +var router = module.exports = new Router(); + +router.post('/webhook', webhook.post); diff --git a/handlers/markup/controller/markup.js b/handlers/markup/controller/markup.js new file mode 100755 index 000000000..82660e933 --- /dev/null +++ b/handlers/markup/controller/markup.js @@ -0,0 +1,16 @@ +var join = require('path').join; +var fs = require('fs'); +var path = require('path'); + +exports.get = function *get(next) { + var templatePath = this.params.path; + + var fullPath = path.join(this.templateDir, templatePath) + '.jade'; + + if (!fs.existsSync(fullPath)) { + this.throw(404); + } + + this.body = this.render(templatePath); +}; + diff --git a/handlers/markup/index.js b/handlers/markup/index.js new file mode 100755 index 000000000..3a5944c8b --- /dev/null +++ b/handlers/markup/index.js @@ -0,0 +1,7 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use( mountHandlerMiddleware('/markup', __dirname) ); +}; + diff --git a/handlers/markup/router.js b/handlers/markup/router.js new file mode 100755 index 000000000..243ae7a74 --- /dev/null +++ b/handlers/markup/router.js @@ -0,0 +1,8 @@ +var Router = require('koa-router'); + +var markup = require('./controller/markup'); + +var router = module.exports = new Router(); + +router.get("/:path*", markup.get); + diff --git a/handlers/markup/templates/blocks/article-foot.jade b/handlers/markup/templates/blocks/article-foot.jade new file mode 100755 index 000000000..7e49b8ee6 --- /dev/null +++ b/handlers/markup/templates/blocks/article-foot.jade @@ -0,0 +1,5 @@ +footer.main__footer + time.main__footer-date(datetime="2010-12-03") 12.03.2010 + span.main__footer-author + a(href="http://ikantor.moikrug.ru") Илья Кантор + span.main__footer-star diff --git a/handlers/markup/templates/blocks/balance-single.jade b/handlers/markup/templates/blocks/balance-single.jade new file mode 100755 index 000000000..ae41b7bbc --- /dev/null +++ b/handlers/markup/templates/blocks/balance-single.jade @@ -0,0 +1,21 @@ ++b.balance._single + +e.pluses + +e.content + +e('ul').list + +e('li').list-item + p Полная интеграция с HTML/CSS. + +e('li').list-item + p Простые вещи делаются просто. + +e('li').list-item + p Поддерживается всеми распространенными браузерами и включен по умолчанию. + ++b.balance._single + +e.minuses + +e.content + +e('ul').list + +e('li').list-item + p Полная интеграция с HTML/CSS. + +e('li').list-item + p Простые вещи делаются просто. + +e('li').list-item + p Поддерживается всеми распространенными браузерами и включен по умолчанию. diff --git a/handlers/markup/templates/blocks/balance.jade b/handlers/markup/templates/blocks/balance.jade new file mode 100755 index 000000000..59ea984aa --- /dev/null +++ b/handlers/markup/templates/blocks/balance.jade @@ -0,0 +1,21 @@ ++b.balance + +e.pluses + +e.content + +e('h3').title Достоинства + +e('ul').list + +e('li').list-item + p Полная интеграция с HTML/CSS. + +e('li').list-item + p Простые вещи делаются просто. + +e('li').list-item + p Поддерживается всеми распространенными браузерами и включен по умолчанию. + +e.minuses + +e.content + +e('h3').title Недостатки + +e('ul').list + +e('li').list-item + p Полная интеграция с HTML/CSS. + +e('li').list-item + p Простые вещи делаются просто. + +e('li').list-item + p Поддерживается всеми распространенными браузерами и включен по умолчанию. \ No newline at end of file diff --git a/handlers/markup/templates/blocks/breadcrumbs.jade b/handlers/markup/templates/blocks/breadcrumbs.jade new file mode 100755 index 000000000..f7f6631cf --- /dev/null +++ b/handlers/markup/templates/blocks/breadcrumbs.jade @@ -0,0 +1,11 @@ ++b('ol').breadcrumbs + +e('li').item._home + +e('a').link(href='/') + //- добавляем элемент hidden-text, чтобы при отключенных стилях ссылка была доступна + +e('span').hidden-text Главная + +e('li').item + +e('a').link(href='/tutorial') Учебник + +e('li').item + +e('a').link(href='/getting-started') Язык JavaScript + +e('li').item + | основы JavaScript \ No newline at end of file diff --git a/handlers/markup/templates/blocks/code-tabs-code.html b/handlers/markup/templates/blocks/code-tabs-code.html new file mode 100755 index 000000000..26d30d193 --- /dev/null +++ b/handlers/markup/templates/blocks/code-tabs-code.html @@ -0,0 +1,49 @@ +
    <!DOCTYPE HTML>
    +<html>
    +  <head>
    +    <meta charset="utf-8">
    +    <link rel="stylesheet" href="style.css">
    +  </head>
    +  <body>
    +  
    +        
    +    <table id="table">
    +      <tr>
    +        <th colspan="3"><em>Bagua</em> Chart: Direction, Element, Color, Meaning</th>
    +      </tr>
    +      <tr>
    +        <td class="nw"><strong>Northwest</strong><br>Metal<br>Silver<br>Elders
    +        </td>
    +        <td class="n"><strong>North</strong><br>Water<br>Blue<br>Change
    +        </td>
    +        <td class="ne"><strong>Northeast</strong><br>Earth<br>Yellow<br>Direction
    +        </td>
    +      </tr>
    +      <tr>  
    +          <td class="w"><strong>West</strong><br>Metal<br>Gold<br>Youth
    +        </td>
    +        <td class="c"><strong>Center</strong><br>All<br>Purple<br>Harmony
    +        </td>
    +        <td class="e"><strong>East</strong><br>Wood<br>Blue<br>Future
    +        </td>
    +      </tr>
    +      <tr>  
    +          <td class="sw"><strong>Southwest</strong><br>Earth<br>Brown<br>Tranquility
    +        </td>
    +        <td class="s"><strong>South</strong><br>Fire<br>Orange<br>Fame
    +        </td>
    +        <td class="se"><strong>Southeast</strong><br>Wood<br>Green<br>Romance
    +        </td>
    +      </tr>
    +      
    +    </table>
    +
    +    <textarea id="text"></textarea>
    +
    +    <input type="button" onclick="text.value=''" value="Очистить">
    +
    +        
    +    <script src="script.js"></script>
    +    
    +  </body>
    +</html>
    \ No newline at end of file diff --git a/handlers/markup/templates/blocks/code-tabs.jade b/handlers/markup/templates/blocks/code-tabs.jade new file mode 100755 index 000000000..ce29fea8f --- /dev/null +++ b/handlers/markup/templates/blocks/code-tabs.jade @@ -0,0 +1,69 @@ ++b.code-tabs._scroll._result_on + +e.tools + +e.scroll-wrap + +e('button').scroll-button._left(disabled="disabled") + +e.switches-wrap + +e.switches + +e.switches-items + +e.switch._current Результат + +e.switch index.html + +e.switch style.css + +e.switch example.js + +e.switch index.html + +e.switch style.css + +e.switch example.js + +e.scroll-wrap + +e('button').scroll-button._right + +e.buttons + +e('a').button._download(target="_blank", href="file.zip") + +e('a').button._open(target="_blank", href="/play/file") + +e.content(style="height: 250px;") + +e.section._current + +e('iframe').result(src="http://lipsum.com/") + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html + ++b.code-tabs._scroll + +e.tools + +e.scroll-wrap + +e('button').scroll-button._left(disabled="disabled") + +e.switches-wrap + +e.switches + +e.switches-items + +e.switch Результат + +e.switch._current index.html + +e.switch style.css + +e.switch example.js + +e.switch index.html + +e.switch style.css + +e.switch example.js + +e.scroll-wrap + +e('button').scroll-button._right + +e.buttons + +e('a').button._download(target="_blank", href="file.zip") + +e('a').button._open(target="_blank", href="/play/file") + +e.content(style="height: 350px;") + +e.section + +e('iframe').result(src="http://lipsum.com/") + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html + +e.section._current + include code-tabs-code.html + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html + +e.section + include code-tabs-code.html diff --git a/handlers/markup/templates/blocks/comments.jade b/handlers/markup/templates/blocks/comments.jade new file mode 100755 index 000000000..613799d41 --- /dev/null +++ b/handlers/markup/templates/blocks/comments.jade @@ -0,0 +1,30 @@ +.comments#comments + .comments__header + h2.comments__header-title + | Комментарии + span.comments__header-number 5 + a.comments__header-write(href="#write-comment") Написать + ul + li Приветствуются комментарии, содержащие дополнения и вопросы по статье, и ответы на них. + li Если ваш комментарий касается задачи — откройте её в отдельном окне и напишите там. + li + | Для кода внутри строки используйте тег + <code> + | , для блока кода — тег + <pre> + | , если больше 10 строк — ссылку на + песочницу + | . + li Если что-то непонятно — пишите, что именно и с какого места. +//-
    +//-
    +//-

    Комментарии 5

    +//- Написать +//-
      +//-
    • Приветствуются комментарии, содержащие дополнения и вопросы по статье, и ответы на них.
    • +//-
    • Если ваш комментарий касается задачи — откройте её в отдельном окне и напишите там.
    • +//-
    • Для кода внутри строки используйте тег <code>, для блока кода — тег <pre>, если больше 10 строк — ссылку на песочницу.
    • +//-
    • Если что-то непонятно — пишите, что именно и с какого места.
    • +//-
    +//-
    +//-
    \ No newline at end of file diff --git a/handlers/markup/templates/blocks/corrector.jade b/handlers/markup/templates/blocks/corrector.jade new file mode 100755 index 000000000..2d5143b0f --- /dev/null +++ b/handlers/markup/templates/blocks/corrector.jade @@ -0,0 +1,2 @@ +.corrector + | Нашли опечатку на сайте? Что-то кажется странным? Выделите соответствующий текст и нажмите Ctrl+Enter \ No newline at end of file diff --git a/handlers/markup/templates/blocks/courses-faq.jade b/handlers/markup/templates/blocks/courses-faq.jade new file mode 100644 index 000000000..30e1aa27e --- /dev/null +++ b/handlers/markup/templates/blocks/courses-faq.jade @@ -0,0 +1,18 @@ +- var questions = []; +- questions.push({ title: 'А это все правда? Действительно ли курсы такие хорошие?', answer: ['

    Вам решать.

    Здесь нет курсов по HTML/CSS/PHP/Photoshop и прочему разному.

    Я провожу курсы только по JavaScript. И стараюсь делать это настолько хорошо, насколько это возможно. Посмотрите эту страницу, внимательно остановитесь на программе и способе обучения, подумайте, подходит ли это вам.

    '] }); +- questions.push({ title: 'Какие есть способы оплаты? Можно ли от организации?', answer: ['

    Вам решать.

    Здесь нет курсов по HTML/CSS/PHP/Photoshop и прочему разному.

    Я провожу курсы только по JavaScript. И стараюсь делать это настолько хорошо, насколько это возможно. Посмотрите эту страницу, внимательно остановитесь на программе и способе обучения, подумайте, подходит ли это вам.

    '] }); + ++b.courses-faq.courses-mix + +e('h2').title Часто задаваемые вопросы + +e.body + + +e('ul').questions + for question, index in questions + +e('li').question + +e('input').input(type="checkbox" id= 'q' + index) + +e('label').question-title(for= 'q' + index) !{ question.title } + +e.answer !{ question.answer } + + p У вас другой вопрос? Напишите его в комментариях внизу этой страницы. Если он может быть полезен другим участникам — я его оставлю, если нет — отвечу и через месяц после своего ответа удалю. + p Для быстрой связи можно также писать мне на email: mk@javascript.ru (проверяется регулярно), а если совсем срочно — звонить по телефону +7-903-5419441. + diff --git a/handlers/markup/templates/blocks/courses-features.jade b/handlers/markup/templates/blocks/courses-features.jade new file mode 100644 index 000000000..0fc68cc54 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-features.jade @@ -0,0 +1,16 @@ +- var features = []; + +- features.push({ name: 'quality', title: 'Качество', text: 'Это самое главное. Мы изучаем разработку на профессиональном уровне' }); +- features.push({ name: 'online', title: 'Дистанционность', text: 'На практике это оказывается удобнее, чем очные курсы' }); +- features.push({ name: 'support', title: 'Поддержка', text: 'Вы получите советы по развитию именно для вас' }); +- features.push({ name: 'result', title: 'Результат', text: 'Цель курсов - получить конкретные результаты в плане знаний и умений' }); +- features.push({ name: 'guarantees', title: 'Гарантии', text: 'Отзывы выпускников говорят сами за себя' }); + ++b.courses-features + +e('h2').title Особенности курсов + +e('ul').features + for feature in features + +e('li')(class=['feature', '_' + feature.name]) + +e('h3').feature-title !{ feature.title } + +e('p').feature-text !{ feature.text } + diff --git a/handlers/markup/templates/blocks/courses-guarantee.jade b/handlers/markup/templates/blocks/courses-guarantee.jade new file mode 100644 index 000000000..40a1def66 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-guarantee.jade @@ -0,0 +1,18 @@ ++b.courses-guarantee.courses-mix + h2 Гарантии + + p Всем участникам курсов, независимо от пола, возраста, ориентации и религиозной принадлежности… + + p(style="color: #b90000") Если объяснения будут вам непонятны + + ul + li + strong Если объяснения будут вам непонятны + li + strong Если курсы не дадут вам новых знаний и умений + li + strong Если вы не сможете подключиться к системе онлайн-обучения + + p(style="color: #b90000") … то вы сможете получить деньги назад. + + p Для этого достаточно не позже окончания первой недели курса написать мне, указать причину из этого списка и что именно вас не устраивает, удостоверить свою личность, чтобы возврат не потребовал хакер, и тогда ваше участие будет прекращено, а вы получите ваши деньги обратно, удобным для вас способом. diff --git a/handlers/markup/templates/blocks/courses-how.jade b/handlers/markup/templates/blocks/courses-how.jade new file mode 100644 index 000000000..c047f226e --- /dev/null +++ b/handlers/markup/templates/blocks/courses-how.jade @@ -0,0 +1,21 @@ ++b.courses-how.courses-mix + +e('h2').title Как проходит обучение? + + +e.body + p Время обучения: 2 месяца, включая одну неделю каникул с самостоятельно выполняемым заданием, плюс видеокурс за неделю до начала занятий. + + p За это время мы планируем освоить очень многое. + + p Это подразумевает не ленивое ковыряние в носу во время лекции, а довольно-таки активный режим обучения. + + ol + li До начала курса вы получаете вводный видео-курс. + p К основному курсу необходимо с ним ознакомиться. Там раскрыты самые базовые темы, которые можно дать в таком формате. Это введение нужно, чтобы мы на занятиях не разбирали ну уж совсем простые темы (но вы сможете задавать вопросы по ним, если будут, в том числе и до начала курса). + + li Далее, к каждому занятию выдаются материалы для освоения и задачи. Если это текст - читаете, если видео - смотрите в удобное для вас время. Делаете задачи. + li Мы встречаемся два раза в неделю онлайн, я рассказываю важные и тонкие моменты, на которые следует обратить внимание в материале (простые вы изучили по лекциям дома), вы задаете вопросы, показываете решения. Мы смотрим, как можно сделать лучше. Продолжительность 1.5 часа, может быть меньше или больше, в зависимости от темы и количества вопросов. + + p + strong Резюмирую: будьте готовы к тому, что придётся учиться и делать реальные задачи, многие из которых не так уж просты. + + diff --git a/handlers/markup/templates/blocks/courses-master.jade b/handlers/markup/templates/blocks/courses-master.jade new file mode 100644 index 000000000..173198f77 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-master.jade @@ -0,0 +1,8 @@ ++b.courses-master + +e('h2').title Ведущий + +e('p').text Веду курсы я сам, Илья Кантор, создатель этого сайта, frontend-разработчик с большим стажем, вот немного обо мне. + +e('p').text Начиная с 2007 года вёл мастер-классы для опытных разработчиков, в которых участвовали, в том числе, сотрудники ведущих IT-компаний России и Украины. Информацию о них вы можете найти здесь. + +e('p').text С января 2011 года открыты эти курсы. + + + diff --git a/handlers/markup/templates/blocks/courses-materials.jade b/handlers/markup/templates/blocks/courses-materials.jade new file mode 100644 index 000000000..a0ecfe422 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-materials.jade @@ -0,0 +1,21 @@ +- var data = []; +- data.push({ url: '/123', name: 'Вводный курс JS', size: '345 Mb', date: moment(new Date()).locale('ru').format('DD MMM YYYY') }); +- data.push({ url: '/123', name: 'Материалы, 2013 08 22 2130', size: '1.3 Gb', date: moment(new Date()).locale('ru').format('DD MMM YYYY') }); +- data.push({ url: '/123', name: 'Материалы, 2013 09 22 2130', size: '2 Gb', date: moment(new Date()).locale('ru').format('DD MMM YYYY') }); +- data.push({ url: '/123', name: 'Материалы, 2013 09 26 2130', size: '2 Gb', date: moment(new Date()).locale('ru').format('DD MMM YYYY') }); + ++b.courses-materials + +e('table').table + +e('tr').line + +e('th').num # + +e('th').name Название + +e('th').size Размер + +e('th').added Добавлено + for material in data + +e('tr').line + +e('td').num + +e('td').name + +e('a').link(href=material.url) !{ material.name } + +e('td').size !{ material.size } + +e('td').added !{ material.date } + diff --git a/handlers/markup/templates/blocks/courses-parts.jade b/handlers/markup/templates/blocks/courses-parts.jade new file mode 100644 index 000000000..17340f06f --- /dev/null +++ b/handlers/markup/templates/blocks/courses-parts.jade @@ -0,0 +1,78 @@ ++b.courses-parts.courses-mix + +e('h2').title Основные темы программы + + +b.tabbed-pane._01 + + +e('ul').tabs + +e('li').tab._01 Первая часть курса + +e('li').tab._02 Вторая часть курса + +e('li').tab._03 Третья часть курса + + +e.body._01 + ol + li + strong Основной JavaScript. + p Здесь мы изучим сам язык, его конструкции и особенности, которые позволяют "разговаривать" на JavaScript коротко, понятно, а главное - без ошибок. + ul + li IDE, настройка, полезные приёмы использования, средства для автопроверки кода. + li Основные структуры данных, работа с числами, строками, датами, массивами, объектами. + li Инструменты разработки, отладка в браузерах. + li Автоматизированное тестирование, инструменты и их применение. + li + strong Более глубокое понимание языка. + p Чтобы писать хороший код, а также грамотно пользоваться современными фреймворками, мы изучим JavaScript лучше, включая тонкости и продвинутое применение языковых конструкций. + ul + li Замыкания и их грамотное применение. + li Внутреннее устройство движка JavaScript. + li Контекст this в деталях. + li Форвардинг, одалживание и делегирование функций. + li Прототипы, классы, прототипное и функциональное ООП, детали использования. + + p По окончанию первой части курса вы свободно пользуетесь языком JavaScript, с учётом его особенностей. Мы улучшим эти навыки в последующих частях курса. + + +e.body._02 + ol + li + strong Документ, генерация интерфейса. + p Здесь мы учимся работать с документом, решать всевозможные задачи в браузере. + ul + li Внутреннее устройство браузера, оптимальная организация страницы со скриптами. + li Дерево DOM, особенности разработки в современных браузерах с отмирающей, но иногда нужной поддержкой старых. + li Динамическая генерация интерфейса - методы DOM, их грамотное использование. + li + strong События, взаимодействие с посетителем. + ul + li Основы и тонкости работы с различными событиями для решения основных интерфейсных задач. + li Drag'n'Drop, по окну и внутри элемента + li Паттерн "делегирование", оптимизация производительности и архитектуры, чтобы интерфейсы не тормозили. + li Объектно-ориентированная разработка, компонентная архитектура с использованием ООП, событий и DOM. + + p По окончании второй части вы можете создавать интерфейсные компоненты, но нужно больше практики. + + +e.body._03 + ol + li Фреймворк jQuery, его важные тонкости и правильное использование. + li Архитектура сложных интерфейсов. + li Node.JS как средство запуска полезных утилит. + li Шаблонизация, организация шаблонов и кода в файлах, автоматизированная сборка проекта. + li Обзор AJAX-технологий и фреймворков (Backbone/Marionette, Angular.JS, React.js), куда двигаться дальше. + li В результате окончания третьей части вы, если конечно делали домашнее задание все это время, можете создать и поддерживать современный JS-проект и понимаете, как развиваться далее. + + p На практике эти части не так чтобы резко отделены друг от друга, переход между ними плавный. Продвинутые темы используют элементы предыдущих. + + script. + var className = 'tabbed-pane', + block = document.querySelector('.' + className); + + block + .querySelector('.' + className + '__tabs') + .addEventListener('click', function(e) { + + block.className = className + ' ' + + className + '_' + + e.target.className.split('_').pop(); + + }); + + + diff --git a/handlers/markup/templates/blocks/courses-programm-and-register.jade b/handlers/markup/templates/blocks/courses-programm-and-register.jade new file mode 100644 index 000000000..b924f0782 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-programm-and-register.jade @@ -0,0 +1,73 @@ ++b.course-info._program.courses-mix + +e.body.columns.columns_2 + + +e.col.columns__col + +e.content + +e('h3').title Программа + p Курс состоит из трёх частей: + ol + li + +e.text Первая часть позволяет хорошо разобраться в языке JavaScript, получить знания и навыки написания хорошего JavaScript-кода. + li + +e.text Вторая часть позволяет научиться работать со страницей и посетителем, создавать меню, слайдеры, Drag’n’Drop и прочие интерфейсные компоненты. + li + +e.text Третья часть посвящена более сложным интерфейсам. На ней мы изучаем, как построить архитектуру, взаимодействие между компонентами, как при помощи шаблонов и Require.JS организовать код, грамотно используем jQuery. + + p Большое внимание на этом курсе уделяется стилю кода. Это важно. Хороший стиль кода позволяет писать более быстро, красиво и делать меньше ошибок. А на серьёзных проектах он просто необходим. + + +e.col.columns__col + +e.content + +e('h3').title Набор в группы + + +b.courses-recruitment + +e('a').anchor#signup + +e('ul').list + +e('li').course + +e.info + +e('h4').title 15 Мар 2014 — 15 Май 2014 + +e('p').text + | Занятия каждый Пн/Чт + br + | 19:30 - 21:00 GMT+3 (Мск). + + +e.apply + +b.price + +e('span') 24000 RUB + +e('span').secondary + |  ≈ 450$ + +e.submit + +b('button').button._action + +e('span').text Записаться + + +e('li').course + +e.info + +e('h4').title 15 Мар 2014 — 15 Май 2014 + +e('p').text + | Занятия каждый Пн/Чт + br + | 19:30 - 21:00 GMT+3 (Мск). + + +e.apply + +b.price + +e('span') 24000 RUB + +e('span').secondary + |  ≈ 450$ + +e.submit + +b('button').button._action + +e('span').text Записаться + + p В цену входит 2 месяца обучения, включая одну неделю каникул с самостоятельно выполняемым заданием и организационное собрание. Также участники получают вводный видеокурс за неделю до начала занятий. + p Вы можете подписаться на уведомления по набору новых групп по этой программе: + + +b.text-input-button + +e.input + +b.text-input._invalid + +e('input').control(placeholder="email", name="email", type="email") + +e('span').err тест ошибки + +e.button + +b('button').button._common + +e('span').text Подписаться + + p(style="font-size: 13px; color: #999") На ваш email придёт письмо с информацией о дате, деталях курсов и ссылка на запись. + + diff --git a/handlers/markup/templates/blocks/courses-programm-register.jade b/handlers/markup/templates/blocks/courses-programm-register.jade new file mode 100644 index 000000000..38bf5f50e --- /dev/null +++ b/handlers/markup/templates/blocks/courses-programm-register.jade @@ -0,0 +1,15 @@ +- var courses = [] +- courses.push({ isOpen: true, title: 'Javascript, DOM, интерфейсы', link: '/123', text: 'В первую очередь это курс для тех, кто не разрабатывал на JS, либо разрабатывал эпизодически и теперь хочет освоить профессионально' }) +- courses.push({ isOpen: false, title: 'Javascript, DOM, интерфейсы', link: '/123', text: 'В первую очередь это курс для тех, кто не разрабатывал на JS, либо разрабатывал эпизодически и теперь хочет освоить профессионально' }) + ++b.courses-programm-register + +e('a').anchor#courses + +e('h2').title Программа курсов и запись + +e('ul').courses + for course in courses + +e('li').course + +e('h3').course-title + +b('a').link(href=course.link) !{ course.title } + if course.isOpen + +e('span').course-bage Идет набор в группы + +e('p').course-text !{ course.text } diff --git a/handlers/markup/templates/blocks/courses-register.jade b/handlers/markup/templates/blocks/courses-register.jade new file mode 100644 index 000000000..22d2f8697 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-register.jade @@ -0,0 +1,262 @@ +// mods: '_step_1', '_step_2', '_step_3', '_step_4' +// _step_1 Participants +// _step_2 Contacts +// _step_3 Pyament +// _step_3 Result + ++b.courses-register._step_1 + + +b.receipts._register + + +e.receipt._step_1 + +e.receipt-body + +e.receipt-content + +e.type Заказ: + +e.title Посещение курсов для 10 человек + +b.course-register-info + +e('p').info._length + +e('time')(datetime="2014-03-15 17:00").time 15 Мар 2014 + | — + +e('time')(datetime="2014-05-15 17:00").time 15 Май 2014 + +e('p').info + | Каждый Пн и Ср в  + +e('time').time 17:00  + | (UTC+4) + + +e.receipt-aside + +e.price + +b('span').price 2400 RUB + +e('a').edit(href="/123") + + +e.receipt._step_2 + +e.receipt-body + +e.receipt-content + +e.type Контактная информация: + +e.title Александр Сергеевич Константинопольский + +e.receipt-aside._center + +e('span').title +7 495 926-22-23 + +e('a').edit(href="/123") + + +e.receipt._step_3 + +e.receipt-body + +e.receipt-content + +e.type Оплата: + +e.status._ok Осуществлена успешно + +e.receipt-aside + +e.pay-method_paypal + + + // Participants + + +b('form').complex-form._step_1 + +e.step._current + +b.courses-register-participants.courses-register-common + +e('h2').title.courses-register-common__title Места и участники + + +b.course-register-info + +e('h2').group Группа 10 + +e('p').info._length + +e('time')(datetime="2014-03-15 17:00").time 15 Мар 2014 + | — + +e('time')(datetime="2014-05-15 17:00").time 15 Май 2014 + +e('p').info + | Каждый Пн и Ср в  + +e('time').time 17:00  + | (UTC+4) + + +b.course-register-settings + +e.number.course-register-settings__cell + +e('h3').title Количество мест + +e.body + +b.number-input + +e('button')(disabled).btn._dec − + +b.text-input._small.__text._invalid + +e('input')(type="number" value="1").control.__input + +e('span').err Укажите поменьше людей + +e('button').btn._inc + + + +e.is-participant.course-register-settings__cell + +e('h3').title Я являюсь участником + +e.body + +b.switch-input + +e('input').checkbox#request-participant(type='checkbox') + +e('i').bg + +e('label').label(for="request-participant") + +e('span').off НЕТ + +e('span').on ДА + + +e.price.course-register-settings__cell + +e('h3').title Стоимость + +e.body + +b.price 2400 RUB + +e('span').secondary (≈ 200$) + + +e.add-participants + +b.course-add-participants._visible + +e('input')(type="checkbox" id="add-participants").checkbox + +e('label').add(for="add-participants") Укзать участников + +e('p').note (это можно сделать позже) + +e.dropdown + +e('label')(for="add-participants").dropdown-close.close-button + +e('ul').dropdown-list + - var n = 0 + while n < 6 + +b('li').course-add-participants-item + +e('label').participant + +e('span').participant-n Участник + +b('span').text-input + +e('input').control(placeholder="email", name="email", type="email") + +e('span').err тест ошибки + - n++ + + +e.next.courses-register-common__next + +b('button')(type="submit").button._action + +e('span').text Сохранить и продолжить + + + // Contacts + + +b('form').complex-form._step_2 + +e.step._current + +b.course-register-contacts.courses-register-common + +e('h2').title.courses-register-common__title Контактная информация + +e('p').note + | Оставьте ваши контактные данные, чтобы мы могли связаться с вами + | в случае необходимости + + +e.body + +b.contact-form + +e.content + +e.fields + +e.name + label(for="contact-name") Имя и Фамилия: + +b.text-input._small.__name-input + +e('input').control#contact-name + +e.tel + label(for="contact-phone") Телефон: + +b.full-phone.__full-phone + +e.tel-wrap + +b.text-input._small.__tel + +e('input').control#contact-phone(placeholder="+X (XXX) XXX-XX-XX") + +e.note + +e('h5').note-title Ваши данные в безопасности + p + | В соответствии с законом о защите личных данных, никакие ваши личные данные + | не будут переданы третьим лицам, кроме как по вашему желанию или для + | целей выполнения заключенного с вами договора. + + +e.next.courses-register-common__next + +b('button')(type="submit").button._action + +e('span').text Сохранить и продолжить + + + // Pyament + + +b('form').complex-form._step_3 + +e.step._current + +b.course-register-payment.courses-register-common + +e('h2').title.courses-register-common__title Оплата + + +e.body + +b.pay-method + +e('ul').methods + + - var paymentMethods = []; + - paymentMethods.push({ name: 'yandexmoney', image: true}); + - paymentMethods.push({ name: 'webmoney', image: true }); + - paymentMethods.push({ name: 'paypal', image: true, settings: true }); + - paymentMethods.push({ name: 'payanyway', image: false, title: 'Payanyway', subtitle: 'и много других методов', cards: ['visa-mastercard'] }); + - paymentMethods.push({ name: 'banksimple', image: false, title: 'Банковсий перевод', subtitle: 'или другой банк', cards: ['sberbank']}); + - paymentMethods.push({ name: 'interkassa', image: false, title: 'Interkassa', subtitle: 'и другие методы (Украина)', cards: ['visa-mastercard', 'privatbank'] }); + - paymentMethods.push({ name: 'invoice', image: false, title: 'Счет на компанию', subtitle: 'Для юрлиц из России', settings: true }); + + + each paymentMethod in paymentMethods + - var paymentMethod = paymentMethod + +e('li').method + + +e('input').method-radio(type="radio" name="paymentMethod" value=paymentMethod.name id=paymentMethod.name) + + +e('label')(class=["method-label", "_"+paymentMethod.name] for=paymentMethod.name) + +e('header').header + + if paymentMethod.title + +e('h3').method-title !{ paymentMethod.title } + if paymentMethod.cards + +e('span').cards + each card in paymentMethod.cards + +e('img').card(src='/pay-methods/' + card + '.svg', alt=card) + if paymentMethod.subtitle + +e('h4').method-subtitle !{ paymentMethod.subtitle } + + if paymentMethod.image + +e('img').logo(src="/pay-methods/pay-" + paymentMethod.name + '.svg' alt=paymentMethod.name.charAt(0).toUpperCase() + paymentMethod.name.slice(1)) + + if paymentMethod.settings + include payment-settings + + + +e.next.courses-register-common__next + +b('button')(type="submit").button._action + +e('span').text Перейти к оплате + + p Если возникли какие-нибудь сложности, вы можеле оплатить заказ позже + + + // Result + + +b('form').complex-form._step_4 + +e.step._current + +b.course-register-success.courses-register-common + +e('h2').title.courses-register-common__title Спасибо за заказ! + +e('h3').title.courses-register-common__title В ближайшее время вам прийдет уведомление на электронный адрес + + +e.body + p + | Вы можете отредактировать детали своего заказа + | в любое время до начала проведения курсов в + | соответствующем разделе вашей учетной записи. + | В случаях каких-либо изменений мы обязательно с вами свяжемся. + + p Если у вас возникли какие-либо вопросы, присылайте их на orders@javascript.ru. + + +b.login-form(data-form="login").complex-form.complex-form_step_4 + +e.step.complex-form__step.complex-form__step_current + +e('form').form(action="#") + +e.line + +e('label').label(for="auth-email") Email: + +b('span').text-input.__input + +e('input').control#auth-email(name="email" type="email" value="bla@bla.com" disabled) + +e.line + +e('label').label(for="auth-password") Пароль: + +b('span').text-input._with-aside.__input + +e('input').control#auth-password(type="password", name="password") + +e('button').aside.__forgot.__button-link(type="button" data-switch="forgot-form") Забыли? + +e.line.__footer + +b('button').button._action(type="submit") + +e('span').text Войти + + +b.login-form(data-form="login").complex-form.complex-form_step_4 + +e.step.complex-form__step.complex-form__step_current + +e('form').form(action="#") + +e.line + +e('label').label(for="auth-email") Email: + +b('span').text-input.__input + +e('input').control#auth-email(name="email" type="email" value="bla@bla.com" disabled) + +e.line + +e('label').label(for="auth-login") Имя пользвателя: + +b('span').text-input.__input + +e('input').control#auth-login(name="login" type="text") + +e.line + +e('label').label(for="auth-password") Новый пароль: + +b('span').text-input._with-aside.__input + +e('input').control#auth-password(type="password", name="password") + +e.line.__footer + +b('button').button._action(type="submit") + +e('span').text Войти + + +b('ul').grayed-list + +e('li').item._step_2 Контактная информация + +e('li').item._step_3 Оплата + +e('li').item._step_4 Подтверждение + diff --git a/handlers/markup/templates/blocks/courses-result.jade b/handlers/markup/templates/blocks/courses-result.jade new file mode 100644 index 000000000..1d5470f5c --- /dev/null +++ b/handlers/markup/templates/blocks/courses-result.jade @@ -0,0 +1,11 @@ ++b.courses-result.courses-mix + +e('h2').title Результат обучения + + +e.body + ol + li Вы хорошо знаете JavaScript, свободно разрабатываете и отлаживаете программы на этом языке. + li Вы умеете организовать JavaScript-проект, шаблоны и стили в файлах на диске в удобную структуру, собирать и оптимально подключать их к странице. + li Ваши интерфейсы работают стабильно, без глюков, их можно удобно дорабатывать и развивать. + li Мы идём от основ и до довольно-таки сложных штук. Успешное прохождение обучения гарантировано в том случае, если вы будете регулярно заниматься и делать домашнее задание. + + diff --git a/handlers/markup/templates/blocks/courses-system-req.jade b/handlers/markup/templates/blocks/courses-system-req.jade new file mode 100644 index 000000000..d8fe9527b --- /dev/null +++ b/handlers/markup/templates/blocks/courses-system-req.jade @@ -0,0 +1,7 @@ ++b.courses-system-req.courses-mix + +e('h2').title Системные требования + + +e.body + p Для общения используются видео, аудио и чат. Если у вас есть гарнитура - вы сможете использовать её для вопросов, но это не обязательно. + + p Системные требования для общения онлайн - Windows/MacOS и скорость 256kbit+, для просмотра видео - Windows вне виртуальной машины. diff --git a/handlers/markup/templates/blocks/courses-tabbed-pane.jade b/handlers/markup/templates/blocks/courses-tabbed-pane.jade new file mode 100644 index 000000000..e2fd34c49 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-tabbed-pane.jade @@ -0,0 +1,89 @@ ++b.tabbed-pane._01 + + +e('ul').tabs + +e('li').tab._01 Чем эти курсы отличаются от других? + +e('li').tab._02 Зачем курсы, когда есть книги и статьи на javascript.ru? + +e('li').tab._03 Зачем курсы, если можно научиться на работе? + + +e.body._01 + + p В интернет есть много различных курсов, но, к сожалению, большинство из них не выдерживают никакой критики. Скорее всего, вы и сами понимаете это, а если нет – спросите знакомого специалиста, он подтвердит. + p Курсы, которые находятся здесь — эффективны и не похожи ни на один из них. + + ul + li Цель — полноценная профессиональная разработка. Курс идёт с расчетом на современную разработку уровня мировых стандартов. Это немного другой уровень, чем «кнопка на коленке», и другой подход к знаниям. Понятно, что «гуру» шлифуют мастерство годами, но мы можем достаточно сильно продвинуться и научиться грамотной разработке за время курса. Для участников «с нуля» существует вводный видеокурс, который позволяет освоить самые базовые моменты заранее. + li Курс построен на примерах и задачах. Программировать — это как плавать, одной теории маловато, нужна практика, и чем больше — тем лучше. Значит – много примеров и задач. Ведь умение их решать, основанное на понимании и прямых руках — и есть реальная цель. + li Правильное понимание языка. JavaScript — особенный язык. Если взять все часы «среднего» JavaScript-разработчика, потерянные на вопросы на форумах, на отладку кривого кода… То важность этого становится очевидной. + li Актуальность… То, как делаются современные проекты, а не как это было 5 лет назад. + li Качество кода — это важно, т.к. большинство времени тратится не на изначальное написание кода, а на его развитие и поддержку. На курсах ему уделяется особое внимание. + li Непрерывная обратная связь — на любые вопросы вы получаете ответы, на ваши решения — грамотный ответ, можно ли так писать и когда возможны проблемы. + + p Курсы возникли в результате долгого опыта разработки и преподавания, очного, заочного и совмещенного, и сочетают преимущества обоих технологий. + + ul + li У вас на руках будут лекционные материалы для изучения и выполнения заданий. + li Ваши вопросы, результаты выполнения заданий, способы сделать лучше и правильнее мы обсуждаем при видео-общении онлайн. + + + +e.body._02 + + p Практика показывает, что язык программирования, как и обычные языки, все же лучше изучаются на курсах. + + p JavaScript в этом смысле особенный язык. На нём очень легко начать что-то делать. Но при этом разница между человеком, который нахватался по верхам и профессионалом, постигшим JS-дзен — колоссальна. Один делает три кнопки, другой пишет Gmail и покоряет мир. + + p Цель курсов — упростить и спрямить вторую дорогу, и пройтись по ее началу вместе, чтобы не свернуть ненароком куда не следует. А уж что вы потом захотите делать — новый Gmail или меню на сайте — вам решать. Главное это скорость и качество разработки. + + blockquote Курсы JavaScript — мощный и быстрый способ обучения. При полноценном участии они гарантируют актуальные, глубокие знания. + + p Наша цель — не просто выучить, какие есть функции. Да, методы знать нужно, но главное — уметь «думать на javascript» и разрабатывать понятный, хороший код, без ошибок и с правильной структурой. + + p Возможность участников общаться онлайн друг с другом и с ведущим, выполнение заданий также даёт более глубокое и эффективное усвоение практических навыков. + + p Ниже находится классическая «пирамида обучения». Слева указаны полученные в результате исследований средние проценты усвоения знаний. Четыре верхние ступени относятся к индивидуальному обучению. Три нижние — к групповому и, в частности, курсам. + + +b.image-with-text + + +e.img + img(src="/courses/pyramid.png" alt="пирамида обучения") + + +e.text + p На текущий момент в курсах уже участвовало более 1000 человек. Могло бы быть гораздо больше, но моя цель — не количество, а качество. Группы веду только я один, мест в них не так много. + + p Все участники как и вы, имеют доступ к гугл, книгам и javascript.ru. Но каждый имеет право на лучшее, они выбрали поход на курсы и, похоже, не пожалели. + + p Курсы — это вложение в себя. Это усилия, которые позволят быстро продвинуться. А где вы хотите быть через несколько месяцев/лет? + + p Может быть, имеет смысл level up? + + +e.body._03 + + p Забавный совет, который дают многим начинающим, такой: «читай книги, иди работай, пиши скрипты и научишься». Он отчасти правилен — действительно, нужно разрабатывать, получать опыт. + + p Но вот что касается «научиться» — на практике все не так просто. Люди могут работать долго, но качество кода при этом не всегда растёт. + + p Это и видно, мы все знаем, что компаниям нужны результаты. Им нужны хорошие разработчики, очень нужны. В современном интернет всё решают люди. За них постоянно идет борьба. На поиск выделяются ресурсы, деньги... + + p Если бы люди быстро вырастали в процессе работы — не было бы огромных трат ресурсов на поиск разработчиков. + + p Для компании обучать людей самостоятельно — гораздо затратнее, чем брать уже учёных. Поэтому предпочитают заплатить хорошему разработчику побольше, чем самостоятельно «допиливать» среднего. + + p Всё это объективные реалии, которые можно наблюдать в мире. Именно поэтому существуют курсы. Хорошие курсы могут дать очень многое, если, конечно, это — действительно хорошие курсы. + + + +script. + var className = 'tabbed-pane', + block = document.querySelector('.' + className); + + block + .querySelector('.' + className + '__tabs') + .addEventListener('click', function(e) { + + block.className = className + ' ' + + className + '_' + + e.target.className.split('_').pop(); + + }); + + + diff --git a/handlers/markup/templates/blocks/courses-table.jade b/handlers/markup/templates/blocks/courses-table.jade new file mode 100644 index 000000000..65a9d1707 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-table.jade @@ -0,0 +1,43 @@ +- var courses = []; +- var time = '2014-01-01T19:45' +- var date = 'Начало ' + moment(time).locale('ru').format('D MMM YYYY'); + +- courses.push({ name: 'Посещение online курсов «Javascript, DOM, интерейсы»', start: date, schedule: 'Занятия каждый Пн/Чт
    19:30 - 21:00 GMT+3 (Мск).', info_links: [{ url: '/123', name: 'Описание курсов' }], status: 'need-verify' }); +- courses.push({ name: 'Посещение online курсов «Javascript, DOM, интерейсы»', start: date, schedule: 'Занятия каждый Пн/Чт
    19:30 - 21:00 GMT+3 (Мск).', info_links: [{ url: '/123', name: 'Описание курсов' }, { url: '/123', name: 'Инструкции по настройке окружения' }, { url: '/123', name: 'Материалы для обучения' }], status: 'verified' }); +- courses.push({ name: 'Посещение online курсов «Javascript, DOM, интерейсы»', start: date, schedule: 'Занятия каждый Пн/Чт
    19:30 - 21:00 GMT+3 (Мск).', info_links: [{ url: '/123', name: 'Описание курсов' }, { url: '/123', name: 'Инструкции по настройке окружения' }, { url: '/123', name: 'Материалы для обучения' }], status: 'started' }); +- courses.push({ name: 'Посещение online курсов «Javascript, DOM, интерейсы»', start: date, schedule: 'Занятия каждый Пн/Чт
    19:30 - 21:00 GMT+3 (Мск).', info_links: [{ url: '/123', name: 'Описание курсов' }, { url: '/123', name: 'Инструкции по настройке окружения' }, { url: '/123', name: 'Материалы для обучения' }], status: 'ended' }); + ++b.courses-table + +e('table').table + + for course in courses + +e('tr').line + + +e('th').main + +e('h3').title !{ course.name } + +e('ul').info-links + for link in course.info_links + +e('li').info-links-item + +e('a').info-link(href=link.url) !{ link.name } + + +e('td').info + +e('strong').start !{ course.start } + +e.schedule !{ course.schedule } + + +e('td').verify + if course.status === 'verified' + +e('span').status._verified Участие подтверждено + + else if course.status === 'need-verify' + +b('a').button._action(href="/123") + +e('span').text Подтвердить участие + + else if course.status === 'started' + +e('span').status._started Занятия начались + + else if course.status === 'ended' + +e('span').status._ended Курсы завершены + br + +e('a').feedback(href="/123") Оставить отзыв + + diff --git a/handlers/markup/templates/blocks/courses-testimonials.jade b/handlers/markup/templates/blocks/courses-testimonials.jade new file mode 100644 index 000000000..d94a54c53 --- /dev/null +++ b/handlers/markup/templates/blocks/courses-testimonials.jade @@ -0,0 +1,44 @@ +- var testimonials = []; +- testimonials.push({ userpic: '/img/userpic/userpic.svg', profile: '/123', rating: '5', location: { country: 'ru', text: 'Россия, Москва' }, name: 'Бендер Константинопольский', text: 'При облучении инфракрасным лазером кондуктометрия захватывает сернистый газ, даже если нанотрубки меняют свою межплоскостную ориентацию. Изомерия, как следует из совокупности экспериментальных наблюдений', text2: 'редко активирует окисленный серный эфир' }) +- testimonials.push({ userpic: '/img/userpic/userpic.svg', profile: '/123', rating: '4', location: { country: 'ua', text: 'Украина, Киев' }, name: 'Иван Пупкин', text: 'Чо норм курсы' }) +- testimonials.push({ userpic: '/img/userpic/userpic.svg', profile: '/123', rating: '3', location: { country: 'ru', text: 'Россия, Усть-Каменогороск'}, name: 'Пьер Безухов', text: 'Продукт реакции разъедает жидкофазный раствор. Упаривание активирует серный эфир' }) + ++b.courses-testimonials.courses-mix + +e('h2').title Что говорят о курсах люди + + +e.wrapper + +e('i').arr._prev + +e('i').arr._next + +e('a').all(href="/123") Все отзывы + + +e.body + +e('ul').testimonials + for testimonial, index in testimonials + +e('li').testimonial + +e.main + +b.rating._4 + for raiting in [1,2,3,4,5] + +e('i').star ★ + + +e('p').testimonial-text !{ testimonial.text } + if testimonial.text2 + =' ' + +e('span').cut … + +e('span').cuted !{ testimonial.text2 } + +e.user + +e.userpic + +e('img').userpic-img(src=testimonial.userpic) + +e.username + +e('a').username-link(href="/123") !{ testimonial.name } + +e.country + +e('img').country-flag(src='/img/flags/' + testimonial.location.country + '.svg') + +e('span').country-text !{ testimonial.location.text } + +script. + + var cut = document.querySelector('.courses-testimonials__cut'); + + cut.addEventListener('click', function() { + cut.style.display = 'none'; + cut.nextSibling.style.display = 'inline' + }); diff --git a/handlers/markup/templates/blocks/head.jade b/handlers/markup/templates/blocks/head.jade new file mode 100755 index 000000000..1f3bd6d4e --- /dev/null +++ b/handlers/markup/templates/blocks/head.jade @@ -0,0 +1,21 @@ +title= (headTitle || title) + +//- for mobile devices +meta(name="viewport" content="width=device-width, initial-scale=1, user-scalable=yes") +meta(name="apple-mobile-web-app-capable" content="yes") +script if (window.devicePixelRatio > 1) document.cookie = 'pixelRatio=' + window.devicePixelRatio + ';path=/;expires=Tue, 19 Jan 2038 03:14:07 GMT'; +link(href='//fonts.googleapis.com/css?family=Open+Sans:400italic,700italic,400,700|Open+Sans+Condensed:700&subset=latin,latin-ext,cyrillic,cyrillic-ext' rel='stylesheet') + +link(href=pack('styles', 1) rel='stylesheet') + +if prev + link(rel="prev" href=prev.url) + +if next + link(rel="next" href=next.url) + + +script(src=pack("head", "js")) + +//- head из конкретной статьи +!=head diff --git a/handlers/markup/templates/blocks/invoice-table.jade b/handlers/markup/templates/blocks/invoice-table.jade new file mode 100644 index 000000000..997c57333 --- /dev/null +++ b/handlers/markup/templates/blocks/invoice-table.jade @@ -0,0 +1,114 @@ +- var invoces = []; +- var datestr = '2014-01-01T19:45'; +- var date = moment('2014-01-01T19:45').locale('ru').format('D MMMM YYYY в h:mm'); +- var participants = [{ email: 'abc@abc.com', approved: true }, { email: 'abc@abc.com' }, {}] + +- invoces.push({number: '3451', date: date, name: 'Курс Javascript/DOM/интерфейсы (01.01)', slots: { total: '5 мест', free: '2', busy: '3', confirmed: '2' }, payment: { amount: '2400', currency: 'RUR', status: 'done', type: 'Paypal' }, participants: participants }); +- invoces.push({number: '3453', date: date, name: 'Курс Javascript/DOM/интерфейсы (01.01)', slots: { total: '4 места', free: '3', busy: '1', confirmed: '1' }, payment: { amount: '2200', currency: 'USD', status: 'await', type: 'Банковская квитанция' }, participants: participants }); +- invoces.push({number: '3456', date: date, name: 'Курс Javascript/DOM/интерфейсы (01.01)', slots: { total: '5 мест', free: '2', busy: '3', confirmed: '2' }, payment: { amount: '2400', currency: 'UAH', status: 'done', type: 'Paypal' }, participants: participants }); +- invoces.push({number: '3458', date: date, name: 'Курс Javascript/DOM/интерфейсы (01.01)', slots: { total: '1 местo' }, payment: { amount: '600', currency: 'RUR', status: 'await', type: 'Банковская квитанция' }, participants: [{}] }); + ++b.invoice-table + + +e('table').table + + for invoce, indexInvoice in invoces + +e('tr')(class=['data', indexInvoice == 1 ? '_show_settings' : '']) + +e('th').main + +e('span').number Заказ № !{ invoce.number } + +e('time').time(datetime=datestr)= date + +e('h3').title !{ invoce.name } + + - var slots = invoce.slots; + + if slots && slots.total + - slots.total = slots.total.split(' '); + + +e.slots + +e('strong').slots-total !{ slots.total[0] } +  !{ slots.total[1] } + + if slots.free + +e('strong').slots-free !{ slots.free } +  свободно + + if slots.busy + +e('strong').slots-busy !{ slots.busy } +  занято + + if slots.confirmed + +e('span').slots-confirmed  (!{ slots.confirmed } подтверждено) + else + +e('span').slots-confirmed._note  (подтверждение участников происходит после оплаты) + + +e('td').info + +e('a').info-link(href='/123') Описание курсов + + +e('td').price + +b.price !{ invoce.payment.amount } !{ invoce.payment.currency } + +e(class=['payment-status', '_' + invoce.payment.status]) + if invoce.payment.status === 'done' + Оплачено + if invoce.payment.status === 'await' + Ожидается оплата + +e.payment-type (!{ invoce.payment.type }) + + +e('tr').settings + +e('td').settings-cell(colspan=3) + +e.settings-dropdown + +e('button').settings-dropdown-close.close-button + +e.settings-dropdown-cell._left + form(action="/123") + +e('h4').settings-title Участники + + +e('ul').settings-participants + + for participant, index in participants + - var number = index + 1 + +e('li').settings-participant + +e('label').participant-label(for='participant' + number + '' + indexInvoice) Участник !{number}: + +b('span')(class=['text-input', index == 1 ? '_invalid' : '', '__input', participant.approved ? '_approved_yes' : '_approved_no']) + +e('input').control(placeholder="email", name="email", type="email", value= participant.email ? participant.email : '', id = 'participant' + number + '' + indexInvoice) + +e('span').status + if index == 1 + +e('span').err Не верный email + + +e.settings-line_submit + +b('button').button._common(type="submit") + +e('span').text Сохранить участников + + + +e.settings-dropdown-cell._right + +e('h4').settings-title Контактная информация + +e('form').contact-form(action="/123") + +e.settings-line + +e("label").contact-form-label(for="contact-name" + indexInvoice) Имя и фамилия: + +b("span").text-input + +e("input").control(type="text", required, name="contact-name", id="contact-name" + indexInvoice) + + +e.settings-line + +e('label').contact-form-label(for="contact-phone" + indexInvoice) Телефон: + +b.full-phone + +e.tel-wrap + +b.text-input._invalid._small.__tel + +e('input').control(placeholder="+X XXX XXX-XX-XX", required, id="contact-phone" + indexInvoice) + +e('span').err Не верный телефон + + +e.settings-line._submit + +b('button').button._common(type="submit") + +e('span').text Сохранить контакты + + +e.settings-line._foot + +e('h4').settings-title Оплата + +e('p').note Вы выбрали вариант оплаты «Оплатить позже», подтвердить участие мы сможем только после того, как получим оплату. + +b('button').button._action + +e('span').text Перейти к оплате + + +e.settings-line._foot + +e('h4').settings-title Оплата + +e('p').note Вы выбрали вариант оплаты «Оплатить позже», подтвердить участие мы сможем только после того, как получим оплату. Вы можете повторно скачать квитанцию. + +b('button').button._common + +e('span').text Изменить метод оплаты + + + diff --git a/handlers/markup/templates/blocks/lesson.jade b/handlers/markup/templates/blocks/lesson.jade new file mode 100755 index 000000000..e69de29bb diff --git a/handlers/markup/templates/blocks/lessons-list-inner.jade b/handlers/markup/templates/blocks/lessons-list-inner.jade new file mode 100755 index 000000000..b1c5f1fb2 --- /dev/null +++ b/handlers/markup/templates/blocks/lessons-list-inner.jade @@ -0,0 +1,24 @@ ++b.lessons-list + +e('ol').lessons + //- data-section-number should contain first part of item number, like _2_.1. + //- Is used to generate numbered bullets automatically. + +e('li').lesson(data-section-number="2") + +e('a').link(href="#intro") Введение + +e('li').lesson(data-section-number="2") + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson(data-section-number="2") + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('li').lesson(data-section-number="2") + +e('a').link(href="#interface") Внутренний и внешний интерфейс + +e('li').lesson(data-section-number="2") + +e('a').link(href="#agreements") Соглашения в коде виджета + +e('li').lesson(data-section-number="2") + +e('a').link(href="#moveevents") События движения: "mouseover/out/move/leave/enter" + +e('li').lesson(data-section-number="2") + +e('a').link(href="#userinteraction") Взаимодействие с пользователем: alert, prompt, confirm + +e('li').lesson(data-section-number="2") + +e('a').link(href="#multiinsert") Мультивставка: insertAdjacentHTML и DocumentFragment + +e('li').lesson(data-section-number="2") + +e('a').link(href="#templates") Шаблонизация в JavaScript + +e('li').lesson(data-section-number="2") + +e('a').link(href="#events") Свои события, подписка - уведомление diff --git a/handlers/markup/templates/blocks/lessons-list.jade b/handlers/markup/templates/blocks/lessons-list.jade new file mode 100755 index 000000000..a37eb5013 --- /dev/null +++ b/handlers/markup/templates/blocks/lessons-list.jade @@ -0,0 +1,144 @@ ++b.lessons-list + //- behavior demo + script. + document.addEventListener('DOMContentLoaded', function() { + var togglingLinks = document.querySelectorAll('.lessons-list__lesson_level_1 > .lessons-list__link'); + + Array.prototype.forEach.call(togglingLinks, function(element) { + element.addEventListener('click', function(e) { + this.parentNode.classList.toggle('lessons-list__lesson_open'); + e.preventDefault(); + }); + }); + }); + +e('ol').lessons + +e('li').lesson._level_1 + +e('a').link(href="#intro") Введение + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson._level_2 + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('li').lesson._level_2 + +e('a').link(href="#interface") Внутренний и внешний интерфейс + +e('li').lesson._level_2 + +e('a').link(href="#agreements") Соглашения в коде виджета + +e('li').lesson._level_2 + +e('a').link(href="#moveevents") События движения: "mouseover/out/move/leave/enter" + +e('li').lesson._level_2 + +e('a').link(href="#userinteraction") Взаимодействие с пользователем: alert, prompt, confirm + +e('li').lesson._level_1._open + +e('a').link(href="#graphical") Верстка графических компонент + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson._level_2 + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('li').lesson._level_2 + +e('a').link(href="#interface") Внутренний и внешний интерфейс + +e('li').lesson._level_1 + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson._level_2 + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('li').lesson._level_2 + +e('a').link(href="#interface") Внутренний и внешний интерфейс + +e('li').lesson._level_2 + +e('a').link(href="#agreements") Соглашения в коде виджета + +e('li').lesson._level_2 + +e('a').link(href="#moveevents") События движения: "mouseover/out/move/leave/enter" + +e('li').lesson._level_2 + +e('a').link(href="#userinteraction") Взаимодействие с пользователем: alert, prompt, confirm + +e('li').lesson._level_2 + +e('a').link(href="#multiinsert") Мультивставка: insertAdjacentHTML и DocumentFragment + +e('li').lesson._level_2 + +e('a').link(href="#templates") Шаблонизация в JavaScript + +e('li').lesson._level_2 + +e('a').link(href="#events") Свои события, подписка - уведомление + +e('li').lesson._level_1 + +e('a').link(href="#interface") Внутренний и внешний интерфейс + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson._level_2 + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('li').lesson._level_1 + +e('a').link(href="#agreements") Соглашения в коде виджета + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson._level_2 + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('li').lesson._level_2 + +e('a').link(href="#interface") Внутренний и внешний интерфейс + +e('li').lesson._level_2 + +e('a').link(href="#agreements") Соглашения в коде виджета + +e('li').lesson._level_2 + +e('a').link(href="#moveevents") События движения: "mouseover/out/move/leave/enter" + +e('li').lesson._level_1 + +e('a').link(href="#moveevents") События движения: "mouseover/out/move/leave/enter" + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#multiinsert") Мультивставка: insertAdjacentHTML и DocumentFragment + +e('li').lesson._level_2 + +e('a').link(href="#templates") Шаблонизация в JavaScript + +e('li').lesson._level_2 + +e('a').link(href="#events") Свои события, подписка - уведомление + +e('li').lesson._level_1 + +e('a').link(href="#userinteraction") Взаимодействие с пользователем: alert, prompt, confirm + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson._level_1 + +e('a').link(href="#multiinsert") Мультивставка: insertAdjacentHTML и DocumentFragment + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#userinteraction") Взаимодействие с пользователем: alert, prompt, confirm + +e('li').lesson._level_2 + +e('a').link(href="#multiinsert") Мультивставка: insertAdjacentHTML и DocumentFragment + +e('li').lesson._level_2 + +e('a').link(href="#templates") Шаблонизация в JavaScript + +e('li').lesson._level_2 + +e('a').link(href="#events") Свои события, подписка - уведомление + +e('li').lesson._level_1 + +e('a').link(href="#templates") Шаблонизация в JavaScript + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент + +e('li').lesson._level_2 + +e('a').link(href="#paradigms") Процедурное и объектное программирование + +e('li').lesson._level_2 + +e('a').link(href="#interface") Внутренний и внешний интерфейс + +e('li').lesson._level_2 + +e('a').link(href="#agreements") Соглашения в коде виджета + +e('li').lesson._level_2 + +e('a').link(href="#moveevents") События движения: "mouseover/out/move/leave/enter" + +e('li').lesson._level_2 + +e('a').link(href="#userinteraction") Взаимодействие с пользователем: alert, prompt, confirm + +e('li').lesson._level_2 + +e('a').link(href="#multiinsert") Мультивставка: insertAdjacentHTML и DocumentFragment + +e('li').lesson._level_2 + +e('a').link(href="#templates") Шаблонизация в JavaScript + +e('li').lesson._level_1 + +e('a').link(href="#events") Свои события, подписка - уведомление + +e('ol').lessons + +e('li').lesson._level_2 + +e('a').link(href="#intro") Введение + +e('li').lesson._level_2 + +e('a').link(href="#graphical") Верстка графических компонент diff --git a/handlers/markup/templates/blocks/lessons.jade b/handlers/markup/templates/blocks/lessons.jade new file mode 100755 index 000000000..cddee8ad3 --- /dev/null +++ b/handlers/markup/templates/blocks/lessons.jade @@ -0,0 +1,8 @@ +- var lessons = []; +- lessons.push({title: 'Переменные', }) +- lessons.push({title: 'Переменные'}) + +.lessons + for [1..10] + .lessons__lesson-wrap + include lesson diff --git a/handlers/markup/templates/blocks/map.jade b/handlers/markup/templates/blocks/map.jade new file mode 100755 index 000000000..02f90f9d9 --- /dev/null +++ b/handlers/markup/templates/blocks/map.jade @@ -0,0 +1,333 @@ ++b.tutorial-map + +e.filter + +e.input-wrap + //- чтобы сделать видимой кнопку очистки необходимо добавить блоку + //- .text-input модификатор ._clear-button + +b('span').text-input._clear-button.__input + +e('input').control(type="text" placeholder="Фильтр по заголовку" autofocus data-tutorial-map-filter) + +b.close-button.__clear + +e.option + +e('label').option-label(for="show-tasks") + +e('input').option-control#show-tasks(type="checkbox" data-tutorial-map-show-tasks) + | Показать задачи + +e.layout + +b.switch.__layout-switch + +e.option + +e('input').control#multicol(type="radio", name="map-layout", checked="checked") + +e('label').label.__multicol(for="multicol") + +e.option + +e('input').control#singlecol(type="radio", name="map-layout") + +e('label').label.__singlecol(for="singlecol") + + script. + (function() { + document.querySelector('.tutorial-map__layout-switch').addEventListener('click', function() { + if(document.getElementById('singlecol').checked) { + document.querySelector('.tutorial-map').classList.add('tutorial-map_singlecol'); + } else { + document.querySelector('.tutorial-map').classList.remove('tutorial-map_singlecol'); + } + }) + })() + + +e.tutorial-map-map.columns.columns_3 + //- использовать блок columns? + +e.section.columns__col + +e('h2').col-title Базовый JavaScript + +e('ul').items + +e('li').item + +e('a').link(href="#001") Общая информация + +e('ul').sub-items + +e('li').sub-item + +e('a').link(href="#002") Введение в JavaScript + +e('li').sub-item + +e('a').link(href="#003") Альтернативные браузерные технологии + +e('li').sub-item + +e('a').link(href="#004") Книги по JS, HTML/CSS и не только + +e('li').sub-item + +e('a').link(href="#005") Справочники + +e('li').sub-item + +e('a').link(href="#006") Редакторы для кода + +e('li').sub-item + +e('a').link(href="#007") SublimeText: шпаргалка + +e('li').sub-item + +e('a').link(href="#008") Книги по JS, HTML/CSS и не только + +e('li').sub-item + +e('a').link(href="#009") Установка браузеров, JS-консоль + +e('li').sub-item + +e('a').link(href="#010") Тестирование в старых браузерах + +e('li').sub-item + +e('a').link(href="#011") Привет, мир! + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#012") Alert + =" " + //- note может использоваться для любой дополняющей или поясняющей информации + +e('span').note 5 + +e('li').item._collapsed + +e('a').link(href="#013") Основы JavaScript + +e('ul').sub-items + +e('li').sub-item + +e('a').link(href="#014") Структура кода + +e('li').sub-item + +e('a').link(href="#015") Переменные + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#016") Работа с переменными + =" " + +e('span').note 2 + +e('li').sub-item + +e('a').link(href="#017") Имена переменных + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#018") Объявление переменных + =" " + +e('span').note 3 + +e('li').sub-item + +e('a').link(href="#019") Введение в типы данных + +e('li').sub-item + +e('a').link(href="#020") Основные операторы + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#021") Инкремент и декремент, примеры + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#022") Результат присваивания + =" " + +e('span').note 3 + +e('li').sub-item + +e('a').link(href="#023") Операторы сравнения и логические значения + +e('li').sub-item + +e('a').link(href="#024") Побитовые операторы + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#025") Побитовый оператор и значение + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#026") Проверка, целое ли число + =" " + +e('span').note 3 + +e('li').sub-sub-item + +e('a').link(href="#027") Симметричны ли операции ^, |, &? + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#028") Почему результат разный? + =" " + +e('span').note 5 + +e('li').sub-item + +e('a').link(href="#029") Взаимодействие с пользователем: alert, prompt, confirm + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#030") Простая страница + =" " + +e('span').note 4 + +e('li').sub-item + +e('a').link(href="#031") Условные операторы: if, '?' + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#032") if(строка с нулем) + =" " + +e('span').note 5 + + +e.section.columns__col + +e('h2').col-title Документ, События, Интерфейсы + +e('ul').items + +e('li').item._collapsed + +e('a').link(href="#001") Общая информация + +e('ul').sub-items + +e('li').sub-item + +e('a').link(href="#002") Введение в JavaScript + +e('li').sub-item + +e('a').link(href="#003") Альтернативные браузерные технологии + +e('li').sub-item + +e('a').link(href="#004") Книги по JS, HTML/CSS и не только + +e('li').sub-item + +e('a').link(href="#005") Справочники + +e('li').sub-item + +e('a').link(href="#006") Редакторы для кода + +e('li').sub-item + +e('a').link(href="#007") SublimeText: шпаргалка + +e('li').sub-item + +e('a').link(href="#008") Книги по JS, HTML/CSS и не только + +e('li').sub-item + +e('a').link(href="#009") Установка браузеров, JS-консоль + +e('li').sub-item + +e('a').link(href="#010") Тестирование в старых браузерах + +e('li').sub-item + +e('a').link(href="#011") Привет, мир! + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#012") Alert + =" " + //- note может использоваться для любой дополняющей или поясняющей информации + +e('span').note 5 + +e('li').item + +e('a').link(href="#013") Основы JavaScript + +e('ul').sub-items + +e('li').sub-item + +e('a').link(href="#014") Структура кода + +e('li').sub-item + +e('a').link(href="#015") Переменные + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#016") Работа с переменными + =" " + +e('span').note 2 + +e('li').sub-item + +e('a').link(href="#017") Имена переменных + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#018") Объявление переменных + =" " + +e('span').note 3 + +e('li').sub-item + +e('a').link(href="#019") Введение в типы данных + +e('li').sub-item + +e('a').link(href="#020") Основные операторы + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#021") Инкремент и декремент, примеры + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#022") Результат присваивания + =" " + +e('span').note 3 + +e('li').sub-item + +e('a').link(href="#023") Операторы сравнения и логические значения + +e('li').sub-item + +e('a').link(href="#024") Побитовые операторы + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#025") Побитовый оператор и значение + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#026") Проверка, целое ли число + =" " + +e('span').note 3 + +e('li').sub-sub-item + +e('a').link(href="#027") Симметричны ли операции ^, |, &? + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#028") Почему результат разный? + =" " + +e('span').note 5 + +e('li').sub-item + +e('a').link(href="#029") Взаимодействие с пользователем: alert, prompt, confirm + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#030") Простая страница + =" " + +e('span').note 4 + +e('li').sub-item + +e('a').link(href="#031") Условные операторы: if, '?' + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#032") if(строка с нулем) + =" " + +e('span').note 5 + + +e.section.columns__col + +e('h2').col-title Дополнительные курсы + +e('ul').items + +e('li').item._collapsed + +e('a').link(href="#001") Общая информация + +e('ul').sub-items + +e('li').sub-item + +e('a').link(href="#002") Введение в JavaScript + +e('li').sub-item + +e('a').link(href="#003") Альтернативные браузерные технологии + +e('li').sub-item + +e('a').link(href="#004") Книги по JS, HTML/CSS и не только + +e('li').sub-item + +e('a').link(href="#005") Справочники + +e('li').sub-item + +e('a').link(href="#006") Редакторы для кода + +e('li').sub-item + +e('a').link(href="#007") SublimeText: шпаргалка + +e('li').sub-item + +e('a').link(href="#008") Книги по JS, HTML/CSS и не только + +e('li').sub-item + +e('a').link(href="#009") Установка браузеров, JS-консоль + +e('li').sub-item + +e('a').link(href="#010") Тестирование в старых браузерах + +e('li').sub-item + +e('a').link(href="#011") Привет, мир! + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#012") Alert + =" " + //- note может использоваться для любой дополняющей или поясняющей информации + +e('span').note 5 + +e('li').item._collapsed + +e('a').link(href="#013") Основы JavaScript + +e('ul').sub-items + +e('li').sub-item + +e('a').link(href="#014") Структура кода + +e('li').sub-item + +e('a').link(href="#015") Переменные + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#016") Работа с переменными + =" " + +e('span').note 2 + +e('li').sub-item + +e('a').link(href="#017") Имена переменных + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#018") Объявление переменных + =" " + +e('span').note 3 + +e('li').sub-item + +e('a').link(href="#019") Введение в типы данных + +e('li').sub-item + +e('a').link(href="#020") Основные операторы + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#021") Инкремент и декремент, примеры + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#022") Результат присваивания + =" " + +e('span').note 3 + +e('li').sub-item + +e('a').link(href="#023") Операторы сравнения и логические значения + +e('li').sub-item + +e('a').link(href="#024") Побитовые операторы + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#025") Побитовый оператор и значение + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#026") Проверка, целое ли число + =" " + +e('span').note 3 + +e('li').sub-sub-item + +e('a').link(href="#027") Симметричны ли операции ^, |, &? + =" " + +e('span').note 5 + +e('li').sub-sub-item + +e('a').link(href="#028") Почему результат разный? + =" " + +e('span').note 5 + +e('li').sub-item + +e('a').link(href="#029") Взаимодействие с пользователем: alert, prompt, confirm + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#030") Простая страница + =" " + +e('span').note 4 + +e('li').sub-item + +e('a').link(href="#031") Условные операторы: if, '?' + +e('ul').sub-sub-items + +e('li').sub-sub-item + +e('a').link(href="#032") if(строка с нулем) + =" " + +e('span').note 5 diff --git a/handlers/markup/templates/blocks/notification-message.jade b/handlers/markup/templates/blocks/notification-message.jade new file mode 100755 index 000000000..e41e8833f --- /dev/null +++ b/handlers/markup/templates/blocks/notification-message.jade @@ -0,0 +1,3 @@ ++b.notification._message._warning + +e.content Обрати внимание: сделано в Германии! + +e('button').close(title="Закрыть") diff --git a/handlers/markup/templates/blocks/notification-popup.jade b/handlers/markup/templates/blocks/notification-popup.jade new file mode 100755 index 000000000..70a504bf5 --- /dev/null +++ b/handlers/markup/templates/blocks/notification-popup.jade @@ -0,0 +1,3 @@ ++b.notification._popup._success + +e.content Действие выполнено успешно + +e('button').close(title="Закрыть") diff --git a/handlers/markup/templates/blocks/notification-stripe.jade b/handlers/markup/templates/blocks/notification-stripe.jade new file mode 100755 index 000000000..de0185262 --- /dev/null +++ b/handlers/markup/templates/blocks/notification-stripe.jade @@ -0,0 +1,4 @@ +//- __notification - потому что одновременно является элементом родителя, .sitetoolbar ++b.notification._top._info.__notification + +e.content Поздравляем, вы — миллионный посетитель! + +e('button').close(title="Закрыть") diff --git a/handlers/markup/templates/blocks/page-footer.jade b/handlers/markup/templates/blocks/page-footer.jade new file mode 100755 index 000000000..a556b6973 --- /dev/null +++ b/handlers/markup/templates/blocks/page-footer.jade @@ -0,0 +1,14 @@ ++b.page-footer + +e.left + +e("ul").list + - var year = new Date().getFullYear() + +e("li").item © 2007—!{year} Илья Кантор + +e("li").item + +e("a")(href="/about#contact-us").link связаться с нами + +e("li").item + +e("a")(href="/about").link о проекте + + +e.right + +e("ul").list + +e("li").item сделано на + +e("a")(href="/aaa").link io.js diff --git a/handlers/markup/templates/blocks/page-nav.jade b/handlers/markup/templates/blocks/page-nav.jade new file mode 100755 index 000000000..630e99f12 --- /dev/null +++ b/handlers/markup/templates/blocks/page-nav.jade @@ -0,0 +1,15 @@ +.page__nav-wrap + a.page__nav.page__nav_prev(href="#prev", title="назад") + span.page__nav-text + span.page__nav-text-shortcut + | Ctrl + + =" " + span.page__nav-text-arr ← + span.page__nav-text-alternate Предыдущий урок + a.page__nav.page__nav_next(href="#next", title="вперед") + span.page__nav-text + span.page__nav-text-shortcut + | Ctrl + + =" " + span.page__nav-text-arr → + span.page__nav-text-alternate Следующий урок diff --git a/handlers/markup/templates/blocks/payment-settings.jade b/handlers/markup/templates/blocks/payment-settings.jade new file mode 100644 index 000000000..f941491a3 --- /dev/null +++ b/handlers/markup/templates/blocks/payment-settings.jade @@ -0,0 +1,39 @@ +block append variables + +- var name = paymentMethod.name + ++b.payment-setting + if name == 'paypal' + +e.item._currency + +e('label').label(for="pay-form-currency") Выберите валюту: + +b('select').input-select._small.__control#pay-form-currency + +e('option').option(value="USD") USD + +e('option').option(value="RUR") RUR + +e('span').small-note + | Если у вас Paypal аккаунт в рублях, вы
    + | не сможете оплатить в другой валюте + + if name == 'invoice' + +e.item + +e('label').label(for="pay-form-company") Название компании: + +b('span').text-input._small + +e('input').control#pay-form-company + + +e.item._with_cb + +e('input').cb._invoice-need#pay-form-contract(type="checkbox") + +e('label').cb-label(for="pay-form-contract") + | Нужен договор + +e('span').small-note  (Договор заключается с компанией зарегистрированной в РФ) + + +e.item._hidden + +e('label').label(for="pay-form-contract-head") Шапка (для акта и договора): + +b('textarea').textarea-input.__textarea-head#pay-form-contract-head(cols="30" rows="10") ___, именуемое в дальнейшем Заказчик, в лице ___, действующего на основании ___, с одной стороны + +e.small-note Например: Общество с ограниченной ответственностью «Лютики», именуемое в дальнейшем Заказчик, в лице Иванова Петра Сергеевича, действующего на основании Устава, с одной стороны + + +e.item_hidden + +e('label').label(for="pay-form-company-address") Юридический адрес: + +b('textarea').textarea-input.__textarea-addr#pay-form-company-address(cols="30", rows="5") + + +e.item_hidden + +e('label').label(for="pay-form-bank-details") Банковские реквизиты: + +b('textarea').textarea-input.__textarea-bank#pay-form-bank-details(cols="30", rows="5") \ No newline at end of file diff --git a/handlers/markup/templates/blocks/profile-ok-cancel.jade b/handlers/markup/templates/blocks/profile-ok-cancel.jade new file mode 100755 index 000000000..6658eee27 --- /dev/null +++ b/handlers/markup/templates/blocks/profile-ok-cancel.jade @@ -0,0 +1,3 @@ ++e.ok-cancel + +b('button').submit-button._small.__item-save Сохранить + +e('button').item-cancel Отмена \ No newline at end of file diff --git a/handlers/markup/templates/blocks/profile-upic.jade b/handlers/markup/templates/blocks/profile-upic.jade new file mode 100755 index 000000000..85cff0f5b --- /dev/null +++ b/handlers/markup/templates/blocks/profile-upic.jade @@ -0,0 +1,21 @@ ++e.upic(style="background-image: url('/img/userpic.svg')") + +e.upic-edit Загрузить
    фотографию + ++e.upic._loading(style="background-image: url('/img/userpic.svg')") + +e.upic-edit Загрузить
    фотографию + +b('span').spinner._active._medium + +e.dot._1 + +e.dot._2 + +e.dot._3 + ++e.upic(style="background-image: url('/img/userpic-deleted.svg')") + +e.upic-edit Загрузить
    фотографию + ++e.upic(style="background-image: url('http://placehold.it/300x100')") + +e.upic-edit Загрузить
    фотографию + ++e.upic(style="background-image: url('http://placehold.it/200x600')") + +e.upic-edit Загрузить
    фотографию + ++e.upic(style="background-image: url('http://placehold.it/20x60')") + +e.upic-edit Загрузить
    фотографию diff --git a/handlers/markup/templates/blocks/quiz-explanations.jade b/handlers/markup/templates/blocks/quiz-explanations.jade new file mode 100755 index 000000000..30640125d --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-explanations.jade @@ -0,0 +1,4 @@ ++b.quiz-explanations + +e("h4").title !{ quiz.explanations.title } + each item, i in quiz.explanations.list + +e("li").item !{ item } diff --git a/handlers/markup/templates/blocks/quiz-question.jade b/handlers/markup/templates/blocks/quiz-question.jade new file mode 100755 index 000000000..fa1d0a426 --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-question.jade @@ -0,0 +1,54 @@ ++b(class="quiz-question "+( quiz.done ? (quiz.correct ? " _correct_true" : " _correct_false") : "")) + +e("form")(action="/asd").body + +e("h1").title !{ quiz.title } + +e("ul").variants + + each variant, index in quiz.variants + + +e("li")(class="variant " + ( quiz.correctNum == (index + 1) ? " _correct" : "")) + +e("label").label + +e("input").input(type=quiz.type name="variant" disabled = quiz.done ? true : false checked = quiz.selected == (index + 1) ? true : false) + +e("span").input-text !{ variant.title } + if variant.description + +e.description !{ variant.description } + + + + if quiz.note + +e("p").note !{ quiz.note } + + if !quiz.done + +e.submit + +b("button").button._action(disabled="disabled") + +e("span").text Продолжить + + + script. + (function() { + var isSpinner, + form = document.querySelector('.quiz-question__body'), + button = document.querySelectorAll('.button_action')[1]; + + + button && button.addEventListener('click', function() { + + this.classList.toggle('button_loading'); + + if (!isSpinner) { + this.insertAdjacentHTML( + 'beforeend', + '' + ); + + isSpinner = true; + } + }); + + + form.addEventListener('change', function() { + + button.hasAttribute('disabled') && + button.removeAttribute('disabled'); + + }); + })() diff --git a/handlers/markup/templates/blocks/quiz-result.jade b/handlers/markup/templates/blocks/quiz-result.jade new file mode 100755 index 000000000..61ce94470 --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-result.jade @@ -0,0 +1,57 @@ +block append variables + + -var quizResult = {}; + -quizResult.resultsLink = '/123' + -quizResult.try = '3' + -quizResult.percents = '62' + -quizResult.position = '25' + -quizResult.level = 'medium' // junior || senior + -quizResult.levelText = 'средний' + -quizResult.weakList = ['события', 'кроссбраузерность', 'замыкания'] + + -var rotate = parseInt((quizResult.percents * 1.8), 10) + 'deg' + + ++b.quiz-result + + +e.try + +e("span").try-num Попытка №!{ quizResult.try } + span ( + +e("a").prev-results(href='/2') предыдущие результаты + span ) + + +e.layout + +e.left + +b.quiz-percents + +e("dl").result + +e("dt").text Ваш результат: + +e("dd") + +e("p").percents !{ quizResult.percents }% + +e("dl").position + +e("dt").text Вы прошли текст лушче, чем + +e("dd") + +e("p").percents !{ quizResult.position }% + +e("p").text респондентов + + style. + .quiz-results-indicator__indicator:after + { + -webkit-transform: rotate(!{ rotate }); + -moz-transform: rotate(!{ rotate }); + -ms-transform: rotate(!{ rotate }); + -o-transform: rotate(!{ rotate }); + transform: rotate(!{ rotate }); + } + + +e.center + +b.quiz-results-indicator + +e.indicator + +e.text Ваш предположительный уровень — + span(class='quiz-results-indicator__level quiz-results-indicator__level_' + quizResult.level) !{ quizResult.levelText } + + +e.right + +b.quiz-weak-list + +e("h1").title Ваши слабые места: + +e("ul").list + for item in quizResult.weakList + +e("li").item !{ item } diff --git a/handlers/markup/templates/blocks/quiz-results-table.jade b/handlers/markup/templates/blocks/quiz-results-table.jade new file mode 100755 index 000000000..88ae9e32b --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-results-table.jade @@ -0,0 +1,31 @@ ++b.quiz-results-table + +e("table").results + + for result in profileTests + +e("tr").result + + +e("th").test-info + +e("time").time= result.date + +e("h1").name= result.name + + // Отложено до следующей версии + // +e("p").try Попытка №!{ result.try } + + +e("td").precents + +e("dl").precents-info + +e("dt").title + +e("h1").title-head Результат: + +e("dd").precents-value= result.result + + +e("td").level + +e("h1").title Уровень: + +e("p").level-info= result.level + + // Отложено до следующей версии + //- +e("td").weak-list + //- +e("h1").title Слабые места: + //- +e("p").weak-list-info !{ result.weakList } + + +e("td").time-spent + +e("h1").title Время прохождения: + +e("p").time-spent-info= result.time \ No newline at end of file diff --git a/handlers/markup/templates/blocks/quiz-selector.jade b/handlers/markup/templates/blocks/quiz-selector.jade new file mode 100755 index 000000000..25421c451 --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-selector.jade @@ -0,0 +1,14 @@ ++b.quiz-selector + +e("ul").list + each item, i in quiz.list + +e("li").item + +e.text + +e("h3").title !{ item.title } + !{ item.description } + +e.start + +e.start-i + +b("a")(href="/123").button._common + +e("span").text Пройти тестирование + if item.result + +e.result Предыдущий результат: !{ item.result } + diff --git a/handlers/markup/templates/blocks/quiz-start.jade b/handlers/markup/templates/blocks/quiz-start.jade new file mode 100755 index 000000000..4add0b4d3 --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-start.jade @@ -0,0 +1,9 @@ ++b.quiz-start + + +b("button").button._action + +e("span").text Начать тестирование + + +e("p").info + | Нажмите на кнопку ниже для того чтобы начать тестирование. + br + |Сразу после этого начнется отчет времени. \ No newline at end of file diff --git a/handlers/markup/templates/blocks/quiz-tablet-timeline.jade b/handlers/markup/templates/blocks/quiz-tablet-timeline.jade new file mode 100644 index 000000000..e24fb6e45 --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-tablet-timeline.jade @@ -0,0 +1,12 @@ +- var n = 0 + ++b.quiz-tablet-timeline.tablet-only + +e('h2').title Вопрос + +e('strong').num   + + while n < quiz.total + - n++ + if (n == quiz.current) + | !{quiz.current}  + | из  + +e('strong').total !{quiz.total} diff --git a/handlers/markup/templates/blocks/quiz-timeline.jade b/handlers/markup/templates/blocks/quiz-timeline.jade new file mode 100644 index 000000000..7d9c22ee2 --- /dev/null +++ b/handlers/markup/templates/blocks/quiz-timeline.jade @@ -0,0 +1,6 @@ +- var n = 0 + ++b.quiz-timeline + while n < quiz.total + - n++ + +e("span")(class="number" + (n == quiz.current ? '_current' : '')) !{ n } diff --git a/handlers/markup/templates/blocks/quiz.jade b/handlers/markup/templates/blocks/quiz.jade new file mode 100644 index 000000000..77b980f63 --- /dev/null +++ b/handlers/markup/templates/blocks/quiz.jade @@ -0,0 +1,4 @@ ++b.quiz + include ../blocks/quiz-timeline + include ../blocks/quiz-tablet-timeline + include ../blocks/quiz-question diff --git a/handlers/markup/templates/blocks/section-intro.jade b/handlers/markup/templates/blocks/section-intro.jade new file mode 100755 index 000000000..2efdee830 --- /dev/null +++ b/handlers/markup/templates/blocks/section-intro.jade @@ -0,0 +1,9 @@ +p + | Здесь мы рассмотрим создание интерфейсных компонент. Более коротко их называют «виджеты» (widgets). +p + | Предполагается, что к этому разделу вы уже знакомы с основами JavaScript, DOM, CSS. +p + | В этом разделе также понадобится библиотека jQuery. Мы используем ее для оптимизации основных операций с DOM/CSS. + | Если вы освоили учебник до этого момента, то уже знаете, как работать с ними средствами обычного JavaScript. + | Поэтому вас не постигнет участь горе-разработчиков, знающих «только jQuery» и впадающих в ступор при необходимости + | минимального выхода за возможности этой библиотеки. diff --git a/handlers/markup/templates/blocks/sidebar.jade b/handlers/markup/templates/blocks/sidebar.jade new file mode 100755 index 000000000..dd4a913f7 --- /dev/null +++ b/handlers/markup/templates/blocks/sidebar.jade @@ -0,0 +1,39 @@ +//- модификатор _sticky-footer делает последнюю секцию всегда прижатой к низу сайдбара ++b.sidebar._sticky-footer.page__sidebar + //- FIXME: инлайновый скрипт — демо и используется для более удобной отладки + +e('button').toggle(onclick="document.getElementsByClassName('page')[0].classList.toggle('page_sidebar_on')") + +e('a').show-map(href="#", data-action="tutorial-map", onclick="document.querySelector('body').classList.add('tutorial-map_on')") + +e.inner + +e.content + +e.section + +e('h4').section-title Раздел + +e('a').link(href="#") Основы JavaScript + +e.section + +e('h4').section-title Навигация по уроку + +e('nav').navigation + +e('ul').navigation-links + +e('li').navigation-link + +e('a').link(href="#") alert + +e('li').navigation-link + +e('a').link(href="#") prompt + +e('li').navigation-link + +e('a').link(href="#") confirm + +e('li').navigation-link + +e('a').link(href="#") Особенности встроенных функций + +e.section._separator_before + +e('nav').navigation + +e('ul').navigation-links + +e('li').navigation-link + +e('a').link(href="#") Задачи (2) + +e('li').navigation-link + +e('a').link(href="#") Комментарии (12) + +e.section._share + +e.section-title Поделиться + +b('a').share._tw.sidebar__share(href="https://twitter.com/share?url=http://design.javascript.ru/intro") + +b('a').share._fb.sidebar__share(href="http://www.facebook.com/sharer/sharer.php?s=100&p[url]=http://design.javascript.ru/intro") + +b('a').share._gp.sidebar__share(href="https://plus.google.com/share?url=http://design.javascript.ru/intro") + +b('a').share._vk.sidebar__share(href="http://vkontakte.ru/share.php?url=http://design.javascript.ru/intro") + +e.section + button(onclick="this.parentNode.innerHTML=new Array(200).join('* ')") click me to add content + +e.section(hidden) + +e('a').link(href="http://github.com/iliakan/javascript-nodejs") Редактировать на github diff --git a/handlers/markup/templates/blocks/sitetoolbar-login-loaders.jade b/handlers/markup/templates/blocks/sitetoolbar-login-loaders.jade new file mode 100755 index 000000000..11a5d3fde --- /dev/null +++ b/handlers/markup/templates/blocks/sitetoolbar-login-loaders.jade @@ -0,0 +1,65 @@ ++b.sitetoolbar + if layout.notificationStripe + include ../blocks/notification-stripe + +e.content + +e.logo-wrap + +e('a').link._logo(href="/") + +e('embed').logo(src="/i/sitetoolbar__logo.svg") + +e.nav-toggle-wrap + //- FIXME: onclick - демо, убрать + //- при начале скролла тут же исчезает, для этого body += .sitetoolbar_hidden + +e('button').nav-toggle(onclick="document.querySelector('.sitetoolbar').classList.toggle('sitetoolbar_menu_open')") + +e('nav').sections + +e('ul').sections-list + +e('li').section._current + //- элемент с модификатором _current может содержать как ссылку, + //- так и простой текст, выглядеть будет одинаково + | Учебник + =' ' + +e('li').section + +e('a').link(href="/courses") Курсы + =' ' + +e('li').section + +e('a').link(href="/spec") Стандарт ES5 + =' ' + +e('li').section + +e('a').link(href="/tests") Тестирование + +e.login-wrap + //- делаем логин кнопкой, так как она только раскрывает форму + //- и никуда не ведет. Если предполагается функциональность ссылки + //- потребуются минимальные доработки + +e('button').login(onclick="document.querySelector('.sitetoolbar__spinner').classList.toggle('spinner_active')") + | Вход / Регистрация + +e.search-wrap + +e('button').search-toggle(onclick="document.querySelector('.sitetoolbar').classList.toggle('sitetoolbar_search_open')") + +e.tablet-menu + +e.tablet-menu-line + +e.tablet-menu-header + +e.tablet-menu-title Навигация по сайту: + +e.tablet-menu-content + +e('select').tablet-menu-nav.input-select + +e('option') Учебник + +e('option') Курсы + +e('option') Стандарт ES5 + +e('option') Тестирование + +e.tablet-menu-aside + +e('a').secondary-link(href="#full-content") Карта учебника + +e.tablet-menu-line + +e.tablet-menu-header + +e.tablet-menu-title Поделиться + +e.tablet-menu-content + +b('a').share._tw.sitetoolbar__tablet-menu-share(href="https://twitter.com/share?url=http://design.javascript.ru/intro") + +b('a').share._fb.sitetoolbar__tablet-menu-share(href="http://www.facebook.com/sharer/sharer.php?s=100&p[url]=http://design.javascript.ru/intro") + +b('a').share._gp.sitetoolbar__tablet-menu-share(href="https://plus.google.com/share?url=http://design.javascript.ru/intro") + +b('a').share._vk.sitetoolbar__tablet-menu-share(href="http://vkontakte.ru/share.php?url=http://design.javascript.ru/intro") + +e.search + +e('form').search-content(action="/search/") + +e.search-query-wrap + +b.text-input.__search-query + +e('input').control(type="text" name="query") + +e.search-submit-wrap + +b('button').submit-button.__search-submit(type="submit") Найти + +b('span').spinner._medium.__spinner + +e('span').dot._1 + +e('span').dot._2 + +e('span').dot._3 diff --git a/handlers/markup/templates/blocks/sitetoolbar.jade b/handlers/markup/templates/blocks/sitetoolbar.jade new file mode 100755 index 000000000..5e4d296df --- /dev/null +++ b/handlers/markup/templates/blocks/sitetoolbar.jade @@ -0,0 +1,76 @@ ++b.sitetoolbar + if layout.notificationStripe + include ../blocks/notification-stripe + +e.content + +e.logo-wrap + +e('a').link._logo(href="/") + +e('embed').logo(src="/i/sitetoolbar__logo.svg") + +e.nav-toggle-wrap + //- FIXME: onclick - демо, убрать + //- при начале скролла тут же исчезает, для этого body += .sitetoolbar_hidden + +e('button').nav-toggle(onclick="document.querySelector('.sitetoolbar').classList.toggle('sitetoolbar_menu_open')") + +e('nav').sections + +e('ul').sections-list + +e('li').section._current + //- элемент с модификатором _current может содержать как ссылку, + //- так и простой текст, выглядеть будет одинаково + | Учебник + =' ' + +e('li').section + +e('a').link(href="/courses") Курсы + =' ' + +e('li').section + +e('a').link(href="/spec") Стандарт ES5 + =' ' + +e('li').section + +e('a').link(href="/tests") Тестирование + +e.user-wrap + //- делаем пользователя кнопкой, так как она только раскрывает меню + //- и никуда не ведет. Если предполагается функциональность ссылки + //- потребуются минимальные доработки + +e('button').user(title="Very very long nickname with spaces", onclick="document.querySelector('.sitetoolbar').classList.toggle('sitetoolbar_user_open')") + +e('img').userpic(src="/img/markup/sitetoolbar-userpic.png", alt="Very very long nickname with spaces", width="36", height="36") + +e('span').user-text + | Very very long nickname with spaces + + +e.search-wrap + +e.search-content + + +e('form').search(method="GET", action="/search") + +e('button').search-toggle(type="button") + +e.search-input + +b.text-input + +e('input').control(name="query" placeholder="Искать на Javascript.ru" ) + +e('button').find(type="submit") Найти + + +e.tablet-menu + +e.tablet-menu-line + +e.tablet-menu-header + +e.tablet-menu-title Навигация по сайту: + +e.tablet-menu-content + +e('select').tablet-menu-nav.input-select + +e('option') Учебник + +e('option') Курсы + +e('option') Стандарт ES5 + +e('option') Тестирование + +e.tablet-menu-aside + +e('a').secondary-link(href="#full-content") Полное содержание + +e.tablet-menu-line + +e.tablet-menu-header + +e.tablet-menu-title Поделиться + +e.tablet-menu-content + +b('a').share._tw.sitetoolbar__tablet-menu-share(href="https://twitter.com/share?url=http://design.javascript.ru/intro") + +b('a').share._fb.sitetoolbar__tablet-menu-share(href="http://www.facebook.com/sharer/sharer.php?s=100&p[url]=http://design.javascript.ru/intro") + +b('a').share._gp.sitetoolbar__tablet-menu-share(href="https://plus.google.com/share?url=http://design.javascript.ru/intro") + +b('a').share._vk.sitetoolbar__tablet-menu-share(href="http://vkontakte.ru/share.php?url=http://design.javascript.ru/intro") + + +e.usermenu + +e('ul').usermenu-items + +e('li').usermenu-item + +e('a').secondary-link.__usermenu-link(href="#profile") Публичный профиль + +e('li').usermenu-item + +e('a').secondary-link.__usermenu-link(href="#settings") Аккаунт + +e('li').usermenu-item + +e('a').secondary-link.__usermenu-link(href="#orders") Заказы + +e('li').usermenu-item + +e('a').secondary-link.__usermenu-link(href="#logout") Выйти diff --git a/handlers/markup/templates/blocks/task-single.jade b/handlers/markup/templates/blocks/task-single.jade new file mode 100755 index 000000000..2580f7254 --- /dev/null +++ b/handlers/markup/templates/blocks/task-single.jade @@ -0,0 +1,70 @@ ++b.task-single + +e('a').back(href="#lesson") + span вернуться к уроку + +b.task.__task + +e.header + +e.title-wrap + +e('h2').title DOM Children + +e.header-note + +e('span').importance(title="Насколько эта задача важна для освоения материала, от 1 до 5") важность: 4 + +e('button').solution(type="button", onclick="toggleSolution(this)") решение + +e.content + +e.answer + +e.step._open + +e('button').step-show(type="button", onclick="showStep(this)") Шаг 1 + +e.answer-content + +e('h4').step-title Шаг 1 + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + +e.step + +e('button').step-show(type="button", onclick="showStep(this)") Шаг 2 + +e.answer-content + +e('h4').step-title Шаг 2 + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + +e.step + +e('button').step-show(type="button", onclick="showStep(this)") Шаг 3 + +e.answer-content + +e('h4').step-title Шаг 3 + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + +b('button').close-button.__answer-close(type="button", title="закрыть", onclick="toggleSolution(this)") + p + | Для страницы: + pre.line-numbers.language-javascript + | <!DOCTYPE html> + | <html> + | <head> + | <title>Задача</title> + | <meta charset="utf-8"> + | </head> + | <body> + | <div>Пользователи:</div> + | <ul> + | <li>Маша</li> + | <li>Вовочка</li> + | </ul> + | <!-- комментарий --> + | <script> + | // ваш код + | </script> + | </body> + | </html> + ul + li Напишите код, который получит элемент HTML. + li Напишите код, который получит UL. + li + | Напишите код, который получит второй LI. Будет ли ваш код работать в IE8-, если + | комментарий переместить между элементами LI? + p + a(href="#source") Открыть исходный документ diff --git a/handlers/markup/templates/blocks/tasks.jade b/handlers/markup/templates/blocks/tasks.jade new file mode 100755 index 000000000..ce9d6c261 --- /dev/null +++ b/handlers/markup/templates/blocks/tasks.jade @@ -0,0 +1,87 @@ ++b.tasks + +e('h2').title#tasks + +e('a').title-anchor.main__anchor.main__anchor_noicon(href="#tasks") Задачи + +b.task.__task + +e.header + +e.title-wrap + +e('h3').title#task1 + a.main__anchor(href="#task1") Использование prompt и alert + +e('a').open-link(href="/tasks/task1", target="_blank") + +e.header-note + +e('span').importance(title="Насколько эта задача важна для освоения материала, от 1 до 5") важность: 4 + +e('button').solution(type="button") решение + +e.content + +e.answer + +e.answer-content + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + +b('button').close-button.__answer-close(type="button", title="закрыть") + p + | Создайте страницу, которая спрашивает имя и выводит его.
    + | Работать должно так: + =" " + a(href="/tutorial/intro/basic.html") /tutorial/intro/basic.html + +b.task.__task + +e.header + +e.title-wrap + +e('h3').title#task2 + a.main__anchor(href="#task2") Использование prompt и alert + +e('a').open-link(href="/tasks/task2", target="_blank") + +e.header-note + +e('span').importance(title="Насколько эта задача важна для освоения материала, от 1 до 5") важность: 4 + +e('button').solution(type="button") решение + +e.content + +e.answer + +e.step._open + +e('button').step-show(type="button") Шаг 1 + +e.answer-content + +e('h4').step-title Шаг 1 + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + +e.step + +e('button').step-show(type="button") Шаг 2 + +e.answer-content + +e('h4').step-title Шаг 2 + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + +e.step + +e('button').step-show(type="button") Шаг 3 + +e.answer-content + +e('h4').step-title Шаг 3 + p + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Animi sequi officiis modi, + | eum nisi quas! Aspernatur ea repellendus ipsam cumque cupiditate voluptates soluta, + | itaque explicabo voluptatibus! Vitae quas fugit culpa. + +b('button').close-button.__answer-close(type="button", title="закрыть") + p Напишите кроссбраузерную функцию insertBefore(elem, html), которая: + ul + li Вставляет HTML-строку перед элементом elem, используя insertAdjacentHTML, + li Если он не поддерживается (старый Firefox) — то через DocumentFragment. + p + | В обоих случаях должна быть лишь одна операция с DOM документа.
    + | Следующий код должен вставить два пропущенных элемента списка <li>3</li><li>4</li>: + + pre.language-javascript.line-numbers + | <ul> + | <li>1</li> + | <li>2</li> + | <li>5</li> + | </ul> + | <script> + | var ul = document.body.children[0]; + | var li5 = ul.children[2]; + | function insertBefore(elem, html) { + | /* Ваш код */ + | } + | insertBefore(li5, '<li>3</li><li>4</li>'); + | </script> diff --git a/handlers/markup/templates/example/user.jade b/handlers/markup/templates/example/user.jade new file mode 100755 index 000000000..3970dae38 --- /dev/null +++ b/handlers/markup/templates/example/user.jade @@ -0,0 +1,11 @@ +//- Статическое объявление переменных шаблона +//- с описаниями, что есть что и зачем :) +-var name = "Vasya" + +doctype html + +html + head + title my jade template + body + h1 Hello #{name} diff --git a/handlers/markup/templates/layouts/base.jade b/handlers/markup/templates/layouts/base.jade new file mode 100755 index 000000000..d0502856a --- /dev/null +++ b/handlers/markup/templates/layouts/base.jade @@ -0,0 +1,63 @@ +doctype html +include /bem + +html + head + - var self = {} + //- по умолчанию все отключено, в шаблоне в секции переменных + //- мы сразу видим набор реально используемых фрагментов + - var layout = {} + - layout.sitetoolbar = false + - layout.prevNext = false + - layout.sidebar = false + - layout.articleFoot = false + - layout.centeredHeader = false + - layout.tutorialMap = false + - layout.header = false + - layout.breadcrumbs = false + - layout.notificationPopup = false + - layout.notificationStripe = false + - layout.bodyClass = "" + + block variables + include ../blocks/head + body.no-icons(class= layout.bodyClass != "" ? layout.bodyClass : undefined) + .page-wrapper(class=[sidebar && 'page-wrapper_sidebar_on']) + script head.fontTest(); + if layout.notificationPopup + include ../blocks/notification-popup + if layout.sitetoolbar + include ../blocks/sitetoolbar + //- include ../blocks/sitetoolbar-login-loaders + .page(class=[sidebar && 'page_sidebar_on', layout_page_class]) + if layout.prevNext + include ../blocks/page-nav + if layout.sidebar + include ../blocks/sidebar + .page__inner + main(class=(mainclass ? mainclass : 'main') + ' ' + [layout_main_class]) + //- отключается только на странице задачи, по возможности отрефакторить + if layout.header + header.main__header(class= layout.centeredHeader == true ? "main__header_center" : undefined) + if layout.breadcrumbs + include ../blocks/breadcrumbs + h1.main__header-title!= self.title + block content + if layout.articleFoot + include ../blocks/article-foot + include ../blocks/corrector + include ../blocks/comments + include ../blocks/page-footer + if layout.tutorialMap + //- блок map должен подгружаться динамически + //- подключен для демонстрации и отладки + //- сделан в виде страницы а не блока чтобы был доступен + //- по собственному url (/markup/pages/map) + .tutorial-map-overlay + include ../blocks/map + +b('button').close-button.tutorial-map-overlay__close + script(src=pack("footer", "js")) + script footer.init(); + + script(src=pack("tutorial", "js")) + script tutorial.init(); diff --git a/handlers/markup/templates/layouts/profile.jade b/handlers/markup/templates/layouts/profile.jade new file mode 100755 index 000000000..caf40b7c6 --- /dev/null +++ b/handlers/markup/templates/layouts/profile.jade @@ -0,0 +1,56 @@ +doctype html +include /bem + +html + head + - var self = {} + //- по умолчанию все отключено, в шаблоне в секции переменных + //- мы сразу видим набор реально используемых фрагментов + - var layout = {} + - layout.sitetoolbar = false + - layout.prevNext = false + - layout.sidebar = false + - layout.articleFoot = false + - layout.centeredHeader = false + - layout.tutorialMap = false + - layout.header = false + - layout.notificationPopup = false + - layout.notificationStripe = false + - layout.bodyClass = "" + + block variables + include ../blocks/head + body.no-icons(class= layout.bodyClass != "" ? layout.bodyClass : undefined) + script head.fontTest(); + if layout.notificationPopup + include ../blocks/notification-popup + if layout.sitetoolbar + include ../blocks/sitetoolbar + //- include ../blocks/sitetoolbar-login-loaders + .page + .page__inner + .main + //- отключается только на странице задачи, по возможности отрефакторить + if layout.header + header.main__header(class= layout.centeredHeader == true ? "main__header_center" : undefined) + include ../blocks/breadcrumbs + h1.main__header-title!= self.title + block content + if layout.articleFoot + include ../blocks/article-foot + include ../blocks/corrector + include ../blocks/comments + //include ../blocks/page-footer + if layout.tutorialMap + //- блок map должен подгружаться динамически + //- подключен для демонстрации и отладки + //- сделан в виде страницы а не блока чтобы был доступен + //- по собственному url (/markup/pages/map) + .tutorial-map-overlay + include ../blocks/map + +b('button').close-button.tutorial-map-overlay__close + script(src=pack("footer", "js")) + script footer.init(); + + script(src=pack("tutorial", "js")) + script tutorial.init(); diff --git a/handlers/markup/templates/pages/403.jade b/handlers/markup/templates/pages/403.jade new file mode 100755 index 000000000..39b724557 --- /dev/null +++ b/handlers/markup/templates/pages/403.jade @@ -0,0 +1,15 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Доступ к этой странице закрыт'; + - layout.sitetoolbar = true; + +block content + +b.error + +e('h1').type Доступ к этой странице закрыт + +e.code 403 + +e.text + | Возможно, вам нужно + =" " + +e('button').button-link.__login-button залогиниться \ No newline at end of file diff --git a/handlers/markup/templates/pages/404.jade b/handlers/markup/templates/pages/404.jade new file mode 100755 index 000000000..3da493954 --- /dev/null +++ b/handlers/markup/templates/pages/404.jade @@ -0,0 +1,24 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Страница не найдена'; + - layout.sitetoolbar = true; + +block content + +b.error + +e('h1').type Страница не найдена + +e.code 404 + +e.text + | Для того, чтобы найти нужную вам страницу, вы можете воспользоваться поиском: + +e.text + +e('form').search(action="#") + +e.search-query-wrap + +b.text-input._small.__search-query + +e('input').control(type="text", name="error-search-query") + +e.search-submit-wrap + +b('button').submit-button._small.__search-submit Найти + +e.text + | или + =" " + +e('a').tutorial-map(href="/tutorial/map", data-action="tutorial-map") картой сайта \ No newline at end of file diff --git a/handlers/markup/templates/pages/500.jade b/handlers/markup/templates/pages/500.jade new file mode 100755 index 000000000..ab0253f52 --- /dev/null +++ b/handlers/markup/templates/pages/500.jade @@ -0,0 +1,12 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Ошибка на сервере'; + - layout.sitetoolbar = true; + +block content + +b.error + +e('h1').type Ошибка на сервере + +e.code 500 + +e.text Мы уже работаем над устранением ошибки diff --git a/handlers/markup/templates/pages/about.jade b/handlers/markup/templates/pages/about.jade new file mode 100755 index 000000000..7b6a0d225 --- /dev/null +++ b/handlers/markup/templates/pages/about.jade @@ -0,0 +1,154 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block append variables + - self.headTitle = 'Современный учебник Javascript'; + + //- layout + - layout.sitetoolbar = true + - layout.header = false + - var layout_page_class = "page_contains_header" + - var mainclass = "main-headered" + +block content + +b.about-banner + +e("header").header + +e("h1").title + проект + br + +e("span").name Javascript.ru + + +e("hr").line + + +b.about-list + +e("ul").list + +e("li").item + +e("h2").num 2 + +e("p").description + | конференции + br + | JS. Talks + + +e("li").item + +e("h2").num 4940 + +e("p").description + | участников очных + br + | JS. Talks + + +e("li").item + +e("h2").num 1255 + +e("p").description + | участников + br + | дистанционного обучения + + +e("li").item + +e("h2").num >282000 + +e("p").description + | посетителей в месяц + br + | (на основе последнего года) + + +e("li").item + +e("h2").num >24000 + +e("p").description + | строк на js в + br + | open-source коде сайта + + +e("li").item + +e("h2").num >93000 + +e("p").description + | строк в учебнике + br + | Javascript + + +b.about-layout.columns.columns_2 + + +b.about-text.about-layout__left.columns__col + +e('h1').title О проекте + +e.body + p Javascript.ru был запущен в 2007 году и с тех пор стал одним из крупнейших русскоязычных порталов по JavaScript. Сегодня основные цели проекта это: + +e('ol').list + +e('li').item Предоставлять грамотную и актуальную информацию по JavaScript и смежным технологиям. + +e('li').item Популяризировать современные фронтенд-технологии + +e('li').item Проводить онлайн и оффлайн-мероприятия по обучению JavaScript. + +e('li').item Создание сообщества JS-разработчиков и обмен знаниями + p + | Код этого сайта и содержимое учебника по Javascript находиться в open-source доступе и его можно посмотреть на  + a(href="/123") github + + + +b.about-text.about-layout__right.columns__col + +e('h1').title Люди + +e.body + +e('ul').humans + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/iliakan.jpg") + +e('h3').human-title Илья Кантор + +e('p').human-role Главный координатор, лектор, JS-разработчик + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/bezart.jpg") + +e('h3').human-title Артем Безценный + +e('p').human-role UX-дизайнер + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/tyv.jpg") + +e('h3').human-title Юрий Ткаченко + +e('p').human-role На дуде игрец + + +e('li').human + +e('i').human-userpic + +e('img').human-userpic-i(src="/about/amax.jpg") + +e('h3').human-title Алексей Максимов + +e('p').human-role Админ + + + +b.about-map + +e('h1').title География офлайн событий + +e('h1').map-container#map + style. + .leaflet-map-pane { + z-index: 2 !important; + } + + .leaflet-google-layer { + z-index: 1 !important; + } + link(rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.css") + + script(src="https://maps.googleapis.com/maps/api/js?v=3.exp") + + script(src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.js") + + script + include circles2.js + + +b.about-layout.columns.columns_2 + + +b.about-text.about-layout__left.columns__col + +e('h1').title Принять участие в проекте + +e.body + p + | Если у вас есть идеи по улучшению работы сайта либо содержимого учебника по Javascript, не стесняйтесь присылать их нам либо заходите на наш  + a(href="/123") github + + + +b.about-text.about-layout__right.columns__col + +e('h1').title Обратная связь + +e.body._center + p + | Илья Кантор + p + a(href="mailto:iliakan@javascript.ru") + iliakan@javascript.ru + br + | +79035419441 + + diff --git a/handlers/markup/templates/pages/article.html b/handlers/markup/templates/pages/article.html new file mode 100755 index 000000000..69614835e --- /dev/null +++ b/handlers/markup/templates/pages/article.html @@ -0,0 +1,529 @@ +

    Давайте посмотрим, что такого особенного в JavaScript, почему именно он, и какие еще технологии существуют, кроме + JavaScript.

    + +

    Что такое JavaScript?

    + +

    JavaScript изначально создавался для того, чтобы сделать web-странички «живыми».
    + Программы на этом языке называются скриптами. Они подключаются напрямую к HTML и, как только загружается + страничка — тут же выполняются.

    +

    ECMAScript + намылить новая вкладка песочница песочница plnkr.co документ архив таблица pdf-документ справка в MDN справка microsoft документация w3c спецификация ECMA + + +

    +

    ECMAScript намылить новая вкладка песочница песочница plnkr.co документ архив таблица pdf-документ справка в MDN справка microsoft документация w3c спецификация ECMA

    +

    ECMAScript + намылить новая вкладка песочница песочница plnkr.co документ + архив таблица pdf-документ + справка в MDN справка microsoft документация + w3c спецификация ECMA

    +

    Программы на JavaScript — обычный текст. Они не требуют какой-то специальной подготовки.

    +

    А здесь мы приведем пример сочетания клавиш Cmd + R + прямо в тексте урока.

    +

    Примеры кода и результата, которые должны создаваться динамически:

    + + +
    <div class="code-example">
    +    <div class="codebox code-example__codebox">
    +        <div class="toolbar codebox__toolbar">
    +            <div class="toolbar__tool">
    +                <a href="/play/abcdef" class="toolbar__button toolbar__button_run" title="выполнить"></a>
    +            </div>
    +            <div class="toolbar__tool">
    +                <a href="/files/file.doc" class="toolbar__button toolbar__button_edit" title="редактировать"></a>
    +            </div>
    +        </div>
    +<pre class="language-javascript line-numbers">function sayHi(name) {
    +  var phrase = "Привет, " + name;
    +  alert(phrase);
    +}
    +
    +sayHi('Вася');</pre>
    +    </div>
    +
    +    <div class="code-result code-example__result">
    +        <div class="toolbar code-result__toolbar">
    +            <div class="toolbar__tool">
    +                <a href="/files/file.doc" class="toolbar__button toolbar__button_edit" title="редактировать"></a>
    +            </div>
    +        </div>
    +        <iframe class="code-result__iframe" src="http://sass-lang.com/documentation/Sass/Script/Functions"></iframe>
    +    </div>
    +</div>
    + +

    Пример кода

    + +
    function sayHi(name) {
    +  var phrase = "Привет, " + name;
    +  alert(phrase);
    +}
    +
    +function HelloWorld(world) {
    +    alert('Hello, ' + world);
    +}
    +
    +HelloWorld('World');
    +
    +sayHi('Вася');
    + +

    Пример самостоятельного результата, без кода, такой код должен создаваться динамически в js

    +
    <div class="code-result">
    +    <div class="toolbar code-result__toolbar">
    +        <div class="toolbar__tool">
    +            <a href="/files/file.doc" class="toolbar__button toolbar__button_edit" title="редактировать"></a>
    +        </div>
    +    </div>
    +    <iframe class="code-result__iframe" src="http://sass-lang.com/documentation/Sass/Script/Functions"></iframe>
    +</div>
    + +

    В этом плане JavaScript сильно отличается от другого языка, который называется Java.

    + +

    Выделенный блок с информацией.

    + +
    +
    + + +

    Почему JavaScript?

    +
    +
    +

    Когда создавался язык JavaScript, у него изначально было другое название: «LiveScript». Но тогда был очень + популярен язык Java, и маркетологи решили, что схожее название сделает новый язык более популярным.

    + +

    Планировалось, что JavaScript будет эдаким «младшим братом» Java. Однако, история распорядилась по-своему, + JavaScript сильно вырос, и сейчас это совершенно независимый язык, со своей спецификацией, которая называется ECMAScript, и к Java не имеет никакого отношения.

    + +

    У него много особенностей, которые усложняют освоение, но по ходу учебника мы с ними разберемся.

    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    XMLHttpRequestIFRAMESCRIPTEventSourceWebSocket
    Кросс-доменностьда, кроме IE<10x1да, сложности в IE<8i1дадада
    МетодыЛюбыеGET / POSTGETGETСвой протокол
    COMETДлинные опросыx2Непрерывное соединениеДлинные опросыНепрерывное соединениеНепрерывное соединение в обе стороны
    ПоддержкаВсе браузеры, ограничения в IE<10x3Все браузерыВсе браузерыКроме IEIE 10, FF11, Chrome 16, Safari 6, Opera 12.5w1
    + +

    Выделенный блок c предупреждением

    + +
    +
    + Важно: + +

    Почему JavaScript?

    +
    +
    +

    Когда создавался язык JavaScript, у него изначально было другое название: «LiveScript». Но тогда был очень + популярен язык Java, и маркетологи решили, что схожее название сделает новый язык более популярным.

    + +

    Планировалось, что JavaScript будет эдаким «младшим братом» Java. Однако, история распорядилась по-своему, + JavaScript сильно вырос, и сейчас это совершенно независимый язык, со своей спецификацией, которая называется ECMAScript, и к Java не имеет никакого отношения.

    + +

    У него много особенностей, которые усложняют освоение, но по ходу учебника мы с ними разберемся.

    +
    +
    + +

    Выделенный блок c вопросом

    + +
    +
    + Вопрос: + +

    Почему JavaScript?

    +
    +
    +

    Когда создавался язык JavaScript, у него изначально было другое название: «LiveScript». Но тогда был очень + популярен язык Java, и маркетологи решили, что схожее название сделает новый язык более популярным.

    + +

    Планировалось, что JavaScript будет эдаким «младшим братом» Java. Однако, история распорядилась по-своему, + JavaScript сильно вырос, и сейчас это совершенно независимый язык, со своей спецификацией, которая называется ECMAScript, и к Java не имеет никакого отношения.

    + +

    У него много особенностей, которые усложняют освоение, но по ходу учебника мы с ними разберемся.

    +
    +
    + +

    Выделенный блок c ответом

    + +

    Чтобы читать и выполнять текст на JavaScript, нужна специальная программа — интерпретатор. + Процесс выполнения скрипта называют «интерпретацией».

    + +
    +
    + На заметку: + +

    Компиляция и интерпретация, для программистов

    +
    +
    +

    Строго говоря, для выполнения программ существуют «компиляторы» и «интерпретаторы».

    + +

    Когда-то между ними была большая разница. Компиляторы преобразовывали программу в машинный код, который потом + можно выполнять. А интерпретаторы — просто выполняли.

    + +

    Сейчас разница гораздо меньше. Современные интерпретаторы перед выполнением преобразуют JavaScript в машинный код + (или близко к нему), чтобы выполнялся он быстрее. То есть, по сути, компилируют, а затем запускают.

    +
    + +
    +

    Результат - ошибка. Попробуйте:

    + +
    var a = 5;
    +(function() {
    +  alert(a)
    +})()
    + +

    Дело в том, что после var a = 5 нет точки с запятой.

    + +

    JavaScript воспринимает этот код как если бы перевода строки не было:

    + +
    var a = 5;
    +(function() {
    +  alert(a)
    +})()
    + +

    То есть, он пытается вызвать функцию 5, что и приводит к ошибке.

    + +

    Если точку с запятой поставить, все будет хорошо:

    + +
    var a = 5;
    +(function() {
    +  alert(a)
    +})()
    + +

    Это один из наиболее частых и опасных подводных камней, приводящих к ошибкам тех, кто не ставит + точки с запятой.

    +
    +
    +
    + +
    +

    К одной задаче могут быть добавлены одно или несколько решений. Решения, идущие подряд, «стыкуются» без + промежутков

    +
    +
    +
    +
    + +

    Во все основные браузеры встроен интерпретатор JavaScript, именно поэтому они могут выполнять скрипты на + странице.

    + +

    Но, разумеется, этим возможности JavaScript не ограничены. Это полноценный язык, программы на котором можно запускать + и на сервере, и даже в стиральной машинке, если в ней установлен соответствующий интерпретатор.

    + +

    Что умеет JavaScript?

    +

    Подзаголовок

    +

    Подподзаголовок

    + +

    Современный JavaScript — это «безопасный» язык программирования общего назначения. Он не предоставляет + низкоуровневых средств работы с памятью, процессором, так как изначально был ориентирован на браузеры, в которых это + не требуется.

    +

    В браузере JavaScript умеет делать все, что относится к манипуляции со страницей, взаимодействию с посетителем и, в + какой-то мере, с сервером:

    +
      +
    • Создавать новые HTML-теги, удалять существующие, менять стили элементов, прятать, показывать элементы и т.п.
    • +
    • Реагировать на действия посетителя, обрабатывать клики мыши, перемещение курсора, нажатие на клавиатуру и т.п. +
    • +
    • Посылать запросы на сервер и загружать данные без перезагрузки страницы(эта технология называется "AJAX").
    • +
    • Получать и устанавливать cookie, запрашивать данные, выводить сообщения…
    • +
    • …и многое, многое другое!
    • +
    + +

    Что НЕ умеет JavaScript? +

    +

    JavaScript — быстрый и мощный язык, но браузер накладывает на его исполнение некоторые + ограничения.

    +

    Это сделано для безопасности пользователей, чтобы злоумышленник не мог с помощью JavaScript получить личные данные + или как-то навредить компьютеру пользователя. В браузере Firefox существует способ «подписи» скриптов с целью обхода + части ограничений, но он нестандартный и не кросс-браузерный.

    + +

    Спойлеры вне блока .important

    + +
    + +
    +

    Результат - ошибка. Попробуйте:

    + +
    var a = 5
    +
    +(function() {
    +  alert(a);
    +})()
    + +

    Дело в том, что после var a = 5 нет точки с запятой.

    + +

    JavaScript воспринимает этот код как если бы перевода строки не было:

    + +
    var a = 5(function() {
    +  alert(a)
    +})()
    + +

    То есть, он пытается вызвать функцию 5, что и приводит к ошибке.

    + +

    Если точку с запятой поставить, все будет хорошо:

    + +
    var a = 5;
    +
    +(function() {
    +  alert(a)
    +})()
    + +

    Это один из наиболее частых и опасных подводных камней, приводящих к ошибкам тех, кто не ставит точки с + запятой.

    +
    +
    +
    + +
    +

    К одной задаче могут быть добавлены одно или несколько решений. Решения, идущие подряд, «стыкуются» без + промежутков

    +
    +
    + +

    Этих ограничений нет там, где JavaScript используется вне браузера, например на сервере.

    +

    Большинство возможностей JavaScript в браузере ограничено текущим окном и страницей.

    + +
    +
    + +
    + +
    +
    + +
      +
    • +

      JavaScript не может читать/записывать произвольные файлы на жесткий диск, копировать их или вызывать программы. + Он не имеет прямого доступа к операционной системе.

      + +

      Современные браузеры могут работать с файлами, но эта возможность ограничена специально выделенной + директорией — песочницей. Возможности по доступу к устройствам также прорабатываются в современных + стандартах и, частично, доступны в некоторых браузерах.

    • +
    • +

      JavaScript, работающий в одной вкладке, почти не может общаться с другими вкладками и окнами. За исключением + случая, когда он сам открыл это окно или несколько вкладок из одного источника (одинаковый домен, порт, + протокол).

      + +

      Есть способы это обойти, и они раскрыты в учебнике, но для этого требуется как минимум явное согласие обеих + сторон. Просто так взять и залезть в произвольную вкладку с другого домена нельзя.

      +
    • +
    • +

      Из JavaScript можно легко посылать запросы на сервер, с которого пришла страничка. Запрос на другой домен тоже + возможен, но менее удобен, т.к. и здесь есть ограничения безопасности.

      +
    • +
    + +

    В чем уникальность + JavaScript?

    +

    Есть как минимум три замечательных особенности JavaScript:

    +
    +
    +
    +
      +
    • Полная интеграция с HTML/CSS. function f (x, y, z) { return x + y + z; }

    • +
    • Простые вещи делаются просто.

      +
      function sayHi(name) {
      +  var phrase = "Привет, " + name;
      +  alert(phrase);
      +  }
      +
      +  sayHi('Вася');
    • +
    • Поддерживается всеми распространенными браузерами и включен по умолчанию.

    • +
    +
    +
    +
    + +

    Вариант блока с достоинствами и недостатками

    + +
    +
    +
    +

    Достоинства

    +
      +
    • Полная интеграция с HTML/CSS.

    • +
    • Простые вещи делаются просто.

    • +
    • Поддерживается всеми распространенными браузерами и включен по умолчанию.

    • +
    +
    +
    +
    +
    +

    Недостатки

    +
      +
    • Полная интеграция с HTML/CSS.

    • +
    • Простые вещи делаются просто.

    • +
    • Поддерживается всеми распространенными браузерами и включен по умолчанию.

    • +
    +
    +
    +
    + +

    Этих трех вещей одновременно нет больше ни в одной браузерной технологии. Поэтому JavaScript и + является самым распространенным средством создания браузерных интерфейсов.

    +

    Пример блока "balance" с узким контентом, который может вызвать ошибку

    + +
    +
    +
    +

    Достоинства

    +
      +
    • +

      Простота реализации.

      +
    • +
    +
    +
    +
    +
    +

    Недостатки

    +
      +
    • +

      Задержки между событием и уведомлением.

      +
    • +
    • +

      Лишний трафик и запросы на сервер.

      +
    • +
    +
    +
    +
    + +
    + Показать простой вариант compareNumeric + +
    +

    Функция должна возвращать положительное число, если a > b, отрицательное, если наоборот, и, + например, 0, если числа равны.

    + +

    Всем этим требованиям удовлетворяет функция.

    + +

    А примера кода не будет.

    +
    +
    + +

    Тенденции развития.

    +

    Перед тем, как вы планируете изучить новую технологию, полезно ознакомиться с ее развитием и перспективами. Здесь в + JavaScript все более чем хорошо.

    + +

    HTML 5

    +

    HTML 5 — эволюция стандарта HTML, добавляющая новые теги и, что более важно, ряд новых возможностей + браузерам.

    +
    + + +
    +

    Здесь может быть что угодно

    +
    +
    +

    Вот несколько примеров:

    +
      +
    • Чтение/запись файлов на диск (в специальной «песочнице», то есть не любые).
    • +
    • Встроенная в браузер база данных, которая позволяет хранить данные на компьютере пользователя.
    • +
    • Многозадачность с одновременным использованием нескольких ядер процессора.
    • +
    • Проигрывание видео/аудио, без Flash.
    • +
    • 2d и 3d-рисование с аппаратной поддержкой, как в современных играх.
    • +
    +

    Многие возможности HTML5 все еще в разработке, но браузеры постепенно начинают их поддерживать.

    +
    +

    Тенденция: JavaScript становится все более и более мощным и возможности браузера + растут в сторону десктопных приложений.

    +
    + +

    EcmaScript

    +

    Сам язык JavaScript улучшается. Современный стандарт EcmaScript 5 включает в себя новые возможности для + разработки.

    +

    Современные браузеры улучшают свои движки, чтобы увеличить скорость исполнения JavaScript, исправляют баги и + стараются следовать стандартам.

    +
    +
    +

    Тенденция: JavaScript становится все быстрее и стабильнее.

    +
    +
    +

    Очень важно то, что новые стандарты HTML5 и ECMAScript сохраняют максимальную совместимость с предыдущими версиями. + Это позволяет избежать неприятностей с уже существующими приложениями.

    +

    Впрочем, небольшая проблема с HTML5 все же есть. Иногда браузеры стараются включить новые возможности, которые еще не + полностью описаны в стандарте, но настолько интересны, что разработчики просто не могут ждать.

    +

    …Однако, со временем стандарт меняется и браузерам приходится подстраиваться к нему, что может + привести к ошибкам в уже написанном (старом) коде. Поэтому следует дважды подумать перед тем, как применять на + практике такие «супер-новые» решения.

    +

    При этом все браузеры сходятся к стандарту, и различий между ними уже гораздо меньше, чем всего лишь несколько лет + назад.

    +
    +
    +

    Тенденция: все идет к полной совместимости со стандартом.

    +
    +
    + +

    Недостатки JavaScript

    +

    Зачастую, недостатки подходов и технологий — это обратная сторона их полезности. Стоит ли упрекать молоток в + том, что он — тяжелый? Да, неудобно, зато гвозди забиваются лучше.

    + +

    Заголовок третьего уровня

    +

    В JavaScript, однако, есть вполне объективные недоработки, связанные с тем, что язык, по выражению его автора + (Brendan Eich) делался «за 10 бессонных дней и ночей». Поэтому некоторые моменты продуманы плохо, есть и откровенные + ошибки (которые признает тот же Brendan).

    +

    Конкретные примеры мы увидим в дальнейшем, т.к. их удобнее обсуждать в процессе освоения языка.

    + +

    Заголовок четвертого уровня

    +

    Пока что нам важно знать, что некоторые «странности» языка не являются чем-то очень умным, а просто не были + достаточно хорошо продуманы в своё время. В этом учебнике мы будем обращать особое внимание на основные недоработки и + «грабли». Ничего критичного в них нет, если знаешь — не наступишь.

    +

    В новых версиях JavaScript (ECMAScript) эти недостатки постепенно убирают. Процесс внедрения + небыстрый, в первую очередь из-за старых версий IE, но они постепенно вымирают. Современный IE в этом отношении + несравнимо лучше.

    diff --git a/handlers/markup/templates/pages/article.jade b/handlers/markup/templates/pages/article.jade new file mode 100755 index 000000000..9cd782d45 --- /dev/null +++ b/handlers/markup/templates/pages/article.jade @@ -0,0 +1,19 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.bodyId = 'page'; + - self.title = 'Учебник — Javascript.ru'; + - self.comments = {} // хм? + - self.comments.lenght = 5; + + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + - layout.articleFoot = true + - layout.header = true + - layout.breadcrumbs = true + +block content + include ./article.html diff --git a/handlers/markup/templates/pages/balance.jade b/handlers/markup/templates/pages/balance.jade new file mode 100755 index 000000000..7f83086a4 --- /dev/null +++ b/handlers/markup/templates/pages/balance.jade @@ -0,0 +1,14 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + + - self.title = "Пример блоков balance" + +block content + include ../blocks/balance-single + include ../blocks/balance diff --git a/handlers/markup/templates/pages/book-purchase-1.jade b/handlers/markup/templates/pages/book-purchase-1.jade new file mode 100755 index 000000000..048422327 --- /dev/null +++ b/handlers/markup/templates/pages/book-purchase-1.jade @@ -0,0 +1,62 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Приобретение книги javascript.ru в формате PDF'; + + //- layout + - layout.header = true + - layout.centeredHeader = true + +block content + +b.complex-form + +e.step._current + +e.step-content + +b.extract._small.__extract + +e.wrap + +e.content + +e('h5').title Основы JavaScript + +e.info 125 стр., pdf (10 Mb) + +e.aside._price._center + | Стоимость + +b.price.__price + | 2400 RUR + +e('span').secondary (≈ 69$) + +e('h2').alternate-title Укажите свой email + +b.text-input.__email + +e('input').control(placeholder="email") + +e.email-note После оплаты ссылка на скачивание учебника придет на этот адрес + +e('h2').alternate-title Выберите метод оплаты + +b.pay-method.__pay-method + +e.methods + +e.method + +e('button').send(name="[payment-type]", value="webmoney") + +e('img').img(src="/img/pay-method__webmoney.png", width="184", height="98", alt="WebMoney") + +e.method + +e('button').send(name="[payment-type]", value="yamoney") + +e('img').img(src="/img/pay-method__yamoney.png", width="184", height="98", alt="Яндекс.Деньги") + +e.method + +e('button').send(name="[payment-type]", value="paypal") + +e('img').img(src="/img/pay-method__paypal.png", width="184", height="98", alt="Paypal") + +e.method + +e('button').send(name="[payment-type]", value="payanyway") + +e('img').img(src="/img/pay-method__payanyway.png", width="184", height="98", alt="Карта") + +e.method + +e('button').send(name="[payment-type]", value="interkassa") + +e('img').img(src="/img/pay-method__interkassa.png", width="184", height="98", alt="Терминалы и банки") + +b.pay-hint.__pay-hint + +e('a').hint(href="hint") + +b('img').flag.__flag(src="/img/flag/flag_ua.png") + | Рекомендации по оплате не из России + +e.step + +b.order-confirm + +e('h2').title__step-title Спасибо за заказ! + +e.accent В ближайшее время вам на электронный адрес придет ссылка на скачивание учебника + +e.content + +e.text + | Если у вас возникли какие-либо вопросы, присылайте их на + =" " + a(href="mailto:orders@javascript.ru") orders@javascript.ru + +b('ul').grayed-list.__next + +e('li').item.__next-item Подтверждение + diff --git a/handlers/markup/templates/pages/book-purchase-success.jade b/handlers/markup/templates/pages/book-purchase-success.jade new file mode 100755 index 000000000..023018d62 --- /dev/null +++ b/handlers/markup/templates/pages/book-purchase-success.jade @@ -0,0 +1,38 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Приобретение книги javascript.ru в формате PDF'; + + //- layout + - layout.header = true + - layout.centeredHeader = true + +block content + +b.complex-form + +b.receipts.__receipts + +e.receipt + +e.receipt-body + +e.receipt-content + +e.type Заказ: + +e.title Учебник «Основы Javascript» + +e.note 125стр., pdf (10Мб) + +e.receipt-aside + +e.price 24000 RUR + +e.receipt + +e.receipt-body + +e.receipt-content + +e.type Оплата: + +e.status._ok Осуществлена успешно + +e.receipt-aside + +e('img').pay-method(src="/img/paypal.png", alt="PayPal", title="PayPal", width="121", height="31") + +e.step._current + +b.order-confirm + +e('h2').title__step-title Спасибо за заказ! + +e.accent В ближайшее время вам на электронный адрес придет ссылка на скачивание учебника + +e.content + +e.text + | Если у вас возникли какие-либо вопросы, присылайте их на + =" " + a(href="mailto:orders@javascript.ru") orders@javascript.ru + diff --git a/handlers/markup/templates/pages/book-purchase.jade b/handlers/markup/templates/pages/book-purchase.jade new file mode 100755 index 000000000..7d67bf0ac --- /dev/null +++ b/handlers/markup/templates/pages/book-purchase.jade @@ -0,0 +1,80 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Приобретение книги javascript.ru в формате PDF'; + + //- layout + - layout.header = true + - layout.centeredHeader = true + +block content + +b.complex-form + +b.notification._error._message.__error + +e.content Оплата не прошла, попробуйте еще раз + +b.receipts.__receipts + +e.receipt + +e.receipt-body + +e.receipt-content + +e.type Заказ: + +e.title Учебник «Основы Javascript» + +e.note 125стр., pdf (10Мб) + +e.receipt-aside + +e.price 24000 RUR + +e.receipt + +e.receipt-body + +e.receipt-content + +e.type Оплата: + +e.status._ok Осуществлена успешно + +e.receipt-aside + +e('img').pay-method(src="/img/paypal.png", alt="PayPal", title="PayPal", width="121", height="31") + +e.step._current + +e.step-content + +b.extract._small.__extract + +e.wrap + +e.content + +e('h5').title Основы JavaScript + +e.info 125 стр., pdf (10 Mb) + +e.aside._price._center + | Стоимость + +b.price.__price + | 2400 RUR + +e('span').secondary (≈ 69$) + +e('h2').alternate-title Укажите свой email + +b.text-input.__email + +e('input').control(placeholder="email") + +e.email-note После оплаты ссылка на скачивание учебника придет на этот адрес + +e('h2').alternate-title Выберите метод оплаты + +b.pay-method.__pay-method + +e.methods + +e.method + +e('button').send(name="[payment-type]", value="webmoney") + +e('img').img(src="/img/pay-method__webmoney.png", width="184", height="98", alt="WebMoney") + +e.method + +e('button').send(name="[payment-type]", value="yamoney") + +e('img').img(src="/img/pay-method__yamoney.png", width="184", height="98", alt="Яндекс.Деньги") + +e.method + +e('button').send(name="[payment-type]", value="paypal") + +e('img').img(src="/img/pay-method__paypal.png", width="184", height="98", alt="Paypal") + +e.method + +e('button').send(name="[payment-type]", value="payanyway") + +e('img').img(src="/img/pay-method__payanyway.png", width="184", height="98", alt="Карта") + +e.method + +e('button').send(name="[payment-type]", value="interkassa") + +e('img').img(src="/img/pay-method__interkassa.png", width="184", height="98", alt="Терминалы и банки") + +b.pay-hint.__pay-hint + +e('a').hint(href="hint") + +b('img').flag.__flag(src="/img/flag/flag_ua.png") + | Рекомендации по оплате не из России + +e.step + +b.order-confirm + +e('h2').title__step-title Спасибо за заказ! + +e.accent В ближайшее время вам на электронный адрес придет ссылка на скачивание учебника + +e.content + +e.text + | Если у вас возникли какие-либо вопросы, присылайте их на + =" " + a(href="mailto:orders@javascript.ru") orders@javascript.ru + +b('ul').grayed-list.__next + +e('li').item.__next-item Подтверждение + diff --git a/handlers/markup/templates/pages/circles.html b/handlers/markup/templates/pages/circles.html new file mode 100755 index 000000000..38786acae --- /dev/null +++ b/handlers/markup/templates/pages/circles.html @@ -0,0 +1,20 @@ + + + + + + Circles + + + + + +
    + + \ No newline at end of file diff --git a/handlers/markup/templates/pages/circles.jade b/handlers/markup/templates/pages/circles.jade new file mode 100755 index 000000000..06f8aac2d --- /dev/null +++ b/handlers/markup/templates/pages/circles.jade @@ -0,0 +1,32 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.headTitle = 'Современный учебник Javascript'; + + //- layout + - layout.sitetoolbar = true + - layout.header = true + - layout.custom = true + +block content + div#map(style="height:400px") + + style. + .leaflet-map-pane { + z-index: 2 !important; + } + + .leaflet-google-layer { + z-index: 1 !important; + } + + link(rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.css") + + script(src="https://maps.googleapis.com/maps/api/js?v=3.exp") + + script(src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.3/leaflet.js") + + + script + include l.js diff --git a/handlers/markup/templates/pages/circles.js b/handlers/markup/templates/pages/circles.js new file mode 100755 index 000000000..2cfc1b494 --- /dev/null +++ b/handlers/markup/templates/pages/circles.js @@ -0,0 +1,223 @@ +// First, create an object containing LatLng and population for each city. +var citymap = { + "Москва": { + "location": { + "lat": 55.755826, + "lng": 37.6173 + }, + radius: 20000 + }, + "Екатеринбург": { + "location": { + "lat": 56.83892609999999, + "lng": 60.6057025 + }, + + radius: 10000 + }, + "Ярославль": { + "location": { + "lat": 57.62607440000001, + "lng": 39.8844708 + }, + + radius: 10000 + }, + "Новосибирск": { + "location": { + "lat": 55.00835259999999, + "lng": 82.9357327 + }, + + radius: 10000 + }, + "Казань": { + "location": { + "lat": 55.790278, + "lng": 49.134722 + }, + + radius: 10000 + }, + "Самара": { + "location": { + "lat": 53.202778, + "lng": 50.140833 + }, + + radius: 10000 + }, + "Пермь": { + "location": { + "lat": 58.00000000000001, + "lng": 56.316667 + }, + + radius: 10000 + }, + "Белгород": { + + "location": { + "lat": 50.5997134, + "lng": 36.5982621 + }, + + radius: 10000 + }, + "Ростов-на-Дону": { + "location": { + "lat": 47.23333299999999, + "lng": 39.7 + }, + + radius: 10000 + }, + "Санкт-Петербург": { + "location": { + "lat": 59.9342802, + "lng": 30.3350986 + }, + radius: 15000 + }, + "Калининград": { + + "location": { + "lat": 54.716667, + "lng": 20.516667 + }, + + radius: 10000 + }, + "Киев": { + + "location": { + "lat": 50.4501, + "lng": 30.5234 + }, + + radius: 20000 + }, + "Харьков": { + + "location": { + "lat": 49.9935, + "lng": 36.230383 + }, + + radius: 18000 + }, + "Днепропетровск": { + + "location": { + "lat": 48.464717, + "lng": 35.046183 + }, + + radius: 10000 + }, + "Одесса": { + + "location": { + "lat": 46.482526, + "lng": 30.7233095 + }, + + radius: 10000 + }, + "Львов": { + + "location": { + "lat": 49.839683, + "lng": 24.029717 + }, + + radius: 10000 + }, + "Херсон": { + + "location": { + "lat": 46.635417, + "lng": 32.616867 + }, + + radius: 10000 + }, + "Донецк": { + + "location": { + "lat": 48.015883, + "lng": 37.80285 + }, + + radius: 10000 + }, + "Винница": { + + "location": { + "lat": 49.233083, + "lng": 28.468217 + }, + + radius: 10000 + }, + "Минск": { + + "location": { + "lat": 53.90453979999999, + "lng": 27.5615244 + }, + + radius: 10000 + } + + +}; + +var cityCircle; + +function initialize() { + // Create the map. + var mapOptions = { + zoom: 5, + center: new google.maps.LatLng(54.231473, 37.734144), + mapTypeId: google.maps.MapTypeId.TERRAIN, + scrollwheel: false, // Disable Mouse Scroll zooming (Essential for responsive sites!) + panControl: false, // Set to false to disable + mapTypeControl: false, // Disable Map/Satellite switch + scaleControl: true, // Set to false to hide scale + streetViewControl: false, // Set to disable to hide street view + overviewMapControl: false, // Set to false to remove overview control + rotateControl: false, // Set to false to disable rotate control + styles: [{ + "featureType": "all", + "elementType": "all", + "stylers": [{"weight": 0.1}, {"hue": "#a39b00"}, {"saturation": -85}, {"lightness": 0}, {"gamma": 1.1}] + }, { + "featureType": "water", + "elementType": "geometry.fill", + "stylers": [{"hue": "#226c94"}, {"saturation": 8}, {"lightness": -10}] + }] + + }; + + var map = new google.maps.Map(document.getElementById('map-canvas'), + mapOptions); + + // Construct the circle for each value in citymap. + // Note: We scale the area of the circle based on the population. + for (var city in citymap) { + var circleOptions = { + strokeColor: '#C13335', + fillColor: '#C13335', + strokeOpacity: 1, + fillOpacity: 1, + map: map, + center: new google.maps.LatLng(citymap[city].location.lat, citymap[city].location.lng), + radius: citymap[city].radius + }; + // Add the circle for this city to the map. + cityCircle = new google.maps.Circle(circleOptions); + } +} + +initialize(); diff --git a/handlers/markup/templates/pages/circles2.js b/handlers/markup/templates/pages/circles2.js new file mode 100755 index 000000000..9e762ce99 --- /dev/null +++ b/handlers/markup/templates/pages/circles2.js @@ -0,0 +1,360 @@ +// First, create an object containing LatLng and population for each city. +var citymap = { + "Москва": { + "location": { + "lat": 55.755826, + "lng": 37.6173 + }, + radius: 30000 + }, + "Екатеринбург": { + "location": { + "lat": 56.83892609999999, + "lng": 60.6057025 + }, + + radius: 20000 + }, + "Ярославль": { + "location": { + "lat": 57.62607440000001, + "lng": 39.8844708 + }, + + radius: 18000 + }, + "Новосибирск": { + "location": { + "lat": 55.00835259999999, + "lng": 82.9357327 + }, + + radius: 18000 + }, + "Казань": { + "location": { + "lat": 55.790278, + "lng": 49.134722 + }, + + radius: 18000 + }, + "Самара": { + "location": { + "lat": 53.202778, + "lng": 50.140833 + }, + + radius: 18000 + }, + "Пермь": { + "location": { + "lat": 58.00000000000001, + "lng": 56.316667 + }, + + radius: 20000 + }, + "Белгород": { + + "location": { + "lat": 50.5997134, + "lng": 36.5982621 + }, + + radius: 18000 + }, + "Ростов-на-Дону": { + "location": { + "lat": 47.23333299999999, + "lng": 39.7 + }, + + radius: 18000 + }, + "Санкт-Петербург": { + "location": { + "lat": 59.9342802, + "lng": 30.3350986 + }, + radius: 20000 + }, + "Калининград": { + + "location": { + "lat": 54.716667, + "lng": 20.516667 + }, + + radius: 18000 + }, + "Киев": { + + "location": { + "lat": 50.4501, + "lng": 30.5234 + }, + radius: 30000 + }, + "Харьков": { + + "location": { + "lat": 49.9935, + "lng": 36.230383 + }, + radius: 30000 + }, + "Днепропетровск": { + + "location": { + "lat": 48.464717, + "lng": 35.046183 + }, + + radius: 25000 + }, + "Одесса": { + + "location": { + "lat": 46.482526, + "lng": 30.7233095 + }, + + radius: 22000 + }, + "Львов": { + + "location": { + "lat": 49.839683, + "lng": 24.029717 + }, + + radius: 18000 + }, + "Херсон": { + + "location": { + "lat": 46.635417, + "lng": 32.616867 + }, + + radius: 18000 + }, + "Донецк": { + + "location": { + "lat": 48.015883, + "lng": 37.80285 + }, + + radius: 18000 + }, + "Винница": { + + "location": { + "lat": 49.233083, + "lng": 28.468217 + }, + + radius: 22000 + }, + "Минск": { + + "location": { + "lat": 53.90453979999999, + "lng": 27.5615244 + }, + + radius: 20000 + } + + +}; + + + +/* + * L.TileLayer is used for standard xyz-numbered tile layers. + * @see https://gist.github.com/crofty/2197042 + */ +L.Google = L.Class.extend({ + includes: L.Mixin.Events, + + options: { + minZoom: 0, + maxZoom: 18, + tileSize: 256, + subdomains: 'abc', + errorTileUrl: '', + attribution: '', + opacity: 1, + continuousWorld: false, + noWrap: false, + }, + + // Possible types: SATELLITE, ROADMAP, HYBRID + initialize: function(type, options) { + L.Util.setOptions(this, options); + + this._type = google.maps.MapTypeId[type || 'SATELLITE']; + }, + + onAdd: function(map, insertAtTheBottom) { + this._map = map; + this._insertAtTheBottom = insertAtTheBottom; + + // create a container div for tiles + this._initContainer(); + this._initMapObject(); + + // set up events + map.on('viewreset', this._resetCallback, this); + + this._limitedUpdate = L.Util.limitExecByInterval(this._update, 150, this); + map.on('move', this._update, this); + //map.on('moveend', this._update, this); + + this._reset(); + this._update(); + }, + + onRemove: function(map) { + this._map._container.removeChild(this._container); + //this._container = null; + + this._map.off('viewreset', this._resetCallback, this); + + this._map.off('move', this._update, this); + //this._map.off('moveend', this._update, this); + }, + + getAttribution: function() { + return this.options.attribution; + }, + + setOpacity: function(opacity) { + this.options.opacity = opacity; + if (opacity < 1) { + L.DomUtil.setOpacity(this._container, opacity); + } + }, + + _initContainer: function() { + var tilePane = this._map._container + first = tilePane.firstChild; + + if (!this._container) { + this._container = L.DomUtil.create('div', 'leaflet-google-layer leaflet-top leaflet-left'); + this._container.id = "_GMapContainer"; + } + + if (true) { + tilePane.insertBefore(this._container, first); + + this.setOpacity(this.options.opacity); + var size = this._map.getSize(); + this._container.style.width = size.x + 'px'; + this._container.style.height = size.y + 'px'; + } + }, + + _initMapObject: function() { + this._google_center = new google.maps.LatLng(0, 0); + var map = new google.maps.Map(this._container, { + center: this._google_center, + zoom: 0, + mapTypeId: this._type, + disableDefaultUI: true, + keyboardShortcuts: false, + draggable: false, + disableDoubleClickZoom: true, + scrollwheel: false, + streetViewControl: false, + styles: [{ + "featureType": "all", + "elementType": "all", + "stylers": [{"weight": 0.1}, {"hue": "#a39b00"}, {"saturation": -85}, {"lightness": 0}, {"gamma": 1.1}] + }, { + "featureType": "water", + "elementType": "geometry.fill", + "stylers": [{"hue": "#226c94"}, {"saturation": 8}, {"lightness": -10}] + }] + }); + + var _this = this; + this._reposition = google.maps.event.addListenerOnce(map, "center_changed", + function() { _this.onReposition(); }); + + map.backgroundColor = '#ff0000'; + this._google = map; + }, + + _resetCallback: function(e) { + this._reset(e.hard); + }, + + _reset: function(clearOldContainer) { + this._initContainer(); + }, + + _update: function() { + this._resize(); + + var bounds = this._map.getBounds(); + var ne = bounds.getNorthEast(); + var sw = bounds.getSouthWest(); + var google_bounds = new google.maps.LatLngBounds( + new google.maps.LatLng(sw.lat, sw.lng), + new google.maps.LatLng(ne.lat, ne.lng) + ); + var center = this._map.getCenter(); + var _center = new google.maps.LatLng(center.lat, center.lng); + + this._google.setCenter(_center); + this._google.setZoom(this._map.getZoom()); + //this._google.fitBounds(google_bounds); + }, + + _resize: function() { + var size = this._map.getSize(); + if (this._container.style.width == size.x && + this._container.style.height == size.y) + return; + this._container.style.width = size.x + 'px'; + this._container.style.height = size.y + 'px'; + google.maps.event.trigger(this._google, "resize"); + }, + + onReposition: function() { + //google.maps.event.trigger(this._google, "resize"); + } +}); + + + +// ==================================================== + + +var map = new L.Map('map', { + center: new L.LatLng(54.231473, 37.734144), + zoom: 5, + attributionControl: false, + markerZoomAnimation: false +}); +var googleLayer = new L.Google('TERRAIN'); +map.addLayer(googleLayer); + +// Construct the circle for each value in citymap. +// Note: We scale the area of the circle based on the population. +for (var city in citymap) (function(city) { + var marker = L.circleMarker([citymap[city].location.lat-0.01, citymap[city].location.lng], { + radius: citymap[city].radius / 3000, + stroke: false, + opacity: 1, + fill: true, + fillColor: '#C13335', + fillOpacity: 1 + }); + map.addLayer(marker); + +}(city)); \ No newline at end of file diff --git a/handlers/markup/templates/pages/code-tabs.jade b/handlers/markup/templates/pages/code-tabs.jade new file mode 100755 index 000000000..dd48d393a --- /dev/null +++ b/handlers/markup/templates/pages/code-tabs.jade @@ -0,0 +1,16 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.bodyId = 'page'; + - self.title = 'Учебник — Javascript.ru'; + - self.comments = {} // хм? + - self.comments.lenght = 5; + + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + +block content + include ../blocks/code-tabs diff --git a/handlers/markup/templates/pages/courses-common.jade b/handlers/markup/templates/pages/courses-common.jade new file mode 100644 index 000000000..74dc3e367 --- /dev/null +++ b/handlers/markup/templates/pages/courses-common.jade @@ -0,0 +1,13 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var title = 'Регистрация на курсы JavaScript, DOM, интерфейсы' + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + + - var content_class = '_center' + + +block content + include ../blocks/courses-register diff --git a/handlers/markup/templates/pages/courses-course.jade b/handlers/markup/templates/pages/courses-course.jade new file mode 100644 index 000000000..6b4ea323a --- /dev/null +++ b/handlers/markup/templates/pages/courses-course.jade @@ -0,0 +1,21 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var title = 'Название курса' + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + + - var content_class = '_center' + + +block content + p В первую очередь этот курс для тех, кто либо не разрабатывал на JS, либо разрабатывал на нём эпизодически и теперь хочет освоить профессионально. + + include ../blocks/courses-programm-and-register + include ../blocks/courses-parts + include ../blocks/courses-how + include ../blocks/courses-result + include ../blocks/courses-system-req + +b.fixed-tab.tablet-only.courses-tab + a.courses-tab__link(href="#signup") Записаться на курсы diff --git a/handlers/markup/templates/pages/courses-feedback.jade b/handlers/markup/templates/pages/courses-feedback.jade new file mode 100644 index 000000000..e9ac04b24 --- /dev/null +++ b/handlers/markup/templates/pages/courses-feedback.jade @@ -0,0 +1,87 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var title = 'Отзыв о курсе Javascript, DOM, интерфейсы' + - var layout_main_class = "main_width-limit" + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + + - var content_class = '_center' + + +block content + +b.course-feedback + +e('form').form + +e.line + +e('h2').title Как вы в целом оцениваете курс + +b.rating-chooser.clearfix + +e('fieldset').fieldset + +e('input').input(type="radio" id="star5" name="rating" value="5") + +e('label').label(for="star5" title="Отлично") + +e('span').label-text Отлично + +e('input').input(type="radio" id="star4" name="rating" value="4") + +e('label').label(for="star4" title="Хорошо") + +e('span').label-text Хорошо + +e('input').input(type="radio" id="star3" name="rating" value="3") + +e('label').label(for="star3" title="Нормально") + +e('span').label-text Нормально + +e('input').input(type="radio" id="star2" name="rating" value="2") + +e('label').label(for="star2" title="Так себе") + +e('span').label-text Так себе + +e('input').input(type="radio" id="star1" name="rating" value="1") + +e('label').label(for="star1" title="Плохо") + +e('span').label-text Плохо + + +e.line + +e('h2').title Порекомендовали бы вы этот курс другим? + +e('label').label + +e('input').input(type="radio" name="recomend" value="yes" checked) + |  Да + br + +e('label').label + +e('input').input(type="radio" name="recomend" value="no") + |  Нет + + +e.line + +e('h2').title Отзыв + +b('textarea').textarea-input.__textarea-head(placeholder="Несколько слов о том, насколько полезным курс оказался для вас, как доступно излагается материал и т.д.") + + +e.line + +e('h2').title Имя + +b.text-input + +e('input').control(value="Пупкин Вася" disabled) + + +e.line + +e('h2').title Фото + +e.userpic + +e('img').userpic-img(src="/img/userpic/userpic.svg" alt="Фото") + +e('a').load-new-userpic Загрузите новое фото + + +e.line + +e('h2').title Страна + +b('select').input-select._small(name="country") + +e('option')(value="ru") Россия + +e('option')(value="ua") Украина + + +e.line + +e('h2').title Город  + +e('span').title-note (не обязательно) + +b.text-input + +e('input').control + + +e.line + +e('h2').title + +e('input').checkbox(type="checkbox" checked) + | Публичный отзыв  + +e('span').title-note (будет опубликован на javascript.ru) + +e.line + +e('h2').title Ссылка на профиль  + +e('span').title-note (не обязательно) + +b.text-input + +e('input').control(placeholder="Адресс вашего сайта, Вконтакте, Facebook, GitHub и т.д.") + + +e.note Было бы здорово если бы мы могли сослаться на вас в соц. сетях. Эта ссылка будет использоваться только внутри вашего отзыва у нас на сайте + + +e.line + +b('button').button._action(type="submit") Отправить diff --git a/handlers/markup/templates/pages/courses-home.jade b/handlers/markup/templates/pages/courses-home.jade new file mode 100644 index 000000000..66b94aa37 --- /dev/null +++ b/handlers/markup/templates/pages/courses-home.jade @@ -0,0 +1,23 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var title = 'Online курсы Javascript' + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + + - var content_class = '_center' + + +block content + p Здесь находятся «правильные» курсы по профессиональному Javascript, цель которых — научить думать на Javascript, писать просто, быстро и красиво. + +b.flex-column + include ../blocks/courses-features + include ../blocks/courses-programm-register + include ../blocks/courses-master + include ../blocks/courses-testimonials + include ../blocks/courses-tabbed-pane + include ../blocks/courses-guarantee + include ../blocks/courses-faq + +b.fixed-tab.tablet-only.courses-tab + a.courses-tab__link(href="#courses") Перейти к списку открытых курсов diff --git a/handlers/markup/templates/pages/courses-materials.jade b/handlers/markup/templates/pages/courses-materials.jade new file mode 100644 index 000000000..ea662089a --- /dev/null +++ b/handlers/markup/templates/pages/courses-materials.jade @@ -0,0 +1,16 @@ +extends /layouts/main + +block append variables + + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit" + - var title = 'Материалы для группы Курс JavaScript/DOM/Интерфейсы /04.05/' + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + + - var content_class = '_center' + + +block content + p Серийный номер для видео: Q123-N456-Y678 + + include ../blocks/courses-materials diff --git a/handlers/markup/templates/pages/form.jade b/handlers/markup/templates/pages/form.jade new file mode 100755 index 000000000..588d99efd --- /dev/null +++ b/handlers/markup/templates/pages/form.jade @@ -0,0 +1,4 @@ +form(method="POST",action="/test") + input(type="hidden",name="name",value="value") + input(type="submit",value="submit") + diff --git a/handlers/markup/templates/pages/lesson-tasks.jade b/handlers/markup/templates/pages/lesson-tasks.jade new file mode 100755 index 000000000..1eb8c394f --- /dev/null +++ b/handlers/markup/templates/pages/lesson-tasks.jade @@ -0,0 +1,36 @@ +extends ../layouts/base + +//- http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Пример задач в уроке — Javascript.ru'; + + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + - layout.articleFoot = true + - layout.header = true + - layout.breadcrumbs = true + +block content + h2 + a.main__anchor#positivism(href="#positivism") + | Онтологический позитивизм в XXI веке + p + | Ассоциация, по определению, индуктивно оспособляет сложный закон внешнего мира. + | Освобождение категорически раскладывает на элементы из ряда вон выходящий конфликт. + | Моцзы, Сюнъцзы и другие считали, что дедуктивный метод может быть получен из опыта. + | Интересно отметить, что дуализм естественно создает мир. Созерцание, как принято + | считать, методологически выводит сенсибельный закон внешнего мира, однако Зигварт + | считал критерием истинности необходимость и общезначимость, для которых нет никакой + | опоры в объективном мире. Даосизм, как принято считать, естественно рассматривается + | позитивизм. + p + | Ощущение мира, по определению, творит сложный даосизм. Мир осмысляет дедуктивный метод, + | ломая рамки привычных представлений. Конфликт может быть получен из опыта. + p + | Герменевтика подрывает конфликт. Освобождение, как следует из вышесказанного, выводит + | напряженный интеллект, ломая рамки привычных представлений. Вероятностная логика творит + | неоднозначный мир. Платоновская академия индуцирует сенсибельный язык образов. + + include ../blocks/tasks \ No newline at end of file diff --git a/handlers/markup/templates/pages/map.jade b/handlers/markup/templates/pages/map.jade new file mode 100755 index 000000000..e0b52bd67 --- /dev/null +++ b/handlers/markup/templates/pages/map.jade @@ -0,0 +1,12 @@ +doctype html +include /bem + +meta(charset='UTF-8') +title Карта сайта — Javascript.ru +//- тут стоило бы использовать блок head, но он также подключает скрипт head.js, +//- а на отдельной странице карты сайта скрипты не нужны +link(href='http://fonts.googleapis.com/css?family=Open+Sans:400italic,700italic,400,700|Open+Sans+Condensed:700&subset=latin,latin-ext,cyrillic,cyrillic-ext' rel='style' type='text/css') +link(href='/styles/base.css' rel='stylesheet') + +body + include ../blocks/map diff --git a/handlers/markup/templates/pages/my.html b/handlers/markup/templates/pages/my.html new file mode 100755 index 000000000..9db1c64de --- /dev/null +++ b/handlers/markup/templates/pages/my.html @@ -0,0 +1 @@ +Hello diff --git a/handlers/markup/templates/pages/notifications.jade b/handlers/markup/templates/pages/notifications.jade new file mode 100755 index 000000000..f8ff16c2b --- /dev/null +++ b/handlers/markup/templates/pages/notifications.jade @@ -0,0 +1,40 @@ +extends ../layouts/base + +//- http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Виды уведомлений'; + + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + - layout.articleFoot = true + - layout.header = true + - layout.breadcrumbs = true + - layout.notificationPopup = true + - layout.notificationStripe = true + - layout.bodyClass = "notification_on" + +block content + include ../blocks/notification-message + h2 + a.main__anchor#positivism(href="#positivism") + | Онтологический позитивизм в XXI веке + p + | Ассоциация, по определению, индуктивно оспособляет сложный закон внешнего мира. + | Освобождение категорически раскладывает на элементы из ряда вон выходящий конфликт. + | Моцзы, Сюнъцзы и другие считали, что дедуктивный метод может быть получен из опыта. + | Интересно отметить, что дуализм естественно создает мир. Созерцание, как принято + | считать, методологически выводит сенсибельный закон внешнего мира, однако Зигварт + | считал критерием истинности необходимость и общезначимость, для которых нет никакой + | опоры в объективном мире. Даосизм, как принято считать, естественно рассматривается + | позитивизм. + p + | Ощущение мира, по определению, творит сложный даосизм. Мир осмысляет дедуктивный метод, + | ломая рамки привычных представлений. Конфликт может быть получен из опыта. + p + | Герменевтика подрывает конфликт. Освобождение, как следует из вышесказанного, выводит + | напряженный интеллект, ломая рамки привычных представлений. Вероятностная логика творит + | неоднозначный мир. Платоновская академия индуцирует сенсибельный язык образов. + + include ../blocks/tasks \ No newline at end of file diff --git a/handlers/markup/templates/pages/profile-accounts.jade b/handlers/markup/templates/pages/profile-accounts.jade new file mode 100755 index 000000000..8c7f08996 --- /dev/null +++ b/handlers/markup/templates/pages/profile-accounts.jade @@ -0,0 +1,101 @@ +extends /layouts/main + +block append variables + - var title = "Nazarkator"; + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit" + - var sitetoolbar = true + +block content + +b.profile + + +e.content + +e.tabs + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Публичный профиль + +e.tab._current + +e.tab-content + | Аккаунт + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Заказы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Курсы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Уведомления + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Тесты + +e.item + +e.item-content + +e('h2').inline-title Управление аккаунтом + +e.item._editable + +e.item-content + +e.item-name Юзернейм: + +e.item-value Nakazator + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-username") Юзернейм: + +b.text-input._small.__control + +e('input').control(type="text", id="profile-username", value="Nakazator") + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Email: + +e.item-value nakazator@mail.com + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-email") Email: + +b.text-input._small.__control + +e('input').control(type="email", id="profile-email", value="nakazator@mail.com") + include ../blocks/profile-ok-cancel + +e.item + +e.item-content + +e('button').action._change-password(onclick="this.parentNode.parentNode.classList.add('profile__item_editing')") Изменить пароль + +e.item-change + +e.change-content + //- смена пароля: используем поля text а не password, символы не скрываются, + //- потому что в дизайне предусмотрено только одно поле для ввода нового пароля, + //- а значит проверить его повторным вводом невозможно, пользователь должен видеть + //- свой новый пароль. Скрывать старый не имеет смысла, поскольку он станет + //- недействительным после отправки формы + +e.labeled.__pass-change + +e.labeled-label(for="profile-pass-old") Старый пароль: + +b.text-input._small.__labeled-text.__pass + +e('input').control(type="text", id="profile-pass-old") + +e.labeled.__pass-change + +e.labeled-label(for="profile-pass-new") Новый пароль: + +b.text-input._small.__labeled-text.__pass + +e('input').control(type="text", id="profile-pass-new") + include ../blocks/profile-ok-cancel + +e.title + +e.title-content + +e('h2').inline-title Привязанные внешние аккаунты + +e('p').note При привязке аккаунта можно будет заходить на сайт одним нажатием кнопки. + +e.linked-account + +e.account-content + +e.linked-name(href='http://vk.com/alexbor') + +e('img').linked-upic(src="/img/linked-upic.png") + | Алексей Борматенко + +e.linked-provider Vkontakte + +e('button').account-remove(title="Удалить аккаунт") + +e.linked-account + +e.account-content + +e.linked-name(href='http://vk.com/alexbor') + +e('img').linked-upic(src="/img/linked-upic.png") + | Alexey Bormatenko + +e.linked-provider Facebook + +e('button').account-remove(title="Удалить аккаунт") + +e.providers + +e.providers-content + +e.providers-title Привязать: + +e.socials + include ../../../auth/templates/providers + +e.action-item + +e.action-content + +e('button').action._remove-account Удалить аккаунт diff --git a/handlers/markup/templates/pages/profile-courses.jade b/handlers/markup/templates/pages/profile-courses.jade new file mode 100644 index 000000000..857adb607 --- /dev/null +++ b/handlers/markup/templates/pages/profile-courses.jade @@ -0,0 +1,39 @@ +extends /layouts/main + +block append variables + - var title = "Nazarkator"; + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit-wide" + - var sitetoolbar = true + + - var profileTests = []; + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-01-01T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '36%', level: 'Новичек', try: 1, weakList: 'Кроссбраузерность, события, CSS', time: '25 мин 23 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-02-04T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '36%', level: 'Новичек', try: 2, weakList: 'Кроссбраузерность, события, CSS', time: '12 мин 65 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-05-14T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '42%', level: 'Новичек', try: 3, weakList: 'Кроссбраузерность, события, CSS', time: '42 мин 45 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2015-03-02T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '56%', level: 'Средний', try: 4, weakList: 'Кроссбраузерность, события, CSS', time: '5 мин 5 сек' }) + +block content + +b.profile + + +e.content + +e.tabs + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Профиль + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Аккаунт + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Заказы + +e.tab._current + +e.tab-content Курсы + +e('span').notification 1 + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Уведомления + +e.tab + +e.tab-content + +e('a').tab-link(href="profile-quiz") Тесты + + include ../blocks/courses-table diff --git a/handlers/markup/templates/pages/profile-invoice.jade b/handlers/markup/templates/pages/profile-invoice.jade new file mode 100644 index 000000000..fb536d92c --- /dev/null +++ b/handlers/markup/templates/pages/profile-invoice.jade @@ -0,0 +1,39 @@ +extends /layouts/main + +block append variables + - var title = "Nazarkator"; + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit-wide" + - var sitetoolbar = true + + - var profileTests = []; + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-01-01T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '36%', level: 'Новичек', try: 1, weakList: 'Кроссбраузерность, события, CSS', time: '25 мин 23 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-02-04T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '36%', level: 'Новичек', try: 2, weakList: 'Кроссбраузерность, события, CSS', time: '12 мин 65 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-05-14T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '42%', level: 'Новичек', try: 3, weakList: 'Кроссбраузерность, события, CSS', time: '42 мин 45 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2015-03-02T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '56%', level: 'Средний', try: 4, weakList: 'Кроссбраузерность, события, CSS', time: '5 мин 5 сек' }) + +block content + +b.profile + + +e.content + +e.tabs + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Профиль + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Аккаунт + +e.tab._current + +e.tab-content + | Заказы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Курсы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Уведомления + +e.tab + +e.tab-content + +e('a').tab-link(href="profile-quiz") Тесты + + include ../blocks/invoice-table diff --git a/handlers/markup/templates/pages/profile-quiz.jade b/handlers/markup/templates/pages/profile-quiz.jade new file mode 100644 index 000000000..f354deb06 --- /dev/null +++ b/handlers/markup/templates/pages/profile-quiz.jade @@ -0,0 +1,42 @@ +extends /layouts/main + +block append head + link(href=pack('quiz', 'css') rel='stylesheet') + +block append variables + - var title = "Nazarkator"; + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit" + - var sitetoolbar = true + + - var profileTests = []; + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-01-01T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '36%', level: 'Новичек', try: 1, weakList: 'Кроссбраузерность, события, CSS', time: '25 мин 23 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-02-04T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '36%', level: 'Новичек', try: 2, weakList: 'Кроссбраузерность, события, CSS', time: '12 мин 65 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2014-05-14T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '42%', level: 'Новичек', try: 3, weakList: 'Кроссбраузерность, события, CSS', time: '42 мин 45 сек' }) + - profileTests.push({ name: 'Основной Javascript', date: moment('2015-03-02T19:45').locale('ru').format('D MMMM YYYY в h:mm'), result: '56%', level: 'Средний', try: 4, weakList: 'Кроссбраузерность, события, CSS', time: '5 мин 5 сек' }) + +block content + +b.profile + + +e.content + +e.tabs + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Публичный профиль + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Аккаунт + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Заказы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Курсы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Уведомления + +e.tab._current + +e.tab-content + | Тесты + + include ../blocks/quiz-results-table \ No newline at end of file diff --git a/handlers/markup/templates/pages/profile-recover.jade b/handlers/markup/templates/pages/profile-recover.jade new file mode 100755 index 000000000..47f80fabf --- /dev/null +++ b/handlers/markup/templates/pages/profile-recover.jade @@ -0,0 +1,21 @@ +extends ../layouts/profile + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Восстановление пароля'; + +block content + +b.recover + +e('h1').title Восстановление пароля + +b.notification._message._error.__message + +e.content Пароль не может быт пустым + +e('button').close + +e.content + +e.controls + +e.label-wrap + +e('label').label(for="newpass") Новый пароль + +e.input-wrap + +b.text-input._small.__input + +e('input').control#newpass(type="password", autofocus) + +e.save-wrap + +b('button').submit-button._small.__save Сохранить пароль diff --git a/handlers/markup/templates/pages/profile-summary.jade b/handlers/markup/templates/pages/profile-summary.jade new file mode 100755 index 000000000..0f852e8fe --- /dev/null +++ b/handlers/markup/templates/pages/profile-summary.jade @@ -0,0 +1,39 @@ +extends ../layouts/profile + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Кабинет пользователя Nakazator'; + + //- layout + - layout.header = true + - layout.breadcrumbs = true + - layout.centeredHeader = true + +block content + +b.profile._summary + include ../blocks/profile-upic + +e.content + +e.item + +e.item-content + +e.item-name Имя: + +e.item-value Валентиновский Валентин Валентинович + +e.item + +e.item-content + +e.item-name Website: + +e.item-value www.valentin.com + +e.item + +e.item-content + +e.item-name Страна: + +e.item-value Украина + +e.item + +e.item-content + +e.item-name Часовой пояс: + +e.item-value Европа/Москва: GMT+4 + +e.item + +e.item-content + +e.item-name Интересы: + +e.item-value музыка, юзабилити, javascript, веб-дизайн, программирование, html, спорт, css + +e.item + +e.item-content + +e.item-name О себе: + +e.item-value Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ullam iure eaque unde, repellat est sapiente. Optio autem odio illo necessitatibus, aliquam, ducimus repellat enim, non distinctio in doloremque, magni eligendi. diff --git a/handlers/markup/templates/pages/profile-tests.jade b/handlers/markup/templates/pages/profile-tests.jade new file mode 100755 index 000000000..13697195c --- /dev/null +++ b/handlers/markup/templates/pages/profile-tests.jade @@ -0,0 +1,80 @@ +extends ../layouts/profile + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Кабинет пользователя Nakazator'; + + //- layout + - layout.header = true + - layout.breadcrumbs = true + - layout.centeredHeader = true + +block content + +b.profile._tests + include ../blocks/profile-upic + +e.content + +e.tabs + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Публичный профиль + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Аккаунт + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Заказы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Курсы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Уведомления + +e.tab._current + +e.tab-content + | Тесты + //- воспроизводим структуру таблицы, чтобы обемпечить выравнивание + //- между разными строками, однако не используем теги таблицы, + //- чтобы оставить простую возможность менять лейаут с помощью css + +e.tests + +e.test + +e.test-name + +e.test-legend 18 июля 2014 в 19:42 + +e.test-title Основной JavaScript + +e.test-legend Попытка #1 + +e.test-score + +e.test-legend Результат + +e.test-percent 36% + +e.test-level + +e.test-legend Уровень + | Новичок + +e.test-weaks + +e.test-legend Слабые места + | Кроссбраузерность, события, CSS + +e.test + +e.test-name + +e.test-legend 18 июля 2014 в 19:42 + +e.test-title Основной JavaScript + +e.test-legend Попытка #2 + +e.test-score + +e.test-legend Результат + +e.test-percent 58% + +e.test-level + +e.test-legend Уровень + | Ученик + +e.test-weaks + +e.test-legend Слабые места + | Кроссбраузерность + +e.test + +e.test-name + +e.test-legend 18 июля 2014 в 19:42 + +e.test-title Основной JavaScript + +e.test-legend Попытка #3 + +e.test-score + +e.test-legend Результат + +e.test-percent 96% + +e.test-level + +e.test-legend Уровень + | Мастер + +e.test-weaks + +e.test-legend Слабые места + | Кроссбраузерность diff --git a/handlers/markup/templates/pages/profile.jade b/handlers/markup/templates/pages/profile.jade new file mode 100755 index 000000000..e64f7f793 --- /dev/null +++ b/handlers/markup/templates/pages/profile.jade @@ -0,0 +1,222 @@ +extends ../layouts/profile + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Кабинет пользователя Nakazator'; + + //- layout + - layout.header = true + - layout.breadcrumbs = true + - layout.centeredHeader = true + +block content + +b.profile._settings + script. + document.addEventListener("DOMContentLoaded", function(e) { + var items = document.querySelectorAll('.profile__item_editable'); + for (var i = 0; i < items.length; i++) { + items[i].addEventListener('click', function(e) { + this.classList.add('profile__item_editing'); + }); + } + document.querySelector('.profile').addEventListener('click', function(e) { + var elem; + if (e.target.classList.contains('profile__item-cancel')) { + elem = e.target; + while (elem && !elem.classList.contains('profile__item_editable')) { + elem = elem.parentNode; + } + elem.classList.remove('profile__item_editing'); + } + }) + }); + include ../blocks/profile-upic + +e.content + +e.tabs + +e.tab._current + +e.tab-content + +e('a').tab-link(href="#") Публичный профиль + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Аккаунт + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Заказы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Курсы + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Уведомления + +e.tab + +e.tab-content + +e('a').tab-link(href="#") Тесты + +e.item + +e.item-content + +e('h2').inline-title Управление аккаунтом + +e.item._editable(data-inline-edit) + +e.item-content + +e.item-name Имя: + +e.item-value Валентиновский Валентин Валентинович + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-name") Имя: + +b.text-input._small._invalid.__control + +e('input').control#profile-name(type="text", value="Валентиновский Валентин Валентинович") + +e.err Таких имен не бывает + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Email: + +e.item-value + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-email") Email: + +b.text-input._small.__control + +e('input').control#profile-email(type="email") + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Website: + +e.item-value www.valentin.com + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-website") Website: + +b.text-input._small.__control + +e('input').control#profile-website(type="url", value="www.valentin.com") + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Страна: + +e.item-value Россия + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-country") Страна: + +b.text-input._small.__control + +e('input').control#profile-country(type="text", value="Россия") + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Город: + +e.item-value + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-city") Город: + +b.text-input._small.__control + +e('input').control#profile-city(type="text") + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Дата рождения: + +e.item-value + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-birth-day") Дата рождения: + +b('select').input-select._small.__control#profile-birth-day + +e('option').option(value="1") 1 + +e('option').option(value="2") 2 + +e('option').option(value="3") 3 + +e('option').option(value="4") 4 + +e('option').option(value="5") 5 + +e('option').option(value="6") 6 + +e('option').option(value="7") 7 + +e('option').option(value="8") 8 + +e('option').option(value="9") 9 + +e('option').option(value="10") 10 + +e('option').option(value="11") 11 + +e('option').option(value="12") 12 + +e('option').option(value="13") 13 + +e('option').option(value="14") 14 + +e('option').option(value="15") 15 + +e('option').option(value="16") 16 + +e('option').option(value="17") 17 + +e('option').option(value="18") 18 + +e('option').option(value="19") 19 + +e('option').option(value="20") 20 + +e('option').option(value="21") 21 + +e('option').option(value="22") 22 + +e('option').option(value="23") 23 + +e('option').option(value="24") 24 + +e('option').option(value="25") 25 + +e('option').option(value="26") 26 + +e('option').option(value="27") 27 + +e('option').option(value="28") 28 + +e('option').option(value="29") 29 + +e('option').option(value="30") 30 + +e('option').option(value="31") 31 + +b('select').input-select._small.__control#profile-birth-month + +e('option').select(value="1") январь + +e('option').select(value="2") февраль + +e('option').select(value="3") март + +e('option').select(value="4") апрель + +e('option').select(value="5") май + +e('option').select(value="6") июнь + +e('option').select(value="7") июль + +e('option').select(value="8") август + +e('option').select(value="9") сентябрь + +e('option').select(value="10") октябрь + +e('option').select(value="11") ноябрь + +e('option').select(value="12") декабрь + +b('select').input-select._small.__control#profile-birth-year + +e('option').select(value="1985") 1985 + +e('option').select(value="1986") 1986 + +e('option').select(value="1987") 1987 + +e('option').select(value="1988") 1988 + +e('option').select(value="1989") 1989 + +e('option').select(value="1990") 1990 + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Часовой пояс: + +e.item-value Европа/Москва: GMT+4 + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-tz") Часовой пояс: + +b('select').input-select._small.__control#profile-tz + +e('option').select(value="+1") Европа/Москва: GMT+1 + +e('option').select(value="+2") Европа/Москва: GMT+2 + +e('option').select(value="+3") Европа/Москва: GMT+3 + +e('option').select(value="+4") Европа/Москва: GMT+4 + +e('option').select(value="+5") Европа/Москва: GMT+5 + +e('option').select(value="+6") Европа/Москва: GMT+6 + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Телефон: + +e.item-value + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-tel") Телефон: + +b.text-input._small.__control + +e('input').control#profile-tel(type="text") + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name Интересы: + +e.item-value музыка, юзабилити, javascript, веб-дизайн, программирование, html, спорт, css + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-hobbies") Интересы: + +b.text-input._small.__control + +e('input').control#profile-hobbies(type="text", value="музыка, юзабилити, javascript, веб-дизайн, программирование, html, спорт, css") + include ../blocks/profile-ok-cancel + +e.item._editable + +e.item-content + +e.item-name О себе: + +e.item-value Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ullam iure eaque unde, repellat est sapiente. Optio autem odio illo necessitatibus, aliquam, ducimus repellat enim, non distinctio in doloremque, magni eligendi. + +e.item-change + +e.change-content + +e.labeled + +e('label').labeled-label(for="profile-about") О себе: + +b('textarea').textarea-input.__control#profile-about(cols="80", rows="5") + | Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ullam iure eaque unde, repellat est sapiente. Optio autem odio illo necessitatibus, aliquam, ducimus repellat enim, non distinctio in doloremque, magni eligendi. + include ../blocks/profile-ok-cancel diff --git a/handlers/markup/templates/pages/quiz-index.jade b/handlers/markup/templates/pages/quiz-index.jade new file mode 100644 index 000000000..34aa76ef0 --- /dev/null +++ b/handlers/markup/templates/pages/quiz-index.jade @@ -0,0 +1,33 @@ +extends /layouts/main + +block append head + link(href=pack('quiz', 'css') rel='stylesheet') + + +block append variables + - var title = 'Тестирование знания Javascript' + - var sitetoolbar = true + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + - var layout_header_class = "main__header_center" + + - var quiz = {} + - quiz.intro = 'На этой странице Вы можете протестировать свои знания Javascript, выбрав один из тестов' + + - quiz.list = [] + - quiz.list.push({ title: 'Основной Javascript', description: 'В тест включены вопросы по взаимодействию Javascript, DOM HTML, по синтаксису языка', url: '/123' }) + - quiz.list.push({ title: 'Особенности и фишки Javascript', description: 'Особенности Javascript по сравнению с другими языками. Трюки и фишки DOM, браузеров', url: '/123', result: '42%' }) + - quiz.list.push({ title: 'Коммуникация с сервером, AJAX, XMLHttpRequest', description: 'Различные аспекты работы с сервером из Javascript, транспорты и технологии', url: '/123' }) + + + - quiz.explanations = { title: 'Некоторые пояснения', list: []} + - quiz.explanations.list.push('Полный список браузеров, на который рассчитаны тесты: Firefox, Opera, Safari, Konqueror не более чем годовой давности и Internet Explorer 6.0+.') + - quiz.explanations.list.push('Если в вопросе ничего не сказано, то все настройки браузера - по умолчанию.') + - quiz.explanations.list.push('Версия Javascript - самая распространенная на текущий день, т.е 1.5.') + - quiz.explanations.list.push('Многие вопросы неочевидны и требуют не только знаний, но и опыта. Удачи!') + + + +block content + +b.intro !{ quiz.intro } + include ../blocks/quiz-selector + include ../blocks/quiz-explanations diff --git a/handlers/markup/templates/pages/quiz-results.jade b/handlers/markup/templates/pages/quiz-results.jade new file mode 100644 index 000000000..206effabe --- /dev/null +++ b/handlers/markup/templates/pages/quiz-results.jade @@ -0,0 +1,40 @@ +extends /layouts/main + +block append head + link(href=pack('quiz', 'css') rel='stylesheet') + + +block append variables + + - var layout_header_class = "main__header_center" + - var title = 'Результаты' + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + + - var contentWithMods = 'content_center' + + - var quiz = {} + + - quiz.title = 'Что из этого не событие мыши' + - quiz.type = 'checkbox' // radio + - quiz.variants = [{ title: 'onmousescroll' }, { title: 'onclick' }, { title: 'onmousover' }, { title: 'onmousemove' }, { title: 'onmousewheel' }, { title: 'some title', description: '
    some BLOCK content
    ' }] + + - var quizes = [JSON.parse(JSON.stringify(quiz)), JSON.parse(JSON.stringify(quiz))] + + - quizes[0].done = quizes[1].done = true + + - quizes[0].correct = true + - quizes[1].correct = false + + - quizes[0].selected = 1 + - quizes[0].correctNum = 1 + + - quizes[1].selected = 3 + - quizes[1].correctNum = 1 + + - quizes[1].note = 'Пояснение результата' + +block content + include ../blocks/quiz-result + + each quiz in quizes + include ../blocks/quiz-question diff --git a/handlers/markup/templates/pages/quiz.jade b/handlers/markup/templates/pages/quiz.jade new file mode 100644 index 000000000..10e4c1a47 --- /dev/null +++ b/handlers/markup/templates/pages/quiz.jade @@ -0,0 +1,32 @@ +extends /layouts/main + +block append head + link(href=pack('quiz', 'css') rel='stylesheet') + + +block append variables + + - var layout_header_class = "main__header_center" + - var headTitle = 'Название теста' + - var title = false + - var breadcrumbs = [{ title: 'Учебник', url: '/tutorial' }, { title: 'JavaScript.ru', url: 'http://javascript.ru' }] + + - var content_class = '_center' + + - var quiz = {} + - quiz.title = 'Что из этого не событие мыши' + - quiz.type = 'checkbox' // radio + - quiz.variants = [{ title: 'onmousescroll' }, { title: 'onclick' }, { title: 'onmousover' }, { title: 'onmousemove' }, { title: 'onmousewheel' }, { title: 'some title', description: '
    some BLOCK content
    ' }] + - quiz.current = 6 + - quiz.total = 18 + + - quiz.explanations = { title: 'Некоторые пояснения', list: []} + - quiz.explanations.list.push('Полный список браузеров, на который рассчитаны тесты: Firefox, Opera, Safari, Konqueror не более чем годовой давности и Internet Explorer 6.0+.') + - quiz.explanations.list.push('Если в вопросе ничего не сказано, то все настройки браузера - по умолчанию.') + - quiz.explanations.list.push('Версия Javascript - самая распространенная на текущий день, т.е 1.5.') + - quiz.explanations.list.push('Многие вопросы неочевидны и требуют не только знаний, но и опыта. Удачи!') + +block content + include ../blocks/quiz-start + include ../blocks/quiz-explanations + include ../blocks/quiz diff --git a/handlers/markup/templates/pages/search-notfound.jade b/handlers/markup/templates/pages/search-notfound.jade new file mode 100755 index 000000000..54b6a8c0d --- /dev/null +++ b/handlers/markup/templates/pages/search-notfound.jade @@ -0,0 +1,25 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Результаты поиска'; + + //- layout + - layout.sitetoolbar = true + - layout.header = true + - layout.breadcrumbs = false + +block content + +b.search-form + +e.content + +e.line + +e.query-wrap + +b.text-input.__query + +e('input').control(type="text") + +e.send-wrap + +b('button').submit-button.__send Найти + +e.footer + +e.status._notfound + | Мы всё перерыли, но « + +e('mark').marked клад + | » так и не нашли :-( diff --git a/handlers/markup/templates/pages/search.jade b/handlers/markup/templates/pages/search.jade new file mode 100755 index 000000000..9f7d0052e --- /dev/null +++ b/handlers/markup/templates/pages/search.jade @@ -0,0 +1,156 @@ +extends ../layouts/base + +//- http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Результаты поиска'; + + //- layout + - layout.sitetoolbar = true + - layout.header = true + - layout.breadcrumbs = false + +block content + script. + document.addEventListener("DOMContentLoaded", function() { + var fixedForm = document.querySelector(".search-form_fixed"); + var fixedFormInput = fixedForm.querySelector(".search-form__query .text-input__control"); + var staticFormInput = document.querySelector(".search-form:not(.search-form_fixed) .search-form__query .text-input__control"); + var fixedInputOffset = parseInt(getComputedStyle(fixedForm, "").paddingTop); + + function updateFixedForm() { + if (staticFormInput.getBoundingClientRect().top <= fixedInputOffset) { + if (fixedForm.classList.contains("search-form_hidden")) { + fixedFormInput.value = staticFormInput.value; + } + fixedForm.classList.remove("search-form_hidden"); + } else { + if (!fixedForm.classList.contains("search-form_hidden")) { + staticFormInput.value = fixedFormInput.value; + } + fixedForm.classList.add("search-form_hidden"); + } + } + + window.addEventListener("scroll", updateFixedForm); + updateFixedForm(); // set initial state + }); + + +b.search-form + +e.content + +e.line + +e.query-wrap + +b.text-input.__query + +e('input').control(type="text") + +e.send-wrap + +b('button').submit-button.__send Найти + +e.footer + +e.types + +e('button').type(type="submit", disabled="disabled") Учебник + +e('button').type(type="submit") Задачи + + + +b.search-results + +e.result + +e.title + +e('a').title-link(href="#") + +e('mark').marked Аргументы функций + +e.extract + | …альтернативная техника работы с + = " " + +e('mark').marked аргументами + | , которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + +e.result + +e.title + +e('a').title-link(href="#") Аргументы функций + +e.extract …альтернативная техника работы с аргументами, которая позволяет обращаться к ним не по имени, а по номеру… + +e('ul').path + +e('li').path-step + +e('a').path-link(href="#") Учебник + +e('li').path-step + +e('a').path-link(href="#") Аргументы функций + + +b.search-form._fixed._hidden + +e.content + +e.line + +e.query-wrap + +b.text-input.__query + +e('input').control(type="text") + +e.send-wrap + +b('button').submit-button.__send Найти diff --git a/handlers/markup/templates/pages/section-inner.jade b/handlers/markup/templates/pages/section-inner.jade new file mode 100755 index 000000000..3c7139e19 --- /dev/null +++ b/handlers/markup/templates/pages/section-inner.jade @@ -0,0 +1,16 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Основы Javascript'; + + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + - layout.header = true + - layout.breadcrumbs = true + +block content + include ../blocks/section-intro + include ../blocks/lessons-list-inner diff --git a/handlers/markup/templates/pages/section.jade b/handlers/markup/templates/pages/section.jade new file mode 100755 index 000000000..49bacd9d2 --- /dev/null +++ b/handlers/markup/templates/pages/section.jade @@ -0,0 +1,16 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Основы Javascript'; + + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + - layout.header = true + - layout.breadcrumbs = true + +block content + include ../blocks/section-intro + include ../blocks/lessons-list diff --git a/handlers/markup/templates/pages/sitemap-open.jade b/handlers/markup/templates/pages/sitemap-open.jade new file mode 100755 index 000000000..b9c9dd921 --- /dev/null +++ b/handlers/markup/templates/pages/sitemap-open.jade @@ -0,0 +1,18 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Учебник — Javascript.ru'; + + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + - layout.header = true + - layout.breadcrumbs = true + - layout.articleFoot = true + - layout.tutorialMap = true + - layout.bodyClass = "tutorial-map_on" + +block content + include ./article.html diff --git a/handlers/markup/templates/pages/task.jade b/handlers/markup/templates/pages/task.jade new file mode 100755 index 000000000..37b7baade --- /dev/null +++ b/handlers/markup/templates/pages/task.jade @@ -0,0 +1,13 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + + - self.title = "DOM Children" + +block content + include ../blocks/task-single diff --git a/handlers/markup/templates/pages/tasks.jade b/handlers/markup/templates/pages/tasks.jade new file mode 100755 index 000000000..ee828d233 --- /dev/null +++ b/handlers/markup/templates/pages/tasks.jade @@ -0,0 +1,13 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + //- layout + - layout.sitetoolbar = true + - layout.prevNext = true + - layout.sidebar = true + + - self.title = "Отдельная страница задач" + +block content + include ../blocks/tasks diff --git a/handlers/markup/templates/pages/tutorial.jade b/handlers/markup/templates/pages/tutorial.jade new file mode 100755 index 000000000..31e2c6d03 --- /dev/null +++ b/handlers/markup/templates/pages/tutorial.jade @@ -0,0 +1,113 @@ +extends ../layouts/base + +// - http://stackoverflow.com/questions/12646451/how-to-pass-variables-between-jade-templates +block variables + - self.title = 'Современный учебник JavaScript'; + + //- layout + - layout.sitetoolbar = true + - layout.header = true + - layout.breadcrumbs = true + - layout.centeredHeader = true + +block content + +b.intro + | Перед вами учебник по JavaScript, начиная с основ, включающий в себя много + | тонкостей и фишек JavaScript/DOM. + +b.course-search + +e('form').form(action="#") + +e.input-wrap._text + +b.text-input.__query + +e('input').control(type="text", placeholder="поиск по учебнику") + +e.input-wrap._submit + +b('button').submit-button.__submit Найти + +b.course-info + +e('h2').header Основной курс + +e.body.columns.columns_2 + +e.col.columns__col + +e.content + +e.title-note Часть первая + +e('h3').title Язык JavaScript + p Эта часть позволит вам изучить JavaScript с нуля или упорядочить и дополнить существующие знания. Мы будем использовать браузер в качестве окружения, но основное внимание будет уделяться именно самому языку JavaScript. + +b('ul').special-links-list + +e('li').item + +e('a').link(href="#") Введение + +e('li').item + +e('a').link(href="#") Основы JavaScript + +e('li').item + +e('a').link(href="#") Качество кода + +e('li').item + +e('a').link(href="#") Структуры данных + +e('li').item + +e('a').link(href="#") Замыкания, область видимости + +e('li').item + +e('a').link(href="#") Методы объектов и контекст вызова + +e('li').item + +e('a').link(href="#") Некоторые другие возможности + +e('li').item + +e('a').link(href="#") ООП в функциональном стиле + +e('li').item + +e('a').link(href="#") ООП в прототипном стиле + +e.col.columns__col + +e.content + +e.title-note Часть вторая + +e('h3').title Документ, события, интерфейсы + p Изучаем работу со страницей -- как получать элементы, манипулировать их размерами, динамически создавать интерфейсы и взаимодействовать с посетителем. + +b('ul').special-links-list + +e('li').item + +e('a').link(href="#") Документ и объекты страницы + +e('li').item + +e('a').link(href="#") Основы работы с событиями + +e('li').item + +e('a').link(href="#") События в деталях + +e('li').item + +e('a').link(href="#") Формы, элементы управления + +e('li').item + +e('a').link(href="#") Создание графических компонентов + +b.bricks + +e('h2').title Дополнительные курсы + +e.container + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") AJAX + +e.brick-content + p Lorem ipsum dolor sit amet, consectetur adipisicing elit. Suscipit voluptatibus excepturi ad quisquam, error ex? + p Сonsectetur adipisicing elit. Suscipit voluptatibus excepturi ad quisquam, error ex? + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Lorem ipsum + +e.brick-content + p Lorem ipsum dolor sit amet, consectetur adipisicing elit. + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Dolor sit amet + +e.brick-content + p Lorem ipsum dolor sit amet, consectetur adipisicing elit. Suscipit voluptatibus excepturi ad quisquam, error ex quaerat reprehenderit enim est soluta hic. + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Consectetur + +e.brick-content + p Lorem ipsum dolor sit amet, consectetur adipisicing elit. Suscipit voluptatibus excepturi ad quisquam, error ex quaerat reprehenderit enim est soluta hic, praesentium magnam laborum. + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Adipisicing elit + +e.brick-content + p Lorem ipsum dolor sit amet. + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Suscipit + +e.brick-content + p Lorem ipsum dolor sit amet, consectetur adipisicing elit. Suscipit voluptatibus. + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Voluptatibus + +e.brick-content + p Lorem ipsum. + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Quisquam + +e.brick + +e('h3').brick-title + +e('a').brick-title-link(href="#") Ex quaerat + +e.brick-content + p Lorem ipsum dolor sit amet, consectetur adipisicing elit. Suscipit voluptatibus excepturi ad quisquam, error ex quaerat. diff --git a/handlers/mongooseHandler.js b/handlers/mongooseHandler.js new file mode 100755 index 000000000..9fb152506 --- /dev/null +++ b/handlers/mongooseHandler.js @@ -0,0 +1,35 @@ +const mongoose = require('lib/mongoose'); + +const shimmer = require('shimmer'); +const clsNamespace = require('continuation-local-storage').getNamespace('app'); + +shimmer.wrap(mongoose.Mongoose.prototype.Promise.prototype, 'on', function (original) { + return function(event, callback) { + callback = clsNamespace.bind(callback); + return original.call(this, event, callback); + }; +}); + + +exports.boot = function*() { + + if (process.env.NODE_ENV == 'production') { + yield function(callback) { + mongoose.waitConnect(callback); + }; + } + + /* in ebook no elasticsearch, so I don't boot it here + var elastic = elasticClient(); + yield elastic.ping({ + requestTimeout: 1000 + }); + */ +}; + + +exports.close = function*() { + yield function(callback) { + mongoose.disconnect(callback); + }; +}; diff --git a/handlers/multipartParser.js b/handlers/multipartParser.js new file mode 100755 index 000000000..7a7dfbeab --- /dev/null +++ b/handlers/multipartParser.js @@ -0,0 +1,94 @@ +const PathListCheck = require('pathListCheck'); +const multiparty = require('multiparty'); +const thunkify = require('thunkify'); + +var log = require('log')(); + +function MultipartParser() { + this.ignore = new PathListCheck(); +} + + +MultipartParser.prototype.parse = thunkify(function(req, callback) { + + var form = new multiparty.Form(); + + var hadError = false; + var fields = {}; + + form.on('field', function(name, value) { + fields[name] = value; + }); + + // multipart file must be the last + form.on('part', function(part) { + if (part.filename !== null) { + // error is made the same way as multiparty uses + callback(createError(400, 'Files are not allowed here')); + } else { + throw new Error("Must never reach this line (field event parses all fields)"); + } + part.on('error', onError); + }); + + form.on('error', onError); + + form.on('close', onDone); + + form.parse(req); + + function onDone() { + log.debug("multipart parse done", fields); + if (hadError) return; + callback(null, fields); + } + + function onError(err) { + log.debug("multipart error", err); + if (hadError) return; + hadError = true; + callback(err); + } + +}); + + +MultipartParser.prototype.middleware = function() { + var self = this; + + return function*(next) { + // skip these methods + var contentType = this.get('content-type') || ''; + if (!~['DELETE', 'POST', 'PUT', 'PATCH'].indexOf(this.method) || !contentType.startsWith('multipart/form-data')) { + return yield* next; + } + + if (!self.ignore.check(this.path)) { + this.log.debug("multipart will parse"); + + // this may throw an error w/ status 400 or 415 or... + this.request.body = yield self.parse(this.req); + + this.log.debug("multipart done parse"); + } else { + this.log.debug("multipart skip"); + } + + yield* next; + }; +}; + + +exports.init = function(app) { + app.multipartParser = new MultipartParser(); + app.use(app.multipartParser.middleware()); +}; + + +function createError(status, message) { + var error = new Error(message); + Error.captureStackTrace(error, createError); + error.status = status; + error.statusCode = status; + return error; +} diff --git a/handlers/newsletter/client/index.js b/handlers/newsletter/client/index.js new file mode 100755 index 000000000..be2a7dcd2 --- /dev/null +++ b/handlers/newsletter/client/index.js @@ -0,0 +1,66 @@ +var Spinner = require('client/spinner'); +var xhr = require('client/xhr'); +var notification = require('client/notification'); + +function init() { + + +} + +function submitSubscribeForm(form, onSuccess) { + + if (!form.elements.email.value) { + return; + } + + const request = xhr({ + method: 'POST', + url: form.action, + body: { + email: form.elements.email.value, + slug: form.elements.slug.value + } + }); + + var submitButton = form.querySelector('[type="submit"]'); + + var spinner = new Spinner({ + elem: submitButton, + size: 'small', + elemClass: 'button_loading' + }); + spinner.start(); + submitButton.disabled = true; + + request.addEventListener('loadend', ()=> { + spinner.stop(); + submitButton.disabled = false; + }); + + var formLabel = form.getAttribute('data-newsletter-subscribe-form'); + + request.addEventListener('success', function(event) { + if (this.status == 200) { + + window.metrika.reachGoal('NEWSLETTER-SUBSCRIBE', { + form: formLabel + }); + window.ga('send', 'event', 'newsletter', 'subscribe', formLabel); + + new notification.Success(event.result.message, 'slow'); + onSuccess && onSuccess(); + } else { + + window.metrika.reachGoal('NEWSLETTER-SUBSCRIBE-FAIL', { + form: formLabel + }); + window.ga('send', 'event', 'newsletter', 'subscribe-fail', formLabel); + + new notification.Error(event.result.message); + } + }); + +} + +exports.init = init; +exports.submitSubscribeForm = submitSubscribeForm; diff --git a/handlers/newsletter/controllers/confirm.js b/handlers/newsletter/controllers/confirm.js new file mode 100755 index 000000000..aefbf4bd5 --- /dev/null +++ b/handlers/newsletter/controllers/confirm.js @@ -0,0 +1,22 @@ +const Newsletter = require('../models/newsletter'); +const Subscription = require('../models/subscription'); +const config = require('config'); + +exports.get = function*() { + this.nocache(); + + const subscription = yield Subscription.findOne({ + accessKey: this.params.accessKey + }).exec(); + + if (!subscription) { + this.throw(404, "Нет такой подписки"); + } + + subscription.confirmed = true; + yield subscription.persist(); + + this.addFlashMessage('success', 'Подписка подтверждена.'); + this.redirect('/newsletter/subscriptions/' + this.params.accessKey); + +}; diff --git a/handlers/newsletter/controllers/subscribe.js b/handlers/newsletter/controllers/subscribe.js new file mode 100644 index 000000000..a4298e8ec --- /dev/null +++ b/handlers/newsletter/controllers/subscribe.js @@ -0,0 +1,85 @@ +const path = require('path'); +const Newsletter = require('../models/newsletter'); +const Subscription = require('../models/subscription'); +const sendMail = require('mailer').send; +const config = require('config'); + +// subscribe a single newsletter (post from somewhere outside of the module) +exports.post = function*() { + + var self = this; + + function respond(message, subscription) { + // allow XHR from javascript.ru + if (self.get('Origin') == 'http://javascript.ru') { + self.set('Access-Control-Allow-Origin', 'http://javascript.ru'); + } + self.body = { + message: message + }; + } + + const newsletter = yield Newsletter.findOne({ + slug: this.request.body.slug + }).exec(); + + if (!newsletter) { + this.throw(404, "Нет такой рассылки"); + } + + if (this.req.user && this.req.user.email == this.request.body.email) { + // registered users need no confirmation + + var subscription = yield Subscription.findOne({ + email: this.request.body.email + }).exec(); + + if (subscription) { + subscription.newsletters.addToSet(newsletter._id); + subscription.confirmed = true; + } else { + subscription = new Subscription({ + email: this.request.body.email, + newsletters: [newsletter._id], + confirmed: true + }); + } + + yield subscription.persist(); + + respond(`Вы успешно подписаны, ждите писем на адрес ${subscription.email}.`, subscription); + + } else { + var subscription = yield Subscription.findOne({ + email: this.request.body.email + }).exec(); + + if (!subscription) { + subscription = new Subscription({ + email: this.request.body.email, + confirmed: false + }); + } + + subscription.newsletters.addToSet(newsletter._id); + + yield subscription.persist(); + + if (subscription.confirmed) { + respond(`Вы успешно подписаны.`, subscription); + } else { + + yield sendMail({ + templatePath: path.join(this.templateDir, 'confirm-email'), + subject: "Подтверждение подписки", + to: subscription.email, + link: (config.server.siteHost || 'http://javascript.in') + '/newsletter/confirm/' + subscription.accessKey + }); + + respond(`На адрес ${subscription.email} направлен запрос подтверждения.`, subscription); + } + } + + +}; + diff --git a/handlers/newsletter/controllers/subscriptions.js b/handlers/newsletter/controllers/subscriptions.js new file mode 100755 index 000000000..7ff64da8b --- /dev/null +++ b/handlers/newsletter/controllers/subscriptions.js @@ -0,0 +1,77 @@ +const path = require('path'); +const Newsletter = require('../models/newsletter'); +const Subscription = require('../models/subscription'); +const sendMail = require('mailer').send; +const config = require('config'); +const _ = require('lodash'); + +exports.get = function*() { + this.nocache(); + + var subscription = yield Subscription.findOne({ + accessKey: this.params.accessKey + }).exec(); + + if (!subscription) { + this.throw(404, "Нет такой подписки."); + } + + var newsletters = yield Newsletter.find({}).sort({weight: 1}).exec(); + + + this.locals.newsletters = newsletters.map(function(newsletter) { + return { + slug: newsletter.slug, + title: newsletter.title, + period: newsletter.period, + // mongoose array can #indexOf ObjectIds + subscribed: ~subscription.newsletters.indexOf(newsletter._id) + }; + }); + + this.locals.accessKey = this.params.accessKey; + + this.body = this.render('subscriptions'); + +}; + + +exports.post = function*() { + + var subscription = yield Subscription.findOne({ + accessKey: this.params.accessKey + }).exec(); + + if (!subscription) { + this.throw(404, "Нет такой подписки."); + } + + if (this.request.body.remove) { + yield subscription.destroy(); + this.body = this.render('removed'); + return; + } + + var slugs = this.request.body.slug || []; + + if (!Array.isArray(slugs)) { + slugs = [slugs]; + } + slugs = slugs.map(String); + + var newsletters = yield Newsletter.find({ + slug: { + $in: slugs + } + }).exec(); + + subscription.newsletters = _.pluck(newsletters, '_id'); + + yield subscription.persist(); + + this.addFlashMessage('success', "Настройки обновлены."); + + this.redirect('/newsletter/subscriptions/' + this.params.accessKey); + +}; + diff --git a/handlers/newsletter/index.js b/handlers/newsletter/index.js new file mode 100755 index 000000000..d59a483e2 --- /dev/null +++ b/handlers/newsletter/index.js @@ -0,0 +1,13 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use( mountHandlerMiddleware('/newsletter', __dirname) ); + + // allow to post from javascript.ru + // subscriptions require confirmation anyway, so disabling CSRF is safe + app.csrfChecker.ignore.add('/newsletter/subscribe'); +}; + +exports.Newsletter = require('./models/newsletter'); +exports.Subscription = require('./models/subscription'); diff --git a/handlers/newsletter/models/newsletter.js b/handlers/newsletter/models/newsletter.js new file mode 100755 index 000000000..32d8576eb --- /dev/null +++ b/handlers/newsletter/models/newsletter.js @@ -0,0 +1,30 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const schema = new Schema({ + title: { + type: String, + required: true + }, + slug: { + type: String, + required: true, + unique: true + }, + // weight for non-internal subscriptions sorting + weight: { + type: Number, + default: 0, + required: true + }, + // how often? string description + period: { + type: String + }, + created: { + type: Date, + default: Date.now + } +}); + +var Newsletter = module.exports = mongoose.model('Newsletter', schema); diff --git a/handlers/newsletter/models/newsletterRelease.js b/handlers/newsletter/models/newsletterRelease.js new file mode 100755 index 000000000..e70079dd9 --- /dev/null +++ b/handlers/newsletter/models/newsletterRelease.js @@ -0,0 +1,20 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; +const Letter = require('mailer').Letter; + +// a letter to one (or more) newsletters +const schema = new Schema({ + newsletters: { + type: [{ + type: Schema.Types.ObjectId, + ref: 'Newsletter' + }], + required: true + }, + created: { + type: Date, + default: Date.now + } +}); + +module.exports = mongoose.model('NewsletterRelease', schema); diff --git a/handlers/newsletter/models/subscription.js b/handlers/newsletter/models/subscription.js new file mode 100755 index 000000000..516e20611 --- /dev/null +++ b/handlers/newsletter/models/subscription.js @@ -0,0 +1,53 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; +const crypto = require('crypto'); +const _ = require('lodash'); + +const schema = new Schema({ + newsletters: { + // can be empty + type: [{ + type: Schema.Types.ObjectId, + ref: 'Newsletter' + }], + default: [], + validate: [ + { + validator: function mustBeUnique(value) { + return _.uniq(value).length == value.length; + }, + msg: 'Список подписок содержит дубликаты.' + } + ] + }, + email: { + type: String, + required: true, + unique: true, + validate: [ + { + validator: function checkEmail(value) { + return /^[-.\w+]+@([\w-]+\.)+[\w-]{2,12}$/.test(value); + }, + msg: 'Укажите, пожалуйста, корректный email.' + } + ] + }, + accessKey: { + type: String, + unique: true, + default: function() { + return parseInt(crypto.randomBytes(6).toString('hex'), 16).toString(36); + } + }, + confirmed: { + type: Boolean, + required: true + }, + created: { + type: Date, + default: Date.now + } +}); + +var Subscription = module.exports = mongoose.model('Subscription', schema); diff --git a/handlers/newsletter/router.js b/handlers/newsletter/router.js new file mode 100755 index 000000000..8b2c1c4a9 --- /dev/null +++ b/handlers/newsletter/router.js @@ -0,0 +1,14 @@ +var Router = require('koa-router'); + +var confirm = require('./controllers/confirm'); +var subscriptions = require('./controllers/subscriptions'); +var subscribe = require('./controllers/subscribe'); + +var router = module.exports = new Router(); + +router.post("/subscribe", subscribe.post); +router.get("/confirm/:accessKey", confirm.get); + +router.get("/subscriptions/:accessKey", subscriptions.get); +router.post("/subscriptions/:accessKey", subscriptions.post); + diff --git a/handlers/newsletter/tasks/createLetters.js b/handlers/newsletter/tasks/createLetters.js new file mode 100644 index 000000000..e4b956472 --- /dev/null +++ b/handlers/newsletter/tasks/createLetters.js @@ -0,0 +1,114 @@ +var co = require('co'); +var fs = require('fs'); +var log = require('log')(); +var gutil = require('gulp-util'); +var glob = require('glob'); +const path = require('path'); +const Newsletter = require('../models/newsletter'); +const NewsletterRelease = require('../models/newsletterRelease'); +const Subscription = require('../models/subscription'); +const mailer = require('mailer'); +const config = require('config'); + +module.exports = function(options) { + + return function() { + + var args = require('yargs') + .usage("Slug is required.") + // gulp newsletter:createLetters --slug js-1405 --templatePath ./js-1405.jade --subject 'Курс JavaScript: напоминание о собрании' --test iliakan@gmail.com --nounsubscribe + .example("gulp newsletter:createLetters --slug nodejs --templatePath ./mail.jade --subject 'Тема письма'") + .describe('slug', 'Названия рассылок NewsLetter через запятую') + .describe('templatePath', 'Шаблон для рассылки') + .describe('subject', 'Тема письма') + .describe('test', 'Email, на который выслать тестовое письмо.') + .describe('nounsubscribe', 'Без ссылки на отписку.') + .demand(['slug', 'templatePath', 'subject']) + .argv; + + return co(function* () { + + var slugs = args.slug.split(',').filter(String); + + var newsletters = yield Newsletter.find({ + slug: { + $in: slugs + } + }).exec(); + + + var newsletterIdToSlug = {}; + newsletters.forEach(function(n) { + newsletterIdToSlug[n._id.toString()] = n.slug; + }); + + if (newsletters.length != slugs.length) { + throw new Error("Can't find one or more newsletters with slugs: " + args.slug); + } + + var release = yield NewsletterRelease.create({ + newsletters: newsletters.map(function(n) { return n._id; }) + }); + + + // subscriptions which match any of newsletter slugs + var subscriptions = yield Subscription.find({ + newsletters: { + $in: newsletters.map(function(n) { return n._id; }) + }, + confirmed: true + }, {email: true, newsletters: true, accessKey: true, _id: false}).exec(); + + + if (args.test) { + subscriptions = [ + new Subscription({ + email: args.test, + accessKey: 'test', + newsletters: newsletters.map(function(n) { return n._id; }) + }) + ]; + } + + console.log(subscriptions[0].newsletters); + + for (var i = 0; i < subscriptions.length; i++) { + var subscription = subscriptions[i]; + var unsubscribeUrl = args.nounsubscribe ? null : + (config.server.siteHost || 'http://javascript.in') + '/newsletter/subscriptions/' + subscription.accessKey; + + + var listSlug = ''; + for (var j = 0; j < subscription.newsletters.length; j++) { + var subscriptionNewsletterId = String(subscription.newsletters[j]); + listSlug = newsletterIdToSlug[subscriptionNewsletterId]; + if (listSlug) break; + } + + if (!listSlug) { + throw new Error("No list slug for subscription (why receiving?) " + subscription._id); + } + + yield* mailer.createLetter({ + from: 'informer', + templatePath: args.templatePath, + to: subscription.email, + subject: args.subject, + unsubscribeUrl: unsubscribeUrl, + newsletterRelease: release._id, + headers: { + Precedence: 'bulk', + 'List-ID': '<' + listSlug + '.list-id.javascript.ru>', + 'List-Unsubscribe': '<' + unsubscribeUrl + '>' + } + }); + + gutil.log("Created letter to " + subscription.email); + + } + + }); + + + }; +}; diff --git a/handlers/newsletter/tasks/send.js b/handlers/newsletter/tasks/send.js new file mode 100755 index 000000000..9887b7200 --- /dev/null +++ b/handlers/newsletter/tasks/send.js @@ -0,0 +1,40 @@ +var co = require('co'); +var fs = require('fs'); +var log = require('log')(); +var gutil = require('gulp-util'); +var glob = require('glob'); +const path = require('path'); +const Newsletter = require('../models/newsletter'); +const Subscription = require('../models/subscription'); +const mailer = require('mailer'); +const Letter = require('mailer').Letter; +const config = require('config'); + +// Sends all newsletter letters +module.exports = function(options) { + + return function() { + + return co(function* () { + + var letters = yield Letter.find({ + sent: false, + // only newsletter emails, not transient ones + newsletterRelease: { + $exists: true + } + }).exec(); + + for (var i = 0; i < letters.length; i++) { + var letter = letters[i]; + + yield mailer.sendLetter(letter); + gutil.log("Sent to " + letter.message.to[0].email); + } + + }); + + }; +}; + + diff --git a/handlers/newsletter/templates/confirm-email.jade b/handlers/newsletter/templates/confirm-email.jade new file mode 100755 index 000000000..3132893a9 --- /dev/null +++ b/handlers/newsletter/templates/confirm-email.jade @@ -0,0 +1,12 @@ +extends /layouts/email + +block body + h1= subject + p Вы запрашивали уведомления на сайте JavaScript.ru? + + p Если да — подтвердите это, перейдя по ссылке + = ' ' + a(href=link)= link + | . + + diff --git a/handlers/newsletter/templates/confirm.jade b/handlers/newsletter/templates/confirm.jade new file mode 100755 index 000000000..80e6803c9 --- /dev/null +++ b/handlers/newsletter/templates/confirm.jade @@ -0,0 +1,14 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var title = "Подписка" + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + + +b.notification._message._success + +e.content + + p Спасибо, подписка подтверждена. diff --git a/handlers/newsletter/templates/removed.jade b/handlers/newsletter/templates/removed.jade new file mode 100755 index 000000000..36475737b --- /dev/null +++ b/handlers/newsletter/templates/removed.jade @@ -0,0 +1,14 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var title = "Адрес удалён" + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + + +b.notification._message._success + +e.content + + p Ваш адрес удалён из базы подписок. diff --git a/handlers/newsletter/templates/subscriptions.jade b/handlers/newsletter/templates/subscriptions.jade new file mode 100755 index 000000000..4cf104480 --- /dev/null +++ b/handlers/newsletter/templates/subscriptions.jade @@ -0,0 +1,40 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var title = "Управление подписками" + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + + p Темы: + + style. + .main ul > li:before { + content: ''; + } + .main ul { + padding: 0; + } + + form(method="POST", action="/newsletter/subscriptions/" + accessKey) + input(type="hidden", name="_csrf", value=csrf()) + ul + each newsletter in newsletters + li + label + input(type="checkbox", name="slug", value=newsletter.slug, checked=newsletter.subscribed || undefined) + = ' ' + = newsletter.title + = ' (' + = newsletter.period + | ) + + li + label + input(type="checkbox", name="remove" value="1") + | Удалить мой адрес из базы + + +b('button').button._action.__save(type="submit") + +e('span').text Сохранить diff --git a/handlers/newsletter/templates/test-email.jade b/handlers/newsletter/templates/test-email.jade new file mode 100755 index 000000000..fe53d0538 --- /dev/null +++ b/handlers/newsletter/templates/test-email.jade @@ -0,0 +1,5 @@ +extends /layouts/email + +block body + h1= subject + p Тест рассылки \ No newline at end of file diff --git a/handlers/newsletter/templates/updated.jade b/handlers/newsletter/templates/updated.jade new file mode 100755 index 000000000..b8bc1f027 --- /dev/null +++ b/handlers/newsletter/templates/updated.jade @@ -0,0 +1,14 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var title = "Настройки обновлены." + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + + +b.notification._message._success + +e.content + + p Настройки обновлены. diff --git a/handlers/nocache.js b/handlers/nocache.js new file mode 100755 index 000000000..27d562b01 --- /dev/null +++ b/handlers/nocache.js @@ -0,0 +1,12 @@ + +exports.init = function(app) { + app.use(function*(next) { + + this.nocache = function() { + this.set('Cache-Control', 'no-cache, no-store, must-revalidate'); + }; + + yield* next; + }); + +}; diff --git a/handlers/nodejsScreencast/client/index.js b/handlers/nodejsScreencast/client/index.js new file mode 100755 index 000000000..d58a41cce --- /dev/null +++ b/handlers/nodejsScreencast/client/index.js @@ -0,0 +1,112 @@ +var Modal = require('client/head/modal'); +var courseForm = require('../templates/course-form.jade'); +var clientRender = require('client/clientRender'); +var newsletter = require('newsletter/client'); +var gaHitCallback = require('gaHitCallback'); + +exports.init = function() { + initList(); + + var form = document.querySelector('[data-newsletter-subscribe-form]'); + + form.onsubmit = function(event) { + event.preventDefault(); + newsletter.submitSubscribeForm(form); + }; + + var link = document.querySelector('[data-nodejs-screencast-top-subscribe]'); + + link.onclick = function(event) { + var modal = new Modal(); + modal.setContent(clientRender(courseForm)); + + var form = modal.elem.querySelector('form'); + form.setAttribute('data-newsletter-subscribe-form', 'nodejs-top'); + form.onsubmit = function(event) { + event.preventDefault(); + newsletter.submitSubscribeForm(form, function() { + modal.remove(); + }); + }; + + event.preventDefault(); + }; + +}; + +function initList() { + var lis = document.querySelectorAll('li[data-mnemo]'); + + for (var i = 0; i < lis.length; i++) { + var li = lis[i]; + var mnemo = li.getAttribute('data-mnemo'); + + li.insertAdjacentHTML( + 'beforeEnd', + + '
    ' + + '
    ' + + '' + + '
    ' + + '
    ' + ); + } + + var links = document.querySelectorAll('a[data-video-id]'); + for (var i = 0; i < links.length; i++) { + var link = links[i]; + link.onclick = function(e) { + e.preventDefault(); + var videoId = this.getAttribute('data-video-id'); + window.ga('send', 'event', 'nodejs-screencast', 'open', videoId, { + hitCallback: gaHitCallback(function() { + openVideo(videoId); + }) + }); + }; + } +} + +function openVideo(videoId) { + // sizes from https://developers.google.com/youtube/iframe_api_reference + var sizeList = [ + {width: 0, height: 0}, // mobile screens lower than any player => new window + {width: 640, height: 360 + 30}, + {width: 853, height: 480 + 30}, + {width: 1280, height: 720 + 30} + ]; + + for(var i=0; i` + ); + } + }) + }); + + +} diff --git a/handlers/nodejsScreencast/controllers/index.js b/handlers/nodejsScreencast/controllers/index.js new file mode 100755 index 000000000..fc3e0b927 --- /dev/null +++ b/handlers/nodejsScreencast/controllers/index.js @@ -0,0 +1,8 @@ +var sendMail = require('mailer').send; +var path = require('path'); +var config = require('config'); + +exports.get = function*() { + this.locals.siteToolbarCurrentSection = "nodejs-screencast"; + this.body = this.render('index'); +}; diff --git a/handlers/nodejsScreencast/index.js b/handlers/nodejsScreencast/index.js new file mode 100755 index 000000000..bd792567c --- /dev/null +++ b/handlers/nodejsScreencast/index.js @@ -0,0 +1,7 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/nodejs-screencast', __dirname)); +}; + diff --git a/handlers/nodejsScreencast/router.js b/handlers/nodejsScreencast/router.js new file mode 100755 index 000000000..e0509cf7f --- /dev/null +++ b/handlers/nodejsScreencast/router.js @@ -0,0 +1,7 @@ +var Router = require('koa-router'); + +var index = require('./controllers/index'); + +var router = module.exports = new Router(); + +router.get('/', index.get); diff --git a/handlers/nodejsScreencast/templates/_course-form.jade b/handlers/nodejsScreencast/templates/_course-form.jade new file mode 100755 index 000000000..a33018b0a --- /dev/null +++ b/handlers/nodejsScreencast/templates/_course-form.jade @@ -0,0 +1,16 @@ + ++b.course-form + +e("h3").title Курс и новые выпуски скринкаста по Node.JS + +e("p").text Время от времени я провожу онлайн-курс по Node.JS / IO.JS. + +e("p") Курс — это практика, решение задач на Node.JS, изучение современной разработки на нём. + +e("p") Пришлю уведомление с деталями программы, когда будет открыта запись, и вы сможете решить, интересно ли это вам. Также уведомление будет при новых выпусках скринкаста. + +e("form").form(data-newsletter-subscribe-form="nodejs-bottom" onsubmit="return false" action="/newsletter/subscribe" method="POST") + input(type="hidden" value="nodejs" name="slug") + if user + input(type="hidden" value=user.email name="email") + else + +b.text-input + +e('input').control(type="email" placeholder="me@mail.com" name="email" data-modal-autofocus required) + + +b("button").button._action(type="submit") + +e("span").text Уведомите меня diff --git a/handlers/nodejsScreencast/templates/course-form.jade b/handlers/nodejsScreencast/templates/course-form.jade new file mode 100755 index 000000000..a8dbc3aa4 --- /dev/null +++ b/handlers/nodejsScreencast/templates/course-form.jade @@ -0,0 +1,3 @@ +include /bem + +include _course-form diff --git a/handlers/nodejsScreencast/templates/index.jade b/handlers/nodejsScreencast/templates/index.jade new file mode 100755 index 000000000..5983e1894 --- /dev/null +++ b/handlers/nodejsScreencast/templates/index.jade @@ -0,0 +1,79 @@ +extends /layouts/main + +block append variables + - var headTitle = 'Скринкаст по Node.JS'; + - var sitetoolbar = true + - var header = true + - var layout_page_class = "page_contains_header" + - var mainclass = "main-headered" + + +block content + +b.nodejs-screencast-header + + +e("h1").title Скринкаст + //- two spaces! one is consumed, one remains + +e("strong").title-accent NODE.JS + + +e("p").description + | Основные возможности и средства создания веб-сервисов, включая внутренние особенности самого сервера Node.JS + + +e("ul").buttons + + +e("li").button + +b("a")(href="https://www.youtube.com/playlist?list=PLDyvV36pndZFWfEQpNixIHVvp191Hb3Gg").simple-button + +e.text + | Смотреть на + +e("strong").accent Youtube + +e.description Плейлист 43 записи + + +e("li").button + +b("a")(href="/nodejs-screencast/nodejs-mp4-low.zip").simple-button + +e.text + | Скачать скринкаст + +e.description Компактный размер + +e("strong").description-accent (228Mb) + + +e("li").button + +b("a")(href="/nodejs-screencast/nodejs-mp4.zip").simple-button + +e.text + | Скачать скринкаст + +e.description Хорошее качество + +e("strong").description-accent (4Gb) + + +e("p").info Если вы где-то выкладываете этот скринкаст (торрент и т.п.), то обязательно давайте ссылку на эту страницу, так как все обновления и важные изменения я публикую здесь. + + +b.share-icons + +e('span').title Поделиться + include /blocks/social-icons + + + +e("p").foot Ниже вы можете ознакомиться более детально с содержанием скринкаста + + +e("a")(href="#" data-nodejs-screencast-top-subscribe).tag + | Не прозевайте + br + |курсы по + strong Node.js! + + + .main(class='main_width-limit-wide').nodejs-screencast-content + + include:simpledown index.md + + +b.faq-cite + +e("h2").title Ответы на частые вопросы: + +e.list + +e('dl') + dt У меня Windows, пытаюсь запустить скрипт в cmd, набираю "node server.js" — выдаёт ошибку, что делать? + dd Перейдите в нужную директорию командой "CD <директория, в которой у вас находится server.js>". Например: "CD C:\node". Оттуда и запускайте. + dt Пробую запускать в FAR, но не вижу вывода скрипта. + dd Нажмите Ctrl + O, это отключит панели FAR и вы сможете всё видеть. Нажмите ещё раз — и панели снова появятся. + + include _course-form + + script(src=pack("nodejsScreencast", "js")) + script nodejsScreencast.init(); + + include /blocks/comments + diff --git a/handlers/nodejsScreencast/templates/index.md b/handlers/nodejsScreencast/templates/index.md new file mode 100755 index 000000000..cf6c2b725 --- /dev/null +++ b/handlers/nodejsScreencast/templates/index.md @@ -0,0 +1,111 @@ + +Вашему вниманию предлагается скринкаст по Node.JS на русском языке. + +Его целью не является разбор всех-всех возможностей и модулей Node.JS, ведь многие из них используются очень редко. + +С другой стороны, мы очень подробно разберём основные возможности и средства создания веб-сервисов, +включая внутренние особенности самого сервера Node.JS, важные для его работы. + +Если вы -- разработчик, то вам наверняка известно: большинство полезной документации и скринкастов делается на английском. + +Конечно, даже на английском много всего устаревшего, приходится порыться, но на русском -- всё гораздо хуже. +Многого просто нет. Хотелось бы поменять эту ситуацию, хотя бы в плане Node.JS. + +## Часть 1: Изучаем Node.JS + +Выпуски были записаны для Node 0.10. + +Каждую запись можно просмотреть или скачать в низком и хорошем качестве. + + + +## Часть 2: Создаём приложение + +В этой части разные технологии и внешние модули, используемые при NodeJS-разработке будут описаны в контексте создания веб-приложения. + +Веб-приложение -- сайт с чатом, посетителями, базой данных и авторизацией. + +[smart header="Express 3 -> Express 4"] +Вторая часть записана с версией фреймворка express 3, сейчас уже express 4. +Устаревшие фичи express3 в скринкасте не используются, так что это единственное существенное отличие -- в express 4 многие библиотеки вынесены отдельно из фреймворка, см. [Migrating from 3.x to 4.x](https://github.com/visionmedia/express/wiki/Migrating-from-3.x-to-4.x). +Если вы хотите следовать скринкасту, то рекомендуется `npm i express@3`, переход на 4 будет для вас очевиден. + +Вторую часть можно использовать и в качестве основы для перехода к более современным фреймворкам, таким как [KoaJS](http://koajs.com). +[/smart] + + + + + +Дополнительно: + + + + + +## Код + +Код к большинству выпусков находится в здесь: [](https://github.com/iliakan/nodejs-screencast), его также можно скачать и в виде [zip-файла](https://github.com/iliakan/nodejs-screencast/archive/master.zip). diff --git a/handlers/passportRememberMe/index.js b/handlers/passportRememberMe/index.js new file mode 100755 index 000000000..cd1df0ac1 --- /dev/null +++ b/handlers/passportRememberMe/index.js @@ -0,0 +1,33 @@ +const mongoose = require('mongoose'); +const passport = require('koa-passport'); +const config = require('config'); +const User = require('users').User; +const RememberMeStrategy = require('./rememberMeStrategy'); +const RememberMeToken = require('./rememberMeToken'); + +// auto logs in X-Test-User-Id when testing +exports.init = function(app) { + + // this strategy stands aside from others, because it has no route + // works automatically, just as sessions (and essentialy is a session add-on) + + var options = config.auth.rememberMe; + passport.use(new RememberMeStrategy(options, RememberMeToken.consume, RememberMeToken.issue)); + + app.use(passport.authenticate('remember-me')); + + app.use(function*(next) { + + this.rememberMe = function*() { + var token = new RememberMeToken({ + user: this.user + }); + yield token.persist(); + this.cookies.set(options.key, token.value, options.cookie); + }; + + yield* next; + + }); +}; + diff --git a/handlers/passportRememberMe/rememberMeStrategy.js b/handlers/passportRememberMe/rememberMeStrategy.js new file mode 100755 index 000000000..ff481f090 --- /dev/null +++ b/handlers/passportRememberMe/rememberMeStrategy.js @@ -0,0 +1,84 @@ +var passport = require('koa-passport'); +var util = require('util'); + +function Strategy(options, verify, issue) { + this._opts = options; + + if (!verify) throw new Error('remember me cookie authentication strategy requires a verify function'); + if (!issue) throw new Error('remember me cookie authentication strategy requires an issue function'); + + this._key = options.key; + + passport.Strategy.call(this); + this.name = 'remember-me'; + this._verify = verify; + this._issue = issue; +} + +util.inherits(Strategy, passport.Strategy); + +/** + * Authenticate request based on remember me cookie. + * + * @param {Object} req + * @api protected + */ +Strategy.prototype.authenticate = function(req, options) { + // The rememeber me cookie is only consumed if the request is not + // authenticated. This is in preference to the session, which is typically + // established at the same time the remember me cookie is issued. + if (req.isAuthenticated()) { return this.pass(); } + + var token = req.cookies.get(this._key); + + // Since the remember me cookie is primarily a convenience, the lack of one is + // not a failure. In this case, a response should be rendered indicating a + // logged out state, rather than denying the request. + if (!token) { return this.pass(); } + + var self = this; + + function verified(err, user, info) { + if (err) { return self.error(err); } + + // Express exposes the response to the request. We need the response to set + // a cookie, so we'll grab it this way. This breaks the encapsulation of + // Passport's Strategy API, but is acceptable for this strategy. + //var res = req.res; + + if (!user) { + // The remember me cookie was not valid. However, because this + // authentication method is primarily a convenience, we don't want to + // deny the request. Instead we'll clear the invalid cookie and proceed + // to respond in a manner which indicates a logged out state. + // + // Note that a failure at this point may indicate a possible theft of the + // cookie. If handling this situation is a requirement, it is up to the + // application to encode the value in such a way that this can be detected. + // For a discussion on such matters, refer to: + // http://fishbowl.pastiche.org/2004/01/19/persistent_login_cookie_best_practice/ + // http://jaspan.com/improved_persistent_login_cookie_best_practice + // http://web.archive.org/web/20130214051957/http://jaspan.com/improved_persistent_login_cookie_best_practice + // http://stackoverflow.com/questions/549/the-definitive-guide-to-forms-based-website-authentication + + req.cookies.set(self._key); // delete cookie + return self.pass(); + } + + // The remember me cookie was valid and consumed. For security reasons, + // the just-used token should have been invalidated by the application. + // A new token will be issued and set as the value of the remember me + // cookie. + function issued(err, val) { + if (err) { return self.error(err); } + req.cookies.set(self._key, val, self._opts.cookie); + return self.success(user, info); + } + + self._issue(user, issued); + } + + self._verify(token, verified); +}; + +module.exports = Strategy; diff --git a/handlers/passportRememberMe/rememberMeToken.js b/handlers/passportRememberMe/rememberMeToken.js new file mode 100755 index 000000000..2badd91be --- /dev/null +++ b/handlers/passportRememberMe/rememberMeToken.js @@ -0,0 +1,71 @@ + +var mongoose = require('mongoose'); +var crypto = require('crypto'); + +var RememberMeTokenSchema = new mongoose.Schema({ + user: { + required: true, + type: mongoose.Schema.Types.ObjectId, + ref: 'User' + }, + + value: { + type: String, + unique: true, + default: function() { + // 8-9 random alphanumeric chars + return parseInt(crypto.randomBytes(5).toString('hex'), 16).toString(36); + } + }, + + createdAt: { + type: Date, + default: new Date(), + expires: 7 * 24 * 3600 // token lives for 7 days + } +}); + +// find user by tokenValue and kill the token after success +RememberMeTokenSchema.statics.consume = function(tokenValue, done) { + + RememberMeToken.findOne({ + value: tokenValue + }).populate('user') + .exec(function(err, token) { + if (err) return done(err); + if (!token) return done(null, false); + + var user = token.user; + + token.remove(function(err) { + if (err) return done(err); + + if (!user || user.deleted) { + done(null, false); + } else { + done(null, user); + } + }); + + }); + +}; + +// create a new token for user and return it's value +RememberMeTokenSchema.statics.issue = function(user, done) { + + var token = new RememberMeToken({ + user: user + }); + + token.save(function(err) { + if (err) return done(err); + return done(null, token.value); + }); + +}; + + +var RememberMeToken = mongoose.model('RememberMeToken', RememberMeTokenSchema); + +module.exports = RememberMeToken; diff --git a/handlers/passportSession/index.js b/handlers/passportSession/index.js new file mode 100755 index 000000000..ea5f6e909 --- /dev/null +++ b/handlers/passportSession/index.js @@ -0,0 +1,62 @@ +const mongoose = require('mongoose'); +const passport = require('koa-passport'); +const config = require('config'); +const User = require('users').User; + +// @see auth for strategies + +passport.serializeUser(function(user, done) { + done(null, user.id); // uses _id as idFieldd +}); + +passport.deserializeUser(function(id, done) { + User.findById(id, done); // callback version checks id validity automatically +}); + +// auto logs in X-Test-User-Id when testing +exports.init = function(app) { + + app.use(function* cleanEmptySessionPassport(next) { + yield* next; + if (this.session && this.session.passport && Object.keys(this.session.passport).length === 0) { + delete this.session.passport; + } + }); + + app.use(function* defineUserGetter(next) { + Object.defineProperty(this, 'user', { + get: function() { + return this.req.user; + } + }); + yield* next; + }); + + + + app.use(passport.initialize()); + app.use(passport.session()); + + if (process.env.NODE_ENV == 'test') { + app.use(testAutoLogin); + } + +}; + +function* testAutoLogin(next) { + var userId = this.get('X-Test-User-Id'); + if (!userId) { + yield* next; + return; + } + + var user = yield User.findById(userId).exec(); + + if (!user) { + this.throw(500, "No test user " + userId); + } + + yield this.login(user); + + yield* next; +} diff --git a/handlers/payments/banksimple/controller/invoice.docx b/handlers/payments/banksimple/controller/invoice.docx new file mode 100755 index 000000000..86e9e8c3a Binary files /dev/null and b/handlers/payments/banksimple/controller/invoice.docx differ diff --git a/handlers/payments/banksimple/controller/invoice.js b/handlers/payments/banksimple/controller/invoice.js new file mode 100755 index 000000000..9ceb13a59 --- /dev/null +++ b/handlers/payments/banksimple/controller/invoice.js @@ -0,0 +1,42 @@ +var fs = require('fs'); +var Docxtemplater = require('docxtemplater'); +var path = require('path'); +const Transaction = require('../../models/transaction'); +var bankConfig = require('config').payments.modules.banksimple; + +// Load the docx file as a binary +// @see https://github.com/open-xml-templating/docxtemplater +var invoiceDocContent = fs.readFileSync(path.join(__dirname, "invoice.docx"), "binary"); + +exports.get = function*() { + yield this.loadTransaction(); + + if (!this.transaction) { + this.log.debug("No transaction"); + this.throw(404); + } + + if (this.transaction.status != Transaction.STATUS_PENDING || this.transaction.paymentMethod != 'banksimple') { + this.log.debug("Improper TX", this.transaction.toObject()); + } + + var invoiceDoc = new Docxtemplater(invoiceDocContent); + + invoiceDoc.setData({ + COMPANY_NAME: bankConfig.COMPANY_NAME, + INN: bankConfig.INN, + ACCOUNT: bankConfig.ACCOUNT, + BANK: bankConfig.BANK, + CORR_ACC: bankConfig.CORR_ACC, + BIK: bankConfig.BIK, + PAYMENT_DESCRIPTION: `Оплата по счёту ${this.transaction.number}`, + AMOUNT: this.transaction.amount + }); + + // apply replacements + invoiceDoc.render(); + + this.type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + this.body = invoiceDoc.getZip().generate({type:"nodebuffer"}); + +}; diff --git a/handlers/payments/banksimple/index.js b/handlers/payments/banksimple/index.js new file mode 100644 index 000000000..df87882f8 --- /dev/null +++ b/handlers/payments/banksimple/index.js @@ -0,0 +1,27 @@ +const Transaction = require('../models/transaction'); +const path = require('path'); + +exports.renderForm = require('./renderForm'); + +// TX gets this status when created +exports.createTransaction = function*(order) { + + var transaction = new Transaction({ + order: order._id, + amount: order.amount, + status: Transaction.STATUS_PENDING, + paymentMethod: path.basename(__dirname) + }); + + yield transaction.persist(); + + return transaction; +}; + +exports.info = { + title: "Банковский перевод", + name: path.basename(__dirname), + hasIcon: false, + cards: ['sberbank'], + subtitle: 'или другой банк' +}; diff --git a/handlers/payments/banksimple/renderForm.js b/handlers/payments/banksimple/renderForm.js new file mode 100755 index 000000000..be726d0d5 --- /dev/null +++ b/handlers/payments/banksimple/renderForm.js @@ -0,0 +1,15 @@ +const jade = require('lib/serverJade'); +const config = require('config'); +const path = require('path'); + +module.exports = function* (transaction, order) { + + var form = jade.renderFile(path.join(__dirname, 'templates/form.jade'), { + orderNumber: order.number + }); + + return form; + +}; + + diff --git a/handlers/payments/banksimple/router.js b/handlers/payments/banksimple/router.js new file mode 100755 index 000000000..4273d135d --- /dev/null +++ b/handlers/payments/banksimple/router.js @@ -0,0 +1,9 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); + +var invoice = require('./controller/invoice'); + +router.get('/:transactionNumber/invoice.docx', invoice.get); + + diff --git a/handlers/payments/banksimple/templates/form.jade b/handlers/payments/banksimple/templates/form.jade new file mode 100755 index 000000000..de2c71ac0 --- /dev/null +++ b/handlers/payments/banksimple/templates/form.jade @@ -0,0 +1,2 @@ +form(method="POST",action="/payments/common/redirect/order/#{orderNumber}") + input(type="submit",value="Оплатить") diff --git a/handlers/payments/common/client/formPayment.js b/handlers/payments/common/client/formPayment.js new file mode 100755 index 000000000..bc0ed4668 --- /dev/null +++ b/handlers/payments/common/client/formPayment.js @@ -0,0 +1,175 @@ +var notification = require('client/notification'); +var xhr = require('client/xhr'); +var Spinner = require('client/spinner'); +var Modal = require('client/head/modal'); + +/** + * Get data from orderForm.getOrderData() + * process payment, ask for more data if needed + */ +class FormPayment { + + constructor(orderForm, paymentMethodElem) { + this.orderForm = orderForm; + this.paymentMethodElem = paymentMethodElem; + } + + request(options) { + var request = xhr(options); + + request.addEventListener('loadstart', function() { + var onEnd = this.startRequestIndication(); + request.addEventListener('loadend', onEnd); + }.bind(this)); + + return request; + } + + startRequestIndication() { + + this.paymentMethodElem.classList.add('modal-overlay_light'); + + var spinner = new Spinner({ + elem: this.paymentMethodElem, + size: 'medium', + class: 'pay-method__spinner' + }); + spinner.start(); + + return () => { + this.paymentMethodElem.classList.remove('modal-overlay_light'); + if (spinner) spinner.stop(); + }; + + } + + readPaymentData() { + var paymentData = {}; + + [].forEach.call(this.paymentMethodElem.querySelectorAll('input,select,textarea'), function(elem) { + if ( (elem.type == 'radio' || elem.type == 'checkbox') && !elem.checked) return; + paymentData[elem.name] = elem.value; + }); + + return paymentData; + } + + submit() { + + var orderData = this.orderForm.getOrderData(); + if (!orderData) return; + + var paymentData = this.readPaymentData(); + + if (!paymentData.paymentMethod) { + new notification.Error("Выберите метод оплаты."); + return; + } + + if (paymentData.paymentMethod == 'invoice' && !paymentData.invoiceCompanyName) { + new notification.Error("Укажите название компании."); + this.paymentMethodElem.querySelector('[name="invoiceCompanyName"]').focus(); + return; + } + + for (var key in paymentData) { + orderData[key] = paymentData[key]; + } + + // response status must be 200 + var request = xhr({ + method: 'POST', + url: '/payments/common/checkout', + normalStatuses: [200, 403, 400], + body: orderData + }); + + if (orderData.orderTemplate) { + window.ga('ec:addProduct', { + id: this.orderForm.product, + variant: orderData.orderTemplate, + price: orderData.amount, + quantity: 1 + }); + } + + window.ga('ec:setAction', 'checkout', { + step: 1, + option: orderData.paymentMethod + }); + + window.metrika.reachGoal('CHECKOUT', { + product: this.orderForm.product, + method: orderData.paymentMethod, + price: orderData.amount + }); + + window.ga('send', 'event', 'payment', 'checkout', 'ebook'); + window.ga('send', 'event', 'payment', 'checkout-method-' + orderData.paymentMethod, this.orderForm.product); + + var onEnd = this.startRequestIndication(); + + request.addEventListener('success', (event) => { + + if (request.status == 403) { + new notification.Error("

    " + (event.result.description || event.result.message) + "

    Пожалуйста, начните оформление заново.

    Если вы считаете, что на сервере ошибка — свяжитесь со службой поддержки.

    "); + onEnd(); + return; + } + + if (request.status == 400) { + new notification.Error("

    " + event.result.message + "

    Если вы считаете, что произошла ошибка — свяжитесь со службой поддержки.

    "); + onEnd(); + return; + } + + var result = event.result; + + if (result.form) { + // don't stop the spinner while submitting the form to the payment system! + // (still in progress) + + window.ga('ec:setAction', 'purchase', { + id: result.orderNumber + }); + + var container = document.createElement('div'); + container.hidden = true; + container.innerHTML = result.form; + document.body.appendChild(container); + + + // submit form after GA or after 500ms, which one comes sooner + var submitForm = function() { + if (!submitForm.called) { + submitForm.called = true; + container.firstChild.submit(); + } + }; + + window.ga('send', 'event', 'payment', 'purchase', 'ebook', { + hitCallback: submitForm + }); + setTimeout(submitForm, 500); + + + window.metrika.reachGoal('PURCHASE', { + product: this.orderForm.product, + method: orderData.paymentMethod, + price: orderData.amount, + number: result.orderNumber + }); + + + } else { + console.error(result); + onEnd(); + new notification.Error("Ошибка на сервере, свяжитесь со службой поддержки."); + } + }); + + request.addEventListener('fail', onEnd); + } +} + +module.exports = FormPayment; diff --git a/handlers/payments/common/client/index.js b/handlers/payments/common/client/index.js new file mode 100755 index 000000000..dfdbd2be3 --- /dev/null +++ b/handlers/payments/common/client/index.js @@ -0,0 +1 @@ +exports.FormPayment = require('./formPayment'); diff --git a/handlers/payments/common/controller/checkout.js b/handlers/payments/common/controller/checkout.js new file mode 100755 index 000000000..edeb1dbb6 --- /dev/null +++ b/handlers/payments/common/controller/checkout.js @@ -0,0 +1,88 @@ +var paymentMethods = require('../../lib/methods'); +var Order = require('../../models/order'); +var OrderTemplate = require('../../models/orderTemplate'); +var OrderCreateError = require('../../lib/orderCreateError'); + +/** + * The order form is sent to checkout when it's 100% valid (client-side code validated it) + * It uses order.module.createOrderFromTemplate to create an order, it can throw if something's wrong + * the order CANNOT be changed after submitting to payment + * @param next + */ +exports.post = function*(next) { + + yield* this.loadOrder(); + + var paymentMethod = paymentMethods[this.request.body.paymentMethod]; + + if (!paymentMethod) { + this.throw(403, "Unsupported payment method"); + } + + if (!this.order) { + // if we don't have the order in our database, then make a new one + // (use the incoming order post for that, but don't trust all its fields) + this.log.debug("new order, template:", this.request.body.orderTemplate); + + var orderTemplate = yield OrderTemplate.findOne({ + slug: this.request.body.orderTemplate + }).exec(); + + if (!orderTemplate) { + this.throw(404); + } + + this.log.debug("orderTemplate", orderTemplate); + + try { + this.order = yield* require(orderTemplate.module).createOrderFromTemplate(orderTemplate, this.user, this.request.body); + } catch (e) { + if (e instanceof OrderCreateError) { + this.status = 400; + this.body = { + message: e.message + }; + return; + } else { + throw e; + } + } + + + if (this.user && !~this.user.profileTabsEnabled.indexOf('orders')) { + this.user.profileTabsEnabled.addToSet('orders'); + yield this.user.persist(); + } + + saveOrderNumberToSession(this.session, this.order); + } else { + + // Many waiting transactions not allowed. + // The old one must had been cancelled before this. + yield* this.order.cancelPendingTransactions(); + + } + + this.log.debug("order", this.order); + + // creates transaction and returns the form to submit for its payment OR the result + var transaction = yield* paymentMethod.createTransaction(this.order, this.request.body); + this.log.debug("new transaction", transaction.toObject()); + + var form = yield* paymentMethod.renderForm(transaction, this.order); + + yield* transaction.log('form', form); + + this.body = { + form: form, + orderNumber: this.order.number + }; + +}; + +function saveOrderNumberToSession(session, order) { + if (!session.orders) { + session.orders = []; + } + session.orders.push(order.number); +} diff --git a/handlers/payments/common/controller/order.js b/handlers/payments/common/controller/order.js new file mode 100644 index 000000000..8197a7d2f --- /dev/null +++ b/handlers/payments/common/controller/order.js @@ -0,0 +1,46 @@ +var loadOrder = require('../../lib/loadOrder'); +var Order = require('../../models/order'); +var _ = require('lodash'); + +// all order modifications pass through this common module +// which may delegate to order modules +exports.patch = function*() { + + yield* this.loadOrder(); + + if (!this.order) { + this.throw(404, 'Нет такого заказа.'); + } + + if (this.isAdmin) { + // support status change + if (this.request.body.status == Order.STATUS_PAID) { + yield* this.order.onPaid(); + } + } + + var orderModule = require(this.order.module); + + if (orderModule.patch) { + yield* orderModule.patch.call(this); + } else { + this.body = {}; + } + +}; + +exports.del = function*() { + + yield* this.loadOrder(); + + if (!this.order) { + this.throw(404, 'Нет такого заказа.'); + } + + yield this.order.persist({ + status: Order.STATUS_CANCEL + }); + + this.body = 'ok'; +}; + diff --git a/handlers/payments/common/controller/ordersByUser.js b/handlers/payments/common/controller/ordersByUser.js new file mode 100644 index 000000000..794e7ddd5 --- /dev/null +++ b/handlers/payments/common/controller/ordersByUser.js @@ -0,0 +1,37 @@ +var paymentMethods = require('../../lib/methods'); +var Order = require('../../models/order'); +var OrderTemplate = require('../../models/orderTemplate'); +var OrderCreateError = require('../../lib/orderCreateError'); + +/** + * The order form is sent to checkout when it's 100% valid (client-side code validated it) + * It uses order.module.createOrderFromTemplate to create an order, it can throw if something's wrong + * the order CANNOT be changed after submitting to payment + * @param next + */ +exports.get = function*(next) { + + var user = this.userById; + + if (String(this.user._id) != String(user._id)) { + this.throw(403); + } + + var orders = yield Order.find({ + user: user._id, + status: { + $ne: Order.STATUS_CANCEL + } + }).sort({created: 1}).populate('user').exec(); + + var ordersToShow = []; + + for (var i = 0; i < orders.length; i++) { + var format = require(orders[i].module).formatOrderForProfile; + if (!format) continue; + ordersToShow.push(yield* format.call(this, orders[i])); + } + + this.body = ordersToShow; + +}; diff --git a/handlers/payments/common/router.js b/handlers/payments/common/router.js new file mode 100755 index 000000000..faa66814c --- /dev/null +++ b/handlers/payments/common/router.js @@ -0,0 +1,24 @@ +var Router = require('router'); + +var router = module.exports = new Router(); +router.param('userById', require('users').routeUserById); + +var order = require('./controller/order'); +var checkout = require('./controller/checkout'); +var ordersByUser = require('./controller/ordersByUser'); + +router.post('/checkout', checkout.post); +router.patch('/order', order.patch); +router.del('/order', order.del); + +router.get('/orders/user/:userById', ordersByUser.get); + +// form for invoices (after generating the transaction) submits here to go back to order, +// without any external service +router.post('/redirect/order/:orderNumber', function*() { + yield this.loadOrder(); + this.redirectToOrder(); +}); + + + diff --git a/handlers/payments/common/templates/invoice-settings.jade b/handlers/payments/common/templates/invoice-settings.jade new file mode 100644 index 000000000..748bd4500 --- /dev/null +++ b/handlers/payments/common/templates/invoice-settings.jade @@ -0,0 +1,31 @@ ++b.payment-setting + +e.item + +e('label').label(for="invoice-company") Название компании: + +b('span').text-input._small + +e('input')(name="invoiceCompanyName").control#invoice-company + +e.item._with_cb + +e('input').cb._invoice-need#invoice-contract(type="checkbox" name="invoiceAgreementRequired" value="1") + +e('label').cb-label(for="invoice-contract") + | Нужен договор + +e('span').small-note  (Договор заключается с компанией зарегистрированной в РФ) + +e.item._hidden + +e('label').label(for="invoice-contract-head") Шапка (для акта и договора): + +b('textarea').textarea-input.__textarea-head#invoice-contract-head(name="invoiceContractHead") ___, именуемое в дальнейшем Заказчик, в лице ___, действующего на основании ___, с одной стороны + +e.small-note Например: Общество с ограниченной ответственностью «Лютики», именуемое в дальнейшем Заказчик, в лице Иванова Петра Сергеевича, действующего на основании Устава, с одной стороны + +e.item_hidden + +e('label').label(for="invoice-company-address") Юридический адрес: + +b('textarea').textarea-input.__textarea-addr#invoice-company-address(name="invoiceCompanyAddress") + +e.item_hidden + +e('label').label(for="invoice-bank-details") Банковские реквизиты: + +b('textarea').textarea-input.__textarea-bank#invoice-bank-details(name="invoiceBankDetails") + | ИНН 12345678901 + = "\n" + | КПП 12345 + = "\n" + | р/сч 0000000000 + = "\n" + | в Банке таком-то + = "\n" + | к/сч 0000000000 + = "\n" + | Тел. +71234567890 diff --git a/handlers/payments/common/templates/payment-methods.jade b/handlers/payments/common/templates/payment-methods.jade new file mode 100644 index 000000000..1c64cea5e --- /dev/null +++ b/handlers/payments/common/templates/payment-methods.jade @@ -0,0 +1,23 @@ ++b.pay-method + +e('ul').methods + + each paymentMethod in paymentMethods + +e('li').method + +e('input').method-radio(type="radio" name="paymentMethod" value=paymentMethod.name id=paymentMethod.name) + +e('label')(class=["method-label", "_" + paymentMethod.name] for=paymentMethod.name) + +e('header').header + if paymentMethod.title && !~['paypal','yandexmoney','webmoney'].indexOf(paymentMethod.name) + +e('h3').method-title !{ paymentMethod.title } + if paymentMethod.cards + +e('span').cards + each card in paymentMethod.cards + +e('img').card(src='/pay-methods/' + card + '.svg', alt=card) + + if paymentMethod.subtitle + +e('h4').method-subtitle!= paymentMethod.subtitle + if paymentMethod.hasIcon + +e('img').logo(src="/pay-methods/pay-" + paymentMethod.name + '.svg' alt=paymentMethod.name[0].toUpperCase() + paymentMethod.name.slice(1)) + if paymentMethod.name == "paypal" + include paypal-settings + if paymentMethod.name == "invoice" + include invoice-settings diff --git a/handlers/payments/common/templates/paypal-settings.jade b/handlers/payments/common/templates/paypal-settings.jade new file mode 100755 index 000000000..344262a73 --- /dev/null +++ b/handlers/payments/common/templates/paypal-settings.jade @@ -0,0 +1,10 @@ + ++b.payment-setting + +e.item._currency + +e('label').label(for="paypal-currency") Выберите валюту: + +b('select')(name="paypalCurrency").input-select._small.__control#paypal-currency + +e('option').option(value="RUB") RUB + +e('option').option(value="USD") USD + +e('option').option(value="EUR") EUR + +e('span').small-note + | Если у вас Российский Paypal аккаунт, вы
    не сможете оплатить в другой валюте. diff --git a/handlers/payments/index.js b/handlers/payments/index.js new file mode 100755 index 000000000..f56f8ad56 --- /dev/null +++ b/handlers/payments/index.js @@ -0,0 +1,42 @@ +var config = require('config'); +var path = require('path'); +var assert = require('assert'); + +var log = require('log')(); + +exports.loadOrder = require('./lib/loadOrder'); +exports.loadTransaction = require('./lib/loadTransaction'); +exports.getOrderInfo = require('./lib/getOrderInfo'); + +var Order = exports.Order = require('./models/order'); +var OrderTemplate = exports.OrderTemplate = require('./models/orderTemplate'); +var Transaction = exports.Transaction = require('./models/transaction'); +var TransactionLog = exports.TransactionLog = require('./models/transactionLog'); +var OrderCreateError = exports.OrderCreateError = require('./lib/orderCreateError'); + +var paymentMethods = exports.methods = require('./lib/methods'); + +// delegate all HTTP calls to payment modules +// mount('/webmoney', webmoney.middleware()) +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); +exports.init = function(app) { + for(var name in paymentMethods) { + app.use(mountHandlerMiddleware('/payments/' + name, path.join(__dirname, name))); + } + + app.use(mountHandlerMiddleware('/payments/common', path.join(__dirname, 'common'))); + + app.csrfChecker.ignore.add('/payments/:any*'); + app.verboseLogger.logPaths.add('/payments/:any*'); +}; + +exports.populateContextMiddleware = function*(next) { + this.redirectToOrder = function(order) { + order = order || this.order; + this.redirect(order.getUrl()); + }; + this.loadOrder = exports.loadOrder; + this.loadTransaction = exports.loadTransaction; + + yield* next; +}; diff --git a/handlers/payments/interkassa/controller/callback.js b/handlers/payments/interkassa/controller/callback.js new file mode 100755 index 000000000..4ffae5df0 --- /dev/null +++ b/handlers/payments/interkassa/controller/callback.js @@ -0,0 +1,60 @@ +const config = require('config'); +const interkassaConfig = config.payments.modules.interkassa; +const Order = require('../../models/order'); +const Transaction = require('../../models/transaction'); +const md5 = require('MD5'); + +exports.post = function* (next) { + + yield* this.loadTransaction('ik_pm_no', {skipOwnerCheck: true}); + + yield this.transaction.logRequest('callback unverified', this.request); + + if (!checkSignature(this.request.body)) { + this.log.debug("wrong signature"); + this.throw(403, "wrong signature"); + } + + yield this.transaction.logRequest('callback', this.request); + + yield this.transaction.persist({ + status: Transaction.STATUS_SUCCESS + }); + + this.log.debug("will call order onPaid module=" + this.order.module); + yield* this.order.onPaid(); + + this.body = 'SUCCESS'; +}; + +// Base64(MD5(Implode(Sort(Params) + SecretKey, ':'))) +// Implode(Sort(Params) + SecretKey, ':') +// Sort(Params) + SecretKey +function checkSignature(body) { + + var incomingSignature = body.ik_sign; + + var signature = Object.keys(body) + .filter(function(key) { + return key != 'ik_sign'; + }) + .sort() + .map(function(key) { + return body[key]; + }); + + console.log(signature); + signature.push(interkassaConfig.secret); + + console.log(signature); + + signature = signature.join(':'); + + console.log(signature); + + signature = new Buffer(md5(signature, {asBytes: true})).toString('base64'); + + console.log(signature, '==', incomingSignature); + + return signature == incomingSignature; +} diff --git a/handlers/payments/interkassa/controller/fail.js b/handlers/payments/interkassa/controller/fail.js new file mode 100755 index 000000000..5aff35b34 --- /dev/null +++ b/handlers/payments/interkassa/controller/fail.js @@ -0,0 +1,15 @@ +const Transaction = require('../../models/transaction'); + +exports.post = function* (next) { + + yield* this.loadTransaction('ik_pm_no'); + + this.transaction.persist({ + status: Transaction.STATUS_FAIL, + statusMessage: 'оплата не прошла' + }); + + this.redirectToOrder(); +}; + + diff --git a/handlers/payments/interkassa/controller/success.js b/handlers/payments/interkassa/controller/success.js new file mode 100755 index 000000000..d1b7fdddf --- /dev/null +++ b/handlers/payments/interkassa/controller/success.js @@ -0,0 +1,8 @@ +const Transaction = require('../../models/transaction'); + +exports.post = function* (next) { + + yield* this.loadTransaction('ik_pm_no'); + + this.redirectToOrder(); +}; diff --git a/handlers/payments/interkassa/index.js b/handlers/payments/interkassa/index.js new file mode 100644 index 000000000..9aa78ef88 --- /dev/null +++ b/handlers/payments/interkassa/index.js @@ -0,0 +1,29 @@ +const Transaction = require('../models/transaction'); +const path = require('path'); + +exports.renderForm = require('./renderForm'); + +// TX gets this status when created +exports.createTransaction = function*(order) { + + var transaction = new Transaction({ + order: order._id, + amount: order.amount, + status: Transaction.STATUS_PENDING, + paymentMethod: path.basename(__dirname) + }); + + yield transaction.persist(); + + return transaction; +}; + + + +exports.info = { + title: "Интеркасса", + name: path.basename(__dirname), + hasIcon: false, + cards: ['visa-mastercard', 'privatbank'], + subtitle: "и другие методы (Украина)" +}; diff --git a/handlers/payments/interkassa/renderForm.js b/handlers/payments/interkassa/renderForm.js new file mode 100755 index 000000000..63cf954f7 --- /dev/null +++ b/handlers/payments/interkassa/renderForm.js @@ -0,0 +1,18 @@ +const jade = require('lib/serverJade'); +const config = require('config'); +const path = require('path'); + +module.exports = function* (transaction) { + + var form = jade.renderFile(path.join(__dirname, 'templates/form.jade'), { + amount: transaction.amount, + number: transaction.number, + currency: config.payments.currency, + id: config.payments.modules.interkassa.id + }); + + return form; + +}; + + diff --git a/handlers/payments/interkassa/router.js b/handlers/payments/interkassa/router.js new file mode 100755 index 000000000..a5ce02fc5 --- /dev/null +++ b/handlers/payments/interkassa/router.js @@ -0,0 +1,16 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); + +var callback = require('./controller/callback'); + +var success = require('./controller/success'); +var fail = require('./controller/fail'); + +router.post('/callback', callback.post); + +router.post('/success', success.post); + +router.post('/fail', fail.post); + + diff --git a/handlers/payments/interkassa/templates/form.jade b/handlers/payments/interkassa/templates/form.jade new file mode 100755 index 000000000..c6b2c841e --- /dev/null +++ b/handlers/payments/interkassa/templates/form.jade @@ -0,0 +1,8 @@ + +form(method="POST" name="payment" action="https://sci.interkassa.com/" accept-charset="UTF-8") + input(type="hidden",name="ik_co_id",value=id) + input(type="hidden",name="ik_pm_no",value=number) + input(type="hidden",name="ik_cur",value=currency) + input(type="hidden",name="ik_am",value=amount) + input(type="hidden",name="ik_desc",value="Оплата по счёту #{number}") + input(type="submit",value="Оплатить") diff --git a/handlers/payments/invoice/controller/agreement.js b/handlers/payments/invoice/controller/agreement.js new file mode 100644 index 000000000..06bf7f1c5 --- /dev/null +++ b/handlers/payments/invoice/controller/agreement.js @@ -0,0 +1,28 @@ +var fs = require('fs'); +var Docxtemplater = require('docxtemplater'); +var path = require('path'); +const Transaction = require('../../models/transaction'); +var invoiceConfig = require('config').payments.modules.invoice; +const moment = require('moment'); +const priceInWords = require('textUtil/priceInWords'); + +exports.get = function*() { + yield this.loadTransaction(); + + if (!this.transaction) { + this.log.debug("No transaction"); + this.throw(404); + } + + if (this.transaction.status != Transaction.STATUS_PENDING || this.transaction.paymentMethod != 'invoice') { + this.log.debug("Improper transaction", this.transaction.toObject()); + this.throw(400); + } + + var orderModule = require(this.transaction.order.module); + var invoiceDoc = yield orderModule.getAgreement(this.transaction); + + this.type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + this.body = invoiceDoc.getZip().generate({type:"nodebuffer"}); + +}; diff --git a/handlers/payments/invoice/controller/invoice.js b/handlers/payments/invoice/controller/invoice.js new file mode 100644 index 000000000..c0a0a163f --- /dev/null +++ b/handlers/payments/invoice/controller/invoice.js @@ -0,0 +1,56 @@ +var fs = require('fs'); +var Docxtemplater = require('docxtemplater'); +var path = require('path'); +const Transaction = require('../../models/transaction'); +var invoiceConfig = require('config').payments.modules.invoice; +const moment = require('moment'); +const priceInWords = require('textUtil/priceInWords'); + +// Same generic invoice for all modules +var invoiceDocContent = fs.readFileSync(path.join(__dirname, "../doc/invoice.docx"), "binary"); + +exports.get = function*() { + yield this.loadTransaction(); + + if (!this.transaction) { + this.log.debug("No transaction"); + this.throw(404); + } + + if (this.transaction.status != Transaction.STATUS_PENDING || this.transaction.paymentMethod != 'invoice') { + this.log.debug("Improper transaction", this.transaction.toObject()); + this.throw(400); + } + + var invoiceDoc = getInvoice(this.transaction); + + this.type = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; + this.body = invoiceDoc.getZip().generate({type:"nodebuffer"}); + +}; + +function getInvoice(transaction) { + var invoiceDoc = new Docxtemplater(invoiceDocContent); + + invoiceDoc.setData({ + COMPANY_NAME: invoiceConfig.COMPANY_NAME, + INN: invoiceConfig.INN, + ACCOUNT: invoiceConfig.ACCOUNT, + BANK: invoiceConfig.BANK, + CORR_ACC: invoiceConfig.CORR_ACC, + BIK: invoiceConfig.BIK, + TRANSACTION_NUMBER: String(transaction.number), + TRANSACTION_DATE: moment(transaction.created).format('DD.MM.YYYY'), + PAYMENT_DESCRIPTION: `Оплата за информационно-консультационные услуги по счёту ${transaction.number}`, + AMOUNT: transaction.amount, + AMOUNT_IN_WORDS: priceInWords(transaction.amount), + SIGN_TITLE: invoiceConfig.SIGN_TITLE, + SIGN_NAME: invoiceConfig.SIGN_NAME, + SIGN_SHORT_NAME: invoiceConfig.SIGN_SHORT_NAME + }); + + // apply replacements + invoiceDoc.render(); + + return invoiceDoc; +} diff --git a/handlers/payments/invoice/doc/invoice.docx b/handlers/payments/invoice/doc/invoice.docx new file mode 100644 index 000000000..4f0f34de8 Binary files /dev/null and b/handlers/payments/invoice/doc/invoice.docx differ diff --git a/handlers/payments/invoice/index.js b/handlers/payments/invoice/index.js new file mode 100644 index 000000000..d43d6e1d7 --- /dev/null +++ b/handlers/payments/invoice/index.js @@ -0,0 +1,34 @@ + +const Transaction = require('../models/transaction'); +const path = require('path'); + +exports.renderForm = require('./renderForm'); + +// TX gets this status when created +exports.createTransaction = function*(order, body) { + + + var transaction = new Transaction({ + order: order._id, + amount: order.amount, + status: Transaction.STATUS_PENDING, + paymentMethod: path.basename(__dirname), + paymentDetails: { + companyName: String(body.invoiceCompanyName), + agreementRequired: Boolean(body.invoiceAgreementRequired), + contractHead: String(body.invoiceContractHead), + companyAddress: String(body.invoiceCompanyAddress), + bankDetails: String(body.invoiceBankDetails) + } + }); + + yield transaction.persist(); + + return transaction; +}; + +exports.info = { + title: "Счёт на компанию", + subtitle: '(для юрлиц из России)', + name: path.basename(__dirname) +}; diff --git a/handlers/payments/invoice/renderForm.js b/handlers/payments/invoice/renderForm.js new file mode 100755 index 000000000..be726d0d5 --- /dev/null +++ b/handlers/payments/invoice/renderForm.js @@ -0,0 +1,15 @@ +const jade = require('lib/serverJade'); +const config = require('config'); +const path = require('path'); + +module.exports = function* (transaction, order) { + + var form = jade.renderFile(path.join(__dirname, 'templates/form.jade'), { + orderNumber: order.number + }); + + return form; + +}; + + diff --git a/handlers/payments/invoice/router.js b/handlers/payments/invoice/router.js new file mode 100755 index 000000000..f3d7dc897 --- /dev/null +++ b/handlers/payments/invoice/router.js @@ -0,0 +1,10 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); + +var invoice = require('./controller/invoice'); +var agreement = require('./controller/agreement'); + +router.get('/:transactionNumber/invoice.docx', invoice.get); +router.get('/:transactionNumber/agreement.docx', agreement.get); + diff --git a/handlers/payments/invoice/templates/form.jade b/handlers/payments/invoice/templates/form.jade new file mode 100755 index 000000000..de2c71ac0 --- /dev/null +++ b/handlers/payments/invoice/templates/form.jade @@ -0,0 +1,2 @@ +form(method="POST",action="/payments/common/redirect/order/#{orderNumber}") + input(type="submit",value="Оплатить") diff --git a/handlers/payments/lib/getOrderInfo.js b/handlers/payments/lib/getOrderInfo.js new file mode 100755 index 000000000..b331725e5 --- /dev/null +++ b/handlers/payments/lib/getOrderInfo.js @@ -0,0 +1,205 @@ +const Order = require('../models/order'); +const Transaction = require('../models/transaction'); +const log = require('log')(); +const escapeHtml = require('escape-html'); + +/** + * high-level order status & transaction which caused it & messages to show + * success -- order success: paid, processed + * paid -- order paid, but not yet success + * error -- server-side error + * fail -- payment failed + * pending -- waiting for payment + * cancel -- order canceled + * @param order + * @returns {*} + */ +module.exports = function*(order) { + var info = yield* getOrderInfo(order); + + var linkToProfile = ''; + if (order.user && require(order.module).formatOrderForProfile) { + linkToProfile = `

    Информацию о заказе вы также можете найти в своём профиле.

    `; + } + + info.linkToProfile = linkToProfile; + + return info; +}; + +function* getOrderInfo(order) { + // get transaction which defines current status + + var mailUrl = 'orders@javascript.ru'; + var transaction; + + if (order.status == Order.STATUS_SUCCESS) { + // may not be the last transaction by modified + // because theoretically it's possible to have 2 transactions: + // pending (1tx) -> fail, pending (2nx tx came) -> success, pending (1st tx got money) + transaction = yield Transaction.findOne({ + order: order._id, + status: Transaction.STATUS_SUCCESS + }).exec(); + + // it is possible that there is no transaction at all + // (if order status is set manually) + return { + number: order.number, + status: "success", + statusText: "Оплата получена", + transaction: transaction + // no title/accent/description, because the action on success is order-module-dependant + }; + } + + if (order.status == Order.STATUS_PAID) { + transaction = yield Transaction.findOne({ + order: order._id, + status: Transaction.STATUS_SUCCESS + }).exec(); + + return { + number: order.number, + status: "paid", + statusText: "Ожидает обработки", + transaction: transaction, + title: "Спасибо за заказ!", + accent: "Оплата получена, заказ обрабатывается.", + description: `

    По окончании вам будет отправлено письмо на электронный адрес ${order.email}.

    +

    Если у вас возникли какие-нибудь вопросы, присылайте их на ${mailUrl}.

    ` + }; + + } + + if (order.status == Order.STATUS_PENDING) { + // PENDING order, but Transaction.STATUS_SUCCESS? + // impossible! + transaction = yield Transaction.findOne({ + order: order._id, + status: Transaction.STATUS_SUCCESS + }).exec(); + + if (transaction) { + log.error("Transaction success, but order pending?!? Impossible! Must be paid", transaction, order); + return { + // our error, the visitor can do nothing + status: "error", + statusText: "Произошла ошибка", + transaction: transaction, + title: "Произошла ошибка.", + accent: "При обработке платежа произошла ошибка.", + description: `

    Пожалуйста, напишите в поддержку ${mailUrl}.

    `, + number: order.number + }; + } + + // NO CALLBACK from online-system yet + // probably he just pressed the "back" button + // OR + // selected the offline method of payment + // OR + // callback will come later + transaction = yield Transaction.findOne({ + order: order._id, + status: Transaction.STATUS_PENDING // there may be only 1 pending tx at time + }).exec(); + + log.debug("findOne pending transaction: ", transaction && transaction.toObject()); + + if (transaction) { + + // Waiting for payment + + if (transaction.paymentMethod == 'banksimple') { + return { + number: order.number, + status: "pending", + statusText: "Ожидается оплата", + transaction: transaction, + title: "Спасибо за заказ!", + accent: `Для завершения заказа скачайте квитанцию и оплатите ее через банк.`, + description: `
    +

    Квитанция действительна три дня. Оплатить можно в Сбербанке (3% комиссия) или любом банке, где у вас есть счёт.

    +

    После оплаты в течение двух рабочих дней мы вышлем вам всю необходимую информацию на адрес ${order.email}.

    +

    Если у вас возникли какие-либо вопросы, присылайте их на ${mailUrl}.

    + `, + descriptionProfile: `
    Вы можете повторно скачать квитанцию, а также изменить метод оплаты, нажав на кнопку ниже.
    ` + }; + } else if (transaction.paymentMethod == 'invoice') { + var invoiceButton = ``; + var agreementButton = transaction.paymentDetails.agreementRequired ? + `` : + ''; + + return { + number: order.number, + status: "pending", + statusText: "Ожидается оплата", + transaction: transaction, + title: "Спасибо за заказ!", + accent: `Для завершения заказа произведите оплату по счёту.`, + description: ` +
    ${invoiceButton} ${agreementButton}
    +

    Счёт действителен пять рабочих дней.

    +

    После оплаты мы вышлем вам всю необходимую информацию на адрес ${order.email}.

    +

    Если у вас возникли какие-либо вопросы, присылайте их на ${mailUrl}.

    + `, + descriptionProfile: `
    Вы можете повторно скачать счёт` + + (transaction.paymentDetails.agreementRequired ? ` и договор с актом` : '') + + `, а также поменять детали и метод оплаты, нажав на кнопку ниже.
    ` + }; + } else { + return { + number: order.number, + status: "pending", + statusText: "Ожидается оплата", + transaction: transaction, + title: "Спасибо за заказ!", + accent: `Как только мы получим подтверждение от платёжной системы, мы вышлем вам всю необходимую информацию на адрес ${order.email}.`, + description: ` +

    Если у вас возникли проблемы при работе с платежной системой, и вы не оплатили заказ, + вы можете выбрать другой метод оплаты и оплатить заново.

    +

    Если у вас возникли какие-либо вопросы, присылайте их на ${mailUrl}.

    ` + }; + } + } + + // Failed? + // Show the latest error and let him pay + transaction = yield Transaction.findOne({ + order: order._id, + status: Transaction.STATUS_FAIL + }).sort({created: -1}).exec(); + + log.debug("findOne failed transaction: ", transaction && transaction.toObject()); + + return { + number: order.number, + status: "fail", + statusText: "Оплата не прошла", + title: "Оплата не прошла.", + transaction: transaction, + accent: "Оплата не прошла, попробуйте ещё раз.", + description: (transaction.statusMessage ? `
    Причина: ${escapeHtml(transaction.statusMessage)}
    ` : '') + + `

    По вопросам, касающимся оплаты, пишите на ${mailUrl}.

    .` + }; + + + } + + + if (order.status == Order.STATUS_CANCEL) { + return { + number: order.number, + status: "cancel", + statusText: "Заказ отменён", + title: "Заказ отменён.", + description: `

    По вопросам, касающимся заказа, пишите на ${mailUrl}.

    .` + }; + } + + log.error("order", order); + throw new Error("Must never reach this point. No transaction?"); + +} diff --git a/handlers/payments/lib/loadOrder.js b/handlers/payments/lib/loadOrder.js new file mode 100755 index 000000000..7dd0174a7 --- /dev/null +++ b/handlers/payments/lib/loadOrder.js @@ -0,0 +1,105 @@ +var mongoose = require('mongoose'); +var Order = require('../models/order'); +var Transaction = require('../models/transaction'); +var assert = require('assert'); + +// Populates this.order with the order by "orderNumber" parameter +module.exports = function* (options) { + options = options || {}; + + var field = options.field || 'orderNumber'; + + var orderNumber = this.request.body && this.request.body[field] || this.params[field] || this.query[field]; + + if (!orderNumber) { + if (options.throwIfNotFound) { + this.throw(404, 'Отсутствует номер заказа'); + } else { + return; + } + } + + function findOrder() { + return Order.findOne({number: orderNumber}).populate('user').exec(); + } + + var order = yield findOrder(); + + if (!order) { + this.throw(404, 'Нет такого заказа'); + } + + + // order.module.onPaid hook may take some time + // it happens that the transaction is already SUCCESS, but the order is still PAID, not SUCCESS + // in this case reload the order + if (order.status == Order.STATUS_PAID && options.ensureSuccessTimeout) { + + // let the onPaid hook to finish + var datediff = new Date() - new Date(order.modified); + while (datediff < options.ensureSuccessTimeout) { + // give it a second to finish and retry, usually up to max 5 seconds + this.log.debug("tx success, but order pending => wait 1s until onPaid hook (maybe?) finishes"); + yield function(callback) { + setTimeout(callback, 1000); + }; + datediff += 1000; + order = yield findOrder(); + } + } + + + //console.log("CHECK", this.req.user._id, order); + + var belongsToUser = this.user && order.user && (String(this.user._id) == String(order.user._id)); + + var orderInSession = this.session.orders && this.session.orders.indexOf(order.number) != -1; + + // allow to load order which belongs to the user or in the current session + // if the order is not in session ( + if (!orderInSession && !belongsToUser && !this.isAdmin) { + this.throw(403, 'Access denied', { + message: 'Доступ запрещён', + description: 'Возможно, этот заказ не ваш, вы не авторизованы, или сессия истекла.' + }); + } + + if (!options.reload) { + // order must be loaded only once + // (otherwise it's probably a bug) + // (unless we know what we're doing) + assert(!this.order, "this.order is already set (by loadTransaction?)"); + } + + this.log.debug("order", order.toObject()); + + this.order = order; + +}; + +/* +function* reloadOrderUntilSuccessFinish() { + + var lastTransaction = yield Transaction.findOne({ + order: this.order._id + }).sort({modified: -1}).limit(1).exec(); + + if (lastTransaction.status == Transaction.STATUS_SUCCESS && + this.order.status == Order.STATUS_PENDING) { + // PENDING order, but Transaction.STATUS_SUCCESS? + // means that order onPaid failed to finalize the job + // OR just did not finish it yet + var datediff = new Date() - new Date(lastTransaction.modified); + while(datediff < Order.MAX_ONSUCCESS_TIME) { + // give it a second to finish and retry, up to max 5 seconds + this.log.debug("tx success, but order pending => wait 1s until onPaid hook (maybe?) finishes"); + yield function(callback) { + setTimeout(callback, 1000); + }; + datediff += 1000; + yield* this.loadOrder({reload: true}); + } + } + +} +*/ diff --git a/handlers/payments/lib/loadTransaction.js b/handlers/payments/lib/loadTransaction.js new file mode 100755 index 000000000..c06c76364 --- /dev/null +++ b/handlers/payments/lib/loadTransaction.js @@ -0,0 +1,46 @@ +var mongoose = require('mongoose'); +var Transaction = require('../models/transaction'); +var User = require('users').User; +var assert = require('assert'); + +// Populates this.transaction with the transaction by "transactionNumber" parameter +// options.skipOwnerCheck is for signed submissions, set to true allows anyone to load transaction +module.exports = function* (field, options) { + options = options || {}; + if (!field) field = 'transactionNumber'; + + var transactionNumber = this.request.body && this.request.body[field] || this.params[field] || this.query[field]; + + this.log.debug('loadTransaction number: ' + transactionNumber); + if (!transactionNumber) { + return; + } + + var transaction = yield Transaction.findOne({number: transactionNumber}).populate('order').exec(); + + if (!transaction) { + this.throw(404, 'Transaction not found'); + } + + yield function(callback) { + transaction.order.populate('user', callback); + }; + + if (!options.skipOwnerCheck) { + var belongsToUser = this.user && transaction.order.user && (String(this.user._id) == String(transaction.order.user._id)); + var orderInSession = this.session.orders && this.session.orders.indexOf(transaction.order.number) != -1; + + if (!belongsToUser && !orderInSession && !this.isAdmin) { + this.throw(403, 'The order is not in session'); + } + } + + assert(!this.transaction, "this.transaction is already set"); + assert(!this.order, "this.order is already set"); + + this.transaction = transaction; + this.order = transaction.order; + + this.log.debug("tx loaded"); + +}; diff --git a/handlers/payments/lib/methods.js b/handlers/payments/lib/methods.js new file mode 100755 index 000000000..311f3c416 --- /dev/null +++ b/handlers/payments/lib/methods.js @@ -0,0 +1,13 @@ +var config = require('config'); +var assert = require('assert'); + +/** + * Configured (only) payment methods + */ +var paymentMethods = {}; +for(var key in config.payments.modules) { + paymentMethods[key] = require('../' + key); + assert(paymentMethods[key].renderForm, key + ": no renderForm"); +} + +module.exports = paymentMethods; diff --git a/handlers/payments/lib/orderCreateError.js b/handlers/payments/lib/orderCreateError.js new file mode 100644 index 000000000..106e7c3e1 --- /dev/null +++ b/handlers/payments/lib/orderCreateError.js @@ -0,0 +1,10 @@ + +function OrderCreateError(message) { + this.name = "OrderCreateError"; + this.message = message; +} + +OrderCreateError.prototype = Object.create(Error.prototype); +OrderCreateError.prototype.constructor = OrderCreateError; + +module.exports = OrderCreateError; diff --git a/handlers/payments/models/order.js b/handlers/payments/models/order.js new file mode 100755 index 000000000..f9a457755 --- /dev/null +++ b/handlers/payments/models/order.js @@ -0,0 +1,103 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var autoIncrement = require('mongoose-auto-increment'); +var OrderTemplate = require('./orderTemplate'); +var Transaction = require('./transaction'); +var _ = require('lodash'); + +var schema = new Schema({ + amount: { + type: Number, + required: true + }, + module: { // module so that transaction handler knows where to go back e.g. 'ebook' + type: String, + required: true + }, + title: { + type: String, + required: true + }, + description: { + type: String + }, + status: { + type: String, + enum: ['success', 'cancel', 'pending', 'paid'], + default: 'pending' + }, + + // order can be bound to either an email or a user + email: { + type: String + }, + user: { + type: Schema.Types.ObjectId, + ref: 'User' + }, + + data: { + type: Schema.Types.Mixed, + default: {} + }, + + created: { + type: Date, + default: Date.now + }, + modified: { + type: Date + } + +}); + +schema.pre('save', function(next) { + this.modified = new Date(); + next(); +}); + +// order must have only 1 pending transaction at 1 time. +// finish one payment then create another +// UI does not allow to create multiple pending transaction +// that's to easily find/cancel a pending method +// Here I guard against hand-made POST requests (just to be sure) +// P.S. it is ok to create a transaction if a SUCCESS one exists (maybe split payment?) +schema.methods.cancelPendingTransactions = function*() { + + yield Transaction.findOneAndUpdate({ + order: this._id, + status: Transaction.STATUS_PENDING + }, { + status: Transaction.STATUS_FAIL, + statusMessage: "смена способа оплаты." + }).exec(); + +}; + +schema.methods.onPaid = function*() { + this.persist({ + status: Order.STATUS_PAID + }); + yield* require(this.module).onPaid(this); +}; + +schema.plugin(autoIncrement.plugin, {model: 'Order', field: 'number', startAt: 1}); + +// order is ready for delivery, hooks finished +schema.statics.STATUS_SUCCESS = 'success'; + +// payment received, but the order hooks did not finish yet +schema.statics.STATUS_PAID = 'paid'; + +// awaiting payment +schema.statics.STATUS_PENDING = 'pending'; + +// not awaiting payment any more +schema.statics.STATUS_CANCEL = 'cancel'; + +schema.methods.getUrl = function() { + return '/' + this.module + '/orders/' + this.number; +}; + +var Order = module.exports = mongoose.model('Order', schema); + diff --git a/handlers/payments/models/orderTemplate.js b/handlers/payments/models/orderTemplate.js new file mode 100644 index 000000000..17b2d2a62 --- /dev/null +++ b/handlers/payments/models/orderTemplate.js @@ -0,0 +1,47 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; + +/** + * In other words, "Store items" + * New orders do *not* reference the items, because store items may change + * Instead new orders contain full information about themselves. + * + * Order template must have + * module (which handles it) + * slug (unique mnemo to search, may be many per module) + * + * OrderTemplate can be deleted, but the order is self-contained. + * @type {Schema} + */ +var schema = new Schema({ + title: { + type: String + }, + description: { + type: String + }, + // on checkout /order/slug, the new order is created from this template + slug: { + type: String, + required: true, + unique: true + }, + weight: { + type: Number + }, + amount: { + type: Number + }, + created: { + type: Date, + default: Date.now + }, + module: { + type: String, + required: true + }, + data: {} +}); + +module.exports = mongoose.model('OrderTemplate', schema); + diff --git a/handlers/payments/models/transaction.js b/handlers/payments/models/transaction.js new file mode 100755 index 000000000..88982a4e7 --- /dev/null +++ b/handlers/payments/models/transaction.js @@ -0,0 +1,196 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; +var crypto = require('crypto'); +var TransactionLog = require('./transactionLog'); + +/** + * Transaction is an actual payment attempt (successful or not) for something + * - Order may exist without any transactions (pay later) + * - Transaction has it's own separate number (payment attempt) + * - Transaction amount can be different from order amount (partial payment) + * - Every transaction save generates a log record + * @type {Schema} + */ +var schema = new Schema({ + order: { + type: Schema.Types.ObjectId, + ref: 'Order', + required: true + }, + amount: { + type: Number, + required: true + }, + + // which method created this TX + paymentMethod: { + type: String, + required: true + }, + + currency: { + // sometimes needed, e.g. paypal allows many currencies + type: String, + enum: ['USD', 'EUR', 'RUB'] + }, + // payment method may initiate the payment + // and provide the token value to track it + // other details are also possible + paymentDetails: { + type: { + nextRetry: Number, // for Ya.Money processPayments + processing: Boolean, // for Ya.Money processPayments & Paypal PDT/IPN locking not to onPaid twice, + oauthToken: String, // for Ya.Money processPayments + requestId: String, // for Ya.Money processPayments, + + // for invoices + companyName: String, + agreementRequired: Boolean, + contractHead: String, + companyAddress: String, + bankDetails: String + }, + default: {} + }, + + // transaction number, external analog for _id + // always a number to be accepted by any payment system + // random, not autoincrement, because more convenient for development, doesn't repeat on dropdb + number: { + type: Number, + default: function() { + // webmoney requires transaction number to be a number 0 < LMI_PAYMENT_NO < 2147483647 + return parseInt(crypto.randomBytes(4).toString('hex'), 16) % 2147483647; + }, + unique: true + }, + created: { + type: Date, + required: true, + default: Date.now + }, + modified: { + type: Date + }, + status: { + type: String, + required: true + }, + statusMessage: { + type: String + } +}); + +// Awaiting for the payment system callback, +// when the user opens the order, let him wait and refresh the page +// we don't know if it's an offline payment or not +schema.statics.STATUS_PENDING = 'pending'; + +schema.statics.STATUS_SUCCESS = 'success'; + +schema.statics.STATUS_FAIL = 'fail'; + +// autolog all changes +schema.pre('save', function logChanges(next) { + + var log = new TransactionLog({ + transaction: this._id, + event: 'save', + data: { + status: this.status, + statusMessage: this.statusMessage, + amount: this.amount + } + }); + + log.save(function(err, doc) { + next(err); + }); +}); + +schema.pre('save', function(next){ + this.modified = new Date(); + next(); +}); + +// allow many failed transactions +// forbid many pending/successful +schema.pre('validate', function ensureSingleTransactionPerOrder(next) { + Transaction.findOne({ + order: this.order, + status: { + $in: [ + Transaction.STATUS_PENDING, + Transaction.STATUS_SUCCESS // enforce payment with a single tx + ] + }, + _id: { + $ne: this._id + } + }, function (err, tx) { + if (err) return next(err); + if (tx) return next(new Error("A transaction " + tx._id + " with status " + tx.status + " already exists for the same order " + tx.order)); + next(); + }); +}); + + + +/* +schema.methods.getStatusDescription = function() { + if (this.status == Transaction.STATUS_SUCCESS) { + return 'Оплата прошла успешно.'; + } + if (this.status == Transaction.STATUS_PENDING) { + return 'Оплата ожидается, о её успешномо окончании вы будете извещены по e-mail.'; + } + + if (this.status == Transaction.STATUS_FAIL) { + var result = 'Оплата не прошла'; + if (this.statusMessage) result += ': ' + this.statusMessage; + return result + '.'; + } + + if (!this.status) { + return 'нет информации об оплате'; + } + + throw new Error("неподдерживаемый статус транзакции"); +}; +*/ + +schema.methods.logRequest = function*(event, request) { + request.log.debug(event); + yield this.log(event, {url: request.originalUrl, body: request.body}); +}; + +// log anything related to the transaction +schema.methods.log = function*(event, data) { + + if (typeof event != "string") { + throw new Error("event name must be a string"); + } + + var options = { + transaction: this._id, + event: event, + data: data + }; + + // for complex objects -> prior to logging make them simple (must be jsonable) + // e.g for HTTP response (HTTP.IncomingMessage) + if (options.data && typeof options.data == 'object') { + // object keys may not contain "." in mongodb, so I may not store arbitrary objects + // only json can help + options.data = JSON.stringify(options.data); + } + +// console.log(options); + + var log = new TransactionLog(options); + yield log.persist(); +}; + +/* jshint -W003 */ +var Transaction = module.exports = mongoose.model('Transaction', schema); + diff --git a/handlers/payments/models/transactionLog.js b/handlers/payments/models/transactionLog.js new file mode 100755 index 000000000..0ef24a5a8 --- /dev/null +++ b/handlers/payments/models/transactionLog.js @@ -0,0 +1,24 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; + +var schema = new Schema({ + + transaction: { + type: Schema.Types.ObjectId, + ref: 'Transaction', + index: true + }, + event: { + type: String, + index: true + }, + data: Schema.Types.Mixed, + + created: { + type: Date, + default: Date.now + } +}); + +module.exports = mongoose.model('TransactionLog', schema); + diff --git a/handlers/payments/payanyway/controller/callback.js b/handlers/payments/payanyway/controller/callback.js new file mode 100755 index 000000000..c84b51672 --- /dev/null +++ b/handlers/payments/payanyway/controller/callback.js @@ -0,0 +1,50 @@ +const config = require('config'); +//require('config/mongoose'); +const payanywayConfig = config.payments.modules.payanyway; +const Order = require('../../models/order'); +const Transaction = require('../../models/transaction'); +const md5 = require('MD5'); + +exports.post = function* (next) { + + yield* this.loadTransaction('MNT_TRANSACTION_ID', {skipOwnerCheck : true}); + + + yield this.transaction.logRequest('callback unverified', this.request); + + if (!checkSignature(this.request.body)) { + this.log.debug("wrong signature"); + this.throw(403, "wrong signature"); + } + + yield this.transaction.logRequest('callback', this.request); + + // signature is valid, so everything MUST be fine + if (this.transaction.amount != parseFloat(this.request.body.MNT_AMOUNT) || + this.request.body.MNT_ID != payanywayConfig.id) { + yield this.transaction.persist({ + status: Transaction.STATUS_FAIL, + statusMessage: "данные транзакции не совпадают с базой, свяжитесь с поддержкой" + }); + this.throw(404, "transaction data doesn't match the POST body"); + } + + yield this.transaction.persist({ + status: Transaction.STATUS_SUCCESS + }); + + this.log.debug("will call order onPaid module=" + this.order.module); + yield* this.order.onPaid(); + + this.body = 'SUCCESS'; +}; + +function checkSignature(body) { + + var signature = body.MNT_ID + body.MNT_TRANSACTION_ID + body.MNT_OPERATION_ID + body.MNT_AMOUNT + + body.MNT_CURRENCY_CODE + (body.MNT_SUBSCRIBER_ID || '') + (+body.MNT_TEST_MODE ? '1' : '0') + payanywayConfig.secret; + + signature = md5(signature); + + return signature == body.MNT_SIGNATURE; +} diff --git a/handlers/payments/payanyway/controller/cancel.js b/handlers/payments/payanyway/controller/cancel.js new file mode 100755 index 000000000..3f8f8982f --- /dev/null +++ b/handlers/payments/payanyway/controller/cancel.js @@ -0,0 +1,15 @@ +const Transaction = require('../../models/transaction'); + +exports.get = function* (next) { + + yield* this.loadTransaction('MNT_TRANSACTION_ID'); + + this.transaction.persist({ + status: Transaction.STATUS_FAIL, + statusMessage: 'отказ от оплаты' + }); + + this.redirectToOrder(); +}; + + diff --git a/handlers/payments/payanyway/controller/fail.js b/handlers/payments/payanyway/controller/fail.js new file mode 100755 index 000000000..0a6f35b0e --- /dev/null +++ b/handlers/payments/payanyway/controller/fail.js @@ -0,0 +1,15 @@ +const Transaction = require('../../models/transaction'); + +exports.get = function* (next) { + + yield* this.loadTransaction('MNT_TRANSACTION_ID'); + + this.transaction.persist({ + status: Transaction.STATUS_FAIL, + statusMessage: 'оплата не прошла' + }); + + this.redirectToOrder(); +}; + + diff --git a/handlers/payments/payanyway/controller/success.js b/handlers/payments/payanyway/controller/success.js new file mode 100755 index 000000000..f4980df2f --- /dev/null +++ b/handlers/payments/payanyway/controller/success.js @@ -0,0 +1,8 @@ +const Transaction = require('../../models/transaction'); + +exports.all = function* (next) { + + yield* this.loadTransaction('MNT_TRANSACTION_ID'); + + this.redirectToOrder(); +}; diff --git a/handlers/payments/payanyway/index.js b/handlers/payments/payanyway/index.js new file mode 100644 index 000000000..bca681bba --- /dev/null +++ b/handlers/payments/payanyway/index.js @@ -0,0 +1,29 @@ +const Transaction = require('../models/transaction'); +const path = require('path'); + +exports.renderForm = require('./renderForm'); + +// TX gets this status when created +exports.createTransaction = function*(order) { + + var transaction = new Transaction({ + order: order._id, + amount: order.amount, + status: Transaction.STATUS_PENDING, + paymentMethod: path.basename(__dirname) + }); + + yield transaction.persist(); + + return transaction; +}; + + +exports.info = { + title: "Payanyway", + name: path.basename(__dirname), + subtitle: "и много других методов", + cards: ['visa-mastercard'], + hasIcon: false +}; + diff --git a/handlers/payments/payanyway/renderForm.js b/handlers/payments/payanyway/renderForm.js new file mode 100755 index 000000000..7aba59dc1 --- /dev/null +++ b/handlers/payments/payanyway/renderForm.js @@ -0,0 +1,19 @@ +const jade = require('lib/serverJade'); +const config = require('config'); +const path = require('path'); + +module.exports = function* (transaction) { + + var form = jade.renderFile(path.join(__dirname, 'templates/form.jade'), { + amount: transaction.amount, + number: transaction.number, + currency: config.payments.currency, + id: config.payments.modules.payanyway.id, + limitIds: process.env.NODE_ENV == 'development' ? '' : '843858,248362,822360,545234,1028,499669' + }); + + return form; + +}; + + diff --git a/handlers/payments/payanyway/router.js b/handlers/payments/payanyway/router.js new file mode 100755 index 000000000..a598bb612 --- /dev/null +++ b/handlers/payments/payanyway/router.js @@ -0,0 +1,18 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); + +var callback = require('./controller/callback'); + +var success = require('./controller/success'); +var cancel = require('./controller/cancel'); +var fail = require('./controller/fail'); + +router.post('/callback', callback.post); + +router.all('/success', success.all); + +router.get('/cancel', cancel.get); +router.get('/fail', fail.get); + + diff --git a/handlers/payments/payanyway/templates/form.jade b/handlers/payments/payanyway/templates/form.jade new file mode 100755 index 000000000..afe7fdb6c --- /dev/null +++ b/handlers/payments/payanyway/templates/form.jade @@ -0,0 +1,10 @@ + +form(method="POST" action="https://www.moneta.ru/assistant.htm" accept-charset="UTF-8") + input(type="hidden",name="MNT_ID",value=id) + input(type="hidden",name="MNT_TRANSACTION_ID",value=number) + input(type="hidden",name="MNT_CURRENCY_CODE",value=currency) + input(type="hidden",name="MNT_AMOUNT",value=amount) + if limitIds + input(type="hidden",name="paymentSystem.limitIds",value=limitIds) + input(type="submit",value="Оплатить") + diff --git a/handlers/payments/paypal/controller/autoreturn.js b/handlers/payments/paypal/controller/autoreturn.js new file mode 100755 index 000000000..6ce46d62b --- /dev/null +++ b/handlers/payments/paypal/controller/autoreturn.js @@ -0,0 +1,4 @@ + +exports.get = function*() { + this.body = 'Thank you for your payment. Your transaction has been completed, and a receipt for your purchase has been emailed to you. You may log into your account at PayPal to view details of this transaction.'; +}; \ No newline at end of file diff --git a/handlers/payments/paypal/controller/cancel.js b/handlers/payments/paypal/controller/cancel.js new file mode 100755 index 000000000..22871d037 --- /dev/null +++ b/handlers/payments/paypal/controller/cancel.js @@ -0,0 +1,14 @@ +const Transaction = require('../../models/transaction'); + +exports.get = function* (next) { + + yield* this.loadTransaction(); + + yield this.transaction.persist({ + status: Transaction.STATUS_FAIL, + statusMessage: 'отказ от оплаты' + }); + + this.redirectToOrder(); +}; + diff --git a/handlers/payments/paypal/controller/ipn.js b/handlers/payments/paypal/controller/ipn.js new file mode 100755 index 000000000..f419c80ff --- /dev/null +++ b/handlers/payments/paypal/controller/ipn.js @@ -0,0 +1,141 @@ +const config = require('config') +const paypalConfig = config.payments.modules.paypal; +const Order = require('../../models/order'); +const Transaction = require('../../models/transaction'); +const TransactionLog = require('../../models/transactionLog'); +const request = require('koa-request'); + +// docs: +// +// https://developer.paypal.com/webapps/developer/docs/classic/ipn/integration-guide/IPNIntro/ + +/* jshint -W106 */ +exports.post = function* (next) { + + yield* this.loadTransaction('invoice', {skipOwnerCheck: true}); + + yield this.transaction.logRequest('ipn: request received', this.request); + + var qs = { + 'cmd': '_notify-validate' + }; + + for (var field in this.request.body) { + qs[field] = this.request.body[field]; + } + + // request oauth token + var options = { + method: 'GET', + qs: qs, + url: 'https://www.paypal.com/cgi-bin/webscr', + headers: { + 'User-Agent': 'request' + } + }; + + yield this.transaction.log('ipn: request verify', options); + + var response; + try { + response = yield request(options); + } catch(e) { + yield this.transaction.log('ipn: request verify failed', e.message); + this.throw(403, "Couldn't verify ipn"); + } + + if (response.body != "VERIFIED") { + yield this.transaction.log('ipn: invalid IPN', response.body); + this.throw(403, "Invalid IPN"); + } + + // ipn is verified now! But we check if it's data matches the transaction (as recommended in docs) + if (this.transaction.amount != parseFloat(this.request.body.mc_gross) || + this.request.body.receiver_email != paypalConfig.email || + this.request.body.mc_currency != this.transaction.currency) { + + yield this.transaction.log("ipn: the response POST data doesn't match the transaction data", response.body); + this.throw(404, "transaction data doesn't match the POST body, strange"); + } + + // IPN is fully verified and valid + + + // match agains latest ipn in logs as recommended: + // if there just was an IPN about the same transaction, and it's state is the same + // => then the current one is a duplicate + var previousIpn = yield TransactionLog.findOne({ + event: "ipn: VALIDATED_IN_PROCESS", + transaction: this.transaction._id + }).sort({created: -1}).exec(); + + if (previousIpn && previousIpn.data.payment_status == this.request.body.payment_status) { + yield this.transaction.log("ipn: duplicate", this.request.body); + // ignore duplicate + this.body = ''; + return; + } + + yield this.transaction.log("ipn: VALIDATED_IN_PROCESS", this.request.body); + + // IPN is fully verified, valid, non-duplicate + + // Do not perform any processing on WPS transactions here that do not have + // transaction IDs, indicating they are non-payment IPNs such as those used + // for subscription signup requests. + if (!this.request.body.txn_id) { + yield this.transaction.log("ipn: without txn_id", this.request.body); + this.body = ''; + return; + } + + switch(this.request.body.payment_status) { + case 'Failed': + case 'Voided': + yield this.transaction.persist({ + status: Transaction.STATUS_FAIL + }); + this.body = ''; + return; + case 'Pending': + yield this.transaction.persist({ + status: Transaction.STATUS_PENDING, + statusMessage: this.request.body.pending_reason + }); + this.body = ''; + return; + case 'Completed': + + // Now let's see if the transaction was already processed by PDT or another IPN + var refreshedTransaction = yield Transaction.findOne({ + _id: this.transaction._id + }).exec(); + + if (refreshedTransaction.status == Transaction.STATUS_SUCCESS) { + // done :) + yield this.transaction.log("ipn: transaction is already processed to success by PDT/IPN"); + } else { + + yield refreshedTransaction.persist({ + status: Transaction.STATUS_SUCCESS, + statusMessage: 'Paypal подтвердил оплату' + }); + + this.log.debug("will call order onPaid module=" + this.order.module); + yield* this.order.onPaid(); + + } + + this.body = ''; + return; + default: + // Refunded ... + yield this.transaction.log("ipn: payment_status unknown", this.request.body); + + this.body = ''; + return; + } + + + +}; diff --git a/handlers/payments/paypal/controller/success.js b/handlers/payments/paypal/controller/success.js new file mode 100755 index 000000000..88696d49c --- /dev/null +++ b/handlers/payments/paypal/controller/success.js @@ -0,0 +1,87 @@ +const Transaction = require('../../models/transaction'); +const Order = require('../../models/order'); +const request = require('koa-request'); +const config = require('config'); +const qs = require('querystring'); + +// /payments/paypal/success?transactionNumber=1481381892&tx=76G37726XX923073E&st=Completed&amt=1%2e00&cc=RUB&cm=&item_number= +exports.get = function* (next) { + yield* this.loadTransaction(); + + yield this.transaction.log('success: return', this.originalUrl); + + // trust only tx parameter (transaction token), other params can be user-generated + // ask the details from Paypal + var tx = this.query.tx; + + // Verify the success url as explained here: + // https://developer.paypal.com/docs/classic/paypal-payments-standard/integration-guide/paymentdatatransfer/ + var options = { + method: 'POST', + url: 'https://www.paypal.com/cgi-bin/webscr', + form: { + cmd: '_notify-synch', + tx: tx, + at: config.payments.modules.paypal.pdtToken + }, + headers: { + 'User-Agent': 'request' + } + }; + + yield this.transaction.log('success: request verify', options); + + var response; + try { + response = yield request(options); + } catch(e) { + yield this.transaction.log('success: request verify failed, error', e.message); + this.throw(403, "Couldn't verify success"); + } + + if (response.body.startsWith("FAIL")) { + yield this.transaction.log('success: request verify failed', response.body); + this.throw(403, "Verification Failed"); + } else if (!response.body.startsWith("SUCCESS")) { + // if it's not fail, must be success (error otherwise) + yield this.transaction.log('success: request verify error', response.body); + this.throw(500, "Verification Error"); + } else { + yield this.transaction.log('success: request verify success', response.body); + } + + // turn response into query string + var queryString = response.body.replace(/\n/g, '&'); + var responseParsed = qs.parse(queryString); + + // don't actually need to check it, but still checking that the amount is correct + // that's an extra check for validity + if (responseParsed.mc_gross != this.transaction.amount) { + yield this.transaction.log('success: request but mc_gross != transaction.amount', response.body); + this.throw(500, "Verification amount error"); + } + + // ...Verified + + // Now let's see if the transaction was already processed by IPN + var refreshedTransaction = yield Transaction.findOne({ + _id: this.transaction._id + }).exec(); + + if (refreshedTransaction.status == Transaction.STATUS_SUCCESS) { + // done :) + yield this.transaction.log("success: transaction is already processed by IPN"); + } else { + + yield refreshedTransaction.persist({ + status: Transaction.STATUS_SUCCESS, + statusMessage: 'Paypal подтвердил оплату' + }); + + this.log.debug("will call order onPaid module=" + this.order.module); + yield* this.order.onPaid(); + } + + this.redirectToOrder(); +}; + diff --git a/handlers/payments/paypal/index.js b/handlers/payments/paypal/index.js new file mode 100755 index 000000000..9e69ee200 --- /dev/null +++ b/handlers/payments/paypal/index.js @@ -0,0 +1,42 @@ +const path = require('path'); +const Transaction = require('../models/transaction'); +const money = require('money'); +const config = require('config'); + +exports.renderForm = require('./renderForm'); + +/** + * Create transaction from order, using optional info in requestBody + * @param order + * @param requestBody + * @returns {*|exports|module.exports} + */ +exports.createTransaction = function*(order, requestBody) { + + var currency = requestBody.paypalCurrency; + if (!~Transaction.schema.path('currency').enumValues.indexOf(currency)) { + throw(new Error("Unsupported currency:" + currency)); + } + + var amount = (order.currency == config.payments.currency) ? + order.amount : Math.round(money.convert(order.amount, {from: config.payments.currency, to: currency})); + + var transaction = new Transaction({ + order: order._id, + amount: amount, + status: Transaction.STATUS_PENDING, + currency: currency, + paymentMethod: path.basename(__dirname) + }); + + yield transaction.persist(); + + return transaction; + +}; + +exports.info = { + title: 'PayPal', + name: path.basename(__dirname), + hasIcon: true +}; diff --git a/handlers/payments/paypal/renderForm.js b/handlers/payments/paypal/renderForm.js new file mode 100755 index 000000000..35639b5c9 --- /dev/null +++ b/handlers/payments/paypal/renderForm.js @@ -0,0 +1,37 @@ +const jade = require('lib/serverJade'); +const config = require('config'); +const paypalConfig = config.payments.modules.paypal; +const path = require('path'); + +module.exports = function* (transaction) { + + /* jshint -W106 */ + var fields = { + business: paypalConfig.email, + invoice: transaction.number, + amount: transaction.amount, + item_name: "Оплата по счёту " + transaction.number, + charset: "utf-8", + cmd: "_xclick", + no_note: 1, + no_shipping: 1, + rm: 2, // the buyer's browser is redirected to the return URL by using the POST method, and all payment variables are included + currency_code: transaction.currency, + lc: "RU", + notify_url: process.env.SITE_HOST + '/payments/paypal/ipn?transactionNumber=' + transaction.number, + cancel_url: process.env.SITE_HOST + '/payments/paypal/cancel?transactionNumber=' + transaction.number, + return: process.env.SITE_HOST + '/payments/paypal/success?transactionNumber=' + transaction.number + }; + + + yield transaction.log("form fields", fields); + + var form = jade.renderFile(path.join(__dirname, 'templates/form.jade'), { + fields: fields + }); + + return form; + +}; + + diff --git a/handlers/payments/paypal/router.js b/handlers/payments/paypal/router.js new file mode 100755 index 000000000..ee45840e0 --- /dev/null +++ b/handlers/payments/paypal/router.js @@ -0,0 +1,17 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); + +var ipn = require('./controller/ipn'); +var success = require('./controller/success'); +var cancel = require('./controller/cancel'); + +// webmoney server posts here (in background) +router.post('/ipn', ipn.post); + +// webmoney server redirects here if payment successful +router.get('/success', success.get); + +router.get('/cancel', cancel.get); + + diff --git a/handlers/payments/paypal/templates/form.jade b/handlers/payments/paypal/templates/form.jade new file mode 100755 index 000000000..a10cb4864 --- /dev/null +++ b/handlers/payments/paypal/templates/form.jade @@ -0,0 +1,5 @@ + +form(method="post" action="https://www.paypal.com/cgi-bin/webscr") + each value, name in fields + input(type="hidden", name=name,value=value) + input(type="submit" value="submit") diff --git a/handlers/payments/readme.txt b/handlers/payments/readme.txt new file mode 100755 index 000000000..9de837efe --- /dev/null +++ b/handlers/payments/readme.txt @@ -0,0 +1,7 @@ +IPNs default to charset = windows-1252, however, you can change this by going to your Go to Profile > My Selling Tools +Down at the bottom click on "PayPal button language encoding" and then More Options. On this page you can change your character set and languages for various notifications. + +Profile > My selling tools > Website preferences > + Auto Return ON (url is not used actually) + Payment Data Transfer ON + diff --git a/handlers/payments/webmoney/controller/callback.js b/handlers/payments/webmoney/controller/callback.js new file mode 100755 index 000000000..5357bda30 --- /dev/null +++ b/handlers/payments/webmoney/controller/callback.js @@ -0,0 +1,66 @@ +const webmoneyConfig = require('config').payments.modules.webmoney; +const mongoose = require('mongoose'); +const Order = require('../../models/order'); +const Transaction = require('../../models/transaction'); +const sha256 = require('sha256'); + +// ONLY ACCESSED from WEBMONEY SERVER +exports.prerequest = function* (next) { + yield* this.loadTransaction('LMI_PAYMENT_NO', {skipOwnerCheck : true}); + + this.log.debug("prerequest"); + + yield this.transaction.logRequest('prerequest', this.request); + + if (this.transaction.status == Transaction.STATUS_SUCCESS || + this.transaction.amount != parseFloat(this.request.body.LMI_PAYMENT_AMOUNT) || + this.request.body.LMI_PAYEE_PURSE != webmoneyConfig.purse + ) { + this.log.debug("no pending transaction " + this.request.body.LMI_PAYMENT_NO); + this.throw(404, 'unfinished transaction with given params not found'); + } + + this.body = 'YES'; + +}; + +exports.post = function* (next) { + + yield* this.loadTransaction('LMI_PAYMENT_NO', {skipOwnerCheck : true}); + + if (!checkSignature(this.request.body)) { + this.log.debug("wrong signature"); + this.throw(403, "wrong signature"); + } + + yield this.transaction.logRequest('callback', this.request); + + if (this.transaction.amount != parseFloat(this.request.body.LMI_PAYMENT_AMOUNT) || + this.request.body.LMI_PAYEE_PURSE != webmoneyConfig.purse) { + // STRANGE, signature is correct + yield this.transaction.persist({ + status: Transaction.STATUS_FAIL, + statusMessage: "данные транзакции не совпадают с базой, свяжитесь с поддержкой" + }); + this.throw(404, "transaction data doesn't match the POST body"); + } + + yield this.transaction.persist({ + status: Transaction.STATUS_SUCCESS + }); + + this.log.debug("will call order onPaid module=" + this.order.module); + yield* this.order.onPaid(); + + this.body = 'OK'; + +}; + +function checkSignature(body) { + + var signature = sha256(body.LMI_PAYEE_PURSE + body.LMI_PAYMENT_AMOUNT + body.LMI_PAYMENT_NO + + body.LMI_MODE + body.LMI_SYS_INVS_NO + body.LMI_SYS_TRANS_NO + body.LMI_SYS_TRANS_DATE + + webmoneyConfig.secretKey + body.LMI_PAYER_PURSE + body.LMI_PAYER_WM).toUpperCase(); + + return signature == body.LMI_HASH; +} diff --git a/handlers/payments/webmoney/controller/fail.js b/handlers/payments/webmoney/controller/fail.js new file mode 100755 index 000000000..1c6a9d57f --- /dev/null +++ b/handlers/payments/webmoney/controller/fail.js @@ -0,0 +1,14 @@ +const mongoose = require('mongoose'); +const Transaction = require('../../models/transaction'); + +exports.post = function* (next) { + + yield* this.loadTransaction('LMI_PAYMENT_NO'); + + yield this.transaction.persist({ + status: Transaction.STATUS_FAIL + }); + + this.redirectToOrder(); + +}; diff --git a/handlers/payments/webmoney/controller/success.js b/handlers/payments/webmoney/controller/success.js new file mode 100755 index 000000000..bcef36e3c --- /dev/null +++ b/handlers/payments/webmoney/controller/success.js @@ -0,0 +1,8 @@ +const config = require('config'); +const mongoose = require('mongoose'); + +exports.post = function* (next) { + yield* this.loadTransaction('LMI_PAYMENT_NO'); + + this.redirectToOrder(); +}; diff --git a/handlers/payments/webmoney/index.js b/handlers/payments/webmoney/index.js new file mode 100755 index 000000000..17bdcd68f --- /dev/null +++ b/handlers/payments/webmoney/index.js @@ -0,0 +1,26 @@ + +const path = require('path'); +const Transaction = require('../models/transaction'); + +exports.renderForm = require('./renderForm'); + +exports.createTransaction = function*(order) { + + var transaction = new Transaction({ + order: order._id, + amount: order.amount, + status: Transaction.STATUS_PENDING, + paymentMethod: path.basename(__dirname) + }); + + yield transaction.persist(); + + return transaction; +}; + +exports.info = { + title: "WebMoney", + name: path.basename(__dirname), + hasIcon: true +}; + diff --git a/handlers/payments/webmoney/renderForm.js b/handlers/payments/webmoney/renderForm.js new file mode 100755 index 000000000..2431209ad --- /dev/null +++ b/handlers/payments/webmoney/renderForm.js @@ -0,0 +1,15 @@ +const jade = require('lib/serverJade'); +const config = require('config'); +const path = require('path'); + +module.exports = function* (transaction) { + + return jade.renderFile(path.join(__dirname, 'templates/form.jade'), { + amount: transaction.amount, + number: transaction.number, + webmoney: config.payments.modules.webmoney + }); + +}; + + diff --git a/handlers/payments/webmoney/router.js b/handlers/payments/webmoney/router.js new file mode 100755 index 000000000..adcb5361e --- /dev/null +++ b/handlers/payments/webmoney/router.js @@ -0,0 +1,25 @@ +var Router = require('koa-router'); + +var router = module.exports = new Router(); + +var callback = require('./controller/callback'); +var success = require('./controller/success'); +var fail = require('./controller/fail'); + + +// webmoney server posts here (in background) +router.post('/callback', function* (next) { + if (this.request.body.LMI_PREREQUEST == '1') { + yield* callback.prerequest.call(this, next); + } else { + yield* callback.post.call(this, next); + } +}); + +// webmoney server redirects here if payment successful +router.post('/success', success.post); + +// webmoney server redirects here if payment failed +router.post('/fail', fail.post); + + diff --git a/handlers/payments/webmoney/templates/form.jade b/handlers/payments/webmoney/templates/form.jade new file mode 100755 index 000000000..8ad6d67ce --- /dev/null +++ b/handlers/payments/webmoney/templates/form.jade @@ -0,0 +1,7 @@ +form(method="POST",action="https://merchant.webmoney.ru/lmi/payment.asp") + input(type="hidden",name="LMI_PAYMENT_AMOUNT",value=amount) + input(type="hidden",name="LMI_PAYMENT_DESC_BASE64",value=new Buffer('оплата по счету ' + number).toString('base64')) + input(type="hidden",name="LMI_PAYMENT_NO",value=number) + input(type="hidden",name="LMI_PAYEE_PURSE",value=webmoney.purse) + input(type="hidden",name="LMI_SIM_MODE",value=(isTest ? 1 : 0)) + input(type="submit",value="Оплатить") diff --git a/handlers/payments/webmoney/test/.jshintrc b/handlers/payments/webmoney/test/.jshintrc new file mode 100755 index 000000000..077663629 --- /dev/null +++ b/handlers/payments/webmoney/test/.jshintrc @@ -0,0 +1,23 @@ +{ + "maxerr": 25, + "latedef": "nofunc", + "browser": false, + "node": true, + "indent": 2, + "camelcase": true, + "newcap": true, + "undef": true, + "esnext": true, + "multistr": true, + "noyield": true, + "expr": true, + "-W004": true, + "globals" : { + "describe" : false, + "it" : false, + "before" : false, + "beforeEach" : false, + "after" : false, + "afterEach" : false + } +} diff --git a/handlers/payments/yandexmoney/controller/back.js b/handlers/payments/yandexmoney/controller/back.js new file mode 100755 index 000000000..c5c7747e2 --- /dev/null +++ b/handlers/payments/yandexmoney/controller/back.js @@ -0,0 +1,157 @@ +const config = require('config'); +const Order = require('../../models/order'); +const Transaction = require('../../models/transaction'); +const request = require('koa-request'); +const log = require('log')(); + +var updatePendingOnlineTransactionStatus = require('../lib/updatePendingOnlineTransactionStatus'); + +/* jshint -W106 */ +exports.get = function* () { + + var self = this; + + yield* this.loadTransaction(); + + yield this.transaction.logRequest('back', this.request); + + if (!this.query.code) { + yield* fail(this.query.error_description || this.query.error); + return; + } + + try { + var oauthTokenResponse = yield* requestOauthToken.call(this, this.query.code); + + var oauthToken = oauthTokenResponse.access_token; + + if (!oauthToken) { + throwResponseError(oauthTokenResponse); + } + + var requestPaymentResponse = yield* requestPayment.call(this, oauthToken); + + if (requestPaymentResponse.status != "success") { + if (requestPaymentResponse.error == 'ext_action_required') { + self.redirect(requestPaymentResponse.ext_action_uri); + return; + } + + throwResponseError(requestPaymentResponse); + } + + // payment approved, success + this.transaction.paymentDetails.oauthToken = oauthToken; + this.transaction.paymentDetails.requestId = requestPaymentResponse.request_id; + yield this.transaction.persist(); + + // payment may not succeed yet, + // so this can be called later too with HTTP GET + yield* updatePendingOnlineTransactionStatus(this.transaction); + + self.redirectToOrder(); + + } catch (e) { + if (e instanceof URIError) { + yield* fail(e.message); + return; + } else if (e instanceof SyntaxError) { + yield* fail("некорректный ответ платёжной системы"); + return; + } else { + throw e; + } + } + + /* jshint -W106 */ + function* fail(reason) { + self.transaction.status = Transaction.STATUS_FAIL; + self.transaction.statusMessage = reason; + + self.log.debug("fail transaction", self.transaction.toObject()); + yield self.transaction.persist(); + + self.redirectToOrder(); + } + + + +}; + + + +function* requestOauthToken(code) { + + // request oauth token + var options = { + method: 'POST', + form: { + code: code, + client_id: config.payments.modules.yandexmoney.clientId, + grant_type: 'authorization_code', + redirect_uri: config.payments.modules.yandexmoney.redirectUri + '?transactionNumber=' + + this.transaction.number, + client_secret: config.payments.modules.yandexmoney.clientSecret + }, + url: 'https://sp-money.yandex.ru/oauth/token' + }; + + + yield this.transaction.log('request oauth/token', options); + + var response = yield request(options); + + yield this.transaction.log('response oauth/token', response.body); + + return JSON.parse(response.body); +} + + +// request payment +// return return request_id +function* requestPayment(oauthToken) { + var options = { + method: 'POST', + form: { + pattern_id: 'p2p', + to: config.payments.modules.yandexmoney.purse, + amount: this.transaction.amount, + comment: 'оплата по счету ' + this.transaction.number, + message: 'оплата по счету ' + this.transaction.number, + identifier_type: 'account' + }, + headers: { + 'Authorization': 'Bearer ' + oauthToken + }, + url: 'https://money.yandex.ru/api/request-payment' + }; + + this.log.debug('request api/request-payment', options); + yield this.transaction.log('request api/request-payment', options); + + var response = yield request(options); + this.log.debug('response api/request-payment', response.body); + yield this.transaction.log('response api/request-payment', response.body); + + return JSON.parse(response.body); +} + + +function throwResponseError(response) { + var message; + + var error = (response.error == 'not_enough_funds') ? 'недостаточно средств.' : + (response.error == 'limit_exceeded') ? 'превышен лимит.' : + (response.error == 'account_blocked') ? 'счёт заблокирован.' : response.error; + + if (error && response.error_description) { + message = '[' + error + '] ' + response.error_description; + } else if (error) { + message = error; + } else { + message = "детали ошибки не указаны."; + } + + + throw new URIError(message); +} diff --git a/handlers/payments/yandexmoney/controller/processPayments.js b/handlers/payments/yandexmoney/controller/processPayments.js new file mode 100755 index 000000000..dbf060ff2 --- /dev/null +++ b/handlers/payments/yandexmoney/controller/processPayments.js @@ -0,0 +1,35 @@ +/** + * Process all unfinished payments for Ya.Money, + * CRONTAB: call every minute + * @type {exports} + */ + +const config = require('config'); +const Order = require('../../models/order'); +const Transaction = require('../../models/transaction'); +const request = require('koa-request'); + +var updatePendingOnlineTransactionStatus = require('../lib/updatePendingOnlineTransactionStatus'); + +/* jshint -W106 */ +exports.get = function* () { + + var transactions = yield Transaction.find({ + order: this.order._id, + status: Transaction.STATUS_PENDING, + paymentMethod: 'yandexmoney', + 'paymentDetails.nextRetry': { + $gte: Date.now() + }, + 'paymentDetails.processing': { + $ne: true + } + }).exec(); + + for (var i = 0; i < transactions.length; i++) { + var transaction = transactions[i]; + this.log("processPayments", transaction); + yield* updatePendingOnlineTransactionStatus(transaction); + } + +}; \ No newline at end of file diff --git a/handlers/payments/yandexmoney/index.js b/handlers/payments/yandexmoney/index.js new file mode 100755 index 000000000..64bc13197 --- /dev/null +++ b/handlers/payments/yandexmoney/index.js @@ -0,0 +1,28 @@ +const config = require('config'); +const jade = require('lib/serverJade'); +const path = require('path'); +const Transaction = require('../models/transaction'); + +exports.renderForm = require('./renderForm'); + +// TX gets this status when created +exports.createTransaction = function*(order) { + + var transaction = new Transaction({ + order: order._id, + amount: order.amount, + status: Transaction.STATUS_PENDING, + paymentMethod: path.basename(__dirname) + }); + + yield transaction.persist(); + + return transaction; +}; + + +exports.info = { + title: "Яндекс.Деньги", + name: path.basename(__dirname), + hasIcon: true +}; diff --git a/handlers/payments/yandexmoney/lib/updatePendingOnlineTransactionStatus.js b/handlers/payments/yandexmoney/lib/updatePendingOnlineTransactionStatus.js new file mode 100755 index 000000000..7e24dd0ef --- /dev/null +++ b/handlers/payments/yandexmoney/lib/updatePendingOnlineTransactionStatus.js @@ -0,0 +1,78 @@ + +const request = require('koa-request'); +const Transaction = require('../../models/transaction'); +const Order = require('../../models/order'); +const assert = require('assert'); + +// update order status if possible, check transactions +/* jshint -W106 */ +module.exports = function*(transaction) { + assert(transaction.status == Transaction.STATUS_PENDING); + + // to avoid race condition with regular update + // not really atomic locking, but much safer than w/o it + if (transaction.paymentDetails.processing) return; + transaction.paymentDetails.processing = true; + yield transaction.persist(); + + var processPaymentResponse = yield* processPayment(transaction); + + yield function(callback) { + transaction.populate('order', callback); + }; + + var order = transaction.order; + + switch (processPaymentResponse.status) { + case 'success': + transaction.status = Transaction.STATUS_SUCCESS; + break; + + case 'refused': + transaction.status = Transaction.STATUS_FAIL; + transaction.statusMessage = processPaymentResponse.error; + break; + + case 'in_progress': + transaction.paymentDetails.nextRetry = Date.now() + processPaymentResponse.next_retry; + break; + + default: + this.log.error("Unexprected response from yandex ", processPaymentResponse); + this.throw(500, "Unexpected response from yandex.money"); + } + + transaction.paymentDetails.processing = false; + + yield transaction.persist(); + + if (transaction.status == Transaction.STATUS_SUCCESS) { + // success! + + yield* order.onPaid(); + + } + +}; + + +function* processPayment(transaction) { + var options = { + method: 'POST', + form: { + request_id: transaction.paymentDetails.requestId + }, + headers: { + 'Authorization': 'Bearer ' + transaction.paymentDetails.oauthToken + }, + url: 'https://money.yandex.ru/api/process-payment' + }; + + yield* transaction.log('request api/process-payment', options); + + var response = yield request(options); + yield* transaction.log('response api/process-payment', response.body); + + return JSON.parse(response.body); +} + diff --git a/handlers/payments/yandexmoney/renderForm.js b/handlers/payments/yandexmoney/renderForm.js new file mode 100755 index 000000000..916b72f20 --- /dev/null +++ b/handlers/payments/yandexmoney/renderForm.js @@ -0,0 +1,21 @@ +const jade = require('lib/serverJade'); +const config = require('config'); +const path = require('path'); +const assert = require('assert'); + +module.exports = function* (transaction) { + + assert(config.payments.modules.yandexmoney.redirectUri.startsWith('http')); + + return jade.renderFile(path.join(__dirname, 'templates/form.jade'), { + clientId: config.payments.modules.yandexmoney.clientId, + redirectUri: config.payments.modules.yandexmoney.redirectUri, + purse: config.payments.modules.yandexmoney.purse, + transactionNumber: transaction.number, + amount: transaction.amount + }); + +}; + + + diff --git a/handlers/payments/yandexmoney/router.js b/handlers/payments/yandexmoney/router.js new file mode 100755 index 000000000..6c5d486f6 --- /dev/null +++ b/handlers/payments/yandexmoney/router.js @@ -0,0 +1,10 @@ +var Router = require('koa-router'); +var mustBeAdmin = require('auth').mustBeAdmin; +var router = module.exports = new Router(); + +var processPayments = require('./controller/processPayments'); +var back = require('./controller/back'); + +router.get('/back', back.get); +router.get('/processPayments', mustBeAdmin, processPayments.get); + diff --git a/handlers/payments/yandexmoney/templates/form.jade b/handlers/payments/yandexmoney/templates/form.jade new file mode 100755 index 000000000..5612a281c --- /dev/null +++ b/handlers/payments/yandexmoney/templates/form.jade @@ -0,0 +1,7 @@ + +form(method="POST",action="https://sp-money.yandex.ru/oauth/authorize") + input(type="hidden",name="client_id",value=clientId) + input(type="hidden",name="response_type",value="code") + input(type="hidden",name="redirect_uri",value=(redirectUri + '?transactionNumber=' + transactionNumber)) + input(type="hidden",name="scope",value=('payment.to-account("' + purse + '").limit(,' + amount + ')')) + input(type="submit",value="Оплатить") diff --git a/handlers/paymentsMethods.js b/handlers/paymentsMethods.js new file mode 100755 index 000000000..3028afc8b --- /dev/null +++ b/handlers/paymentsMethods.js @@ -0,0 +1,8 @@ +const mongoose = require('mongoose'); +const payments = require('payments'); +const config = require('config'); + +exports.init = function(app) { + app.use(payments.populateContextMiddleware); +}; + diff --git a/handlers/play/controllers/play.js b/handlers/play/controllers/play.js new file mode 100755 index 000000000..2a0f87db6 --- /dev/null +++ b/handlers/play/controllers/play.js @@ -0,0 +1,31 @@ +var path = require('path'); +var fs = require('mz/fs'); +var config = require('config'); + +exports.get = function*() { + + var playId = this.params.playId; + + if (playId) { + playId = playId.replace(/\W/g, ''); // must be alphpanumeric + + var playPath = playId.slice(0,2).toLowerCase() + '/' + playId.slice(2,4).toLowerCase() + '/' + playId + '.zip'; + + //console.log(playPath); + + var exists = yield fs.exists(path.join(config.projectRoot, 'play', playPath)); + if (!exists) { + this.throw(404); + } + + this.locals.play = { + url: `/play/${playPath}`, + title: `${playId}.zip` + }; + + this.body = this.render('play'); + } else { + this.body = this.render('index'); + } + +}; diff --git a/handlers/play/index.js b/handlers/play/index.js new file mode 100755 index 000000000..c5b957c9c --- /dev/null +++ b/handlers/play/index.js @@ -0,0 +1,8 @@ + + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/play', __dirname)); +}; + diff --git a/handlers/play/router.js b/handlers/play/router.js new file mode 100755 index 000000000..7dc34f8fe --- /dev/null +++ b/handlers/play/router.js @@ -0,0 +1,7 @@ +var Router = require('koa-router'); + +var play = require('./controllers/play'); + +var router = module.exports = new Router(); + +router.get('/:playId?', play.get); diff --git a/handlers/play/templates/index.jade b/handlers/play/templates/index.jade new file mode 100755 index 000000000..fc1a63228 --- /dev/null +++ b/handlers/play/templates/index.jade @@ -0,0 +1,18 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var title = "Песочница" + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + + +b.notification._message._warning + +e.content + + p Извините, сервис "Песочница" на сайте больше не предоставляется. + + p Все сохранённые пользователями примеры доступны для скачивания по тем же ссылкам, но создавать новые песочницы нельзя. + + p В качестве альтернативы можно использовать plnkr.co, codepen.io или jsfiddle.net. diff --git a/handlers/play/templates/play.jade b/handlers/play/templates/play.jade new file mode 100755 index 000000000..c872fb4af --- /dev/null +++ b/handlers/play/templates/play.jade @@ -0,0 +1,19 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var title = "Песочница" + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + + +b.notification._message._warning + +e.content + + p Извините, сервис "Песочница" на сайте больше не предоставляется. + + p Вы можете скачать содержимое этой песочницы в виде архива + = ' ' + a(href=play.url)= play.title + | . diff --git a/handlers/profile/client/.jshintrc b/handlers/profile/client/.jshintrc new file mode 100755 index 000000000..89a1dcf00 --- /dev/null +++ b/handlers/profile/client/.jshintrc @@ -0,0 +1,18 @@ +{ + "maxerr": 25, + "latedef": "nofunc", + "browser": true, + "node": true, // for browserify require etc + "globals": ["$", "Prism"], + "indent": 2, + "camelcase": true, + "newcap": true, + "undef": true, + "multistr": true, + "esnext": true, + "noyield": true, + "devel": true, + "loopfunc": true, + "-W004": true, + "-W030": true // for yield* ... +} diff --git a/handlers/profile/client/config.js b/handlers/profile/client/config.js new file mode 100644 index 000000000..f7874e10c --- /dev/null +++ b/handlers/profile/client/config.js @@ -0,0 +1,66 @@ +var angular = require('angular'); + +angular.module('profile').config(($locationProvider, $stateProvider, $urlRouterProvider) => { + $locationProvider.html5Mode(true); + + // For any unmatched url, redirect to / + $urlRouterProvider.otherwise("/"); + + $stateProvider + .state('root', { + abstract: true, + resolve: { + me: (Me) => Me.get() + }, + templateUrl: "/profile/templates/partials/root", + controller: 'ProfileRootCtrl' + }); + + var states = { + 'root.aboutme': { + url: "/", + title: 'Публичный профиль', + templateUrl: "/profile/templates/partials/aboutme", + controller: 'ProfileAboutMeCtrl' + }, + 'root.account': { + url: '/account', + title: 'Аккаунт', + templateUrl: "/profile/templates/partials/account", + controller: 'ProfileAccountCtrl' + }, + 'root.quiz': { + url: '/quiz', + title: 'Тесты', + templateUrl: "/profile/templates/partials/quiz", + controller: 'ProfileQuizResultsCtrl', + resolve: { + quizResults: (QuizResults) => QuizResults.query() + } + }, + 'root.orders': { + url: '/orders', + title: 'Заказы', + templateUrl: "/profile/templates/partials/orders", + controller: 'ProfileOrdersCtrl', + resolve: { + orders: (Orders) => Orders.query() + } + }, + 'root.courses': { + url: '/courses', + title: 'Курсы', + templateUrl: "/profile/templates/partials/courseGroups", + controller: 'ProfileCourseGroupsCtrl', + resolve: { + courseGroups: (CourseGroups) => CourseGroups.query() + } + } + }; + + // enable all states, but show in tabs only those which have info + for (var key in states) { + $stateProvider.state(key, states[key]); + } + +}); diff --git a/handlers/profile/client/controller/aboutme.js b/handlers/profile/client/controller/aboutme.js new file mode 100644 index 000000000..7ed5bbd3c --- /dev/null +++ b/handlers/profile/client/controller/aboutme.js @@ -0,0 +1,8 @@ +var angular = require('angular'); +var profile = angular.module('profile'); + +profile.controller('ProfileAboutMeCtrl', ($scope, me) => { + + $scope.me = me; + +}); diff --git a/handlers/profile/client/controller/account.js b/handlers/profile/client/controller/account.js new file mode 100644 index 000000000..75614f1e3 --- /dev/null +++ b/handlers/profile/client/controller/account.js @@ -0,0 +1,52 @@ +var angular = require('angular'); +var notification = require('client/notification'); +var moment = require('momentWithLocale'); +var profile = angular.module('profile'); + +profile.controller('ProfileAccountCtrl', ($scope, $http, me, Me) => { + + $scope.me = me; + + $scope.remove = function() { + var isSure = confirm(`${me.displayName} (${me.email}) - удалить пользователя без возможности восстановления?`); + + if (!isSure) return; + + $http({ + method: 'DELETE', + url: '/users/me', + tracker: $scope.loadingTracker, + headers: {'Content-Type': undefined}, + transformRequest: angular.identity, + data: new FormData() + }).then((response) => { + + new notification.Success('Пользователь удалён.'); + setTimeout(function() { + window.location.href = '/'; + }, 1500); + + }, (response) => { + new notification.Error("Ошибка загрузки, статус " + response.status); + }); + }; + + $scope.removeProvider = function(providerName) { + var isSure = confirm(`${providerName} - удалить привязку?`); + + if (!isSure) return; + + $http({ + method: 'POST', + url: '/auth/disconnect/' + providerName, + tracker: this.loadingTracker + }).then((response) => { + // refresh user + $scope.me = Me.get(); + }, (response) => { + new notification.Error("Ошибка загрузки, статус " + response.status); + }); + + }; + +}); diff --git a/handlers/profile/client/controller/courseGroups.js b/handlers/profile/client/controller/courseGroups.js new file mode 100644 index 000000000..29ea6ed95 --- /dev/null +++ b/handlers/profile/client/controller/courseGroups.js @@ -0,0 +1,8 @@ +var angular = require('angular'); +var notification = require('client/notification'); +var moment = require('momentWithLocale'); +var profile = angular.module('profile'); + +profile.controller('ProfileCourseGroupsCtrl', ($scope, $http, $window, courseGroups) => { + $scope.courseGroups = courseGroups; +}); diff --git a/handlers/profile/client/controller/orders.js b/handlers/profile/client/controller/orders.js new file mode 100644 index 000000000..03414046a --- /dev/null +++ b/handlers/profile/client/controller/orders.js @@ -0,0 +1,42 @@ +var angular = require('angular'); +var notification = require('client/notification'); +var moment = require('momentWithLocale'); +var profile = angular.module('profile'); + +profile.controller('ProfileOrdersCtrl', ($scope, $http, $window, orders) => { + $scope.orders = orders; + + $scope.changePayment = function(order) { + $window.location.href = `/courses/orders/${order.number}?changePayment=1`; + }; + + $scope.cancelOrder = function(order) { + + var isOk = confirm("Заказ будет отменён, без возможности восстановления. Продолжать?"); + + if (!isOk) return; + + var formData = new FormData(); + formData.append("orderNumber", order.number); + + $http({ + method: 'DELETE', + url: '/payments/common/order', + headers: {'Content-Type': undefined}, + transformRequest: angular.identity, + data: formData + }).then((response) => { + + orders.splice(orders.indexOf(order), 1); + new notification.Success("Заказ удалён."); + + }, (response) => { + if (response.status == 400) { + new notification.Error(response.data.message); + } else { + new notification.Error("Ошибка загрузки, статус " + response.status); + } + }); + + }; +}); diff --git a/handlers/profile/client/controller/quizResults.js b/handlers/profile/client/controller/quizResults.js new file mode 100644 index 000000000..2a65a3189 --- /dev/null +++ b/handlers/profile/client/controller/quizResults.js @@ -0,0 +1,6 @@ +var angular = require('angular'); +var profile = angular.module('profile'); + +profile.controller('ProfileQuizResultsCtrl', ($scope, quizResults) => { + $scope.quizResults = quizResults; +}); diff --git a/handlers/profile/client/controller/root.js b/handlers/profile/client/controller/root.js new file mode 100644 index 000000000..2c0a61409 --- /dev/null +++ b/handlers/profile/client/controller/root.js @@ -0,0 +1,25 @@ +var angular = require('angular'); +var profile = angular.module('profile'); + +profile.controller('ProfileRootCtrl', ($scope, $state, $timeout, $http, me, promiseTracker) => { + + //window.me = me; + $scope.me = me; + + $scope.loadingTracker = promiseTracker(); + + var tabs = ['root.aboutme', 'root.account']; + window.currentUser.profileTabsEnabled.forEach(function(tab) { + tabs.push('root.' + tab); + }); + + $scope.tabs = tabs.map((stateName) => { + var state = $state.get(stateName); + return { + title: state.title, + name: state.name, + url: state.url + }; + }); + +}); diff --git a/handlers/profile/client/directive/dateInput.js b/handlers/profile/client/directive/dateInput.js new file mode 100644 index 000000000..348eee2e4 --- /dev/null +++ b/handlers/profile/client/directive/dateInput.js @@ -0,0 +1,44 @@ +var angular = require('angular'); +var moment = require('momentWithLocale'); + +angular.module('profile') + .directive('dateInput', function() { + return { + require: 'ngModel', + link: function(scope, element, attrs, ngModel) { + + + if (0) ngModel.$validators.date = function(modelValue, viewValue) { + // modelValue is + if (!viewValue) return true; + + var value = modelValue || viewValue; + if (!value) return true; + var split = value.split('.'); + if (split.length != 3) return false; + var date = new Date(split[2], split[1] - 1, split[0]); + + if (split[2].length != 4) return false; + + return date.getFullYear() == split[2] && date.getMonth() == split[1] - 1 && date.getDate() == split[0]; + }; + + //Set the initial value to the View and the Model + ngModel.$formatters.unshift(function(modelValue) { + if (!modelValue) return ""; + return moment(modelValue).format("DD.MM.YYYY"); + }); + + ngModel.$parsers.unshift(function(inputValue) { + if (!inputValue) return; + var momentDate = moment(inputValue, "DD.MM.YYYY"); + + ngModel.$setValidity('date', momentDate.isValid()); + + return momentDate.toDate(); + }); + + } + }; + }); + diff --git a/handlers/profile/client/directive/dateRangeValidator.js b/handlers/profile/client/directive/dateRangeValidator.js new file mode 100644 index 000000000..ad93666df --- /dev/null +++ b/handlers/profile/client/directive/dateRangeValidator.js @@ -0,0 +1,33 @@ +var notification = require('client/notification'); +var angular = require('angular'); +var moment = require('momentWithLocale'); + +angular.module('profile') + .directive('dateRangeValidator', function() { + return { + require: 'ngModel', + link: function(scope, element, attrs, ngModel) { + + var range = attrs.dateRangeValidator.split('-'); + var from = range[0] ? moment(range[0], "DD.MM.YYYY").toDate() : new Date(); + var to = range[1] ? moment(range[1], "DD.MM.YYYY").toDate() : new Date(); + + ngModel.$validators.dateRange = function(modelValue, viewValue) { + var value = modelValue || viewValue; + if (!value) return true; + + var split = value.split('.'); + if (split.length != 3) return false; + var date = new Date(split[2], split[1]-1, split[0]); + + if (split[2].length != 4) return false; + + return date >= from && date <= to; + }; + } + }; + + }); + + + diff --git a/handlers/profile/client/directive/dateValidator.js b/handlers/profile/client/directive/dateValidator.js new file mode 100644 index 000000000..c91829226 --- /dev/null +++ b/handlers/profile/client/directive/dateValidator.js @@ -0,0 +1,26 @@ +var angular = require('angular'); + +angular.module('profile') + .directive('dateValidator', function() { + return { + require: 'ngModel', + link: function(scope, element, attrs, ngModel) { + + ngModel.$validators.date = function(modelValue, viewValue) { + var value = modelValue || viewValue; + if (!value) return true; + var split = value.split('.'); + if (split.length != 3) return false; + var date = new Date(split[2], split[1]-1, split[0]); + + if (split[2].length != 4) return false; + + return date.getFullYear() == split[2] && date.getMonth() == split[1]-1 && date.getDate() == split[0]; + }; + } + }; + + }); + + + diff --git a/handlers/profile/client/directive/orderContact.js b/handlers/profile/client/directive/orderContact.js new file mode 100644 index 000000000..22938851a --- /dev/null +++ b/handlers/profile/client/directive/orderContact.js @@ -0,0 +1,59 @@ +var notification = require('client/notification'); +var angular = require('angular'); + +angular.module('profile') + .directive('orderContact', function(promiseTracker, $http, $timeout) { + return { + templateUrl: '/profile/templates/partials/orderContact', + scope: { + order: '=' + }, + replace: true, + link: function(scope, element, attrs, noCtrl, transclude) { + + scope.contactName = scope.order.contactName; + scope.contactPhone = scope.order.contactPhone; + + scope.loadingTracker = promiseTracker(); + + scope.submit = function() { + if (this.contactForm.$invalid) return; + + var formData = new FormData(); + + formData.append("orderNumber", scope.order.number); + formData.append("contactName", scope.contactName); + formData.append("contactPhone", scope.contactPhone); + + $http({ + method: 'PATCH', + url: '/payments/common/order', + tracker: this.loadingTracker, + headers: {'Content-Type': undefined}, + transformRequest: angular.identity, + data: formData + }).then((response) => { + + new notification.Success("Информация обновлена."); + scope.order.contactName = scope.contactName; + scope.order.contactPhone = scope.contactPhone; + + }, (response) => { + if (response.status == 400) { + new notification.Error(response.data.message); + } else { + new notification.Error("Ошибка загрузки, статус " + response.status); + } + }); + + }; + + + + } + }; + + }); + + + diff --git a/handlers/profile/client/directive/orderParticipants.js b/handlers/profile/client/directive/orderParticipants.js new file mode 100644 index 000000000..92b246d0e --- /dev/null +++ b/handlers/profile/client/directive/orderParticipants.js @@ -0,0 +1,105 @@ +var notification = require('client/notification'); +var angular = require('angular'); + +angular.module('profile') + .directive('orderParticipants', function(promiseTracker, $http, $timeout) { + return { + templateUrl: '/profile/templates/partials/orderParticipants', + scope: { + order: '=' + }, + replace: true, + link: function(scope, element, attrs, noCtrl, transclude) { + + scope.participants = angular.copy(scope.order.participants); + + // add empty fields up to order.count + while (scope.participants.length != scope.order.count) { + scope.participants.push({ + inGroup: false, + email: "" + }); + } + + scope.loadingTracker = promiseTracker(); + + // returns true iff any of participants was removed + function editingParticipantsRemoved(order) { + var removedEmails = []; + for (var i = 0; i < order.participants.length; i++) { + var oldParticipant = order.participants[i]; + var wasRemoved = !scope.participants.some(function(newParticipant) { + return newParticipant.email == oldParticipant.email; + }); + if (wasRemoved) removedEmails.push(oldParticipant.email); + } + + return removedEmails; + } + + // on enter - next participant + scope.onEmailKeyDown = function($event) { + if ($event.keyCode != 13) return; + + var nextName = $event.target.name.split('_'); + nextName.push( +nextName.pop() + 1 ); + nextName = nextName.join('_'); + + var nextInput = document.getElementById(nextName); + if (nextInput) { + nextInput.focus(); + } + }; + + + scope.submit = function() { + if (this.participantsForm.$invalid) return; + + // if the order is paid, warn that removing participants is bad + if (scope.order.status == 'success') { + var removedEmails = editingParticipantsRemoved(scope.order); + var isOk = confirm("Вы удалили участников, которые получили приглашения на курс: " + removedEmails + ".\nПри продолжении их приглашения станут недействительными.\nПродолжить?"); + + if (!isOk) return; + } + + var formData = new FormData(); + + formData.append("orderNumber", scope.order.number); + var emails = scope.participants.map(function(participant) { + if (participant.inGroup) return; // cannot modify accepted, server will give error if I try + return participant.email; + }).filter(Boolean); + + formData.append("emails", emails); + + $http({ + method: 'PATCH', + url: '/payments/common/order', + tracker: this.loadingTracker, + headers: {'Content-Type': undefined}, + transformRequest: angular.identity, + data: formData + }).then((response) => { + + new notification.Success(response.data); + scope.order.participants = angular.copy(scope.participants); + + }, (response) => { + if (response.status == 400) { + new notification.Error(response.data.message); + } else { + new notification.Error("Ошибка загрузки, статус " + response.status); + } + }); + + }; + + + } + }; + + }); + + + diff --git a/handlers/profile/client/directive/profileAuthProviders.js b/handlers/profile/client/directive/profileAuthProviders.js new file mode 100644 index 000000000..49396095d --- /dev/null +++ b/handlers/profile/client/directive/profileAuthProviders.js @@ -0,0 +1,36 @@ +var notification = require('client/notification'); +var angular = require('angular'); +require('../service/authPopup'); + +angular.module('profile') + .directive('profileAuthProviders', function(promiseTracker, $http, authPopup, Me) { + return { + templateUrl: '/profile/templates/partials/profileAuthProviders', + replace: true, + + link: function(scope) { + + scope.connect = function(providerName) { + authPopup('/auth/connect/' + providerName, () => { + // refresh user + scope.me = Me.get(); + + }, () => { + console.error("fail", arguments); + }); + }; + + scope.connected = function(providerName) { + var connected = false; + + if (!scope.me.providers) return false; + scope.me.providers.forEach(function(provider) { + if (provider.name == providerName) connected = true; + }); + + return connected; + }; + } + }; + + }); diff --git a/handlers/profile/client/directive/profileField.js b/handlers/profile/client/directive/profileField.js new file mode 100644 index 000000000..103e1b52f --- /dev/null +++ b/handlers/profile/client/directive/profileField.js @@ -0,0 +1,108 @@ +var notification = require('client/notification'); +var angular = require('angular'); + + +angular.module('profile') + .directive('profileField', function(promiseTracker, $http, $timeout) { + return { + templateUrl: '/profile/templates/partials/profileField', + scope: { + title: '@fieldTitle', + name: '@fieldName', + formatValue: '=?fieldFormatValue', + value: '=fieldValue' + }, + replace: true, + transclude: true, + link: function(scope, element, attrs, noCtrl, transclude) { + + if (!scope.formatValue) { + scope.formatValue = function(value) { + return value; + }; + } + + + scope.loadingTracker = promiseTracker(); + + scope.edit = function() { + if (this.editing) return; + this.editing = true; + this.editingValue = this.value; + }; + + scope.submit = function() { + if (this.form.$invalid) return; + + if (this.value == this.editingValue) { + this.editing = false; + this.editingValue = ''; + return; + } + + var formData = new FormData(); + formData.append(this.name, this.editingValue); + + $http({ + method: 'PATCH', + url: '/users/me', + tracker: this.loadingTracker, + headers: {'Content-Type': undefined}, + transformRequest: angular.identity, + data: formData + }).then((response) => { + + if (this.name == 'displayName') { + new notification.Success("Изменение имени везде произойдёт после перезагрузки страницы.", 'slow'); + } else if (this.name == 'email') { + new notification.Warning("Требуется подтвердить смену email, проверьте почту.", 'slow'); + } else if (this.name == 'profileName') { + new notification.Success("Ваш профиль доступен по новому адресу, страница будет перезагружена"); + var newProfileName = this.editingValue; // remember now, (editing field will be reset) + setTimeout(function() { + window.location.href = '/profile/' + newProfileName + '/account'; + }, 2000); + } else { + new notification.Success("Информация обновлена."); + } + + this.editing = false; + this.value = this.editingValue; + this.editingValue = ''; + + }, (response) => { + //console.log(response); + if (response.status == 400) { + + new notification.Error(response.data.message); + } else if (response.status == 409) { + new notification.Error(response.data.message); + } else { + new notification.Error("Ошибка загрузки, статус " + response.status); + } + }); + + }; + + + scope.cancel = function() { + if (!this.editing) return; + // if we turn editing off now, then click event may bubble up, reach the form and enable editing back + // so we wait until the event bubbles and ends, and *then* cancel + $timeout(() => { + this.editing = false; + this.editingValue = ""; + }); + }; + + transclude(scope, function(clone, scope) { + element[0].querySelector('[control-transclude]').append(clone[0]); + }); + + } + }; + + }); + + + diff --git a/handlers/profile/client/directive/profilePassword.js b/handlers/profile/client/directive/profilePassword.js new file mode 100644 index 000000000..d1d6fb174 --- /dev/null +++ b/handlers/profile/client/directive/profilePassword.js @@ -0,0 +1,81 @@ +var notification = require('client/notification'); +var angular = require('angular'); + + +angular.module('profile') + .directive('profilePassword', function(promiseTracker, $http, $timeout) { + return { + templateUrl: '/profile/templates/partials/profilePassword', + scope: { + hasPassword: '=' + }, + replace: true, + link: function(scope, element, attrs, noCtrl, transclude) { + + scope.password = ''; + scope.passwordOld = ''; + + scope.loadingTracker = promiseTracker(); + + scope.edit = function() { + if (this.editing) return; + this.editing = true; + + $timeout(function() { + var input = element[0].elements[scope.hasPassword ? 'passwordOld' : 'password']; + input.focus(); + }); + }; + + scope.submit = function() { + if (scope.form.$invalid) return; + + var formData = new FormData(); + formData.append("password", this.password); + formData.append("passwordOld", this.passwordOld); + + $http({ + method: 'PATCH', + url: '/users/me', + tracker: this.loadingTracker, + headers: {'Content-Type': undefined}, + transformRequest: angular.identity, + data: formData + }).then((response) => { + new notification.Success("Пароль обновлён."); + scope.editing = false; + // now have password for sure + scope.hasPassword = true; + + // and clean password fields + scope.password = ''; + scope.passwordOld = ''; + scope.form.$setPristine(); + + }, (response) => { + if (response.status == 400) { + new notification.Error(response.data.message || response.data.errors.password); + } else { + new notification.Error("Ошибка загрузки, статус " + response.status); + } + }); + + }; + + + scope.cancel = function() { + if (!this.editing) return; + // if we turn editing off now, then click event may bubble up, reach the form and enable editing back + // so we wait until the event bubbles and ends, and *then* cancel + $timeout(() => { + this.editing = false; + }); + }; + + } + }; + + }); + + + diff --git a/handlers/profile/client/directive/profilePhoto.js b/handlers/profile/client/directive/profilePhoto.js new file mode 100644 index 000000000..e380890da --- /dev/null +++ b/handlers/profile/client/directive/profilePhoto.js @@ -0,0 +1,90 @@ +var notification = require('client/notification'); +var angular = require('angular'); +var thumb = require('client/image').thumb; +var cutPhoto = require('photoCut').cutPhoto; + +angular.module('profile') + .directive('profilePhoto', function(promiseTracker, $http) { + return { + templateUrl: '/profile/templates/partials/profilePhoto', + scope: { + photo: '=' + }, + replace: true, + + link: function(scope, element, attrs, noCtrl) { + scope.loadingTracker = promiseTracker(); + var self = this; + + scope.changePhoto = function() { + var fileInput = document.createElement('input'); + fileInput.type = 'file'; + fileInput.accept = "image/*"; + + fileInput.onchange = function() { + fileInput.remove(); + var reader = new FileReader(); + var file = fileInput.files[0]; + + reader.onload = function(event) { + var image = new Image(); + image.onload = function() { + + if (image.height < 160 || image.width < 160) { + new notification.Error("Изображение должно иметь размер 160x160 или больше"); + } else if (image.width == image.height) { + uploadPhoto(file); + } else { + cutPhoto(image, function(blob) { + // @see http://stackoverflow.com/questions/13198131/how-to-save-a-html5-canvas-as-image-on-a-server + // @see http://stackoverflow.com/questions/12391628/how-can-i-upload-an-embedded-image-with-javascript + uploadPhoto(blob); + }); + } + }; + image.src = event.target.result; + }; + reader.readAsDataURL(file); + + }; + + // must be in body for IE + fileInput.hidden = true; + document.body.appendChild(fileInput); + fileInput.click(); + + }; + + function uploadPhoto(file) { + + var formData = new FormData(); + formData.append("photo", file); + + $http({ + method: 'PATCH', + url: '/users/me', + headers: {'Content-Type': undefined }, + tracker: scope.loadingTracker, + transformRequest: angular.identity, + data: formData + }).then(function(response) { + scope.photo = response.data.photo; + new notification.Success("Изображение обновлено."); + }, function(response) { + if (response.status == 400) { + new notification.Error("Неверный тип файла или изображение повреждено."); + } else { + new notification.Error("Ошибка загрузки, статус " + response.status); + } + }); + + + } + } + }; + + }) + .filter('thumb', () => thumb); + + + diff --git a/handlers/profile/client/factory/courseGroups.js b/handlers/profile/client/factory/courseGroups.js new file mode 100644 index 000000000..49cf08737 --- /dev/null +++ b/handlers/profile/client/factory/courseGroups.js @@ -0,0 +1,19 @@ +var angular = require('angular'); + +angular.module('profile').factory('CourseGroups', ($resource) => { + return $resource('/courses/profile/' + window.currentUser.id, {}, { + query: { + method: 'GET', + isArray: true, + transformResponse: function(data, headers) { + data = JSON.parse(data); + data.forEach(function(group) { + group.dateStart = new Date(group.dateStart); + group.dateEnd = new Date(group.dateEnd); + }); + + return data; + } + } + }); +}); diff --git a/handlers/profile/client/factory/me.js b/handlers/profile/client/factory/me.js new file mode 100644 index 000000000..2b38985e6 --- /dev/null +++ b/handlers/profile/client/factory/me.js @@ -0,0 +1,15 @@ +var angular = require('angular'); +var profile = angular.module('profile'); + +angular.module('profile').factory('Me', ($resource) => { + return $resource('/users/me', {}, { + get: { + method: 'GET', + transformResponse: function(data, headers) { + data = JSON.parse(data); + data.created = new Date(data.created); + return data; + } + } + }); +}); diff --git a/handlers/profile/client/factory/orders.js b/handlers/profile/client/factory/orders.js new file mode 100644 index 000000000..283ce231a --- /dev/null +++ b/handlers/profile/client/factory/orders.js @@ -0,0 +1,27 @@ +var angular = require('angular'); + +angular.module('profile').factory('Orders', ($resource) => { + return $resource('/payments/common/orders/user/' + window.currentUser.id, {}, { + query: { + method: 'GET', + isArray: true, + transformResponse: function(data, headers) { + data = JSON.parse(data); + data.forEach(function(order) { + order.created = new Date(order.created); + + order.countDetails = { + free: order.count - order.participants.length, + busy: order.participants.length, + inGroup: order.participants.filter(function(participant) { + return participant.inGroup; + }).length + }; + + }); + + return data; + } + } + }); +}); diff --git a/handlers/profile/client/factory/quizResults.js b/handlers/profile/client/factory/quizResults.js new file mode 100644 index 000000000..5c00a7c59 --- /dev/null +++ b/handlers/profile/client/factory/quizResults.js @@ -0,0 +1,18 @@ +var angular = require('angular'); + +angular.module('profile').factory('QuizResults', ($resource) => { + return $resource('/quiz/results/user/' + window.currentUser.id, {}, { + query: { + method: 'GET', + isArray: true, + transformResponse: function(data, headers) { + + data = JSON.parse(data); + data.forEach(function(result) { + result.created = new Date(result.created); + }); + return data; + } + } + }); +}); diff --git a/handlers/profile/client/index.js b/handlers/profile/client/index.js new file mode 100755 index 000000000..171b95176 --- /dev/null +++ b/handlers/profile/client/index.js @@ -0,0 +1,67 @@ +var angular = require('angular'); +var notification = require('client/notification'); +var moment = require('momentWithLocale'); +var pluralize = require('textUtil/pluralize'); + +var profile = angular.module('profile', [ + 'ui.router', 'ngResource', 'global403Interceptor', 'ajoslin.promise-tracker', 'progress', 'focusOn', 'ngMessages' +]); + +require('./directive/profileField'); +require('./directive/orderParticipants'); +require('./directive/orderContact'); +require('./directive/profilePhoto'); +require('./directive/profilePassword'); +require('./directive/profileAuthProviders'); +require('./directive/dateValidator'); +require('./directive/dateRangeValidator'); + +require('./factory/me'); + +require('./factory/quizResults'); + +require('./factory/orders'); +require('./factory/courseGroups'); + +require('./config'); + +require('./controller/root'); + +require('./controller/orders'); + +require('./controller/courseGroups'); + +require('./controller/aboutme'); + +require('./controller/quizResults'); + +require('./controller/account'); + + +profile + .filter('capitalize', () => function(str) { + return str[0].toUpperCase() + str.slice(1); + }) + .filter('longDate', () => function(date) { + return moment(date).format('D MMMM YYYY в LT'); + }) + .filter('shortDate', () => function(date) { + return moment(date).format('D MMM YYYY'); + }) + .filter('quizDuration', () => function(ms) { + var seconds = Math.round(ms / 1000); + return moment.duration(seconds, 'seconds').humanize(); + }) + .filter('pluralize', function() { + return pluralize; + }) + .filter('trust_html', function($sce){ + return function(text) { + text = $sce.trustAsHtml(text); + return text; + }; + }); + + + + diff --git a/handlers/profile/client/service/authPopup.js b/handlers/profile/client/service/authPopup.js new file mode 100644 index 000000000..87c4d5fc7 --- /dev/null +++ b/handlers/profile/client/service/authPopup.js @@ -0,0 +1,28 @@ +var angular = require('angular'); + +angular.module('profile') + .service('authPopup', function() { + + var authPopup; + + return function(url, onSuccess, onFail) { + + if (authPopup && !authPopup.closed) { + authPopup.close(); // close old popup if any + } + var width = 800, height = 600; + var top = (window.outerHeight - height) / 2; + var left = (window.outerWidth - width) / 2; + + window.authModal = { + onAuthSuccess: onSuccess, + onAuthFailure: onFail + }; + + authPopup = window.open(url, 'authModal', 'width=' + width + ',height=' + height + ',scrollbars=0,top=' + top + ',left=' + left); + }; + +}); + + + diff --git a/handlers/profile/controller/index.js b/handlers/profile/controller/index.js new file mode 100755 index 000000000..fa4452541 --- /dev/null +++ b/handlers/profile/controller/index.js @@ -0,0 +1,39 @@ +var config = require('config'); +var User = require('users').User; +var mongoose = require('mongoose'); +var QuizResult = require('quiz').QuizResult; +var Order = require('payments').Order; + +// skips the request unless it's the owner +exports.get = function* (next) { + + if (!this.user) { + yield* next; + return; + } + + // /profile -> /profile/iliakan + if (!this.params.profileName) { + this.status = 301; + this.redirect(`/profile/${this.user.profileName}`); + return; + } + + var user = yield User.findOne({profileName: this.params.profileName}).exec(); + + if (!user) { + this.throw(404); + } + + // if the visitor is the profile owner + if (String(this.user._id) == String(user._id)) { + + this.locals.title = this.user.displayName; + + this.body = this.render('index'); + } else { + yield* next; + } + +}; + diff --git a/handlers/profile/controller/partials.js b/handlers/profile/controller/partials.js new file mode 100755 index 000000000..055589900 --- /dev/null +++ b/handlers/profile/controller/partials.js @@ -0,0 +1,10 @@ +var config = require('config'); +var path = require('path'); + +exports.get = function* (next) { + // aboutme -> return rendererd partials/aboutme.jade + + var partialJade = path.join('partials', path.basename(this.params.partial.replace(/\./g, ''))); + this.body = this.render(partialJade); +}; + diff --git a/handlers/profile/index.js b/handlers/profile/index.js new file mode 100755 index 000000000..d3f3f9b61 --- /dev/null +++ b/handlers/profile/index.js @@ -0,0 +1,6 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/profile', __dirname)); +}; diff --git a/handlers/profile/router.js b/handlers/profile/router.js new file mode 100755 index 000000000..ab09d058f --- /dev/null +++ b/handlers/profile/router.js @@ -0,0 +1,13 @@ +var Router = require('koa-router'); + +var partials = require('./controller/partials'); +var index = require('./controller/index'); + +var router = module.exports = new Router(); + +router.get('/', index.get); + +router.get('/:profileName/:tab?', index.get); + +router.get('/templates/partials/:partial', partials.get); + diff --git a/handlers/profile/templates/blocks/profile-ok-cancel.jade b/handlers/profile/templates/blocks/profile-ok-cancel.jade new file mode 100755 index 000000000..09b6efc4f --- /dev/null +++ b/handlers/profile/templates/blocks/profile-ok-cancel.jade @@ -0,0 +1,4 @@ ++e.ok-cancel + +b('button')(type="submit").button._action.__item-save + +e('span').text Сохранить + +e('button').item-cancel Отмена diff --git a/handlers/profile/templates/index.jade b/handlers/profile/templates/index.jade new file mode 100755 index 000000000..c6f88a8b6 --- /dev/null +++ b/handlers/profile/templates/index.jade @@ -0,0 +1,20 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit-wide" + - var sitetoolbar = true + +block append head + base(href=user.getProfileUrl() + '/') + link(href=pack('quiz', 'css') rel='stylesheet') + +block content + + script(src=pack("angular", "js")) + script(src=pack("profile", "js")) + + +b(ng-app="profile" ng-strict-di) + + +e('ui-view').view(progress="loadingTracker.active()" progress-overlay progress-spinner="{class:'profile__spinner'}") Загрузка... + diff --git a/handlers/profile/templates/partials/aboutme.jade b/handlers/profile/templates/partials/aboutme.jade new file mode 100755 index 000000000..6bc4eaebf --- /dev/null +++ b/handlers/profile/templates/partials/aboutme.jade @@ -0,0 +1,33 @@ +include /bem + ++b.profile + + +e.title + +e.title-content + +e('p').note Информация о вас, которая будет видна другим посетителям. + + +e.fields._about + +e('profile-field')(field-name="realName" field-title="Имя" field-value="me.realName") + input.text-input__control(focus-on="editing" placeholder="Иван Иванович" ng-model="editingValue" type="text" name="input") + + +e('profile-field')(field-name="publicEmail" field-title="Публичный email" field-value="me.publicEmail") + input.text-input__control(focus-on="editing" ng-model="editingValue" type="email" name='input') + + +e('profile-field')(field-name="country" field-title="Страна" field-value="me.country") + input.text-input__control(focus-on="editing" ng-model="editingValue" type="text" name='input') + + // +e('profile-field')(field-name="country" field-title="Страна" field-value="me.country") + select(ng-model="editingValue" name="input") + - var countries = {Russia: 'Россия', Belorussia: 'Белоруссия'} + for title, key in countries + option(value=key)= title + + + +e('profile-field')(field-name="town" field-title="Город" field-value="me.town") + input.text-input__control(focus-on="editing" ng-model="editingValue" type="text" name='input') + + +e('profile-field')(field-name="birthday" field-title="Дата рождения" field-value="me.birthday") + input.text-input__control(focus-on="editing" date-validator date-range-validator="01.01.1900-" placeholder="30.12.2000" ng-model="editingValue" type="text" name="input") + + +e('profile-field')(field-name="interests" field-title="Интересы" field-value="me.interests") + input.text-input__control(focus-on="editing" ng-model="editingValue" type="text") diff --git a/handlers/profile/templates/partials/account.jade b/handlers/profile/templates/partials/account.jade new file mode 100755 index 000000000..30d010b78 --- /dev/null +++ b/handlers/profile/templates/partials/account.jade @@ -0,0 +1,56 @@ +include /bem + ++b.profile + + +e.title + +e.title-content + +e('h2').inline-title Управление аккаунтом + + +e.fields._account + +e('profile-field')(field-name="displayName" field-title="Имя пользователя" field-value="me.displayName") + input.text-input__control(type="text" name="input" + focus-on="editing" + required + ng-minlength="2" + ng-model="editingValue" + ng-model-options="{ updateOn: 'default blur', debounce: {'default': 200, 'blur': 0} }" + ) + + +e('profile-field')(field-name="profileName" field-title="Имя страницы профиля" field-value="me.profileName") + input.text-input__control(type="text" name="input" + focus-on="editing" + ng-pattern="/^[a-z0-9-]*$/" + ng-maxlength="64" + ng-minlength="2" + ng-model="editingValue" + ng-model-options="{ updateOn: 'default blur', debounce: {'default': 0, 'blur': 0} }" + ) + + +e('profile-field')(field-name="email" field-title="Email" field-value="me.email") + input.text-input__control(type="email" name="input" + focus-on="editing" + required + ng-model="editingValue" + ng-model-options="{ updateOn: 'default blur', debounce: {'default': 200, 'blur': 0} }" + ) + + + +e('profile-password')(has-password="me.hasPassword") + + +e.title + +e.title-content + +e('h2').inline-title Привязанные внешние аккаунты + +e('p').note(ng-if="!me.providers.length") При привязке аккаунта можно будет заходить на сайт одним нажатием кнопки. + + +e.linked-account(ng-repeat="provider in me.providers") + +e.account-content + +e.linked-name + +e('img').linked-upic(ng-src="{{provider.photo}}") {{provider.displayName}} + +e.linked-provider {{provider.name | capitalize}} + +e('button').linked-provider-remove(ng-click="removeProvider(provider.name)", title="Удалить привязку") + + +e('profile-auth-providers') + + +e.action-item + +e.action-content + +e('button')(type="button" ng-click="remove()").action._remove-account Удалить аккаунт diff --git a/handlers/profile/templates/partials/courseGroups.jade b/handlers/profile/templates/partials/courseGroups.jade new file mode 100644 index 000000000..e08213cf8 --- /dev/null +++ b/handlers/profile/templates/partials/courseGroups.jade @@ -0,0 +1,25 @@ +include /bem + ++b.profile + + +b.courses-table + +e('table').table + + +e('tr').line(ng-repeat="group in courseGroups") + +e('th').main + +e('h3').title {{group.title}} + +e('ul').info-links + +e('li').info-links-item(ng-repeat="link in group.links") + +e('a').info-link(ng-href="{{link.url}}") {{link.title}} + +e('td').info + +e('strong').start Начало {{group.dateStart | shortDate}} + +e.schedule Занятия {{ group.timeDesc }} + +e('td').verify + +e('span').status._verified(ng-if="group.status == 'accepted'") Участие подтверждено + +b('a').button._action(ng-href="{{group.inviteUrl}}" ng-if="group.status == 'invite'") + +e('span').text Подтвердить участие + +e('span').status._started(ng-if="group.status == 'started'") Занятия начались + +e('span')(ng-if="group.status == 'ended'") + +e('span').status._ended Курсы завершены + br + +e('a').feedback(href="/123") Оставить отзыв !TODO! diff --git a/handlers/profile/templates/partials/orderContact.jade b/handlers/profile/templates/partials/orderContact.jade new file mode 100644 index 000000000..8e879c826 --- /dev/null +++ b/handlers/profile/templates/partials/orderContact.jade @@ -0,0 +1,42 @@ +include /bem + ++b.invoice-table + + +e('h4').settings-title Контактная информация + + +e('form').contact-form( + name="contactForm" + novalidate + progress="loadingTracker.active()" + progress-overlay + ng-submit="submit()" + ) + + +e.settings-line + +e("label").contact-form-label(for="contact-name{{order.number}}") Имя и фамилия: + +b("span").text-input + +e("input").control( + type="text", + ng-required, + name="contact-name", + id="contact-name{{order.number}}" + ng-model="contactName" + placeholder="Пушкин Александр Сергеевич" + ) + + +e.settings-line + +e('label').contact-form-label(for="contact-phone{{order.number}}") Телефон: + +b.full-phone + +e.tel-wrap + +b.text-input._small.__tel + +e('input').control( + placeholder="+X XXX XXX-XX-XX", + type="tel", + id="contact-phone{{order.number}}" + ng-model="contactPhone" + ) + + +e.settings-line._submit + +b('button').button._common(type="submit" progress="loadingTracker.active()" progress-spinner="{elemClass:'button_loading',size:'small'}") + +e('span').text Сохранить контакты + diff --git a/handlers/profile/templates/partials/orderParticipants.jade b/handlers/profile/templates/partials/orderParticipants.jade new file mode 100644 index 000000000..6bcc963a7 --- /dev/null +++ b/handlers/profile/templates/partials/orderParticipants.jade @@ -0,0 +1,49 @@ +include /bem + ++b.invoice-table + +e('form').participants-form( + name="participantsForm" + novalidate + progress="loadingTracker.active()" + progress-overlay + ng-submit="submit()" + ) + +e('h4').settings-title Участники + + +e('ul').settings-participants + +e('li').settings-participant(ng-repeat="participant in participants") + +e('label').participant-label(for='participant_{{order.number}}_{{$index}}') Участник {{$index + 1}}: + +b('span').text-input.__input( + ng-form="participantForm" + ng-class="[participant.inGroup ? 'text-input_approved_yes' : 'text-input_approved_no', participantForm.$invalid && 'text-input_invalid']" + ) + +e('input').control( + placeholder="email", + name='participant_{{order.number}}_{{$index}}', + ng-type="email", + ng-pattern="/^[-.\\w]+@([\\w-]+\\.)+[\\w-]{2,12}$/", + ng-model="participant.email", + ng-keydown="onEmailKeyDown($event)" + ng-model-options="{ updateOn: 'default blur', debounce: {'default': 200, 'blur': 0} }" + id='participant_{{order.number}}_{{$index}}', + ng-disabled="participant.inGroup" + ) + +e('span').status( + ng-if="participant.inGroup" + data-tooltip="Участие подтверждено." + ) + +e('span').status( + ng-if="!participant.inGroup && order.status != 'success'" + data-tooltip="Подтверждение участия станет возможным после оплаты." + ) + +e('span').status( + ng-if="!participant.inGroup && order.status == 'success'" + data-tooltip="Участнику требуется подтвердить участие." + ) + +e.err(ng-if="participantForm.$invalid") Некорректный email. + + + +e.settings-line_submit(ng-if="order.count > order.countDetails.inGroup") + +b('button').button._common(type="submit" progress="loadingTracker.active()" progress-spinner="{elemClass:'button_loading',size:'small'}") + +e('span').text Сохранить участников + diff --git a/handlers/profile/templates/partials/orders.jade b/handlers/profile/templates/partials/orders.jade new file mode 100644 index 000000000..678450904 --- /dev/null +++ b/handlers/profile/templates/partials/orders.jade @@ -0,0 +1,72 @@ +include /bem + + ++b.invoice-table + + +e('p').empty-message(ng-if="!orders.length") Нет активных заказов. + + +e('table').table + + +e('tr').data(ng-repeat-start="order in orders" ng-class="{'invoice-table__data_show_settings': order.isEditing}") + +e('th').main + +e('span').number Заказ № {{ order.number }} + +e('time').time(datetime="{{order.created}}") {{order.created | longDate}} + +e('h3').title {{ order.title }} + + +e.slots + +e('strong').slots-total {{ order.count }} {{ order.count | pluralize:"место":"места":"мест" }} + + +e('strong').slots-free(ng-if="order.countDetails.free") + | {{ order.countDetails.free }} свободно + + +e('strong').slots-busy(ng-if="order.countDetails.busy") + | {{ order.countDetails.busy }} занято + + +e('span').slots-confirmed(ng-if="order.countDetails.inGroup") + |  ({{ order.countDetails.inGroup }} подтверждено) + + div + a(href='#' ng-click="order.isEditing = !order.isEditing") детали заказа + + +e('td').info + +e('a').info-link(ng-href='{{order.courseUrl}}') Описание курса + + +e('td').price + +b.price {{ order.amount }} RUB + +e(ng-class="['invoice-table__payment-status', 'invoice-table__payment-status_' + order.orderInfo.status]") + | {{ order.orderInfo.statusText }} + +e.payment-type(ng-if="order.paymentMethod") ({{ order.paymentMethod }}) + + +e('tr').settings(ng-repeat-end) + +e('td').settings-cell(colspan=3) + +e.settings-dropdown + +e('button').settings-dropdown-close.close-button(ng-click="order.isEditing = false") + + div(ng-if="order.orderInfo.status == 'pending'") + +b.notification.__state-notification._message._info + +e.content + | В данный момент мы ожидаем от вас оплату. После того, как мы получим подтверждение оплаты, + | указанным участникам курсов придёт письмо со всей необходимой информацией. + + div(ng-if="order.orderInfo.status == 'success'") + div(ng-if="order.count > order.countDetails.inGroup") + +b.notification.__state-notification._message._success + +e.content + | Каждому участнику отправляется письмо-приглашение. Участника можно изменить до тех пор, пока он не принял его. + + +e.settings-dropdown-cell._left + +e('order-participants')(order="order") + + +e.settings-dropdown-cell._right + +e('order-contact')(order="order") + + +e.settings-line._foot(ng-if="order.orderInfo.status != 'success'") + +e('h4').settings-title Оплата + +e('p').note(ng-bind-html="order.orderInfo.descriptionProfile | trust_html") + +b('button').button._common(type="button" ng-click="changePayment(order)") + +e('span').text Изменить метод оплаты + + +e('a').cancel-order(ng-click="cancelOrder(order)") Отменить заказ + + + diff --git a/handlers/profile/templates/partials/profileAuthProviders.jade b/handlers/profile/templates/partials/profileAuthProviders.jade new file mode 100755 index 000000000..01c1023c1 --- /dev/null +++ b/handlers/profile/templates/partials/profileAuthProviders.jade @@ -0,0 +1,11 @@ +include /bem + ++b.profile-providers + +e.content + +e.title Привязать: + +e.socials + +b('button').social-login._facebook.__social-login(ng-click="connect('facebook')" ng-if="!connected('facebook')") Facebook + +b('button').social-login._google.__social-login(ng-click="connect('google')" ng-if="!connected('google')") Google+ + +b('button').social-login._vkontakte.__social-login(ng-click="connect('vkontakte')" ng-if="!connected('vkontakte')") Вконтакте + +b('button').social-login._github.__social-login(ng-click="connect('github')" ng-if="!connected('github')") Github + +b('button').social-login._yandex.__social-login(ng-click="connect('yandex')" ng-if="!connected('yandex')") Яндекс \ No newline at end of file diff --git a/handlers/profile/templates/partials/profileField.jade b/handlers/profile/templates/partials/profileField.jade new file mode 100755 index 000000000..3bbd8ec52 --- /dev/null +++ b/handlers/profile/templates/partials/profileField.jade @@ -0,0 +1,34 @@ +include /bem + ++b('form').profile-field._editable( + novalidate + progress="loadingTracker.active()" + progress-overlay + name="form" + ng-submit="submit()" + ng-click="edit()" + ng-class="{'profile-field_editing': editing}" +) + + +e.lcell + +e.name {{title}}: + + +e.rcell + +e.value(ng-bind="formatValue(value)") + +e.change(ng-show="editing") + +e.change-content + +b.text-input._small.__control(ng-class="{'text-input_invalid':form.$invalid}") + div(control-transclude) + //- ng-transclude comes here + //- directive content will be appended here with current scope, not parent scope! + +e(ng-messages="form.input.$error").err + +e(ng-message="required") Значение не должно быть пустым. + +e(ng-message="minlength") Значение слишком короткое. + +e(ng-message="email") Некорректный email. + +e(ng-message="date") Дата неверна, формат: дд.мм.гггг. + +e(ng-message="dateRange") Такой даты здесь не может быть. + + +e.ok-cancel + +b('button')(type="submit" progress="loadingTracker.active()" progress-spinner="{elemClass:'button_loading',size:'small'}").button._action.__save + +e('span').text Сохранить + +e('button').cancel(type="button" ng-click="cancel()") Отмена diff --git a/handlers/profile/templates/partials/profilePassword.jade b/handlers/profile/templates/partials/profilePassword.jade new file mode 100755 index 000000000..a8b324900 --- /dev/null +++ b/handlers/profile/templates/partials/profilePassword.jade @@ -0,0 +1,37 @@ +include /bem + ++b('form').profile-field._editable._password( + novalidate + progress="loadingTracker.active()" + progress-overlay + name="form" + ng-submit="submit()" + ng-click="edit()" + ng-class="{'profile-field_editing': editing}" +) + + +e('button').action._change-password(type="button" ng-hide="editing") Изменить пароль + + +e.change(ng-show="editing") + +e.change-content + + //- not ng-if, because ng-if creates a scope, so ng-model won't work + //- http://stackoverflow.com/questions/18342917/angularjs-ng-model-doesnt-work-inside-ng-if + +e.labeled.__pass-change(ng-show="hasPassword") + +e.labeled-label Старый пароль: + +b.text-input._small.__labeled-text.__pass(ng-class="{'text-input_invalid':form.passwordOld.$invalid}") + +e('input').control(type="password" name="passwordOld" ng-model="passwordOld") + + +e.labeled.__pass-change + +e.labeled-label(ng-if="hasPassword") Новый пароль: + +e.labeled-label(ng-if="!hasPassword") Укажите пароль + +b.text-input._small.__labeled-text.__pass(ng-class="{'text-input_invalid':(form.password.$dirty && form.password.$invalid)}") + +e('input').control(type="password" name="password" minlength="4" required ng-model="password") + +e(ng-messages="form.password.$error").err + +e(ng-message="required") Пароль не должен быть пустым. + +e(ng-message="minlength") Пароль слишком короткий. + + +e.ok-cancel + +b('button')(type="submit" progress="loadingTracker.active()" progress-spinner="{elemClass:'button_loading',size:'small'}").button._action.__save + +e('span').text Сохранить + +e('button').cancel(type="button" ng-click="cancel()") Отмена diff --git a/handlers/profile/templates/partials/profilePhoto.jade b/handlers/profile/templates/partials/profilePhoto.jade new file mode 100755 index 000000000..dd057fd3c --- /dev/null +++ b/handlers/profile/templates/partials/profilePhoto.jade @@ -0,0 +1,11 @@ +include /bem + ++b.profile-photo + +e.upic( + style="background-image: url('{{photo | thumb:146:146 }}')" + progress="loadingTracker.active()" + progress-overlay + progress-spinner="{elemClass:'profile__upic_loading'}" + ) + +e.upic-edit(ng-click="changePhoto()") Загрузить
    фотографию + +e.content diff --git a/handlers/profile/templates/partials/quiz.jade b/handlers/profile/templates/partials/quiz.jade new file mode 100755 index 000000000..50cb4d70b --- /dev/null +++ b/handlers/profile/templates/partials/quiz.jade @@ -0,0 +1,34 @@ +include /bem + ++b.profile + + + +b.quiz-results-table + + +e('p').empty-message(ng-if="!quizResults.length") Нет пройденных тестов. + + + +e("table").results(ng-if="quizResults.length") + + //- [{"created":"2015-03-25T15:44:09.907Z","quizTitle":"Второй тест","score":0,"level":"junior","levelTitle":"новичок","time":2286}] + +e("tr").result(ng-repeat="result in quizResults") + + +e("th").test-info + +e("time").time {{result.created | longDate}} + +e("h1").name(ng-if="result.quizUrl") + a(href="{{result.quizUrl}}") {{result.quizTitle}} + +e("h1").name(ng-if="!result.quizUrl") {{result.quizTitle}} + + +e("td").precents + +e("dl").precents-info + +e("dt").title + +e("h1").title-head Результат: + +e("dd").precents-value {{result.score}}% + + +e("td").level + +e("h1").title Уровень: + +e("p").level-info {{result.levelTitle}} + + +e("td").time-spent + +e("h1").title Время прохождения: + +e("p").time-spent-info {{result.time | quizDuration}} diff --git a/handlers/profile/templates/partials/root.jade b/handlers/profile/templates/partials/root.jade new file mode 100755 index 000000000..8b00f415e --- /dev/null +++ b/handlers/profile/templates/partials/root.jade @@ -0,0 +1,13 @@ +include /bem + ++b.profile + + +e('profile-photo')(photo="me.photo") + + +e.content + +e.tabs + +e.tab(ng-repeat="tab in tabs" ui-sref-active="profile__tab_current") + +e.tab-content + +e('a').tab-link(ui-sref="{{tab.name}}") {{tab.title}} + + ui-view Loading... diff --git a/handlers/profileGuest/controller/index.js b/handlers/profileGuest/controller/index.js new file mode 100755 index 000000000..8383764f3 --- /dev/null +++ b/handlers/profileGuest/controller/index.js @@ -0,0 +1,67 @@ +var config = require('config'); +var User = require('users').User; +var mongoose = require('mongoose'); +var QuizResult = require('quiz').QuizResult; + +// skips the request if it's the owner +exports.get = function* (next) { + + var user = yield User.findOne({ + profileName: this.params.profileName + }).exec(); + + if (!user) { + this.throw(404); + } + + // the visitor is the owner => another middleware + if (this.user && String(this.user._id) == String(user._id)) { + yield* next; + return; + } + + var tabName = this.params.tab || 'aboutme'; + + this.locals.tabs = { + aboutme: { + url: user.getProfileUrl() + } + }; + + if (~user.profileTabsEnabled.indexOf('quiz')) { + + this.locals.tabs.quiz = { + url: user.getProfileUrl() + '/' + tabName + }; + + var quizResults = yield* QuizResult.getLastAttemptsForUser(user._id); + + quizResults = quizResults.map(function(result) { + return { + created: result.created, + quizTitle: result.quizTitle, + score: result.score, + level: result.level, + levelTitle: result.levelTitle, + quizUrl: result.quiz && result.quiz.getUrl(), + time: result.time + }; + }); + + this.locals.quizResults = quizResults; + } + + if (!this.locals.tabs[tabName]) { + this.throw(404); + } + + this.locals.title = user.displayName; + + this.locals.tabs[tabName].active = true; + + this.body = this.render(tabName, { + profileUser: user + }); + +}; + diff --git a/handlers/profileGuest/index.js b/handlers/profileGuest/index.js new file mode 100755 index 000000000..d3f3f9b61 --- /dev/null +++ b/handlers/profileGuest/index.js @@ -0,0 +1,6 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/profile', __dirname)); +}; diff --git a/handlers/profileGuest/router.js b/handlers/profileGuest/router.js new file mode 100755 index 000000000..d21e13acb --- /dev/null +++ b/handlers/profileGuest/router.js @@ -0,0 +1,9 @@ +var Router = require('koa-router'); + +var index = require('./controller/index'); + +var router = module.exports = new Router(); + +router.get('/:profileName/:tab?', index.get); + + diff --git a/handlers/profileGuest/templates/aboutme.jade b/handlers/profileGuest/templates/aboutme.jade new file mode 100755 index 000000000..46f58e9d3 --- /dev/null +++ b/handlers/profileGuest/templates/aboutme.jade @@ -0,0 +1,61 @@ +extends root + +block profileContent + + +e.fields + + if profileUser.realName + +b.profile-field + +e.lcell + +e.name Имя + +e.rcell + +e.value= profileUser.realName + + if profileUser.country + +b.profile-field + +e.lcell + +e.name Страна + +e.rcell + +e.value= profileUser.country + + if profileUser.town + +b.profile-field + +e.lcell + +e.name Город + +e.rcell + +e.value= profileUser.town + + if profileUser.publicEmail + +b.profile-field + +e.lcell + +e.name E-Mail + +e.rcell + +e.value= profileUser.publicEmail + + + if profileUser.interests + +b.profile-field + +e.lcell + +e.name Интересы + +e.rcell + +e.value= profileUser.interests + + if profileUser.birthday + +b.profile-field + +e.lcell + +e.name Дата рождения + +e.rcell + +e.value= profileUser.birthday + + +b.profile-field + +e.lcell + +e.name Зарегистрирован + +e.rcell + +e.value= moment(profileUser.created).format('DD.MM.YY в LT') + + +b.profile-field + +e.lcell + +e.name Активность + +e.rcell + +e.value Последняя активность #{moment(profileUser.lastActivity || profileUser.created).format('DD.MM.YY в LT')} + diff --git a/handlers/profileGuest/templates/quiz.jade b/handlers/profileGuest/templates/quiz.jade new file mode 100755 index 000000000..51fcb7fed --- /dev/null +++ b/handlers/profileGuest/templates/quiz.jade @@ -0,0 +1,30 @@ +extends root + +block append head + link(href=pack('quiz', 'css') rel='stylesheet') + +block profileContent + + +b.quiz-results-table + +e("table").results + each result in quizResults + +e("tr").result(ng-repeat="result in quizResults") + +e("th").test-info + +e("time").time= moment(result.created).format('D MMMM YYYY в LT') + +e("h1").name + if result.quizUrl + a(href=result.quizUrl)= result.quizTitle + else + = result.quizTitle + + +e("td").precents + +e("dl").precents-info + +e("dt").title + +e("h1").title-head Результат: + +e("dd").precents-value #{result.score}% + +e("td").level + +e("h1").title Уровень: + +e("p").level-info #{result.levelTitle} + +e("td").time-spent + +e("h1").title Время прохождения: + +e("p").time-spent-info= moment.duration(Math.round(result.time/1000), 'seconds').humanize() diff --git a/handlers/profileGuest/templates/root.jade b/handlers/profileGuest/templates/root.jade new file mode 100755 index 000000000..bad59e562 --- /dev/null +++ b/handlers/profileGuest/templates/root.jade @@ -0,0 +1,30 @@ +extends /layouts/main + +block append variables + - var layout_header_class = "main__header_center" + - var layout_main_class = "main_width-limit-wide" + - var sitetoolbar = true + + +block content + + +b.profile + + +b.profile-photo + +e.upic(style="background-image: url('" + profileUser.getPhotoUrl(146, 146) + "')") + + + +e.content + + if tabs.length == 1 + +e.single-tab-header + else + +e.tabs + each tab in tabs + +e(class=['tab', tab.active && '_current']) + +e.tab-content + +e('a').tab-link(href=tab.url)= tab.title + + +e.fields + + block profileContent diff --git a/handlers/quiz/client/index.js b/handlers/quiz/client/index.js new file mode 100755 index 000000000..c58e80e4f --- /dev/null +++ b/handlers/quiz/client/index.js @@ -0,0 +1,187 @@ +require('./styles'); + +var Spinner = require('client/spinner'); +var xhr = require('client/xhr'); + +var prism = require('client/prism'); +var notification = require('client/notification'); + +function init() { + var quizQuestionForm = document.querySelector('[data-quiz-question-form]'); + + if (quizQuestionForm) { + initQuizForm(quizQuestionForm); + } + + var quizResultSaveForm = document.querySelector('[data-quiz-result-save-form]'); + + if (quizResultSaveForm) { + initQuizResultSaveForm(quizResultSaveForm); + } + + prism.init(); +} + +function initQuizResultSaveForm(form) { + form.onsubmit = function(e) { + e.preventDefault(); + + if (window.currentUser) { + saveResult(); + return; + } + + authAndSaveResult(); + }; + + function authAndSaveResult() { + + // let's authorize first + var submitButton = form.querySelector('[type="submit"]'); + + var spinner = new Spinner({ + elem: submitButton, + size: 'small', + class: 'submit-button__spinner', + elemClass: 'submit-button_progress' + }); + spinner.start(); + + require.ensure('auth/client', function() { + spinner.stop(); + var AuthModal = require('auth/client').AuthModal; + new AuthModal({ + callback: saveResult + }); + }, 'authClient'); + + } + + function saveResult() { + + var request = xhr({ + method: 'POST', + url: form.action + }); + + var submitButton = form.querySelector('[type="submit"]'); + + var spinner = new Spinner({ + elem: submitButton, + size: 'small', + elemClass: 'button_loading' + }); + spinner.start(); + submitButton.disabled = true; + + function onEnd() { + spinner.stop(); + submitButton.disabled = false; + } + + request.addEventListener('loadend', onEnd); + + request.addEventListener('success', (event) => { + new notification.Success(`Результат сохранён в профиле! Перейти в профиль.`, 'slow'); + }); + + } +} + + +function initQuizForm(form) { + + function getValue() { + var type = form.elements.type.value; + + var answerElems = form.elements.answer; + + var value = []; + + for (var i = 0; i < answerElems.length; i++) { + if (answerElems[i].checked) { + value.push(+answerElems[i].value); + } + } + + if (type == 'single') { + value = value[0]; + } + + return value; + } + + form.onchange = function() { + var value = getValue(); + + switch(form.elements.type.value) { + case 'single': + form.querySelector('[type="submit"]').disabled = (value === undefined); + break; + case 'multi': + form.querySelector('[type="submit"]').disabled = value.length ? false : true; + break; + default: + throw new Error("unknown type"); + } + }; + + form.onsubmit = function(event) { + event.preventDefault(); + var value = getValue(); + + var request = xhr({ + method: 'POST', + url: form.action, + body: { + answer: value + } + }); + + var submitButton = form.querySelector('[type="submit"]'); + + var spinner = new Spinner({ + elem: submitButton, + size: 'small', + elemClass: 'button_loading' + }); + spinner.start(); + submitButton.disabled = true; + + // stop spinned on success/fail, but not when window is going to be reloaded + function onEnd() { + spinner.stop(); + submitButton.disabled = false; + } + + request.addEventListener('fail', onEnd); + request.addEventListener('success', (event) => { + + if (event.result.reload) { + window.location.reload(); + } else if (event.result.html) { + onEnd(); + document.querySelector('.quiz-timeline .quiz-timeline__number_current') + .classList.remove('quiz-timeline__number_current'); + + document.querySelectorAll('.quiz-timeline span')[event.result.questionNumber] + .classList.add('quiz-timeline__number_current'); + + + document.querySelector('.quiz-tablet-timeline__num') + .innerHTML = ' ' + (event.result.questionNumber + 1) + ' '; + + form.innerHTML = event.result.html; + prism.highlight(form); + } else { + onEnd(); + console.error(`Bad response: ${event.result}`); + } + }); + + + }; + +} + +exports.init = init; diff --git a/handlers/quiz/client/styles/index.styl b/handlers/quiz/client/styles/index.styl new file mode 100644 index 000000000..7356e423a --- /dev/null +++ b/handlers/quiz/client/styles/index.styl @@ -0,0 +1,16 @@ + +@require "~styles/blocks/variables/variables" + + +@require "quiz-selector" +@require "quiz-start" +@require "quiz-explanations" +@require "quiz" +@require "quiz-question" +@require "quiz-timeline" +@require "quiz-result" +@require "quiz-results-indicator" +@require "quiz-percents" +@require "quiz-weak-list" +@require "quiz-results-table" +@require "quiz-tablet-timeline" diff --git a/handlers/quiz/client/styles/quiz-explanations/index.styl b/handlers/quiz/client/styles/quiz-explanations/index.styl new file mode 100644 index 000000000..9de0260ee --- /dev/null +++ b/handlers/quiz/client/styles/quiz-explanations/index.styl @@ -0,0 +1,10 @@ +.quiz-explanations + text-align left + + + @media (max-width: 568px) + & + padding 0 10px + + &__title + text-align center diff --git a/handlers/quiz/client/styles/quiz-percents/index.styl b/handlers/quiz/client/styles/quiz-percents/index.styl new file mode 100755 index 000000000..20145d6ed --- /dev/null +++ b/handlers/quiz/client/styles/quiz-percents/index.styl @@ -0,0 +1,16 @@ +.quiz-percents + & dt + font-weight normal + + &__result &__percents + font-size 48px + font-weight bold + line-height initial + + &__position + margin-top 10px + + &__position &__percents + font-size 32px + font-weight bold + line-height initial diff --git a/handlers/quiz/client/styles/quiz-question/index.styl b/handlers/quiz/client/styles/quiz-question/index.styl new file mode 100644 index 000000000..0ea6a0d21 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-question/index.styl @@ -0,0 +1,132 @@ +.quiz-question + min-width 600px + margin-top 25px + + & ul, + & li, + & ul li, + & h3 + padding 0 + margin 0 + line-height auto + + & p + padding 0 + margin-top 0 + margin-bottom 1em + line-height auto + + & ul li:before + display none + + &__body + padding 30px + + text-align left + + border 3px solid #eee + border-radius 10px + + &__title + font-size 16px + + & &__variants + margin-top 25px + + & &__variant + position relative + + line-height 16px + + margin-top 4px + padding-left 25px + + &__label + font-size 14px + + display inline-block + + position relative + + cursor pointer + + &__input + position absolute + top 50% + left -25px + + margin -7px 0 0 0 + width 16px + height 16px + + &__input:checked + &__input-text + font-weight bold + + &__description + margin-bottom 20px + + &__description .code-example, + &__description .codebox + margin 0 + + & &__note + font-size 12px + + margin 20px + margin-bottom 0 + + color #666 + + &__submit + margin-top 25px + + + &_correct_true &__body + border-color #bbd4a5 + + &_correct_false &__body + border-color #eaaaad + + + //- coloring logic is here: https://github.com/iliakan/javascript-nodejs/issues/293 + &__variant_correct_true &__input-text + font-weight bold + color #060 + + &__variant_correct_false&__variant_selected &__input-text + font-weight bold + color #d90000 + + @media tablet + & + min-width initial + + @media (max-width: 568px) + & + min-width initial + + &__body + padding 20px + + border-width 0 + + border-radius initial + + &__body .code-example + margin 0 -20px + + &_correct_true, + &_correct_false + margin 15px -10px 0 + + + &_correct_true &__body, + &_correct_false &__body + border-width 3px + + & &__variant + margin-top 10px + + &__title + font-size 14px + font-weight normal diff --git a/handlers/quiz/client/styles/quiz-result/index.styl b/handlers/quiz/client/styles/quiz-result/index.styl new file mode 100644 index 000000000..e63bc92ff --- /dev/null +++ b/handlers/quiz/client/styles/quiz-result/index.styl @@ -0,0 +1,128 @@ +.quiz-result + display inline-block + position relative + height 290px + + margin-top 10px + + .main & ul, + .main & li, + .main & dl, + .main & dt, + .main & dd, + .main & h1, + .main & p + margin 0 + padding 0 + + .main & li:before + display none + + &__save-form, + &__retry-form + display inline-block + + &__retry-form + margin-right 30px + + &__retry-button, + &__save-button + min-width 215px + + &__try + text-align center + + &__try-num + color #c60800 + + &__layout + display table + position relative + + padding 20px + margin-top 10px + + border-radius 10px 10px 0 0 + background #222 + + &__left, + &__center, + &__right + display table-cell + box-sizing: border-box + + width 33% + padding 10px 15px + + border-right: 2px solid #3c3c3c + + vertical-align top + text-align center + + &__right + width 33% + + border 0 + + &__save-result + background #4C4B4B + border-radius 0 0 10px 10px + + &__bottom + padding 20px 0 + + & .quiz-percents, + & .quiz-weak-list__title + color #7a7a7a + + & .quiz-percents__percents, + & .quiz-weak-list__list, + & .quiz-results-indicator + color: #fff + + @media tablet + &, + &__layout + display block + height auto + padding 0 + + &__left, + &__center, + &__right + display block + box-sizing: border-box + + width auto + padding 30px 25px + + border: 0 + + &__center, + &__right + border-top 2px solid rgba(213,214,214,0.15) + + + @media (max-width: 568px) + + &__layout, + &__save-result + border-radius initial + + margin-left -10px + margin-right -10px + + &__bottom + padding 30px + + & .quiz-weak-list__list + display block + + text-align left + + &__retry-form, + &__save-form + display block + + &__retry-form + margin 0 0 20px 0 diff --git a/handlers/quiz/client/styles/quiz-results-indicator/chart.svg b/handlers/quiz/client/styles/quiz-results-indicator/chart.svg new file mode 100755 index 000000000..cf37b7656 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-results-indicator/chart.svg @@ -0,0 +1,44 @@ + + + + Chart + Created with Sketch. + + + + + + 0 + + + 100 + + + + + + + + + 20 + + + 80 + + + 40 + + + 60 + + + + + + + + + + + + \ No newline at end of file diff --git a/handlers/quiz/client/styles/quiz-results-indicator/index.styl b/handlers/quiz/client/styles/quiz-results-indicator/index.styl new file mode 100755 index 000000000..2be28d318 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-results-indicator/index.styl @@ -0,0 +1,55 @@ +.quiz-results-indicator + + &__indicator + position relative + + display inline-block + + width 203px + height 102px + + background no-repeat url('quiz-results-indicator/chart.svg'); + + &__indicator:after + position absolute + bottom 2px + left 51px + + + width 45px + height 4px + + transform-origin 100% 50% + + background #d8d8d8 + + content: "" + + &__text + display inline-block + + //- commented out by @iliakan + //- breaks text into 3 lines => breaks markup on extra-wide screens (where font-size is 16px) + //-width 197px + + margin-top 10px + + &__level + font-weight bold + + &__level_junior + color #FFC800 + + &__level_medium + color #ff7b00 + + &__level_senior + color #c20800 + + + @media tablet + &__text, + &__indicator + display block + margin-left auto + margin-right auto diff --git a/handlers/quiz/client/styles/quiz-results-table/index.styl b/handlers/quiz/client/styles/quiz-results-table/index.styl new file mode 100755 index 000000000..961974192 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-results-table/index.styl @@ -0,0 +1,83 @@ +.quiz-results-table + + .main &__empty-message + margin 12px + padding 10px + + .main &__results ul, + .main &__results li, + .main &__results dl, + .main &__results dt, + .main &__results dd, + .main &__results h1, + .main &__results p, + .main &__results th, + .main &__results td + .main &__results + font-size inherit + font-weight normal + + margin 0 + padding 0 + + .main &__results &__result:nth-child(even) + background none + + &__results + width 100% + white-space nowrap + + .main &__results tr:first-child th + vertical-align top + border-bottom-width: 1px + + .main &__results &__result:last-child, + .main &__results &__result:last-child th + border none + + .main &__results th, + .main &__results td + padding 25px + + vertical-align top + + .main &__results &__time, + .main &__results &__try, + .main &__results &__title, + .main &__results &__title-head + font-size 12px + line-height 12px + + display block + + margin-bottom 7px + + color #727272 + + .main &__results &__name + font: 17px/17px secondary_font + + white-space normal + + .main &__results &__try + margin-top 7px + + &__precents + text-align center + + .main &__results &__precents-value + font-size 28px + line-height 28px + + color #4C906B + + .main &__results &__level-info, + .main &__results &__time-spent-info + font-size 14px + line-height 14px + + @media (min-width: largescreen) + .main &__results &__level-info, + .main &__results &__time-spent-info + font-size 16px + line-height 16px diff --git a/handlers/quiz/client/styles/quiz-selector/index.styl b/handlers/quiz/client/styles/quiz-selector/index.styl new file mode 100644 index 000000000..4edcee60b --- /dev/null +++ b/handlers/quiz/client/styles/quiz-selector/index.styl @@ -0,0 +1,91 @@ +.quiz-selector + background #f7f6ea; + border-radius 3px + + & ul, + & li, + & ul li, + & h3, + & p + padding 0 + margin 0 + line-height auto + + & ul li:before + display none + + & &__item + font-size 13px + + padding 25px 30px + + white-space nowrap + + border-bottom 1px solid #eae5d9 + + & &__item:last-child + border none + + &__text, + &__start + display inline-block + + box-sizing border-box + + vertical-align middle + + &__text + width 60% + padding-right 20px + + white-space normal + + &__start + width 40% + min-width 230px + text-align right + + &__start-i + position relative + + display inline-block + + & &__title + font-family secondary_font + font-size 18px + + margin-bottom 10px + + color: #b20000 + + &__result + position absolute + + width 100% + + text-align center + color #508F6C + + @media tablet + &__text, + &__start + width auto + display block + + &__start + text-align left + margin-top 15px + + @media (max-width: 568px) + & + margin 0 -10px + border-radius none + + text-align center + + & &__item + border-bottom 2px solid #fff + + &__text, + &__start + text-align center diff --git a/handlers/quiz/client/styles/quiz-start/index.styl b/handlers/quiz/client/styles/quiz-start/index.styl new file mode 100755 index 000000000..56c3094cc --- /dev/null +++ b/handlers/quiz/client/styles/quiz-start/index.styl @@ -0,0 +1,29 @@ +.quiz-start + + &__pane + padding 40px + + border 3px solid #eee + border-radius 10px + + // tag to override .main p + p&__description + margin-top 0 + margin-bottom 32px + color #999 + + &__pane p + margin 0 + margin-top 20px + + & .button_action + font-size 18px + + line-height 47px + + padding 1px 40px + + & .button_action:active, + & .button_action:focus + padding 0px 39px + diff --git a/handlers/quiz/client/styles/quiz-tablet-timeline/index.styl b/handlers/quiz/client/styles/quiz-tablet-timeline/index.styl new file mode 100644 index 000000000..6d704e6cd --- /dev/null +++ b/handlers/quiz/client/styles/quiz-tablet-timeline/index.styl @@ -0,0 +1,9 @@ +.quiz-tablet-timeline + & &__title + margin 0 + + &__num + color #F8AB47 + + &__total + color #A9A9A9 diff --git a/handlers/quiz/client/styles/quiz-timeline/index.styl b/handlers/quiz/client/styles/quiz-timeline/index.styl new file mode 100644 index 000000000..6ff9a0147 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-timeline/index.styl @@ -0,0 +1,36 @@ +.quiz-timeline + font-size 12px + line-height 20px + + min-width 400px + height 20px + padding 5px + + white-space nowrap + + border 2px solid #EEE + border-radius 20px + + &__number + display inline-block + + width 20px + height 20px + margin 0 8px + + line-height inherit + + text-align center + + color #F8AB47 + + &__number_current + border-radius 10px + color #fff + background #f8ab47 + + &__number_current ~ &__number + color #333 + + @media tablet + display none diff --git a/handlers/quiz/client/styles/quiz-weak-list/index.styl b/handlers/quiz/client/styles/quiz-weak-list/index.styl new file mode 100755 index 000000000..a02ed81b8 --- /dev/null +++ b/handlers/quiz/client/styles/quiz-weak-list/index.styl @@ -0,0 +1,15 @@ +.quiz-weak-list + + margin-top 10px + + &__title + font-size 14px + font-weight normal + + &__list + display inline-block + + margin-top 15px !important + + text-align left + list-style disc outside !important diff --git a/handlers/quiz/client/styles/quiz/index.styl b/handlers/quiz/client/styles/quiz/index.styl new file mode 100644 index 000000000..9194c66a5 --- /dev/null +++ b/handlers/quiz/client/styles/quiz/index.styl @@ -0,0 +1,9 @@ +.quiz + display inline-block + margin-top 10px + + @media (max-width: 568px) + & + display block + margin-left -10px + margin-right -10px diff --git a/handlers/quiz/controllers/answer.js b/handlers/quiz/controllers/answer.js new file mode 100755 index 000000000..a115b6a3e --- /dev/null +++ b/handlers/quiz/controllers/answer.js @@ -0,0 +1,114 @@ +const Quiz = require('../models/quiz'); +const QuizResult = require('../models/quizResult'); +const QuizStat = require('../models/quizStat'); +const QuizQuestion = require('../models/quizQuestion'); +const _ = require('lodash'); + +exports.post = function*() { + var self = this; + if (!this.session.quizzes) { + this.log.debug("No session quizzes"); + this.throw(404); + } + + var sessionQuiz = this.session.quizzes[this.params.slug]; + + if (!sessionQuiz) { + this.log.debug("No session quiz with such slug"); + this.throw(404); + } + + var quiz = yield Quiz.findById(sessionQuiz.id).exec(); + + if (!quiz) { + this.log.debug("No quiz with id " + sessionQuiz.id); + this.throw(404); + } + + // save selected answers in the question and push to questionsTaken + var question = quiz.questions.id(sessionQuiz.questionCurrentId); + + sessionQuiz.questionsTakenIds.push(question._id); + + if (question.type == 'single') { + sessionQuiz.answers.push(+this.request.body.answer); + } else if (question.type == 'multi') { + if (!Array.isArray(this.request.body.answer)) { + this.throw(400); + } + sessionQuiz.answers.push(this.request.body.answer.map(Number)); + } else { + throw new Error("Unknown question type: " + question.type); + } + + if (sessionQuiz.questionsTakenIds.length == quiz.questionsToAskCount) { + + var totalScore = 0; + sessionQuiz.questionsTakenIds.forEach(function(id, i) { + totalScore += quiz.questions.id(id).checkAnswer(sessionQuiz.answers[i]); + }); + + // percentage of solved + totalScore = Math.round(totalScore / quiz.questionsToAskCount * 100); + + var quizResult = new QuizResult({ + user: this.user && this.user._id, + quizSlug: quiz.slug, + quizTitle: quiz.title, + score: totalScore, + level: totalScore <= 40 ? 'junior' : totalScore <= 80 ? 'medium' : 'senior', + time: Date.now() - sessionQuiz.started // in ms! + }); + + sessionQuiz.result = quizResult.toObject(); + + + yield QuizStat.update({ + slug: quiz.slug, + score: totalScore + }, { + $inc: { + count: 1 + } + }, { + upsert: true + }).exec(); + + + this.body = { + reload: true + }; + + } else { + + // select one more question among non-taken + var questionsAvailable = quiz.questions.filter(function(question) { + // if a quiz.question is taken, exclude it from the list + var found = false; + sessionQuiz.questionsTakenIds.forEach(function(id) { + + if (String(id) == String(question._id)) { + self.log.debug("Excluding " + id); + found = true; + } + }); + + return !found; + }); + + self.log.debug(questionsAvailable); + sessionQuiz.questionCurrentId = _.sample(questionsAvailable, 1)[0]._id; + + this.locals.question = quiz.questions.id(sessionQuiz.questionCurrentId); + + self.log.debug(this.locals.question, sessionQuiz.questionCurrentId); + + this.body = { + html: this.render('partials/_question'), + questionNumber: sessionQuiz.questionsTakenIds.length + }; + + + } + +}; diff --git a/handlers/quiz/controllers/index.js b/handlers/quiz/controllers/index.js new file mode 100755 index 000000000..b815d927e --- /dev/null +++ b/handlers/quiz/controllers/index.js @@ -0,0 +1,40 @@ +const Quiz = require('../models/quiz'); +const QuizResult = require('../models/quizResult'); + +exports.get = function*() { + + this.nocache(); + + var quizzes = yield Quiz.find({ + archived: false + }).sort({weight: 1}).exec(); + + this.locals.quizzes = []; + + // FIXME: all quiz/* must have this + this.locals.siteToolbarCurrentSection = "quiz"; + this.locals.title = 'Тестирование знаний'; + + var quizResults = []; + if (this.user) { + quizResults = yield* QuizResult.getLastAttemptsForUser(this.user._id); + } + + for (var i = 0; i < quizzes.length; i++) { + var quiz = quizzes[i]; + var q = { + title: quiz.title, + description: quiz.description, + slug: quiz.slug + }; + quizResults.forEach(function(quizResult) { + if (quizResult.quizSlug == quiz.slug) { + q.quizResultScore = quizResult.score; + } + }); + + this.locals.quizzes.push(q); + } + + this.body = this.render('index'); +}; diff --git a/handlers/quiz/controllers/quiz.js b/handlers/quiz/controllers/quiz.js new file mode 100755 index 000000000..073434b67 --- /dev/null +++ b/handlers/quiz/controllers/quiz.js @@ -0,0 +1,78 @@ +const config = require('config'); +const Quiz = require('../models/quiz'); +const QuizResult = require('../models/quizResult'); +const QuizStat = require('../models/quizStat'); +const formatTitle = require('simpledownParser').formatTitle; +const renderSimpledown = require('renderSimpledown'); + +exports.get = function*() { + + this.nocache(); + + // session may have many quiz at the same time + // take the current one + // it may be archived! + var sessionQuiz = this.session.quizzes && this.session.quizzes[this.params.slug]; + + if (!sessionQuiz) { + // let the user start a new quiz here + // not archived! + var quiz = yield Quiz.findOne({ + slug: this.params.slug, + archived: false + }).exec(); + + if (!quiz) { + this.log.debug("No quiz: " + this.params.slug); + this.throw(404); + } + + this.locals.quiz = quiz; + this.locals.title = formatTitle(quiz.title); + this.body = this.render('quiz-start'); + return; + } + + // we have a session quiz, but it may be archived! (user started it before the update) + // so let's look by id + var quiz = yield Quiz.findById(sessionQuiz.id).exec(); + + if (!quiz) { + // invalid id in sessionQuiz, probably db was cleared + this.log.debug("No quiz with id: " + sessionQuiz.id); + // invalid quiz in session, delete and go /quiz + delete this.session.quizzes[this.params.slug]; + this.redirect('/quiz'); + return; + } + + this.locals.quiz = quiz; + this.locals.title = formatTitle(quiz.title); + + this.log.debug("sessionQuiz", sessionQuiz); + + if (sessionQuiz.result) { + + var belowPercentage = yield QuizStat.getBelowScorePercentage(quiz.slug, sessionQuiz.result.score); + + this.locals.quizResult = new QuizResult(sessionQuiz.result); + this.locals.quizBelowPercentage = belowPercentage; + + this.locals.quizQuestions = sessionQuiz.questionsTakenIds.map(function(id, num) { + var question = quiz.questions.id(id).toObject(); + question.userAnswer = sessionQuiz.answers[num]; + question.correct = quiz.questions.id(id).checkAnswer(question.userAnswer); + return question; + }); + + this.body = this.render('results'); + } else { + // show current question + this.locals.question = quiz.questions.id(sessionQuiz.questionCurrentId); + + this.locals.progressNow = sessionQuiz.questionsTakenIds.length + 1; + this.locals.progressTotal = quiz.questionsToAskCount; + + this.body = this.render('quiz'); + } +}; diff --git a/handlers/quiz/controllers/resultsByUser.js b/handlers/quiz/controllers/resultsByUser.js new file mode 100644 index 000000000..a1459b0e2 --- /dev/null +++ b/handlers/quiz/controllers/resultsByUser.js @@ -0,0 +1,30 @@ +const config = require('config'); +const mongoose = require('mongoose'); +const QuizResult = require('../models/quizResult'); +const User = require('users').User; + +exports.get = function*() { + + var user = this.userById; + + if (String(this.user._id) != String(user._id)) { + this.throw(403); + } + + var results = yield* QuizResult.getLastAttemptsForUser(user._id); + + results = results.map(function(result) { + return { + created: result.created, + quizTitle: result.quizTitle, + quizUrl: result.quiz && result.quiz.getUrl(), + score: result.score, + level: result.level, + levelTitle: result.levelTitle, + time: result.time + }; + }); + + this.body = results; + +}; diff --git a/handlers/quiz/controllers/save.js b/handlers/quiz/controllers/save.js new file mode 100755 index 000000000..848c26228 --- /dev/null +++ b/handlers/quiz/controllers/save.js @@ -0,0 +1,49 @@ +const config = require('config'); +const Quiz = require('../models/quiz'); +const QuizResult = require('../models/quizResult'); + +exports.post = function*() { + + if (!this.session.quizzes) { + this.redirect('/quiz'); + return; + } + + // session may have many quiz at the same time + // take the current one + var sessionQuiz = this.session.quizzes[this.params.slug]; + + if (!sessionQuiz || !sessionQuiz.result) { + this.redirect('/quiz'); + return; + } + + // prevent double saving of the same result + if (!sessionQuiz.resultSaved) { + + var result = sessionQuiz.result; + + // only now we bind quizResult to user (!) + // because the user may be GUEST when finishing the test + // and authorize after it + + result.user = this.user._id; + + result = new QuizResult(result); + + yield result.persist(); + + if (!~this.user.profileTabsEnabled.indexOf('quiz')) { + this.user.profileTabsEnabled.addToSet('quiz'); + yield this.user.persist(); + } + + sessionQuiz.resultSaved = true; + } + + // done with that quiz + // delete this.session.quizzes[this.params.slug]; + + this.body = "DONE"; + +}; diff --git a/handlers/quiz/controllers/start.js b/handlers/quiz/controllers/start.js new file mode 100755 index 000000000..0f2d5f9c3 --- /dev/null +++ b/handlers/quiz/controllers/start.js @@ -0,0 +1,35 @@ +const Quiz = require('../models/quiz'); +const QuizResult = require('../models/quizResult'); +const _ = require('lodash'); + +exports.post = function*() { + + var quiz = yield Quiz.findOne({ + slug: this.params.slug, + archived: false + }).exec(); + + if (!quiz) { + this.throw(404); + } + + this.log.debug("Starting quiz ", quiz.toObject()); + + if (!this.session.quizzes) { + this.session.quizzes = {}; + } + + var sessionQuiz = { + started: Date.now(), + id: quiz._id, + questionsTakenIds: [], + answers: [] + }; + + // previous attempt will be automatically removed from the session + this.session.quizzes[quiz.slug] = sessionQuiz; + + sessionQuiz.questionCurrentId = _.sample(quiz.questions, 1)[0]._id; + + this.redirect(quiz.getUrl()); +}; diff --git a/handlers/quiz/index.js b/handlers/quiz/index.js new file mode 100755 index 000000000..2c6517bff --- /dev/null +++ b/handlers/quiz/index.js @@ -0,0 +1,9 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use( mountHandlerMiddleware('/quiz', __dirname) ); +}; + +exports.QuizResult = require('./models/quizResult'); + diff --git a/handlers/quiz/models/quiz.js b/handlers/quiz/models/quiz.js new file mode 100755 index 000000000..3561f8b29 --- /dev/null +++ b/handlers/quiz/models/quiz.js @@ -0,0 +1,55 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; +const config = require('config'); +const path = require('path'); +const assert = require('assert'); +const _ = require('lodash'); +const QuizQuestion = require('./quizQuestion'); + + +const quizSchema = new Schema({ + // when a new quiz is imported, the current one gets archived: false, + // but still remains in db for some time, to those people who are passing it in the moment of update + archived: { + type: Boolean, + required: true + }, + title: { + type: String, + required: true + }, + description: { + type: String, + required: true + }, + weight: { + type: Number, + required: true + }, + slug: { + type: String, + required: true, + index: true + }, + questionsToAskCount: { + type: Number, + required: true + }, + created: { + type: Date, + required: true, + default: Date.now + }, + questions: [QuizQuestion.schema] +}); + +quizSchema.statics.getUrlBySlug = function(slug) { + return '/quiz/' + slug; +}; + +quizSchema.methods.getUrl = function() { + return quizSchema.statics.getUrlBySlug(this.get('slug')); +}; + + +module.exports = mongoose.model('Quiz', quizSchema); \ No newline at end of file diff --git a/handlers/quiz/models/quizQuestion.js b/handlers/quiz/models/quizQuestion.js new file mode 100755 index 000000000..050d90ce5 --- /dev/null +++ b/handlers/quiz/models/quizQuestion.js @@ -0,0 +1,60 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; +const config = require('config'); +const path = require('path'); +const assert = require('assert'); +const _ = require('lodash'); + + +const schema = new Schema({ + content: { + type: String, + required: true + }, + // question types, determines how to show/check answers + // single - a selection 1 from many, correctAnswer is the number + // multi - a selection of many from many, correctAnswer is a set + // for future possible: string - string match, eval - JS result eval match + type: { + type: String, + required: true, + default: 'single', + enum: ['single', 'multi'] + }, + answers: [{}], // array of generic answer variants, e.g. [{title: String, desc: String}] + correctAnswer: {}, // generic correct answer, e.g Number or [Number] for multi + correctAnswerComment: String // why is the answer correct, optional comment +}); + +schema.path('correctAnswer').validate(function (value) { + if (this.type == 'single') { + // 1 number + return typeof value == 'number'; + } + + if (this.type == 'multi') { + // array of numbers + return Array.isArray(value) && !value.filter(function(v) { + return typeof v != 'number'; + }).length; + } + +}, 'Invalid color'); + + +schema.methods.checkAnswer = function(answer) { + + switch (this.type) { + case 'single': + return this.correctAnswer == answer ? 1 : 0; + case 'multi': + assert(Array.isArray(answer)); + assert(Array.isArray(this.correctAnswer)); + + return _.isEqual( this.correctAnswer.sort(), answer.sort()) ? 1 : 0; + } + +}; + + +module.exports = mongoose.model('QuizQuestion', schema); diff --git a/handlers/quiz/models/quizResult.js b/handlers/quiz/models/quizResult.js new file mode 100755 index 000000000..b265416a2 --- /dev/null +++ b/handlers/quiz/models/quizResult.js @@ -0,0 +1,91 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; +const Quiz = require('./quiz'); + +// An attempt of quiz solving +const schema = new Schema({ + user: { + type: Schema.Types.ObjectId, + ref: 'User', + index: true + }, + // we keep full information about the quiz, not linking by id, + // because the quiz may be replaced + // and even deleted + // but the information must stay + quizSlug: { + type: String, + required: true + }, + + quizTitle: { + type: String, + required: true + }, + + level: { + type: String, + enum: ['junior','medium', 'senior'], + required: true + }, + + score: { + type: Number, + required: true + }, + + time: { + type: Number, + required: true + }, + + // better than XX% participants is not stored here, + // because it is not persistent + + created: { + type: Date, + required: true, + default: Date.now + } +}); + +schema.virtual('levelTitle').get(function() { + return {junior: 'новичок', medium: 'средний', senior: 'профи'}[this.level]; +}); + +schema.statics.getLastAttemptsForUser = function*(user) { + + var allResults = yield QuizResult.find({user: user}).sort({created: -1}).exec(); + + var lastAttemptResults = {}; + + // get only first (by creation) result for each quizSlug + for (var i = 0; i < allResults.length; i++) { + var result = allResults[i]; + if (lastAttemptResults[result.quizSlug]) continue; + lastAttemptResults[result.quizSlug] = result; + } + + var quizzes = yield Quiz.find({ + archived: false, + slug: { + $in: Object.keys(lastAttemptResults) + } + }).exec(); + + var quizBySlug = {}; + for (var i = 0; i < quizzes.length; i++) { + var quiz = quizzes[i]; + quizBySlug[quiz.slug] = quiz; + } + + var results = []; + for(var key in lastAttemptResults) { + lastAttemptResults[key].quiz = quizBySlug[lastAttemptResults[key].quizSlug]; + results.push(lastAttemptResults[key]); + } + + return results; +}; + +var QuizResult = module.exports = mongoose.model('QuizResult', schema); \ No newline at end of file diff --git a/handlers/quiz/models/quizStat.js b/handlers/quiz/models/quizStat.js new file mode 100755 index 000000000..3de91d485 --- /dev/null +++ b/handlers/quiz/models/quizStat.js @@ -0,0 +1,67 @@ +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const schema = new Schema({ + slug: { + type: String, + required: true, + index: true + }, + score: { + type: Number, + required: true + }, + // count of tests with this score + count: { + type: Number, + required: true + } +}); + +schema.index({slug: 1, score: 1}, {unique: true}); + +// TODO: test me +// http://docs.mongodb.org/v2.6/MongoDB-aggregation-guide.pdf +schema.statics.getBelowScorePercentage = function*(slug, score) { + + var belowCount = yield QuizStat.aggregate( + { + $match: { + slug: slug, + score: { + $lt: score + } + } + }, { + $group: { + _id: null, + total: { + $sum: "$count" + } + } + } + ).exec(); + + var totalCount = yield QuizStat.aggregate( + { + $match: { + slug: slug + } + }, { + $group: { + _id: null, + total: { + $sum: "$count" + } + } + } + ).exec(); + + belowCount = belowCount.length ? belowCount[0].total : 0; + totalCount = totalCount.length ? totalCount[0].total : 1; + return Math.round(belowCount / totalCount * 100); + +}; + + +var QuizStat = module.exports = mongoose.model('QuizStat', schema); \ No newline at end of file diff --git a/handlers/quiz/quizImporter.js b/handlers/quiz/quizImporter.js new file mode 100755 index 000000000..823aa8f5b --- /dev/null +++ b/handlers/quiz/quizImporter.js @@ -0,0 +1,76 @@ +var yaml = require('js-yaml'); +var fs = require('fs'); +var Quiz = require('./models/quiz'); +var path = require('path'); +var log = require('log')(); + +function QuizImporter(options) { + this.fileContent = fs.realpathSync(options.yml); +} + + +QuizImporter.prototype.addDot = function(question) { + // numbers have no dot (looks better) + if (/^\d+$/.test(question)) return question; + + // do not wrap code + if (/^`[^`]+`$/.test(question)) return question; + + if (!/[.!?)]$/.test(question)) { + question += '.'; + } + return question; +}; + +QuizImporter.prototype.import = function*() { + + var quizObj = yaml.safeLoad(fs.readFileSync(this.fileContent, 'utf8')); + + + for (var i = 0; i < quizObj.questions.length; i++) { + var question = quizObj.questions[i]; + + for (var j = 0; j < question.answers.length; j++) { + var answer = question.answers[j]; + // all primitive values become titles w/o description + if (typeof answer != 'object') { + answer = question.answers[j] = { + title: answer + }; + } else { + if (!answer.title) { + log.error("No title for answer", question); + } + } + // convert title to string, cause string methods will be called on it + answer.title = this.addDot(String(answer.title).trim()); + } + + } + + + var quiz = new Quiz(quizObj); + + quiz.archived = false; + + yield Quiz.update({ + slug: quiz.slug + }, { + $set: { + archived: true + } + }, { + multi: true + }).exec(); + + try { + yield quiz.persist(); + } catch (e) { + if (e.errors) console.error(e.errors); + throw e; + } + +}; + + +module.exports = QuizImporter; \ No newline at end of file diff --git a/handlers/quiz/router.js b/handlers/quiz/router.js new file mode 100755 index 000000000..e275cfb3b --- /dev/null +++ b/handlers/quiz/router.js @@ -0,0 +1,20 @@ +var Router = require('router'); + +var index = require('./controllers/index'); +var start = require('./controllers/start'); +var save = require('./controllers/save'); +var answer = require('./controllers/answer'); +var quiz = require('./controllers/quiz'); +var resultsByUser = require('./controllers/resultsByUser'); + +var mustBeAuthenticated = require('auth').mustBeAuthenticated; +var router = module.exports = new Router(); +router.param('userById', require('users').routeUserById); + +router.get("/", index.get); +router.get("/results/user/:userById", mustBeAuthenticated, resultsByUser.get); +router.post("/start/:slug", start.post); +router.post("/save/:slug", mustBeAuthenticated, save.post); +router.post("/answer/:slug", answer.post); +router.get("/:slug", quiz.get); + diff --git a/handlers/quiz/tasks/quizImport.js b/handlers/quiz/tasks/quizImport.js new file mode 100755 index 000000000..813c3900d --- /dev/null +++ b/handlers/quiz/tasks/quizImport.js @@ -0,0 +1,52 @@ +var co = require('co'); +var fs = require('fs'); +var path = require('path'); +var log = require('log')(); +var gutil = require('gulp-util'); +var glob = require('glob'); +var QuizImporter = require('../quizImporter'); +var Quiz = require('../models/quiz'); + +module.exports = function(options) { + + return function() { + + var args = require('yargs') + .usage("Path to quiz root is required.") + .demand(['root']) + .argv; + + var root = fs.realpathSync(args.root); + + return co(function* () { + + var files = glob.sync(path.join(root, '*.yml')); + + if (args.reset) { + yield Quiz.destroy({}); + } + + for (var i = 0; i < files.length; i++) { + var yml = files[i]; + if (path.basename(yml)[0] == '_') { + gutil.log("Skip unfinished " + yml); + continue; + } + + gutil.log("Importing " + yml); + + var importer = new QuizImporter({ + yml: yml + }); + + + yield* importer.import(); + } + + log.info("DONE"); + + }); + }; +}; + + diff --git a/handlers/quiz/templates/blocks/question.jade b/handlers/quiz/templates/blocks/question.jade new file mode 100755 index 000000000..d60529544 --- /dev/null +++ b/handlers/quiz/templates/blocks/question.jade @@ -0,0 +1,46 @@ ++b(class=["quiz-question", quizResult && (question.correct ? "_correct_true" : "_correct_false")]) + input(type="hidden" name="type" value=question.type) + +e.body + != renderSimpledown(question.content) + + if question.type == 'single' + +e("ul").variants + each answer, num in question.answers + - var variantCorrect = (num == question.correctAnswer); + - var variantSelected = question.userAnswer !== undefined && (question.userAnswer == num); + +e("li")(class=[ + "variant", + variantSelected && "_selected" || undefined, + quizResult && variantCorrect && "_correct_true" || undefined, + quizResult && variantSelected && !variantCorrect && "_correct_false" || undefined + ]) + +e("label").label + +e("input").input(type="radio" value=num name=(!quizResult && "answer") disabled=!!quizResult checked=(variantSelected ? "checked" : undefined)) + +e("span").input-text!= renderSimpledown(answer.title, {applyContextTypography: false}) + if answer.description + +e.description!= renderSimpledown(answer.description, {applyContextTypography: false}) + + + if question.type == 'multi' + +e("ul").variants + each answer, num in question.answers + - var variantCorrect = ~question.correctAnswer.indexOf(num); + - var variantSelected = question.userAnswer !== undefined && ~question.userAnswer.indexOf(num); + +e("li")(class=[ + "variant", + variantSelected && "_selected" || undefined, + quizResult && variantCorrect && "_correct_true" || undefined, + quizResult && variantSelected && !variantCorrect && "_correct_false" || undefined + ]) + +e("label").label + +e("input").input(type="checkbox" value=num name=(!quizResult && "answer") disabled=!!quizResult checked=(variantSelected ? "checked" : undefined)) + +e("span").input-text!= renderSimpledown(answer.title, {applyContextTypography: false}) + if answer.description + +e.description!= renderSimpledown(answer.description, {applyContextTypography: false}) + + + if !quizResult + +e.submit + +b("button").button._action(type="submit" disabled) + +e("span").text Продолжить + diff --git a/handlers/quiz/templates/blocks/quiz-explanations.jade b/handlers/quiz/templates/blocks/quiz-explanations.jade new file mode 100755 index 000000000..5ddabf913 --- /dev/null +++ b/handlers/quiz/templates/blocks/quiz-explanations.jade @@ -0,0 +1,7 @@ ++b.quiz-explanations + +e("h4").title Пояснения: + +e("ul") + +e('li') Тесты предполагают современные браузеры. + +e('li') Все настройки браузера — по умолчанию. + +e('li') Версия Javascript — самая распространенная на текущий день, т.е ES5. + +e('li') Везде "use strict". \ No newline at end of file diff --git a/handlers/quiz/templates/blocks/quiz-selector.jade b/handlers/quiz/templates/blocks/quiz-selector.jade new file mode 100755 index 000000000..617edbf07 --- /dev/null +++ b/handlers/quiz/templates/blocks/quiz-selector.jade @@ -0,0 +1,17 @@ ++b.quiz-selector + +e("ul").list + each quiz in quizzes + +e("li").item + +e.text + +e("h3").title!= quiz.title + != quiz.description + +e.start + +e.start-i + form(action="/quiz/start/#{quiz.slug}", method="POST") + input(type="hidden", name="_csrf", value=csrf()) + +b("button")(type="submit").button._common + +e("span").text Пройти тестирование + // the past score may be 0, so we check it's existance like this + if (quiz.quizResultScore !== undefined) + +e.result Предыдущий результат: #{quiz.quizResultScore}% + diff --git a/handlers/quiz/templates/blocks/quiz-tablet-timeline.jade b/handlers/quiz/templates/blocks/quiz-tablet-timeline.jade new file mode 100644 index 000000000..b9a408d41 --- /dev/null +++ b/handlers/quiz/templates/blocks/quiz-tablet-timeline.jade @@ -0,0 +1,12 @@ +- var n = 0 + ++b.quiz-tablet-timeline.tablet-only + +e('h2').title Вопрос + +e('strong').num   + + while n < progressTotal + - n++ + if (n == progressNow) + | !{progressNow}  + | из  + +e('strong').total !{progressTotal} diff --git a/handlers/quiz/templates/blocks/quiz-timeline.jade b/handlers/quiz/templates/blocks/quiz-timeline.jade new file mode 100755 index 000000000..f0115c84e --- /dev/null +++ b/handlers/quiz/templates/blocks/quiz-timeline.jade @@ -0,0 +1,6 @@ +- var n = 0; + ++b.quiz-timeline + while n < progressTotal + - n++ + +e("span")(class="number" + (n == progressNow ? '_current' : ''))= n diff --git a/handlers/quiz/templates/blocks/quiz.jade b/handlers/quiz/templates/blocks/quiz.jade new file mode 100755 index 000000000..2e6962ddb --- /dev/null +++ b/handlers/quiz/templates/blocks/quiz.jade @@ -0,0 +1,6 @@ ++b.quiz + include quiz-timeline + include quiz-tablet-timeline + + form(action="/quiz/answer/#{quiz.slug}" data-quiz-question-form method="POST") + include question diff --git a/handlers/quiz/templates/blocks/result.jade b/handlers/quiz/templates/blocks/result.jade new file mode 100755 index 000000000..33ec4ef9b --- /dev/null +++ b/handlers/quiz/templates/blocks/result.jade @@ -0,0 +1,43 @@ +block append variables + + - var rotate = parseInt((quizResult.score * 1.8), 10) + 'deg' + + ++b.quiz-result + + +e.layout + + +e.left + +b.quiz-percents + +e("dl").result + +e("dt").text Ваш результат: + +e("dd") + +e("p").percents #{quizResult.score}% + + style .quiz-results-indicator__indicator:after { -webkit-transform: rotate(!{ rotate }); transform: rotate(!{ rotate }); } + + +e.center + +b.quiz-results-indicator + +e.indicator + +e.text Ваш предположительный уровень —  + span(class='quiz-results-indicator__level quiz-results-indicator__level_' + quizResult.level)= quizResult.levelTitle + + +e.right + +b.quiz-percents + +e("dl").result + +e("dt").text Вы прошли тест лучше, чем + +e("dd") + +e("p").percents !{ quizBelowPercentage }% + +e("p").text респондентов + + +e.save-result + + +e.bottom + +e('form').retry-form(data-quiz-result-retry-form action="/quiz/start/#{quiz.slug}" method="POST") + input(type="hidden", name="_csrf", value=csrf()) + +b('button').button_common.__retry-button(type="submit") + +e('span').text Пройти тест заново + +e('form').save-form(data-quiz-result-save-form action="/quiz/save/#{quiz.slug}" method="POST") + input(type="hidden", name="_csrf", value=csrf()) + +b('button')(type="submit").button._action.__save-button + +e('span').text Сохранить результат diff --git a/handlers/quiz/templates/index.jade b/handlers/quiz/templates/index.jade new file mode 100755 index 000000000..e345d6a12 --- /dev/null +++ b/handlers/quiz/templates/index.jade @@ -0,0 +1,22 @@ +extends /layouts/main + +block append head + link(href=pack('quiz', 'css') rel='stylesheet') + +block append variables + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + - var layout_header_class = "main__header_center" + +block content + +b.notification._message._info + +e.content Раздел работает в тестовом режиме. О любых проблемах и странностях сообщайте, пожалуйста, на github. + + + +b.intro На этой странице вы можете протестировать свои знания Javascript, выбрав один из тестов. + + include blocks/quiz-selector + + include blocks/quiz-explanations + + p Если у вас не получилось ответить на многие вопросы – не расстраивайтесь. Его цель – не только проверить знания, но и помочь заполнить пробелы в них. Многие вопросы неочевидны и требуют не только знаний, но и опыта. Удачи! diff --git a/handlers/quiz/templates/partials/_question.jade b/handlers/quiz/templates/partials/_question.jade new file mode 100755 index 000000000..fc22ef6cb --- /dev/null +++ b/handlers/quiz/templates/partials/_question.jade @@ -0,0 +1,3 @@ +include /bem + +include ../blocks/question diff --git a/handlers/quiz/templates/quiz-start.jade b/handlers/quiz/templates/quiz-start.jade new file mode 100755 index 000000000..f225eb01f --- /dev/null +++ b/handlers/quiz/templates/quiz-start.jade @@ -0,0 +1,34 @@ +extends /layouts/main + +block append head + link(href=pack('quiz', 'css') rel='stylesheet') + +block append variables + + - var layout_header_class = "main__header_center" + - var breadcrumbs = [{ title: 'JavaScript.ru', url: 'http://javascript.ru' }, { title: 'Тесты', url: '/quiz' }] + - var content_class = 'content_center' + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + + +b.quiz-start + + +e("p").description= quiz.description + + +e('form')(action="/quiz/start/#{quiz.slug}", method="POST").pane + input(type="hidden", name="_csrf", value=csrf()) + + +b("button")(type="submit").button._action + +e("span").text Начать тестирование + + +e("p").info + | Нажмите на кнопку выше, чтобы начать тестирование. + br + | Сразу после этого начнется отчет времени. + + include blocks/quiz-explanations + + script(src=pack("quiz", "js")) + script quiz.init(); diff --git a/handlers/quiz/templates/quiz.jade b/handlers/quiz/templates/quiz.jade new file mode 100755 index 000000000..b6f965f65 --- /dev/null +++ b/handlers/quiz/templates/quiz.jade @@ -0,0 +1,19 @@ +extends /layouts/main + +block append head + link(href=pack('quiz', 'css') rel='stylesheet') + + +block append variables + + - var layout_header_class = "main__header_center" + - var breadcrumbs = [{ title: 'JavaScript.ru', url: 'http://javascript.ru' }, { title: 'Тесты', url: '/quiz' }] + - var content_class = 'content_center' + - var sitetoolbar = true + - var layout_main_class = "main_width-limit" + +block content + include blocks/quiz + + script(src=pack("quiz", "js")) + script quiz.init(); diff --git a/handlers/quiz/templates/results.jade b/handlers/quiz/templates/results.jade new file mode 100755 index 000000000..fda4fea1a --- /dev/null +++ b/handlers/quiz/templates/results.jade @@ -0,0 +1,21 @@ +extends /layouts/main + +block append head + link(href=pack('quiz', 'css') rel='stylesheet') + +block append variables + + - var layout_header_class = "main__header_center" + - var breadcrumbs = [{ title: 'JavaScript.ru', url: 'http://javascript.ru' }, { title: 'Тесты', url: '/quiz' }] + - var sitetoolbar = true + - var content_class = 'content_center' + - var layout_main_class = "main_width-limit" + +block content + include blocks/result + + each question in quizQuestions + include blocks/question + + script(src=pack("quiz", "js")) + script quiz.init(); diff --git a/handlers/render.js b/handlers/render.js new file mode 100755 index 000000000..b9203b11d --- /dev/null +++ b/handlers/render.js @@ -0,0 +1,177 @@ +'use strict'; + +const moment = require('momentWithLocale'); +const util = require('util'); +const path = require('path'); +const config = require('config'); +const fs = require('fs'); +const log = require('log')(); +const jade = require('lib/serverJade'); +const _ = require('lodash'); +const assert = require('assert'); +const i18n = require('i18next'); +const money = require('money'); +const url = require('url'); + +// public.versions.json is regenerated and THEN node is restarted on redeploy +// so it loads a new version. +var publicVersions; + +function getPublicVersion(publicPath) { + if (!publicVersions) { + // don't include at module top, let the generating task to finish + publicVersions = require(path.join(config.projectRoot, 'public.versions.json')); + } + var busterPath = publicPath.slice(1); + return publicVersions[busterPath]; +} + +function addStandardHelpers(locals, ctx) { + // same locals may be rendered many times, let's not add helpers twice + if (locals._hasStandardHelpers) return; + + locals.moment = moment; + + locals._ = _; + + locals.url = url.parse(ctx.protocol + '://' + ctx.host + ctx.originalUrl); + locals.context = ctx; + + locals.analyticsEnabled = ctx.query.noa ? false : (ctx.host == 'learn.javascript.ru' && process.env.NODE_ENV == 'production'); + + // we don't use defer in sessions, so can assign it + // (simpler, need to call yield this.session) + locals.session = ctx.session; + + + locals.env = process.env; + + Object.defineProperty(locals, "user", { + get: function() { + return ctx.req.user; + } + }); + + locals.profileTabNames = { + quiz: 'Тесты', + orders: 'Заказы', + courses: 'Курсы', + aboutme: 'Публичный профиль', + account: 'Аккаунт' + }; + + // flash middleware may be attached later in the chain + Object.defineProperty(locals, "flashMessages", { + get: function() { + return ctx.flash && ctx.flash.messages; + } + }); + + var renderSimpledown; + Object.defineProperty(locals, "renderSimpledown", { + get: function() { + if (!renderSimpledown) { + renderSimpledown = require('renderSimpledown'); + } + return renderSimpledown; // attach at 1st use + } + }); + + locals.csrf = function() { + // function, not a property to prevent autogeneration + // jade touches all local properties + return ctx.user ? ctx.csrf : null; + }; + + // this.locals.debug causes jade to dump function + /* jshint -W087 */ + locals.deb = function() { + debugger; + }; + + locals.t = i18n.t; + locals.bem = require('bem-jade')(); + + locals.thumb = function(url, width, height) { + // return 2 times larger image for retina + var modifier = (width < 320 && height < 320) ? 't' : + (width < 640 && height < 640) ? 'm' : + (width < 1280 && height < 1280) ? 'l' : ''; + + return url.slice(0, url.lastIndexOf('.')) + modifier + url.slice(url.lastIndexOf('.')) + }; + + locals.currencyConvertRound = function(amount, from, to) { + return Math.round(money.convert(amount, {from: from, to: to})); + }; + + + locals.pack = function(name, ext) { + var versions = JSON.parse( + fs.readFileSync(path.join(config.manifestRoot, 'pack.versions.json'), {encoding: 'utf-8'}) + ); + var versionName = versions[name]; + // e.g style = [ style.js, style.js.map, style.css, style.css.map ] + + if (!Array.isArray(versionName)) return versionName; + + var extTestReg = new RegExp(`.${ext}\\b`); + + // select right .js\b extension from files + for (var i = 0; i < versionName.length; i++) { + var versionNameItem = versionName[i]; // e.g. style.css.map + if (/\.map/.test(versionNameItem)) continue; // we never need a map + if (extTestReg.test(versionNameItem)) return versionNameItem; + } + + throw new Error(`Not found pack name:${name} ext:${ext}`); + /* + if (process.env.NODE_ENV == 'development') { + // webpack-dev-server url + versionName = process.env.STATIC_HOST + ':' + config.webpack.devServer.port + versionName; + }*/ + + }; + + + + locals._hasStandardHelpers = true; +} + + +// (!) this.render does not assign this.body to the result +// that's because render can be used for different purposes, e.g to send emails +exports.init = function(app) { + app.use(function *(next) { + var ctx = this; + + this.locals = _.assign({}, config.jade); + + // render('article', {}) -- 2 args + // render('article') + this.render = function(templatePath, locals) { + + // add helpers at render time, not when middleware is used time + // probably we will have more stuff initialized here + addStandardHelpers(this.locals, this); + + this.log.debug("Lookup " + templatePath + " in " + this.templateDir); + + // warning! + // _.assign does NOT copy defineProperty + // so I use this.locals as a root and merge all props in it, instead of cloning this.locals + var loc = Object.create(this.locals); + + _.assign(loc, locals); + + templatePath += '.jade'; + var templatePathResolved = path.join(templatePath[0] == '/' ? loc.basedir : this.templateDir, templatePath); + + this.log.debug("render file " + templatePathResolved); + return jade.renderFile(templatePathResolved, loc); + }; + + yield* next; + }); + +}; diff --git a/handlers/requestId.js b/handlers/requestId.js new file mode 100755 index 000000000..6804fba5c --- /dev/null +++ b/handlers/requestId.js @@ -0,0 +1,11 @@ +var uuid = require('node-uuid').v4; + +// RequestCaptureStream wants "req_id" to identify the request +// we take it from upper chain (varnish? nginx on top?) OR generate +exports.init = function(app) { + app.use(function*(next) { + /* jshint -W106 */ + this.requestId = this.get('X-Request-Id') || uuid(); + yield next; + }); +}; diff --git a/handlers/requestLog.js b/handlers/requestLog.js new file mode 100755 index 000000000..60abccd30 --- /dev/null +++ b/handlers/requestLog.js @@ -0,0 +1,16 @@ + +exports.init = function(app) { + app.use(function*(next) { + + /* jshint -W106 */ + this.log = app.log.child({ + requestId: this.requestId + }); + + // fixme: remove (passport js issue fixed) + this.request.log = this.log; // passport.js strategy passes req around + + yield* next; + }); + +}; diff --git a/handlers/search/client/index.js b/handlers/search/client/index.js new file mode 100755 index 000000000..3b5f2445a --- /dev/null +++ b/handlers/search/client/index.js @@ -0,0 +1,24 @@ + +exports.init = function() { + var fixedForm = document.querySelector(".search-form_fixed"); + var fixedFormInput = fixedForm.querySelector(".search-form__query .text-input__control"); + var staticFormInput = document.querySelector(".search-form:not(.search-form_fixed) .search-form__query .text-input__control"); + var fixedInputOffset = parseInt(getComputedStyle(fixedForm, "").paddingTop); + + function updateFixedForm() { + if (staticFormInput.getBoundingClientRect().top <= fixedInputOffset) { + if (fixedForm.classList.contains("search-form_hidden")) { + fixedFormInput.value = staticFormInput.value; + } + fixedForm.classList.remove("search-form_hidden"); + } else { + if (!fixedForm.classList.contains("search-form_hidden")) { + staticFormInput.value = fixedFormInput.value; + } + fixedForm.classList.add("search-form_hidden"); + } + } + + window.addEventListener("scroll", updateFixedForm); + updateFixedForm(); // set initial state +}; \ No newline at end of file diff --git a/handlers/search/controllers/index.js b/handlers/search/controllers/index.js new file mode 100755 index 000000000..d0afe56a2 --- /dev/null +++ b/handlers/search/controllers/index.js @@ -0,0 +1,163 @@ +var elasticClient = require('elastic').client; + +var Task = require('tutorial').Task; +var Article = require('tutorial').Article; + +// known types and methods to convert hits to showable results +// FIXME: many queries to MongoDB for parents (breadcrumbs) Cache them? +var searchTypes = { + articles: { + title: 'Статьи учебника', + hit2url: function(hit) { + return Article.getUrlBySlug(hit.fields.slug[0]); + }, + hit2breadcrumb: function*(hit) { + var article = yield Article.findById(hit._id).select('slug title isFolder parent').exec(); + if (!article) return null; + var parents = yield* article.findParents(); + parents.forEach(function(parent) { + parent.url = parent.getUrl(); + }); + return parents; + } + }, + + tasks: { + title: 'Задачи', + hit2url: function(hit) { + return Task.getUrlBySlug(hit.fields.slug[0]); + }, + hit2breadcrumb: function*(hit) { + var task = yield Task.findById(hit._id).select('slug title parent').exec(); + if (!task) return null; + var article = yield Article.findById(task.parent).select('slug title isFolder parent').exec(); + if (!article) return null; + var parents = (yield* article.findParents()).concat(article); + parents.forEach(function(parent) { + parent.url = parent.getUrl(); + }); + return parents; + } + } + +}; + +exports.get = function *get(next) { + + var locals = {}; + locals.sitetoolbar = true; + locals.sidebar = false; + + var searchQuery = locals.searchQuery = this.request.query.query || ''; + var searchType = locals.searchType = this.request.query.type || 'articles'; + + locals.title = searchQuery ? 'Результаты поиска' : 'Поиск'; + + if (!searchTypes[searchType]) { + this.throw(400); + } + + locals.searchTypes = searchTypes; + + locals.results = []; + + // for every type - total results# + locals.resultsCountPerType = {}; + + if (searchQuery) { + var result = yield* search(searchQuery); + + var hits = result[searchType].hits.hits; + + + // will show these results + for (var i = 0; i < hits.length; i++) { + var hit = hits[i]; + + var hitFormatted = { + url: searchTypes[hit._type].hit2url(hit), + // if no highlighted words in title, hit.highlight.title would be empty + title: hit.highlight.title ? hit.highlight.title.join('… ') : hit.fields.title[0], + // if no highlighted words in text, hit.highlight.search would be empty + search: hit.highlight.search ? hit.highlight.search.join('… ') : '…', + breadcrumb: yield* searchTypes[hit._type].hit2breadcrumb(hit) + }; + + if (!hitFormatted.url || !hitFormatted.breadcrumb) { + this.log.error("Cannot find result from the search response in MongoDB", hit); + continue; + } + + locals.results.push(hitFormatted); + } + + // will just show counts + for(var type in result) { + locals.resultsCountPerType[type] = result[type].hits.total; + } + } + + this.body = this.render("index", locals); + +}; + + +/** + * search all types +result = { + articles: + { took: 4, + timed_out: false, + _shards: { total: 1, successful: 1, failed: 0 }, + hits: { total: 54, max_score: 2.7859237, hits: [Object] } }, + tasks: + { took: 2, + timed_out: false, + _shards: { total: 1, successful: 1, failed: 0 }, + hits: { total: 28, max_score: 2.7859237, hits: [Object] } } } + */ +function* search(query) { + + /*jshint -W106 */ + var queryBody = { + size: 50, + filter: { + bool: { + must_not: { + term: {isFolder: true} + } + } + }, + query: { + multi_match: { + query: query, + fields: ['title^10', 'search'] + } + }, + fields: ["title", "slug"], + highlight: { + pre_tags : [""], + post_tags : [""], + fields: { + search: {type: 'postings'}, + title: {type: 'plain'} + } + } + }; + + var queries = {}; + for(var type in searchTypes) { + // object of promises + queries[type] = elasticClient().search({ + index: 'js', + type: type, + body: queryBody + }); + } + + // 1 query per type to ES + // maybe: replace w/ ES aggregations? + var result = yield queries; + + return result; +} \ No newline at end of file diff --git a/handlers/search/index.js b/handlers/search/index.js new file mode 100755 index 000000000..d7744d897 --- /dev/null +++ b/handlers/search/index.js @@ -0,0 +1,7 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/search', __dirname)); +}; + diff --git a/handlers/search/router.js b/handlers/search/router.js new file mode 100755 index 000000000..e0509cf7f --- /dev/null +++ b/handlers/search/router.js @@ -0,0 +1,7 @@ +var Router = require('koa-router'); + +var index = require('./controllers/index'); + +var router = module.exports = new Router(); + +router.get('/', index.get); diff --git a/handlers/search/templates/index.jade b/handlers/search/templates/index.jade new file mode 100755 index 000000000..541638882 --- /dev/null +++ b/handlers/search/templates/index.jade @@ -0,0 +1,64 @@ +extends /layouts/main + +block append variables + - var layout_main_class = "main_width-limit" + +block content + + +b("form").search-form(action="/search/") + +e.content + +e.line + +e.query-wrap + +b.text-input.__query + +e('input').control(type="text", name="query" value=searchQuery) + +e.send-wrap + +b('button').button._action.__send(type="submit" name="type" value=searchType) + +e('span').text Найти + + if searchQuery + +e.footer + +e.types + each type, name in searchTypes + +e('button').type(type="submit" disabled=(name == searchType ? true : null) name="type" value=name) + = type.title + = ' (' + (resultsCountPerType[name] || 0) + ')' + + + if results.length + +b.search-results + each result in results + +e.result + +e.title + +e('a').title-link(href=result.url)!= result.title + +e.extract!= result.search + +e('ul').path + each crumb in result.breadcrumb + +e('li').path-step + +e('a').path-link(href=crumb.url)!= crumb.title + + + if results.length < resultsCountPerType[searchType] + +e.count-note Показываются первые #{results.length} результатов. + else if searchQuery + +b.search-results + | Извините, мы ничего не нашли + = ' ' + if searchType=="articles" + | в статьях учебника + if searchType=="tasks" + | в задачах + | . + + +b("form").search-form._fixed._hidden(action="/search/") + +e.content + +e.line + +e.query-wrap + +b.text-input.__query + +e('input').control(type="text" name="query" value=searchQuery) + +e.send-wrap + +b('button').button._action.__send(type="submit" name="type" value=searchType) + +e('span').text Найти + + + script(src=pack("search", "js")) + script search.init(); diff --git a/handlers/session.js b/handlers/session.js new file mode 100755 index 000000000..136e14e1d --- /dev/null +++ b/handlers/session.js @@ -0,0 +1,21 @@ +const mongoose = require('mongoose'); +const session = require('koa-generic-session'); +const mongooseStore = require('koa-session-mongoose'); +const config = require('config'); +const _ = require('lodash'); + +exports.init = function(app) { + + var options = { + store: mongooseStore.create({ + model: 'Session', + // expires in DB is same as cookie maxAge, but in seconds + expires: config.auth.session.cookie.maxAge / 1000 + }) + }; + + app.use(session(_.extend(options, config.auth.session))); + + app.keys = config.appKeys; // needed for cookie-signing + +}; diff --git a/handlers/staticPage/index.js b/handlers/staticPage/index.js new file mode 100755 index 000000000..241ad3664 --- /dev/null +++ b/handlers/staticPage/index.js @@ -0,0 +1,7 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use( mountHandlerMiddleware('/', __dirname) ); +}; + diff --git a/handlers/staticPage/router.js b/handlers/staticPage/router.js new file mode 100755 index 000000000..36fada69f --- /dev/null +++ b/handlers/staticPage/router.js @@ -0,0 +1,16 @@ +var Router = require('koa-router'); + +var glob = require('glob'); +var path = require('path'); +var router = module.exports = new Router(); + +var files = glob.sync(path.join('**', '*.jade'), { + cwd: path.join(__dirname, 'templates') +}); + +files.forEach(function(relPath) { + var url = relPath.replace('.jade', ''); + router.get('/' + url, function*() { + this.body = this.render(url); + }); +}); diff --git a/handlers/staticPage/templates/agreement.jade b/handlers/staticPage/templates/agreement.jade new file mode 100755 index 000000000..244cd0d2e --- /dev/null +++ b/handlers/staticPage/templates/agreement.jade @@ -0,0 +1,224 @@ + +extends /layouts/main + + +block append variables + + - var layout_main_class = "main_width-limit" + - var title = 'Пользовательское соглашение'; + - var sitetoolbar = true + +block append head + style. + .agreement ol { + counter-reset: agreement-item; + display: table; + border-spacing: 6px; + } + .agreement li { + font-weight: bold; + } + .agreement li p { + font-weight: normal; + } + .agreement li li { + font-weight: normal; + } + .agreement li { + counter-increment: agreement-item; + display: table-row; + } + .agreement li::before { + content: counters(agreement-item, '.') ". "; + display: table-cell; /* aha! */ + text-align: left; + width: 2em; + } + +block content + + + .agreement + p Добро пожаловать на JavaScript.ru! + + ol + li + | Общие положения + + ol + li. + Предметом настоящего Пользовательского соглашения (далее "Соглашение") являются отношения между + ИП Кантор Илья Александрович (ОГРНИП 308770000232690, Россия, 123423, г.Москва, ул.Народного Ополчения 15-4-3), + далее "Админинстратор", являющегося правообладателем Интернет-сервисов, расположенных в сети Интернет на доменах javascript.ru, + а также js.cx и lookatcode.com и соответствующих доменах третьего уровня + (далее в совокупности "Сайт"), и Вами (физическим лицом пользователем сети Интернет), далее "Пользователь", по поводу использования + Сайта и размещенных в нем сервисов (далее "Сервисы"). + + li. + Каждым доступом к Сайту и использованием Сервисов Пользователь соглашается соблюдать условия, описанные в настоящем Пользовательском соглашении. + + li. + Пользователь может ознакомиться с действующей версией Соглашения в сети Интернет по адресу https://learn.javascript.ru/agreement. + Соглашение может быть изменено Администратором без какого-либо специального уведомления. + Новая редакция Соглашения вступает в силу с момента опубликования на Сайте. + + li + | Интеллектуальная собственность + + ol + li. + Все объекты, доступные на Сайте, в том числе элементы дизайна, текст, графические изображения, иллюстрации, видео, программы, + базы данных, музыка, звуки и другие объекты, являются объектами исключительных прав Администратора, Пользователей и других правообладателей. + + li. + Пользователь не вправе воспроизводить, повторять и копировать, продавать, передавать кому-либо, + а также использовать для каких-либо коммерческих целей информацию, представленную на Сайте или доступ к ней, + кроме тех случаев, когда Пользователь получил такое разрешение от Администратора, а также когда Администратором письменно указаны другие условия лицензирования. + + li. + Используя Сервисы, позволяющие публиковать информацию на Сайте, а также предлагая информацию для публикации посредством сайта http://github.com + и не только, Пользователь безвозмездно предоставляет Администратору простую (неисключительную) лицензию + на воспроизведение информации, её публичный показ и доведение до всеобщего сведения, распространение любыми способами, + сублицензирование и переработку, на весь срок действия исключительных прав на объекты авторских и (или) смежных прав, + содаржащихся в информации, для использования на территории всех стран мира. + Пользователь гарантирует наличие права на распоряжение информацией на этих условиях в необходимом объеме. + + li. + В случае привлечения Администратора к ответственности или наложения на него взыскания в связи с допущенными Пользователем + нарушениями прав и/или интересов третьих лиц, а равно установленных законодательством запретов или ограничений, + такой Пользователь обязан в полном объеме возместить убытки Администратора. + + + li + | Отказ от ответственности + + ol + + li. + Администратор вправе прекратить (временно или окончательно) работу Сайта, Сервисов или любых их частей на период обновления, + профилактического обслуживания или по иной причине, а также изменить их функциональность и внешний вид без предварительного уведомления. + + li. + Администратор никак не связан с информацией, размещённой Пользователями при помощи Сервисов, не осуществляет проверку содержания, + подлинности и безопасности такой информации, а равно её соответствия требованиям действующего законодательства + и наличия у Пользователей необходимого объема прав на её распространение и/или использование. + + li. + Размещая информацию при помощи Сервисов или предлагая её к размещению при помощи сайта http://github.com или другими способами, + Пользователь самостоятельно несет ответственность за её соответствие требованиям действующего законодательства, + включая ответственность перед третьим лицами в случаях, когда размещение такой информации на Сайте + нарушает права и законные интересы третьих лиц, в том числе личные неимущественные права авторов, + иные интеллектуальные права третьих лиц, и/ или посягает на принадлежащие им нематериальные блага. + li. + Пользователь соглашается с тем, что Администратор не обязан осуществлять предварительную проверку информации любого вида, + размещаемой Пользователем при помощи Сервисов сайта или предложенной им к размещению посредством сайта http://github.com или иным способом, + а также на то, что Администратор + имеет право по своему усмотрению отказать Пользователю в размещении и/или распространении такой информации + и удалить её. Пользователь должен самостоятельно оценивать все риски, связанные с размещением и распространением такой информации, + включая оценку её безопасности, полноты и полезности. + + li. + Любую информация и/или материалы (в том числе загружаемое ПО, письма, какие- либо инструкции и руководства к действию и т.д.), + доступ к которым Пользователь получает с использованием Сайта и Сервисов, + Пользователь может использовать на свой собственный риск и самостоятельно несет ответственность за возможные последствия использования + указанных информации и/или материалов, в том числе за ущерб, который это может причинить компьютеру Пользователя или третьим лицам, + за потерю данных или любой другой вред. + + li. + Администратор не несет ответственности за любые виды убытков, + произошедшие вследствие использования Пользователем Сайта, Сервисов и информации с Сайта. + + li. + При любых обстоятельствах ответственность Администратора ограничена полученной от Пользователя оплатой + и возлагается на него исключительно при наличии в его действиях вины. + + li. + Администратор имеет право по своему усмотрению заблокировать либо удалить учетную запись Пользователя, + а также отказать Пользователю в использовании Сайта и Сервисов. + + li + | Политика конфиденциальности + + ol + li. + Под персональной информацией (далее "Персональная информация") понимается информация, которую Пользователь предоставляет о себе с использованием Сервисов, + при регистрации и входе в сайт, а также данные, которые передаются в автоматическом режиме при взаимодействии с Пользователем, + включая IP-адрес, cookie, другие HTTP-заголовки, но не ограничиваясь ими. + + li. + Пользователь осознаёт и принимает возможность размещения на страницах Сайта программного обеспечения третьих лиц, включая: + системы по сбору статистики посещений, системы комментирования, плагины социальных сетей, системы логирования ошибок, + но не ограничиваясь ими, в результате чего такие лица могут получать обезличенные данные, собираемые на основе Персональной информации. + + li. + Сайт осуществляет обработку, в том числе сбор и хранение Персональной информации, + её охрану от несанкционированного доступа и использование в соответствиии с законодательством + Российской федерации и данной политикой конфиденциальности. + + li. + Заключая Соглашение, Пользователь даёт бессрочное безотзывное письменное согласие на любые способы обработки своих персональных данных, + включая любое действие (операцию) или совокупность действий (операций), совершаемых с использованием средств автоматизации + или без использования таких средств с персональными данными, + в том числе сбор, запись, систематизацию, накопление, хранение, уточнение (обновление, изменение), + извлечение, использование, передачу (распространение, предоставление, доступ), + обезличивание, блокирование, удаление, уничтожение персональных данных в связи с выполнением Соглашения. + + li. + Администратор не вправе передавать Персональную информацию третьим лицам, за исключением тех случаев, + когда передача необходима в рамках использования Сервисов, выполнения данного Соглашения, + или других соглашений, заключённых с Пользователем, + по запросу суда или иного уполномоченного государственного органа в рамках установленной законодательством процедуры, + для защиты прав и законных интересов Администратора в связи с нарушением заключенных с Пользователем договоров, либо когда + Пользователь выразил свое согласие на такую передачу, + включая случаи применения Пользователем настроек используемого программного обеспечения, + не ограничивающих предоставление определенной информации. + + li. + На сайте установлена внешняя система комментирования (Disqus), взаимодействие Пользователя с которой происходит независимо от Сайта, + Пользователь отдаёт себе отчёт и принимает все риски, связанные с тем, что политика конфиденциальности этой системы + может отличаться от политики конфиденциальности Сайта. + + li. + Администратор вправе посылать Пользователю на указанный им электронный адрес информационные электронные сообщения произвольной формы и содержания. + + li. + Пользователь вправе в любой момент самостоятельно отредактировать предоставленную им при регистрации или авторизации + Персональную информацию при помощи Сервисов Сайта, а также удалить её на странице Аккаунта. + + li. + В целях повышения качества Сервисов и обеспечения возможности правовой защиты, Администратор вправе хранить лог-файлы о действиях, + совершенных Пользователем, в течение 3 (Трёх) лет с момента их совершения. + + li + | Заключительная информация + + ol + + li. + Ничто в Соглашении не может пониматься как установление между Пользователем и Администратором + агентских отношений, отношений товарищества, отношений по совместной деятельности, отношений личного найма, + либо каких-то иных отношений, прямо не предусмотренных Соглашением. + + li. + Признание судом какого-либо положения Соглашения недействительным, или не подлежащим принудительному исполнению, не влечет недействительности или неисполнимости иных положений Соглашения. + + li. + Бездействие со стороны Администратора в случае нарушения Пользователем либо иными лицами положений Соглашения + не лишает Администратора права предпринять соответствующие действия в защиту своих интересов позднее. + + li. + Все споры сторон по настоящему соглашению подлежат разрешению путем переписки и переговоров + с использованием обязательного досудебного (претензионного) порядка. + В случае невозможности достичь согласия между сторонами путем переговоров в течение 30 (тридцати) календарных дней + с момента получения другой Стороной письменной претензии, рассмотрение спора должно быть передано любой заинтересованной + стороной в суд по месту нахождения Администратора. + + li + | Реквизиты Администратора + + p ИП Кантор Илья Александрович + p ОГРНИП 308770000232690 + p Адрес Россия, 123423, г. Москва, ул. Народного Ополчения 15-4-3 + p Телефон +7(903)541-94-41 + p Электронный адрес iliakan@javascript.ru + diff --git a/handlers/staticPage/templates/course-js-howto.jade b/handlers/staticPage/templates/course-js-howto.jade new file mode 100644 index 000000000..55f222aac --- /dev/null +++ b/handlers/staticPage/templates/course-js-howto.jade @@ -0,0 +1,13 @@ +extends /layouts/main + + +block append variables + + - var layout_main_class = "main_width-limit" + - var title = 'Онлайн-курс: настройка окружения'; + - var sitetoolbar = true + + +block content + + include:simpledown ./course-js-howto.md diff --git a/handlers/staticPage/templates/course-js-howto.md b/handlers/staticPage/templates/course-js-howto.md new file mode 100644 index 000000000..402ea067f --- /dev/null +++ b/handlers/staticPage/templates/course-js-howto.md @@ -0,0 +1,208 @@ +Эта инструкция -- о том, как настроить у себя окружение для обучения. + +Прочитайте, пожалуйста, ее полностью. Настройте всё и, желательно, протестируйте на собрании. +Это важно, чтобы вы могли сразу же полноценно принимать участие в процессе. + +Для общения используется одновременно видео, аудио и чат. + +## Общение в чате + +Для общения в чате используется Jabber-клиент. + +Самые популярные клиенты: + +
      +
    • Для Windows и Linux: Pidgin.
    • +
    • Для MacOS: Adium.
    • +
    + +Зарегистрируйтесь, пожалуйста, на сайте https://learn.javascript.ru, используя тот же email, что на javascript.ru. + +Для настройки вам понадобятся: +
      +
    • Имя страницы профиля в качестве логина -- его можно посмотреть в профиле, во вкладке Аккаунт. Оно обычно похоже на имя пользователя, но не всегда совпадает, так как в нём не допускается кириллица, пробелы и т.п.
    • +
    • Пароль для входа в сайт learn.javascript.ru (не javascript.ru). Если вы всегда входили через социальную сеть -- его может не быть, тогда создайте его во вкладке профиля Аккаунт.
    • +
    • Номер комнаты -- можно посмотреть на странице участника, доступной с вкладки профиля Курсы (в старом движке -- на странице заказа).
    • +
    + +Обратим внимание: имя пользователя и email вам не нужны. Только то, что указано выше. + +Например, если ваши данные: +
      +
    • Email: `vasya@gmail.com`
    • +
    • Имя пользователя: `Василий Иванович`
    • +
    • Имя страницы профиля: `vasily-ivanovich`
    • +
    • Пароль: `chapaev`
    • +
    + +То для чата вам понадобятся `vasily-ivanovich` как логин и `chapaev` как пароль. + +Если вы уже знаете, как настраивать Jabber-клиенты, то вот детали настройки: + +
      +
    • Сервер: javascript.ru
    • +
    • Сервер конференций: conference.javascript.ru
    • +
    • Название комнаты: со страницы группы, это число вида 12982351439.
    • +
    + +В качестве ника(псевдонима) для комнаты укажите свое имя и фамилию в формате "Имя Фамилия" (учитывая регистр). + +Также есть короткое видео по установке и настройке Pidgin + и Adium с нуля. + +## Система для разделения экрана и общения + +Для ее использования в браузере должна быть установлена и включена java. Если у вас ее нет -- скачать можно здесь: http://java.com/ru/download/. + +Для захода в систему зайдите на адрес http://joingotowebinar.com и укажите в первом поле название комнаты, а во втором -- `логин@javascript.ru`. + +[warn] +Нужно использовать именно номер комнаты и логин, например 12982351439 и vasily-ivanovich@javascript.ru. Свою почту указывать не нужно. + +Ваша почта намеренно не используется в настройках из соображений приватности. +[/warn] + +Для запуска системы отвечайте Yes на вопросы. Если ничего не запускается -- возможно, нужно нажать кнопку Launch или Download Software и запустить скачанную программу вручную. + +Когда программа поставится, при следующем заходе скачивать или запускать ее заново будет не нужно. + +[warn] +Заход в видео возможен только в период проведения мероприятия. + +При попытке зайти во "внеурочное" время видео не запустится. Это нормально. +[/warn] + +Заранее, для проверки, можно подключиться по адресу [](https://www3.gotomeeting.com/join/406552062), людей там нет, но программа должна поставиться и запуститься. +Полная инструкция по тестированию входа находится на [](http://support.citrixonline.com/en_US/GoToMeeting/help_files/GTM140010?title=Test+Your+GoToMeeting+Connection). + +Практика показывает, что если у вас стоит Java и работает Skype, то и видео тоже нормально заработает. + + +## Общение голосом + +Для общения голосом служит та же система, что и для видео. + +Желательно иметь микрофон неподалёку, хотя он и не обязателен для участия, задавать вопросы можно и текстом в чате. + +## Как задавать вопросы? + +Темп занятий соответствует тому, насколько я вижу понимание с вашей стороны. +Если вдруг вам что-то непонятно в материале или решении задачи -- обязательно говорите об этом, задавайте вопросы. + +Бывает так, что вроде бы непонятно, но конкретно сформулировать сложно. +В этом случае отличным выходом является вопрос "с этого момента поподробнее". Главное - спрашивайте, участвуйте в занятии. + +Ответы на ваши вопросы могут содержать дополнительные интересные сведения, которые помогут не только вам, но и другим участникам. +Поэтому участвуйте, задавайте, все вам скажут только спасибо. + +Задавать вопросы можно двумя способами. + +
      +
    1. Первый -- написать в общем чате. Настройки чата описаны выше
    2. +
    3. Второй -- спросить голосом. +Для этого нужно нажать на кнопку "ладонь со стрелкой вверх" ("Raise hand"), которая находится на мини-пульте управления системой разделения экрана. +По умолчанию это справа-сверху. В этом случае, когда ведущий увидит вашу руку -- он передаст вам "микрофон". + +При получении микрофона значок микрофона на мини-пульте изменится и раздастся голосовое оповещение на английском "unmuted", и вас будет слышно.
    4. +
    + +Бывает, что поднятая рука заметна ведущему не сразу, тогда можно написать об этом в чате -- "вопрос голосом". + +## Решение задач + +Для обмена решениями задач используется онлайн-песочница. Для учебника взят Plunker, + но вы можете использовать и jsbin и CodePen и любую другую. + +Все решения просьба подписывать сверху своим именем, можно комментарий под <html>: + +[html] + + + +... +[/html] + +...И, конечно, решения нужно не только делать, но и показывать их. Но показать не все, а только те, которые отличаются от приведённых в учебнике. + +Рекомендуемый алгоритм действий при решении задачи: + +
    +
    Если вы решили задачу сами...
    +
    В этом случае нужно посмотреть решение из учебника -- вдруг там подводные камни где-то, и просто чтобы увидеть альтернативный вариант. + +Если ваше решение чем-то отличается от данного в учебнике -- покажите его на занятии. +
    +
    Если вы не решили, но разобрались в решении...
    +
    Включать решение из учебника в домашнюю работу не надо, оно не ваше.
    +
    Если вы не решили и не понятны какие-то моменты в решении. +
    Обязательно спросите на занятии!
    +
    + +Любые ваши вопросы определённо стоят того, чтобы их обсудить на занятии. + + +## Дополнительно + +Вам также может понадобиться просмотр PDF. Как правило, для этого используют Acrobat Reader. Скачать можно, например, здесь (выберите OS, язык и уберите галочку Free McAfee). + +Ну и, конечно же, нужны будут браузеры, которые вы собираетесь поддерживать. Обычно это Chrome, Internet Explorer и Firefox. + +Настройте свое рабочее место. Поставьте редакторы -- я использую Webstorm и Sublime, +но есть и много других, выбор целиком ваш. + +Обязательно выставьте точное время на часах (свериться можно с [google](https://www.google.ru/search?q=время)). Это нужно для координации времени на перерывы и решение задач. + +Все эти приготовления и система задуманы так, чтобы сделать процесс обучения максимально комфортным и эффективным. + +Если что-то из этой инструкции непонятно -- задавайте вопросы на mk@javascript.ru, я на них отвечу. + +## Возможные проблемы и их решения + + +### Если чат не работает + +
      +
    1. Во-первых, проверьте, что вы вводите в качестве логина именно имя страницы профиля. Оно указано во вкладке профиля Аккаунт. + +Это не имя пользователя (хотя зачастую совпадает). Это не часть вашего email.
    2. +
    3. Во-вторых, проверьте пароль -- это должен быть пароль для входа на сайт learn.javascript.ru.
    4. +
    5. Это бывает весьма редко, но некоторые провайдеры имеют сложности с правильным разрешением особых ДНС-записей для Jabber. +В результате аккаунт не может подключиться. +Попробуйте поставить DNS-сервер `8.8.8.8` (это открытый сервер от Google), если заработает, значит дело в этом.
    6. +
    + +Если всё ещё не работает -- напишите мне на mk@javascript.ru, постараюсь помочь. + +**Чат должен работать в любое время, проверьте его заранее.** + + +### Если не работает видео + +**Видео, в отличие от чата, работает только во время занятий. Как правило, оно стартует в течение 1-2 минут после захода ведущего в чат.** + +Если вход не удаётся -- проверьте, во-первых, правильные ли данные вы вводите. + +**На сайте http://joingotowebinar.com в поле E-mail нужно ввести имя страницы профиля, прибавив к нему `"javascript.ru"` например `vasya@javascript.ru`, а не ваш email.** + +В поле Webinar Id вводите `Номер комнаты` со страницы группы. + +Если всё ещё не работает -- посмотрите системные требования. Операционная система: Windows или Mac OS. Нужна Java. + +Бывает так, что автоматически система не стартует, на этот случай при входе есть предложение скачать (download) программу и запустить её вручную. + +Напоминаю, что во время онлайн-собрания можно задавать вопросы по Skype, ник: `javascript.ru`. + +### Форс-мажор: если нет ведущего + +Если вдруг случится что-то непредвиденное (на линии электропередач упало дерево, интернет-провода погрыз ополоумевший барсук, ведущего переехал самосвал) -- занятия всё равно будут, +но, возможно, с опозданием или переносом. + +Подобное бывает очень редко. + +Обычно занятия начинаются по расписанию. Максимально возможное опоздание ведущего -- 15 минут. +Если его нет дольше и нет информации, значит произошло что-то серьезное. + +Можно попытаться узнать, что именно, позвонив по телефону +7(903)541-94-41. Не стесняйтесь -- звоните. +В качестве финального порога отмены занятия устанавливается задержка на 30 минут. + +Разъяснения и соответствующее обновление расписания в этом случае будут в ближайшее возможное время. diff --git a/handlers/staticPage/templates/courses-offer.jade b/handlers/staticPage/templates/courses-offer.jade new file mode 100644 index 000000000..b5fc36b14 --- /dev/null +++ b/handlers/staticPage/templates/courses-offer.jade @@ -0,0 +1,169 @@ + +extends /layouts/main + + +block append variables + + - var layout_main_class = "main_width-limit" + - var title = 'Договор-оферта'; + - var sitetoolbar = true + +block append head + style. + .agreement ol { + counter-reset: agreement-item; + display: table; + border-spacing: 6px; + } + .agreement li { + font-weight: bold; + } + .agreement li p { + font-weight: normal; + } + .agreement li li { + font-weight: normal; + } + .agreement li { + counter-increment: agreement-item; + display: table-row; + } + .agreement li::before { + content: counters(agreement-item, '.') ". "; + display: table-cell; /* aha! */ + text-align: left; + width: 2em; + } + +block content + + + .agreement + p 05 сентября 2009г. + + p Публичный договор-оферта по оказанию информационно-консультационных услуг. + + ol + li + | Общие положения + ol + li Данный документ является официальным предложением (публичной офертой) индивидуального предпринимателя Кантора Ильи Александровича (в дальнейшем Исполнитель), действующего на основании свидетельства о государственной регистрации физического лица в качестве индивидуального предпринимателя 308770000232690, выданного 23 апреля 2008 года, и содержит все существенные условия договора на оказание информационно-консультационных услуг в форме информационно-консультационных семинаров (далее Мероприятия), + li Данный договор-оферта не требует подписания в письменном виде. + li В соответствии с пунктом 2 статьи 437 Гражданского Кодекса Российской Федерации (ГК РФ) в случае принятия изложенных ниже условий и оплаты услуг юридическое или физическое лицо, производящее акцепт этой Оферты, становится Заказчиком (в соответствии с пунктом 3 статьи 438 ГК РФ акцепт Оферты равносилен заключению договора на условиях, изложенных в Оферте), а Исполнитель и Заказчик совместно - Сторонами договора Оферты. + li + | Термины + ol + li. + В настоящей публичной Оферте нижеприведенные термины используются в следующем значении: Оферта - настоящий документ "Публичный договор-оферта по оказанию дистанционных информационно-консультационных услуг", опубликованный в сети Интернет по адресу: https://learn.javascript.ru/courses-offer. + li. + Акцепт Оферты - полное и безоговорочное принятие Оферты путем осуществления действий, указанных в пункте 2 раздела "Условия и порядок предоставления услуг" настоящей Оферты. Акцепт Оферты создает Договор Оферты. + li. + Заказчик - лицо, осуществившее Акцепт Оферты, и являющееся таким образом Заказчиком услуг Исполнителя по заключенному договору Оферты. + li. + Договор Оферты - договор между Исполнителем и Заказчиком на предоставление дистанционных информационно-консультационных услуг, который заключается посредством Акцепта Оферты. + li. + Сайт – интернет-сайт, размещённый на домене javascript.ru или на его поддоменах, в частности на поддомене learn.javascript.ru. + li. + Прейскурант - действующий систематизированный перечень дистанционных информационно-консультационных услуг Исполнителя с ценами, публикуемый на Сайте на странице с адресом: https://learn.javascript.ru/courses, а также и на других страницах, напрямую доступных по ссылкам с этого адреса. + + li + | Предмет Оферты + ol + li. + Предметом настоящей Оферты является предоставление Заказчику информационно-консультационных услуг в соответствии с условиями настоящей Оферты и текущим Прейскурантом услуг Исполнителя. + li. + Перечень оказываемых дистанционных информационно-консультационных услуг приведен в Приложении 1, являющимся неотъемлемой частью настоящей Оферты. + li. + Публичная Оферта и Приложения к публичной Оферте являются официальными документами и публикуются на Сайте по адресу: https://learn.javascript.ru/courses-offer. + li. + Исполнитель имеет право в любой момент изменять Прейскурант и условия настоящей публичной Оферты в одностороннем порядке без предварительного согласования с Заказчиком, обеспечивая при этом публикацию измененных условий на Сайте, изменения вступают в силу немедленно после опубликования. + + li + | Условия и порядок предоставления услуг + ol + li. + Информационно-консультационные услуги предоставляются в полном объеме при условии их 100% (сто процентов) оплаты Заказчиком. + li. + Ознакомившись с Прейскурантом Исполнителя, списком предоставляемых услуг и текстом настоящей публичной Оферты, Заказчик оформляет на Сайте электронную заявку, в которой указывает одно или несколько Мероприятий, а также электронные адреса участников, если они не совпадают с электронным адресом Заказчика. + li. + На основании полученной заявки Исполнитель выставляет Заказчику счет на оплату выбранной услуги. + li. + Заказчик перечисляет денежные средства на расчетный счет Исполнителя. + li. + После проведения Заказчиком оплаты выставленного счета и зачисления денежных средств на расчетный счет Исполнителя, договор Оферты вступает в силу. + li. + Срок Акцепта Оферты составляет 5 (пять) рабочих дней с момента выставления счета на оплату дистанционных информационно-консультационных услуг. + li. + В течение не более трех рабочих дней с момента зачисления денежных средств Заказчика на расчетный счет Исполнителя, последний высылает письма по электронным адресам, указанным в электронной заявке Заказчика. Каждый участник должен подтвердить своё участие указанным в письме образом, при необходимости зарегистрировавшись на Сайте и указав своё имя и фамилию. + li. + Не позднее даты начала Мероприятия, указанной в Прейскуранте, Заказчик начинает высылать участникам по электронной почте инструкции и/или блоки информационных материалов. Информационные материалы могут включать в себя информационные, методические материалы, тесты, ситуационные задачи. Продолжение (следующие блоки информационных материалов) высылаются участникам после прохождения и усвоения участниками предыдущей части материалов. + li. + В ходе изучения информационных материалов участники имеют право задавать вопросы по изучаемому блоку способами, указанными в описании Мероприятия на Сайте. Дистанционные информационно-консультационные услуги оказываются Исполнителем в течение не более трех месяцев. + li. + При запросе со стороны Заказчика Исполнитель по окончании услуг формирует односторонний Акт об оказанных услугах и направляет его Заказчику. + li. + Услуги считаются оказанными Исполнителем надлежащим образом и принятыми Заказчиком в полном объеме, если в течении пятнадцати дней с даты окончания Мероприятий Исполнитель не получил от Заказчика мотивированных письменных возражений. По истечении срока, указанного выше, претензии Заказчика относительно недостатков Услуг, в том числе по количеству(объему), стоимости и качеству не принимаются. + li. + Заказчик вправе отказаться от услуг Исполнителя полностью или частично в течение действия срока договора Оферты, при этом возврат сумм, уплаченных по данному договору Оферты, Исполнителем не производится. В случае отказа от услуг Заказчик обязан уведомить Исполнителя. + li. + Заказчику и участникам запрещается распространять (публиковать, размещать на Интернет- сайтах, делать рассылку по электронной почте, копировать, передавать или перепродавать третьим лицам) в коммерческих или некоммерческих целях предоставляемую Исполнителем информацию и материалы в рамках настоящего Договора, создавать на ее основе информационные продукты, производить её фотосъёмку, аудио- или видеозапись , аудио- или видеотрансляцию, а также использовать эту информацию каким-либо иным образом, кроме как для обучения участников. Заказчик и участники обязуются сохранять конфиденциальность в отношении учебных и других материалов, полученных вследствие выполнения настоящего договора. + li. + По письменному требованию Заказчика Исполнитель может оформить печатную версию Оферты с подписями Сторон, равному по юридической силе настоящему публичному договору-оферте. Письменным требованием Заказчика о подписании бумажного экземпляра настоящей Оферты считается доставка Исполнителю подписанной Заказчиком в двух экземплярах печатной версии настоящей Оферты, содержащей паспортные данные Заказчика, если Заказчик – физическое лицо и сведения о юр. лице, если Заказчик – юридическое лицо. Адрес для отправки: 125445, г. Москва, ул. Беломорская д.3 корп. 1 кв. 71, Кантору Илье Александровичу. + + li + | Срок действия договора: + ol + li. + Договор вступает в силу с момента поступления на счет Исполнителя авансового платежа от Заказчика, согласно п.4.5. настоящего договора. + li. + Условия настоящего Договора могут быть изменены только по взаимному соглашению Сторон. Соглашение об изменении и дополнении настоящего Договора имеет силу только в том случае, если оно оформлено в письменном виде и подписано обеими сторонами. + li. + Исполнитель имеет право в любой момент отказаться от настоящего Договора, уведомив Заказчика. При этом Договор будет считаться расторгнутым с момента получения Заказчиком уведомления от Исполнителя, если иной срок не указан в таком уведомлении. Авансовая оплата в этом случае возвращается Заказчику полностью, если отказ произошёл до начала Мероприятий, и пропорционально объёму оказанных услуг, если отказ произошёл во время Мероприятий. + + li + | Прочие условия: + ol + li. + Настоящий договор составлен в двух экземплярах, по одному для каждой из сторон, имеющих равную юридическую силу. + + li + | Споры сторон: + ol + li. + Все споры, возникающие при исполнении настоящего Договора, решаются Сторонами путем переговоров, которые могут проводиться, в том числе, путем отправления писем по почте. + li. + Если Стороны не придут к cоглашению путем переговоров, все споры и рассматриваются в претензионном порядке. Срок рассмотрения претензии — три недели с даты получения претензии. + li. + При любых обстоятельствах ответственность Исполнителя ограничена суммой полученной оплаты и возлагается на него исключительно при наличии в его действиях вины. + li. + В случае если споры не урегулированы Сторонами с помощью переговоров и в претензионном порядке, они передаются заинтересованной Стороной в Арбитражный суд города Москвы. + + li + | Обстоятельства непреодолимой силы: + ol + li. + Исполнитель не несет ответственности за нарушение условий договора Оферты, + если такое нарушение вызвано действием обстоятельств непреодолимой силы (форс-мажор), + включая: действия органов государственной власти, пожар, наводнение, землетрясение, другие стихийные действия, + отсутствие электроэнергии и/или сбои работы компьютерной сети, забастовки, гражданские волнения, + беспорядки, болезнь Исполнителя и любые иные обстоятельства, не ограничиваясь перечисленным, + которые могут повлиять на выполнение Исполнителем условий настоящей публичной Оферты и неподконтрольные Исполнителю. + li. + В случае наступления обстоятельств, описанных в п.8.1., + срок исполнения Исполнителем своих обязательств по настоящему Договору отодвигается соразмерно времени, + в течение которого будут действовать такие обстоятельства. + Исполнитель в этом случае обязан в течение 3 (трех) календарных дней со дня наступления указанных обстоятельств + или позже, если обстоятельства не позволяют сделать это в указанный срок, письменно известить об этом Заказчика. + li. + В случае если вышеуказанные обстоятельства будут продолжаться более 20 (двадцати) календарных дней, любая из Сторон Договора может расторгнуть настоящий Договор в одностороннем порядке. + + + li + | Реквизиты Исполнителя + + p ИП Кантор Илья Александрович + p ОГРНИП 308770000232690 + p Адрес Россия, 123423, г. Москва, ул. Народного Ополчения 15-4-3 + p Телефон +7(903)541-94-41 + p Электронный адрес iliakan@javascript.ru + diff --git a/handlers/staticPage/templates/interkassa.jade b/handlers/staticPage/templates/interkassa.jade new file mode 100644 index 000000000..8e21db001 --- /dev/null +++ b/handlers/staticPage/templates/interkassa.jade @@ -0,0 +1,36 @@ +extends /layouts/main + +block append variables + - var layout_main_class = "main_width-limit" + - var title = 'Интеркасса'; + - var sitetoolbar = true + +block content + + p. + Оплата за курсы картами Visa/MasterCard, а также в терминалах и с Украины временно принимается + используя эту форму. + + p. + Для этого оформите запись на странице курса с оплатой через "Сбербанк", + и полученный номер заказа введите в форме ниже. + + p. + Затем нажмите "Оплатить". + + form(name="payment" method="POST" action="https://sci.interkassa.com/" accept-charset="UTF-8") + input(type="hidden" name="ik_co_id" value="529dd1d1bf4efcc635dbe96e") + | Введите номер заказа + br + input(type="number" name="ik_pm_no" placeholder="1234" value="" autofocus) + br + | Сумма к оплате + br + input(type="number" name="ik_am" placeholder="15000") + input(type="hidden" name="ik_cur" value="RUB") + input(type="hidden" name="ik_desc" value="Оплата курсов") + input(type="submit" value="Оплатить") + + p. + По всем вопросам вы можете обращаться по адресу mk@javascript.ru, + он часто проверяется. diff --git a/handlers/throwFinish.js b/handlers/throwFinish.js new file mode 100755 index 000000000..a0b66ddd7 --- /dev/null +++ b/handlers/throwFinish.js @@ -0,0 +1,31 @@ +/** + * Adds this.throwFinish() to stop request processing + * @constructor + */ + +function FinishRequestProcessing() { + this.message = "Finish request"; +} + +exports.init = function(app) { + + app.use(function*(next) { + this.throwFinish = function() { + throw new FinishRequestProcessing(); + }; + + try { + yield* next; + } catch (err) { + if (err instanceof FinishRequestProcessing) { + this.log.debug("throwFinish cought"); + // do nothing + return; + } else { + throw err; + } + } + + }); + +}; diff --git a/handlers/time.js b/handlers/time.js new file mode 100755 index 000000000..825582ae1 --- /dev/null +++ b/handlers/time.js @@ -0,0 +1,9 @@ + +exports.init = function(app) { + app.use(function*(next) { + var d = new Date(); + yield next; + console.log("time diff: " + (new Date() - d)/1000 + "s"); + }); + +}; diff --git a/handlers/tutorial/client/ebookExtras.js b/handlers/tutorial/client/ebookExtras.js new file mode 100755 index 000000000..84d2803a5 --- /dev/null +++ b/handlers/tutorial/client/ebookExtras.js @@ -0,0 +1,47 @@ +exports.init = function() { + + relinkToHeaders(); + + replaceSlashesInFragments(); + +}; + +// Internal links like /object +// In Ebook article headers get id=url, e.g h2(id=/object) +// Task headers also get similar urls +// a(href=/object) should go to a(href=#/object) (if exists) +function relinkToHeaders() { + + var internalLinks = document.querySelectorAll('a[href^="/"]'); + + for (var i = 0; i < internalLinks.length; i++) { + var link = internalLinks[i]; + if (document.getElementById(link.getAttribute('href'))) { + link.setAttribute('href', '#' + link.getAttribute('href')); + } + } + +} + +// quick fix for ebook-converter issue +// http://www.mobileread.com/forums/showthread.php?p=3044812#post3044812 +// contrary to http://tools.ietf.org/html/rfc3986 +// forbids / in fragments https://github.com/kovidgoyal/calibre/blob/ef09e886b3d95d6de5c76ad3a179694ae75c65f4/src/calibre/ebooks/conversion/plugins/epub_output.py#L350-L359 +function replaceSlashesInFragments() { + + var internalLinks = document.querySelectorAll('a[href^="#"]'); + + for (var i = 0; i < internalLinks.length; i++) { + var link = internalLinks[i]; + link.setAttribute('href', link.getAttribute('href').replace(/\//g, '-')); + } + + var elems = document.querySelectorAll('[id]'); + + for (var i = 0; i < elems.length; i++) { + var elem = elems[i]; + elem.id = elem.id.replace(/\//g, '-'); + } + + +} \ No newline at end of file diff --git a/handlers/tutorial/client/index.js b/handlers/tutorial/client/index.js new file mode 100755 index 000000000..4f05e5d4c --- /dev/null +++ b/handlers/tutorial/client/index.js @@ -0,0 +1,97 @@ +var delegate = require('client/delegate'); +var prism = require('client/prism'); +var xhr = require('client/xhr'); +var TutorialMapModal = require('./tutorialMapModal'); + +exports.init = function() { + + + initTaskButtons(); + initFolderList(); + + initSidebarHighlight(); + + delegate(document, '[data-action="tutorial-map"]', 'click', function(event) { + new TutorialMapModal(); + event.preventDefault(); + }); + + prism.init(); + + if (window.ebookType) { + require.ensure('./ebookExtras', function() { + require('./ebookExtras').init(); + }, 'ebookExtras'); + } +}; + +exports.TutorialMap = require('./tutorialMap'); + +function initSidebarHighlight() { + + function highlight() { + + var current = document.getElementsByClassName('sidebar__navigation-link_active'); + if (current[0]) current[0].classList.remove('sidebar__navigation-link_active'); + + //debugger; + var h2s = document.getElementsByTagName('h2'); + for (var i = 0; i < h2s.length; i++) { + var h2 = h2s[i]; + // first in-page header + // >1, because when visiting http://javascript.in/native-prototypes#native-prototype-change, + // top may be 0.375 or kind of... + if (h2.getBoundingClientRect().top > 1) break; + } + i--; // we need the one before it (currently reading) + + if (i>=0) { + var href = h2s[i].firstElementChild && h2s[i].firstElementChild.getAttribute('href'); + var li = document.querySelector('.sidebar__navigation-link a[href="' + href + '"]'); + if (href && li) { + li.classList.add('sidebar__navigation-link_active'); + } + } + + } + + document.addEventListener('DOMContentLoaded', function() { + highlight(); + + window.addEventListener('scroll', highlight); + }); + + +} + + +function initTaskButtons() { + // solution button + delegate(document, '.task__solution', 'click', function(event) { + event.target.closest('.task').classList.toggle('task__answer_open'); + }); + + // close solution button + delegate(document, '.task__answer-close', 'click', function(event) { + event.target.closest('.task').classList.toggle('task__answer_open'); + }); + + // every step button (if any steps) + delegate(document, '.task__step-show', 'click', function(event) { + event.target.closest('.task__step').classList.toggle('task__step_open'); + }); +} + +function initFolderList() { + delegate(document, '.lessons-list__lesson_level_1 > .lessons-list__link', 'click', function(event) { + var link = event.delegateTarget; + var openFolder = link.closest('.lessons-list').querySelector('.lessons-list__lesson_open'); + // close the previous open folder (thus making an accordion) + if (openFolder && openFolder != link.parentNode) { + openFolder.classList.remove('lessons-list__lesson_open'); + } + link.parentNode.classList.toggle('lessons-list__lesson_open'); + event.preventDefault(); + }); +} + diff --git a/handlers/tutorial/client/tutorialMap.js b/handlers/tutorial/client/tutorialMap.js new file mode 100755 index 000000000..85c8643e0 --- /dev/null +++ b/handlers/tutorial/client/tutorialMap.js @@ -0,0 +1,187 @@ +var throttle = require('lib/throttle'); +var delegate = require('client/delegate'); + +function TutorialMap(elem) { + this.elem = elem; + + this.showTasksCheckbox = elem.querySelector('[data-tutorial-map-show-tasks]'); + this.showTasksCheckbox.checked = +localStorage.showTasksCheckbox; + + this.updateShowTasks(); + + this.showTasksCheckbox.onchange = this.updateShowTasks.bind(this); + + this.filterInput = this.elem.querySelector('[data-tutorial-map-filter]'); + this.textInputBlock = this.elem.querySelector('.tutorial-map__filter .text-input'); + + this.layoutSwitch = this.elem.querySelector('[data-tutorial-map-layout-switch]'); + var isMapSingleColumn = +localStorage.isMapSingleColumn; + this.layoutSwitch.querySelector('[value="0"]').checked = !isMapSingleColumn; + this.layoutSwitch.querySelector('[value="1"]').checked = isMapSingleColumn; + this.updateLayout(); + this.layoutSwitch.onchange = this.onLayoutSwitchChange.bind(this); + + this.filterInput.oninput = this.onFilterInput.bind(this); + this.filterInput.onkeydown = this.onFilterKeydown.bind(this); + + this.elem.querySelector('.close-button').onclick = () => { + this.filterInput.value = ''; + this.showClearButton(false); + this.filter(''); + }; + + this.chaptersCollapsed = JSON.parse(localStorage.tutorialMapChapters || "{}"); + this.showChaptersCollapsed(); + + this.delegate('.tutorial-map__item > .tutorial-map__link', 'click', function(event) { + event.preventDefault(); + var href = event.delegateTarget.getAttribute('href'); + if (this.chaptersCollapsed[href]) { + delete this.chaptersCollapsed[href]; + } else { + this.chaptersCollapsed[href] = 1; + } + localStorage.tutorialMapChapters = JSON.stringify(this.chaptersCollapsed); + this.showChaptersCollapsed(); + }); + + var activeLink = this.elem.querySelector('[href="' + location.pathname + '"]'); + if (activeLink) { + activeLink.classList.add('tutorial-map__link_active'); + } + + this.filterInput.focus(); + +} + + +TutorialMap.prototype.showChaptersCollapsed = function() { + var links = this.elem.querySelectorAll('.tutorial-map__item > .tutorial-map__link'); + for (var i = 0; i < links.length; i++) { + var link = links[i]; + + if (this.chaptersCollapsed[link.getAttribute('href')]) { + link.parentNode.classList.add('tutorial-map__item_collapsed'); + } else { + link.parentNode.classList.remove('tutorial-map__item_collapsed'); + } + } +}; + +TutorialMap.prototype.onLayoutSwitchChange = function(event) { + this.updateLayout(); +}; + + +TutorialMap.prototype.updateLayout = function() { + var isMapSingleColumn = +this.elem.querySelector('[name="map-layout"]:checked').value; + if (isMapSingleColumn) { + this.elem.classList.add('tutorial-map_singlecol'); + } else { + this.elem.classList.remove('tutorial-map_singlecol'); + } + + localStorage.isMapSingleColumn = isMapSingleColumn ? "1" : "0"; +}; + +TutorialMap.prototype.updateShowTasks = function() { + if (this.showTasksCheckbox.checked) { + this.elem.classList.add('tutorial-map_show-tasks'); + } else { + this.elem.classList.remove('tutorial-map_show-tasks'); + } + + localStorage.showTasksCheckbox = this.showTasksCheckbox.checked ? "1" : "0"; +}; + +TutorialMap.prototype.onFilterInput = function(event) { + this.showClearButton(event.target.value); + this.throttleFilter(event.target.value); +}; + +TutorialMap.prototype.onFilterKeydown = function(event) { + if (event.keyCode == 27) { // escape + this.filterInput.value = ''; + this.showClearButton(false); + this.filter(''); + } +}; + +TutorialMap.prototype.showClearButton = function(show) { + if (show) { + this.textInputBlock.classList.add('text-input_clear-button'); + } else { + this.textInputBlock.classList.remove('text-input_clear-button'); + } +}; + +// focus on the map itself, to allow immediate scrolling with arrow up/down keys +TutorialMap.prototype.focus = function() { + this.elem.tabIndex = -1; + this.elem.focus(); +}; + +TutorialMap.prototype.filter = function(value) { + value = value.toLowerCase(); + var showingTasks = this.showTasksCheckbox.checked; + + var links = this.elem.querySelectorAll('.tutorial-map-link'); + + var topItems = this.elem.querySelectorAll('.tutorial-map__item'); + + function checkLiMatch(li) { + return isSubSequence(li.querySelector('a').innerHTML.toLowerCase(), value.replace(/\s/g, '')); + } + + // an item is shown if any of its children is shown OR it's link matches the filter + for (var i = 0; i < topItems.length; i++) { + var li = topItems[i]; + var subItems = li.querySelectorAll('.tutorial-map__sub-item'); + + var childMatch = Array.prototype.reduce.call(subItems, function(prevValue, subItem) { + + var childMatch = false; + + if (showingTasks) { + var subItems = subItem.querySelectorAll('.tutorial-map__sub-sub-item'); + childMatch = Array.prototype.reduce.call(subItems, function(prevValue, subItem) { + var match = checkLiMatch(subItem); + subItem.hidden = !match; + return prevValue || match; + }, false); + } + + var match = childMatch || checkLiMatch(subItem); + //console.log(subItem, match); + subItem.hidden = !match; + + return prevValue || match; + }, false); + + li.hidden = !(childMatch || checkLiMatch(li)); + + } + +}; + + +TutorialMap.prototype.throttleFilter = throttle(TutorialMap.prototype.filter, 200); +delegate.delegateMixin(TutorialMap.prototype); + + +function isSubSequence(str1, str2) { + var i = 0; + var j = 0; + while (i < str1.length && j < str2.length) { + if (str1[i] == str2[j]) { + i++; + j++; + } else { + i++; + } + } + return j == str2.length; +} + + +module.exports = TutorialMap; diff --git a/handlers/tutorial/client/tutorialMapModal.js b/handlers/tutorial/client/tutorialMapModal.js new file mode 100755 index 000000000..4d266188d --- /dev/null +++ b/handlers/tutorial/client/tutorialMapModal.js @@ -0,0 +1,70 @@ +var xhr = require('client/xhr'); + +var delegate = require('client/delegate'); +var Modal = require('client/head/modal'); +var Spinner = require('client/spinner'); +var TutorialMap = require('./tutorialMap'); +var trackSticky = require('client/trackSticky'); + +/** + * Options: + * - callback: function to be called after successful login (by default - go to successRedirect) + * - message: form message to be shown when the login form appears ("Log in to leave the comment") + * - successRedirect: the page to redirect (current page by default) + * - after immediate login + * - after registration for "confirm email" link + */ +function TutorialMapModal() { + var modal = new Modal({hasClose: false}); + var spinner = new Spinner(); + modal.setContent(spinner.elem); + spinner.start(); + + this.onDocumentKeyDown = this.onDocumentKeyDown.bind(this); + + var request = xhr({ + url: '/tutorial/map' + }); + + request.addEventListener('success', (event) => { + modal.remove(); + document.body.insertAdjacentHTML('beforeEnd', '
    '); + this.elem = document.body.lastChild; + this.elem.innerHTML = event.result + ''; + + this.elem.addEventListener('click', (e) => { + if (e.target.classList.contains('tutorial-map-overlay__close')) { + this.remove(); + } + }); + + document.addEventListener("keydown", this.onDocumentKeyDown); + + document.body.classList.add('tutorial-map_on'); + + this.elem.addEventListener('scroll', trackSticky); + + new TutorialMap(this.elem.firstElementChild); + }); + + request.addEventListener('fail', () => modal.remove()); + +} + +delegate.delegateMixin(TutorialMapModal.prototype); + +TutorialMapModal.prototype.remove = function() { + this.elem.remove(); + document.body.classList.remove('tutorial-map_on'); + document.removeEventListener("keydown", this.onDocumentKeyDown); +}; + +TutorialMapModal.prototype.onDocumentKeyDown = function(event) { + if (event.keyCode == 27) { + event.preventDefault(); + this.remove(); + } +}; + + +module.exports = TutorialMapModal; diff --git a/handlers/tutorial/controller/article.js b/handlers/tutorial/controller/article.js new file mode 100755 index 000000000..cee606ff5 --- /dev/null +++ b/handlers/tutorial/controller/article.js @@ -0,0 +1,297 @@ +const mongoose = require('mongoose'); +const Article = require('../models/article'); +const Task = require('../models/task'); +const ArticleRenderer = require('../renderer/articleRenderer'); +const TaskRenderer = require('../renderer/taskRenderer'); +const _ = require('lodash'); +const CacheEntry = require('cache').CacheEntry; +const makeAnchor = require('textUtil/makeAnchor'); + +exports.get = function *get(next) { + + + var renderedArticle = yield* CacheEntry.getOrGenerate({ + key: 'tutorial:article:' + this.params.slug, + tags: ['article'] + }, renderArticle.bind(this, this.params.slug)); + + + if (!renderedArticle) { + yield* next; + return; + } + + var locals = renderedArticle; + locals.sitetoolbar = true; + + locals.siteToolbarCurrentSection = "tutorial"; + + if (!renderedArticle.isFolder) { + locals.comments = true; + } + + var sections = []; + if (renderedArticle.isFolder) { + + sections.push({ + title: 'Смежные разделы', + links: renderedArticle.siblings + }); + + } else { + + sections.push({ + title: 'Раздел', + links: [renderedArticle.breadcrumbs[renderedArticle.breadcrumbs.length-1]] + }); + + var headerLinks = renderedArticle.headers + .filter(function(header) { + // [level, titleHtml, anchor] + return header.level == 2; + }).map(function(header) { + return { + title: header.title, + url: '#' + header.anchor + }; + }); + + if (headerLinks.length) { + sections.push({ + title: 'Навигация по уроку', + links: headerLinks + }); + } + + } + + if (!renderedArticle.isFolder) { + + var section2 = { + class: '_separator_before', + links: [] + }; + + if (renderedArticle.tasks.length) { + section2.links.push({ + title: 'Задачи (' + renderedArticle.tasks.length + ')', + url: '#tasks' + }); + } + + section2.links.push({ + title: 'Комментарии', + url: '#comments' + }); + + sections.push(section2); + + } + + locals.sidebar = { + class: "sidebar_sticky-footer", + sections: sections + }; + + this.body = this.render(renderedArticle.isFolder ? "folder" : "article", locals); + +}; + +// body +// metadata +// modified +// title +// isFolder +// prev +// next +// path +// siblings +function* renderArticle(slug) { + + const article = yield Article.findOne({ slug: slug }).exec(); + if (!article) { + return null; + } + + this.log.debug("article", article._id); + + + var renderer = new ArticleRenderer(); + + var rendered = yield* renderer.renderWithCache(article); + + this.log.debug("rendered"); + + rendered.isFolder = article.isFolder; + rendered.modified = article.modified; + rendered.title = article.title; + rendered.isFolder = article.isFolder; + rendered.weight = article.weight; + + const tree = yield* Article.findTree(); + const articleInTree = tree.byId(article._id); + + yield* renderProgress(); + yield* renderPrevNext(); + yield* renderBreadCrumb(); + yield* renderSiblings(); + yield* renderChildren(); + yield* renderTasks(); + + + // strip / and /tutorial + rendered.level = rendered.breadcrumbs.length - 2; // starts at 0 + + if (articleInTree.isFolder) { + // levelMax is 2 for deep courses or 1 for plain courses + rendered.levelMax = articleInTree.children[0].isFolder ? rendered.level + 2 : rendered.level + 1; + } + + + function* renderPrevNext() { + + var prev = tree.byId(articleInTree.prev); + + if (prev) { + rendered.prev = { + url: Article.getUrlBySlug(prev.slug), + title: prev.title + }; + } + + var next = tree.byId(articleInTree.next); + if (next) { + rendered.next = { + url: Article.getUrlBySlug(next.slug), + title: next.title + }; + } + } + + function* renderProgress() { + var parent = articleInTree.parent; + var bookRoot = articleInTree; + while (parent) { + bookRoot = tree.byId(parent); + parent = bookRoot.parent; + } + + // now bookroot is 1st level tree item, book root, let's count items in it + + //console.log(bookRoot); + + var bookLeafCount = 0; + var bookChildNumber; + function countChildren(tree) { + if (tree == articleInTree) { + bookChildNumber = bookLeafCount + 1; + } + + if (!tree.children) { + bookLeafCount++; + } else { + tree.children.forEach(countChildren); + } + } + + countChildren(bookRoot); + + if (!(bookChildNumber == 1 && rendered.isFolder)) { + // not on top level first chapters + rendered.bookLeafCount = bookLeafCount; + rendered.bookChildNumber = bookChildNumber; + } + + //console.log(bookLeafCount, bookChildNumber); + } + + function* renderBreadCrumb() { + var path = []; + var parent = articleInTree.parent; + while (parent) { + var a = tree.byId(parent); + path.push({ + title: a.title, + url: Article.getUrlBySlug(a.slug) + }); + parent = a.parent; + } + path.push({ + title: 'Учебник', + url: '/' + }); + path.push({ + title: 'JavaScript.ru', + url: 'http://javascript.ru' + }); + + path = path.reverse(); + + rendered.breadcrumbs = path; + } + + function* renderSiblings() { + var siblings = tree.siblings(articleInTree._id); + rendered.siblings = siblings.map(function(sibling) { + return { + title: sibling.title, + url: Article.getUrlBySlug(sibling.slug) + }; + }); + } + + function* renderChildren() { + if (!articleInTree.isFolder) return; + var children = articleInTree.children || []; + rendered.children = children.map(function(child) { + var renderedChild = { + title: child.title, + url: Article.getUrlBySlug(child.slug), + weight: child.weight + }; + + if (child.isFolder) { + renderedChild.children = (child.children || []).map(function(subChild) { + return { + title: subChild.title, + url: Article.getUrlBySlug(subChild.slug), + weight: child.weight + }; + }); + } + + return renderedChild; + }); + } + + function *renderTasks() { + var tasks = yield Task.find({ + parent: article._id + }).sort({weight: 1}).exec(); + + const taskRenderer = new TaskRenderer(); + + + rendered.tasks = []; + + for (var i = 0; i < tasks.length; i++) { + var task = tasks[i]; + + var taskRendered = yield* taskRenderer.renderWithCache(task); + rendered.tasks.push({ + url: task.getUrl(), + title: task.title, + anchor: makeAnchor(task.title), + importance: task.importance, + content: taskRendered.content, + solution: taskRendered.solution + }); + + } + + } + + return rendered; + +} + diff --git a/handlers/tutorial/controller/frontpage.js b/handlers/tutorial/controller/frontpage.js new file mode 100755 index 000000000..21d382373 --- /dev/null +++ b/handlers/tutorial/controller/frontpage.js @@ -0,0 +1,84 @@ +const mongoose = require('mongoose'); +const Article = require('../models/article'); +const Task = require('../models/task'); +const _ = require('lodash'); +const ArticleRenderer = require('../renderer/articleRenderer'); +const CacheEntry = require('cache').CacheEntry; + +exports.get = function *get(next) { + + this.locals.sitetoolbar = true; + this.locals.siteToolbarCurrentSection = "tutorial"; + this.locals.title = "Современный учебник JavaScript"; + + var tutorial = yield CacheEntry.getOrGenerate({ + key: 'tutorial:frontpage', + tags: ['article'] + }, renderTutorial); + + + var locals = { + chapters: tutorial + }; + + this.body = this.render('frontpage', locals); +}; + +// content +// metadata +// modified +// title +// isFolder +// prev +// next +// path +// siblings +function* renderTutorial() { + const tree = yield* Article.findTree(); + + var treeRendered = yield* renderTree(tree); + + // render top-level content + for (var i = 0; i < treeRendered.length; i++) { + var child = treeRendered[i]; + yield* populateContent(child); + } + + + return treeRendered; + +} + + +function* renderTree(tree) { + var children = []; + + for (var i = 0; i < tree.children.length; i++) { + var child = tree.children[i]; + + var childRendered = { + id: child._id, + url: Article.getUrlBySlug(child.slug), + title: child.title + }; + + if (child.isFolder) { + childRendered.children = yield* renderTree(child); + } + + children.push(childRendered); + + } + return children; +} + + +function* populateContent(articleObj) { + var article = yield Article.findById(articleObj.id).exec(); + + var renderer = new ArticleRenderer(); + + var rendered = yield* renderer.renderWithCache(article); + + articleObj.content = rendered.content; +} diff --git a/handlers/tutorial/controller/map.js b/handlers/tutorial/controller/map.js new file mode 100755 index 000000000..3baa3a62d --- /dev/null +++ b/handlers/tutorial/controller/map.js @@ -0,0 +1,87 @@ +const mongoose = require('mongoose'); +const Article = require('../models/article'); +const Task = require('../models/task'); +const ArticleRenderer = require('../renderer/articleRenderer'); +const TaskRenderer = require('../renderer/taskRenderer'); +const _ = require('lodash'); +const CacheEntry = require('cache').CacheEntry; +const makeAnchor = require('textUtil/makeAnchor'); + +exports.get = function *get(next) { + + var renderedMap = yield CacheEntry.getOrGenerate({ + key: 'map:rendered', + tags: ['article'] + }, renderMap); + + var locals = { + children: renderedMap + }; + + var template = this.get('X-Requested-With') ? '_map' : 'map'; + + this.body = this.render(template, locals); +}; + +// body +// metadata +// modified +// title +// isFolder +// prev +// next +// path +// siblings +function* renderMap() { + + const tree = yield* Article.findTree(); + + function* renderTree(tree) { + var children = []; + + for (var i = 0; i < tree.children.length; i++) { + var child = tree.children[i]; + + var childRendered = { + url: Article.getUrlBySlug(child.slug), + title: child.title + }; + + if (child.isFolder) { + childRendered.children = yield* renderTree(child); + } + + var tasks = yield Task.find({ + parent: child._id + }).sort({weight: 1}).select('-_id slug title importance').lean().exec(); + + tasks = tasks.map(function(task) { + task.url = Task.getUrlBySlug(task.slug); + delete task.slug; + task.anchor = childRendered.url + '#' + makeAnchor(task.title); + return task; + }); + + childRendered.tasks = tasks; + children.push(childRendered); + + } + return children; + } + + var treeRendered = yield* renderTree(tree); + + + return [ + treeRendered[0], + treeRendered[1], + { + url: '#', + title: 'Дополнительно', + children: treeRendered.slice(2) + } + ]; + +} + + diff --git a/handlers/tutorial/controller/node.js b/handlers/tutorial/controller/node.js new file mode 100755 index 000000000..d907c1ddc --- /dev/null +++ b/handlers/tutorial/controller/node.js @@ -0,0 +1,141 @@ +var path = require('path'); +var url = require('url'); +var fs = require('mz/fs'); +var config = require('config'); +var util = require('util'); + +function clean(pathOrPiece) { + pathOrPiece = pathOrPiece.replace(/[^\/.a-z0-9_-]/gim, ''); + + // .. -> . + pathOrPiece = pathOrPiece.replace(/\.+/g, '.'); + + // //// -> / + pathOrPiece = pathOrPiece.replace(/\/+/g, '/'); + + return pathOrPiece; +} + + +exports.all = function*() { + + // bad path: http://javascript.in/task/capslock-warning-field/solution + if (this.params.serverPath === undefined) { + this.throw(404); + } + + // for /article/ajax-xmlhttprequest/xhr/test: xhr/test + var serverPath = clean(this.params.serverPath); + var slug = clean(this.params.slug); + var view = clean(this.params.view); + var taskOrArticle = this.url.match(/\w+/)[0]; + + var modulePath = path.join(config.publicRoot, taskOrArticle, slug, view, 'server.js'); + + this.log.debug("trying modulePath", modulePath); + + if (yield fs.exists(modulePath)) { + + var server = require(modulePath); + + this.req.url = "/" + serverPath; + this.res.statusCode = 200; // reset default koa 404 assignment + this.log.debug("passing control to modulePath server, url=", this.req.url); + this.respond = false; + server.accept(this.req, this.res); + } else { + this.throw(404); + } + +}; + +/* +process.chdir(__dirname); + +var accept = require('js-example').makeAccept(module); +http.createServer(function(req, res) { + + req.url = req.url.replace(/^\/files\/tutorial\/ajax\//, '/'); +// console.log(req.method, " ", req.url); + accept(req, res); +}).listen(8080); + + +function makeAccept(dirModule) { + + + return function(req, res) { + + process.chdir(path.dirname(dirModule.filename)); + + var urlParsed = url.parse(req.url); + + // null for root requests (http://new.javascript.ru/files/tutorial/ajax/xhr) + if (!urlParsed.pathname) urlParsed.pathname = ''; + + // clean pathname + var pathname = urlParsed.pathname.replace(/[^\/.a-z0-9_-]/gim, ''); + + // .. -> . + pathname = pathname.replace(/\.+/g, '.'); + + // //// -> / + pathname = pathname.replace(/\/+/g, '/'); + + var urlSplit = pathname.split('/'); + console.log(urlSplit); + var moduleName = urlSplit.splice(1, 1)[0]; + urlParsed.pathname = urlSplit.join('/'); + + req.url = url.format(urlParsed); + if(req.url == "") req.url = "/"; + + // find module.js or module/index.js + var isFile; + + console.log("CWD " + process.cwd() + " look for FILE:"+moduleName) + + if (fs.existsSync(moduleName+'.js')) { + console.log("Found " + moduleName+'.js'); + isFile = true; + } else { + + if (!fs.existsSync(moduleName)) { + res.writeHead(404); + console.log("No folder or file at " + dirModule.filename); + res.end("Not found folder or file"); + return; + } + + if (!fs.existsSync(moduleName+'/index.js')) { + res.writeHead(404); + console.log("No index in module folder at " + dirModule.filename); + res.end("Not found index in module folder"); + return; + } + + isFile = false; + } + + try { + // ok, let's go for it + if (!isFile) process.chdir('./'+moduleName); + console.log("running " + moduleName); + + var handler = dirModule.require('./'+moduleName); + + if (!handler || !handler.accept) { + res.writeHead(500); + res.end("No accepting handler"); + return; + } + + handler.accept(req, res); + } catch(e) { + res.writeHead(500); + res.end(e.stack); + } + } + +} +*/ diff --git a/handlers/tutorial/controller/task.js b/handlers/tutorial/controller/task.js new file mode 100755 index 000000000..bd3fcce12 --- /dev/null +++ b/handlers/tutorial/controller/task.js @@ -0,0 +1,34 @@ +const mongoose = require('mongoose'); +const Task = require('../models/task'); +const TaskRenderer = require('../renderer/taskRenderer'); + +exports.get = function *get(next) { + + const task = yield Task.findOne({ + slug: this.params.slug + }).populate('parent', 'slug').exec(); + + if (!task) { + yield* next; + return; + } + + const renderer = new TaskRenderer(); + + const rendered = yield* renderer.renderWithCache(task); + + this.locals.siteToolbarCurrentSection = "tutorial"; + + this.locals.title = task.title; + this.locals.task = { + title: task.title, + importance: task.importance, + content: rendered.content, + solution: rendered.solution + }; + + this.locals.articleUrl = task.parent.getUrl(); + + this.body = this.render("task"); +}; + diff --git a/handlers/tutorial/controller/zipview.js b/handlers/tutorial/controller/zipview.js new file mode 100755 index 000000000..42b7f278c --- /dev/null +++ b/handlers/tutorial/controller/zipview.js @@ -0,0 +1,14 @@ +const Plunk = require('plunk').Plunk; +const mongoose = require('mongoose'); + +exports.get = function*() { + var plunk = yield Plunk.findOne({ plunkId: this.query.plunkId }).exec(); + + if (!plunk) { + this.throw(404); + } + + this.set('Content-Type', 'application/zip'); + this.body = plunk.getZip(); + +}; \ No newline at end of file diff --git a/handlers/tutorial/figuresImporter.js b/handlers/tutorial/figuresImporter.js new file mode 100755 index 000000000..edd2fccf9 --- /dev/null +++ b/handlers/tutorial/figuresImporter.js @@ -0,0 +1,128 @@ +const co = require('co'); +const util = require('util'); +const fs = require('fs'); +const fse = require('fs-extra'); +const path = require('path'); +const config = require('config'); +const mongoose = require('lib/mongoose'); +const glob = require("glob"); + +const log = require('log')(); + +const execSync = require('child_process').execSync; + +// TODO: use htmlhint/jslint for html/js examples + +function FiguresImporter(options) { + this.sketchtool = options.sketchtool || '/usr/local/bin/sketchtool'; + + this.root = fs.realpathSync(options.root); + this.figuresFilePath = options.figuresFilePath; +} + +FiguresImporter.prototype.syncFigures = function*() { + + if (!fs.existsSync('/usr/local/bin/sketchtool')) { + log.info("No sketchtool"); + return; + } + + var outputDir = path.join(config.tmpRoot, 'sketchtool'); + + fse.removeSync(outputDir); + fse.mkdirsSync(outputDir); + + var artboardsByPages = JSON.parse(execSync('/usr/local/bin/sketchtool list artboards "' + this.figuresFilePath + '"', { + encoding: 'utf-8' + })); + + var artboards = artboardsByPages + .pages + .reduce(function(prev, current) { + return prev.concat(current.artboards); + }, []); + + var svgIds = []; + var pngIds = []; + var artboardsExported = []; + + for (var i = 0; i < artboards.length; i++) { + var artboard = artboards[i]; + + // only allow artboards with extensions are exported + // others are temporary / helpers + var ext = path.extname(artboard.name).slice(1); + if (ext == 'png') { + pngIds.push(artboard.id); + artboardsExported.push(artboard); + } + if (ext == 'svg') { + svgIds.push(artboard.id); + artboardsExported.push(artboard); + } + } + + // NB: Artboards are NOT trimmed (sketchtool doesn't do that yet) + execSync('/usr/local/bin/sketchtool export artboards "' + this.figuresFilePath + '" ' + + '--overwriting=YES --trimmed=YES --formats=png --scales=1,2 --output="' + outputDir + '" --items=' + pngIds.join(','), { + stdio: 'inherit', + encoding: 'utf-8' + }); + + // NB: Artboards are NOT trimmed (sketchtool doesn't do that yet) + execSync('/usr/local/bin/sketchtool export artboards "' + this.figuresFilePath + '" ' + + '--overwriting=YES --trimmed=YES --formats=svg --output="' + outputDir + '" --items=' + svgIds.join(','), { + stdio: 'inherit', + encoding: 'utf-8' + }); + + // files are exported as array-pop.svg.svg, metric-css.png@2x.png + // => remove first extension + var images = glob.sync(path.join(outputDir, '*.*')); + images.forEach(function(image) { + fs.renameSync(image, image.replace(/.(svg|png)/, '')); + }); + + var allFigureFilePaths = glob.sync(path.join(this.root, '**/*.{png,svg}')); + + function findArtboardPaths(artboard) { + + var paths = []; + for (var j = 0; j < allFigureFilePaths.length; j++) { + if (path.basename(allFigureFilePaths[j]) == artboard.name) { + paths.push(path.dirname(allFigureFilePaths[j])); + } + } + + return paths; + + } + + // copy should trigger folder resync on watch + // and that's right (img size changed, must be rerendered) + + for (var i = 0; i < artboardsExported.length; i++) { + var artboard = artboardsExported[i]; + var artboardPaths = findArtboardPaths(artboard); + if (!artboardPaths.length) { + log.error("Artboard path not found " + artboard.name); + continue; + } + + for (var j = 0; j < artboardPaths.length; j++) { + var artboardPath = artboardPaths[j]; + + log.info("syncFigure move " + artboard.name + " -> " + artboardPath); + fse.copySync(path.join(outputDir, artboard.name), path.join(artboardPath, artboard.name)); + if (path.extname(artboard.name) == '.png') { + var x2Name = artboard.name.replace('.png', '@2x.png'); + fse.copySync(path.join(outputDir, x2Name), path.join(artboardPath, x2Name)); + } + } + + } + + +}; + +module.exports = FiguresImporter; diff --git a/handlers/tutorial/index.js b/handlers/tutorial/index.js new file mode 100755 index 000000000..a7284ac3a --- /dev/null +++ b/handlers/tutorial/index.js @@ -0,0 +1,26 @@ + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + app.use(mountHandlerMiddleware('/', __dirname)); + + // for "node" middleware which executes server.js inside the content + addNodeIgnores(app); +}; + +// these urls may only contain example scripts for the "node" middleware +function addNodeIgnores(app) { + app.csrfChecker.ignore.add('/task/:any*'); + app.csrfChecker.ignore.add('/article/:any*'); + app.multipartParser.ignore.add('/task/:any*'); + app.multipartParser.ignore.add('/article/:any*'); + app.bodyParser.ignore.add('/task/:any*'); + app.bodyParser.ignore.add('/article/:any*'); +} + + +exports.Article = require('./models/article'); +exports.ArticleRenderer = require('./renderer/articleRenderer'); +exports.Reference = require('./models/reference'); +exports.Task = require('./models/task'); +exports.TaskRenderer = require('./renderer/taskRenderer'); diff --git a/handlers/tutorial/models/article.js b/handlers/tutorial/models/article.js new file mode 100755 index 000000000..bbc5f01e0 --- /dev/null +++ b/handlers/tutorial/models/article.js @@ -0,0 +1,221 @@ +const mongoose = require('mongoose'); +const troop = require('mongoose-troop'); +const ObjectId = mongoose.Schema.Types.ObjectId; +const Schema = mongoose.Schema; +const config = require('config'); +const path = require('path'); +const Reference = require('./reference'); +const Task = require('./task'); +const html2search = require('elastic').html2search; + +const schema = new Schema({ + title: { + type: String, + required: true + }, + + slug: { + type: String, + unique: true, + required: true, + index: true + }, + + content: { + type: String, + required: true + }, + + parent: { + type: ObjectId, + ref: 'Article', + index: true + }, + + weight: { + type: Number, + required: true + }, + + rendered: { + type: {} + }, + + search: String, + + isFolder: { + type: Boolean, + required: true + } + +}); + +// all resources are here +schema.statics.resourceFsRoot = path.join(config.publicRoot, 'article'); + +schema.statics.getResourceFsRootBySlug = function(slug) { + return path.join(schema.statics.resourceFsRoot, slug); +}; + +schema.statics.getResourceWebRootBySlug = function(slug) { + return '/article/' + slug; +}; + +schema.statics.getUrlBySlug = function(slug) { + return '/' + slug; +}; + +schema.methods.getResourceFsRoot = function() { + return schema.statics.getResourceFsRootBySlug(this.get('slug')); +}; + + +schema.methods.getResourceWebRoot = function() { + return schema.statics.getResourceWebRootBySlug(this.get('slug')); +}; + +schema.methods.getUrl = function() { + return schema.statics.getUrlBySlug(this.get('slug')); +}; + +schema.methods.findParents = function*() { + var parents = []; + var article = this; + while(true) { + article = yield Article.findById(article.parent).select('slug parent title').exec(); + if (!article) break; + parents.push(article); + } + + return parents.reverse(); +}; + + +schema.methods.destroyTree = function* () { + if (this.isFolder) { + var children = yield Article.find({parent: this._id}).select('isFolder').exec(); + + for (var i = 0; i < children.length; i++) { + var child = children[i]; + yield child.destroyTree(); + } + } + + yield this.destroy(); +}; + +schema.statics.destroyTree = function* (condition) { + var articles = yield Article.find(condition).select('isFolder').exec(); + + for (var i = 0; i < articles.length; i++) { + yield* articles[i].destroyTree(); + } +}; + +/** + * Returns {children: [whole article tree]} with nested children + * @returns {{children: Array}} + */ +schema.statics.findTree = function* (options) { + const Article = this; + options = options || {}; + + var query = options.query || Article.find({}).sort({weight: 1}).select('parent slug title weight isFolder').lean(); + var articles = yield query.exec(); + + // arrange by ids + var articlesById = {}; + for (var i = 0; i < articles.length; i++) { + var article = articles[i]; + article._id = article._id.toString(); + articlesById[article._id] = article; + } + + var root = []; + + addChildren(); + + addPrevNext(); + + return { + articles: articlesById, + children: root, + byId: function(id) { + if (!id) return undefined; + return articlesById[id.toString()]; + }, + siblings: function(id) { + id = id.toString(); + var parent = articlesById[id].parent; + return parent ? articlesById[parent].children : root; + } + }; + + // --- + + function addChildren() { + + for (var _id in articlesById) { + var article = articlesById[_id]; + if (article.parent) { + var parent = articlesById[article.parent]; + if (!parent.children) parent.children = []; + parent.children.push(article); + } else { + root.push(article); + } + } + + } + + function addPrevNext() { + + var prev; + var next; + + addPrev(root); + addNext(); + + function addPrev(children) { + for (var i = 0; i < children.length; i++) { + var child = children[i]; + if (prev) child.prev = prev; + prev = child._id; + if (child.children) { + addPrev(child.children); + } + } + } + + function addNext() { + for (var _id in articlesById) { + var article = articlesById[_id]; + if (article.prev) { + articlesById[article.prev].next = _id; + } + } + } + } + +}; + +schema.pre('remove', function(next) { + Reference.remove({article: this._id}, next); +}); + +schema.pre('remove', function(next) { + Task.remove({parent: this._id}, next); +}); + +schema.pre('save', function(next) { + if (this.rendered) { + this.search = html2search(this.rendered.content); + } + next(); +}); + + +schema.plugin(troop.timestamp); + +var Article = module.exports = mongoose.model('Article', schema); + diff --git a/handlers/tutorial/models/reference.js b/handlers/tutorial/models/reference.js new file mode 100755 index 000000000..b82103ee0 --- /dev/null +++ b/handlers/tutorial/models/reference.js @@ -0,0 +1,28 @@ +var mongoose = require('mongoose'); +var Schema = mongoose.Schema; + +// references can be only in articles +// [...](/my-url) is not a reference +// [...](#anchor) *is* a reference +// when no title, reference content is used: [](#anchor) -> anchor +var schema = new Schema({ + + article: { + type: Schema.Types.ObjectId, + ref: 'Article' + }, + + anchor: { + type: String, + index: true, + unique: true, + required: true + } + +}); + +schema.methods.getUrl = function() { + return this.article.getUrl() + '#' + this.anchor; +}; + +module.exports = mongoose.model('Reference', schema); diff --git a/handlers/tutorial/models/task.js b/handlers/tutorial/models/task.js new file mode 100755 index 000000000..109d5bcbb --- /dev/null +++ b/handlers/tutorial/models/task.js @@ -0,0 +1,105 @@ +var mongoose = require('mongoose'); +var troop = require('mongoose-troop'); +var ObjectId = mongoose.Schema.Types.ObjectId; +var Schema = mongoose.Schema; +const config = require('config'); +const path = require('path'); +const html2search = require('elastic').html2search; + +var schema = new Schema({ + title: { + type: String, + required: true + }, + + importance: { + type: Number + }, + + slug: { + type: String, + unique: true, + required: true, + index: true + }, + + content: { + type: String, + required: true + }, + + solution: { + // can be empty (assuming there is a solution.view which will be autolinked) + type: String, + default: "" + }, + + rendered: { + type: {} + }, + + search: String, + solutionPlunkId: String, + sourcePlunkId: String, + + weight: { + type: Number, + required: true + }, + + parent: { + type: ObjectId, + ref: 'Article', + required: true, + index: true + } + +}); + +// all resources are here +schema.statics.resourceFsRoot = path.join(config.publicRoot, 'task'); + +schema.statics.getResourceFsRootBySlug = function(slug) { + return path.join(schema.statics.resourceFsRoot, slug); +}; + +schema.statics.getResourceWebRootBySlug = function(slug) { + return '/task/' + slug; +}; + +schema.statics.getUrlBySlug = function(slug) { + return '/task/' + slug; +}; + +schema.methods.getResourceFsRoot = function() { + return schema.statics.getResourceFsRootBySlug(this.get('slug')); +}; + +schema.methods.getResourceWebRoot = function() { + return schema.statics.getResourceWebRootBySlug(this.get('slug')); +}; + +schema.methods.getUrl = function() { + return schema.statics.getUrlBySlug(this.get('slug')); +}; + +schema.pre('save', function(next) { + if (!this.rendered) return next(); + + var searchContent = this.rendered.content; + + var searchSolution = Array.isArray(this.rendered.solution) ? this.rendered.solution.map(function(part) { + return part.title + "\n" + part.content; + }).reduce(function(prev, current) { + return prev + "\n" + current; + }, '') : this.rendered.solution; + + this.search = html2search(searchContent + "\n\n" + searchSolution); + next(); +}); + +schema.plugin(troop.timestamp); + +module.exports = mongoose.model('Task', schema); + + diff --git a/handlers/tutorial/renderer/articleRenderer.js b/handlers/tutorial/renderer/articleRenderer.js new file mode 100755 index 000000000..42d96a636 --- /dev/null +++ b/handlers/tutorial/renderer/articleRenderer.js @@ -0,0 +1,191 @@ +const _ = require('lodash'); +const config = require('config'); +const BodyParser = require('simpledownParser').BodyParser; +const ServerHtmlTransformer = require('serverHtmlTransformer'); +const log = require('log')(); +const Article = require('../models/article'); + +// Порядок библиотек на странице +// - встроенный CSS +// - библиотеки CSS +// - [head] (css, important short js w/o libs, js waits libs on DocumentContentLoaded) +// ... +// - встроенный JS +// - библиотеки JS + +/** + * Can render many articles, keeping metadata + * @constructor + */ +function ArticleRenderer() { + this.metadata = {}; +} + +// gets content from metadata.libs & metadata.head +ArticleRenderer.prototype.getHead = function() { + return [].concat( + this._libsToJsCss( + this._unmapLibsNames(this.metadata.libs.toArray()) + ).css, + this._libsToJsCss( + this._unmapLibsNames(this.metadata.libs.toArray()) + ).js, + this.metadata.head) + .filter(Boolean).join("\n"); +}; + +// js at bottom +ArticleRenderer.prototype.getFoot = function() { + return this._libsToJsCss(this._unmapLibsNames(this.metadata.libs.toArray())).js + .filter(Boolean).join("\n"); +}; + +// Все библиотеки должны быть уникальны +// Если один ресурс требует JQUERY и другой тоже, то нужно загрузить только один раз JQUERY +// Именно форматтер окончательно форматирует библиотеки, т.к. он знает про эти мапппинги +// +// Кроме того, парсер может распарсить много документов для сбора метаданных +ArticleRenderer.prototype._unmapLibsNames = function(libs) { + var libsUnmapped = []; + + // заменить все-все короткие имена + // предполагается, что короткое имя при раскрытии не содержит другого короткого имени (легко заимплементить) + + libs.forEach(function(lib) { + switch (lib) { + case 'lodash': + libsUnmapped.push("https://cdnjs.cloudflare.com/ajax/libs/lodash.js/3.3.1/lodash.min.js"); + break; + + case 'd3': + libsUnmapped.push("https://cdnjs.cloudflare.com/ajax/libs/d3/3.5.5/d3.min.js"); + break; + + case 'domtree': + libsUnmapped.push("domtree.css", "domtree.js"); + break; + + default: + libsUnmapped.push(lib); + } + }); + + return libsUnmapped; +}; + + +ArticleRenderer.prototype._libsToJsCss = function(libs) { + var js = []; + var css = []; + + _.uniq(libs).forEach(function(lib) { + if (!~lib.indexOf('://')) { + lib = 'https://js.cx/libs/' + lib; + } + + if (lib.slice(-3) == '.js') { + js.push(''); + } else if (lib.slice(-4) == '.css') { + css.push(""); + } else { + js.push(""); + } + }); + + return { + js: js, + css: css + }; +}; + +/** + * Render, gather metadata to the renderer object + * @param article + * @param options + * options.noStripTitle disables stripping of the first header + * options.headerLevelShift shifts all headers (to render in ebook as a subchapter0 + * @returns {{content: *, headers: *, head: *, foot: *}} + */ +ArticleRenderer.prototype.render = function* (article, options) { + + options = Object.create(options || {}); + options.metadata = this.metadata; + options.trusted = true; + if (options.linkHeaderTag === undefined) options.linkHeaderTag = true; + + // shift off the title header + const node = new BodyParser(article.content, options).parseAndWrap(); + + if (!options.noStripTitle) { + node.removeChild(node.getChild(0)); + } + + this.headers = []; + + node.getChildren().forEach(function(child) { + if (child.getType() != 'HeaderTag') return; + + if (options.headerLevelShift) { + child.level += options.headerLevelShift; + } + + this.headers.push({ + level: child.level, + anchor: child.anchor, + title: child.text + }); + + }, this); + + const transformer = new ServerHtmlTransformer({ + staticHost: config.server.staticHost, + resourceWebRoot: article.getResourceWebRoot(), + linkHeaderTag: options.linkHeaderTag, + ebookType: options.ebookType + }); + + this.content = yield* transformer.transform(node, true); + + return { + content: this.content, + headers: this.headers, + head: this.getHead(), + foot: this.getFoot() + }; +}; + +/** + * Render with cache + * @param article + * @param options Add refreshCache: true not to use the cached value + * @returns {*} + */ +ArticleRenderer.prototype.renderWithCache = function*(article, options) { + options = options || {}; + + var useCache = !options.refreshCache && config.renderedCacheEnabled; + + if (article.rendered && useCache) return article.rendered; + + var rendered = yield* this.render(article); + + article.rendered = rendered; + + yield article.persist(); + + return rendered; +}; + + +ArticleRenderer.regenerateCaches = function*() { + var articles = yield Article.find({}).exec(); + + for (var i = 0; i < articles.length; i++) { + var article = articles[i]; + log.debug("regenerate article", article._id); + yield* (new ArticleRenderer()).renderWithCache(article, {refreshCache: true}); + } +}; + + +module.exports = ArticleRenderer; diff --git a/handlers/tutorial/renderer/taskRenderer.js b/handlers/tutorial/renderer/taskRenderer.js new file mode 100755 index 000000000..5bd506b28 --- /dev/null +++ b/handlers/tutorial/renderer/taskRenderer.js @@ -0,0 +1,183 @@ +const HeaderTag = require('simpledownParser').HeaderTag; +const BodyParser = require('simpledownParser').BodyParser; +const ServerHtmlTransformer = require('serverHtmlTransformer'); +const CompositeTag = require('simpledownParser').CompositeTag; +const config = require('config'); +const Plunk = require('plunk').Plunk; +const Task = require('../models/task'); +const log = require('log')(); + +/** + * Can render many articles, keeping metadata + * @constructor + */ +function TaskRenderer() { + this.metadata = {}; +} + +TaskRenderer.prototype.renderContent = function* (task, options) { + + options = Object.create(options); + options.metadata = this.metadata; + options.trusted = true; + + const node = new BodyParser(task.content, options).parseAndWrap(); + + node.removeChild(node.getChild(0)); + + const transformer = new ServerHtmlTransformer({ + resourceWebRoot: task.getResourceWebRoot(), + staticHost: config.server.staticHost, + ebookType: options.ebookType + }); + + var content = yield* transformer.transform(node, true); + + content = yield* this.addContentPlunkLink(task, content); + return content; +}; + + +TaskRenderer.prototype.addContentPlunkLink = function*(task, content) { + + var sourcePlunk = yield Plunk.findOne({webPath: task.getResourceWebRoot() + '/source'}).exec(); + + if (sourcePlunk) { + + var files = sourcePlunk.files.toObject(); + var hasTest = false; + for (var i = 0; i < files.length; i++) { + if (files[i].filename == 'test.js') hasTest = true; + } + + var title = hasTest ? + 'Открыть песочницу с тестами для задачи.' : + 'Открыть песочницу для задачи.'; + + + content += '' + title + ''; + } + + return content; +}; + +TaskRenderer.prototype.render = function*(task, options) { + + this.content = yield* this.renderContent(task, options); + this.solution = yield* this.renderSolution(task, options); + + return { + content: this.content, + solution: this.solution + }; +}; + +TaskRenderer.prototype.renderWithCache = function*(task, options) { + options = options || {}; + + var useCache = !options.refreshCache && config.renderedCacheEnabled; + + if (task.rendered && useCache) return task.rendered; + + var rendered = yield* this.render(task, options); + + task.rendered = rendered; + + yield task.persist(); + + return rendered; +}; + + +TaskRenderer.prototype.renderSolution = function* (task, options) { + + options = Object.create(options); + options.metadata = this.metadata; + options.trusted = true; + + const node = new BodyParser(task.solution, options).parseAndWrap(); + + var children = node.getChildren(); + + const transformer = new ServerHtmlTransformer({ + resourceWebRoot: task.getResourceWebRoot(), + staticHost: config.server.staticHost, + ebookType: options.ebookType + }); + + const solutionParts = []; + +// if no #header at start +// no parts, single solution + if (!(children[0] instanceof HeaderTag)) { + var solution = yield* transformer.transform(node, true); + solution = yield* this.addSolutionPlunkLink(task, solution); + return solution; + } + +// otherwise, split into parts + var currentPart; + for (var i = 0; i < children.length; i++) { + var child = children[i]; + if (child instanceof HeaderTag) { + currentPart = {title: stripTags(yield transformer.transform(child, true)), content: []}; + solutionParts.push(currentPart); + continue; + } + + currentPart.content.push(child); + } + + for (var i = 0; i < solutionParts.length; i++) { + var part = solutionParts[i]; + var child = new CompositeTag(null, part.content); + child.trusted = node.trusted; + part.content = yield* transformer.transform(child, true); + } + + var solutionPartLast = solutionParts[solutionParts.length - 1]; + solutionParts[solutionParts.length - 1].content = yield* this.addSolutionPlunkLink(task, solutionPartLast.content); + + return solutionParts; +} +; + +TaskRenderer.prototype.addSolutionPlunkLink = function*(task, solution) { + + var solutionPlunk = yield Plunk.findOne({webPath: task.getResourceWebRoot() + '/solution'}).exec(); + + if (solutionPlunk) { + var files = solutionPlunk.files.toObject(); + var hasTest = false; + for (var i = 0; i < files.length; i++) { + if (files[i].filename == 'test.js') hasTest = true; + } + + var title = hasTest ? + 'Открыть решение с тестами в песочнице.' : + 'Открыть решение в песочнице'; + + solution += '' + title + ''; + + } + + return solution; +}; + + +TaskRenderer.regenerateCaches = function*() { + var tasks = yield Task.find({}).exec(); + + for (var i = 0; i < tasks.length; i++) { + var task = tasks[i]; + log.debug("regenerate task", task._id); + yield* (new TaskRenderer()).renderWithCache(task, {refreshCache: true}); + } +}; + + +function stripTags(text) { + return text.replace(/<\/?[a-z].*?>/gim, ''); +} + +module.exports = TaskRenderer; diff --git a/handlers/tutorial/router.js b/handlers/tutorial/router.js new file mode 100755 index 000000000..23097e4c6 --- /dev/null +++ b/handlers/tutorial/router.js @@ -0,0 +1,24 @@ +var Router = require('koa-router'); + +var task = require('./controller/task'); +var article = require('./controller/article'); +var frontpage = require('./controller/frontpage'); +var node = require('./controller/node'); +var zipview = require('./controller/zipview'); +var map = require('./controller/map'); + +var router = module.exports = new Router(); + +router.all('/task/:slug/:view/:serverPath*', node.all); +router.all('/article/:slug/:view/:serverPath*', node.all); + +router.get('/task/:slug', task.get); +router.get('/tutorial/map', map.get); +router.get('/tutorial/zipview/:name', zipview.get); +router.get('/', frontpage.get); +router.get('/tutorial', function*() { + this.status = 301; + this.redirect('/'); +}); + +router.get('/:slug', article.get); diff --git a/handlers/tutorial/tasks/beautify.js b/handlers/tutorial/tasks/beautify.js new file mode 100755 index 000000000..488fa76f9 --- /dev/null +++ b/handlers/tutorial/tasks/beautify.js @@ -0,0 +1,146 @@ +var co = require('co'); +var fs = require('fs'); +var path = require('path'); +var log = require('log')(); +var glob = require('glob'); +var beautify = require('js-beautify'); +var readlineSync = require('readline-sync'); + +module.exports = function(options) { + + return function() { + + var args = require('yargs') + .usage("Path to tutorial root is required.") + .demand(['root']) + .argv; + + var root = fs.realpathSync(args.root); + + var options = { + indent_size: 2, + selector_separator_newline: true, + newline_between_rules: true, + preserve_newlines: true + //space_in_paren: true + }; + return co(function* () { + + var jsFiles = glob.sync( path.join( root, '**', '*.js' ) ); + + for (var i = 0; i < jsFiles.length; i++) { + var jsFile = jsFiles[i]; + var content = fs.readFileSync(jsFile, 'utf8'); + + fs.writeFileSync(jsFile, beautify.js(content, options), 'utf8'); + } + + var cssFiles = glob.sync(path.join(root, '**', '*.css')); + + for (var i = 0; i < cssFiles.length; i++) { + var cssFile = cssFiles[i]; + var content = fs.readFileSync(cssFile, 'utf8'); + fs.writeFileSync(cssFile, beautify.css(content, options), 'utf8'); + } + + + var htmlFiles = glob.sync(path.join(root, '**', '*.html')); + + for (var i = 0; i < htmlFiles.length; i++) { + var htmlFile = htmlFiles[i]; + var content = fs.readFileSync(htmlFile, 'utf8'); + fs.writeFileSync(htmlFile, beautify.html(content, options), 'utf8'); + } + + var mdFiles = glob.sync(path.join(root, '**', '*.md')); + + for (var i = 0; i < mdFiles.length; i++) { + var mdFile = mdFiles[i]; + var content = fs.readFileSync(mdFile, 'utf8'); + console.log(mdFile); + fs.writeFileSync(mdFile, beautifyMd(content, mdFile, options), 'utf8'); + } + + + }); + }; +}; + +function beautifyMd(content, mdFile, options) { + + var contentNew; + + contentNew = content.replace(/```(js|html|css)\n([\s\S]*?)\n```/gim, function(match, lang, code) { + var codeOpts = code.match(/^\/\/\+.*\n/) || code.match(/^' : '/**!**/'); + beautified = beautified.replace(/^[ \t]*\*\/!\*/gim, lang == 'html' ? '' : '/**-!**/'); + + //console.log(beautified); + beautified = beautify[lang](beautified, options); + + //console.log(beautified); + beautified = beautified.replace(/^[ \t]*/gim, '*!*'); + beautified = beautified.replace(/^[ \t]*/gim, '*/!*'); + beautified = beautified.replace(/^[ \t]*\/\*\*!\*\*\//gim, '*!*'); + beautified = beautified.replace(/^[ \t]*\/\*\*-!\*\*\//gim, '*/!*'); + + beautified = beautified.replace(/alert\((\S.*?)\);/gim, 'alert( $1 );'); + + if (beautified === codeNoOpts) { + return match; // nothing changed (already beautified?), skip + } + + // clear console + process.stdout.write('\u001B[2J\u001B[0;0f'); + console.log("\n" + mdFile); + console.log("=======================================================\n"); + console.log(codeNoOpts); + console.log("-------------------------------------------------------"); + console.log(beautified); + + var keep = readlineSync.question('Beautify [y]?'); + + var result; + if (keep == 'y' || keep === '') { + result = codeOpts + beautified; + } else { + var codeOptsNoBeautify = codeOpts.slice(0, 2) == '//' ? codeOpts.replace("\n", " no-beautify\n") : + codeOpts.slice(0, 2) == '/*' ? codeOpts.replace("*/", " no-beautify */") : + codeOpts.slice(0, 2) == '", " no-beautify -->") : + lang == 'html' ? '\n' : + lang == 'css' ? '/*+ no-beautify */\n' : + '//+ no-beautify\n'; + + result = codeOptsNoBeautify + codeNoOpts; + } + + result = "```" + lang + "\n" + result + "\n```"; + return result; + }); + + + + return contentNew; + +} + diff --git a/handlers/tutorial/tasks/cacheRegenerate.js b/handlers/tutorial/tasks/cacheRegenerate.js new file mode 100755 index 000000000..d575e95bb --- /dev/null +++ b/handlers/tutorial/tasks/cacheRegenerate.js @@ -0,0 +1,17 @@ +const gulp = require('gulp'); + +const ArticleRenderer = require('../renderer/articleRenderer'); +const TaskRenderer = require('../renderer/taskRenderer'); +const co = require('co'); + +module.exports = function() { + + return function() { + return co(function*() { + yield* ArticleRenderer.regenerateCaches(); + yield* TaskRenderer.regenerateCaches(); + }); + + }; +}; + diff --git a/handlers/tutorial/tasks/edit.js b/handlers/tutorial/tasks/edit.js new file mode 100755 index 000000000..e3ebbeaea --- /dev/null +++ b/handlers/tutorial/tasks/edit.js @@ -0,0 +1,60 @@ +var co = require('co'); +var fs = require('fs'); +var path = require('path'); +var log = require('log')(); +var Article = require('../models/article'); +var Task = require('../models/task'); +var url = require('url'); +var execSync = require('child_process').execSync; + +module.exports = function(options) { + + return function() { + + return co(function* () { + + var args = require('yargs') + .usage("tutorial url is required.") + .example("gulp tutorial:edit --url http://javascript.in/memory-leaks-jquery") + .demand(['url']) + .argv; + + var urlPath = url.parse(args.url).pathname.split('/').filter(Boolean); + + if (urlPath.length == 1) { + var article = yield Article.findOne({slug: urlPath[0]}).exec(); + if (!article) { + return; + } + + var dirName = article.weight + '-' + article.slug; + var result = execSync("find /js/javascript-tutorial -path '*/" + dirName + "/article.md'", {encoding: 'utf8'}).trim(); + + if (!result) { + return; + } + + console.log(path.dirname(result)); + execSync('s ' + result); + } + + if (urlPath[0] == 'task') { + var task = yield Task.findOne({slug: urlPath[1]}).exec(); + if (!task) { + return; + } + + var dirName = task.weight + '-' + task.slug; + var result = execSync("find /js/javascript-tutorial -path '*/" + dirName + "/task.md'", {encoding: 'utf8'}).trim(); + + if (!result) { + return; + } + + console.log(path.dirname(result)); + execSync('s ' + result + ' ' + result.replace('task.md', 'solution.md')); + } + + }); + }; +}; diff --git a/handlers/tutorial/tasks/figuresImport.js b/handlers/tutorial/tasks/figuresImport.js new file mode 100755 index 000000000..5ccd5d520 --- /dev/null +++ b/handlers/tutorial/tasks/figuresImport.js @@ -0,0 +1,37 @@ +/** + * Import figures.sketch into tutorial + * @type {FiguresImporter|exports} + */ + +var FiguresImporter = require('../figuresImporter'); +var co = require('co'); +var fs = require('fs'); +var path = require('path'); +var log = require('log')(); + +module.exports = function(options) { + + return function() { + + var args = require('yargs') + .usage("Path to tutorial root is required.") + .demand(['root']) + .argv; + + var root = fs.realpathSync(args.root); + + var importer = new FiguresImporter({ + root: root, + figuresFilePath: path.join(root, 'figures.sketch') + }); + + return co(function* () { + + yield* importer.syncFigures(); + + log.info("Figures imported"); + }); + }; +}; + + diff --git a/handlers/tutorial/tasks/import.js b/handlers/tutorial/tasks/import.js new file mode 100755 index 000000000..8931e1904 --- /dev/null +++ b/handlers/tutorial/tasks/import.js @@ -0,0 +1,43 @@ +var TutorialImporter = require('../tutorialImporter'); +var co = require('co'); +var fs = require('fs'); +var path = require('path'); + +module.exports = function(options) { + + return function() { + + var args = require('yargs') + .usage("Path to tutorial root is required.") + .demand(['root']) + .argv; + + var root = fs.realpathSync(args.root); + + var importer = new TutorialImporter({ + root: root + }); + + return co(function* () { + + yield* importer.destroyAll(); + + yield* importer.syncFigures(path.join(root, 'figures.sketch')); + + var subRoots = fs.readdirSync(root); + + for (var i = 0; i < subRoots.length; i++) { + var subRoot = subRoots[i]; + if (!parseInt(subRoot)) continue; + yield* importer.sync(path.join(root, subRoot)); + } + + yield* importer.generateCaches(); + + console.log("DONE"); + + }); + }; +}; + + diff --git a/handlers/tutorial/tasks/importWatch.js b/handlers/tutorial/tasks/importWatch.js new file mode 100755 index 000000000..02eaa9356 --- /dev/null +++ b/handlers/tutorial/tasks/importWatch.js @@ -0,0 +1,106 @@ +var TutorialImporter = require('../tutorialImporter'); +var FiguresImporter = require('../figuresImporter'); +var co = require('co'); +var fs = require('fs'); +var path = require('path'); +var livereload = require('gulp-livereload'); +var log = require('log')(); +var chokidar = require('chokidar'); + +module.exports = function(options) { + + return function(callback) { + + if (!options.root) { + throw new Error("Import watch root is not provided"); + } + + var root = fs.realpathSync(options.root); + + if (!root) { + throw new Error("Import watch root does not exist " + options.root); + } + + watchTutorial(root); + watchFigures(root); + + livereload.listen(); + + }; + +}; + + +function watchTutorial(root) { + + + var importer = new TutorialImporter({ + root: root, + onchange: function(path) { + log.info("livereload.change", path); + livereload.changed(path); + } + }); + + + var subRoots = fs.readdirSync(root); + subRoots = subRoots.filter(function(subRoot) { + return parseInt(subRoot); + }).map(function(dir) { + return path.join(root, dir); + }); + + var tutorialWatcher = chokidar.watch(subRoots, {ignoreInitial: true}); + + tutorialWatcher.on('add', onTutorialModify.bind(null, false)); + tutorialWatcher.on('change', onTutorialModify.bind(null, false)); + tutorialWatcher.on('unlink', onTutorialModify.bind(null, false)); + tutorialWatcher.on('unlinkDir', onTutorialModify.bind(null, true)); + tutorialWatcher.on('addDir', onTutorialModify.bind(null, true)); + + function onTutorialModify(isDir, filePath) { + if (~filePath.indexOf('___jb_')) return; // ignore JetBrains Webstorm tmp files + + log.debug("ImportWatch Modify " + filePath); + + co(function* () { + + var folder; + if (isDir) { + folder = filePath; + } else { + folder = path.dirname(filePath); + } + + yield* importer.sync(folder); + + }).catch(function(err) { + log.error(err); + }); + } + +} + +function watchFigures(root) { + + var figuresFilePath = path.join(root, 'figures.sketch'); + var importer = new FiguresImporter({ + root: root, + figuresFilePath: figuresFilePath + }); + + var figuresWatcher = chokidar.watch(figuresFilePath, {ignoreInitial: true}); + figuresWatcher.on('change', onFiguresModify); + + function onFiguresModify() { + + co(function* () { + + yield* importer.syncFigures(); + + }).catch(function(err) { + throw err; + }); + } + +} \ No newline at end of file diff --git a/handlers/tutorial/tasks/killContent.js b/handlers/tutorial/tasks/killContent.js new file mode 100755 index 000000000..f542e0e4c --- /dev/null +++ b/handlers/tutorial/tasks/killContent.js @@ -0,0 +1,87 @@ +var co = require('co'); +var Article = require('../models/article'); +var Task = require('../models/task'); +const ArticleRenderer = require('../renderer/articleRenderer'); +const TaskRenderer = require('../renderer/taskRenderer'); + +module.exports = function() { + + return function() { + + return co(function*() { + + yield* killArticles(); + yield* killTasks(); + + yield* renderTasks(); + yield* renderArticles(); + console.log("DONE"); + }); + }; + +}; + +function* killArticles() { + + var articles = yield Article.find({}).exec(); + + for (var i = 0; i < articles.length; i++) { + var article = articles[i]; + + article.content = '# ' + article.title + '\n\n## Article ' + article.weight + '\n\nText'; + + yield article.persist(); + + } + +} + +function* renderArticles() { + + var articles = yield Article.find({}).exec(); + + for (var i = 0; i < articles.length; i++) { + var article = articles[i]; + + var renderer = new ArticleRenderer(); + + yield* renderer.renderWithCache(article, {refreshCache: true}); + + yield article.persist(); + + } + +} + + +function* renderTasks() { + + var tasks = yield Task.find({}).exec(); + + for (var i = 0; i < tasks.length; i++) { + var task = tasks[i]; + + var renderer = new TaskRenderer(); + + yield* renderer.renderWithCache(task, {refreshCache: true}); + + yield task.persist(); + + } + +} + +function* killTasks() { + + var tasks = yield Task.find({}).exec(); + + for (var i = 0; i < tasks.length; i++) { + var task = tasks[i]; + + task.content = '# ' + task.title + '\n\nTask content ' + task.weight; + task.solution = 'Task solution ' + task.weight; + + yield task.persist(); + } + +} \ No newline at end of file diff --git a/handlers/tutorial/tasks/remoteUpdate.js b/handlers/tutorial/tasks/remoteUpdate.js new file mode 100755 index 000000000..f855d8327 --- /dev/null +++ b/handlers/tutorial/tasks/remoteUpdate.js @@ -0,0 +1,136 @@ +/** + * Copy local collections to remove mongo without drop + * (drop breaks elastic) + */ + +var co = require('co'); +var fs = require('fs'); +var path = require('path'); +var log = require('log')(); +var del = require('del'); +var gutil = require('gulp-util'); +var execSync = require('child_process').execSync; +var config = require('config'); + +var ecosystem = require(path.join(config.projectRoot, 'ecosystem.json')); + +module.exports = function(options) { + + return function() { + + var args = require('yargs') + .usage("Path to host is required.") + .demand(['host']) + .argv; + + var collections = ['tasks', 'plunks', 'articles', 'references']; + + var host = args.host; + return co(function* () { + + exec('rsync -crlDvtz --delete-after /js/javascript-nodejs/public/task /js/javascript-nodejs/public/article ' + host + ':/js/javascript-nodejs/current/public/'); + + + del.sync('dump'); + + + exec('mkdir dump'); + + /* + monngoexport/import instead of mongodump => for better debug + collections.forEach(function(coll) { + exec('mongoexport --out dump/'+coll+'.json -d js -c ' + coll); + }); + + exec('ssh ' + args.host + ' "rm -rf dump"'); + exec('scp -r -C dump ' + host + ':'); + + collections.forEach(function(coll) { + exec('ssh ' + host + ' "mongoimport --db js_sync --drop --file dump/'+coll+'.json"'); + });*/ + collections.forEach(function(coll) { + exec('mongodump -d js -c ' + coll); + }); + exec('mv dump/js dump/js_sync'); + + exec('ssh ' + args.host + ' "rm -rf dump"'); + exec('scp -r -C dump ' + host + ':'); + + exec('ssh ' + host + ' "mongorestore --drop"'); + + + + var file = fs.openSync("/tmp/cmd.js", "w"); + + fs.writeFileSync("/tmp/check.sh", 'mongo js --eval "db.articles.find().length()";\n'); + + // copy/overwrite collections from js_sync to js and then remove non-existing ids + // without destroy! (elasticsearch river breaks) + fs.writeSync(file, collections.map(function(coll) { + // copyTo does not work + // also see https://jira.mongodb.org/browse/SERVER-732 + + // remove non-existing articles + // insert (replace) synced ones + var cmd = ` + db.COLL.find({}, {id:1}).forEach(function(d) { + var cursor = db.getSiblingDB('js_sync').COLL.find({_id:d._id}, {id:1}); + + if (!cursor.hasNext()) { + db.COLL.remove({_id: d._id}); + } + }); + + db.getSiblingDB('js_sync').COLL.find().forEach(function(d) { db.COLL.update({_id:d._id}, d, { upsert: true}) }); + `.replace(/COLL/g, coll); + // db.getSiblingDB('js_sync').COLL.find().forEach(function(d) { print(db.COLL.update({_id:d._id}, d, { upsert: true}) ) }); + + + return cmd; + + }).join("\n\n")); + + + fs.closeSync(file); + + exec('scp /tmp/cmd.js ' + host + ':/tmp/'); + exec('scp /tmp/check.sh ' + host + ':/tmp/'); + + exec('ssh ' + host + ' "bash /tmp/check.sh"'); + exec('ssh ' + host + ' "mongo js /tmp/cmd.js"'); + exec('ssh ' + host + ' "bash /tmp/check.sh"'); + + /* jshint -W106 */ + var env = ecosystem.apps[0].env_production; + + exec('ssh ' + host + ' "cd /js/javascript-nodejs/current && SITE_HOST=' + env.SITE_HOST+ ' STATIC_HOST=' + env.STATIC_HOST + ' gulp tutorial:cache:regenerate && gulp cache:clean"'); + }); + }; +}; + +function exec(cmd) { + gutil.log(cmd); + execSync(cmd, {stdio: 'inherit'}); +} + +/* + #!/bin/bash + + + rm -rf dump && + mongodump -d js -c tasks && + mongodump -d js -c plunks && + mongodump -d js -c articles && + mongodump -d js -c references && + ssh nightly 'rm -rf dump' && + scp -r -C dump nightly: && + ssh nightly 'mongorestore --drop ' && + rsync -rlDv /js/javascript-nodejs/public/ nightly:/js/javascript-nodejs/current/public/ && + ssh nightly 'cd /js/javascript-nodejs/current/scripts/elastic; bash db' && + echo "Tutorial updated" + + + db.getSiblingDB('js_sync').articles.copyTo(db.articles) + vals = db.getSiblingDB('js_sync').articles.find({}, {id:1}).map(function(a){return a._id;}) + db.articles.remove({_id: {$nin: vals}}) +*/ diff --git a/handlers/tutorial/tasks/tutorialImport.js b/handlers/tutorial/tasks/tutorialImport.js new file mode 100755 index 000000000..ff286ea3b --- /dev/null +++ b/handlers/tutorial/tasks/tutorialImport.js @@ -0,0 +1,47 @@ +/** + * Import tutorial into DB + * @type {TutorialImporter|exports} + */ + +var TutorialImporter = require('../tutorialImporter'); +var co = require('co'); +var fs = require('fs'); +var path = require('path'); +var log = require('log')(); + +module.exports = function(options) { + + return function() { + + var args = require('yargs') + .usage("Path to tutorial root is required.") + .demand(['root']) + .argv; + + var root = fs.realpathSync(args.root); + + var importer = new TutorialImporter({ + root: root + }); + + return co(function* () { + + yield* importer.destroyAll(); + + var subRoots = fs.readdirSync(root); + + for (var i = 0; i < subRoots.length; i++) { + var subRoot = subRoots[i]; + if (!parseInt(subRoot)) continue; + yield* importer.sync(path.join(root, subRoot)); + } + + yield* importer.generateCaches(); + + log.info("DONE"); + + }); + }; +}; + + diff --git a/handlers/tutorial/templates/_map.jade b/handlers/tutorial/templates/_map.jade new file mode 100755 index 000000000..6024f376c --- /dev/null +++ b/handlers/tutorial/templates/_map.jade @@ -0,0 +1,41 @@ +include /bem + ++b.tutorial-map + +e.filter + +e.filter-t + +e.input-wrap + +b('span').text-input.__input + +e('input').control(type="text" placeholder="Фильтр по заголовку" data-tutorial-map-filter) + +b.close-button.__clear + +e.option + +e('label').option-label(for="show-tasks") + +e('input').option-control#show-tasks(type="checkbox" data-tutorial-map-show-tasks) + | Показать задачи + +e.layout + +b.switch.__layout-switch(data-tutorial-map-layout-switch) + +e.option + +e('input').control#multicol(type="radio" name="map-layout" value="0" checked="checked") + +e('label').label.__multicol(for="multicol") + +e.option + +e('input').control#singlecol(type="radio" name="map-layout" value="1") + +e('label').label.__singlecol(for="singlecol") + + +e.tutorial-map-map.columns.columns_3 + each topic in children + +e.section.columns__col + +e('h2').col-title= topic.title + +e('ul').items + each subtopic in topic.children + +e('li').item + +e('a').link(href=subtopic.url)= subtopic.title + +e('ul').sub-items + each article in subtopic.children + +e('li').sub-item + +e('a').link(href=article.url)= article.title + if article.tasks + +e('ul').sub-sub-items + each task in article.tasks + +e('li').sub-sub-item + +e('a').link(href=task.url)= task.title + =" " + +e('span').note= task.importance diff --git a/handlers/tutorial/templates/_task_content.jade b/handlers/tutorial/templates/_task_content.jade new file mode 100755 index 000000000..1c67918c8 --- /dev/null +++ b/handlers/tutorial/templates/_task_content.jade @@ -0,0 +1,17 @@ ++e.content + +e.answer + if (task.solution instanceof Array) + each step, i in task.solution + +e.step._open + +e('button').step-show(type="button", onclick="showStep(this)")= step.title + +e.answer-content + +e('h4').step-title= step.title + != step.content + + else + +e.answer-content + != task.solution + + +b('button').close-button.__answer-close(type="button", title="закрыть") + + != task.content diff --git a/handlers/tutorial/templates/article.jade b/handlers/tutorial/templates/article.jade new file mode 100755 index 000000000..7ccbb1b72 --- /dev/null +++ b/handlers/tutorial/templates/article.jade @@ -0,0 +1,37 @@ +extends /layouts/main + +block append variables + - var layout_main_class = "main_width-limit" + +block sidebar + include sidebar + +block content + != content + + if tasks.length + + +b.tasks + +e('h2').title#tasks + +e('a').title-anchor.main__anchor.main__anchor_noicon(href="#tasks") Задачи + + each task, i in tasks + +b.task.__task + +e.header + +e.title-wrap + +e('h3').title + a.main__anchor(href=("#"+task.anchor) name=task.anchor)= task.title + +e('a').open-link(href=task.url, target="_blank") + + +e.header-note + if task.importance + +e('span').importance(title="Насколько эта задача важна для освоения материала, от 1 до 5") важность: #{task.importance} + +e('button').solution(type="button") решение + + include _task_content + + + script(src=pack("tutorial", "js")) + script tutorial.init(); + + include /blocks/banner-bottom diff --git a/handlers/tutorial/templates/folder.jade b/handlers/tutorial/templates/folder.jade new file mode 100755 index 000000000..12fea2fd9 --- /dev/null +++ b/handlers/tutorial/templates/folder.jade @@ -0,0 +1,37 @@ +extends /layouts/main + +block append variables + - var layout_main_class = "main_width-limit" + +block sidebar + include sidebar + +block content + + != content + + +b.lessons-list + +e('ol').lessons + if levelMax == 2 && level == 0 + // top-level folders in 2-level courses + each article in children + +e('li')(class="lesson #{article.children ? '_level_1' : ''}") + +e('a').link(href=article.url) #{article.title} + if article.children + +e('ol').lessons + each subChild in article.children + +e('li')(data-section-number=subChild.weight).lesson._level_2 + +e('a').link(href=subChild.url) #{subChild.title} + else if levelMax == 2 && level == 1 + // 1-level folders in 2-level courses + each article in children + +e('li')(data-section-number=weight, class="lesson #{article.children ? '_level_1' : ''}") + +e('a').link(href=article.url) #{article.title} + else + // folders in plain courses + each article in children + +e('li').lesson + +e('a').link(href=article.url) #{article.title} + + script(src=pack("tutorial", "js")) + script tutorial.init(); diff --git a/handlers/tutorial/templates/frontpage.jade b/handlers/tutorial/templates/frontpage.jade new file mode 100755 index 000000000..33d6904e3 --- /dev/null +++ b/handlers/tutorial/templates/frontpage.jade @@ -0,0 +1,78 @@ +extends /layouts/main + +block append variables + - var layout_main_class = "main_width-limit-wide" + - var comments = true + - var layout_header_class = "main__header_center" + +block content + +b.intro + | Перед вами учебник по JavaScript, начиная с основ, включающий в себя много + | тонкостей и фишек JavaScript/DOM. + +b.course-search + +e('form').form(action="/search/") + +e.input-wrap._text + +b.text-input.__query + +e('input').control(type="text" name="query" placeholder="Искать в учебнике" required) + +e.input-wrap._submit + +b("button").button._action(type="submit" name="type" value="articles") + +e("span").text Найти + +b.course-info + +e.header + +e('h2').header-title Основной курс + include /blocks/map-with-title + + +e.body.columns.columns_2 + +e.col.columns__col + +e.content + +e.title-note Часть первая + +e('h3').title= chapters[0].title + != chapters[0].content + +b('ul').special-links-list + - each folder in chapters[0].children + +e('li').item + +e('a').link(href=folder.url)= folder.title + + +e.col.columns__col + +e.content + +e.title-note Часть вторая + +e('h3').title= chapters[1].title + != chapters[1].content + +b('ul').special-links-list + - each folder in chapters[1].children + +e('li').item + +e('a').link(href=folder.url)= folder.title + + +b.course-bricks + //- отдельные полноценные книги, в каждой свой progress + +e('h2').title Дополнительно + +e('p') Разделы, не вошедшие в основной курс (постоянно обновляются) + +e.container + - var i = 1 + while ++i < chapters.length + - var chapter = chapters[i] + +e.brick + +e('h3').brick-title= chapter.title + +e.brick-content!= chapter.content + +e('ul').chapters-list + each child in chapter.children + +e('li').item + +e('a').link(href=child.url)= child.title + + include /blocks/banner-bottom + + script(src=pack("tutorial", "js")) + script tutorial.init(); + + script(type="application/ld+json"). + { + "@context": "http://schema.org", + "@type": "WebSite", + "url": "https://learn.javascript.ru/", + "potentialAction": { + "@type": "SearchAction", + "target": "https://learn.javascript.ru/search?query={search_term_string}", + "query-input": "required name=search_term_string" + } + } + diff --git a/handlers/tutorial/templates/map.jade b/handlers/tutorial/templates/map.jade new file mode 100755 index 000000000..809258ec7 --- /dev/null +++ b/handlers/tutorial/templates/map.jade @@ -0,0 +1,9 @@ +extends /layouts/html + +block body + + include _map + + script(src=pack("tutorial", "js")) + script tutorial.init(); + script new (tutorial.TutorialMap)(document.querySelector('.tutorial-map')); diff --git a/handlers/tutorial/templates/sidebar.jade b/handlers/tutorial/templates/sidebar.jade new file mode 100755 index 000000000..3f6bacf96 --- /dev/null +++ b/handlers/tutorial/templates/sidebar.jade @@ -0,0 +1,21 @@ +each section in sidebar.sections + +e.section + if section.title + +e('h4').section-title !{section.title} + +e('nav').navigation + +e('ul').navigation-links + each link in section.links + +e('li').navigation-link + +e('a').link(href=link.url)= link.title + ++e.section + +e.section-title Поделиться + +b('a').share._tw.sidebar__share(href="https://twitter.com/share?url="+encodeURIComponent(url.href)) + //- [] encoded as %5B %5D to make validator happy + +b('a').share._fb.sidebar__share(href="https://www.facebook.com/sharer/sharer.php?s=100&p%5Burl%5D="+encodeURIComponent(url.href)) + +b('a').share._gp.sidebar__share(href="https://plus.google.com/share?url="+encodeURIComponent(url.href)) + +b('a').share._vk.sidebar__share(href="https://vkontakte.ru/share.php?url="+encodeURIComponent(url.href)) + ++e.section + +e('a').link(href="https://github.com/iliakan/javascript-nodejs" style="visibility:hidden") Редактировать на github + diff --git a/handlers/tutorial/templates/task.jade b/handlers/tutorial/templates/task.jade new file mode 100755 index 000000000..783b67e7e --- /dev/null +++ b/handlers/tutorial/templates/task.jade @@ -0,0 +1,23 @@ + +extends /layouts/body + +block append variables + - var layout_main_class = "main_width-limit" + +block main + +b.task-single + +e('a').back(href=articleUrl) + span вернуться к уроку + + +b.task.__task + +e.header + +e.title-wrap + +e('h2').title= task.title + +e.header-note + if task.importance + +e('span').importance(title="Насколько эта задача важна для освоения материала, от 1 до 5") важность: #{task.importance} + +e('button').solution(type="button") решение + include _task_content + + script(src=pack("tutorial", "js")) + script tutorial.init(); diff --git a/handlers/tutorial/test/.jshintrc b/handlers/tutorial/test/.jshintrc new file mode 100755 index 000000000..077663629 --- /dev/null +++ b/handlers/tutorial/test/.jshintrc @@ -0,0 +1,23 @@ +{ + "maxerr": 25, + "latedef": "nofunc", + "browser": false, + "node": true, + "indent": 2, + "camelcase": true, + "newcap": true, + "undef": true, + "esnext": true, + "multistr": true, + "noyield": true, + "expr": true, + "-W004": true, + "globals" : { + "describe" : false, + "it" : false, + "before" : false, + "beforeEach" : false, + "after" : false, + "afterEach" : false + } +} diff --git a/handlers/tutorial/test/fixture/article.js b/handlers/tutorial/test/fixture/article.js new file mode 100755 index 000000000..2d1f9b25d --- /dev/null +++ b/handlers/tutorial/test/fixture/article.js @@ -0,0 +1,65 @@ +const mongoose = require('mongoose'); + +var ids = []; +for(var i=0; i<10; i++) ids[i] = new mongoose.Types.ObjectId(); + +exports.Article = [ + { + _id: ids[0], + title: "Article 1", + slug: "article-1", + content: "Content 1", + modified: new Date(2014, 0, 1), + isFolder: true, + weight: 0 + }, + { + _id: ids[1], + title: "Article 2", + slug: "article-2", + content: "Content 2", + modified: new Date(2014, 0, 2), + isFolder: true, + weight: 1 + }, + { + _id: ids[2], + title: "Article 1.1", + slug: "article-1.1", + content: "Content 1.1", + modified: new Date(2014, 0, 2), + isFolder: false, + weight: 0, + parent: ids[0] + }, + { + _id: ids[3], + title: "Article 1.2", + slug: "article-1.2", + content: "Content 1.2", + modified: new Date(2014, 0, 3), + isFolder: false, + weight: 1, + parent: ids[0] + }, + { + _id: ids[4], + title: "Article 2.1", + slug: "article-2.1", + content: "Content 2.1", + modified: new Date(2014, 0, 4), + isFolder: false, + weight: 0, + parent: ids[1] + }, + { + _id: ids[5], + title: "Article 2.2", + slug: "article-2.2", + content: "Content 2.2", + modified: new Date(2014, 0, 5), + isFolder: false, + weight: 1, + parent: ids[1] + } +]; diff --git a/handlers/tutorial/test/model/article.js b/handlers/tutorial/test/model/article.js new file mode 100755 index 000000000..d798efa5b --- /dev/null +++ b/handlers/tutorial/test/model/article.js @@ -0,0 +1,48 @@ +require('app'); + +var dataUtil = require('lib/dataUtil'); +var mongoose = require('mongoose'); +var assert = require('assert'); +var path = require('path'); +var treeUtil = require('lib/treeUtil'); +var Article = require('../../models/article'); + +describe('Article', function() { + + before(function* () { + yield* dataUtil.loadModels(path.join(__dirname, '../fixture/article'), {reset: true}); + }); + + + it('sets created & modified', function*() { + var date = new Date(); + + var article = new Article({ + title: "Title", + slug: 'slug', + content: "Content", + isFolder: false, + weight: 0 + }); + + yield article.persist(); + + assert(article.modified >= date); + + yield article.destroy(); + }); + + describe('findTree', function() { + + it("returns nested structure { children: [ ... ] }", function* () { + var tree = yield Article.findTree(); + tree.children.length.should.be.eql(2); + tree.children[0].children.length.should.be.eql(2); + tree.children[1].children.length.should.be.eql(2); + // console.log(treeUtil.flattenArray(tree)); +// console.log(util.inspect(tree, {depth:100})); + }); + + }); + +}); diff --git a/handlers/tutorial/test/renderer/articleRenderer.js b/handlers/tutorial/test/renderer/articleRenderer.js new file mode 100755 index 000000000..e767fade0 --- /dev/null +++ b/handlers/tutorial/test/renderer/articleRenderer.js @@ -0,0 +1,55 @@ +const app = require('app'); + +const ArticleRenderer = require('../../renderer/articleRenderer'); +const mongoose = require('lib/mongoose'); +const Article = require('../../models/article'); + +describe("ArticleRenderer", function() { + + beforeEach(function* () { + yield Article.destroy(); + }); + + it("appends -2, -3... to header with same title", function* () { + + const article = new Article({ + title: "Title", + slug: "test", + content: "# Title\n\n## Title\n\nMy html *string*." + }); + const renderer = new ArticleRenderer(); + + const result = yield renderer.render(article); + result.content.replace(/\n/g, '').should.be.eql( + '

    Title

    My html string.

    ' + ); + + }); + + it("resolves references to article", function* () { + + yield new Article({ + title: "Title", + slug: "test", + weight: 0, + isFolder: false, + content: "# Title\n\n## Title\n\nMy html *string*." + }).persist(); + + const article = new Article({ + title: "Title", + slug: "test", + weight: 0, + isFolder: false, + content: "# Title\n\n[](/test)" + }); + + const renderer = new ArticleRenderer(); + + const result = yield renderer.render(article); + result.content.replace(/\n/g, '').should.be.eql( + '

    Title

    ' + ); + + }); +}); diff --git a/handlers/tutorial/test/renderer/taskRenderer.js b/handlers/tutorial/test/renderer/taskRenderer.js new file mode 100755 index 000000000..9f095838e --- /dev/null +++ b/handlers/tutorial/test/renderer/taskRenderer.js @@ -0,0 +1,49 @@ +const app = require('app'); + +const TaskRenderer = require('../../renderer/taskRenderer'); +const mongoose = require('lib/mongoose'); +const Task = require('../../models/task'); +const Plunk = require('plunk').Plunk; + +describe("TaskRenderer", function() { + + beforeEach(function* () { + yield Task.destroy(); + }); + + it("renderContent", function* () { + + const task = new Task({ + "content": "# Title\n\nContent", + "slug": "unique-slug-no-plunk-link-add", + "title": "Title", + "importance": 4, + "solution": "..." + }); + + const renderer = new TaskRenderer(); + + const result = yield renderer.renderContent(task, {}); + + result.replace(/\n/g, '').should.be.eql('

    Content

    '); + }); + + + it("renderSolution", function* () { + + const task = new Task({ + "content": "# Title\n\nContent", + "slug": "unique-slug-no-plunk-link-add", + "title": "Title", + "importance": 4, + "solution": "# Part 1\n\nContent 1\n\n# Part 2\n\nContent 2" + }); + const renderer = new TaskRenderer(); + + const result = yield renderer.renderSolution(task, {}); + + result.should.be.eql([{title: 'Part 1', content: '

    \nContent 1

    '}, + {title: 'Part 2', content: '

    \nContent 2

    '}]) + + }); +}); diff --git a/handlers/tutorial/tutorialImporter.js b/handlers/tutorial/tutorialImporter.js new file mode 100755 index 000000000..5a2435254 --- /dev/null +++ b/handlers/tutorial/tutorialImporter.js @@ -0,0 +1,547 @@ +const co = require('co'); +const util = require('util'); +const fs = require('fs'); +const fse = require('fs-extra'); +const path = require('path'); +const config = require('config'); +const mongoose = require('lib/mongoose'); + +const Article = require('tutorial').Article; +const Reference = require('tutorial').Reference; +const Plunk = require('plunk').Plunk; +const Task = require('tutorial').Task; +const ArticleRenderer = require('./renderer/articleRenderer'); +const TaskRenderer = require('./renderer/taskRenderer'); +const BodyParser = require('simpledownParser').BodyParser; +const TreeWalkerSync = require('simpledownParser').TreeWalkerSync; +const HeaderTag = require('simpledownParser').HeaderTag; +const log = require('log')(); + +// TODO: use htmlhint/jslint for html/js examples + +function TutorialImporter(options) { + this.root = fs.realpathSync(options.root); + this.onchange = options.onchange || function() { + }; +} + +TutorialImporter.prototype.sync = function* (directory) { + + log.info("sync", directory); + var dir = fs.realpathSync(directory); + var type; + while (true) { + if (dir.endsWith('.view') && !dir.endsWith('/_js.view')) { + type = 'View'; + break; + } + if (fs.existsSync(path.join(dir, 'index.md'))) { + type = 'Folder'; + break; + } + if (fs.existsSync(path.join(dir, 'article.md'))) { + type = 'Article'; + break; + } + if (fs.existsSync(path.join(dir, 'task.md'))) { + type = 'Task'; + break; + } + + dir = path.dirname(dir); + + if (directory == this.root || directory == '/') { + throw new Error("Unknown directory type: " + directory); + } + } + + var parentDir = path.dirname(dir); + + var parentSlug = path.basename(parentDir); + parentSlug = parentSlug.slice(parentSlug.indexOf('-') + 1); + + var parent; + if (fs.existsSync(path.join(parentDir, 'task.md'))) { + parent = yield Task.findOne({slug: parentSlug}).exec(); + } else { + parent = yield Article.findOne({slug: parentSlug}).exec(); + } + yield* this['sync' + type](dir, parent); + +}; + +/** + * Call this after all import is complete to generate caches/searches for ElasticSearch to consume + */ +TutorialImporter.prototype.generateCaches = function*() { + yield ArticleRenderer.regenerateCaches(); + yield TaskRenderer.regenerateCaches(); +}; + +TutorialImporter.prototype.extractHeader = function(parsed) { + log.debug("extracting header"); + + const titleHeader = parsed.getChild(0); + if (titleHeader.getType() != 'HeaderTag') { + throw new Error("must start with a #Header"); + } + + return titleHeader.text; // no more ugly code in headers +}; + + +// maybe move to separate task? +TutorialImporter.prototype.checkIfErrorsInParsed = function(parsed) { + log.debug("checking errors in parsed"); + const walker = new TreeWalkerSync(parsed); + const errors = []; + + walker.walk(function(node) { + if (node.getType() == 'ErrorTag') { + errors.push(node.text); + } + }); + + if (errors.length) { + throw new Error("Errors: " + errors.join()); + } +}; + + +TutorialImporter.prototype.destroyAll = function* () { + yield Article.destroy({}); + yield Task.destroy({}); + yield Reference.destroy({}); +}; + +TutorialImporter.prototype.syncFolder = function*(sourceFolderPath, parent) { + log.info("syncFolder", sourceFolderPath); + + const contentPath = path.join(sourceFolderPath, 'index.md'); + const content = fs.readFileSync(contentPath, 'utf-8').trim(); + + const folderFileName = path.basename(sourceFolderPath); + + const data = { + isFolder: true, + content: content + }; + + if (parent) { + data.parent = parent._id; + } + + data.weight = parseInt(folderFileName); + data.slug = folderFileName.slice(String(data.weight).length + 1); + + yield Article.destroyTree({slug: data.slug}); + + var options = { + staticHost: config.server.staticHost, + resourceWebRoot: Article.getResourceWebRootBySlug(data.slug), + metadata: {}, + trusted: true + }; + + const parsed = new BodyParser(content, options).parseAndWrap(); + + this.checkIfErrorsInParsed(parsed); + data.title = this.extractHeader(parsed); + + const folder = new Article(data); + yield folder.persist(); + + const subPaths = fs.readdirSync(sourceFolderPath); + + for (var i = 0; i < subPaths.length; i++) { + if (subPaths[i] == 'index.md') continue; + + var subPath = path.join(sourceFolderPath, subPaths[i]); + + if (fs.existsSync(path.join(subPath, 'index.md'))) { + yield* this.syncFolder(subPath, folder); + } else if (fs.existsSync(path.join(subPath, 'article.md'))) { + yield* this.syncArticle(subPath, folder); + } else { + yield* this.syncResource(subPath, folder.getResourceFsRoot()); + } + } + + this.onchange(folder.getUrl()); + +}; + +TutorialImporter.prototype.syncArticle = function* (articlePath, parent) { + log.info("syncArticle", articlePath); + + const contentPath = path.join(articlePath, 'article.md'); + const content = fs.readFileSync(contentPath, 'utf-8').trim(); + + const articlePathName = path.basename(articlePath); + + const data = { + content: content, + isFolder: false + }; + + if (parent) { + data.parent = parent._id; + } + + data.weight = parseInt(articlePathName); + data.slug = articlePathName.slice(String(data.weight).length + 1); + + yield Article.destroyTree({slug: data.slug}); + + const options = { + staticHost: config.server.staticHost, + resourceWebRoot: Article.getResourceWebRootBySlug(data.slug), + metadata: {}, + trusted: true + }; + + const parsed = new BodyParser(content, options).parseAndWrap(); + + this.checkIfErrorsInParsed(parsed); + data.title = this.extractHeader(parsed); + + + // todo: updating: + // first check if references are unique, + // -> fail w/o deleting old article if not unique, + // delete old article & insert the new one & insert refs + const article = new Article(data); + yield article.persist(); + + const refs = options.metadata.refs.toArray(); + const refThunks = refs.map(function(anchor) { + return new Reference({anchor: anchor, article: article._id}).persist(); + }); + + try { + // save all references in parallel + yield refThunks; + } catch (e) { + // something went wrong => we don't need an unfinished article + yield article.destroy(); // will kill it's refs too + throw e; + } + + + const subPaths = fs.readdirSync(articlePath); + + for (var i = 0; i < subPaths.length; i++) { + if (subPaths[i] == 'article.md') continue; + + var subPath = path.join(articlePath, subPaths[i]); + + + if (fs.existsSync(path.join(subPath, 'task.md'))) { + yield* this.syncTask(subPath, article); + } else if (subPath.endsWith('.view')) { + yield* this.syncView(subPath, article); + } else { + // resources + yield* this.syncResource(subPath, article.getResourceFsRoot()); + } + + } + + this.onchange(article.getUrl()); + +}; + + +TutorialImporter.prototype.syncResource = function*(sourcePath, destDir) { + fse.ensureDirSync(destDir); + + log.info("syncResource", sourcePath, destDir); + + const stat = fs.statSync(sourcePath); + const ext = getFileExt(sourcePath); + const destPath = path.join(destDir, path.basename(sourcePath)); + + if (stat.isFile()) { + if (ext == 'png' || ext == 'jpg' || ext == 'gif' || ext == 'svg') { + yield* importImage(sourcePath, destDir); + return; + } + copySync(sourcePath, destPath); + } else if (stat.isDirectory()) { + fse.ensureDirSync(destPath); + const subPathNames = fs.readdirSync(sourcePath); + for (var i = 0; i < subPathNames.length; i++) { + var subPath = path.join(sourcePath, subPathNames[i]); + yield* this.syncResource(subPath, destPath); + } + + } else { + throw new Error("Unsupported file type at " + sourcePath); + } + +}; + +function getFileExt(filePath) { + var ext = filePath.match(/\.([^.]+)$/); + return ext && ext[1]; +} + +function* importImage(srcPath, dstDir) { + log.info("importImage", srcPath, "to", dstDir); + const filename = path.basename(srcPath); + const dstPath = path.join(dstDir, filename); + + copySync(srcPath, dstPath); +} + +function copySync(srcPath, dstPath) { + if (checkSameMtime(srcPath, dstPath)) { + log.debug("copySync: same mtime %s = %s", srcPath, dstPath); + return; + } + + log.debug("copySync %s -> %s", srcPath, dstPath); + + fse.copySync(srcPath, dstPath); +} + +TutorialImporter.prototype.syncTask = function*(taskPath, parent) { + log.info("syncTask", taskPath); + + const contentPath = path.join(taskPath, 'task.md'); + const content = fs.readFileSync(contentPath, 'utf-8').trim(); + + const taskPathName = path.basename(taskPath); + + const data = { + content: content, + parent: parent._id + }; + + data.weight = parseInt(taskPathName); + data.slug = taskPathName.slice(String(data.weight).length + 1); + + yield Task.destroy({slug: data.slug}); + + const options = { + staticHost: config.server.staticHost, + resourceWebRoot: Task.getResourceWebRootBySlug(data.slug), + metadata: {}, + trusted: true + }; + + const parsed = new BodyParser(content, options).parseAndWrap(); + this.checkIfErrorsInParsed(parsed); + data.title = this.extractHeader(parsed); + + data.importance = options.metadata.importance; + + const solutionPath = path.join(taskPath, 'solution.md'); + const solution = fs.readFileSync(solutionPath, 'utf-8').trim(); + data.solution = solution; + + log.debug("parsing solution"); + + options.metadata = {}; + //console.log(util.inspect(options, {depth: 50})); + const parsedSolution = new BodyParser(solution, options).parseAndWrap(); + + this.checkIfErrorsInParsed(parsedSolution); + + const task = new Task(data); + yield task.persist(); + + const subPaths = fs.readdirSync(taskPath); + + for (var i = 0; i < subPaths.length; i++) { + // names starting with _ don't sync + if (subPaths[i] == 'task.md' || subPaths[i] == 'solution.md' || subPaths[i][0] == '_') continue; + + var subPath = path.join(taskPath, subPaths[i]); + + if (subPath.endsWith('.view')) { + yield* this.syncView(subPath, task); + } else { + yield* this.syncResource(subPath, task.getResourceFsRoot()); + } + } + + if (fs.existsSync(path.join(taskPath, '_js.view'))) { + yield* this.syncTaskJs(path.join(taskPath, '_js.view'), task); + } + + this.onchange(task.getUrl()); + +}; + +TutorialImporter.prototype.syncView = function*(dir, parent) { + + log.info("syncView: dir", dir); + var pathName = path.basename(dir).replace('.view', ''); + if (pathName == '_js') { + throw new Error("Must not syncView " + pathName); + } + + var webPath = parent.getResourceWebRoot() + '/' + pathName; + + log.debug("syncView webpath", webPath); + var plunk = yield Plunk.findOne({webPath: webPath}).exec(); + + if (plunk) { + log.debug("Plunk from db", plunk); + } else { + plunk = new Plunk({ + webPath: webPath, + description: "Fork from http://javascript.ru" + }); + log.debug("Created new plunk (db empty)", plunk); + } + + var filesForPlunk = require('plunk').readFs(dir); + log.debug("Files for plunk", filesForPlunk); + + if (!filesForPlunk) return; // had errors + + yield* plunk.mergeAndSyncRemote(filesForPlunk); + + log.debug("Plunk merged"); + + var dst = path.join(parent.getResourceFsRoot(), pathName); + + fse.ensureDirSync(dst); + fs.readdirSync(dir).forEach(function(dirFile) { + copySync(path.join(dir, dirFile), path.join(dst, dirFile)); + }); +}; + + +TutorialImporter.prototype.syncTaskJs = function*(jsPath, task) { + + log.debug("syncTaskJs", jsPath); + + var sourceJs; + + try { + sourceJs = fs.readFileSync(path.join(jsPath, 'source.js'), 'utf8'); + } catch (e) { + sourceJs = "// ...ваш код..."; + } + + var testJs; + try { + testJs = fs.readFileSync(path.join(jsPath, 'test.js'), 'utf8'); + } catch (e) { + testJs = ""; + } + + var solutionJs = fs.readFileSync(path.join(jsPath, 'solution.js'), 'utf8'); + + // Source + var webPath = task.getResourceWebRoot() + '/source'; + + var source = makeSource(sourceJs, testJs); + + var sourcePlunk = yield Plunk.findOne({webPath: webPath}).exec(); + + if (!sourcePlunk) { + sourcePlunk = new Plunk({ + webPath: webPath, + description: "Fork from http://javascript.ru" + }); + } + + var filesForPlunk = { + 'index.html': { + content: source, + filename: 'index.html' + }, + 'test.js': !testJs ? null : { + content: testJs.trim(), + filename: 'test.js' + } + }; + + log.debug("save plunk for ", webPath); + yield* sourcePlunk.mergeAndSyncRemote(filesForPlunk); + + // Solution + var webPath = task.getResourceWebRoot() + '/solution'; + + var solution = makeSolution(solutionJs, testJs); + + var solutionPlunk = yield Plunk.findOne({webPath: webPath}).exec(); + + if (!solutionPlunk) { + solutionPlunk = new Plunk({ + webPath: webPath, + description: "Fork from http://javascript.ru" + }); + } + + var filesForPlunk = { + 'index.html': { + content: solution, + filename: 'index.html' + }, + 'test.js': !testJs ? null : { + content: testJs.trim(), + filename: 'test.js' + } + }; + + log.debug("save plunk for ", webPath); + yield* solutionPlunk.mergeAndSyncRemote(filesForPlunk); + +}; + + +function makeSource(sourceJs, testJs) { + var source = "\n\n\n \n"; + if (testJs) { + source += " \n"; + source += " \n"; + } + source += "\n\n\n \n"; + source += "\n\n"; + return source; +} + + +function makeSolution(solutionJs, testJs) { + var solution = "\n\n\n \n"; + if (testJs) { + solution += " \n"; + solution += " \n"; + } + solution += "\n\n\n \n"; + solution += "\n\n"; + + return solution; +} + + +function checkSameMtime(filePath1, filePath2) { + if (!fs.existsSync(filePath2)) return false; + + const stat1 = fs.statSync(filePath1); + if (!stat1.isFile()) { + throw new Error("not a file: " + filePath1); + } + + const stat2 = fs.statSync(filePath2); + if (!stat2.isFile()) { + throw new Error("not a file: " + filePath2); + } + + return stat1.mtime == stat2.mtime; +} + + +module.exports = TutorialImporter; diff --git a/handlers/users/controllers/id.js b/handlers/users/controllers/id.js new file mode 100755 index 000000000..16e867b4a --- /dev/null +++ b/handlers/users/controllers/id.js @@ -0,0 +1,186 @@ +var User = require('../models/user'); +var _ = require('lodash'); +var imgur = require('imgur'); +var multiparty = require('multiparty'); +var co = require('co'); +var thunkify = require('thunkify'); +var config = require('config'); +var sendMail = require('mailer').send; +var path = require('path'); + +exports.get = function*(next) { + + var fields = 'created displayName realName birthday email gender country town interests profileName publicEmail'.split(' '); + + this.body = { }; + fields.forEach( function(field) { + this.body[field] = this.params.user[field]; + }, this); + + this.body.photo = this.params.user.getPhotoUrl(); + + this.body.hasPassword = Boolean(this.params.user.passwordHash); + + this.body.providers = this.params.user.providers.map(function(provider) { + return { + name: provider.name, + photo: provider.profile.photos && provider.profile.photos[0] && provider.profile.photos[0].value, + displayName: provider.profile.displayName + }; + }); + + +}; + +/* Deleting a user */ +exports.del = function*(next) { + var user = this.params.user; + + yield function(callback) { + user.softDelete(callback); + }; + + this.logout(); + + this.body = { + deleted: true, + modified: user.modified + }; +}; + + +var readMultipart = thunkify(function(ctx, done) { + var req = ctx.req; + + var hadError = false; + var fields = {}; + + // initially we're waiting for form.close only + // each part increases the counter on start and decreases back when nested processing (upoading) is done + var waitStreamsCount = 1; + var form = new multiparty.Form(); + + form.on('field', function(name, value) { + ctx.log.debug("Field", name, value); + fields[name] = value; + }); + + // multipart file must be the last + form.on('part', function(part) { + ctx.log.debug("Part", part.name, part.filename); + + // upload multipart to imgur, no other multipart items in the form + if (part.name != 'photo') { + return onError(new Error("Unexpected multipart field: " + part.name)); + } + + waitStreamsCount++; + if (!part.filename) { + return onError(new Error("No filename for form part " + part.name)); + } + + co(function*() { + // filename='blob' for FormData(photo, blob) where blob comes from canvas.toBlob + return yield* imgur.uploadStream(part.filename, part.byteCount, part); + }).then(function(result) { + if (hadError) return; + fields[part.name] = result; + onStreamDone(); + }, onError); + }); + + form.on('error', onError); + + form.on('close', onStreamDone); + + form.parse(req); + + function onStreamDone() { + if (hadError) return; + waitStreamsCount--; + if (!waitStreamsCount) { + done(null, fields); + } + } + + function onError(err) { + if (hadError) return; + hadError = true; + done(err); + } + +}); + +/* Partial update */ +exports.patch = function*(next) { + + var user = this.params.user; + + var fields; + try { + fields = yield readMultipart(this); + } catch (e) { + if (e.name == 'BadImageError') { + this.throw(400, e.message); + } else { + throw e; + } + } + + 'displayName realName birthday gender photo country town interests profileName publicEmail'.split(' ').forEach(function(field) { + if (field in fields) { + user[field] = fields[field]; + } + }); + + + + if (fields.email !== undefined && fields.email != user.email) { + + var isOccupied = yield User.findOne({email: fields.email}).exec(); + + if (isOccupied) { + this.throw(409, "Такой email используется другим пользователем."); + } + + user.pendingVerifyEmail = fields.email; + user.verifyEmailToken = Math.random().toString(36).slice(2, 10); + user.verifyEmailRedirect = '/profile/account'; + + yield sendMail({ + templatePath: path.join(this.templateDir, 'verify-change-email'), + to: user.pendingVerifyEmail, + subject: "Подтвердите смену email", + link: config.server.siteHost + '/auth/verify/' + user.verifyEmailToken + }); + + } + + if (fields.password) { + if (user.passwordHash && !user.checkPassword(fields.passwordOld)) { + this.throw(400, "Старый пароль неверен."); + } + + user.password = fields.password; + } + + try { + yield user.persist(); + } catch(e) { + if (e.name != 'ValidationError') { + throw e; + } else { + // rethrow as a single ordinary error + var message; + for(var key in e.errors) { + message = e.errors[key].message; + break; + } + this.throw(400, message); + } + return; + } + + this.body = user.getInfoFields(); + +}; diff --git a/handlers/users/index.js b/handlers/users/index.js new file mode 100755 index 000000000..96cc095df --- /dev/null +++ b/handlers/users/index.js @@ -0,0 +1,27 @@ +// must be above router, because router uses auth (which uses user) +// cyclic require here + +const config = require('config'); +exports.User = require('./models/user'); +exports.routeUserById = require('./lib/routeUserById'); + +var mountHandlerMiddleware = require('lib/mountHandlerMiddleware'); + +exports.init = function(app) { + + app.use( mountHandlerMiddleware('/users', __dirname) ); + + app.use(function*(next) { + Object.defineProperty(this, 'isAdmin', { + get: function() { + // service may authorize with X-Admin-Key header + return this.user && this.user.isAdmin || this.get('X-Admin-Key') === config.adminKey; + } + }); + yield* next; + }); + + app.multipartParser.ignore.add('/users/:id'); // also handles /users/me + +}; + diff --git a/handlers/users/lib/hash.js b/handlers/users/lib/hash.js new file mode 100644 index 000000000..ab2803a03 --- /dev/null +++ b/handlers/users/lib/hash.js @@ -0,0 +1,11 @@ +const crypto = require('crypto'); +const config = require('config'); + +// warning, takes time, about ~70ms for length=128, iterations=12000 +exports.createHashSlow = function(password, salt) { + return crypto.pbkdf2Sync(password, salt, config.crypto.hash.iterations, config.crypto.hash.length); +}; + +exports.createSalt = function() { + return crypto.randomBytes(config.crypto.hash.length).toString('base64'); +}; diff --git a/handlers/users/lib/routeUserById.js b/handlers/users/lib/routeUserById.js new file mode 100644 index 000000000..11a75c49a --- /dev/null +++ b/handlers/users/lib/routeUserById.js @@ -0,0 +1,23 @@ +var mongoose = require('mongoose'); +var User = require('../models/user'); + +module.exports = function*(id, next) { + + try { + new mongoose.Types.ObjectId(id); + } catch (e) { + // cast error (invalid id) + this.throw(404); + } + + var user = yield User.findById(id).exec(); + + if (!user) { + this.throw(404); + } + + this.userById = user; + + yield* next; + +}; diff --git a/handlers/users/models/user.js b/handlers/users/models/user.js new file mode 100755 index 000000000..b28adc3d5 --- /dev/null +++ b/handlers/users/models/user.js @@ -0,0 +1,321 @@ +var transliterate = require('textUtil/transliterate'); +var mongoose = require('mongoose'); +var hash = require('../lib/hash'); +var troop = require('mongoose-troop'); +var _ = require('lodash'); +var co = require('co'); + +var ProviderSchema = new mongoose.Schema({ + name: String, + nameId: { + type: String, + index: true + }, + profile: {} // updates just fine if I replace it with a new value, w/o going inside +}); + +var UserSchema = new mongoose.Schema({ + displayName: { + type: String, + default: "", // need a value for validator to run + validate: [ + { + validator: function(value) { + //console.log("VALIDATING", this.deleted, value, this.deleted ? true : (value.length > 0)); + return this.deleted ? true : (value.length >= 2); + }, + msg: "Имя пользователя должно иметь не менее 2 символов." + }, + { + validator: function(value) { + if (!value) return true; + return value.length <= 256; + }, + msg: "Имя пользователя должно быть не длиннее 256 символов." + } + ] + }, + email: { + type: String, + default: "", // need a value for validator to run + // если посетитель удалён, то у него нет email! + validate: [ + { + validator: function checkNonEmpty(value) { + return this.deleted ? true : (value.length > 0); + }, + msg: "E-mail пользователя не должен быть пустым." + }, + { + validator: function checkEmail(value) { + return this.deleted ? true : /^[-.\w]+@([\w-]+\.)+[\w-]{2,12}$/.test(value); + }, + msg: 'Укажите, пожалуйста, корректный email.' + } + ], + + // sparse (don't index users without email) + // dangerous: if mongodb uses this in queries (that search emails only), users w/o email will be ignored + index: { + unique: true, + sparse: true, + errorMessage: "Такой e-mail уже используется." + } + }, + passwordHash: { + type: String // user may have no password if used facebook to login/register + }, + salt: { + type: String + }, + providers: [ProviderSchema], + gender: { + type: String, + enum: { + values: ['male', 'female'], + message: "Неизвестное значение для пола." + } + }, + profileName: { + type: String, + validate: [ + { + validator: function(value) { + // also checks required + if (this.deleted) return true; + return value && value.length >= 2; + }, + msg: "Минимальная длина имени профиля: 2 символа." + }, + { + validator: function(value) { + return this.deleted || /^[a-z0-9-]*$/.test(value); + }, + msg: "В имени профиля допустимы только буквы a-z, цифры и дефис." + }, + { + validator: function(value) { + // if no value, this validator passes (another one triggers the error) + return this.deleted || !value || value.length <= 64; + }, + msg: "Максимальная длина имени профиля: 64 символа." + } + ], + + index: { + unique: true, + sparse: true, + errorMessage: "Такое имя профиля уже используется." + } + }, + realName: String, + // not Date, because Date requires time zone, + // so if I enter 18.04.1982 00:00:00 in GMT+3 zone, it will be 17.04.1982 21:00 actually (prbably wrong) + // string is like a "date w/o time zone" + birthday: String, + verifiedEmail: { + type: Boolean, + default: false + }, + + // e.g. ['orders', 'quiz'], the profile tabs which have info + // a tab is enabled by the associated module when it generates data + profileTabsEnabled: [String], + + // we store all verified emails of the user for the history & account restoration issues + verifiedEmailsHistory: [{date: Date, email: String}], + + // new not-yet-verified email, set on change attempt + pendingVerifyEmail: String, + + // impossible-to-guess token + // used on both new user & email change + // new user: + // - generate a random roken + // - keep/resend on verification attempts (so that a user can use any letter, that's convenient) + // email change: + // - generate a random token + // - regenerate on change attempts (if entered a wrong email, next letter will void the previous one) + // cleared after use + verifyEmailToken: { + type: String, + index: true + }, + verifyEmailRedirect: String, // where to redirect after verify + passwordResetToken: { // refresh with each recovery request + type: String, + index: true + }, + passwordResetTokenExpires: Date, // valid until this date + passwordResetRedirect: String, // where to redirect after password recovery + photo: {/* { link: ..., } */}, // imgur data + country: String, + town: String, + publicEmail: String, + interests: String, + deleted: { // private & login data is deleted + type: Boolean, + default: false + }, + readOnly: Boolean, // data is not deleted, just flagged as banned + isAdmin: Boolean, + lastActivity: Date + /* created, modified from plugin */ +}); + +UserSchema.virtual('password') + .set(function(password) { + + if (password !== undefined) { + if (password.length < 4) { + this.invalidate('password', 'Пароль должен быть минимум 4 символа.'); + } + } + + this._plainPassword = password; + + if (password) { + this.salt = hash.createSalt(); + this.passwordHash = hash.createHashSlow(password, this.salt); + } else { + // remove password (unable to login w/ password any more, but can use providers) + this.salt = undefined; + this.passwordHash = undefined; + } + }) + .get(function() { + return this._plainPassword; + }); + +// get all fields available to a visitor (except the secret/internal ones) +// normally in-page JS has access to these +UserSchema.methods.getInfoFields = function() { + return User.getInfoFields(this); +}; + + +UserSchema.statics.getInfoFields = function(user) { + return { + id: user._id, + hasPassword: Boolean(user.passwordHash), + displayName: user.displayName, + profileName: user.profileName, + gender: user.gender, + birthday: user.birthday, + country: user.country, + town: user.town, + publicEmail: user.publicEmail, + interests: user.interests, + email: user.email, + verifiedEmail: user.verifiedEmail, + photo: user.photo && user.photo.link, + deleted: user.deleted, + readOnly: user.readOnly, + isAdmin: user.isAdmin, + created: user.created, + lastActivity: user.lastActivity, + profileTabsEnabled: user.profileTabsEnabled + }; +}; + + +UserSchema.methods.getProfileUrl = function() { + return '/profile/' + this.profileName; +}; + + +UserSchema.methods.checkPassword = function(password) { + if (!password) return false; // empty password means no login by password + if (!this.passwordHash) return false; // this user does not have password (the line below would hang!) + + return hash.createHashSlow(password, this.salt) == this.passwordHash; +}; + + +UserSchema.methods.softDelete = function(callback) { + // delete this.email does not work + // need to assign to undefined to $unset + this.email = undefined; + this.realName = undefined; + this.displayName = 'Аккаунт удалён'; + this.gender = undefined; + this.birthday = undefined; + this.profileName = undefined; + this.verifyEmailToken = undefined; + this.verifyEmailRedirect = undefined; + this.created = undefined; + this.lastActivity = undefined; + this.passwordResetToken = undefined; + this.passwordResetTokenExpires = undefined; + this.passwordResetRedirect = undefined; + this.providers = []; + this.password = undefined; + + this.photo = undefined; + // keep verifiedEmail status as it was, maybe for some displays? + // user.verifiedEmail = false; + + this.deleted = true; + + this.save(function(err, user, numberAffected) { + callback(err, user); + }); +}; + +UserSchema.statics.photoDefault = "//i.imgur.com/zSGftLc.png"; +UserSchema.statics.photoDeleted = "//i.imgur.com/7KZD6XK.png"; + +UserSchema.methods.getPhotoUrl = function(width, height) { + var url = this.deleted ? User.photoDeleted : + !this.photo ? User.photoDefault : this.photo.link; + + // I don't resize to square, because it breaks background + // @see http://i.imgur.com/zSGftLcs.png + var modifier = (width <= 80 && height < 80) ? 't' : + (width <= 160 && height <= 160) ? 'm' : + (width <= 320 && height <= 320) ? 'i' : + (width <= 512 && height <= 512) ? 'h' : ''; + + return url.slice(0, url.lastIndexOf('.')) + modifier + url.slice(url.lastIndexOf('.')); + +}; + +UserSchema.methods.generateProfileName = function*() { + var profileName = this.displayName.trim() + .replace(/<\/?[a-z].*?>/gim, '') // strip tags, leave /?@[\\\]^_`{|}~]/g, '-') // пунктуация, пробелы -> дефис + .replace(/[^a-zа-яё0-9-]/gi, '') // убрать любые символы, кроме [слов цифр дефиса]) + .replace(/-+/gi, '-') // слить дефисы вместе + .replace(/^-|-$/g, ''); // убрать дефисы с концов + + profileName = transliterate(profileName); + profileName = profileName.toLowerCase(); + + var existingUser; + while (true) { + existingUser = yield User.findOne({profileName: profileName}).exec(); + + if (!existingUser) break; + // add one more random digit and retry the search + profileName += Math.random() * 10 ^ 0; + } + + this.profileName = profileName; +}; + +UserSchema.pre('save', function(next) { + if (this.deleted || this.profileName) return next(); + + co(function*() { + yield* this.generateProfileName(); + }.bind(this)).then(next, next); +}); + + +UserSchema.plugin(troop.timestamp, {useVirtual: false}); + +// all references using mongoose.model for safe recreation +// when I recreate model (for tests) => I can reload it from mongoose.model (single source of truth) +// exports are less convenient to update +var User = module.exports = mongoose.model('User', UserSchema); + diff --git a/handlers/users/router.js b/handlers/users/router.js new file mode 100755 index 000000000..838c27832 --- /dev/null +++ b/handlers/users/router.js @@ -0,0 +1,68 @@ +var Router = require('koa-router'); +var mustBeAuthenticated = require('auth').mustBeAuthenticated; +var User = require('./models/user'); +var mongoose = require('mongoose'); + +var router = module.exports = new Router(); + +var id = require('./controllers/id'); + +/** + * REST API + * /users/me GET PATCH DEL + * /users/:id GET PATCH DEL (for admin or self) + */ + +router.get('/me', mustBeAuthenticated, loadUserByReq, id.get); +router.patch('/me', mustBeAuthenticated, loadUserByReq, id.patch); +//router.post('/me', id.patch); +router.del('/me', mustBeAuthenticated, loadUserByReq, id.del); + +router.get('/:id', loadUserById, id.get); +router.patch('/:id', loadUserById, id.patch); +router.del('/:id', loadUserById, id.del); + +function* loadUserByReq(next) { + + //yield function(callback) {} + + this.params.user = this.req.user; + yield* next; +} + +function* loadUserById(next) { + + try { + new mongoose.Types.ObjectId(this.params.id); + } catch (e) { + // cast error (invalid id) + this.throw(404); + } + + var user = yield User.findById(this.params.id).exec(); + + if (!user) { + this.throw(404); + } + + var allowed = false; + + // public info open to everyone + if (~['GET', 'OPTIONS', 'HEAD'].indexOf(this.method)) { + allowed = true; + } + + // modification allowed to admin or user himself + if (this.req.user) { + if (String(this.req.user._id) == String(user._id) || this.req.user.isAdmin) { + allowed = true; + } + } + + if (allowed) { + this.params.user = user; + yield* next; + } else { + this.throw(403, "Недостаточно прав на это действие."); + } +} diff --git a/handlers/users/templates/verify-change-email.jade b/handlers/users/templates/verify-change-email.jade new file mode 100755 index 000000000..962a1dbf2 --- /dev/null +++ b/handlers/users/templates/verify-change-email.jade @@ -0,0 +1,8 @@ +extends /layouts/email + +block body + h1 Подтверждение смены email на javascript.ru + p Для смены email перейдите, пожалуйста, по ссылке + =' ' + a(href=link) #{link} + | . diff --git a/handlers/users/test/.jshintrc b/handlers/users/test/.jshintrc new file mode 100755 index 000000000..077663629 --- /dev/null +++ b/handlers/users/test/.jshintrc @@ -0,0 +1,23 @@ +{ + "maxerr": 25, + "latedef": "nofunc", + "browser": false, + "node": true, + "indent": 2, + "camelcase": true, + "newcap": true, + "undef": true, + "esnext": true, + "multistr": true, + "noyield": true, + "expr": true, + "-W004": true, + "globals" : { + "describe" : false, + "it" : false, + "before" : false, + "beforeEach" : false, + "after" : false, + "afterEach" : false + } +} diff --git a/handlers/users/test/api/id.js b/handlers/users/test/api/id.js new file mode 100755 index 000000000..a02f156c7 --- /dev/null +++ b/handlers/users/test/api/id.js @@ -0,0 +1,122 @@ +/* globals describe, it, before */ + +const db = require('lib/dataUtil'); +const mongoose = require('mongoose'); +const path = require('path'); +const request = require('supertest'); +const fixtures = require(path.join(__dirname, '../fixtures/db')); +const app = require('app'); +const should = require('should'); + +describe('Authorization', function() { + + var server; + + before(function* () { + yield* db.loadModels(fixtures); + + // app.listen() uses a random port, + // which superagent gets as server.address().port + // so that every run will get it's own port + server = app.listen(); + server.unref(); + }); + + describe('patch', function() { + + it('saves the user', function(done) { + request(server) + .patch('/users/me') + .set('X-Test-User-Id', fixtures.User[0]._id) + .set('Accept', 'application/json') + .field('displayName', 'test') // will send as multipart + .end(function(err, res) { + res.body.displayName.should.exist; + done(err); + }); + }); + + it('transloads the user photo', function(done) { + request(server) + .patch('/users/me') + .set('X-Test-User-Id', fixtures.User[0]._id) + .set('Accept', 'application/json') + .attach('photo', path.join(__dirname, 'me.jpg')) + .end(function(err, res) { + if (err) return done(err); + res.body.photo.should.startWith('http://'); + done(); + }); + }); + + it('returns not all errors, but only one error if a field is wrong', function(done) { + request(server) + .patch('/users/me') + .set('X-Test-User-Id', fixtures.User[0]._id) + .set('Accept', 'application/json') + .field('displayName', '') + .field('email', Math.random() + "@mail.com") + .field('gender', 'invalid') + .expect(400) + .end(function(err, res) { + //console.log(res.body); + if (err) return done(err); + res.body.message.should.exist; + done(); + }); + }); + + + it('returns a single error if an email is duplicated', function(done) { + + request(server) + .patch('/users/me') + .set('X-Test-User-Id', fixtures.User[0]._id) + .set('Accept', 'application/json') + .field('displayName', "Such mail belongs to another user") + .field('email', "tester@mail.com") + .field('gender', "male") + .expect(409) + .end(function(err, res) { + + if (err) return done(err); + + should(res.body.errors).not.exist; + res.body.message.should.exist; + done(); + }); + }); + + }); + + describe('get', function() { + + it("returns public user info", function(done) { + request(server) + .get('/users/me') + .set('X-Test-User-Id', fixtures.User[0]._id) + .send() + .end(function(err, res) { + res.body.displayName.should.eql("test"); + done(err); + }); + + }); + + }); + + describe('delete', function() { + + it("deletes all user fields", function(done) { + request(server) + .del('/users/me') + .set('X-Test-User-Id', fixtures.User[0]._id) + .send() + .expect(200, done); + + }); + + }); + + +}); diff --git a/handlers/users/test/api/me.jpg b/handlers/users/test/api/me.jpg new file mode 100755 index 000000000..71221dc45 Binary files /dev/null and b/handlers/users/test/api/me.jpg differ diff --git a/handlers/users/test/fixtures/db.js b/handlers/users/test/fixtures/db.js new file mode 100755 index 000000000..815893837 --- /dev/null +++ b/handlers/users/test/fixtures/db.js @@ -0,0 +1,26 @@ +exports.User = [ + { "_id": "000000000000000000000001", + "created": new Date(2014,0,1), + "displayName": "ilya kantor", + "profileName": "iliakan", + "email": "iliakan@gmail.com", + "password": "1234", + "verifiedEmail": true + }, + { "_id": "000000000000000000000002", + "created": new Date(2014,0,1), + "displayName": "tester", + "email": "tester@mail.com", + "profileName": "tester", + "password": "1234", + "verifiedEmail": true + }, + { "_id": "000000000000000000000003", + "created": new Date(2014,0,1), + "displayName": "vasya", + "profileName": "vasya", + "email": "vasya@mail.com", + "password": "1234", + "verifiedEmail": false + } +]; diff --git a/handlers/users/test/unit/model/user.js b/handlers/users/test/unit/model/user.js new file mode 100755 index 000000000..12b132b3d --- /dev/null +++ b/handlers/users/test/unit/model/user.js @@ -0,0 +1,84 @@ +var app = require('app'); +var mongoose = require('lib/mongoose'); + +var dataUtil = require('lib/dataUtil'); + +describe('User', function() { + + var User = require('../../../models/user'); + + before(function* () { + yield* dataUtil.createEmptyDb(); + }); + + it('given bad email errors on save', function*() { + var user = new User({ + email: "BAD", + displayName: "John", + profileName: "bad", + password: "1234" + }); + + user.persist()(function(err) { + err.name.should.equal('ValidationError'); + err.errors.email.value.should.equal(user.get('email')); + }); + + }); + + // TEST FAILS, @see https://github.com/LearnBoost/mongoose/issues/2446 + // does not require password, because social login does not use it + it('requires email & displayName & profileName', function() { + [ + { + email: "my@gmail.com" + }, + { + displayName: "John" + }, + { + profileName: "profile" + } + ].map(function(data) { + var user = new User(data); + // cannot use yield* because inside map + user.persist()(function(err) { + err.name.should.equal('ValidationError'); + }); + }); + + }); + + it('autogenerates salt and hash', function* () { + + var user = new User({ + email: "a@b.ru", + displayName: "John", + password: "pass" + }); + + user.get('salt').should.not.be.empty; + user.get('passwordHash').should.not.be.empty; + user.checkPassword("pass").should.be.true; + + }); + + it('requires unique email', function* () { + + var data = { + displayName: "nonunique", + email: "nonunique@b.ru", + password: "pass" + }; + + yield new User(data).persist(); + try { + yield new User(data).persist(); + throw new Error("Same email is saved twice!"); + } catch(err) { + err.name.should.equal('ValidationError'); + err.errors.email.should.exist; + } + + }); +}); diff --git a/handlers/verboseLogger.js b/handlers/verboseLogger.js new file mode 100755 index 000000000..5c1bf3ab2 --- /dev/null +++ b/handlers/verboseLogger.js @@ -0,0 +1,8 @@ +const VerboseLogger = require('lib/verboseLogger'); + +exports.init = function(app) { + + app.verboseLogger = new VerboseLogger(); + app.use(app.verboseLogger.middleware()); + +}; diff --git a/learn b/learn new file mode 100755 index 000000000..ac49cc545 --- /dev/null +++ b/learn @@ -0,0 +1,8 @@ +#!/bin/bash +export SITE_HOST=https://learn.javascript.ru +export STATIC_HOST=https://learn.javascript.ru + +#ssh -R 1212:localhost:80 ilia@stage.javascript.ru & +ssh -f -nNT -R 1213:localhost:80 root@host.learn.javascript.ru + +NODE_ENV=development WATCH=1 gulp dev | bunyan -o short -l debug diff --git a/lib/error/httpError.js b/lib/error/httpError.js deleted file mode 100755 index cd5a3dd78..000000000 --- a/lib/error/httpError.js +++ /dev/null @@ -1,19 +0,0 @@ -var util = require('util'); -var http = require('http'); - -// ошибки для выдачи посетителю -function HttpError(status, message) { - Error.apply(this, arguments); - Error.captureStackTrace(this, HttpError); - - this.status = status; - this.message = message || http.STATUS_CODES[status] || "Error"; -} - -util.inherits(HttpError, Error); -module.exports = HttpError; - -HttpError.prototype.name = 'HttpError'; - - - diff --git a/lib/error/index.js b/lib/error/index.js deleted file mode 100755 index 3522cd29c..000000000 --- a/lib/error/index.js +++ /dev/null @@ -1,9 +0,0 @@ -var fs = require('fs'); -var path = require('path'); - -var files = fs.readdirSync(__dirname); -files.forEach(function(file) { - if (file == 'index.js') return; - var errorClass = require(path.join(__dirname, file)); - module.exports[errorClass.prototype.name] = errorClass; -}); diff --git a/lib/hash.js b/lib/hash.js deleted file mode 100755 index 70dfb8177..000000000 --- a/lib/hash.js +++ /dev/null @@ -1,16 +0,0 @@ -var crypto = require('crypto'); - -var LEN = 128; - -/** - * Iterations. ~300ms - */ -var ITERATIONS = 12000; - -exports.createHash = function(password, salt) { - return crypto.pbkdf2Sync(password, salt, ITERATIONS, LEN); -}; - -exports.createSalt = function() { - return crypto.randomBytes(LEN).toString('base64'); -}; diff --git a/lib/log.js b/lib/log.js deleted file mode 100755 index 9ab59ca4a..000000000 --- a/lib/log.js +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Flexible logging wrapper around windston - * - * usage: - * var log = require('lib/log')(module) - * log.debug("Winston %s", "syntax"); - * - * enabling debug directly in the code: - * log.debugOn(); - * enabling debug for paths from CLI: - * DEBUG=path node app - * where path is calculated from the project root (where package.json resides) - * example: - * DEBUG=models/* node app - * DEBUG=models/*,lib/* node app - * exclusion: - * DEBUG=-models/user,models/* node app (to log all models except user) - */ - -var winston = require('winston'); -var path = require('path'); -var fs = require('fs'); - -var names = [], skips = []; - -(process.env.DEBUG || '') - .split(/[\s,]+/) - .forEach(function(name) { - name = name.replace('*', '.*?'); - if (name[0] === '-') { - skips.push(new RegExp('^' + name.substr(1) + '$')); - } else { - names.push(new RegExp('^' + name + '$')); - } - }); - -function findProjectRoot() { - - var dir = __dirname; - while (!fs.existsSync(path.join(dir, 'package.json'))) { - dir = path.dirname(dir); - } - - return path.normalize(dir); -} - -var projectRoot = findProjectRoot(); - -function getLogLevel(module) { - - var modulePath = module.filename.slice(projectRoot.length + 1); // models/user.js - modulePath = modulePath.replace(/\.js$/, ''); // models.user - - var logLevel = 'info'; - - var isSkipped = skips.some(function(re) { - return re.test(modulePath); - }); - - if (!isSkipped) { - var isIncluded = names.some(function(re) { - return re.test(modulePath); - }); - - if (isIncluded) logLevel = 'debug'; - } - - return logLevel; -} - -function getLogger(module) { - - var showPath = module.filename.split('/').slice(-2).join('/'); - - var logLevel = getLogLevel(module); - var logger = new winston.Logger({ - transports: [ - new winston.transports.Console({ - colorize: true, - level: logLevel, - label: showPath - }) - ] - }); - - logger.debugOn = function() { - Object.keys(this.transports).forEach(function(key) { - logger.transports[key].level = 'debug'; - }, this); - }; - - return logger; -} - -module.exports = getLogger; \ No newline at end of file diff --git a/lib/middleware/auth.js b/lib/middleware/auth.js deleted file mode 100755 index 44d2a9225..000000000 --- a/lib/middleware/auth.js +++ /dev/null @@ -1,26 +0,0 @@ -var HttpError = require('lib/error').HttpError; - -exports.mustBeAuthenticated = function(req, res, next) { - if (req.isAuthenticated()) { - next(); - } else { - return next(403); - } -}; - -exports.userIdMustBeCurrentUser = function(req, res, next) { - if (req.params.userId != req.user.id) { - next(403); - } else { - next(); - } -}; - -exports.mustBeAnonymous = function(req, res, next) { - if (req.isAuthenticated()) { - res.redirect('/'); - } else { - next(); - } - -}; \ No newline at end of file diff --git a/lib/middleware/sendHttpError.js b/lib/middleware/sendHttpError.js deleted file mode 100755 index ee8c6ebec..000000000 --- a/lib/middleware/sendHttpError.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = function(req, res, next) { - - res.sendHttpError = function(error) { - - res.status(error.status); - - var preferredType = res.req.accepts('html, json'); - if (preferredType == 'json') { - res.json(error); - } else { - res.render("error", {error: error}); - } - }; - - next(); - -}; \ No newline at end of file diff --git a/lib/mongoose.js b/lib/mongoose.js deleted file mode 100755 index 03b6b9f6b..000000000 --- a/lib/mongoose.js +++ /dev/null @@ -1,9 +0,0 @@ -var mongoose = require('mongoose'); -var config = require('config'); - -mongoose.connect(config.mongoose.uri, config.mongoose.options); - -module.exports = mongoose; - -// require('models'); - diff --git a/lib/passport.js b/lib/passport.js deleted file mode 100755 index 912f974cf..000000000 --- a/lib/passport.js +++ /dev/null @@ -1,42 +0,0 @@ -var passport = require("passport"); -var mongoose = require('mongoose'); -var LocalStrategy = require('passport-local').Strategy; - -var User = mongoose.models.User; - -passport.serializeUser(function(user, done) { - done(null, user.id); -}); - -passport.deserializeUser(function(id, done) { - User.findOne({ _id: id }, function(err, user) { - done(err, user); - }); -}); - -passport.use(new LocalStrategy({ - usernameField: 'email', - passwordField: 'password' - }, - function(email, password, done) { - if (!email) return done(null, false, { message: 'Please provide email.' }); - if (!password) return done(null, false, { message: 'Please provide password.' }); - - User.findOne({email: email}, function(err, user) { - if (err) return done(err); - if (!user) return done(null, false, { message: 'Non-registered email.' }); - - if (user.checkPassword(password)) { - return done(null, user); - } else { - done(null, false, { - message: 'Incorrect password.' - }); - } - }); - } -)); - - - -module.exports = passport; diff --git a/lib/sessionStore.js b/lib/sessionStore.js deleted file mode 100755 index 011e6a1c9..000000000 --- a/lib/sessionStore.js +++ /dev/null @@ -1,7 +0,0 @@ -var mongoose = require('mongoose'); -var session = require('express-session'); -var MongoStore = require('connect-mongo')(session); - -var sessionStore = new MongoStore({mongoose_connection: mongoose.connection}); - -module.exports = sessionStore; \ No newline at end of file diff --git a/locales/en/translation.json b/locales/en/translation.json new file mode 100755 index 000000000..56c93eabd --- /dev/null +++ b/locales/en/translation.json @@ -0,0 +1,14 @@ +{ + "app": { + "name": "javascript" + }, + "creator": { + "firstname": "Ilya", + "lastname": "Kantor" + }, + "Next lesson": "Next lesson", + "Previous lesson": "Previous lesson", + "/i/sitetoolbar__logo": { + "svg": "/i/sitetoolbar__logo.svg" + } +} \ No newline at end of file diff --git a/locales/ru/translation.json b/locales/ru/translation.json new file mode 100755 index 000000000..804ad9e95 --- /dev/null +++ b/locales/ru/translation.json @@ -0,0 +1,14 @@ +{ + "app": { + "name": "javascript" + }, + "creator": { + "firstname": "Илья", + "lastname": "Кантор" + }, + "Previous lesson": "Предыдущий урок", + "Next lesson": "Следующий урок", + "/i/sitetoolbar__logo": { + "svg": "/i/sitetoolbar__logo.svg" + } +} \ No newline at end of file diff --git a/manifest/.gitkeep b/manifest/.gitkeep new file mode 100755 index 000000000..e69de29bb diff --git a/middleware/auth.js b/middleware/auth.js deleted file mode 100755 index 44d2a9225..000000000 --- a/middleware/auth.js +++ /dev/null @@ -1,26 +0,0 @@ -var HttpError = require('lib/error').HttpError; - -exports.mustBeAuthenticated = function(req, res, next) { - if (req.isAuthenticated()) { - next(); - } else { - return next(403); - } -}; - -exports.userIdMustBeCurrentUser = function(req, res, next) { - if (req.params.userId != req.user.id) { - next(403); - } else { - next(); - } -}; - -exports.mustBeAnonymous = function(req, res, next) { - if (req.isAuthenticated()) { - res.redirect('/'); - } else { - next(); - } - -}; \ No newline at end of file diff --git a/middleware/sendHttpError.js b/middleware/sendHttpError.js deleted file mode 100755 index ee8c6ebec..000000000 --- a/middleware/sendHttpError.js +++ /dev/null @@ -1,17 +0,0 @@ -module.exports = function(req, res, next) { - - res.sendHttpError = function(error) { - - res.status(error.status); - - var preferredType = res.req.accepts('html, json'); - if (preferredType == 'json') { - res.json(error); - } else { - res.render("error", {error: error}); - } - }; - - next(); - -}; \ No newline at end of file diff --git a/middleware/templateHelpers.js b/middleware/templateHelpers.js deleted file mode 100755 index cee101805..000000000 --- a/middleware/templateHelpers.js +++ /dev/null @@ -1,53 +0,0 @@ -var util = require('util'); - -function Block(name) { - this.members = []; -} - -Block.prototype.unshift = function(name) { - if (name instanceof Array) { - [].unshift.apply(this.members, name); - } else { - this.members.unshift(name); - } -}; - -Block.prototype.push = function(name) { - if (name instanceof Array) { - [].push.apply(this.members, name); - } else { - this.members.push(name); - } -}; - -function Script() { - Block.apply(this, arguments); -} -util.inherits(Script, Block); -Script.prototype.toString = function() { - var result = ''; - this.members.forEach(function(script) { - result += '\n'; - }); - return result; -}; - -function Style() { - Block.apply(this, arguments); -} -util.inherits(Style, Block); - -Style.prototype.toString = function() { - var result = ''; - this.members.forEach(function(style) { - result += '\n'; - }); - return result; -}; - - -module.exports = function(req, res, next) { - res.locals.script = new Script; - res.locals.style = new Style; - next(); -}; \ No newline at end of file diff --git a/mocha.sh b/mocha.sh index 1f9e10e2b..a7ed0a2ec 100755 --- a/mocha.sh +++ b/mocha.sh @@ -1,3 +1,12 @@ #!/bin/bash -NODE_ENV=test NODE_PATH=. mocha --timeout 10000 --require should --require test/env $* +# this script is used to run all or standalone mocha scripts +# like this: +# ./mocha.sh +# OR +# ./mocha.sh test/unit/model/user.js + +# tried also gulp-mocha and node `which gulp` test, +# but it hangs after tests, not sure why, mocha.sh works fine so leave it as is +NODE_ENV=development gulp init --harmony_classes +NODE_ENV=test mocha --harmony_classes --reporter spec --colors --timeout 100000 --require lib/mongoose --require should --require co --require co-mocha --require cls --recursive --ui bdd -d $* diff --git a/models/user.js b/models/user.js deleted file mode 100755 index 20f9db57c..000000000 --- a/models/user.js +++ /dev/null @@ -1,78 +0,0 @@ -var mongoose = require('lib/mongoose'); -var hash = require('lib/hash'); -var Schema = mongoose.Schema; - -var schema = Schema({ - username: { - type: String, - required: true, - unique: true - }, - email: { - type: String, - unique: true, - required: true, - index: true - }, - salt: String, - hash: String, - created: { - type: Date, - default: Date.now - }, - avatar: { - type: String, - default: '1.jpg' - } -}); - -schema.methods.toPublicObject = function() { - return { - username: this.get('username'), - email: this.get('email'), - avatar: this.get('avatar'), - following: this.get('following'), - created: this.get('created'), - messagesCount: this.get('messagesCount'), - id: this.id - }; -}; - -schema.virtual('password') - .set(function(password) { - this._plainPassword = password; - this.salt = hash.createSalt(); - this.hash = hash.createHash(password, this.salt); - }) - .get(function() { - return this._plainPassword; - }); - - -schema.statics.signup = function(username, email, password, done) { - var User = this; - - User.create({ - username: username, - email: email, - password: password, - avatar: (Math.random()*42^0) + '.jpg' - }, done); - -}; - -schema.methods.checkPassword = function(password) { - return hash.createHash(password, this.salt) == this.hash; -}; - -schema.path('email').validate(function(value) { - // wrap in new RegExp instead of /.../, to evade WebStorm validation errors (buggy webstorm) - return new RegExp('^[\\w-.]+@([\\w-]+\\.)+[\\w-]{2,5}$').test(value); -}, 'Please provide the correct e-mail.'); - -// all references using mongoose.model for safe recreation -// when I recreate model (for tests) => I can reload it from mongoose.model (single source of truth) -// exports are less convenient to update -mongoose.model('User', schema); - - diff --git a/modules/app.js b/modules/app.js new file mode 100755 index 000000000..e95a658a2 --- /dev/null +++ b/modules/app.js @@ -0,0 +1,127 @@ +//require("time-require"); + +const fs = require('fs'); +const config = require('config'); + +require('cls'); // init CLS namespace once, handler used below + +const Application = require('application'); +const app = new Application(); + + +if (process.env.NODE_ENV != 'development') { + + // only log.error in prod, otherwise just die + process.on('uncaughtException', function(err) { + // let bunyan handle the error + app.log.error({ + message: err.message, + name: err.name, + errors: err.errors, + stack: err.stack + }); + process.exit(255); + }); + +} + + +// The app is always behind Nginx which serves static +// (Maybe behind Cloudflare as well) +// trust all headers from the proxy +// X-Forwarded-Host +// X-Forwarded-Proto +// X-Forwarded-For -> ip +app.proxy = true; + +// ========= Helper handlers =========== +app.requireHandler('cls'); + +app.requireHandler('mongooseHandler'); + +app.requireHandler('requestId'); +app.requireHandler('requestLog'); + +app.requireHandler('nocache'); + +//app.requireHandler('time'); + +// this middleware adds this.render method +// it is *before errorHandler*, because errors need this.render +app.requireHandler('render'); + +// errors wrap everything +app.requireHandler('errorHandler'); + +// this logger only logs HTTP status and URL +// before everything to make sure it log all +app.requireHandler('accessLogger'); + +// before anything that may deal with body +// it parses JSON & URLENCODED FORMS, +// it does not parse form/multipart +app.requireHandler('bodyParser'); + +// parse FORM/MULTIPART +// (many tweaks possible, lets the middleware decide how to parse it) +app.requireHandler('multipartParser'); + +// right after parsing body, make sure we logged for development +app.requireHandler('verboseLogger'); + +if (process.env.NODE_ENV == 'development') { +// app.verboseLogger.addPath('/:any*'); +} + +app.requireHandler('conditional'); + +app.requireHandler('session'); + +app.requireHandler('passportSession'); + +app.requireHandler('passportRememberMe'); + +app.requireHandler('lastActivity'); + +app.requireHandler('csrf'); + +app.requireHandler('flash'); + +app.requireHandler('paymentsMethods'); + +// ======== Endpoint services that actually generate something ========== + +var endpoints = []; + +if (process.env.NODE_ENV == 'development') { + endpoints.push('markup', 'dev'); +} + +endpoints.push( + 'users', 'auth', 'ebook', 'cache', 'search', 'profile', 'jb', 'play', 'nodejsScreencast', 'about', + 'profileGuest', 'quiz', 'currencyRate', 'payments', 'download', 'staticPage', 'newsletter', 'mailer', 'courses' +); + +// stick to bottom to detect any not-yet-processed /:slug +endpoints.push('tutorial'); + +endpoints.push('404'); + +endpoints.forEach(function(name) { + app.requireHandler(name); +}); + + +if (fs.existsSync(config.extraHandlersRoot)) { + fs.readdirSync(config.extraHandlersRoot).forEach(function(extraHandler) { + if (extraHandler[0] == '.') return; + app.requireHandler(extraHandler); + }); +} + +// uncomment for time-require to work +//process.emit('exit'); + +module.exports = app; + + diff --git a/modules/application.js b/modules/application.js new file mode 100755 index 000000000..c14010532 --- /dev/null +++ b/modules/application.js @@ -0,0 +1,102 @@ +/** + * Custom application, inherits from Koa Application + * Gets requireModules which adds a module to handlers. + * + * Handlers are called on: + * - init (sync) - initial requires + * - boot (async) - ensure ready to get a request + * - close (async) - close connections + * + * @type {Application} + */ + + +const KoaApplication = require('koa'); +const inherits = require('inherits'); + +const log = require('log')(); + + +module.exports = Application; + +function Application() { + KoaApplication.apply(this, arguments); + this.handlers = {}; + this.log = log; +} + +inherits(Application, KoaApplication); + + +// wait for full app load and all associated warm-ups to finish +// mongoose buffers queries, +// so for TEST/DEV there's no reason to wait +// for PROD, there is a reason: to check if DB is ok before taking a request +Application.prototype.waitBoot = function* () { + + for (var path in this.handlers) { + var handler = this.handlers[path]; + if (!handler.boot) continue; + yield* handler.boot(); + } + +}; + +// adding middlewares only possible *before* app.run +// (before server.listen) +// assigns server instance (meaning only 1 app can be run) +// +// app.listen can also be called from tests directly (and synchronously), without waitBoot (many times w/ random port) +// it's ok for tests, db requests are buffered, no need to waitBoot + +Application.prototype.waitBootAndListen = function*(host, port) { + yield* this.waitBoot(); + + yield function(callback) { + this.server = this.listen(port, host, callback); + }.bind(this); + + this.log.info('Server is listening %s:%d', host, port); +}; + +Application.prototype.close = function*() { + this.log.info("Closing app server..."); + yield function(callback) { + this.server.close(callback); + }.bind(this); + + this.log.info("App connections are closed"); + + for (var path in this.handlers) { + var handler = this.handlers[path]; + if (!handler.close) continue; + yield* handler.close(); + } + + this.log.info("App stopped"); +}; + +Application.prototype.requireHandler = function(path) { + + // if debug is on => will log the middleware travel chain + if (process.env.NODE_ENV == 'development' || process.env.LOG_LEVEL) { + var log = this.log; + this.use(function *(next) { + log.trace("-> setup " + path); + var d = new Date(); + yield* next; + log.trace("<- setup " + path, new Date() - d); + }); + } + + var handler = require(path); + + // init is always sync, for tests to run fast + // boot is async + if (handler.init) { + handler.init(this); + } + + this.handlers[path] = handler; + +}; diff --git a/modules/bem-jade.js b/modules/bem-jade.js new file mode 100644 index 000000000..1e754016c --- /dev/null +++ b/modules/bem-jade.js @@ -0,0 +1,130 @@ +// Adapted from bemto.jade, copyright(c) 2012 Roman Komarov + +/* jshint -W106 */ + +var jade = require('jade/lib/runtime'); + +module.exports = function(settings) { + settings = settings || {}; + + settings.prefix = settings.prefix || ''; + settings.element = settings.element || '__'; + settings.modifier = settings.modifier || '_'; + + return function(buf, bem_chain, tag, isElement) { + //console.log("-->", arguments); + var block = this.block; + var attributes = this.attributes || {}; + + if (!attributes.class && tag && !isElement) { + throw new Error("Block without class: " + tag); + } + + // Rewriting the class for elements and modifiers + if (attributes.class) { + var bem_classes = attributes.class; + + if (bem_classes instanceof Array) { + bem_classes = bem_classes.join(' '); + } + bem_classes = bem_classes.split(' '); + + var bem_block; + try { + bem_block = bem_classes[0].match(new RegExp('^(((?!' + settings.element + '|' + settings.modifier + ').)+)'))[1]; + } catch (e) { + throw new Error("Incorrect bem class: " + bem_classes[0]); + } + + if (!isElement) { + bem_chain[bem_chain.length] = bem_block; + } else { + bem_classes[0] = bem_chain[bem_chain.length - 1] + settings.element + bem_classes[0]; + } + + var current_block = (isElement ? bem_chain[bem_chain.length - 1] + settings.element : '') + bem_block; + + // Adding the block if there is only modifier and/or element + if (bem_classes.indexOf(current_block) === -1) { + bem_classes[bem_classes.length] = current_block; + } + + for (var i = 0; i < bem_classes.length; i++) { + var klass = bem_classes[i]; + + if (klass.match(new RegExp('^(?!' + settings.element + ')' + settings.modifier))) { + // Expanding the modifiers + bem_classes[i] = current_block + klass; + } else if (klass.match(new RegExp('^' + settings.element))) { + //- Expanding the mixed in elements + if (bem_chain[bem_chain.length - 2]) { + bem_classes[i] = bem_chain[bem_chain.length - 2] + klass; + } else { + bem_classes[i] = bem_chain[bem_chain.length - 1] + klass; + } + } + + // Adding prefixes + if (bem_classes[i].match(new RegExp('^' + current_block + '($|(?=' + settings.element + '|' + settings.modifier + '))'))) { + bem_classes[i] = settings.prefix + bem_classes[i]; + } + } + + // Write modified classes to attributes in the correct order + attributes.class = bem_classes.sort().join(' '); + } + + bem_tag(buf, block, attributes, bem_chain, tag); + + // Closing actions (remove the current block from the chain) + if (!isElement) { + bem_chain.pop(); + } + }; + + + // used for tweaking what tag we are throwing and do we need to wrap anything here + function bem_tag(buf, block, attributes, bem_chain, tag) { + // rewriting tag name on different contexts + var newTag = tag || 'div'; + + switch (newTag) { + case 'img': + // If there is no title we don't need it to show even if there is some alt + if (attributes.alt && !attributes.title) { + attributes.title = ''; + } + // If we have title, we must have it in alt if it's not set + if (attributes.title && !attributes.alt) { + attributes.alt = attributes.title; + } + if (!attributes.alt) { + attributes.alt = ''; + } + break; + case 'input': + if (!attributes.type) { + attributes.type = "text"; + } + break; + case 'html': + buf.push(''); + break; + case 'a': + if (!attributes.href) { + attributes.href = '#'; + } + } + + buf.push('<' + newTag + jade.attrs(jade.merge([attributes]), true) + ">"); + + if (block) block(); + + if (['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'keygen', 'link', 'menuitem', 'meta', 'param', 'source', 'track', 'wbr'].indexOf(newTag) == -1) { + buf.push(''); + } + + } + + +}; diff --git a/modules/cls.js b/modules/cls.js new file mode 100644 index 000000000..4a0cc05fc --- /dev/null +++ b/modules/cls.js @@ -0,0 +1,33 @@ +// This module initializes CLS +// and throws in additional modules to integrate it w/ other libs if needed + +const clsNamespace = require("continuation-local-storage").createNamespace("app"); + +// Must teach bluebird work with CLS +// mz/fs uses bluebird by default if installed +// something else installs bluebird, so it gets used +// if I don't teach bluebird here, it won't keep CLS context, then yield fs.stat will spoil context +require('cls-bluebird')(clsNamespace); + +exports.init = function(app) { + + app.use(function*(next) { + var context = clsNamespace.createContext(); + clsNamespace.enter(context); + + // some modules like accessLogger await for this.res.on('finish'/'close'), + // so let's bind these emitters to keep CLS context in handlers + clsNamespace.bindEmitter(this.req); + clsNamespace.bindEmitter(this.res); + + try { + clsNamespace.set('context', this); + yield* next; + } finally { + // important: all request-related events must be finished within request + // after request finished, the context is lost. + clsNamespace.exit(context); + } + }); + +}; diff --git a/modules/config/authProviders.js b/modules/config/authProviders.js new file mode 100755 index 000000000..c661abd0c --- /dev/null +++ b/modules/config/authProviders.js @@ -0,0 +1,44 @@ +var secret = require('./secret'); + +module.exports = { + facebook: { + testCredentials: secret.facebook.testCredentials, + appId: secret.facebook.appId, + appSecret: secret.facebook.appSecret, + passportOptions: { + display: 'popup', + scope: ['email'] + } + }, + google: { + appId: secret.google.appId, + appSecret: secret.google.appSecret, + passportOptions: { + scope: [ + 'profile', + // https://www.googleapis.com/auth/plus.login - request access to circles (not needed) + // 'https://www.googleapis.com/auth/plus.profile.emails.read' - also works + 'email' + ] + } + }, + github: { + appId: secret.github.appId, + appSecret: secret.github.appSecret, + passportOptions: { + scope: 'user:email' + } + }, + yandex: { + appId: secret.yandex.appId, + appSecret: secret.yandex.appSecret, + passportOptions: {} + }, + vkontakte: { + appId: secret.vkontakte.appId, + appSecret: secret.vkontakte.appSecret, + passportOptions: { + scope: 'email' + } + } +}; \ No newline at end of file diff --git a/modules/config/i18n.js b/modules/config/i18n.js new file mode 100755 index 000000000..0bbfcbd89 --- /dev/null +++ b/modules/config/i18n.js @@ -0,0 +1,11 @@ +var i18n = require("i18next"); + +i18n.init({ + lng: process.env.NODE_LANG || 'ru', + supportedLngs: ['en', 'ru'], + fallbackLng: false, + saveMissing: true, + sendMissingTo : 'all', + debug: process.env.NODE_ENV == 'development' +}); + diff --git a/modules/config/index.js b/modules/config/index.js new file mode 100755 index 000000000..e78bc6dfe --- /dev/null +++ b/modules/config/index.js @@ -0,0 +1,130 @@ +var path = require('path'); +var env = process.env; + +// NODE_ENV = development || test || production +env.NODE_ENV = env.NODE_ENV || 'development'; + +//if (!env.SITE_HOST) throw new Error("env.SITE_HOST is not set"); +//if (!env.STATIC_HOST) throw new Error("env.STATIC_HOST is not set"); + +var secret = require('./secret'); + +module.exports = { + server: { + port: env.PORT || 3000, + host: env.HOST || '0.0.0.0', + siteHost: env.SITE_HOST || '', + staticHost: env.STATIC_HOST || '' + }, + + test: { + e2e: { + sshHost: secret.test.e2e.sshHost, // remote host for testing e2e callbacks + sshUser: secret.test.e2e.sshUser, + siteHost: secret.test.e2e.siteHost, + browser: env.E2E_BROWSER || 'firefox' + } + }, + + mongoose: require('./mongoose'), + + cloudflare: { + url: 'https://www.cloudflare.com/api_json.html', + apiKey: secret.cloudflare.apiKey, + email: secret.cloudflare.email + }, + + xmpp: { + admin: secret.xmpp.admin + }, + + appKeys: [secret.sessionKey], + auth: { + session: { + key: 'sid', + prefix: 'sess:', + cookie: { + httpOnly: true, + path: '/', + overwrite: true, + signed: true, + maxAge: 3600 * 4 * 1e3 // session expires in 4 hours, remember me lives longer + }, + // touch session.updatedAt in DB & reset cookie on every visit to prolong the session + // koa-session-mongoose resaves the session as a whole, not just a single field + rolling: true + }, + rememberMe: { + key: 'remember', + cookie: { + httpOnly: true, + path: '/', + overwrite: true, + signed: true, + maxAge: 7 * 3600 * 24 * 1e3 // 7days + } + }, + + providers: require('./authProviders') + }, + payments: require('./payments'), + + imgur: secret.imgur, + adminKey: secret.adminKey, + + certDir: path.join(secret.dir, 'cert'), + + openexchangerates: { + appId: secret.openexchangerates.appId + }, + + jb: secret.jb, + lang: env.NODE_LANG || 'ru', + elastic: { + host: 'localhost:9200' + }, + + plnkrAuthId: secret.plnkrAuthId, + + assetVersioning: env.ASSET_VERSIONING == 'file' ? 'file' : + env.ASSET_VERSIONING == 'query' ? 'query' : null, + + mailer: require('./mailer'), + + jade: { + basedir: path.join(process.cwd(), 'templates'), + cache: env.NODE_ENV != 'development' + }, + crypto: { + hash: { + length: 128, + // may be slow(!): iterations = 12000 take ~60ms to generate strong password + iterations: env.NODE_ENV == 'production' ? 12000 : 1 + } + }, + + sauceLabs: { + username: secret.sauceLabs.username, + accessKey: secret.sauceLabs.accessKey, + address: 'http://ondemand.saucelabs.com:80/wd/hub' + }, + + renderedCacheEnabled: env.NODE_ENV == 'production', + projectRoot: process.cwd(), + // public files, served by nginx + publicRoot: path.join(process.cwd(), 'public'), + // private files, for expiring links, not directly accessible + downloadRoot: path.join(process.cwd(), 'download'), + courseRoot: path.join(process.cwd(), 'course'), + tmpRoot: path.join(process.cwd(), 'tmp'), + // extra handlers from outside of the main repo + extraHandlersRoot: path.join(process.cwd(), 'extra/handlers'), + // js/css build versions + manifestRoot: path.join(process.cwd(), 'manifest') +}; + +// webpack config uses general config +// we have a loop dep here +module.exports.webpack = require('./webpack'); +require('./i18n'); + diff --git a/modules/config/mailer.js b/modules/config/mailer.js new file mode 100755 index 000000000..6b69eb926 --- /dev/null +++ b/modules/config/mailer.js @@ -0,0 +1,33 @@ +var secret = require('./secret'); + +module.exports = { + senders: { + // transactional emails, register/forgot pass etc + default: { + fromEmail: 'notify@javascript.ru', + fromName: 'JavaScript.ru', + signature: "С уважением,
    Илья Кантор
    " + }, + // important emails about orders + orders: { + fromEmail: 'orders@javascript.ru', + fromName: 'JavaScript.ru', + signature: "С уважением,
    Илья Кантор
    " + }, + // newsletters + informer: { + fromEmail: 'informer@javascript.ru', + fromName: 'JavaScript.ru', + signature: "Успешной разработки!
    Илья Кантор
    " + } + }, + mandrill: { + apiKey: secret.mandrill.apiKey, + webhookKey: secret.mandrill.webhookKey, + // current running site may have another domain (proxied from webhookurl) + // that's why I set the webhookUrl separately + webhookUrl: secret.mandrill.webhookUrl + } + + +}; diff --git a/modules/config/mongoose.js b/modules/config/mongoose.js new file mode 100755 index 000000000..666684ff4 --- /dev/null +++ b/modules/config/mongoose.js @@ -0,0 +1,11 @@ +module.exports = { + "uri": "mongodb://localhost/" + (process.env.NODE_ENV == 'test' ? "js_test" : "js"), + "options": { + "server": { + "socketOptions": { + "keepAlive": 1 + }, + "poolSize": 5 + } + } +}; diff --git a/modules/config/payments.js b/modules/config/payments.js new file mode 100755 index 000000000..e14e512ad --- /dev/null +++ b/modules/config/payments.js @@ -0,0 +1,38 @@ +var secret = require('./secret'); + +module.exports = { + currency: 'RUB', + supportEmail: 'orders@javascript.ru', + modules: { + webmoney: { + secretKey: secret.webmoney.secretKey, + purse: secret.webmoney.purse + }, + yandexmoney: { + // full redirectUri, with host, because form-creating function isn't middleware, doesn't know context + redirectUri: process.env.SITE_HOST + '/payments/yandexmoney/back', + clientId: secret.yandexmoney.clientId, + clientSecret: secret.yandexmoney.clientSecret, + purse: secret.yandexmoney.purse + }, + + payanyway: { + id: secret.payanyway.id, + secret: secret.payanyway.secret + }, + + banksimple: secret.banksimple, + + paypal: { + email: secret.paypal.email, + pdtToken: secret.paypal.pdtToken + }, + + interkassa: { + id: secret.interkassa.id, + secret: secret.interkassa.secret + }, + + invoice: secret.invoice + } +}; diff --git a/modules/config/secret.dev.js b/modules/config/secret.dev.js new file mode 100755 index 000000000..c05a4fccb --- /dev/null +++ b/modules/config/secret.dev.js @@ -0,0 +1,73 @@ +// this file contains all passwords etc, +// should not be in repo + +module.exports = { + sessionKey: "KillerIsJim", + adminKey: 'admin', + + webmoney: {}, + yandexmoney: {}, + paypal: {}, + payanyway: {}, + banksimple: {}, + interkassa: {}, + + cloudflare: {}, + + xmpp: { + admin: { + login: 'a', + password: 'b' + } + }, + + // dev credentials + imgur: { + url: 'https://api.imgur.com/3/', + clientId: '658726429918c83', + clientSecret: '9195ed91c629b9c933187d3eba8a4d0567ba4644' + }, + + openexchangerates: { + // login: mk@javascript.ru + appId: "a41430df4d734553ae0edd5a932e8169" + }, + + test: { + e2e: { + sshHost: null, + sshUser: null, + siteHost: null + } + }, + + mandrill: { + apiKey: 'no mail please', + webhookKey: 'no hooks for dev', + webhookUrl: 'no hooks for dev' + }, + + jb: {}, + facebook: { + appId: '*', + appSecret: '*' + }, + google: { + appId: '*', + appSecret: '*' + }, + github: { + appId: '*', + appSecret: '*' + }, + yandex: { + appId: '*', + appSecret: '*' + }, + vkontakte: { + appId: '*', + appSecret: '*' + }, + sauceLabs: {} + +}; diff --git a/modules/config/secret.js b/modules/config/secret.js new file mode 100755 index 000000000..83e2a011f --- /dev/null +++ b/modules/config/secret.js @@ -0,0 +1,17 @@ + // use env.SECRET_DIR/secret.js if exists OR ./secret.example + +var path = require('path'); +var fs = require('fs'); + +var secretDir = process.env.SECRET_DIR || '/js/secret'; + +if (fs.existsSync(path.join(secretDir, 'secret.js'))) { + module.exports = require(path.join(secretDir, 'secret')); +} else { + module.exports = require('./secret.dev'); +} + +module.exports.dir = secretDir; + + + diff --git a/modules/config/webpack.js b/modules/config/webpack.js new file mode 100755 index 000000000..f17775abe --- /dev/null +++ b/modules/config/webpack.js @@ -0,0 +1,197 @@ +var fs = require('fs'); +var nib = require('nib'); +var path = require('path'); +var config = require('config'); +var webpack = require('webpack'); +var CommonsChunkPlugin = require("webpack/lib/optimize/CommonsChunkPlugin"); +var WriteVersionsPlugin = require('lib/webpack/writeVersionsPlugin'); +var ExtractTextPlugin = require("extract-text-webpack-plugin"); + +var del = require('del'); + +// 3rd party / slow to build modules +// no webpack dependencies inside +// no es6 (for 6to5 processing) inside +// NB: includes angular-* +var noProcessModulesRegExp = /node_modules\/(angular|prismjs)/; + +// tutorial.js?hash +// tutorial.hash.js +function extHash(name, ext, hash) { + if (!hash) hash = '[hash]'; + return config.assetVersioning == 'query' ? `${name}.${ext}?${hash}` : + config.assetVersioning == 'file' ? `${name}.${hash}.${ext}` : + `${name}.${ext}`; +} + +var webpackConfig = { + output: { + // fs path + path: path.join(config.publicRoot, 'pack'), + // path as js sees it + // if I use another domain here, need enable Allow-Access-.. header there + // and add to scripts, to let error handler track errors + publicPath: '/pack/', + // в dev-режиме файлы будут вида [name].js, но обращения - через [name].js?[hash], т.е. версия учтена + // в prod-режиме не можем ?, т.к. CDN его обрезают, поэтому [hash] в имени + // (какой-то [hash] здесь необходим, иначе к chunk'ам типа 3.js, которые генерируются require.ensure, + // будет обращение без хэша при загрузке внутри сборки. при изменении - барузерный кеш их не подхватит) + filename: extHash("[name]", 'js'), + + chunkFilename: extHash("[name]-[id]", 'js'), + // the setting below does not work with CommonsChunkPlugin + library: '[name]' + }, + + cache: process.env.NODE_ENV == 'development', + watchDelay: 10, + watch: process.env.NODE_ENV == 'development', + + devtool: process.env.NODE_ENV == 'development' ? "eval" : // try "inline-source-map" ? + process.env.NODE_ENV == 'production' ? 'source-map' : "", + + profile: true, + + entry: { + about: 'about/client', + angular: 'client/angular', + head: 'client/head', + tutorial: 'tutorial/client', + profile: 'profile/client', + search: 'search/client', + quiz: 'quiz/client', + ebook: 'ebook/client', + courses: 'courses/client', + footer: 'client/footer', + nodejsScreencast: 'nodejsScreencast/client' + }, + + externals: { + // require("angular") is external and available + // on the global var angular + "angular": "angular" + }, + + module: { + loaders: [ + { + test: /\.jade$/, + loader: "jade?root=" + config.projectRoot + '/templates' + }, + { + test: /\.js$/, + // babel shouldn't process webpack, because it contains ws/browser.js, + // which must not be run in strict mode (global becomes undefined) + // babel would make all modules strict + exclude: /node_modules\/(angular|prismjs|moment|blueimp-canvas-to-blob)/, + loaders: ['ng-annotate', 'babel'] + }, + { + test: /\.styl$/, + // ExtractTextPlugin breaks HMR for CSS + loader: ExtractTextPlugin.extract('style', 'css!autoprefixer?browsers=last 2 version!stylus?linenos=true') + //loader: 'style!css!autoprefixer?browsers=last 2 version!stylus?linenos=true' + }, + { + test: /\.(png|jpg|gif|woff|eot|otf|ttf|svg)$/, + loader: extHash('file?name=[path][name]', '[ext]') + } + ], + noParse: [ + // regexp gets full path with loader like + // '/js/javascript-nodejs/node_modules/client/angular.js' + // or even + // '/js/javascript-nodejs/node_modules/6to5-loader/index.js?modules=commonInterop!/js/javascript-nodejs/node_modules/client/head/index.js' + { + test: function(path) { + //console.log(path); + return noProcessModulesRegExp.test(path); + } + } + ] + }, + + stylus: { + use: [nib()] + }, + + resolve: { + // allow require('styles') which looks for styles/index.styl + extensions: ['', '.js', '.styl'], + alias: { + lodash: 'lodash/dist/lodash', + angular: 'angular/angular', + angularRouter: 'angular-ui-router/release/angular-ui-router', + angularCookies: 'angular-cookies/angular-cookies', + angularResource: 'angular-resource/angular-resource' + } + }, + + node: { + fs: 'empty' + }, + + plugins: [ + // lodash is loaded when free variable _ occurs in the code + new webpack.ProvidePlugin({ + _: 'lodash' + }), + + // prevent autorequire all moment locales + // https://github.com/webpack/webpack/issues/198 + new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/), + + // any common chunks from entries go to head + new CommonsChunkPlugin("head", extHash("head", 'js')), + new WriteVersionsPlugin(path.join(config.manifestRoot, "pack.versions.json")), + + new ExtractTextPlugin(extHash('[name]', 'css', '[contenthash]'), {allChunks: true}) + ], + + recordsPath: path.join(config.tmpRoot, 'webpack.json'), + devServer: { + port: 3001, // dev server itself does not use it, but outer tasks do + historyApiFallback: true, + hot: true, + watchDelay: 10, + //noInfo: true, + publicPath: process.env.STATIC_HOST + ':3001/pack/', + contentBase: config.publicRoot + } +}; + + +//if (process.env.NODE_ENV != 'development') { // production, ebook +if (process.env.NODE_ENV == 'production') { // production, ebook + webpackConfig.plugins.push( + function clearBeforeRun() { + function clear(compiler, callback) { + del.sync(this.options.output.path + '/*'); + callback(); + } + + // in watch mode this will clear between partial rebuilds + // thus removing unchanged files + // => use this plugin only in normal run + this.plugin('run', clear); + }, + + /* jshint -W106 */ + new webpack.optimize.UglifyJsPlugin({ + compress: { + // don't show unreachable variables etc + warnings: false, + drop_console: true, + unsafe: true, + screw_ie8: true + }, + beautify: true, + output: { + indent_level: 0 // for error reporting, to see which line actually has the problem + // source maps actually didn't work in Qbaka that's why I put it here + } + }) + ); +} + +module.exports = webpackConfig; diff --git a/modules/elastic/client.js b/modules/elastic/client.js new file mode 100755 index 000000000..b8d216093 --- /dev/null +++ b/modules/elastic/client.js @@ -0,0 +1,37 @@ +var elasticsearch = require('elasticsearch'); +var config = require('config'); + +const log = require('log')(); + +// logger from +// http://www.elasticsearch.org/guide/en/elasticsearch/client/javascript-api/current/logging.html +function LogToBunyan(config) { + // config is the object passed to the client constructor. + this.error = log.error.bind(log); + this.warning = log.warn.bind(log); + this.info = log.info.bind(log); + this.debug = log.debug.bind(log); + this.trace = function(method, requestUrl, body, responseBody, responseStatus) { + log.trace({ + method: method, + requestUrl: requestUrl, + body: body, + responseBody: responseBody, + responseStatus: responseStatus + }); + }; + this.close = function() { + /* bunyan's loggers do not need to be closed */ + }; +} + +var client; +module.exports = function() { + if (!client) client = new elasticsearch.Client({ + host: config.elastic.host, + log: LogToBunyan + }); + + return client; +}; + diff --git a/modules/elastic/html2search.js b/modules/elastic/html2search.js new file mode 100755 index 000000000..f5d1d87b9 --- /dev/null +++ b/modules/elastic/html2search.js @@ -0,0 +1,18 @@ +module.exports = function prepareHtml(html) { + + // Format the source for search + // I must do it here, not in char_filter of elastic, + // because this text WILL BE DISPLAYED + // (or it's part) + // + // if I leave html tags here => they will appear in search result + + return html + .replace(/([^.])(<\/h\d>)/gim, '$1.$2') // make all headers sentences: # Text -> # Text. + // should we make "search in sources" an optional checkbox? + .replace(/
    /gim, '') // remove code
    +    .replace(/)[\s\S]*?<\/style>/gim, '')  // remove styles
    +    .replace(/)[\s\S]*?<\/script>/gim, '') // remove inline scripts
    +    .replace(/<\/?[a-z].*?>/gim, ''); // strip all tags
    +};
    diff --git a/modules/elastic/index.js b/modules/elastic/index.js
    new file mode 100755
    index 000000000..625398f0d
    --- /dev/null
    +++ b/modules/elastic/index.js
    @@ -0,0 +1,5 @@
    +// ElasticSearch client & helper functions
    +
    +exports.html2search = require('./html2search');
    +exports.client = require('./client');
    +
    diff --git a/modules/gaHitCallback.js b/modules/gaHitCallback.js
    new file mode 100644
    index 000000000..ddeb03473
    --- /dev/null
    +++ b/modules/gaHitCallback.js
    @@ -0,0 +1,12 @@
    +
    +// gaCallback(f) will return a wrapper that works only once
    +// autocalled after 500ms
    +module.exports = function(f) {
    +  function callback() {
    +    if (callback.wasCalled) return;
    +    callback.wasCalled = true;
    +    f();
    +  }
    +  setTimeout(callback, 500);
    +  return callback;
    +};
    diff --git a/modules/imgur/index.js b/modules/imgur/index.js
    new file mode 100755
    index 000000000..ffebfeebf
    --- /dev/null
    +++ b/modules/imgur/index.js
    @@ -0,0 +1,105 @@
    +var request = require('request');
    +var config = require('config');
    +var log = require('log')();
    +var inherits = require('inherits');
    +var _ = require('lodash');
    +var mime = require('mime');
    +
    +//require('request-debug')(request);
    +
    +function BadImageError(msg) {
    +  Error.call(this, msg);
    +  this.message = msg;
    +  this.name = 'BadImageError';
    +}
    +inherits(BadImageError, Error);
    +
    +exports.transload = function*(url) {
    +
    +  log.debug("transload", url);
    +  var response = yield imgurRequest('image', {
    +    formData: {
    +      type:  'url',
    +      image: url
    +    }
    +  });
    +
    +  if (!response.success) {
    +    throw new BadImageError(response.data.error);
    +  }
    +
    +  return response.data;
    +};
    +
    +exports.uploadBuffer = function*(fileName, buffer) {
    +  // the same code actually
    +  return yield* exports.uploadStream(fileName, buffer.length, buffer);
    +};
    +
    +/*
    +custom_file: {
    +      value:  fs.createReadStream('/dev/urandom'),
    +        options: {
    +        filename: 'topsecret.jpg',
    +          contentType: 'image/jpg'
    +      }
    +*/
    +
    +/**
    + * Uploads a stream (file or multiparty part or...)
    + * @param fileName fileName (for mime)
    + * @param knownLength contentLength (from file stream request could get it, but not from multiparty part)
    + * @param stream
    + * @returns {*}
    + */
    +exports.uploadStream = function*(fileName, knownLength, stream) {
    +
    +  if (!knownLength) {
    +    throw new BadImageError("Пустое изображение.");
    +  }
    +  var mimeType = mime.lookup(fileName);
    +
    +  var response = yield* imgurRequest('image', {
    +    formData: {
    +      type:  'file',
    +      image: {
    +        value: stream,
    +        options: {
    +          filename: fileName,
    +          contentType: mimeType,
    +          knownLength: knownLength
    +        }
    +      }
    +    }
    +  });
    +
    +  if (!response.success) {
    +    throw new BadImageError(response.data.error);
    +  }
    +
    +  return response.data;
    +};
    +
    +
    +function* imgurRequest(serviceName, options) {
    +  options = _.merge({
    +    method:  'POST',
    +    url:     config.imgur.url + serviceName,
    +    headers: {'Authorization': 'Client-ID ' + config.imgur.clientId},
    +    json:    true
    +  }, options);
    +
    +  var response = yield function(callback) {
    +    request(options, function(error, response) {
    +      callback(error, response);
    +    });
    +  };
    +
    +  if (response.statusCode != 200 && response.statusCode != 400) {
    +    log.error("Imgur error", {res: response});
    +    throw new Error("Error communicating with imgur service.");
    +  }
    +
    +  return response.body;
    +
    +}
    diff --git a/modules/imgur/test/ball.gif b/modules/imgur/test/ball.gif
    new file mode 100755
    index 000000000..4843c13d5
    Binary files /dev/null and b/modules/imgur/test/ball.gif differ
    diff --git a/modules/imgur/test/index.js b/modules/imgur/test/index.js
    new file mode 100755
    index 000000000..1497b8179
    --- /dev/null
    +++ b/modules/imgur/test/index.js
    @@ -0,0 +1,58 @@
    +var imgur = require('..');
    +var fs = require('fs');
    +var path = require('path');
    +
    +describe("imgur", function() {
    +
    +  describe("transload", function() {
    +
    +    var urlExample = 'http://upload.wikimedia.org/wikipedia/commons/thumb/3/3d/LARGE_elevation.jpg/800px-LARGE_elevation.jpg';
    +    var url14MB = 'http://upload.wikimedia.org/wikipedia/commons/3/3d/LARGE_elevation.jpg';
    +
    +    it("works for a normal url", function*() {
    +      var response = yield* imgur.transload(urlExample);
    +      response.should.be.string;
    +    });
    +
    +
    +    it("fails for too big picture", function*() {
    +      var hasError = false;
    +      try {
    +        yield* imgur.transload(url14MB);
    +      } catch (e) {
    +        hasError = true;
    +      }
    +      hasError.should.be.true;
    +    });
    +
    +
    +  });
    +
    +  describe("uploadStream", function() {
    +
    +    it("uploads a stream as image (gif)", function*() {
    +
    +      var filePath = path.join(__dirname, 'ball.gif');
    +      var stream = fs.createReadStream(filePath);
    +
    +      var response = yield* imgur.uploadStream(filePath, fs.statSync(filePath).size, stream);
    +
    +      response.size.should.be.eql(fs.statSync(filePath).size);
    +      response.should.be.string;
    +    });
    +
    +    it("uploads a stream as image (png)", function*() {
    +
    +      var filePath = path.join(__dirname, 'test.png');
    +      var stream = fs.createReadStream(filePath);
    +
    +      var response = yield* imgur.uploadStream(filePath,  fs.statSync(filePath).size, stream);
    +
    +      response.size.should.be.eql(fs.statSync(filePath).size);
    +      response.should.be.string;
    +    });
    +
    +  });
    +
    +
    +});
    \ No newline at end of file
    diff --git a/modules/imgur/test/test.png b/modules/imgur/test/test.png
    new file mode 100755
    index 000000000..3cba80920
    Binary files /dev/null and b/modules/imgur/test/test.png differ
    diff --git a/modules/lib/capitalizeKeys.js b/modules/lib/capitalizeKeys.js
    new file mode 100755
    index 000000000..d9f61a43c
    --- /dev/null
    +++ b/modules/lib/capitalizeKeys.js
    @@ -0,0 +1,23 @@
    +
    +
    +function capitalizeKeys(obj) {
    +  if (Array.isArray(obj)) {
    +    return obj.map(capitalizeKeys);
    +  }
    +
    +  var output = {};
    +
    +  for (var key in obj) {
    +    var keyCapitalized = key.replace(/_(\w)/g, function(match, letter) {
    +      return letter.toUpperCase();
    +    });
    +    if (Object.prototype.toString.apply(obj[key]) === '[object Object]') {
    +      output[keyCapitalized] = capitalizeKeys(obj[key]);
    +    } else {
    +      output[keyCapitalized] = obj[key];
    +    }
    +  }
    +  return output;
    +}
    +
    +module.exports = capitalizeKeys;
    \ No newline at end of file
    diff --git a/modules/lib/dataUtil.js b/modules/lib/dataUtil.js
    new file mode 100755
    index 000000000..7d50d5385
    --- /dev/null
    +++ b/modules/lib/dataUtil.js
    @@ -0,0 +1,137 @@
    +
    +var mongoose = require('mongoose');
    +var log = require('log')();
    +var co = require('co');
    +var thunk = require('thunkify');
    +
    +var db;
    +
    +function *createEmptyDb() {
    +
    +  function *open() {
    +
    +    if (mongoose.connection.readyState == 1) { // connected
    +      return mongoose.connection.db;
    +    }
    +
    +    yield thunk(mongoose.connection.on)('open');
    +
    +    return mongoose.connection.db;
    +  }
    +
    +  function *clearDatabase() {
    +
    +    var collections = yield new Promise(function(resolve, reject) {
    +      db.listCollections().toArray(function(err, items) {
    +        if (err) return reject(err);
    +        resolve(items);
    +      });
    +    });
    +
    +    var collectionNames = collections
    +      .map(function(collection) {
    +        //console.log(collection.name);
    +        //var collectionName = collection.name.slice(db.databaseName.length + 1);
    +        if (collection.name.indexOf('system.') === 0) {
    +          return null;
    +        }
    +        return collection.name;
    +      })
    +      .filter(Boolean);
    +
    +    yield collectionNames.map(function(name) {
    +      log.debug("drop ", name);
    +      return thunk(db.dropCollection)(name);
    +    });
    +
    +  }
    +
    +  // wait till indexes are complete, especially unique
    +  // required to throw errors
    +  function *ensureIndexes() {
    +
    +    yield mongoose.modelNames().map(function(modelName) {
    +      var model = mongoose.models[modelName];
    +      return thunk(model.ensureIndexes.bind(model))();
    +    });
    +
    +  }
    +
    +  // ensure that capped collections are actually capped
    +  function *ensureCapped() {
    +
    +    yield mongoose.modelNames().map(function(modelName) {
    +      var model = mongoose.models[modelName];
    +      var schema = model.schema;
    +      if (!schema.options.capped) return;
    +
    +      return thunk(db.command)({convertToCapped: model.collection.name, size: schema.options.capped});
    +    });
    +  }
    +
    +  log.debug("co");
    +
    +  db = yield open();
    +  log.debug("open");
    +
    +  yield clearDatabase();
    +  log.debug("clear");
    +
    +  yield ensureIndexes();
    +  log.debug('indexes');
    +
    +  yield ensureCapped();
    +  log.debug('capped');
    +
    +}
    +
    +// tried using pow-mongoose-fixtures,
    +// but it fails with capped collections, it calls remove() on them => everything dies
    +// so rolling my own tiny-loader
    +function *loadModels(data, options) {
    +  options = options || {};
    +  var modelsData = (typeof data == 'string') ? require(data) : data;
    +
    +  var modelNames = Object.keys(modelsData);
    +
    +  for(var modelName in modelsData) {
    +    var Model = mongoose.models[modelName];
    +    if (options.reset) {
    +      yield Model.destroy({});
    +    }
    +    yield* loadModel(Model, modelsData[modelName]);
    +  }
    +}
    +
    +// load data into the DB, replace if _id is the same
    +function *loadModel(Model, data) {
    +
    +  for (var i = 0; i < data.length; i++) {
    +    if (data[i]._id) {
    +      yield Model.destroy({_id: data[i]._id});
    +    }
    +    var model = new Model(data[i]);
    +
    +    log.debug("persist", data[i]);
    +    try {
    +      yield model.persist();
    +    } catch (e) {
    +      if (e.name == 'ValidationError') {
    +        log.error("loadModel persist validation error", e, e.errors);
    +      }
    +      throw e;
    +    }
    +  }
    +
    +  log.debug("loadModel is done");
    +}
    +
    +exports.loadModels = loadModels;
    +exports.createEmptyDb = createEmptyDb;
    +
    +/*
    + Usage:
    + co(loadModels('sampleDb'))(function(err) {
    + if (err) throw err;
    + mongoose.connection.close();
    + });*/
    diff --git a/modules/lib/debug.js b/modules/lib/debug.js
    new file mode 100755
    index 000000000..ad6bf3d33
    --- /dev/null
    +++ b/modules/lib/debug.js
    @@ -0,0 +1,44 @@
    +/*
    + crap code to log & isolate steps for stackless errors when node-inspector dies
    + p() will print next number
    + */
    +if (process.env.NODE_ENV == 'development') {
    +
    +  global.p = function() {
    +    var stack = new Error().stack.split("\n")[2].trim();
    +    console.log("----> " + global.p.counter++ + " at " + stack);
    +  };
    +  global.p.counter = 1;
    +
    +
    +
    +
    +} else {
    +  global.p = function() {
    +
    +  };
    +}
    +
    +/**
    + * When a code dies with a strange event error w/o trace
    + * Here I try to see what actually died
    + */
    +if (process.env.DEBUG_ERROR) {
    +  var proto = require('events').EventEmitter.prototype;
    +  var emit = proto.emit;
    +  proto.emit = function(type, err) {
    +    if (type == 'error') {
    +      console.log(this.test, this.constructor.name, err.message, err.stack);
    +      process.exit(1);
    +    }
    +    else emit.apply(this, arguments);
    +  };
    +
    +}
    +
    +if (process.env.DEBUG_CONSOLE) {
    +  // find where console.log is called from
    +  console.log = function() {
    +    require('fs').writeSync(1, new Error().stack.toString())
    +  };
    +}
    \ No newline at end of file
    diff --git a/modules/lib/e2eTunnel.js b/modules/lib/e2eTunnel.js
    new file mode 100755
    index 000000000..a595489bd
    --- /dev/null
    +++ b/modules/lib/e2eTunnel.js
    @@ -0,0 +1,71 @@
    +const spawn = require('child_process').spawn;
    +const config = require('config');
    +const path = require('path');
    +const fs = require('fs');
    +const log = require('log')();
    +
    +// only 1 tunnel on the host
    +killOldPid();
    +
    +var pidFilePath = path.join(config.tmpRoot, 'e2eTunnel.pid');
    +var tunnelReady = false;
    +
    +module.exports = function*() {
    +  if (tunnelReady) return;
    +  yield spawnSsh;
    +  tunnelReady = true;
    +};
    +
    +
    +function spawnSsh(callback) {
    +
    +// use -tt flag to force terminal, otherwise it fail
    +  var tunnel = spawn('ssh', ['-tt', '-R', '1212:localhost:80', config.test.e2e.sshUser + '@' + config.test.e2e.sshHost], {
    +    stdio: ['ignore', 'pipe', 'pipe']
    +  });
    +
    +  tunnel.stderr.setEncoding('utf8');
    +  tunnel.stdout.setEncoding('utf8');
    +
    +// an error means the tunnel is unusable
    +  tunnel.stderr.on('data', function(data) {
    +    if (data.startsWith('Killed')) return; // Will get killed eventually
    +    if (data.startsWith('setsockopt')) return; // e.g setsockopt TCP_NODELAY: Invalid argument
    +    // otherwise stop all further processing (gulp plumbered task should not continue too)
    +    setImmediate(function() {
    +      process.exit(1);
    +    });
    +    throw new Error(data);
    +  });
    +
    +  tunnel.stdout.on('data', function(data) {
    +    log.debug(data);
    +    fs.writeFileSync(pidFilePath, tunnel.pid.toString());
    +    if (callback) callback();
    +    callback = null;
    +  });
    +
    +
    +// don't make the process wait for the child to end, but instead kill it
    +  tunnel.unref();
    +  process.once('exit', function() {
    +    tunnel.kill();
    +  });
    +
    +// tunnel.stdout & tunnel.stderr are net.Socket streams
    +// must unref them in addition
    +  tunnel.stdout.unref();
    +  tunnel.stderr.unref();
    +
    +
    +  return tunnel;
    +}
    +
    +function killOldPid() {
    +  try {
    +    var oldPid = fs.readFileSync(pidFilePath);
    +    process.kill(oldPid);
    +  } catch (e) {
    +    // no file or no kill, who cares, we tried out best
    +  }
    +}
    \ No newline at end of file
    diff --git a/modules/lib/lazyRouterMiddleware.js b/modules/lib/lazyRouterMiddleware.js
    new file mode 100755
    index 000000000..ec26f6f36
    --- /dev/null
    +++ b/modules/lib/lazyRouterMiddleware.js
    @@ -0,0 +1,19 @@
    +// router middleware which does require() on first activation
    +// instead of:
    +//   require('./router').middleware()
    +// do:
    +//   require('lib/lazyRouter')('./router')
    +// purpose: don't require everything on startup
    +module.exports = function(routerModulePath) {
    +  var middleware = null;
    +
    +  return function*(next) {
    +    if (!middleware) {
    +      middleware = module.parent.require(routerModulePath).middleware();
    +    }
    +    yield* middleware.call(this, next);
    +  };
    +
    +};
    +
    +delete require.cache[__filename];
    diff --git a/modules/lib/logMemoryUsage.js b/modules/lib/logMemoryUsage.js
    new file mode 100755
    index 000000000..46310e21d
    --- /dev/null
    +++ b/modules/lib/logMemoryUsage.js
    @@ -0,0 +1,13 @@
    +var csvPath = require('path').resolve(__dirname, 'memory.csv');
    +var out = require('fs').createWriteStream(csvPath);
    +
    +setInterval(function() {
    +  var time = Date.now();
    +  var memo = process.memoryUsage();
    +  out.write(
    +    time + ',' +
    +    memo.rss + ', ' +
    +    memo.heapTotal + ', ' +
    +    memo.heapUsed + '\n'
    +  );
    +}, 200);
    diff --git a/modules/lib/mongoose.js b/modules/lib/mongoose.js
    new file mode 100755
    index 000000000..7b6f61595
    --- /dev/null
    +++ b/modules/lib/mongoose.js
    @@ -0,0 +1,175 @@
    +/**
    + * This file must be required at least ONCE.
    + * After it's done, one can use require('mongoose')
    + *
    + * In web-app: this is done at init phase
    + * In tests: in mocha.opts
    + * In gulpfile: in beginning
    + */
    +
    +var mongoose = require('mongoose');
    +var path = require('path');
    +var fs = require('fs');
    +var log = require('log')();
    +var autoIncrement = require('mongoose-auto-increment');
    +var ValidationError = require('mongoose/lib/error').ValidationError;
    +var ValidatorError = require('mongoose/lib/error').ValidatorError;
    +
    +var config = require('config');
    +var _ = require('lodash');
    +
    +
    +if (process.env.MONGOOSE_DEBUG) {
    +  mongoose.set('debug', true);
    +  log.debug(config.mongoose.uri, config.mongoose.options);
    +}
    +
    +
    +mongoose.connect(config.mongoose.uri, config.mongoose.options);
    +
    +autoIncrement.initialize(mongoose.connection);
    +
    +// bind context now for thunkify without bind
    +_.bindAll(mongoose.connection);
    +_.bindAll(mongoose.connection.db);
    +
    +// plugin from https://github.com/LearnBoost/mongoose/issues/1859
    +// yield.. .persist() or .destroy() for generators instead of save/remove
    +// mongoose 3.10 will not need that (!)
    +mongoose.plugin(function(schema) {
    +
    +  schema.methods.persist = function(body) {
    +    var model = this;
    +
    +    return function(callback) {
    +      if (body) model.set(body);
    +      model.save(function(err, changed) {
    +
    +        if (!err || err.code != 11000) {
    +          return callback(err, changed);
    +        }
    +
    +        log.trace("uniqueness error", err);
    +        log.trace("will look for indexName in message", err.message);
    +
    +
    +        var indexName = err.message.match(/\$(\w+)/);
    +        indexName = indexName[1];
    +
    +        model.collection.getIndexes(function(err2, indexes) {
    +          if (err2) return callback(err);
    +
    +          // e.g. indexes = {idxName:  [ [displayName, 1], [email, 1] ] }
    +
    +          // e.g indexInfo = [ [displayName, 1], [email, 1] ]
    +          var indexInfo = indexes[indexName];
    +
    +          // convert to indexFields = { displayName: 1, email: 1 }
    +          var indexFields = {};
    +          indexInfo.forEach(function toObject(item) {
    +            indexFields[item[0]] = item[1];
    +          });
    +
    +          var schemaIndexes = schema.indexes();
    +
    +          //console.log("idxes:", schemaIndexes, "idxf", indexFields, schemaIndexes.find);
    +          var schemaIndex = null;
    +
    +          for (var i = 0; i < schemaIndexes.length; i++) {
    +            if (_.isEqual(schemaIndexes[i][0], indexFields)) {
    +              schemaIndex = schemaIndexes[i];
    +              break;
    +            }
    +          }
    +
    +          log.trace("Schema index which failed:", schemaIndex);
    +
    +          var errorMessage;
    +          if (!schemaIndex) {
    +            // index exists in DB, but not in schema
    +            // strange
    +            // that's usually the case for _id_
    +            if (indexName == '_id_') {
    +              errorMessage = 'Id is not unique';
    +            } else {
    +              // non-standard index in DB, but not in schema? fix it!
    +              return callback(new Error("index " + indexName + " in DB, but not in schema"));
    +            }
    +          } else {
    +            // schema index object, e.g
    +            // { unique: 1, sparse: 1 ... }
    +            var schemaIndexInfo = schemaIndex[1];
    +
    +            errorMessage = schemaIndexInfo.errorMessage || ("Index error: " + indexName);
    +          }
    +
    +          var valError = new ValidationError(err);
    +
    +          var field = indexInfo[0][0]; // if many fields in uniq index - we take the 1st one for error
    +
    +          log.trace("Generating error for field", field, ':', errorMessage);
    +
    +          // example:
    +          // err = { path="email", message="Email is not unique", type="notunique", value=model.email }
    +          valError.errors[field] = new ValidatorError({
    +            path: "email",
    +            message: errorMessage,
    +            type: 'notunique',
    +            value: model[field]
    +          });
    +
    +          valError.code = err.code; // if (err.code == 11000) in the outer code will still work
    +
    +          return callback(valError);
    +        });
    +
    +      });
    +    };
    +  };
    +  schema.methods.destroy = function() {
    +    var model = this;
    +
    +    return function(callback) {
    +      model.remove(callback);
    +    };
    +  };
    +
    +  schema.statics.destroy = function(query) {
    +    return function(callback) {
    +      this.remove(query, callback);
    +    }.bind(this);
    +  };
    +});
    +
    +mongoose.waitConnect = function(callback) {
    +  if (mongoose.connection.readyState == 1) {
    +    setImmediate(callback);
    +  } else {
    +    // we wait either for an error
    +    // OR
    +    // for a successful connection
    +    //console.log("MONGOOSE", mongoose, "CONNECTION", mongoose.connection, "ON", mongoose.connection.on);
    +    mongoose.connection.on("connected", onConnected);
    +    mongoose.connection.on("error", onError);
    +  }
    +
    +  function onConnected() {
    +    log.debug("Mongoose has just connected");
    +    cleanUp();
    +    callback();
    +  }
    +
    +  function onError(err) {
    +    log.debug('Failed to connect to DB', err);
    +    cleanUp();
    +    callback(err);
    +  }
    +
    +  function cleanUp() {
    +    mongoose.connection.removeListener("connected", onConnected);
    +    mongoose.connection.removeListener("error", onError);
    +  }
    +
    +};
    +
    +module.exports = mongoose;
    diff --git a/modules/lib/mountHandlerMiddleware.js b/modules/lib/mountHandlerMiddleware.js
    new file mode 100755
    index 000000000..e06adc8e0
    --- /dev/null
    +++ b/modules/lib/mountHandlerMiddleware.js
    @@ -0,0 +1,53 @@
    +var path = require('path');
    +var mount = require('koa-mount');
    +
    +
    +// wrap('modulePath')
    +// is same as
    +// require('modulePath').middleware,
    +// but also calls apply/undo upon entering/leaving the middleware
    +//   --> here it does: this.templateDir = handlerModule dirname
    +module.exports = function(prefix, moduleDir) {
    +
    +  // actually includes router when the middleware is accessed (mount prefix matches)
    +  var lazyRouterMiddleware = require('lib/lazyRouterMiddleware')(path.join(moduleDir, 'router'));
    +
    +  function* wrapMiddleware(next) {
    +    var self = this;
    +
    +    var templateDir = path.join(moduleDir, 'templates');
    +
    +    // before entering middeware
    +    function apply() {
    +      self.templateDir = templateDir;
    +    }
    +
    +    // before leaving middleware
    +    function undo() {
    +      delete self.templateDir;
    +    }
    +
    +    apply();
    +
    +    try {
    +      yield* lazyRouterMiddleware.call(this, function* () {
    +        // when middleware does yield next, undo changes
    +        undo();
    +        try {
    +          yield* next;
    +        } finally {
    +          // ...then apply back, when control goes back after yield next
    +          apply();
    +        }
    +      }());
    +
    +    } finally {
    +      undo();
    +    }
    +
    +  }
    +
    +  return mount(prefix, wrapMiddleware);
    +
    +};
    +
    diff --git a/modules/lib/readline.js b/modules/lib/readline.js
    new file mode 100755
    index 000000000..339b5d63f
    --- /dev/null
    +++ b/modules/lib/readline.js
    @@ -0,0 +1,51 @@
    +/**
    + * DISCLAIMER: Had to implement custom input reading,
    + *
    + * because both node-read and node-prompt had bugs on Windows-8.1
    + * E.g. every input letter was double-printed
    + * (and I really need starred * input for passwords)
    + *
    + */
    +var readline = require('readline');
    +
    +
    +/**
    + * @param options object { message: what to ask?, hidden: true for passwords }
    + * @param callback function Calls callback(null, result), no errors no matter what
    + */
    +function readLine(options, callback) {
    +
    +  var rl = readline.createInterface({
    +    input: process.stdin,
    +    output: process.stdout
    +  });
    +
    +  setup();
    +  rl.question(options.message, function(result) {
    +    tearDown();
    +    callback(null, result);
    +  });
    +
    +  function onReadable() {
    +    if (!options.hidden) return;
    +    hideInput();
    +  }
    +
    +  function hideInput() {
    +    if (!rl.line) return; // happens on \n when the input is finished
    +    process.stdout.write("\033[2K\033[200D" + options.message + new Array(rl.line.length+1).join("*"));
    +  }
    +
    +
    +  function setup() {
    +    process.stdin.on("readable", onReadable);
    +  }
    +
    +  function tearDown() {
    +    process.stdin.removeListener("readable", onReadable);
    +    rl.close();
    +  }
    +
    +}
    +
    +module.exports = readLine;
    diff --git a/modules/lib/selenium/browser.js b/modules/lib/selenium/browser.js
    new file mode 100755
    index 000000000..a56a96cae
    --- /dev/null
    +++ b/modules/lib/selenium/browser.js
    @@ -0,0 +1,19 @@
    +
    +// for local selenium, not a string, but a promise for address
    +var address = require('./server').address;
    +var webdriver = require('selenium-webdriver');
    +var hostname = require('os').hostname();
    +var config = require('config');
    +
    +module.exports = function() {
    +  return new webdriver.Builder().
    +    usingServer(address).
    +    withCapabilities({
    +      browserName: config.test.e2e.browser,
    +      name: hostname + ': ' + new Date().toLocaleString(),
    +      build: process.env.TRAVIS_BUILD_NUMBER,
    +      username: config.sauceLabs.username,
    +      accessKey: config.sauceLabs.accessKey
    +    }).
    +    build();
    +};
    diff --git a/modules/lib/selenium/selenium-server-standalone-2.45.0.jar b/modules/lib/selenium/selenium-server-standalone-2.45.0.jar
    new file mode 100755
    index 000000000..020191b73
    Binary files /dev/null and b/modules/lib/selenium/selenium-server-standalone-2.45.0.jar differ
    diff --git a/modules/lib/selenium/server.js b/modules/lib/selenium/server.js
    new file mode 100755
    index 000000000..b7626e87e
    --- /dev/null
    +++ b/modules/lib/selenium/server.js
    @@ -0,0 +1,32 @@
    +const log = require('log')();
    +const SeleniumServer = require('selenium-webdriver/remote').SeleniumServer;
    +const config = require('config');
    +
    +var seleniumServer;
    +
    +// SELENIUM_LOCAL means using local selenium server + browser
    +// otherwise sauceLabs
    +
    +// SELENIUM_DEBUG turns on debugging output from the local selenium server
    +// (sauceLabs doesn't use it, because it gives full log on-site)
    +if (+process.env.SELENIUM_LOCAL) {
    +
    +  var pathToJar = require.resolve('lib/selenium/selenium-server-standalone-2.45.0.jar');
    +
    +  // stdio goes to child_process.spawn()
    +  // must be an array of file descriptors or stream which have file descriptors or ...
    +  // because of file descriptors can't just redirect to log (there must be a way btw)
    +  // so using a variable
    +  seleniumServer = new SeleniumServer(pathToJar, {
    +    port:  4444,
    +    stdio: process.env.SELENIUM_DEBUG ? 'inherit' : 'ignore' // ignore by default, but inherit shows all output
    +  });
    +
    +  // selenium starts unref'ed
    +  seleniumServer.start();
    +  exports.server = seleniumServer;
    +  exports.address = seleniumServer.address();
    +} else {
    +  exports.address = config.sauceLabs.address;
    +}
    +
    diff --git a/modules/lib/serverJade/filterSimpledown.js b/modules/lib/serverJade/filterSimpledown.js
    new file mode 100755
    index 000000000..f57d2e4b6
    --- /dev/null
    +++ b/modules/lib/serverJade/filterSimpledown.js
    @@ -0,0 +1,10 @@
    +var filters = require('jade').filters;
    +
    +var renderSimpledown = require('renderSimpledown');
    +
    +filters.simpledown = function (html) {
    +  return renderSimpledown(html, {
    +    trusted: true
    +  });
    +};
    +
    diff --git a/modules/lib/serverJade/filterUglify.js b/modules/lib/serverJade/filterUglify.js
    new file mode 100755
    index 000000000..fdc83935e
    --- /dev/null
    +++ b/modules/lib/serverJade/filterUglify.js
    @@ -0,0 +1,9 @@
    +var filters = require('jade').filters;
    +
    +var UglifyJS = require("uglify-js");
    +
    +filters.uglify = function(str) {
    +  var result = UglifyJS.minify(str, {fromString: true});
    +  return result.code;
    +};
    +
    diff --git a/modules/lib/serverJade/index.js b/modules/lib/serverJade/index.js
    new file mode 100755
    index 000000000..fd64ad761
    --- /dev/null
    +++ b/modules/lib/serverJade/index.js
    @@ -0,0 +1,37 @@
    +const fs = require('fs');
    +const path = require('path');
    +const config = require('config');
    +const jade = require('jade');
    +const _ = require('lodash');
    +
    +/**
    + * extension for require('file.jade'),
    + * works in libs that are shared between client & server
    + */
    +require.extensions['.jade'] = function(module, filename) {
    +
    +  var compiled = jade.compile(
    +    fs.readFileSync(filename, 'utf-8'),
    +    _.assign({}, config.jade, {
    +      pretty:        false,
    +      compileDebug:  false,
    +      filename:      filename
    +    })
    +  );
    +
    +  module.exports = function(locals) {
    +    locals = locals || {};
    +    locals.bem = require('bem-jade')();
    +
    +    return compiled(locals);
    +  };
    +
    +//  console.log("---------------> HERE", fs.readFileSync(filename, 'utf-8'), module.exports);
    +
    +};
    +
    +require('./filterSimpledown');
    +
    +require('./filterUglify');
    +
    +module.exports = jade;
    \ No newline at end of file
    diff --git a/modules/lib/stylusAsset.js b/modules/lib/stylusAsset.js
    new file mode 100755
    index 000000000..7ea76888d
    --- /dev/null
    +++ b/modules/lib/stylusAsset.js
    @@ -0,0 +1,41 @@
    +var fs = require('fs'),
    +    path = require('path'),
    +    crypto = require('crypto'),
    +    nodes = require('stylus').nodes,
    +    utils = require('stylus').utils;
    +
    +module.exports = function(options) {
    +
    +  var getVersion = options.getVersion || function(file) {
    +    var buf = fs.readFileSync(file);
    +    return crypto.createHash('md5').update(buf).digest('hex').substring(0, 8);
    +  };
    +
    +  return function(style) {
    +    var paths = style.options.paths || [];
    +
    +    style.define('asset', function(url) {
    +      var literal = new nodes.Literal('url("' + url.val + '")');
    +
    +      var evaluator = this;
    +      var file = utils.lookup(url.val, paths);
    +
    +      if (!file) {
    +        throw new Error('File ' + literal + ' not be found');
    +      }
    +
    +      var version = getVersion(file);
    +
    +      var ext = path.extname(url.val);
    +      var filepath = url.val.slice(0, url.val.length - ext.length);
    +
    +      var newUrl = options.assetVersioning == 'query' ? (url.val + '?' + version) :
    +        options.assetVersioning == 'file' ? (filepath + '.v' + version + ext) :
    +          url.val;
    +
    +      literal = new nodes.Literal('url("../i/' + newUrl + '")');
    +
    +      return literal;
    +    });
    +  };
    +};
    diff --git a/modules/lib/throttle.js b/modules/lib/throttle.js
    new file mode 100755
    index 000000000..cbb688a5a
    --- /dev/null
    +++ b/modules/lib/throttle.js
    @@ -0,0 +1,32 @@
    +
    +function throttle(func, ms) {
    +
    +  var isThrottled = false,
    +      savedArgs,
    +      savedThis;
    +
    +  function wrapper() {
    +
    +    if (isThrottled) {
    +      savedArgs = arguments;
    +      savedThis = this;
    +      return;
    +    }
    +
    +    func.apply(this, arguments);
    +
    +    isThrottled = true;
    +
    +    setTimeout(function() {
    +      isThrottled = false;
    +      if (savedArgs) {
    +        wrapper.apply(savedThis, savedArgs);
    +        savedArgs = savedThis = null;
    +      }
    +    }, ms);
    +  }
    +
    +  return wrapper;
    +}
    +
    +module.exports = throttle;
    diff --git a/modules/lib/treeUtil.js b/modules/lib/treeUtil.js
    new file mode 100755
    index 000000000..dcba47b20
    --- /dev/null
    +++ b/modules/lib/treeUtil.js
    @@ -0,0 +1,27 @@
    +function walkArray(node, visitor) {
    +
    +  for (var i = 0; i < node.children.length; i++) {
    +    var treeNode = node.children[i];
    +    visitor(treeNode);
    +    if (treeNode.children) {
    +      walkArray(treeNode, visitor);
    +    }
    +  }
    +
    +}
    +
    +function flattenArray(root) {
    +
    +  const flatten = [];
    +
    +  walkArray(root, function(node) {
    +    flatten.push(node);
    +  });
    +
    +  return flatten;
    +
    +}
    +
    +exports.walkArray = walkArray;
    +
    +exports.flattenArray = flattenArray;
    diff --git a/modules/lib/verboseLogger.js b/modules/lib/verboseLogger.js
    new file mode 100755
    index 000000000..7ea48807b
    --- /dev/null
    +++ b/modules/lib/verboseLogger.js
    @@ -0,0 +1,36 @@
    +const PathListCheck = require('pathListCheck');
    +
    +function VerboseLogger() {
    +  this.logPaths = new PathListCheck();
    +}
    +
    +
    +VerboseLogger.prototype.middleware = function() {
    +  var self = this;
    +
    +  return function*(next) {
    +
    +    if (self.logPaths.check(this.path)) {
    +      this.log.info({requestVerbose: this.request});
    +    }
    +
    +    yield* next;
    +  };
    +
    +};
    +
    +/*
    +VerboseLogger.prototype.log = function(context) {
    +
    +  for (var name in context.req.headers) {
    +    console.log(name + ": " + context.req.headers[name]);
    +  }
    +
    +  if (context.request.body) {
    +    console.log(context.request.body);
    +  }
    +
    +};
    +*/
    +
    +module.exports = VerboseLogger;
    diff --git a/modules/lib/webpack/angularInjectorPlugin.js b/modules/lib/webpack/angularInjectorPlugin.js
    new file mode 100755
    index 000000000..ad518f3d2
    --- /dev/null
    +++ b/modules/lib/webpack/angularInjectorPlugin.js
    @@ -0,0 +1,41 @@
    +/* UNFINISHED UNUSED
    +var SourceMapSource = require('webpack/lib/SourceMapSource');
    +var injector = require('angular-injector');
    +
    +function AngularInjectorPlugin(files) {
    +  this.files = files;
    +}
    +
    +WriteVersionsPlugin.prototype.apply = function(compiler) {
    +  var files = this.files;
    +
    +  compiler.plugin('compilation', function(compilation) {
    +
    +    compilation.plugin('optimize-chunk-assets', function(chunks, callback) {
    +
    +      chunks.forEach(function(chunk) {
    +
    +      });
    +
    +      files = []
    +
    +        chunks.forEach (chunk) ->
    +          files = files.concat chunk.files
    +
    +        files = files.concat compilation.additionalChunkAssets
    +
    +        files.forEach (file) ->
    +          if not options.exclude? or not options.exclude.test file
    +            map = compilation.assets[file].map()
    +            source = injector.annotate compilation.assets[file].source(), options
    +            compilation.assets[file] = new OriginalSource source, file, map
    +
    +        callback()
    +
    +    });
    +  });
    +};
    +
    +module.exports = AngularInjectorPlugin;
    +
    +*/
    \ No newline at end of file
    diff --git a/modules/lib/webpack/writeVersionsPlugin.js b/modules/lib/webpack/writeVersionsPlugin.js
    new file mode 100755
    index 000000000..b746f293e
    --- /dev/null
    +++ b/modules/lib/webpack/writeVersionsPlugin.js
    @@ -0,0 +1,29 @@
    +var fs = require('fs');
    +
    +function WriteVersionsPlugin(file) {
    +  this.file = file;
    +}
    +
    +WriteVersionsPlugin.prototype.writeStats = function(compiler, stats) {
    +  stats = stats.toJson();
    +  var assetsByChunkName = stats.assetsByChunkName;
    +
    +  for (var name in assetsByChunkName) {
    +    if (assetsByChunkName[name] instanceof Array) {
    +      assetsByChunkName[name] = assetsByChunkName[name].map(function(path) {
    +        return compiler.options.output.publicPath + path;
    +      });
    +    } else {
    +      assetsByChunkName[name] = compiler.options.output.publicPath + assetsByChunkName[name];
    +    }
    +  }
    +
    +  //console.log(assetsByChunkName);
    +  fs.writeFileSync(this.file, JSON.stringify(assetsByChunkName));
    +};
    +
    +WriteVersionsPlugin.prototype.apply = function(compiler) {
    +  compiler.plugin("done", this.writeStats.bind(this, compiler));
    +};
    +
    +module.exports = WriteVersionsPlugin;
    \ No newline at end of file
    diff --git a/modules/linkModules.js b/modules/linkModules.js
    new file mode 100755
    index 000000000..24e2c7e07
    --- /dev/null
    +++ b/modules/linkModules.js
    @@ -0,0 +1,74 @@
    +/**
    + * This module does not have any other module dependencies,
    + * because even require('log') requires "log" to be linked in.
    + * @type {exports}
    + */
    +var fs = require('fs');
    +var glob = require('glob');
    +var path = require('path');
    +
    +var DEBUG = false;
    +
    +// Ensures the existance of a symlink linkDst -> linkSrc
    +// returns true if link was created
    +// returns false if link exists alread (and is correct)
    +// throws error if conflict (another file or link by that name)
    +function createSymlinkSync(linkSrc, linkDst) {
    +  var lstat;
    +  // check same-named link
    +  try {
    +    lstat = fs.lstatSync(linkDst);
    +  } catch (e) {
    +  }
    +
    +  if (lstat) {
    +    if (!lstat.isSymbolicLink()) {
    +      throw new Error("Conflict: path exist and is not a link: " + linkDst);
    +    }
    +
    +    var oldDst = fs.readlinkSync(linkDst);
    +    if (oldDst == linkSrc) {
    +      return false; // already exists and is correct
    +    }
    +
    +    // kill old link!
    +    fs.unlinkSync(linkDst);
    +  }
    +
    +  // check same file/dir module
    +  // if src is "module.js", check "module/"
    +  // if src is "module/", check module.js
    +
    +  var conflictingName;
    +  try {
    +    conflictingName = linkDst.endsWith('.js') ? linkDst.slice(0, -3) : (linkDst + '.js');
    +    lstat = fs.lstatSync(conflictingName);
    +  } catch(e) {
    +  }
    +
    +  if (lstat) {
    +    throw new Error("Conflict: path exist: " + conflictingName);
    +  }
    +
    +  fs.symlinkSync(linkSrc, linkDst);
    +  return true;
    +}
    +
    +module.exports = function(options) {
    +  var modules = [];
    +  options.src.forEach(function(pattern) {
    +    modules = modules.concat(glob.sync(pattern));
    +  });
    +
    +  for (var i = 0; i < modules.length; i++) {
    +    var moduleToLinkRelPath = modules[i];  // hmvc/auth
    +    var moduleToLinkName = path.basename(moduleToLinkRelPath); // auth
    +    var linkSrc = path.join('..', moduleToLinkRelPath);
    +    var linkDst = path.join('node_modules', moduleToLinkName);
    +
    +    if (createSymlinkSync(linkSrc, linkDst)) {
    +      if (DEBUG) console.log(linkSrc + " -> " + linkDst);
    +    }
    +  }
    +
    +};
    diff --git a/modules/log/browser.js b/modules/log/browser.js
    new file mode 100755
    index 000000000..b9ca5c843
    --- /dev/null
    +++ b/modules/log/browser.js
    @@ -0,0 +1,21 @@
    +
    +// browserify-version
    +// supports standard methods, but no settings
    +module.exports = function() {
    +
    +  var logger = {
    +    info: function() {
    +      this.isDebug && console.info.apply(console, arguments);
    +    },
    +    debug: function() {
    +      this.isDebug && console.debug.apply(console, arguments);
    +    },
    +    error: function() {
    +      this.isDebug && console.error.apply(console, arguments);
    +    }
    +  };
    +
    +  return logger;
    +
    +
    +};
    diff --git a/modules/log/bunyan.js b/modules/log/bunyan.js
    new file mode 100644
    index 000000000..85fe3ab0c
    --- /dev/null
    +++ b/modules/log/bunyan.js
    @@ -0,0 +1,31 @@
    +var bunyan = require('bunyan');
    +
    +const CLS = require('continuation-local-storage');
    +
    +var emit = bunyan.prototype._emit;
    +bunyan.prototype._emit = function addReqIdToEveryRecord(rec, noemit) {
    +  // get it runtime, so that it may be require'd later than this module
    +  const clsNamespace = CLS.getNamespace('app');
    +
    +  if (clsNamespace) {
    +    var clsContext = clsNamespace.get('context');
    +
    +    if (clsContext) {
    +      if (!rec.requestId) {
    +        rec.requestId = clsContext.requestId;
    +      } else {
    +        // rec.requestId comes from the child logger object this.log, it is always correct
    +        // clsContext.requestId kept across async calls by CLS, should be correct
    +        if (rec.requestId !== clsContext.requestId) {
    +          // trust rec.requestId, if we're here => context is lost somewhere (bug)
    +          console.error(`LOG: CLS returned wrong context? (${rec.requestId}) !== (${clsContext.requestId}).`, rec);
    +        }
    +      }
    +    }
    +  } else {
    +    console.error("LOG: no CLS namespace");
    +  }
    +  return emit.call(this, rec, noemit);
    +};
    +
    +module.exports = bunyan;
    diff --git a/modules/log/errSerializer.js b/modules/log/errSerializer.js
    new file mode 100755
    index 000000000..93697fc61
    --- /dev/null
    +++ b/modules/log/errSerializer.js
    @@ -0,0 +1,33 @@
    +module.exports = function(err) {
    +  if (!err || !err.stack)
    +    return err;
    +  var obj = {
    +    message: err.message,
    +    name: err.name,
    +    stack: getFullErrorStack(err),
    +    code: err.code,
    +    signal: err.signal
    +  };
    +  return obj;
    +};
    +
    +/*
    + * This function dumps long stack traces for exceptions having a cause()
    + * method. The error classes from
    + * [verror](https://github.com/davepacheco/node-verror) and
    + * [restify v2.0](https://github.com/mcavage/node-restify) are examples.
    + *
    + * Based on `dumpException` in
    + * https://github.com/davepacheco/node-extsprintf/blob/master/lib/extsprintf.js
    + */
    +function getFullErrorStack(ex) {
    +  var ret = ex.stack || ex.toString();
    +  if (ex.cause) {
    +    var cex = typeof (ex.cause) === 'function' ? ex.cause() : ex.cause;
    +    if (cex) {
    +      ret += '\nCaused by: ' + getFullErrorStack(cex);
    +    }
    +  }
    +  return ret;
    +}
    +
    diff --git a/modules/log/httpErrorSerializer.js b/modules/log/httpErrorSerializer.js
    new file mode 100755
    index 000000000..54590e479
    --- /dev/null
    +++ b/modules/log/httpErrorSerializer.js
    @@ -0,0 +1,10 @@
    +module.exports = function(httpError) {
    +  if (!httpError.status) {
    +    return httpError;
    +  }
    +
    +  return {
    +    status: httpError.status,
    +    message: httpError.message
    +  };
    +};
    diff --git a/modules/log/index.js b/modules/log/index.js
    new file mode 100755
    index 000000000..2f198ef74
    --- /dev/null
    +++ b/modules/log/index.js
    @@ -0,0 +1,45 @@
    +// Usage: require('log')()
    +// NB: this file is RELOADED for EVERY REQUIRE
    +// (cleared from cache, to get parent filename every time)
    +
    +var bunyan = require('./bunyan');
    +var requestSerializer = require('./requestSerializer');
    +var requestVerboseSerializer = require('./requestVerboseSerializer');
    +var resSerializer = require('./resSerializer');
    +var errSerializer = require('./errSerializer');
    +var httpErrorSerializer = require('./httpErrorSerializer');
    +var path = require('path');
    +
    +
    +// log.debug({req: ...})
    +// exported => new serializers can be added by other modules
    +var serializers = exports.serializers = {
    +  requestVerbose: requestVerboseSerializer,
    +  request:        requestSerializer,
    +  res:            resSerializer,
    +  err:            errSerializer,
    +  httpError:      httpErrorSerializer
    +};
    +
    +var streams = require('./streams');
    +
    +// if no name, then name is a parent module filename (or it's directory if index)
    +module.exports = function(name) {
    +  if (!name) {
    +    name = path.basename(module.parent.filename, '.js');
    +    if (name == 'index') {
    +      name = path.basename(path.dirname(module.parent.filename)) + '/index';
    +    }
    +  }
    +
    +  var logger = bunyan.createLogger({
    +    name:        name,
    +    streams:     streams,
    +    serializers: serializers
    +  });
    +
    +  return logger;
    +};
    +
    +
    +delete require.cache[__filename];
    diff --git a/modules/log/package.json b/modules/log/package.json
    new file mode 100755
    index 000000000..d0728023b
    --- /dev/null
    +++ b/modules/log/package.json
    @@ -0,0 +1,19 @@
    +{
    +  "name": "log",
    +  "version": "0.4.1",
    +  "description": "Configurable as winston, tunable as debug()",
    +  "main": "index.js",
    +  "dependencies": {
    +    "winston": "*"
    +  },
    +  "browser": "./browser.js",
    +  "scripts": {
    +    "test": "echo \"Error: no test specified\" && exit 1"
    +  },
    +  "repository": {
    +    "type": "git",
    +    "url": "https://github.com/iliakan/log.git"
    +  },
    +  "author": "Ilya Kantor",
    +  "license": "CC-BY 3.0"
    +}
    diff --git a/modules/log/requestCaptureStream.js b/modules/log/requestCaptureStream.js
    new file mode 100644
    index 000000000..a3d4db0cd
    --- /dev/null
    +++ b/modules/log/requestCaptureStream.js
    @@ -0,0 +1,177 @@
    +"use strict";
    +
    +// Copyright 2012 Mark Cavage, Inc.  All rights reserved.
    +// from restify
    +var Stream = require('stream').Stream;
    +var util = require('util');
    +
    +var assert = require('assert-plus');
    +var bunyan = require('bunyan');
    +var LRU = require('lru-cache');
    +var os = require('os');
    +
    +///--- Globals
    +
    +var sprintf = util.format;
    +
    +// every node.js run, in every process this id will be different
    +var PROCESS_ID = os.hostname() + '-' + process.pid;
    +
    +///--- Helpers
    +
    +function appendStream(streams, s) {
    +  assert.arrayOfObject(streams, 'streams');
    +  assert.object(s, 'stream');
    +
    +  if (s instanceof Stream) {
    +    streams.push({
    +      raw:    false,
    +      stream: s
    +    });
    +  } else {
    +    assert.optionalBool(s.raw, 'stream.raw');
    +    assert.object(s.stream, 'stream.stream');
    +    streams.push(s);
    +  }
    +}
    +
    +
    +///--- API
    +
    +/**
    + * A Bunyan stream to capture records in a ring buffer and only pass through
    + * on a higher-level record. E.g. buffer up all records but only dump when
    + * getting a WARN or above.
    + *
    + * @param {Object} options contains the parameters:
    + *      - {Object} stream The stream to which to write when dumping captured
    + *        records. One of `stream` or `streams` must be specified.
    + *      - {Array} streams One of `stream` or `streams` must be specified.
    + *      - {Number|String} level The level at which to trigger dumping captured
    + *        records. Defaults to bunyan.WARN.
    + *      - {Number} maxRecords Number of records to capture. Default 100.
    + *      - {Number} maxRequestIds Number of simultaneous request id capturing
    + *        buckets to maintain. Default 1000.
    + */
    +class RequestCaptureStream extends Stream {
    +  constructor(opts) {
    +    super();
    +
    +    assert.object(opts, 'options');
    +    assert.optionalObject(opts.stream, 'options.stream');
    +    assert.optionalArrayOfObject(opts.streams, 'options.streams');
    +    assert.optionalNumber(opts.level, 'options.level');
    +    assert.optionalNumber(opts.maxRecords, 'options.maxRecords');
    +    assert.optionalNumber(opts.maxRequestIds, 'options.maxRequestIds');
    +
    +    this.level = opts.level ? bunyan.resolveLevel(opts.level) : bunyan.WARN;
    +    this.limit = opts.maxRecords || 100;
    +    this.maxRequestIds = opts.maxRequestIds || 1000;
    +    this.requestMap = LRU({
    +      max: this.maxRequestIds
    +    });
    +
    +    this._offset = -1;
    +    this._rings = [];
    +
    +    this.streams = [];
    +
    +    if (opts.streams) {
    +      opts.streams.forEach(appendStream.bind(null, this.streams));
    +    }
    +
    +    this.haveNonRawStreams = false;
    +    for (var i = 0; i < this.streams.length; i++) {
    +      if (!this.streams[i].raw) {
    +        this.haveNonRawStreams = true;
    +        break;
    +      }
    +    }
    +  }
    +
    +
    +  write(record) {
    +    console.log(record);
    +    var reqId = record.requestId || PROCESS_ID;
    +    var ring;
    +    var self = this;
    +
    +    if (!(ring = this.requestMap.get(reqId))) {
    +      if (++this._offset > this.maxRequestIds)
    +        this._offset = 0;
    +
    +      if (this._rings.length <= this._offset) {
    +        this._rings.push(new bunyan.RingBuffer({
    +          limit: self.limit
    +        }));
    +      }
    +
    +      ring = this._rings[this._offset];
    +      ring.records.length = 0;
    +      this.requestMap.set(reqId, ring);
    +    }
    +
    +    assert.ok(ring, 'no ring found');
    +
    +    ring.write(record);
    +
    +    if (record.level >= this.level) {
    +      this.dump(ring);
    +    }
    +  }
    +
    +  dump(ring) {
    +
    +    var lastRecord = ring.records[ring.records.length - 1];
    +    var lastRequestId = lastRecord.requestId;
    +
    +    var recordsToDump = [];
    +
    +    if (!lastRequestId) {
    +      // error outside of request
    +      // no idea which context is required
    +      // let's dump everything context for the error
    +    }
    +
    +    var i, r, ser;
    +    for (i = 0; i < ring.records.length; i++) {
    +      r = ring.records[i];
    +      if (this.haveNonRawStreams) {
    +        ser = JSON.stringify(r, bunyan.safeCycles()) + '\n';
    +      }
    +      this.streams.forEach(function(s) {
    +        s.stream.write(s.raw ? r : ser);
    +      });
    +    }
    +    ring.records.length = 0;
    +
    +    var defaultRing = self.requestMap.get(PROCESS_ID);
    +    for (i = 0; i < defaultRing.records.length; i++) {
    +      r = defaultRing.records[i];
    +      if (this.haveNonRawStreams) {
    +        ser = JSON.stringify(r,
    +            bunyan.safeCycles()) + '\n';
    +      }
    +      self.streams.forEach(function(s) {
    +        s.stream.write(s.raw ? r : ser);
    +      });
    +    }
    +    defaultRing.records.length = 0;
    +
    +  }
    +
    +  toString() {
    +    var STR_FMT = '[object %s]';
    +
    +    return (sprintf(STR_FMT,
    +      this.constructor.name,
    +      this.level,
    +      this.limit,
    +      this.maxRequestIds));
    +  }
    +
    +
    +}
    +
    +
    +module.exports = RequestCaptureStream;
    diff --git a/modules/log/requestSerializer.js b/modules/log/requestSerializer.js
    new file mode 100755
    index 000000000..e1a78283e
    --- /dev/null
    +++ b/modules/log/requestSerializer.js
    @@ -0,0 +1,9 @@
    +module.exports = function(request) {
    +  if (!request || !request.method) {
    +    return request;
    +  }
    +  return {
    +    method: request.method,
    +    url:    request.originalUrl
    +  };
    +};
    diff --git a/modules/log/requestVerboseSerializer.js b/modules/log/requestVerboseSerializer.js
    new file mode 100755
    index 000000000..b63fbfa82
    --- /dev/null
    +++ b/modules/log/requestVerboseSerializer.js
    @@ -0,0 +1,11 @@
    +module.exports = function(request) {
    +  if (!request || !request.method)
    +    return request;
    +  return {
    +    method:        request.method,
    +    url:           request.originalUrl,
    +    headers:       request.headers,
    +    body:          request.body,
    +    ip:            request.ip
    +  };
    +};
    diff --git a/modules/log/resSerializer.js b/modules/log/resSerializer.js
    new file mode 100755
    index 000000000..8b8a0b237
    --- /dev/null
    +++ b/modules/log/resSerializer.js
    @@ -0,0 +1,9 @@
    +module.exports = function(res) {
    +  if (!res || !res.statusCode)
    +    return res;
    +  return {
    +    statusCode: res.statusCode,
    +    header:     res._header
    +  };
    +};
    +
    diff --git a/modules/log/streams.js b/modules/log/streams.js
    new file mode 100644
    index 000000000..c90295305
    --- /dev/null
    +++ b/modules/log/streams.js
    @@ -0,0 +1,48 @@
    +// disable requestcapturestream (pending rewrite if CLS works good)
    +
    +//const RequestCaptureStream = require('./requestCaptureStream');
    +
    +var streams;
    +
    +if (process.env.LOG_LEVEL) {
    +  streams = [{
    +    level:  process.env.LOG_LEVEL,
    +    stream: process.stdout
    +  }];
    +} else {
    +
    +  switch (process.env.NODE_ENV) {
    +  case 'development':
    +    streams = [{
    +      level:  'debug',
    +      stream: process.stdout
    +    }];
    +    break;
    +  case 'test':
    +    streams = [/* empty, don't log anything, set LOG_LEVEL if want to see errors */];
    +    break;
    +  case 'ebook':
    +  case 'production':
    +
    +    // normally I see only info, but look in error in case of problems
    +    streams = [
    +      {
    +        level:  'info',
    +        stream: process.stdout
    +      }/*,
    +      {
    +        level:  'debug',
    +        type:   'raw',
    +        stream: new RequestCaptureStream({
    +          maxRecords:    150,
    +          maxRequestIds: 2000,
    +          dumpDefault:   true, // if error happens also dump all records, not bound to a request
    +          // default records dumped AFTER request
    +          streams:       [process.stderr]
    +        })
    +      }*/
    +    ];
    +  }
    +}
    +
    +module.exports = streams;
    diff --git a/modules/momentWithLocale.js b/modules/momentWithLocale.js
    new file mode 100755
    index 000000000..76e0c0dce
    --- /dev/null
    +++ b/modules/momentWithLocale.js
    @@ -0,0 +1,3 @@
    +require('moment/locale/ru');
    +
    +module.exports = require('moment');
    diff --git a/modules/pathListCheck.js b/modules/pathListCheck.js
    new file mode 100755
    index 000000000..f89660d35
    --- /dev/null
    +++ b/modules/pathListCheck.js
    @@ -0,0 +1,31 @@
    +const log = require('log')();
    +const pathToRegexp = require('path-to-regexp');
    +
    +function PathListCheck() {
    +  this.paths = [];
    +}
    +
    +PathListCheck.prototype.add = function(path) {
    +  if (path instanceof RegExp) {
    +    this.paths.push(path);
    +  } else if (typeof path == 'string') {
    +    this.paths.push(pathToRegexp(path));
    +  } else {
    +    throw new Error("unsupported path type: " + path);
    +  }
    +};
    +
    +PathListCheck.prototype.check = function(path) {
    +
    +  for (var i = 0; i < this.paths.length; i++) {
    +    log.trace("path test " + path + " against " + this.paths[i]);
    +    if (this.paths[i].test(path)) {
    +      log.trace("path match found");
    +      return true;
    +    }
    +  }
    +
    +  return false;
    +};
    +
    +module.exports = PathListCheck;
    \ No newline at end of file
    diff --git a/modules/photoCut/canvasSelection.js b/modules/photoCut/canvasSelection.js
    new file mode 100644
    index 000000000..1e95f21fb
    --- /dev/null
    +++ b/modules/photoCut/canvasSelection.js
    @@ -0,0 +1,26 @@
    +class CanvasSelection {
    +
    +  constructor({x, y, size}) {
    +    this.x = x;
    +    this.y = y;
    +    this.size = size;
    +  }
    +
    +  get bottom() {
    +    return this.y + this.size;
    +  }
    +
    +  get right() {
    +    return this.x + this.size;
    +  }
    +
    +  get center() {
    +    return {
    +      x: this.x + this.size / 2,
    +      y: this.y + this.size / 2
    +    };
    +  }
    +
    +}
    +
    +module.exports = CanvasSelection;
    diff --git a/modules/photoCut/index.js b/modules/photoCut/index.js
    new file mode 100644
    index 000000000..ba8188dfe
    --- /dev/null
    +++ b/modules/photoCut/index.js
    @@ -0,0 +1,94 @@
    +// Client-side module to cut a square from a picture
    +const Modal = require('client/head/modal');
    +
    +const modalTemplate = require('./templates/modal.jade');
    +const clientRender = require('client/clientRender');
    +
    +const PhotoCut = exports.PhotoCut = require('./photoCut');
    +require('blueimp-canvas-to-blob/js/canvas-to-blob');
    +
    +exports.cutPhoto = function(img, onSuccess) {
    +  var modal = new Modal();
    +  modal.setContent(clientRender(modalTemplate));
    +
    +  var canvas = modal.elem.querySelector('.photo-cut__canvas');
    +  canvas.focus();
    +  var selectionCanvasElems = modal.elem.querySelectorAll('.photo-cut__selection-canvas');
    +
    +  // copy size from CSS to scale correctly
    +  for (var i = 0; i < selectionCanvasElems.length; i++) {
    +    selectionCanvasElems[i].width = selectionCanvasElems[i].offsetWidth;
    +    selectionCanvasElems[i].height = selectionCanvasElems[i].offsetHeight;
    +  }
    +
    +  var photoCut = new PhotoCut(canvas, { maxImageSize: 300 });
    +  photoCut.setImage(img);
    +
    +  /*
    +  photoCut.setSelection({
    +    x: canvas.width * 0.1,
    +    width: canvas.width * 0.8,
    +    y: canvas.height * 0.1,
    +    height: canvas.height * 0.8
    +  });*/
    +
    +  canvas.addEventListener("selection", function(event) {
    +    var selection = photoCut.getCanvasSelection();
    +
    +    for (var i = 0; i < selectionCanvasElems.length; i++) {
    +      var elem = selectionCanvasElems[i];
    +      elem.getContext('2d').clearRect(0, 0, elem.width, elem.height);
    +
    +      if (selection) {
    +        elem.getContext('2d').drawImage(
    +          selection.source,
    +          selection.x, selection.y, selection.size, selection.size, // from
    +          0, 0, elem.width, elem.height // to
    +        );
    +      }
    +    }
    +  });
    +
    +
    +  modal.elem.querySelector('[data-action="rotate-right"]').addEventListener('click', () => photoCut.rotate(1));
    +
    +  modal.elem.querySelector('[data-form]').addEventListener('submit', event => {
    +    event.preventDefault();
    +    save();
    +  });
    +
    +  canvas.addEventListener('submit', event => {
    +    save();
    +  });
    +
    +  function save() {
    +    var selection = photoCut.getCanvasSelection();
    +
    +    if (!selection) return;
    +
    +    var finalCanvas = document.createElement('canvas');
    +
    +    // resize canvas to the actual selection part of the image,
    +    // to make as large as possible resolution/size avatar
    +    finalCanvas.width = selection.size;
    +    finalCanvas.height = selection.size;
    +
    +    // draw the selected piece on the canvas to make Blob of it
    +    finalCanvas.getContext('2d').drawImage(
    +      selection.source, selection.x, selection.y, selection.size, selection.size,
    +      0, 0, selection.size, selection.size
    +    );
    +
    +    modal.remove();
    +
    +    finalCanvas.toBlob(
    +      function(blob) {
    +        onSuccess(blob);
    +      },
    +      'image/jpeg'
    +    );
    +
    +
    +  }
    +
    +};
    diff --git a/modules/photoCut/photoCut.js b/modules/photoCut/photoCut.js
    new file mode 100644
    index 000000000..850367230
    --- /dev/null
    +++ b/modules/photoCut/photoCut.js
    @@ -0,0 +1,487 @@
    +const CanvasSelection = require('./canvasSelection');
    +
    +
    +class PhotoCut {
    +
    +  constructor(canvas, {maxImageSize} = {}) {
    +    this.maxImageSize = maxImageSize || 200;
    +
    +    this.canvas = canvas;
    +
    +    this.canvas.onmousedown = event => this.onMouseDown(event);
    +    this.canvas.onmouseup = event => this.onMouseUp(event);
    +    this.canvas.onkeydown = event => this.onKeyDown(event);
    +
    +    document.addEventListener('mousemove', (event) => this.onMouseMove(event));
    +
    +    this.ctx = canvas.getContext('2d');
    +
    +    this.state = false; // moving | selecting | modifying
    +
    +    this.mouseDownShift = null; // remember initial mousedown for moving
    +
    +    this.selectionStartCoords = null;
    +
    +    this.rotation = 0; // no rotation by default, can be a number, 1 => 90deg, 2 => 180deg -1 => -90deg ...
    +
    +    this.selection = null; // current CanvasSelection object (if any)
    +
    +    this.cornerSize = 5;
    +  }
    +
    +  setImage(img) {
    +    // fit into document & 400px
    +
    +    this.img = img;
    +
    +    // fit to canvas
    +    this.scale = Math.min(this.maxImageSize / img.width, this.maxImageSize / img.height);
    +
    +    this.fullImageCanvas = document.createElement('canvas');
    +    this.fullImageCtx = this.fullImageCanvas.getContext('2d');
    +
    +    this.renderFullImageRotated();
    +
    +    this.render();
    +  }
    +
    +  getEventCoordsRelativeCanvasImage(event) {
    +    return {
    +      x: event.clientX - this.canvas.getBoundingClientRect().left - this.cornerSize,
    +      y: event.clientY - this.canvas.getBoundingClientRect().top - this.cornerSize
    +    };
    +  }
    +
    +
    +  onKeyDown(event) {
    +    if (!this.selection) return;
    +
    +    if (event.keyCode == 13) { // down
    +      this.canvas.dispatchEvent(new CustomEvent("submit"));
    +    }
    +
    +    if (event.keyCode == 40) { // down
    +      if (this.selection.bottom < this.height) {
    +        this.setSelection({
    +          y: this.selection.y + 1
    +        });
    +      }
    +      event.preventDefault();
    +    }
    +
    +    if (event.keyCode == 38) { // up
    +      if (this.selection.y > 0) {
    +        this.setSelection({
    +          y: this.selection.y - 1
    +        });
    +      }
    +      event.preventDefault();
    +    }
    +
    +    if (event.keyCode == 37) { // left
    +      if (this.selection.x > 0) {
    +        this.setSelection({
    +          x: this.selection.x - 1
    +        });
    +      }
    +      event.preventDefault();
    +    }
    +
    +    if (event.keyCode == 39) { // right
    +      if (this.selection.right < this.width) {
    +        this.setSelection({
    +          x: this.selection.x + 1
    +        });
    +      }
    +      event.preventDefault();
    +    }
    +
    +  }
    +
    +  onMouseDown(event) {
    +    event.preventDefault(); // don't start selection please
    +    var coords = this.getEventCoordsRelativeCanvasImage(event);
    +
    +    var position = this.findCoordsInSelection(coords);
    +
    +    switch (position) {
    +    case 'inside':
    +      // move selection
    +      this.state = 'moving';
    +      this.mouseDownShift = {
    +        x: coords.x - this.selection.x,
    +        y: coords.y - this.selection.y
    +      };
    +      break;
    +    case 'outside':
    +      this.setSelection(null);
    +      this.state = 'selecting';
    +      this.selectionStartCoords = coords;
    +      break;
    +    case 'nw':
    +    case 'ne':
    +    case 'sw':
    +    case 'se':
    +      this.state = 'modifying';
    +      break;
    +    default:
    +      throw new Error("Must never reach here");
    +    }
    +  }
    +
    +  /**
    +   * Return the relative position of coords to this.selection
    +   * false - if outside
    +   * nesw - if near sides
    +   * inside - if inside far from sides
    +   * @param coords
    +   * @returns {*}
    +   */
    +  findCoordsInSelection(coords) {
    +    if (!this.selection) return 'outside';
    +
    +    if (Math.abs(coords.x - this.selection.x) < this.cornerSize && Math.abs(coords.y - this.selection.y) < this.cornerSize) {
    +      return 'nw';
    +    }
    +
    +    if (Math.abs(coords.x - this.selection.x) < this.cornerSize && Math.abs(coords.y - this.selection.bottom) < this.cornerSize) {
    +      return 'sw';
    +    }
    +
    +    if (Math.abs(coords.x - this.selection.right) < this.cornerSize && Math.abs(coords.y - this.selection.bottom) < this.cornerSize) {
    +      return 'se';
    +    }
    +
    +    if (Math.abs(coords.x - this.selection.right) < this.cornerSize && Math.abs(coords.y - this.selection.y) < this.cornerSize) {
    +      return 'ne';
    +    }
    +
    +    if (coords.x >= this.selection.x && coords.x <= this.selection.right &&
    +      coords.y >= this.selection.y && coords.y <= this.selection.bottom
    +    ) return 'inside';
    +
    +    return 'outside';
    +
    +  }
    +
    +
    +  onMouseMove(event) {
    +    // coords may be anywhere in the document
    +
    +    // recalculate relative to canvas image edge
    +    var coords = this.getEventCoordsRelativeCanvasImage(event);
    +
    +    // force-fit into image
    +    if (coords.x < 0) coords.x = 0;
    +    if (coords.x > this.width) coords.x = this.width;
    +    if (coords.y < 0) coords.y = 0;
    +    if (coords.y > this.height) coords.y = this.height;
    +
    +    switch (this.state) {
    +    case false:
    +      this.showCursorAtCoords(coords);
    +      break;
    +    case 'moving':
    +      this.moveSelection(coords);
    +      break;
    +    case 'selecting':
    +      this.createSelection(coords);
    +      break;
    +    case 'modifying':
    +      this.modifySelection(coords);
    +      break;
    +    default:
    +      throw new Error("Must never reach here");
    +    }
    +
    +  }
    +
    +  showCursorAtCoords(coords) {
    +
    +    var cursorPosition = this.findCoordsInSelection(coords);
    +    if (cursorPosition == 'outside') {
    +      this.canvas.style.cursor = 'crosshair';
    +    } else if (cursorPosition == 'inside') {
    +      this.canvas.style.cursor = 'move';
    +    } else {
    +      this.canvas.style.cursor = cursorPosition + '-resize';
    +    }
    +  }
    +
    +  modifySelection(coords) {
    +    var center = this.selection.center;
    +    var direction = coords.x < center.x && coords.y < center.y ? 'nw' :
    +      coords.x < center.x && coords.y >= center.y ? 'sw' :
    +        coords.x > center.x && coords.y < center.y ? 'ne' :
    +          'se';
    +
    +    switch (direction) {
    +    case 'nw':
    +      this.selectionStartCoords = {
    +        x: this.selection.right,
    +        y: this.selection.bottom
    +      };
    +      break;
    +    case 'ne':
    +      this.selectionStartCoords = {
    +        x: this.selection.x,
    +        y: this.selection.bottom
    +      };
    +      break;
    +    case 'sw':
    +      this.selectionStartCoords = {
    +        x: this.selection.right,
    +        y: this.selection.y
    +      };
    +      break;
    +    case 'se':
    +      this.selectionStartCoords = {
    +        x: this.selection.x,
    +        y: this.selection.y
    +      };
    +      break;
    +    }
    +
    +    this.createSelection(coords);
    +  }
    +
    +  moveSelection(coords) {
    +    var x = Math.min(coords.x - this.mouseDownShift.x, this.width - this.selection.size);
    +    var y = Math.min(coords.y - this.mouseDownShift.y, this.height - this.selection.size);
    +    if (x < 0) x = 0;
    +    if (y < 0) y = 0;
    +    this.setSelection({
    +      x:    x,
    +      y:    y,
    +      size: this.selection.size
    +    });
    +    this.canvas.style.cursor = 'move';
    +  }
    +
    +  setSelection(selection) {
    +    if (selection) {
    +      selection = Object.create(selection);
    +      if (this.selection) {
    +        selection.x = selection.x || this.selection.x;
    +        selection.y = selection.y || this.selection.y;
    +        selection.size = selection.size || this.selection.size;
    +      }
    +
    +      // round to make all rectangles pixel-perfect
    +      this.selection = new CanvasSelection(selection);
    +    } else {
    +      this.selection = null;
    +    }
    +    this.render();
    +
    +    this.canvas.dispatchEvent(new CustomEvent("selection", {
    +      bubbles: true
    +    }));
    +
    +  }
    +
    +  createSelection(coords) {
    +
    +    var maxDistance = Math.max(
    +      Math.abs(this.selectionStartCoords.x - coords.x),
    +      Math.abs(this.selectionStartCoords.y - coords.y)
    +    );
    +
    +    var selection = {};
    +
    +    if (coords.x >= this.selectionStartCoords.x) {
    +      if (coords.y >= this.selectionStartCoords.y) {
    +        this.canvas.style.cursor = 'se-resize';
    +        selection.size = Math.min(maxDistance, this.height - this.selectionStartCoords.y, this.width - this.selectionStartCoords.x);
    +        selection.x = this.selectionStartCoords.x;
    +        selection.y = this.selectionStartCoords.y;
    +      } else {
    +        this.canvas.style.cursor = 'ne-resize';
    +        selection.size = Math.min(maxDistance, this.selectionStartCoords.y, this.width - this.selectionStartCoords.x);
    +        selection.x = this.selectionStartCoords.x;
    +        selection.y = this.selectionStartCoords.y - selection.size;
    +      }
    +    } else {
    +      if (coords.y >= this.selectionStartCoords.y) {
    +        this.canvas.style.cursor = 'sw-resize';
    +        selection.size = Math.min(maxDistance, this.selectionStartCoords.x, this.height - this.selectionStartCoords.y);
    +        selection.x = this.selectionStartCoords.x - selection.size;
    +        selection.y = this.selectionStartCoords.y;
    +      } else {
    +        this.canvas.style.cursor = 'nw-resize';
    +        selection.size = Math.min(maxDistance, this.selectionStartCoords.x, this.selectionStartCoords.y);
    +        selection.x = this.selectionStartCoords.x - selection.size;
    +        selection.y = this.selectionStartCoords.y - selection.size;
    +      }
    +    }
    +
    +
    +    this.setSelection(selection);
    +
    +  }
    +
    +  onMouseUp(event) {
    +    if (!this.state) return;
    +    this.state = false;
    +
    +    if (this.selection.size < this.cornerSize * 2 + 2) {
    +      // too small
    +      this.setSelection(null);
    +    }
    +
    +    // must render to show corners after end of selection
    +    this.render();
    +  }
    +
    +  renderFullImageRotated() {
    +    // translate context to center of canvas
    +    if (this.rotation % 2 === 0) {
    +      this.fullImageCanvas.width = this.img.width;
    +      this.fullImageCanvas.height = this.img.height;
    +    } else {
    +      this.fullImageCanvas.height = this.img.width;
    +      this.fullImageCanvas.width = this.img.height;
    +    }
    +
    +    this.fullImageCtx.translate(this.fullImageCanvas.width / 2, this.fullImageCanvas.height / 2);
    +
    +    this.fullImageCtx.rotate(this.rotation * Math.PI / 2);
    +
    +    this.fullImageCtx.drawImage(this.img, -this.img.width / 2, -this.img.height / 2, this.img.width, this.img.height);
    +
    +    this.fullImageCtx.rotate(-this.rotation * Math.PI / 2);
    +    this.fullImageCtx.translate(-this.fullImageCanvas.width / 2, -this.fullImageCanvas.heigh / 2);
    +  }
    +
    +  /**
    +   * Rotate
    +   * @param direction +1 for +90deg, -1 for -90deg
    +   */
    +  rotate() {
    +    this.rotation++;
    +
    +    this.state = false;
    +    this.renderFullImageRotated();
    +    this.render(); // sets this.width/height props (need below)
    +
    +    if (this.selection) {
    +      // translate selection to new coords
    +      this.setSelection({
    +        x: this.width - this.selection.bottom,
    +        y: this.selection.x
    +      });
    +    }
    +
    +    // after resetting width/height - refocus
    +    this.canvas.focus();
    +  }
    +
    +
    +  render() {
    +
    +    // resized image height
    +    this.width = this.fullImageCanvas.width * this.scale;
    +    this.height = this.fullImageCanvas.height * this.scale;
    +
    +    // image + corner space
    +    this.canvas.width = this.width + this.cornerSize * 2;
    +    this.canvas.height = this.height + this.cornerSize * 2;
    +
    +    this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
    +
    +    this.ctx.translate(this.cornerSize, this.cornerSize);
    +
    +    this.ctx.drawImage(
    +      this.fullImageCanvas,
    +      0, 0,
    +      this.width, this.height
    +    );
    +
    +
    +    if (this.selection && this.selection.size) {
    +
    +      var x = Math.floor(this.selection.x);
    +      var y = Math.floor(this.selection.y);
    +      var size = Math.ceil(this.selection.size);
    +
    +      this.ctx.fillStyle = "rgba(0, 0, 0, 0.5)";
    +      this.ctx.fillRect(0, 0, this.width, y); // up
    +      this.ctx.fillRect(0, y, x, this.height - y); // left
    +      this.ctx.fillRect(x + size, y, this.width - (x + size), size); // right
    +      this.ctx.fillRect(x, y + size, this.width - x, this.height - (y + size)); // bottom
    +
    +      // corners
    +      this.renderCorner('nw');
    +      this.renderCorner('ne');
    +      this.renderCorner('sw');
    +      this.renderCorner('se');
    +    }
    +
    +    this.ctx.translate(-this.cornerSize, -this.cornerSize);
    +  }
    +
    +  renderCorner(corner) {
    +
    +    var rect;
    +    switch (corner) {
    +    case 'nw':
    +      rect = {
    +        x: this.selection.x - this.cornerSize,
    +        y: this.selection.y - this.cornerSize
    +      };
    +      break;
    +    case 'ne':
    +      rect = {
    +        x: this.selection.right - this.cornerSize,
    +        y: this.selection.y - this.cornerSize
    +      };
    +      break;
    +    case 'sw':
    +      rect = {
    +        x: this.selection.x - this.cornerSize,
    +        y: this.selection.bottom - this.cornerSize
    +      };
    +      break;
    +    case 'se':
    +      rect = {
    +        x: this.selection.right - this.cornerSize,
    +        y: this.selection.bottom - this.cornerSize
    +      };
    +      break;
    +    }
    +
    +    rect.width = this.cornerSize * 2;
    +    rect.height = this.cornerSize * 2;
    +
    +    if (!this.state) {
    +      // usual "inactive" style
    +      this.ctx.fillStyle = "rgba(255, 255, 255, 0.3)";
    +    } else {
    +      if ((this.state == 'modifying' || this.state == 'selecting') && // starting point unless moving
    +        this.selectionStartCoords.x >= rect.x && this.selectionStartCoords.y >= rect.y &&
    +        this.selectionStartCoords.x <= rect.x + rect.width && this.selectionStartCoords.y <= rect.y + rect.height
    +      ) {
    +        // selection start corner is "fixed" when selecting
    +        this.ctx.fillStyle = "rgba(0, 0, 0, 0.6)";
    +      } else {
    +        this.ctx.fillStyle = "rgba(255, 255, 255, 0.8)";
    +      }
    +    }
    +
    +    this.ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
    +
    +  }
    +
    +  getCanvasSelection() {
    +    if (!this.selection) return null;
    +
    +    return {
    +      source: this.fullImageCanvas,
    +      x:      this.selection.x / this.scale,
    +      y:      this.selection.y / this.scale,
    +      size:   this.selection.size / this.scale
    +    };
    +  }
    +}
    +
    +module.exports = PhotoCut;
    diff --git a/modules/photoCut/templates/modal.jade b/modules/photoCut/templates/modal.jade
    new file mode 100644
    index 000000000..029e0322c
    --- /dev/null
    +++ b/modules/photoCut/templates/modal.jade
    @@ -0,0 +1,23 @@
    +include /bem
    +
    ++b.photo-cut
    +
    +  +e('h1').title Выберите миниатюру
    +
    +  +e('form')(data-form)
    +
    +    +e.layout
    +      +e.main
    +        +e.canvas-wrapper
    +          +e('canvas').canvas(tabindex="-1")
    +          +e('button').rotate(type="button", data-action="rotate-right")
    +
    +      +e.result
    +        +e('canvas').selection-canvas
    +        +e('canvas').selection-canvas._small
    +
    +    +e.submit
    +      +b('button').button._action(type="submit")
    +        +e('span').text Сохранить
    +
    +      +e('a')(href='#').close-link.modal__close Отмена
    diff --git a/modules/plunk/index.js b/modules/plunk/index.js
    new file mode 100755
    index 000000000..ffc98c11d
    --- /dev/null
    +++ b/modules/plunk/index.js
    @@ -0,0 +1,2 @@
    +exports.readFs = require('./readFs');
    +exports.Plunk = require('./models/plunk');
    diff --git a/modules/plunk/login.js b/modules/plunk/login.js
    new file mode 100755
    index 000000000..8c66e59d0
    --- /dev/null
    +++ b/modules/plunk/login.js
    @@ -0,0 +1,100 @@
    +// Log in to Plnkr.co using Github credentials
    +// Prints Auth ID (put to config, reuse)
    +
    +// Usage
    +// node modules/plunk/login.js
    +
    +var config = require('config');
    +var readLine = require('lib/readLine');
    +var log = require('log')();
    +var request = require('koa-request');
    +
    +/* jshint -W106 */
    +function* login() {
    +
    +  log.debug("readCredentials");
    +
    +  process.stdout.write('Hi there! We need to establish an auth session with plunker first (only one time).\n1) Log in (sign up if needed) to http://github.com, please.\n2) And then go to http://plnkr.co and log in using GitHub (upper-right corner).\n3) And finally enter GitHub login/password here (will not give anyone, will not store anywhere).\n');
    +  var username = yield function(callback) {
    +    readLine({message: 'GitHub Login: '}, callback);
    +  };
    +
    +  var password = yield function(callback) {
    +      readLine({message: 'GitHub Password: ', hidden: true}, callback);
    +  };
    +
    +  log.debug("readGithubToken");
    +
    +  var githubAuthResponse = yield request({
    +    url:  'https://api.github.com/authorizations',
    +    auth: {username: username, password: password},
    +    headers: { 'User-Agent': 'App' },
    +    json: true
    +  });
    +
    +
    +  log.debug(githubAuthResponse);
    +
    +  if (githubAuthResponse.statusCode == 403) {
    +    process.stderr.write("Wrong GitHub Login or Password");
    +    process.exit(1);
    +  } else if (githubAuthResponse.statusCode != 200) {
    +    process.stderr.write("Error " + githubAuthResponse.statusCode);
    +    process.exit(1);
    +  }
    +
    +
    +  var authorizations = githubAuthResponse.body;
    +  var githubPlunkerAuth = authorizations.find(function(item) {
    +    return item.app.name == 'Plunker';
    +  });
    +
    +  if (!githubPlunkerAuth) {
    +    process.stderr.write("Plunker Auth not found");
    +    process.exit(1);
    +  }
    +
    +  log.debug("readPlnkrAuth");
    +
    +  var session = yield request({
    +    url: 'http://api.plnkr.co/sessions',
    +    json: true
    +  });
    +
    +  if (session.statusCode != 200) {
    +    log.error(session);
    +    process.exit(1);
    +  }
    +
    +  session = session.body;
    +
    +
    +  log.debug("TOKEN", githubPlunkerAuth.token);
    +  var plunkerAuthResponse = yield request.post({
    +    url: session.user_url,
    +    headers: { 'Content-Type': 'application/json' }, // required(!)
    +    json: true,
    +    body: JSON.stringify({
    +      service: 'github',
    +      token: githubPlunkerAuth.token
    +    })
    +  });
    +
    +  if (plunkerAuthResponse.statusCode != 201) {
    +    log.debug(plunkerAuthResponse, githubPlunkerAuth);
    +    process.stderr.write("Incorrect response from " + session.user_url);
    +    process.exit(1);
    +  }
    +
    +  log.debug("plunkerAuthResponse", plunkerAuthResponse.body);
    +
    +  process.stdout.write("Plunker Auth ID:" + plunkerAuthResponse.body.id);
    +}
    +
    +module.exports = login;
    +
    +if (!module.parent) {
    +  require('co')(login)(function(err) {
    +    if (err) console.error(err.message, err.stack);
    +  });
    +}
    diff --git a/modules/plunk/models/plunk.js b/modules/plunk/models/plunk.js
    new file mode 100755
    index 000000000..748e34f60
    --- /dev/null
    +++ b/modules/plunk/models/plunk.js
    @@ -0,0 +1,193 @@
    +var mongoose = require('mongoose');
    +var assert = require('assert');
    +var request = require('koa-request');
    +var config = require('config');
    +var Schema = mongoose.Schema;
    +var _ = require('lodash');
    +var log = require('log')();
    +var zip = require('node-zip');
    +
    +var schema = new Schema({
    +  description: {
    +    type:    String,
    +    default: ""
    +  },
    +  webPath:     {
    +    type:     String,
    +    unique:   true,
    +    required: true
    +  },
    +  plunkId:     {
    +    type:     String,
    +    required: true
    +  },
    +  files:       [{
    +    filename: String,
    +    content:  String
    +  }]
    +});
    +
    +schema.methods.getUrl = function() {
    +  return 'http://plnkr.co/edit/' + this.plunkId + '?p=preview';
    +};
    +
    +schema.methods.getZip = function() {
    +  var archive = new zip();
    +
    +  for (var i = 0; i < this.files.length; i++) {
    +    var file = this.files[i];
    +    archive.file(file.filename, file.content);
    +  }
    +
    +  var buffer = archive.generate({type: 'nodebuffer'});
    +
    +  return buffer;
    +};
    +
    +/**
    + * Merges files into the current plunk
    + * @param files
    + * @returns {boolean} new files list to post w/ nulls where files are deleted
    + */
    +schema.methods.mergeAndSyncRemote = function*(files) {
    +
    +  var changes = {};
    +
    +  log.debug("mergeAndSyncRemote " + this.plunkId);
    +  log.debug("OLD files", this.files);
    +  log.debug("NEW files", files);
    +
    +  /* delete this.files which are absent in files */
    +  for (var i = 0; i < this.files.length; i++) {
    +    var file = this.files[i];
    +    if (!files[file.filename]) {
    +      this.files.splice(i--, 1);
    +      changes[file.filename] = null; // for submitting to plnkr
    +    }
    +  }
    +
    +  for (var name in files) {
    +    /**
    +     * This hangs dunno why, doesn't print anything, maybe v8 or mongoose bug (this.files has 1 file)
    +     var existingFile = this.files.find(function(item) {
    +      console.log("find", item);
    +      return item.filename == name;
    +    });
    +     */
    +
    +    var existingFile = null;
    +    for (var i = 0; i < this.files.length; i++) {
    +      var item = this.files[i];
    +      if (item.filename == name) {
    +        existingFile = item;
    +        break;
    +      }
    +    }
    +    if (existingFile) {
    +      if (existingFile.content == files[name].content) continue;
    +      existingFile.content = files[name].content;
    +    } else {
    +      this.files.push(files[name]);
    +    }
    +    changes[name] = files[name];
    +  }
    +
    +  log.debug("UPDATED files", this.files);
    +
    +  if (_.isEmpty(changes)) {
    +    log.debug("no changes, skip updating");
    +    return;
    +  } else {
    +    log.debug("plunk " + this.plunkId + " changes", changes);
    +  }
    +
    +  if (this.plunkId) {
    +    log.debug("update remotely", this.webPath, this.plunkId);
    +    yield* Plunk.updateRemote(this.plunkId, changes);
    +  } else {
    +    log.debug("create plunk remotely", this.webPath);
    +    this.plunkId = yield* Plunk.createRemote(this.description, this.files);
    +  }
    +
    +
    +  yield this.persist();
    +
    +};
    +
    +schema.statics.createRemote = function*(description, files) {
    +
    +  if (Plunk.REMOTE_OFF) {
    +    return Math.random().toString(36).slice(2);
    +  }
    +
    +  var filesObj = {};
    +  files.forEach(function(file) {
    +    filesObj[file.filename] = {
    +      filename: file.filename,
    +      content:  file.content
    +    }; // no _id
    +  });
    +
    +  var form = {
    +    description: description,
    +    tags:        [],
    +    files:       filesObj,
    +    private:     true
    +  };
    +
    +  var data = {
    +    method:  'POST',
    +    headers: {'Content-Type': 'application/json;charset=utf-8'},
    +    json:    true,
    +    url:     "http://api.plnkr.co/plunks/?sessid=" + config.plnkrAuthId,
    +    body:    form
    +  };
    +
    +  var result = yield Plunk.request(data);
    +
    +  assert.equal(result.statusCode, 201);
    +
    +  return result.body.id;
    +
    +};
    +
    +schema.statics.request = function*(data) {
    +  var result = yield request(data);
    +
    +  if (result.statusCode == 404) {
    +    throw new Error("result status code 404, probably plnkrAuthId is too old");
    +  }
    +  if (result.statusCode == 400) {
    +    throw new Error("invalid json, probably you don't need to stringify body (request will do it)");
    +  }
    +
    +  return result;
    +};
    +
    +schema.statics.updateRemote = function* (plunkId, changes) {
    +
    +
    +  if (Plunk.REMOTE_OFF) {
    +    return;
    +  }
    +
    +  var form = {
    +    tags:  {},
    +    files: changes
    +  };
    +
    +  var result = yield Plunk.request({
    +    method:  'POST',
    +    headers: {'Content-Type': 'application/json'},
    +    json:    true,
    +    url:     "http://api.plnkr.co/plunks/" + plunkId + "?sessid=" + config.plnkrAuthId,
    +    body:    form
    +  });
    +
    +  assert.equal(result.statusCode, 200);
    +};
    +
    +
    +var Plunk = module.exports = mongoose.model('Plunk', schema);
    +
    +
    diff --git a/modules/plunk/readFs.js b/modules/plunk/readFs.js
    new file mode 100755
    index 000000000..ef59b282d
    --- /dev/null
    +++ b/modules/plunk/readFs.js
    @@ -0,0 +1,164 @@
    +var fs = require('fs');
    +var path = require('path');
    +var mime = require('mime');
    +var log = require('log')();
    +var stripIndents = require('textUtil/stripIndents');
    +
    +function readFs(dir) {
    +
    +  var files = fs.readdirSync(dir);
    +
    +  var hadErrors = false;
    +  files = files.filter(function(file) {
    +    if (file[0] == ".") return false;
    +
    +    var filePath = path.join(dir, file);
    +    if (fs.statSync(filePath).isDirectory()) {
    +      log.error("Directory not allowed: " + file);
    +      hadErrors = true;
    +    }
    +
    +    var type = mime.lookup(file).split('/');
    +    if (type[0] != 'text' && type[1] != 'json' && type[1] != 'javascript') {
    +      log.error("Bad file extension: " + file);
    +      hadErrors = true;
    +    }
    +
    +    return true;
    +  });
    +
    +  if (hadErrors) {
    +    return null;
    +  }
    +
    +  files = files.sort(function(fileA, fileB) {
    +    var extA = fileA.slice(fileA.lastIndexOf('.') + 1);
    +    var extB = fileB.slice(fileB.lastIndexOf('.') + 1);
    +
    +    if (extA == extB) {
    +      return fileA > fileB ? 1 : -1;
    +    }
    +
    +    // html always first
    +    if (extA == 'html') return 1;
    +    if (extB == 'html') return -1;
    +
    +    // then goes CSS
    +    if (extA == 'css') return 1;
    +    if (extB == 'css') return -1;
    +
    +    // then JS
    +    if (extA == 'js') return 1;
    +    if (extB == 'js') return -1;
    +
    +    // then other extensions
    +    return fileA > fileB ? 1 : -1;
    +  });
    +
    +  var filesForPlunk = {};
    +  for (var i = 0; i < files.length; i++) {
    +    var file = files[i];
    +    filesForPlunk[file] = {
    +      filename: file,
    +      content: stripIndents(fs.readFileSync(path.join(dir, file), 'utf-8'))
    +    };
    +  }
    +
    +  return filesForPlunk;
    +}
    +
    +
    +module.exports = readFs;
    +
    +/*
    +function* readFs(dir) {
    +
    +  var files = fs.readdirSync(dir);
    +
    +  var errors = [];
    +  files = files.filter(function(file) {
    +    if (file[0] == ".") return false;
    +
    +    var filePath = path.join(dir, file);
    +    if (fs.statSync(filePath).isDirectory()) {
    +      errors.push("Directory not allowed: " + file);
    +      return false;
    +    }
    +
    +    var type = mime.lookup(file).split('/');
    +    if (type[0] != 'text' && type[1] != 'json' && type[1] != 'javascript') {
    +      errors.push("Bad file extension: " + file);
    +    }
    +
    +    return true;
    +  });
    +
    +  var meta = {};
    +  var plunkFilePath = path.join(dir, '.plnkr');
    +
    +  if (fs.existsSync(plunkFilePath)) {
    +    var existingPlunk = fs.readFileSync(plunkFilePath, 'utf-8');
    +    existingPlunk = JSON.parse(existingPlunk);
    +
    +    // dir name change (2 levels up) = new plunk
    +    var plunkDirName = fs.realpathSync(dir);
    +    plunkDirName = path.basename(path.dirname(plunkDirName)) + path.sep + path.basename(plunkDirName);
    +
    +    if (existingPlunk.name == plunkDirName) {
    +      meta = existingPlunk;
    +    }
    +  }
    +
    +
    +  if (errors.length) {
    +    log.error(errors);
    +    return false;
    +  }
    +
    +  files = files.sort(function(fileA, fileB) {
    +    var extA = fileA.slice(fileA.lastIndexOf('.') + 1);
    +    var extB = fileB.slice(fileB.lastIndexOf('.') + 1);
    +
    +    if (extA == extB) {
    +      return fileA > fileB ? 1 : -1;
    +    }
    +
    +    // html always first
    +    if (extA == 'html') return 1;
    +    if (extB == 'html') return -1;
    +
    +    // then goes CSS
    +    if (extA == 'css') return 1;
    +    if (extB == 'css') return -1;
    +
    +    // then JS
    +    if (extA == 'js') return 1;
    +    if (extB == 'js') return -1;
    +
    +    // then other extensions
    +    return fileA > fileB ? 1 : -1;
    +  });
    +
    +  var filesForPlunk = {};
    +  for (var i = 0; i < files.length; i++) {
    +    var file = files[i];
    +    filesForPlunk[file] = {
    +      filename: file,
    +      content: fs.readFileSync(path.join(dir, file), 'utf-8')
    +    };
    +  }
    +
    +  return {
    +    meta: meta,
    +    files: filesForPlunk
    +  };
    +}
    +*/
    +
    +
    +/*
    +require('co')(readPlunkContent('/private/var/site/js-dev/tutorial/03-more/11-css-for-js/17-css-sprite/height48'))(function(err, res) {
    +  if (err) console.error(err.message, err.stack);
    +  else console.log(res);
    +});
    +*/
    \ No newline at end of file
    diff --git a/modules/renderSimpledown.js b/modules/renderSimpledown.js
    new file mode 100755
    index 000000000..a549e3eb5
    --- /dev/null
    +++ b/modules/renderSimpledown.js
    @@ -0,0 +1,36 @@
    +const BodyParser = require('simpledownParser').BodyParser;
    +const HtmlTransformer = require('simpledownParser').HtmlTransformer;
    +const config = require('config');
    +
    +/**
    + * Simple renderer for a text w/o resources
    + * @param text
    + * @param options
    + * @returns {*}
    + */
    +module.exports = function(text, options) {
    +  options = Object.create(options || {});
    +  if (options.trusted === undefined) {
    +    options.trusted = true;
    +  }
    +
    +  if (options.applyContextTypography === undefined) {
    +    options.applyContextTypography = true;
    +  }
    +
    +  const node = new BodyParser(text, options).parseAndWrap();
    +
    +  const transformer = new HtmlTransformer({
    +    staticHost:      config.server.staticHost
    +  });
    +
    +  var result = transformer.transform(node, options.applyContextTypography);
    +
    +  // if typography is not applied, we need to strip local  tags which prevent it
    +  if (!options.applyContextTypography) {
    +    result = result.replace(/<\/?no-typography>/g, '');
    +  }
    +
    +  return result;
    +
    +};
    \ No newline at end of file
    diff --git a/modules/router.js b/modules/router.js
    new file mode 100644
    index 000000000..c486907e6
    --- /dev/null
    +++ b/modules/router.js
    @@ -0,0 +1,12 @@
    +var KoaRouter = require('koa-router');
    +var inherits = require('inherits');
    +var mongoose = require('mongoose');
    +var User = require('users').User;
    +
    +function Router() {
    +  KoaRouter.apply(this, arguments);
    +}
    +
    +inherits(Router, KoaRouter);
    +
    +module.exports = Router;
    diff --git a/modules/serverHtmlTransformer/index.js b/modules/serverHtmlTransformer/index.js
    new file mode 100755
    index 000000000..0b2094ee2
    --- /dev/null
    +++ b/modules/serverHtmlTransformer/index.js
    @@ -0,0 +1,390 @@
    +var HtmlTransformer = require('simpledownParser').HtmlTransformer;
    +var ParseError = require('simpledownParser').ParseError;
    +var inherits = require('inherits');
    +const Reference = require('tutorial/models/reference');
    +const log = require('log')();
    +const Article = require('tutorial/models/article');
    +const Plunk = require('plunk').Plunk;
    +const Task = require('tutorial/models/task');
    +const ErrorTag = require('simpledownParser').ErrorTag;
    +const TextNode = require('simpledownParser').TextNode;
    +const CompositeTag = require('simpledownParser').CompositeTag;
    +const url = require('url');
    +const path = require('path');
    +const config = require('config');
    +const fs = require('mz/fs');
    +var jade = require('lib/serverJade');
    +var bem = require('bem-jade');
    +var gm = require('gm');
    +var thunkify = require('thunkify');
    +var imageSize = thunkify(require('image-size'));
    +var escapeHtml = require('escape-html');
    +
    +var codeTabsTemplate = require('./templates/codeTabs.jade');
    +
    +function ServerHtmlTransformer(options) {
    +
    +  HtmlTransformer.apply(this, arguments);
    +}
    +
    +inherits(ServerHtmlTransformer, HtmlTransformer);
    +
    +ServerHtmlTransformer.prototype.transform = function*(node, applyContextTypography) {
    +
    +  var method = this['transform' + node.getType()];
    +
    +  if (!method) {
    +    throw new Error("Unsupported node type: " + node.getType());
    +  }
    +
    +  var html;
    +  try {
    +    if (method.constructor.name == 'GeneratorFunction') {
    +      html = yield method.call(this, node);
    +    } else {
    +      html = method.call(this, node);
    +    }
    +  } catch (e) {
    +    if (e instanceof ParseError) {
    +      html = this.transformErrorTag(new ErrorTag(e.tag, e.message));
    +    } else {
    +      throw e;
    +    }
    +  }
    +
    +
    +  if (applyContextTypography) {
    +    html = this.applyContextTypography(html);
    +  }
    +
    +  return html;
    +};
    +
    +function* resolveReference(value) {
    +  if (value[0] == '#') {
    +    var ref = yield Reference.findOne({anchor: value.slice(1)}).populate('article', 'slug title').exec();
    +    if (!ref) {
    +      return null;
    +    }
    +    if (!ref.article) {
    +      log.error("No article for reference", ref.toObject());
    +    }
    +    return ref && {title: ref.article.title, url: ref.getUrl()};
    +  }
    +
    +  if (value.indexOf('/task/') === 0) {
    +    var task = yield Task.findOne({slug: value.slice('/task/'.length)}, 'slug title').exec();
    +    return task && {title: task.title, url: task.getUrl()};
    +  }
    +
    +  var article = yield Article.findOne({slug: value.slice(1)}, 'slug title').exec();
    +  return article && {title: article.title, url: article.getUrl()};
    +}
    +
    +
    +ServerHtmlTransformer.prototype.transformCompositeTag = function* (node) {
    +  var labels = {};
    +  var html = '';
    +
    +  var children = node.getChildren();
    +
    +  for (var i = 0; i < children.length; i++) {
    +    var child = children[i];
    +
    +    var childHtml = yield this.transform(child);
    +
    +    if (child.getType() != 'TextNode') {
    +      childHtml = this.replaceHtmlWithLabel(child.tag, childHtml, labels);
    +    }
    +
    +    html += childHtml;
    +  }
    +
    +  node.ensureKnowTrusted();
    +
    +  html = this.formatHtml(html, node.isTrusted());
    +
    +  html = this.replaceLabels(html, labels);
    +
    +  if (node.tag) {
    +    html = this.wrapTagAround(node.tag, node.attrs, html);
    +  }
    +
    +  return html;
    +};
    +
    +ServerHtmlTransformer.prototype.transformEditTag = function*(node) {
    +  // load plunk from DB
    +  if (node.attrs.src) {
    +    var plunk = yield Plunk.findOne({webPath: this.resourceWebRoot + '/' + node.attrs.src}).exec();
    +    if (!plunk) {
    +      throw new ParseError("div", "Нет такого plunk.");
    +    }
    +    node.attrs.plunkId = plunk.plunkId;
    +  }
    +
    +  return HtmlTransformer.prototype.transformEditTag.call(this, node);
    +
    +};
    +
    +ServerHtmlTransformer.prototype.transformLinkTag = function*(node) {
    +
    +  var ref;
    +  if (node.attrs.href[0] == '/' && !node.getChildren().length) {
    +    ref = node.attrs.href;
    +  } else if (node.attrs.href[0] == '#') {
    +    ref = node.attrs.href;
    +  }
    +
    +  if (ref) {
    +    const referenceObj = yield* resolveReference(ref);
    +
    +    if (!referenceObj) {
    +      throw new ParseError('span', 'Нет такой ссылки: ' + ref);
    +    }
    +
    +    node.attrs.href = referenceObj.url;
    +
    +    if (node.getChildren().length === 0) {
    +      if (ref[0] == '#') {
    +        node.appendChild(new TextNode(ref.slice(1)));
    +      } else {
    +        node.appendChild(new TextNode(referenceObj.title));
    +      }
    +    }
    +
    +  }
    +
    +  return yield* HtmlTransformer.prototype.transformLinkTag.call(this, node);
    +};
    +
    +
    +ServerHtmlTransformer.prototype.transformIframeTag = function*(node) {
    +  // load plunk from DB
    +  if (node.attrs.edit) {
    +    var plunk = yield Plunk.findOne({webPath: this.resourceWebRoot + '/' + node.attrs.src}).exec();
    +    if (!plunk) {
    +      throw new ParseError("div", "Нет такого plunk.");
    +    }
    +    node.attrs.plunkId = plunk.plunkId;
    +  }
    +
    +  return HtmlTransformer.prototype.transformIframeTag.call(this, node);
    +};
    +
    +ServerHtmlTransformer.prototype.transformImgTag = function*(node) {
    +
    +  if (!/\.(png|jpg|gif|jpeg|svg)$/i.test(node.attrs.src)) {
    +    throw new ParseError("div", "Неподдерживамое расширение, должно оканчиваться на png/jpg/gif/jpeg/svg: " + node.attrs.src);
    +  }
    +
    +  // external srcs go "as is"
    +  if (~node.attrs.src.indexOf('://') || node.attrs.src.startsWith('//')) {
    +    return HtmlTransformer.prototype.transformImgTag.call(this, node);
    +  }
    +
    +  var src = node.attrs.src[0] == '/' ? node.attrs.src : path.join(this.resourceWebRoot, node.attrs.src);
    +
    +  var imagePath = this._srcUnderRoot(config.publicRoot, src);
    +
    +  var stat;
    +
    +  try {
    +    stat = yield fs.stat(imagePath);
    +  } catch (e) {
    +    throw new ParseError("div", "Нет такого файла: " + node.attrs.src +
    +      (process.env.NODE_ENV == 'development' ? " [" + imagePath + "]" : "")
    +    );
    +  }
    +
    +  if (!stat.isFile()) {
    +    throw new ParseError("div", "Не файл: " + node.attrs.src);
    +  }
    +
    +  if (!node.attrs.width || !node.attrs.height) {
    +
    +    var size;
    +
    +    try {
    +      if (/\.svg$/i.test(this.src)) {
    +        var size = yield function(callback) {
    +          // GraphicsMagick fails with `gm identify my.svg`
    +          gm(imagePath).options({imageMagick: true}).identify('{"width":%w,"height":%h}', callback);
    +        };
    +
    +        size = JSON.parse(size); // warning: no error processing
    +      } else {
    +        size = yield imageSize(imagePath);
    +      }
    +
    +    } catch (e) {
    +      throw new ParseError('div', e.message);
    +    }
    +
    +    node.attrs.width = size.width;
    +    node.attrs.height = size.height;
    +  }
    +
    +  node.attrs.src = this.staticHost + src;
    +
    +  return HtmlTransformer.prototype.transformImgTag.call(this, node);
    +};
    +
    +ServerHtmlTransformer.prototype.transformCodeTabsTag = function* (node) {
    +
    +  var src = path.join(this.resourceWebRoot, node.attrs.src);
    +
    +  var plunk = yield Plunk.findOne({webPath: src}).exec();
    +
    +  if (!plunk) {
    +    throw new ParseError('div', 'No such plunk');
    +  }
    +
    +  if (this.ebookType) {
    +    var title = node.attrs.title || 'Смотреть пример онлайн';
    +    return '

    ' + escapeHtml(title) + '

    '; + } + + var files = plunk.files; + + var tabs = []; + + var prismLanguageMap = { + html: 'markup', + js: 'javascript', + json: 'javascript', + coffee: 'coffeescript' + }; + + var languagesSupported = 'markup css c javascript coffeescript http scss sql php python ruby java'.split(' '); + + var hasServerJs = false; + + for (var i = 0; i < files.length; i++) { + var file = files[i]; + + var ext = path.extname(file.filename).slice(1); + + var prismLanguage = prismLanguageMap[ext] || ext; + + if (!~languagesSupported.indexOf(prismLanguage)) prismLanguage = 'none'; + + var languageClass = 'language-' + prismLanguage + ' line-numbers'; + + tabs.push({ + title: file.filename, + class: languageClass, + content: file.content + }); + + if (file.filename == 'server.js') { + hasServerJs = true; + } + } + + var height = parseInt(node.attrs.height) || 200; + + var locals = { + tabs: tabs, + height: node.isTrusted() ? height : Math.min(height, 800), + src: src + '/' + }; + + if (hasServerJs) { + locals.zip = { + href: '/tutorial/zipview/' + path.basename(src) + '.zip?plunkId=' + plunk.plunkId + }; + } else { + locals.edit = { + href: 'http://plnkr.co/edit/' + plunk.plunkId + '?p=preview', + plunkId: plunk.plunkId + }; + } + + locals.external = { + href: src + '/' + }; + + var rendered = codeTabsTemplate(locals); + + return this.wrapTagAround('no-typography', {}, rendered); +}; + +/* + + options = { + 'class' => 'result__iframe', + 'data-trusted' => @trusted ? '1' : '0' + } + + if @params['height'] + options['data-demo-height'] = @params['height'] + else + options['data-demo-height'] = '350' + end + + #options['src'] = prefix_relative_src(@params['src']) + "/" + + begin + plunk_id = read_plunk_id(@params['src']) + options['data-play'] = plunk_id + rescue => e + return Node::ErrorTag.new(:div, "#{@bbtag}: нет такой песочницы #{@params['src']}") + end + + options['src'] = "http://embed.plnkr.co/#{plunk_id}/preview" + + options['data-zip'] = "1" if @params['zip'] + + Node::Tag.new(:iframe, "", options) + + end + */ + +ServerHtmlTransformer.prototype._srcUnderRoot = function(root, src) { + src = path.join(root, src); + + if (src.slice(0, root.length + 1) != root + '/') { + throw new ParseError("div", "src goes outside of root: " + src); + } + + return src; +}; + +ServerHtmlTransformer.prototype.transformSourceTag = function* (node) { + + if (node.attrs.src) { + var sourcePath = this._srcUnderRoot(config.publicRoot, path.join(this.resourceWebRoot, node.attrs.src)); + + var content; + + try { + content = yield fs.readFile(sourcePath, 'utf-8'); + } catch (e) { + throw new ParseError('div', "Не могу прочитать файл: " + node.src + + (process.env.NODE_ENV == 'development' ? " [" + sourcePath + "]" : "") + ); + } + + node.text = content; + } + + return HtmlTransformer.prototype.transformSourceTag.call(this, node); + +}; + +ServerHtmlTransformer.prototype.transformHeaderTag = function(node) { + var headerContent = this.transformVerbatimText(node); + + if (this.linkHeaderTag) { + return '' + + headerContent + + ''; + } else { + // for ebook need id + return '' + headerContent + ''; + } + +}; + +module.exports = ServerHtmlTransformer; diff --git a/modules/serverHtmlTransformer/templates/codeTabs.jade b/modules/serverHtmlTransformer/templates/codeTabs.jade new file mode 100755 index 000000000..a6bb7e36b --- /dev/null +++ b/modules/serverHtmlTransformer/templates/codeTabs.jade @@ -0,0 +1,32 @@ +include /bem + ++b.code-tabs._result_on + +e.tools + +e.scroll-wrap + +e('button').scroll-button._left(title="←" data-code-tabs-left) + +e.switches-wrap + +e.switches(data-code-tabs-switches) + +e.switches-items + +e.switch._current Результат + each tab in tabs + +e.switch #{tab.title} + +e.scroll-wrap + +e('button').scroll-button._right(title="→" data-code-tabs-right) + +e.buttons + if external + +e('a').button._external(target="_blank", title="открыть в отдельном окне", href=external.href) + if edit + +e('a').button._edit(target="_blank", title="редактировать в песочнице", href=edit.href) + if zip + +e('a').button._download(target="_blank", href=zip.href) + + +e.content(data-code-tabs-content style=('height:' + height + 'px')) + +e.section._current + //- assigning an attribute to false removes the attribute + +e('iframe').result(src=src) + + each tab in tabs + // Chrome extension jsonview breaks language-json here + +e.section + pre(class=tab.class) + code= tab.content diff --git a/modules/serverHtmlTransformer/treeWalker.js b/modules/serverHtmlTransformer/treeWalker.js new file mode 100755 index 000000000..0e02c598c --- /dev/null +++ b/modules/serverHtmlTransformer/treeWalker.js @@ -0,0 +1,41 @@ +/** + * Takes single root node, not array of nodes, because it may need to transform on top-level + * @param root + * @constructor + */ +function TreeWalker(root) { + this.root = root; // node or array +} + +TreeWalker.prototype.walk = function* (visitCallback) { + if (!isGeneratorFunction(visitCallback)) { + throw new Error("Walker must be generator"); + } + + yield this.visitWithChildren(this.root, visitCallback); +}; + +TreeWalker.prototype.visitWithChildren = function* (node, visitCallback) { + + var replacementNode = yield* visitCallback(node); + + if (replacementNode) { + node.parent.replaceChild(replacementNode, node); + node = replacementNode; + } + + if (node.getChildren) { + var children = node.getChildren(); + for (var i = 0; i < children.length; i++) { + var child = children[i]; + yield* this.visitWithChildren(child, visitCallback); + } + } +}; + + +function isGeneratorFunction(obj) { + return obj && obj.constructor && 'GeneratorFunction' == obj.constructor.name; +} + +module.exports = TreeWalker; diff --git a/modules/simpledownParser/Readme.md b/modules/simpledownParser/Readme.md new file mode 100755 index 000000000..7b6a9fc5a --- /dev/null +++ b/modules/simpledownParser/Readme.md @@ -0,0 +1,359 @@ + +# Парсер для JavaScript.ru + + +Парсер для адаптированного формата Markdown, который используется на Javascript.ru. + +У него есть два режима работы: + 1. Доверенный -- для статей, задач и другого основного материала. Возможны любые теги и т.п. + 2. Безопасный -- для комментариев и другого user-generated content. Большинство тегов HTML можно использовать. + +## Вставка кода ```js + +Блок кода вставляется как в github: + +
    +```js
    +alert(1);
    +```
    +
    + +Или: +
    +```html
    +<!DOCTYPE HTML>
    +<title>Viva la HTML5!</title>
    +```
    +
    + +Поддерживаемые языки (список может быть расширен): + - html + - js + - css + - coffee + - php + - http + - java + - ruby + - scss + - sql + +### Выполняемый код `//+ run` и другие настройки + +Если хочется, чтобы посетитель мог запустить код -- добавьте первую строку `//+ run`: + +
    +```js
    +//+ run
    +alert(1);
    +```
    +
    + +Независимо от языка можно использовать любой стиль комментария: `//+ ... `, `/*+ ... */`, `#+ ...` или ``, +главное чтобы он был *первой строкой* и в начале был *плюс и пробел*. Этот комментарий не попадёт в итоговый вывод. + +Есть два языка, для которых это поддерживается: + 1. `js` - в доверенном режиме через `eval`, в безопасном -- в `iframe` с другого домена. + 2. `html` - в доверенном режиме показ будет в `iframe` с того же домена, в безопасном -- с другого домена. + +Прочие настройки, возможные в этой же строке: + - `height=100` -- высота (в пикселях) для `iframe`, в котором будет выполняться пример. Обычно для HTML она вычисляется автоматически по содержимому. + - `src="my.js"` -- код будет взят из файла `my.js` + - `autorun` -- пример будет запущен автоматически по загрузке страницы. + - `refresh` -- каждый запуск JS-кода будет осуществлён в "чистом" окружении. + Этот флаг актуален только для безопасного режима, т.к. обычно `iframe` с другого домена кешируется и используется многократно. + - `demo` - флаг актуален только для решений задач, он означает, что при нажатии на кнопку "Демо" в условии запустится этот код. + +Пример ниже возьмёт код из файла `my.js` и запускает его автоматически: +
    +```js
    +//+ src="my.js" autorun
    +```
    +
    + +Этот пример будет при запуске показан в `